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.

294 lines · 16.6 KBRaw History
1import { Hono } from "hono";
2import { contextStorage } from "hono/context-storage";
3import { getCookie } from "hono/cookie";
4import type { DiffineryEvent } from "@diffinery/shared";
5import type { Env } from "./env.js";
6import { credentialFrom, resolvePrincipal, type AppVars } from "./auth/principal.js";
7import { Directory, pruneAuditLog } from "./lib/directory.js";
8import { pruneExpired } from "./lib/retention.js";
9import { getFirst } from "./lib/storage-keys.js";
10import { BOOKMARK_COOKIE, BOOKMARK_HEADER, d1Session, sessionConstraint } from "./lib/d1.js";
11import { SESSION_COOKIE } from "./auth/sessions.js";
12import { newId, now } from "./lib/util.js";
13import { bucketsFor, overLimit, tooMany } from "./lib/rate-limit.js";
14import { deliverWebhook, type WebhookDeps, type WebhookRetry } from "./lib/webhooks.js";
15import { runStages, type Stage, type StageRetry } from "./lib/stages.js";
16import { apiNotFound, errorResponse, loadRepo, repoStub } from "./http/common.js";
17import { git } from "./http/git.js";
18import { runner } from "./http/runner.js";
19import { actionsRuntime } from "./http/actions-runtime.js";
20import { api, wellKnown } from "./http/api.js";
21import { avatars } from "./http/avatars.js";
22import { camo } from "./http/camo.js";
23import { mailApi } from "./mail/api.js";
24import { files } from "./http/files.js";
25import { webhookPayload } from "./lib/webhook-payload.js";
26import { mcp } from "./mcp/server.js";
27import { graphql } from "./http/graphql.js";
28import { registry } from "./registry/http.js";
29import { packagesApi } from "./registry/api.js";
30import { RegistryStore } from "./registry/store.js";
31import { mailCron, notifyEvent } from "./mail/notify.js";
32import { schedulerStub } from "./lib/schedules.js";
33import { mailQueue } from "./mail/send.js";
34import { mailEvents, mailEventsQueue } from "./mail/events.js";
35import { securityTxtRoutes } from "./http/security-txt.js";
36import { securityReportsApi } from "./http/security-reports.js";
37import { earlyAccessApi } from "./http/early-access.js";
38import { statusApi } from "./http/status.js";
39import { onReviewEvent } from "./agentic/reviews.js";
40import { onVerifyEvent } from "./agentic/tasks.js";
41import { recordActivity } from "./agentic/today.js";
42import { modelProxy } from "./agentic/proxy.js";
43import type { MailJob } from "./mail/types.js";
44import { expireExports } from "./accounts/export.js";
45import { cliLogin } from "./http/cli-login.js";
46import { CliLogins } from "./auth/cli-login.js";
47import { OAUTH_API_PATH, oauthApi } from "./http/oauth.js";
48import { OAuth } from "./auth/oauth.js";
49import { Timing, timedEnv } from "./lib/timing.js";
50
51export const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
52
53app.onError((e, c) => errorResponse(e, c));
54app.notFound((c) => (c.req.path.startsWith("/api/") ? apiNotFound() : c.text("404 Not Found", 404)));
55
56// The model proxy for agent tasks: its own task-scoped token, not an account credential (agentic/proxy.ts).
57app.all("/-/model/:provider/*", (c) => modelProxy(c));
58
59// The request's context, for code that renders without it in hand (the Markdown component signs image proxy URLs with its settings).
60app.use("*", contextStorage());
61
62// Security headers on every response, then identity: tokens, Access JWT, or anonymous.
63app.use("*", async (c, next) => {
64 // Browser sessions ride on cookies, so refuse state-changing requests that come from another site.
65 // Token-authenticated calls (git, CLI, MCP, runners) carry no ambient credentials and are exempt.
66 // OAuth's token, registration and revocation endpoints use no cookies and take client credentials
67 // of their own (http/oauth.ts): any site's MCP client may call them, and they skip the session lookup.
68 const oauthCall = OAUTH_API_PATH.test(c.req.path);
69 if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && !oauthCall && !credentialFrom(c.req.raw)) {
70 const site = c.req.header("sec-fetch-site");
71 const origin = c.req.header("origin");
72 const ours = new URL(c.env.PUBLIC_URL).origin;
73 if ((site && site !== "same-origin" && site !== "none") || (!site && origin && origin !== ours)) {
74 return c.json({ message: "cross-site request refused" }, 403);
75 }
76 }
77 // One D1 session per request: replica reads that still see this client's earlier writes (see lib/d1.ts).
78 const token = !oauthCall && !!credentialFrom(c.req.raw);
79 const cookie = getCookie(c, BOOKMARK_COOKIE);
80 const d1 = d1Session(c.env, sessionConstraint({ header: c.req.header(BOOKMARK_HEADER), cookie, token }));
81 const timing = new Timing();
82 c.env = timedEnv(d1.env, timing);
83 const dir = new Directory(c.env);
84 c.set("dir", dir);
85 // The Actions runtime services authenticate with their own signed tokens and URLs (http/actions-runtime.ts).
86 const runtime = c.req.path.startsWith("/twirp/") || c.req.path.startsWith("/_runtime/");
87 // Avatar and proxied Markdown images are public and on every page many times over: skip the session lookup for them.
88 const avatar = (c.req.method === "GET" || c.req.method === "HEAD") && (c.req.path.startsWith("/api/avatars/") || c.req.path.startsWith("/-/camo/"));
89 c.set("principal", runtime || avatar || oauthCall ? { type: "anonymous" } : await resolvePrincipal(c.req.raw, c.env, dir));
90 const limited = await overLimit(c.env, bucketsFor(c.req.method, c.req.path, c.get("principal"), c.req.header("cf-connecting-ip") ?? "unknown"));
91 if (limited) c.res = tooMany(c, limited);
92 else await next();
93 if (c.res.status === 101) return;
94 const h = c.res.headers;
95 const bookmark = d1.bookmark();
96 if (bookmark) {
97 h.set(BOOKMARK_HEADER, bookmark);
98 // Browsers: remember it once they have written something or are signed in.
99 const browser = !token && (!["GET", "HEAD"].includes(c.req.method) || cookie !== undefined || getCookie(c, SESSION_COOKIE) !== undefined);
100 if (browser && !avatar && bookmark !== cookie) h.append("set-cookie", `${BOOKMARK_COOKIE}=${bookmark}; Max-Age=86400; Path=/; HttpOnly; Secure; SameSite=Lax`);
101 }
102 h.set("strict-transport-security", "max-age=63072000; includeSubDomains; preload");
103 h.set("x-content-type-options", "nosniff");
104 h.set("referrer-policy", "strict-origin-when-cross-origin");
105 if (!h.has("x-frame-options")) h.set("x-frame-options", "DENY");
106 h.set("permissions-policy", "camera=(), microphone=(), geolocation=()");
107 // Where the time went, for signed-in people's developer tools (lib/timing.ts).
108 if (c.get("principal").type === "account" && c.res.status < 400 && !h.has("server-timing")) h.set("server-timing", timing.header((c.req.raw.cf as { colo?: string } | undefined)?.colo));
109});
110
111app.get("/healthz", (c) => c.json({ ok: true }));
112
113app.route("/", avatars);
114app.route("/", camo);
115app.route("/", cliLogin);
116app.route("/", oauthApi);
117app.route("/", wellKnown);
118app.route("/", securityTxtRoutes);
119app.route("/", git);
120app.route("/", runner);
121app.route("/", mailApi);
122app.route("/", securityReportsApi);
123app.route("/", earlyAccessApi);
124app.route("/", statusApi);
125app.route("/", mailEvents);
126app.route("/", registry);
127app.route("/", packagesApi);
128app.route("/", actionsRuntime);
129app.route("/", graphql);
130app.route("/", api);
131app.route("/", files);
132app.route("/", mcp);
133
134// Live updates: authorise in the Worker, then hand the WebSocket to the repository's Durable Object.
135app.get("/:owner/:repo/live", async (c) => {
136 if (c.req.header("upgrade") !== "websocket") return c.text("expected websocket", 426);
137 const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read", { follow: true });
138 return stub.fetch(c.req.raw);
139});
140
141app.get("/:owner/:repo/actions/runs/:runId/artifacts/:name", async (c) => {
142 const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"));
143 // By name, or by id (the artifact-url output of upload-artifact).
144 const name = c.req.param("name");
145 const all = await stub.artifacts(c.req.param("runId"));
146 const artifact = all.find((a) => a.name === name) ?? (/^\d+$/.test(name) ? all.find((a) => a.id === Number(name)) : undefined);
147 if (!artifact) return c.json({ message: "artifact not found" }, 404);
148 const obj = await getFirst(c.env.BUCKET, artifact.r2Keys);
149 if (!obj) return c.json({ message: "artifact expired" }, 410);
150 return new Response(obj.body, {
151 headers: {
152 "content-type": artifact.contentType,
153 "content-length": String(obj.size),
154 "content-disposition": `attachment; filename="${artifact.fileName.replace(/[^\w.-]+/g, "_")}"`,
155 },
156 });
157});
158
159// ---------------------------------------------------------------- events queue
160
161const GITHUB_EVENT: Record<DiffineryEvent["type"], string> = {
162 push: "push",
163 pull_request: "pull_request",
164 issues: "issues",
165 issue_comment: "issue_comment",
166 pull_request_review: "pull_request_review",
167 workflow_run: "workflow_run",
168 workflow_dispatch: "workflow_dispatch",
169 release: "release",
170 status: "status",
171 deployment: "deployment",
172 deployment_status: "deployment_status",
173 deployment_review: "deployment_review",
174 repository_dispatch: "repository_dispatch",
175 audit: "audit",
176};
177
178async function deliverWebhooks(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>): Promise<void> {
179 const stub = repoStub(env, ev.repoId);
180 const hooks = (await stub.deliverableWebhooks()).filter((h) => h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type]));
181 if (!hooks.length) return;
182 const body = JSON.stringify(webhookPayload(env.PUBLIC_URL, ev, await new Directory(env).repoById(ev.repoId)));
183 // Each hook retries on its own (deliverWebhook requeues it). Once a hook may have been sent to,
184 // an error must not fail the stage, or the stage retry would send every hook again.
185 const sent = await Promise.allSettled(hooks.map((h) => deliverWebhook(webhookDeps(env), { type: "webhook_retry", repoId: ev.repoId, hookId: h.id, event: GITHUB_EVENT[ev.type], diffineryEvent: ev.type, body, delivery: newId(), attempt: 0 }, h)));
186 for (const r of sent) if (r.status === "rejected") console.error("webhook delivery bookkeeping failed", ev.type, r.reason);
187}
188
189const webhookDeps = (env: Env): WebhookDeps => ({
190 ownHost: new URL(env.PUBLIC_URL).hostname,
191 hook: async (repoId, hookId) => (await repoStub(env, repoId).deliverableWebhooks()).find((h) => h.id === hookId) ?? null,
192 record: (repoId, hookId, status) => repoStub(env, repoId).recordDelivery(hookId, status),
193 requeue: async (r, delaySeconds) => {
194 await env.EVENTS.send(r as unknown as DiffineryEvent, { delaySeconds });
195 },
196});
197
198async function handleEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> {
199 if (ev.type === "audit") {
200 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 });
201 return;
202 }
203 // The Today view finds repositories by their latest activity (agentic/today.ts).
204 await recordActivity(env, ev);
205 if (ev.type === "push") {
206 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 });
207 }
208 if (ev.type === "pull_request" && ev.action === "merged") {
209 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 });
210 }
211 // Agent code review (agentic/reviews.ts): opt-in per repository, best effort, never retried.
212 await onReviewEvent(env, dir, ev).catch((e) => console.error("agent review event failed", e));
213 // Agent tasks' Fix failing checks (agentic/tasks.ts): opt-in per task, best effort.
214 await onVerifyEvent(env, ev).catch((e) => console.error("agent task verify failed", e));
215 await runEventStages(env, dir, ev, ["webhooks", "notify"]);
216}
217
218type RepoEvent = Exclude<DiffineryEvent, { type: "audit" }>;
219
220/** Webhooks and notifications run independently; a failed one is retried alone (see lib/stages.ts). */
221function runEventStages(env: Env, dir: Directory, ev: RepoEvent, stages: Stage[], attempt = 0): Promise<void> {
222 return runStages(
223 ev,
224 stages,
225 (stage) => (stage === "webhooks" ? deliverWebhooks(env, ev) : notifyEvent(env, dir, ev)),
226 async (r, delaySeconds) => {
227 await env.EVENTS.send(r as unknown as DiffineryEvent, { delaySeconds });
228 },
229 attempt,
230 );
231}
232
233export async function queue(batch: MessageBatch<DiffineryEvent | MailJob>, env: Env): Promise<void> {
234 // Bounces and complaints from Cloudflare Email Sending's Event Subscriptions (mail/events.ts).
235 if (batch.queue.startsWith("diffinery-mail-events")) return mailEventsQueue(batch as MessageBatch<unknown>, env);
236 // Email has its own queue (diffinery-mail): its jobs can hold sign-in and invite links, so they stay out of the event archive.
237 if (batch.queue.startsWith("diffinery-mail")) return mailQueue(batch as MessageBatch<MailJob>, env);
238 return events(batch as MessageBatch<DiffineryEvent>, env);
239}
240
241async function events(batch: MessageBatch<DiffineryEvent>, env: Env): Promise<void> {
242 const dir = new Directory(env);
243 // Keep an append-only copy of every event in R2 for long-term audit retention.
244 const day = new Date().toISOString().slice(0, 10).replaceAll("-", "/");
245 const fresh = batch.messages.filter((m) => !["webhook_retry", "stage_retry"].includes((m.body as { type: string }).type));
246 if (fresh.length) await env.BUCKET.put(`events/${day}/${Date.now()}-${newId()}.jsonl`, fresh.map((m) => JSON.stringify(m.body)).join("\n")).catch((e) => console.error("event archive failed", e));
247 for (const msg of batch.messages) {
248 try {
249 const body = msg.body as DiffineryEvent | WebhookRetry | StageRetry<RepoEvent>;
250 if (body.type === "webhook_retry") await deliverWebhook(webhookDeps(env), body);
251 else if (body.type === "stage_retry") await runEventStages(env, dir, body.event, [body.stage], body.attempt);
252 else await handleEvent(env, dir, body);
253 msg.ack();
254 } catch (e) {
255 console.error("event handling failed", msg.body.type, e);
256 msg.retry({ delaySeconds: Math.min(300, 10 * 2 ** msg.attempts) });
257 }
258 }
259}
260
261// ---------------------------------------------------------------- cron: housekeeping
262
263export async function scheduled(controller: ScheduledController, env: Env, ctx?: ExecutionContext): Promise<void> {
264 // Container registry housekeeping: abandoned uploads and bytes no image uses any more.
265 const cleanup = new RegistryStore(env).cleanup().catch((e) => console.error("registry cleanup failed", e));
266 if (ctx) ctx.waitUntil(cleanup);
267 else await cleanup;
268 const at = new Date(controller.scheduledTime);
269 at.setUTCSeconds(0, 0);
270 await Promise.all([
271 // Workflow schedules fire from the scheduler's own alarm (src/scheduler.ts); this trigger only makes sure it is set.
272 schedulerStub(env).ensure().catch((e) => console.error("scheduler alarm check failed", e)),
273 // Token expiry reminders and email log pruning.
274 mailCron(env, new Directory(env), at).catch((e) => console.error("mail cron failed", e)),
275 // Audit log entries past their 2 years, and expired sessions, old notifications and compute
276 // records (lib/retention.ts): a bounded batch each hour, which keeps up with a busy instance.
277 (at.getUTCMinutes() < 5 ? pruneAuditLog(env.DB, at) : Promise.resolve(0)).catch((e) => console.error("audit log pruning failed", e)),
278 (at.getUTCMinutes() < 5 ? pruneExpired(env.DB, at) : Promise.resolve(null)).catch((e) => console.error("retention pruning failed", e)),
279 // Tell search engines about public pages a deploy added, changed or removed, and public repositories pushed since (web/indexnow.ts). Never throws.
280 // Imported here: web/indexnow.ts reaches the docs, whose API reference imports this module.
281 import("./web/indexnow.js").then((m) => m.indexNowCron(env, new Directory(env))).catch((e) => console.error("indexnow failed", e)),
282 // CLI sign-ins a day past their expiry (auth/cli-login.ts).
283 new CliLogins(env).prune().catch((e) => console.error("cli sign-in pruning failed", e)),
284 // Spent OAuth codes, expired connected-app tokens, old connections and unused registrations (auth/oauth.ts).
285 new OAuth(env).prune().catch((e) => console.error("oauth pruning failed", e)),
286 // Account exports past their 7 days (accounts/export.ts).
287 expireExports(env).catch((e) => console.error("export expiry failed", e)),
288 // Expire abandoned draft bulk imports after 7 days, and null their stored GitHub token (Low 5).
289 env.DB.prepare("UPDATE imports SET status = 'cancelled', token_ciphertext = NULL, error = 'draft expired', finished_at = ?, updated_at = ? WHERE status = 'draft' AND created_at < ?")
290 .bind(now(), now(), new Date(Date.now() - 7 * 86400_000).toISOString())
291 .run()
292 .catch((e) => console.error("draft import expiry failed", e)),
293 ]);
294}