import { first, run } from "./db"; import { isScratchRepo } from "./sites/config"; import { sha256Hex } from "./crypto"; import { credentialFromRequest, loadUserById, PAT_PREFIX, toViewer, viewerFromSessionCookie } from "./auth"; import { viewerFromGrant } from "./usercontent-grant"; import { hasPermission, repoPermission } 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: "classic"; tokenId: string } | { 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)); } 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 loadUserById(row.user_id); if (!user) return null; const grant: TokenGrant = row.permissions === null ? { kind: "classic", tokenId: row.id } : { kind: "fine", tokenId: row.id, allRepos: row.all_repos === 1, permissions: parseTokenPermissions(row.permissions), }; return { viewer: toViewer(user), grant }; } async function deployCredential(token: string): Promise { const row = await first( "SELECT id, repo_id, permission, expires_at, last_used_at FROM deploy_tokens WHERE token_hash = ?", 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; } function areaCeiling(area: TokenArea, access: AreaAccess | undefined): Permission { if (!access) return "none"; if (area === "admin") return access === "write" ? "admin" : "read"; 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 anonymous: Permission = repo.visibility === "public" ? "read" : "none"; const { grant, viewer } = credential; 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 || grant.kind === "classic") return base; if (!(await tokenCoversRepo(grant, repo.id))) return anonymous; return maxPermission(anonymous, minPermission(base, areaCeiling(area, grant.permissions[area]))); } export async function credentialAllows( repo: RepoRow, credential: RequestCredential, area: TokenArea, needed: Permission, ): Promise { return hasPermission(await scopedRepoPermission(repo, credential, area), needed); }