import { DurableObject, env } from "cloudflare:workers"; import { loadActiveUserById, toViewer } from "../auth"; import { hasPermission, loadRepoById, repoPermission } from "../repos"; import { decryptRepoSecret, encryptRepoSecret } from "../secrets"; import { randomToken } from "../crypto"; import { HttpError } from "../errors"; import { advertisement } from "../git/relay"; import { billingSettings } from "../metering/settings"; import { stripeEnabled } from "../billing/config"; import { eventBudget, isMirroredRef, nextEventRunAt, scheduleIntervalMs, utcDay } from "./plan"; import { runMirrorSync } from "./sync"; import type { MirrorSettings } from "./sync"; import type { MirrorRun, MirrorStatus, MirrorTrigger } from "~/lib/mirror"; import type { RepoRow } from "~/lib/types"; const MAX_RUNS = 30; const MAX_ERROR_LENGTH = 400; const FIRST_RUN_DELAY_MS = 5_000; const RUNNING_RETRY_MS = 30_000; type Sealed = { ciphertext: string; iv: string }; type Config = MirrorSettings & { repoId: string; enabled: boolean; token: Sealed | null; webhookSecret: Sealed; }; type State = { /** Ref → sha both sides agreed on after the last sync; tells a deletion apart from a new ref. */ synced: Record; /** Ref → sha this mirror last wrote into the forge repo, so its own pushes don't trigger another run. */ written: Record; runs: MirrorRun[]; lastRunAt: number | null; nextScheduledAt: number | null; pendingAt: number | null; pendingTrigger: MirrorTrigger | null; eventDay: string; eventCount: number; }; export type MirrorInput = Omit & { enabled: boolean }; const EMPTY_STATE: State = { synced: {}, written: {}, runs: [], lastRunAt: null, nextScheduledAt: null, pendingAt: null, pendingTrigger: null, eventDay: "", eventCount: 0, }; export function mirrorFlagKey(repoId: string): string { return `mirror:${repoId}`; } function errorMessage(error: unknown): string { const message = error instanceof HttpError || error instanceof Error ? error.message : String(error); return message.slice(0, MAX_ERROR_LENGTH); } /** Whether the remote answers without credentials; a private remote may only be pulled into a private repo. */ async function remoteIsPublic(url: string): Promise { try { await advertisement({ url, authorization: null }); return true; } catch { return false; } } /** Checks that run before every sync and on save: who configured it may still administer the repository. */ export async function assertMirrorAllowed(repo: RepoRow, settings: MirrorSettings, hasToken: boolean) { if (repo.is_archived) throw new HttpError(400, "The repository is archived"); if (repo.hidden_at || repo.owner_disabled_at) throw new HttpError(400, "The repository is unavailable"); const user = await loadActiveUserById(settings.createdBy); if (!user || !hasPermission(await repoPermission(repo, toViewer(user)), "admin")) { throw new HttpError( 400, "The admin who configured this mirror no longer administers the repository; save it again", ); } if (hasToken && settings.direction !== "push" && repo.visibility !== "private") { if (!(await remoteIsPublic(settings.remoteUrl))) { throw new HttpError(400, "A private remote can only be pulled into a private repository"); } } } /** Hourly on Pro (and on instances without billing), daily otherwise. */ async function hourlyFor(repo: RepoRow): Promise { if (!stripeEnabled()) return true; return (await billingSettings(repo.owner_id)).plan.id === "pro"; } /** * One per repository: the mirror's settings (token and webhook secret sealed with SECRETS_KEY), the * ref state of the last sync, and its schedule. A single alarm drives both the plan cadence (daily or * hourly) and coalesced push / webhook triggers, so mirrored repos cost nothing between runs. */ export class RepoMirror extends DurableObject { private running = false; private async config(): Promise { return (await this.ctx.storage.get("config")) ?? null; } private async state(): Promise { return { ...EMPTY_STATE, ...(await this.ctx.storage.get("state")) }; } private async saveState(state: State): Promise { await this.ctx.storage.put("state", state); } async configure( repoId: string, createdBy: string, input: MirrorInput, token: string | null | undefined, ): Promise<{ webhookSecret: string | null }> { const previous = await this.config(); let webhookSecret: string | null = null; let sealedSecret = previous?.webhookSecret; if (!sealedSecret) { webhookSecret = randomToken("gfm_"); sealedSecret = await encryptRepoSecret(webhookSecret); } const sealedToken = token === undefined ? (previous?.token ?? null) : token ? await encryptRepoSecret(token) : null; const remoteChanged = previous !== null && previous.remoteUrl !== input.remoteUrl; const config: Config = { ...input, createdBy, repoId, token: sealedToken, webhookSecret: sealedSecret }; await this.ctx.storage.put("config", config); const state = await this.state(); if (remoteChanged) state.synced = {}; if (config.enabled) { state.nextScheduledAt = Date.now() + FIRST_RUN_DELAY_MS; await env.KV.put(mirrorFlagKey(repoId), "1"); } else { state.nextScheduledAt = null; state.pendingAt = null; await env.KV.delete(mirrorFlagKey(repoId)); } await this.saveState(state); await this.reschedule(state, config); return { webhookSecret }; } async rotateWebhookSecret(): Promise { const config = await this.config(); if (!config) return null; const secret = randomToken("gfm_"); await this.ctx.storage.put("config", { ...config, webhookSecret: await encryptRepoSecret(secret) }); return secret; } async webhookSecret(): Promise { const config = await this.config(); if (!config?.enabled) return null; return decryptRepoSecret(config.webhookSecret.ciphertext, config.webhookSecret.iv); } async remove(repoId: string): Promise { await this.ctx.storage.deleteAlarm(); await this.ctx.storage.deleteAll(); await env.KV.delete(mirrorFlagKey(repoId)); } async status(): Promise { const config = await this.config(); if (!config) return null; const state = await this.state(); const repo = await loadRepoById(config.repoId); const hourly = repo ? await hourlyFor(repo) : false; return { enabled: config.enabled, remoteUrl: config.remoteUrl, username: config.username, hasToken: config.token !== null, direction: config.direction, strategy: config.strategy, branches: config.branches, syncTags: config.syncTags, propagateDeletes: config.propagateDeletes, configuredBy: config.createdBy, hourly, nextRunAt: config.enabled ? state.nextScheduledAt : null, pendingAt: state.pendingAt, running: this.running, eventsToday: state.eventDay === utcDay(Date.now()) ? state.eventCount : 0, eventBudget: eventBudget(hourly), runs: state.runs, }; } /** Queues a coalesced run for a webhook, a push to the forge repo, or the "Sync now" button. */ async requestSync(trigger: MirrorTrigger): Promise<{ ok: true; at: number } | { ok: false; reason: string }> { const config = await this.config(); if (!config?.enabled) return { ok: false, reason: "Mirroring is not enabled" }; const repo = await loadRepoById(config.repoId); if (!repo) return { ok: false, reason: "Repository not found" }; const state = await this.state(); const now = Date.now(); if (state.pendingAt !== null) return { ok: true, at: state.pendingAt }; const today = utcDay(now); const used = state.eventDay === today ? state.eventCount : 0; if (used >= eventBudget(await hourlyFor(repo))) { return { ok: false, reason: "Today's triggered syncs are used up; the next scheduled sync still runs" }; } state.eventDay = today; state.eventCount = used + 1; state.pendingAt = nextEventRunAt(now, state.lastRunAt); state.pendingTrigger = trigger; await this.saveState(state); await this.reschedule(state, config); return { ok: true, at: state.pendingAt }; } /** A push landed in the forge repo; mirror it out unless this mirror wrote it. */ async notifyPush(ref: string, after: string): Promise { const config = await this.config(); if (!config?.enabled || config.direction === "pull") return; if (!isMirroredRef(ref, config.branches, config.syncTags)) return; const state = await this.state(); if (state.written[ref] === after) return; await this.requestSync("push"); } override async alarm(): Promise { const config = await this.config(); if (!config) return; const repo = await loadRepoById(config.repoId); if (!repo) { await this.remove(config.repoId); return; } const state = await this.state(); const now = Date.now(); const scheduledDue = config.enabled && state.nextScheduledAt !== null && state.nextScheduledAt <= now; const eventDue = config.enabled && state.pendingAt !== null && state.pendingAt <= now; if ((scheduledDue || eventDue) && !this.running) { const trigger: MirrorTrigger = eventDue ? (state.pendingTrigger ?? "webhook") : "schedule"; state.pendingAt = null; state.pendingTrigger = null; state.lastRunAt = now; if (scheduledDue || state.nextScheduledAt === null) { state.nextScheduledAt = now + scheduleIntervalMs(await hourlyFor(repo)); } await this.saveState(state); await this.run(repo, config, trigger); } await this.reschedule(await this.state(), config); } private async run(repo: RepoRow, config: Config, trigger: MirrorTrigger): Promise { this.running = true; const startedAt = Date.now(); let record: MirrorRun; try { await assertMirrorAllowed(repo, config, config.token !== null); const token = config.token ? await decryptRepoSecret(config.token.ciphertext, config.token.iv) : null; const before = await this.state(); const result = await runMirrorSync(repo, config, token, before.synced, async (written) => { const current = await this.state(); current.written = { ...current.written, ...Object.fromEntries(written) }; await this.saveState(current); }); const state = await this.state(); state.synced = result.synced; const kept = Object.entries(state.written).filter(([ref]) => ref in result.synced); state.written = { ...Object.fromEntries(kept), ...result.written }; await this.saveState(state); record = { startedAt, finishedAt: Date.now(), trigger, error: null, report: result.report }; } catch (error) { if (!(error instanceof HttpError)) console.error("mirror sync failed", repo.id, error); record = { startedAt, finishedAt: Date.now(), trigger, error: errorMessage(error), report: null }; } finally { this.running = false; } const state = await this.state(); state.runs = [record, ...state.runs].slice(0, MAX_RUNS); await this.saveState(state); } private async reschedule(state: State, config: Config): Promise { const times = config.enabled ? [state.nextScheduledAt, state.pendingAt].filter((at) => at !== null) : []; if (times.length === 0) { await this.ctx.storage.deleteAlarm(); return; } const floor = Date.now() + (this.running ? RUNNING_RETRY_MS : 0); await this.ctx.storage.setAlarm(Math.max(Math.min(...times), floor)); } } export function mirrorStub(repoId: string) { return env.REPO_MIRROR.get(env.REPO_MIRROR.idFromName(repoId)); }