// The public web app: explore/search, a GitHub-like repo page, login // (Cloudflare + Google OAuth), a profile page, and a JSON /api/* layer // so the CLI (`zz login`, `zz agent`, `zz repo`) has full parity with // the web UI -- everything doable in the browser is doable from a // terminal or by an agent with no browser at all. Added as routes on // the existing zz-maintainer Worker rather than a second service, // given the time budget (see the approved plan). // // One identity model throughout: there is no "account" distinct from // "agent" -- per governance.md, every participant, human or // synthetic, is an Agent, and none is inherently superior to another. // `created_by` is provenance for the profile page's display only, // never an authority relationship; all real authority is a repo-level // Role grant (roles.json on that repo's zz-meta branch), the same // mechanism for every Agent. // // Every self-referential link uses `new URL(request.url).origin`, // never a hardcoded domain -- the domain (zzbin.dev today) can change // with zero code changes here; only the OAuth redirect URIs (Worker // vars) need updating alongside a Dashboard change. import { zipSync } from "fflate"; import { createInitialCommit } from "./cloudflareGitBackend.js"; import type { MaintainerDO } from "./MaintainerDO.js"; // Artifacts/ArtifactsRepo are ambient globals from @cloudflare/workers-types // (no import needed/possible -- see cloudflareGitBackend.ts's header for // why a module-level import of these resolves wrong). import { type Agent, type RepoRow, type LadderSearchHit, createAgent, createAgentIdentity, createAgentToken, createCliLoginSession, createSession, deleteRepoRow, deleteSession, findActiveAgentToken, findAgentIdentity, fulfillCliLoginSession, getAgent, getAgentByEmail, getAgentToken, getRepoBySlug, getRepoRoleForPrincipal, getValidSession, isTrackingRepo, listAgentsCreatedBy, listMyRepos, listRecentRepos, listReposOwnedBy, listShardsOf, listTokensForAgent, pollAndConsumeCliLoginSession, refreshAgentProfile, revokeAgentToken, searchAgents, searchExplore, searchLadderIndex, searchRepos, setIngestStatus, shortId, syncRepoRoleCache, touchAgentToken, touchRepoUpdatedAt, trackRepo, untrackRepo, updateIdentityTokens, updateRepoDescription, updateRepoOwner, updateRepoVisibility, upsertRepo, } from "./accountsDb.js"; import { buildAuthorizeUrl, clearSessionCookieHeader, decryptSecret, encryptSecret, exchangeCodeForIdentity, generateAgentToken, hashAgentToken, readBearerToken, readSessionCookie, sessionCookieHeader, type OAuthEnv, type Provider, } from "./auth.js"; import { ensureMeta, readRepoMeta, readRepoMetaAndRoles, readRepoRoles, repoAtLeast, repoRoleFor, writeRepoRoles, writeRepoMeta, type RbacRole } from "./repoMeta.js"; import { LICENSES, LICENSE_ORDER, type LicenseOption } from "./licenses.js"; import { isRef, type EmbeddedRung } from "../../engine/ladderNotes.js"; import { REPO_RUNG_PATH, type Rung } from "../../engine/ladder.js"; import type { ResolvedRung } from "../../engine/ladderRender.js"; import { claimableAsks } from "../../engine/workqueue.js"; import type { AskEntry, HistoryEntry, Lane } from "../../engine/types.js"; export interface WebEnv extends OAuthEnv { ARTIFACTS: Artifacts; DB: D1Database; MAINTAINER_DO: DurableObjectNamespace; } // --- HTML helpers --- function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // http(s) URL detection, plain and deliberately conservative -- a // trailing sentence-punctuation character right after a URL (".", // ",", ")", etc., the common case in a hand-written description: "see // https://example.com.") is excluded from the match rather than // swallowed into the link, so the link target doesn't end up with a // stray period baked in. const URL_PATTERN = /https?:\/\/[^\s<>"]+[^\s<>".,;:!?)]/g; /** Same escaping `esc` does, plus turning any raw http(s) URL in the text into a real, clickable link -- used for repo descriptions specifically (free-text the owner typed, same as a GitHub repo's own "Website" blurb, not markdown), not every `esc` call generally. */ function autolinkText(s: string): string { let out = ""; let last = 0; for (const m of s.matchAll(URL_PATTERN)) { out += esc(s.slice(last, m.index)); const url = m[0]; out += `${esc(url)}`; last = m.index + url.length; } out += esc(s.slice(last)); return out; } /** An Ask/Answer payload, pretty-printed instead of dumped as one unreadable JSON.stringify() line -- a scrollable box so a large field (e.g. a proposal's full file contents) doesn't blow out the rest of the page's layout. */ function jsonBlock(value: unknown): string { return `
${esc(JSON.stringify(value ?? {}, null, 2))}
`; } // A deliberately small markdown-to-HTML renderer for README display -- // not a CommonMark implementation, just headers/bold/italic/inline and // fenced code/links/lists/paragraphs, which covers the overwhelming // majority of real READMEs. Safe by construction rather than by // sanitizing afterward: the source is HTML-escaped FIRST, so every // regex below only ever adds tags around already-inert text -- a // README containing a literal ` `; const headers = new Headers({ "content-type": "text/html; charset=utf-8" }); for (const cookie of opts.cookies ?? []) headers.append("set-cookie", cookie); return new Response(html, { headers }); } // Takes `cookies` as a real array, appended individually -- joining // multiple Set-Cookie values with ", " into one header line is NOT // valid (unlike most headers, Set-Cookie can't be comma-combined; // cookie values can contain commas themselves). Confirmed live: doing // that once silently dropped everything but the first cookie, which // broke zz_cli_nonce riding alongside zz_oauth_state. function redirect(url: string, opts: { cookies?: string[] } = {}): Response { const headers = new Headers({ location: url }); for (const cookie of opts.cookies ?? []) headers.append("set-cookie", cookie); return new Response(null, { status: 302, headers }); } function json(data: unknown, status = 200): Response { return Response.json(data, { status }); } class ApiError extends Error { constructor( public status: number, message: string, ) { super(message); } } // --- Auth --- /** Session cookie (browser) OR bearer token (CLI, bots, git -- see resolveGitAuthPrincipal) -- same identity model either way, just two ways to present a credential for the same kind of entity. */ async function resolveRequestAgent(request: Request, env: WebEnv): Promise { const sessionId = readSessionCookie(request); if (sessionId) { const session = await getValidSession(env.DB, sessionId); if (session) return getAgent(env.DB, session.agent_username); } const bearer = readBearerToken(request); if (bearer) { const token = await findActiveAgentToken(env.DB, hashAgentToken(bearer)); if (token) { await touchAgentToken(env.DB, token.id); return getAgent(env.DB, token.agent_username); } } return null; } async function handleLogin(request: Request, env: WebEnv): Promise { const cliNonce = new URL(request.url).searchParams.get("cli_nonce") ?? ""; // Only when there's no cli_nonce -- the CLI's own login bridge needs // a real OAuth round trip to mint a fresh token for itself even from // an already-logged-in browser, so that flow always shows the // buttons. Without one, this is just "click login from the main // page" (or a stale bookmark/back-button), and a valid session // already answers that -- showing the buttons again instead of just // taking you into the app was the actual complaint. if (!cliNonce) { const me = await resolveRequestAgent(request, env); if (me) return redirect(`/${me.username}`); } const qs = cliNonce ? `?cli_nonce=${encodeURIComponent(cliNonce)}` : ""; return layout( "Log in", null, `${cliNonce ? '

Logging in for the zz CLI -- this browser tab can be closed once it redirects back.

' : ""} `, { centerTitle: true }, ); } async function handleAuthStart(provider: Provider, request: Request, env: WebEnv): Promise { const cliNonce = new URL(request.url).searchParams.get("cli_nonce") ?? ""; const state = shortId("state"); const url = buildAuthorizeUrl(provider, env, state); const cookies = [`zz_oauth_state=${state}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`]; if (cliNonce) cookies.push(`zz_cli_nonce=${cliNonce}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`); return redirect(url, { cookies }); } async function handleAuthCallback(provider: Provider, request: Request, env: WebEnv): Promise { const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const cookieHeader = request.headers.get("cookie") ?? ""; const cookieState = cookieHeader.match(/zz_oauth_state=([^;]+)/)?.[1]; const cliNonce = cookieHeader.match(/zz_cli_nonce=([^;]+)/)?.[1]; if (!code || !state || state !== cookieState) { return new Response("OAuth state mismatch or missing code -- please try logging in again.", { status: 400 }); } const identity = await exchangeCodeForIdentity(provider, env, code); const existing = await findAgentIdentity(env.DB, provider, identity.subject); const expiresAt = identity.expiresInSeconds ? new Date(Date.now() + identity.expiresInSeconds * 1000).toISOString() : null; const accessTokenEnc = await encryptSecret(env, identity.accessToken); const refreshTokenEnc = identity.refreshToken ? await encryptSecret(env, identity.refreshToken) : null; let agent: Agent; if (existing) { agent = (await getAgent(env.DB, existing.agent_username))!; await updateIdentityTokens(env.DB, existing.id, { accessTokenEnc, refreshTokenEnc, expiresAt }); await refreshAgentProfile(env.DB, agent.username, identity.displayName, identity.email); } else { // A second OAuth provider with the SAME verified email links to // the agent that already owns that email instead of minting a // disconnected duplicate -- confirmed live as a real bug: logging // in with Google, then later with Cloudflare, produced two // separate Agents sharing no history (different provider_subject, // so findAgentIdentity above correctly found nothing -- the gap // was never checking for an existing Agent by email before // falling through to createAgent). const byEmail = identity.email ? await getAgentByEmail(env.DB, identity.email) : null; if (byEmail) { agent = byEmail; await refreshAgentProfile(env.DB, agent.username, identity.displayName, identity.email); } else { agent = await createAgent(env.DB, identity.displayName, identity.email, null); } await createAgentIdentity(env.DB, agent.username, provider, identity.subject, { accessTokenEnc, refreshTokenEnc, expiresAt }); } const session = await createSession(env.DB, agent.username, 60 * 60 * 24 * 30); // CLI bridge: a `zz login` browser tab carries a nonce through as a // cookie; fulfill it with a fresh bearer token so the waiting CLI // process can pick it up on its next poll. The web session above is // independent -- this tab also ends up logged in normally if left open. if (cliNonce) { const plaintext = generateAgentToken(); await createAgentToken(env.DB, agent.username, hashAgentToken(plaintext)); const fulfilled = await fulfillCliLoginSession(env.DB, cliNonce, agent.username, plaintext); if (fulfilled) { return layout( "Logged in", agent, `

Logged in as ${esc(agent.username)}. You can close this tab and return to your terminal.

`, ); } } // First-ever browser login, no CLI involved: mint a token right // here too, same reasoning as doCreateAgent -- a credential that's // only ever issued from a buried "issue yourself a token" button on // /profile is a credential most people won't find until they // already need it (confirmed live: git push failure asking "how do // I even get a token"). Only on first login (zero existing active // tokens) -- a token is shown-once by design, so minting one on // every return visit would just produce unusable, never-seen tokens. const existingTokens = (await listTokensForAgent(env.DB, agent.username)).filter((t) => !t.revoked_at); if (existingTokens.length === 0) { const plaintext = generateAgentToken(); await createAgentToken(env.DB, agent.username, hashAgentToken(plaintext)); return layout( "Welcome", agent, `

Logged in as ${esc(agent.username)}. Here's a token for git push, the CLI, or any script acting as you -- copy it now, it won't be shown again:

${esc(plaintext)}

Continue to your profile →

`, { cookies: [sessionCookieHeader(session.id, 60 * 60 * 24 * 30)] }, ); } return redirect(`/${agent.username}`, { cookies: [sessionCookieHeader(session.id, 60 * 60 * 24 * 30)] }); } async function handleLogout(request: Request, env: WebEnv): Promise { const sessionId = readSessionCookie(request); if (sessionId) await deleteSession(env.DB, sessionId); return redirect("/explore", { cookies: [clearSessionCookieHeader()] }); } // --- CLI login bridge (JSON) --- async function handleApiLoginStart(request: Request, env: WebEnv): Promise { const nonce = shortId("nonce"); await createCliLoginSession(env.DB, nonce); const origin = new URL(request.url).origin; return json({ nonce, url: `${origin}/login?cli_nonce=${nonce}` }); } async function handleApiLoginPoll(request: Request, env: WebEnv): Promise { const nonce = new URL(request.url).searchParams.get("nonce"); if (!nonce) return json({ error: "missing ?nonce=" }, 400); const result = await pollAndConsumeCliLoginSession(env.DB, nonce); if (!result) return json({ status: "pending" }); const agent = await getAgent(env.DB, result.agentUsername); return json({ status: "ready", token: result.token, agent: { username: agent?.username ?? result.agentUsername } }); } // --- Shared core logic (used by both HTML forms and the JSON API) --- // Issues the agent's first credential as part of creation itself, // not a separate click -- there's no reason to make "spin up an Agent // and get it a usable token" a two-step dance when both steps happen // in the same request anyway. Issuing an *additional* token later // (rotation, a second credential for a second machine/process) stays // its own explicit action -- see doMintToken. async function doCreateAgent(env: WebEnv, creator: Agent, displayName: string): Promise<{ agent: Agent; token: string }> { if (!displayName.trim()) throw new ApiError(400, "display_name is required"); const agent = await createAgent(env.DB, displayName.trim(), null, creator.username); const token = generateAgentToken(); await createAgentToken(env.DB, agent.username, hashAgentToken(token)); return { agent, token }; } /** Minting a token for an agent is allowed for the agent itself (re-authenticating with an existing token/session) or whoever created it -- a practical bootstrap allowance (a brand-new agent has no credential yet, so someone has to issue its first one), not a general override. */ async function requireTokenMintAuthority(env: WebEnv, actor: Agent, targetUsername: string): Promise { const target = await getAgent(env.DB, targetUsername); if (!target) throw new ApiError(404, "no such agent"); if (target.username !== actor.username && target.created_by !== actor.username) throw new ApiError(403, "you can only mint tokens for yourself or an agent you created"); return target; } async function doMintToken(env: WebEnv, actor: Agent, targetUsername: string): Promise<{ agent: Agent; token: string }> { const target = await requireTokenMintAuthority(env, actor, targetUsername); const plaintext = generateAgentToken(); await createAgentToken(env.DB, target.username, hashAgentToken(plaintext)); return { agent: target, token: plaintext }; } function slugify(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent"; } /** The repo URL's owner segment, and the segment at / for this Agent's own profile page. Frozen at creation (accountsDb.ts's createAgent) rather than recomputed live from display_name -- see migration 0003's header for why that matters. */ function ownerUrlSegment(agent: Agent): string { return agent.username; } // A new repo's Ladder is bootstrapped mechanically, never via an LLM // call -- repo creation has to stay fast and free, and "fold in a // summary" (the one case ladder_write_rung's own CUMULATIVE_SUMMARY_KINDS // handling calls a model) only triggers when a rung ALREADY has a // summary to fold against, which a brand-new repo never does. Since // there's no model call to lean on, these are hand-written explanations // of what a README/LICENSE actually IS and does -- a fixed "added." // would be technically true but tell a reader nothing they couldn't // already see from the filename. function buildInitialLadderRungs(opts: { description: string | null; ownerName: string; repoName: string; sha: string; license: LicenseOption | null }): Rung[] { const at = new Date().toISOString(); const source = (title: string) => [{ kind: "commit" as const, ref: opts.sha, title, author: opts.ownerName, at }]; const trimmedDescription = opts.description?.trim().replace(/\.$/, "") || null; const readmeSummary = `A README introducing the "${opts.repoName}" repository${trimmedDescription ? ` -- ${trimmedDescription}` : ""}. A README is the front door of an open-source project: the first document most visitors read, explaining what it does and how to use it.`; const licenseSummary = opts.license ? `The ${opts.license.name} license text. ${opts.license.blurb} A LICENSE file is what makes a repository's code legally reusable by others -- without one, the default under copyright law is "all rights reserved."` : null; const readmeRung: Rung = { path: "README.md", kind: "file", summary: readmeSummary, sources: source("Initial commit"), children: [] }; const licenseRung: Rung | null = opts.license && licenseSummary ? { path: "LICENSE", kind: "file", summary: licenseSummary, sources: source("Initial commit"), children: [] } : null; const changesetRung: Rung = { // Full sha, not truncated -- the established convention // (agent/tools.ts's VALID_RUNG_EXAMPLE, cli/commands/work.ts's own // comment), and load-bearing for the Cloudflare backend // specifically: readAnchor derives the anchor sha directly from // this path for "changesets/" paths (see its own comment), so a // truncated sha would make this rung permanently unreadable. path: `changesets/${opts.sha}`, kind: "changeset", summary: `Initial commit: added README.md${opts.license ? ", LICENSE" : ""}.`, sources: source("Initial commit"), children: [ { path: "README.md", kind: "filechange", summary: `Added. ${readmeSummary}`, sources: source("Initial commit"), children: [] }, ...(opts.license && licenseSummary ? [{ path: "LICENSE", kind: "filechange" as const, summary: `Added. ${licenseSummary}`, sources: source("Initial commit"), children: [] }] : []), ], }; const repoRung: Rung = { path: REPO_RUNG_PATH, kind: "repo", summary: `${trimmedDescription ?? "No description"}. Contains README.md${opts.license ? ` and a ${opts.license.name} LICENSE` : ""}.`, sources: source("Initial commit"), children: [], }; return [repoRung, readmeRung, ...(licenseRung ? [licenseRung] : []), changesetRung]; } async function doCreateRepo(env: WebEnv, owner: Agent, name: string, description: string | null, licenseKey: string | null): Promise { if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new ApiError(400, "repo name must match [A-Za-z0-9._-]+"); const artifactsName = shortId("repo"); const slug = `${ownerUrlSegment(owner)}/${slugify(name)}`; const license = licenseKey ? LICENSES[licenseKey] : null; const created = await env.ARTIFACTS.create(artifactsName, { description: description ?? undefined, setDefaultBranch: "main" }); using repo = await env.ARTIFACTS.get(artifactsName); const token = await repo.createToken("write", 3600); // commit_changeset (and anything else that anchors to a commit) needs // a parent to diff against -- a brand new repo has none, so seed one. const readmeContent = `# ${name}\n\n${description ?? ""}\n`; const files = [{ path: "README.md", content: readmeContent }]; if (license) files.push({ path: "LICENSE", content: license.text(owner.display_name, new Date().getFullYear()) }); const sha = await createInitialCommit(created.remote, token, files, { name: owner.display_name, email: owner.email ?? `${owner.username}@zz.local` }); await ensureMeta(created.remote, token, { description: description ?? "", owner: owner.username }); const row = await upsertRepo(env.DB, { slug, artifactsName, description, ownerUsername: owner.username }); await syncRepoRoleCache(env.DB, row.slug, [{ principal: owner.username, role: "owner", granted_by: "system", at: row.created_at }]); const rungs = buildInitialLadderRungs({ description, ownerName: owner.display_name, repoName: name, sha, license }); const stub = doStub(env, artifactsName); await stub.configure(artifactsName); await stub.callTool("ladder_write_rung", { repo: artifactsName, rung: rungs }); // The repo's own long-lived root Lane -- matches cli/commands/ // ladder-build.ts's identical bootstrap for the local onboarding // path. Without this, lane_list on a brand-new repo returned [] // (not just "no history yet", genuinely no Lane at all), which is // why a freshly created repo looked completely unreachable from zz // top's protocol/lanes tabs, not just empty -- confirmed live. await stub.callTool("lane_propose", { repo: artifactsName, id: REPO_RUNG_PATH, name: slug, objective: `the ${slug} repository`, scope: ["**"], actor: owner.username }); return row; } // Stopwords stripped so the character budget buys meaningful words, not // filler -- "add the sum function" and "add sum function" should slugify // to the same thing. const TASK_SLUG_STOPWORDS = new Set(["a", "an", "the", "to", "for", "and", "or", "of", "in", "on", "with", "is", "are", "this", "that"]); const TASK_SLUG_MAX_CHARS = 40; const SHARD_SLUG_MAX_CHARS = 100; /** Turns a task's free-text description into a short, meaningful bracket segment ("add the sum function" -> "add-sum-function"). Not a blind character truncation: stopwords are dropped first so the ~40-char budget is spent on words that actually distinguish the task, and whole words are kept (never cut mid-word). */ function slugifyTask(text: string): string { const words = text .toLowerCase() .replace(/[^a-z0-9\s-]+/g, " ") .split(/[\s-]+/) .filter((w) => w && !TASK_SLUG_STOPWORDS.has(w)); const kept: string[] = []; let len = 0; for (const w of words) { const next = len + (kept.length > 0 ? 1 : 0) + w.length; if (next > TASK_SLUG_MAX_CHARS) break; kept.push(w); len = next; } return kept.join("-") || "task"; } /** A short, stable stand-in for a slug segment that's too long to keep verbatim -- deterministic (same input always collapses the same way) so re-deriving a shard's slug twice still agrees. */ function shortHash(input: string): string { let h = 0; for (let i = 0; i < input.length; i++) h = (Math.imul(h, 31) + input.charCodeAt(i)) | 0; return (h >>> 0).toString(36).slice(0, 8); } /** Computes a shard's slug from its parent's, appending `[taskSlug]` to the parent's repo-name segment and re-prefixing with the acting agent's own username -- the bracket-array-accessor convention the user chose for agent identities, extended to task decomposition. Recursive by construction: `parentSlug` may itself already be a shard's slug (it already carries its own bracket chain), so sharding a shard needs no special-casing here. If the result would exceed the overall length budget, the parent's chain prefix collapses into a short deterministic hash first, rather than growing the segment itself shorter (see slugifyTask's own comment for why). */ function shardSlug(parentSlug: string, actorUsername: string, taskSlug: string): string { const slashIdx = parentSlug.indexOf("/"); const parentName = slashIdx === -1 ? parentSlug : parentSlug.slice(slashIdx + 1); let name = `${parentName}[${taskSlug}]`; if (actorUsername.length + 1 + name.length > SHARD_SLUG_MAX_CHARS) { name = `${shortHash(parentName)}[${taskSlug}]`; } return `${actorUsername}/${name}`; } /** Create-or-reuse a task shard: a real forked repo (ArtifactsRepo.fork, full default-branch history) scoped to one task, owned by the acting agent. `parentSlug` is whatever repo the task's own Lane currently lives in -- the true origin on a first-level shard, or an existing shard's slug when a sub-agent is being delegated a piece of an already-sharded task, which is what makes this recursive without extra code (see the user's "foo[task-a][task-b]" naming request). Idempotent: the slug is fully deterministic from (actorUsername, parentSlug, taskText), so a repeated call for the same task reuses the same shard rather than creating a new one each time. */ async function ensureShard(env: WebEnv, parentSlug: string, actor: Agent, taskText: string): Promise { const parentRow = await getRepoBySlug(env.DB, parentSlug); if (!parentRow) throw new ApiError(404, `no such repo "${parentSlug}"`); const taskSlug = slugifyTask(taskText); let slug = shardSlug(parentSlug, actor.username, taskSlug); const existing = await getRepoBySlug(env.DB, slug); if (existing) { if (existing.shard_of_slug === parentSlug) return existing; // Same (actor, parent, taskSlug) collided with an unrelated repo -- // the numeric-suffix convention already used elsewhere in this // account's own repo list (e.g. "khkjhkh"/"khkjhkh2"). let n = 2; while (await getRepoBySlug(env.DB, `${slug}-${n}`)) n++; slug = `${slug}-${n}`; } const artifactsName = shortId("shard"); using parentRepo = await env.ARTIFACTS.get(parentRow.artifacts_name); const created = await parentRepo.fork(artifactsName, { description: `${taskText} -- shard of ${parentSlug}` }); using shardRepo = await env.ARTIFACTS.get(artifactsName); const token = await shardRepo.createToken("write", 3600); // Unconditional writes, not ensureMeta's "only if missing" check -- // confirmed live that ArtifactsRepo.fork carries the zz-meta branch // over from the parent regardless of defaultBranchOnly, so // ensureMeta's existing-content check would see the PARENT's // meta.json/roles.json (wrong owner/description/no shard_of) and // silently no-op, leaving them stale. A freshly created shard always // needs its own values written, never inherited ones. const now = new Date().toISOString(); await writeRepoMeta(created.remote, token, { description: taskText, policy: { require_changeset_review: false, visibility: "public" }, owner: actor.username, shard_of: parentSlug, created_at: now, }); await writeRepoRoles(created.remote, token, { grants: [{ principal: actor.username, role: "owner", granted_by: "system", at: now }] }); const row = await upsertRepo(env.DB, { slug, artifactsName, description: taskText, ownerUsername: actor.username, shardOfSlug: parentSlug }); await syncRepoRoleCache(env.DB, row.slug, [{ principal: actor.username, role: "owner", granted_by: "system", at: row.created_at }]); return row; } async function doRbacGrant(env: WebEnv, actor: Agent, repoSlug: string, principal: string, role: RbacRole): Promise { const repo = await getRepoBySlug(env.DB, repoSlug); if (!repo) throw new ApiError(404, "no such repo"); if (!(await getAgent(env.DB, principal))) throw new ApiError(404, `no such agent "${principal}"`); using artifactsRepo = await env.ARTIFACTS.get(repo.artifacts_name); const token = await artifactsRepo.createToken("write", 3600); const info = await artifactsRepo.info(); const roles = await readRepoRoles(info.remote, token); // No more "or any agent I created" indirection -- peers means the // ACTOR's own standing is what's checked, full stop, same as every // other RBAC check in this codebase (canReadRepo/canPushRepo). if (!repoAtLeast(repoRoleFor(roles, actor.username), "owner")) throw new ApiError(403, `${actor.username} is not an Owner of ${repoSlug}`); const nextGrants = (roles?.grants ?? []).filter((g) => g.principal !== principal); nextGrants.push({ principal, role, granted_by: actor.username, at: new Date().toISOString() }); await writeRepoRoles(info.remote, token, { grants: nextGrants }); await syncRepoRoleCache(env.DB, repo.slug, nextGrants); } /** Removes one grant by its exact stored `principal` value -- doesn't require it to resolve to a current agent, so a stale/orphaned grant can still be cleaned up. */ async function doRevokeGrant(env: WebEnv, actor: Agent, repoSlug: string, principal: string): Promise { const repo = await getRepoBySlug(env.DB, repoSlug); if (!repo) throw new ApiError(404, "no such repo"); using artifactsRepo = await env.ARTIFACTS.get(repo.artifacts_name); const token = await artifactsRepo.createToken("write", 3600); const info = await artifactsRepo.info(); const roles = await readRepoRoles(info.remote, token); if (!repoAtLeast(repoRoleFor(roles, actor.username), "owner")) throw new ApiError(403, `${actor.username} is not an Owner of ${repoSlug}`); const nextGrants = (roles?.grants ?? []).filter((g) => g.principal !== principal); await writeRepoRoles(info.remote, token, { grants: nextGrants }); await syncRepoRoleCache(env.DB, repo.slug, nextGrants); } async function doSetVisibility(env: WebEnv, actor: Agent, repoSlug: string, visibility: "public" | "private"): Promise { const repo = await getRepoBySlug(env.DB, repoSlug); if (!repo) throw new ApiError(404, "no such repo"); using artifactsRepo = await env.ARTIFACTS.get(repo.artifacts_name); const token = await artifactsRepo.createToken("write", 3600); const info = await artifactsRepo.info(); const { meta, roles } = await readRepoMetaAndRoles(info.remote, token); if (!meta) throw new ApiError(404, "repo has no metadata yet"); if (!repoAtLeast(repoRoleFor(roles, actor.username), "owner")) throw new ApiError(403, `${actor.username} is not an Owner of ${repoSlug}`); await writeRepoMeta(info.remote, token, { ...meta, policy: { ...meta.policy, visibility } }); await updateRepoVisibility(env.DB, repo.slug, visibility); } /** Same two-places-at-once shape doSetVisibility already uses (zz-meta's meta.json is the real source of truth; the D1 column is a read-side cache the repo page and every listing/search query actually read from) -- description was previously set once at creation (doCreateRepo) and never editable again. */ async function doSetDescription(env: WebEnv, actor: Agent, repoSlug: string, description: string): Promise { const repo = await getRepoBySlug(env.DB, repoSlug); if (!repo) throw new ApiError(404, "no such repo"); using artifactsRepo = await env.ARTIFACTS.get(repo.artifacts_name); const token = await artifactsRepo.createToken("write", 3600); const info = await artifactsRepo.info(); const { meta, roles } = await readRepoMetaAndRoles(info.remote, token); if (!meta) throw new ApiError(404, "repo has no metadata yet"); if (!repoAtLeast(repoRoleFor(roles, actor.username), "owner")) throw new ApiError(403, `${actor.username} is not an Owner of ${repoSlug}`); await writeRepoMeta(info.remote, token, { ...meta, description }); await updateRepoDescription(env.DB, repo.slug, description); } /** Ownership transfer keeps the repo's URL (slug) unchanged -- the slug is frozen at creation time off the ORIGINAL owner's username, same as a GitHub repo's path staying put after a transfer within the same org would. The new owner gets an explicit "owner" grant in roles.json; the previous owner's own grant is left as-is (not stripped), so transferring doesn't silently revoke their access -- they can demote themselves separately via the roles list if that's what they want. */ async function doTransferOwnership(env: WebEnv, actor: Agent, repoSlug: string, newOwnerUsername: string): Promise { const repo = await getRepoBySlug(env.DB, repoSlug); if (!repo) throw new ApiError(404, "no such repo"); const newOwner = await getAgent(env.DB, newOwnerUsername); if (!newOwner) throw new ApiError(404, "no such agent"); using artifactsRepo = await env.ARTIFACTS.get(repo.artifacts_name); const token = await artifactsRepo.createToken("write", 3600); const info = await artifactsRepo.info(); const { meta, roles } = await readRepoMetaAndRoles(info.remote, token); if (!meta) throw new ApiError(404, "repo has no metadata yet"); if (!repoAtLeast(repoRoleFor(roles, actor.username), "owner")) throw new ApiError(403, `${actor.username} is not an Owner of ${repoSlug}`); await writeRepoMeta(info.remote, token, { ...meta, owner: newOwner.username }); const nextGrants = (roles?.grants ?? []).filter((g) => g.principal !== newOwner.username); nextGrants.push({ principal: newOwner.username, role: "owner", granted_by: actor.username, at: new Date().toISOString() }); await writeRepoRoles(info.remote, token, { grants: nextGrants }); await syncRepoRoleCache(env.DB, repo.slug, nextGrants); await updateRepoOwner(env.DB, repo.slug, newOwner.username); } async function doDeleteRepo(env: WebEnv, actor: Agent, repoSlug: string): Promise { const repo = await getRepoBySlug(env.DB, repoSlug); if (!repo) throw new ApiError(404, "no such repo"); try { using artifactsRepo = await env.ARTIFACTS.get(repo.artifacts_name); const token = await artifactsRepo.createToken("read", 120); const info = await artifactsRepo.info(); const roles = await readRepoRoles(info.remote, token); if (!repoAtLeast(repoRoleFor(roles, actor.username), "owner")) throw new ApiError(403, `${actor.username} is not an Owner of ${repoSlug}`); await env.ARTIFACTS.delete(repo.artifacts_name); } catch (e) { // The Artifacts-side repo being gone already (confirmed live: // "ArtifactsError: Repository not found") means this D1 row is an // orphan -- left over from some earlier delete that removed the // Artifacts repo but failed before clearing this row. There's // nothing left to check roles against via Artifacts in that case, // so fall back to the D1 row's own recorded owner rather than // leaving the orphan permanently stuck. Any other failure // (a real role check, a real network error) surfaces as before. if (e instanceof ApiError) throw e; if (!(e instanceof Error) || !/not found/i.test(e.message)) throw e; if (actor.username !== repo.owner_username) throw new ApiError(403, `${actor.username} is not an Owner of ${repoSlug}`); } await deleteRepoRow(env.DB, repo.slug, repo.artifacts_name); } interface ShardCleanupResult { deleted: number; failed: { slug: string; error: string }[]; } /** Deletes every direct shard of `parentSlug`, and every shard of those shards, recursively (grandchildren before the shard whose children they are, so a shard's own shard_of_slug never points at an already-gone row mid-operation). No permission check of its own -- the caller (doCleanupShards) has already verified ownership of the top-level parent, and every repo reached from here is being torn down wholesale as part of that one decision, not individually authorized. Tolerates a shard whose Artifacts repo is already gone (same idempotency as doDeleteRepo's own orphan handling), since a shard tree can legitimately be mid-cleanup already. * * One shard failing (confirmed live: a transient Artifacts error, gone on retry) used to throw straight out of the loop, silently abandoning every sibling and deeper descendant still left to process -- which is exactly the "cleaned up some, left two behind, no error shown" report this fixes. Each shard's own failure is now caught and collected instead, so the rest of the tree still gets a chance; a shard whose OWN children failed to clean up is left alone too (not deleted out from under them), so nothing gets orphaned with no shard_of_slug parent left to find it through. */ async function deleteShardTreeOf(env: WebEnv, parentSlug: string): Promise { const shards = await listShardsOf(env.DB, parentSlug); let deleted = 0; const failed: { slug: string; error: string }[] = []; for (const shard of shards) { try { const child = await deleteShardTreeOf(env, shard.slug); deleted += child.deleted; failed.push(...child.failed); if (child.failed.length > 0) continue; try { await env.ARTIFACTS.delete(shard.artifacts_name); } catch (e) { if (!(e instanceof Error) || !/not found/i.test(e.message)) throw e; } await deleteRepoRow(env.DB, shard.slug, shard.artifacts_name); deleted += 1; } catch (e) { failed.push({ slug: shard.slug, error: e instanceof Error ? e.message : String(e) }); } } return { deleted, failed }; } /** Owner-of-the-parent is the one check that matters -- every shard being wiped is a scratch workspace that exists only because of this repo's own task delegation, not a separately-owned repo someone else gets a say over. */ async function doCleanupShards(env: WebEnv, actor: Agent, repoSlug: string): Promise { const repo = await getRepoBySlug(env.DB, repoSlug); if (!repo) throw new ApiError(404, "no such repo"); using artifactsRepo = await env.ARTIFACTS.get(repo.artifacts_name); const token = await artifactsRepo.createToken("read", 120); const info = await artifactsRepo.info(); const roles = await readRepoRoles(info.remote, token); if (!repoAtLeast(repoRoleFor(roles, actor.username), "owner")) throw new ApiError(403, `${actor.username} is not an Owner of ${repoSlug}`); return deleteShardTreeOf(env, repoSlug); } function doStub(env: WebEnv, artifactsName: string) { const stub = env.MAINTAINER_DO.get(env.MAINTAINER_DO.idFromName(artifactsName)); return stub; } /** The root ("_repo") Lane's full history -- every ASK/ANSWER posted via doPostAsk or the engine's own protocol. Null if the repo has no root Lane yet (never had an Ask posted to it). Real `Lane` objects throughout (not a trimmed display type): claimableAsks (engine/workqueue.ts) needs the real shape (coordinators AND maintainers), and the DO already hands back exactly that. */ async function getRootLane(env: WebEnv, artifactsName: string): Promise { const stub = doStub(env, artifactsName); await stub.configure(artifactsName); return (await stub.callTool("lane_get", { repo: artifactsName, lane_id: "_repo" })) as Lane | null; } /** Every Lane in the repo, for the "Lanes" tab -- the full flattened cross-lane protocol feed (the "Protocol" tab) and the Work page's claimable-asks query are both derived from these same objects, not a second fetch. */ async function getAllLanes(env: WebEnv, artifactsName: string): Promise { const stub = doStub(env, artifactsName); await stub.configure(artifactsName); return (await stub.callTool("lane_list", { repo: artifactsName })) as Lane[]; } interface ProtocolEvent { laneId: string; entry: HistoryEntry; } function flattenProtocol(lanes: Lane[]): ProtocolEvent[] { const events = lanes.flatMap((lane) => lane.history.map((entry) => ({ laneId: lane.id, entry }))); return events.sort((a, b) => (a.entry.at < b.entry.at ? 1 : -1)); } // Ladder tab: the repo's full Ladder tree, in one ladder_tree call -- // server-side resolved and durably cached (cloudflareBackend.ts's // readLadderTree), invalidated only on an actual Ladder write. // Replaces what used to be a manual, worker-side walk of repeated // ladder_read calls (one per rung): confirmed live as a real // performance bug (every one of those calls re-paid a full git-notes // checkout, and the manual walk couldn't benefit from a cache that // lived across separate stub.callTool invocations). This also means // the web page's own first view of a repo is what warms the cache // the TUI/CLI then benefits from too, and vice versa -- one cache, // whichever side reads first. async function getLadderTree(env: WebEnv, artifactsName: string): Promise { const stub = doStub(env, artifactsName); await stub.configure(artifactsName); return (await stub.callTool("ladder_tree", { repo: artifactsName })) as ResolvedRung | null; } // Fixed display order for the Ladder tab -- children of a node are // accumulated over time in whatever order rungs happened to get // written (a file touched by an early commit, then a changeset from a // much later one, then another file, ...), which reads as shuffled. // Grouping same-kind rungs together (all files, then all changesets, // ...) makes the tab scannable without changing anything about the // underlying tree. const LADDER_KIND_ORDER: Record = { repo: 0, directory: 1, file: 2, package: 3, release: 4, dependencies: 5, contributor: 6, changeset: 7, filechange: 8, function: 9, lines: 10, linechange: 11, ask: 12, answer: 13, }; function renderLadderNode(node: ResolvedRung, depth = 0): string { const indent = "  ".repeat(depth); const self = `
  • ${indent}${esc(node.kind)} ${esc(node.path)} ${esc(node.summary)}
  • `; // An ask's children are a conversation thread (ask/answer/ask/...) // in chronological order -- that sequence is the whole point, so // it's left alone rather than grouped by kind like everything else. const children = node.kind === "ask" ? node.children : [...node.children].sort((a, b) => (LADDER_KIND_ORDER[a.kind] ?? 99) - (LADDER_KIND_ORDER[b.kind] ?? 99)); return [self, ...children.map((c) => renderLadderNode(c, depth + 1))].join(""); } // One rung read directly by its own path -- the commit/file view's // "related Ladder" panel, not the whole-repo Ladder tab (getLadderTree // above). A changeset rung's path IS the commit sha // ("changesets/"), so this is a single direct read, no searching; // same for a file rung (path IS the file's path). Non-ask children // (a changeset's filechange entries, a file's function/line rungs) // come back embedded already -- this doesn't walk further than that, // since it's a small "related" panel, not a full tree resolve. async function readLadderRung(env: WebEnv, artifactsName: string, path: string): Promise { const stub = doStub(env, artifactsName); await stub.configure(artifactsName); return (await stub.callTool("ladder_read", { repo: artifactsName, path })) as EmbeddedRung | null; } function renderLadderDetail(rung: EmbeddedRung, depth = 0): string { const indent = "  ".repeat(depth); const self = `
  • ${indent}${esc(rung.kind)} ${esc(rung.summary)}
  • `; // Ask-thread refs stay unresolved here -- this panel is a quick // "related" summary, not a place to follow conversation threads. const children = rung.children.filter((c): c is EmbeddedRung => !isRef(c)).map((c) => renderLadderDetail(c, depth + 1)); return [self, ...children].join(""); } /** Collapsed-by-default
    , smaller text than the rest of the page -- one rung's own summary, attached directly to the thing it's about (a file, a commit), not a combined dump. */ function summaryDetailHtml(summary: string): string { return `
    Ladder Summary

    ${esc(summary)}

    `; } /** Same, but keeps a rung's own embedded (non-ask) children nested inside it -- only used where there's nowhere more specific yet to attach them (e.g. a file rung's function-level children, until line-anchored summaries exist). A commit's filechange children get attached to their own file row instead (handleCommit), not nested here. */ function ladderDetailHtml(rung: EmbeddedRung | null): string { if (!rung) return ""; return `
    Ladder Summary
      ${renderLadderDetail(rung)}
    `; } // --- Work: every claimable Ask across every repo the viewer can see, // mirroring `zz work`'s own claimableAcrossRepos query -- same pure // engine function (engine/workqueue.ts's claimableAsks), just fed Lane // objects fetched via callTool instead of a local Backend. A repo a // human never set up `~/.zz/config.json` for is still visible here // (and vice versa): this reads from the same viewer-aware repo listing // /explore uses (private repos excluded unless the viewer has a // role), not a client-side config file. interface ClaimableWorkItem { repoSlug: string; laneId: string; ask: AskEntry; } async function getClaimableForViewer(env: WebEnv, viewerUsername: string): Promise { const repos = await listRecentRepos(env.DB, 200, viewerUsername); const out: ClaimableWorkItem[] = []; for (const repo of repos) { const lanes = await getAllLanes(env, repo.artifacts_name); for (const lane of lanes) { for (const ask of claimableAsks(lane, viewerUsername)) { out.push({ repoSlug: repo.slug, laneId: lane.id, ask }); } } } return out; } async function doClaimAsk(env: WebEnv, actor: Agent, repoSlug: string, laneId: string, askId: string): Promise { const repo = await getRepoBySlug(env.DB, repoSlug); if (!repo) throw new ApiError(404, "no such repo"); const stub = doStub(env, repo.artifacts_name); await stub.configure(repo.artifacts_name); await stub.callTool("ask_claim", { repo: repo.artifacts_name, lane_id: laneId, ask_id: askId, actor: actor.username }); } async function handleWorkPage(request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const items = await getClaimableForViewer(env, me.username); const rows = items .map( (it) => `
  • ${esc(it.repoSlug)}${esc(it.laneId)}${esc(it.ask.kind)}
    ${jsonBlock(it.ask.payload)}
  • `, ) .join(""); return layout( "Work", me, `${card( "Claimable", `

    Every open Ask across every repo you can see that you're permitted to act on -- the same query \`zz work\` runs, just for a human clicking instead of an agent looping.

      ${rows || '
    • Nothing claimable right now.
    • '}
    `, )} ${card( "Post an ask to a repo's root lane", `
    `, )}`, ); } function workInstructions(repoSlug: string, laneId: string, askId: string, ask: AskEntry): string { if (ask.kind === "changeset_review") { return `
    zz review --repos ${esc(repoSlug)}

    Walks you through every changeset_review Ask you can approve, one at a time -- approve, request changes, or skip.

    `; } return `
    zz repo clone ${esc(repoSlug)}
    cd ${esc(repoSlug.split("/").pop() ?? "repo")}
    # ...edit files...
    git add <the files you changed>
    zz repo submit ${esc(repoSlug)} ${esc(laneId)} ${esc(askId)} --message "what you did"

    Posts your changes for review and marks this Ask complete -- the same protocol sequence a scripted agent follows (changeset_review + submit_changeset, then a Maintainer's \`zz review\` and a coordinator's \`zz work\` merge it), just with content you wrote instead of generated.

    `; } async function handleWorkClaimForm(request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const form = await request.formData(); const repoSlug = String(form.get("repo_slug") ?? ""); const laneId = String(form.get("lane_id") ?? ""); const askId = String(form.get("ask_id") ?? ""); try { await doClaimAsk(env, me, repoSlug, laneId, askId); } catch (err) { if (err instanceof ApiError) return new Response(err.message, { status: err.status }); throw err; } const repo = await getRepoBySlug(env.DB, repoSlug); const lanes = repo ? await getAllLanes(env, repo.artifacts_name) : []; const resolvedAsk = lanes.find((l) => l.id === laneId)?.history.find((e): e is AskEntry => e.type === "ASK" && e.id === askId); return layout( "Claimed", me, `

    Claimed ${esc(resolvedAsk?.kind ?? "?")} on ${esc(repoSlug)} (lane ${esc(laneId)}).

    ${resolvedAsk ? workInstructions(repoSlug, laneId, askId, resolvedAsk) : '

    Could not load this Ask\'s details -- check the repo page\'s Asks tab.

    '}

    ← Back to Work

    `, ); } async function doPostAsk(env: WebEnv, actor: Agent, repoSlug: string, description: string, decompose?: boolean): Promise { const repo = await getRepoBySlug(env.DB, repoSlug); if (!repo) throw new ApiError(404, "no such repo"); const stub = env.MAINTAINER_DO.get(env.MAINTAINER_DO.idFromName(repo.artifacts_name)); await stub.configure(repo.artifacts_name); // The "_repo" root Lane isn't the same thing as the Ladder's "_repo" // root rung (ensureMeta/writeRung territory) -- it's a real Lane, // which doCreateRepo never bootstrapped (confirmed live: posting an // ask to a repo created through the web/API threw "no such lane: // _repo"). Lazily create it here, the same bootstrap // demo/greenfield.ts's local flow always did, so this self-heals for // every repo already created before this fix too, not just new ones. const existingLane = await stub.callTool("lane_get", { repo: repo.artifacts_name, lane_id: "_repo" }); if (!existingLane) { await stub.callTool("lane_propose", { repo: repo.artifacts_name, id: "_repo", name: repo.slug, objective: `the ${repo.slug} repository`, scope: ["**"], actor: repo.owner_username }); } await stub.callTool("ask_post", { repo: repo.artifacts_name, lane_id: "_repo", kind: "work", from: actor.username, payload: { description, ...(decompose ? { decompose: true } : {}) } }); } // --- Agent profile: / is BOTH the public view (anyone) and // the private, editable view (when you're logged in as that agent) -- // no separate /profile route. See migration 0003's header for why // `username` is a frozen, stored column rather than recomputed. async function handleAgentPage(username: string, request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); const pageAgent = await getAgent(env.DB, username); if (!pageAgent) return new Response("no such agent", { status: 404 }); if (me && me.username === pageAgent.username) return renderOwnProfile(me, env); return renderPublicProfile(pageAgent, request, env); } async function renderPublicProfile(agent: Agent, request: Request, env: WebEnv): Promise { const owned = await listReposOwnedBy(env.DB, agent.username); const visible: RepoRow[] = []; // Always a different viewer than `agent` here (handleAgentPage // routes you to renderOwnProfile for your own page instead), so an // ingesting repo never belongs on this list -- same reasoning // accountsDb.ts's INGEST_VISIBLE_CLAUSE gates search/listing with. for (const r of owned) { if (r.ingest_status === "ingesting") continue; const ctx = await getRepoAccessContext(r.slug, env); if (ctx && (await canReadRepo(request, env, ctx.meta, ctx.roles))) visible.push(r); } return layout( agent.username, await resolveRequestAgent(request, env), `${card( "Repos", `
      ${ shardGroupedListHtml( visible, (r) => r.slug, (r) => r.shard_of_slug, (r) => `${esc(r.slug)}${r.description ? `
      ${autolinkText(r.description)}
      ` : ""}`, ) || '
    • No public repos.
    • ' }
    `, )}`, ); } async function renderOwnProfile(me: Agent, env: WebEnv): Promise { const createdAgents = await listAgentsCreatedBy(env.DB, me.username); const allSelf = [me, ...createdAgents]; const tokenLists = await Promise.all(allSelf.map((a) => listTokensForAgent(env.DB, a.username))); // One flat, unified list across you and every agent you manage -- // no more "your tokens" vs "managed agents' tokens" as two separate // sub-UIs (per direct instruction: token management should not // distinguish you from the agents you manage). const tokenRows = allSelf .flatMap((a, i) => tokenLists[i].filter((t) => !t.revoked_at).map((t) => ({ agent: a, token: t }))) .map( ({ agent, token }) => `
  • ${esc(agent.username)} ${esc(token.id)} ${token.last_used_at ? `last used ${esc(token.last_used_at)}` : "never used"}
  • `, ) .join(""); // Owned, role-granted, and merely-tracked repos, already merged and // role-sorted in one place (listMyRepos) -- also backs GET // /api/me/repos, so this page and the TUI's "start from your repos // when logged in" read the exact same list. const repoRows = await listMyRepos(env.DB, me.username); return layout( `${me.username} (you)`, me, `${card( "Credentials", `

    You, and every agent you manage, share one token list -- not "yours" vs "theirs" in any authority sense, just one place to issue, rotate, or revoke whatever can authenticate as any of them.

      ${tokenRows || '
    • No active tokens.
    • '}
    `, { addModal: "modal-add-token" }, )} ${card( "Repos", `
      ${ shardGroupedListHtml( repoRows, ({ repo }) => repo.slug, ({ repo }) => repo.shard_of_slug, ({ repo, role }) => `${esc(repo.slug)}${roleBadge(role)}`, ) || '
    • None yet.
    • ' }
    `, { addModal: "modal-create-repo" }, )} `, ); } /** A single value covers both the earlier two actions (mint a token for an existing agent, or create a brand-new managed agent and mint its first token) -- matches the unified "+" modal: the username of an agent you already manage mints; anything else creates. */ async function handleTokenAddForm(request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const form = await request.formData(); const value = String(form.get("agent") ?? "").trim(); try { const existing = await getAgent(env.DB, value); const { agent, token } = existing ? await doMintToken(env, me, existing.username) : await doCreateAgent(env, me, value); const zzId = `${agent.username}:${token}`; return layout( "New token", me, `

    Token for ${esc(agent.username)} -- copy it now, it won't be shown again:

    ${esc(token)}

    ← Back to profile

    ${card( "Run zz work as this identity", `

    From the same machine's \`zz login\` session, you don't need this at all -- zz work --as ${esc(value)} mints its own short-lived token automatically and revokes it on exit. This one's for a separate machine or a headless run with no login session:

    ZZ_ID="${esc(zzId)}"
    `, )}`, ); } catch (err) { if (err instanceof ApiError) return new Response(err.message, { status: err.status }); throw err; } } async function handleRevokeTokenForm(tokenId: string, request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const token = await getAgentToken(env.DB, tokenId); if (token) { const owner = await getAgent(env.DB, token.agent_username); if (owner && (owner.username === me.username || owner.created_by === me.username)) await revokeAgentToken(env.DB, tokenId); } return redirect(`/${me.username}`); } async function handleRbacGrantForm(repoSlug: string, request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const form = await request.formData(); try { await doRbacGrant(env, me, repoSlug, String(form.get("principal") ?? ""), String(form.get("role") ?? "viewer") as RbacRole); } catch (err) { if (err instanceof ApiError) return new Response(err.message, { status: err.status }); throw err; } return redirect(`/${repoSlug}/settings`); } async function handleRbacRevokeForm(repoSlug: string, request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const form = await request.formData(); try { await doRevokeGrant(env, me, repoSlug, String(form.get("principal") ?? "")); } catch (err) { if (err instanceof ApiError) return new Response(err.message, { status: err.status }); throw err; } return redirect(`/${repoSlug}/settings`); } async function handleCreateRepoForm(request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const form = await request.formData(); try { const repo = await doCreateRepo(env, me, String(form.get("name") ?? "").trim(), String(form.get("description") ?? "").trim() || null, String(form.get("license") ?? "").trim() || null); return redirect(`/${repo.slug}`); } catch (err) { if (err instanceof ApiError) return new Response(err.message, { status: err.status }); throw err; } } async function handleAskForm(request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const form = await request.formData(); try { await doPostAsk(env, me, String(form.get("repo_slug") ?? ""), String(form.get("description") ?? "")); } catch (err) { if (err instanceof ApiError) return new Response(err.message, { status: err.status }); throw err; } return redirect("/work"); } async function handleVisibilityForm(repoSlug: string, request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const form = await request.formData(); try { await doSetVisibility(env, me, repoSlug, String(form.get("visibility") ?? "public") as "public" | "private"); } catch (err) { if (err instanceof ApiError) return new Response(err.message, { status: err.status }); throw err; } return redirect(`/${repoSlug}/settings`); } async function handleDescriptionForm(repoSlug: string, request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const form = await request.formData(); try { await doSetDescription(env, me, repoSlug, String(form.get("description") ?? "").trim()); } catch (err) { if (err instanceof ApiError) return new Response(err.message, { status: err.status }); throw err; } return redirect(`/${repoSlug}/settings`); } async function handleTransferForm(repoSlug: string, request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const form = await request.formData(); try { await doTransferOwnership(env, me, repoSlug, String(form.get("new_owner") ?? "").trim()); } catch (err) { if (err instanceof ApiError) return new Response(err.message, { status: err.status }); throw err; } return redirect(`/${repoSlug}/settings`); } async function handleDeleteRepoForm(repoSlug: string, request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); try { await doDeleteRepo(env, me, repoSlug); } catch (err) { if (err instanceof ApiError) return new Response(err.message, { status: err.status }); throw err; } return redirect(`/${me.username}`); } async function handleCleanupShardsForm(repoSlug: string, request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); let result: ShardCleanupResult; try { result = await doCleanupShards(env, me, repoSlug); } catch (err) { if (err instanceof ApiError) return new Response(err.message, { status: err.status }); throw err; } // Surfaced as plain text instead of the usual clean redirect -- // a partial failure silently redirecting as if it fully succeeded // is exactly what made the underlying bug this fixes so confusing // to notice in the first place. Whatever's left can be retried by // clicking the button again. if (result.failed.length > 0) { const lines = result.failed.map((f) => ` ${f.slug}: ${f.error}`).join("\n"); return new Response(`Deleted ${result.deleted} shard(s), but ${result.failed.length} failed and are still there -- try again:\n\n${lines}`, { status: 207, headers: { "content-type": "text/plain; charset=utf-8" } }); } return redirect(`/${repoSlug}/settings`); } /** Track/untrack a repo -- any logged-in agent, no RBAC check at all (not a grant, just a personal bookmark -- see migration 0011's header). */ async function handleTrackForm(repoSlug: string, tracking: boolean, request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const repo = await getRepoBySlug(env.DB, repoSlug); if (!repo) return new Response("no such repo", { status: 404 }); if (tracking) await trackRepo(env.DB, me.username, repoSlug); else await untrackRepo(env.DB, me.username, repoSlug); return redirect(`/${repoSlug}`); } // --- JSON API (same core logic, bearer-token or session authenticated, for the CLI and any script) --- async function requireApiAgent(request: Request, env: WebEnv): Promise { const agent = await resolveRequestAgent(request, env); if (!agent) throw new ApiError(401, "authentication required (Authorization: Bearer )"); return agent; } async function withApi(fn: () => Promise): Promise { try { return await fn(); } catch (err) { if (err instanceof ApiError) return json({ error: err.message }, err.status); throw err; } } async function handleApiMe(request: Request, env: WebEnv): Promise { return withApi(async () => json(await requireApiAgent(request, env))); } /** Owned + role-granted + tracked repos, role-sorted -- the same listMyRepos the profile page renders, as JSON. Backs the TUI's "start from your own repos when logged in" (zz top, once it goes past the local/remote plumbing fixed in this pass). */ async function handleApiMyRepos(request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await requireApiAgent(request, env); const rows = await listMyRepos(env.DB, me.username); return json({ repos: rows.map(({ repo, role }) => ({ slug: repo.slug, role })) }); }); } /** Same trackRepo/untrackRepo handleTrackForm already calls for the web UI's own button -- a JSON sibling since that one only ever returns an HTML redirect, which the CLI/TUI's Explore "track" action (zz top) needs a real {ok:true}/{error} response from instead. */ async function handleApiTrack(repoSlug: string, tracking: boolean, request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await requireApiAgent(request, env); const repo = await getRepoBySlug(env.DB, repoSlug); if (!repo) throw new ApiError(404, "no such repo"); if (tracking) await trackRepo(env.DB, me.username, repoSlug); else await untrackRepo(env.DB, me.username, repoSlug); return json({ ok: true }); }); } async function handleApiCreateAgent(request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await requireApiAgent(request, env); const body = (await request.json()) as { display_name?: string }; const { agent, token } = await doCreateAgent(env, me, body.display_name ?? ""); return json({ ...agent, token }, 201); }); } async function handleApiListAgents(request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await requireApiAgent(request, env); return json({ you: me, created: await listAgentsCreatedBy(env.DB, me.username) }); }); } /** Autocomplete backing for any "which agent" field (add-token, grant-role, transfer-ownership) -- searches every Agent known to zz, not just ones the caller created. The server-side authority check for what you're actually trying to DO with the chosen agent (mint a token, grant a role) still applies at the real action endpoint; this just helps you find the right username. */ async function handleApiSearchAgents(request: Request, env: WebEnv): Promise { return withApi(async () => { await requireApiAgent(request, env); const q = new URL(request.url).searchParams.get("q")?.trim() ?? ""; if (!q) return json({ agents: [] }); return json({ agents: await searchAgents(env.DB, q, 10) }); }); } async function handleApiMintToken(username: string, request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await requireApiAgent(request, env); const { agent, token } = await doMintToken(env, me, username); return json({ agent, token }, 201); }); } // Revokes whichever token authenticated THIS request -- no id needed // client-side, which is exactly the point: a CLI session that just // minted a token for one `--as ` session (lib/identity.ts) can // clean up after itself at exit using nothing but the token it // already has in hand, with no separate id to have tracked. Scoped to // "your own" by construction (the bearer token IS the identity being // revoked), so no additional authority check is needed the way // handleRevokeTokenForm's id-based version needs (owner === you, or // you created it). async function handleApiRevokeSelf(request: Request, env: WebEnv): Promise { return withApi(async () => { const bearer = readBearerToken(request); if (!bearer) throw new ApiError(401, "no bearer token presented"); const token = await findActiveAgentToken(env.DB, hashAgentToken(bearer)); if (!token) throw new ApiError(404, "token not found or already revoked"); await revokeAgentToken(env.DB, token.id); return json({ ok: true }); }); } async function handleApiCreateRepo(request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await requireApiAgent(request, env); const body = (await request.json()) as { name?: string; description?: string; owner_username?: string; license?: string }; const owner = !body.owner_username || body.owner_username === me.username ? me : await getAgent(env.DB, body.owner_username); if (!owner || (owner.username !== me.username && owner.created_by !== me.username)) throw new ApiError(403, "not yours to create repos as"); const repo = await doCreateRepo(env, owner, (body.name ?? "").trim(), body.description?.trim() || null, body.license ?? null); return json(repo, 201); }); } async function handleApiListRepos(request: Request, env: WebEnv): Promise { const q = new URL(request.url).searchParams.get("q")?.trim() ?? ""; const viewer = await resolveRequestAgent(request, env); const repos = q ? await searchRepos(env.DB, q, 50, viewer?.username ?? null) : await listRecentRepos(env.DB, 50, viewer?.username ?? null); return json({ repos }); } async function handleApiGetRepo(slug: string, env: WebEnv): Promise { return withApi(async () => { const ctx = await getRepoAccessContext(slug, env); if (!ctx) throw new ApiError(404, "no such repo"); return json({ repo: ctx.repoRow, meta: ctx.meta, roles: ctx.roles }); }); } /** Token-authenticated sibling of handleDeleteRepoForm's own session-cookie flow (the repo settings page's delete button) -- same doDeleteRepo, same Owner check and orphan-row fallback, just reachable from the CLI (`zz repo delete`) instead of only a browser session. */ async function handleApiDeleteRepo(slug: string, request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await requireApiAgent(request, env); await doDeleteRepo(env, me, slug); return json({ ok: true }); }); } /** `zz ladder push`'s own mark-ingesting/mark-ready calls, bracketing a bulk import or (later) an incremental sync run -- see accountsDb.ts's RepoRow.ingest_status for what this gates. A plain owner_username check, not the Artifacts-roles round trip doDeleteRepo makes for a real delete -- this is reversible and low-stakes (worst case, a repo shows up in search a little early or a little late), so the D1 row's own recorded owner is authority enough. */ async function handleApiSetIngestStatus(slug: string, request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await requireApiAgent(request, env); const repo = await getRepoBySlug(env.DB, slug); if (!repo) throw new ApiError(404, "no such repo"); if (repo.owner_username !== me.username) throw new ApiError(403, `${me.username} is not the owner of ${slug}`); const body = (await request.json()) as { status?: string }; if (body.status !== "ready" && body.status !== "ingesting") throw new ApiError(400, 'status must be "ready" or "ingesting"'); await setIngestStatus(env.DB, slug, body.status); return json({ ok: true }); }); } async function handleApiGetAsks(slug: string, env: WebEnv): Promise { return withApi(async () => { const ctx = await getRepoAccessContext(slug, env); if (!ctx) throw new ApiError(404, "no such repo"); const lane = await getRootLane(env, ctx.repoRow.artifacts_name); return json({ lane }); }); } // Generic passthrough to MaintainerDO.callTool -- the same mechanism // the web app's own doPostAsk/doRbacGrant etc. use internally, just // exposed directly so `zz work` (CLI) can drive the real protocol // (ask_claim, ask_answer, commit_changeset, ladder_write_rung, ...) // against a remote repo without the CLI needing its own copy of any // of that logic. No repo-level RBAC gate here beyond "authenticated as // someone real" -- every tool already enforces its own Lane-level // rules (coordinators/maintainers/ASK_SCOPE) server-side, the same as // it does called from the web app or MaintainerDO's existing routes. // The one thing checked here: args.actor/args.from, when present, // must equal the authenticated caller's own username -- otherwise a // valid token for Agent A could act as Agent B just by naming it in // the payload, which no tool-level check catches (they trust the // caller-supplied actor/from field completely, same as the local CLI // always has). // // ANON_READ_TOOLS: the two tools zz top's own views call (lane_list, // ladder_tree) are allowed with no auth at all, for a public repo -- // matching a real, already-existing capability, not a new one: the // web repo page itself (handleRepoPage's getLadderTree/getRootLane) // already shows this exact content to anonymous visitors by calling // stub.callTool() directly, outside this JSON layer entirely. Without // this, zz top's "remote, not logged in" mode (the whole point of // Explore's anonymous search -- see top.ts's own header) could track // a public repo but never actually see it: every lane_list/ladder_tree // poll came back 401 and was silently swallowed by useLaneSnapshots/ // useLadderTrees's own "not reachable yet" catch, which looked // indistinguishable from "nothing to show" -- confirmed live. Nothing // else changes: a private repo still requires a real, granted Role // (not just "logged in as somebody," see the private-repo check // below), and commit_changeset/ladder_write_rung still require // Maintainer+ regardless of visibility (COORDINATOR_ONLY_REMOTE_TOOLS, // below) -- both enforced here now, not just by the CLI's own choice // of which tools to hand a model. const ANON_READ_TOOLS = new Set(["lane_list", "ladder_tree"]); // These two write committed content directly, with no further // per-call authority check of their own the way claim/answer have // (engine/lane.ts's answer() already checks lane.coordinators/ // maintainers at the protocol level) -- held here to the same // Maintainer-or-above bar canPushRepo already holds a raw git push to, // since a direct commit_changeset bypasses the per-changeset review a // push would otherwise go through. Previously enforced only by zz // work's own choice of which tools to hand the model // (COORDINATOR_ONLY_TOOLS, agent/tools.ts) -- a direct API call // bypassed that entirely. const COORDINATOR_ONLY_REMOTE_TOOLS = new Set(["commit_changeset", "ladder_write_rung"]); async function handleApiCallTool(slug: string, toolName: string, request: Request, env: WebEnv): Promise { return withApi(async () => { const repo = await getRepoBySlug(env.DB, slug); if (!repo) throw new ApiError(404, "no such repo"); const canAnonRead = repo.visibility === "public" && ANON_READ_TOOLS.has(toolName); const me = canAnonRead ? await resolveRequestAgent(request, env) : await requireApiAgent(request, env); // Private repo: being logged in as *somebody* was never the real // bar -- canReadRepo already holds every other read surface (the // web repo page, git clone/fetch) to "has an actual Role grant," // and this JSON tool-call layer was the one place that fact didn't // apply, letting any authenticated account read a private repo's // Lane/Ladder/file content (or call a write tool on it) through // here regardless of Role. if (repo.visibility === "private") { const role = me ? await getRepoRoleForPrincipal(env.DB, repo.slug, me.username) : null; if (!role) throw new ApiError(403, `${me?.username ?? "anonymous"} has no Role on private repo ${slug}`); } if (COORDINATOR_ONLY_REMOTE_TOOLS.has(toolName)) { const role = me ? await getRepoRoleForPrincipal(env.DB, repo.slug, me.username) : null; if (!role || !repoAtLeast(role as RbacRole, "maintainer")) { throw new ApiError(403, `${me?.username ?? "anonymous"} is not a Maintainer of ${slug} -- "${toolName}" requires at least Maintainer`); } } const body = (await request.json().catch(() => ({}))) as Record; for (const field of ["actor", "from"]) { if (field in body && me && body[field] !== me.username) throw new ApiError(403, `${field} must be your own username (${me.username}) -- a token only acts as the agent it belongs to`); } const stub = env.MAINTAINER_DO.get(env.MAINTAINER_DO.idFromName(repo.artifacts_name)); await stub.configure(repo.artifacts_name); try { const result = await stub.callTool(toolName, { ...body, repo: repo.artifacts_name }); return json({ result }); } catch (err) { throw new ApiError(400, err instanceof Error ? err.message : String(err)); } }); } async function handleApiRbacGrant(slug: string, request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await requireApiAgent(request, env); const body = (await request.json()) as { principal?: string; role?: RbacRole }; if (!body.principal || !body.role) throw new ApiError(400, "principal and role are required"); await doRbacGrant(env, me, slug, body.principal, body.role); return json({ ok: true }); }); } async function handleApiShard(slug: string, request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await requireApiAgent(request, env); const body = (await request.json()) as { task?: string }; if (!body.task?.trim()) throw new ApiError(400, "task is required"); const row = await ensureShard(env, slug, me, body.task.trim()); return json(row, 201); }); } async function handleApiPostAsk(slug: string, request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await requireApiAgent(request, env); const body = (await request.json()) as { description?: string; decompose?: boolean }; if (!body.description) throw new ApiError(400, "description is required"); await doPostAsk(env, me, slug, body.description, body.decompose); return json({ ok: true }); }); } // --- Explore (list + search, one route) --- async function handleApiExploreSearch(request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); const q = new URL(request.url).searchParams.get("q")?.trim() ?? ""; if (!q) return json({ repos: [], agents: [] }); return json(await searchExplore(env.DB, q, 8, me?.username ?? null)); } // --- Ladder search: "ecosystem-wide intelligence" you can actually // query, not just generate. Deliberately NOT a per-repo tool routed // through one repo's MaintainerDO (every other tool is) -- this reads // across every repo at once, which is a D1 concern (the one place // that's already cross-repo infrastructure, same as searchRepos/ // searchExplore), not a single repo's Ladder/git-notes concern. async function handleApiLadderSearch(request: Request, env: WebEnv): Promise { return withApi(async () => { const me = await resolveRequestAgent(request, env); const q = new URL(request.url).searchParams.get("q")?.trim() ?? ""; if (!q) return json({ hits: [] }); const limit = Math.min(Number(new URL(request.url).searchParams.get("limit")) || 10, 25); const hits = await searchLadderIndex(env.DB, q, limit, me?.username ?? null); return json({ hits }); }); } function repoRowHtml(r: RepoRow): string { // Only ever reached here for your own repo (everyone else's ingesting // repos are already filtered out of every listing this renders into -- // see accountsDb.ts's INGEST_VISIBLE_CLAUSE and renderPublicProfile's // own filter), so this is purely "here's one of mine still importing", // not a leak of someone else's in-progress state. const ingestBadge = r.ingest_status === "ingesting" ? ` ⏳ ingesting` : ""; return `REPO ${esc(r.slug)}${ingestBadge}${r.description ? ` ${autolinkText(r.description)}` : ""}`; } function repoResultHtml(r: RepoRow): string { return `
  • ${repoRowHtml(r)}
  • `; } interface ShardNode { item: T; children: ShardNode[]; } /** Groups a flat row list into a parent -> shard tree by walking shard_of_slug pointers (a shard's parent can itself be a shard, so this nests arbitrarily deep). A row whose parent isn't in this same list -- the viewer can't see it, or it's just not part of this particular listing -- is treated as top-level, since there's nothing here to nest it under. */ function groupByShard(rows: T[], slugOf: (t: T) => string, parentSlugOf: (t: T) => string | null): ShardNode[] { const slugs = new Set(rows.map(slugOf)); const childrenOf = new Map(); const roots: T[] = []; for (const r of rows) { const parent = parentSlugOf(r); if (parent && slugs.has(parent)) { const list = childrenOf.get(parent) ?? []; list.push(r); childrenOf.set(parent, list); } else { roots.push(r); } } const build = (item: T): ShardNode => ({ item, children: (childrenOf.get(slugOf(item)) ?? []).map(build) }); return roots.map(build); } /** A repo listing with shard repos nested -- collapsed by default -- under the repo they were forked from, instead of sitting flat alongside it. Shared by both profile pages and Explore's repo results, since a shard is exactly as real a repo as its parent in either listing, just one that belongs grouped rather than browsed as a peer. */ function shardGroupedListHtml(rows: T[], slugOf: (t: T) => string, parentSlugOf: (t: T) => string | null, renderRow: (t: T) => string): string { const render = (nodes: ShardNode[]): string => nodes .map((n) => { const nested = n.children.length ? `
    ${n.children.length} shard${n.children.length === 1 ? "" : "s"}
      ${render(n.children)}
    ` : ""; return `
  • ${renderRow(n.item)}
    ${nested}
  • `; }) .join(""); return render(groupByShard(rows, slugOf, parentSlugOf)); } /** Every descendant shard of `rootSlug`, at any depth, as one flat list (not including rootSlug itself) -- feeds shardGroupedListHtml for the repo page's own "Shards" panel, same as the already-flat listReposOwnedBy/searchExplore results feed it for profile pages and Explore. */ async function listShardTreeFlat(env: WebEnv, rootSlug: string): Promise { const direct = await listShardsOf(env.DB, rootSlug); const deeper = await Promise.all(direct.map((s) => listShardTreeFlat(env, s.slug))); return [...direct, ...deeper.flat()]; } function agentResultHtml(a: Agent): string { return `
  • USER ${esc(a.username)}${a.display_name && a.display_name !== a.username ? ` ${esc(a.display_name)}` : ""}
  • `; } // A Ladder hit is a fundamentally different shape than a repo/user // match: it's not "here's a thing", it's "here's something SAID about // a specific file, anchored to a specific commit" -- so it gets both // links (file content as it is now, and the commit that summary is // actually anchored to), not just one link like the other two types. function ladderResultHtml(h: LadderSearchHit): string { const fileLink = `${esc(h.path)}`; const commitLink = h.commit_sha ? ` -- ${esc(h.commit_sha.slice(0, 8))}` : ""; return `
  • LADDER ${esc(h.summary)}
    in ${esc(h.repo_slug)}: ${fileLink}${commitLink}
  • `; } async function handleExplore(request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); const q = new URL(request.url).searchParams.get("q")?.trim() ?? ""; let bodyHtml: string; if (!q) { // No query: the MRU repo browse list, unchanged -- heterogeneous // results are a SEARCH behavior, not something to force onto // plain browsing where "recently active repos" is the actually // useful default view. const repos = await listRecentRepos(env.DB, 50, me?.username ?? null); bodyHtml = `
      ${shardGroupedListHtml(repos, (r) => r.slug, (r) => r.shard_of_slug, repoRowHtml) || '
    • No repos yet.
    • '}
    `; } else { const { repos, agents, ladderHits } = await searchExplore(env.DB, q, 25, me?.username ?? null); const rows = [shardGroupedListHtml(repos, (r) => r.slug, (r) => r.shard_of_slug, repoRowHtml), ...agents.map(agentResultHtml), ...ladderHits.map(ladderResultHtml)]; bodyHtml = `
      ${rows.join("") || '
    • No matches.
    • '}
    `; } return layout( "Explore", me, `
    ${bodyHtml}`, ); } // --- Repo page --- interface TreeEntry { name: string; hash: string; type: string; mode: string; } /** log/readTree/readFile all resolve a branch, tag, or commit sha directly as `ref` -- but not the literal "HEAD", which a caller might reasonably construct when there's no specific commit to point at (the ladder-search autocomplete below does exactly this, having no sha of its own to use). Normalizes that one case to the repo's real default branch name; anything else passes through untouched. */ async function resolveRefName(repo: ArtifactsRepo, ref: string): Promise { if (ref !== "HEAD") return ref; const info = await repo.info(); return info.defaultBranch; } /** repo.readTree only ever returns one level -- this walks one path segment at a time to list a subdirectory's own entries, returning null if any segment along the way is missing or isn't itself a directory. `path` of "" returns `rootEntries` unchanged (the repo root itself needs no walk). */ async function readTreeAtPath(repo: ArtifactsRepo, rootEntries: TreeEntry[], path: string): Promise { if (!path) return rootEntries; let entries = rootEntries; for (const segment of path.split("/")) { const entry = entries.find((e) => e.name === segment); if (!entry || entry.type !== "tree") return null; entries = (await repo.readTree(entry.hash)) ?? []; } return entries; } /** Shared by handleRepoPage's "Files" tab, handleCommit's tree, and handleTree below, so a directory link added to one always matches the others' own route. `ref` is whatever the caller is already browsing at -- a real commit sha (handleCommit's own tree, pinned to that commit) or the default branch name (handleRepoPage's "Files" tab, i.e. HEAD) -- carried into both the file and directory links so clicking through stays pinned to the same point in history rather than silently jumping to HEAD. Previously a directory rendered as a plain unlinked ; that's the "can't navigate into a directory" gap this fixes. */ function fileListRowsHtml(slug: string, ref: string, basePath: string, entries: TreeEntry[], summaryFor?: (name: string) => string | undefined): string { return entries .map((e) => { const fullPath = basePath ? `${basePath}/${e.name}` : e.name; const href = e.type === "tree" ? `/${esc(slug)}/tree/${esc(ref)}/${esc(fullPath)}` : `/${esc(slug)}/blob/${esc(ref)}/${esc(fullPath)}`; const label = `${esc(e.name)}`; const summary = summaryFor?.(e.name); return summary ? `
  • ${e.type === "tree" ? "📁" : "📄"} ${label}
    ${summaryDetailHtml(summary)}
  • ` : `
  • ${e.type === "tree" ? "📁" : "📄"} ${label}
  • `; }) .join(""); } /** The repo-name link plus one clickable crumb per path segment, each pointing at that ancestor directory's own /tree// -- shared by handleTree (every segment, including the last, links to itself -- harmless, and keeps the breadcrumb's own build simpler) and handleBlob (the last segment is the file name, not a directory, so it renders as plain text instead). */ function pathBreadcrumbHtml(slug: string, ref: string, path: string, lastIsLink: boolean): string { const segments = path.split("/").filter(Boolean); const crumbs = [`${esc(slug)}`]; let acc = ""; segments.forEach((seg, i) => { acc = acc ? `${acc}/${seg}` : seg; const isLast = i === segments.length - 1; crumbs.push(isLast && !lastIsLink ? `${esc(seg)}` : `${esc(seg)}`); }); return crumbs.join(" / "); } /** The "Files" tab's own tree, one directory deeper -- GitHub's /tree/ equivalent, always at HEAD (no ref segment in the path, same convention handleBlob already uses). */ /** `ref` is a branch name, tag, or commit sha -- repo.log/readTree/readFile all resolve any of the three directly, so a tree reached from a historical commit's own page stays pinned to that commit rather than jumping to HEAD, and the ref lives in the URL path (not a query param) so the page itself is the permalink. */ async function handleTree(slug: string, ref: string, path: string, request: Request, env: WebEnv): Promise { const ctx = await getRepoAccessContext(slug, env); if (!ctx) return new Response("no such repo", { status: 404 }); if (!(await canReadRepo(request, env, ctx.meta, ctx.roles))) return new Response("no such repo", { status: 404 }); const me = await resolveRequestAgent(request, env); using repo = await env.ARTIFACTS.get(ctx.repoRow.artifacts_name); ref = await resolveRefName(repo, ref); const [head] = await repo.log({ ref, limit: 1 }); if (!head) return new Response("no such ref", { status: 404 }); const rootEntries: TreeEntry[] = (await repo.readTree(head.treeHash)) ?? []; const entries = await readTreeAtPath(repo, rootEntries, path); if (!entries) return new Response("no such directory", { status: 404 }); return layout( path || slug, me, `

    ${pathBreadcrumbHtml(slug, ref, path, true)}

    at ${esc(head.hash.slice(0, 8))}

      ${fileListRowsHtml(slug, ref, path, entries) || '
    • Empty directory.
    • '}
    `, { titleHtml: ` \xb7 ${esc(slug)}` }, ); } async function findReadme(repo: ArtifactsRepo, tree: TreeEntry[]): Promise { const entry = tree.find((e) => /^readme(\.md)?$/i.test(e.name)); if (!entry) return null; const blob = await repo.readBlob(entry.hash); return blob ? blob.text() : null; } async function walkTreeForZip(repo: ArtifactsRepo, treeHash: string, prefix: string, out: Record): Promise { const entries = (await repo.readTree(treeHash)) ?? []; for (const entry of entries) { const path = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.type === "tree") { await walkTreeForZip(repo, entry.hash, path, out); } else { const blob = await repo.readBlob(entry.hash); if (blob) out[path] = new Uint8Array(await blob.arrayBuffer()); } } } // Loads what every read path needs to decide access + render: the D1 // row plus meta.json/roles.json off zz-meta. A second, separate // `env.ARTIFACTS.get()` happens in each handler afterward for the // actual content read -- an extra token mint per request, traded for // every read path (page, blob, archive, the git proxy below) sharing // one access-control check instead of several that could drift out // of sync. // This is the one function every repo-viewing route (handleRepoPage, // handleBlob, handleCommit, handleArchive, handleDownload, ...) calls // first, so its own cost sets the floor for all of them. It used to // do THREE separate full git fetches of the same zz-meta branch on // every single call: one hidden inside ensureMeta's own existence // check (readRepoMeta internally, to decide whether to no-op), plus // two more for meta/roles themselves. For a repo that's already been // viewed once (i.e. almost always), only the third pair was ever // doing real work -- now it's one fetch total, and ensureMeta (a real // write, only for a repo's actual first-ever access) only runs on the // rare path where that one read comes back empty. async function getRepoAccessContext(slug: string, env: WebEnv): Promise<{ repoRow: RepoRow; meta: Awaited>; roles: Awaited> } | null> { const repoRow = await getRepoBySlug(env.DB, slug); if (!repoRow) return null; using repo = await env.ARTIFACTS.get(repoRow.artifacts_name); const token = await repo.createToken("read", 300); const info = await repo.info(); let { meta, roles } = await readRepoMetaAndRoles(info.remote, token); if (!meta) { await ensureMeta(info.remote, token, { description: repoRow.description ?? "", owner: repoRow.owner_username }); ({ meta, roles } = await readRepoMetaAndRoles(info.remote, token)); } return { repoRow, meta, roles }; } // Git has no session cookie -- it authenticates over Basic Auth on // the smart-HTTP request itself, resolving to the same bearer-token // path the JSON API uses (requireApiAgent/resolveRequestAgent), just // read from the Basic Auth slot instead of an Authorization: Bearer // header. GitHub's own convention is the token as username with a // blank password, so that's checked first, but either slot works here. function parseBasicAuth(request: Request): { username: string; password: string } | null { const header = request.headers.get("authorization"); if (!header?.startsWith("Basic ")) return null; try { const decoded = atob(header.slice(6)); const idx = decoded.indexOf(":"); if (idx === -1) return null; return { username: decoded.slice(0, idx), password: decoded.slice(idx + 1) }; } catch { return null; } } async function resolveGitAuthPrincipal(request: Request, env: WebEnv): Promise { const basic = parseBasicAuth(request); if (!basic) return null; const candidate = basic.username || basic.password; if (!candidate) return null; const token = await findActiveAgentToken(env.DB, hashAgentToken(candidate)); if (!token) return null; await touchAgentToken(env.DB, token.id); return token.agent_username; } // Public repos (the default, and everything created so far): open to // anyone, same as today. Private repos: a repo needs an explicit // grant (any role, including "viewer") for the signed-in Agent (web // session) or the Agent resolved from a Basic Auth / bearer token // (git CLI, API clients) -- one check, one principal, no "or any of // my agents" indirection now that every Agent's standing is its own. async function canReadRepo(request: Request, env: WebEnv, meta: Awaited>, roles: Awaited>): Promise { if (meta?.policy.visibility !== "private") return true; const me = await resolveRequestAgent(request, env); const gitPrincipal = await resolveGitAuthPrincipal(request, env); const principals = [me?.username, gitPrincipal].filter((p): p is string => !!p); if (principals.length === 0) return false; return principals.some((p) => (roles?.grants ?? []).some((g) => g.principal === p)); } // Push always requires real, resolved authentication -- never // anonymous, even on a public repo (same as GitHub: public means // publicly readable, not publicly writable). Gated at Maintainer or // above, same bar as granting a changeset_review -- matching // governance.md's own model (Contributor "needs a Maintainer's grant // for each changeset"; a raw push bypasses that per-changeset review // entirely, so it's deliberately held to the same authority that // could grant the review anyway, not opened to every Contributor). async function canPushRepo(request: Request, env: WebEnv, roles: Awaited>): Promise { const principal = await resolveGitAuthPrincipal(request, env); if (!principal) return false; return repoAtLeast(repoRoleFor(roles, principal), "maintainer"); } function needGitAuthResponse(): Response { return new Response("authentication required\n", { status: 401, headers: { "www-authenticate": 'Basic realm="zz"' } }); } async function handleRepoPage(me: Agent | null, slug: string, request: Request, env: WebEnv, origin: string): Promise { const ctx = await getRepoAccessContext(slug, env); if (!ctx) return new Response("no such repo", { status: 404 }); if (!(await canReadRepo(request, env, ctx.meta, ctx.roles))) return new Response("no such repo", { status: 404 }); const { repoRow, meta, roles } = ctx; // Started here, awaited down below where they're actually used -- // neither depends on anything from the git-content reads that // follow (they only need artifacts_name, already in hand), so // there's no reason the DO/Ladder round-trip should wait for the // Artifacts round-trip to finish first, or vice versa. const lanesAndLadder = Promise.all([getAllLanes(env, repoRow.artifacts_name), getLadderTree(env, repoRow.artifacts_name)]); const shardTree = listShardTreeFlat(env, slug); const trackingCheck = me ? isTrackingRepo(env.DB, me.username, slug) : Promise.resolve(false); using repo = await env.ARTIFACTS.get(repoRow.artifacts_name); const info = await repo.info(); // Separate from the Commits tab's own paginated fetch below -- once // that can be any page, not just the first, commits[0] (this used to // reuse that single call's own first result) is no longer // necessarily HEAD. const [head] = await repo.log({ ref: info.defaultBranch, limit: 1 }); const tree: TreeEntry[] = head ? ((await repo.readTree(head.treeHash)) ?? []) : []; const readme = head ? await findReadme(repo, tree) : null; // Commits tab pagination: ?commitsPage=<1-based>. Fetches one extra // past the page size to know whether a "next" link is warranted // without a second round trip just to count -- confirmed live this // was previously a silent, fixed 30-commit cutoff with no way past it. const COMMITS_PAGE_SIZE = 30; const commitsPage = Math.max(1, Number(new URL(request.url).searchParams.get("commitsPage")) || 1); const commitsFetched = await repo.log({ ref: info.defaultBranch, limit: COMMITS_PAGE_SIZE + 1, offset: (commitsPage - 1) * COMMITS_PAGE_SIZE }); const hasMoreCommits = commitsFetched.length > COMMITS_PAGE_SIZE; const commits = hasMoreCommits ? commitsFetched.slice(0, COMMITS_PAGE_SIZE) : commitsFetched; const fileListHtml = fileListRowsHtml(slug, info.defaultBranch, "", tree); const rolesHtml = (roles?.grants ?? []).map((g) => `
  • ${esc(g.principal)}${roleBadge(g.role)}
  • `).join(""); const visibility = meta?.policy.visibility ?? "public"; const isOwner = !!me && repoAtLeast(repoRoleFor(roles, me.username), "owner"); const tracking = !isOwner && (await trackingCheck); const [lanes, ladder] = await lanesAndLadder; const rootLane = lanes.find((l) => l.id === "_repo") ?? null; const asksHtml = (rootLane?.history ?? []) .slice() .reverse() .map((e) => { if (e.type === "ASK") return `
  • ASK ${esc(e.kind ?? "?")} from ${esc(e.from ?? "?")} ${esc(e.at)}
    ${jsonBlock(e.payload)}
  • `; if (e.type === "ANSWER") return `
  • ANSWER ${esc(e.action ?? "?")} by ${esc(e.from ?? "?")} on ${esc(e.ask ?? "?")} ${esc(e.at)}
  • `; return `
  • INSIGHT ${esc(e.text)} ${esc(e.at)}
  • `; }) .join(""); const lanesHtml = lanes .map((l) => `
  • ${esc(l.id)} ${esc(l.status)} coord: ${esc(l.coordinators?.join(", ") ?? "")}
  • `) .join(""); // "Activity" -- the closest thing a static page has to the CLI's own // activity feed: there's no server-side record of raw tool-call // traffic (the TUI's activity tab reads a live process's in-memory // LogBuffer, which only exists while `zz work` is actually running), // so this is the full persisted protocol trail instead, styled as a // dense log feed rather than badge-decorated rows to read the way a // live feed does. const logFeedHtml = flattenProtocol(lanes) .slice(0, 150) .map(({ laneId, entry: e }) => { const cls = e.type === "ASK" ? "t-ask" : e.type === "ANSWER" ? "t-answer" : "t-insight"; const rest = e.type === "ASK" ? `${esc(e.kind ?? "?")} from ${esc(e.from ?? "?")}` : e.type === "ANSWER" ? `${esc(e.action ?? "?")} by ${esc(e.from ?? "?")} on ${esc(e.ask ?? "?")}` : esc(e.text); return `
    ${esc(e.type)}  ${esc(laneId)}  ${rest}  ${esc(e.at)}
    `; }) .join(""); const commitsHtml = commits .map((c) => `
  • ${esc(c.hash.slice(0, 8))} ${esc(c.message.split("\n")[0])} ${esc(c.author.name)} ${esc(new Date(c.authoredAt * 1000).toISOString())}
  • `) .join(""); // #commits (restored on load by zzInitTabs below) keeps the Commits // tab open across a prev/next reload -- without it, a page link // would land back on whichever tab renders first (readme/ladder), // losing your place every single click. const commitsPagerHtml = commitsPage > 1 || hasMoreCommits ? `
    ${commitsPage > 1 ? `← newer` : ""} ${hasMoreCommits ? `older →` : ""}
    ` : ""; const fileTabs = [ readme ? { id: "readme", label: "README", html: `
    ${renderMarkdown(readme)}
    ` } : null, { id: "ladder", label: "Ladder", html: `
      ${ladder ? renderLadderNode(ladder) : '
    • No Ladder content yet.
    • '}
    ` }, { id: "files", label: "Files", html: `
      ${fileListHtml || '
    • Empty repo.
    • '}
    ` }, { id: "commits", label: "Commits", html: `
      ${commitsHtml || '
    • No commits yet.
    • '}
    ${commitsPagerHtml}` }, ].filter((t): t is { id: string; label: string; html: string } => t !== null); const activityTabs = [ { id: "activity", label: "Activity", html: `
    ${logFeedHtml || '
    No activity yet.
    '}
    ` }, { id: "asks", label: "Asks", html: `
      ${asksHtml || '
    • No Asks posted yet.
    • '}
    ` }, { id: "lanes", label: "Lanes", html: `
      ${lanesHtml || '
    • No Lanes yet.
    • '}
    ` }, ]; const tabGroup = (groupId: string, tabs: { id: string; label: string; html: string }[]): string => `
    ${tabs.map((t, i) => ``).join("")}
    ${tabs.map((t, i) => `
    ${t.html}
    `).join("")}
    `; const cloneUrl = `${origin}/${slug}`; const workCmd = `zz work --repos ${slug}`; const resolvedShardTree = await shardTree; const shardsHtml = resolvedShardTree.length > 0 ? `
    Shards
      ${shardGroupedListHtml(resolvedShardTree, (r) => r.slug, (r) => r.shard_of_slug, repoRowHtml)}
    ` : ""; const [ownerUsername, repoNameSegment] = slug.split("/"); return layout( slug, me, `
    ${repoRow.ingest_status === "ingesting" ? `

    ⏳ ingesting still importing -- content below may be incomplete until this finishes.

    ` : ""}

    ${autolinkText(meta?.description ?? repoRow.description ?? "")}

    ${ me && !isOwner ? `
    ` : "" } ${isOwner ? `` : ""}
    git clone ${esc(cloneUrl)}
    ${esc(workCmd)}

    To push: issue yourself (or a managed agent) a token on your profile (needs at least Maintainer). zz repo clone ${esc(slug)} does this automatically when logged in via the CLI.

    Join as a worker: run that from a terminal logged in via zz login to pick up and work this repo's Asks, same as any other agent.

    Roles
      ${rolesHtml}
    ${shardsHtml}
    ${tabGroup("files", fileTabs)} ${tabGroup("act", activityTabs)}`, { h1Html: `${esc(ownerUsername)}/${esc(repoNameSegment)}`, titleHtml: `${esc(visibility)}`, }, ); } async function handleRepoSettings(slug: string, request: Request, env: WebEnv): Promise { const me = await resolveRequestAgent(request, env); if (!me) return redirect("/login"); const ctx = await getRepoAccessContext(slug, env); if (!ctx) return new Response("no such repo", { status: 404 }); const { meta, roles } = ctx; if (!repoAtLeast(repoRoleFor(roles, me.username), "owner")) return new Response("Owners only", { status: 403 }); // Direct shards only, for the count shown here -- the actual // cleanup (doCleanupShards) recurses to shards of shards itself; // this is just "does the button need to show at all." const directShards = await listShardsOf(env.DB, slug); const roleRows = (roles?.grants ?? []) .map( (g) => `
  • ${esc(g.principal)}
  • `, ) .join(""); const visibility = meta?.policy.visibility ?? "public"; return layout( `${slug} settings`, me, `${card( "Description", `
    `, )} ${card( "Roles", `
      ${roleRows || '
    • No grants yet.
    • '}
    `, { addModal: "modal-add-role" }, )} ${card( "Visibility", `

    Public repos are readable by anyone; private repos need an explicit role grant above to even view.

    `, )} ${card( "Danger zone", `
    ${agentAutocompleteField("new_owner", "new owner (agent name or id)", "transfer")}
    ${ directShards.length > 0 ? `
    ` : "" }
    `, { extraClass: "danger-zone" }, )} `, ); } // A deliberately minimal commit permalink -- message/author/date and // the tree at that commit, each file linking to its current (HEAD) // blob, not a full historical diff view. Exists specifically so a // Ladder search hit (handleApiLadderSearch) has somewhere real to // send its "commit" link, not just its "file" link -- a rung's // summary is anchored to a commit, and that anchor deserves its own // permalink the way GitHub's does, even in this minimal form. async function handleCommit(slug: string, sha: string, request: Request, env: WebEnv): Promise { const ctx = await getRepoAccessContext(slug, env); if (!ctx) return new Response("no such repo", { status: 404 }); if (!(await canReadRepo(request, env, ctx.meta, ctx.roles))) return new Response("no such repo", { status: 404 }); const me = await resolveRequestAgent(request, env); // Started here, awaited down by the render -- a separate DO // round-trip with no dependency on the Artifacts reads that follow, // so there's no reason to serialize behind them. A changeset rung's // path IS "changesets/", so this is one direct read. const ladderRung = readLadderRung(env, ctx.repoRow.artifacts_name, `changesets/${sha}`); using repo = await env.ARTIFACTS.get(ctx.repoRow.artifacts_name); const info = await repo.info(); const commit = await repo.readCommit(sha); if (!commit) return new Response("no such commit", { status: 404 }); const tree: TreeEntry[] = (await repo.readTree(commit.treeHash)) ?? []; // Forward/back through the log: a commit doesn't know its own newer // neighbor natively (only `parents`, which only ever points older), // so finding "newer" means walking the first-parent chain from HEAD // and noting whichever commit came right before this one. Bounded, // not a full history walk -- a commit older than this window only // gets a "back" link (via its own `parents[0]`), not a "forward" one; // fine at this project's scale, same spirit as the Commits tab's own // 30-commit cap. const log = await repo.log({ ref: info.defaultBranch, limit: 1000 }); const i = log.findIndex((c) => c.hash === sha); const newer = i > 0 ? log[i - 1] : null; const older: string | null = i >= 0 && i < log.length - 1 ? log[i + 1].hash : (commit.parents[0] ?? null); // Each filechange rung is about one specific file in this commit, so // its summary belongs on that file's own row in the tree below, not // bundled into one combined block up here -- the changeset rung's // OWN summary is the only one that's actually about the commit as a // whole. A filechange's path is "changesets//" (or // sometimes just ""), so match on the last path segment // either way. const changesetRung = await ladderRung; const fileSummaries = new Map(); for (const child of changesetRung?.children ?? []) { if (isRef(child)) continue; fileSummaries.set(child.path.split("/").pop() ?? child.path, child.summary); } // sha as ref -- clicking into a file or directory from this // historical commit's own tree stays pinned to it, not HEAD. const fileListHtml = fileListRowsHtml(slug, sha, "", tree, (name) => fileSummaries.get(name)); const navHtml = ``; return layout( sha.slice(0, 8), me, `

    Commit

    ${esc(sha)}

    ${esc(commit.message)}

    ${esc(commit.author.name)} <${esc(commit.author.email)}> -- ${esc(new Date(commit.authoredAt * 1000).toISOString())}

    ${changesetRung ? summaryDetailHtml(changesetRung.summary) : ""} ${navHtml}

    Tree at this commit

      ${fileListHtml || '
    • Empty.
    • '}
    `, { titleHtml: ` \xb7 ${esc(slug)}` }, ); } /** The file's actual bytes, unchanged from what /blob/ used to serve directly -- the viewer's download button and anything else that wants the real file content (not an HTML page) points here now. */ async function handleDownload(slug: string, ref: string, path: string, request: Request, env: WebEnv): Promise { const ctx = await getRepoAccessContext(slug, env); if (!ctx) return new Response("not found", { status: 404 }); if (!(await canReadRepo(request, env, ctx.meta, ctx.roles))) return new Response("not found", { status: 404 }); using repo = await env.ARTIFACTS.get(ctx.repoRow.artifacts_name); const blob = await repo.readFile({ ref: await resolveRefName(repo, ref), path }); if (!blob) return new Response("not found", { status: 404 }); return new Response(blob, { headers: { "content-type": blob.type || "application/octet-stream" } }); } // highlight.js's language-detection aliases are mostly just the file // extension already -- this only needs to cover the handful where // they genuinely differ. const HLJS_LANG_BY_EXT: Record = { js: "javascript", mjs: "javascript", cjs: "javascript", ts: "typescript", tsx: "typescript", jsx: "javascript", py: "python", rb: "ruby", rs: "rust", sh: "bash", yml: "yaml", md: "markdown", cs: "csharp", kt: "kotlin", txt: "plaintext", }; function looksBinary(bytes: Uint8Array): boolean { const sample = bytes.subarray(0, 8000); for (const b of sample) if (b === 0) return true; return false; } /** The file viewer page: syntax-highlighted (via highlight.js from cdnjs -- a real CDN dependency, not hand-rolled, since this is a normal deployed website, not a sandboxed Artifact), line-numbered, with a header (repo, latest commit, path, download). GitHub's /blob/ convention -- this is the page a file link lands on; /download/ is the actual bytes now. */ async function handleBlob(slug: string, ref: string, path: string, request: Request, env: WebEnv): Promise { const ctx = await getRepoAccessContext(slug, env); if (!ctx) return new Response("not found", { status: 404 }); if (!(await canReadRepo(request, env, ctx.meta, ctx.roles))) return new Response("not found", { status: 404 }); const me = await resolveRequestAgent(request, env); // Started here, awaited down by the render -- a separate DO // round-trip with no dependency on the Artifacts reads that follow. // A file rung's path IS the file's own path, so this is one direct // read, no searching. Always the current Ladder summary regardless // of `ref` -- a rung tracks a file's current state, not a per-commit // history of it, so there's nothing more "correct" to show for an // older ref here even though the code body itself is pinned to one. const ladderRung = readLadderRung(env, ctx.repoRow.artifacts_name, path); using repo = await env.ARTIFACTS.get(ctx.repoRow.artifacts_name); // ref resolves a branch, tag, or commit sha directly (both calls // below) -- the same ref a historical commit's own tree view links // here with, so the file shown is that commit's version, not HEAD's. ref = await resolveRefName(repo, ref); const [blob, [head]] = await Promise.all([repo.readFile({ ref, path }), repo.log({ ref, limit: 1 })]); if (!blob) return new Response("not found", { status: 404 }); const bytes = new Uint8Array(await blob.arrayBuffer()); const ext = path.includes(".") ? path.slice(path.lastIndexOf(".") + 1).toLowerCase() : ""; // Nested inside .blob-header, not a sibling block after it -- a // sibling would land in the gap between the header's rounded-top // border and the body's rounded-bottom one (the two only look like // one continuous box because the body has no top border), breaking // that illusion. Staying inside the header's own border keeps it intact. const headerHtml = `
    ${pathBreadcrumbHtml(slug, ref, path, false)} ${head ? ` @ ${esc(head.hash.slice(0, 8))}` : ""} Download ${ladderDetailHtml(await ladderRung)}
    `; if (looksBinary(bytes)) { return layout(path, me, `${headerHtml}
    Binary file not shown.
    `); } const text = new TextDecoder().decode(bytes); // No extension at all (LICENSE, Dockerfile, Makefile, ...) -- hljs // treats an empty/unrecognized language class as a signal to run // its own auto-detection heuristic instead of rendering plain text, // which is exactly the wrong behavior for a file with no real // language. "plaintext" is a real hljs language that just renders // the text unhighlighted. const lang = ext ? (HLJS_LANG_BY_EXT[ext] ?? ext) : "plaintext"; const codeHtml = `
    ${esc(text)}
    `; return layout( path, me, `${headerHtml}
    ${codeHtml}
    `, ); } async function handleArchive(slug: string, request: Request, env: WebEnv): Promise { const ctx = await getRepoAccessContext(slug, env); if (!ctx) return new Response("not found", { status: 404 }); if (!(await canReadRepo(request, env, ctx.meta, ctx.roles))) return new Response("not found", { status: 404 }); using repo = await env.ARTIFACTS.get(ctx.repoRow.artifacts_name); const info = await repo.info(); const [head] = await repo.log({ ref: info.defaultBranch, limit: 1 }); if (!head) return new Response("empty repo", { status: 404 }); const files: Record = {}; await walkTreeForZip(repo, head.treeHash, "", files); const zipped = zipSync(files); return new Response(zipped, { headers: { "content-type": "application/zip", "content-disposition": `attachment; filename="${ctx.repoRow.slug.replace("/", "-")}.zip"` } }); } // --- Git smart-HTTP proxy, so the full range of git operations -- // clone, fetch, AND push -- work against `/` with no // credential ever visible in a URL. See git-compatibility docs page // for the full supported/unsupported surface. type GitService = "git-upload-pack" | "git-receive-pack"; function stripHopByHopHeaders(headers: Headers): Headers { const copy = new Headers(headers); copy.delete("content-encoding"); copy.delete("content-length"); copy.delete("transfer-encoding"); return copy; } async function checkGitServiceAccess(service: GitService, request: Request, env: WebEnv, ctx: { meta: Awaited>; roles: Awaited> }): Promise<"ok" | "need-auth" | "denied"> { if (service === "git-receive-pack") { if (await canPushRepo(request, env, ctx.roles)) return "ok"; return request.headers.get("authorization") ? "denied" : "need-auth"; } if (await canReadRepo(request, env, ctx.meta, ctx.roles)) return "ok"; const isPrivate = ctx.meta?.policy.visibility === "private"; return isPrivate && !request.headers.get("authorization") ? "need-auth" : "denied"; } async function handleGitInfoRefs(slug: string, request: Request, env: WebEnv): Promise { const service = new URL(request.url).searchParams.get("service"); if (service !== "git-upload-pack" && service !== "git-receive-pack") return new Response("unsupported service", { status: 400 }); const ctx = await getRepoAccessContext(slug, env); if (!ctx) return new Response("not found", { status: 404 }); const access = await checkGitServiceAccess(service, request, env, ctx); if (access === "need-auth") return needGitAuthResponse(); if (access === "denied") return new Response("not found", { status: 404 }); using repo = await env.ARTIFACTS.get(ctx.repoRow.artifacts_name); const token = await repo.createToken(service === "git-receive-pack" ? "write" : "read", 120); const info = await repo.info(); const tokenPlaintext = typeof token === "string" ? token : token.plaintext; const upstream = await fetch(`${info.remote}/info/refs?service=${service}`, { headers: { authorization: `Basic ${btoa(`x-token-auth:${tokenPlaintext}`)}` }, }); return new Response(upstream.body, { status: upstream.status, headers: stripHopByHopHeaders(upstream.headers) }); } async function handleGitPack(service: GitService, slug: string, request: Request, env: WebEnv): Promise { const ctx = await getRepoAccessContext(slug, env); if (!ctx) return new Response("not found", { status: 404 }); const access = await checkGitServiceAccess(service, request, env, ctx); if (access === "need-auth") return needGitAuthResponse(); if (access === "denied") return new Response("not found", { status: 404 }); using repo = await env.ARTIFACTS.get(ctx.repoRow.artifacts_name); const token = await repo.createToken(service === "git-receive-pack" ? "write" : "read", 120); const info = await repo.info(); const tokenPlaintext = typeof token === "string" ? token : token.plaintext; const headers: Record = { authorization: `Basic ${btoa(`x-token-auth:${tokenPlaintext}`)}`, "content-type": request.headers.get("content-type") ?? `application/x-${service}-request`, }; const gitProtocol = request.headers.get("git-protocol"); if (gitProtocol) headers["git-protocol"] = gitProtocol; const upstream = await fetch(`${info.remote}/${service}`, { method: "POST", headers, body: request.body }); const response = new Response(upstream.body, { status: upstream.status, headers: stripHopByHopHeaders(upstream.headers) }); if (service === "git-receive-pack" && upstream.ok) { await touchRepoUpdatedAt(env.DB, ctx.repoRow.slug).catch(() => {}); } return response; } // --- Dispatch --- /** Returns null for any path this module doesn't own, so index.ts can fall through to its existing diagnostic routes. */ export async function handleWebRequest(request: Request, env: WebEnv): Promise { const url = new URL(request.url); // Decoded once, up front: a username can contain "[", "]", "@" -- // none of those are in the WHATWG URL spec's path percent-encode // set, so a conformant browser leaves them literal in `pathname` // (confirmed live: a route built from `agent.username` works as-is). // But a client that DOES percent-encode them anyway (confirmed live: // `encodeURIComponent` on a bracketed username, which some CLI/API // call sites reasonably reach for) would otherwise 404 forever, // since every lookup below (getRepoBySlug, getAgent, ...) // compares against the literal, undecoded form stored in D1. // Decoding here once means every route matches either form the same // way, instead of every individual call site needing to know which // encoding its particular caller happened to use. const path = decodeURIComponent(url.pathname); const origin = url.origin; if (path === "/login") return handleLogin(request, env); if (path === "/logout") return handleLogout(request, env); if (path === "/auth/start/cloudflare") return handleAuthStart("cloudflare", request, env); if (path === "/auth/start/google") return handleAuthStart("google", request, env); if (path === "/auth/callback/cloudflare") return handleAuthCallback("cloudflare", request, env); if (path === "/auth/callback/google") return handleAuthCallback("google", request, env); // /profile is kept only as a convenience redirect to your own // / page -- see that handler's header for why there's no // separate profile route anymore (one route, public or editable // depending on who's looking). if (path === "/profile" && request.method === "GET") { const me = await resolveRequestAgent(request, env); return me ? redirect(`/${me.username}`) : redirect("/login"); } if (path === "/profile/tokens" && request.method === "POST") return handleTokenAddForm(request, env); if (path === "/work/ask" && request.method === "POST") return handleAskForm(request, env); if (path === "/profile/repos" && request.method === "POST") return handleCreateRepoForm(request, env); { const m = path.match(/^\/profile\/tokens\/([^/]+)\/revoke$/); if (m && request.method === "POST") return handleRevokeTokenForm(m[1], request, env); } if (path === "/explore") return handleExplore(request, env); if (path === "/work" && request.method === "GET") return handleWorkPage(request, env); if (path === "/work/claim" && request.method === "POST") return handleWorkClaimForm(request, env); if (path === "/api/explore/search" && request.method === "GET") return handleApiExploreSearch(request, env); if (path === "/api/ladder/search" && request.method === "GET") return handleApiLadderSearch(request, env); // --- JSON API (CLI/agent parity with everything above) --- if (path === "/api/login/start" && request.method === "POST") return handleApiLoginStart(request, env); if (path === "/api/login/poll" && request.method === "GET") return handleApiLoginPoll(request, env); if (path === "/api/me" && request.method === "GET") return handleApiMe(request, env); if (path === "/api/me/repos" && request.method === "GET") return handleApiMyRepos(request, env); if (path === "/api/me/revoke" && request.method === "POST") return handleApiRevokeSelf(request, env); if (path === "/api/agents" && request.method === "POST") return handleApiCreateAgent(request, env); if (path === "/api/agents" && request.method === "GET") return handleApiListAgents(request, env); if (path === "/api/agents/search" && request.method === "GET") return handleApiSearchAgents(request, env); if (path === "/api/repos" && request.method === "POST") return handleApiCreateRepo(request, env); if (path === "/api/repos" && request.method === "GET") return handleApiListRepos(request, env); { const m = path.match(/^\/api\/agents\/([^/]+)\/tokens$/); if (m && request.method === "POST") return handleApiMintToken(m[1], request, env); } { const m = path.match(/^\/api\/repos\/([^/]+)\/([^/]+)\/roles$/); if (m && request.method === "POST") return handleApiRbacGrant(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/api\/repos\/([^/]+)\/([^/]+)\/ingest-status$/); if (m && request.method === "POST") return handleApiSetIngestStatus(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/api\/repos\/([^/]+)\/([^/]+)\/shard$/); if (m && request.method === "POST") return handleApiShard(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/api\/repos\/([^/]+)\/([^/]+)\/ask$/); if (m && request.method === "POST") return handleApiPostAsk(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/api\/repos\/([^/]+)\/([^/]+)\/asks$/); if (m && request.method === "GET") return handleApiGetAsks(`${m[1]}/${m[2]}`, env); } { const m = path.match(/^\/api\/repos\/([^/]+)\/([^/]+)\/tools\/([^/]+)$/); if (m && request.method === "POST") return handleApiCallTool(`${m[1]}/${m[2]}`, m[3], request, env); } { const m = path.match(/^\/api\/repos\/([^/]+)\/([^/]+)\/track$/); if (m && request.method === "POST") return handleApiTrack(`${m[1]}/${m[2]}`, true, request, env); } { const m = path.match(/^\/api\/repos\/([^/]+)\/([^/]+)\/untrack$/); if (m && request.method === "POST") return handleApiTrack(`${m[1]}/${m[2]}`, false, request, env); } { const m = path.match(/^\/api\/repos\/([^/]+)\/([^/]+)$/); if (m && request.method === "GET") return handleApiGetRepo(`${m[1]}/${m[2]}`, env); if (m && request.method === "DELETE") return handleApiDeleteRepo(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/archive\.zip$/); if (m) return handleArchive(`${m[1]}/${m[2]}`, request, env); } { // ref is always a required, separate path segment (a branch name, // tag, or commit sha) -- /tree/ alone (no path) is the repo // root at that ref, same as GitHub's own convention. const m = path.match(/^\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/); if (m) return handleBlob(`${m[1]}/${m[2]}`, m[3], m[4], request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/tree\/([^/]+)(?:\/(.+))?$/); if (m) return handleTree(`${m[1]}/${m[2]}`, m[3], m[4] ?? "", request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/download\/([^/]+)\/(.+)$/); if (m) return handleDownload(`${m[1]}/${m[2]}`, m[3], m[4], request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/commit\/([0-9a-f]+)$/); if (m) return handleCommit(`${m[1]}/${m[2]}`, m[3], request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/settings$/); if (m && request.method === "GET") return handleRepoSettings(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/settings\/roles$/); if (m && request.method === "POST") return handleRbacGrantForm(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/settings\/roles\/revoke$/); if (m && request.method === "POST") return handleRbacRevokeForm(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/settings\/visibility$/); if (m && request.method === "POST") return handleVisibilityForm(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/settings\/description$/); if (m && request.method === "POST") return handleDescriptionForm(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/settings\/transfer$/); if (m && request.method === "POST") return handleTransferForm(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/settings\/delete$/); if (m && request.method === "POST") return handleDeleteRepoForm(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/settings\/cleanup-shards$/); if (m && request.method === "POST") return handleCleanupShardsForm(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/track$/); if (m && request.method === "POST") return handleTrackForm(`${m[1]}/${m[2]}`, true, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/untrack$/); if (m && request.method === "POST") return handleTrackForm(`${m[1]}/${m[2]}`, false, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/info\/refs$/); if (m) return handleGitInfoRefs(`${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/git-upload-pack$/); if (m) return handleGitPack("git-upload-pack", `${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)\/git-receive-pack$/); if (m) return handleGitPack("git-receive-pack", `${m[1]}/${m[2]}`, request, env); } { const m = path.match(/^\/([^/]+)\/([^/]+)$/); if (m) { const me = await resolveRequestAgent(request, env); return handleRepoPage(me, `${m[1]}/${m[2]}`, request, env, origin); } } { const m = path.match(/^\/([^/]+)$/); if (m && request.method === "GET") return handleAgentPage(m[1], request, env); } return null; } export { upsertRepo, type RepoRow, type Agent };