import type { z } from "zod"; import { first } from "../db"; import { badRequest, forbidden, HttpError, notFoundError, unauthorized } from "../errors"; import { publicUrl } from "../events/emit"; import { assertNotArchived, hasPermission, loadRepo } from "../repos"; import { scopedRepoPermission, tokenCredential } from "../token-scope"; import type { RequestCredential, TokenArea } from "../token-scope"; import { loadPull } from "../pulls"; import type { PullRow } from "../pulls"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; export type ApiContext = { request: Request; url: URL; credential: RequestCredential; viewer: Viewer }; export function unprocessable(message: string): HttpError { return new HttpError(422, message); } export const noContent = (): Response => new Response(null, { status: 204 }); /** * Token-only: session cookies are never read, and Basic is refused because browsers replay cached Basic * credentials cross-site. This is why /api/v1 can skip the Worker's same-origin check. */ export async function apiContext(request: Request, url: URL): Promise { const scheme = (request.headers.get("authorization") ?? "").split(" ", 1)[0].toLowerCase(); if (scheme !== "bearer" && scheme !== "token") { throw unauthorized("A personal access token is required (Authorization: Bearer )"); } const credential = await tokenCredential(request); if (!credential?.viewer) throw unauthorized("A valid personal access token is required"); return { request, url, credential, viewer: credential.viewer }; } /** Account-level actions (creating repos, managing tokens) are outside any fine-grained repo grant. */ export function requireAccountScope(ctx: ApiContext): void { const grant = ctx.credential.grant; if (grant?.kind === "classic") return; if (grant?.kind === "fine" && grant.allRepos && grant.permissions.admin === "write") return; throw forbidden("This token cannot perform account-level actions"); } export async function readJson(request: Request, schema: z.ZodType): Promise { let raw: unknown; try { const text = await request.text(); raw = text.trim() === "" ? {} : JSON.parse(text); } catch { throw badRequest("Request body must be valid JSON"); } const parsed = schema.safeParse(raw); if (!parsed.success) throw unprocessable(parsed.error.issues.map((issue) => issue.message).join("; ")); return parsed.data; } export type ScopedRepo = { repo: RepoRow; permission: Permission }; /** Loads a repo and checks the credential's area-scoped permission; hides repos the caller can't read. */ export async function requireScopedRepo( ctx: ApiContext, owner: string, name: string, area: TokenArea, needed: Permission, ): Promise { const repo = await loadRepo(owner, name); if (!repo) throw notFoundError("Repository not found"); const permission = await scopedRepoPermission(repo, ctx.credential, area); if (permission === "none" && (await scopedRepoPermission(repo, ctx.credential, "contents")) === "none") { throw notFoundError("Repository not found"); } if (!hasPermission(permission, needed)) throw forbidden(); if (needed !== "read" && needed !== "admin") assertNotArchived(repo); return { repo, permission }; } export { ownerAccount } from "../collaborators"; async function parentName(repo: RepoRow): Promise { if (!repo.fork_of_id) return null; const parent = await first<{ owner_name: string; name: string }>( "SELECT a.name AS owner_name, r.name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?", repo.fork_of_id, ); return parent ? `${parent.owner_name}/${parent.name}` : null; } export async function repoJson(repo: RepoRow) { const parent = await parentName(repo); return { id: repo.id, owner: repo.owner_name, name: repo.name, full_name: `${repo.owner_name}/${repo.name}`, description: repo.description, private: repo.visibility !== "public", fork: repo.fork_of_id !== null, ...(parent ? { parent } : {}), default_branch: repo.default_branch, clone_url: publicUrl(`/${repo.owner_name}/${repo.name}.git`), html_url: publicUrl(`/${repo.owner_name}/${repo.name}`), }; } export async function pullJson(pull: PullRow, baseRepo: RepoRow) { const [head, author] = await Promise.all([ first<{ owner_name: string; name: string }>( "SELECT a.name AS owner_name, r.name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?", pull.head_repo_id, ), first<{ name: string }>("SELECT name FROM accounts WHERE id = ?", pull.author_id), ]); return { number: pull.number, title: pull.title, body: pull.body, state: pull.state, head: pull.head_ref, head_sha: pull.head_sha, base: pull.base_ref, head_repo: head ? `${head.owner_name}/${head.name}` : null, base_repo: `${baseRepo.owner_name}/${baseRepo.name}`, author: author?.name ?? "ghost", merged: pull.merged_at !== null, html_url: publicUrl(`/${baseRepo.owner_name}/${baseRepo.name}/pull/${pull.number}`), }; } /** The token itself must grant write in each area (classic PATs grant every area); this never raises the user's permission. */ export async function requireTokenWrite(ctx: ApiContext, repo: RepoRow, areas: TokenArea[]): Promise { const grant = ctx.credential.grant; if (!grant || grant.kind === "classic") return; if (grant.kind !== "fine") throw forbidden("This token cannot do that"); const covered = grant.allRepos || (await first<{ ok: number }>( "SELECT 1 AS ok FROM access_token_repos WHERE token_id = ? AND repo_id = ?", grant.tokenId, repo.id, )) !== null; for (const area of areas) { if (!covered || grant.permissions[area] !== "write") throw forbidden(`This token lacks ${area}:write`); } } export type ScopedPull = { repo: RepoRow; permission: Permission; pull: PullRow }; /** * Mirrors `requirePull`: the user's permission (capped by the token's per-area ceilings) must reach `needed`, * and the token must hold write in every listed area. */ export async function requireScopedPull( ctx: ApiContext, owner: string, name: string, number: number, needed: Permission, areas: TokenArea[], ): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "pulls", "read"); await requireTokenWrite(ctx, repo, areas); let permission = await scopedRepoPermission(repo, ctx.credential, "pulls"); for (const area of areas) { const other = await scopedRepoPermission(repo, ctx.credential, area); if (!hasPermission(other, permission)) permission = other; } if (!hasPermission(permission, needed)) throw forbidden(); assertNotArchived(repo); const pull = await loadPull(repo.id, number); if (!pull) throw notFoundError("Pull request not found"); return { repo, permission, pull }; }