import type { z } from "zod"; import { first } from "../db"; import { badRequest, forbidden, HttpError, notFoundError, unauthorized } from "../errors"; import { publicUrl } from "../events/emit"; import { hasPermission, loadRepo } from "../repos"; import { scopedRepoPermission, tokenCredential } from "../token-scope"; import type { RequestCredential, TokenArea } from "../token-scope"; 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 notImplemented = (): Response => Response.json({ error: "not implemented" }, { status: 501 }); export const noContent = (): Response => new Response(null, { status: 204 }); /** Token-only: the API never honours session cookies, so it carries no ambient browser authority. */ export async function apiContext(request: Request, url: URL): Promise { 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(); 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, 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}`), }; }