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