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 = ` + + + + +app-builder + + + +
+ Chat + History + Preview + Code + + Logs + Settings + + +
+ process ram — + process cpu — + typecheck — + bundle — + idle +
+
+
+
+
Chat
+
+
+
+ + +
+ + + + +
+
+
+
+ + +
+ -${bundle === null ? "" : ``} - -`; - -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). - */ -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 }; -} - -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. - */ -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). */ -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
-
-
-
- - -
- - - - -
-
-
-
- - -
- - -`; \ 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\n`;