import { peekAuthToken, storeAuthToken } from "../auth-tokens"; import { first } from "../db"; import { accountDb } from "../scoped-db"; import { containersEnabled } from "./container-config"; import { containersAllowedFor } from "./container-meter"; import { credentialFromRequest, loadActiveUserById, sessionHandleIsLive, 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 { credentialAllows, fineGrant, scopedRepoPermission } from "../token-scope"; import type { TokenGrant } from "../token-scope"; 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; * `sessionHandle` ties a browser-minted session to the forge sign-in it came from, so signing out ends it. */ type SessionPayload = { repoId: string; ref: string; ceiling: Permission; canOpenPull: boolean; patId: string | null; sessionHandle: string | null; }; export type MintGrant = { ceiling: Permission; canOpenPull: boolean; patId: string | null; sessionHandle?: 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; token: string; expiresAt: number; }; /** * 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, sessionHandle: null, ...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"); const sessionHandle: unknown = Reflect.get(parsed, "sessionHandle"); if (typeof repoId !== "string" || typeof ref !== "string" || !isPermission(ceiling)) return null; return { repoId, ref, ceiling, canOpenPull: canOpenPull === true, patId: typeof patId === "string" ? patId : null, sessionHandle: typeof sessionHandle === "string" ? sessionHandle : null, }; } type PatRow = { expires_at: number | null; permissions: string | null; all_repos: number }; /** Sign-out-everywhere and password resets end editor sessions minted before them. */ async function signedOutSince(userId: string, createdAt: number): Promise { const user = await first<{ sessions_revoked_at: number | null }>( "SELECT sessions_revoked_at FROM users WHERE account_id = ?", userId, ); return !user || (user.sessions_revoked_at !== null && createdAt < user.sessions_revoked_at); } /** * The live grant of the PAT a session was minted from, re-read on every request and socket check so * narrowing or revoking the PAT applies at once. Null when the PAT is gone or expired. */ async function currentPatGrant(patId: string, userId: string): Promise { const pat = await accountDb(userId).first( "SELECT expires_at, permissions, all_repos FROM access_tokens WHERE id = ? AND user_id = ?", patId, userId, ); if (!pat || (pat.expires_at !== null && pat.expires_at < Date.now())) return null; return fineGrant(patId, pat); } /** * Resolves an editor token and re-derives the user's current permission, so revoking access takes effect * immediately. Used for HTTP requests and for every WebSocket (re)check in the editor room. */ export async function resolveEditorToken(token: string): Promise { 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 signedOutSince(stored.userId, stored.createdAt)) { throw unauthorized("The editor session was revoked; reopen the editor"); } if (payload.sessionHandle && !(await sessionHandleIsLive(payload.sessionHandle))) { throw unauthorized("You signed out; reopen the editor"); } const [user, repo] = await Promise.all([loadActiveUserById(stored.userId), loadRepoById(payload.repoId)]); if (!user || !repo) throw unauthorized("The editor session is no longer valid"); const viewer = toViewer(user); let permission: Permission; let canOpenPull = payload.canOpenPull; if (payload.patId) { const grant = await currentPatGrant(payload.patId, user.id); if (!grant) throw unauthorized("The token that opened this editor session was revoked"); const credential = { viewer, grant }; permission = await scopedRepoPermission(repo, credential, "contents"); canOpenPull = canOpenPull && (await credentialAllows(repo, credential, "pulls", "write")); } else { permission = await repoPermission(repo, viewer); } permission = capPermission(permission, 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, token, expiresAt: stored.createdAt + EDITOR_TOKEN_TTL_SECONDS * 1000, }; } export async function editorSessionFromRequest(request: Request): Promise { const token = credentialFromRequest(request); if (!token) throw unauthorized("An editor session token is required"); return resolveEditorToken(token); } 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; } export type EditorSessionInfo = { token: string; expiresAt: number; owner: string; repo: string; ref: string; refKind: ResolvedRevision["kind"]; sha: string; writable: boolean; protected: boolean; user: string; containers: boolean; }; /** What the forge page hands the editor: the token plus enough context to render without another call. */ export async function editorSessionInfo(session: EditorSession): Promise { const writable = canWrite(session); return { token: session.token, expiresAt: session.expiresAt, owner: session.repo.owner_name, repo: session.repo.name, ref: session.revision.name, refKind: session.revision.kind, sha: session.revision.sha, writable, protected: writable ? await needsPullRequest(session) : false, user: session.viewer.displayName ?? session.viewer.name, containers: writable && containersEnabled() && (await containersAllowedFor(session.viewer.id)), }; }