import { first } from "./db"; import { accountDb, repoDb } from "./scoped-db"; import { locate } from "./scoped-db/locators"; import { embeddedAccountId } from "./account-scope"; import { isScratchRepo } from "./sites/config"; import { sha256Hex } from "./crypto"; import { badRequest, forbidden } from "./errors"; 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 tokenHash = await sha256Hex(token); const userId = embeddedAccountId(token, PAT_PREFIX) ?? (await locate("access_token", tokenHash)); if (!userId) return null; const row = await accountDb(userId).first(`${PAT_SELECT} WHERE token_hash = ?`, tokenHash); 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 { const userId = await locate("access_token_id", tokenId); if (!userId) return null; return patRowCredential(await accountDb(userId).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 userId = await locate("access_token_id", tokenId); if (!userId) return false; const [row, owner] = await Promise.all([ accountDb(userId).first<{ ok: number }>( "SELECT 1 AS ok FROM access_tokens WHERE id = ? AND (expires_at IS NULL OR expires_at >= ?)", tokenId, Date.now(), ), first<{ ok: number }>("SELECT 1 AS ok FROM accounts WHERE id = ? AND disabled_at IS NULL", userId), ]); return row !== null && owner !== 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 accountDb(row.user_id).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 tokenHash = await sha256Hex(token); const repoId = embeddedAccountId(token, DEPLOY_TOKEN_PREFIX) ?? (await locate("deploy_token", tokenHash)); if (!repoId) return null; const owner = await first<{ ok: number }>( "SELECT 1 AS ok FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ? AND a.disabled_at IS NULL", repoId, ); if (!owner) return null; const db = repoDb(repoId); const row = await db.first( "SELECT id, repo_id, permission, expires_at, last_used_at FROM deploy_tokens WHERE token_hash = ?", tokenHash, ); const now = Date.now(); if (!row || (row.expires_at !== null && row.expires_at < now)) return null; if (shouldTouch(row.last_used_at, now)) await db.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; } /** * Areas that expose another area's data and so require it: a pull request is a view of the repository's code * (diffs, conflicted files, templates), so `pulls` only takes effect alongside at least `contents:read`. */ const AREA_REQUIRES: Partial> = { pulls: "contents" }; /** Rejects a permission set that holds an area without the area it depends on. */ export function assertAreaDependencies(permissions: TokenPermissions): void { for (const [area, required] of Object.entries(AREA_REQUIRES) as [TokenArea, TokenArea][]) { if (permissions[area] && !permissions[required]) { throw badRequest(`${area} access also needs ${required}:read`); } } } /** * 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. * An area whose prerequisite area is missing grants nothing (`AREA_REQUIRES`). */ function areaCeiling(area: TokenArea, permissions: TokenPermissions): Permission { const access = permissions[area]; if (!access) return "none"; const required = AREA_REQUIRES[area]; if (required && !permissions[required]) return "none"; if (area === "admin") return access === "write" ? "admin" : "read"; if (access === "write" && permissions.admin === "write") return "admin"; return access; } /** A fine-grained token's viewer is its owner, whose AccountState holds the token's repository list. */ async function tokenCoversRepo( grant: Extract, repoId: string, viewer: Viewer | null, ): Promise { if (grant.allRepos) return true; if (!viewer) return false; return ( (await accountDb(viewer.id).first<{ ok: number }>( "SELECT 1 AS ok FROM access_token_repos WHERE token_id = ? AND repo_id = ?", grant.tokenId, repoId, )) !== null ); } /** * Whether the credential's grant reaches this repository at all: sessions always do, deploy tokens only their * own repository, fine-grained tokens their repository list. Use it where access is granted on identity alone * (guest writes on public repositories, advisory authorship) so a token can't act outside its list. */ export async function credentialCoversRepo(credential: RequestCredential, repoId: string): Promise { const { grant, viewer } = credential; if (!grant) return true; if (grant.kind === "deploy") return grant.repoId === repoId; return tokenCoversRepo(grant, repoId, viewer); } /** * 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; // Before any grant: deploy and fine-grained tokens must not outlive a moderation hide or an account takedown. if (repoHiddenFrom(repo, viewer) || (repoTakenDown(repo) && !viewer?.isAdmin)) return "none"; const anonymous: Permission = repo.visibility === "public" ? "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, viewer))) return anonymous; return maxPermission(anonymous, minPermission(base, areaCeiling(area, grant.permissions))); } const AREA_ACCESS_RANK: Record = { read: 1, write: 2 }; /** * The rule every token-minting path (REST and MCP) applies: the parent must be a fine-grained token, and the * child may hold no area above the parent's. Repository coverage is checked by the caller against the parent's list. */ export function assertAreasWithinGrant( grant: TokenGrant | null, requested: TokenPermissions, ): Extract { if (grant?.kind !== "fine") throw forbidden("This token cannot create tokens"); for (const area of TOKEN_AREAS) { const want = requested[area]; const have = grant.permissions[area]; if (want && (!have || AREA_ACCESS_RANK[want] > AREA_ACCESS_RANK[have])) { throw forbidden(`A token cannot grant more ${area} access than it holds`); } } return grant; } export async function credentialAllows( repo: RepoRow, credential: RequestCredential, area: TokenArea, needed: Permission, ): Promise { return hasPermission(await scopedRepoPermission(repo, credential, area), needed); }