Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Wire the main Worker: REST API, MCP server, webhooks, cron, self-hosted runners

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Claude authored signed1 parent 2199d7ccommit 263cb1ef5106ab76f04bce1cee15b42d4d17ffdbBrowse files
26 files changed+5201−90
Files (26)
modifiedapps/diffinery/src/git/protocol.ts+2−2View file
@@ -3,10 +3,10 @@
33
44 export const AGENT = "diffinery/0.1";
55
6-export interface RefEntry {
6+export type RefEntry = {
77 name: string;
88 oid: string;
9-}
9+};
1010
1111 export interface UploadContext {
1212 store: ObjectReader & { readRaw(oid: string): Promise<RawObject | null>; has(oid: string): boolean };
addedapps/diffinery/src/http/api.ts+680−0View file
@@ -1,0 +1,680 @@
1+import { Hono } from "hono";
2+import { fromUtf8 } from "@diffinery/git-core";
3+import type { Env } from "../env.js";
4+import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js";
5+import type { Account, Repo, TokenScope } from "../lib/directory.js";
6+import { encryptSecret } from "../lib/crypto.js";
7+import { publicJwk } from "../lib/jwt.js";
8+import { HttpError, b64, fromB64 } from "../lib/util.js";
9+import type { ProtectionRules, Role } from "../repo/types.js";
10+import type { RunInfo } from "../repo/actions.js";
11+import { loadRepo, repoStub } from "./common.js";
12+
13+/**
14+ * REST API. Paths and payloads follow GitHub's REST API v3 where practical so
15+ * existing scripts and tools work; Diffinery-only endpoints live under /api/v3/diffinery.
16+ */
17+export const api = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3");
18+
19+const userJson = (c: AppContext, a: Pick<Account, "id" | "login" | "kind" | "name">) => ({
20+ id: a.id,
21+ login: a.login,
22+ name: a.name,
23+ type: a.kind === "org" ? "Organization" : a.kind === "agent" ? "Bot" : "User",
24+ html_url: `${c.env.PUBLIC_URL}/${a.login}`,
25+});
26+
27+const repoJson = (c: AppContext, r: Repo) => ({
28+ id: r.id,
29+ name: r.name,
30+ full_name: `${r.owner_login}/${r.name}`,
31+ owner: { login: r.owner_login, type: r.owner_kind === "org" ? "Organization" : "User" },
32+ private: r.visibility !== "public",
33+ visibility: r.visibility,
34+ description: r.description,
35+ default_branch: r.default_branch,
36+ archived: !!r.archived,
37+ html_url: `${c.env.PUBLIC_URL}/${r.owner_login}/${r.name}`,
38+ clone_url: `${c.env.PUBLIC_URL}/${r.owner_login}/${r.name}.git`,
39+ created_at: r.created_at,
40+ updated_at: r.updated_at,
41+ pushed_at: r.pushed_at,
42+});
43+
44+async function body<T>(c: AppContext): Promise<T> {
45+ try {
46+ return (await c.req.json()) as T;
47+ } catch {
48+ throw new HttpError(400, "request body must be JSON");
49+ }
50+}
51+
52+const P = (c: AppContext) => ({ owner: c.req.param("owner")!, repo: c.req.param("repo")! });
53+const num = (c: AppContext, name = "number") => {
54+ const n = Number(c.req.param(name));
55+ if (!Number.isInteger(n) || n < 1) throw new HttpError(404, "not found");
56+ return n;
57+};
58+
59+// ---------------------------------------------------------------- meta & users
60+
61+api.get("/", (c) => c.json({ name: "Diffinery", api: "v3", docs: `${c.env.PUBLIC_URL}/docs/api`, mcp: `${c.env.PUBLIC_URL}/mcp` }));
62+api.get("/meta", (c) => c.json({ name: "Diffinery", oidc: !!c.env.OIDC_PRIVATE_KEY }));
63+api.get("/user", (c) => {
64+ const a = requireAccount(c);
65+ return c.json({ ...userJson(c, a), email: a.email, site_admin: !!a.site_admin });
66+});
67+api.get("/users/:login", async (c) => {
68+ requireAccount(c);
69+ const a = await c.get("dir").accountByLogin(c.req.param("login"));
70+ if (!a) throw new HttpError(404, "not found");
71+ return c.json(userJson(c, a));
72+});
73+api.get("/user/orgs", async (c) => c.json((await c.get("dir").orgsFor(requireAccount(c).id)).map((o) => ({ ...userJson(c, o), role: o.role }))));
74+api.post("/user/orgs", async (c) => {
75+ const a = requireAccount(c);
76+ const b = await body<{ login: string; name?: string }>(c);
77+ return c.json(userJson(c, await c.get("dir").createOrg(b.login, b.name ?? b.login, a)), 201);
78+});
79+api.get("/orgs/:org/members", async (c) => {
80+ const a = requireAccount(c);
81+ const dir = c.get("dir");
82+ const org = await dir.accountByLogin(c.req.param("org"));
83+ if (!org || org.kind !== "org") throw new HttpError(404, "not found");
84+ if (!(await dir.orgRole(org.id, a.id)) && !a.site_admin) throw new HttpError(404, "not found");
85+ return c.json((await dir.orgMembers(org.id)).map((m) => ({ ...userJson(c, m), role: m.role })));
86+});
87+api.post("/orgs/:org/invitations", async (c) => {
88+ const a = requireAccount(c);
89+ const dir = c.get("dir");
90+ const org = await dir.accountByLogin(c.req.param("org"));
91+ if (!org || org.kind !== "org") throw new HttpError(404, "not found");
92+ const role = await dir.orgRole(org.id, a.id);
93+ if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "only organization owners and admins can invite");
94+ const b = await body<{ email: string; role?: "member" | "admin" | "owner" }>(c);
95+ if (!/^[^@\s]+@[^@\s]+$/.test(b.email ?? "")) throw new HttpError(422, "a valid email is required");
96+ const inv = await dir.createInvite(a, { email: b.email, orgId: org.id, role: b.role ?? "member" });
97+ c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "org.invite", target: org.login, detail: { email: b.email, role: b.role ?? "member" } }));
98+ return c.json({ id: inv.id, url: inv.url }, 201);
99+});
100+
101+// ---------------------------------------------------------------- repositories
102+
103+api.get("/user/repos", async (c) => c.json((await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r))));
104+api.post("/user/repos", async (c) => {
105+ const a = requireAccount(c);
106+ const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c);
107+ const owner = a.kind === "agent" ? (await c.get("dir").account(a.owner_id!))! : a;
108+ const r = await c.get("dir").createRepo(owner, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch });
109+ return c.json(repoJson(c, r), 201);
110+});
111+api.get("/orgs/:org/repos", async (c) => {
112+ const a = requireAccount(c);
113+ const org = await c.get("dir").accountByLogin(c.req.param("org"));
114+ if (!org) throw new HttpError(404, "not found");
115+ return c.json((await c.get("dir").reposVisibleTo(a, org.id)).map((r) => repoJson(c, r)));
116+});
117+api.post("/orgs/:org/repos", async (c) => {
118+ const a = requireAccount(c);
119+ const org = await c.get("dir").accountByLogin(c.req.param("org"));
120+ if (!org || org.kind !== "org") throw new HttpError(404, "organization not found");
121+ const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c);
122+ const r = await c.get("dir").createRepo(org, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch });
123+ return c.json(repoJson(c, r), 201);
124+});
125+api.get("/repos/:owner/:repo", async (c) => {
126+ const { repo, caller, stub } = await loadRepo(c, P(c).owner, P(c).repo);
127+ return c.json({ ...repoJson(c, repo), permissions: { role: caller.role }, stats: await stub.stats() });
128+});
129+api.patch("/repos/:owner/:repo", async (c) => {
130+ const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
131+ const b = await body<{ name?: string; description?: string; visibility?: Repo["visibility"]; default_branch?: string; archived?: boolean }>(c);
132+ return c.json(repoJson(c, await c.get("dir").updateRepo(repo, { name: b.name, description: b.description, visibility: b.visibility, defaultBranch: b.default_branch, archived: b.archived })));
133+});
134+api.delete("/repos/:owner/:repo", async (c) => {
135+ const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
136+ if (caller.kind === "agent") throw new HttpError(403, "agents cannot delete repositories");
137+ await c.get("dir").deleteRepo(repo);
138+ return c.body(null, 204);
139+});
140+api.get("/repos/:owner/:repo/collaborators", async (c) => {
141+ const { repo } = await loadRepo(c, P(c).owner, P(c).repo);
142+ return c.json((await c.get("dir").collaborators(repo.id)).map((x) => ({ ...userJson(c, x.account), role_name: x.role })));
143+});
144+api.put("/repos/:owner/:repo/collaborators/:login", async (c) => {
145+ const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
146+ const who = await c.get("dir").accountByLogin(c.req.param("login"));
147+ if (!who) throw new HttpError(404, "user not found");
148+ const b = await body<{ permission?: Role | "pull" | "push" }>(c).catch(() => ({ permission: "write" as Role }));
149+ const map: Record<string, Role> = { pull: "read", push: "write" };
150+ await c.get("dir").setCollaborator(repo.id, who.id, map[b.permission ?? ""] ?? (b.permission as Role) ?? "write");
151+ return c.body(null, 204);
152+});
153+api.delete("/repos/:owner/:repo/collaborators/:login", async (c) => {
154+ const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
155+ const who = await c.get("dir").accountByLogin(c.req.param("login"));
156+ if (who) await c.get("dir").setCollaborator(repo.id, who.id, null);
157+ return c.body(null, 204);
158+});
159+
160+// ---------------------------------------------------------------- git data
161+
162+api.get("/repos/:owner/:repo/branches", async (c) => {
163+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
164+ return c.json((await stub.branches()).map((b) => ({ name: b.name, commit: { sha: b.sha }, protected: b.protected, default: b.isDefault })));
165+});
166+api.get("/repos/:owner/:repo/branches/:branch", async (c) => {
167+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
168+ const sha = await stub.resolve(c.req.param("branch"));
169+ if (!sha) throw new HttpError(404, "branch not found");
170+ return c.json({ name: c.req.param("branch"), commit: await stub.commitInfo(sha) });
171+});
172+api.get("/repos/:owner/:repo/tags", async (c) => {
173+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
174+ return c.json((await stub.tags()).map((t) => ({ name: t.name, commit: { sha: t.target } })));
175+});
176+api.get("/repos/:owner/:repo/git/refs/*", async (c) => {
177+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
178+ const prefix = `refs/${c.req.path.split("/git/refs/")[1] ?? ""}`;
179+ return c.json(stub.refs ? (await stub.refs(prefix)).map((r) => ({ ref: r.name, object: { sha: r.oid } })) : []);
180+});
181+api.post("/repos/:owner/:repo/git/refs", async (c) => {
182+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
183+ const b = await body<{ ref: string; sha: string }>(c);
184+ await stub.createRef(b.ref, b.sha, caller);
185+ return c.json({ ref: b.ref, object: { sha: b.sha } }, 201);
186+});
187+api.patch("/repos/:owner/:repo/git/refs/*", async (c) => {
188+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
189+ const ref = `refs/${c.req.path.split("/git/refs/")[1]}`;
190+ const b = await body<{ sha: string; force?: boolean }>(c);
191+ await stub.updateRef(ref, b.sha, !!b.force, caller);
192+ return c.json({ ref, object: { sha: b.sha } });
193+});
194+api.delete("/repos/:owner/:repo/git/refs/*", async (c) => {
195+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
196+ await stub.deleteRef(`refs/${c.req.path.split("/git/refs/")[1]}`, caller);
197+ return c.body(null, 204);
198+});
199+api.get("/repos/:owner/:repo/commits", async (c) => {
200+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
201+ const perPage = Number(c.req.query("per_page") ?? 30);
202+ const page = Number(c.req.query("page") ?? 1);
203+ return c.json(await stub.log(c.req.query("sha") ?? "HEAD", { path: c.req.query("path"), limit: perPage, skip: (page - 1) * perPage }));
204+});
205+api.get("/repos/:owner/:repo/commits/:ref", async (c) => {
206+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
207+ return c.json(await stub.commitWithDiff(c.req.param("ref")));
208+});
209+api.get("/repos/:owner/:repo/compare/:spec", async (c) => {
210+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
211+ const [base, head] = c.req.param("spec").split("...");
212+ if (!base || !head) throw new HttpError(422, "use base...head");
213+ return c.json(await stub.compare(base, head));
214+});
215+api.get("/repos/:owner/:repo/contents/*", async (c) => {
216+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
217+ const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? "");
218+ const ref = c.req.query("ref") ?? "HEAD";
219+ const tree = await stub.tree(ref, path);
220+ if (tree) return c.json(tree.items.map((i) => ({ name: i.name, path: i.path, sha: i.oid, size: i.size ?? 0, type: i.type === "tree" ? "dir" : i.type === "commit" ? "submodule" : "file" })));
221+ const blob = await stub.blob(ref, path);
222+ if (!blob) throw new HttpError(404, "not found");
223+ return c.json({ type: "file", name: path.split("/").pop(), path, sha: blob.oid, size: blob.size, encoding: "base64", content: b64(blob.content), commit: blob.sha });
224+});
225+api.get("/repos/:owner/:repo/contents", async (c) => {
226+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
227+ const tree = await stub.tree(c.req.query("ref") ?? "HEAD", "");
228+ if (!tree) return c.json([]);
229+ return c.json(tree.items.map((i) => ({ name: i.name, path: i.path, sha: i.oid, size: i.size ?? 0, type: i.type === "tree" ? "dir" : "file" })));
230+});
231+api.put("/repos/:owner/:repo/contents/*", async (c) => {
232+ const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "write");
233+ const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? "");
234+ const b = await body<{ message: string; content: string; branch?: string; sha?: string }>(c);
235+ const res = await stub.commitFiles({ branch: b.branch ?? repo.default_branch, message: b.message, changes: [{ path, content: fromB64(b.content) }] }, caller);
236+ return c.json({ commit: { sha: res.sha }, content: { path } }, 201);
237+});
238+api.delete("/repos/:owner/:repo/contents/*", async (c) => {
239+ const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "write");
240+ const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? "");
241+ const b = await body<{ message: string; branch?: string }>(c);
242+ const res = await stub.commitFiles({ branch: b.branch ?? repo.default_branch, message: b.message, changes: [{ path, delete: true }] }, caller);
243+ return c.json({ commit: { sha: res.sha } });
244+});
245+
246+/** Agent-friendly multi-file commit without a Git client. */
247+api.post("/repos/:owner/:repo/diffinery/commits", async (c) => {
248+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
249+ const b = await body<{ branch: string; message: string; base?: string; expected_head_sha?: string; files: Array<{ path: string; content?: string; encoding?: "utf-8" | "base64"; delete?: boolean; mode?: "100644" | "100755" }> }>(c);
250+ if (!Array.isArray(b.files) || b.files.length === 0) throw new HttpError(422, "files is required");
251+ const changes = b.files.map((f) => (f.delete ? { path: f.path, delete: true } : { path: f.path, mode: f.mode, content: f.encoding === "base64" ? fromB64(f.content ?? "") : new TextEncoder().encode(f.content ?? "") }));
252+ return c.json(await stub.commitFiles({ branch: b.branch, message: b.message, changes, createBranchFrom: b.base, expectedHeadSha: b.expected_head_sha }, caller), 201);
253+});
254+
255+api.get("/repos/:owner/:repo/raw/:ref/*", async (c) => {
256+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
257+ const path = decodeURIComponent(c.req.path.split(`/raw/${c.req.param("ref")}/`)[1] ?? "");
258+ const blob = await stub.blob(c.req.param("ref"), path);
259+ if (!blob) throw new HttpError(404, "not found");
260+ return new Response(blob.content, { headers: { "content-type": blob.binary ? "application/octet-stream" : "text/plain; charset=utf-8", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } });
261+});
262+
263+// ---------------------------------------------------------------- branch protection
264+
265+api.get("/repos/:owner/:repo/branches/:branch/protection", async (c) => {
266+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
267+ const rule = (await stub.listProtections()).find((p) => p.pattern === c.req.param("branch"));
268+ if (!rule) throw new HttpError(404, "branch not protected");
269+ return c.json(rule);
270+});
271+api.put("/repos/:owner/:repo/branches/:branch/protection", async (c) => {
272+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
273+ if (caller.kind === "agent") throw new HttpError(403, "agents cannot change branch protection");
274+ const rules = await body<ProtectionRules>(c);
275+ await stub.setProtection(c.req.param("branch"), rules);
276+ return c.json({ pattern: c.req.param("branch"), rules });
277+});
278+api.delete("/repos/:owner/:repo/branches/:branch/protection", async (c) => {
279+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
280+ if (caller.kind === "agent") throw new HttpError(403, "agents cannot change branch protection");
281+ await stub.setProtection(c.req.param("branch"), null);
282+ return c.body(null, 204);
283+});
284+
285+// ---------------------------------------------------------------- issues & pulls
286+
287+const issueJson = (c: AppContext, owner: string, repo: string, i: Awaited<ReturnType<ReturnType<typeof repoStub>["issue"]>>["issue"]) => ({
288+ number: i.number,
289+ title: i.title,
290+ body: i.body,
291+ state: i.pull?.merged ? "closed" : i.state,
292+ state_reason: i.stateReason,
293+ user: { login: i.author.login, type: i.author.kind === "agent" ? "Bot" : "User" },
294+ labels: i.labels.map((name) => ({ name })),
295+ assignees: i.assignees.map((login) => ({ login })),
296+ comments: i.comments,
297+ created_at: i.createdAt,
298+ updated_at: i.updatedAt,
299+ closed_at: i.closedAt,
300+ html_url: `${c.env.PUBLIC_URL}/${owner}/${repo}/${i.kind === "pull" ? "pull" : "issues"}/${i.number}`,
301+ ...(i.pull
302+ ? {
303+ pull_request: { url: `${c.env.PUBLIC_URL}/api/v3/repos/${owner}/${repo}/pulls/${i.number}` },
304+ head: { ref: i.pull.head, sha: i.pull.headSha },
305+ base: { ref: i.pull.base },
306+ draft: i.pull.draft,
307+ merged: i.pull.merged,
308+ merged_at: i.pull.mergedAt,
309+ merged_by: i.pull.mergedBy ? { login: i.pull.mergedBy } : null,
310+ merge_commit_sha: i.pull.mergeCommit,
311+ requested_reviewers: i.pull.requestedReviewers.map((login) => ({ login })),
312+ intent: i.pull.intent,
313+ }
314+ : {}),
315+});
316+
317+for (const kind of ["issues", "pulls"] as const) {
318+ api.get(`/repos/:owner/:repo/${kind}`, async (c) => {
319+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
320+ const perPage = Math.min(Number(c.req.query("per_page") ?? 30), 100);
321+ const page = Number(c.req.query("page") ?? 1);
322+ const res = await stub.issues({ kind: kind === "pulls" ? "pull" : undefined, state: (c.req.query("state") as "open" | "closed" | "all") ?? "open", label: c.req.query("labels"), author: c.req.query("creator"), q: c.req.query("q"), limit: perPage, offset: (page - 1) * perPage });
323+ c.header("x-total-count", String(res.total));
324+ return c.json(res.items.map((i) => issueJson(c, P(c).owner, P(c).repo, i)));
325+ });
326+}
327+api.post("/repos/:owner/:repo/issues", async (c) => {
328+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
329+ const b = await body<{ title: string; body?: string; labels?: string[]; assignees?: string[] }>(c);
330+ return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.createIssue(b, caller)), 201);
331+});
332+api.get("/repos/:owner/:repo/issues/:number", async (c) => {
333+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
334+ return c.json(issueJson(c, P(c).owner, P(c).repo, (await stub.issue(num(c))).issue));
335+});
336+api.patch("/repos/:owner/:repo/issues/:number", async (c) => {
337+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
338+ const b = await body<{ title?: string; body?: string; state?: "open" | "closed"; state_reason?: string; labels?: string[]; assignees?: string[] }>(c);
339+ return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.updateIssue(num(c), { ...b, stateReason: b.state_reason }, caller)));
340+});
341+api.get("/repos/:owner/:repo/issues/:number/comments", async (c) => {
342+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
343+ return c.json((await stub.issue(num(c))).comments.filter((x) => !x.reviewId).map((x) => ({ id: x.id, body: x.body, user: { login: x.author.login, type: x.author.kind === "agent" ? "Bot" : "User" }, created_at: x.createdAt, updated_at: x.updatedAt })));
344+});
345+api.post("/repos/:owner/:repo/issues/:number/comments", async (c) => {
346+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
347+ const b = await body<{ body: string }>(c);
348+ const x = await stub.addComment(num(c), b.body, caller);
349+ return c.json({ id: x.id, body: x.body, user: { login: x.author.login }, created_at: x.createdAt }, 201);
350+});
351+api.patch("/repos/:owner/:repo/issues/comments/:id", async (c) => {
352+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
353+ await stub.editComment(c.req.param("id"), (await body<{ body: string }>(c)).body, caller);
354+ return c.json({ id: c.req.param("id") });
355+});
356+api.get("/repos/:owner/:repo/labels", async (c) => {
357+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
358+ return c.json(await stub.labels());
359+});
360+api.post("/repos/:owner/:repo/labels", async (c) => {
361+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "triage");
362+ const b = await body<{ name: string; color?: string; description?: string }>(c);
363+ await stub.setLabel(b.name, b.color ?? "888888", b.description ?? "");
364+ return c.json(b, 201);
365+});
366+
367+api.post("/repos/:owner/:repo/pulls", async (c) => {
368+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
369+ const b = await body<{ title: string; body?: string; head: string; base?: string; draft?: boolean; intent?: string; reviewers?: string[] }>(c);
370+ return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.createPull({ ...b, head: b.head.includes(":") ? b.head.split(":")[1]! : b.head }, caller)), 201);
371+});
372+api.get("/repos/:owner/:repo/pulls/:number", async (c) => {
373+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
374+ const n = num(c);
375+ const { issue } = await stub.issue(n);
376+ if (issue.kind !== "pull") throw new HttpError(404, "not a pull request");
377+ const m = await stub.mergeability(n, caller);
378+ return c.json({ ...issueJson(c, P(c).owner, P(c).repo, issue), mergeable: m.mergeable, mergeable_state: m.state, merge_blockers: m.blockers, approvals: m.approvals, required_approvals: m.requiredApprovals, checks: m.checks });
379+});
380+api.patch("/repos/:owner/:repo/pulls/:number", async (c) => {
381+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
382+ const b = await body<{ title?: string; body?: string; state?: "open" | "closed" }>(c);
383+ return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.updateIssue(num(c), b, caller)));
384+});
385+api.get("/repos/:owner/:repo/pulls/:number/files", async (c) => {
386+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
387+ const res = await stub.pullFiles(num(c));
388+ return c.json(res.files.map((f) => ({ filename: f.path, status: f.status === "deleted" ? "removed" : f.status, additions: f.additions, deletions: f.deletions, changes: f.additions + f.deletions, patch: f.patch, binary: f.binary })));
389+});
390+api.get("/repos/:owner/:repo/pulls/:number/commits", async (c) => {
391+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
392+ return c.json(await stub.pullCommits(num(c)));
393+});
394+api.get("/repos/:owner/:repo/pulls/:number/merge", async (c) => {
395+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
396+ return c.json(await stub.mergeability(num(c), caller));
397+});
398+api.put("/repos/:owner/:repo/pulls/:number/merge", async (c) => {
399+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
400+ const b = await body<{ merge_method?: "merge" | "squash" | "rebase"; commit_title?: string; commit_message?: string; sha?: string; delete_branch?: boolean }>(c).catch(() => ({}) as Record<string, undefined>);
401+ return c.json(await stub.mergePull(num(c), { method: b.merge_method, title: b.commit_title, message: b.commit_message, expectedHeadSha: b.sha, deleteBranch: b.delete_branch }, caller));
402+});
403+api.get("/repos/:owner/:repo/pulls/:number/reviews", async (c) => {
404+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
405+ return c.json((await stub.issue(num(c))).reviews.map((r) => ({ id: r.id, state: r.state, body: r.body, commit_id: r.commitId, submitted_at: r.submittedAt, user: { login: r.author.login, type: r.author.kind === "agent" ? "Bot" : "User" } })));
406+});
407+api.post("/repos/:owner/:repo/pulls/:number/reviews", async (c) => {
408+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
409+ const b = await body<{ event?: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; comments?: Array<{ path: string; line: number; side?: "LEFT" | "RIGHT"; body: string }> }>(c);
410+ const r = await stub.submitReview(num(c), { event: b.event ?? "COMMENT", body: b.body, comments: b.comments }, caller);
411+ return c.json({ id: r.id, state: r.state, body: r.body, commit_id: r.commitId, submitted_at: r.submittedAt }, 201);
412+});
413+api.get("/repos/:owner/:repo/pulls/:number/comments", async (c) => {
414+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
415+ return c.json((await stub.issue(num(c))).comments.filter((x) => x.reviewId).map((x) => ({ id: x.id, body: x.body, path: x.path, line: x.line, side: x.side, commit_id: x.commitId, in_reply_to_id: x.inReplyTo, user: { login: x.author.login }, created_at: x.createdAt, resolved: x.resolved })));
416+});
417+api.post("/repos/:owner/:repo/pulls/:number/requested_reviewers", async (c) => {
418+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
419+ const b = await body<{ reviewers: string[] }>(c);
420+ await stub.requestReviewers(num(c), b.reviewers ?? [], caller);
421+ return c.json({ requested_reviewers: b.reviewers }, 201);
422+});
423+
424+// ---------------------------------------------------------------- checks (for plugins and external CI)
425+
426+api.get("/repos/:owner/:repo/commits/:ref/check-runs", async (c) => {
427+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
428+ const sha = await stub.resolve(c.req.param("ref"));
429+ if (!sha) throw new HttpError(404, "ref not found");
430+ const runs = await stub.checks(sha);
431+ return c.json({ total_count: runs.length, check_runs: runs.map((r) => ({ id: r.id, name: r.name, head_sha: r.sha, status: r.status, conclusion: r.conclusion, details_url: r.detailsUrl, output: r.output, app: { slug: r.app }, started_at: r.startedAt, completed_at: r.completedAt })) });
432+});
433+api.post("/repos/:owner/:repo/check-runs", async (c) => {
434+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
435+ const b = await body<{ name: string; head_sha: string; status?: "queued" | "in_progress" | "completed"; conclusion?: string; details_url?: string; output?: { title?: string; summary?: string } }>(c);
436+ const check = await stub.upsertCheck({ sha: b.head_sha, name: b.name, status: b.status ?? "queued", conclusion: b.conclusion, detailsUrl: b.details_url, output: b.output, app: caller.login });
437+ return c.json(check, 201);
438+});
439+
440+// ---------------------------------------------------------------- actions
441+
442+const runJson = (c: AppContext, owner: string, repo: string, r: RunInfo) => ({
443+ id: r.id,
444+ run_number: r.number,
445+ run_attempt: r.attempt,
446+ name: r.workflowName,
447+ display_title: r.title,
448+ path: r.workflowPath,
449+ event: r.event,
450+ head_branch: r.ref.replace(/^refs\/heads\//, ""),
451+ head_sha: r.sha,
452+ status: r.status,
453+ conclusion: r.conclusion,
454+ actor: { login: r.actor.login, type: r.actor.kind === "agent" ? "Bot" : "User" },
455+ created_at: r.createdAt,
456+ updated_at: r.updatedAt,
457+ html_url: `${c.env.PUBLIC_URL}/${owner}/${repo}/actions/runs/${r.id}`,
458+ pull_request_number: r.prNumber,
459+});
460+
461+api.get("/repos/:owner/:repo/actions/workflows", async (c) => {
462+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
463+ const wfs = await stub.workflows();
464+ return c.json({ total_count: wfs.length, workflows: wfs.map((w) => ({ id: w.path, name: w.name, path: w.path, state: w.errors.length ? "invalid" : "active", errors: w.errors, triggers: w.triggers })) });
465+});
466+api.post("/repos/:owner/:repo/actions/workflows/:workflow/dispatches", async (c) => {
467+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
468+ const b = await body<{ ref: string; inputs?: Record<string, unknown> }>(c);
469+ const run = await stub.dispatchWorkflow(decodeURIComponent(c.req.param("workflow")), b.ref, b.inputs ?? {}, caller);
470+ return c.json({ run_id: run.id, html_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${run.id}` }, 201);
471+});
472+// Self-hosted runners poll this to pick up `runs-on: [self-hosted, ...]` jobs (see `diffinery runner`).
473+api.post("/repos/:owner/:repo/actions/runners/claim", async (c) => {
474+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
475+ const b = await body<{ name?: string; labels?: string[] }>(c);
476+ const job = await stub.claimJob(b.name ?? "runner", Array.isArray(b.labels) ? b.labels.map(String) : [], caller);
477+ if (!job) return c.body(null, 204);
478+ return c.json({ job_id: job.jobId, run_id: job.runId, name: job.name, token: job.token, api_url: new URL(c.req.url).origin });
479+});
480+api.get("/repos/:owner/:repo/actions/runs", async (c) => {
481+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
482+ const perPage = Math.min(Number(c.req.query("per_page") ?? 25), 100);
483+ const page = Number(c.req.query("page") ?? 1);
484+ const res = await stub.runs({ workflow: c.req.query("workflow"), branch: c.req.query("branch"), status: c.req.query("status"), event: c.req.query("event"), sha: c.req.query("head_sha"), limit: perPage, offset: (page - 1) * perPage });
485+ return c.json({ total_count: res.total, workflow_runs: res.runs.map((r) => runJson(c, P(c).owner, P(c).repo, r)) });
486+});
487+api.get("/repos/:owner/:repo/actions/runs/:id", async (c) => {
488+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
489+ const r = await stub.run(c.req.param("id"));
490+ return c.json({ ...runJson(c, P(c).owner, P(c).repo, r), jobs: r.jobs });
491+});
492+api.get("/repos/:owner/:repo/actions/runs/:id/jobs", async (c) => {
493+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
494+ const jobs = await stub.runJobs(c.req.param("id"));
495+ return c.json({ total_count: jobs.length, jobs });
496+});
497+api.post("/repos/:owner/:repo/actions/runs/:id/cancel", async (c) => {
498+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
499+ await stub.cancelRun(c.req.param("id"), caller);
500+ return c.json({}, 202);
501+});
502+for (const path of ["rerun", "rerun-failed-jobs"]) {
503+ api.post(`/repos/:owner/:repo/actions/runs/:id/${path}`, async (c) => {
504+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
505+ const r = await stub.rerun(c.req.param("id"), caller);
506+ return c.json({ run_id: r.id }, 201);
507+ });
508+}
509+api.get("/repos/:owner/:repo/actions/jobs/:id", async (c) => {
510+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
511+ return c.json(await stub.job(c.req.param("id")));
512+});
513+api.get("/repos/:owner/:repo/actions/jobs/:id/logs", async (c) => {
514+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
515+ const { lines } = await stub.jobLogs(c.req.param("id"), Number(c.req.query("after") ?? -1));
516+ if (c.req.query("format") === "json") return c.json(lines);
517+ return c.text(lines.map((l) => `${l.t} ${l.text}`).join("\n"));
518+});
519+api.get("/repos/:owner/:repo/actions/runs/:id/artifacts", async (c) => {
520+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
521+ const list = await stub.artifacts(c.req.param("id"));
522+ return c.json({ total_count: list.length, artifacts: list.map((a) => ({ name: a.name, size_in_bytes: a.size, created_at: a.createdAt, archive_download_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${c.req.param("id")}/artifacts/${encodeURIComponent(a.name)}` })) });
523+});
524+api.get("/repos/:owner/:repo/actions/secrets", async (c) => {
525+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
526+ const s = await stub.secretNames();
527+ return c.json({ total_count: s.length, secrets: s.map((x) => ({ name: x.name, updated_at: x.updatedAt })) });
528+});
529+api.put("/repos/:owner/:repo/actions/secrets/:name", async (c) => {
530+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
531+ if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets");
532+ const b = await body<{ value: string }>(c);
533+ if (typeof b.value !== "string") throw new HttpError(422, "value is required (send it over HTTPS; it is encrypted at rest)");
534+ await stub.setSecret(c.req.param("name"), b.value);
535+ return c.body(null, 204);
536+});
537+api.delete("/repos/:owner/:repo/actions/secrets/:name", async (c) => {
538+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
539+ await stub.deleteSecret(c.req.param("name"));
540+ return c.body(null, 204);
541+});
542+api.get("/repos/:owner/:repo/actions/variables", async (c) => {
543+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "write");
544+ return c.json({ variables: Object.entries(await stub.variables()).map(([name, value]) => ({ name, value })) });
545+});
546+api.put("/repos/:owner/:repo/actions/variables/:name", async (c) => {
547+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
548+ await stub.setVariable(c.req.param("name"), (await body<{ value: string }>(c)).value);
549+ return c.body(null, 204);
550+});
551+api.put("/orgs/:org/actions/secrets/:name", async (c) => {
552+ const a = requireAccount(c);
553+ if (a.kind === "agent") throw new HttpError(403, "agents cannot manage secrets");
554+ const dir = c.get("dir");
555+ const org = await dir.accountByLogin(c.req.param("org"));
556+ if (!org || org.kind !== "org") throw new HttpError(404, "not found");
557+ const role = await dir.orgRole(org.id, a.id);
558+ if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "organization admins only");
559+ const name = c.req.param("name").toUpperCase();
560+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) throw new HttpError(422, "invalid secret name");
561+ const { value } = await body<{ value: string }>(c);
562+ await c.env.DB.prepare("INSERT OR REPLACE INTO org_secrets (org_id, name, ciphertext, updated_at) VALUES (?, ?, ?, ?)").bind(org.id, name, await encryptSecret(c.env.MASTER_KEY, `org:${org.id}`, value), new Date().toISOString()).run();
563+ return c.body(null, 204);
564+});
565+
566+// ---------------------------------------------------------------- webhooks
567+
568+api.get("/repos/:owner/:repo/hooks", async (c) => {
569+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
570+ return c.json((await stub.webhooks()).map(({ secret: _s, ...h }) => h));
571+});
572+api.post("/repos/:owner/:repo/hooks", async (c) => {
573+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
574+ const b = await body<{ config: { url: string; secret?: string }; events?: string[] }>(c);
575+ return c.json(await stub.createWebhook(b.config?.url, b.events ?? ["*"], b.config?.secret), 201);
576+});
577+api.delete("/repos/:owner/:repo/hooks/:id", async (c) => {
578+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
579+ await stub.deleteWebhook(c.req.param("id"));
580+ return c.body(null, 204);
581+});
582+
583+// ---------------------------------------------------------------- Diffinery: tokens, agents, sandboxes, notifications
584+
585+api.get("/diffinery/tokens", async (c) => c.json(await c.get("dir").tokens(requireAccount(c).id)));
586+api.post("/diffinery/tokens", async (c) => {
587+ const a = requireAccount(c);
588+ const p = c.get("principal");
589+ if (p.type === "account" && p.via === "token" && !p.scopes.includes("admin")) throw new HttpError(403, "creating tokens requires an admin-scoped token or a browser session");
590+ const b = await body<{ name: string; scopes?: TokenScope[]; repositories?: string[]; expires_in_days?: number }>(c);
591+ let repoIds: string[] | null = null;
592+ if (b.repositories?.length) {
593+ repoIds = [];
594+ for (const full of b.repositories) {
595+ const [o, n] = full.split("/");
596+ const r = o && n ? await c.get("dir").repo(o, n) : null;
597+ if (!r) throw new HttpError(422, `unknown repository ${full}`);
598+ repoIds.push(r.id);
599+ }
600+ }
601+ const t = await c.get("dir").createToken(a, { name: b.name, scopes: b.scopes, repoIds, expiresInDays: b.expires_in_days });
602+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action: "token.create", detail: { name: b.name, scopes: b.scopes } }));
603+ return c.json(t, 201);
604+});
605+api.delete("/diffinery/tokens/:id", async (c) => {
606+ await c.get("dir").revokeToken(requireAccount(c).id, c.req.param("id"));
607+ return c.body(null, 204);
608+});
609+api.get("/diffinery/agents", async (c) => {
610+ const a = requireAccount(c);
611+ const orgs = (await c.get("dir").orgsFor(a.id)).filter((o) => o.role !== "member").map((o) => o.id);
612+ return c.json((await c.get("dir").agentsOwnedBy([a.id, ...orgs])).map((x) => ({ ...userJson(c, x), owner_id: x.owner_id, policy: x.agent_policy ? JSON.parse(x.agent_policy) : {} })));
613+});
614+api.post("/diffinery/agents", async (c) => {
615+ const a = requireAccount(c);
616+ if (a.kind !== "user") throw new HttpError(403, "only people can create agents");
617+ const b = await body<{ login: string; name?: string; org?: string; policy?: Record<string, unknown>; token?: { scopes?: TokenScope[]; expires_in_days?: number } }>(c);
618+ const dir = c.get("dir");
619+ const org = b.org ? await dir.accountByLogin(b.org) : null;
620+ const agent = await dir.createAgent(a, { login: b.login, name: b.name, orgId: org?.id, policy: b.policy });
621+ const token = await dir.createToken(agent, { name: "default", scopes: b.token?.scopes ?? ["write"], expiresInDays: b.token?.expires_in_days ?? 90 });
622+ c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "agent.create", target: agent.login }));
623+ return c.json({ agent: userJson(c, agent), token }, 201);
624+});
625+api.post("/diffinery/agents/:login/tokens", async (c) => {
626+ const a = requireAccount(c);
627+ const dir = c.get("dir");
628+ const agent = await dir.accountByLogin(c.req.param("login"));
629+ if (!agent || agent.kind !== "agent") throw new HttpError(404, "agent not found");
630+ const orgRole = agent.owner_id && agent.owner_id !== a.id ? await dir.orgRole(agent.owner_id, a.id) : null;
631+ if (agent.owner_id !== a.id && orgRole !== "owner" && orgRole !== "admin") throw new HttpError(403, "not your agent");
632+ const b = await body<{ name?: string; scopes?: TokenScope[]; expires_in_days?: number }>(c).catch(() => ({}) as Record<string, undefined>);
633+ return c.json(await dir.createToken(agent, { name: b.name ?? "token", scopes: b.scopes, expiresInDays: b.expires_in_days }), 201);
634+});
635+api.delete("/diffinery/agents/:login", async (c) => {
636+ const a = requireAccount(c);
637+ const dir = c.get("dir");
638+ const agent = await dir.accountByLogin(c.req.param("login"));
639+ if (!agent || agent.kind !== "agent") throw new HttpError(404, "agent not found");
640+ const orgRole = agent.owner_id && agent.owner_id !== a.id ? await dir.orgRole(agent.owner_id, a.id) : null;
641+ if (agent.owner_id !== a.id && orgRole !== "owner" && orgRole !== "admin" && !a.site_admin) throw new HttpError(403, "not your agent");
642+ await dir.setStatus(agent.id, "disabled");
643+ c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "agent.disable", target: agent.login }));
644+ return c.body(null, 204);
645+});
646+
647+api.post("/repos/:owner/:repo/diffinery/sandboxes", async (c) => {
648+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
649+ const b = await body<{ ref?: string }>(c).catch(() => ({}) as { ref?: string });
650+ return c.json(await stub.createSandbox(caller, b.ref), 201);
651+});
652+api.post("/repos/:owner/:repo/diffinery/sandboxes/:id/:action", async (c) => {
653+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
654+ const id = c.req.param("id");
655+ const action = c.req.param("action");
656+ await stub.sandbox(id, caller);
657+ if (action === "close") {
658+ await stub.closeSandbox(id, caller);
659+ return c.body(null, 204);
660+ }
661+ const map: Record<string, string> = { exec: "/exec", read: "/files/read", write: "/files/write" };
662+ if (!map[action]) throw new HttpError(404, "unknown sandbox action");
663+ const res = await c.env.SANDBOX.get(c.env.SANDBOX.idFromName(id)).call(map[action]!, JSON.stringify(await c.req.json().catch(() => ({}))));
664+ return new Response(res.body, { status: res.status, headers: { "content-type": res.json ? "application/json" : "text/plain; charset=utf-8" } });
665+});
666+
667+api.get("/notifications", async (c) => c.json(await c.get("dir").notifications(requireAccount(c).id, c.req.query("all") !== "true")));
668+api.put("/notifications", async (c) => {
669+ await c.get("dir").markRead(requireAccount(c).id);
670+ return c.body(null, 205);
671+});
672+
673+// OIDC discovery for workflow id tokens.
674+export const wellKnown = new Hono<{ Bindings: Env; Variables: AppVars }>();
675+wellKnown.get("/.well-known/openid-configuration", (c) =>
676+ c.json({ issuer: c.env.PUBLIC_URL, jwks_uri: `${c.env.PUBLIC_URL}/.well-known/jwks`, response_types_supported: ["id_token"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["ES256"], claims_supported: ["sub", "aud", "iss", "repository", "ref", "sha", "workflow", "actor", "run_id"] }),
677+);
678+wellKnown.get("/.well-known/jwks", (c) => c.json({ keys: c.env.OIDC_PRIVATE_KEY ? [publicJwk(JSON.parse(c.env.OIDC_PRIVATE_KEY))] : [] }));
679+
680+export { fromUtf8 };
addedapps/diffinery/src/http/git.ts+92−0View file
@@ -1,0 +1,92 @@
1+import { Hono } from "hono";
2+import type { Env } from "../env.js";
3+import type { AppVars } from "../auth/principal.js";
4+import { HttpError } from "../lib/util.js";
5+import { loadRepo } from "./common.js";
6+
7+/**
8+ * Git smart HTTP (protocol v2 for fetch/clone, v0 for push) and the Git LFS
9+ * batch API, at https://diffinery.com/<owner>/<repo>.git
10+ */
11+export const git = new Hono<{ Bindings: Env; Variables: AppVars }>();
12+
13+const noCache = { "cache-control": "no-cache, max-age=0, must-revalidate", pragma: "no-cache" };
14+
15+git.get("/:owner/:repo/info/refs", async (c) => {
16+ const service = c.req.query("service");
17+ if (service !== "git-upload-pack" && service !== "git-receive-pack") throw new HttpError(403, "dumb HTTP protocol is not supported; update git");
18+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), service === "git-receive-pack" ? "write" : "read");
19+ if (service === "git-upload-pack" && !/version=2/.test(c.req.header("git-protocol") ?? "")) {
20+ throw new HttpError(400, "Diffinery requires Git protocol v2 (git 2.26 or newer, or run: git config --global protocol.version 2)");
21+ }
22+ const body = await stub.gitInfoRefs(service);
23+ return new Response(body, { headers: { "content-type": `application/x-${service}-advertisement`, ...noCache } });
24+});
25+
26+git.post("/:owner/:repo/git-upload-pack", async (c) => {
27+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read");
28+ const body = await readBody(c.req.raw);
29+ const out = await stub.gitUploadPack(body);
30+ return new Response(out, { headers: { "content-type": "application/x-git-upload-pack-result", ...noCache } });
31+});
32+
33+git.post("/:owner/:repo/git-receive-pack", async (c) => {
34+ const { stub, caller, repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write");
35+ const body = await readBody(c.req.raw);
36+ const out = await stub.gitReceivePack(body, caller);
37+ c.executionCtx.waitUntil(c.get("dir").touchPushed(repo.id));
38+ return new Response(out, { headers: { "content-type": "application/x-git-receive-pack-result", ...noCache } });
39+});
40+
41+async function readBody(req: Request): Promise<Uint8Array> {
42+ let stream = req.body;
43+ if (!stream) return new Uint8Array(0);
44+ if (req.headers.get("content-encoding") === "gzip") stream = stream.pipeThrough(new DecompressionStream("gzip"));
45+ return new Uint8Array(await new Response(stream).arrayBuffer());
46+}
47+
48+// ---------------------------------------------------------------- Git LFS
49+
50+const lfsKey = (repoId: string, oid: string) => `repos/${repoId}/lfs/${oid.slice(0, 2)}/${oid.slice(2, 4)}/${oid}`;
51+const LFS_TYPE = "application/vnd.git-lfs+json";
52+
53+git.post("/:owner/:repo/info/lfs/objects/batch", async (c) => {
54+ const req = (await c.req.json()) as { operation: "upload" | "download"; objects: Array<{ oid: string; size: number }> };
55+ const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), req.operation === "upload" ? "write" : "read");
56+ const base = `${c.env.PUBLIC_URL}/${c.req.param("owner")}/${c.req.param("repo")}/info/lfs/objects`;
57+ const auth = c.req.header("authorization");
58+ const objects = [];
59+ for (const o of req.objects.slice(0, 1000)) {
60+ if (!/^[0-9a-f]{64}$/.test(o.oid)) {
61+ objects.push({ oid: o.oid, size: o.size, error: { code: 422, message: "invalid oid" } });
62+ continue;
63+ }
64+ const head = await c.env.BUCKET.head(lfsKey(repo.id, o.oid));
65+ if (req.operation === "download") {
66+ if (!head) objects.push({ oid: o.oid, size: o.size, error: { code: 404, message: "object not found" } });
67+ else objects.push({ oid: o.oid, size: head.size, authenticated: true, actions: { download: { href: `${base}/${o.oid}`, header: auth ? { Authorization: auth } : {}, expires_in: 3600 } } });
68+ } else {
69+ objects.push(head ? { oid: o.oid, size: o.size, authenticated: true } : { oid: o.oid, size: o.size, authenticated: true, actions: { upload: { href: `${base}/${o.oid}`, header: auth ? { Authorization: auth } : {}, expires_in: 3600 } } });
70+ }
71+ }
72+ return c.body(JSON.stringify({ transfer: "basic", objects, hash_algo: "sha256" }), 200, { "content-type": LFS_TYPE });
73+});
74+
75+git.get("/:owner/:repo/info/lfs/objects/:oid", async (c) => {
76+ const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read");
77+ const obj = await c.env.BUCKET.get(lfsKey(repo.id, c.req.param("oid")));
78+ if (!obj) throw new HttpError(404, "object not found");
79+ return new Response(obj.body, { headers: { "content-type": "application/octet-stream", "content-length": String(obj.size) } });
80+});
81+
82+git.put("/:owner/:repo/info/lfs/objects/:oid", async (c) => {
83+ const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write");
84+ const oid = c.req.param("oid");
85+ if (!/^[0-9a-f]{64}$/.test(oid)) throw new HttpError(422, "invalid oid");
86+ // Verify the content hash while storing: R2 checks the SHA-256 we pass.
87+ const body = await c.req.arrayBuffer();
88+ const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", body))].map((b) => b.toString(16).padStart(2, "0")).join("");
89+ if (digest !== oid) throw new HttpError(422, "content does not match oid");
90+ await c.env.BUCKET.put(lfsKey(repo.id, oid), body);
91+ return c.body(null, 200);
92+});
addedapps/diffinery/src/http/runner.ts+134−0View file
@@ -1,0 +1,134 @@
1+import { Hono } from "hono";
2+import type { JobCompletion, LogLine, StepReport } from "@diffinery/shared";
3+import type { Env } from "../env.js";
4+import type { AppContext, AppVars } from "../auth/principal.js";
5+import { credentialFrom } from "../auth/principal.js";
6+import { signEs256 } from "../lib/jwt.js";
7+import { HttpError } from "../lib/util.js";
8+import { repoStub } from "./common.js";
9+
10+/** Endpoints the runner agent inside a job container calls. Auth: the job token. */
11+export const runner = new Hono<{ Bindings: Env; Variables: AppVars }>();
12+
13+function jobAuth(req: Request, jobId: string): { token: string; repoId: string } {
14+ const token = credentialFrom(req);
15+ if (!token?.startsWith("djt_")) throw new HttpError(401, "job token required");
16+ const [, repoId, tokJob] = token.split("_");
17+ if (!repoId || tokJob !== jobId) throw new HttpError(401, "token does not match job");
18+ return { token, repoId };
19+}
20+
21+runner.get("/runner/jobs/:jobId", async (c) => {
22+ const jobId = c.req.param("jobId");
23+ const { token, repoId } = jobAuth(c.req.raw, jobId);
24+ return c.json(await repoStub(c.env, repoId).runnerSpec(jobId, token));
25+});
26+
27+runner.post("/runner/jobs/:jobId/logs", async (c) => {
28+ const jobId = c.req.param("jobId");
29+ const { token, repoId } = jobAuth(c.req.raw, jobId);
30+ const { lines } = (await c.req.json()) as { lines: LogLine[] };
31+ await repoStub(c.env, repoId).runnerLogs(jobId, token, lines ?? []);
32+ return c.json({ ok: true });
33+});
34+
35+runner.post("/runner/jobs/:jobId/steps/:index", async (c) => {
36+ const jobId = c.req.param("jobId");
37+ const { token, repoId } = jobAuth(c.req.raw, jobId);
38+ await repoStub(c.env, repoId).runnerStep(jobId, token, Number(c.req.param("index")), (await c.req.json()) as StepReport);
39+ return c.json({ ok: true });
40+});
41+
42+runner.post("/runner/jobs/:jobId/complete", async (c) => {
43+ const jobId = c.req.param("jobId");
44+ const { token, repoId } = jobAuth(c.req.raw, jobId);
45+ await repoStub(c.env, repoId).runnerComplete(jobId, token, (await c.req.json()) as JobCompletion);
46+ return c.json({ ok: true });
47+});
48+
49+// ---- cache (actions/cache compatible semantics, backed by R2)
50+
51+runner.get("/runner/jobs/:jobId/cache", async (c) => {
52+ const jobId = c.req.param("jobId");
53+ const { token, repoId } = jobAuth(c.req.raw, jobId);
54+ const key = c.req.query("key") ?? "";
55+ const restore = (c.req.query("restore") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
56+ const hit = await repoStub(c.env, repoId).cacheLookup(jobId, token, key, restore);
57+ if (!hit) return c.json({ message: "cache miss" }, 404);
58+ return c.json({ key: hit.key, url: `${new URL(c.req.url).origin}/runner/jobs/${jobId}/cache/blob/${encodeURIComponent(hit.key)}` });
59+});
60+
61+runner.get("/runner/jobs/:jobId/cache/blob/:key", async (c) => {
62+ const jobId = c.req.param("jobId");
63+ const { repoId } = jobAuth(c.req.raw, jobId);
64+ const r2Key = await repoStub(c.env, repoId).cacheKeyFor(c.req.param("key"));
65+ const obj = await c.env.BUCKET.get(r2Key);
66+ if (!obj) throw new HttpError(404, "cache entry missing");
67+ return new Response(obj.body, { headers: { "content-type": "application/gzip", "content-length": String(obj.size) } });
68+});
69+
70+runner.put("/runner/jobs/:jobId/cache/:key", async (c) => {
71+ const jobId = c.req.param("jobId");
72+ const { token, repoId } = jobAuth(c.req.raw, jobId);
73+ const key = c.req.param("key");
74+ if (key.length > 512) throw new HttpError(422, "cache key too long");
75+ const stub = repoStub(c.env, repoId);
76+ const r2Key = await stub.cacheKeyFor(key);
77+ if (await c.env.BUCKET.head(r2Key)) return c.json({ message: "cache entry already exists" }, 409);
78+ const put = await c.env.BUCKET.put(r2Key, c.req.raw.body!, { httpMetadata: { contentType: "application/gzip" } });
79+ const evicted = await stub.cacheSaved(jobId, token, key, put?.size ?? 0);
80+ if (evicted.length) await c.env.BUCKET.delete(evicted);
81+ return c.json({ ok: true });
82+});
83+
84+// ---- artifacts
85+
86+runner.put("/runner/jobs/:jobId/artifacts/:name", async (c) => {
87+ const jobId = c.req.param("jobId");
88+ const { token, repoId } = jobAuth(c.req.raw, jobId);
89+ const stub = repoStub(c.env, repoId);
90+ const r2Key = await stub.artifactKeyFor(jobId, c.req.param("name"));
91+ const put = await c.env.BUCKET.put(r2Key, c.req.raw.body!, { httpMetadata: { contentType: "application/gzip" } });
92+ await stub.artifactSaved(jobId, token, c.req.param("name"), put?.size ?? 0);
93+ return c.json({ ok: true });
94+});
95+
96+runner.get("/runner/jobs/:jobId/artifacts/:name", async (c) => {
97+ const jobId = c.req.param("jobId");
98+ const { repoId } = jobAuth(c.req.raw, jobId);
99+ const obj = await c.env.BUCKET.get(await repoStub(c.env, repoId).artifactKeyFor(jobId, c.req.param("name")));
100+ if (!obj) throw new HttpError(404, "artifact not found");
101+ return new Response(obj.body, { headers: { "content-type": "application/gzip" } });
102+});
103+
104+// ---- OIDC id tokens (actions/core getIDToken compatible: GET ?audience= → { value })
105+
106+const oidc = async (c: AppContext) => {
107+ const jobId = c.req.param("jobId")!;
108+ const { token, repoId } = jobAuth(c.req.raw, jobId);
109+ if (!c.env.OIDC_PRIVATE_KEY) throw new HttpError(501, "OIDC is not configured on this Diffinery instance");
110+ const claims = await repoStub(c.env, repoId).oidcClaims(jobId, token);
111+ if (!claims.allowed) throw new HttpError(403, "this job lacks permissions: id-token: write");
112+ const g = claims.github;
113+ const nowS = Math.floor(Date.now() / 1000);
114+ const value = await signEs256(JSON.parse(c.env.OIDC_PRIVATE_KEY), {
115+ iss: c.env.PUBLIC_URL,
116+ aud: c.req.query("audience") ?? c.env.PUBLIC_URL,
117+ sub: `repo:${g.repository}:ref:${g.ref}`,
118+ iat: nowS,
119+ nbf: nowS,
120+ exp: nowS + 600,
121+ repository: g.repository,
122+ repository_owner: g.repository_owner,
123+ ref: g.ref,
124+ sha: g.sha,
125+ workflow: g.workflow,
126+ event_name: g.event_name,
127+ run_id: g.run_id,
128+ actor: g.actor,
129+ job_workflow_ref: g.workflow_ref,
130+ });
131+ return c.json({ value });
132+};
133+runner.get("/runner/jobs/:jobId/oidc", oidc);
134+runner.post("/runner/jobs/:jobId/oidc", oidc);
addedapps/diffinery/src/index.ts+209−0View file
@@ -1,0 +1,209 @@
1+import { Hono } from "hono";
2+import type { DiffineryEvent } from "@diffinery/shared";
3+import { cronMatches } from "@diffinery/workflow-yaml";
4+import type { Env } from "./env.js";
5+import { resolvePrincipal, type AppVars } from "./auth/principal.js";
6+import { Directory } from "./lib/directory.js";
7+import { hmacSha256Hex, newId } from "./lib/util.js";
8+import { errorResponse, loadRepo, repoStub } from "./http/common.js";
9+import { git } from "./http/git.js";
10+import { runner } from "./http/runner.js";
11+import { api, wellKnown } from "./http/api.js";
12+import { mcp } from "./mcp/server.js";
13+import { web } from "./web/routes.js";
14+
15+export { RepoDO } from "./repo/RepoDO.js";
16+export { RunnerContainer, SandboxContainer } from "./runner/containers.js";
17+export { RunWorkflow } from "./runner/workflow.js";
18+
19+const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
20+
21+app.onError((e) => errorResponse(e));
22+
23+// Security headers on every response, then identity: tokens, Access JWT, or anonymous.
24+app.use("*", async (c, next) => {
25+ const dir = new Directory(c.env);
26+ c.set("dir", dir);
27+ c.set("principal", await resolvePrincipal(c.req.raw, c.env, dir));
28+ await next();
29+ if (c.res.status === 101) return;
30+ const h = c.res.headers;
31+ h.set("strict-transport-security", "max-age=63072000; includeSubDomains; preload");
32+ h.set("x-content-type-options", "nosniff");
33+ h.set("referrer-policy", "strict-origin-when-cross-origin");
34+ if (!h.has("x-frame-options")) h.set("x-frame-options", "DENY");
35+ h.set("permissions-policy", "camera=(), microphone=(), geolocation=()");
36+});
37+
38+app.get("/healthz", (c) => c.json({ ok: true }));
39+
40+app.route("/", wellKnown);
41+app.route("/", git);
42+app.route("/", runner);
43+app.route("/", api);
44+app.route("/", mcp);
45+
46+// Live updates: authorise in the Worker, then hand the WebSocket to the repository's Durable Object.
47+app.get("/:owner/:repo/live", async (c) => {
48+ if (c.req.header("upgrade") !== "websocket") return c.text("expected websocket", 426);
49+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"));
50+ return stub.fetch(c.req.raw);
51+});
52+
53+app.get("/:owner/:repo/actions/runs/:runId/artifacts/:name", async (c) => {
54+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"));
55+ const name = c.req.param("name");
56+ if (!(await stub.artifacts(c.req.param("runId"))).some((a) => a.name === name)) return c.json({ message: "artifact not found" }, 404);
57+ const obj = await c.env.BUCKET.get(await stub.artifactKeyForRun(c.req.param("runId"), name));
58+ if (!obj) return c.json({ message: "artifact expired" }, 410);
59+ return new Response(obj.body, {
60+ headers: {
61+ "content-type": "application/gzip",
62+ "content-length": String(obj.size),
63+ "content-disposition": `attachment; filename="${name.replace(/[^\w.-]+/g, "_")}.tar.gz"`,
64+ },
65+ });
66+});
67+
68+app.route("/", web);
69+
70+// ---------------------------------------------------------------- events queue
71+
72+const GITHUB_EVENT: Record<DiffineryEvent["type"], string> = {
73+ push: "push",
74+ pull_request: "pull_request",
75+ issues: "issues",
76+ issue_comment: "issue_comment",
77+ pull_request_review: "pull_request_review",
78+ workflow_run: "workflow_run",
79+ workflow_dispatch: "workflow_dispatch",
80+ audit: "audit",
81+};
82+
83+function webhookPayload(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>) {
84+ const repository = { id: ev.repoId, name: ev.repo, full_name: `${ev.owner}/${ev.repo}`, owner: { login: ev.owner }, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}`, clone_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}.git` };
85+ const sender = { id: ev.actor.id, login: ev.actor.login, type: ev.actor.kind === "agent" ? "Bot" : ev.actor.kind === "system" ? "Bot" : "User", on_behalf_of: ev.actor.onBehalfOf };
86+ const { type: _t, repoId: _r, owner: _o, repo: _n, actor: _a, ...rest } = ev;
87+ const action = "action" in ev ? (ev.action === "merged" ? "closed" : ev.action) : undefined;
88+ const extra: Record<string, unknown> = {};
89+ if (ev.type === "pull_request") {
90+ extra.pull_request = { number: ev.number, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/pull/${ev.number}`, head: { ref: ev.headRef, sha: ev.headSha }, base: { ref: ev.baseRef }, merged: ev.action === "merged" };
91+ }
92+ if (ev.type === "issues" || ev.type === "issue_comment") extra.issue = { number: ev.number, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/issues/${ev.number}` };
93+ return { ...rest, action, repository, sender, ...extra };
94+}
95+
96+async function deliverWebhooks(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>): Promise<void> {
97+ const stub = repoStub(env, ev.repoId);
98+ const hooks = (await stub.webhooks()).filter((h) => h.active && (h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type])));
99+ if (!hooks.length) return;
100+ const body = JSON.stringify(webhookPayload(env, ev));
101+ await Promise.all(
102+ hooks.map(async (h) => {
103+ const delivery = newId();
104+ let status = 0;
105+ try {
106+ const res = await fetch(h.url, {
107+ method: "POST",
108+ headers: {
109+ "content-type": "application/json",
110+ "user-agent": "Diffinery-Hookshot/1",
111+ "x-github-event": GITHUB_EVENT[ev.type],
112+ "x-github-delivery": delivery,
113+ "x-diffinery-event": ev.type,
114+ "x-hub-signature-256": `sha256=${await hmacSha256Hex(h.secret, body)}`,
115+ },
116+ body,
117+ redirect: "manual",
118+ signal: AbortSignal.timeout(10_000),
119+ });
120+ status = res.status;
121+ } catch {
122+ status = 0;
123+ }
124+ await stub.recordDelivery(h.id, status);
125+ }),
126+ );
127+}
128+
129+/** People who should hear about an event: participants, assignees and requested reviewers, minus the actor. */
130+async function notifyParticipants(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> {
131+ if (ev.type === "workflow_run") {
132+ if (ev.action !== "completed" || ev.conclusion !== "failure" || ev.actor.kind === "system") return;
133+ await dir.notify([ev.actor.id], { repoId: ev.repoId, kind: "run_failed", title: `Workflow run failed in ${ev.owner}/${ev.repo}`, url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${ev.runId}` });
134+ return;
135+ }
136+ if (ev.type !== "pull_request" && ev.type !== "issues" && ev.type !== "issue_comment" && ev.type !== "pull_request_review") return;
137+ if (ev.type === "pull_request" && ev.action === "synchronize") return;
138+ const { issue, comments, reviews } = await repoStub(env, ev.repoId).issue(ev.number);
139+ const ids = new Set<string>([issue.author.id, ...comments.map((c) => c.author.id), ...reviews.map((r) => r.author.id)]);
140+ for (const login of [...issue.assignees, ...(issue.pull?.requestedReviewers ?? [])]) {
141+ const a = await dir.accountByLogin(login);
142+ if (a) ids.add(a.id);
143+ }
144+ ids.delete(ev.actor.id);
145+ if (!ids.size) return;
146+ const kind = issue.kind === "pull" ? "pull" : "issues";
147+ const verb = ev.type === "issue_comment" ? "commented on" : ev.type === "pull_request_review" ? `reviewed (${ev.state.toLowerCase()})` : ev.action;
148+ await dir.notify([...ids].slice(0, 100), {
149+ repoId: ev.repoId,
150+ kind: ev.type,
151+ title: `${ev.actor.login} ${verb} ${ev.owner}/${ev.repo}#${ev.number}: ${issue.title}`,
152+ url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/${kind}/${ev.number}`,
153+ });
154+}
155+
156+async function handleEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> {
157+ if (ev.type === "audit") {
158+ await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.action, target: ev.target, detail: ev.detail, at: ev.at });
159+ return;
160+ }
161+ if (ev.type === "push") {
162+ await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.forced ? "git.force_push" : ev.deleted ? "git.delete_ref" : "git.push", target: `${ev.owner}/${ev.repo}:${ev.ref}`, detail: { before: ev.before, after: ev.after }, at: ev.at });
163+ }
164+ if (ev.type === "pull_request" && ev.action === "merged") {
165+ await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: "pull.merge", target: `${ev.owner}/${ev.repo}#${ev.number}`, at: ev.at });
166+ }
167+ await Promise.all([deliverWebhooks(env, ev), notifyParticipants(env, dir, ev)]);
168+}
169+
170+async function queue(batch: MessageBatch<DiffineryEvent>, env: Env): Promise<void> {
171+ const dir = new Directory(env);
172+ // Keep an append-only copy of every event in R2 for long-term audit retention.
173+ const day = new Date().toISOString().slice(0, 10).replaceAll("-", "/");
174+ await env.BUCKET.put(`events/${day}/${Date.now()}-${newId()}.jsonl`, batch.messages.map((m) => JSON.stringify(m.body)).join("\n")).catch((e) => console.error("event archive failed", e));
175+ for (const msg of batch.messages) {
176+ try {
177+ await handleEvent(env, dir, msg.body);
178+ msg.ack();
179+ } catch (e) {
180+ console.error("event handling failed", msg.body.type, e);
181+ msg.retry({ delaySeconds: Math.min(300, 10 * 2 ** msg.attempts) });
182+ }
183+ }
184+}
185+
186+// ---------------------------------------------------------------- cron: workflow schedules
187+
188+async function scheduled(controller: ScheduledController, env: Env): Promise<void> {
189+ // The trigger runs every 5 minutes (GitHub's minimum schedule interval); fire anything due in the window.
190+ const end = new Date(controller.scheduledTime);
191+ end.setUTCSeconds(0, 0);
192+ const minutes = Array.from({ length: 5 }, (_, i) => new Date(end.getTime() - i * 60_000));
193+ const { results } = await env.DB.prepare("SELECT s.repo_id, s.workflow_path, s.cron FROM schedules s JOIN repos r ON r.id = s.repo_id WHERE r.archived = 0").all<{ repo_id: string; workflow_path: string; cron: string }>();
194+ await Promise.all(
195+ results.map(async (s) => {
196+ try {
197+ if (minutes.some((m) => cronMatches(s.cron, m))) await repoStub(env, s.repo_id).scheduleTick(s.workflow_path, s.cron);
198+ } catch (e) {
199+ console.error("schedule failed", s.repo_id, s.workflow_path, e);
200+ }
201+ }),
202+ );
203+}
204+
205+export default {
206+ fetch: app.fetch,
207+ queue,
208+ scheduled,
209+} satisfies ExportedHandler<Env, DiffineryEvent>;
modifiedapps/diffinery/src/lib/directory.ts+4−4View file
@@ -195,7 +195,7 @@
195195
196196 async tokens(accountId: string): Promise<Array<{ id: string; name: string; prefix: string; scopes: string[]; repoIds: string[] | null; expiresAt: string; lastUsedAt: string | null; createdAt: string }>> {
197197 const r = await this.db.prepare("SELECT * FROM tokens WHERE account_id = ? AND revoked_at IS NULL ORDER BY created_at DESC").bind(accountId).all<Record<string, string | null>>();
198- return r.results.map((t) => ({ id: t.id!, name: t.name!, prefix: t.prefix!, scopes: JSON.parse(t.scopes!), repoIds: t.repo_ids ? JSON.parse(t.repo_ids) : null, expiresAt: t.expires_at!, lastUsedAt: t.last_used_at, createdAt: t.created_at! }));
198+ return r.results.map((t) => ({ id: t.id!, name: t.name!, prefix: t.prefix!, scopes: JSON.parse(t.scopes!), repoIds: t.repo_ids ? JSON.parse(t.repo_ids) : null, expiresAt: t.expires_at!, lastUsedAt: t.last_used_at ?? null, createdAt: t.created_at! }));
199199 }
200200
201201 async revokeToken(accountId: string, id: string): Promise<void> {
@@ -230,7 +230,7 @@
230230
231231 async invites(filter: { orgId?: string }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null }>> {
232232 const r = await this.db.prepare("SELECT * FROM invites WHERE (? IS NULL OR org_id = ?) ORDER BY created_at DESC LIMIT 200").bind(filter.orgId ?? null, filter.orgId ?? null).all<Record<string, string | null>>();
233- return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at }));
233+ return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at ?? null }));
234234 }
235235
236236 async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> {
@@ -238,7 +238,7 @@
238238 if (!inv || inv.accepted_at || inv.expires_at! < now()) throw new HttpError(404, "this invite is invalid or has expired");
239239 if (inv.email && user.email && inv.email.toLowerCase() !== user.email.toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`);
240240 await this.applyInvite(inv, user);
241- return { orgId: inv.org_id, repoId: inv.repo_id };
241+ return { orgId: inv.org_id ?? null, repoId: inv.repo_id ?? null };
242242 }
243243
244244 private async acceptInvitesForEmail(userId: string, email: string): Promise<void> {
@@ -383,7 +383,7 @@
383383
384384 async notifications(accountId: string, unreadOnly = false): Promise<Array<{ id: string; kind: string; title: string; url: string; createdAt: string; readAt: string | null }>> {
385385 const r = await this.db.prepare(`SELECT * FROM notifications WHERE account_id = ? ${unreadOnly ? "AND read_at IS NULL" : ""} ORDER BY created_at DESC LIMIT 100`).bind(accountId).all<Record<string, string | null>>();
386- return r.results.map((n) => ({ id: n.id!, kind: n.kind!, title: n.title!, url: n.url!, createdAt: n.created_at!, readAt: n.read_at }));
386+ return r.results.map((n) => ({ id: n.id!, kind: n.kind!, title: n.title!, url: n.url!, createdAt: n.created_at!, readAt: n.read_at ?? null }));
387387 }
388388
389389 async markRead(accountId: string, id?: string): Promise<void> {
addedapps/diffinery/src/mcp/server.ts+457−0View file
@@ -1,0 +1,457 @@
1+import { Hono } from "hono";
2+import type { Env } from "../env.js";
3+import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js";
4+import { HttpError, b64, fromB64 } from "../lib/util.js";
5+import type { Role } from "../repo/types.js";
6+import { loadRepo } from "../http/common.js";
7+
8+/**
9+ * Model Context Protocol server (Streamable HTTP transport, JSON responses).
10+ * Agents connect with a Diffinery token: `Authorization: Bearer dfy_…`.
11+ * Every tool runs through the same permission checks, branch protection and
12+ * agent guardrails as the REST API and git, so an agent can never do more
13+ * than its token, its owner and the repository's rules allow.
14+ */
15+export const mcp = new Hono<{ Bindings: Env; Variables: AppVars }>();
16+
17+const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
18+
19+type Args = Record<string, unknown>;
20+interface Tool {
21+ description: string;
22+ input: Record<string, { type: string; description?: string; items?: unknown; enum?: string[] }>;
23+ required?: string[];
24+ readOnly?: boolean;
25+ run: (c: AppContext, a: Args) => Promise<unknown>;
26+}
27+
28+const str = (a: Args, k: string, def?: string): string => {
29+ const v = a[k];
30+ if (v === undefined || v === null || v === "") {
31+ if (def !== undefined) return def;
32+ throw new HttpError(422, `${k} is required`);
33+ }
34+ if (typeof v !== "string") throw new HttpError(422, `${k} must be a string`);
35+ return v;
36+};
37+const optStr = (a: Args, k: string) => (typeof a[k] === "string" && a[k] !== "" ? (a[k] as string) : undefined);
38+const int = (a: Args, k: string) => {
39+ const n = Number(a[k]);
40+ if (!Number.isInteger(n) || n < 1) throw new HttpError(422, `${k} must be a positive integer`);
41+ return n;
42+};
43+const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min);
44+const OWNER_REPO = {
45+ owner: { type: "string", description: "Repository owner (user or org login)" },
46+ repo: { type: "string", description: "Repository name" },
47+};
48+const decoder = new TextDecoder();
49+
50+const TOOLS: Record<string, Tool> = {
51+ whoami: {
52+ description: "Show the account this token acts as, including the human an agent works on behalf of.",
53+ input: {},
54+ readOnly: true,
55+ run: async (c) => {
56+ const a = requireAccount(c);
57+ const owner = a.owner_id ? await c.get("dir").account(a.owner_id) : null;
58+ return { login: a.login, kind: a.kind, on_behalf_of: owner?.login ?? null };
59+ },
60+ },
61+ list_repos: {
62+ description: "List repositories visible to you, optionally filtered by owner.",
63+ input: { owner: { type: "string" } },
64+ readOnly: true,
65+ run: async (c, a) => {
66+ const me = requireAccount(c);
67+ const dir = c.get("dir");
68+ const owner = optStr(a, "owner") ? await dir.accountByLogin(optStr(a, "owner")!) : null;
69+ const repos = await dir.reposVisibleTo(me, owner?.id);
70+ return repos.map((r) => ({ full_name: `${r.owner_login}/${r.name}`, description: r.description, default_branch: r.default_branch, visibility: r.visibility, pushed_at: r.pushed_at }));
71+ },
72+ },
73+ list_tree: {
74+ description: "List files and directories at a path in a repository.",
75+ input: { ...OWNER_REPO, ref: { type: "string", description: "Branch, tag or commit (default: default branch)" }, path: { type: "string" } },
76+ required: ["owner", "repo"],
77+ readOnly: true,
78+ run: async (c, a) => {
79+ const { stub } = await repoOf(c, a);
80+ const t = await stub.tree(optStr(a, "ref") ?? "HEAD", optStr(a, "path") ?? "");
81+ if (!t) throw new HttpError(404, "path not found");
82+ return { sha: t.sha, items: t.items.map((i) => ({ path: i.path, type: i.type })) };
83+ },
84+ },
85+ get_file: {
86+ description: "Read a file's contents.",
87+ input: { ...OWNER_REPO, path: { type: "string" }, ref: { type: "string" } },
88+ required: ["owner", "repo", "path"],
89+ readOnly: true,
90+ run: async (c, a) => {
91+ const { stub } = await repoOf(c, a);
92+ const b = await stub.blob(optStr(a, "ref") ?? "HEAD", str(a, "path"));
93+ if (!b) throw new HttpError(404, "file not found");
94+ if (b.binary) return { path: str(a, "path"), commit: b.sha, size: b.size, binary: true };
95+ return { path: str(a, "path"), commit: b.sha, size: b.size, content: decoder.decode(b.content) };
96+ },
97+ },
98+ list_branches: {
99+ description: "List branches, marking the default and protected ones.",
100+ input: { ...OWNER_REPO },
101+ required: ["owner", "repo"],
102+ readOnly: true,
103+ run: async (c, a) => (await repoOf(c, a)).stub.branches(),
104+ },
105+ create_branch: {
106+ description: "Create a branch from another branch, tag or commit.",
107+ input: { ...OWNER_REPO, branch: { type: "string" }, from: { type: "string", description: "Default: the default branch" } },
108+ required: ["owner", "repo", "branch"],
109+ run: async (c, a) => {
110+ const { stub, caller } = await repoOf(c, a, "write");
111+ const sha = await stub.resolve(optStr(a, "from") ?? "HEAD");
112+ if (!sha) throw new HttpError(404, "source ref not found");
113+ await stub.createRef(`refs/heads/${str(a, "branch")}`, sha, caller);
114+ return { branch: str(a, "branch"), sha };
115+ },
116+ },
117+ commit_files: {
118+ description:
119+ "Create one commit that writes and deletes files on a branch, without a git client. Pass expected_head_sha to avoid racing other writers; pass create_branch_from to start a new branch.",
120+ input: {
121+ ...OWNER_REPO,
122+ branch: { type: "string" },
123+ message: { type: "string" },
124+ files: { type: "array", description: "[{ path, content, encoding?: 'utf-8'|'base64', executable?: boolean }]", items: { type: "object" } },
125+ deletes: { type: "array", items: { type: "string" } },
126+ expected_head_sha: { type: "string" },
127+ create_branch_from: { type: "string" },
128+ },
129+ required: ["owner", "repo", "branch", "message"],
130+ run: async (c, a) => {
131+ const { stub, caller } = await repoOf(c, a, "write");
132+ const files = Array.isArray(a.files) ? (a.files as Array<{ path: string; content?: string; encoding?: string; executable?: boolean }>) : [];
133+ const deletes = Array.isArray(a.deletes) ? (a.deletes as string[]) : [];
134+ if (!files.length && !deletes.length) throw new HttpError(422, "nothing to commit");
135+ const changes = [
136+ ...files.map((f) => ({
137+ path: String(f.path),
138+ mode: f.executable ? ("100755" as const) : ("100644" as const),
139+ content: f.encoding === "base64" ? fromB64(f.content ?? "") : new TextEncoder().encode(f.content ?? ""),
140+ })),
141+ ...deletes.map((p) => ({ path: String(p), delete: true })),
142+ ];
143+ return stub.commitFiles({ branch: str(a, "branch"), message: str(a, "message"), changes, expectedHeadSha: optStr(a, "expected_head_sha"), createBranchFrom: optStr(a, "create_branch_from") }, caller);
144+ },
145+ },
146+ compare: {
147+ description: "Compare two refs: commits ahead/behind and a unified diff.",
148+ input: { ...OWNER_REPO, base: { type: "string" }, head: { type: "string" } },
149+ required: ["owner", "repo", "base", "head"],
150+ readOnly: true,
151+ run: async (c, a) => (await repoOf(c, a)).stub.compare(str(a, "base"), str(a, "head")),
152+ },
153+ list_pull_requests: {
154+ description: "List pull requests.",
155+ input: { ...OWNER_REPO, state: { type: "string", enum: ["open", "closed", "all"] } },
156+ required: ["owner", "repo"],
157+ readOnly: true,
158+ run: async (c, a) => {
159+ const { stub } = await repoOf(c, a);
160+ const { items, total } = await stub.issues({ kind: "pull", state: (optStr(a, "state") as "open") ?? "open", limit: 100 });
161+ return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, head: i.pull?.head, base: i.pull?.base, draft: i.pull?.draft, merged: !!i.pull?.mergedAt })) };
162+ },
163+ },
164+ get_pull_request: {
165+ description: "Get a pull request with its conversation, reviews, mergeability and (optionally) the full diff.",
166+ input: { ...OWNER_REPO, number: { type: "integer" }, include_diff: { type: "boolean" } },
167+ required: ["owner", "repo", "number"],
168+ readOnly: true,
169+ run: async (c, a) => {
170+ const { stub, caller } = await repoOf(c, a);
171+ const n = int(a, "number");
172+ const [detail, mergeability] = await Promise.all([stub.issue(n), stub.mergeability(n, caller).catch(() => null)]);
173+ const files = a.include_diff ? await stub.pullFiles(n) : undefined;
174+ return { ...detail, mergeability, files };
175+ },
176+ },
177+ create_pull_request: {
178+ description: "Open a pull request. Use `intent` to explain the goal for reviewers (agents should always set it).",
179+ input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, head: { type: "string" }, base: { type: "string" }, draft: { type: "boolean" }, intent: { type: "string" }, reviewers: { type: "array", items: { type: "string" } } },
180+ required: ["owner", "repo", "title", "head"],
181+ run: async (c, a) => {
182+ const { stub, caller } = await repoOf(c, a, "write");
183+ return stub.createPull(
184+ { title: str(a, "title"), body: optStr(a, "body"), head: str(a, "head"), base: optStr(a, "base"), draft: !!a.draft, intent: optStr(a, "intent"), reviewers: Array.isArray(a.reviewers) ? (a.reviewers as string[]) : undefined },
185+ caller,
186+ );
187+ },
188+ },
189+ comment: {
190+ description: "Comment on an issue or pull request.",
191+ input: { ...OWNER_REPO, number: { type: "integer" }, body: { type: "string" } },
192+ required: ["owner", "repo", "number", "body"],
193+ run: async (c, a) => {
194+ const { stub, caller } = await repoOf(c, a, "triage");
195+ return stub.addComment(int(a, "number"), str(a, "body"), caller);
196+ },
197+ },
198+ review_pull_request: {
199+ description: "Submit a review: APPROVE, REQUEST_CHANGES or COMMENT, with optional line comments [{ path, line, side?, body }]. Agent approvals never count toward required human approvals of agent-authored PRs.",
200+ input: { ...OWNER_REPO, number: { type: "integer" }, event: { type: "string", enum: ["APPROVE", "REQUEST_CHANGES", "COMMENT"] }, body: { type: "string" }, comments: { type: "array", items: { type: "object" } } },
201+ required: ["owner", "repo", "number", "event"],
202+ run: async (c, a) => {
203+ const { stub, caller } = await repoOf(c, a, "read");
204+ return stub.submitReview(int(a, "number"), { event: str(a, "event") as "COMMENT", body: optStr(a, "body"), comments: Array.isArray(a.comments) ? (a.comments as []) : undefined }, caller);
205+ },
206+ },
207+ merge_pull_request: {
208+ description: "Merge a pull request when it is mergeable (checks, approvals and branch protection are enforced).",
209+ input: { ...OWNER_REPO, number: { type: "integer" }, method: { type: "string", enum: ["merge", "squash", "rebase"] }, expected_head_sha: { type: "string" }, delete_branch: { type: "boolean" } },
210+ required: ["owner", "repo", "number"],
211+ run: async (c, a) => {
212+ const { stub, caller } = await repoOf(c, a, "write");
213+ return stub.mergePull(int(a, "number"), { method: (optStr(a, "method") as "merge") ?? "merge", expectedHeadSha: optStr(a, "expected_head_sha"), deleteBranch: !!a.delete_branch }, caller);
214+ },
215+ },
216+ list_issues: {
217+ description: "List or search issues.",
218+ input: { ...OWNER_REPO, state: { type: "string", enum: ["open", "closed", "all"] }, q: { type: "string" }, label: { type: "string" } },
219+ required: ["owner", "repo"],
220+ readOnly: true,
221+ run: async (c, a) => {
222+ const { stub } = await repoOf(c, a);
223+ const { items, total } = await stub.issues({ kind: "issue", state: (optStr(a, "state") as "open") ?? "open", q: optStr(a, "q"), label: optStr(a, "label"), limit: 100 });
224+ return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, labels: i.labels })) };
225+ },
226+ },
227+ get_issue: {
228+ description: "Get an issue with its comments.",
229+ input: { ...OWNER_REPO, number: { type: "integer" } },
230+ required: ["owner", "repo", "number"],
231+ readOnly: true,
232+ run: async (c, a) => (await repoOf(c, a)).stub.issue(int(a, "number")),
233+ },
234+ create_issue: {
235+ description: "Open an issue.",
236+ input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, labels: { type: "array", items: { type: "string" } } },
237+ required: ["owner", "repo", "title"],
238+ run: async (c, a) => {
239+ const { stub, caller } = await repoOf(c, a, "read");
240+ return stub.createIssue({ title: str(a, "title"), body: optStr(a, "body"), labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller);
241+ },
242+ },
243+ update_issue: {
244+ description: "Update an issue or pull request: title, body, state (open/closed), labels.",
245+ input: { ...OWNER_REPO, number: { type: "integer" }, title: { type: "string" }, body: { type: "string" }, state: { type: "string", enum: ["open", "closed"] }, labels: { type: "array", items: { type: "string" } } },
246+ required: ["owner", "repo", "number"],
247+ run: async (c, a) => {
248+ const { stub, caller } = await repoOf(c, a, "triage");
249+ return stub.updateIssue(int(a, "number"), { title: optStr(a, "title"), body: optStr(a, "body"), state: optStr(a, "state") as "open" | undefined, labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller);
250+ },
251+ },
252+ list_workflow_runs: {
253+ description: "List workflow runs, newest first. Filter by branch, commit sha, PR number or status.",
254+ input: { ...OWNER_REPO, branch: { type: "string" }, sha: { type: "string" }, pr_number: { type: "integer" }, status: { type: "string" } },
255+ required: ["owner", "repo"],
256+ readOnly: true,
257+ run: async (c, a) => {
258+ const { stub } = await repoOf(c, a);
259+ return stub.runs({ branch: optStr(a, "branch"), sha: optStr(a, "sha"), status: optStr(a, "status"), prNumber: a.pr_number ? int(a, "pr_number") : undefined, limit: 30 });
260+ },
261+ },
262+ get_workflow_run: {
263+ description: "Get a run with its jobs, their status, conclusion and failure reason.",
264+ input: { ...OWNER_REPO, run_id: { type: "string" } },
265+ required: ["owner", "repo", "run_id"],
266+ readOnly: true,
267+ run: async (c, a) => (await repoOf(c, a)).stub.run(str(a, "run_id")),
268+ },
269+ get_job_logs: {
270+ description: "Fetch a job's log as plain text. Use `tail` to limit to the last N lines (default 400).",
271+ input: { ...OWNER_REPO, job_id: { type: "string" }, tail: { type: "integer" } },
272+ required: ["owner", "repo", "job_id"],
273+ readOnly: true,
274+ run: async (c, a) => {
275+ const { stub } = await repoOf(c, a);
276+ const { lines, complete } = await stub.jobLogs(str(a, "job_id"));
277+ const tail = a.tail ? int(a, "tail") : 400;
278+ return { complete, truncated: lines.length > tail, log: lines.slice(-tail).map((l) => l.text).join("\n") };
279+ },
280+ },
281+ rerun_workflow: {
282+ description: "Re-run a workflow run.",
283+ input: { ...OWNER_REPO, run_id: { type: "string" } },
284+ required: ["owner", "repo", "run_id"],
285+ run: async (c, a) => {
286+ const { stub, caller } = await repoOf(c, a, "write");
287+ return stub.rerun(str(a, "run_id"), caller);
288+ },
289+ },
290+ cancel_workflow: {
291+ description: "Cancel a queued or running workflow run.",
292+ input: { ...OWNER_REPO, run_id: { type: "string" } },
293+ required: ["owner", "repo", "run_id"],
294+ run: async (c, a) => {
295+ const { stub, caller } = await repoOf(c, a, "write");
296+ await stub.cancelRun(str(a, "run_id"), caller);
297+ return { cancelled: true };
298+ },
299+ },
300+ dispatch_workflow: {
301+ description: "Start a workflow_dispatch run, e.g. workflow 'deploy.yml' on ref 'main' with inputs.",
302+ input: { ...OWNER_REPO, workflow: { type: "string" }, ref: { type: "string" }, inputs: { type: "object" } },
303+ required: ["owner", "repo", "workflow"],
304+ run: async (c, a) => {
305+ const { stub, caller, repo } = await repoOf(c, a, "write");
306+ return stub.dispatchWorkflow(str(a, "workflow"), optStr(a, "ref") ?? repo.default_branch, (a.inputs as Record<string, unknown>) ?? {}, caller);
307+ },
308+ },
309+ sandbox_create: {
310+ description: "Start an isolated Linux sandbox with the repository checked out (git push from inside it uses your permissions). Returns a sandbox id.",
311+ input: { ...OWNER_REPO, ref: { type: "string" } },
312+ required: ["owner", "repo"],
313+ run: async (c, a) => {
314+ const { stub, caller } = await repoOf(c, a, "write");
315+ return stub.createSandbox(caller, optStr(a, "ref"));
316+ },
317+ },
318+ sandbox_exec: {
319+ description: "Run a shell command in a sandbox. Returns exit code, stdout and stderr.",
320+ input: { ...OWNER_REPO, sandbox_id: { type: "string" }, command: { type: "string" }, cwd: { type: "string" }, timeout_seconds: { type: "integer" } },
321+ required: ["owner", "repo", "sandbox_id", "command"],
322+ run: (c, a) => sandboxCall(c, a, "/exec", { cmd: str(a, "command"), cwd: optStr(a, "cwd"), timeoutMs: a.timeout_seconds ? int(a, "timeout_seconds") * 1000 : undefined }),
323+ },
324+ sandbox_read_file: {
325+ description: "Read a file inside a sandbox.",
326+ input: { ...OWNER_REPO, sandbox_id: { type: "string" }, path: { type: "string" } },
327+ required: ["owner", "repo", "sandbox_id", "path"],
328+ run: async (c, a) => {
329+ const r = (await sandboxCall(c, a, "/files/read", { path: str(a, "path") })) as { path: string; size: number; content: string };
330+ const bytes = fromB64(r.content);
331+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
332+ try {
333+ return { path: r.path, size: r.size, content: text.decode(bytes) };
334+ } catch {
335+ return { path: r.path, size: r.size, encoding: "base64", content: r.content };
336+ }
337+ },
338+ },
339+ sandbox_write_file: {
340+ description: "Write a file inside a sandbox.",
341+ input: { ...OWNER_REPO, sandbox_id: { type: "string" }, path: { type: "string" }, content: { type: "string" } },
342+ required: ["owner", "repo", "sandbox_id", "path", "content"],
343+ run: (c, a) => sandboxCall(c, a, "/files/write", { path: str(a, "path"), content: b64(new TextEncoder().encode(str(a, "content", ""))) }),
344+ },
345+ sandbox_close: {
346+ description: "Shut down a sandbox.",
347+ input: { ...OWNER_REPO, sandbox_id: { type: "string" } },
348+ required: ["owner", "repo", "sandbox_id"],
349+ run: async (c, a) => {
350+ const { stub, caller } = await repoOf(c, a, "write");
351+ await stub.closeSandbox(str(a, "sandbox_id"), caller);
352+ return { closed: true };
353+ },
354+ },
355+};
356+
357+async function sandboxCall(c: AppContext, a: Args, path: string, payload: unknown) {
358+ const { stub, caller } = await repoOf(c, a, "write");
359+ const id = str(a, "sandbox_id");
360+ await stub.sandbox(id, caller);
361+ const res = await c.env.SANDBOX.get(c.env.SANDBOX.idFromName(id)).call(path, JSON.stringify(payload));
362+ let out: unknown = res.body;
363+ try {
364+ out = JSON.parse(res.body);
365+ } catch {
366+ /* plain text */
367+ }
368+ if (res.status >= 400) throw new HttpError(res.status, out && typeof out === "object" && "error" in out ? String((out as { error: unknown }).error) : res.body || "sandbox error");
369+ return out;
370+}
371+
372+interface RpcRequest {
373+ jsonrpc: "2.0";
374+ id?: string | number | null;
375+ method: string;
376+ params?: Record<string, unknown>;
377+}
378+
379+const rpcError = (id: RpcRequest["id"], code: number, message: string) => ({ jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } });
380+
381+async function handle(c: AppContext, req: RpcRequest): Promise<unknown | null> {
382+ const isNotification = req.id === undefined;
383+ switch (req.method) {
384+ case "initialize": {
385+ const asked = String(req.params?.protocolVersion ?? "");
386+ return {
387+ jsonrpc: "2.0",
388+ id: req.id,
389+ result: {
390+ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
391+ capabilities: { tools: { listChanged: false } },
392+ serverInfo: { name: "diffinery", title: "Diffinery", version: "0.1.0" },
393+ instructions:
394+ "Diffinery is a git forge built for agents. Read code with get_file/list_tree, change it with commit_files on a branch, open a pull request with an `intent`, watch CI with list_workflow_runs/get_job_logs, and use sandboxes to build and test. Protected branches and required human reviews are enforced server-side.",
395+ },
396+ };
397+ }
398+ case "notifications/initialized":
399+ case "notifications/cancelled":
400+ return null;
401+ case "ping":
402+ return { jsonrpc: "2.0", id: req.id, result: {} };
403+ case "tools/list":
404+ return {
405+ jsonrpc: "2.0",
406+ id: req.id,
407+ result: {
408+ tools: Object.entries(TOOLS).map(([name, t]) => ({
409+ name,
410+ description: t.description,
411+ inputSchema: { type: "object", properties: t.input, required: t.required ?? [] },
412+ annotations: { readOnlyHint: !!t.readOnly },
413+ })),
414+ },
415+ };
416+ case "tools/call": {
417+ const name = String(req.params?.name ?? "");
418+ const tool = TOOLS[name];
419+ if (!tool) return rpcError(req.id, -32602, `unknown tool: ${name}`);
420+ try {
421+ const out = await tool.run(c, (req.params?.arguments as Args) ?? {});
422+ return { jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out && typeof out === "object" && !Array.isArray(out) ? out : { result: out }, isError: false } };
423+ } catch (e) {
424+ const msg = e instanceof HttpError ? e.detail : String((e as Error)?.message ?? e).replace(/^\d{3}: /, "");
425+ return { jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: msg }], isError: true } };
426+ }
427+ }
428+ default:
429+ return isNotification ? null : rpcError(req.id, -32601, `method not found: ${req.method}`);
430+ }
431+}
432+
433+mcp.get("/mcp", (c) => c.json({ message: "Diffinery MCP: POST JSON-RPC here with Authorization: Bearer <token>" }, 405, { allow: "POST" }));
434+mcp.delete("/mcp", (c) => c.body(null, 405));
435+mcp.post("/mcp", async (c) => {
436+ // Every MCP call needs an active account (a token for an agent or a person).
437+ requireAccount(c);
438+ let payload: unknown;
439+ try {
440+ payload = await c.req.json();
441+ } catch {
442+ return c.json(rpcError(null, -32700, "parse error"), 400);
443+ }
444+ const batch = Array.isArray(payload);
445+ const reqs = (batch ? payload : [payload]) as RpcRequest[];
446+ const out: unknown[] = [];
447+ for (const r of reqs) {
448+ if (!r || typeof r !== "object" || typeof r.method !== "string") {
449+ out.push(rpcError(null, -32600, "invalid request"));
450+ continue;
451+ }
452+ const res = await handle(c, r);
453+ if (res) out.push(res);
454+ }
455+ if (!out.length) return c.body(null, 202);
456+ return c.json(batch ? out : out[0]);
457+});
modifiedapps/diffinery/src/repo/RepoDO.ts+14−5View file
@@ -45,16 +45,16 @@
4545
4646 const MAX_PUSH_BYTES = 100 * 1024 * 1024;
4747
48-export interface TreeItem {
48+export type TreeItem = {
4949 name: string;
5050 path: string;
5151 type: "tree" | "blob" | "commit";
5252 mode: string;
5353 oid: string;
5454 size?: number;
55-}
55+};
5656
57-export interface CommitInfo {
57+export type CommitInfo = {
5858 sha: string;
5959 tree: string;
6060 parents: string[];
@@ -62,7 +62,7 @@
6262 committer: Signature;
6363 message: string;
6464 signed: boolean;
65-}
65+};
6666
6767 /**
6868 * One Durable Object per repository. It is the single writer for the repo's
@@ -83,7 +83,7 @@
8383 const meta = this.readMeta();
8484 if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId);
8585 this.collab = new Collab(this);
86- this.actions = new Actions(this);
86+ this.actions = new Actions(this, env);
8787 }
8888
8989 // ---------------------------------------------------------------- meta
@@ -578,6 +578,12 @@
578578 runJobs(...a: Parameters<Actions["jobs"]>) {
579579 return this.actions.jobs(...a);
580580 }
581+ claimJob(...a: Parameters<Actions["claimJob"]>) {
582+ return this.actions.claimJob(...a);
583+ }
584+ jobStatus(jobId: string): string {
585+ return this.actions.getJob(jobId).status;
586+ }
581587 job(...a: Parameters<Actions["getJob"]>) {
582588 return this.actions.getJob(...a);
583589 }
@@ -614,6 +620,9 @@
614620 finishRun(...a: Parameters<Actions["finishRun"]>) {
615621 return this.actions.finishRun(...a);
616622 }
623+ oidcClaims(...a: Parameters<Actions["oidcClaims"]>) {
624+ return this.actions.oidcClaims(...a);
625+ }
617626 runnerSpec(...a: Parameters<Actions["runnerSpec"]>) {
618627 return this.actions.runnerSpec(...a);
619628 }
modifiedapps/diffinery/src/repo/actions.ts+62−17View file
@@ -1,5 +1,5 @@
11 import { ZERO_OID, diffTrees, fromUtf8, isTreeMode, readCommit, readTree, resolvePath } from "@diffinery/git-core";
2-import type { Actor, Conclusion, JobCompletion, JobSpec, LogLine, NeedsResult, StepReport } from "@diffinery/shared";
2+import type { Actor, Conclusion, Json, JobCompletion, JobSpec, LogLine, NeedsResult, StepReport } from "@diffinery/shared";
33 import {
44 evaluateIf,
55 evaluateValue,
@@ -17,13 +17,14 @@
1717 } from "@diffinery/workflow-yaml";
1818 import { decryptSecret, encryptSecret } from "../lib/crypto.js";
1919 import { HttpError, newId, now, randomToken, sha256 } from "../lib/util.js";
20+import type { Env } from "../env.js";
2021 import type { RepoDO } from "./RepoDO.js";
2122 import { atLeast, type Caller } from "./types.js";
2223
2324 const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"];
2425 const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i;
2526
26-export interface RunRow {
27+export type RunRow = {
2728 id: string;
2829 run_number: number;
2930 workflow_path: string;
@@ -42,9 +43,9 @@
4243 inputs: string;
4344 concurrency_group: string | null;
4445 display_title: string;
45-}
46+};
4647
47-export interface JobRow {
48+export type JobRow = {
4849 id: string;
4950 run_id: string;
5051 job_key: string;
@@ -62,9 +63,11 @@
6263 summary: string | null;
6364 container: string | null;
6465 timeout_minutes: number;
65-}
66+ /** runs-on labels for self-hosted jobs (JSON array); null for Diffinery-hosted containers. */
67+ labels: string | null;
68+};
6669
67-export interface RunInfo {
70+export type RunInfo = {
6871 id: string;
6972 number: number;
7073 attempt: number;
@@ -80,23 +83,23 @@
8083 createdAt: string;
8184 updatedAt: string;
8285 prNumber: number | null;
83-}
86+};
8487
85-export interface JobInfo {
88+export type JobInfo = {
8689 id: string;
8790 runId: string;
8891 key: string;
8992 name: string;
9093 status: JobRow["status"];
9194 conclusion: JobRow["conclusion"];
92- matrix: Record<string, unknown>;
95+ matrix: Record<string, Json>;
9396 startedAt: string | null;
9497 completedAt: string | null;
9598 outputs: Record<string, string>;
9699 steps: Array<StepReport & { index: number }>;
97100 failure: JobCompletion["failure"] | null;
98101 summary: string | null;
99-}
102+};
100103
101104 const toRun = (r: RunRow): RunInfo => ({
102105 id: r.id,
@@ -145,14 +148,14 @@
145148 * each job's container reports back through the runner* methods.
146149 */
147150 export class Actions {
148- constructor(private repo: RepoDO) {}
151+ constructor(
152+ private repo: RepoDO,
153+ private env: Env,
154+ ) {}
149155
150156 private get sql() {
151157 return this.repo.sql;
152158 }
153- private get env() {
154- return this.repo.env;
155- }
156159
157160 // ---------------------------------------------------------------- workflow discovery
158161
@@ -572,7 +575,8 @@
572575 if (job.uses) return fail(job.name ?? jobKey, `Reusable workflows (jobs.${jobKey}.uses) are not supported yet on Diffinery.`);
573576 const labels = job.runsOn.map((l) => String(evaluateValue(l, baseCtx)));
574577 const unsupported = labels.filter((l) => !SUPPORTED_LABEL.test(l));
575- if (unsupported.length) return fail(job.name ?? jobKey, `No Diffinery runner matches runs-on: ${unsupported.join(", ")}. Linux (ubuntu-*) runners are available today.`);
578+ const selfHosted = labels.some((l) => l.toLowerCase() === "self-hosted");
579+ if (unsupported.length && !selfHosted) return fail(job.name ?? jobKey, `No Diffinery runner matches runs-on: ${unsupported.join(", ")}. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.`);
576580 if (job.container) return fail(job.name ?? jobKey, "Job containers (jobs.<id>.container) are not supported yet; steps run directly on the Ubuntu runner image.");
577581 if (job.services && Object.keys(job.services).length) return fail(job.name ?? jobKey, "Service containers (jobs.<id>.services) are not supported yet on Diffinery runners.");
578582
@@ -593,7 +597,7 @@
593597 const timeout = Number(evaluateValue(job.timeoutMinutes, ctx)) || 360;
594598 const id = newId();
595599 this.sql.exec(
596- "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?)",
600+ "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes, labels) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?)",
597601 id,
598602 runId,
599603 jobKey,
@@ -601,6 +605,7 @@
601605 JSON.stringify(matrix),
602606 JSON.stringify(ctx.strategy),
603607 Math.min(timeout, 360),
608+ selfHosted ? JSON.stringify(labels.map((l) => l.toLowerCase())) : null,
604609 );
605610 ids.push(id);
606611 });
@@ -617,6 +622,12 @@
617622 const job = this.jobRow(jobId);
618623 const run = this.runRow(job.run_id);
619624 const m = this.repo.meta;
625+ if (job.labels) {
626+ // Self-hosted: park the job until a registered runner with matching labels claims it.
627+ this.sql.exec("UPDATE jobs SET status = 'waiting', container = NULL WHERE id = ?", jobId);
628+ this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
629+ return;
630+ }
620631 const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`;
621632 const container = `job-${jobId}`;
622633 this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ? WHERE id = ?", await sha256(token), container, jobId);
@@ -642,8 +653,28 @@
642653 await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The runner stopped unexpectedly (exit code ${exitCode ?? "unknown"}).` } });
643654 }
644655
656+ /**
657+ * A self-hosted runner asks for work. Returns the oldest waiting job whose
658+ * runs-on labels are all offered by the runner, with a fresh job token.
659+ */
660+ async claimJob(runnerName: string, runnerLabels: string[], caller: Caller): Promise<{ jobId: string; token: string; runId: string; name: string } | null> {
661+ if (!atLeast(caller.role, "admin")) throw new HttpError(403, "admin access is required to run self-hosted jobs");
662+ const offered = new Set(["self-hosted", ...runnerLabels.map((l) => l.toLowerCase())]);
663+ const waiting = this.sql
664+ .exec<JobRow>("SELECT j.* FROM jobs j JOIN runs r ON r.id = j.run_id WHERE j.status = 'waiting' AND j.labels IS NOT NULL AND j.container IS NULL AND r.status != 'completed' ORDER BY r.created_at, j.rowid")
665+ .toArray();
666+ const job = waiting.find((j) => (JSON.parse(j.labels!) as string[]).every((l) => offered.has(l)));
667+ if (!job) return null;
668+ const m = this.repo.meta;
669+ const token = `djt_${m.repoId}_${job.id}_${randomToken(24)}`;
670+ const container = `self-hosted:${runnerName.slice(0, 64)}`;
671+ this.sql.exec("UPDATE jobs SET token_hash = ?, container = ? WHERE id = ? AND container IS NULL", await sha256(token), container, job.id);
672+ this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId: job.id });
673+ return { jobId: job.id, token, runId: job.run_id, name: job.name };
674+ }
675+
645676 private async stopContainer(j: JobRow): Promise<void> {
646- if (!j.container) return;
677+ if (!j.container || j.container.startsWith("self-hosted:")) return;
647678 try {
648679 await this.env.RUNNER.get(this.env.RUNNER.idFromName(j.container)).stopJob();
649680 } catch (e) {
@@ -735,6 +766,20 @@
735766 return this.jobRow(jobId);
736767 }
737768
769+ /** Claims for an OIDC id token; side-effect free (unlike runnerSpec). */
770+ async oidcClaims(jobId: string, token: string): Promise<{ allowed: boolean; github: Record<string, string> }> {
771+ const job = await this.authJob(jobId, token);
772+ if (job.status !== "in_progress") throw new HttpError(409, "job is not running");
773+ const run = this.runRow(job.run_id);
774+ const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path)!.workflow!;
775+ const def: Job = wf.jobs[job.job_key]!;
776+ const allowed = wf.permissions === "write-all" || (typeof wf.permissions === "object" && wf.permissions["id-token"] === "write") || (typeof def.permissions === "object" && def.permissions["id-token"] === "write") || def.permissions === "write-all";
777+ const ctx = this.githubContext(run, job.job_key, token) as Record<string, Json>;
778+ const github: Record<string, string> = {};
779+ for (const [k, v] of Object.entries(ctx)) if (typeof v === "string" || typeof v === "number") github[k] = String(v);
780+ return { allowed, github };
781+ }
782+
738783 async runnerSpec(jobId: string, token: string): Promise<JobSpec> {
739784 const job = await this.authJob(jobId, token);
740785 const run = this.runRow(job.run_id);
modifiedapps/diffinery/src/repo/collab.ts+21−21View file
@@ -4,7 +4,7 @@
44 import { toInfo } from "./RepoDO.js";
55 import { atLeast, type Caller } from "./types.js";
66
7-export interface IssueRow {
7+export type IssueRow = {
88 number: number;
99 kind: "issue" | "pull";
1010 title: string;
@@ -20,9 +20,9 @@
2020 updated_at: string;
2121 closed_at: string | null;
2222 locked: number;
23-}
23+};
2424
25-export interface PullRow {
25+export type PullRow = {
2626 number: number;
2727 head_ref: string;
2828 head_sha: string;
@@ -34,9 +34,9 @@
3434 merge_commit: string | null;
3535 requested_reviewers: string;
3636 intent: string | null;
37-}
37+};
3838
39-export interface Issue {
39+export type Issue = {
4040 number: number;
4141 kind: "issue" | "pull";
4242 title: string;
@@ -62,9 +62,9 @@
6262 requestedReviewers: string[];
6363 intent: string | null;
6464 };
65-}
65+};
6666
67-export interface Comment {
67+export type Comment = {
6868 id: string;
6969 author: { id: string; login: string; kind: string };
7070 body: string;
@@ -77,18 +77,18 @@
7777 commitId: string | null;
7878 inReplyTo: string | null;
7979 resolved: boolean;
80-}
80+};
8181
82-export interface Review {
82+export type Review = {
8383 id: string;
8484 author: { id: string; login: string; kind: string };
8585 state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED";
8686 body: string;
8787 commitId: string;
8888 submittedAt: string;
89-}
89+};
9090
91-export interface Check {
91+export type Check = {
9292 id: string;
9393 sha: string;
9494 name: string;
@@ -101,9 +101,9 @@
101101 app: string;
102102 startedAt: string | null;
103103 completedAt: string | null;
104-}
104+};
105105
106-export interface Mergeability {
106+export type Mergeability = {
107107 mergeable: boolean;
108108 state: "clean" | "blocked" | "conflicting" | "behind" | "draft" | "merged" | "closed" | "unknown";
109109 conflicts: string[];
@@ -113,7 +113,7 @@
113113 checks: { name: string; state: string }[];
114114 blockers: string[];
115115 mergeBase: string | null;
116-}
116+};
117117
118118 const actorOf = (c: Caller) => ({ id: c.id, login: c.login, kind: c.kind, onBehalfOf: c.onBehalfOf });
119119
@@ -210,7 +210,7 @@
210210 get(number: number): { issue: Issue; comments: Comment[]; reviews: Review[] } {
211211 const issue = this.toIssue(this.row(number));
212212 const comments = this.sql
213- .exec<Record<string, unknown>>("SELECT * FROM comments WHERE number = ? ORDER BY created_at", number)
213+ .exec<Record<string, SqlStorageValue>>("SELECT * FROM comments WHERE number = ? ORDER BY created_at", number)
214214 .toArray()
215215 .map(
216216 (c): Comment => ({
@@ -485,14 +485,14 @@
485485 sha: c.sha!,
486486 name: c.name!,
487487 status: c.status as Check["status"],
488- conclusion: c.conclusion,
489- runId: c.run_id,
490- jobId: c.job_id,
491- detailsUrl: c.details_url,
488+ conclusion: c.conclusion ?? null,
489+ runId: c.run_id ?? null,
490+ jobId: c.job_id ?? null,
491+ detailsUrl: c.details_url ?? null,
492492 output: c.output ? JSON.parse(c.output) : null,
493493 app: c.app!,
494- startedAt: c.started_at,
495- completedAt: c.completed_at,
494+ startedAt: c.started_at ?? null,
495+ completedAt: c.completed_at ?? null,
496496 }));
497497 }
498498
modifiedapps/diffinery/src/repo/schema.ts+1−1View file
@@ -56,7 +56,7 @@
5656 id TEXT PRIMARY KEY, run_id TEXT NOT NULL, job_key TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL,
5757 conclusion TEXT, matrix TEXT NOT NULL DEFAULT '{}', strategy TEXT NOT NULL DEFAULT '{}', started_at TEXT, completed_at TEXT,
5858 outputs TEXT NOT NULL DEFAULT '{}', steps TEXT NOT NULL DEFAULT '[]', failure TEXT, token_hash TEXT, summary TEXT,
59- container TEXT, timeout_minutes INTEGER NOT NULL DEFAULT 360
59+ container TEXT, timeout_minutes INTEGER NOT NULL DEFAULT 360, labels TEXT
6060 );
6161 CREATE INDEX IF NOT EXISTS jobs_run ON jobs(run_id);
6262 CREATE TABLE IF NOT EXISTS logs (job_id TEXT NOT NULL, seq INTEGER NOT NULL, t TEXT NOT NULL, step INTEGER NOT NULL, text TEXT NOT NULL, PRIMARY KEY (job_id, seq));
modifiedapps/diffinery/src/runner/containers.ts+4−11View file
@@ -53,22 +53,15 @@
5353 await this.startAndWaitForPorts({ ports: 8080, startOptions: { envVars, entrypoint: ["node", "/opt/diffinery/agent.mjs", "--sandbox"] } });
5454 }
5555
56- /** Forward an API call to the sandbox's HTTP server with its secret attached. */
57- async call(path: string, body: unknown): Promise<{ status: number; body: unknown }> {
56+ /** Forward an API call (JSON text) to the sandbox's HTTP server with its secret attached. */
57+ async call(path: string, body: string): Promise<{ status: number; body: string; json: boolean }> {
5858 const secret = await this.ctx.storage.get<string>("secret");
5959 const res = await this.containerFetch(`http://sandbox${path}`, {
6060 method: "POST",
6161 headers: { "content-type": "application/json", authorization: `Bearer ${secret}` },
62- body: JSON.stringify(body ?? {}),
62+ body: body || "{}",
6363 });
64- const text = await res.text();
65- let parsed: unknown = text;
66- try {
67- parsed = JSON.parse(text);
68- } catch {
69- /* plain text */
70- }
71- return { status: res.status, body: parsed };
64+ return { status: res.status, body: await res.text(), json: (res.headers.get("content-type") ?? "").includes("json") };
7265 }
7366
7467 async shutdown(): Promise<void> {
modifiedapps/diffinery/src/runner/workflow.ts+2−2View file
@@ -32,8 +32,8 @@
3232 const jobIds = started.flatMap((s) => s.jobIds);
3333 await Promise.all(
3434 jobIds.map(async (jobId) => {
35- const job = await step.do(`job ${jobId} info`, () => repo().job(jobId));
36- if (job.status === "completed") return;
35+ const status = await step.do(`job ${jobId} info`, () => repo().jobStatus(jobId));
36+ if (status === "completed") return;
3737 try {
3838 await step.waitForEvent(`wait ${jobId}`, { type: `job-${jobId}`, timeout: "6 hours" });
3939 } catch {
addedapps/diffinery/src/web/account.tsx+1620−0View file
@@ -1,0 +1,1620 @@
1+/** @jsxImportSource hono/jsx */
2+import { raw } from "hono/html";
3+import type { Child, FC } from "hono/jsx";
4+import { requireAccount, type AppContext } from "../auth/principal.js";
5+import type { Account, Repo, TokenScope } from "../lib/directory.js";
6+import { loadRepo } from "../http/common.js";
7+import { HttpError } from "../lib/util.js";
8+import { ROLE_LEVEL, type Role } from "../repo/types.js";
9+import { ICONS } from "./assets.js";
10+import { csrfToken } from "./csrf.js";
11+import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp } from "./ui.js";
12+
13+// ---------------------------------------------------------------- shared bits
14+
15+const RepoList: FC<{ repos: Repo[]; showOwner?: boolean; empty?: Child }> = ({ repos, showOwner = true, empty }) =>
16+ repos.length ? (
17+ <ul class="list">
18+ {repos.map((r) => (
19+ <li>
20+ <span class="st faint">{raw(r.visibility === "private" ? ICONS.lock : ICONS.repo)}</span>
21+ <div class="grow">
22+ <a class="title" href={repoUrl(r)}>
23+ {showOwner ? `${r.owner_login}/` : ""}
24+ {r.name}
25+ </a>{" "}
26+ <span class="badge">{r.visibility}</span> {r.archived ? <span class="badge amber">archived</span> : null}
27+ {r.description ? <div class="meta">{r.description}</div> : null}
28+ </div>
29+ <span class="small muted nowrap">Updated {ago(r.pushed_at ?? r.updated_at)}</span>
30+ </li>
31+ ))}
32+ </ul>
33+ ) : (
34+ <div class="empty">{empty ?? "No repositories yet."}</div>
35+ );
36+
37+function parsePolicy(a: Account): { maxRole?: Role } {
38+ try {
39+ return a.agent_policy ? (JSON.parse(a.agent_policy) as { maxRole?: Role }) : {};
40+ } catch {
41+ return {};
42+ }
43+}
44+
45+async function adminOrgs(c: AppContext, me: Account): Promise<Array<Account & { role: string }>> {
46+ return (await c.get("dir").orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin");
47+}
48+
49+/** Whether `me` may manage an agent (its owner, an admin of its owning org, or a site admin). */
50+async function canManageAgent(c: AppContext, me: Account, agent: Account): Promise<boolean> {
51+ if (agent.kind !== "agent" || !agent.owner_id) return false;
52+ if (me.site_admin || agent.owner_id === me.id) return true;
53+ const role = await c.get("dir").orgRole(agent.owner_id, me.id);
54+ return role === "owner" || role === "admin";
55+}
56+
57+const SCOPES: TokenScope[] = ["read", "write", "admin"];
58+
59+// ---------------------------------------------------------------- landing / dashboard
60+
61+const Landing: FC = () => (
62+ <div>
63+ <section class="hero">
64+ <h1>Diffinery</h1>
65+ <p>Code hosting and CI for the agentic era. Git repositories, pull requests with intent, GitHub Actions–compatible workflows and AI agents as first-class collaborators, running on Cloudflare.</p>
66+ <p class="row" style="justify-content:center">
67+ <a class="btn primary" href="/settings">
68+ Sign in
69+ </a>
70+ </p>
71+ <p class="muted small">Access is through Cloudflare Access. Sign in with the email address you were invited with; new accounts wait for an invite from an administrator.</p>
72+ </section>
73+ <div class="features">
74+ <div class="box">
75+ <h3>{raw(ICONS.repo)} Git, natively</h3>
76+ <p class="muted m0">Clone, fetch and push over smart HTTP. Every repository is its own strongly consistent Durable Object.</p>
77+ </div>
78+ <div class="box">
79+ <h3>{raw(ICONS.pr)} Reviews with intent</h3>
80+ <p class="muted m0">Pull requests carry what a change is meant to do, so human and AI reviewers check the right thing.</p>
81+ </div>
82+ <div class="box">
83+ <h3>{raw(ICONS.play)} Actions-compatible CI</h3>
84+ <p class="muted m0">Bring your existing workflow YAML. Jobs run in containers with live logs, caches and artifacts.</p>
85+ </div>
86+ <div class="box">
87+ <h3>{raw(ICONS.bot)} Agents with guardrails</h3>
88+ <p class="muted m0">Agents get their own accounts, tokens and policies. Protected branches keep a human in the loop.</p>
89+ </div>
90+ </div>
91+ </div>
92+);
93+
94+export function registerAccount(web: WebApp): void {
95+ web.get("/", async (c) => {
96+ const p = c.get("principal");
97+ if (p.type !== "account") return render(c, { title: "" }, <Landing />);
98+ const me = p.account;
99+ const t = csrfToken(c);
100+ if (me.status === "disabled") {
101+ return render(
102+ c,
103+ { title: "Account disabled", status: 403 },
104+ <div class="blank" style="max-width:560px;margin:48px auto">
105+ <h1>Your account is disabled</h1>
106+ <p class="muted m0">Contact an administrator of this Diffinery instance if you think this is a mistake.</p>
107+ </div>,
108+ );
109+ }
110+ if (me.status === "pending") {
111+ return render(
112+ c,
113+ { title: "Waiting for an invite" },
114+ <div class="box" style="max-width:600px;margin:48px auto">
115+ <div class="body">
116+ <h1>Waiting for an invite</h1>
117+ <p class="muted">
118+ You are signed in as <b>{me.email ?? me.login}</b>, but this Diffinery instance is invite-only. Ask someone with access to invite your email address, or send a request to the administrators.
119+ </p>
120+ <Form action="/request-access" t={t}>
121+ <div class="field">
122+ <label for="note">Note to the administrators</label>
123+ <textarea id="note" name="note" maxlength={500} placeholder="Who are you, and what would you like to work on?" style="min-height:90px"></textarea>
124+ </div>
125+ <button class="btn primary">Request access</button>
126+ </Form>
127+ </div>
128+ </div>,
129+ );
130+ }
131+ const dir = c.get("dir");
132+ const [repos, orgs, notes] = await Promise.all([dir.reposVisibleTo(me), dir.orgsFor(me.id), dir.notifications(me.id, true)]);
133+ const mine = repos.filter((r) => r.owner_id === me.id || orgs.some((o) => o.id === r.owner_id));
134+ const others = repos.filter((r) => !mine.includes(r));
135+ return render(
136+ c,
137+ { title: "Dashboard" },
138+ <div class="grid left">
139+ <aside class="side">
140+ <section>
141+ <div class="row between mb">
142+ <h3 class="m0">Your repositories</h3>
143+ <a class="btn sm primary" href="/new">
144+ {raw(ICONS.plus)} New
145+ </a>
146+ </div>
147+ {mine.length ? (
148+ <div class="nav">
149+ {mine.slice(0, 40).map((r) => (
150+ <a href={repoUrl(r)} class="nowrap" style="overflow:hidden;text-overflow:ellipsis">
151+ <Avatar login={r.owner_login} /> {r.owner_login}/<b>{r.name}</b>
152+ </a>
153+ ))}
154+ </div>
155+ ) : (
156+ <p class="muted small">You have no repositories yet.</p>
157+ )}
158+ </section>
159+ <section>
160+ <h3>Organizations</h3>
161+ <div class="nav">
162+ {orgs.map((o) => (
163+ <a href={`/${o.login}`}>
164+ <Avatar login={o.login} /> {o.login} <span class="faint small">{o.role}</span>
165+ </a>
166+ ))}
167+ <a href="/settings/orgs" class="muted">
168+ {raw(ICONS.plus)} New organization
169+ </a>
170+ </div>
171+ </section>
172+ </aside>
173+ <div>
174+ {repos.length === 0 ? (
175+ <div class="box">
176+ <div class="body">
177+ <h2>Welcome to Diffinery, {me.name ?? me.login}</h2>
178+ <ol class="muted" style="padding-left:20px">
179+ <li>
180+ <a href="/new">Create a repository</a> (or an <a href="/settings/orgs">organization</a> to share work with friends).
181+ </li>
182+ <li>
183+ <a href="/settings/tokens">Create a personal access token</a> — Git uses it as the password over HTTPS.
184+ </li>
185+ <li>Push your code, open pull requests and add workflows under <code>.github/workflows</code>.</li>
186+ <li>
187+ Invite people from an organization's <b>People</b> page or a repository's <b>Settings → Access</b>.
188+ </li>
189+ </ol>
190+ </div>
191+ </div>
192+ ) : null}
193+ {notes.length ? (
194+ <div class="box">
195+ <div class="head">
196+ <h2>{raw(ICONS.bell)} Unread notifications</h2>
197+ <a class="small" href="/notifications" style="margin-left:auto">
198+ View all
199+ </a>
200+ </div>
201+ <ul class="list">
202+ {notes.slice(0, 8).map((n) => (
203+ <li>
204+ <div class="grow">
205+ <a class="title" href={`/notifications/${n.id}`}>
206+ {n.title}
207+ </a>
208+ <div class="meta">
209+ {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} />
210+ </div>
211+ </div>
212+ </li>
213+ ))}
214+ </ul>
215+ </div>
216+ ) : null}
217+ <div class="box">
218+ <div class="head">
219+ <h2>Repositories</h2>
220+ <span class="muted small">{repos.length} you can access</span>
221+ </div>
222+ <RepoList repos={[...mine, ...others].slice(0, 100)} empty={<a href="/new">Create your first repository</a>} />
223+ </div>
224+ </div>
225+ </div>,
226+ );
227+ });
228+
229+ post(web, "/request-access", async (c, f) => {
230+ const p = c.get("principal");
231+ if (p.type !== "account") throw new HttpError(401, "sign in required");
232+ if (p.account.status !== "pending") return c.redirect("/", 303);
233+ return act(c, "/", async () => c.get("dir").requestAccess(p.account.id, f.s("note")), "Request sent. An administrator will review it.");
234+ });
235+
236+ // ---------------------------------------------------------------- new repository
237+
238+ const NewRepoForm: FC<{ t: string; me: Account; orgs: Array<Account & { role: string }>; values: Record<string, string>; error?: string }> = ({ t, me, orgs, values, error }) => (
239+ <div style="max-width:720px;margin:0 auto">
240+ <h1>Create a new repository</h1>
241+ <p class="muted">A repository holds your project's files, history, issues, pull requests and workflows.</p>
242+ {error ? <div class="flash error">{error}</div> : null}
243+ <Form action="/new" t={t} class="box">
244+ <div class="body">
245+ <div class="row" style="align-items:flex-end">
246+ <div class="field" style="min-width:180px">
247+ <label for="owner">Owner</label>
248+ <select id="owner" name="owner">
249+ <option value={me.login} selected={values.owner === me.login}>
250+ {me.login}
251+ </option>
252+ {orgs.map((o) => (
253+ <option value={o.login} selected={values.owner === o.login}>
254+ {o.login}
255+ </option>
256+ ))}
257+ </select>
258+ </div>
259+ <div class="field" style="padding-bottom:8px">/</div>
260+ <div class="field grow">
261+ <label for="name">Repository name</label>
262+ <input id="name" name="name" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" value={values.name} autofocus autocomplete="off" />
263+ </div>
264+ </div>
265+ <div class="field">
266+ <label for="description">
267+ Description <span class="faint">(optional)</span>
268+ </label>
269+ <input id="description" name="description" type="text" value={values.description} maxlength={350} />
270+ </div>
271+ <div class="field">
272+ <label>Visibility</label>
273+ {(
274+ [
275+ ["private", "Private", "Only you, collaborators and organization members can see it."],
276+ ["internal", "Internal", "Everyone signed in to this Diffinery instance can see it."],
277+ ["public", "Public", "Anyone on the internet can see and clone it."],
278+ ] as const
279+ ).map(([v, l, d]) => (
280+ <label class="inline" style="margin:4px 0">
281+ <input type="radio" name="visibility" value={v} checked={(values.visibility || "private") === v} /> <b>{l}</b> <span class="muted small">— {d}</span>
282+ </label>
283+ ))}
284+ </div>
285+ <div class="field" style="max-width:280px">
286+ <label for="branch">Default branch</label>
287+ <input id="branch" name="default_branch" type="text" value={values.default_branch || "main"} />
288+ </div>
289+ <button class="btn primary">Create repository</button>
290+ </div>
291+ </Form>
292+ </div>
293+ );
294+
295+ web.get("/new", async (c) => {
296+ const me = requireAccount(c);
297+ const orgs = await c.get("dir").orgsFor(me.id);
298+ return render(c, { title: "New repository" }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={{ owner: c.req.query("owner") ?? me.login }} />);
299+ });
300+
301+ post(web, "/new", async (c, f) => {
302+ const me = requireAccount(c);
303+ const dir = c.get("dir");
304+ const values = { owner: f.s("owner"), name: f.s("name").trim(), description: f.s("description").trim(), visibility: f.s("visibility"), default_branch: f.s("default_branch").trim() };
305+ try {
306+ const owner = values.owner === me.login ? me : await dir.accountByLogin(values.owner);
307+ if (!owner || owner.kind === "agent") throw new HttpError(422, "unknown owner");
308+ const visibility = (["private", "internal", "public"] as const).find((v) => v === values.visibility) ?? "private";
309+ const repo = await dir.createRepo(owner, me, { name: values.name, description: values.description, visibility, defaultBranch: values.default_branch || "main" });
310+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "repo.create", target: `${repo.owner_login}/${repo.name}` }));
311+ return c.redirect(repoUrl(repo), 303);
312+ } catch (e) {
313+ if (!(e instanceof HttpError) || e.status >= 500) throw e;
314+ const orgs = await dir.orgsFor(me.id);
315+ return render(c, { title: "New repository", status: e.status }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={values} error={e.detail} />);
316+ }
317+ });
318+
319+ // ---------------------------------------------------------------- notifications
320+
321+ web.get("/notifications", async (c) => {
322+ const me = requireAccount(c);
323+ const t = csrfToken(c);
324+ const all = c.req.query("all") === "1";
325+ const notes = await c.get("dir").notifications(me.id, !all);
326+ return render(
327+ c,
328+ { title: "Notifications" },
329+ <div style="max-width:900px;margin:0 auto">
330+ <div class="row between mb">
331+ <h1 class="m0">Notifications</h1>
332+ <div class="row">
333+ <div class="subnav">
334+ <a href="/notifications" class={!all ? "on" : ""}>
335+ Unread
336+ </a>
337+ <a href="/notifications?all=1" class={all ? "on" : ""}>
338+ All
339+ </a>
340+ </div>
341+ <Form action="/notifications/read" t={t} class="inline">
342+ <button class="btn sm">Mark all as read</button>
343+ </Form>
344+ </div>
345+ </div>
346+ <div class="box">
347+ {notes.length ? (
348+ <ul class="list">
349+ {notes.map((n) => (
350+ <li>
351+ <span class={`st ${n.readAt ? "faint" : ""}`} style={n.readAt ? "" : "color:var(--accent)"}>
352+ {raw(n.kind.startsWith("pull") ? ICONS.pr : n.kind.includes("run") || n.kind.includes("workflow") ? ICONS.play : ICONS.bell)}
353+ </span>
354+ <div class="grow">
355+ <a class="title" href={`/notifications/${n.id}`} style={n.readAt ? "font-weight:400" : ""}>
356+ {n.title}
357+ </a>
358+ <div class="meta">
359+ {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} />
360+ </div>
361+ </div>
362+ {!n.readAt ? (
363+ <Form action="/notifications/read" t={t} class="inline">
364+ <input type="hidden" name="id" value={n.id} />
365+ <button class="btn sm" title="Mark as read">
366+ {raw(ICONS.check)}
367+ </button>
368+ </Form>
369+ ) : null}
370+ </li>
371+ ))}
372+ </ul>
373+ ) : (
374+ <div class="empty">{all ? "No notifications." : "All caught up — no unread notifications."}</div>
375+ )}
376+ </div>
377+ </div>,
378+ );
379+ });
380+
381+ web.get("/notifications/:id", async (c) => {
382+ const me = requireAccount(c);
383+ const dir = c.get("dir");
384+ const id = c.req.param("id");
385+ const n = (await dir.notifications(me.id)).find((x) => x.id === id);
386+ if (!n) throw new HttpError(404, "notification not found");
387+ await dir.markRead(me.id, id);
388+ const base = publicUrl(c);
389+ let to = n.url;
390+ if (to.startsWith(base)) to = to.slice(base.length) || "/";
391+ if (!to.startsWith("/") || to.startsWith("//")) to = "/notifications";
392+ return c.redirect(to, 302);
393+ });
394+
395+ post(web, "/notifications/read", async (c, f) => {
396+ const me = requireAccount(c);
397+ const id = f.s("id");
398+ return act(c, c.req.header("referer")?.includes("/notifications") ? "/notifications" : "/", async () => c.get("dir").markRead(me.id, id || undefined));
399+ });
400+
401+ // ---------------------------------------------------------------- invites
402+
403+ web.get("/invite/:token", async (c) => {
404+ const p = c.get("principal");
405+ if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite");
406+ if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
407+ return render(
408+ c,
409+ { title: "Accept invite" },
410+ <div class="box" style="max-width:560px;margin:48px auto">
411+ <div class="body">
412+ <h1>You have been invited</h1>
413+ <p class="muted">
414+ Accept this invite as <b>{p.account.login}</b> ({p.account.email ?? "no email"}). Invites only work for the email address they were sent to.
415+ </p>
416+ <Form action={`/invite/${encodeURIComponent(c.req.param("token"))}`} t={csrfToken(c)}>
417+ <button class="btn primary">Accept invite</button>
418+ </Form>
419+ </div>
420+ </div>,
421+ );
422+ });
423+
424+ post(web, "/invite/:token", async (c) => {
425+ const p = c.get("principal");
426+ if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite");
427+ if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
428+ const dir = c.get("dir");
429+ const res = await dir.acceptInvite(c.req.param("token"), p.account);
430+ let to = "/";
431+ if (res.repoId) {
432+ const r = await dir.repoById(res.repoId);
433+ if (r) to = repoUrl(r);
434+ } else if (res.orgId) {
435+ const o = await dir.account(res.orgId);
436+ if (o) to = `/${o.login}`;
437+ }
438+ c.executionCtx.waitUntil(dir.audit({ actorId: p.account.id, actorLogin: p.account.login, action: "invite.accept", target: to }));
439+ return c.redirect(to, 303);
440+ });
441+
442+ // ---------------------------------------------------------------- palette data
443+
444+ web.get("/-/palette.json", async (c) => {
445+ const me = requireAccount(c);
446+ const repos = (await c.get("dir").reposVisibleTo(me)).slice(0, 300).map((r) => `${r.owner_login}/${r.name}`);
447+ const param = c.req.query("repo") ?? "";
448+ let files: string[] = [];
449+ let ref: string | undefined;
450+ const m = /^([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*)$/.exec(param);
451+ if (m) {
452+ try {
453+ const { stub, repo } = await loadRepo(c, m[1]!, m[2]!, "read");
454+ ref = c.req.query("ref") || repo.default_branch;
455+ files = await listFiles(stub, ref);
456+ } catch {
457+ files = [];
458+ }
459+ }
460+ return c.json({ repos, files, ref }, 200, { "cache-control": "private, max-age=30" });
461+ });
462+
463+ // ---------------------------------------------------------------- personal settings
464+
465+ registerSettings(web);
466+ registerProfiles(web);
467+}
468+
469+/** Breadth-first file listing for "go to file" (bounded). */
470+async function listFiles(stub: Stub, ref: string): Promise<string[]> {
471+ const out: string[] = [];
472+ let queue = [""];
473+ let reads = 0;
474+ while (queue.length && reads < 120 && out.length < 5000) {
475+ const batch = queue.slice(0, 12);
476+ queue = queue.slice(12);
477+ reads += batch.length;
478+ const trees = await Promise.all(batch.map((p) => stub.tree(ref, p).catch(() => null)));
479+ for (const t of trees) {
480+ if (!t) continue;
481+ for (const it of t.items) {
482+ if (it.type === "tree") {
483+ if (!/^(node_modules|\.git|vendor|dist|target)$/.test(it.name)) queue.push(it.path);
484+ } else if (it.type === "blob") out.push(it.path);
485+ }
486+ }
487+ }
488+ return out.slice(0, 5000);
489+}
490+
491+// ---------------------------------------------------------------- /settings
492+
493+type SettingsTab = "profile" | "tokens" | "agents" | "orgs" | "admin" | "audit";
494+
495+const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
496+ <aside class="side">
497+ <section>
498+ <div class="row mb">
499+ <Avatar login={me.login} size="lg" />
500+ <div>
501+ <b>{me.name ?? me.login}</b>
502+ <div class="muted small">{me.login}</div>
503+ </div>
504+ </div>
505+ <div class="nav">
506+ <a href="/settings" class={tab === "profile" ? "on" : ""}>
507+ Profile
508+ </a>
509+ <a href="/settings/tokens" class={tab === "tokens" ? "on" : ""}>
510+ Access tokens
511+ </a>
512+ <a href="/settings/agents" class={tab === "agents" ? "on" : ""}>
513+ Agents
514+ </a>
515+ <a href="/settings/orgs" class={tab === "orgs" ? "on" : ""}>
516+ Organizations
517+ </a>
518+ <a href="/notifications">Notifications</a>
519+ {me.site_admin ? (
520+ <>
521+ <a href="/settings/admin" class={tab === "admin" ? "on" : ""}>
522+ Site administration
523+ </a>
524+ <a href="/settings/audit" class={tab === "audit" ? "on" : ""}>
525+ Audit log
526+ </a>
527+ </>
528+ ) : null}
529+ </div>
530+ </section>
531+ </aside>
532+);
533+
534+const TokenOnce: FC<{ token: string; title: string; c: AppContext; note?: Child }> = ({ token, title, c, note }) => (
535+ <div class="box" style="border-color:var(--amber)">
536+ <div class="body">
537+ <h3>{title}</h3>
538+ <p class="muted">Copy it now — it will not be shown again.</p>
539+ <div class="row">
540+ <div class="secret grow">{token}</div>
541+ <CopyButton text={token} />
542+ </div>
543+ {note}
544+ <details class="mt">
545+ <summary class="small" style="cursor:pointer">
546+ How to use it
547+ </summary>
548+ <pre class="cmd mt">
549+ {`# Git over HTTPS (any username, the token as password)\ngit clone https://x:${token.slice(0, 8)}…@${new URL(publicUrl(c)).host}/OWNER/REPO.git\n\n# Or store it once in your credential helper\ngit config --global credential.helper store\n\n# REST API / MCP\ncurl -H "Authorization: Bearer <token>" ${publicUrl(c)}/api/...`}
550+ </pre>
551+ </details>
552+ </div>
553+ </div>
554+);
555+
556+function registerSettings(web: WebApp): void {
557+ const page = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child, status?: number) =>
558+ render(
559+ c,
560+ { title, status },
561+ <div class="grid left">
562+ <SettingsNav me={me} tab={tab} />
563+ <div>{body}</div>
564+ </div>,
565+ );
566+
567+ // Profile
568+ web.get("/settings", async (c) => {
569+ const me = requireAccount(c);
570+ const t = csrfToken(c);
571+ return page(
572+ c,
573+ me,
574+ "profile",
575+ "Settings",
576+ <>
577+ <h1>Profile</h1>
578+ <Form action="/settings/profile" t={t} class="box">
579+ <div class="body">
580+ <div class="field">
581+ <label for="login">Username</label>
582+ <input id="login" name="login" type="text" value={me.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" />
583+ <div class="hint">Changing your username changes the URLs of your repositories. Existing clones need a new remote URL.</div>
584+ </div>
585+ <div class="field">
586+ <label for="name">Display name</label>
587+ <input id="name" name="name" type="text" value={me.name ?? ""} />
588+ </div>
589+ <dl class="kv mb">
590+ <dt>Email</dt>
591+ <dd>{me.email ?? "—"}</dd>
592+ <dt>Account type</dt>
593+ <dd>{me.kind}</dd>
594+ <dt>Joined</dt>
595+ <dd>
596+ <Time at={me.created_at} />
597+ </dd>
598+ <dt>Site admin</dt>
599+ <dd>{me.site_admin ? "yes" : "no"}</dd>
600+ </dl>
601+ <button class="btn primary">Save profile</button>
602+ </div>
603+ </Form>
604+ </>,
605+ );
606+ });
607+
608+ post(web, "/settings/profile", async (c, f) => {
609+ const me = requireAccount(c);
610+ return act(
611+ c,
612+ "/settings",
613+ async () => {
614+ const login = f.s("login").trim();
615+ await c.get("dir").updateAccount(me.id, { login: login && login !== me.login ? login : undefined, name: f.s("name").trim() });
616+ },
617+ "Profile saved.",
618+ );
619+ });
620+
621+ // Tokens
622+ const tokensPage = async (c: AppContext, me: Account, created?: { token: string }) => {
623+ const dir = c.get("dir");
624+ const t = csrfToken(c);
625+ const [tokens, repos] = await Promise.all([dir.tokens(me.id), dir.reposVisibleTo(me)]);
626+ const repoName = new Map(repos.map((r) => [r.id, `${r.owner_login}/${r.name}`]));
627+ return page(
628+ c,
629+ me,
630+ "tokens",
631+ "Access tokens",
632+ <>
633+ <h1>Personal access tokens</h1>
634+ <p class="muted">Tokens authenticate Git over HTTPS, the REST API, the CLI and MCP clients. Use your username (or anything) as the Git username and the token as the password.</p>
635+ {created ? <TokenOnce c={c} token={created.token} title="Your new token" /> : null}
636+ <div class="box">
637+ <div class="head">
638+ <h2>Active tokens</h2>
639+ </div>
640+ {tokens.length ? (
641+ <table class="tbl">
642+ <thead>
643+ <tr>
644+ <th>Name</th>
645+ <th>Scopes</th>
646+ <th>Repositories</th>
647+ <th>Expires</th>
648+ <th>Last used</th>
649+ <th />
650+ </tr>
651+ </thead>
652+ <tbody>
653+ {tokens.map((k) => (
654+ <tr>
655+ <td>
656+ <b>{k.name}</b>
657+ <div class="faint mono small">{k.prefix}…</div>
658+ </td>
659+ <td>{k.scopes.join(", ")}</td>
660+ <td class="small">{k.repoIds ? k.repoIds.map((id) => repoName.get(id) ?? id.slice(0, 8)).join(", ") : <span class="muted">all</span>}</td>
661+ <td class="small">{new Date(k.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}</td>
662+ <td class="small">{k.lastUsedAt ? ago(k.lastUsedAt) : <span class="muted">never</span>}</td>
663+ <td class="right">
664+ <Form action={`/settings/tokens/${k.id}/revoke`} t={t} class="inline" confirm={`Revoke token "${k.name}"? Anything using it stops working.`}>
665+ <button class="btn sm danger">Revoke</button>
666+ </Form>
667+ </td>
668+ </tr>
669+ ))}
670+ </tbody>
671+ </table>
672+ ) : (
673+ <div class="empty">No tokens yet.</div>
674+ )}
675+ </div>
676+ <Form action="/settings/tokens" t={t} class="box">
677+ <div class="head">
678+ <h2>Create a token</h2>
679+ </div>
680+ <div class="body">
681+ <div class="row" style="align-items:flex-start">
682+ <div class="field grow">
683+ <label for="tname">Name</label>
684+ <input id="tname" name="name" type="text" required placeholder="laptop, ci, claude-code…" maxlength={80} />
685+ </div>
686+ <div class="field" style="width:140px">
687+ <label for="days">Expires in (days)</label>
688+ <input id="days" name="days" type="number" min={1} max={366} value="90" />
689+ </div>
690+ </div>
691+ <div class="field">
692+ <label>Scope</label>
693+ {SCOPES.map((s) => (
694+ <label class="inline" style="margin-right:16px">
695+ <input type="radio" name="scope" value={s} checked={s === "write"} /> {s}
696+ </label>
697+ ))}
698+ <div class="hint">read: clone and view · write: push, issues, pull requests, runs (up to maintain) · admin: everything you can do, including settings.</div>
699+ </div>
700+ <div class="field">
701+ <label for="repos">
702+ Restrict to repositories <span class="faint">(optional)</span>
703+ </label>
704+ <select id="repos" name="repos" multiple size={Math.min(6, Math.max(3, repos.length))}>
705+ {repos.map((r) => (
706+ <option value={r.id}>
707+ {r.owner_login}/{r.name}
708+ </option>
709+ ))}
710+ </select>
711+ <div class="hint">Leave empty for access to every repository you can access.</div>
712+ </div>
713+ <button class="btn primary">Create token</button>
714+ </div>
715+ </Form>
716+ </>,
717+ );
718+ };
719+
720+ web.get("/settings/tokens", async (c) => tokensPage(c, requireAccount(c)));
721+
722+ post(web, "/settings/tokens", async (c, f) => {
723+ const me = requireAccount(c);
724+ const dir = c.get("dir");
725+ const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write";
726+ const visible = new Set((await dir.reposVisibleTo(me)).map((r) => r.id));
727+ const repoIds = f.all("repos").filter((id) => visible.has(id));
728+ const name = f.s("name").trim() || "token";
729+ const created = await dir.createToken(me, { name, scopes: [scope], repoIds: repoIds.length ? repoIds : null, expiresInDays: parseInt(f.s("days"), 10) || 90 });
730+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "token.create", target: me.login, detail: { name, scope, repos: repoIds.length } }));
731+ return tokensPage(c, me, created);
732+ });
733+
734+ post(web, "/settings/tokens/:id/revoke", async (c) => {
735+ const me = requireAccount(c);
736+ return act(
737+ c,
738+ "/settings/tokens",
739+ async () => {
740+ await c.get("dir").revokeToken(me.id, c.req.param("id"));
741+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "token.revoke", target: c.req.param("id") }));
742+ },
743+ "Token revoked.",
744+ );
745+ });
746+
747+ // Agents
748+ const agentsPage = async (c: AppContext, me: Account, created?: { token: string; agent: string }) => {
749+ const dir = c.get("dir");
750+ const t = csrfToken(c);
751+ const orgs = await adminOrgs(c, me);
752+ const agents = await dir.agentsOwnedBy([me.id, ...orgs.map((o) => o.id)]);
753+ const ownerName = new Map<string, string>([[me.id, me.login], ...orgs.map((o) => [o.id, o.login] as [string, string])]);
754+ const tokenLists = await Promise.all(agents.map((a) => dir.tokens(a.id)));
755+ return page(
756+ c,
757+ me,
758+ "agents",
759+ "Agents",
760+ <>
761+ <h1>Agents</h1>
762+ <p class="muted">
763+ Agents are accounts for AI tools and automation. They act on behalf of their owner, are capped by a policy, cannot push to protected or default branches, and their pull requests need a human approval. Give each agent its own token.
764+ </p>
765+ {created ? <TokenOnce c={c} token={created.token} title={`Token for ${created.agent}`} /> : null}
766+ <div class="box">
767+ <div class="head">
768+ <h2>Your agents</h2>
769+ </div>
770+ {agents.length ? (
771+ <ul class="list">
772+ {agents.map((a, i) => {
773+ const policy = parsePolicy(a);
774+ const toks = tokenLists[i] ?? [];
775+ return (
776+ <li style="display:block">
777+ <div class="row">
778+ <Actor login={a.login} kind="agent" />
779+ <span class="muted small">
780+ owned by <b>{ownerName.get(a.owner_id ?? "") ?? "?"}</b> · max role <b>{policy.maxRole ?? "write"}</b> · {a.status}
781+ </span>
782+ <span class="grow" />
783+ <Form action={`/settings/agents/${a.id}/status`} t={t} class="inline">
784+ <input type="hidden" name="status" value={a.status === "active" ? "disabled" : "active"} />
785+ <button class={`btn sm ${a.status === "active" ? "danger" : ""}`}>{a.status === "active" ? "Disable" : "Enable"}</button>
786+ </Form>
787+ </div>
788+ {toks.length ? (
789+ <div class="small muted mt" style="margin-top:6px">
790+ Tokens:{" "}
791+ {toks.map((k) => (
792+ <span class="row" style="display:inline-flex;gap:4px;margin-right:10px">
793+ <span class="mono">{k.name}</span> ({k.scopes.join(",")}, last used {k.lastUsedAt ? ago(k.lastUsedAt) : "never"})
794+ <Form action={`/settings/agents/${a.id}/tokens/${k.id}/revoke`} t={t} class="inline" confirm="Revoke this agent token?">
795+ <button class="btn sm link bad">revoke</button>
796+ </Form>
797+ </span>
798+ ))}
799+ </div>
800+ ) : null}
801+ <details style="margin-top:6px">
802+ <summary class="small" style="cursor:pointer;color:var(--accent)">
803+ Create a token for {a.login}
804+ </summary>
805+ <Form action={`/settings/agents/${a.id}/token`} t={t} class="row mt" style="align-items:flex-end">
806+ <div class="field grow m0">
807+ <label>Name</label>
808+ <input name="name" type="text" required value="default" />
809+ </div>
810+ <div class="field m0">
811+ <label>Scope</label>
812+ <select name="scope">
813+ {SCOPES.map((s) => (
814+ <option value={s} selected={s === "write"}>
815+ {s}
816+ </option>
817+ ))}
818+ </select>
819+ </div>
820+ <div class="field m0" style="width:110px">
821+ <label>Days</label>
822+ <input name="days" type="number" min={1} max={366} value="30" />
823+ </div>
824+ <button class="btn primary">Create token</button>
825+ </Form>
826+ </details>
827+ </li>
828+ );
829+ })}
830+ </ul>
831+ ) : (
832+ <div class="empty">No agents yet.</div>
833+ )}
834+ </div>
835+ <Form action="/settings/agents" t={t} class="box">
836+ <div class="head">
837+ <h2>Create an agent</h2>
838+ </div>
839+ <div class="body">
840+ <div class="row" style="align-items:flex-start">
841+ <div class="field grow">
842+ <label for="alogin">Agent username</label>
843+ <input id="alogin" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="review-bot" />
844+ </div>
845+ <div class="field grow">
846+ <label for="aname">Display name</label>
847+ <input id="aname" name="name" type="text" placeholder="Review bot" />
848+ </div>
849+ </div>
850+ <div class="row" style="align-items:flex-start">
851+ <div class="field grow">
852+ <label for="aowner">Owner</label>
853+ <select id="aowner" name="owner">
854+ <option value="">{me.login} (you)</option>
855+ {orgs.map((o) => (
856+ <option value={o.id}>{o.login} (organization)</option>
857+ ))}
858+ </select>
859+ <div class="hint">An agent can never do more than its owner.</div>
860+ </div>
861+ <div class="field grow">
862+ <label for="arole">Maximum role</label>
863+ <select id="arole" name="maxRole">
864+ {ROLES.map((r) => (
865+ <option value={r} selected={r === "write"}>
866+ {r}
867+ </option>
868+ ))}
869+ </select>
870+ </div>
871+ </div>
872+ <button class="btn primary">Create agent</button>
873+ </div>
874+ </Form>
875+ </>,
876+ );
877+ };
878+
879+ web.get("/settings/agents", async (c) => agentsPage(c, requireAccount(c)));
880+
881+ post(web, "/settings/agents", async (c, f) => {
882+ const me = requireAccount(c);
883+ const dir = c.get("dir");
884+ return act(
885+ c,
886+ "/settings/agents",
887+ async () => {
888+ const maxRole = ROLES.find((r) => r === f.s("maxRole")) ?? "write";
889+ const a = await dir.createAgent(me, { login: f.s("login").trim(), name: f.s("name").trim() || undefined, orgId: f.s("owner") || undefined, policy: { maxRole } });
890+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.create", target: a.login, detail: { maxRole } }));
891+ },
892+ "Agent created. Create a token for it below.",
893+ );
894+ });
895+
896+ const loadAgent = async (c: AppContext, me: Account): Promise<Account> => {
897+ const agent = await c.get("dir").account(c.req.param("id") ?? "");
898+ if (!agent || !(await canManageAgent(c, me, agent))) throw new HttpError(404, "agent not found");
899+ return agent;
900+ };
901+
902+ post(web, "/settings/agents/:id/token", async (c, f) => {
903+ const me = requireAccount(c);
904+ const agent = await loadAgent(c, me);
905+ const dir = c.get("dir");
906+ const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write";
907+ const created = await dir.createToken(agent, { name: f.s("name").trim() || "agent", scopes: [scope], expiresInDays: parseInt(f.s("days"), 10) || 30 });
908+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.token.create", target: agent.login, detail: { scope } }));
909+ return agentsPage(c, me, { token: created.token, agent: agent.login });
910+ });
911+
912+ post(web, "/settings/agents/:id/tokens/:tid/revoke", async (c) => {
913+ const me = requireAccount(c);
914+ const agent = await loadAgent(c, me);
915+ return act(c, "/settings/agents", async () => c.get("dir").revokeToken(agent.id, c.req.param("tid")), "Token revoked.");
916+ });
917+
918+ post(web, "/settings/agents/:id/status", async (c, f) => {
919+ const me = requireAccount(c);
920+ const agent = await loadAgent(c, me);
921+ const status = f.s("status") === "active" ? "active" : "disabled";
922+ return act(c, "/settings/agents", async () => {
923+ await c.get("dir").setStatus(agent.id, status);
924+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: `agent.${status === "active" ? "enable" : "disable"}`, target: agent.login }));
925+ });
926+ });
927+
928+ // Organizations
929+ web.get("/settings/orgs", async (c) => {
930+ const me = requireAccount(c);
931+ const t = csrfToken(c);
932+ const orgs = await c.get("dir").orgsFor(me.id);
933+ return page(
934+ c,
935+ me,
936+ "orgs",
937+ "Organizations",
938+ <>
939+ <h1>Organizations</h1>
940+ <p class="muted">Organizations group people, agents and repositories. Members get write access to the organization's repositories; owners and admins manage everything.</p>
941+ <div class="box">
942+ {orgs.length ? (
943+ <ul class="list">
944+ {orgs.map((o) => (
945+ <li>
946+ <Avatar login={o.login} />
947+ <div class="grow">
948+ <a class="title" href={`/${o.login}`}>
949+ {o.login}
950+ </a>
951+ {o.name && o.name !== o.login ? <span class="muted"> · {o.name}</span> : null}
952+ </div>
953+ <span class="badge">{o.role}</span>
954+ </li>
955+ ))}
956+ </ul>
957+ ) : (
958+ <div class="empty">You are not a member of any organization.</div>
959+ )}
960+ </div>
961+ <Form action="/settings/orgs" t={t} class="box">
962+ <div class="head">
963+ <h2>New organization</h2>
964+ </div>
965+ <div class="body">
966+ <div class="row" style="align-items:flex-start">
967+ <div class="field grow">
968+ <label for="olog">Organization name</label>
969+ <input id="olog" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="my-team" />
970+ </div>
971+ <div class="field grow">
972+ <label for="oname">Display name</label>
973+ <input id="oname" name="name" type="text" />
974+ </div>
975+ </div>
976+ <button class="btn primary">Create organization</button>
977+ </div>
978+ </Form>
979+ </>,
980+ );
981+ });
982+
983+ post(web, "/settings/orgs", async (c, f) => {
984+ const me = requireAccount(c);
985+ const dir = c.get("dir");
986+ return act(c, "/settings/orgs", async () => {
987+ const org = await dir.createOrg(f.s("login").trim(), f.s("name").trim(), me);
988+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.create", target: org.login }));
989+ return `/${org.login}?tab=people`;
990+ });
991+ });
992+
993+ // Site administration
994+ const requireSiteAdmin = (c: AppContext): Account => {
995+ const me = requireAccount(c);
996+ if (!me.site_admin) throw new HttpError(403, "site administrators only");
997+ return me;
998+ };
999+
1000+ const adminPage = async (c: AppContext, me: Account, invite?: { url: string; email: string }) => {
1001+ const dir = c.get("dir");
1002+ const t = csrfToken(c);
1003+ const [users, requests] = await Promise.all([dir.users(), dir.accessRequests()]);
1004+ return page(
1005+ c,
1006+ me,
1007+ "admin",
1008+ "Site administration",
1009+ <>
1010+ <h1>Site administration</h1>
1011+ {invite ? (
1012+ <div class="box" style="border-color:var(--amber)">
1013+ <div class="body">
1014+ <h3>Invite created for {invite.email}</h3>
1015+ <p class="muted">Send this link. Signing in with that email through Cloudflare Access also activates the account automatically.</p>
1016+ <div class="row">
1017+ <div class="secret grow">{invite.url}</div>
1018+ <CopyButton text={invite.url} />
1019+ </div>
1020+ </div>
1021+ </div>
1022+ ) : null}
1023+ <div class="box">
1024+ <div class="head">
1025+ <h2>Pending access requests</h2>
1026+ <span class="badge">{requests.length}</span>
1027+ </div>
1028+ {requests.length ? (
1029+ <ul class="list">
1030+ {requests.map((r) => (
1031+ <li>
1032+ <Avatar login={r.account.login} />
1033+ <div class="grow">
1034+ <b>{r.account.email ?? r.account.login}</b> <span class="muted small">requested <Time at={r.createdAt} /></span>
1035+ {r.note ? <div class="meta" style="white-space:pre-wrap">{r.note}</div> : null}
1036+ </div>
1037+ <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline">
1038+ <input type="hidden" name="status" value="active" />
1039+ <button class="btn sm green">Approve</button>
1040+ </Form>
1041+ <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline">
1042+ <input type="hidden" name="status" value="disabled" />
1043+ <button class="btn sm danger">Deny</button>
1044+ </Form>
1045+ </li>
1046+ ))}
1047+ </ul>
1048+ ) : (
1049+ <div class="empty">No pending requests.</div>
1050+ )}
1051+ </div>
1052+ <Form action="/settings/admin/invite" t={t} class="box">
1053+ <div class="head">
1054+ <h2>Invite someone to this instance</h2>
1055+ </div>
1056+ <div class="body row" style="align-items:flex-end">
1057+ <div class="field grow m0">
1058+ <label for="iemail">Email address</label>
1059+ <input id="iemail" name="email" type="email" required placeholder="friend@example.com" />
1060+ <div class="hint">They also need to be allowed by your Cloudflare Access policy.</div>
1061+ </div>
1062+ <button class="btn primary">Create invite</button>
1063+ </div>
1064+ </Form>
1065+ <div class="box">
1066+ <div class="head">
1067+ <h2>Users</h2>
1068+ <span class="badge">{users.length}</span>
1069+ </div>
1070+ <table class="tbl">
1071+ <thead>
1072+ <tr>
1073+ <th>User</th>
1074+ <th>Email</th>
1075+ <th>Joined</th>
1076+ <th>Status</th>
1077+ </tr>
1078+ </thead>
1079+ <tbody>
1080+ {users.map((u) => (
1081+ <tr>
1082+ <td>
1083+ <Actor login={u.login} kind="user" /> {u.site_admin ? <span class="badge blue">admin</span> : null}
1084+ </td>
1085+ <td class="small">{u.email ?? "—"}</td>
1086+ <td class="small">{ago(u.created_at)}</td>
1087+ <td>
1088+ {u.id === me.id ? (
1089+ <span class="badge green">{u.status}</span>
1090+ ) : (
1091+ <Form action={`/settings/admin/users/${u.id}/status`} t={t} class="row" style="gap:6px;flex-wrap:nowrap">
1092+ <select name="status" style="width:auto;padding:3px 6px">
1093+ {(["active", "pending", "disabled"] as const).map((s) => (
1094+ <option value={s} selected={u.status === s}>
1095+ {s}
1096+ </option>
1097+ ))}
1098+ </select>
1099+ <button class="btn sm">Save</button>
1100+ </Form>
1101+ )}
1102+ </td>
1103+ </tr>
1104+ ))}
1105+ </tbody>
1106+ </table>
1107+ </div>
1108+ </>,
1109+ );
1110+ };
1111+
1112+ web.get("/settings/admin", async (c) => adminPage(c, requireSiteAdmin(c)));
1113+
1114+ post(web, "/settings/admin/users/:id/status", async (c, f) => {
1115+ const me = requireSiteAdmin(c);
1116+ const dir = c.get("dir");
1117+ const status = (["active", "pending", "disabled"] as const).find((s) => s === f.s("status"));
1118+ return act(
1119+ c,
1120+ "/settings/admin",
1121+ async () => {
1122+ if (!status) throw new HttpError(422, "invalid status");
1123+ const target = await dir.account(c.req.param("id"));
1124+ if (!target || target.kind !== "user") throw new HttpError(404, "user not found");
1125+ if (target.id === me.id) throw new HttpError(422, "you cannot change your own status");
1126+ await dir.setStatus(target.id, status);
1127+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: `user.${status}`, target: target.login }));
1128+ },
1129+ "User updated.",
1130+ );
1131+ });
1132+
1133+ post(web, "/settings/admin/invite", async (c, f) => {
1134+ const me = requireSiteAdmin(c);
1135+ const email = f.s("email").trim();
1136+ if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address");
1137+ const inv = await c.get("dir").createInvite(me, { email, role: "read" });
1138+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "invite.create", target: email }));
1139+ return adminPage(c, me, { url: inv.url, email });
1140+ });
1141+
1142+ web.get("/settings/audit", async (c) => {
1143+ const me = requireSiteAdmin(c);
1144+ const rows = await c.get("dir").auditLog(200);
1145+ return page(
1146+ c,
1147+ me,
1148+ "audit",
1149+ "Audit log",
1150+ <>
1151+ <h1>Audit log</h1>
1152+ <p class="muted">The 200 most recent security-relevant events.</p>
1153+ <div class="box" style="overflow:auto">
1154+ <table class="tbl">
1155+ <thead>
1156+ <tr>
1157+ <th>When</th>
1158+ <th>Actor</th>
1159+ <th>Action</th>
1160+ <th>Target</th>
1161+ <th>Detail</th>
1162+ </tr>
1163+ </thead>
1164+ <tbody>
1165+ {rows.map((r) => (
1166+ <tr>
1167+ <td class="small nowrap">
1168+ <Time at={r.at} />
1169+ </td>
1170+ <td class="small">
1171+ {r.actor_login ?? "system"}
1172+ {r.on_behalf_of ? <span class="muted"> for {r.on_behalf_of}</span> : null}
1173+ </td>
1174+ <td>
1175+ <span class="badge">{r.action}</span>
1176+ </td>
1177+ <td class="small mono">{r.target ?? ""}</td>
1178+ <td class="small mono muted" style="max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title={r.detail ?? ""}>
1179+ {r.detail ?? ""}
1180+ </td>
1181+ </tr>
1182+ ))}
1183+ </tbody>
1184+ </table>
1185+ {rows.length === 0 ? <div class="empty">No events yet.</div> : null}
1186+ </div>
1187+ </>,
1188+ );
1189+ });
1190+}
1191+
1192+// ---------------------------------------------------------------- /:owner profiles
1193+
1194+type OrgTab = "repositories" | "people" | "invites" | "agents" | "settings";
1195+
1196+function registerProfiles(web: WebApp): void {
1197+ const orgContext = async (c: AppContext) => {
1198+ const me = requireAccount(c);
1199+ const dir = c.get("dir");
1200+ const org = await dir.accountByLogin(c.req.param("owner") ?? "");
1201+ if (!org) throw new HttpError(404, "not found");
1202+ if (org.kind !== "org") throw new HttpError(404, "not an organization");
1203+ const role = await dir.orgRole(org.id, me.id);
1204+ const isAdmin = !!me.site_admin || role === "owner" || role === "admin";
1205+ const isOwner = !!me.site_admin || role === "owner";
1206+ return { me, org, role, isAdmin, isOwner, dir };
1207+ };
1208+
1209+ const profile = async (c: AppContext, invite?: { url: string; email: string }) => {
1210+ const me = requireAccount(c);
1211+ const dir = c.get("dir");
1212+ const t = csrfToken(c);
1213+ const acct = await dir.accountByLogin(c.req.param("owner") ?? "");
1214+ if (!acct) throw new HttpError(404, "not found");
1215+ if (acct.login !== c.req.param("owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301);
1216+
1217+ if (acct.kind === "agent") {
1218+ const owner = acct.owner_id ? await dir.account(acct.owner_id) : null;
1219+ const policy = parsePolicy(acct);
1220+ return render(
1221+ c,
1222+ { title: acct.login },
1223+ <div style="max-width:720px;margin:24px auto" class="box">
1224+ <div class="body row" style="gap:18px">
1225+ <Avatar login={acct.login} kind="agent" size="xl" />
1226+ <div>
1227+ <h1 class="m0">
1228+ {acct.name ?? acct.login} <span class="badge agent">agent</span>
1229+ </h1>
1230+ <p class="muted">{acct.login}</p>
1231+ <dl class="kv">
1232+ <dt>Acts on behalf of</dt>
1233+ <dd>{owner ? <a href={`/${owner.login}`}>{owner.login}</a> : "—"}</dd>
1234+ <dt>Maximum role</dt>
1235+ <dd>{policy.maxRole ?? "write"}</dd>
1236+ <dt>Status</dt>
1237+ <dd>{acct.status}</dd>
1238+ <dt>Created</dt>
1239+ <dd>
1240+ <Time at={acct.created_at} />
1241+ </dd>
1242+ </dl>
1243+ </div>
1244+ </div>
1245+ </div>,
1246+ );
1247+ }
1248+
1249+ const repos = await dir.reposVisibleTo(me, acct.id);
1250+ if (acct.kind === "user") {
1251+ const orgs = await dir.orgsFor(acct.id);
1252+ return render(
1253+ c,
1254+ { title: acct.login },
1255+ <div class="grid left">
1256+ <aside class="side">
1257+ <section>
1258+ <Avatar login={acct.login} size="xl" />
1259+ <h1 class="mt m0">{acct.name ?? acct.login}</h1>
1260+ <p class="muted">{acct.login}</p>
1261+ {acct.id === me.id ? (
1262+ <a class="btn sm" href="/settings">
1263+ Edit profile
1264+ </a>
1265+ ) : null}
1266+ </section>
1267+ {orgs.length ? (
1268+ <section>
1269+ <h3>Organizations</h3>
1270+ <div class="nav">
1271+ {orgs.map((o) => (
1272+ <a href={`/${o.login}`}>
1273+ <Avatar login={o.login} /> {o.login}
1274+ </a>
1275+ ))}
1276+ </div>
1277+ </section>
1278+ ) : null}
1279+ </aside>
1280+ <div>
1281+ <div class="row between mb">
1282+ <h2 class="m0">Repositories</h2>
1283+ {acct.id === me.id ? (
1284+ <a class="btn sm primary" href="/new">
1285+ {raw(ICONS.plus)} New
1286+ </a>
1287+ ) : null}
1288+ </div>
1289+ <div class="box">
1290+ <RepoList repos={repos} showOwner={false} />
1291+ </div>
1292+ </div>
1293+ </div>,
1294+ );
1295+ }
1296+
1297+ // Organization
1298+ const role = await dir.orgRole(acct.id, me.id);
1299+ const isAdmin = !!me.site_admin || role === "owner" || role === "admin";
1300+ const isOwner = !!me.site_admin || role === "owner";
1301+ const requested = (c.req.query("tab") ?? "repositories") as OrgTab;
1302+ const tab: OrgTab = invite ? "invites" : (["repositories", "people", "invites", "agents", "settings"] as const).includes(requested) ? requested : "repositories";
1303+ if ((tab === "invites" && !isAdmin) || (tab === "settings" && !isOwner)) throw new HttpError(403, "organization admins only");
1304+ let body: Child = null;
1305+ const base = `/${acct.login}`;
1306+ if (tab === "repositories") {
1307+ body = (
1308+ <>
1309+ <div class="row between mb">
1310+ <h2 class="m0">Repositories</h2>
1311+ {role ? (
1312+ <a class="btn sm primary" href={`/new?owner=${encodeURIComponent(acct.login)}`}>
1313+ {raw(ICONS.plus)} New repository
1314+ </a>
1315+ ) : null}
1316+ </div>
1317+ <div class="box">
1318+ <RepoList repos={repos} showOwner={false} />
1319+ </div>
1320+ </>
1321+ );
1322+ } else if (tab === "people") {
1323+ const members = await dir.orgMembers(acct.id);
1324+ body = (
1325+ <>
1326+ <h2>People</h2>
1327+ <div class="box">
1328+ <table class="tbl">
1329+ <thead>
1330+ <tr>
1331+ <th>Member</th>
1332+ <th>Role</th>
1333+ {isAdmin ? <th /> : null}
1334+ </tr>
1335+ </thead>
1336+ <tbody>
1337+ {members.map((m) => (
1338+ <tr>
1339+ <td>
1340+ <Actor login={m.login} kind="user" /> {m.name && m.name !== m.login ? <span class="muted small">{m.name}</span> : null}
1341+ </td>
1342+ <td>
1343+ {isAdmin && (isOwner || m.role !== "owner") ? (
1344+ <Form action={`${base}/-/org/members`} t={t} class="row" style="gap:6px;flex-wrap:nowrap">
1345+ <input type="hidden" name="login" value={m.login} />
1346+ <select name="role" style="width:auto;padding:3px 6px">
1347+ {(isOwner ? ["owner", "admin", "member"] : ["admin", "member"]).map((r) => (
1348+ <option value={r} selected={m.role === r}>
1349+ {r}
1350+ </option>
1351+ ))}
1352+ </select>
1353+ <button class="btn sm">Save</button>
1354+ </Form>
1355+ ) : (
1356+ <span class="badge">{m.role}</span>
1357+ )}
1358+ </td>
1359+ {isAdmin ? (
1360+ <td class="right">
1361+ {isOwner || m.role !== "owner" ? (
1362+ <Form action={`${base}/-/org/members`} t={t} class="inline" confirm={`Remove ${m.login} from ${acct.login}?`}>
1363+ <input type="hidden" name="login" value={m.login} />
1364+ <input type="hidden" name="role" value="remove" />
1365+ <button class="btn sm danger">Remove</button>
1366+ </Form>
1367+ ) : null}
1368+ </td>
1369+ ) : null}
1370+ </tr>
1371+ ))}
1372+ </tbody>
1373+ </table>
1374+ </div>
1375+ {isAdmin ? (
1376+ <div class="row" style="align-items:stretch">
1377+ <Form action={`${base}/-/org/members`} t={t} class="box grow">
1378+ <div class="head">
1379+ <h3>Add an existing user</h3>
1380+ </div>
1381+ <div class="body row" style="align-items:flex-end">
1382+ <div class="field grow m0">
1383+ <label>Username</label>
1384+ <input name="login" type="text" required />
1385+ </div>
1386+ <div class="field m0">
1387+ <label>Role</label>
1388+ <select name="role">
1389+ <option value="member">member</option>
1390+ <option value="admin">admin</option>
1391+ {isOwner ? <option value="owner">owner</option> : null}
1392+ </select>
1393+ </div>
1394+ <button class="btn primary">Add</button>
1395+ </div>
1396+ </Form>
1397+ <div class="box grow">
1398+ <div class="head">
1399+ <h3>Invite by email</h3>
1400+ </div>
1401+ <div class="body">
1402+ <p class="muted small">
1403+ New people sign in through Cloudflare Access with their email and join automatically. <a href={`${base}?tab=invites`}>Create an invite →</a>
1404+ </p>
1405+ </div>
1406+ </div>
1407+ </div>
1408+ ) : null}
1409+ </>
1410+ );
1411+ } else if (tab === "invites") {
1412+ const invites = await dir.invites({ orgId: acct.id });
1413+ body = (
1414+ <>
1415+ <h2>Invites</h2>
1416+ {invite ? (
1417+ <div class="box" style="border-color:var(--amber)">
1418+ <div class="body">
1419+ <h3>Invite created for {invite.email}</h3>
1420+ <p class="muted">Send them this link. It expires in 14 days and only works for that email address.</p>
1421+ <div class="row">
1422+ <div class="secret grow">{invite.url}</div>
1423+ <CopyButton text={invite.url} />
1424+ </div>
1425+ </div>
1426+ </div>
1427+ ) : null}
1428+ <Form action={`${base}/-/org/invites`} t={t} class="box">
1429+ <div class="head">
1430+ <h3>Invite someone</h3>
1431+ </div>
1432+ <div class="body row" style="align-items:flex-end">
1433+ <div class="field grow m0">
1434+ <label>Email address</label>
1435+ <input name="email" type="email" required placeholder="friend@example.com" />
1436+ </div>
1437+ <div class="field m0">
1438+ <label>Role</label>
1439+ <select name="role">
1440+ <option value="member">member</option>
1441+ <option value="admin">admin</option>
1442+ {isOwner ? <option value="owner">owner</option> : null}
1443+ </select>
1444+ </div>
1445+ <button class="btn primary">Create invite</button>
1446+ </div>
1447+ <div class="body muted small" style="padding-top:0">The person must also be allowed by your Cloudflare Access policy to reach this site.</div>
1448+ </Form>
1449+ <div class="box">
1450+ {invites.length ? (
1451+ <table class="tbl">
1452+ <thead>
1453+ <tr>
1454+ <th>Email</th>
1455+ <th>Role</th>
1456+ <th>Created</th>
1457+ <th>Status</th>
1458+ </tr>
1459+ </thead>
1460+ <tbody>
1461+ {invites.map((i) => (
1462+ <tr>
1463+ <td>{i.email}</td>
1464+ <td>{i.role}</td>
1465+ <td class="small">{ago(i.createdAt)}</td>
1466+ <td class="small">
1467+ {i.acceptedAt ? (
1468+ <span class="badge green">accepted</span>
1469+ ) : Date.parse(i.expiresAt) < Date.now() ? (
1470+ <span class="badge">expired</span>
1471+ ) : (
1472+ <span class="badge amber">pending</span>
1473+ )}
1474+ </td>
1475+ </tr>
1476+ ))}
1477+ </tbody>
1478+ </table>
1479+ ) : (
1480+ <div class="empty">No invites yet.</div>
1481+ )}
1482+ </div>
1483+ </>
1484+ );
1485+ } else if (tab === "agents") {
1486+ const agents = await dir.agentsOwnedBy([acct.id]);
1487+ body = (
1488+ <>
1489+ <div class="row between mb">
1490+ <h2 class="m0">Agents</h2>
1491+ {isAdmin ? (
1492+ <a class="btn sm primary" href="/settings/agents">
1493+ Manage agents
1494+ </a>
1495+ ) : null}
1496+ </div>
1497+ <div class="box">
1498+ {agents.length ? (
1499+ <ul class="list">
1500+ {agents.map((a) => (
1501+ <li>
1502+ <Actor login={a.login} kind="agent" />
1503+ <span class="grow" />
1504+ <span class="muted small">max role {parsePolicy(a).maxRole ?? "write"}</span>
1505+ <span class={`badge ${a.status === "active" ? "green" : ""}`}>{a.status}</span>
1506+ </li>
1507+ ))}
1508+ </ul>
1509+ ) : (
1510+ <div class="empty">This organization has no agents.</div>
1511+ )}
1512+ </div>
1513+ </>
1514+ );
1515+ } else {
1516+ body = (
1517+ <>
1518+ <h2>Settings</h2>
1519+ <Form action={`${base}/-/org/settings`} t={t} class="box">
1520+ <div class="body">
1521+ <div class="field">
1522+ <label>Organization name</label>
1523+ <input name="login" type="text" value={acct.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" />
1524+ <div class="hint">Renaming changes every repository URL in this organization.</div>
1525+ </div>
1526+ <div class="field">
1527+ <label>Display name</label>
1528+ <input name="name" type="text" value={acct.name ?? ""} />
1529+ </div>
1530+ <button class="btn primary">Save</button>
1531+ </div>
1532+ </Form>
1533+ </>
1534+ );
1535+ }
1536+ return render(
1537+ c,
1538+ { title: acct.login },
1539+ <>
1540+ <div class="row mb" style="gap:16px">
1541+ <Avatar login={acct.login} size="lg" />
1542+ <div>
1543+ <h1 class="m0">{acct.name ?? acct.login}</h1>
1544+ <div class="muted small">
1545+ Organization · {acct.login}
1546+ {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""}
1547+ </div>
1548+ </div>
1549+ </div>
1550+ <nav class="tabs mb" style="border-bottom:1px solid var(--border)">
1551+ {(
1552+ [
1553+ ["repositories", "Repositories", true],
1554+ ["people", "People", true],
1555+ ["invites", "Invites", isAdmin],
1556+ ["agents", "Agents", true],
1557+ ["settings", "Settings", isOwner],
1558+ ] as const
1559+ )
1560+ .filter((x) => x[2])
1561+ .map(([k, label]) => (
1562+ <a href={`${base}?tab=${k}`} class={tab === k ? "on" : ""}>
1563+ {label}
1564+ </a>
1565+ ))}
1566+ </nav>
1567+ {body}
1568+ </>,
1569+ );
1570+ };
1571+
1572+ web.get("/:owner", async (c) => profile(c));
1573+
1574+ post(web, "/:owner/-/org/invites", async (c, f) => {
1575+ const { me, org, isAdmin, isOwner, dir } = await orgContext(c);
1576+ if (!isAdmin) throw new HttpError(403, "organization admins only");
1577+ const email = f.s("email").trim();
1578+ const role = (["member", "admin", "owner"] as const).find((r) => r === f.s("role")) ?? "member";
1579+ if (role === "owner" && !isOwner) throw new HttpError(403, "only owners can invite owners");
1580+ if (!/^[^\s@]+@[^\s@]+$/.test(email)) {
1581+ return act(c, `/${org.login}?tab=invites`, async () => {
1582+ throw new HttpError(422, "enter a valid email address");
1583+ });
1584+ }
1585+ const inv = await dir.createInvite(me, { email, orgId: org.id, role });
1586+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { email, role } }));
1587+ return profile(c, { url: inv.url, email });
1588+ });
1589+
1590+ post(web, "/:owner/-/org/members", async (c, f) => {
1591+ const { me, org, isAdmin, isOwner, dir } = await orgContext(c);
1592+ const back = `/${org.login}?tab=people`;
1593+ return act(c, back, async () => {
1594+ if (!isAdmin) throw new HttpError(403, "organization admins only");
1595+ const user = await dir.accountByLogin(f.s("login").trim());
1596+ if (!user || user.kind !== "user") throw new HttpError(404, "user not found");
1597+ const current = await dir.orgRole(org.id, user.id);
1598+ const role = f.s("role");
1599+ if ((current === "owner" || role === "owner") && !isOwner) throw new HttpError(403, "only owners can manage owners");
1600+ if (role === "remove") await dir.setOrgMember(org.id, user.id, null);
1601+ else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role);
1602+ else throw new HttpError(422, "invalid role");
1603+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: role === "remove" ? "org.member.remove" : "org.member.set", target: org.login, detail: { user: user.login, role } }));
1604+ });
1605+ });
1606+
1607+ post(web, "/:owner/-/org/settings", async (c, f) => {
1608+ const { me, org, isOwner, dir } = await orgContext(c);
1609+ return act(c, `/${org.login}?tab=settings`, async () => {
1610+ if (!isOwner) throw new HttpError(403, "organization owners only");
1611+ const login = f.s("login").trim();
1612+ await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim() });
1613+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login } }));
1614+ return `/${login || org.login}?tab=settings`;
1615+ }, "Organization updated.");
1616+ });
1617+}
1618+
1619+/** Role helper for other modules. */
1620+export const roleRank = (r: Role): number => ROLE_LEVEL[r];
addedapps/diffinery/src/web/assets.ts+356−0View file
@@ -1,0 +1,356 @@
1+/** Inline stylesheet, client script and icons for the web UI (no external assets). */
2+
3+export const CSS = `
4+:root{color-scheme:light dark;
5+--bg:#ffffff;--bg2:#f7f8fa;--bg3:#eff1f4;--bg-hover:#f3f4f7;--fg:#1c2024;--fg2:#5b6370;--fg3:#8b929c;
6+--border:#dfe3e8;--border2:#ebeef1;--accent:#5e6ad2;--accent-hover:#4f5bc4;--accent-fg:#fff;--accent-soft:#eef0fc;
7+--green:#1a7f37;--green-soft:#e3f6e8;--red:#cf222e;--red-soft:#fdecec;--amber:#9a6700;--amber-soft:#fff6d6;
8+--purple:#8250df;--purple-soft:#f4edff;--blue:#0969da;--blue-soft:#e6f0fd;
9+--add-bg:#e9f8ee;--add-ln:#cdf0d7;--del-bg:#fdeeee;--del-ln:#fbd5d5;--hunk-bg:#eef3fb;--hl:#fff8c5;
10+--code-bg:#f5f6f8;--shadow:0 1px 2px rgba(20,24,31,.05),0 8px 24px rgba(20,24,31,.08);--r:8px;
11+--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
12+--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif}
13+@media (prefers-color-scheme:dark){:root{
14+--bg:#0f1115;--bg2:#15181d;--bg3:#1c2027;--bg-hover:#1a1e24;--fg:#e6e8eb;--fg2:#a0a7b1;--fg3:#6f7782;
15+--border:#2a2f37;--border2:#22262d;--accent:#7c86e8;--accent-hover:#8e97ef;--accent-fg:#0f1115;--accent-soft:#232744;
16+--green:#3fb950;--green-soft:#15291c;--red:#f85149;--red-soft:#2d1517;--amber:#d29922;--amber-soft:#2b2312;
17+--purple:#a371f7;--purple-soft:#241a35;--blue:#58a6ff;--blue-soft:#14233a;
18+--add-bg:#12261a;--add-ln:#1a3a25;--del-bg:#2a1416;--del-ln:#45191d;--hunk-bg:#151f30;--hl:#3a3213;
19+--code-bg:#171a20;--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px rgba(0,0,0,.35)}}
20+*{box-sizing:border-box}
21+html{-webkit-text-size-adjust:100%}
22+body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 var(--sans)}
23+a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
24+code,pre,kbd,.mono{font-family:var(--mono);font-size:12.5px}
25+kbd{border:1px solid var(--border);border-bottom-width:2px;border-radius:4px;padding:0 5px;background:var(--bg2);font-size:11px;color:var(--fg2)}
26+h1,h2,h3,h4{line-height:1.25;margin:0 0 8px}h1{font-size:22px}h2{font-size:18px}h3{font-size:15px}
27+p{margin:0 0 10px}
28+hr{border:0;border-top:1px solid var(--border);margin:16px 0}
29+.muted{color:var(--fg2)}.faint{color:var(--fg3)}.small{font-size:12px}.nowrap{white-space:nowrap}
30+.ok{color:var(--green)}.bad{color:var(--red)}.warn{color:var(--amber)}.run{color:var(--amber)}.merged{color:var(--purple)}
31+.top{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:saturate(1.6) blur(10px);border-bottom:1px solid var(--border)}
32+.top .wrap{display:flex;align-items:center;gap:14px;height:52px}
33+.brand{display:flex;align-items:center;gap:8px;font-weight:650;color:var(--fg);letter-spacing:-.01em}
34+.brand:hover{text-decoration:none}.brand svg{color:var(--accent)}
35+.top nav{display:flex;align-items:center;gap:4px;margin-left:auto}
36+.top nav a{color:var(--fg2);padding:6px 10px;border-radius:6px}.top nav a:hover{background:var(--bg-hover);color:var(--fg);text-decoration:none}
37+.pal-btn{display:flex;align-items:center;gap:8px;min-width:220px;padding:5px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg2);color:var(--fg3);font:inherit;cursor:pointer}
38+.pal-btn:hover{border-color:var(--fg3)}.pal-btn kbd{margin-left:auto}
39+.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-left:2px;vertical-align:top}
40+.wrap{max-width:1280px;margin:0 auto;padding:0 20px}
41+main.wrap{padding-top:20px;padding-bottom:60px}
42+.flash{border:1px solid;border-radius:var(--r);padding:10px 14px;margin:0 0 16px}
43+.flash.error{border-color:color-mix(in srgb,var(--red) 40%,transparent);background:var(--red-soft);color:var(--red)}
44+.flash.notice{border-color:color-mix(in srgb,var(--green) 40%,transparent);background:var(--green-soft);color:var(--green)}
45+.flash.info{border-color:color-mix(in srgb,var(--blue) 40%,transparent);background:var(--blue-soft)}
46+.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important}
47+.btn:hover{background:var(--bg-hover);border-color:var(--fg3)}
48+.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn.primary:hover{background:var(--accent-hover)}
49+.btn.green{background:var(--green);border-color:var(--green);color:#fff}
50+.btn.danger{color:var(--red)}.btn.danger:hover{background:var(--red);border-color:var(--red);color:#fff}
51+.btn.sm{padding:3px 8px;font-size:12px}.btn.link{border-color:transparent;background:none;color:var(--accent);padding:0}
52+.btn[disabled]{opacity:.5;cursor:not-allowed}
53+input[type=text],input[type=search],input[type=email],input[type=number],input[type=url],input[type=password],input:not([type]),select,textarea{
54+width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--fg);font:inherit}
55+textarea{min-height:120px;resize:vertical;font-family:var(--sans)}textarea.code{font-family:var(--mono);font-size:12.5px;min-height:420px;tab-size:4;white-space:pre}
56+input:focus,select:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--accent) 45%,transparent);outline-offset:0;border-color:var(--accent)}
57+input[type=checkbox],input[type=radio]{accent-color:var(--accent)}
58+label{display:block;font-weight:550;margin:0 0 4px}label.inline{display:inline-flex;align-items:center;gap:6px;font-weight:400}
59+.field{margin:0 0 14px}.field .hint{font-size:12px;color:var(--fg2);margin-top:4px;font-weight:400}
60+.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.row.end{justify-content:flex-end}.row.between{justify-content:space-between}
61+.grow{flex:1;min-width:0}
62+.grid{display:grid;grid-template-columns:minmax(0,1fr) 296px;gap:24px}
63+.grid.left{grid-template-columns:260px minmax(0,1fr)}
64+@media (max-width:900px){.grid,.grid.left{grid-template-columns:minmax(0,1fr)}.pal-btn{min-width:0}.pal-btn span{display:none}}
65+.box{border:1px solid var(--border);border-radius:var(--r);background:var(--bg);margin:0 0 16px;overflow:hidden}
66+.box>.head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border-bottom:1px solid var(--border);flex-wrap:wrap}
67+.box>.body{padding:14px}
68+.box>.head h2,.box>.head h3{margin:0;font-size:14px}
69+.list{list-style:none;margin:0;padding:0}
70+.list>li{display:flex;gap:10px;align-items:flex-start;padding:10px 14px;border-top:1px solid var(--border2)}
71+.list>li:first-child{border-top:0}.list>li:hover{background:var(--bg2)}
72+.list .title{font-weight:600;color:var(--fg)}.list .title:hover{color:var(--accent)}
73+.list .meta{font-size:12px;color:var(--fg2);margin-top:2px}
74+.empty{padding:32px 16px;text-align:center;color:var(--fg2)}
75+.blank{border:1px dashed var(--border);border-radius:var(--r);padding:32px;text-align:center;color:var(--fg2)}
76+.badge{display:inline-flex;align-items:center;gap:4px;padding:1px 8px;border:1px solid var(--border);border-radius:999px;font-size:12px;font-weight:500;color:var(--fg2);line-height:18px;white-space:nowrap;vertical-align:middle}
77+.badge.agent{border-color:color-mix(in srgb,var(--purple) 45%,transparent);color:var(--purple);background:var(--purple-soft)}
78+.badge.green{border-color:transparent;background:var(--green-soft);color:var(--green)}
79+.badge.red{border-color:transparent;background:var(--red-soft);color:var(--red)}
80+.badge.amber{border-color:transparent;background:var(--amber-soft);color:var(--amber)}
81+.badge.purple{border-color:transparent;background:var(--purple-soft);color:var(--purple)}
82+.badge.blue{border-color:transparent;background:var(--blue-soft);color:var(--blue)}
83+.state{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:999px;font-weight:600;font-size:13px;color:#fff}
84+.state.open{background:var(--green)}.state.closed{background:var(--red)}.state.merged{background:var(--purple)}.state.draft{background:var(--fg3)}
85+.lbl{display:inline-block;padding:0 8px;border-radius:999px;font-size:12px;font-weight:550;line-height:20px;background:color-mix(in srgb,var(--lc) 18%,transparent);color:color-mix(in srgb,var(--lc) 70%,var(--fg));border:1px solid color-mix(in srgb,var(--lc) 40%,transparent);margin-right:4px}
86+.av{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:hsl(var(--h) 55% 55%);color:#fff;font-size:11px;font-weight:700;flex:none;vertical-align:middle}
87+.av.lg{width:40px;height:40px;font-size:18px}.av.xl{width:96px;height:96px;font-size:40px}.av.bot{border-radius:6px}
88+.who{display:inline-flex;align-items:center;gap:6px;font-weight:600;color:var(--fg)}
89+.repohead{padding:18px 0 0;border-bottom:1px solid var(--border);background:var(--bg2)}
90+.repohead h1{font-size:19px;font-weight:500;display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px}
91+.repohead h1 a{color:var(--fg)}.repohead h1 strong a{font-weight:650}
92+.tabs{display:flex;gap:2px;overflow-x:auto;scrollbar-width:none;margin-top:10px}
93+.tabs a{display:inline-flex;align-items:center;gap:6px;padding:8px 12px;color:var(--fg2);border-bottom:2px solid transparent;white-space:nowrap}
94+.tabs a:hover{color:var(--fg);text-decoration:none;border-bottom-color:var(--border)}
95+.tabs a.on{color:var(--fg);font-weight:600;border-bottom-color:var(--accent)}
96+.tabs .n{background:var(--bg3);border-radius:999px;padding:0 7px;font-size:12px;color:var(--fg2)}
97+.subnav{display:flex;gap:0;border:1px solid var(--border);border-radius:7px;overflow:hidden;width:max-content;max-width:100%}
98+.subnav a{padding:5px 12px;color:var(--fg2);border-left:1px solid var(--border)}.subnav a:first-child{border-left:0}
99+.subnav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.subnav a:hover{text-decoration:none;background:var(--bg-hover)}
100+.side h3{font-size:12px;color:var(--fg2);text-transform:uppercase;letter-spacing:.04em;margin:0 0 8px}
101+.side section{padding:0 0 16px;margin:0 0 16px;border-bottom:1px solid var(--border2)}
102+.side .nav a{display:block;padding:6px 10px;border-radius:6px;color:var(--fg)}.side .nav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.side .nav a:hover{background:var(--bg-hover);text-decoration:none}
103+details.menu{position:relative;display:inline-block}
104+details.menu>summary{list-style:none;cursor:pointer}details.menu>summary::-webkit-details-marker{display:none}
105+details.menu>.pop{position:absolute;z-index:30;top:calc(100% + 4px);left:0;min-width:260px;max-height:360px;overflow:auto;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);padding:6px}
106+details.menu>.pop.right{left:auto;right:0}
107+details.menu .pop a{display:flex;gap:8px;align-items:center;padding:6px 8px;border-radius:5px;color:var(--fg)}details.menu .pop a:hover{background:var(--bg-hover);text-decoration:none}
108+details.menu .pop h4{font-size:11px;text-transform:uppercase;color:var(--fg3);margin:6px 8px 4px}
109+.crumbs{font-size:15px;display:flex;flex-wrap:wrap;align-items:center;gap:4px}.crumbs a{font-weight:600}
110+table.files{width:100%;border-collapse:collapse}
111+table.files td{padding:7px 14px;border-top:1px solid var(--border2)}table.files tr:first-child td{border-top:0}
112+table.files tr:hover td{background:var(--bg2)}table.files td.ic{width:20px;padding-right:0;color:var(--fg3)}
113+table.files td.sz{text-align:right;color:var(--fg2);width:100px;white-space:nowrap}
114+table.files a{color:var(--fg)}table.files a:hover{color:var(--accent)}
115+.ic-dir{color:var(--accent)!important}
116+.clone{display:flex;gap:6px;align-items:center}
117+.clone input{font-family:var(--mono);font-size:12px}
118+pre.cmd{background:var(--code-bg);border:1px solid var(--border2);border-radius:7px;padding:12px 14px;overflow:auto;margin:0 0 12px}
119+.code-wrap{overflow:auto}
120+table.blob{border-collapse:collapse;width:100%;font-family:var(--mono);font-size:12.5px;line-height:20px}
121+table.blob td{padding:0 12px;vertical-align:top;white-space:pre}
122+table.blob td.ln{width:1%;min-width:50px;text-align:right;color:var(--fg3);user-select:none;padding-right:10px}
123+table.blob td.ln a{color:inherit}table.blob tr:target td{background:var(--hl)}
124+table.diff{border-collapse:collapse;width:100%;font-family:var(--mono);font-size:12px;line-height:20px}
125+table.diff td{padding:0 8px;vertical-align:top}
126+table.diff td.ln{width:1%;min-width:44px;text-align:right;color:var(--fg3);user-select:none;white-space:nowrap}
127+table.diff td.code{white-space:pre-wrap;word-break:break-all;width:100%}
128+table.diff tr.add td{background:var(--add-bg)}table.diff tr.add td.ln{background:var(--add-ln)}
129+table.diff tr.del td{background:var(--del-bg)}table.diff tr.del td.ln{background:var(--del-ln)}
130+table.diff tr.hunk td{background:var(--hunk-bg);color:var(--fg2);padding-top:2px;padding-bottom:2px}
131+table.diff tr.meta td{color:var(--fg3);font-style:italic}
132+table.diff td.act{width:1%;padding:0 2px}
133+table.diff .addc{visibility:hidden;border:0;background:var(--accent);color:#fff;border-radius:4px;width:18px;height:18px;line-height:16px;cursor:pointer;font-weight:700;padding:0}
134+table.diff tr:hover .addc{visibility:visible}
135+table.diff tr.thread td{background:var(--bg2);padding:8px 12px;font-family:var(--sans);font-size:14px;white-space:normal}
136+.filehead{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);border-bottom:1px solid var(--border);cursor:pointer;position:sticky;top:52px;z-index:5;flex-wrap:wrap}
137+details.file{border:1px solid var(--border);border-radius:var(--r);margin:0 0 14px;overflow:hidden}
138+details.file>summary{list-style:none}details.file>summary::-webkit-details-marker{display:none}
139+details.file:not([open])>.filehead{border-bottom:0}
140+.stat-add{color:var(--green);font-weight:600}.stat-del{color:var(--red);font-weight:600}
141+.bar{display:inline-flex;gap:1px;vertical-align:middle}.bar i{width:8px;height:8px;border-radius:1px;background:var(--bg3)}.bar i.a{background:var(--green)}.bar i.d{background:var(--red)}
142+.comment{border:1px solid var(--border);border-radius:var(--r);margin:0 0 16px;background:var(--bg)}
143+.comment>.head{display:flex;gap:8px;align-items:center;padding:8px 14px;background:var(--bg2);border-bottom:1px solid var(--border);border-radius:var(--r) var(--r) 0 0;flex-wrap:wrap;font-size:13px}
144+.comment>.body{padding:14px}
145+.comment.intent{border-color:color-mix(in srgb,var(--accent) 45%,transparent)}.comment.intent>.head{background:var(--accent-soft)}
146+.timeline{position:relative}
147+.event{display:flex;gap:10px;align-items:center;padding:6px 0 6px 14px;margin:0 0 16px;color:var(--fg2);font-size:13px}
148+.event .ic{display:inline-flex;width:28px;height:28px;border-radius:50%;align-items:center;justify-content:center;background:var(--bg3);flex:none}
149+.event.approved .ic{background:var(--green-soft);color:var(--green)}.event.changes .ic{background:var(--red-soft);color:var(--red)}
150+.linec{border-left:3px solid var(--border);padding:6px 10px;margin:8px 0;background:var(--bg2);border-radius:0 6px 6px 0}
151+.linec .where{font-family:var(--mono);font-size:12px;color:var(--fg2)}
152+.mergebox{border:1px solid var(--border);border-radius:var(--r);margin:0 0 16px;overflow:hidden}
153+.mergebox>div{padding:12px 14px;border-top:1px solid var(--border2)}.mergebox>div:first-child{border-top:0}
154+.mergebox.clean{border-color:color-mix(in srgb,var(--green) 50%,transparent)}.mergebox.blocked,.mergebox.conflicting{border-color:color-mix(in srgb,var(--amber) 55%,transparent)}
155+.mergebox.merged{border-color:color-mix(in srgb,var(--purple) 50%,transparent)}
156+.checks{list-style:none;margin:0;padding:0}.checks li{display:flex;gap:8px;align-items:center;padding:4px 0}
157+.st{display:inline-flex;width:16px;height:16px;flex:none;align-items:center;justify-content:center}
158+.st svg{width:16px;height:16px}
159+.st.spin svg{animation:spin 1.1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
160+.job{display:flex;align-items:center;gap:10px;padding:8px 14px;border-top:1px solid var(--border2);color:var(--fg)}
161+.job:hover{background:var(--bg2);text-decoration:none}.job:first-of-type{border-top:0}
162+.jobgroup{margin:0 0 16px}
163+.failure{border:1px solid color-mix(in srgb,var(--red) 45%,transparent);background:var(--red-soft);border-radius:var(--r);padding:12px 14px;margin:0 0 16px}
164+.failure pre{white-space:pre-wrap;margin:6px 0 0;color:var(--fg)}
165+.log{background:#0d1014;color:#d5d9de;border-radius:var(--r);overflow:hidden;margin:0 0 16px;border:1px solid var(--border)}
166+.log details{border-top:1px solid #1d2229}.log details:first-child{border-top:0}
167+.log summary{display:flex;gap:10px;align-items:center;padding:8px 14px;cursor:pointer;list-style:none;color:#e6e8eb}
168+.log summary::-webkit-details-marker{display:none}.log summary:hover{background:#151a20}
169+.log summary .dur{margin-left:auto;color:#7f8792;font-size:12px}
170+.log pre{margin:0;padding:6px 0 10px;overflow:auto;font-size:12px;line-height:19px;counter-reset:l}
171+.log pre span{display:block;padding:0 14px 0 64px;position:relative;white-space:pre-wrap;word-break:break-all;min-height:19px}
172+.log pre span::before{counter-increment:l;content:counter(l);position:absolute;left:0;width:48px;text-align:right;color:#59616c}
173+.log pre span.grp{color:#8fb3ff;font-weight:600}.log pre span.err{color:#ff8c84}.log pre span.wrn{color:#e5c07b}
174+.markdown{line-height:1.65;word-wrap:break-word}
175+.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}
176+.markdown h1,.markdown h2{padding-bottom:6px;border-bottom:1px solid var(--border2);margin:22px 0 12px}
177+.markdown h3,.markdown h4,.markdown h5,.markdown h6{margin:18px 0 8px}
178+.markdown h1 .anchor,.markdown h2 .anchor,.markdown h3 .anchor,.markdown h4 .anchor,.markdown h5 .anchor,.markdown h6 .anchor{float:left;margin-left:-18px;padding-right:4px;opacity:0;color:var(--fg3)}
179+.markdown :is(h1,h2,h3,h4,h5,h6):hover .anchor{opacity:1}
180+.markdown p,.markdown ul,.markdown ol,.markdown blockquote,.markdown pre,.markdown .table-wrap{margin:0 0 14px}
181+.markdown ul,.markdown ol{padding-left:2em}.markdown li+li{margin-top:3px}.markdown li.task{list-style:none;margin-left:-1.4em}
182+.markdown code{background:var(--code-bg);padding:.15em .4em;border-radius:5px;font-size:85%}
183+.markdown pre{background:var(--code-bg);padding:14px;border-radius:7px;overflow:auto;line-height:1.45;border:1px solid var(--border2)}
184+.markdown pre code{background:none;padding:0;font-size:12.5px}
185+.markdown blockquote{padding:0 1em;color:var(--fg2);border-left:3px solid var(--border)}
186+.markdown img{max-width:100%}.markdown hr{height:2px;background:var(--border2);border:0}
187+.markdown .table-wrap{overflow:auto}.markdown table{border-collapse:collapse}
188+.markdown th,.markdown td{border:1px solid var(--border);padding:6px 12px}.markdown th{background:var(--bg2);font-weight:600}
189+.readme{margin-top:16px}.readme>.body{padding:24px 32px}
190+@media (max-width:700px){.readme>.body{padding:16px}.wrap{padding:0 12px}}
191+dialog#palette{border:1px solid var(--border);border-radius:12px;padding:0;width:min(640px,94vw);background:var(--bg);color:var(--fg);box-shadow:var(--shadow);margin-top:12vh}
192+dialog#palette::backdrop{background:rgba(10,12,16,.45)}
193+#palette input{border:0;border-bottom:1px solid var(--border);border-radius:0;padding:14px 16px;font-size:15px;outline:none}
194+#palette ul{list-style:none;margin:0;padding:6px;max-height:min(420px,60vh);overflow:auto}
195+#palette li a{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:6px;color:var(--fg)}
196+#palette li a span{margin-left:auto;color:var(--fg3);font-size:12px}
197+#palette li.sel a,#palette li a:hover{background:var(--accent-soft);text-decoration:none}
198+#palette .foot{display:flex;gap:14px;padding:8px 14px;border-top:1px solid var(--border);font-size:12px;color:var(--fg3)}
199+.hero{padding:72px 0 40px;text-align:center}
200+.hero h1{font-size:44px;letter-spacing:-.03em;margin:0 0 14px}
201+.hero p{font-size:18px;color:var(--fg2);max-width:640px;margin:0 auto 26px}
202+.features{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;margin:24px 0}
203+.features .box{padding:18px}.features h3{margin:0 0 6px}
204+.stats{display:flex;gap:18px;flex-wrap:wrap;color:var(--fg2);font-size:13px}
205+.stats b{color:var(--fg)}
206+.kv{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;font-size:13px}.kv dt{color:var(--fg2)}.kv dd{margin:0}
207+table.tbl{width:100%;border-collapse:collapse;font-size:13px}
208+table.tbl th{text-align:left;font-weight:600;color:var(--fg2);padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);white-space:nowrap}
209+table.tbl td{padding:8px 12px;border-top:1px solid var(--border2);vertical-align:middle}
210+.secret{font-family:var(--mono);background:var(--amber-soft);border:1px solid color-mix(in srgb,var(--amber) 45%,transparent);border-radius:7px;padding:10px 12px;word-break:break-all}
211+.pager{display:flex;gap:8px;justify-content:center;margin:16px 0}
212+.sha{font-family:var(--mono);font-size:12px;padding:1px 6px;border-radius:5px;background:var(--bg3);color:var(--fg)}
213+a.sha:hover{text-decoration:none;background:var(--accent-soft);color:var(--accent)}
214+.right{text-align:right}.mt{margin-top:16px}.mb{margin-bottom:16px}.m0{margin:0}
215+form.inline{display:inline}
216+.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
217+`;
218+
219+/** Client behaviour: palette, shortcuts, copy buttons, live updates, line comments. */
220+export const JS = `(()=>{
221+const d=document,b=d.body,$=(s,r=d)=>r.querySelector(s),$$=(s,r=d)=>[...r.querySelectorAll(s)];
222+const repo=b.dataset.repo||'',ref=b.dataset.ref||'';
223+const enc=p=>p.split('/').map(encodeURIComponent).join('/');
224+d.addEventListener('click',e=>{
225+ const c=e.target.closest('[data-copy]');
226+ if(c){e.preventDefault();const v=c.dataset.copy;(navigator.clipboard?navigator.clipboard.writeText(v):Promise.reject()).then(()=>{const o=c.textContent;c.textContent='Copied';setTimeout(()=>{c.textContent=o},1200)}).catch(()=>{window.prompt('Copy:',v)});return}
227+ const p=e.target.closest('[data-palette]');if(p){e.preventDefault();openPalette();return}
228+ const a=e.target.closest('[data-comment-line]');if(a){e.preventDefault();lineComment(a);return}
229+ const x=e.target.closest('[data-cancel-line]');if(x){e.preventDefault();const tr=x.closest('tr');tr&&tr.remove()}
230+ if(!e.target.closest('details.menu'))$$('details.menu[open]').forEach(m=>m.removeAttribute('open'));
231+});
232+d.addEventListener('submit',e=>{const f=e.target;if(f.dataset.confirm&&!confirm(f.dataset.confirm)){e.preventDefault();return}
233+ if(f.dataset.match){const i=f.querySelector('[name=confirm]');if(i&&i.value!==f.dataset.match){e.preventDefault();alert('Type '+f.dataset.match+' to confirm.');return}}
234+ $$('button[type=submit],button:not([type])',f).forEach(x=>setTimeout(()=>{x.disabled=true},0));},true);
235+function lineComment(btn){
236+ const tpl=$('#line-comment-tpl');if(!tpl)return;
237+ const tr=btn.closest('tr');const next=tr.nextElementSibling;
238+ if(next&&next.classList.contains('newc')){next.querySelector('textarea').focus();return}
239+ const row=d.createElement('tr');row.className='thread newc';const td=d.createElement('td');td.colSpan=tr.children.length;
240+ td.append(tpl.content.cloneNode(true));row.append(td);tr.after(row);
241+ row.querySelector('[name=path]').value=btn.dataset.path;row.querySelector('[name=line]').value=btn.dataset.commentLine;
242+ row.querySelector('[name=side]').value=btn.dataset.side||'RIGHT';row.querySelector('.where').textContent=btn.dataset.path+':'+btn.dataset.commentLine;
243+ row.querySelector('textarea').focus();
244+}
245+// Palette
246+const dlg=$('#palette'),inp=dlg&&$('input',dlg),ul=dlg&&$('ul',dlg);let items=null,shown=[],sel=0,loading=false;
247+function base(){const a=[['Dashboard','/'],['New repository','/new'],['Notifications','/notifications'],['Settings','/settings'],['Tokens','/settings/tokens'],['Agents','/settings/agents']].map(([t,u])=>({t,u,k:'Go to'}));
248+ if(repo){const r='/'+repo;[['Code',''],['Issues','/issues'],['New issue','/issues/new'],['Pull requests','/pulls'],['Actions','/actions'],['Branches','/branches'],['Tags','/tags'],['Commits','/commits/'+enc(ref||'HEAD')],['Compare','/compare'],['Settings','/settings']].forEach(([t,p])=>a.unshift({t:repo+' › '+t,u:r+p,k:'Repository'}))}
249+ return a}
250+async function load(){if(items||loading)return;loading=true;items=base();draw();
251+ try{const r=await fetch('/-/palette.json'+(repo?'?repo='+encodeURIComponent(repo)+'&ref='+encodeURIComponent(ref):''),{credentials:'same-origin'});
252+ if(r.ok){const j=await r.json();(j.repos||[]).forEach(x=>items.push({t:x,u:'/'+x,k:'Repository'}));(j.files||[]).forEach(f=>items.push({t:f,u:'/'+repo+'/blob/'+enc(j.ref||ref||'HEAD')+'/'+enc(f),k:'File'}));draw()}}catch(_){}}
253+function score(q,s){if(!q)return 1;let i=0,sc=0,last=-2;const lo=s.toLowerCase();const at=lo.indexOf(q);if(at>=0)return 1000-at-(s.length/100);
254+ for(const ch of q){const k=lo.indexOf(ch,i);if(k<0)return -1;sc+=k===last+1?5:1;if(k===0||'/ -_.'.includes(lo[k-1]))sc+=3;last=k;i=k+1}return sc-(s.length/100)}
255+function draw(){if(!ul)return;const q=inp.value.trim().toLowerCase();let list=(items||[]).map(x=>[score(q,x.t),x]).filter(a=>a[0]>=0).sort((a,b)=>b[0]-a[0]).slice(0,60).map(a=>a[1]);
256+ const m=/^#?(\\d+)$/.exec(q);if(m&&repo)list.unshift({t:'Go to #'+m[1],u:'/'+repo+'/issues/'+m[1],k:'Issue / PR'});
257+ shown=list;if(sel>=shown.length)sel=Math.max(0,shown.length-1);
258+ ul.replaceChildren(...shown.map((x,i)=>{const li=d.createElement('li');if(i===sel)li.className='sel';const a=d.createElement('a');a.href=x.u;a.textContent=x.t;const k=d.createElement('span');k.textContent=x.k;a.append(k);li.append(a);return li}));
259+ const s=$('li.sel',ul);s&&s.scrollIntoView({block:'nearest'})}
260+function openPalette(){if(!dlg)return;if(!dlg.open)dlg.showModal();inp.value='';sel=0;load();draw();inp.focus()}
261+if(dlg){inp.addEventListener('input',()=>{sel=0;draw()});
262+ inp.addEventListener('keydown',e=>{if(e.key==='ArrowDown'){e.preventDefault();sel=Math.min(sel+1,shown.length-1);draw()}else if(e.key==='ArrowUp'){e.preventDefault();sel=Math.max(sel-1,0);draw()}else if(e.key==='Enter'){e.preventDefault();const x=shown[sel];if(x)location.href=x.u}});
263+ dlg.addEventListener('click',e=>{if(e.target===dlg)dlg.close()})}
264+// Shortcuts
265+let g=0;
266+d.addEventListener('keydown',e=>{
267+ if((e.metaKey||e.ctrlKey)&&!e.altKey&&e.key.toLowerCase()==='k'){e.preventDefault();openPalette();return}
268+ const t=e.target;if(t&&t.closest&&t.closest('input,textarea,select,[contenteditable],dialog'))return;
269+ if(e.metaKey||e.ctrlKey||e.altKey)return;
270+ if(g&&Date.now()-g<1500){g=0;const m={c:'',i:'/issues',p:'/pulls',a:'/actions',s:'/settings'}[e.key];if(repo&&m!==undefined){e.preventDefault();location.href='/'+repo+m}else if(e.key==='d'){location.href='/'}else if(e.key==='n'){location.href='/notifications'}return}
271+ if(e.key==='g'){g=Date.now();return}
272+ if(e.key==='t'&&repo){e.preventDefault();openPalette();return}
273+ if(e.key==='/'){const s=$('input[type=search]');if(s){e.preventDefault();s.focus()}}
274+});
275+// Live updates
276+const live=b.dataset.live,mode=b.dataset.liveMode||'reload';
277+if(live&&repo&&'WebSocket' in window){let ws,tries=0,timer=null,lastSeq=Number(b.dataset.lastSeq||'-1'),opened=false;
278+ function connect(){ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/'+repo+'/live?topics='+encodeURIComponent(live));
279+ ws.onopen=()=>{if(opened)refresh();opened=true;tries=0};
280+ ws.onmessage=e=>{if(e.data==='pong')return;let m;try{m=JSON.parse(e.data)}catch(_){return}handle(m)};
281+ ws.onclose=()=>{setTimeout(connect,Math.min(30000,1000*Math.pow(2,tries++)))}}
282+ setInterval(()=>{if(ws&&ws.readyState===1)ws.send('ping')},25000);
283+ function handle(m){const a=m.data&&m.data.action;
284+ if(mode==='log'&&String(m.topic).startsWith('job:')){
285+ if(a==='logs'){append(m.data.lines||[]);return}
286+ if(a==='step'&&m.data.step){stepState(m.data.step);return}
287+ if(a==='completed'){setTimeout(()=>location.reload(),700);return}}
288+ clearTimeout(timer);timer=setTimeout(refresh,350)}
289+ async function refresh(){const frags=$$('[data-fragment]');if(!frags.length){location.reload();return}
290+ try{const r=await fetch(location.href,{credentials:'same-origin'});if(!r.ok)return;const doc=new DOMParser().parseFromString(await r.text(),'text/html');
291+ for(const f of frags){if(f.contains(d.activeElement)&&d.activeElement!==b)continue;const n=doc.querySelector('[data-fragment="'+f.dataset.fragment+'"]');if(n)f.replaceWith(d.importNode(n,true))}}catch(_){}}
292+ function clean(s){return s.replace(/\\x1b\\[[0-9;?]*[A-Za-z]/g,'')}
293+ function section(step){let s=$('.log [data-step="'+step+'"]');if(!s){const log=$('.log');if(!log)return null;s=d.createElement('details');s.open=true;s.dataset.step=step;
294+ const sm=d.createElement('summary');sm.innerHTML='<span class="st spin run"></span>';sm.append(step<0?'Set up job':'Step '+(step+1));s.append(sm,d.createElement('pre'));log.append(s)}
295+ return $('pre',s)}
296+ function append(lines){const follow=(innerHeight+scrollY)>=d.body.scrollHeight-80;
297+ for(const l of lines){if(l.seq<=lastSeq)continue;lastSeq=l.seq;const pre=section(l.step);if(!pre)continue;const sp=d.createElement('span');const t=clean(l.text);
298+ if(t.startsWith('::group::')){sp.className='grp';sp.textContent='\\u25be '+t.slice(9)}else if(/^##\\[error\\]|^::error/.test(t)){sp.className='err';sp.textContent=t}else if(/^::warning/.test(t)){sp.className='wrn';sp.textContent=t}else sp.textContent=t;
299+ pre.append(sp);const det=pre.parentElement;if(det&&!det.open)det.open=true}
300+ if(follow)scrollTo(0,d.body.scrollHeight)}
301+ function stepState(s){const sec=$('.log [data-step="'+s.index+'"]');if(!sec){section(s.index);return stepState2(s)}stepState2(s)}
302+ function stepState2(s){const sec=$('.log [data-step="'+s.index+'"]');if(!sec)return;const sm=$('summary',sec);if(!sm)return;const icon=$('.st',sm);
303+ if(s.status==='completed'&&icon){icon.className='st '+(s.conclusion==='success'?'ok':s.conclusion==='failure'?'bad':'faint');icon.textContent=s.conclusion==='success'?'\\u2713':s.conclusion==='failure'?'\\u2715':'\\u2212'}
304+ if(s.name){const label=[...sm.childNodes].find(n=>n.nodeType===3);if(label)label.textContent=s.name}}
305+ connect()}
306+})();`;
307+
308+/** Inline SVG icons (16×16, stroke-based). Static strings only. */
309+const I = (body: string, extra = "") => `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${extra}>${body}</svg>`;
310+
311+export const ICONS = {
312+ logo: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></svg>`,
313+ success: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'),
314+ failure: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/>'),
315+ running: I('<circle cx="8" cy="8" r="6.5" opacity=".25"/><path d="M8 1.5a6.5 6.5 0 0 1 6.5 6.5"/>'),
316+ queued: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.6" fill="currentColor" stroke="none"/>'),
317+ cancelled: I('<circle cx="8" cy="8" r="6.5"/><path d="M3.4 12.6l9.2-9.2"/>'),
318+ skipped: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'),
319+ neutral: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'),
320+ issueOpen: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.4" fill="currentColor"/>'),
321+ issueClosed: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'),
322+ pr: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V6.5a2 2 0 0 0-2-2H7.5M9 3l-1.5 1.5L9 6"/>'),
323+ prMerged: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="8" r="1.8"/><path d="M4 5.3v5.4M4 5.3c0 2 1.5 2.7 6.2 2.7"/>'),
324+ prClosed: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V8M10.3 2.3l3.4 3.4M13.7 2.3l-3.4 3.4"/>'),
325+ dir: I('<path d="M1.8 3.5h4.3l1.5 1.6h6.6v7.4H1.8z" fill="currentColor" fill-opacity=".15"/>'),
326+ file: I('<path d="M3.5 1.8h5.6l3.4 3.4v9H3.5z"/><path d="M9 1.8v3.5h3.5"/>'),
327+ submodule: I('<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 8h6"/>'),
328+ branch: I('<circle cx="4.5" cy="3.5" r="1.7"/><circle cx="4.5" cy="12.5" r="1.7"/><circle cx="11.5" cy="5" r="1.7"/><path d="M4.5 5.2v5.6M11.5 6.7c0 3-7 2-7 4.1"/>'),
329+ tag: I('<path d="M2 2.5h5.5l6.5 6.5-5 5-6.5-6.5z"/><circle cx="5" cy="5.5" r="1" fill="currentColor"/>'),
330+ commit: I('<circle cx="8" cy="8" r="2.5"/><path d="M1 8h4.5M10.5 8H15"/>'),
331+ lock: I('<rect x="3" y="7" width="10" height="7" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>'),
332+ globe: I('<circle cx="8" cy="8" r="6.5"/><path d="M1.5 8h13M8 1.5c2 2.2 2 10.8 0 13M8 1.5c-2 2.2-2 10.8 0 13"/>'),
333+ search: I('<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5l3.5 3.5"/>'),
334+ bell: I('<path d="M4 11V7a4 4 0 0 1 8 0v4l1.5 1.5h-11zM6.5 14h3"/>'),
335+ plus: I('<path d="M8 3v10M3 8h10"/>'),
336+ gear: I('<circle cx="8" cy="8" r="2.2"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.4 3.4l1.4 1.4M11.2 11.2l1.4 1.4M3.4 12.6l1.4-1.4M11.2 4.8l1.4-1.4"/>'),
337+ play: I('<path d="M5 3.5v9l7-4.5z"/>'),
338+ code: I('<path d="M5.5 4.5L2 8l3.5 3.5M10.5 4.5L14 8l-3.5 3.5"/>'),
339+ comment: I('<path d="M2.5 3h11v7.5H7L4 13v-2.5H2.5z"/>'),
340+ eye: I('<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>'),
341+ bot: I('<rect x="2.5" y="5" width="11" height="8" rx="2"/><path d="M8 2.5V5M5.5 9h.01M10.5 9h.01"/>'),
342+ history: I('<path d="M2 8a6 6 0 1 0 1.8-4.3M2 2.5v3h3"/><path d="M8 5v3l2 1.5"/>'),
343+ copy: I('<rect x="5" y="5" width="8.5" height="8.5" rx="1.5"/><path d="M3 10.5H2.5v-8h8V3"/>'),
344+ download: I('<path d="M8 2v8M4.5 7L8 10.5 11.5 7M2.5 13.5h11"/>'),
345+ edit: I('<path d="M10.5 2.5l3 3-8 8H2.5v-3z"/>'),
346+ x: I('<path d="M4 4l8 8M12 4l-8 8"/>'),
347+ check: I('<path d="M3 8.5l3 3 7-7"/>'),
348+ people: I('<circle cx="6" cy="5.5" r="2.3"/><path d="M1.8 13.5c0-2.4 1.9-4 4.2-4s4.2 1.6 4.2 4"/><path d="M10.5 3.4a2.3 2.3 0 0 1 0 4.4M12 9.7c1.3.5 2.2 1.9 2.2 3.8"/>'),
349+ key: I('<circle cx="5" cy="11" r="3"/><path d="M7.2 8.8L13.5 2.5M11 5l2 2"/>'),
350+ shield: I('<path d="M8 1.5l5.5 2v4c0 3.5-2.4 6-5.5 7-3.1-1-5.5-3.5-5.5-7v-4z"/>'),
351+ repo: I('<path d="M3 2.5h9.5v9H4.5A1.5 1.5 0 0 0 3 13z"/><path d="M3 13a1.5 1.5 0 0 0 1.5 1.5h8v-3"/>'),
352+ org: I('<rect x="2.5" y="2" width="11" height="12.5" rx="1"/><path d="M5.5 5h1M9.5 5h1M5.5 8h1M9.5 8h1M7 14.5V11.5h2v3"/>'),
353+ zap: I('<path d="M9 1.5L3 9h4.5L7 14.5 13 7H8.5z"/>'),
354+} as const;
355+
356+export type IconName = keyof typeof ICONS;
addedapps/diffinery/src/web/csrf.ts+74−0View file
@@ -1,0 +1,74 @@
1+import { getCookie, setCookie } from "hono/cookie";
2+import type { AppContext } from "../auth/principal.js";
3+import { HttpError, randomToken, safeEqual } from "../lib/util.js";
4+
5+/**
6+ * Double-submit cookie CSRF protection for the web UI. Every rendered page
7+ * ensures a random `dfy_csrf` cookie (HttpOnly, SameSite=Strict, Secure) and
8+ * every form carries the same value in a hidden `_csrf` field.
9+ */
10+export const CSRF_COOKIE = "dfy_csrf";
11+export const CSRF_FIELD = "_csrf";
12+
13+const memo = new WeakMap<Request, string>();
14+const VALID = /^[A-Za-z0-9_-]{20,64}$/;
15+
16+/** The CSRF token for this request; sets the cookie when the browser has none. */
17+export function csrfToken(c: AppContext): string {
18+ const cached = memo.get(c.req.raw);
19+ if (cached) return cached;
20+ let token = getCookie(c, CSRF_COOKIE);
21+ if (!token || !VALID.test(token)) {
22+ token = randomToken(24);
23+ setCookie(c, CSRF_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 60 * 60 * 24 * 30 });
24+ }
25+ memo.set(c.req.raw, token);
26+ return token;
27+}
28+
29+export interface FormData {
30+ /** First string value of a field ("" when absent). */
31+ s(name: string): string;
32+ /** All string values of a (repeated) field. */
33+ all(name: string): string[];
34+ /** Checkbox semantics: present and not "false"/"0". */
35+ b(name: string): boolean;
36+ /** Field names present in the form. */
37+ keys(): string[];
38+}
39+
40+/** Parse an urlencoded/multipart form once per request (Hono caches the body). */
41+export async function formData(c: AppContext): Promise<FormData> {
42+ const raw = (await c.req.parseBody({ all: true })) as Record<string, string | File | Array<string | File>>;
43+ const all = (name: string): string[] => {
44+ const v = raw[name];
45+ const list = Array.isArray(v) ? v : v === undefined ? [] : [v];
46+ return list.filter((x): x is string => typeof x === "string");
47+ };
48+ return {
49+ s: (name) => all(name)[0] ?? "",
50+ all,
51+ b: (name) => {
52+ const v = all(name)[0];
53+ return v !== undefined && v !== "" && v !== "false" && v !== "0" && v !== "off";
54+ },
55+ keys: () => Object.keys(raw),
56+ };
57+}
58+
59+/** Reject state-changing requests without a matching token (and cross-origin requests). */
60+export async function verifyCsrf(c: AppContext): Promise<FormData> {
61+ const origin = c.req.header("origin");
62+ if (origin && origin !== "null") {
63+ const self = new URL(c.req.url).origin;
64+ const pub = c.env.PUBLIC_URL ? new URL(c.env.PUBLIC_URL).origin : null;
65+ if (origin !== self && origin !== pub) throw new HttpError(403, "cross-origin form submission rejected");
66+ }
67+ const type = c.req.header("content-type") ?? "";
68+ if (!/^(application\/x-www-form-urlencoded|multipart\/form-data)/i.test(type)) throw new HttpError(415, "expected a form submission");
69+ const form = await formData(c);
70+ const cookie = getCookie(c, CSRF_COOKIE);
71+ const sent = form.s(CSRF_FIELD);
72+ if (!cookie || !sent || !safeEqual(cookie, sent)) throw new HttpError(403, "your session token expired or is missing; reload the page and try again");
73+ return form;
74+}
addedapps/diffinery/src/web/markdown.ts+543−0View file
@@ -1,0 +1,543 @@
1+/**
2+ * A small, safe Markdown renderer for READMEs, issues, pull requests and job
3+ * summaries. All raw HTML in the source is escaped; only http(s), mailto-free
4+ * relative and fragment URLs survive in links and images.
5+ *
6+ * Supported: ATX headings, paragraphs, hard breaks, fenced code, thematic
7+ * breaks, blockquotes, ordered/unordered/task lists (nested), tables, inline
8+ * code, bold, italic, strikethrough, links, images and bare/auto links.
9+ */
10+
11+export interface MarkdownBase {
12+ /** Repository owner and name, for resolving relative links. */
13+ owner: string;
14+ repo: string;
15+ /** Ref (branch, tag or sha) that relative links point at. */
16+ ref: string;
17+ /** Directory of the document inside the repository ("" for the root). */
18+ dir: string;
19+}
20+
21+export interface MarkdownOptions {
22+ base?: MarkdownBase;
23+}
24+
25+const MAX_SOURCE = 512 * 1024;
26+const MAX_DEPTH = 12;
27+const MAX_INLINE = 60_000;
28+const MAX_SCAN = 2000;
29+
30+const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
31+export const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ESC[c]!);
32+
33+const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/");
34+
35+export function slugify(text: string): string {
36+ return text
37+ .toLowerCase()
38+ .replace(/<[^>]*>/g, "")
39+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
40+ .trim()
41+ .replace(/\s+/g, "-")
42+ .slice(0, 80);
43+}
44+
45+/** Normalise "a/./b/../c" style paths; returns null when the path escapes the root. */
46+function normalise(path: string): string | null {
47+ const out: string[] = [];
48+ for (const seg of path.split("/")) {
49+ if (!seg || seg === ".") continue;
50+ if (seg === "..") {
51+ if (!out.length) return null;
52+ out.pop();
53+ } else out.push(seg);
54+ }
55+ return out.join("/");
56+}
57+
58+/**
59+ * Validate and resolve a URL from Markdown source. Returns null for anything
60+ * that is not http(s), a fragment, or a relative path.
61+ */
62+export function safeUrl(url: string, kind: "link" | "image", opts: MarkdownOptions): string | null {
63+ const u = url.trim();
64+ if (!u) return null;
65+ // Browsers ignore control characters and whitespace when parsing schemes.
66+ const probe = u.replace(/[\x00-\x20\x7f]/g, "");
67+ if (/^https?:\/\//i.test(probe)) return u;
68+ if (/^[a-z][a-z0-9+.-]*:/i.test(probe) || probe.startsWith("//") || probe.startsWith("\\\\") || probe.startsWith("/\\")) return null;
69+ if (u.startsWith("#")) return kind === "link" ? `#h-${slugify(decodeSafe(u.slice(1)))}` : null;
70+ const base = opts.base;
71+ if (!base) return u.startsWith("/") ? u : null;
72+ const hashAt = u.search(/[?#]/);
73+ const pathPart = hashAt >= 0 ? u.slice(0, hashAt) : u;
74+ const suffix = hashAt >= 0 ? u.slice(hashAt) : "";
75+ const joined = pathPart.startsWith("/") ? pathPart : `${base.dir ? `${base.dir}/` : ""}${pathPart}`;
76+ const norm = normalise(decodeSafe(joined));
77+ if (norm === null) return null;
78+ const root = `/${base.owner}/${base.repo}`;
79+ if (kind === "image") return `${root}/raw/${encPath(base.ref)}/${encPath(norm)}`;
80+ const isDir = pathPart.endsWith("/") || norm === "";
81+ return `${root}/${isDir ? "tree" : "blob"}/${encPath(base.ref)}${norm ? `/${encPath(norm)}` : ""}${suffix.replace(/[<>"']/g, "")}`;
82+}
83+
84+function decodeSafe(s: string): string {
85+ try {
86+ return decodeURIComponent(s);
87+ } catch {
88+ return s;
89+ }
90+}
91+
92+// ---------------------------------------------------------------- inline
93+
94+function findClosing(src: string, delim: string, from: number, wordy: boolean): number {
95+ let at = from;
96+ const limit = Math.min(src.length, from + MAX_SCAN * 2);
97+ for (;;) {
98+ const idx = src.indexOf(delim, at);
99+ if (idx < 0 || idx >= limit) return -1;
100+ const before = src[idx - 1];
101+ const after = src[idx + delim.length];
102+ if (idx > from && before !== undefined && !/\s/.test(before) && (!wordy || after === undefined || !/[\p{L}\p{N}]/u.test(after))) {
103+ // When closing a single delimiter, skip over a double run ("**") that belongs to nested strong text.
104+ if (delim.length === 1 && after === delim && src[idx + 2] !== delim) {
105+ at = idx + 2;
106+ continue;
107+ }
108+ return idx;
109+ }
110+ at = idx + 1;
111+ }
112+}
113+
114+interface LinkMatch {
115+ text: string;
116+ url: string;
117+ title: string | null;
118+ end: number;
119+}
120+
121+/** Parse `[text](url "title")` starting at the "[" at `i`. */
122+function parseLink(src: string, i: number): LinkMatch | null {
123+ let depth = 0;
124+ let j = i;
125+ const limit = Math.min(src.length, i + MAX_SCAN);
126+ for (; j < limit; j++) {
127+ const ch = src[j];
128+ if (ch === "\\") {
129+ j++;
130+ continue;
131+ }
132+ if (ch === "`") {
133+ const close = src.indexOf("`", j + 1);
134+ if (close > 0 && close < limit) j = close;
135+ continue;
136+ }
137+ if (ch === "[") depth++;
138+ else if (ch === "]") {
139+ depth--;
140+ if (depth === 0) break;
141+ }
142+ }
143+ if (depth !== 0 || src[j] !== "]" || src[j + 1] !== "(") return null;
144+ const text = src.slice(i + 1, j);
145+ let k = j + 2;
146+ while (src[k] === " ") k++;
147+ let url = "";
148+ if (src[k] === "<") {
149+ const close = src.indexOf(">", k);
150+ if (close < 0 || close - k > MAX_SCAN) return null;
151+ url = src.slice(k + 1, close);
152+ k = close + 1;
153+ } else {
154+ let parens = 0;
155+ const start = k;
156+ for (; k < src.length && k - start < MAX_SCAN; k++) {
157+ const ch = src[k]!;
158+ if (ch === "\\") {
159+ k++;
160+ continue;
161+ }
162+ if (/\s/.test(ch)) break;
163+ if (ch === "(") parens++;
164+ else if (ch === ")") {
165+ if (parens === 0) break;
166+ parens--;
167+ }
168+ }
169+ url = src.slice(start, k).replace(/\\([()])/g, "$1");
170+ }
171+ while (src[k] === " ") k++;
172+ let title: string | null = null;
173+ const q = src[k];
174+ if (q === '"' || q === "'" || q === "(") {
175+ const closeCh = q === "(" ? ")" : q;
176+ const close = src.indexOf(closeCh, k + 1);
177+ if (close < 0) return null;
178+ title = src.slice(k + 1, close);
179+ k = close + 1;
180+ while (src[k] === " ") k++;
181+ }
182+ if (src[k] !== ")") return null;
183+ return { text, url, title, end: k + 1 };
184+}
185+
186+const BARE_URL = /^https?:\/\/[^\s<>"'`]*[^\s<>"'`.,:;!?)\]}*_~]/i;
187+
188+function inline(src: string, opts: MarkdownOptions, depth = 0): string {
189+ if (src.length > MAX_INLINE || depth > MAX_DEPTH) return escapeHtml(src);
190+ let out = "";
191+ let i = 0;
192+ const n = src.length;
193+ while (i < n) {
194+ const ch = src[i]!;
195+ // Backslash escapes and hard line breaks.
196+ if (ch === "\\") {
197+ const next = src[i + 1];
198+ if (next === "\n") {
199+ out += "<br>\n";
200+ i += 2;
201+ continue;
202+ }
203+ if (next !== undefined && /[!-/:-@[-`{-~]/.test(next)) {
204+ out += escapeHtml(next);
205+ i += 2;
206+ continue;
207+ }
208+ }
209+ if (ch === "\n") {
210+ if (out.endsWith(" ")) out = `${out.trimEnd()}<br>`;
211+ out += "\n";
212+ i++;
213+ continue;
214+ }
215+ // Code spans.
216+ if (ch === "`") {
217+ let run = 1;
218+ while (src[i + run] === "`") run++;
219+ const fence = "`".repeat(run);
220+ let close = src.indexOf(fence, i + run);
221+ while (close >= 0 && src[close + run] === "`") {
222+ let skip = close;
223+ while (src[skip] === "`") skip++;
224+ close = src.indexOf(fence, skip);
225+ }
226+ if (close >= 0) {
227+ let code = src.slice(i + run, close).replace(/\n/g, " ");
228+ if (code.length > 2 && code.startsWith(" ") && code.endsWith(" ") && code.trim()) code = code.slice(1, -1);
229+ out += `<code>${escapeHtml(code)}</code>`;
230+ i = close + run;
231+ continue;
232+ }
233+ out += fence;
234+ i += run;
235+ continue;
236+ }
237+ // Images.
238+ if (ch === "!" && src[i + 1] === "[") {
239+ const m = parseLink(src, i + 1);
240+ if (m) {
241+ const url = safeUrl(m.url, "image", opts);
242+ const alt = escapeHtml(m.text.replace(/[\\*_`[\]]/g, ""));
243+ if (url) out += `<img src="${escapeHtml(url)}" alt="${alt}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""} loading="lazy" referrerpolicy="no-referrer">`;
244+ else out += alt;
245+ i = m.end;
246+ continue;
247+ }
248+ }
249+ // Links.
250+ if (ch === "[") {
251+ const m = parseLink(src, i);
252+ if (m) {
253+ const url = safeUrl(m.url, "link", opts);
254+ const label = inline(m.text, opts, depth + 1);
255+ if (url) {
256+ const external = /^https?:/i.test(url);
257+ out += `<a href="${escapeHtml(url)}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""}${external ? ' rel="nofollow noopener noreferrer"' : ""}>${label}</a>`;
258+ } else out += label;
259+ i = m.end;
260+ continue;
261+ }
262+ }
263+ // Autolinks <https://…>.
264+ if (ch === "<") {
265+ const m = /^<(https?:\/\/[^\s<>]+)>/i.exec(src.slice(i, i + MAX_SCAN));
266+ if (m) {
267+ const url = escapeHtml(m[1]!);
268+ out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`;
269+ i += m[0].length;
270+ continue;
271+ }
272+ }
273+ // Bare URLs.
274+ if ((ch === "h" || ch === "H") && (i === 0 || /[\s(]/.test(src[i - 1]!))) {
275+ const m = BARE_URL.exec(src.slice(i, i + MAX_SCAN));
276+ if (m) {
277+ const url = escapeHtml(m[0]);
278+ out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`;
279+ i += m[0].length;
280+ continue;
281+ }
282+ }
283+ // Strikethrough.
284+ if (ch === "~" && src[i + 1] === "~" && src[i + 2] && !/\s/.test(src[i + 2]!)) {
285+ const close = findClosing(src, "~~", i + 2, false);
286+ if (close > i + 2) {
287+ out += `<del>${inline(src.slice(i + 2, close), opts, depth + 1)}</del>`;
288+ i = close + 2;
289+ continue;
290+ }
291+ }
292+ // Emphasis.
293+ if (ch === "*" || ch === "_") {
294+ let run = 1;
295+ while (src[i + run] === ch) run++;
296+ const wordy = ch === "_";
297+ const prev = src[i - 1];
298+ const nextCh = src[i + run];
299+ const canOpen = nextCh !== undefined && !/\s/.test(nextCh) && !(wordy && prev !== undefined && /[\p{L}\p{N}]/u.test(prev));
300+ let matched = false;
301+ if (canOpen && run <= 3) {
302+ for (const size of run === 3 ? [3, 2, 1] : run === 2 ? [2, 1] : [1]) {
303+ const delim = ch.repeat(size);
304+ const start = i + run;
305+ const close = findClosing(src, delim, start, wordy);
306+ if (close > start) {
307+ const inner = inline(src.slice(start, close), opts, depth + 1);
308+ out += escapeHtml(ch.repeat(run - size));
309+ out += size === 3 ? `<strong><em>${inner}</em></strong>` : size === 2 ? `<strong>${inner}</strong>` : `<em>${inner}</em>`;
310+ i = close + size;
311+ matched = true;
312+ break;
313+ }
314+ }
315+ }
316+ if (matched) continue;
317+ out += escapeHtml(ch.repeat(run));
318+ i += run;
319+ continue;
320+ }
321+ out += ESC[ch] ?? ch;
322+ i++;
323+ }
324+ return out;
325+}
326+
327+// ---------------------------------------------------------------- blocks
328+
329+const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)[^`]*$/;
330+const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
331+const HR = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
332+const QUOTE = /^ {0,3}> ?(.*)$/;
333+const LIST_ITEM = /^( {0,12})([*+-]|\d{1,9}[.)])([ \t]+(.*))?$/;
334+const TABLE_SEP = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
335+
336+function splitRow(line: string): string[] {
337+ let s = line.trim();
338+ if (s.startsWith("|")) s = s.slice(1);
339+ if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
340+ const cells: string[] = [];
341+ let cur = "";
342+ let inCode = false;
343+ for (let i = 0; i < s.length; i++) {
344+ const ch = s[i]!;
345+ if (ch === "\\" && s[i + 1] === "|") {
346+ cur += "|";
347+ i++;
348+ continue;
349+ }
350+ if (ch === "`") inCode = !inCode;
351+ if (ch === "|" && !inCode) {
352+ cells.push(cur.trim());
353+ cur = "";
354+ continue;
355+ }
356+ cur += ch;
357+ }
358+ cells.push(cur.trim());
359+ return cells;
360+}
361+
362+function startsBlock(line: string, next: string | undefined): boolean {
363+ return (
364+ FENCE.test(line) ||
365+ HEADING.test(line) ||
366+ HR.test(line) ||
367+ QUOTE.test(line) ||
368+ /^ {0,3}([*+-]|1[.)])[ \t]+\S/.test(line) ||
369+ (line.includes("|") && next !== undefined && TABLE_SEP.test(next) && next.includes("-"))
370+ );
371+}
372+
373+function blocks(lines: string[], opts: MarkdownOptions, depth: number, tight = false): string {
374+ if (depth > MAX_DEPTH) return `<p>${escapeHtml(lines.join("\n"))}</p>`;
375+ let i = 0;
376+ const parts: string[] = [];
377+ while (i < lines.length) {
378+ const line = lines[i]!;
379+ if (!line.trim()) {
380+ i++;
381+ continue;
382+ }
383+ // Fenced code.
384+ const fence = FENCE.exec(line);
385+ if (fence) {
386+ const marker = fence[1]!;
387+ const lang = fence[2] ?? "";
388+ const indent = line.length - line.trimStart().length;
389+ const body: string[] = [];
390+ i++;
391+ while (i < lines.length && !new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`).test(lines[i]!)) {
392+ body.push(lines[i]!.replace(new RegExp(`^ {0,${indent}}`), ""));
393+ i++;
394+ }
395+ i++;
396+ const cls = lang && /^[\w+#.-]{1,30}$/.test(lang) ? ` class="language-${escapeHtml(lang)}"` : "";
397+ parts.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`);
398+ continue;
399+ }
400+ // Indented code (4 spaces), only outside lists.
401+ if (/^( {4}|\t)/.test(line) && !tight) {
402+ const body: string[] = [];
403+ while (i < lines.length && (/^( {4}|\t)/.test(lines[i]!) || !lines[i]!.trim())) {
404+ body.push(lines[i]!.replace(/^( {4}|\t)/, ""));
405+ i++;
406+ }
407+ while (body.length && !body[body.length - 1]!.trim()) body.pop();
408+ parts.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
409+ continue;
410+ }
411+ const h = HEADING.exec(line);
412+ if (h) {
413+ const level = h[1]!.length;
414+ const text = h[2] ?? "";
415+ const html = inline(text, opts, depth);
416+ const slug = slugify(text);
417+ parts.push(`<h${level} id="h-${escapeHtml(slug)}"><a class="anchor" href="#h-${escapeHtml(slug)}" aria-hidden="true">#</a>${html}</h${level}>`);
418+ i++;
419+ continue;
420+ }
421+ if (HR.test(line)) {
422+ parts.push("<hr>");
423+ i++;
424+ continue;
425+ }
426+ if (QUOTE.test(line)) {
427+ const body: string[] = [];
428+ while (i < lines.length && lines[i]!.trim()) {
429+ const m = QUOTE.exec(lines[i]!);
430+ if (m) body.push(m[1]!);
431+ else if (body.length && !startsBlock(lines[i]!, lines[i + 1])) body.push(lines[i]!);
432+ else break;
433+ i++;
434+ }
435+ parts.push(`<blockquote>${blocks(body, opts, depth + 1)}</blockquote>`);
436+ continue;
437+ }
438+ // Tables.
439+ if (line.includes("|") && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]!) && lines[i + 1]!.includes("-")) {
440+ const head = splitRow(line);
441+ const aligns = splitRow(lines[i + 1]!).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : ""));
442+ i += 2;
443+ const rows: string[][] = [];
444+ while (i < lines.length && lines[i]!.trim() && lines[i]!.includes("|")) {
445+ rows.push(splitRow(lines[i]!));
446+ i++;
447+ }
448+ const cell = (tag: string, text: string, idx: number) => `<${tag}${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(text, opts, depth)}</${tag}>`;
449+ let t = `<div class="table-wrap"><table><thead><tr>${head.map((c, idx) => cell("th", c, idx)).join("")}</tr></thead>`;
450+ if (rows.length) t += `<tbody>${rows.map((r) => `<tr>${head.map((_, idx) => cell("td", r[idx] ?? "", idx)).join("")}</tr>`).join("")}</tbody>`;
451+ parts.push(`${t}</table></div>`);
452+ continue;
453+ }
454+ // Lists.
455+ const li = LIST_ITEM.exec(line);
456+ if (li) {
457+ const ordered = /\d/.test(li[2]!);
458+ const baseIndent = li[1]!.length;
459+ const start = ordered ? parseInt(li[2]!, 10) : 1;
460+ const items: string[][] = [];
461+ let loose = false;
462+ while (i < lines.length) {
463+ const m = LIST_ITEM.exec(lines[i]!);
464+ if (!m || m[1]!.length > baseIndent + 1 || m[1]!.length < baseIndent || /\d/.test(m[2]!) !== ordered) break;
465+ const contentIndent = m[1]!.length + m[2]!.length + Math.max(1, Math.min(4, (m[3] ?? " ").length - (m[4] ?? "").length));
466+ const item: string[] = [m[4] ?? ""];
467+ i++;
468+ let sawBlank = false;
469+ while (i < lines.length) {
470+ const l = lines[i]!;
471+ if (!l.trim()) {
472+ sawBlank = true;
473+ item.push("");
474+ i++;
475+ continue;
476+ }
477+ const ind = l.length - l.trimStart().length;
478+ if (ind >= contentIndent || (ind > baseIndent && LIST_ITEM.test(l))) {
479+ item.push(l.slice(Math.min(ind, contentIndent)));
480+ sawBlank = false;
481+ i++;
482+ continue;
483+ }
484+ if (!sawBlank && !startsBlock(l, lines[i + 1]) && !LIST_ITEM.test(l)) {
485+ // Lazy continuation of the item's paragraph.
486+ item.push(l.trim());
487+ i++;
488+ continue;
489+ }
490+ break;
491+ }
492+ while (item.length && !item[item.length - 1]!.trim()) {
493+ item.pop();
494+ if (i < lines.length && LIST_ITEM.test(lines[i]!)) loose = true;
495+ }
496+ if (item.slice(1).some((l, idx) => !l.trim() && item[idx + 2] !== undefined && !/^\s*([*+-]|\d+[.)])\s/.test(item[idx + 2]!))) loose = true;
497+ items.push(item);
498+ }
499+ const rendered = items.map((item) => {
500+ let first = item[0] ?? "";
501+ let task = "";
502+ const tm = /^\[([ xX])\][ \t]+/.exec(first);
503+ if (tm) {
504+ task = `<input type="checkbox" disabled${tm[1] !== " " ? " checked" : ""}> `;
505+ first = first.slice(tm[0].length);
506+ }
507+ const body = blocks([first, ...item.slice(1)], opts, depth + 1, !loose);
508+ return `<li${task ? ' class="task"' : ""}>${task}${body}</li>`;
509+ });
510+ parts.push(ordered ? `<ol${start !== 1 ? ` start="${start}"` : ""}>${rendered.join("")}</ol>` : `<ul>${rendered.join("")}</ul>`);
511+ continue;
512+ }
513+ // Paragraph.
514+ const para: string[] = [line.trimStart()];
515+ i++;
516+ while (i < lines.length && lines[i]!.trim() && !startsBlock(lines[i]!, lines[i + 1])) {
517+ // Setext headings.
518+ if (/^ {0,3}=+\s*$/.test(lines[i]!) || (/^ {0,3}-+\s*$/.test(lines[i]!) && para.length === 1)) {
519+ const level = lines[i]!.includes("=") ? 1 : 2;
520+ const text = para.join(" ");
521+ const slug = slugify(text);
522+ para.length = 0;
523+ parts.push(`<h${level} id="h-${escapeHtml(slug)}"><a class="anchor" href="#h-${escapeHtml(slug)}" aria-hidden="true">#</a>${inline(text, opts, depth)}</h${level}>`);
524+ i++;
525+ break;
526+ }
527+ para.push(lines[i]!.trimStart());
528+ i++;
529+ }
530+ if (para.length) {
531+ const html = inline(para.join("\n").replace(/[ \t]+$/, ""), opts, depth);
532+ parts.push(tight ? html : `<p>${html}</p>`);
533+ }
534+ }
535+ return parts.join("\n");
536+}
537+
538+/** Render Markdown to sanitized HTML. */
539+export function renderMarkdown(source: string, opts: MarkdownOptions = {}): string {
540+ if (source.length > MAX_SOURCE) return `<pre>${escapeHtml(source)}</pre>`;
541+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
542+ return blocks(lines, opts, 0);
543+}
addedapps/diffinery/src/web/ui.tsx+732−0View file
@@ -1,0 +1,732 @@
1+/** @jsxImportSource hono/jsx */
2+import type { Hono } from "hono";
3+import { deleteCookie, getCookie, setCookie } from "hono/cookie";
4+import { raw } from "hono/html";
5+import type { Child, FC, PropsWithChildren } from "hono/jsx";
6+import type { Account, Repo } from "../lib/directory.js";
7+import type { Env } from "../env.js";
8+import type { AppContext, AppVars } from "../auth/principal.js";
9+import { HttpError, randomToken } from "../lib/util.js";
10+import { atLeast, type Caller } from "../repo/types.js";
11+import type { repoStub } from "../http/common.js";
12+import { CSS, ICONS, JS, type IconName } from "./assets.js";
13+import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js";
14+import { renderMarkdown, type MarkdownBase } from "./markdown.js";
15+
16+export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>;
17+export type Stub = ReturnType<typeof repoStub>;
18+
19+// ---------------------------------------------------------------- small helpers
20+
21+export const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/");
22+export const repoUrl = (r: Pick<Repo, "owner_login" | "name">): string => `/${r.owner_login}/${r.name}`;
23+export const shortSha = (sha: string): string => sha.slice(0, 7);
24+export const firstLine = (s: string): string => s.split("\n")[0] ?? "";
25+
26+export function publicUrl(c: AppContext): string {
27+ return (c.env.PUBLIC_URL || new URL(c.req.url).origin).replace(/\/$/, "");
28+}
29+
30+export function cloneUrl(c: AppContext, repo: Repo): string {
31+ return `${publicUrl(c)}/${repo.owner_login}/${repo.name}.git`;
32+}
33+
34+export function fmtBytes(n: number | undefined | null): string {
35+ if (n === undefined || n === null) return "";
36+ if (n < 1024) return `${n} B`;
37+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
38+ if (n < 1024 ** 3) return `${(n / 1024 / 1024).toFixed(1)} MB`;
39+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
40+}
41+
42+export function fmtDuration(ms: number): string {
43+ if (!Number.isFinite(ms) || ms < 0) return "";
44+ const s = Math.round(ms / 1000);
45+ if (s < 60) return `${s}s`;
46+ const m = Math.floor(s / 60);
47+ if (m < 60) return `${m}m ${s % 60}s`;
48+ return `${Math.floor(m / 60)}h ${m % 60}m`;
49+}
50+
51+export function duration(start: string | null | undefined, end: string | null | undefined): string {
52+ if (!start) return "";
53+ return fmtDuration((end ? Date.parse(end) : Date.now()) - Date.parse(start));
54+}
55+
56+export function ago(iso: string | null | undefined): string {
57+ if (!iso) return "";
58+ const t = Date.parse(iso);
59+ if (!Number.isFinite(t)) return iso;
60+ const s = Math.round((Date.now() - t) / 1000);
61+ if (s < 45) return "just now";
62+ const m = Math.round(s / 60);
63+ if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`;
64+ const h = Math.round(m / 60);
65+ if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`;
66+ const d = Math.round(h / 24);
67+ if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`;
68+ return new Date(t).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
69+}
70+
71+export const Time: FC<{ at: string | null | undefined }> = ({ at }) => (at ? <time datetime={at} title={at}>{ago(at)}</time> : <span />);
72+
73+export const Icon: FC<{ name: IconName; class?: string }> = ({ name, class: cls }) => <span class={`st ${cls ?? ""}`}>{raw(ICONS[name])}</span>;
74+
75+/** Status icon for runs, jobs, checks and steps. */
76+export const Status: FC<{ status?: string | null; conclusion?: string | null }> = ({ status, conclusion }) => {
77+ if (status && status !== "completed") {
78+ if (status === "in_progress") return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>;
79+ return <span class="st warn" title={status === "waiting" ? "Waiting" : "Queued"}>{raw(ICONS.queued)}</span>;
80+ }
81+ switch (conclusion) {
82+ case "success":
83+ return <span class="st ok" title="Success">{raw(ICONS.success)}</span>;
84+ case "failure":
85+ case "timed_out":
86+ case "startup_failure":
87+ return <span class="st bad" title={conclusion === "timed_out" ? "Timed out" : "Failed"}>{raw(ICONS.failure)}</span>;
88+ case "cancelled":
89+ return <span class="st faint" title="Cancelled">{raw(ICONS.cancelled)}</span>;
90+ case "skipped":
91+ return <span class="st faint" title="Skipped">{raw(ICONS.skipped)}</span>;
92+ case "in_progress":
93+ return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>;
94+ case "queued":
95+ case "waiting":
96+ case "pending":
97+ return <span class="st warn" title="Queued">{raw(ICONS.queued)}</span>;
98+ default:
99+ return <span class="st faint" title={conclusion ?? "Neutral"}>{raw(ICONS.neutral)}</span>;
100+ }
101+};
102+
103+function hue(s: string): number {
104+ let h = 0;
105+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
106+ return h % 360;
107+}
108+
109+export const Avatar: FC<{ login: string; kind?: string; size?: "lg" | "xl" }> = ({ login, kind, size }) => (
110+ <span class={`av ${size ?? ""} ${kind === "agent" || kind === "system" ? "bot" : ""}`} style={`--h:${hue(login)}`} aria-hidden="true">
111+ {(login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase()}
112+ </span>
113+);
114+
115+/** Linked actor name with an "agent" badge and the human it acts for. */
116+export const Actor: FC<{ login: string; kind?: string; onBehalfOf?: string | null; avatar?: boolean }> = ({ login, kind, onBehalfOf, avatar = true }) => (
117+ <span class="who">
118+ {avatar ? <Avatar login={login} kind={kind} /> : null}
119+ {kind === "system" ? <span>{login}</span> : <a href={`/${encodeURIComponent(login)}`} style="color:inherit">{login}</a>}
120+ {kind === "agent" ? <span class="badge agent">agent</span> : null}
121+ {kind === "system" ? <span class="badge">bot</span> : null}
122+ {onBehalfOf ? (
123+ <span class="muted small" style="font-weight:400">
124+ on behalf of <a href={`/${encodeURIComponent(onBehalfOf)}`}>{onBehalfOf}</a>
125+ </span>
126+ ) : null}
127+ </span>
128+);
129+
130+export const Label: FC<{ name: string; color?: string; href?: string }> = ({ name, color, href }) => {
131+ const c = color && /^[0-9a-fA-F]{6}$/.test(color) ? `#${color}` : "#8b929c";
132+ return href ? (
133+ <a class="lbl" style={`--lc:${c}`} href={href}>
134+ {name}
135+ </a>
136+ ) : (
137+ <span class="lbl" style={`--lc:${c}`}>{name}</span>
138+ );
139+};
140+
141+export const Markdown: FC<{ text: string; base?: MarkdownBase; empty?: string }> = ({ text, base, empty }) =>
142+ text.trim() ? <div class="markdown">{raw(renderMarkdown(text, { base }))}</div> : <p class="muted m0"><em>{empty ?? "No description provided."}</em></p>;
143+
144+/** CSRF-protected POST form. */
145+export const Form: FC<PropsWithChildren<{ action: string; t: string; class?: string; confirm?: string; match?: string; id?: string; style?: string }>> = (p) => (
146+ <form method="post" action={p.action} class={p.class} data-confirm={p.confirm} data-match={p.match} id={p.id} style={p.style}>
147+ <input type="hidden" name={CSRF_FIELD} value={p.t} />
148+ {p.children}
149+ </form>
150+);
151+
152+export const CopyButton: FC<{ text: string; label?: string; class?: string }> = ({ text, label, class: cls }) => (
153+ <button type="button" class={`btn sm ${cls ?? ""}`} data-copy={text} title="Copy to clipboard">
154+ {label ?? "Copy"}
155+ </button>
156+);
157+
158+export const Pager: FC<{ page: number; hasNext: boolean; href: (page: number) => string }> = ({ page, hasNext, href }) =>
159+ page > 1 || hasNext ? (
160+ <div class="pager">
161+ {page > 1 ? <a class="btn sm" href={href(page - 1)}>← Newer</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">← Newer</span>}
162+ <span class="muted small" style="align-self:center">Page {page}</span>
163+ {hasNext ? <a class="btn sm" href={href(page + 1)}>Older →</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">Older →</span>}
164+ </div>
165+ ) : null;
166+
167+export function qs(params: Record<string, string | number | undefined | null | false>): string {
168+ const u = new URLSearchParams();
169+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "" && v !== false) u.set(k, String(v));
170+ const s = u.toString();
171+ return s ? `?${s}` : "";
172+}
173+
174+export const pageNum = (c: AppContext): number => Math.max(1, Math.min(1000, parseInt(c.req.query("page") ?? "1", 10) || 1));
175+
176+// ---------------------------------------------------------------- errors, flash and actions
177+
178+export function statusOf(e: unknown): { status: number; message: string } {
179+ if (e instanceof HttpError) return { status: e.status, message: e.detail };
180+ const msg = (e as Error)?.message ?? String(e);
181+ const m = /^(\d{3}): ([\s\S]*)$/.exec(msg);
182+ if (m) return { status: Number(m[1]), message: m[2]! };
183+ return { status: 500, message: "internal error" };
184+}
185+
186+const FLASH = "dfy_flash";
187+
188+export function flash(c: AppContext, kind: "error" | "notice", message: string): void {
189+ setCookie(c, FLASH, encodeURIComponent(JSON.stringify({ kind, message: message.slice(0, 600) })), { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 60 });
190+}
191+
192+function takeFlash(c: AppContext): { kind: "error" | "notice"; message: string } | null {
193+ const v = getCookie(c, FLASH);
194+ if (!v) return null;
195+ deleteCookie(c, FLASH, { path: "/", secure: true });
196+ try {
197+ const f = JSON.parse(decodeURIComponent(v)) as { kind: string; message: string };
198+ if (typeof f.message !== "string") return null;
199+ return { kind: f.kind === "notice" ? "notice" : "error", message: f.message };
200+ } catch {
201+ return null;
202+ }
203+}
204+
205+/** Run a state-changing action and redirect (303); client errors become a flash message on `back`. */
206+export async function act(c: AppContext, back: string, fn: () => Promise<string | void>, notice?: string): Promise<Response> {
207+ try {
208+ const to = await fn();
209+ if (notice) flash(c, "notice", notice);
210+ return c.redirect(to ?? back, 303);
211+ } catch (e) {
212+ const s = statusOf(e);
213+ if (s.status >= 500) throw e;
214+ flash(c, "error", s.message);
215+ return c.redirect(back, 303);
216+ }
217+}
218+
219+/** Register a CSRF-checked POST route. */
220+export function post(app: WebApp, path: string, handler: (c: AppContext, form: FormData) => Promise<Response>): void {
221+ app.post(path, async (c) => handler(c, await verifyCsrf(c)));
222+}
223+
224+export function principalAccount(c: AppContext): Account | null {
225+ const p = c.get("principal");
226+ return p?.type === "account" ? p.account : null;
227+}
228+
229+// ---------------------------------------------------------------- layout
230+
231+export interface RepoCtx {
232+ repo: Repo;
233+ caller: Caller;
234+ tab?: "code" | "issues" | "pulls" | "actions" | "settings" | "";
235+ /** Current ref, used by the palette's "go to file". */
236+ ref?: string;
237+ counts?: { issues?: number; pulls?: number };
238+}
239+
240+export interface PageOpts {
241+ title: string;
242+ repo?: RepoCtx;
243+ /** WebSocket topics for live updates (e.g. "run:abc,pr:3"). */
244+ live?: string;
245+ liveMode?: "reload" | "log";
246+ lastSeq?: number;
247+ status?: number;
248+ /** Full-width page (diffs, logs). */
249+ wide?: boolean;
250+}
251+
252+const RepoHeader: FC<{ r: RepoCtx }> = ({ r }) => {
253+ const base = repoUrl(r.repo);
254+ const tab = r.tab ?? "";
255+ const vis = r.repo.visibility;
256+ return (
257+ <div class="repohead">
258+ <div class="wrap">
259+ <h1>
260+ <span class="st faint">{raw(vis === "public" ? ICONS.globe : vis === "internal" ? ICONS.org : ICONS.lock)}</span>
261+ <a href={`/${r.repo.owner_login}`}>{r.repo.owner_login}</a>
262+ <span class="faint">/</span>
263+ <strong>
264+ <a href={base}>{r.repo.name}</a>
265+ </strong>
266+ <span class="badge">{vis}</span>
267+ {r.repo.archived ? <span class="badge amber">archived</span> : null}
268+ </h1>
269+ {r.repo.description ? <p class="muted m0">{r.repo.description}</p> : null}
270+ <nav class="tabs">
271+ <a href={base} class={tab === "code" ? "on" : ""}>
272+ {raw(ICONS.code)} Code
273+ </a>
274+ <a href={`${base}/issues`} class={tab === "issues" ? "on" : ""}>
275+ {raw(ICONS.issueOpen)} Issues {r.counts?.issues ? <span class="n">{r.counts.issues}</span> : null}
276+ </a>
277+ <a href={`${base}/pulls`} class={tab === "pulls" ? "on" : ""}>
278+ {raw(ICONS.pr)} Pull requests {r.counts?.pulls ? <span class="n">{r.counts.pulls}</span> : null}
279+ </a>
280+ <a href={`${base}/actions`} class={tab === "actions" ? "on" : ""}>
281+ {raw(ICONS.play)} Actions
282+ </a>
283+ {atLeast(r.caller.role, "admin") ? (
284+ <a href={`${base}/settings`} class={tab === "settings" ? "on" : ""}>
285+ {raw(ICONS.gear)} Settings
286+ </a>
287+ ) : null}
288+ </nav>
289+ </div>
290+ </div>
291+ );
292+};
293+
294+function csp(nonce: string, c: AppContext): string {
295+ const host = new URL(c.req.url).host;
296+ return [
297+ "default-src 'self'",
298+ `script-src 'nonce-${nonce}'`,
299+ "style-src 'self' 'unsafe-inline'",
300+ "img-src 'self' https: data:",
301+ `connect-src 'self' wss://${host} ws://${host}`,
302+ "font-src 'self'",
303+ "object-src 'none'",
304+ "base-uri 'none'",
305+ "form-action 'self'",
306+ "frame-ancestors 'none'",
307+ ].join("; ");
308+}
309+
310+/** Render a full page. */
311+export function render(c: AppContext, opts: PageOpts, body: Child): Response | Promise<Response> {
312+ csrfToken(c);
313+ const nonce = randomToken(16);
314+ const fl = takeFlash(c);
315+ const p = c.get("principal");
316+ const me = p?.type === "account" ? p.account : null;
317+ const active = me?.status === "active";
318+ c.header("content-security-policy", csp(nonce, c));
319+ c.header("x-frame-options", "DENY");
320+ c.header("x-content-type-options", "nosniff");
321+ c.header("referrer-policy", "same-origin");
322+ c.header("cache-control", "private, no-store");
323+ const r = opts.repo;
324+ const page = (
325+ <html lang="en">
326+ <head>
327+ <meta charset="utf-8" />
328+ <meta name="viewport" content="width=device-width, initial-scale=1" />
329+ <meta name="color-scheme" content="light dark" />
330+ <title>{opts.title ? `${opts.title} · Diffinery` : "Diffinery"}</title>
331+ <link rel="icon" href={`data:image/svg+xml,${encodeURIComponent(ICONS.logo.replace("currentColor", "#5e6ad2"))}`} />
332+ <style>{raw(CSS)}</style>
333+ </head>
334+ <body
335+ data-repo={r ? `${r.repo.owner_login}/${r.repo.name}` : undefined}
336+ data-ref={r?.ref ?? r?.repo.default_branch}
337+ data-live={opts.live}
338+ data-live-mode={opts.live ? (opts.liveMode ?? "reload") : undefined}
339+ data-last-seq={opts.lastSeq !== undefined ? String(opts.lastSeq) : undefined}
340+ >
341+ <header class="top">
342+ <div class="wrap">
343+ <a class="brand" href="/">
344+ {raw(ICONS.logo)} Diffinery
345+ </a>
346+ {active ? (
347+ <button type="button" class="pal-btn" data-palette="1" aria-label="Open command palette">
348+ {raw(ICONS.search)}
349+ <span>Jump to…</span>
350+ <kbd>Ctrl K</kbd>
351+ </button>
352+ ) : null}
353+ <nav>
354+ {active && me ? (
355+ <>
356+ <a href="/new" title="New repository">
357+ {raw(ICONS.plus)}
358+ </a>
359+ <a href="/notifications" title="Notifications">
360+ {raw(ICONS.bell)}
361+ </a>
362+ <a href="/settings" title="Settings" class="who">
363+ <Avatar login={me.login} /> <span>{me.login}</span>
364+ </a>
365+ {p?.type === "account" && p.via === "access" ? <a href="/cdn-cgi/access/logout">Sign out</a> : null}
366+ </>
367+ ) : null}
368+ </nav>
369+ </div>
370+ </header>
371+ {r ? <RepoHeader r={r} /> : null}
372+ <main class="wrap" style={opts.wide ? "max-width:none" : undefined}>
373+ {fl ? <div class={`flash ${fl.kind}`} role={fl.kind === "error" ? "alert" : "status"}>{fl.message}</div> : null}
374+ {body}
375+ </main>
376+ {active ? (
377+ <dialog id="palette" aria-label="Command palette">
378+ <input type="text" placeholder="Jump to a repository, file, page or #number…" autocomplete="off" spellcheck={false} />
379+ <ul role="listbox"></ul>
380+ <div class="foot">
381+ <span>
382+ <kbd>↑</kbd> <kbd>↓</kbd> navigate
383+ </span>
384+ <span>
385+ <kbd>Enter</kbd> open
386+ </span>
387+ <span>
388+ <kbd>g</kbd> then <kbd>c</kbd>/<kbd>i</kbd>/<kbd>p</kbd>/<kbd>a</kbd> code, issues, pulls, actions
389+ </span>
390+ </div>
391+ </dialog>
392+ ) : null}
393+ <script nonce={nonce}>{raw(JS)}</script>
394+ </body>
395+ </html>
396+ );
397+ return c.html(`<!doctype html>${page}`, (opts.status ?? 200) as 200);
398+}
399+
400+/** Friendly error page for HttpErrors (and DO errors carrying a status prefix). */
401+export function errorPage(c: AppContext, e: unknown): Response | Promise<Response> {
402+ const s = statusOf(e);
403+ if (s.status >= 500) console.error(e);
404+ const titles: Record<number, string> = { 401: "Sign in required", 403: "Access denied", 404: "Not found", 405: "Not allowed", 409: "Conflict", 422: "Could not complete the request" };
405+ const title = titles[s.status] ?? (s.status >= 500 ? "Something went wrong" : "Request failed");
406+ const p = c.get("principal");
407+ return render(
408+ c,
409+ { title, status: s.status },
410+ <div class="blank" style="max-width:640px;margin:48px auto">
411+ <div class="faint" style="font-size:48px;font-weight:700;letter-spacing:-.03em">{s.status}</div>
412+ <h1>{title}</h1>
413+ {s.status === 401 ? (
414+ <p class="muted">
415+ Sign in through Cloudflare Access to continue. If you were just signed out, <a href={c.req.path}>reload this page</a> to start the Access sign-in flow.
416+ </p>
417+ ) : s.status === 404 ? (
418+ <p class="muted">The page or repository does not exist, or you do not have access to it.</p>
419+ ) : (
420+ <p class="muted">{s.status >= 500 ? "An unexpected error occurred. Please try again." : s.message}</p>
421+ )}
422+ {s.status === 403 && p?.type === "account" && p.account.status === "pending" ? (
423+ <p>
424+ <a class="btn primary" href="/">
425+ Request access
426+ </a>
427+ </p>
428+ ) : (
429+ <p>
430+ <a class="btn" href="/">
431+ Go to dashboard
432+ </a>
433+ </p>
434+ )}
435+ </div>,
436+ );
437+}
438+
439+// ---------------------------------------------------------------- refs and paths
440+
441+/** Path segments after a fixed number of leading segments, decoded. */
442+export function restOfPath(c: AppContext, skip: number): string {
443+ const parts = new URL(c.req.url).pathname.split("/").slice(skip + 1);
444+ return parts
445+ .filter((p) => p !== "")
446+ .map((p) => {
447+ try {
448+ return decodeURIComponent(p);
449+ } catch {
450+ return p;
451+ }
452+ })
453+ .join("/");
454+}
455+
456+/** Split "feature/x/src/a.ts" into the longest matching ref and the path inside it. */
457+export async function splitRefPath(stub: Stub, rest: string): Promise<{ ref: string; path: string }> {
458+ const segs = rest.split("/").filter(Boolean);
459+ if (segs.length <= 1) return { ref: segs[0] ?? "", path: "" };
460+ const names = new Set((await stub.refs("refs/")).map((r) => r.name.replace(/^refs\/(heads|tags)\//, "")));
461+ for (let i = segs.length; i >= 1; i--) {
462+ const cand = segs.slice(0, i).join("/");
463+ if (names.has(cand)) return { ref: cand, path: segs.slice(i).join("/") };
464+ }
465+ return { ref: segs[0]!, path: segs.slice(1).join("/") };
466+}
467+
468+/** Branch/tag switcher; `to` builds the target URL for a ref. */
469+export const RefSwitcher: FC<{ current: string; branches: string[]; tags: string[]; to: (ref: string) => string }> = ({ current, branches, tags, to }) => (
470+ <details class="menu">
471+ <summary class="btn sm">
472+ {raw(/^[0-9a-f]{40}$/.test(current) ? ICONS.commit : tags.includes(current) && !branches.includes(current) ? ICONS.tag : ICONS.branch)}
473+ <span class="mono">{/^[0-9a-f]{40}$/.test(current) ? shortSha(current) : current}</span> ▾
474+ </summary>
475+ <div class="pop">
476+ <h4>Branches</h4>
477+ {branches.length ? (
478+ branches.map((b) => (
479+ <a href={to(b)}>
480+ {b === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {b}
481+ </a>
482+ ))
483+ ) : (
484+ <p class="muted small" style="margin:4px 8px">No branches</p>
485+ )}
486+ {tags.length ? <h4>Tags</h4> : null}
487+ {tags.map((t) => (
488+ <a href={to(t)}>
489+ {t === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {t}
490+ </a>
491+ ))}
492+ </div>
493+ </details>
494+);
495+
496+export async function refNames(stub: Stub): Promise<{ branches: string[]; tags: string[] }> {
497+ const refs = await stub.refs("refs/");
498+ return {
499+ branches: refs.filter((r) => r.name.startsWith("refs/heads/")).map((r) => r.name.slice(11)),
500+ tags: refs.filter((r) => r.name.startsWith("refs/tags/")).map((r) => r.name.slice(10)),
501+ };
502+}
503+
504+// ---------------------------------------------------------------- diffs
505+
506+export interface DiffHunk {
507+ oldStart: number;
508+ oldLines: number;
509+ newStart: number;
510+ newLines: number;
511+ lines: string[];
512+}
513+
514+export interface DiffFile {
515+ path: string;
516+ status: "added" | "modified" | "deleted";
517+ binary: boolean;
518+ additions: number;
519+ deletions: number;
520+ hunks: DiffHunk[];
521+ truncated?: boolean;
522+}
523+
524+export interface LineThread {
525+ side: "LEFT" | "RIGHT";
526+ line: number;
527+ node: Child;
528+}
529+
530+const StatBar: FC<{ a: number; d: number }> = ({ a, d }) => {
531+ const total = a + d;
532+ const blocks = 5;
533+ const ga = total ? Math.round((a / total) * blocks) : 0;
534+ const gd = total ? Math.min(blocks - ga, Math.round((d / total) * blocks)) : 0;
535+ return (
536+ <span class="bar" aria-hidden="true">
537+ {Array.from({ length: blocks }, (_, i) => (
538+ <i class={i < ga ? "a" : i < ga + gd ? "d" : ""} />
539+ ))}
540+ </span>
541+ );
542+};
543+
544+export const DiffStats: FC<{ files: number; additions: number; deletions: number }> = ({ files, additions, deletions }) => (
545+ <span class="row" style="gap:10px">
546+ <span>
547+ <b>{files}</b> file{files === 1 ? "" : "s"} changed
548+ </span>
549+ <span class="stat-add">+{additions}</span>
550+ <span class="stat-del">−{deletions}</span>
551+ <StatBar a={additions} d={deletions} />
552+ </span>
553+);
554+
555+export interface DiffOptions {
556+ /** Link to view the file at the new revision. */
557+ viewHref?: (path: string) => string | null;
558+ /** Show "+" buttons for line comments (requires the line comment template on the page). */
559+ commentable?: boolean;
560+ /** Existing review threads per file path. */
561+ threads?: Map<string, LineThread[]>;
562+}
563+
564+const LINE_BUDGET = 20000;
565+
566+/** Unified diff with line numbers; each file is collapsible. */
567+export const DiffView: FC<{ files: DiffFile[]; opts?: DiffOptions }> = ({ files, opts = {} }) => {
568+ let budget = LINE_BUDGET;
569+ return (
570+ <div>
571+ {files.map((f) => {
572+ const lines = f.hunks.reduce((n, h) => n + h.lines.length, 0);
573+ const big = lines > 800;
574+ const render = budget > 0 && !f.binary && !f.truncated;
575+ budget -= lines;
576+ const threads = opts.threads?.get(f.path) ?? [];
577+ const view = opts.viewHref?.(f.path);
578+ return (
579+ <details class="file" open={!big && render} id={`diff-${encodeURIComponent(f.path)}`}>
580+ <summary class="filehead">
581+ <span class={`badge ${f.status === "added" ? "green" : f.status === "deleted" ? "red" : "amber"}`}>{f.status}</span>
582+ <span class="mono grow" style="font-weight:600;word-break:break-all">{f.path}</span>
583+ <span class="stat-add small">+{f.additions}</span>
584+ <span class="stat-del small">−{f.deletions}</span>
585+ {view && f.status !== "deleted" ? (
586+ <a class="btn sm" href={view}>
587+ View file
588+ </a>
589+ ) : null}
590+ </summary>
591+ {f.binary ? (
592+ <div class="empty">Binary file not shown.</div>
593+ ) : f.truncated ? (
594+ <div class="empty">This diff is too large to display.</div>
595+ ) : !render ? (
596+ <div class="empty">Diff not rendered to keep this page fast. {view ? <a href={view}>View the file</a> : null}</div>
597+ ) : f.hunks.length === 0 ? (
598+ <div class="empty">{f.status === "added" ? "Empty file added." : f.status === "deleted" ? "Empty file deleted." : "Mode change only."}</div>
599+ ) : (
600+ <div class="code-wrap">
601+ <table class="diff">
602+ {f.hunks.map((h) => {
603+ let oldN = h.oldStart;
604+ let newN = h.newStart;
605+ return (
606+ <>
607+ <tr class="hunk">
608+ {opts.commentable ? <td class="act" /> : null}
609+ <td class="ln" />
610+ <td class="ln" />
611+ <td class="code">
612+ @@ -{h.oldStart},{h.oldLines} +{h.newStart},{h.newLines} @@
613+ </td>
614+ </tr>
615+ {h.lines.map((l) => {
616+ const k = l[0];
617+ if (k === "\\") {
618+ return (
619+ <tr class="meta">
620+ {opts.commentable ? <td class="act" /> : null}
621+ <td class="ln" />
622+ <td class="ln" />
623+ <td class="code">{l}</td>
624+ </tr>
625+ );
626+ }
627+ const cls = k === "+" ? "add" : k === "-" ? "del" : "ctx";
628+ const o = k === "+" ? null : oldN++;
629+ const n = k === "-" ? null : newN++;
630+ const side = k === "-" ? "LEFT" : "RIGHT";
631+ const lineNo = side === "LEFT" ? o : n;
632+ const here = threads.filter((t) => t.side === side && t.line === lineNo);
633+ return (
634+ <>
635+ <tr class={cls}>
636+ {opts.commentable ? (
637+ <td class="act">
638+ <button type="button" class="addc" title="Comment on this line" data-comment-line={String(lineNo)} data-path={f.path} data-side={side}>
639+ +
640+ </button>
641+ </td>
642+ ) : null}
643+ <td class="ln">{o ?? ""}</td>
644+ <td class="ln">{n ?? ""}</td>
645+ <td class="code">
646+ {k === "+" || k === "-" ? k : " "}
647+ {l.slice(1)}
648+ </td>
649+ </tr>
650+ {here.length ? (
651+ <tr class="thread">
652+ <td colspan={opts.commentable ? 4 : 3}>{here.map((t) => t.node)}</td>
653+ </tr>
654+ ) : null}
655+ </>
656+ );
657+ })}
658+ </>
659+ );
660+ })}
661+ </table>
662+ </div>
663+ )}
664+ </details>
665+ );
666+ })}
667+ </div>
668+ );
669+};
670+
671+/** Changed-files summary list with anchors into the diff. */
672+export const FileSummary: FC<{ files: DiffFile[] }> = ({ files }) =>
673+ files.length > 3 ? (
674+ <details class="box">
675+ <summary class="head" style="cursor:pointer">
676+ <span>Files ({files.length})</span>
677+ </summary>
678+ <ul class="list">
679+ {files.map((f) => (
680+ <li style="padding:6px 14px">
681+ <a class="mono grow" href={`#diff-${encodeURIComponent(f.path)}`}>
682+ {f.path}
683+ </a>
684+ <span class="stat-add small">+{f.additions}</span>
685+ <span class="stat-del small">−{f.deletions}</span>
686+ </li>
687+ ))}
688+ </ul>
689+ </details>
690+ ) : null;
691+
692+/** Commit list row. */
693+export const CommitRow: FC<{ base: string; sha: string; message: string; author: { name: string; email?: string; timestamp: number }; signed?: boolean; extra?: Child }> = ({ base, sha, message, author, signed, extra }) => (
694+ <li>
695+ <div class="grow">
696+ <a class="title" href={`${base}/commit/${sha}`}>
697+ {firstLine(message)}
698+ </a>
699+ <div class="meta">
700+ <b>{author.name}</b> committed <Time at={new Date(author.timestamp * 1000).toISOString()} />
701+ {signed ? <span class="badge green" style="margin-left:6px">signed</span> : null}
702+ </div>
703+ </div>
704+ {extra}
705+ <CopyButton text={sha} label="Copy" />
706+ <a class="sha" href={`${base}/commit/${sha}`}>
707+ {shortSha(sha)}
708+ </a>
709+ <a class="btn sm" href={`${base}/tree/${sha}`} title="Browse the repository at this point">
710+ {raw(ICONS.code)}
711+ </a>
712+ </li>
713+);
714+
715+/** Look up agents' owners for "on behalf of" labels. */
716+export async function onBehalfOf(c: AppContext, actors: Array<{ login: string; kind: string }>): Promise<Map<string, string>> {
717+ const out = new Map<string, string>();
718+ const dir = c.get("dir");
719+ const logins = [...new Set(actors.filter((a) => a.kind === "agent").map((a) => a.login))].slice(0, 20);
720+ await Promise.all(
721+ logins.map(async (login) => {
722+ const a = await dir.accountByLogin(login);
723+ if (a?.owner_id) {
724+ const owner = await dir.account(a.owner_id);
725+ if (owner) out.set(login, owner.login);
726+ }
727+ }),
728+ );
729+ return out;
730+}
731+
732+export const ROLES = ["read", "triage", "write", "maintain", "admin"] as const;
addedapps/diffinery/tsconfig.json+11−0View file
@@ -1,0 +1,11 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "noEmit": true,
5+ "lib": ["ES2023"],
6+ "types": ["@cloudflare/workers-types"],
7+ "jsx": "react-jsx",
8+ "jsxImportSource": "hono/jsx"
9+ },
10+ "include": ["src"]
11+}
addedcontainers/runner/.dockerignore+2−0View file
@@ -1,0 +1,2 @@
1+*
2+!dist/agent.mjs
addedcontainers/runner/Dockerfile+50−0View file
@@ -1,0 +1,50 @@
1+# Diffinery runner image (Cloudflare Containers, linux/amd64).
2+# Build context: containers/runner. Run `pnpm build` first so dist/agent.mjs exists.
3+#
4+# docker build --platform linux/amd64 -t diffinery-runner containers/runner
5+FROM --platform=linux/amd64 ubuntu:24.04
6+
7+ARG NODE_VERSION=22.22.0
8+
9+ENV DEBIAN_FRONTEND=noninteractive \
10+ LANG=C.UTF-8 \
11+ LC_ALL=C.UTF-8 \
12+ DIFFINERY_WORK_ROOT=/workspace \
13+ RUNNER_TOOL_CACHE=/opt/hostedtoolcache \
14+ AGENT_TOOLSDIRECTORY=/opt/hostedtoolcache \
15+ COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
16+ # Ubuntu 24.04 marks the system Python as externally managed (PEP 668);
17+ # CI scripts commonly `pip install` directly, so allow it like hosted runners do.
18+ PIP_BREAK_SYSTEM_PACKAGES=1
19+
20+RUN apt-get update \
21+ && apt-get install -y --no-install-recommends \
22+ ca-certificates curl git jq zip unzip xz-utils tar gzip \
23+ build-essential pkg-config openssh-client gnupg \
24+ python3 python3-pip python3-venv python-is-python3 \
25+ && git config --system --add safe.directory '*' \
26+ && apt-get clean \
27+ && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
28+
29+# Node.js LTS from the official tarball (checksum-verified), with corepack shims for pnpm/yarn.
30+RUN set -eux; \
31+ cd /tmp; \
32+ file="node-v${NODE_VERSION}-linux-x64.tar.xz"; \
33+ curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/${file}"; \
34+ curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" | grep " ${file}\$" | sha256sum -c -; \
35+ tar -xJf "${file}" -C /usr/local --strip-components=1 --no-same-owner \
36+ --exclude='*/CHANGELOG.md' --exclude='*/README.md' --exclude='*/LICENSE' --exclude='*/include/*' --exclude='*/share/doc/*'; \
37+ rm -f "${file}"; \
38+ corepack enable; \
39+ corepack prepare pnpm@10.28.0 --activate; \
40+ corepack prepare yarn@1.22.22 --activate; \
41+ node --version; npm --version; pnpm --version; yarn --version; \
42+ npm cache clean --force; \
43+ rm -rf /root/.npm /tmp/*
44+
45+RUN mkdir -p /workspace /opt/hostedtoolcache /opt/diffinery
46+COPY dist/agent.mjs /opt/diffinery/agent.mjs
47+
48+WORKDIR /workspace
49+EXPOSE 8080
50+ENTRYPOINT ["node", "/opt/diffinery/agent.mjs"]
modifiedcontainers/runner/src/mask.ts+8−2View file
@@ -1,3 +1,9 @@
1+/**
2+ * Values shorter than this are not masked: masking e.g. a 1-character secret
3+ * would replace that character everywhere and make logs unreadable.
4+ */
5+export const MIN_MASK_LENGTH = 3;
6+
17 /** Replaces registered secret values with `***` in log output. */
28 export class Masker {
39 private values = new Set<string>();
@@ -6,7 +12,7 @@
612 add(value: string | undefined | null): void {
713 if (value === undefined || value === null) return;
814 const v = String(value);
9- if (v.trim() === "") return;
15+ if (v.trim().length < MIN_MASK_LENGTH) return;
1016 const before = this.values.size;
1117 this.values.add(v);
1218 this.values.add(v.trim());
@@ -14,7 +20,7 @@
1420 if (/\r?\n/.test(v)) {
1521 for (const line of v.split(/\r?\n/)) {
1622 const t = line.trim();
17- if (t.length >= 2) this.values.add(t);
23+ if (t.length >= MIN_MASK_LENGTH) this.values.add(t);
1824 }
1925 }
2026 if (this.values.size !== before) {
modifiedcontainers/runner/test/job.test.ts+98−10View file
@@ -1,8 +1,10 @@
1-import { execFileSync } from "node:child_process";
1+import { execFileSync, spawn } from "node:child_process";
2+import { createServer } from "node:http";
3+import type { AddressInfo } from "node:net";
24 import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
35 import path from "node:path";
46 import { afterEach, beforeEach, describe, expect, it } from "vitest";
5-import type { JobSpec } from "@diffinery/shared";
7+import type { JobSpec, StepSpec } from "@diffinery/shared";
68 import { apiBaseFor, runAgentJob } from "../src/agent.js";
79 import type { JobRunner } from "../src/job.js";
810 import { makeSpec, startFakeApi, tmp, type FakeApi } from "./helpers.js";
@@ -32,6 +34,22 @@
3234
3335 const workspace = () => path.join(workRoot, "widgets");
3436
37+/** Async spawn: the fake API lives in this process, so the event loop must stay free. */
38+function spawnAsync(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<{ status: number | null; stdout: string; stderr: string }> {
39+ return new Promise((resolve) => {
40+ const child = spawn(cmd, args, { env });
41+ let stdout = "";
42+ let stderr = "";
43+ child.stdout.on("data", (d) => (stdout += d));
44+ child.stderr.on("data", (d) => (stderr += d));
45+ const t = setTimeout(() => child.kill("SIGKILL"), 30_000);
46+ child.on("close", (status) => {
47+ clearTimeout(t);
48+ resolve({ status, stdout, stderr });
49+ });
50+ });
51+}
52+
3553 describe("job runner end-to-end", () => {
3654 it("propagates env, outputs, path, summaries and job outputs", async () => {
3755 const spec = makeSpec(
@@ -349,6 +367,58 @@
349367 expect(api.logText(1)).toContain("after");
350368 });
351369
370+ it("downloads and runs remote actions (owner/repo/path@ref)", async () => {
371+ // fake codeload: serves a tarball with a top-level folder, like GitHub does
372+ const src = tmp("remote-action-");
373+ const top = path.join(src, "tools-abc123");
374+ mkdirSync(path.join(top, "hello"), { recursive: true });
375+ writeFileSync(
376+ path.join(top, "hello/action.yml"),
377+ "name: hello\ninputs:\n name:\n default: anon\noutputs:\n said:\n description: x\nruns:\n using: 'node24'\n main: 'main.js'\n",
378+ );
379+ writeFileSync(
380+ path.join(top, "hello/main.js"),
381+ 'require("fs").appendFileSync(process.env.GITHUB_OUTPUT, `said=hi ${process.env.INPUT_NAME}\\n`); console.log("remote action ran in", process.env.GITHUB_ACTION_REPOSITORY, process.env.GITHUB_ACTION_REF);',
382+ );
383+ const tarball = path.join(src, "a.tgz");
384+ execFileSync("tar", ["-czf", tarball, "-C", src, "tools-abc123"]);
385+ const hits: string[] = [];
386+ const server = createServer((req, res) => {
387+ hits.push(req.url ?? "");
388+ if (req.url === "/acme/tools/tar.gz/v1") {
389+ res.writeHead(200, { "content-type": "application/gzip" });
390+ res.end(readFileSync(tarball));
391+ } else {
392+ res.writeHead(404);
393+ res.end();
394+ }
395+ });
396+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
397+ const prev = process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
398+ process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}/{owner}/{repo}/tar.gz/{ref}`;
399+ try {
400+ const c = await run(
401+ makeSpec(api, [
402+ { id: "h", uses: "acme/tools/hello@v1", with: { name: "bob" } },
403+ { uses: "acme/tools/hello@v1" },
404+ { run: 'echo "out=${{ steps.h.outputs.said }}"' },
405+ { uses: "acme/missing@v9" },
406+ ]),
407+ );
408+ expect(api.logText(0)).toContain("remote action ran in acme/tools v1");
409+ expect(api.logText(2)).toContain("out=hi bob");
410+ expect(hits.filter((h) => h === "/acme/tools/tar.gz/v1")).toHaveLength(1); // cached after first download
411+ expect(c.conclusion).toBe("failure");
412+ expect(c.failure?.step).toBe(3);
413+ expect(c.failure?.message).toContain("Unable to resolve action 'acme/missing@v9'");
414+ } finally {
415+ if (prev === undefined) delete process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL;
416+ else process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = prev;
417+ await new Promise<void>((r) => server.close(() => r()));
418+ rmSync(src, { recursive: true, force: true });
419+ }
420+ });
421+
352422 it("rejects docker actions and github-script with clear errors", async () => {
353423 const c = await run(makeSpec(api, [{ uses: "docker://alpine:3" }]));
354424 expect(c.failure?.message).toContain("Docker actions are not supported on Diffinery runners yet");
@@ -390,7 +460,7 @@
390460 });
391461
392462 it("caches and restores via the runner cache endpoint, and round-trips artifacts", async () => {
393- const steps = [
463+ const steps: StepSpec[] = [
394464 { run: 'mkdir -p deps dist/sub && echo "${{ github.run_id }}" > deps/marker && echo a > dist/a.txt && echo b > dist/sub/b.txt' },
395465 { id: "cache", uses: "actions/cache@v4", with: { path: "deps", key: "deps-${{ hashFiles('**/lock.txt') }}-v1", "restore-keys": "deps-" } },
396466 { run: 'echo "hit=${{ steps.cache.outputs.cache-hit }}"; cat deps/marker' },
@@ -499,10 +569,9 @@
499569 const spec = makeSpec(api, [{ run: 'echo "from bundle $GITHUB_JOB"' }], { outputs: { x: "y" } });
500570 const specFile = path.join(workRoot, "spec.json");
501571 writeFileSync(specFile, JSON.stringify(spec));
502- const env = { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w1"), DIFFINERY_API_URL: "" };
572+ const env: NodeJS.ProcessEnv = { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w1") };
503573 delete env.DIFFINERY_API_URL;
504- const { spawnSync } = await import("node:child_process");
505- const r = spawnSync(process.execPath, [out, "--spec", specFile], { env, encoding: "utf8", timeout: 30_000 });
574+ const r = await spawnAsync(process.execPath, [out, "--spec", specFile], env);
506575 expect(r.status, r.stderr + r.stdout).toBe(0);
507576 expect(api.completion).toMatchObject({ conclusion: "success", outputs: { x: "y" } });
508577 expect(api.logText(0)).toContain("from bundle build");
@@ -510,13 +579,32 @@
510579 // env mode: fetch the spec from the API
511580 api.completion = null;
512581 api.specs.set("job-2", { ...spec, jobId: "job-2", steps: [{ run: "exit 4" }] });
513- const r2 = spawnSync(process.execPath, [out], {
514- env: { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w2"), DIFFINERY_API_URL: api.url, DIFFINERY_JOB_ID: "job-2", DIFFINERY_JOB_TOKEN: api.token },
515- encoding: "utf8",
516- timeout: 30_000,
582+ const r2 = await spawnAsync(process.execPath, [out], {
583+ ...process.env,
584+ DIFFINERY_WORK_ROOT: path.join(workRoot, "w2"),
585+ DIFFINERY_API_URL: api.url,
586+ DIFFINERY_JOB_ID: "job-2",
587+ DIFFINERY_JOB_TOKEN: api.token,
517588 });
518589 expect(r2.status).toBe(1);
519590 expect(api.completion).toMatchObject({ conclusion: "failure", failure: { step: 0 } });
520591 expect(readFileSync(path.join(workRoot, "w2/_temp/_github_workflow/event.json"), "utf8")).toContain("pusher");
592+
593+ // SIGTERM: kill the running step's process group and report cancelled
594+ api.completion = null;
595+ api.steps = [];
596+ api.specs.set("job-3", { ...spec, jobId: "job-3", steps: [{ run: "echo started; sleep 60" }, { run: "echo never" }] });
597+ const child = spawn(process.execPath, [out], {
598+ env: { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w3"), DIFFINERY_API_URL: api.url, DIFFINERY_JOB_ID: "job-3", DIFFINERY_JOB_TOKEN: api.token },
599+ });
600+ const exited = new Promise<number | null>((r) => child.on("close", (code) => r(code)));
601+ const t0 = Date.now();
602+ while (!api.logs.some((l) => l.text === "started") && Date.now() - t0 < 15_000) await new Promise((r) => setTimeout(r, 50));
603+ child.kill("SIGTERM");
604+ expect(await exited).toBe(1);
605+ expect(Date.now() - t0).toBeLessThan(15_000);
606+ expect(api.completion).toMatchObject({ conclusion: "cancelled" });
607+ expect(api.lastReport(0)?.conclusion).toBe("cancelled");
608+ expect(api.lastReport(1)?.conclusion).toBe("cancelled");
521609 }, 60_000);
522610 });
modifiedcontainers/runner/test/unit.test.ts+2−0View file
@@ -131,6 +131,8 @@
131131 m.add("-----BEGIN KEY-----\nAAAABBBB\n-----END KEY-----");
132132 m.add("");
133133 m.add(" ");
134+ m.add("t"); // too short to mask
135+ expect(m.mask("test")).toBe("test");
134136 expect(m.mask("x abcdef y abc z")).toBe("x *** y *** z");
135137 expect(m.mask("AAAABBBB")).toBe("***");
136138 expect(m.mask("nothing here")).toBe("nothing here");
modifiedpackages/shared/src/events.ts+3−2View file
@@ -1,3 +1,4 @@
1+import type { Json } from "./runner.js";
12 /** Events flowing through the `events` queue. Payload shapes follow GitHub webhooks where practical. */
23 export type DiffineryEvent =
34 | { type: "push"; repoId: string; owner: string; repo: string; ref: string; before: string; after: string; actor: Actor; forced: boolean; created: boolean; deleted: boolean; at: string }
@@ -6,8 +7,8 @@
67 | { type: "issue_comment"; action: "created"; repoId: string; owner: string; repo: string; number: number; commentId: string; actor: Actor; at: string }
78 | { type: "pull_request_review"; action: "submitted"; repoId: string; owner: string; repo: string; number: number; state: string; actor: Actor; at: string }
89 | { type: "workflow_run"; action: "requested" | "completed"; repoId: string; owner: string; repo: string; runId: string; conclusion?: string; actor: Actor; at: string }
9- | { type: "workflow_dispatch"; repoId: string; owner: string; repo: string; ref: string; workflow: string; inputs: Record<string, unknown>; actor: Actor; at: string }
10- | { type: "audit"; action: string; actor: Actor; target?: string; detail?: Record<string, unknown>; at: string };
10+ | { type: "workflow_dispatch"; repoId: string; owner: string; repo: string; ref: string; workflow: string; inputs: Record<string, Json>; actor: Actor; at: string }
11+ | { type: "audit"; action: string; actor: Actor; target?: string; detail?: Record<string, Json>; at: string };
1112
1213 export interface Actor {
1314 id: string;
modifiedpackages/shared/src/runner.ts+20−13View file
@@ -1,4 +1,11 @@
11 /**
2+ * JSON-safe values. Typed loosely on purpose: a recursive JSON type is too deep
3+ * for the Durable Object RPC type mapping, and `unknown` is not serialisable.
4+ */
5+// biome-ignore lint/suspicious/noExplicitAny: see above
6+export type Json = any;
7+
8+/**
29 * Contract between the Diffinery control plane (Worker) and the runner agent
310 * that executes a single job inside a Cloudflare Container.
411 *
@@ -10,7 +17,7 @@
1017
1118 export type Conclusion = "success" | "failure" | "cancelled" | "skipped";
1219
13-export interface StepSpec {
20+export type StepSpec = {
1421 id?: string;
1522 name?: string;
1623 if?: string;
@@ -22,14 +29,14 @@
2229 "working-directory"?: string;
2330 "continue-on-error"?: boolean | string;
2431 "timeout-minutes"?: number;
25-}
32+};
2633
2734 export interface NeedsResult {
2835 result: Conclusion;
2936 outputs: Record<string, string>;
3037 }
3138
32-export interface JobSpec {
39+export type JobSpec = {
3340 runId: string;
3441 jobId: string;
3542 /** Key of the job in the workflow file, e.g. `build`. */
@@ -41,14 +48,14 @@
4148 ref: string;
4249 workflowPath: string;
4350 /** The full `github` context (event name, payload, actor, sha, ref, ...). */
44- github: Record<string, unknown>;
51+ github: Record<string, Json>;
4552 /** Workflow-level env merged with job-level env; values may contain `${{ }}`. */
4653 env: Record<string, string>;
4754 defaults?: { run?: { shell?: string; "working-directory"?: string } };
48- matrix: Record<string, unknown>;
55+ matrix: Record<string, Json>;
4956 strategy: { "fail-fast": boolean; "job-index": number; "job-total": number; "max-parallel"?: number };
5057 needs: Record<string, NeedsResult>;
51- inputs: Record<string, unknown>;
58+ inputs: Record<string, Json>;
5259 vars: Record<string, string>;
5360 /** Only the secrets this job may see. Values must be masked in logs. */
5461 secrets: Record<string, string>;
@@ -63,32 +70,32 @@
6370 apiUrl: string;
6471 /** Bearer token for runner endpoints and for `git clone` (as password). */
6572 token: string;
66-}
73+};
6774
68-export interface LogLine {
75+export type LogLine = {
6976 /** ISO timestamp. */
7077 t: string;
7178 /** Index of the step this line belongs to; -1 for job setup/teardown. */
7279 step: number;
7380 text: string;
74-}
81+};
7582
76-export interface StepReport {
83+export type StepReport = {
7784 status: "in_progress" | "completed";
7885 conclusion?: Conclusion;
7986 name: string;
8087 startedAt?: string;
8188 completedAt?: string;
82-}
89+};
8390
84-export interface JobCompletion {
91+export type JobCompletion = {
8592 conclusion: Conclusion;
8693 outputs: Record<string, string>;
8794 /** Markdown written to $GITHUB_STEP_SUMMARY, if any. */
8895 summary?: string;
8996 /** Short machine-readable reason when the job failed (for agents). */
9097 failure?: { step?: number; message: string };
91-}
98+};
9299
93100 /** Runner API paths, relative to `${apiUrl}/runner/jobs/${jobId}`. */
94101 export const RunnerPaths = {