import { first, run } from "./db"; import { isScratchRepo } from "./sites/config"; import { sha256Hex } from "./crypto"; import { credentialFromRequest, loadActiveUserById, PAT_PREFIX, toViewer, viewerFromSessionCookie } from "./auth"; import { viewerFromGrant } from "./usercontent-grant"; import { hasPermission, repoHiddenFrom, repoPermission, repoTakenDown } from "./repos"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; export const DEPLOY_TOKEN_PREFIX = "gitfor_dpl"; export const TOKEN_AREAS = ["contents", "issues", "pulls", "actions", "admin"] as const; export type TokenArea = (typeof TOKEN_AREAS)[number]; export type AreaAccess = "read" | "write"; export type TokenPermissions = Partial>; /** What a bearer credential is allowed to do beyond the identity it represents. */ export type TokenGrant = | { kind: "fine"; tokenId: string; allRepos: boolean; permissions: TokenPermissions } | { kind: "deploy"; tokenId: string; repoId: string; permission: AreaAccess }; export type RequestCredential = { viewer: Viewer | null; grant: TokenGrant | null }; const TOUCH_INTERVAL_MS = 60 * 1000; const PERMISSION_ORDER: Permission[] = ["none", "read", "write", "admin"]; type PatRow = { id: string; user_id: string; expires_at: number | null; last_used_at: number | null; permissions: string | null; all_repos: number; audience: string | null; }; type DeployRow = { id: string; repo_id: string; permission: AreaAccess; expires_at: number | null; last_used_at: number | null; }; function shouldTouch(lastUsedAt: number | null, now: number): boolean { return lastUsedAt === null || now - lastUsedAt > TOUCH_INTERVAL_MS; } export function parseTokenPermissions(raw: string): TokenPermissions { const parsed: unknown = JSON.parse(raw); const out: TokenPermissions = {}; if (typeof parsed !== "object" || parsed === null) return out; for (const area of TOKEN_AREAS) { const value = area in parsed ? Reflect.get(parsed, area) : undefined; if (value === "read" || value === "write") out[area] = value; } return out; } const PAT_SELECT = "SELECT id, user_id, expires_at, last_used_at, permissions, all_repos, audience FROM access_tokens"; /** Where a token is presented: the MCP endpoint or anything else (git, LFS, REST, raw, artifacts). */ type Surface = "mcp" | "forge"; export const MCP_AUDIENCE = "mcp"; const MCP_ENDPOINT = "/mcp"; function surfaceOf(request: Request): Surface { const path = new URL(request.url).pathname; return path === MCP_ENDPOINT || path.startsWith(`${MCP_ENDPOINT}/`) ? "mcp" : "forge"; } /** RFC 8707 audience binding: an `mcp`-audience token is only valid when presented to /mcp. */ async function patCredential(token: string, surface: Surface): Promise { const row = await first(`${PAT_SELECT} WHERE token_hash = ?`, await sha256Hex(token)); if (row?.audience === MCP_AUDIENCE && surface !== "mcp") return null; return patRowCredential(row); } /** Re-derives a personal token's credential by id, for long-lived MCP sessions that never hold the secret. */ export async function patCredentialById(tokenId: string): Promise { return patRowCredential(await first(`${PAT_SELECT} WHERE id = ?`, tokenId)); } /** * One primary-key read: the token still exists (deletes and revoke triggers remove the row), has not expired, * and its owner is not disabled. Cheap enough to run on every event a cached MCP session receives. */ export async function tokenIsLive(tokenId: string): Promise { const row = await first<{ ok: number }>( `SELECT 1 AS ok FROM access_tokens t JOIN accounts a ON a.id = t.user_id WHERE t.id = ? AND (t.expires_at IS NULL OR t.expires_at >= ?) AND a.disabled_at IS NULL`, tokenId, Date.now(), ); return row !== null; } /** Every personal token is fine-grained; a row without permissions (none should remain) grants nothing. */ export function fineGrant( tokenId: string, row: { permissions: string | null; all_repos: number }, ): Extract { return { kind: "fine", tokenId, allRepos: row.permissions !== null && row.all_repos === 1, permissions: row.permissions === null ? {} : parseTokenPermissions(row.permissions), }; } async function patRowCredential(row: PatRow | null): Promise { const now = Date.now(); if (!row || (row.expires_at !== null && row.expires_at < now)) return null; if (shouldTouch(row.last_used_at, now)) await run("UPDATE access_tokens SET last_used_at = ? WHERE id = ?", now, row.id); const user = await loadActiveUserById(row.user_id); if (!user) return null; return { viewer: toViewer(user), grant: fineGrant(row.id, row) }; } async function deployCredential(token: string): Promise { const row = await first( `SELECT t.id, t.repo_id, t.permission, t.expires_at, t.last_used_at FROM deploy_tokens t JOIN repos r ON r.id = t.repo_id JOIN accounts a ON a.id = r.owner_id WHERE t.token_hash = ? AND a.disabled_at IS NULL`, await sha256Hex(token), ); const now = Date.now(); if (!row || (row.expires_at !== null && row.expires_at < now)) return null; if (shouldTouch(row.last_used_at, now)) await run("UPDATE deploy_tokens SET last_used_at = ? WHERE id = ?", now, row.id); return { viewer: null, grant: { kind: "deploy", tokenId: row.id, repoId: row.repo_id, permission: row.permission } }; } /** Resolves a bearer/basic credential (personal or deploy token); null when absent or invalid. */ export async function tokenCredential(request: Request): Promise { const token = credentialFromRequest(request); if (!token) return null; if (token.startsWith(`${PAT_PREFIX}_`)) return patCredential(token, surfaceOf(request)); if (token.startsWith(`${DEPLOY_TOKEN_PREFIX}_`)) return deployCredential(token); return null; } /** Session cookie or user-content grant first (full access), then tokens. Used by raw and browser-facing Worker routes. */ export async function requestCredential(request: Request, allowSession: boolean): Promise { if (allowSession) { const viewer = (await viewerFromSessionCookie(request)) ?? (await viewerFromGrant(request)); if (viewer) return { viewer, grant: null }; } return (await tokenCredential(request)) ?? { viewer: null, grant: null }; } function minPermission(left: Permission, right: Permission): Permission { return PERMISSION_ORDER.indexOf(left) <= PERMISSION_ORDER.indexOf(right) ? left : right; } function maxPermission(left: Permission, right: Permission): Permission { return PERMISSION_ORDER.indexOf(left) >= PERMISSION_ORDER.indexOf(right) ? left : right; } /** * The most a token lets its user do in `area`. A token that also grants admin:write passes the user's admin * standing through to writes in other areas, so admin-only bypasses (branch protection, force-unlock, * approval overrides) keep working with fine-grained tokens, never beyond the user's own permission. */ function areaCeiling(area: TokenArea, permissions: TokenPermissions): Permission { const access = permissions[area]; if (!access) return "none"; if (area === "admin") return access === "write" ? "admin" : "read"; if (access === "write" && permissions.admin === "write") return "admin"; return access; } async function tokenCoversRepo(grant: Extract, repoId: string): Promise { if (grant.allRepos) return true; return ( (await first<{ ok: number }>( "SELECT 1 AS ok FROM access_token_repos WHERE token_id = ? AND repo_id = ?", grant.tokenId, repoId, )) !== null ); } /** * The effective permission of a credential on a repo for one capability area. Fine-grained and deploy * tokens are capped by their grant but never drop below anonymous access to public repos. */ export async function scopedRepoPermission( repo: RepoRow, credential: RequestCredential, area: TokenArea, ): Promise { const { grant, viewer } = credential; if (repoHiddenFrom(repo, viewer)) return "none"; const anonymous: Permission = repo.visibility === "public" && !repoTakenDown(repo) ? "read" : "none"; if (grant?.kind === "deploy") { if (grant.repoId !== repo.id || area !== "contents" || isScratchRepo(repo)) return anonymous; return maxPermission(anonymous, grant.permission); } const base = await repoPermission(repo, viewer); if (!grant) return base; if (!(await tokenCoversRepo(grant, repo.id))) return anonymous; return maxPermission(anonymous, minPermission(base, areaCeiling(area, grant.permissions))); } export async function credentialAllows( repo: RepoRow, credential: RequestCredential, area: TokenArea, needed: Permission, ): Promise { return hasPermission(await scopedRepoPermission(repo, credential, area), needed); }