1// GRASP-02 Git reconciliation uses signed event targets and one bounded source2// attempt per alarm. Durable due times survive restarts and rotate failed sources.3import { fetchGitPack, encodePack, sha256, classifyError } from "ntig";4import { gitPackLimits } from "./git-limits.ts";5import { now, tag, type Event } from "./event.ts";6import { featureOn } from "./settings.ts";7import { KIND_GIT_PR, KIND_GIT_PR_UPDATE } from "./kinds.ts";8import { graspEvents, hostedAnnouncements, repositoryState, eventRepository, repository } from "./grasp-state.ts";9import { authorizedRepository, alternativePrAddress, gitRepository, promote } from "./grasp.ts";10import { localName } from "./pull.ts";11import type { RepositoryAnnouncement } from "./grasp-policy.ts";12import type { Relay } from "./relay.ts";13import { gitGraph, gitObjects } from "./git-objects.ts";1415interface Task { id: string; repo: RepositoryAnnouncement; eventId: string; event?: Event; refs: Record<string, string>; sources: string[]; }16const HOUR = 3600;17const RETRY = 300;1819// gitSource accepts public HTTPS names or another local relay's binding path.20export function gitSource(relay: Relay, raw: string): string | null {21 try {22 const url = new URL(raw);23 const host = url.hostname.replace(/\.$/u, "");24 url.hostname = host;25 if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || (url.port && url.port !== "443")) return null;26 if (!host.includes(".") || /^[0-9.]+$/.test(host) || host.includes(":") || host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".localhost")) return null;27 if (localName(url, relay.domain) === relay.slug || relay.settings.policy.customHosts.some(h => h.host === host)) return null;28 return url.href.replace(/\/$/u, "");29 } catch { return null; }30}3132async function sourceFetch(relay: Relay, request: Request): Promise<Response> {33 if (!gitSource(relay, request.url.split("?")[0])) throw new Error("restricted: Git source is not admitted");34 const url = new URL(request.url);35 const local = localName(url, relay.domain);36 if (local) {37 const headers = new Headers(request.headers);38 headers.set("x-relay-name", local);39 return relay.relays.getByName(local).fetch(new Request(request, { headers }));40 }41 return relay.fetcher(request.url, {42 method: request.method, headers: request.headers, redirect: "manual", signal: request.signal,43 ...(request.method === "POST" ? { body: await request.arrayBuffer() } : {}),44 });45}4647function tasks(relay: Relay): Task[] {48 const result: Task[] = [];49 for (const repo of hostedAnnouncements(relay)) {50 const state = repositoryState(relay, repo);51 if (state) result.push({ id: `state:${repo.id}:${state.id}`, repo, eventId: state.id, refs: { ...state.refs }, sources: repo.clone });52 }53 for (const e of [...graspEvents(relay, KIND_GIT_PR), ...graspEvents(relay, KIND_GIT_PR_UPDATE)]) {54 const repo = eventRepository(relay, e);55 const tip = tag(e, "c");56 if (!repo || !/^(?!0{40}$)[0-9a-f]{40}$/.test(tip)) continue;57 // PR objects belong to the target's ordinary repository, as GRASP-02 requires.58 // GRASP-06 is an independent upload service, not a sync destination.59 result.push({ id: `pr:${repo.id}:${e.id}`, repo, eventId: e.id, event: e, refs: { [`refs/nostr/${e.id}`]: tip }, sources: e.tags.filter(t => t[0] === "clone").flatMap(t => t.slice(1)) });60 }61 return result;62}6364async function reconcile(relay: Relay, task: Task, attempt: number): Promise<boolean> {65 const wal = await gitRepository(relay, task.repo);66 const snapshot = await wal.loadRefs();67 const names = new Set(Object.keys(task.refs));68 if (!task.event) for (const name of Object.keys(snapshot.refs)) if (!name.startsWith("refs/nostr/")) names.add(name);69 const updates = [...names].filter(name => (snapshot.refs[name] ?? null) !== (task.refs[name] ?? null)).map(name => ({ name, old: snapshot.refs[name] ?? null, new: task.refs[name] ?? null }));70 if (!updates.length) { await promote(relay, task.repo, wal); return true; }71 const objects = await gitObjects(wal);72 const missing: string[] = [];73 for (const oid of new Set(Object.values(task.refs))) {74 if (!(await objects.info(oid))) missing.push(oid);75 }76 let pack: Uint8Array | undefined;77 if (missing.length && task.event) {78 // A contributor may have uploaded here through GRASP-06. Copy its79 // authorized object graph locally instead of fetching this relay over HTTP.80 const address = alternativePrAddress(relay, task.event);81 if (address) {82 const local = await gitRepository(relay, address, true);83 const ref = `refs/nostr/${task.event.id}`;84 if ((await local.loadRefs()).refs[ref] === tag(task.event, "c")) {85 const available = await gitGraph(local, missing, relay.settings.policy.git);86 pack = await encodePack(available, gitPackLimits(relay.settings.policy.git));87 }88 }89 }90 if (missing.length && !pack) {91 const sources = [...new Set(task.sources.map(raw => gitSource(relay, raw)).filter((value): value is string => value !== null))].slice(0, 16);92 if (!sources.length) throw new Error("No admitted Git source");93 const source = sources[attempt % sources.length];94 const fetched = await fetchGitPack(source, {95 wants: missing,96 maxPackBytes: relay.settings.policy.git.maxPackBytes,97 maxResponseBytes: relay.settings.policy.git.maxPackBytes + 1024 * 1024,98 maxRefs: relay.settings.policy.git.maxRefs,99 gitLimits: gitPackLimits(relay.settings.policy.git),100 fetch: request => sourceFetch(relay, request),101 timeoutMs: 10_000,102 observe: event => relay.meterBytes(event.responseBytes, event.requestBytes),103 });104 pack = fetched.pack;105 }106 // The alarm owns the authority fence, but clocks, event expiry and fuel may change.107 if (!featureOn(relay.settings.policy, "grasp02") || relay.fuelStatus().outOfFuel || repository(relay, task.repo.owner, task.repo.identifier)?.id !== task.repo.id) return false;108 if (!task.event && repositoryState(relay, task.repo)?.id !== task.eventId) return false;109 const hash = await sha256(new TextEncoder().encode(JSON.stringify({ event: task.eventId, updates })));110 await authorizedRepository(relay, task.repo, wal).commit({ id: `grasp02:${hash}`, updates, ...(pack ? { pack } : {}) });111 return true;112}113114// syncGitTick runs under the shared alarm fence and never schedules background writes.115export async function syncGitTick(relay: Relay): Promise<number> {116 if (!featureOn(relay.settings.policy, "grasp02") || relay.settings.policy.reads !== "open" || relay.settings.isUnclaimed() || relay.fuelStatus().outOfFuel) return 0;117 if (!relay.repositoryAccess.owned) throw new Error("Git synchronization requires repository authority");118 const pending = tasks(relay);119 const ids = new Set(pending.map(task => task.id));120 for (const row of relay.sql.exec<{ id: string }>(`SELECT id FROM grasp_git_sync`).toArray()) if (!ids.has(row.id)) relay.sql.exec(`DELETE FROM grasp_git_sync WHERE id=?`, row.id);121 for (const task of pending) relay.sql.exec(`INSERT OR IGNORE INTO grasp_git_sync(id) VALUES(?)`, task.id);122 const candidate = relay.sql.exec<{ id: string; due: number; attempts: number }>(`SELECT id,due,attempts FROM grasp_git_sync ORDER BY due,id LIMIT 1`).toArray()[0];123 if (!candidate) return 0;124 if (candidate.due > now()) return candidate.due;125 const task = pending.find(task => task.id === candidate.id)!;126 // Reserve the retry before network I/O so interruption cannot form a hot loop.127 relay.sql.exec(`UPDATE grasp_git_sync SET due=?,attempts=attempts+1 WHERE id=?`, now() + RETRY, candidate.id);128 try {129 if (await reconcile(relay, task, candidate.attempts)) relay.sql.exec(`UPDATE grasp_git_sync SET due=?,attempts=0,error='' WHERE id=?`, now() + HOUR, candidate.id);130 } catch (error) { relay.sql.exec(`UPDATE grasp_git_sync SET error=? WHERE id=?`, classifyError(error).code, candidate.id); }131 const next = relay.sql.exec<{ due: number | null }>(`SELECT min(due) AS due FROM grasp_git_sync`).one().due;132 return next === null ? 0 : Math.max(now() + 1, next);133}