import { z } from "zod"; import { all, chunks, placeholders } from "../db"; import { accountDb, repoDb } from "../scoped-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 { assertAreasWithinGrant, scopedRepoPermission, TOKEN_AREAS } from "../token-scope"; import type { TokenPermissions } from "../token-scope"; import { createDeployToken, createFineGrainedToken, revokeDeployToken } from "../token-write"; import { labelColor } from "~/lib/validation"; import { noContent, readJson, requireScopedAdminWrite, 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) { await limitWrite(ctx.viewer); 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 requireScopedAdminWrite(ctx, owner, name); 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(), }); type ParentToken = { tokenId: string; expiresAt: number | null }; /** * Only a first-generation token may mint tokens over REST (AUTH-A1): tokens that were themselves minted by a * token (REST or MCP delegation) cannot mint further. */ /** `access_tokens.scopes` of rows migration 0026 converted from classic tokens. */ const CONVERTED_CLASSIC_SCOPE = "converted-classic"; export async function mintingParent(ctx: ApiContext): Promise { const grant = ctx.credential.grant; if (grant?.kind !== "fine") throw forbidden("This token cannot create tokens"); if (!ctx.credential.viewer) throw forbidden("This token cannot create tokens"); const parent = await accountDb(ctx.credential.viewer.id).first<{ expires_at: number | null; parent_token_id: string | null; delegated: number; scopes: string; }>( `SELECT t.expires_at, t.parent_token_id, t.scopes, EXISTS (SELECT 1 FROM mcp_token_agents m WHERE m.token_id = t.id AND m.parent_token_id IS NOT NULL) AS delegated FROM access_tokens t WHERE t.id = ?`, grant.tokenId, ); if (!parent) throw forbidden("This token cannot create tokens"); if (parent.scopes === CONVERTED_CLASSIC_SCOPE) { throw forbidden("Tokens converted from classic tokens cannot create tokens; create one in Settings"); } if (parent.parent_token_id !== null || parent.delegated === 1) { throw forbidden("A token created by another token cannot create tokens"); } return { tokenId: grant.tokenId, expiresAt: parent.expires_at }; } /** * 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. */ export async function assertChildWithinParent( ctx: ApiContext, requested: TokenPermissions, allRepos: boolean, repos: string[], ) { const grant = assertAreasWithinGrant(ctx.credential.grant, requested); if (!grant.allRepos) { if (allRepos) throw forbidden("A token limited to some repositories cannot create an all-repositories token"); const repoIds = ctx.credential.viewer ? ( await accountDb(ctx.credential.viewer.id).all<{ repo_id: string }>( "SELECT repo_id FROM access_token_repos WHERE token_id = ?", grant.tokenId, ) ).map((row) => row.repo_id) : []; const covered: { full_name: string }[] = []; for (const ids of chunks(repoIds)) { covered.push( ...(await all<{ full_name: string }>( `SELECT a.name || '/' || r.name AS full_name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id IN (${placeholders(ids.length)})`, ...ids, )), ); } 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 DAY_MS = 24 * 60 * 60 * 1000; /** Tokens minted by a token always expire: never "never", and never after the parent. */ function requireChildExpiry(expiresInDays: number, parent: ParentToken): void { if (expiresInDays <= 0) throw forbidden("A token created by another token must have an expiry"); if (parent.expiresAt !== null && Date.now() + expiresInDays * DAY_MS > parent.expiresAt) { throw forbidden("A token cannot outlive the token that creates it"); } } export async function createToken(ctx: ApiContext): Promise { const data = await readJson(ctx.request, tokenInput); await limitWrite(ctx.viewer); const permissions: TokenPermissions = data.permissions; const parent = await mintingParent(ctx); requireChildExpiry(data.expires_in_days, parent); await assertChildWithinParent(ctx, permissions, data.all_repos, data.repos); const created = await createFineGrainedToken(ctx.viewer, { name: data.name, expiresInDays: data.expires_in_days, allRepos: data.all_repos, repos: data.repos, permissions, lineage: { parentTokenId: parent.tokenId, notAfter: parent.expiresAt }, }); return Response.json({ id: created.id, token: created.token, expires_at: created.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), }); /** Minting is blocked on archived repositories; revoking stays allowed so credentials can always be withdrawn. */ async function deployRepo(ctx: ApiContext, owner: string, name: string, minting: boolean) { const { repo } = minting ? await requireScopedAdminWrite(ctx, owner, name) : 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 repoDb(repo.id).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. * It is tied to the minting token (revoked with it, expiry capped at its) like any other child (AUTH-A1). */ 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, true); if (!hasPermission(await scopedRepoPermission(repo, ctx.credential, "contents"), data.permission)) { throw forbidden(`This token cannot grant contents:${data.permission}`); } const parent = await mintingParent(ctx); requireChildExpiry(data.expires_in_days, parent); const created = await createDeployToken(repo, ctx.viewer, { name: data.name, permission: data.permission, expiresInDays: data.expires_in_days, lineage: { parentTokenId: parent.tokenId, notAfter: parent.expiresAt }, }); return Response.json({ id: created.id, token: created.token, expires_at: created.expiresAt }, { 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, false); if (!(await revokeDeployToken(repo.id, id))) throw notFoundError("Deploy token not found"); return noContent(); }