app-builder
+Log in with GitHub to use Chat, Code, Preview, Database and Logs.
+ +OAuth; token stays server-side.
+diff --git a/src/client/agent.ts b/src/client/agent.ts new file mode 100644 index 0000000..e1ab1e9 --- /dev/null +++ b/src/client/agent.ts @@ -0,0 +1,236 @@ +export const AGENT_JS = `/* ---------- agent stream ---------- */ +async function send() { + const text = input.value.trim(); + const ready = pendingImages.filter((p) => p.id && !p.uploading); + if ((!text && !ready.length) || busy || uploading > 0 || booting) return; + const images = ready.map((p) => ({ id: p.id, mimeType: p.mimeType })); + // Entries sent this turn: kept aside so a request that never reaches the + // agent can be retried without re-uploading (their R2 objects persist). + // Once the SSE stream opens, the DO owns the message (refs are persisted + // before Pi even submits), so restoring would duplicate it — only + // pre-acceptance failures restore. + const sentEntries = ready; + let accepted = false; + input.value = ""; + input.style.height = "auto"; + pendingImages = []; + renderPending(); + busy = true; sendBtn.disabled = true; + // Shared playgrounds attribute the turn; the server stores it on the message. + const me = (ghStatus && ghStatus.user && ghStatus.user.login) || null; + addUser(text, images, me); + const thinking = addThinking(); + // One agent turn runs at a time per playground: if nothing streams back + // within a few seconds we are queued behind the other member's turn, not + // stuck. Best-effort who/position from the sharing status. + let firstEvent = false; + const queueTimer = setTimeout(async () => { + if (firstEvent || !thinking.isConnected) return; + let who = "the other member's"; + try { + const st = await apiFetch("/api/share/status", { cache: "no-store" }).then((r) => r.json()); + if (st && st.turn && st.turn.active) { + who = st.turn.actor ? (st.turn.actor + "'s") : who; + if (typeof st.turn.queued === "number" && st.turn.queued > 0) { + thinking.textContent = "queued behind " + who + " turn (" + st.turn.queued + " waiting)…"; + return; + } + } + } catch { /* best-effort label only */ } + thinking.textContent = "queued behind " + who + " turn…"; + }, 3000); + + let settled = false; + // Open narration segments by Pi content index. Deltas append to their + // segment; a whole block replaces the segment with the same index when + // it extends it (grown block), commits what is open and starts a fresh + // bubble when the index is new (consecutive distinct paragraphs) or + // when the same index carries non-extending text (retry rewrite, + // redaction). Distinct text never overwrites visible text: the live + // transcript keeps everything the restored snapshot will show. + const segTexts = new Map(); + const segNodes = new Map(); + const segOrder = []; + const previewText = () => segOrder.map((i) => segTexts.get(i) || "").join(""); + const showSeg = (i) => { + if (thinking.isConnected) thinking.textContent = previewText().slice(0, 400); + else { + let node = segNodes.get(i); + if (!node) { node = el("div", "msg agent", ""); log.appendChild(node); segNodes.set(i, node); } + node.innerHTML = markdownToHtml(segTexts.get(i)); + scroll(); + } + }; + const commitSegs = () => { + if (thinking.isConnected) thinking.remove(); + const order = [...segOrder].sort((a, b) => a - b); + let any = false; + for (const i of order) { + const t = segTexts.get(i); + if (!t) continue; + any = true; + if (!segNodes.get(i)) { + const bubble = el("div", "msg agent", ""); + bubble.innerHTML = markdownToHtml(t); + log.appendChild(bubble); + } else { + segNodes.get(i).innerHTML = markdownToHtml(t); + } + } + if (any) log.appendChild(el("div", "model", "✳ " + getGateway() + "/" + modelSel.value)); + segOrder.length = 0; segTexts.clear(); segNodes.clear(); + scroll(); + }; + const flush = () => commitSegs(); + + const apiKey = readApiKey(); + if (apiKey) localStorage.setItem("ab_key", apiKey); + try { + const res = await apiFetch("/api/chat", { + method: "POST", + headers: { "content-type": "application/json", ...(apiKey ? { "x-provider-key": apiKey } : {}) }, + body: JSON.stringify({ + message: text || "(see attached images)", + gateway: getGateway(), + model: modelSel.value, + ...(images.length ? { images } : {}), + }), + }); + if (res.status === 401) { + const body = await res.json().catch(() => ({})); + // The gate flip already happened inside apiFetch; the key-missing 401 + // keeps the chat visible with the key prompt. + throw new Error(body.error || "unauthorized: set the API key below"); + } + if (!res.ok || !res.body) throw new Error("chat request failed: " + res.status); + // The stream is open, so the DO owns the turn (its refs were persisted + // before Pi submission): from here on, failures must not restore the + // sent entries, or a retry would duplicate the recorded message — and + // removing a restored thumb could delete bytes history still needs. + accepted = true; + + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ""; + const drain = (final) => { + const frames = buf.split("\\n\\n"); + buf = final ? "" : (frames.pop() || ""); + for (const frame of frames) { + const line = frame.split("\\n").find((l) => l.startsWith("data: ")); + if (!line) continue; + const ev = JSON.parse(line.slice(6)); + if (!firstEvent) { + firstEvent = true; + clearTimeout(queueTimer); + } + if (ev.type === "text") { + const i = ev.index || 0; + if (!segTexts.has(i)) segOrder.push(i); + segTexts.set(i, (segTexts.get(i) || "") + ev.text); + showSeg(i); + } else if (ev.type === "text_block") { + if (ev.text) { + const i = ev.index || 0; + const cur = segTexts.get(i); + if (cur === undefined) { + commitSegs(); + segOrder.push(i); + segTexts.set(i, ev.text); + } else if (ev.text !== cur && ev.text.indexOf(cur) !== 0) { + // Same index but not a grown block (retry rewrite, redaction): + // keep the open text as its own bubble instead of overwriting + // it, so a revision never deletes visible narration. + commitSegs(); + segOrder.push(i); + segTexts.set(i, ev.text); + } else { + segTexts.set(i, ev.text); + } + showSeg(i); + } + } else if (ev.type === "tool") { + // Flush any pending narration first: the model often sends text + // ("building your redesign now") ahead of its tool calls in the + // same round, and dropping it makes the transcript look stuck. + commitSegs(); + addTool(ev.name, ev.detail); + } else if (ev.type === "tool_result") { + // The tool line already showed the action. Only surface a result + // when it failed, otherwise the transcript is just file dumps. + if (!ev.ok) addTool(ev.name, ev.detail, false); + } else if (ev.type === "build") { + built = ev.result; + if (ev.result.files) files = ev.result.files; + setMetrics(built); + renderPane(); + } else if (ev.type === "done") { + settled = true; + files = ev.files; + setMetrics(built); + renderPane(); + } else if (ev.type === "error") { + settled = true; + flush(); + addError(ev.message); + } + } + }; + // A failure inside the frame handlers must not silently drop narration + // that already streamed: flush the open segments before the error + // reaches the turn-level handler. The one exception is a partial tail + // frame when the stream closes (complete-but-unterminated final frames + // are recovered above; a truly partial one cannot parse and is skipped, + // with the persisted transcript as its backstop). + const drainGuarded = (final) => { + try { + drain(final); + } catch (e) { + if (final && e instanceof SyntaxError) return; + try { commitSegs(); } catch { /* never mask the original failure */ } + throw e; + } + }; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + drainGuarded(false); + } + drainGuarded(true); + flush(); + // A killed proxy/Worker request closes the stream with no done/error, + // which previously looked stuck after the last edit. Progress is saved + // every round, so a retry continues cleanly. + if (!settled) addError("interrupted before finishing — progress is saved, retry your message"); + } catch (e) { + if (thinking.isConnected) thinking.remove(); + addError(e.message ?? String(e)); + } finally { + clearTimeout(queueTimer); + if (!accepted && sentEntries.length) { + // The request never reached the agent, so nothing was recorded: + // give the attachments back for a retry (plus anything attached + // meanwhile), capped so the next send is not rejected outright. + const restored = [...sentEntries, ...pendingImages]; + if (restored.length > 4) { + addError("kept the first 4 attachments; remove some before retrying"); + } + pendingImages = restored.slice(0, 4); + renderPending(); + } + busy = false; sendBtn.disabled = false; + input.focus(); + } +} + +sendBtn.onclick = send; +input.addEventListener("keydown", (e) => { + // Enter inserts a newline for multiline prompts; ⌘/Ctrl+Enter sends. + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); } +}); +input.addEventListener("input", () => { + input.style.height = "auto"; + input.style.height = Math.min(140, input.scrollHeight) + "px"; +}); + +`; diff --git a/src/client/attachments.ts b/src/client/attachments.ts new file mode 100644 index 0000000..8133c5c --- /dev/null +++ b/src/client/attachments.ts @@ -0,0 +1,142 @@ +export const ATTACH_JS = `/* ---------- image attachments (paste / attach, stored in R2) ---------- */ +const attachBtn = document.getElementById("attach"); +const filePick = document.getElementById("filepick"); +const pendingBox = document.getElementById("pending"); +const box = document.getElementById("box"); +let pendingImages = []; +let uploading = 0; +function renderPending() { + if (!pendingBox) return; + pendingBox.innerHTML = ""; + pendingBox.hidden = !pendingImages.length; + for (const p of pendingImages) { + const t = el("div", "thumb" + (p.uploading ? " uploading" : "")); + if (p.preview) { + const img = document.createElement("img"); + img.src = p.preview; + img.alt = "attached image"; + t.appendChild(img); + } else { + t.appendChild(el("div", "thumbwait", "…")); + } + const x = el("button", null, "×"); + x.title = "Remove attachment"; + x.setAttribute("aria-label", "Remove attachment"); + x.onclick = () => { + pendingImages = pendingImages.filter((q) => q !== p); + // The bytes were already persisted on attach: delete the R2 object so + // removed attachments do not linger as orphans. Best-effort — a + // failure keeps the (harmless, unreferenced) bytes, never the UI. + if (p.id) { + apiFetch("/api/images/" + encodeURIComponent(p.id), { method: "DELETE" }).catch(() => {}); + } + renderPending(); + syncSend(); + }; + t.appendChild(x); + pendingBox.appendChild(t); + } +} +function syncSend() { + sendBtn.disabled = busy || uploading > 0 || booting; +} +/** Read a File as a data URL (for upload + instant thumbnail). */ +function readAsDataUrl(file) { + return new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(String(r.result || "")); + r.onerror = () => reject(new Error("could not read " + (file.name || "image"))); + r.readAsDataURL(file); + }); +} +/** Upload data URLs: at most 4 total, images only, 5 MiB each (server enforces). */ +async function attachFiles(files) { + const list = [...(files || [])].filter((f) => f && /^image\\//.test(f.type || "")); + if (!list.length) return; + const valid = []; + for (const file of list) { + if (file.size > 5 * 1024 * 1024) { + addError("image too large (max 5 MiB): " + (file.name || "pasted image")); + continue; + } + valid.push(file); + } + if (!valid.length) return; + // Reserve slots synchronously: concurrent paste/drop/pick events must not + // all pass the cap check before any of them records its entries. + const slots = 4 - pendingImages.length; + if (slots <= 0) { + addError("at most 4 images per message"); + return; + } + const take = valid.slice(0, slots); + if (valid.length > slots) addError("at most 4 images per message (extras ignored)"); + uploading += take.length; + syncSend(); + const entries = take.map((file) => { + const entry = { preview: "", uploading: true, id: null, mimeType: file.type || "image/png", file }; + pendingImages.push(entry); + return entry; + }); + renderPending(); + await Promise.all( + entries.map(async (entry) => { + try { + const preview = await readAsDataUrl(entry.file); + entry.preview = preview; + renderPending(); + const r = await apiFetch("/api/images", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ data: preview, mimeType: entry.mimeType }), + }).then((r) => r.json()); + if (r.error) throw new Error(r.error); + entry.id = r.id; + entry.mimeType = r.mimeType || entry.mimeType; + entry.uploading = false; + } catch (e) { + pendingImages = pendingImages.filter((q) => q !== entry); + addError("upload failed: " + (e.message ?? e)); + } finally { + uploading--; + } + }), + ); + renderPending(); + syncSend(); +} +if (attachBtn && filePick) { + attachBtn.onclick = () => filePick.click(); + filePick.onchange = () => { + attachFiles(filePick.files).catch((e) => addError(e.message ?? String(e))); + filePick.value = ""; + }; +} +if (box) { + box.addEventListener("dragover", (e) => { + if ([...(e.dataTransfer?.types || [])].includes("Files")) e.preventDefault(); + }); + box.addEventListener("drop", (e) => { + // Claim the drop before filtering: a non-image file must not trigger the + // browser default (which navigates away from the app). + if (![...(e.dataTransfer?.types || [])].includes("Files")) return; + e.preventDefault(); + const files = [...(e.dataTransfer?.files || [])].filter((f) => /^image\\//.test(f.type || "")); + if (files.length) { + attachFiles(files).catch((err) => addError(err.message ?? String(err))); + } + }); +} +input.addEventListener("paste", (e) => { + const files = [...(e.clipboardData?.files || [])].filter((f) => /^image\\//.test(f.type || "")); + const items = [...(e.clipboardData?.items || [])].filter((i) => i.type.startsWith("image/")); + if (files.length || items.length) { + const picked = files.length ? files : items.map((i) => i.getAsFile()).filter(Boolean); + if (picked.length) { + e.preventDefault(); + attachFiles(picked).catch((err) => addError(err.message ?? String(err))); + } + } +}); + +`; diff --git a/src/client/core.ts b/src/client/core.ts new file mode 100644 index 0000000..763dd55 --- /dev/null +++ b/src/client/core.ts @@ -0,0 +1,38 @@ +export const CLIENT_PRELUDE_JS = `const log = document.getElementById("log"); + +// Surface script errors in the transcript. Without this a throw before the +// first render leaves the page silently idle. +window.addEventListener("error", (e) => { + // Third-party scripts injected into the page (wallet shims, extensions, + // antivirus/proxy injectors) run cross-origin, so their throws reach us + // sanitized as "Script error." with no file or line. Our own inline script + // always reports a filename, so a filenameless "Script error." cannot be + // ours. Not actionable — drop it instead of alarming the transcript. + if (e.message === "Script error." && !e.filename) { + console.debug("ignored cross-origin script error"); + return; + } + const msg = e.message || String(e.error); + // Brave on iOS injects a wallet script into every page that throws on + // "window.ethereum.selectedAddress = undefined" because no provider is + // injected. Not ours, not actionable. brave-ios#6656. + if (msg.includes("ethereum.selectedAddress")) return; + // Chrome on iOS injects GCrWeb helpers (__gCrWeb.*) into every page; when + // they throw, window.onerror attributes them to our inline script. Not + // ours, not actionable. + if (msg.includes("__gCrWeb")) return; + const n = document.createElement("div"); + n.className = "err"; + n.textContent = "js error: " + msg; + log.appendChild(n); +}); +const pane = document.getElementById("pane"); +// Static-html Preview helpers (pure, unit-tested in tests/preview-assets.test.ts; +// serialized here so the page and the tests run the same code). +`; +export const MARKDOWN_SHIM_JS = `// Assistant markdown (pure, unit-tested in tests/markdown.test.ts; serialized +// here so the page and the tests run the same code). +// The worker TS transform wraps nested helpers with __name(), which has no +// browser definition — the identity shim below is what that helper does. +var __name = (fn) => fn; +`; diff --git a/src/client/head.ts b/src/client/head.ts new file mode 100644 index 0000000..8e7d512 --- /dev/null +++ b/src/client/head.ts @@ -0,0 +1,308 @@ +export const PAGE_HEAD = ` + +
+ + +Log in with GitHub to use Chat, Code, Preview, Database and Logs.
+ +OAuth; token stays server-side.
+" invite codes (the id routes, the code redeems). */
-function parseInviteCode(raw: unknown): { playgroundId: string; code: string } | null {
- if (typeof raw !== "string") return null;
- const dot = raw.lastIndexOf(".");
- if (dot === -1) return null;
- const playgroundId = raw.slice(0, dot);
- const code = raw.slice(dot + 1);
- // The playground id is routable, never authenticating: a legacy device id
- // or a bare canonical id (`gh-`, the shared object's name — the
- // secret stays server-side and is never part of the invite). Membership is
- // proven separately by the member credential (see requireAccess).
- if ((!SID_RE.test(playgroundId) && !isCanonicalId(playgroundId)) || !/^[A-Za-z0-9]{12}$/.test(code)) return null;
- return { playgroundId, code };
-}
-
-type TurnLike = {
- getTurnStatus?: () => Promise<{ active: boolean; actor: string | null }>;
- isTurnActive?: () => Promise;
-};
-
-/** 409 when a turn runs (naming the actor on shared playgrounds), else null. */
-async function turnConflict(
- stub: TurnLike,
- session: { id: string; isNew: boolean },
-): Promise {
- try {
- if (typeof stub.getTurnStatus === "function") {
- const turn = await stub.getTurnStatus();
- if (turn?.active) {
- return sessionJson({ error: turnBusyError(turn.actor ?? null) }, session, 409);
- }
- } else if (typeof stub.isTurnActive === "function") {
- // Legacy stubs predate getTurnStatus: same guard, unnamed actor.
- if (await stub.isTurnActive()) {
- return sessionJson({ error: turnBusyError(null) }, session, 409);
- }
- }
- } catch {
- /* status unreadable: let the op run rather than fail the panel */
- }
- return null;
-}
-
-type VersionLike = { getFilesVersion?: () => Promise; getFiles?: () => Promise };
-type AuthStub = {
- getGitHubToken(): Promise;
- getGitHubStatus(): Promise<{ connected: boolean; user: { login: string; id: number } | null; grantedScopes?: string[] }>;
-};
-
-/**
- * Resolve the request's cookie to the owning Durable Object.
- *
- * Canonical bearers (`gh-.`) address the shared per-user
- * object after the secret allowlist check — one workspace across all of that
- * user's browsers, which is what resumes a session on a new device. Legacy
- * random cookies from before the switch lazily migrate on first use: the
- * device object's state is copied to the fresh canonical object once, a
- * secret is minted, and the response reissues the cookie as a bearer.
- *
- * Shared playgrounds (issue #30) are exempt from that migration: the cookie
- * names the shared object and members prove themselves with their own
- * `ab_member` credential (requireAccess), so adopting the playground into
- * one member's canonical object would fork it away from everyone else. A
- * bare canonical id likewise routes only to a share-enabled object — the id
- * alone is public and never authenticates a solo session.
- *
- * Stubs without the new methods (tests, older isolates) fall back to the
- * legacy direct mapping.
- */
-async function resolveAuth(
- env: Env,
- session: { id: string; isNew: boolean },
-): Promise<{ stub: AuthStub & Record; session: { id: string; isNew: boolean } }> {
- const parsed = parseSessionValue(session.id);
- const direct = (name: string) => env.APP.get(env.APP.idFromName(name)) as unknown as AuthStub & Record;
- /** The named object iff it is an enabled shared playground, else null. */
- const sharedTarget = async (name: string): Promise<(AuthStub & Record) | null> => {
- const stub = direct(name);
- try {
- const maybeShared = stub as unknown as { getShare?: () => Promise<{ enabled?: boolean } | null> };
- if (typeof maybeShared.getShare === "function") {
- const share = await maybeShared.getShare();
- if (share?.enabled) return stub;
- }
- } catch {
- /* share check failed: not a proven playground — no direct routing */
- }
- return null;
- };
- if (!parsed) {
- // Bare canonical ids join members to a shared playground; anything else
- // unparseable is anonymous. Solo sessions never route by bare id.
- if (isCanonicalId(session.id)) {
- const target = await sharedTarget(session.id);
- if (target) return { stub: target, session };
- }
- const fresh = crypto.randomUUID();
- return { stub: direct(fresh), session: { id: fresh, isNew: true } };
- }
- if (parsed.kind === "canonical") {
- const stub = direct(parsed.canonicalId);
- try {
- if (typeof stub.checkSessionSecret === "function") {
- if (await stub.checkSessionSecret(parsed.secret)) return { stub, session };
- } else {
- // Mock/legacy stub shape: preserve the old direct behavior.
- return { stub, session };
- }
- } catch {
- /* storage failure: fall through to anonymous */
- }
- const fresh = crypto.randomUUID();
- return { stub: direct(fresh), session: { id: fresh, isNew: true } };
- }
- const device = direct(parsed.deviceId);
- try {
- // A shared playground is owned collectively: skip the canonical adoption
- // below so the playground is never copied into one member's object.
- if (await sharedTarget(parsed.deviceId)) return { stub: device, session };
- if (typeof device.getGitHubStatus !== "function") return { stub: device, session };
- const status = await device.getGitHubStatus();
- const userId = status?.connected ? status?.user?.id : undefined;
- if (!userId) return { stub: device, session };
- const canonicalId = canonicalIdForGithubUser(userId);
- const canonical = direct(canonicalId);
- const secret = crypto.randomUUID();
- const hasClaim =
- typeof canonical.claimGithubSession === "function" && typeof canonical.addSessionSecret === "function";
- if (!hasClaim) return { stub: device, session };
- const existing = await canonical.getGitHubToken().catch(() => null);
- if (!existing) {
- const data =
- typeof device.exportState === "function" ? await device.exportState().catch(() => null) : null;
- const token = await device.getGitHubToken().catch(() => null);
- if (!token || !status.user) return { stub: device, session };
- await canonical.claimGithubSession(secret, token, status.user, status.grantedScopes ?? []);
- // Only invalidate the device once its state landed: a failed import
- // leaves the source intact so the next request retries instead of
- // losing data. Image adoption is best-effort — source R2 objects are
- // never deleted, so a failure there loses nothing.
- let imported = true;
- if (data && typeof canonical.importState === "function") {
- try {
- await canonical.importState(data);
- } catch {
- imported = false;
- }
- }
- if (typeof canonical.adoptImagesFrom === "function") {
- await canonical.adoptImagesFrom(parsed.deviceId).catch(() => {});
- }
- if (imported && typeof device.clearGitHub === "function") {
- await device.clearGitHub().catch(() => {});
- }
- } else {
- await canonical.addSessionSecret(secret);
- // The canonical workspace is authoritative from here: revoke the
- // legacy bearer so the old cookie/sid cannot fork state through the
- // direct device mapping.
- if (typeof device.clearGitHub === "function") await device.clearGitHub().catch(() => {});
- }
- return { stub: canonical, session: { id: `${canonicalId}.${secret}`, isNew: true } };
- } catch {
- return { stub: device, session };
- }
-}
-
-/**
- * Same bearer check for the cookie-less app iframe endpoints, with one
- * exception: a `sid` identical to the request's own cookie belongs to the
- * browser `resolveAuth` just authorized (and migrated), so it uses the
- * resolved stub directly. Without this, a preview racing its page's first
- * migration would 401 on the stale device id and never rotate its cookie.
- */
-async function resolveSidForRequest(
- env: Env,
- req: Request,
- sid: string,
- resolved: DurableObjectStub,
-): Promise | null> {
- if (!SID_RE.test(sid)) return null;
- const cookieVal = readCookie(req, SESSION_COOKIE);
- if (cookieVal && sid === cookieVal) return resolved;
- return (await resolveSid(env, sid)) as unknown as DurableObjectStub | null;
-}
-
-/**
- * Same bearer check for the cookie-less app iframe endpoints: the `sid` is
- * the full bearer, and the canonical id alone is rejected. Legacy sids keep
- * the old token-presence behavior for mock-shaped stubs only.
- */
-async function resolveSid(env: Env, sid: string): Promise<(AuthStub & Record) | null> {
- if (!SID_RE.test(sid)) return null;
- const parsed = parseSessionValue(sid);
- const direct = (name: string) => env.APP.get(env.APP.idFromName(name)) as unknown as AuthStub & Record;
- if (!parsed) return null;
- if (parsed.kind === "canonical") {
- const stub = direct(parsed.canonicalId);
- try {
- if (typeof stub.checkSessionSecret === "function") {
- return (await stub.checkSessionSecret(parsed.secret)) ? stub : null;
- }
- return (await stub.getGitHubToken()) ? stub : null;
- } catch {
- return null;
- }
- }
- return direct(parsed.deviceId);
-}
-
export default {
async fetch(req: Request, env: Env): Promise {
const url = new URL(req.url);
@@ -504,4135 +37,17 @@ export default {
return new Response(page(), { headers: withSession(headers, session) });
}
- if (url.pathname === "/api/state") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const files = await stub.getFiles();
- const messages = await stub.getMessages();
- const built = await stub.build();
- // version feeds the teammate-edit reload guard; turn/share feed the
- // Sharing panel so the other member sees live state without polling.
- let version = 0;
- let turn = null;
- let share = null;
- try {
- if (typeof stub.getFilesVersion === "function") version = await stub.getFilesVersion();
- if (typeof stub.getTurnStatus === "function") turn = await stub.getTurnStatus();
- const s = stub as { getShareStatus?: (viewerMemberId?: string | null) => Promise };
- if (typeof s.getShareStatus === "function") share = await s.getShareStatus(authed.access.memberId);
- } catch {
- /* best-effort extras: files/messages/built above are the state */
- }
- return sessionJson({ files, messages, built, version, turn, share }, session, 200, { "cache-control": "no-store" });
- }
-
- if (url.pathname === "/api/build" && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- return sessionJson({ built: await stub.build() }, session);
- }
-
- if (url.pathname === "/api/file" && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- // Same clobber hazard as the bulk save: read-mutate-write of the whole
- // map races an agent turn's write_file persists.
- const conflict = await turnConflict(stub, session);
- if (conflict) return conflict;
- const { path, content, baseVersion } = (await req.json()) as { path?: string; content?: string; baseVersion?: unknown };
- const key = (path ?? "").trim() || "index.ts";
- if (!isSafePath(key)) {
- return sessionJson({ error: "unsafe path" }, session, 400);
- }
- if (!isWorktreeKey(key)) {
- return sessionJson({ error: "refused path inside .git" }, session, 400);
- }
- // Teammate-edit guard: on shared playgrounds the version is required
- // (an omitted version could silently overwrite a teammate's save), and
- // the compare-and-write runs atomically in the DO — edge
- // check-then-set would still race concurrent saves.
- const saver = stub as {
- saveFileIfVersion?: (
- path: string,
- content: string,
- base: number | undefined,
- ) => Promise<{ ok: boolean; files: FileMap; version: number }>;
- getFilesVersion?: () => Promise;
- };
- if (authed.access.shared && baseVersion === undefined) {
- let version = 0;
- try {
- if (typeof saver.getFilesVersion === "function") version = await saver.getFilesVersion();
- } catch {
- /* best-effort */
- }
- return sessionJson(
- { error: "shared save needs the files version — reload and retry", files: await stub.getFiles(), version },
- session,
- 409,
- );
- }
- if (typeof saver.saveFileIfVersion === "function") {
- const saved = await saver.saveFileIfVersion(key, content ?? "", baseVersion as number | undefined);
- if (!saved.ok) {
- return sessionJson(
- { error: "teammate edited — reload: files changed since you loaded them, your copy is stale", files: saved.files, version: saved.version },
- session,
- 409,
- );
- }
- return sessionJson({ files: saved.files, built: await stub.build(), version: saved.version }, session);
- }
- const files = { ...(await stub.getFiles()) };
- files[key] = content ?? "";
- await stub.setFiles(files);
- let version = 0;
- try {
- if (typeof saver.getFilesVersion === "function") version = await saver.getFilesVersion();
- } catch {
- /* best-effort */
- }
- return sessionJson({ files, built: await stub.build(), version }, session);
- }
-
- // Chat image attachments: bytes live in R2 (via the session object), the
- // transcript keeps only `{ id, mimeType }` refs, and the model receives
- // inline base64 through Pi's UserInput on the next turn.
- if (url.pathname === "/api/images" && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- let body: unknown;
- try {
- body = await req.json();
- } catch {
- return sessionJson({ error: "expected a JSON body" }, session, 400);
- }
- const { data, mimeType } = (body ?? {}) as { data?: unknown; mimeType?: unknown };
- try {
- if (typeof data !== "string") throw new Error("image data is required");
- if (typeof mimeType !== "string") throw new Error("image mimeType is required");
- const saved = await stub.saveImage(data, mimeType);
- return sessionJson(
- { id: saved.id, url: `/api/images/${saved.id}`, mimeType: saved.mimeType, size: saved.size },
- session,
- );
- } catch (e) {
- return sessionJson({ error: e instanceof Error ? e.message : String(e) }, session, 400);
- }
- }
-
- const imageMatch = url.pathname.match(/^\/api\/images\/([A-Za-z0-9-]+)$/);
- if (imageMatch && req.method === "DELETE") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const id = imageMatch[1] as string;
- try {
- await stub.deleteImage(id);
- return sessionJson({ ok: true }, session);
- } catch (e) {
- return sessionJson({ error: e instanceof Error ? e.message : String(e) }, session, 400);
- }
- }
- if (imageMatch && req.method === "GET") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const id = imageMatch[1] as string;
- if (!isImageId(id)) return sessionJson({ error: "bad image id" }, session, 400);
- const stored = await stub.getImage(id);
- if (!stored) return sessionJson({ error: "image not found" }, session, 404);
- let bytes: Uint8Array;
- try {
- const checked = validateImageInput(stored.data, stored.mimeType);
- bytes = Uint8Array.from(atob(checked.data), (c) => c.charCodeAt(0));
- } catch {
- return sessionJson({ error: "image unreadable" }, session, 500);
- }
- // `bytes.buffer` may be a SharedArrayBuffer over a pool: slice to the
- // exact view before responding.
- const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
- return new Response(body, {
- headers: withSession(
- new Headers({
- "content-type": stored.mimeType,
- "cache-control": "private, max-age=3153600",
- }),
- session,
- ),
- });
- }
-
- if (url.pathname === "/api/reset" && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- return sessionJson(await stub.reset(), session);
- }
-
- // Clear agent context only; capsule files are left untouched. The chat
- // transcript persists per session in the Durable Object, so without this
- // a refresh restores an invisible history that still consumes context.
- if (url.pathname === "/api/clear-chat" && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- await stub.clearChat();
- return sessionJson({ ok: true }, session);
- }
-
- // Named chat checkpoints: snapshot the current messages + files, list
- // them newest-first, or restore one into the live session.
- if (url.pathname === "/api/chats" && req.method === "GET") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- // Current link rides along so the picker can show it without a second
- // round-trip; best-effort for stub shapes that lack getGitHubStatus.
- let linked: unknown = null;
- try {
- linked = (await stub.getGitHubStatus())?.linked ?? null;
- } catch {
- linked = null;
- }
- return sessionJson({ chats: await stub.listChats(), linked }, session, 200, { "cache-control": "no-store" });
- }
-
- if (url.pathname === "/api/chats/snapshot" && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- let title = "";
- let files: FileMap | undefined;
- try {
- const body = (await req.json()) as { title?: unknown; files?: unknown };
- title = body.title as string ?? "";
- // The Code tab keeps unsaved edits in browser memory: the snapshot
- // button sends its file map so the checkpoint captures them. Validate
- // like the bulk-save endpoint; omitted means "persisted state" (Pi
- // turns, auto-checkpoints, older clients).
- if (body.files !== undefined) {
- if (!body.files || typeof body.files !== "object" || Array.isArray(body.files)) {
- return sessionJson({ error: "expected { files }" }, session, 400);
- }
- for (const key of Object.keys(body.files as FileMap)) {
- if (!isWorktreeKey(key) || typeof (body.files as FileMap)[key] !== "string") {
- return sessionJson({ error: `refused path: ${key}` }, session, 400);
- }
- }
- files = { ...(body.files as FileMap) };
- }
- } catch {
- /* empty body: default title */
- }
- try {
- return sessionJson({ chat: await stub.snapshotChat(typeof title === "string" ? title : "", files, authed.access.actor ?? undefined) }, session);
- } catch (e) {
- const message = e instanceof Error ? e.message : String(e);
- const status = message.startsWith("chat snapshot too large") ? 413
- : message.startsWith("an agent turn is in progress") ? 409
- : 500;
- return sessionJson({ error: message }, session, status);
- }
- }
-
- const renameMatch = url.pathname.match(/^\/api\/chats\/([A-Za-z0-9_-]{8,64})\/?rename$/);
- if (renameMatch && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- let title = "";
- try {
- title = ((await req.json()) as { title?: unknown }).title as string ?? "";
- } catch {
- /* empty body: title required below */
- }
- if (typeof title !== "string" || !title.trim()) {
- return sessionJson({ error: "snapshot title is required" }, session, 400);
- }
- try {
- return sessionJson({ chat: await stub.renameChat(renameMatch[1] as string, title) }, session);
- } catch (e) {
- const message = e instanceof Error ? e.message : String(e);
- const status =
- message === "chat snapshot not found" ? 404
- : message.startsWith("chat snapshot too large") ? 413
- : 500;
- return sessionJson({ error: message }, session, status);
- }
- }
-
- const resumeMatch = url.pathname.match(/^\/api\/chats\/([A-Za-z0-9_-]{8,64})\/?resume$/);
- if (resumeMatch && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- try {
- // resumeChat builds under its own lock, so the response always
- // describes the session it restored.
- return sessionJson(await stub.resumeChat(resumeMatch[1] as string, authed.access.actor ?? undefined), session);
- } catch (e) {
- const message = e instanceof Error ? e.message : String(e);
- const status = message === "chat snapshot not found" ? 404 : message.startsWith("chat snapshot too large") ? 413 : 500;
- return sessionJson({ error: message }, session, status);
- }
- }
-
- const deleteMatch = url.pathname.match(/^\/api\/chats\/([A-Za-z0-9_-]{8,64})\/delete\/?$/);
- if (deleteMatch && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- try {
- return sessionJson(await stub.deleteChat(deleteMatch[1] as string), session);
- } catch (e) {
- const message = e instanceof Error ? e.message : String(e);
- const status = message === "chat snapshot not found" ? 404 : 500;
- return sessionJson({ error: message }, session, status);
- }
- }
-
- // Start a fresh chat session, optionally bound to a repo: an optional
- // title snapshots the current chat first, an optional repoRef clones the
- // repo, then the agent context clears so the new session starts clean.
- if (url.pathname === "/api/chats/start" && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- // Same clobber hazard as the bulk file save: cloning replaces the whole
- // FileMap, so refuse while an agent turn is writing instead of racing
- // it and losing either side's files.
- const conflict = await turnConflict(stub, session);
- if (conflict) return conflict;
- const as = authed.access.shared && authed.access.user
- ? { token: authed.access.token, user: authed.access.user }
- : undefined;
- let body: { repoRef?: unknown; branch?: unknown; title?: unknown } = {};
- try {
- const parsed = (await req.json()) as typeof body;
- if (parsed && typeof parsed === "object") body = parsed;
- } catch {
- /* empty body: fresh chat with no repo */
- }
- const repoRef = typeof body.repoRef === "string" ? body.repoRef.trim() : "";
- const branch = typeof body.branch === "string" ? body.branch.trim() : "";
- const title = typeof body.title === "string" ? body.title : "";
- try {
- if (title.trim()) await stub.snapshotChat(title, undefined, authed.access.actor ?? undefined);
- let cloned: { files?: unknown; meta?: unknown } | null = null;
- if (repoRef) {
- const { owner, repo } = parseRepoRef(repoRef);
- cloned = await stub.cloneFromGitHub({ owner, repo, branch: branch || undefined, ...(as ? { as } : {}) });
- } else {
- // No repo picked: drop a stale link so the fresh chat, its header
- // badge, and its snapshots do not inherit the previous repo.
- await stub.unlinkRepo();
- }
- await stub.clearChat();
- const built = await stub.build();
- let linked: unknown = cloned?.meta ?? null;
- try {
- linked = (await stub.getGitHubStatus())?.linked ?? linked;
- } catch {
- /* stub without status: keep the clone meta */
- }
- return sessionJson({ files: await stub.getFiles(), built, linked }, session);
- } catch (e) {
- const message = (e as Error).message;
- const status = message.startsWith("chat snapshot too large") ? 413 : 500;
- return sessionJson({ error: message }, session, status);
- }
- }
-
- if (url.pathname === "/api/app-shim.js") {
- return new Response(APP_SHIM_JS, {
- headers: {
- "content-type": "text/javascript; charset=utf-8",
- "cache-control": "public, max-age=3600",
- ...APP_CORS,
- },
- });
- }
-
- // Live app view endpoints. The iframe runs sandboxed to an opaque origin
- // with no cookies, so it cannot present `ab_member`. It presents a
- // short-lived preview capability minted by the cookie-authenticated
- // parent instead (POST /api/share/preview): a bare `?sid=` — which the
- // invite URL discloses — never suffices on its own.
- if (url.pathname === "/api/app-state" || url.pathname === "/api/app-mutate") {
- if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: APP_CORS });
- }
-
- /**
- * Preview capability check for the sandboxed iframe. Stubs that predate
- * preview tokens (tests, older shapes) fall back to the object-token
- * gate; real objects always enforce the capability.
- */
- const checkPreview = async (
- sidStub: {
- getGitHubToken?: () => Promise;
- checkPreviewToken?: (preview: unknown) => Promise<{ memberId: string | null } | null>;
- },
- preview: unknown,
- ): Promise => {
- if (typeof sidStub.checkPreviewToken === "function") {
- const grant = await sidStub.checkPreviewToken(preview).catch(() => null);
- if (!grant) {
- return corsJson(
- { ok: false, error: { name: "Auth", message: "preview expired or invalid — reload the preview" } },
- 401,
- );
- }
- return null;
- }
- return null;
- };
-
- if (url.pathname === "/api/app-state") {
- const sid = url.searchParams.get("sid") ?? "";
- const sidStub = await resolveSidForRequest(env, req, sid, stub);
- if (!sid) return corsJson({ ok: false, error: { name: "Session", message: "bad sid" } }, 400);
- if (!sidStub) {
- // Unknown shape or revoked bearer: indistinguishable from bad sid.
- return SID_RE.test(sid)
- ? corsJson({ ok: false, error: { name: "Auth", message: "github login required" } }, 401)
- : corsJson({ ok: false, error: { name: "Session", message: "bad sid" } }, 400);
- }
- if (!(await sidStub.getGitHubToken())) {
- return corsJson({ ok: false, error: { name: "Auth", message: "github login required" } }, 401);
- }
- const previewGate = await checkPreview(sidStub, url.searchParams.get("preview"));
- if (previewGate) return previewGate;
- return corsJson(await sidStub.appState());
- }
-
- if (url.pathname === "/api/app-mutate" && req.method === "POST") {
- let body: { sid?: unknown; preview?: unknown; kind?: unknown; name?: unknown; args?: unknown };
- try {
- body = (await req.json()) as typeof body;
- } catch {
- return corsJson({ ok: false, error: { name: "Request", message: "expected a JSON body" } }, 400);
- }
- if (typeof body.sid !== "string" || !SID_RE.test(body.sid)) {
- return corsJson({ ok: false, error: { name: "Session", message: "bad sid" } }, 400);
- }
- const mutateStub = await resolveSidForRequest(env, req, body.sid, stub);
- if (!mutateStub) {
- return corsJson({ ok: false, error: { name: "Auth", message: "github login required" } }, 401);
- }
- if (!(await mutateStub.getGitHubToken())) {
- return corsJson({ ok: false, error: { name: "Auth", message: "github login required" } }, 401);
- }
- const mutateGate = await checkPreview(mutateStub, body.preview);
- if (mutateGate) return mutateGate;
- if (body.kind !== "mutation" && body.kind !== "action") {
- return corsJson({ ok: false, error: { name: "Request", message: "kind must be mutation or action" } }, 400);
- }
- if (typeof body.name !== "string" || !body.name) {
- return corsJson({ ok: false, error: { name: "Request", message: "name must be a non-empty string" } }, 400);
- }
- if (body.args !== undefined && !Array.isArray(body.args)) {
- return corsJson({ ok: false, error: { name: "Request", message: "args must be an array" } }, 400);
- }
- const kind: AppKind = body.kind;
- return corsJson(
- await mutateStub.appMutate(kind, body.name, body.args ?? []),
- );
- }
-
- if (url.pathname === "/api/app") {
- const sid = url.searchParams.get("sid") ?? "";
- if (!SID_RE.test(sid)) return new Response("bad sid", { status: 400 });
- const appStub = await resolveSidForRequest(env, req, sid, stub);
- if (!appStub) {
- return new Response("github login required", { status: 401 });
- }
- if (!(await appStub.getGitHubToken())) {
- return new Response("github login required", { status: 401 });
- }
- // The served document embeds live state: gate the capability like the
- // JSON endpoints, or the sid alone (disclosed by invite URLs) loads it.
- if (typeof appStub.checkPreviewToken === "function") {
- const grant = await appStub.checkPreviewToken(url.searchParams.get("preview")).catch(() => null);
- if (!grant) return new Response("preview expired or invalid — reload the preview", { status: 401 });
- }
- const previewParam = url.searchParams.get("preview") ?? "";
- const appFiles = await appStub.getFiles();
- // The RPC stub type does not preserve the discriminated union, so
- // restore it: at runtime this is plain data in exactly this shape.
- const state = (await appStub.appState()) as AppState;
- let bundle: string | null;
- let bundleError: string | null = null;
- try {
- bundle = buildAppBundle(appFiles, `${url.origin}/api/app-shim.js`);
- } catch (e: any) {
- bundle = null;
- bundleError = e?.message ?? String(e);
- }
- return new Response(
- appDoc(bundle, {
- sid,
- preview: previewParam,
- api: { state: `${url.origin}/api/app-state`, mutate: `${url.origin}/api/app-mutate` },
- state: state.ok ? { tables: state.tables, queries: state.queries } : null,
- error: state.ok ? (bundleError ? { message: bundleError } : null) : state.error,
- }),
- {
- headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
- },
- );
- }
-
- // Pi boot probe (no key needed): forwards to the object's /pi-status.
- // Gated like every other session API: it answers for the caller's object.
- if (url.pathname === "/api/pi-harness-status" && req.method === "GET") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const res = await stub.fetch(new Request(new URL("/pi-status", url.origin), { method: "GET" }));
- const headers = withSession(new Headers(res.headers), session);
- // Probes must never cache: busyflips live while runs come and go.
- headers.set("cache-control", "no-store");
- return new Response(res.body, { status: res.status, headers });
- }
-
- // The agent loop runs in Pi (Durable Object PiHarness): validate and
- // resolve BYOK at the edge, forward to the object, stream its SSE back
- // with the session cookie.
- if (url.pathname === "/api/chat" && req.method === "POST") {
- // GitHub login comes before the API-key check: anonymous visitors get
- // the login gate, not the key prompt.
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const actor = authed.access.actor;
- let body: unknown;
- try {
- body = await req.json();
- } catch {
- return sessionJson({ error: "expected a JSON body" }, session, 400);
- }
- if (!body || typeof body !== "object") {
- return sessionJson({ error: "expected a JSON body" }, session, 400);
- }
- const { message, key, gateway, model, images } = body as {
- message?: unknown;
- key?: string;
- gateway?: string;
- model?: string;
- images?: unknown;
- };
- if (typeof message !== "string" || !message.trim()) {
- return sessionJson({ error: "empty message" }, session, 400);
- }
- let imageRefs: { id: string }[];
- try {
- imageRefs = validateImageRefs(images);
- } catch (e) {
- return sessionJson({ error: e instanceof Error ? e.message : String(e) }, session, 400);
- }
- const bodyKey = typeof key === "string" ? key.trim() : "";
- const apiKey =
- req.headers.get("x-provider-key")?.trim() || bodyKey || env.OPENCODE_ZEN_KEY?.trim();
- if (!apiKey) {
- return sessionJson(
- { error: "no API key — enter one below (stored only in this browser)" },
- session,
- 401,
- );
- }
- // withSession: the forwarded DO response carries no cookies, so a
- // first request must still set ab_session or the next request lands
- // on another object. A rejected forward (dead object, RPC failure)
- // still answers as an SSE error frame with the cookie, never a bare
- // 500 that also drops the session.
- let res: Response;
- try {
- res = await stub.fetch(
- new Request(new URL("/pi-harness", url.origin), {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ message, apiKey, gateway, model, images: imageRefs, author: actor, memberId: authed.access.memberId }),
- }),
- );
- } catch (e) {
- const frame = `data: ${JSON.stringify({ type: "error", message: e instanceof Error ? e.message : String(e) })}\n\n`;
- return new Response(frame, {
- headers: withSession(
- new Headers({ "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-store" }),
- session,
- ),
- });
- }
- const headers = withSession(new Headers(res.headers), session);
- return new Response(res.body, { status: res.status, headers });
- }
-
- // ---------- GitHub (OAuth + just-git, server-side) ----------
-
- if (url.pathname === "/api/github/status") {
- // Shared playgrounds scope status to the calling member (their own
- // login/token), so one member never sees another's token state as
- // their own. No member credential: legacy solo status.
- try {
- const lookedUp = await stubShare(stub);
- const share = lookedUp.ok ? lookedUp.share : null;
- if (share?.enabled) {
- const secret = readCookie(req, MEMBER_COOKIE);
- const member = secret ? (share.members.find((m) => m.id === secret) ?? null) : null;
- if (member) {
- const memberToken = typeof stub.getMemberToken === "function"
- ? await stub.getMemberToken(member.id).catch(() => null)
- : null;
- const memberUser = typeof stub.getMemberUser === "function"
- ? await stub.getMemberUser(member.id).catch(() => null)
- : null;
- const linked = (await stub.getGitHubStatus().catch(() => null))?.linked ?? null;
- const scopes = memberToken ? await getTokenScopes(memberToken).catch(() => []) : [];
- let shareStatus: unknown = null;
- try {
- const s = stub as { getShareStatus?: (viewerMemberId?: string | null) => Promise };
- if (typeof s.getShareStatus === "function") shareStatus = await s.getShareStatus(member.id);
- } catch {
- shareStatus = null;
- }
- return sessionJson(
- {
- connected: !!memberToken,
- user: memberUser ?? (member.login ? { login: member.login, id: member.userId ?? 0 } : null),
- linked,
- scopes,
- grantedScopes: [],
- tokenKind: tokenKind(memberToken),
- ...(shareStatus ? { share: shareStatus } : {}),
- },
- session,
- 200,
- { "cache-control": "no-store" },
- );
- }
- }
- } catch {
- /* fall through to the solo status */
- }
- return sessionJson(await stub.getGitHubStatus(), session, 200, {
- "cache-control": "no-store",
- });
- }
-
- if (url.pathname === "/api/github/login") {
- try {
- const { clientId } = requireEnv(env as unknown as Record);
- const redirectUri = `${url.origin}/api/github/callback`;
- // state carries the session id so the callback can store the token in
- // the right Durable Object even if the cookie is missing.
- //
- // Bare playground ids are public, so they must not travel alone: bind
- // an unguessable one-time nonce to this browser via an HttpOnly cookie
- // (the callback requires the two to match). Bearer/legacy states stay
- // as-is — they are already unguessable.
- let state = session.id;
- const headers = withSession(new Headers(), session);
- if (isCanonicalId(session.id)) {
- const nonce = crypto.randomUUID().replace(/-/g, "").slice(0, 20);
- state = `${session.id}.${nonce}`;
- const secure = url.protocol === "https:" ? "; Secure" : "";
- headers.append(
- "set-cookie",
- `${LOGIN_STATE_COOKIE}=${state}; Path=/; Max-Age=600; HttpOnly; SameSite=Lax${secure}`,
- );
- }
- const target = loginUrl({ clientId, redirectUri, state });
- headers.append("location", target);
- return new Response(null, { status: 302, headers });
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- if (url.pathname === "/api/github/callback") {
- const code = url.searchParams.get("code");
- const state = url.searchParams.get("state");
- // `state` is a session value, or the bare id of a shared playground
- // (the join cookie — members log in from it; see below).
- if (!code || !state || (!parseSessionValue(state) && !isCanonicalId(state))) {
- return new Response("invalid oauth callback", { status: 400 });
- }
- // Login CSRF / session fixation guard: `state` is the session id sent in
- // the authorize URL, so only honor it when it matches the browser's own
- // session cookie. The login redirect sets that cookie (even for new
- // sessions), so a same-browser flow always round-trips; a crafted link
- // opened by a victim carries the attacker's state but the victim's
- // cookie and is rejected here before any token is stored.
- //
- // Bare playground ids are public, so the cookie match alone proves
- // nothing for them: logins started from a bare id also carry a
- // one-time nonce echoed in an HttpOnly cookie, and the playground is
- // accepted only when the two match (validated below).
- const sessionCookie = readCookie(req, SESSION_COOKIE);
- const loginEcho = readCookie(req, LOGIN_STATE_COOKIE);
- let playground: string | null = null;
- if (loginEcho && loginEcho === state) {
- const dot = state.lastIndexOf(".");
- const maybeId = dot === -1 ? "" : state.slice(0, dot);
- if (isCanonicalId(maybeId)) playground = maybeId;
- }
- if (sessionCookie !== state && sessionCookie !== playground) {
- return new Response("session mismatch — start login again from the GitHub tab", { status: 400 });
- }
- // Consuming the nonce: whatever happens below, this login attempt is
- // over, so a captured state cannot be replayed with a fresh code.
- const consumeNonce = (headers: Headers) => {
- if (playground) {
- const secure = url.protocol === "https:" ? "; Secure" : "";
- headers.append("set-cookie", `${LOGIN_STATE_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`);
- }
- return headers;
- };
- try {
- const { clientId, clientSecret } = requireEnv(env as unknown as Record);
- const redirectUri = `${url.origin}/api/github/callback`;
- const { token, grantedScopes } = await exchangeCode({ clientId, clientSecret, code, redirectUri });
- const user = await getUser(token);
- // `state` names the pre-login object: a canonical bearer contributes
- // its object name, a legacy value is the name itself.
- const targetParsed = parseSessionValue(state);
- const target = env.APP.get(
- env.APP.idFromName(targetParsed?.kind === "canonical" ? targetParsed.canonicalId : state),
- );
- // Shared playgrounds store the token under the calling member, never
- // the owner slot: joining + logging in must not grant the owner's
- // stored credential, and must not overwrite it either.
- let memberId: string | null = null;
- let sharedLookupFailed = false;
- try {
- const t = target as unknown as {
- getShare?: () => Promise;
- setMemberToken?: (id: string, token: string, user: GitHubUser) => Promise;
- };
- if (typeof t.getShare === "function") {
- const share = await t.getShare();
- if (share?.enabled) {
- // A bare playground id is public: without a validated login
- // nonce this state could be any browser's, so refuse before
- // touching member slots (login CSRF). Legacy/bearer states are
- // unguessable and need no nonce.
- if (!targetParsed && !playground) {
- return new Response("start login again from the GitHub tab", { status: 400 });
- }
- const secret = readCookie(req, MEMBER_COOKIE);
- const member = secret ? (share.members.find((m) => m.id === secret) ?? null) : null;
- if (!member) {
- return new Response("this playground is shared — join with an invite code first", {
- status: 403,
- headers: consumeNonce(new Headers()),
- });
- }
- if (typeof t.setMemberToken === "function") memberId = member.id;
- }
- }
- } catch {
- // Share state unreadable: storing the token now could credit the
- // wrong slot, so fail instead of guessing.
- sharedLookupFailed = true;
- }
- if (sharedLookupFailed) {
- return new Response("could not verify playground membership — retry login", {
- status: 500,
- headers: consumeNonce(new Headers()),
- });
- }
- if (memberId) {
- const t = target as unknown as {
- setMemberToken: (id: string, token: string, user: GitHubUser) => Promise;
- };
- await t.setMemberToken(memberId, token, user);
- // Members stay on the playground cookie: no canonical bearer is
- // minted for them, and the shared object is never adopted into a
- // personal one (see resolveAuth).
- return new Response(null, {
- status: 302,
- headers: consumeNonce(new Headers({ location: "/" })),
- });
- }
- if (!targetParsed) {
- // Bare object ids are only meaningful for shared playgrounds
- // (handled above). Anywhere else the state is invalid: the
- // canonical flow below must never treat a foreign object as the
- // caller's pre-login device — it would export and clear it.
- return new Response("invalid oauth callback", { status: 400 });
- }
- // Solo session: fall through to the canonical flow below.
- // Every login lands on the canonical per-user object, so a second
- // browser picks up the same files/chat. The cookie bearer is the
- // full `gh-.`; the id alone never authenticates.
- const canonicalId = canonicalIdForGithubUser(user.id);
- const canonical = env.APP.get(env.APP.idFromName(canonicalId)) as unknown as Record;
- const stateParsed = parseSessionValue(state);
- if (stateParsed?.kind === "canonical" && stateParsed.canonicalId === canonicalId) {
- // Re-login from a canonical bearer: refresh the token, keep the
- // browser's existing secret.
- if (typeof canonical.claimGithubSession === "function") {
- await canonical.claimGithubSession(stateParsed.secret, token, user, grantedScopes);
- } else {
- await canonical.setGitHubToken(token, user, grantedScopes);
- }
- const headers = new Headers({ location: "/" });
- headers.append(
- "set-cookie",
- `${SESSION_COOKIE}=${state}; Path=/; Max-Age=31536000; SameSite=Lax`,
- );
- return new Response(null, { status: 302, headers });
- }
- const secret = crypto.randomUUID();
- const bearer = `${canonicalId}.${secret}`;
- if (typeof canonical.claimGithubSession === "function") {
- const existing = await canonical.getGitHubToken().catch(() => null);
- if (!existing) {
- // Fresh canonical object: adopt the pre-login device state (if
- // any — the login gate normally keeps it empty) and its images.
- // The device is invalidated only after the import lands, so a
- // storage failure retries instead of losing state.
- const device = env.APP.get(env.APP.idFromName(state)) as unknown as Record;
- const data = typeof device.exportState === "function"
- ? await device.exportState().catch(() => null)
- : null;
- await canonical.claimGithubSession(secret, token, user, grantedScopes);
- let imported = true;
- if (data && typeof canonical.importState === "function") {
- try {
- await canonical.importState(data);
- } catch {
- imported = false;
- }
- }
- if (typeof canonical.adoptImagesFrom === "function") {
- await canonical.adoptImagesFrom(state).catch(() => {});
- }
- if (imported && typeof device.clearGitHub === "function") {
- await device.clearGitHub().catch(() => {});
- }
- } else {
- await canonical.claimGithubSession(secret, token, user, grantedScopes);
- if (stateParsed?.kind === "legacy") {
- const device = env.APP.get(env.APP.idFromName(stateParsed.deviceId)) as unknown as Record;
- if (typeof device.clearGitHub === "function") await device.clearGitHub().catch(() => {});
- }
- }
- } else {
- await canonical.setGitHubToken(token, user, grantedScopes);
- }
- const headers = new Headers({ location: "/" });
- headers.append(
- "set-cookie",
- `${SESSION_COOKIE}=${typeof canonical.claimGithubSession === "function" ? bearer : state}; Path=/; Max-Age=31536000; SameSite=Lax`,
- );
- return new Response(null, { status: 302, headers });
- } catch (e) {
- return new Response(`github login failed: ${(e as Error).message}`, { status: 500 });
- }
- }
-
- if (url.pathname === "/api/github/logout" && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- // On a shared playground, logging out drops only the caller's own
- // token (membership stays, so re-login restores). clearGitHub would
- // wipe the link/history for everyone — never run it on a shared DO.
- if (authed.access.shared && authed.access.memberId && typeof stub.clearMemberToken === "function") {
- await stub.clearMemberToken(authed.access.memberId);
- return sessionJson({ ok: true }, session);
- }
- const parsed = parseSessionValue(session.id);
- const expire = () => {
- const headers = new Headers({ "content-type": "application/json; charset=utf-8" });
- headers.append("set-cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
- return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
- };
- if (parsed?.kind === "canonical") {
- // Single-browser logout: revoke this bearer, atomically with the
- // last-browser wipe so a concurrent OAuth claim cannot interleave.
- // The last browser out also clears the token/link, matching the old
- // global logout. Mock-shaped stubs without logoutSecret fall back
- // to the two-step sequence.
- if (typeof stub.logoutSecret === "function") {
- await stub.logoutSecret(parsed.secret);
- return expire();
- }
- if (typeof stub.revokeSessionSecret === "function") {
- const { remaining } = await stub.revokeSessionSecret(parsed.secret);
- if (remaining <= 0) await stub.clearGitHub();
- return expire();
- }
- }
- await stub.clearGitHub();
- return sessionJson({ ok: true }, session);
- }
-
- // ---------- Live sharing (issue #30) ----------
-
- // Owner creates (or rotates) the join invite. Joining mints a fresh
- // member credential — the owner's cookie and token are never shared.
- if (url.pathname === "/api/share/invite" && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- if (authed.access.shared && authed.access.role !== "owner") {
- return sessionJson({ error: "only the playground owner can invite" }, session, 403);
- }
- try {
- // The caller's authenticated credentials decide (never the stale
- // stored token): rotation after a logout/re-auth must not rebind
- // the owner slot to a revoked token or a different identity.
- const invite = await stub.createInvite({
- token: authed.access.token,
- user: authed.access.user,
- memberId: authed.access.memberId,
- });
- // The owner needs their member credential from here on: shared mode
- // requires it on every request, and without this cookie the invite
- // would lock the owner out of their own playground.
- const headers = new Headers({ "content-type": "application/json; charset=utf-8" });
- withMember(headers, (invite as { memberSecret?: string }).memberSecret ?? null, req);
- // The routable object name — never the canonical bearer: the secret
- // after the dot authenticates the owner and must not leave the
- // server. Members route by this id and prove themselves with their
- // own member credential (see resolveAuth/requireAccess).
- const sessionParsed = parseSessionValue(session.id);
- const playgroundId =
- sessionParsed?.kind === "canonical" ? sessionParsed.canonicalId : session.id;
- return new Response(JSON.stringify({ ...invite, playgroundId }), { headers });
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- // Redeem ".": the caller's cookie is mapped onto the
- // shared DO and a per-browser member secret is set. No login needed yet —
- // the member links their own GitHub account afterwards.
- if (url.pathname === "/api/share/join" && req.method === "POST") {
- let body: unknown;
- try {
- body = await req.json();
- } catch {
- return sessionJson({ error: "expected a JSON body" }, session, 400);
- }
- const parsed = parseInviteCode((body as { code?: unknown })?.code);
- if (!parsed) {
- return sessionJson({ error: 'expected { code } as "."' }, session, 400);
- }
- const target = env.APP.get(env.APP.idFromName(parsed.playgroundId));
- try {
- const { memberSecret } = await target.joinPlayground(parsed.code);
- const headers = new Headers({ "content-type": "application/json; charset=utf-8" });
- headers.append(
- "set-cookie",
- `${SESSION_COOKIE}=${parsed.playgroundId}; Path=/; Max-Age=31536000; SameSite=Lax`,
- );
- withMember(headers, memberSecret, req);
- return new Response(JSON.stringify({ ok: true }), { headers });
- } catch (e) {
- return sessionJson({ error: e instanceof Error ? e.message : String(e) }, session, 400);
- }
- }
-
- // Sharing panel state. Members see members/turn/audit; non-members only
- // learn that the playground is shared (join state), never the roster.
- if (url.pathname === "/api/share/status") {
- try {
- const lookedUp = await stubShare(stub);
- if (!lookedUp.ok) {
- return sessionJson({ error: "could not verify playground membership — retry" }, session, 500);
- }
- const share = lookedUp.share;
- if (!share?.enabled) {
- let turn = null;
- let filesVersion = 0;
- try {
- const s = stub as {
- getTurnStatus?: () => Promise;
- getFilesVersion?: () => Promise;
- };
- if (typeof s.getTurnStatus === "function") turn = await s.getTurnStatus();
- if (typeof s.getFilesVersion === "function") filesVersion = await s.getFilesVersion();
- } catch {
- /* best-effort */
- }
- return sessionJson({ shared: false, joined: true, turn, filesVersion }, session, 200, {
- "cache-control": "no-store",
- });
- }
- const secret = readCookie(req, MEMBER_COOKIE);
- const member = secret ? (share.members.find((m) => m.id === secret) ?? null) : null;
- if (!member) {
- return sessionJson({ shared: true, joined: false }, session, 200, { "cache-control": "no-store" });
- }
- const s = stub as { getShareStatus?: (viewerMemberId?: string | null) => Promise };
- const status =
- typeof s.getShareStatus === "function" ? await s.getShareStatus(member.id) : { shared: true };
- return sessionJson({ ...(status as Record), joined: true }, session, 200, {
- "cache-control": "no-store",
- });
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- // Short-lived iframe capability (see preview tokens in session): minted
- // by the cookie-authenticated, member-gated parent before mounting the
- // sandboxed frame, which cannot present cookies or `ab_member` itself.
- if (url.pathname === "/api/share/preview" && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- try {
- if (typeof stub.mintPreviewToken !== "function") {
- return sessionJson({ error: "preview tokens unavailable" }, session, 500);
- }
- return sessionJson(await stub.mintPreviewToken(authed.access.memberId), session);
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- // Leave a shared playground: membership and the member's token go, the
- // playground persists for the rest. Fresh solo session afterwards.
- if (url.pathname === "/api/share/leave" && req.method === "POST") {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- if (!authed.access.shared || !authed.access.memberId) {
- return sessionJson({ error: "this playground is not shared" }, session, 400);
- }
- try {
- if (typeof stub.leavePlayground === "function") {
- await stub.leavePlayground(authed.access.memberId);
- }
- const fresh = crypto.randomUUID();
- const headers = new Headers({ "content-type": "application/json; charset=utf-8" });
- headers.append("set-cookie", `${SESSION_COOKIE}=${fresh}; Path=/; Max-Age=31536000; SameSite=Lax`);
- withMember(headers, null, req);
- return new Response(JSON.stringify({ ok: true }), { headers });
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- if (url.pathname === "/api/github/repos") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- return sessionJson({ repos: await listRepos(authed.access.token) }, session, 200, {
- "cache-control": "no-store",
- });
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- // Bulk-save the editor's in-memory file map without building. The panel
- // calls this before commit/create so unsaved browser edits are not
- // silently omitted from the push.
- if (url.pathname === "/api/files" && req.method === "POST") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- // The browser snapshot can lag an active agent turn; replacing the
- // whole FileMap then would discard already-persisted agent edits.
- const conflict = await turnConflict(stub, session);
- if (conflict) return conflict;
- const { files: incoming, baseVersion } = (await req.json()) as { files?: FileMap; baseVersion?: unknown };
- if (!incoming || typeof incoming !== "object") {
- return sessionJson({ error: "expected { files }" }, session, 400);
- }
- for (const key of Object.keys(incoming)) {
- if (!isWorktreeKey(key) || typeof incoming[key] !== "string") {
- return sessionJson({ error: `refused path: ${key}` }, session, 400);
- }
- }
- const saver = stub as {
- saveFilesIfVersion?: (
- files: FileMap,
- base: number | undefined,
- ) => Promise<{ ok: boolean; files?: FileMap; version: number }>;
- getFilesVersion?: () => Promise;
- getFiles?: () => Promise;
- };
- // Same required-version + atomic-compare rule as /api/file: on
- // shared playgrounds an omitted version could silently overwrite a
- // teammate's save, and edge check-then-set still races.
- if (authed.access.shared && baseVersion === undefined) {
- let version = 0;
- let files: FileMap = {};
- try {
- if (typeof saver.getFilesVersion === "function") version = await saver.getFilesVersion();
- if (typeof saver.getFiles === "function") files = await saver.getFiles();
- } catch {
- /* best-effort */
- }
- return sessionJson(
- { error: "shared save needs the files version — reload and retry", files, version },
- session,
- 409,
- );
- }
- if (typeof saver.saveFilesIfVersion === "function") {
- const saved = await saver.saveFilesIfVersion({ ...incoming }, baseVersion as number | undefined);
- if (!saved.ok) {
- return sessionJson(
- { error: "teammate edited — reload: files changed since you loaded them, your copy is stale", files: saved.files, version: saved.version },
- session,
- 409,
- );
- }
- let files: FileMap = { ...incoming };
- try {
- if (typeof saver.getFiles === "function") files = await saver.getFiles();
- } catch {
- /* best-effort */
- }
- return sessionJson({ files, version: saved.version }, session);
- }
- await stub.setFiles({ ...incoming });
- let version = 0;
- try {
- if (typeof saver.getFilesVersion === "function") version = await saver.getFilesVersion();
- } catch {
- /* best-effort */
- }
- return sessionJson({ files: await stub.getFiles(), version }, session);
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- if (url.pathname === "/api/github/create" && req.method === "POST") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- // The acting member's own token (never another member's): on a shared
- // playground a member without write access gets the collaborator
- // error from assertPushAccess, not an owner-token push.
- const token = authed.access.token;
- const as = authed.access.shared && authed.access.user
- ? { token, user: authed.access.user }
- : undefined;
- const { name, private: isPrivate, description, message } = (await req.json()) as {
- name?: string;
- private?: boolean;
- description?: string;
- message?: string;
- };
- if (!name || !name.trim()) return sessionJson({ error: "repo name is required" }, session, 400);
- const created = await createRepo(token, { name, private: isPrivate, description });
- const meta = {
- owner: created.owner,
- repo: created.name,
- branch: created.defaultBranch || "main",
- remoteUrl: remoteUrl(created.owner, created.name),
- };
- try {
- const pushed = await stub.pushCurrentToRemote({
- remoteUrl: meta.remoteUrl,
- meta,
- message: message || "initial commit from app-builder",
- ...(as ? { as } : {}),
- });
- return sessionJson({ repo: created, meta, output: pushed.output, files: await stub.getFiles() }, session);
- } catch (pushErr) {
- // The repo already exists at this point: say so explicitly, and
- // remove the empty shell so a retry does not collide with it.
- let cleaned = false;
- try {
- await deleteRepo(token, created.owner, created.name);
- cleaned = true;
- } catch {
- /* report below; the repo stays for manual deletion */
- }
- return sessionJson(
- {
- error:
- `repo ${created.fullName} was created but the initial push failed: ${(pushErr as Error).message}` +
- (cleaned ? " The empty repo was deleted, so retrying is safe." : " Delete it on GitHub before retrying."),
- },
- session,
- 500,
- );
- }
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- if (url.pathname === "/api/github/promote" && req.method === "POST") {
- try {
- const {
- name,
- private: isPrivate,
- description,
- deleteSourceBranch,
- includeUncommitted,
- } = (await req.json()) as {
- name?: string;
- private?: boolean;
- description?: string;
- deleteSourceBranch?: boolean;
- includeUncommitted?: boolean;
- };
- if (!name || !name.trim()) return sessionJson({ error: "repo name is required" }, session, 400);
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const token = authed.access.token;
- // On shared playgrounds the push is attributed to the acting member
- // (their own token, never the owner's).
- const as = authed.access.shared && authed.access.user
- ? { token: authed.access.token, user: authed.access.user }
- : undefined;
- // No linked branch, no promote: fail before creating the shell
- // instead of creating it and deleting it again on the way out.
- const linked = (await stub.getGitHubStatus())?.linked ?? null;
- if (!linked) {
- return sessionJson({ error: "no linked repo: open a repo first, then promote its branch" }, session, 400);
- }
- const created = await createRepo(token, { name, private: isPrivate, description });
- const meta = {
- owner: created.owner,
- repo: created.name,
- branch: created.defaultBranch || "main",
- remoteUrl: remoteUrl(created.owner, created.name),
- };
- try {
- const promoted = await stub.promoteBranchToRepo({
- remoteUrl: meta.remoteUrl,
- meta,
- deleteSourceBranch: deleteSourceBranch === true,
- includeUncommitted: includeUncommitted === true,
- ...(as ? { as } : {}),
- });
- return sessionJson(
- {
- repo: created,
- meta,
- output: promoted.output,
- deletedSource: promoted.deletedSource,
- files: await stub.getFiles(),
- },
- session,
- );
- } catch (pushErr) {
- // Same shell-cleanup contract as Create: the repo already exists,
- // so remove the empty shell (or say how to) instead of leaving a
- // name that collides with the retry.
- let cleaned = false;
- try {
- await deleteRepo(token, created.owner, created.name);
- cleaned = true;
- } catch {
- /* report below; the repo stays for manual deletion */
- }
- return sessionJson(
- {
- error:
- `repo ${created.fullName} was created but promoting the branch failed: ${(pushErr as Error).message}` +
- (cleaned ? " The empty repo was deleted, so retrying is safe." : " Delete it on GitHub before retrying."),
- },
- session,
- 500,
- );
- }
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- if (url.pathname === "/api/github/open" && req.method === "POST") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const as = authed.access.shared && authed.access.user
- ? { token: authed.access.token, user: authed.access.user }
- : undefined;
- const { ref, branch } = (await req.json()) as { ref?: string; branch?: string };
- if (!ref || !ref.trim()) return sessionJson({ error: "owner/repo is required" }, session, 400);
- const { owner, repo } = parseRepoRef(ref);
- const cloned = await stub.cloneFromGitHub({ owner, repo, branch: branch?.trim() || undefined, ...(as ? { as } : {}) });
- const built = await stub.build();
- return sessionJson({ ...cloned, built }, session);
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- if (url.pathname === "/api/git/commit" && req.method === "POST") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const as = authed.access.shared && authed.access.user
- ? { token: authed.access.token, user: authed.access.user }
- : undefined;
- const { message } = (await req.json()) as { message?: string };
- if (!message || !message.trim()) return sessionJson({ error: "commit message is required" }, session, 400);
- const r = await stub.commitPush(message.trim(), ...(as ? [as] : []));
- return sessionJson({ ...r, status: await stub.gitStatus() }, session);
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- if (url.pathname === "/api/git/pull" && req.method === "POST") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const as = authed.access.shared && authed.access.user
- ? { token: authed.access.token, user: authed.access.user }
- : undefined;
- const r = await stub.pullFromRemote(...(as ? [as] : []));
- const built = await stub.build();
- if (r.conflict) {
- // 409, but still with the conflict-marked files: the editor must
- // receive the markers it is told to resolve.
- return sessionJson({ ...r, built }, session, 409);
- }
- return sessionJson({ ...r, built }, session);
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- if (url.pathname === "/api/git/status") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- // Behind the serialized git queue server-side: bound it so a
- // stalled push/pull/fetch fails this read visibly instead of
- // hanging the panel indefinitely.
- const status = await withToolTimeout(stub.gitStatus(), TOOL_TIMEOUT_MS, "git status");
- return sessionJson({ status }, session, 200, {
- "cache-control": "no-store",
- });
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- if (url.pathname === "/api/git/log") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const log = await withToolTimeout(stub.gitLog(), TOOL_TIMEOUT_MS, "git log");
- return sessionJson({ log }, session, 200, {
- "cache-control": "no-store",
- });
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- if (url.pathname === "/api/git/run" && req.method === "POST") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const as = authed.access.shared && authed.access.user
- ? { token: authed.access.token, user: authed.access.user }
- : undefined;
- const { command } = (await req.json()) as { command?: string };
- if (!command || !command.trim()) return sessionJson({ error: "command is required" }, session, 400);
- // Same bound as the Pi tool path and the panel reads: a stalled
- // push/pull/fetch must fail visibly, not spin ghBusy forever. The
- // server-side op may still complete; push/pull/fetch retries are safe.
- const r = await withToolTimeout(stub.gitRun(command.trim(), ...(as ? [as] : [])), TOOL_TIMEOUT_MS, "git command");
- // A non-capsule repo will not build: still return the git result and
- // let the panel show the build error alongside it.
- let built: unknown = null;
- try {
- built = await stub.build();
- } catch {
- built = { ok: false, error: { name: "Build", message: "repo does not build as a capsule" } };
- }
- return sessionJson({ ...r, built }, session);
- } catch (e) {
- // Timeout (or RPC/storage failure) with a possibly still-running
- // server op: return current server files so the panel resyncs
- // instead of later overwriting the op's working-tree changes with
- // its stale copy. Best-effort — a completion landing after this
- // read can still race; the version guard in gitRun protects the
- // op's own write, not a later bulk save.
- let files: FileMap | undefined;
- try {
- files = await stub.getFiles();
- } catch {
- /* storage failure: report the error without files */
- }
- return sessionJson({ error: (e as Error).message, ...(files ? { files } : {}) }, session, 500);
- }
- }
-
- // Branch picker data for the linked checkout: local + remote-tracking
- // branches plus the current branch, no typing required.
- if (url.pathname === "/api/git/branches") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const as = authed.access.shared && authed.access.user
- ? { token: authed.access.token, user: authed.access.user }
- : undefined;
- const r = await withToolTimeout(stub.getBranches(...(as ? [as] : [])), TOOL_TIMEOUT_MS, "git branches");
- return sessionJson(r, session, 200, { "cache-control": "no-store" });
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- // Remote branch names for an arbitrary repo (New-chat picker): the repo
- // is not cloned yet, so this reads the GitHub API, not the checkout.
- if (url.pathname === "/api/github/branches") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const ref = url.searchParams.get("ref") ?? "";
- if (!ref.trim()) return sessionJson({ error: "ref is required" }, session, 400);
- const { owner, repo } = parseRepoRef(ref);
- const token = authed.access.token;
- const r = await withToolTimeout(
- listRemoteBranches(token, owner, repo),
- TOOL_TIMEOUT_MS,
- "github branches",
- );
- return sessionJson(r, session, 200, { "cache-control": "no-store" });
- } catch (e) {
- return sessionJson({ error: (e as Error).message }, session, 500);
- }
- }
-
- // Create proto/ from HEAD and push `-u origin`. Guarded like
- // Create/Commit: the panel saves editor files first, and a mid-turn
- // request is refused instead of racing the FileMap.
- if (url.pathname === "/api/git/branch/new" && req.method === "POST") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const as = authed.access.shared && authed.access.user
- ? { token: authed.access.token, user: authed.access.user }
- : undefined;
- if (await stub.isTurnActive()) {
- return sessionJson(
- { error: "an agent turn is in progress — wait for it to finish, then retry" },
- session,
- 409,
- );
- }
- const { name } = (await req.json()) as { name?: string };
- if (!name || !name.trim()) return sessionJson({ error: "branch name is required" }, session, 400);
- // The endpoint owns the branch-per-prototype contract: only proto/*
- // branches are created here, matching the delete path.
- if (!name.trim().startsWith("proto/")) {
- return sessionJson({ error: "branch name must start with proto/ (e.g. proto/my-prototype)" }, session, 400);
- }
- const r = await withToolTimeout(stub.createBranch(name.trim(), ...(as ? [as] : [])), TOOL_TIMEOUT_MS, "git branch");
- let built: unknown = null;
- try {
- built = await stub.build();
- } catch {
- built = { ok: false, error: { name: "Build", message: "repo does not build as a capsule" } };
- }
- let linked: unknown = null;
- try {
- linked = (await stub.getGitHubStatus())?.linked ?? null;
- } catch {
- /* keep null */
- }
- return sessionJson({ ...r, built, linked }, session);
- } catch (e) {
- let files: FileMap | undefined;
- try {
- files = await stub.getFiles();
- } catch {
- /* storage failure: report the error without files */
- }
- return sessionJson({ error: (e as Error).message, ...(files ? { files } : {}) }, session, 500);
- }
- }
-
- if (url.pathname === "/api/git/branch/switch" && req.method === "POST") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const as = authed.access.shared && authed.access.user
- ? { token: authed.access.token, user: authed.access.user }
- : undefined;
- if (await stub.isTurnActive()) {
- return sessionJson(
- { error: "an agent turn is in progress — wait for it to finish, then retry" },
- session,
- 409,
- );
- }
- const { branch } = (await req.json()) as { branch?: string };
- if (!branch || !branch.trim()) return sessionJson({ error: "branch is required" }, session, 400);
- const r = await withToolTimeout(stub.switchBranch(branch.trim(), ...(as ? [as] : [])), TOOL_TIMEOUT_MS, "git checkout");
- let built: unknown = null;
- try {
- built = await stub.build();
- } catch {
- built = { ok: false, error: { name: "Build", message: "repo does not build as a capsule" } };
- }
- let linked: unknown = null;
- try {
- linked = (await stub.getGitHubStatus())?.linked ?? null;
- } catch {
- /* keep null */
- }
- return sessionJson({ ...r, built, linked }, session);
- } catch (e) {
- let files: FileMap | undefined;
- try {
- files = await stub.getFiles();
- } catch {
- /* storage failure: report the error without files */
- }
- return sessionJson({ error: (e as Error).message, ...(files ? { files } : {}) }, session, 500);
- }
- }
-
- if (url.pathname === "/api/git/branch/delete" && req.method === "POST") {
- try {
- const authed = await requireAccess(req, stub, session);
- if ("gated" in authed) return authed.gated;
- const as = authed.access.shared && authed.access.user
- ? { token: authed.access.token, user: authed.access.user }
- : undefined;
- if (await stub.isTurnActive()) {
- return sessionJson(
- { error: "an agent turn is in progress — wait for it to finish, then retry" },
- session,
- 409,
- );
- }
- const { branch } = (await req.json()) as { branch?: string };
- if (!branch || !branch.trim()) return sessionJson({ error: "branch is required" }, session, 400);
- const r = await withToolTimeout(stub.deleteBranch(branch.trim(), ...(as ? [as] : [])), TOOL_TIMEOUT_MS, "git branch delete");
- let linked: unknown = null;
- try {
- linked = (await stub.getGitHubStatus())?.linked ?? null;
- } catch {
- /* keep null */
- }
- return sessionJson({ ...r, linked }, session);
- } catch (e) {
- let files: FileMap | undefined;
- try {
- files = await stub.getFiles();
- } catch {
- /* storage failure: report the error without files */
- }
- return sessionJson({ error: (e as Error).message, ...(files ? { files } : {}) }, session, 500);
- }
- }
-
- return new Response("not found", { status: 404 });
+ const ctx = { url, env, session, stub };
+ return (
+ (await handleFiles(req, ctx)) ??
+ (await handleChats(req, ctx)) ??
+ (await handleApps(req, ctx)) ??
+ (await handleChat(req, ctx)) ??
+ (await handleGithubAuth(req, ctx)) ??
+ (await handleShare(req, ctx)) ??
+ (await handleGithubRepos(req, ctx)) ??
+ (await handleGit(req, ctx)) ??
+ new Response("not found", { status: 404 })
+ );
},
} satisfies ExportedHandler;
-
-
-function sessionJson(
- body: unknown,
- session: { id: string; isNew: boolean },
- status = 200,
- extra: Record = {},
-) {
- return new Response(JSON.stringify(body), {
- status,
- headers: withSession(
- new Headers({ "content-type": "application/json; charset=utf-8", ...extra }),
- session,
- ),
- });
-}
-
-const page = () => `
-
-
-
-
-app-builder
-
-
-
-
- Chat
- History
- Preview
- Code
-
- Logs
- Settings
- +
-
- process ram —
- process cpu —
- typecheck —
- bundle —
- idle
-
-
-
-
- Chat
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- app-builder
- Log in with GitHub to use Chat, Code, Preview, Database and Logs.
-
- OAuth; token stays server-side.
-
-
-
-
-`;
\ No newline at end of file
diff --git a/src/routes/apps.ts b/src/routes/apps.ts
new file mode 100644
index 0000000..fbfc284
--- /dev/null
+++ b/src/routes/apps.ts
@@ -0,0 +1,163 @@
+import { APP_SHIM_JS } from "../app-shim";
+import { buildAppBundle } from "../lakebed";
+import type { AppKind, AppState } from "../app-runtime";
+import { APP_CORS, corsJson, requireAccess, requireLogin, resolveSidForRequest, sessionJson, withSession } from "../server/auth";
+import { SID_RE } from "../server/auth";
+import { appDoc } from "../server/page";
+import type { RouteCtx } from "../server/auth";
+
+/** Sandboxed app runtime endpoints and harness status. */
+export async function handleApps(req: Request, ctx: RouteCtx): Promise {
+ const { url, env, session, stub } = ctx;
+ if (url.pathname === "/api/app-shim.js") {
+ return new Response(APP_SHIM_JS, {
+ headers: {
+ "content-type": "text/javascript; charset=utf-8",
+ "cache-control": "public, max-age=3600",
+ ...APP_CORS,
+ },
+ });
+ }
+
+ // Live app view endpoints. The iframe runs sandboxed to an opaque origin
+ // with no cookies, so it cannot present `ab_member`. It presents a
+ // short-lived preview capability minted by the cookie-authenticated
+ // parent instead (POST /api/share/preview): a bare `?sid=` — which the
+ // invite URL discloses — never suffices on its own.
+ if (url.pathname === "/api/app-state" || url.pathname === "/api/app-mutate") {
+ if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: APP_CORS });
+ }
+
+ /**
+ * Preview capability check for the sandboxed iframe. Stubs that predate
+ * preview tokens (tests, older shapes) fall back to the object-token
+ * gate; real objects always enforce the capability.
+ */
+ const checkPreview = async (
+ sidStub: {
+ getGitHubToken?: () => Promise;
+ checkPreviewToken?: (preview: unknown) => Promise<{ memberId: string | null } | null>;
+ },
+ preview: unknown,
+ ): Promise => {
+ if (typeof sidStub.checkPreviewToken === "function") {
+ const grant = await sidStub.checkPreviewToken(preview).catch(() => null);
+ if (!grant) {
+ return corsJson(
+ { ok: false, error: { name: "Auth", message: "preview expired or invalid — reload the preview" } },
+ 401,
+ );
+ }
+ return null;
+ }
+ return null;
+ };
+
+ if (url.pathname === "/api/app-state") {
+ const sid = url.searchParams.get("sid") ?? "";
+ const sidStub = await resolveSidForRequest(env, req, sid, stub);
+ if (!sid) return corsJson({ ok: false, error: { name: "Session", message: "bad sid" } }, 400);
+ if (!sidStub) {
+ // Unknown shape or revoked bearer: indistinguishable from bad sid.
+ return SID_RE.test(sid)
+ ? corsJson({ ok: false, error: { name: "Auth", message: "github login required" } }, 401)
+ : corsJson({ ok: false, error: { name: "Session", message: "bad sid" } }, 400);
+ }
+ if (!(await sidStub.getGitHubToken())) {
+ return corsJson({ ok: false, error: { name: "Auth", message: "github login required" } }, 401);
+ }
+ const previewGate = await checkPreview(sidStub, url.searchParams.get("preview"));
+ if (previewGate) return previewGate;
+ return corsJson(await sidStub.appState());
+ }
+
+ if (url.pathname === "/api/app-mutate" && req.method === "POST") {
+ let body: { sid?: unknown; preview?: unknown; kind?: unknown; name?: unknown; args?: unknown };
+ try {
+ body = (await req.json()) as typeof body;
+ } catch {
+ return corsJson({ ok: false, error: { name: "Request", message: "expected a JSON body" } }, 400);
+ }
+ if (typeof body.sid !== "string" || !SID_RE.test(body.sid)) {
+ return corsJson({ ok: false, error: { name: "Session", message: "bad sid" } }, 400);
+ }
+ const mutateStub = await resolveSidForRequest(env, req, body.sid, stub);
+ if (!mutateStub) {
+ return corsJson({ ok: false, error: { name: "Auth", message: "github login required" } }, 401);
+ }
+ if (!(await mutateStub.getGitHubToken())) {
+ return corsJson({ ok: false, error: { name: "Auth", message: "github login required" } }, 401);
+ }
+ const mutateGate = await checkPreview(mutateStub, body.preview);
+ if (mutateGate) return mutateGate;
+ if (body.kind !== "mutation" && body.kind !== "action") {
+ return corsJson({ ok: false, error: { name: "Request", message: "kind must be mutation or action" } }, 400);
+ }
+ if (typeof body.name !== "string" || !body.name) {
+ return corsJson({ ok: false, error: { name: "Request", message: "name must be a non-empty string" } }, 400);
+ }
+ if (body.args !== undefined && !Array.isArray(body.args)) {
+ return corsJson({ ok: false, error: { name: "Request", message: "args must be an array" } }, 400);
+ }
+ const kind: AppKind = body.kind;
+ return corsJson(
+ await mutateStub.appMutate(kind, body.name, body.args ?? []),
+ );
+ }
+
+ if (url.pathname === "/api/app") {
+ const sid = url.searchParams.get("sid") ?? "";
+ if (!SID_RE.test(sid)) return new Response("bad sid", { status: 400 });
+ const appStub = await resolveSidForRequest(env, req, sid, stub);
+ if (!appStub) {
+ return new Response("github login required", { status: 401 });
+ }
+ if (!(await appStub.getGitHubToken())) {
+ return new Response("github login required", { status: 401 });
+ }
+ // The served document embeds live state: gate the capability like the
+ // JSON endpoints, or the sid alone (disclosed by invite URLs) loads it.
+ if (typeof appStub.checkPreviewToken === "function") {
+ const grant = await appStub.checkPreviewToken(url.searchParams.get("preview")).catch(() => null);
+ if (!grant) return new Response("preview expired or invalid — reload the preview", { status: 401 });
+ }
+ const previewParam = url.searchParams.get("preview") ?? "";
+ const appFiles = await appStub.getFiles();
+ // The RPC stub type does not preserve the discriminated union, so
+ // restore it: at runtime this is plain data in exactly this shape.
+ const state = (await appStub.appState()) as AppState;
+ let bundle: string | null;
+ let bundleError: string | null = null;
+ try {
+ bundle = buildAppBundle(appFiles, `${url.origin}/api/app-shim.js`);
+ } catch (e: any) {
+ bundle = null;
+ bundleError = e?.message ?? String(e);
+ }
+ return new Response(
+ appDoc(bundle, {
+ sid,
+ preview: previewParam,
+ api: { state: `${url.origin}/api/app-state`, mutate: `${url.origin}/api/app-mutate` },
+ state: state.ok ? { tables: state.tables, queries: state.queries } : null,
+ error: state.ok ? (bundleError ? { message: bundleError } : null) : state.error,
+ }),
+ {
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
+ },
+ );
+ }
+
+ // Pi boot probe (no key needed): forwards to the object's /pi-status.
+ // Gated like every other session API: it answers for the caller's object.
+ if (url.pathname === "/api/pi-harness-status" && req.method === "GET") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const res = await stub.fetch(new Request(new URL("/pi-status", url.origin), { method: "GET" }));
+ const headers = withSession(new Headers(res.headers), session);
+ // Probes must never cache: busyflips live while runs come and go.
+ headers.set("cache-control", "no-store");
+ return new Response(res.body, { status: res.status, headers });
+ }
+ return null;
+}
diff --git a/src/routes/chat.ts b/src/routes/chat.ts
new file mode 100644
index 0000000..b1fcd14
--- /dev/null
+++ b/src/routes/chat.ts
@@ -0,0 +1,79 @@
+import { validateImageRefs } from "../images";
+import { requireAccess, sessionJson, withSession } from "../server/auth";
+import type { RouteCtx } from "../server/auth";
+
+/** Agent chat turn endpoint. */
+export async function handleChat(req: Request, ctx: RouteCtx): Promise {
+ const { url, env, session, stub } = ctx;
+ // The agent loop runs in Pi (Durable Object PiHarness): validate and
+ // resolve BYOK at the edge, forward to the object, stream its SSE back
+ // with the session cookie.
+ if (url.pathname === "/api/chat" && req.method === "POST") {
+ // GitHub login comes before the API-key check: anonymous visitors get
+ // the login gate, not the key prompt.
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const actor = authed.access.actor;
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return sessionJson({ error: "expected a JSON body" }, session, 400);
+ }
+ if (!body || typeof body !== "object") {
+ return sessionJson({ error: "expected a JSON body" }, session, 400);
+ }
+ const { message, key, gateway, model, images } = body as {
+ message?: unknown;
+ key?: string;
+ gateway?: string;
+ model?: string;
+ images?: unknown;
+ };
+ if (typeof message !== "string" || !message.trim()) {
+ return sessionJson({ error: "empty message" }, session, 400);
+ }
+ let imageRefs: { id: string }[];
+ try {
+ imageRefs = validateImageRefs(images);
+ } catch (e) {
+ return sessionJson({ error: e instanceof Error ? e.message : String(e) }, session, 400);
+ }
+ const bodyKey = typeof key === "string" ? key.trim() : "";
+ const apiKey =
+ req.headers.get("x-provider-key")?.trim() || bodyKey || env.OPENCODE_ZEN_KEY?.trim();
+ if (!apiKey) {
+ return sessionJson(
+ { error: "no API key — enter one below (stored only in this browser)" },
+ session,
+ 401,
+ );
+ }
+ // withSession: the forwarded DO response carries no cookies, so a
+ // first request must still set ab_session or the next request lands
+ // on another object. A rejected forward (dead object, RPC failure)
+ // still answers as an SSE error frame with the cookie, never a bare
+ // 500 that also drops the session.
+ let res: Response;
+ try {
+ res = await stub.fetch(
+ new Request(new URL("/pi-harness", url.origin), {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ message, apiKey, gateway, model, images: imageRefs, author: actor, memberId: authed.access.memberId }),
+ }),
+ );
+ } catch (e) {
+ const frame = `data: ${JSON.stringify({ type: "error", message: e instanceof Error ? e.message : String(e) })}\n\n`;
+ return new Response(frame, {
+ headers: withSession(
+ new Headers({ "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-store" }),
+ session,
+ ),
+ });
+ }
+ const headers = withSession(new Headers(res.headers), session);
+ return new Response(res.body, { status: res.status, headers });
+ }
+ return null;
+}
diff --git a/src/routes/chats.ts b/src/routes/chats.ts
new file mode 100644
index 0000000..c48b0e6
--- /dev/null
+++ b/src/routes/chats.ts
@@ -0,0 +1,167 @@
+import type { FileMap } from "../session";
+import { isWorktreeKey } from "../gitops";
+import { parseRepoRef } from "../github";
+import { requireAccess, sessionJson, turnConflict } from "../server/auth";
+import type { RouteCtx } from "../server/auth";
+
+/** Chat history snapshots, resume, and admin. */
+export async function handleChats(req: Request, ctx: RouteCtx): Promise {
+ const { url, env, session, stub } = ctx;
+ // Named chat checkpoints: snapshot the current messages + files, list
+ // them newest-first, or restore one into the live session.
+ if (url.pathname === "/api/chats" && req.method === "GET") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ // Current link rides along so the picker can show it without a second
+ // round-trip; best-effort for stub shapes that lack getGitHubStatus.
+ let linked: unknown = null;
+ try {
+ linked = (await stub.getGitHubStatus())?.linked ?? null;
+ } catch {
+ linked = null;
+ }
+ return sessionJson({ chats: await stub.listChats(), linked }, session, 200, { "cache-control": "no-store" });
+ }
+
+ if (url.pathname === "/api/chats/snapshot" && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ let title = "";
+ let files: FileMap | undefined;
+ try {
+ const body = (await req.json()) as { title?: unknown; files?: unknown };
+ title = body.title as string ?? "";
+ // The Code tab keeps unsaved edits in browser memory: the snapshot
+ // button sends its file map so the checkpoint captures them. Validate
+ // like the bulk-save endpoint; omitted means "persisted state" (Pi
+ // turns, auto-checkpoints, older clients).
+ if (body.files !== undefined) {
+ if (!body.files || typeof body.files !== "object" || Array.isArray(body.files)) {
+ return sessionJson({ error: "expected { files }" }, session, 400);
+ }
+ for (const key of Object.keys(body.files as FileMap)) {
+ if (!isWorktreeKey(key) || typeof (body.files as FileMap)[key] !== "string") {
+ return sessionJson({ error: `refused path: ${key}` }, session, 400);
+ }
+ }
+ files = { ...(body.files as FileMap) };
+ }
+ } catch {
+ /* empty body: default title */
+ }
+ try {
+ return sessionJson({ chat: await stub.snapshotChat(typeof title === "string" ? title : "", files, authed.access.actor ?? undefined) }, session);
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ const status = message.startsWith("chat snapshot too large") ? 413
+ : message.startsWith("an agent turn is in progress") ? 409
+ : 500;
+ return sessionJson({ error: message }, session, status);
+ }
+ }
+
+ const renameMatch = url.pathname.match(/^\/api\/chats\/([A-Za-z0-9_-]{8,64})\/?rename$/);
+ if (renameMatch && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ let title = "";
+ try {
+ title = ((await req.json()) as { title?: unknown }).title as string ?? "";
+ } catch {
+ /* empty body: title required below */
+ }
+ if (typeof title !== "string" || !title.trim()) {
+ return sessionJson({ error: "snapshot title is required" }, session, 400);
+ }
+ try {
+ return sessionJson({ chat: await stub.renameChat(renameMatch[1] as string, title) }, session);
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ const status =
+ message === "chat snapshot not found" ? 404
+ : message.startsWith("chat snapshot too large") ? 413
+ : 500;
+ return sessionJson({ error: message }, session, status);
+ }
+ }
+
+ const resumeMatch = url.pathname.match(/^\/api\/chats\/([A-Za-z0-9_-]{8,64})\/?resume$/);
+ if (resumeMatch && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ try {
+ // resumeChat builds under its own lock, so the response always
+ // describes the session it restored.
+ return sessionJson(await stub.resumeChat(resumeMatch[1] as string, authed.access.actor ?? undefined), session);
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ const status = message === "chat snapshot not found" ? 404 : message.startsWith("chat snapshot too large") ? 413 : 500;
+ return sessionJson({ error: message }, session, status);
+ }
+ }
+
+ const deleteMatch = url.pathname.match(/^\/api\/chats\/([A-Za-z0-9_-]{8,64})\/delete\/?$/);
+ if (deleteMatch && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ try {
+ return sessionJson(await stub.deleteChat(deleteMatch[1] as string), session);
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ const status = message === "chat snapshot not found" ? 404 : 500;
+ return sessionJson({ error: message }, session, status);
+ }
+ }
+
+ // Start a fresh chat session, optionally bound to a repo: an optional
+ // title snapshots the current chat first, an optional repoRef clones the
+ // repo, then the agent context clears so the new session starts clean.
+ if (url.pathname === "/api/chats/start" && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ // Same clobber hazard as the bulk file save: cloning replaces the whole
+ // FileMap, so refuse while an agent turn is writing instead of racing
+ // it and losing either side's files.
+ const conflict = await turnConflict(stub, session);
+ if (conflict) return conflict;
+ const as = authed.access.shared && authed.access.user
+ ? { token: authed.access.token, user: authed.access.user }
+ : undefined;
+ let body: { repoRef?: unknown; branch?: unknown; title?: unknown } = {};
+ try {
+ const parsed = (await req.json()) as typeof body;
+ if (parsed && typeof parsed === "object") body = parsed;
+ } catch {
+ /* empty body: fresh chat with no repo */
+ }
+ const repoRef = typeof body.repoRef === "string" ? body.repoRef.trim() : "";
+ const branch = typeof body.branch === "string" ? body.branch.trim() : "";
+ const title = typeof body.title === "string" ? body.title : "";
+ try {
+ if (title.trim()) await stub.snapshotChat(title, undefined, authed.access.actor ?? undefined);
+ let cloned: { files?: unknown; meta?: unknown } | null = null;
+ if (repoRef) {
+ const { owner, repo } = parseRepoRef(repoRef);
+ cloned = await stub.cloneFromGitHub({ owner, repo, branch: branch || undefined, ...(as ? { as } : {}) });
+ } else {
+ // No repo picked: drop a stale link so the fresh chat, its header
+ // badge, and its snapshots do not inherit the previous repo.
+ await stub.unlinkRepo();
+ }
+ await stub.clearChat();
+ const built = await stub.build();
+ let linked: unknown = cloned?.meta ?? null;
+ try {
+ linked = (await stub.getGitHubStatus())?.linked ?? linked;
+ } catch {
+ /* stub without status: keep the clone meta */
+ }
+ return sessionJson({ files: await stub.getFiles(), built, linked }, session);
+ } catch (e) {
+ const message = (e as Error).message;
+ const status = message.startsWith("chat snapshot too large") ? 413 : 500;
+ return sessionJson({ error: message }, session, status);
+ }
+ }
+ return null;
+}
diff --git a/src/routes/files.ts b/src/routes/files.ts
new file mode 100644
index 0000000..7626fd2
--- /dev/null
+++ b/src/routes/files.ts
@@ -0,0 +1,184 @@
+import { isSafePath } from "../paths";
+import { isWorktreeKey } from "../gitops";
+import { isImageId, validateImageInput } from "../images";
+import type { FileMap } from "../session";
+import { requireAccess, sessionJson, turnConflict, withSession } from "../server/auth";
+import type { RouteCtx } from "../server/auth";
+
+/** State, files, images, and chat-admin endpoints. */
+export async function handleFiles(req: Request, ctx: RouteCtx): Promise {
+ const { url, env, session, stub } = ctx;
+ if (url.pathname === "/api/state") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const files = await stub.getFiles();
+ const messages = await stub.getMessages();
+ const built = await stub.build();
+ // version feeds the teammate-edit reload guard; turn/share feed the
+ // Sharing panel so the other member sees live state without polling.
+ let version = 0;
+ let turn = null;
+ let share = null;
+ try {
+ if (typeof stub.getFilesVersion === "function") version = await stub.getFilesVersion();
+ if (typeof stub.getTurnStatus === "function") turn = await stub.getTurnStatus();
+ const s = stub as { getShareStatus?: (viewerMemberId?: string | null) => Promise };
+ if (typeof s.getShareStatus === "function") share = await s.getShareStatus(authed.access.memberId);
+ } catch {
+ /* best-effort extras: files/messages/built above are the state */
+ }
+ return sessionJson({ files, messages, built, version, turn, share }, session, 200, { "cache-control": "no-store" });
+ }
+
+ if (url.pathname === "/api/build" && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ return sessionJson({ built: await stub.build() }, session);
+ }
+
+ if (url.pathname === "/api/file" && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ // Same clobber hazard as the bulk save: read-mutate-write of the whole
+ // map races an agent turn's write_file persists.
+ const conflict = await turnConflict(stub, session);
+ if (conflict) return conflict;
+ const { path, content, baseVersion } = (await req.json()) as { path?: string; content?: string; baseVersion?: unknown };
+ const key = (path ?? "").trim() || "index.ts";
+ if (!isSafePath(key)) {
+ return sessionJson({ error: "unsafe path" }, session, 400);
+ }
+ if (!isWorktreeKey(key)) {
+ return sessionJson({ error: "refused path inside .git" }, session, 400);
+ }
+ // Teammate-edit guard: on shared playgrounds the version is required
+ // (an omitted version could silently overwrite a teammate's save), and
+ // the compare-and-write runs atomically in the DO — edge
+ // check-then-set would still race concurrent saves.
+ const saver = stub as {
+ saveFileIfVersion?: (
+ path: string,
+ content: string,
+ base: number | undefined,
+ ) => Promise<{ ok: boolean; files: FileMap; version: number }>;
+ getFilesVersion?: () => Promise;
+ };
+ if (authed.access.shared && baseVersion === undefined) {
+ let version = 0;
+ try {
+ if (typeof saver.getFilesVersion === "function") version = await saver.getFilesVersion();
+ } catch {
+ /* best-effort */
+ }
+ return sessionJson(
+ { error: "shared save needs the files version — reload and retry", files: await stub.getFiles(), version },
+ session,
+ 409,
+ );
+ }
+ if (typeof saver.saveFileIfVersion === "function") {
+ const saved = await saver.saveFileIfVersion(key, content ?? "", baseVersion as number | undefined);
+ if (!saved.ok) {
+ return sessionJson(
+ { error: "teammate edited — reload: files changed since you loaded them, your copy is stale", files: saved.files, version: saved.version },
+ session,
+ 409,
+ );
+ }
+ return sessionJson({ files: saved.files, built: await stub.build(), version: saved.version }, session);
+ }
+ const files = { ...(await stub.getFiles()) };
+ files[key] = content ?? "";
+ await stub.setFiles(files);
+ let version = 0;
+ try {
+ if (typeof saver.getFilesVersion === "function") version = await saver.getFilesVersion();
+ } catch {
+ /* best-effort */
+ }
+ return sessionJson({ files, built: await stub.build(), version }, session);
+ }
+
+ // Chat image attachments: bytes live in R2 (via the session object), the
+ // transcript keeps only `{ id, mimeType }` refs, and the model receives
+ // inline base64 through Pi's UserInput on the next turn.
+ if (url.pathname === "/api/images" && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return sessionJson({ error: "expected a JSON body" }, session, 400);
+ }
+ const { data, mimeType } = (body ?? {}) as { data?: unknown; mimeType?: unknown };
+ try {
+ if (typeof data !== "string") throw new Error("image data is required");
+ if (typeof mimeType !== "string") throw new Error("image mimeType is required");
+ const saved = await stub.saveImage(data, mimeType);
+ return sessionJson(
+ { id: saved.id, url: `/api/images/${saved.id}`, mimeType: saved.mimeType, size: saved.size },
+ session,
+ );
+ } catch (e) {
+ return sessionJson({ error: e instanceof Error ? e.message : String(e) }, session, 400);
+ }
+ }
+
+ const imageMatch = url.pathname.match(/^\/api\/images\/([A-Za-z0-9-]+)$/);
+ if (imageMatch && req.method === "DELETE") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const id = imageMatch[1] as string;
+ try {
+ await stub.deleteImage(id);
+ return sessionJson({ ok: true }, session);
+ } catch (e) {
+ return sessionJson({ error: e instanceof Error ? e.message : String(e) }, session, 400);
+ }
+ }
+ if (imageMatch && req.method === "GET") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const id = imageMatch[1] as string;
+ if (!isImageId(id)) return sessionJson({ error: "bad image id" }, session, 400);
+ const stored = await stub.getImage(id);
+ if (!stored) return sessionJson({ error: "image not found" }, session, 404);
+ let bytes: Uint8Array;
+ try {
+ const checked = validateImageInput(stored.data, stored.mimeType);
+ bytes = Uint8Array.from(atob(checked.data), (c) => c.charCodeAt(0));
+ } catch {
+ return sessionJson({ error: "image unreadable" }, session, 500);
+ }
+ // `bytes.buffer` may be a SharedArrayBuffer over a pool: slice to the
+ // exact view before responding.
+ const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
+ return new Response(body, {
+ headers: withSession(
+ new Headers({
+ "content-type": stored.mimeType,
+ "cache-control": "private, max-age=3153600",
+ }),
+ session,
+ ),
+ });
+ }
+
+ if (url.pathname === "/api/reset" && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ return sessionJson(await stub.reset(), session);
+ }
+
+ // Clear agent context only; capsule files are left untouched. The chat
+ // transcript persists per session in the Durable Object, so without this
+ // a refresh restores an invisible history that still consumes context.
+ if (url.pathname === "/api/clear-chat" && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ await stub.clearChat();
+ return sessionJson({ ok: true }, session);
+ }
+ return null;
+}
diff --git a/src/routes/git.ts b/src/routes/git.ts
new file mode 100644
index 0000000..9926a4e
--- /dev/null
+++ b/src/routes/git.ts
@@ -0,0 +1,255 @@
+import type { FileMap } from "../session";
+import { TOOL_TIMEOUT_MS, withToolTimeout } from "../agent";
+import { listRemoteBranches, parseRepoRef } from "../github";
+import { requireAccess, sessionJson } from "../server/auth";
+import type { RouteCtx } from "../server/auth";
+
+/** Just-git commit, pull, inspect, run, and branch endpoints. */
+export async function handleGit(req: Request, ctx: RouteCtx): Promise {
+ const { url, env, session, stub } = ctx;
+ if (url.pathname === "/api/git/commit" && req.method === "POST") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const as = authed.access.shared && authed.access.user
+ ? { token: authed.access.token, user: authed.access.user }
+ : undefined;
+ const { message } = (await req.json()) as { message?: string };
+ if (!message || !message.trim()) return sessionJson({ error: "commit message is required" }, session, 400);
+ const r = await stub.commitPush(message.trim(), ...(as ? [as] : []));
+ return sessionJson({ ...r, status: await stub.gitStatus() }, session);
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ if (url.pathname === "/api/git/pull" && req.method === "POST") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const as = authed.access.shared && authed.access.user
+ ? { token: authed.access.token, user: authed.access.user }
+ : undefined;
+ const r = await stub.pullFromRemote(...(as ? [as] : []));
+ const built = await stub.build();
+ if (r.conflict) {
+ // 409, but still with the conflict-marked files: the editor must
+ // receive the markers it is told to resolve.
+ return sessionJson({ ...r, built }, session, 409);
+ }
+ return sessionJson({ ...r, built }, session);
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ if (url.pathname === "/api/git/status") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ // Behind the serialized git queue server-side: bound it so a
+ // stalled push/pull/fetch fails this read visibly instead of
+ // hanging the panel indefinitely.
+ const status = await withToolTimeout(stub.gitStatus(), TOOL_TIMEOUT_MS, "git status");
+ return sessionJson({ status }, session, 200, {
+ "cache-control": "no-store",
+ });
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ if (url.pathname === "/api/git/log") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const log = await withToolTimeout(stub.gitLog(), TOOL_TIMEOUT_MS, "git log");
+ return sessionJson({ log }, session, 200, {
+ "cache-control": "no-store",
+ });
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ if (url.pathname === "/api/git/run" && req.method === "POST") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const as = authed.access.shared && authed.access.user
+ ? { token: authed.access.token, user: authed.access.user }
+ : undefined;
+ const { command } = (await req.json()) as { command?: string };
+ if (!command || !command.trim()) return sessionJson({ error: "command is required" }, session, 400);
+ // Same bound as the Pi tool path and the panel reads: a stalled
+ // push/pull/fetch must fail visibly, not spin ghBusy forever. The
+ // server-side op may still complete; push/pull/fetch retries are safe.
+ const r = await withToolTimeout(stub.gitRun(command.trim(), ...(as ? [as] : [])), TOOL_TIMEOUT_MS, "git command");
+ // A non-capsule repo will not build: still return the git result and
+ // let the panel show the build error alongside it.
+ let built: unknown = null;
+ try {
+ built = await stub.build();
+ } catch {
+ built = { ok: false, error: { name: "Build", message: "repo does not build as a capsule" } };
+ }
+ return sessionJson({ ...r, built }, session);
+ } catch (e) {
+ // Timeout (or RPC/storage failure) with a possibly still-running
+ // server op: return current server files so the panel resyncs
+ // instead of later overwriting the op's working-tree changes with
+ // its stale copy. Best-effort — a completion landing after this
+ // read can still race; the version guard in gitRun protects the
+ // op's own write, not a later bulk save.
+ let files: FileMap | undefined;
+ try {
+ files = await stub.getFiles();
+ } catch {
+ /* storage failure: report the error without files */
+ }
+ return sessionJson({ error: (e as Error).message, ...(files ? { files } : {}) }, session, 500);
+ }
+ }
+
+ // Branch picker data for the linked checkout: local + remote-tracking
+ // branches plus the current branch, no typing required.
+ if (url.pathname === "/api/git/branches") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const as = authed.access.shared && authed.access.user
+ ? { token: authed.access.token, user: authed.access.user }
+ : undefined;
+ const r = await withToolTimeout(stub.getBranches(...(as ? [as] : [])), TOOL_TIMEOUT_MS, "git branches");
+ return sessionJson(r, session, 200, { "cache-control": "no-store" });
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ // Create proto/ from HEAD and push `-u origin`. Guarded like
+ // Create/Commit: the panel saves editor files first, and a mid-turn
+ // request is refused instead of racing the FileMap.
+ if (url.pathname === "/api/git/branch/new" && req.method === "POST") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const as = authed.access.shared && authed.access.user
+ ? { token: authed.access.token, user: authed.access.user }
+ : undefined;
+ if (await stub.isTurnActive()) {
+ return sessionJson(
+ { error: "an agent turn is in progress — wait for it to finish, then retry" },
+ session,
+ 409,
+ );
+ }
+ const { name } = (await req.json()) as { name?: string };
+ if (!name || !name.trim()) return sessionJson({ error: "branch name is required" }, session, 400);
+ // The endpoint owns the branch-per-prototype contract: only proto/*
+ // branches are created here, matching the delete path.
+ if (!name.trim().startsWith("proto/")) {
+ return sessionJson({ error: "branch name must start with proto/ (e.g. proto/my-prototype)" }, session, 400);
+ }
+ const r = await withToolTimeout(stub.createBranch(name.trim(), ...(as ? [as] : [])), TOOL_TIMEOUT_MS, "git branch");
+ let built: unknown = null;
+ try {
+ built = await stub.build();
+ } catch {
+ built = { ok: false, error: { name: "Build", message: "repo does not build as a capsule" } };
+ }
+ let linked: unknown = null;
+ try {
+ linked = (await stub.getGitHubStatus())?.linked ?? null;
+ } catch {
+ /* keep null */
+ }
+ return sessionJson({ ...r, built, linked }, session);
+ } catch (e) {
+ let files: FileMap | undefined;
+ try {
+ files = await stub.getFiles();
+ } catch {
+ /* storage failure: report the error without files */
+ }
+ return sessionJson({ error: (e as Error).message, ...(files ? { files } : {}) }, session, 500);
+ }
+ }
+
+ if (url.pathname === "/api/git/branch/switch" && req.method === "POST") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const as = authed.access.shared && authed.access.user
+ ? { token: authed.access.token, user: authed.access.user }
+ : undefined;
+ if (await stub.isTurnActive()) {
+ return sessionJson(
+ { error: "an agent turn is in progress — wait for it to finish, then retry" },
+ session,
+ 409,
+ );
+ }
+ const { branch } = (await req.json()) as { branch?: string };
+ if (!branch || !branch.trim()) return sessionJson({ error: "branch is required" }, session, 400);
+ const r = await withToolTimeout(stub.switchBranch(branch.trim(), ...(as ? [as] : [])), TOOL_TIMEOUT_MS, "git checkout");
+ let built: unknown = null;
+ try {
+ built = await stub.build();
+ } catch {
+ built = { ok: false, error: { name: "Build", message: "repo does not build as a capsule" } };
+ }
+ let linked: unknown = null;
+ try {
+ linked = (await stub.getGitHubStatus())?.linked ?? null;
+ } catch {
+ /* keep null */
+ }
+ return sessionJson({ ...r, built, linked }, session);
+ } catch (e) {
+ let files: FileMap | undefined;
+ try {
+ files = await stub.getFiles();
+ } catch {
+ /* storage failure: report the error without files */
+ }
+ return sessionJson({ error: (e as Error).message, ...(files ? { files } : {}) }, session, 500);
+ }
+ }
+
+ if (url.pathname === "/api/git/branch/delete" && req.method === "POST") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const as = authed.access.shared && authed.access.user
+ ? { token: authed.access.token, user: authed.access.user }
+ : undefined;
+ if (await stub.isTurnActive()) {
+ return sessionJson(
+ { error: "an agent turn is in progress — wait for it to finish, then retry" },
+ session,
+ 409,
+ );
+ }
+ const { branch } = (await req.json()) as { branch?: string };
+ if (!branch || !branch.trim()) return sessionJson({ error: "branch is required" }, session, 400);
+ const r = await withToolTimeout(stub.deleteBranch(branch.trim(), ...(as ? [as] : [])), TOOL_TIMEOUT_MS, "git branch delete");
+ let linked: unknown = null;
+ try {
+ linked = (await stub.getGitHubStatus())?.linked ?? null;
+ } catch {
+ /* keep null */
+ }
+ return sessionJson({ ...r, linked }, session);
+ } catch (e) {
+ let files: FileMap | undefined;
+ try {
+ files = await stub.getFiles();
+ } catch {
+ /* storage failure: report the error without files */
+ }
+ return sessionJson({ error: (e as Error).message, ...(files ? { files } : {}) }, session, 500);
+ }
+ }
+ return null;
+}
diff --git a/src/routes/github-auth.ts b/src/routes/github-auth.ts
new file mode 100644
index 0000000..2004582
--- /dev/null
+++ b/src/routes/github-auth.ts
@@ -0,0 +1,325 @@
+import { exchangeCode, getTokenScopes, getUser, loginUrl, requireEnv, tokenKind } from "../github";
+import type { GitHubUser } from "../github";
+import { canonicalIdForGithubUser, isCanonicalId, parseSessionValue } from "../session-key";
+import { MEMBER_COOKIE } from "../share";
+import type { ShareState } from "../share";
+import {
+ LOGIN_REQUIRED,
+ LOGIN_STATE_COOKIE,
+ SESSION_COOKIE,
+ corsJson,
+ readCookie,
+ requireAccess,
+ resolveSession,
+ sessionJson,
+ stubShare,
+ withMember,
+ withSession,
+} from "../server/auth";
+import type { RouteCtx } from "../server/auth";
+
+/** GitHub OAuth login, status, and logout. */
+export async function handleGithubAuth(req: Request, ctx: RouteCtx): Promise {
+ const { url, env, session, stub } = ctx;
+ // ---------- GitHub (OAuth + just-git, server-side) ----------
+
+ if (url.pathname === "/api/github/status") {
+ // Shared playgrounds scope status to the calling member (their own
+ // login/token), so one member never sees another's token state as
+ // their own. No member credential: legacy solo status.
+ try {
+ const lookedUp = await stubShare(stub);
+ const share = lookedUp.ok ? lookedUp.share : null;
+ if (share?.enabled) {
+ const secret = readCookie(req, MEMBER_COOKIE);
+ const member = secret ? (share.members.find((m) => m.id === secret) ?? null) : null;
+ if (member) {
+ const memberToken = typeof stub.getMemberToken === "function"
+ ? await stub.getMemberToken(member.id).catch(() => null)
+ : null;
+ const memberUser = typeof stub.getMemberUser === "function"
+ ? await stub.getMemberUser(member.id).catch(() => null)
+ : null;
+ const linked = (await stub.getGitHubStatus().catch(() => null))?.linked ?? null;
+ const scopes = memberToken ? await getTokenScopes(memberToken).catch(() => []) : [];
+ let shareStatus: unknown = null;
+ try {
+ const s = stub as { getShareStatus?: (viewerMemberId?: string | null) => Promise };
+ if (typeof s.getShareStatus === "function") shareStatus = await s.getShareStatus(member.id);
+ } catch {
+ shareStatus = null;
+ }
+ return sessionJson(
+ {
+ connected: !!memberToken,
+ user: memberUser ?? (member.login ? { login: member.login, id: member.userId ?? 0 } : null),
+ linked,
+ scopes,
+ grantedScopes: [],
+ tokenKind: tokenKind(memberToken),
+ ...(shareStatus ? { share: shareStatus } : {}),
+ },
+ session,
+ 200,
+ { "cache-control": "no-store" },
+ );
+ }
+ }
+ } catch {
+ /* fall through to the solo status */
+ }
+ return sessionJson(await stub.getGitHubStatus(), session, 200, {
+ "cache-control": "no-store",
+ });
+ }
+
+ if (url.pathname === "/api/github/login") {
+ try {
+ const { clientId } = requireEnv(env as unknown as Record);
+ const redirectUri = `${url.origin}/api/github/callback`;
+ // state carries the session id so the callback can store the token in
+ // the right Durable Object even if the cookie is missing.
+ //
+ // Bare playground ids are public, so they must not travel alone: bind
+ // an unguessable one-time nonce to this browser via an HttpOnly cookie
+ // (the callback requires the two to match). Bearer/legacy states stay
+ // as-is — they are already unguessable.
+ let state = session.id;
+ const headers = withSession(new Headers(), session);
+ if (isCanonicalId(session.id)) {
+ const nonce = crypto.randomUUID().replace(/-/g, "").slice(0, 20);
+ state = `${session.id}.${nonce}`;
+ const secure = url.protocol === "https:" ? "; Secure" : "";
+ headers.append(
+ "set-cookie",
+ `${LOGIN_STATE_COOKIE}=${state}; Path=/; Max-Age=600; HttpOnly; SameSite=Lax${secure}`,
+ );
+ }
+ const target = loginUrl({ clientId, redirectUri, state });
+ headers.append("location", target);
+ return new Response(null, { status: 302, headers });
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ if (url.pathname === "/api/github/callback") {
+ const code = url.searchParams.get("code");
+ const state = url.searchParams.get("state");
+ // `state` is a session value, or the bare id of a shared playground
+ // (the join cookie — members log in from it; see below).
+ if (!code || !state || (!parseSessionValue(state) && !isCanonicalId(state))) {
+ return new Response("invalid oauth callback", { status: 400 });
+ }
+ // Login CSRF / session fixation guard: `state` is the session id sent in
+ // the authorize URL, so only honor it when it matches the browser's own
+ // session cookie. The login redirect sets that cookie (even for new
+ // sessions), so a same-browser flow always round-trips; a crafted link
+ // opened by a victim carries the attacker's state but the victim's
+ // cookie and is rejected here before any token is stored.
+ //
+ // Bare playground ids are public, so the cookie match alone proves
+ // nothing for them: logins started from a bare id also carry a
+ // one-time nonce echoed in an HttpOnly cookie, and the playground is
+ // accepted only when the two match (validated below).
+ const sessionCookie = readCookie(req, SESSION_COOKIE);
+ const loginEcho = readCookie(req, LOGIN_STATE_COOKIE);
+ let playground: string | null = null;
+ if (loginEcho && loginEcho === state) {
+ const dot = state.lastIndexOf(".");
+ const maybeId = dot === -1 ? "" : state.slice(0, dot);
+ if (isCanonicalId(maybeId)) playground = maybeId;
+ }
+ if (sessionCookie !== state && sessionCookie !== playground) {
+ return new Response("session mismatch — start login again from the GitHub tab", { status: 400 });
+ }
+ // Consuming the nonce: whatever happens below, this login attempt is
+ // over, so a captured state cannot be replayed with a fresh code.
+ const consumeNonce = (headers: Headers) => {
+ if (playground) {
+ const secure = url.protocol === "https:" ? "; Secure" : "";
+ headers.append("set-cookie", `${LOGIN_STATE_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`);
+ }
+ return headers;
+ };
+ try {
+ const { clientId, clientSecret } = requireEnv(env as unknown as Record);
+ const redirectUri = `${url.origin}/api/github/callback`;
+ const { token, grantedScopes } = await exchangeCode({ clientId, clientSecret, code, redirectUri });
+ const user = await getUser(token);
+ // `state` names the pre-login object: a canonical bearer contributes
+ // its object name, a legacy value is the name itself.
+ const targetParsed = parseSessionValue(state);
+ const target = env.APP.get(
+ env.APP.idFromName(targetParsed?.kind === "canonical" ? targetParsed.canonicalId : state),
+ );
+ // Shared playgrounds store the token under the calling member, never
+ // the owner slot: joining + logging in must not grant the owner's
+ // stored credential, and must not overwrite it either.
+ let memberId: string | null = null;
+ let sharedLookupFailed = false;
+ try {
+ const t = target as unknown as {
+ getShare?: () => Promise;
+ setMemberToken?: (id: string, token: string, user: GitHubUser) => Promise;
+ };
+ if (typeof t.getShare === "function") {
+ const share = await t.getShare();
+ if (share?.enabled) {
+ // A bare playground id is public: without a validated login
+ // nonce this state could be any browser's, so refuse before
+ // touching member slots (login CSRF). Legacy/bearer states are
+ // unguessable and need no nonce.
+ if (!targetParsed && !playground) {
+ return new Response("start login again from the GitHub tab", { status: 400 });
+ }
+ const secret = readCookie(req, MEMBER_COOKIE);
+ const member = secret ? (share.members.find((m) => m.id === secret) ?? null) : null;
+ if (!member) {
+ return new Response("this playground is shared — join with an invite code first", {
+ status: 403,
+ headers: consumeNonce(new Headers()),
+ });
+ }
+ if (typeof t.setMemberToken === "function") memberId = member.id;
+ }
+ }
+ } catch {
+ // Share state unreadable: storing the token now could credit the
+ // wrong slot, so fail instead of guessing.
+ sharedLookupFailed = true;
+ }
+ if (sharedLookupFailed) {
+ return new Response("could not verify playground membership — retry login", {
+ status: 500,
+ headers: consumeNonce(new Headers()),
+ });
+ }
+ if (memberId) {
+ const t = target as unknown as {
+ setMemberToken: (id: string, token: string, user: GitHubUser) => Promise;
+ };
+ await t.setMemberToken(memberId, token, user);
+ // Members stay on the playground cookie: no canonical bearer is
+ // minted for them, and the shared object is never adopted into a
+ // personal one (see resolveAuth).
+ return new Response(null, {
+ status: 302,
+ headers: consumeNonce(new Headers({ location: "/" })),
+ });
+ }
+ if (!targetParsed) {
+ // Bare object ids are only meaningful for shared playgrounds
+ // (handled above). Anywhere else the state is invalid: the
+ // canonical flow below must never treat a foreign object as the
+ // caller's pre-login device — it would export and clear it.
+ return new Response("invalid oauth callback", { status: 400 });
+ }
+ // Solo session: fall through to the canonical flow below.
+ // Every login lands on the canonical per-user object, so a second
+ // browser picks up the same files/chat. The cookie bearer is the
+ // full `gh-.`; the id alone never authenticates.
+ const canonicalId = canonicalIdForGithubUser(user.id);
+ const canonical = env.APP.get(env.APP.idFromName(canonicalId)) as unknown as Record;
+ const stateParsed = parseSessionValue(state);
+ if (stateParsed?.kind === "canonical" && stateParsed.canonicalId === canonicalId) {
+ // Re-login from a canonical bearer: refresh the token, keep the
+ // browser's existing secret.
+ if (typeof canonical.claimGithubSession === "function") {
+ await canonical.claimGithubSession(stateParsed.secret, token, user, grantedScopes);
+ } else {
+ await canonical.setGitHubToken(token, user, grantedScopes);
+ }
+ const headers = new Headers({ location: "/" });
+ headers.append(
+ "set-cookie",
+ `${SESSION_COOKIE}=${state}; Path=/; Max-Age=31536000; SameSite=Lax`,
+ );
+ return new Response(null, { status: 302, headers });
+ }
+ const secret = crypto.randomUUID();
+ const bearer = `${canonicalId}.${secret}`;
+ if (typeof canonical.claimGithubSession === "function") {
+ const existing = await canonical.getGitHubToken().catch(() => null);
+ if (!existing) {
+ // Fresh canonical object: adopt the pre-login device state (if
+ // any — the login gate normally keeps it empty) and its images.
+ // The device is invalidated only after the import lands, so a
+ // storage failure retries instead of losing state.
+ const device = env.APP.get(env.APP.idFromName(state)) as unknown as Record;
+ const data = typeof device.exportState === "function"
+ ? await device.exportState().catch(() => null)
+ : null;
+ await canonical.claimGithubSession(secret, token, user, grantedScopes);
+ let imported = true;
+ if (data && typeof canonical.importState === "function") {
+ try {
+ await canonical.importState(data);
+ } catch {
+ imported = false;
+ }
+ }
+ if (typeof canonical.adoptImagesFrom === "function") {
+ await canonical.adoptImagesFrom(state).catch(() => {});
+ }
+ if (imported && typeof device.clearGitHub === "function") {
+ await device.clearGitHub().catch(() => {});
+ }
+ } else {
+ await canonical.claimGithubSession(secret, token, user, grantedScopes);
+ if (stateParsed?.kind === "legacy") {
+ const device = env.APP.get(env.APP.idFromName(stateParsed.deviceId)) as unknown as Record;
+ if (typeof device.clearGitHub === "function") await device.clearGitHub().catch(() => {});
+ }
+ }
+ } else {
+ await canonical.setGitHubToken(token, user, grantedScopes);
+ }
+ const headers = new Headers({ location: "/" });
+ headers.append(
+ "set-cookie",
+ `${SESSION_COOKIE}=${typeof canonical.claimGithubSession === "function" ? bearer : state}; Path=/; Max-Age=31536000; SameSite=Lax`,
+ );
+ return new Response(null, { status: 302, headers });
+ } catch (e) {
+ return new Response(`github login failed: ${(e as Error).message}`, { status: 500 });
+ }
+ }
+
+ if (url.pathname === "/api/github/logout" && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ // On a shared playground, logging out drops only the caller's own
+ // token (membership stays, so re-login restores). clearGitHub would
+ // wipe the link/history for everyone — never run it on a shared DO.
+ if (authed.access.shared && authed.access.memberId && typeof stub.clearMemberToken === "function") {
+ await stub.clearMemberToken(authed.access.memberId);
+ return sessionJson({ ok: true }, session);
+ }
+ const parsed = parseSessionValue(session.id);
+ const expire = () => {
+ const headers = new Headers({ "content-type": "application/json; charset=utf-8" });
+ headers.append("set-cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
+ };
+ if (parsed?.kind === "canonical") {
+ // Single-browser logout: revoke this bearer, atomically with the
+ // last-browser wipe so a concurrent OAuth claim cannot interleave.
+ // The last browser out also clears the token/link, matching the old
+ // global logout. Mock-shaped stubs without logoutSecret fall back
+ // to the two-step sequence.
+ if (typeof stub.logoutSecret === "function") {
+ await stub.logoutSecret(parsed.secret);
+ return expire();
+ }
+ if (typeof stub.revokeSessionSecret === "function") {
+ const { remaining } = await stub.revokeSessionSecret(parsed.secret);
+ if (remaining <= 0) await stub.clearGitHub();
+ return expire();
+ }
+ }
+ await stub.clearGitHub();
+ return sessionJson({ ok: true }, session);
+ }
+ return null;
+}
diff --git a/src/routes/github-repos.ts b/src/routes/github-repos.ts
new file mode 100644
index 0000000..2989cc5
--- /dev/null
+++ b/src/routes/github-repos.ts
@@ -0,0 +1,278 @@
+import type { FileMap } from "../session";
+import { createRepo, deleteRepo, listRemoteBranches, listRepos, parseRepoRef, remoteUrl } from "../github";
+import { isWorktreeKey } from "../gitops";
+import { TOOL_TIMEOUT_MS, withToolTimeout } from "../agent";
+import { requireAccess, sessionJson, turnConflict } from "../server/auth";
+import type { RouteCtx } from "../server/auth";
+
+/** GitHub repo list, open, create, and promote. */
+export async function handleGithubRepos(req: Request, ctx: RouteCtx): Promise {
+ const { url, env, session, stub } = ctx;
+ if (url.pathname === "/api/github/repos") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ return sessionJson({ repos: await listRepos(authed.access.token) }, session, 200, {
+ "cache-control": "no-store",
+ });
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ // Bulk-save the editor's in-memory file map without building. The panel
+ // calls this before commit/create so unsaved browser edits are not
+ // silently omitted from the push.
+ if (url.pathname === "/api/files" && req.method === "POST") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ // The browser snapshot can lag an active agent turn; replacing the
+ // whole FileMap then would discard already-persisted agent edits.
+ const conflict = await turnConflict(stub, session);
+ if (conflict) return conflict;
+ const { files: incoming, baseVersion } = (await req.json()) as { files?: FileMap; baseVersion?: unknown };
+ if (!incoming || typeof incoming !== "object") {
+ return sessionJson({ error: "expected { files }" }, session, 400);
+ }
+ for (const key of Object.keys(incoming)) {
+ if (!isWorktreeKey(key) || typeof incoming[key] !== "string") {
+ return sessionJson({ error: `refused path: ${key}` }, session, 400);
+ }
+ }
+ const saver = stub as {
+ saveFilesIfVersion?: (
+ files: FileMap,
+ base: number | undefined,
+ ) => Promise<{ ok: boolean; files?: FileMap; version: number }>;
+ getFilesVersion?: () => Promise;
+ getFiles?: () => Promise;
+ };
+ // Same required-version + atomic-compare rule as /api/file: on
+ // shared playgrounds an omitted version could silently overwrite a
+ // teammate's save, and edge check-then-set still races.
+ if (authed.access.shared && baseVersion === undefined) {
+ let version = 0;
+ let files: FileMap = {};
+ try {
+ if (typeof saver.getFilesVersion === "function") version = await saver.getFilesVersion();
+ if (typeof saver.getFiles === "function") files = await saver.getFiles();
+ } catch {
+ /* best-effort */
+ }
+ return sessionJson(
+ { error: "shared save needs the files version — reload and retry", files, version },
+ session,
+ 409,
+ );
+ }
+ if (typeof saver.saveFilesIfVersion === "function") {
+ const saved = await saver.saveFilesIfVersion({ ...incoming }, baseVersion as number | undefined);
+ if (!saved.ok) {
+ return sessionJson(
+ { error: "teammate edited — reload: files changed since you loaded them, your copy is stale", files: saved.files, version: saved.version },
+ session,
+ 409,
+ );
+ }
+ let files: FileMap = { ...incoming };
+ try {
+ if (typeof saver.getFiles === "function") files = await saver.getFiles();
+ } catch {
+ /* best-effort */
+ }
+ return sessionJson({ files, version: saved.version }, session);
+ }
+ await stub.setFiles({ ...incoming });
+ let version = 0;
+ try {
+ if (typeof saver.getFilesVersion === "function") version = await saver.getFilesVersion();
+ } catch {
+ /* best-effort */
+ }
+ return sessionJson({ files: await stub.getFiles(), version }, session);
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ if (url.pathname === "/api/github/create" && req.method === "POST") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ // The acting member's own token (never another member's): on a shared
+ // playground a member without write access gets the collaborator
+ // error from assertPushAccess, not an owner-token push.
+ const token = authed.access.token;
+ const as = authed.access.shared && authed.access.user
+ ? { token, user: authed.access.user }
+ : undefined;
+ const { name, private: isPrivate, description, message } = (await req.json()) as {
+ name?: string;
+ private?: boolean;
+ description?: string;
+ message?: string;
+ };
+ if (!name || !name.trim()) return sessionJson({ error: "repo name is required" }, session, 400);
+ const created = await createRepo(token, { name, private: isPrivate, description });
+ const meta = {
+ owner: created.owner,
+ repo: created.name,
+ branch: created.defaultBranch || "main",
+ remoteUrl: remoteUrl(created.owner, created.name),
+ };
+ try {
+ const pushed = await stub.pushCurrentToRemote({
+ remoteUrl: meta.remoteUrl,
+ meta,
+ message: message || "initial commit from app-builder",
+ ...(as ? { as } : {}),
+ });
+ return sessionJson({ repo: created, meta, output: pushed.output, files: await stub.getFiles() }, session);
+ } catch (pushErr) {
+ // The repo already exists at this point: say so explicitly, and
+ // remove the empty shell so a retry does not collide with it.
+ let cleaned = false;
+ try {
+ await deleteRepo(token, created.owner, created.name);
+ cleaned = true;
+ } catch {
+ /* report below; the repo stays for manual deletion */
+ }
+ return sessionJson(
+ {
+ error:
+ `repo ${created.fullName} was created but the initial push failed: ${(pushErr as Error).message}` +
+ (cleaned ? " The empty repo was deleted, so retrying is safe." : " Delete it on GitHub before retrying."),
+ },
+ session,
+ 500,
+ );
+ }
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ if (url.pathname === "/api/github/promote" && req.method === "POST") {
+ try {
+ const {
+ name,
+ private: isPrivate,
+ description,
+ deleteSourceBranch,
+ includeUncommitted,
+ } = (await req.json()) as {
+ name?: string;
+ private?: boolean;
+ description?: string;
+ deleteSourceBranch?: boolean;
+ includeUncommitted?: boolean;
+ };
+ if (!name || !name.trim()) return sessionJson({ error: "repo name is required" }, session, 400);
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const token = authed.access.token;
+ // On shared playgrounds the push is attributed to the acting member
+ // (their own token, never the owner's).
+ const as = authed.access.shared && authed.access.user
+ ? { token: authed.access.token, user: authed.access.user }
+ : undefined;
+ // No linked branch, no promote: fail before creating the shell
+ // instead of creating it and deleting it again on the way out.
+ const linked = (await stub.getGitHubStatus())?.linked ?? null;
+ if (!linked) {
+ return sessionJson({ error: "no linked repo: open a repo first, then promote its branch" }, session, 400);
+ }
+ const created = await createRepo(token, { name, private: isPrivate, description });
+ const meta = {
+ owner: created.owner,
+ repo: created.name,
+ branch: created.defaultBranch || "main",
+ remoteUrl: remoteUrl(created.owner, created.name),
+ };
+ try {
+ const promoted = await stub.promoteBranchToRepo({
+ remoteUrl: meta.remoteUrl,
+ meta,
+ deleteSourceBranch: deleteSourceBranch === true,
+ includeUncommitted: includeUncommitted === true,
+ ...(as ? { as } : {}),
+ });
+ return sessionJson(
+ {
+ repo: created,
+ meta,
+ output: promoted.output,
+ deletedSource: promoted.deletedSource,
+ files: await stub.getFiles(),
+ },
+ session,
+ );
+ } catch (pushErr) {
+ // Same shell-cleanup contract as Create: the repo already exists,
+ // so remove the empty shell (or say how to) instead of leaving a
+ // name that collides with the retry.
+ let cleaned = false;
+ try {
+ await deleteRepo(token, created.owner, created.name);
+ cleaned = true;
+ } catch {
+ /* report below; the repo stays for manual deletion */
+ }
+ return sessionJson(
+ {
+ error:
+ `repo ${created.fullName} was created but promoting the branch failed: ${(pushErr as Error).message}` +
+ (cleaned ? " The empty repo was deleted, so retrying is safe." : " Delete it on GitHub before retrying."),
+ },
+ session,
+ 500,
+ );
+ }
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ if (url.pathname === "/api/github/open" && req.method === "POST") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const as = authed.access.shared && authed.access.user
+ ? { token: authed.access.token, user: authed.access.user }
+ : undefined;
+ const { ref, branch } = (await req.json()) as { ref?: string; branch?: string };
+ if (!ref || !ref.trim()) return sessionJson({ error: "owner/repo is required" }, session, 400);
+ const { owner, repo } = parseRepoRef(ref);
+ const cloned = await stub.cloneFromGitHub({ owner, repo, branch: branch?.trim() || undefined, ...(as ? { as } : {}) });
+ const built = await stub.build();
+ return sessionJson({ ...cloned, built }, session);
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+ // Remote branch names for an arbitrary repo (New-chat picker): the repo
+ // is not cloned yet, so this reads the GitHub API, not the checkout.
+ if (url.pathname === "/api/github/branches") {
+ try {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ const ref = url.searchParams.get("ref") ?? "";
+ if (!ref.trim()) return sessionJson({ error: "ref is required" }, session, 400);
+ const { owner, repo } = parseRepoRef(ref);
+ const token = authed.access.token;
+ const r = await withToolTimeout(
+ listRemoteBranches(token, owner, repo),
+ TOOL_TIMEOUT_MS,
+ "github branches",
+ );
+ return sessionJson(r, session, 200, { "cache-control": "no-store" });
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+
+ return null;
+}
diff --git a/src/routes/share.ts b/src/routes/share.ts
new file mode 100644
index 0000000..01a17f6
--- /dev/null
+++ b/src/routes/share.ts
@@ -0,0 +1,155 @@
+import { MEMBER_COOKIE } from "../share";
+import { parseSessionValue } from "../session-key";
+import { SESSION_COOKIE, corsJson, parseInviteCode, readCookie, requireAccess, sessionJson, stubShare, withMember, withSession } from "../server/auth";
+import type { RouteCtx } from "../server/auth";
+
+/** Shared-playground invite, join, status, preview, and leave. */
+export async function handleShare(req: Request, ctx: RouteCtx): Promise {
+ const { url, env, session, stub } = ctx;
+ // ---------- Live sharing (issue #30) ----------
+
+ // Owner creates (or rotates) the join invite. Joining mints a fresh
+ // member credential — the owner's cookie and token are never shared.
+ if (url.pathname === "/api/share/invite" && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ if (authed.access.shared && authed.access.role !== "owner") {
+ return sessionJson({ error: "only the playground owner can invite" }, session, 403);
+ }
+ try {
+ // The caller's authenticated credentials decide (never the stale
+ // stored token): rotation after a logout/re-auth must not rebind
+ // the owner slot to a revoked token or a different identity.
+ const invite = await stub.createInvite({
+ token: authed.access.token,
+ user: authed.access.user,
+ memberId: authed.access.memberId,
+ });
+ // The owner needs their member credential from here on: shared mode
+ // requires it on every request, and without this cookie the invite
+ // would lock the owner out of their own playground.
+ const headers = new Headers({ "content-type": "application/json; charset=utf-8" });
+ withMember(headers, (invite as { memberSecret?: string }).memberSecret ?? null, req);
+ // The routable object name — never the canonical bearer: the secret
+ // after the dot authenticates the owner and must not leave the
+ // server. Members route by this id and prove themselves with their
+ // own member credential (see resolveAuth/requireAccess).
+ const sessionParsed = parseSessionValue(session.id);
+ const playgroundId =
+ sessionParsed?.kind === "canonical" ? sessionParsed.canonicalId : session.id;
+ return new Response(JSON.stringify({ ...invite, playgroundId }), { headers });
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ // Redeem ".": the caller's cookie is mapped onto the
+ // shared DO and a per-browser member secret is set. No login needed yet —
+ // the member links their own GitHub account afterwards.
+ if (url.pathname === "/api/share/join" && req.method === "POST") {
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return sessionJson({ error: "expected a JSON body" }, session, 400);
+ }
+ const parsed = parseInviteCode((body as { code?: unknown })?.code);
+ if (!parsed) {
+ return sessionJson({ error: 'expected { code } as "."' }, session, 400);
+ }
+ const target = env.APP.get(env.APP.idFromName(parsed.playgroundId));
+ try {
+ const { memberSecret } = await target.joinPlayground(parsed.code);
+ const headers = new Headers({ "content-type": "application/json; charset=utf-8" });
+ headers.append(
+ "set-cookie",
+ `${SESSION_COOKIE}=${parsed.playgroundId}; Path=/; Max-Age=31536000; SameSite=Lax`,
+ );
+ withMember(headers, memberSecret, req);
+ return new Response(JSON.stringify({ ok: true }), { headers });
+ } catch (e) {
+ return sessionJson({ error: e instanceof Error ? e.message : String(e) }, session, 400);
+ }
+ }
+
+ // Sharing panel state. Members see members/turn/audit; non-members only
+ // learn that the playground is shared (join state), never the roster.
+ if (url.pathname === "/api/share/status") {
+ try {
+ const lookedUp = await stubShare(stub);
+ if (!lookedUp.ok) {
+ return sessionJson({ error: "could not verify playground membership — retry" }, session, 500);
+ }
+ const share = lookedUp.share;
+ if (!share?.enabled) {
+ let turn = null;
+ let filesVersion = 0;
+ try {
+ const s = stub as {
+ getTurnStatus?: () => Promise;
+ getFilesVersion?: () => Promise;
+ };
+ if (typeof s.getTurnStatus === "function") turn = await s.getTurnStatus();
+ if (typeof s.getFilesVersion === "function") filesVersion = await s.getFilesVersion();
+ } catch {
+ /* best-effort */
+ }
+ return sessionJson({ shared: false, joined: true, turn, filesVersion }, session, 200, {
+ "cache-control": "no-store",
+ });
+ }
+ const secret = readCookie(req, MEMBER_COOKIE);
+ const member = secret ? (share.members.find((m) => m.id === secret) ?? null) : null;
+ if (!member) {
+ return sessionJson({ shared: true, joined: false }, session, 200, { "cache-control": "no-store" });
+ }
+ const s = stub as { getShareStatus?: (viewerMemberId?: string | null) => Promise };
+ const status =
+ typeof s.getShareStatus === "function" ? await s.getShareStatus(member.id) : { shared: true };
+ return sessionJson({ ...(status as Record), joined: true }, session, 200, {
+ "cache-control": "no-store",
+ });
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ // Short-lived iframe capability (see preview tokens in session): minted
+ // by the cookie-authenticated, member-gated parent before mounting the
+ // sandboxed frame, which cannot present cookies or `ab_member` itself.
+ if (url.pathname === "/api/share/preview" && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ try {
+ if (typeof stub.mintPreviewToken !== "function") {
+ return sessionJson({ error: "preview tokens unavailable" }, session, 500);
+ }
+ return sessionJson(await stub.mintPreviewToken(authed.access.memberId), session);
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+
+ // Leave a shared playground: membership and the member's token go, the
+ // playground persists for the rest. Fresh solo session afterwards.
+ if (url.pathname === "/api/share/leave" && req.method === "POST") {
+ const authed = await requireAccess(req, stub, session);
+ if ("gated" in authed) return authed.gated;
+ if (!authed.access.shared || !authed.access.memberId) {
+ return sessionJson({ error: "this playground is not shared" }, session, 400);
+ }
+ try {
+ if (typeof stub.leavePlayground === "function") {
+ await stub.leavePlayground(authed.access.memberId);
+ }
+ const fresh = crypto.randomUUID();
+ const headers = new Headers({ "content-type": "application/json; charset=utf-8" });
+ headers.append("set-cookie", `${SESSION_COOKIE}=${fresh}; Path=/; Max-Age=31536000; SameSite=Lax`);
+ withMember(headers, null, req);
+ return new Response(JSON.stringify({ ok: true }), { headers });
+ } catch (e) {
+ return sessionJson({ error: (e as Error).message }, session, 500);
+ }
+ }
+ return null;
+}
diff --git a/src/server/auth.ts b/src/server/auth.ts
new file mode 100644
index 0000000..436b812
--- /dev/null
+++ b/src/server/auth.ts
@@ -0,0 +1,434 @@
+import { canonicalIdForGithubUser, isCanonicalId, parseSessionValue } from "../session-key";
+import type { GitHubUser } from "../github";
+import { MEMBER_COOKIE, turnBusyError } from "../share";
+import type { ShareState } from "../share";
+import type { AppSession, FileMap } from "../session";
+import type { Env } from "../index";
+
+export const SESSION_COOKIE = "ab_session";
+// One-time OAuth login nonce (issue #35 review): bound to the browser that
+// started the login so a bare playground id in `state` cannot be replayed
+// cross-browser (login CSRF). See the login/callback routes.
+export const LOGIN_STATE_COOKIE = "ab_login_state";
+// Canonical bearers (`gh-.`); the bare canonical id alone
+// never authenticates (see parseSessionValue), and the lookahead keeps it
+// out of the legacy branch too so over-long numeric ids can't slip through.
+export const SID_RE = /^(?:(?!gh-\d+$)[A-Za-z0-9_-]{8,64}|gh-\d{1,20}\.[A-Za-z0-9_-]{8,64})$/;
+
+/** Anonymous visitors must log in with GitHub before using the app. */
+export const LOGIN_REQUIRED = { error: "github login required" };
+
+/** Caller identity resolved per request (issue #30: caller-has-access). */
+export type Access = {
+ /** Token to use for GitHub REST/git: the caller's own, never another's. */
+ token: string;
+ user: GitHubUser | null;
+ /** Member id on a shared playground; null on solo sessions. */
+ memberId: string | null;
+ role: "owner" | "member" | null;
+ shared: boolean;
+ /** Login to attribute turns/snapshots/pushes to (null when unknown). */
+ actor: string | null;
+};
+
+export type StubLike = {
+ getGitHubToken(): Promise;
+ getShare?: () => Promise;
+ getMemberToken?: (memberId: string) => Promise;
+ getMemberUser?: (memberId: string) => Promise;
+};
+
+/**
+ * Playground share state, distinguishing "not shared" from "unavailable".
+ * A missing method means a legacy stub (solo sessions predate sharing);
+ * a throwing method means storage/RPC failure, which must never fail open
+ * into owner-token authentication.
+ */
+export async function stubShare(stub: StubLike): Promise<{ ok: true; share: ShareState | null } | { ok: false }> {
+ try {
+ if (typeof stub.getShare !== "function") return { ok: true, share: null };
+ return { ok: true, share: (await stub.getShare()) ?? null };
+ } catch {
+ return { ok: false };
+ }
+}
+
+/** Shared request context for route handlers (see src/routes/*). */
+export interface RouteCtx {
+ url: URL;
+ env: Env;
+ session: { id: string; isNew: boolean };
+ stub: DurableObjectStub;
+}
+
+/**
+ * Gate every app API behind a GitHub login. Solo sessions keep the old
+ * object-has-token posture; shared playgrounds (issue #30) require
+ * caller-has-access: the request must carry the caller's own `ab_member`
+ * credential, and the token returned is that member's own — the owner's
+ * stored token is never handed to another member.
+ */
+export async function requireAccess(
+ req: Request,
+ stub: StubLike,
+ session: { id: string; isNew: boolean },
+): Promise<{ access: Access } | { gated: Response }> {
+ const lookedUp = await stubShare(stub);
+ if (!lookedUp.ok) {
+ // Share state unreadable: membership cannot be verified, so reject
+ // instead of failing open into owner-token authentication.
+ return { gated: sessionJson({ error: "could not verify playground membership — retry" }, session, 500) };
+ }
+ const share = lookedUp.share;
+ if (!share?.enabled) {
+ const token = await stub.getGitHubToken();
+ if (!token) return { gated: sessionJson(LOGIN_REQUIRED, session, 401) };
+ return { access: { token, user: null, memberId: null, role: null, shared: false, actor: null } };
+ }
+ const secret = readCookie(req, MEMBER_COOKIE);
+ const member = secret ? (share.members.find((m) => m.id === secret) ?? null) : null;
+ if (!member) {
+ return {
+ gated: sessionJson({ error: "not a member of this playground — join with an invite code" }, session, 403),
+ };
+ }
+ let token: string | null = null;
+ let user: GitHubUser | null = null;
+ try {
+ token = typeof stub.getMemberToken === "function" ? await stub.getMemberToken(member.id) : null;
+ user = typeof stub.getMemberUser === "function" ? await stub.getMemberUser(member.id) : null;
+ } catch {
+ token = null;
+ }
+ // Both or neither: a token without user metadata must not silently fall
+ // back to the owner's credential on git routes — make them log in again.
+ if (!token || !user) return { gated: sessionJson(LOGIN_REQUIRED, session, 401) };
+ return {
+ access: {
+ token,
+ user,
+ memberId: member.id,
+ role: member.role,
+ shared: true,
+ actor: user.login,
+ },
+ };
+}
+
+/**
+ * Legacy gate for routes that predate per-member auth. Shared playgrounds
+ * should prefer requireAccess; this stays for the sid-selected app endpoints,
+ * which address the owning object directly from the sandbox.
+ */
+export async function requireLogin(
+ stub: { getGitHubToken(): Promise },
+ session: { id: string; isNew: boolean },
+): Promise {
+ const token = await stub.getGitHubToken();
+ if (!token) return sessionJson(LOGIN_REQUIRED, session, 401);
+ return null;
+}
+
+/**
+ * The app view iframe runs sandboxed with an opaque origin, so it cannot use
+ * the session cookie (and must never see the parent page). It addresses its
+ * Durable Object with an explicit `sid` instead: the full session bearer,
+ * verified against the allowlist exactly like the cookie (see resolveSid).
+ * A ?sid= URL is not a share link: on shared playgrounds the sid endpoints
+ * still require the owning object's token, and joining always mints a fresh
+ * member credential (see docs/sharing.md).
+ */
+export const APP_CORS = {
+ "access-control-allow-origin": "*",
+ "access-control-allow-methods": "GET, POST, OPTIONS",
+ "access-control-allow-headers": "content-type, accept",
+};
+
+export const corsJson = (body: unknown, status = 200, extra: Record = {}) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: new Headers({ "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...APP_CORS, ...extra }),
+ });
+
+export function readCookie(req: Request, name: string): string | null {
+ const header = req.headers.get("cookie");
+ if (!header) return null;
+ for (const part of header.split(";")) {
+ const eq = part.indexOf("=");
+ if (eq === -1) continue;
+ if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim());
+ }
+ return null;
+}
+
+/**
+ * One session id, carried by cookie, selects the Durable Object that owns the
+ * files, the chat history, and the QuickJS runtime. A cookie rather than
+ * localStorage because cookies are keyed on host alone: the port can change
+ * behind a proxy and the session must survive that. After GitHub login the
+ * value is a canonical bearer (`gh-.`) shared by all of that
+ * user's browsers (see resolveAuth); before login it is a random per-browser
+ * id that lazily migrates on first authenticated use. On shared playgrounds
+ * (issue #30) the cookie names the shared object but proves nothing alone —
+ * each member also presents their own `ab_member` credential, and joining
+ * mints one instead of copying the cookie (docs/sharing.md).
+ */
+export function resolveSession(req: Request): { id: string; isNew: boolean } {
+ const fromCookie = readCookie(req, SESSION_COOKIE);
+ // A bare canonical id (`gh-`) is routable but never authenticating
+ // (see parseSessionValue): it is the join cookie for a shared playground,
+ // whose members prove themselves with `ab_member` instead (requireAccess).
+ if (fromCookie && (parseSessionValue(fromCookie) || isCanonicalId(fromCookie))) {
+ return { id: fromCookie, isNew: false };
+ }
+ return { id: crypto.randomUUID(), isNew: true };
+}
+
+export const withSession = (headers: Headers, session: { id: string; isNew: boolean }) => {
+ // Secure is omitted so the cookie also works over plain http in local dev.
+ if (session.isNew) {
+ headers.append("set-cookie", `${SESSION_COOKIE}=${session.id}; Path=/; Max-Age=31536000; SameSite=Lax`);
+ }
+ return headers;
+};
+
+/**
+ * Set the caller's per-browser playground credential (issue #30). HttpOnly:
+ * page scripts never need to read it (it travels on the Cookie header), so
+ * script access is denied. Secure follows the request protocol so plain-http
+ * local dev keeps working while deployed https gets the flag.
+ */
+export const withMember = (headers: Headers, secret: string | null, req?: Request) => {
+ const secure = req && new URL(req.url).protocol === "https:" ? "; Secure" : "";
+ if (secret) {
+ headers.append("set-cookie", `${MEMBER_COOKIE}=${secret}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax${secure}`);
+ } else {
+ headers.append("set-cookie", `${MEMBER_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`);
+ }
+ return headers;
+};
+
+/** Parse "." invite codes (the id routes, the code redeems). */
+export function parseInviteCode(raw: unknown): { playgroundId: string; code: string } | null {
+ if (typeof raw !== "string") return null;
+ const dot = raw.lastIndexOf(".");
+ if (dot === -1) return null;
+ const playgroundId = raw.slice(0, dot);
+ const code = raw.slice(dot + 1);
+ // The playground id is routable, never authenticating: a legacy device id
+ // or a bare canonical id (`gh-`, the shared object's name — the
+ // secret stays server-side and is never part of the invite). Membership is
+ // proven separately by the member credential (see requireAccess).
+ if ((!SID_RE.test(playgroundId) && !isCanonicalId(playgroundId)) || !/^[A-Za-z0-9]{12}$/.test(code)) return null;
+ return { playgroundId, code };
+}
+
+type TurnLike = {
+ getTurnStatus?: () => Promise<{ active: boolean; actor: string | null }>;
+ isTurnActive?: () => Promise;
+};
+
+/** 409 when a turn runs (naming the actor on shared playgrounds), else null. */
+export async function turnConflict(
+ stub: TurnLike,
+ session: { id: string; isNew: boolean },
+): Promise {
+ try {
+ if (typeof stub.getTurnStatus === "function") {
+ const turn = await stub.getTurnStatus();
+ if (turn?.active) {
+ return sessionJson({ error: turnBusyError(turn.actor ?? null) }, session, 409);
+ }
+ } else if (typeof stub.isTurnActive === "function") {
+ // Legacy stubs predate getTurnStatus: same guard, unnamed actor.
+ if (await stub.isTurnActive()) {
+ return sessionJson({ error: turnBusyError(null) }, session, 409);
+ }
+ }
+ } catch {
+ /* status unreadable: let the op run rather than fail the panel */
+ }
+ return null;
+}
+
+type VersionLike = { getFilesVersion?: () => Promise; getFiles?: () => Promise };
+export type AuthStub = {
+ getGitHubToken(): Promise;
+ getGitHubStatus(): Promise<{ connected: boolean; user: { login: string; id: number } | null; grantedScopes?: string[] }>;
+};
+
+/**
+ * Resolve the request's cookie to the owning Durable Object.
+ *
+ * Canonical bearers (`gh-.`) address the shared per-user
+ * object after the secret allowlist check — one workspace across all of that
+ * user's browsers, which is what resumes a session on a new device. Legacy
+ * random cookies from before the switch lazily migrate on first use: the
+ * device object's state is copied to the fresh canonical object once, a
+ * secret is minted, and the response reissues the cookie as a bearer.
+ *
+ * Shared playgrounds (issue #30) are exempt from that migration: the cookie
+ * names the shared object and members prove themselves with their own
+ * `ab_member` credential (requireAccess), so adopting the playground into
+ * one member's canonical object would fork it away from everyone else. A
+ * bare canonical id likewise routes only to a share-enabled object — the id
+ * alone is public and never authenticates a solo session.
+ *
+ * Stubs without the new methods (tests, older isolates) fall back to the
+ * legacy direct mapping.
+ */
+export async function resolveAuth(
+ env: Env,
+ session: { id: string; isNew: boolean },
+): Promise<{ stub: AuthStub & Record; session: { id: string; isNew: boolean } }> {
+ const parsed = parseSessionValue(session.id);
+ const direct = (name: string) => env.APP.get(env.APP.idFromName(name)) as unknown as AuthStub & Record;
+ /** The named object iff it is an enabled shared playground, else null. */
+ const sharedTarget = async (name: string): Promise<(AuthStub & Record) | null> => {
+ const stub = direct(name);
+ try {
+ const maybeShared = stub as unknown as { getShare?: () => Promise<{ enabled?: boolean } | null> };
+ if (typeof maybeShared.getShare === "function") {
+ const share = await maybeShared.getShare();
+ if (share?.enabled) return stub;
+ }
+ } catch {
+ /* share check failed: not a proven playground — no direct routing */
+ }
+ return null;
+ };
+ if (!parsed) {
+ // Bare canonical ids join members to a shared playground; anything else
+ // unparseable is anonymous. Solo sessions never route by bare id.
+ if (isCanonicalId(session.id)) {
+ const target = await sharedTarget(session.id);
+ if (target) return { stub: target, session };
+ }
+ const fresh = crypto.randomUUID();
+ return { stub: direct(fresh), session: { id: fresh, isNew: true } };
+ }
+ if (parsed.kind === "canonical") {
+ const stub = direct(parsed.canonicalId);
+ try {
+ if (typeof stub.checkSessionSecret === "function") {
+ if (await stub.checkSessionSecret(parsed.secret)) return { stub, session };
+ } else {
+ // Mock/legacy stub shape: preserve the old direct behavior.
+ return { stub, session };
+ }
+ } catch {
+ /* storage failure: fall through to anonymous */
+ }
+ const fresh = crypto.randomUUID();
+ return { stub: direct(fresh), session: { id: fresh, isNew: true } };
+ }
+ const device = direct(parsed.deviceId);
+ try {
+ // A shared playground is owned collectively: skip the canonical adoption
+ // below so the playground is never copied into one member's object.
+ if (await sharedTarget(parsed.deviceId)) return { stub: device, session };
+ if (typeof device.getGitHubStatus !== "function") return { stub: device, session };
+ const status = await device.getGitHubStatus();
+ const userId = status?.connected ? status?.user?.id : undefined;
+ if (!userId) return { stub: device, session };
+ const canonicalId = canonicalIdForGithubUser(userId);
+ const canonical = direct(canonicalId);
+ const secret = crypto.randomUUID();
+ const hasClaim =
+ typeof canonical.claimGithubSession === "function" && typeof canonical.addSessionSecret === "function";
+ if (!hasClaim) return { stub: device, session };
+ const existing = await canonical.getGitHubToken().catch(() => null);
+ if (!existing) {
+ const data =
+ typeof device.exportState === "function" ? await device.exportState().catch(() => null) : null;
+ const token = await device.getGitHubToken().catch(() => null);
+ if (!token || !status.user) return { stub: device, session };
+ await canonical.claimGithubSession(secret, token, status.user, status.grantedScopes ?? []);
+ // Only invalidate the device once its state landed: a failed import
+ // leaves the source intact so the next request retries instead of
+ // losing data. Image adoption is best-effort — source R2 objects are
+ // never deleted, so a failure there loses nothing.
+ let imported = true;
+ if (data && typeof canonical.importState === "function") {
+ try {
+ await canonical.importState(data);
+ } catch {
+ imported = false;
+ }
+ }
+ if (typeof canonical.adoptImagesFrom === "function") {
+ await canonical.adoptImagesFrom(parsed.deviceId).catch(() => {});
+ }
+ if (imported && typeof device.clearGitHub === "function") {
+ await device.clearGitHub().catch(() => {});
+ }
+ } else {
+ await canonical.addSessionSecret(secret);
+ // The canonical workspace is authoritative from here: revoke the
+ // legacy bearer so the old cookie/sid cannot fork state through the
+ // direct device mapping.
+ if (typeof device.clearGitHub === "function") await device.clearGitHub().catch(() => {});
+ }
+ return { stub: canonical, session: { id: `${canonicalId}.${secret}`, isNew: true } };
+ } catch {
+ return { stub: device, session };
+ }
+}
+
+/**
+ * Same bearer check for the cookie-less app iframe endpoints, with one
+ * exception: a `sid` identical to the request's own cookie belongs to the
+ * browser `resolveAuth` just authorized (and migrated), so it uses the
+ * resolved stub directly. Without this, a preview racing its page's first
+ * migration would 401 on the stale device id and never rotate its cookie.
+ */
+export async function resolveSidForRequest(
+ env: Env,
+ req: Request,
+ sid: string,
+ resolved: DurableObjectStub,
+): Promise | null> {
+ if (!SID_RE.test(sid)) return null;
+ const cookieVal = readCookie(req, SESSION_COOKIE);
+ if (cookieVal && sid === cookieVal) return resolved;
+ return (await resolveSid(env, sid)) as unknown as DurableObjectStub | null;
+}
+
+/**
+ * Same bearer check for the cookie-less app iframe endpoints: the `sid` is
+ * the full bearer, and the canonical id alone is rejected. Legacy sids
+ * resolve directly to the device object; callers must still check the token.
+ */
+export async function resolveSid(env: Env, sid: string): Promise<(AuthStub & Record) | null> {
+ if (!SID_RE.test(sid)) return null;
+ const parsed = parseSessionValue(sid);
+ const direct = (name: string) => env.APP.get(env.APP.idFromName(name)) as unknown as AuthStub & Record;
+ if (!parsed) return null;
+ if (parsed.kind === "canonical") {
+ const stub = direct(parsed.canonicalId);
+ try {
+ if (typeof stub.checkSessionSecret === "function") {
+ return (await stub.checkSessionSecret(parsed.secret)) ? stub : null;
+ }
+ return (await stub.getGitHubToken()) ? stub : null;
+ } catch {
+ return null;
+ }
+ }
+ return direct(parsed.deviceId);
+}
+
+export function sessionJson(
+ body: unknown,
+ session: { id: string; isNew: boolean },
+ status = 200,
+ extra: Record = {},
+) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: withSession(
+ new Headers({ "content-type": "application/json; charset=utf-8", ...extra }),
+ session,
+ ),
+ });
+}
diff --git a/src/server/page.ts b/src/server/page.ts
new file mode 100644
index 0000000..ffbc9dd
--- /dev/null
+++ b/src/server/page.ts
@@ -0,0 +1,80 @@
+import { DEFAULT_GATEWAY, DEFAULT_MODEL, GO_MODELS, ZEN_MODELS } from "../agent";
+import { inlineLocalAssets, pickPreviewHtml, resolveAssetRef } from "../preview-assets";
+import { markdownToHtml } from "../markdown";
+import { PAGE_HEAD } from "../client/head";
+import { CLIENT_PRELUDE_JS, MARKDOWN_SHIM_JS } from "../client/core";
+import { STATE_JS } from "../client/state";
+import { TRANSCRIPT_JS } from "../client/transcript";
+import { HISTORY_JS } from "../client/history";
+import { PANE_JS } from "../client/pane";
+import { SETTINGS_A_JS } from "../client/settings-a";
+import { SETTINGS_B_JS } from "../client/settings-b";
+import { ATTACH_JS } from "../client/attachments";
+import { AGENT_JS } from "../client/agent";
+import { TABS_JS } from "../client/tabs";
+
+export const escHtml = (s: string) =>
+ s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+
+/** Full document for the live app iframe: boot state plus the client bundle. */
+export const appDoc = (
+ bundle: string | null,
+ boot: { sid: string; preview: string; api: { state: string; mutate: string }; state: unknown; error: unknown },
+): string => `
+
+
+
+
+
+capsule app
+
+
+
+${bundle === null ? `App failed to build
${escHtml((boot.error as { message?: string } | null)?.message ?? "unknown error")} ` : "Loading… "}
+
+${bundle === null ? "" : ``}
+
+`;
+
+/** App shell page: static head plus the client script assembled from src/client/*. Order matters: the error handler and prelude come first, MARKDOWN_SHIM_JS must precede the serialized markdownToHtml (the worker TS transform wraps helpers with __name(), which only the shim defines), and the feature chunks follow in page order. */
+export const page = () =>
+ PAGE_HEAD +
+ `const GATEWAYS = { zen: ${JSON.stringify(ZEN_MODELS)}, go: ${JSON.stringify(GO_MODELS)} };\n` +
+ `const DEFAULT_GATEWAY = ${JSON.stringify(DEFAULT_GATEWAY)};\n` +
+ `const DEFAULT_MODEL = ${JSON.stringify(DEFAULT_MODEL)};\n` +
+ CLIENT_PRELUDE_JS +
+ `${pickPreviewHtml}\n${resolveAssetRef}\n${inlineLocalAssets}\n` +
+ MARKDOWN_SHIM_JS +
+ `${markdownToHtml}\n` +
+ STATE_JS +
+ TRANSCRIPT_JS +
+ HISTORY_JS +
+ PANE_JS +
+ SETTINGS_A_JS +
+ SETTINGS_B_JS +
+ ATTACH_JS +
+ AGENT_JS +
+ TABS_JS +
+ `\n