import { parseTopics } from "~/lib/topics"; import type { z } from "zod"; import { first } from "../db"; import { accountDb } from "../scoped-db"; import { badRequest, forbidden, HttpError, notFoundError, unauthorized } from "../errors"; import { publicUrl } from "../events/emit"; import { assertNotArchived, hasPermission, loadRepoFor } 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) need an all-repositories token with admin:write. */ export function hasAccountScope(ctx: ApiContext): boolean { const grant = ctx.credential.grant; return grant?.kind === "fine" && grant.allRepos && grant.permissions.admin === "write"; } export function requireAccountScope(ctx: ApiContext): void { if (!hasAccountScope(ctx)) 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 loadRepoFor(owner, name, ctx.credential.viewer); 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 }; } /** Admin-level writes: archived repositories are read-only except for unarchive, delete and transfer (authz-P4). */ export async function requireScopedAdminWrite(ctx: ApiContext, owner: string, name: string): Promise { const scoped = await requireScopedRepo(ctx, owner, name, "admin", "admin"); assertNotArchived(scoped.repo); return scoped; } 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, topics: parseTopics(repo.topics), language: repo.language ?? null, 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; 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) return; if (grant.kind !== "fine") throw forbidden("This token cannot do that"); const covered = grant.allRepos || (ctx.credential.viewer !== null && (await accountDb(ctx.credential.viewer.id).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 }; }