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.

311 lines · 16.8 KBRaw History
1import type { Context } from "hono";
2import type { Env } from "../env.js";
3import { devAuth } from "./dev.js";
4import { Directory, type Account, type Repo, type RepoGrants, type TokenScope } from "../lib/directory.js";
5import { Auth, SESSION_COOKIE, cookieFrom } from "./sessions.js";
6import { HttpError, fromB64 } from "../lib/util.js";
7import type { Caller, Role } from "../repo/types.js";
8import { ROLE_LEVEL } from "../repo/types.js";
9import { taskPersonAllowed } from "../agentic/access.js";
10import { blockSubjects, isBlocked } from "../lib/moderation.js";
11import { agentMayMerge } from "../lib/agent-setup.js";
12import type { WatchLevel } from "../mail/prefs.js";
13import { Apps, INSTALLATION_TOKEN_PREFIX, type App, type InstallationAuth } from "../apps/store.js";
14import { looksLikeJwt } from "../apps/keys.js";
15import { toJobPermissions } from "../apps/model.js";
16import { roleForPermissions } from "../lib/job-permissions.js";
17
18export type Principal =
19 | {
20 type: "account";
21 account: Account;
22 via: "session" | "token" | "dev";
23 scopes: TokenScope[];
24 repoIds: string[] | null;
25 sessionId?: string;
26 sessionAt?: string;
27 importId?: string;
28 tokenId?: string;
29 /** An agent's owner as its token's check read it (lib/directory.ts verifyToken), so the request needn't read it again. */
30 owner?: Account | null;
31 }
32 | { type: "repo-token"; repoId: string; token: string }
33 /** A GitHub-compatible app signed in with a JWT made with its private key (apps/keys.ts): app endpoints only. */
34 | { type: "app"; app: App }
35 /** An app's installation access token ("dia_"): the repositories the installation covers, within its permissions. */
36 | { type: "installation"; auth: InstallationAuth }
37 | { type: "anonymous" };
38
39export interface AppVars {
40 principal: Principal;
41 dir: Directory;
42 /** Set for MCP tools that only read, so POST-carried reads count as reads. */
43 readOnly?: boolean;
44 /** Suspended people whose content this request hides, once looked up (http/common.ts hiddenAuthors). */
45 hidden?: Promise<string[]>;
46 /** The signed-in person's watch level for the repository the request is about, once looked up (http/common.ts watchLevelOnce). */
47 watch?: { repoId: string; level: Promise<WatchLevel> };
48}
49
50export type AppContext = Context<{ Bindings: Env; Variables: AppVars }>;
51
52/** Extract a bearer/basic credential, as git, curl, the CLI and MCP clients send it. */
53export function credentialFrom(req: Request): string | null {
54 // A request can carry the header twice (git-lfs sends http.extraheader and the batch API's
55 // header), which arrives joined with ", ": use the first.
56 const h = req.headers.get("authorization")?.split(",")[0]!.trim();
57 if (!h) return null;
58 const [scheme, value] = h.split(/\s+/, 2);
59 if (!value) return null;
60 if (/^bearer$/i.test(scheme!) || /^token$/i.test(scheme!)) return value.trim();
61 if (/^basic$/i.test(scheme!)) {
62 let bytes: Uint8Array;
63 try {
64 // Git LFS may send the same header twice (http.extraHeader and the batch action's header), joined by a comma.
65 bytes = fromB64(value.split(",")[0]!.trim());
66 } catch {
67 throw new HttpError(401, "malformed basic credentials");
68 }
69 const decoded = new TextDecoder().decode(bytes);
70 const idx = decoded.indexOf(":");
71 const pass = idx >= 0 ? decoded.slice(idx + 1) : decoded;
72 const user = idx >= 0 ? decoded.slice(0, idx) : "";
73 return pass || user || null;
74 }
75 return null;
76}
77
78/** The Cloudflare Access assertion, present only on the Access-protected recovery path. */
79export function accessToken(req: Request): string | null {
80 const h = req.headers.get("cf-access-jwt-assertion");
81 if (h) return h;
82 const cookie = req.headers.get("cookie") ?? "";
83 const m = /(?:^|;\s*)CF_Authorization=([^;]+)/.exec(cookie);
84 return m ? m[1]! : null;
85}
86
87const IMPORT_GIT_PATH = /^\/[^/]+\/[^/]+\/(info\/refs|git-upload-pack|git-receive-pack|info\/lfs\/objects\/[^/]+)$/;
88
89export async function resolvePrincipal(req: Request, env: Env, dir: Directory): Promise<Principal> {
90 const cred = credentialFrom(req);
91 if (cred) {
92 if (cred.startsWith("dfy_")) {
93 const v = await dir.verifyToken(cred);
94 if (!v) throw new HttpError(401, "invalid, expired or revoked token");
95 if (v.importId) {
96 // An import's push token reaches only Git, Git LFS and its own agent endpoints.
97 const path = new URL(req.url).pathname;
98 const agent = path.startsWith(`/api/v3/diffinery/imports/${v.importId}/agent/`);
99 if (!agent && !IMPORT_GIT_PATH.test(path)) throw new HttpError(403, "this token can only be used by its GitHub import");
100 return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds, importId: v.importId, tokenId: v.id, owner: v.owner };
101 }
102 return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds, tokenId: v.id, owner: v.owner };
103 }
104 // Job and sandbox tokens, and self-hosted runners' own tokens (which only claim jobs: runnerAuth refuses them).
105 if (cred.startsWith("djt_") || cred.startsWith("dst_") || cred.startsWith("drn_")) {
106 const repoId = cred.split("_")[1];
107 if (!repoId) throw new HttpError(401, "malformed token");
108 return { type: "repo-token", repoId, token: cred };
109 }
110 if (cred.startsWith(INSTALLATION_TOKEN_PREFIX)) {
111 const auth = await new Apps(env, dir).verifyToken(cred);
112 if (!auth) throw new HttpError(401, "Bad credentials");
113 return { type: "installation", auth };
114 }
115 if (looksLikeJwt(cred)) return { type: "app", app: await new Apps(env, dir).appForJwt(cred) };
116 throw new HttpError(401, "unrecognised token; create one under Settings → Access tokens");
117 }
118 // Browsers sign in with a passkey (or the Access recovery path) and carry a session cookie.
119 const session = cookieFrom(req, SESSION_COOKIE);
120 if (session) {
121 const s = await new Auth(env).session(session);
122 if (s) return { type: "account", account: s.account, via: "session", scopes: ["admin"], repoIds: null, sessionId: s.session.id, sessionAt: s.session.created_at };
123 }
124 if (devAuth(env, req.url)) {
125 const account = await dir.userForEmail(env.DEV_AUTH!);
126 return { type: "account", account, via: "dev", scopes: ["admin"], repoIds: null };
127 }
128 return { type: "anonymous" };
129}
130
131export function requireAccount(c: AppContext): Account {
132 const p = c.get("principal");
133 // GitHub's answer to an app's credentials on an endpoint for people.
134 if (p.type === "app" || p.type === "installation") throw new HttpError(403, "Resource not accessible by integration");
135 if (p.type !== "account") throw new HttpError(401, "sign in required");
136 if (p.account.status === "pending") throw new HttpError(403, "your account is waiting for an invite");
137 if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
138 return p.account;
139}
140
141export const TOKEN_SCOPES: readonly TokenScope[] = ["read", "write", "admin"];
142
143/**
144 * Personal access tokens carry a scope; browser sessions act with full scope.
145 * Throws 403 when the request's token lacks `need` (read < write < admin).
146 */
147export function requireScope(c: AppContext, need: TokenScope): void {
148 const p = c.get("principal");
149 if (p.type !== "account" || p.via !== "token") return;
150 const have = Math.max(...p.scopes.map((s) => TOKEN_SCOPES.indexOf(s)));
151 if (have < TOKEN_SCOPES.indexOf(need)) throw new HttpError(403, `this token needs the ${need} scope`);
152}
153
154/** Account-level changes (orgs, invitations, agents, tokens, settings) also refuse tokens limited to repositories. */
155export function requireAccountScope(c: AppContext, need: TokenScope): void {
156 requireScope(c, need);
157 const p = c.get("principal");
158 if (p.type === "account" && p.via === "token" && p.repoIds) throw new HttpError(403, "this token is limited to repositories and can't change account settings");
159}
160
161/** `repos` narrowed to the ones a repository-limited token may use, so listing never reveals the others. */
162export function tokenRepos<T extends { id: string }>(c: AppContext, repos: T[]): T[] {
163 const p = c.get("principal");
164 const only = p.type === "account" ? p.repoIds : null;
165 return only ? repos.filter((r) => only.includes(r.id)) : repos;
166}
167
168/** Notifications span every repository, so a repository-limited token can't read them (as with GitHub's fine-grained tokens). */
169export function requireUnlimitedToken(c: AppContext, what: string): void {
170 const p = c.get("principal");
171 if (p.type === "account" && p.via === "token" && p.repoIds) throw new HttpError(403, `this token is limited to repositories and can't ${what}`);
172}
173
174/**
175 * Minting credentials (tokens, agents and their tokens) needs a person signed in with a
176 * passkey. No token can create another, so a leaked, repository-limited, agent, job or
177 * sandbox token can never turn itself into a wider or longer-lived one.
178 */
179export function requireBrowserSession(c: AppContext): Account {
180 const p = c.get("principal");
181 if (p.type === "account" && (p.via === "session" || p.via === "dev")) {
182 requireRecentSignIn(c, "/settings/tokens");
183 return requireAccount(c);
184 }
185 if (p.type === "anonymous") throw new HttpError(401, "sign in required");
186 throw new HttpError(403, `creating tokens needs a browser session: sign in at ${c.env.PUBLIC_URL}/settings/tokens`);
187}
188
189/** How long after a sign-in a browser may do sensitive things, as in GitHub's sudo mode. */
190export const RECENT_SIGNIN_MS = 2 * 3600_000;
191
192/** A sensitive action from a browser whose sign-in isn't recent: the person signs in again, then comes back to `next`. */
193export class SignInAgain extends HttpError {
194 readonly confirm: string;
195 constructor(next: string) {
196 const confirm = `/login?confirm=1&next=${encodeURIComponent(next)}`;
197 super(403, "Confirm it's you: sign in with your passkey again to do this.", { confirm });
198 this.confirm = confirm;
199 }
200}
201
202/**
203 * Adding a passkey, minting a token or an agent, issuing a sign-in link, changing your
204 * username, and deleting or transferring a repository or organization need a sign-in from
205 * the last 2 hours. So a
206 * session left open on a shared computer, or a stolen session cookie, can't turn itself
207 * into a lasting credential or destroy work. Tokens are bounded by their scopes instead.
208 */
209export function requireRecentSignIn(c: AppContext, next: string): void {
210 const p = c.get("principal");
211 if (p.type !== "account" || p.via !== "session") return;
212 if (p.sessionAt && Date.now() - Date.parse(p.sessionAt) < RECENT_SIGNIN_MS) return;
213 throw new SignInAgain(next);
214}
215
216/**
217 * grantsFor reads for whoever the caller's role in `owner/name` depends on (the person, or an agent and its owner),
218 * so loadRepo can read them alongside the repository. Nothing for callers whose role needs no grants.
219 */
220export function grantsFor(c: AppContext, owner: string, name: string): Promise<RepoGrants[]> {
221 const p = c.get("principal");
222 if (p.type !== "account" || p.account.status !== "active" || p.account.site_admin) return Promise.resolve([]);
223 const a = p.account;
224 const ids = a.kind === "agent" ? [a.id, ...(a.owner_id && p.owner?.id === a.owner_id && p.owner.kind === "user" ? [a.owner_id] : [])] : a.kind === "user" ? [a.id] : [];
225 const dir = c.get("dir");
226 return Promise.all(ids.map((id) => dir.grantsFor(owner, name, id))).then((all) => all.flat());
227}
228
229/** Validates scopes sent by a client: known names only, default write. */
230export function parseScopes(input: unknown): TokenScope[] {
231 if (input === undefined || input === null) return ["write"];
232 if (!Array.isArray(input) || !input.length || input.some((s) => !TOKEN_SCOPES.includes(s as TokenScope))) throw new HttpError(422, `scopes must be a non-empty list of: ${TOKEN_SCOPES.join(", ")}`);
233 return [...new Set(input as TokenScope[])];
234}
235
236const SCOPE_CAP: Record<TokenScope, Role> = { read: "read", write: "maintain", admin: "admin" };
237
238/**
239 * Resolve the caller's effective role on a repository, combining account
240 * permissions with token scopes and repo restrictions. Returns null for no access.
241 */
242export async function callerFor(c: AppContext, repo: Repo, grants: RepoGrants[] = []): Promise<Caller | null> {
243 const p = c.get("principal");
244 const dir = c.get("dir");
245 // A suspended person's repositories are hidden from everyone but site administrators, and their
246 // job, sandbox and runner tokens stop working (lib/moderation.ts).
247 if (repo.owner_suspended_at && !(p.type === "account" && p.account.site_admin)) return null;
248 if (p.type === "repo-token") {
249 // Like GITHUB_TOKEN, a job or sandbox token reads other repositories only when they are public,
250 // and only once its own repository has vouched for it: a made-up token must not pass as a
251 // credential (it would skip the anonymous rate limit). Reads only: loadRepo refuses writes.
252 if (p.repoId !== repo.id) {
253 if (repo.visibility !== "public") return null;
254 const own = await dir.repoById(p.repoId);
255 if (!own || !(await c.env.REPO.get(c.env.REPO.idFromName(own.id)).runnerAuth(p.token))) throw new HttpError(401, "invalid, expired or revoked token");
256 return { id: "anonymous", login: "anonymous", kind: "user", role: "read" };
257 }
258 const stub = c.env.REPO.get(c.env.REPO.idFromName(repo.id));
259 const auth = await stub.runnerAuth(p.token);
260 if (!auth) return null;
261 // A job token can do what the workflow's `permissions:` grant, checked per request in
262 // loadRepo. Runs an agent started act as an agent, so its guardrails still apply.
263 if (auth.kind === "job") {
264 return { id: `job:${auth.jobId}`, login: "diffinery-actions[bot]", kind: auth.actorKind === "agent" ? "agent" : "system", role: auth.role, permissions: auth.permissions };
265 }
266 // A sandbox acts as the person or agent who started it, never beyond what they can do now:
267 // disabling the account or losing access ends its sandboxes' tokens too.
268 const account = await dir.account(auth.caller.id);
269 const now = account?.status === "active" ? await dir.roleFor(account, repo) : null;
270 if (!now) return null;
271 // An agent task's sandbox (agentic/) also stops working once the person who started the task
272 // is disabled, loses write access or can no longer drive the agent.
273 if (auth.taskPerson && !(await taskPersonAllowed(dir, auth.taskPerson, account!, repo))) return null;
274 let cap: Role = ROLE_LEVEL[auth.caller.role] > ROLE_LEVEL.write ? "write" : auth.caller.role;
275 if (ROLE_LEVEL[now] < ROLE_LEVEL[cap]) cap = now;
276 return { ...auth.caller, role: cap };
277 }
278 if (p.type === "installation") return installationCaller(c, p.auth, repo);
279 // An app's JWT works on app endpoints only (http/apps.ts), as on GitHub.
280 if (p.type === "app") return null;
281 if (p.type !== "account") {
282 return repo.visibility === "public" ? { id: "anonymous", login: "anonymous", kind: "user", role: "read" } : null;
283 }
284 if (p.repoIds && !p.repoIds.includes(repo.id)) return null;
285 const a = p.account;
286 // An agent's owner: from its token's check when that read it, else read alongside the role.
287 const known = a.kind === "agent" && a.owner_id && p.owner?.id === a.owner_id ? p.owner : undefined;
288 const [found, owner] = await Promise.all([dir.roleFor(a, repo, known, grants), a.kind === "agent" && a.owner_id ? (known ?? dir.account(a.owner_id)) : null]);
289 let role = found;
290 if (!role) return null;
291 const cap = p.scopes.reduce<Role>((best, s) => (ROLE_LEVEL[SCOPE_CAP[s]] > ROLE_LEVEL[best] ? SCOPE_CAP[s] : best), "read");
292 if (ROLE_LEVEL[role] > ROLE_LEVEL[cap]) role = cap;
293 const onBehalfOf = owner?.login;
294 // Someone the repository's owner blocked (or their agent) can still read, but not take part (lib/moderation.ts).
295 // Checked for changes only: reads stay as fast as they were.
296 const writing = c.req.method !== "GET" && c.req.method !== "HEAD";
297 const blocked = writing && repo.owner_id !== a.id && (await isBlocked(c.env, repo.owner_id, blockSubjects(a)));
298 return { id: a.id, login: a.login, kind: a.kind === "agent" ? "agent" : "user", onBehalfOf, role, ...(blocked ? { blockedBy: repo.owner_login } : {}), ...(agentMayMerge(a) ? { mayMerge: true } : {}) };
299}
300
301/**
302 * An installation token in a repository: the app's bot with the token's permissions, checked per
303 * endpoint by the same table as a workflow's GITHUB_TOKEN (loadRepo). Outside what the installation
304 * covers (or the repositories the token was narrowed to) it reads public repositories like anyone.
305 */
306async function installationCaller(c: AppContext, auth: InstallationAuth, repo: Repo): Promise<Caller | null> {
307 const mine = (!auth.repoIds || auth.repoIds.includes(repo.id)) && (await new Apps(c.env, c.get("dir")).covers(auth.installation, repo));
308 if (!mine) return repo.visibility === "public" ? { id: "anonymous", login: "anonymous", kind: "user", role: "read" } : null;
309 const permissions = toJobPermissions(auth.permissions);
310 return { id: `app:${auth.installation.id}`, login: `${auth.app.slug}[bot]`, kind: "system", role: roleForPermissions(permissions), permissions };
311}