import { peekAuthToken, storeAuthToken } from "../auth-tokens"; import { first } from "../db"; import { credentialFromRequest, loadUserById, toViewer } from "../auth"; import { randomToken } from "../crypto"; import { notFoundError, unauthorized } from "../errors"; import { assertVisibleRevision } from "../git/reachable"; import { listRefs, resolveRevision } from "../git/refs"; import { directUpdateViolation, loadProtections, protectionFor } from "../protection"; import { hasPermission, loadRepoById, repoPermission } from "../repos"; import type { ResolvedRevision } from "../git/refs"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; export const EDITOR_TOKEN_PREFIX = "gitfor_edt"; const EDITOR_TOKEN_KIND = "editor-session"; const EDITOR_TOKEN_TTL_SECONDS = 60 * 60; const UNLIMITED_ATTEMPTS = Number.MAX_SAFE_INTEGER; /** `patId` ties a session minted from a personal access token to that token, so revoking it ends the session. */ type SessionPayload = { repoId: string; ref: string; ceiling: Permission; canOpenPull: boolean; patId: string | null }; export type MintGrant = { ceiling: Permission; canOpenPull: boolean; patId: string | null }; const PERMISSION_ORDER: Permission[] = ["none", "read", "write", "admin"]; function isPermission(value: unknown): value is Permission { return typeof value === "string" && PERMISSION_ORDER.some((permission) => permission === value); } function capPermission(actual: Permission, ceiling: Permission): Permission { return PERMISSION_ORDER.indexOf(actual) <= PERMISSION_ORDER.indexOf(ceiling) ? actual : ceiling; } export type EditorSession = { viewer: Viewer; repo: RepoRow; permission: Permission; revision: ResolvedRevision; canOpenPull: boolean; }; /** * Mints a one-hour bearer token bound to one user, repo and ref; the forge page hands it to the sandboxed * frame. `ceiling` caps it below the minting credential (a read-only PAT can't mint a writable session). */ export async function mintEditorToken( viewer: Viewer, repo: RepoRow, ref: string, grant: MintGrant, ): Promise<{ token: string; expiresAt: number }> { const token = randomToken(EDITOR_TOKEN_PREFIX); const payload: SessionPayload = { repoId: repo.id, ref, ...grant }; await storeAuthToken(EDITOR_TOKEN_KIND, token, viewer.id, JSON.stringify(payload), EDITOR_TOKEN_TTL_SECONDS); return { token, expiresAt: Date.now() + EDITOR_TOKEN_TTL_SECONDS * 1000 }; } function parsePayload(raw: string): SessionPayload | null { const parsed: unknown = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null) return null; const repoId: unknown = Reflect.get(parsed, "repoId"); const ref: unknown = Reflect.get(parsed, "ref"); const ceiling: unknown = Reflect.get(parsed, "ceiling"); const canOpenPull: unknown = Reflect.get(parsed, "canOpenPull"); const patId: unknown = Reflect.get(parsed, "patId"); if (typeof repoId !== "string" || typeof ref !== "string" || !isPermission(ceiling)) return null; return { repoId, ref, ceiling, canOpenPull: canOpenPull === true, patId: typeof patId === "string" ? patId : null, }; } /** Sign-out-everywhere, password resets and PAT revocation all end editor sessions minted before them. */ async function mintingCredentialRevoked(userId: string, payload: SessionPayload, createdAt: number): Promise { const user = await first<{ sessions_revoked_at: number | null }>( "SELECT sessions_revoked_at FROM users WHERE account_id = ?", userId, ); if (!user || (user.sessions_revoked_at !== null && createdAt < user.sessions_revoked_at)) return true; if (!payload.patId) return false; const pat = await first<{ expires_at: number | null }>( "SELECT expires_at FROM access_tokens WHERE id = ? AND user_id = ?", payload.patId, userId, ); return !pat || (pat.expires_at !== null && pat.expires_at < Date.now()); } /** * Resolves the bearer editor token on every request and re-derives the user's current permission, so * revoking access takes effect immediately. Session cookies are never consulted. */ export async function editorSessionFromRequest(request: Request): Promise { const token = credentialFromRequest(request); if (!token?.startsWith(`${EDITOR_TOKEN_PREFIX}_`)) throw unauthorized("An editor session token is required"); const stored = await peekAuthToken(EDITOR_TOKEN_KIND, token, UNLIMITED_ATTEMPTS); const payload = stored ? parsePayload(stored.payload) : null; if (!stored?.userId || !payload) throw unauthorized("The editor session expired; reopen the editor"); if (await mintingCredentialRevoked(stored.userId, payload, stored.createdAt)) { throw unauthorized("The editor session was revoked; reopen the editor"); } const [user, repo] = await Promise.all([loadUserById(stored.userId), loadRepoById(payload.repoId)]); if (!user || !repo) throw unauthorized("The editor session is no longer valid"); const viewer = toViewer(user); const permission = capPermission(await repoPermission(repo, viewer), payload.ceiling); if (!hasPermission(permission, "read")) throw notFoundError("Repository not found"); const revision = resolveRevision(await listRefs(repo), payload.ref); if (!revision) throw notFoundError("Branch or ref not found"); await assertVisibleRevision(repo, revision); return { viewer, repo, permission, revision, canOpenPull: payload.canOpenPull }; } export function canWrite(session: EditorSession): boolean { return ( hasPermission(session.permission, "write") && session.revision.kind === "branch" && session.repo.is_archived === 0 ); } /** Whether a direct commit to the session branch would be rejected, so the editor should branch and open a PR. */ export async function needsPullRequest(session: EditorSession): Promise { const protection = protectionFor(await loadProtections(session.repo.id), session.revision.name); return directUpdateViolation(protection, session.permission, false) !== null; }