import { z } from "zod"; import { all, first } from "../db"; import { forbidden, notFoundError } from "../errors"; import { limitWrite } from "../limits"; import { deleteLabel, listRepoLabels, loadLabel, saveLabel } from "../label-write"; import { loadProtectionRule, setCodeOwnerRequirement } from "../protection-write"; import { hasPermission } from "../repos"; import { scopedRepoPermission, TOKEN_AREAS } from "../token-scope"; import type { AreaAccess, TokenArea, TokenPermissions } from "../token-scope"; import { createDeployToken, createFineGrainedToken, revokeDeployToken } from "../token-write"; import { labelColor } from "~/lib/validation"; import { noContent, readJson, requireScopedRepo, requireTokenWrite } from "./http"; import type { ApiContext } from "./http"; const labelInput = z.object({ name: z.string().trim().min(1).max(50), color: labelColor, description: z.string().max(100).nullable().default(null), }); export async function listLabels(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "issues", "read"); return Response.json(await listRepoLabels(repo.id)); } async function labelRepo(ctx: ApiContext, owner: string, name: string) { const { repo } = await requireScopedRepo(ctx, owner, name, "issues", "write"); await requireTokenWrite(ctx, repo, ["issues"]); return repo; } export async function createLabel(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, labelInput); const repo = await labelRepo(ctx, owner, name); const id = await saveLabel(repo.id, null, data); return Response.json(await loadLabel(repo.id, id), { status: 201 }); } export async function updateLabel(ctx: ApiContext, owner: string, name: string, id: string): Promise { const data = await readJson(ctx.request, labelInput); const repo = await labelRepo(ctx, owner, name); if (!(await loadLabel(repo.id, id))) throw notFoundError("Label not found"); await saveLabel(repo.id, id, data); return Response.json(await loadLabel(repo.id, id)); } export async function removeLabel(ctx: ApiContext, owner: string, name: string, id: string): Promise { const repo = await labelRepo(ctx, owner, name); if (!(await deleteLabel(repo.id, id))) throw notFoundError("Label not found"); return noContent(); } const codeOwnerInput = z.object({ enabled: z.boolean() }); export async function putCodeOwnerReview(ctx: ApiContext, owner: string, name: string, id: string) { const data = await readJson(ctx.request, codeOwnerInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); await requireTokenWrite(ctx, repo, ["admin"]); if (!(await loadProtectionRule(repo.id, id))) throw notFoundError("Rule not found"); await setCodeOwnerRequirement(repo.id, id, data.enabled); return noContent(); } const areaAccess = z.enum(["read", "write"]).optional(); const tokenInput = z.object({ name: z.string().trim().min(1).max(100), expires_in_days: z.number().int().min(0).max(366), all_repos: z.boolean().default(false), repos: z .array(z.string().regex(/^[^/\s]+\/[^/\s]+$/, "Use owner/name")) .max(50) .default([]), permissions: z.object(Object.fromEntries(TOKEN_AREAS.map((area) => [area, areaAccess]))).strict(), }); const ACCESS_RANK: Record = { read: 1, write: 2 }; const DAY_MS = 24 * 60 * 60 * 1000; /** * A fine-grained parent may only mint a child that is no broader: every area at or below the parent's, * all_repos only if the parent has it, repos within the parent's set, and never outliving the parent. */ async function childExpiryCap(ctx: ApiContext, requested: TokenPermissions, allRepos: boolean, repos: string[]) { const grant = ctx.credential.grant; if (grant?.kind === "classic") return undefined; if (grant?.kind !== "fine") throw forbidden("This token cannot create tokens"); for (const area of TOKEN_AREAS as readonly TokenArea[]) { const want = requested[area]; const have = grant.permissions[area]; if (want && (!have || ACCESS_RANK[want] > ACCESS_RANK[have])) { throw forbidden(`A token cannot grant more ${area} access than it holds`); } } if (!grant.allRepos) { if (allRepos) throw forbidden("A token limited to some repositories cannot create an all-repositories token"); const covered = await all<{ full_name: string }>( `SELECT a.name || '/' || r.name AS full_name FROM access_token_repos tr JOIN repos r ON r.id = tr.repo_id JOIN accounts a ON a.id = r.owner_id WHERE tr.token_id = ?`, grant.tokenId, ); const allowed = new Set(covered.map((row) => row.full_name.toLowerCase())); if (repos.some((repo) => !allowed.has(repo.toLowerCase()))) { throw forbidden("A token cannot grant repositories outside its own scope"); } } const parent = await first<{ expires_at: number | null }>( "SELECT expires_at FROM access_tokens WHERE id = ?", grant.tokenId, ); return parent?.expires_at ?? null; } export async function createToken(ctx: ApiContext): Promise { const data = await readJson(ctx.request, tokenInput); await limitWrite(ctx.viewer); const permissions: TokenPermissions = data.permissions; const parentExpiry = await childExpiryCap(ctx, permissions, data.all_repos, data.repos); const expiresAt = data.expires_in_days > 0 ? Date.now() + data.expires_in_days * DAY_MS : null; if (parentExpiry !== undefined && parentExpiry !== null && (expiresAt === null || expiresAt > parentExpiry)) { throw forbidden("A token cannot outlive the token that creates it"); } const created = await createFineGrainedToken(ctx.viewer, { name: data.name, expiresInDays: data.expires_in_days, allRepos: data.all_repos, repos: data.repos, permissions, }); return Response.json({ id: created.id, token: created.token, expires_at: expiresAt }, { status: 201 }); } const deployInput = z.object({ name: z.string().trim().min(1).max(100), permission: z.enum(["read", "write"]), expires_in_days: z.number().int().min(0).max(366), }); async function deployRepo(ctx: ApiContext, owner: string, name: string) { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); await requireTokenWrite(ctx, repo, ["admin"]); return repo; } export async function listDeployTokens(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); const rows = await all<{ id: string; name: string; token_prefix: string; permission: string; last_used_at: number | null; expires_at: number | null; created_at: number; }>( "SELECT id, name, token_prefix, permission, last_used_at, expires_at, created_at FROM deploy_tokens WHERE repo_id = ? ORDER BY created_at DESC", repo.id, ); return Response.json(rows.map(({ token_prefix: prefix, ...row }) => ({ ...row, prefix }))); } /** A deploy token is repo-wide contents access, so the caller's own token must already hold that level. */ export async function createRepoDeployToken(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, deployInput); await limitWrite(ctx.viewer); const repo = await deployRepo(ctx, owner, name); if (!hasPermission(await scopedRepoPermission(repo, ctx.credential, "contents"), data.permission)) { throw forbidden(`This token cannot grant contents:${data.permission}`); } const created = await createDeployToken(repo, ctx.viewer, { name: data.name, permission: data.permission, expiresInDays: data.expires_in_days, }); return Response.json({ id: created.id, token: created.token }, { status: 201 }); } export async function removeDeployToken(ctx: ApiContext, owner: string, name: string, id: string) { await limitWrite(ctx.viewer); const repo = await deployRepo(ctx, owner, name); if (!(await revokeDeployToken(repo.id, id))) throw notFoundError("Deploy token not found"); return noContent(); }