- commit
- ecec699f1907bd9d740ddff9274ea5122c66b81e
- parent
- 5b700554ad2974a2c04e860b19c60aecb781b35a
- author
- Dami <dami.etoile@gmail.com>
| M | relay-config.schema.json | 4 | + |
| M | src/ | 11 | +- |
| A | src/ | 138 | ++++++++++++++++++++ |
| M | src/ | 28 | +++- |
| A | src/ | 209 | ++++++++++++++++++++++++++++++ |
| M | src/ | 137 | +++++++++++++++++--- |
| M | src/ | 8 | +- |
| M | src/ | 8 | +- |
| M | src/ | 18 | ++- |
@@ -29,7 +29,7 @@ export type BackupArchive = {
2929 events: string[];3030 blobs: (Payload & { sha256: string; type: string; uploader: string; uploaded: number })[];3131 git: (Payload & { key: string })[];32- state?: { listHistory: string[]; hidden: string[]; hosted: string[]; pending: { id: string; until: number }[] };32+ state?: { listHistory: string[]; hidden: string[]; hosted: string[]; pending: { id: string; until: number }[]; prRefs?: { repo: string; ref: string; until: number }[] };3333 };3434 3535 const enc = new TextEncoder();@@ -116,12 +116,12 @@ export async function createBackup(relay: Relay, id: string): Promise<{ manifest
116116 }117117 const config = exportConfig(relay.settings, relay.slug);118118 if (!reserve(enc.encode(JSON.stringify(config)).length)) return "restricted: backup exceeds its bounded size or object limit";119- const state = { listHistory: [] as string[], hidden: [...relay.settings.hiddenEvents], hosted: relay.sql.exec<{ id: string }>(`SELECT id FROM grasp_hosted`).toArray().map((r) => r.id), pending: relay.sql.exec<{ id: string; until: number }>(`SELECT id,until FROM grasp_pending`).toArray() };119+ const state = { listHistory: [] as string[], hidden: [...relay.settings.hiddenEvents], hosted: relay.sql.exec<{ id: string }>(`SELECT id FROM grasp_hosted`).toArray().map((r) => r.id), pending: relay.sql.exec<{ id: string; until: number }>(`SELECT id,until FROM grasp_pending`).toArray(), prRefs: relay.sql.exec<{ repo: string; ref: string; until: number }>(`SELECT repo,ref,until FROM grasp_pr_refs`).toArray() };120120 for (const row of relay.sql.exec<{ raw: string }>(`SELECT raw FROM list_history WHERE expires=0 OR expires>? ORDER BY saved_at`, now())) {121121 if (!reserve(enc.encode(row.raw).length)) return "restricted: backup history exceeds its bounded size";122122 state.listHistory.push(row.raw);123123 }124- if (!reserve(enc.encode(JSON.stringify({ ...state, listHistory: [] })).length, state.hidden.length + state.hosted.length + state.pending.length)) return "restricted: backup state exceeds its bounded size";124+ if (!reserve(enc.encode(JSON.stringify({ ...state, listHistory: [] })).length, state.hidden.length + state.hosted.length + state.pending.length + state.prRefs.length)) return "restricted: backup state exceeds its bounded size";125125 const archive = { format: BACKUP_FORMAT, manifest: { id, slug: relay.slug, owner, relayIdentity: relay.identity.pubkey, createdAt: now(), bytes: 0, events: events.length, blobs: blobs.length, git: git.length, archiveSha256: "" }, config, events, blobs, git, state } as BackupArchive;126126 archive.manifest.archiveSha256 = "0".repeat(64);127127 for (let i = 0; i < 4; i++) archive.manifest.bytes = bytesOf(archive).length;@@ -162,7 +162,8 @@ const checkedArchive = (bytes: Uint8Array): BackupArchive | string => {
162162 const state = archive.state;163163 if (state) {164164 if (!Array.isArray(state.listHistory) || !Array.isArray(state.hidden) || !Array.isArray(state.hosted) || !Array.isArray(state.pending)) return "invalid: backup state malformed";165- if (archive.events.length + archive.blobs.length + archive.git.length + state.listHistory.length + state.hidden.length + state.hosted.length + state.pending.length > BACKUP_MAX_OBJECTS) return "restricted: backup object limit reached";165+ if (state.prRefs !== undefined && (!Array.isArray(state.prRefs) || state.prRefs.some(r => !r || typeof r.repo !== "string" || !/^(?:pr|30617):[0-9a-f]{64}:.+$/u.test(r.repo) || new TextEncoder().encode(r.repo).length > 327 || typeof r.ref !== "string" || !/^refs\/nostr\/[0-9a-f]{64}$/u.test(r.ref) || !Number.isSafeInteger(r.until) || r.until < 0))) return "invalid: backup PR deadlines malformed";166+ if (archive.events.length + archive.blobs.length + archive.git.length + (state.prRefs?.length ?? 0) + state.listHistory.length + state.hidden.length + state.hosted.length + state.pending.length > BACKUP_MAX_OBJECTS) return "restricted: backup object limit reached";166167 const hex = (v: unknown) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);167168 if (state.hidden.some((v) => !hex(v)) || state.hosted.some((v) => !hex(v)) || state.pending.some((v) => !v || !hex(v.id) || !Number.isSafeInteger(v.until))) return "invalid: backup state malformed";168169 for (const raw of state.listHistory) {@@ -213,6 +214,7 @@ export async function restoreBackup(relay: Relay, bytes: Uint8Array, caller: str
213214 for (const id of archive.state?.hidden ?? []) relay.settings.setEvent(id, "hide");214215 for (const id of archive.state?.hosted ?? []) relay.sql.exec(`INSERT OR IGNORE INTO grasp_hosted(id) SELECT id FROM events WHERE id=?`, id);215216 for (const row of archive.state?.pending ?? []) relay.sql.exec(`INSERT OR IGNORE INTO grasp_pending(id,until) SELECT id,? FROM events WHERE id=?`, row.until, row.id);217+ for (const row of archive.state?.prRefs ?? []) relay.sql.exec(`INSERT OR IGNORE INTO grasp_pr_refs(repo,ref,until) VALUES(?,?,?)`, row.repo, row.ref, row.until);216218 for (const b of archive.blobs) relay.sql.exec(`INSERT OR REPLACE INTO blobs(sha256,size,type,uploader,uploaded) VALUES(?,?,?,?,?)`, b.sha256, b.size, b.type, b.uploader, b.uploaded);217219 for (const g of archive.git) relay.sql.exec(`INSERT OR REPLACE INTO grasp_objects(key,owner,size) VALUES(?,?,?)`, `${relay.slug}/${g.key}`, caller, g.size);218220 });@@ -226,6 +228,7 @@ export async function restoreBackup(relay: Relay, bytes: Uint8Array, caller: str
226228 await relay.syncSites();227229 await relay.publishMembership();228230 await relay.publishDiscovery();231+ if (relay.sql.exec("SELECT 1 FROM grasp_pending UNION ALL SELECT 1 FROM grasp_pr_refs LIMIT 1").toArray().length || relay.settings.policy.features.grasp02) await relay.ensureAlarm(now() + 1);229232 return { restored: true, owner: caller, sourceRelayIdentity: archive.manifest.relayIdentity, targetRelayIdentity: relay.identity.pubkey, events: archive.events.length, blobs: archive.blobs.length, git: archive.git.length, bytes: bytes.length };230233 }231234 @@ -0,0 +1,138 @@
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.3+import { fetchGitPack, readObjects, objectLinks, encodePack, sha256, classifyError } from "ntig";4+import { now, tag, type Event } from "./event.ts";5+import { featureOn } from "./settings.ts";6+import { KIND_GIT_PR, KIND_GIT_PR_UPDATE } from "./kinds.ts";7+import { graspEvents, hostedAnnouncements, repositoryState, eventRepository, repository } from "./grasp-state.ts";8+import { authorizedRepository, alternativePrAddress, gitRepository, promote } from "./grasp.ts";9+import { localName } from "./pull.ts";10+import type { RepositoryAnnouncement } from "./grasp-policy.ts";11+import type { Relay } from "./relay.ts";12+13+interface Task { id: string; repo: RepositoryAnnouncement; eventId: string; event?: Event; refs: Record<string, string>; sources: string[]; }14+const HOUR = 3600;15+const RETRY = 300;16+17+// gitSource accepts public HTTPS names or another local relay's binding path.18+export function gitSource(relay: Relay, raw: string): string | null {19+ try {20+ const url = new URL(raw);21+ const host = url.hostname.replace(/\.$/u, "");22+ url.hostname = host;23+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || (url.port && url.port !== "443")) return null;24+ if (!host.includes(".") || /^[0-9.]+$/.test(host) || host.includes(":") || host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".localhost")) return null;25+ if (localName(url, relay.domain) === relay.slug || relay.settings.policy.customHosts.some(h => h.host === host)) return null;26+ return url.href.replace(/\/$/u, "");27+ } catch { return null; }28+}29+30+async function sourceFetch(relay: Relay, request: Request): Promise<Response> {31+ if (!gitSource(relay, request.url.split("?")[0])) throw new Error("restricted: Git source is not admitted");32+ const url = new URL(request.url);33+ const local = localName(url, relay.domain);34+ if (local) {35+ const headers = new Headers(request.headers);36+ headers.set("x-relay-name", local);37+ return relay.relays.getByName(local).fetch(new Request(request, { headers }));38+ }39+ return relay.fetcher(request.url, {40+ method: request.method, headers: request.headers, redirect: "manual", signal: request.signal,41+ ...(request.method === "POST" ? { body: await request.arrayBuffer() } : {}),42+ });43+}44+45+function tasks(relay: Relay): Task[] {46+ const result: Task[] = [];47+ for (const repo of hostedAnnouncements(relay)) {48+ const state = repositoryState(relay, repo);49+ if (state) result.push({ id: `state:${repo.id}:${state.id}`, repo, eventId: state.id, refs: { ...state.refs }, sources: repo.clone });50+ }51+ for (const e of [...graspEvents(relay, KIND_GIT_PR), ...graspEvents(relay, KIND_GIT_PR_UPDATE)]) {52+ const repo = eventRepository(relay, e);53+ const tip = tag(e, "c");54+ if (!repo || !/^(?!0{40}$)[0-9a-f]{40}$/.test(tip)) continue;55+ // PR objects belong to the target's ordinary repository, as GRASP-02 requires.56+ // GRASP-06 is an independent upload service, not a sync destination.57+ 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)) });58+ }59+ return result;60+}61+62+async function reconcile(relay: Relay, task: Task, attempt: number): Promise<boolean> {63+ const wal = await gitRepository(relay, task.repo);64+ const snapshot = await wal.loadRefs();65+ const names = new Set(Object.keys(task.refs));66+ if (!task.event) for (const name of Object.keys(snapshot.refs)) if (!name.startsWith("refs/nostr/")) names.add(name);67+ 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 }));68+ if (!updates.length) { await promote(relay, task.repo, wal); return true; }69+ const current = await wal.load();70+ const objects = await readObjects(current.packs);71+ const missing = [...new Set(Object.values(task.refs))].filter(oid => !objects.has(oid));72+ let pack: Uint8Array | undefined;73+ if (missing.length && task.event) {74+ // A contributor may have uploaded here through GRASP-06. Copy its75+ // authorized object graph locally instead of fetching this relay over HTTP.76+ const address = alternativePrAddress(relay, task.event);77+ if (address) {78+ const local = await gitRepository(relay, address, true);79+ const ref = `refs/nostr/${task.event.id}`;80+ if ((await local.loadRefs()).refs[ref] === tag(task.event, "c")) {81+ const available = await readObjects((await local.load()).packs);82+ const needed = new Set<string>(), queue = new Set(missing);83+ while (queue.size) {84+ const oid = queue.values().next().value!;85+ queue.delete(oid);86+ if (needed.has(oid)) continue;87+ const object = available.get(oid);88+ if (!object) throw new Error("Local PR object graph is incomplete");89+ needed.add(oid);90+ for (const link of objectLinks(object)) {91+ if (!available.has(link.oid)) throw new Error("Local PR dependency is missing");92+ if (!needed.has(link.oid)) queue.add(link.oid);93+ }94+ }95+ pack = await encodePack([...needed].map(oid => available.get(oid)!));96+ }97+ }98+ }99+ if (missing.length && !pack) {100+ const sources = [...new Set(task.sources.map(raw => gitSource(relay, raw)).filter((value): value is string => value !== null))].slice(0, 16);101+ if (!sources.length) throw new Error("No admitted Git source");102+ const source = sources[attempt % sources.length];103+ const fetched = await fetchGitPack(source, {104+ wants: missing,105+ fetch: request => sourceFetch(relay, request),106+ timeoutMs: 10_000,107+ observe: event => relay.meterBytes(event.responseBytes, event.requestBytes),108+ });109+ pack = fetched.pack;110+ }111+ // The alarm owns the authority fence, but clocks, event expiry and fuel may change.112+ if (!featureOn(relay.settings.policy, "grasp02") || relay.fuelStatus().outOfFuel || repository(relay, task.repo.owner, task.repo.identifier)?.id !== task.repo.id) return false;113+ if (!task.event && repositoryState(relay, task.repo)?.id !== task.eventId) return false;114+ const hash = await sha256(new TextEncoder().encode(JSON.stringify({ event: task.eventId, updates })));115+ await authorizedRepository(relay, task.repo, wal).commit({ id: `grasp02:${hash}`, updates, ...(pack ? { pack } : {}) });116+ return true;117+}118+119+// syncGitTick runs under the shared alarm fence and never schedules background writes.120+export async function syncGitTick(relay: Relay): Promise<number> {121+ if (!featureOn(relay.settings.policy, "grasp02") || relay.settings.policy.reads !== "open" || relay.settings.isUnclaimed() || relay.fuelStatus().outOfFuel) return 0;122+ if (!relay.repositoryAccess.owned) throw new Error("Git synchronization requires repository authority");123+ const pending = tasks(relay);124+ const ids = new Set(pending.map(task => task.id));125+ 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);126+ for (const task of pending) relay.sql.exec(`INSERT OR IGNORE INTO grasp_git_sync(id) VALUES(?)`, task.id);127+ 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];128+ if (!candidate) return 0;129+ if (candidate.due > now()) return candidate.due;130+ const task = pending.find(task => task.id === candidate.id)!;131+ // Reserve the retry before network I/O so interruption cannot form a hot loop.132+ relay.sql.exec(`UPDATE grasp_git_sync SET due=?,attempts=attempts+1 WHERE id=?`, now() + RETRY, candidate.id);133+ try {134+ 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);135+ } catch (error) { relay.sql.exec(`UPDATE grasp_git_sync SET error=? WHERE id=?`, classifyError(error).code, candidate.id); }136+ const next = relay.sql.exec<{ due: number | null }>(`SELECT min(due) AS due FROM grasp_git_sync`).one().due;137+ return next === null ? 0 : Math.max(now() + 1, next);138+}@@ -17,6 +17,10 @@ CREATE TRIGGER IF NOT EXISTS grasp_removed AFTER DELETE ON events BEGIN DELETE F
1717 CREATE TABLE IF NOT EXISTS grasp_tick (id INTEGER PRIMARY KEY CHECK(id=1), coordinate TEXT NOT NULL);1818 CREATE TABLE IF NOT EXISTS grasp_pr_refs (repo TEXT NOT NULL, ref TEXT NOT NULL, until INTEGER NOT NULL, PRIMARY KEY(repo,ref));1919 CREATE TABLE IF NOT EXISTS grasp_objects (key TEXT PRIMARY KEY, owner TEXT NOT NULL, size INTEGER NOT NULL);20+CREATE TABLE IF NOT EXISTS grasp_git_sync (id TEXT PRIMARY KEY, due INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '');21+CREATE TABLE IF NOT EXISTS grasp_event_sync (id TEXT PRIMARY KEY, source TEXT NOT NULL, scope TEXT NOT NULL, windows TEXT NOT NULL, live_since INTEGER NOT NULL, due INTEGER NOT NULL DEFAULT 0, failures INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '');22+CREATE INDEX IF NOT EXISTS grasp_event_sync_due ON grasp_event_sync(due,id);23+CREATE TABLE IF NOT EXISTS grasp_sync_status (id INTEGER PRIMARY KEY CHECK(id=1), partial INTEGER NOT NULL DEFAULT 0);2024 `;2125 2226 // graspEvents includes purgatory for write authority, but never expired or@@ -59,8 +63,8 @@ export function graspGate(relay: Relay, e: Event, host: string): string {
5963 const listed = serviceListed(r, origin + gitRepositoryPath(npubEncode(e.pubkey), r.identifier), origin.replace(/^http/, "ws"));6064 const all = announcements(relay);6165 const related = hostedAnnouncements(relay).some((root) => root.owner !== e.pubkey && root.identifier === r.identifier && recursiveMaintainers(root, all)?.has(e.pubkey));62- if (!listed && !related) return "restricted: repository clone and relays tags must name this service";63- if (listed && !repository(relay, r.owner, r.identifier) && hostedAnnouncements(relay).length >= MAX_REPOSITORIES) return `restricted: at most ${MAX_REPOSITORIES} repositories per relay`;66+ if (!listed && !related && !featureOn(relay.settings.policy, "grasp05")) return "restricted: repository clone and relays tags must name this service";67+ if ((listed || featureOn(relay.settings.policy, "grasp05")) && !repository(relay, r.owner, r.identifier) && hostedAnnouncements(relay).length >= MAX_REPOSITORIES) return `restricted: at most ${MAX_REPOSITORIES} repositories per relay`;6468 }6569 if (e.kind === KIND_REPO_STATE) {6670 const p = parseRepositoryState(e);@@ -69,7 +73,22 @@ export function graspGate(relay: Relay, e: Event, host: string): string {
6973 if (!all.some((r) => r.identifier === p.value!.identifier && recursiveMaintainers(r, all)?.has(e.pubkey))) return "restricted: state author is not an accepted repository maintainer";7074 }7175 if (e.kind === KIND_GIT_PR || e.kind === KIND_GIT_PR_UPDATE) {72- if (!eventRepository(relay, e)) return "restricted: pull request must name an accepted repository";76+ // GRASP-06 permits a contributor to publish a PR before the target77+ // repository announcement reaches this relay. Keep the repository78+ // coordinate mandatory; the PR namespace will authorize its matching79+ // objects once the event is stored.80+ const hosted = eventRepository(relay, e);81+ if (!hosted && !featureOn(relay.settings.policy, "grasp06")) return "restricted: pull request must name an accepted repository";82+ if (!hosted && !relatedRepositoryCoordinates(e).some((c) => {83+ const p = c.split(":");84+ return p.length >= 3 && p[0] === "30617" && /^[0-9a-f]{64}$/.test(p[1]) && !!p.slice(2).join(":");85+ })) return "restricted: pull request must name a repository";86+ if (!hosted) {87+ const origin = relay.webURL(host || `${relay.slug}.${relay.domain}`);88+ const paths = relatedRepositoryCoordinates(e).map(c => origin + "/prs" + gitRepositoryPath(npubEncode(e.pubkey), c.split(":").slice(2).join(":")));89+ const listed = e.tags.filter(t => t[0] === "clone").flatMap(t => t.slice(1)).some(raw => { try { const u = new URL(raw); return !u.username && !u.password && paths.includes(u.href.replace(/\/$/u, "")); } catch { return false; } });90+ if (!listed) return "restricted: pull request clone must name this service's signer PR path";91+ }7392 const tips = tagValues(e, "c");7493 if (tips.length !== 1 || !/^[0-9a-f]{40}$/.test(tips[0]) || /^0+$/.test(tips[0])) return "invalid: pull request needs one SHA-1 c tag";7594 }@@ -83,7 +102,8 @@ export function holdGrasp(relay: Relay, e: Event, host: string): boolean {
83102 if (e.kind === KIND_REPO) {84103 const r = parseRepositoryAnnouncement(e).value!;85104 const origin = relay.webURL(host || `${relay.slug}.${relay.domain}`);86- if (!serviceListed(r, origin + gitRepositoryPath(npubEncode(e.pubkey), r.identifier), origin.replace(/^http/, "ws"))) return false;105+ const listed = serviceListed(r, origin + gitRepositoryPath(npubEncode(e.pubkey), r.identifier), origin.replace(/^http/, "ws"));106+ if (!listed && !featureOn(relay.settings.policy, "grasp05")) return false;87107 relay.sql.exec(`INSERT OR REPLACE INTO grasp_hosted(id) VALUES(?)`, e.id);88108 }89109 relay.sql.exec(`INSERT OR REPLACE INTO grasp_pending(id,until) VALUES(?,?)`, e.id, now() + PURGATORY_SECONDS);@@ -0,0 +1,209 @@
1+// Bounded GRASP-02/03 reconciliation. Durable windows retain history gaps;2+// each connection follows EOSE with a live tail and overlaps the next wake.3+import { sha256 } from "@noble/hashes/sha2.js";4+import { bytesToHex } from "./negentropy.ts";5+import { Socket, localName, type PullSocket } from "./pull.ts";6+import { validate, now, isPrivate, isEphemeral, type Event } from "./event.ts";7+import { match, type Filter } from "./filter.ts";8+import { featureOn } from "./settings.ts";9+import { announcements, hostedAnnouncements, graspEvents, graspVisible } from "./grasp-state.ts";10+import { repositoryCoordinate, recursiveMaintainers, relatedRepositoryCoordinates } from "./grasp-policy.ts";11+import { gitSource } from "./grasp-git-sync.ts";12+import { KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR, KIND_GIT_PR_UPDATE, KIND_REPO, KIND_REPO_STATE } from "./kinds.ts";13+import type { Relay } from "./relay.ts";14+15+export const GRASP_SYNC_PAGE = 256;16+const MAX_JOBS = 512, MAX_SOURCES = 16, MAX_WINDOWS = 128, LIVE_MS = 1500, ROUND_MS = 10_000;17+const UTF8 = new TextEncoder();18+type Window = { since: number; until: number };19+type Scope = { repo: string; mode: "repository" | "thread" | "metadata"; filters: Filter[] };20+type Job = { id: string; source: string; scope: Scope };21+type Row = { id: string; source: string; scope: string; windows: string; live_since: number; due: number; failures: number; error: string };22+export type GraspSyncConnect = (relay: Relay, source: string) => Promise<PullSocket>;23+const enabled = (relay: Relay) => featureOn(relay.settings.policy, "grasp02") && relay.settings.policy.reads === "open" && !relay.settings.isUnclaimed() && !relay.fuelStatus().outOfFuel;24+25+export function eventSource(relay: Relay, raw: string): string | null {26+ try {27+ const u = new URL(raw);28+ if (u.protocol !== "wss:") return null;29+ u.protocol = "https:";30+ const safe = gitSource(relay, u.href);31+ return safe ? safe.replace(/^https:/u, "wss:") : null;32+ } catch { return null; }33+}34+35+const productionConnect: GraspSyncConnect = async (relay, source) => {36+ const safe = eventSource(relay, source);37+ if (!safe) throw new Error("source-not-admitted");38+ const url = new URL(safe.replace(/^wss:/u, "https:"));39+ const local = localName(url, relay.domain);40+ const abort = new AbortController();41+ const timer = setTimeout(() => abort.abort(), ROUND_MS);42+ try {43+ const init: RequestInit = { headers: { upgrade: "websocket", ...(local ? { "x-relay-name": local } : {}) }, redirect: "manual", signal: abort.signal };44+ const response = local ? await relay.relays.getByName(local).fetch(url.href, init) : await relay.fetcher(url.href, init);45+ if (response.status !== 101 || !response.webSocket) { await response.body?.cancel(); throw new Error("websocket-refused"); }46+ response.webSocket.accept();47+ return new Socket(response.webSocket);48+ } finally { clearTimeout(timer); }49+};50+51+function jobs(relay: Relay): { jobs: Job[]; partial: boolean } {52+ const result = new Map<string, Job>();53+ let partial = false;54+ const sources = (raw: string[]) => {55+ const values = [...new Set(raw.map(s => eventSource(relay, s)).filter((s): s is string => s !== null))].sort();56+ if (values.length > MAX_SOURCES) partial = true;57+ return values.slice(0, MAX_SOURCES);58+ };59+ const add = (scope: Scope, urls: string[]) => {60+ for (const source of sources(urls)) {61+ const id = bytesToHex(sha256(UTF8.encode(JSON.stringify({ source, scope }))));62+ if (result.has(id)) continue;63+ if (result.size >= MAX_JOBS) { partial = true; return; }64+ result.set(id, { id, source, scope });65+ }66+ };67+ const all = announcements(relay);68+ const roots = [KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR, KIND_GIT_PR_UPDATE].flatMap(k => { const events = graspEvents(relay, k); if (events.length >= 1025) partial = true; return events; }).filter(e => graspVisible(relay, e.id));69+ const rootCoordinates = new Map(roots.map(e => [e.id, relatedRepositoryCoordinates(e)]));70+ for (const repo of hostedAnnouncements(relay)) {71+ const coordinate = repositoryCoordinate(repo);72+ const maintainers = recursiveMaintainers(repo, all);73+ if (!maintainers) { partial = true; continue; }74+ add({ repo: coordinate, mode: "repository", filters: [75+ { kinds: [KIND_REPO, KIND_REPO_STATE], authors: [...maintainers].sort(), tags: { d: [repo.identifier] } },76+ { tags: { a: [coordinate] } }, { tags: { A: [coordinate] } },77+ { tags: { e: [repo.id] } }, { tags: { E: [repo.id] } },78+ ] }, repo.relays);79+ const threads = roots.filter(e => rootCoordinates.get(e.id)?.includes(coordinate) || e.tags.some(t => (t[0] === "e" || t[0] === "E") && rootCoordinates.get(t[1])?.includes(coordinate))).sort((a, b) => a.id.localeCompare(b.id));80+ for (let start = 0; start < threads.length; start += 50) {81+ const batch = threads.slice(start, start + 50), ids = batch.map(e => e.id);82+ const filters: Filter[] = [{ tags: { e: ids } }, { tags: { E: ids } }];83+ const mentioned = [...new Set(batch.flatMap(e => e.tags.filter(t => t[0] === "e" || t[0] === "E").map(t => t[1]).filter(id => /^[0-9a-f]{64}$/u.test(id))))];84+ if (mentioned.length > 50) partial = true;85+ if (mentioned.length) filters.push({ ids: mentioned.slice(0, 50).sort(), tags: {} });86+ const outboxes: string[] = [];87+ if (featureOn(relay.settings.policy, "grasp03")) {88+ const authors = new Set(batch.map(e => e.pubkey));89+ for (const f of filters.slice(0, 2)) {90+ const rows = relay.store.query(f, { pubkeys: [], all: true }, GRASP_SYNC_PAGE, now()).rows;91+ if (rows.length >= GRASP_SYNC_PAGE) partial = true;92+ for (const raw of rows) { const e = JSON.parse(raw) as Event; if (!isPrivate(e.kind) && graspVisible(relay, e.id)) authors.add(e.pubkey); }93+ }94+ if (authors.size > 64) partial = true;95+ for (const author of [...authors].sort().slice(0, 64)) {96+ const lists = relay.store.query({ kinds: [10002], authors: [author], tags: {} }, { pubkeys: [], all: true }, 1, now()).rows;97+ const outbox = lists.flatMap(raw => (JSON.parse(raw) as Event).tags.filter(t => t[0] === "r" && (!t[2] || t[2] === "write")).map(t => t[1]));98+ outboxes.push(...outbox);99+ add({ repo: coordinate, mode: "metadata", filters: [{ kinds: [0, 10002, 10317], authors: [author], tags: {} }] }, [...repo.relays, ...outbox]);100+ }101+ }102+ add({ repo: coordinate, mode: "thread", filters }, [...repo.relays, ...outboxes]);103+ }104+ }105+ return { jobs: [...result.values()], partial };106+}107+108+const wire = (f: Filter, range: Partial<Window>) => ({ ...(f.ids ? { ids: f.ids } : {}), ...(f.kinds ? { kinds: f.kinds } : {}), ...(f.authors ? { authors: f.authors } : {}), ...Object.fromEntries(Object.entries(f.tags).map(([k, v]) => ["#" + k, v])), ...range, limit: GRASP_SYNC_PAGE });109+110+async function round(relay: Relay, job: Job, row: Row, connect: GraspSyncConnect) {111+ const windows: Window[] = JSON.parse(row.windows);112+ let warning = "", liveSince = row.live_since;113+ const socket = await connect(relay, job.source);114+ const deadline = Date.now() + ROUND_MS;115+ let messages = 0, bytes = 0;116+ const send = (...m: unknown[]) => { relay.meterBytes(0, UTF8.encode(JSON.stringify(m)).length); socket.send(...m); };117+ const recv = async (end: number) => {118+ if (++messages > 1024 || Date.now() >= deadline) throw new Error("round-limit");119+ const m = await socket.recv(Math.max(1, Math.min(end, deadline) - Date.now()));120+ const n = UTF8.encode(JSON.stringify(m)).length;121+ relay.meterBytes(n, 0); bytes += n;122+ if (bytes > 4 * 1024 * 1024 || n > 512 * 1024) throw new Error("byte-limit");123+ return m;124+ };125+ const accept = async (raw: unknown, range: Partial<Window>) => {126+ if (validate(raw)) { warning = "invalid-event"; return; }127+ const e = raw as Event;128+ if (!job.scope.filters.some(f => match({ ...f, ...range }, e))) { warning = "out-of-scope-event"; return; }129+ if (isPrivate(e.kind) || isEphemeral(e.kind) || e.tags.some(t => t[0] === "-")) { warning = "private-event-skipped"; return; }130+ if (!enabled(relay)) throw new Error("sync-disabled");131+ const result = await relay.acceptAny(e, { host: `${relay.slug}.${relay.domain}`, ip: "grasp-sync", challenge: "", authed: [], subs: {} });132+ if (result.stored) relay.broadcast(e, false);133+ if (!result.ok) warning = "admission-gap";134+ };135+ try {136+ if (windows.length) {137+ const range = windows[windows.length - 1], sub = "grasp-history";138+ let received = 0;139+ send("REQ", sub, ...job.scope.filters.map(f => wire(f, range)));140+ while (true) {141+ const m = await recv(deadline);142+ if (m[1] !== sub) continue;143+ if (m[0] === "EOSE") break;144+ if (m[0] === "CLOSED") throw new Error("history-refused");145+ if (m[0] !== "EVENT") continue;146+ if (++received > GRASP_SYNC_PAGE * job.scope.filters.length) throw new Error("page-limit");147+ await accept(m[2], range);148+ }149+ send("CLOSE", sub);150+ if (received >= GRASP_SYNC_PAGE) {151+ if (range.since >= range.until) warning = "partial-single-second";152+ else if (windows.length >= MAX_WINDOWS) warning = "partial-window-limit";153+ else {154+ windows.pop();155+ const mid = range.since + Math.floor((range.until - range.since) / 2);156+ windows.push({ since: range.since, until: mid }, { since: mid + 1, until: range.until });157+ }158+ } else if (!warning) windows.pop(); // Invalid/refused coverage remains repairable.159+ }160+ const historyWarning = warning;161+ warning = "";162+ const sub = "grasp-live", began = now(), range = { since: row.live_since };163+ const end = Math.min(deadline, Date.now() + LIVE_MS);164+ let eose = false, received = 0;165+ send("REQ", sub, ...job.scope.filters.map(f => wire(f, range)));166+ while (Date.now() < end) {167+ let m: unknown[];168+ try { m = await recv(end); } catch (error) { if (!eose || messages > 1024 || bytes > 4 * 1024 * 1024 || (error instanceof Error && error.message === "byte-limit")) throw error; break; }169+ if (m[1] !== sub) continue;170+ if (m[0] === "CLOSED") throw new Error("live-refused");171+ if (m[0] === "EOSE") { eose = true; continue; }172+ if (m[0] !== "EVENT") continue;173+ if (++received > GRASP_SYNC_PAGE) { warning = "partial-live-limit"; break; }174+ await accept(m[2], range);175+ }176+ send("CLOSE", sub);177+ if (!eose) throw new Error("live-missing-eose");178+ if (received >= GRASP_SYNC_PAGE || warning) {179+ if (windows.length < MAX_WINDOWS) windows.push({ since: row.live_since, until: began });180+ else warning = "partial-window-limit";181+ // Keep the inclusive live cursor when coverage was uncertain.182+ } else liveSince = began;183+ return { windows, liveSince, warning: warning || historyWarning };184+ } finally { socket.close(); }185+}186+187+// One fair, bounded source round per wake; failed jobs reserve their retry188+// before I/O. Source/filter changes remove stale jobs, including outboxes.189+export async function graspSyncTick(relay: Relay, connect: GraspSyncConnect = productionConnect): Promise<number> {190+ if (!enabled(relay)) return 0;191+ if (!relay.repositoryAccess.owned) throw new Error("event synchronization requires repository authority");192+ const current = jobs(relay), ids = new Set(current.jobs.map(j => j.id));193+ relay.sql.exec(`INSERT INTO grasp_sync_status(id,partial) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET partial=excluded.partial WHERE partial!=excluded.partial`, current.partial ? 1 : 0);194+ const existing = new Set(relay.sql.exec<{ id: string }>(`SELECT id FROM grasp_event_sync`).toArray().map(row => row.id));195+ for (const id of existing) if (!ids.has(id)) relay.sql.exec(`DELETE FROM grasp_event_sync WHERE id=?`, id);196+ for (const j of current.jobs) if (!existing.has(j.id)) relay.sql.exec(`INSERT OR IGNORE INTO grasp_event_sync(id,source,scope,windows,live_since) VALUES(?,?,?,?,?)`, j.id, j.source, JSON.stringify(j.scope), JSON.stringify([{ since: 0, until: now() }]), now());197+ const row = relay.sql.exec<Row>(`SELECT * FROM grasp_event_sync ORDER BY due,id LIMIT 1`).toArray()[0];198+ if (!row) return 0;199+ if (row.due > now()) return row.due;200+ relay.sql.exec(`UPDATE grasp_event_sync SET due=?,failures=failures+1 WHERE id=?`, now() + Math.min(300, 30 * 2 ** Math.min(row.failures, 4)), row.id);201+ try {202+ const result = await round(relay, current.jobs.find(j => j.id === row.id)!, row, connect);203+ relay.sql.exec(`UPDATE grasp_event_sync SET windows=?,live_since=?,due=?,failures=0,error=? WHERE id=?`, JSON.stringify(result.windows), result.liveSince, now() + (result.warning ? 300 : result.windows.length ? 1 : 60), result.warning, row.id);204+ } catch (error) {205+ relay.sql.exec(`UPDATE grasp_event_sync SET error=? WHERE id=?`, (error instanceof Error ? error.message : "sync-failed").slice(0, 120), row.id);206+ }207+ const next = relay.sql.exec<{ due: number | null }>(`SELECT min(due) AS due FROM grasp_event_sync`).one().due;208+ return next === null ? 0 : Math.max(now() + 1, next);209+}@@ -1,14 +1,16 @@
11 // GRASP-01 Smart HTTP on a relay origin. Nostr events authorize changes;22 // ntig owns the conditional R2 root, pack verification and Git protocol.3-import { createGitHandler, createAcceptedStateRepository, WalRepository, NativeGitEngine, R2ObjectStore, parseRepositoryPath, repositoryAddress, repositoryStoragePrefix, readObjects, LimitError, type ObjectStore, type CommitRequest, type GitRepository } from "ntig";3+import { createGitHandler, createAcceptedStateRepository, createPrRepository, WalRepository, NativeGitEngine, R2ObjectStore, parseRepositoryPath, repositoryAddress, repositoryStoragePrefix, readObjects, LimitError, type ObjectStore, type CommitRequest, type GitRepository } from "ntig";44 import { npubEncode, naddrEncode } from "nostr-tools/nip19";55 import { now, tag, type Event } from "./event.ts";66 import { KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR, KIND_GIT_PR_UPDATE } from "./kinds.ts";77 import { featureOn } from "./settings.ts";88 import { page, escapeHTML as esc } from "./ui.ts";9-import { repositoryCoordinate, type RepositoryAnnouncement } from "./grasp-policy.ts";9+import { relatedRepositoryCoordinates, repositoryCoordinate, type RepositoryAnnouncement } from "./grasp-policy.ts";1010 import { hostedAnnouncements, repository, repositoryState, graspBytes, requiredObjects, eventRepository, PR_REF_SECONDS } from "./grasp-state.ts";1111 import type { Relay } from "./relay.ts";12+import { syncGitTick } from "./grasp-git-sync.ts";13+import { graspSyncTick } from "./grasp-sync.ts";1214 1315 const CORS = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST", "access-control-allow-headers": "Content-Type, Authorization, Git-Protocol, X-Git-Request-Id" };1416 export function graspCORS(response: Response, preflight = false): Response {@@ -21,12 +23,12 @@ const answer = (reason: string, status: number) => new Response(reason, { status
2123 // isGitPath reserves malformed Git paths too, so every failure has CORS.2224 export const isGitPath = (url: URL) => /^\/(?:npub|prs\/)/.test(url.pathname) || /\.git(?:\/|$)/.test(url.pathname);2325 24-export const gitStoragePrefix = async (relay: Relay, repo: RepositoryAnnouncement) => `${relay.slug}/git/${await repositoryStoragePrefix(repositoryAddress(npubEncode(repo.owner), repo.identifier))}`;26+export const gitStoragePrefix = async (relay: Relay, repo: RepositoryAnnouncement, alternativePRs = false) => `${relay.slug}/git/${await repositoryStoragePrefix(repositoryAddress(npubEncode(repo.owner), repo.identifier), alternativePRs)}`;2527 2628 // gitRepository records each R2 key's retained size, including orphan writes.2729 // Reservations precede PUT: an ambiguous response never makes bytes free.28-export async function gitRepository(relay: Relay, repo: RepositoryAnnouncement): Promise<WalRepository> {29- const prefix = await gitStoragePrefix(relay, repo);30+export async function gitRepository(relay: Relay, repo: RepositoryAnnouncement, alternativePRs = false): Promise<WalRepository> {31+ const prefix = await gitStoragePrefix(relay, repo, alternativePRs);3032 const r2 = new R2ObjectStore(relay.media, { prefix, maxObjectBytes: 4 * 1024 * 1024 });3133 const store: ObjectStore = {3234 get: async (key) => r2.get(key),@@ -69,12 +71,75 @@ function prTip(relay: Relay, repo: RepositoryAnnouncement, id: string): string |
6971 return /^[0-9a-f]{40}$/.test(tag(e, "c")) ? tag(e, "c") : false;7072 }7173 74+export const prCoordinate = (repo: Pick<RepositoryAnnouncement, "owner" | "identifier">) => `pr:${repo.owner}:${repo.identifier}`;75+export const prRepositoryIdentity = (owner: string, identifier: string): RepositoryAnnouncement => ({ id: `pr:${owner}:${identifier}`, owner, identifier, clone: [], relays: [], maintainers: [], private: false });76+77+// alternativePrAddress matches an accepted event's own signer path on this relay.78+export function alternativePrAddress(relay: Relay, e: Event, host = ""): RepositoryAnnouncement | null {79+ const hosts = new Set([`${relay.slug}.${relay.domain}`, ...(host ? [host] : []), ...relay.settings.policy.customHosts.filter(h => !h.site && h.status === "active").map(h => h.host)]);80+ for (const raw of e.tags.filter(t => t[0] === "clone").flatMap(t => t.slice(1))) {81+ try {82+ const url = new URL(raw);83+ if (!hosts.has(url.host) || url.origin !== relay.webURL(url.host) || url.username || url.password || url.search || url.hash) continue;84+ const path = parseRepositoryPath(url.pathname);85+ if (!path || !path.alternativePRs || path.endpoint !== "root" || path.address.pubkey !== e.pubkey) continue;86+ if (!relatedRepositoryCoordinates(e).some(c => c.split(":").slice(2).join(":") === path.address.identifier)) continue;87+ return prRepositoryIdentity(e.pubkey, path.address.identifier);88+ } catch { /* Another clone tag may name this service. */ }89+ }90+ return null;91+}92+93+// alternativePrTip retains unknown upload deadlines independently of normal repos.94+function alternativePrTip(relay: Relay, repo: RepositoryAnnouncement, id: string, host = ""): string | null | false {95+ const row = relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE id=?`, id).toArray()[0];96+ if (!row) {97+ const timed = relay.sql.exec<{ until: number }>(`SELECT until FROM grasp_pr_refs WHERE repo=? AND ref=?`, prCoordinate(repo), `refs/nostr/${id}`).toArray()[0];98+ return timed && timed.until <= now() ? false : null;99+ }100+ const e = JSON.parse(row.raw) as Event;101+ if (![KIND_GIT_PR, KIND_GIT_PR_UPDATE].includes(e.kind) || e.pubkey !== repo.owner || relay.settings.isEventHidden(id) || relay.settings.isEventBanned(id) || relay.settings.isBanned(e.pubkey)) return false;102+ const expires = Number(tag(e, "expiration"));103+ const pending = relay.sql.exec<{ until: number }>(`SELECT until FROM grasp_pending WHERE id=?`, id).toArray()[0];104+ if ((expires && expires <= now()) || (pending && pending.until <= now())) return false;105+ if (alternativePrAddress(relay, e, host)?.id !== repo.id) return false;106+ return /^(?!0{40}$)[0-9a-f]{40}$/.test(tag(e, "c")) ? tag(e, "c") : false;107+}108+109+// alternativePrRepository keeps transport policy in ntig and visibility in the relay.110+function alternativePrRepository(relay: Relay, repo: RepositoryAnnouncement, wal: WalRepository, host: string): GitRepository {111+ const tracked: GitRepository = {112+ load: () => wal.load(), loadRefs: () => wal.loadRefs(), lookupRecord: id => wal.lookupRecord(id),113+ commit: async request => {114+ const coordinate = prCoordinate(repo);115+ const existing = relay.sql.exec<{ ref: string }>(`SELECT ref FROM grasp_pr_refs WHERE repo=?`, coordinate).toArray();116+ const namespaces = relay.sql.exec<{ n: number }>(`SELECT count(DISTINCT repo) AS n FROM grasp_pr_refs WHERE repo LIKE 'pr:%'`).one().n;117+ if (!existing.length && namespaces >= 16) throw new LimitError("Alternative PR repository quota reached");118+ if (new Set([...existing.map(row => row.ref), ...request.updates.map(update => update.name)]).size > 1024) throw new LimitError("Alternative PR ref quota reached");119+ // Reserve deadlines before publication, including an ambiguous successful120+ // root write. Failed uploads expire through the same bounded bookkeeping.121+ for (const update of request.updates) relay.sql.exec(`INSERT INTO grasp_pr_refs(repo,ref,until) VALUES(?,?,?) ON CONFLICT(repo,ref) DO NOTHING`, coordinate, update.name, typeof alternativePrTip(relay, repo, update.name.slice(11), host) === "string" ? 0 : now() + PR_REF_SECONDS);122+ return wal.commit(request);123+ },124+ };125+ const authorized = createPrRepository(tracked, { lookupTip: async id => alternativePrTip(relay, repo, id, host), allowUnknownPrRefs: true });126+ return {127+ load: () => authorized.load(),128+ loadRefs: () => authorized.loadRefs!(),129+ commit: async request => {130+ const receipt = await authorized.commit(request);131+ await promote(relay, repo, wal, true);132+ return receipt;133+ },134+ };135+}136+72137 // promote checks pending work before loading Git packs, and rechecks authority73138 // after the read. Replays still finish promotion left pending by an earlier write.74-async function promote(relay: Relay, repo: RepositoryAnnouncement, wal: WalRepository) {139+export async function promote(relay: Relay, repo: RepositoryAnnouncement, wal: WalRepository, alternative = false) {75140 const requiredFor = (e: Event) => {76141 if (relay.settings.isEventHidden(e.id) || relay.settings.isEventBanned(e.id) || relay.settings.isBanned(e.pubkey)) return null;77- const relevant = e.kind === KIND_REPO ? repository(relay, repo.owner, repo.identifier)?.id === e.id : e.kind === KIND_REPO_STATE ? tag(e, "d") === repo.identifier && repositoryState(relay, repo)?.id === e.id : eventRepository(relay, e)?.id === repo.id;142+ const relevant = alternative ? alternativePrTip(relay, repo, e.id) === tag(e, "c") : e.kind === KIND_REPO ? repository(relay, repo.owner, repo.identifier)?.id === e.id : e.kind === KIND_REPO_STATE ? tag(e, "d") === repo.identifier && repositoryState(relay, repo)?.id === e.id : eventRepository(relay, e)?.id === repo.id;78143 return relevant ? requiredObjects(relay, e) : null;79144 };80145 const pending = () => relay.sql.exec<{ raw: string }>(`SELECT raw FROM events JOIN grasp_pending ON grasp_pending.id=events.id WHERE grasp_pending.until>? AND (events.expires=0 OR events.expires>?) LIMIT 1024`, now(), now()).toArray();@@ -86,6 +151,7 @@ async function promote(relay: Relay, repo: RepositoryAnnouncement, wal: WalRepos
86151 const e = JSON.parse(raw) as Event;87152 const required = requiredFor(e);88153 if (required && required.every((oid) => objects.has(oid))) {154+ if (e.kind === KIND_GIT_PR || e.kind === KIND_GIT_PR_UPDATE) relay.sql.exec(`UPDATE grasp_pr_refs SET until=0 WHERE repo=? AND ref=?`, alternative ? prCoordinate(repo) : repositoryCoordinate(repo), `refs/nostr/${e.id}`);89155 relay.sql.exec(`DELETE FROM grasp_pending WHERE id=?`, e.id);90156 relay.broadcast(e);91157 }@@ -93,7 +159,7 @@ async function promote(relay: Relay, repo: RepositoryAnnouncement, wal: WalRepos
93159 }94160 95161 // authorizedRepository adds event authority to the WAL without duplicating refs.96-function authorizedRepository(relay: Relay, repo: RepositoryAnnouncement, wal: WalRepository): GitRepository {162+export function authorizedRepository(relay: Relay, repo: RepositoryAnnouncement, wal: WalRepository): GitRepository {97163 const authorized = createAcceptedStateRepository(wal, {98164 lookupState: async () => { const state = repositoryState(relay, repo); return state ? { eventId: state.id, refs: state.refs, head: state.head } : null; },99165 lookupPrTip: async (id) => prTip(relay, repo, id),@@ -107,7 +173,7 @@ function authorizedRepository(relay: Relay, repo: RepositoryAnnouncement, wal: W
107173 for (const update of request.updates) {108174 if (!/^refs\/nostr\/[0-9a-f]{64}$/.test(update.name)) continue;109175 if (update.new === null) relay.sql.exec(`DELETE FROM grasp_pr_refs WHERE repo=? AND ref=?`, repositoryCoordinate(repo), update.name);110- else relay.sql.exec(`INSERT INTO grasp_pr_refs(repo,ref,until) VALUES(?,?,?) ON CONFLICT(repo,ref) DO NOTHING`, repositoryCoordinate(repo), update.name, now() + PR_REF_SECONDS);176+ else relay.sql.exec(`INSERT INTO grasp_pr_refs(repo,ref,until) VALUES(?,?,?) ON CONFLICT(repo,ref) DO NOTHING`, repositoryCoordinate(repo), update.name, typeof prTip(relay, repo, update.name.slice(11)) === "string" ? 0 : now() + PR_REF_SECONDS);111177 }112178 await promote(relay, repo, wal);113179 return receipt;@@ -123,8 +189,8 @@ export async function grasp(relay: Relay, req: Request, url: URL): Promise<Respo
123189 if (relay.settings.policy.reads !== "open") return answer("restricted: public Git hosting requires open reads", 403);124190 if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return answer("restricted: relay is not active", 403);125191 const parsed = parseRepositoryPath(url.pathname);126- if (!parsed || parsed.alternativePRs) return answer("not found", 404);127- const repo = repository(relay, parsed.address.pubkey, parsed.address.identifier);192+ if (!parsed || (parsed.alternativePRs && !featureOn(relay.settings.policy, "grasp06"))) return answer("not found", 404);193+ const repo = parsed.alternativePRs ? prRepositoryIdentity(parsed.address.pubkey, parsed.address.identifier) : repository(relay, parsed.address.pubkey, parsed.address.identifier);128194 if (!repo) return answer("not found", 404);129195 const limited = relay.ipLimit(req.headers.get("x-relay-ip") || "unknown", req.method === "POST" ? "events" : "reqs");130196 if (limited) return answer(limited, 429);@@ -134,12 +200,14 @@ export async function grasp(relay: Relay, req: Request, url: URL): Promise<Respo
134200 if (parsed.endpoint === "root") {135201 if (req.method !== "GET" && req.method !== "HEAD") return answer("invalid: method not allowed", 405);136202 const coordinate = naddrEncode({ kind: KIND_REPO, pubkey: repo.owner, identifier: repo.identifier });137- const html = page(repo.identifier, `<h1>${esc(repo.identifier)}</h1><p>Git storage follows signed Nostr repository state.</p><p><a href="nostr:${coordinate}">Open in a Nostr Git client</a></p><pre>git clone ${esc(relay.webURL(url.host) + parsed.prefix)}</pre>`);203+ const description = parsed.alternativePRs ? "Alternative pull request storage follows the signer's accepted PR events. Only refs/nostr/<event-id> are served." : "Git storage follows signed Nostr repository state.";204+ const link = parsed.alternativePRs ? "" : `<p><a href="nostr:${coordinate}">Open in a Nostr Git client</a></p>`;205+ const html = page(repo.identifier, `<h1>${esc(repo.identifier)}</h1><p>${description}</p>${link}<pre>git clone ${esc(relay.webURL(url.host) + parsed.prefix)}</pre>`);138206 return new Response(req.method === "HEAD" ? null : html, { headers: { ...CORS, "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });139207 }140- const wal = await gitRepository(relay, repo);208+ const wal = await gitRepository(relay, repo, parsed.alternativePRs);141209 const response = await wal.withReadSession(async (scopedWal) => {142- const handler = createGitHandler(authorizedRepository(relay, repo, scopedWal), { prefix: parsed.prefix, authorizePush: () => true, observe: (event) => relay.meterBytes(event.requestBytes, event.responseBytes) });210+ const handler = createGitHandler(parsed.alternativePRs ? alternativePrRepository(relay, repo, scopedWal, url.host) : authorizedRepository(relay, repo, scopedWal), { prefix: parsed.prefix, authorizePush: () => true, observe: (event) => relay.meterBytes(event.requestBytes, event.responseBytes) });143211 return await handler(req);144212 });145213 const headers = new Headers(response.headers);@@ -163,22 +231,53 @@ export async function graspTick(relay: Relay): Promise<number> {
163231 const repo = ordered.find((r) => repositoryCoordinate(r) > last) ?? ordered[0];164232 if (repo) relay.sql.exec(`INSERT OR REPLACE INTO grasp_tick(id,coordinate) VALUES(1,?)`, repositoryCoordinate(repo));165233 for (const { repo: coordinate } of relay.sql.exec<{ repo: string }>(`SELECT DISTINCT repo FROM grasp_pr_refs`).toArray()) {166- if (!all.some((r) => repositoryCoordinate(r) === coordinate)) relay.sql.exec(`DELETE FROM grasp_pr_refs WHERE repo=?`, coordinate);234+ if (!coordinate.startsWith("pr:") && !all.some((r) => repositoryCoordinate(r) === coordinate)) relay.sql.exec(`DELETE FROM grasp_pr_refs WHERE repo=?`, coordinate);167235 }168236 if (repo && featureOn(relay.settings.policy, "grasp") && !relay.fuelStatus().outOfFuel) {169237 try {170238 const wal = await gitRepository(relay, repo);171239 const snapshot = await wal.load();172- const refs = relay.sql.exec<{ ref: string }>(`SELECT ref FROM grasp_pr_refs WHERE repo=? AND until<=?`, repositoryCoordinate(repo), now()).toArray();240+ const refs = relay.sql.exec<{ ref: string }>(`SELECT ref FROM grasp_pr_refs WHERE repo=? AND until>0 AND until<=?`, repositoryCoordinate(repo), now()).toArray();173241 for (const { ref } of refs) {174242 const current = snapshot.refs[ref];175243 const tip = prTip(relay, repo, ref.slice("refs/nostr/".length));176244 if (current && tip !== current) await wal.commit({ id: `expire:${ref.slice(-64)}:${current}`, updates: [{ name: ref, old: current, new: null }] });177- relay.sql.exec(`DELETE FROM grasp_pr_refs WHERE repo=? AND ref=?`, repositoryCoordinate(repo), ref);245+ if (tip === current && current) relay.sql.exec(`UPDATE grasp_pr_refs SET until=0 WHERE repo=? AND ref=?`, repositoryCoordinate(repo), ref);246+ else relay.sql.exec(`DELETE FROM grasp_pr_refs WHERE repo=? AND ref=?`, repositoryCoordinate(repo), ref);178247 }179248 await promote(relay, repo, wal);180249 } catch { /* The next bounded alarm retries; retained bytes stay metered. */ }181250 }182- const earliest = relay.sql.exec<{ n: number | null }>(`SELECT min(at) AS n FROM (SELECT until AS at FROM grasp_pending UNION ALL SELECT until AS at FROM grasp_pr_refs)`).one().n;183- return earliest === null ? 0 : now() + 60;251+ if (featureOn(relay.settings.policy, "grasp06") && !relay.fuelStatus().outOfFuel) {252+ const expired = relay.sql.exec<{ repo: string; ref: string }>(`SELECT repo,ref FROM grasp_pr_refs WHERE repo LIKE 'pr:%' AND until>0 AND until<=? ORDER BY until,repo,ref LIMIT 1`, now()).toArray()[0];253+ if (expired) {254+ const match = /^pr:([0-9a-f]{64}):(.+)$/u.exec(expired.repo);255+ if (match) {256+ const pr = prRepositoryIdentity(match[1], match[2]);257+ try {258+ const wal = await gitRepository(relay, pr, true);259+ const current = (await wal.loadRefs()).refs[expired.ref];260+ const tip = alternativePrTip(relay, pr, expired.ref.slice(11));261+ if (current && tip !== current) await wal.commit({ id: `expire:${expired.ref.slice(-64)}:${current}`, updates: [{ name: expired.ref, old: current, new: null }] });262+ if (current && tip === current) relay.sql.exec(`UPDATE grasp_pr_refs SET until=0 WHERE repo=? AND ref=?`, expired.repo, expired.ref);263+ else relay.sql.exec(`DELETE FROM grasp_pr_refs WHERE repo=? AND ref=?`, expired.repo, expired.ref);264+ await promote(relay, pr, wal, true);265+ } catch { /* Expired uploads stay hidden when the WAL cannot advance. */ }266+ }267+ }268+ // Event-first and Git-first pairs are promoted even without a later push.269+ const pending = relay.sql.exec<{ raw: string }>(`SELECT raw FROM events JOIN grasp_pending ON grasp_pending.id=events.id WHERE kind IN (?,?) AND grasp_pending.until>? ORDER BY grasp_pending.until LIMIT 16`, KIND_GIT_PR, KIND_GIT_PR_UPDATE, now()).toArray();270+ for (const row of pending) {271+ const e = JSON.parse(row.raw) as Event;272+ const parsed = alternativePrAddress(relay, e);273+ if (!parsed) continue;274+ try { await promote(relay, parsed, await gitRepository(relay, parsed, true), true); } catch { /* A later alarm retries. */ }275+ break;276+ }277+ }278+ const syncAt = await syncGitTick(relay);279+ const eventsAt = await graspSyncTick(relay);280+ const earliest = relay.sql.exec<{ n: number | null }>(`SELECT min(at) AS n FROM (SELECT until AS at FROM grasp_pending UNION ALL SELECT until AS at FROM grasp_pr_refs WHERE until>0)`).one().n;281+ const next = Math.min(earliest === null ? Infinity : now() + 60, syncAt || Infinity, eventsAt || Infinity);282+ return Number.isFinite(next) ? next : 0;184283 }@@ -10,7 +10,7 @@ import { now } from "./event.ts";
1010 import type { Relay } from "./relay.ts";1111 import { CODE_RE, inviteCreator, listClaims, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts";1212 import { descriptor, type Blob } from "./blossom.ts";13-import { badBlockedWord, blockedWords, policyPatch, type Policy, type Settings } from "./settings.ts";13+import { featureOn, badBlockedWord, blockedWords, policyPatch, type Policy, type Settings } from "./settings.ts";1414 import { deliveryStatus } from "./delivery.ts";1515 import { applyConfig, exportConfig, parseConfig, planConfig } from "./config.ts";1616 import { isReplaceable, isProtected, validIP } from "./settings.ts";@@ -500,7 +500,10 @@ export const METHODS: Record<string, Method> = {
500500 run: ({ relay, s, reply }) => {501501 const kinds = relay.store.kindStats().map((k) => ({ ...k, days: s.retentionDays(k.kind), replaceable: isReplaceable(k.kind), protected: isProtected(k.kind) }));502502 const blobs = relay.sql.exec<{ n: number | null; bytes: number | null }>(`SELECT count(*) AS n, sum(size) AS bytes FROM blobs`).one();503- return reply({ result: { kinds, events: kinds.reduce((a, k) => a + k.n, 0), eventBytes: kinds.reduce((a, k) => a + k.bytes, 0), databaseBytes: relay.eventBytes(), blobs: blobs.n ?? 0, mediaBytes: blobs.bytes ?? 0, dumps: listDumps(relay.sql).length, dumpBytes: dumpBytes(relay.sql), retention: s.listRetention() } });503+ const eventSync = relay.sql.exec<{ jobs: number; failed: number; history: number; due: number | null }>(`SELECT count(*) AS jobs, coalesce(sum(error!=''),0) AS failed, coalesce(sum(windows!='[]'),0) AS history, min(due) AS due FROM grasp_event_sync`).one();504+ const gitSync = relay.sql.exec<{ jobs: number; failed: number; due: number | null }>(`SELECT count(*) AS jobs, coalesce(sum(error!=''),0) AS failed, min(due) AS due FROM grasp_git_sync`).one();505+ const partial = relay.sql.exec<{ partial: number }>(`SELECT partial FROM grasp_sync_status WHERE id=1`).toArray()[0]?.partial === 1;506+ return reply({ result: { graspSync: { enabled: featureOn(s.policy, "grasp02"), partial, events: eventSync, git: gitSync }, kinds, events: kinds.reduce((a, k) => a + k.n, 0), eventBytes: kinds.reduce((a, k) => a + k.bytes, 0), databaseBytes: relay.eventBytes(), blobs: blobs.n ?? 0, mediaBytes: blobs.bytes ?? 0, dumps: listDumps(relay.sql).length, dumpBytes: dumpBytes(relay.sql), retention: s.listRetention() } });504507 },505508 },506509 gitstorage: {@@ -834,6 +837,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
834837 if (resp.status === 200 && !m.reads && !dryRun && method !== "deleterelay") {835838 const { target, detail } = detailOf(method, params);836839 relay.audit.record(t, caller, method, target, detail);840+ if (["setpolicy", "importconfig", "applypreset"].includes(method) && (featureOn(s.policy, "grasp02") || featureOn(s.policy, "grasp06"))) await relay.ensureAlarm(now() + 1);837841 }838842 return resp;839843 }, () => new Response(JSON.stringify({ error: "restricted: relay operation in progress; retry" }), { status: 429, headers: { ...cors, "retry-after": "1" } }));@@ -16,7 +16,7 @@ import { nip11Views } from "./views.ts";
1616 // (publishDiscovery); 77 is negentropy sync (handleSync).1717 export const SUPPORTED_NIPS = [1, 5, 9, 11, 13, 17, 29, 40, 42, 45, 46, 50, 56, 59, 62, 66, 67, 70, 77, 86, 98];1818 // The numbers a switched-off feature takes with it (settings.ts, features).19-export const FEATURE_NIPS: Record<Feature, number[]> = { search: [50], sync: [77], count: [45], discovery: [66], names: [5], files: [], pages: [], signer: [46], sites: [], marmot: [], grasp: [], push: [] };19+export const FEATURE_NIPS: Record<Feature, number[]> = { search: [50], sync: [77], count: [45], discovery: [66], names: [5], files: [], pages: [], signer: [46], sites: [], marmot: [], grasp: [], grasp02: [], grasp03: [], grasp05: [], grasp06: [], push: [] };2020 2121 // Lettered identifiers retain their protocol spelling, never decimal values.2222 // New draft capabilities belong here only alongside their implementation.@@ -79,7 +79,8 @@ export function nip11(relay: Relay, host: string) {
7979 if (p.succession && relay.succession.warn) doc.succession_pending = new Date((relay.succession.warn.since + SUCCESSION_WARN_DAYS * 86400) * 1000).toISOString().slice(0, 10);8080 if (relay.settings.isLeased() && p.lease) doc.lease = { expires_at: p.lease.until, holder: p.lease.holder || undefined, claim_url: host ? "https://" + host + "/" : undefined };8181 if (featureOn(p, "grasp") && p.reads === "open") {82- doc.supported_grasps = ["GRASP-01"];82+ // The bounded sync previews remain unadvertised pending interoperability audit.83+ doc.supported_grasps = ["GRASP-01", ...(featureOn(p, "grasp06") ? ["GRASP-06"] : [])];8384 const access = {8485 open: "Anyone may announce a repository.",8586 allowlist: "Allowlist hosting: only the relay owner and members may announce repositories.",@@ -92,7 +93,8 @@ export function nip11(relay: Relay, host: string) {
9293 : p.writes !== "open" && p.openKinds.includes(KIND_REPO) ? "Anyone may announce a repository through the guest exception for kind 30617."9394 : access[p.writes];9495 const guests = p.writes !== "open" && p.openKinds.length ? ` Guest write exceptions apply to kinds ${p.openKinds.join(", ")}.` : "";95- doc.repo_acceptance_criteria = `${eligibility} Repository announcements name this service in clone and relays. State and collaboration events follow the relay write rule (${p.writes}) and kind rules; bans, proof of work and fuel apply.${guests} At most 16 repositories, 4 MiB per pack, 16 MiB packed history and 128 Git transactions per unmigrated format-1 repository. Explicitly migrated format-2 repositories retain at most 128 unique packs.`;96+ const listing = featureOn(p, "grasp05") ? "Archive preview accepts repository announcements without naming this service in clone and relays." : "Repository announcements name this service in clone and relays.";97+ doc.repo_acceptance_criteria = `${eligibility} ${listing} State and collaboration events follow the relay write rule (${p.writes}) and kind rules; bans, proof of work and fuel apply.${guests} At most 16 repositories, 4 MiB per pack, 16 MiB packed history and 128 Git transactions per unmigrated format-1 repository. Explicitly migrated format-2 repositories retain at most 128 unique packs.`;9698 if (p.writes !== "open" || p.blockedWords.length) doc.curation = "The relay's configured write rule and blocked topics apply to Nostr repository and collaboration events.";9799 }98100 if (featureOn(p, "sites")) doc.nsites = {@@ -93,7 +93,7 @@ export type ViewSetting = (typeof VIEW_SETTINGS)[number];
9393 // is not one: the console runs on it. What is off leaves supported_nips9494 // (nip11.ts), answers 404 at its door (routes.ts) and is refused at the9595 // socket (gates.ts, relay.ts).96-export const FEATURE_NAMES = ["search", "sync", "count", "discovery", "names", "files", "pages", "signer", "sites", "marmot", "grasp", "push"] as const;96+export const FEATURE_NAMES = ["search", "sync", "count", "discovery", "names", "files", "pages", "signer", "sites", "marmot", "grasp", "grasp02", "grasp03", "grasp05", "grasp06", "push"] as const;9797 export type Feature = (typeof FEATURE_NAMES)[number];9898 export type SearchMode = "full" | "prose" | "off";9999 export interface Features {@@ -106,12 +106,24 @@ export interface Features {
106106 pages: boolean; // notes and articles as pages, the feed107107 sites: { enabled: boolean; mirror: boolean }; // NIP-5A hosting and automatic blob copies108108 grasp: boolean; // GRASP Git repositories and accepted repository state109+ grasp02: boolean; // GRASP-02 proactive repository synchronization110+ grasp03: boolean; // GRASP-03 outbox discussion synchronization111+ grasp05: boolean; // GRASP-05 archive admission112+ grasp06: boolean; // GRASP-06 alternative PR hosting109113 signer: boolean; // NIP-46 traffic carried for anyone110114 push: boolean; // NIP-9a relay-to-callback delivery111115 marmot: boolean; // Marmot's opaque MLS transport events112116 }113-export const DEFAULT_FEATURES: Features = { search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true }, marmot: false, grasp: false, push: false };114-export const featureOn = (p: Policy, f: Feature): boolean => f === "sites" ? p.features.sites.enabled : p.features[f] !== false && p.features[f] !== "off";117+export const DEFAULT_FEATURES: Features = { search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true }, marmot: false, grasp: false, grasp02: false, grasp03: false, grasp05: false, grasp06: false, push: false };118+export const featureOn = (p: Policy, f: Feature): boolean => {119+ if (f === "sites") return p.features.sites.enabled;120+ if (["grasp02", "grasp03", "grasp05", "grasp06"].includes(f) && p.features[f] !== true) return false;121+ if (p.features[f] === false || p.features[f] === "off") return false;122+ if (f === "grasp02") return featureOn(p, "grasp");123+ if (f === "grasp03" || f === "grasp05") return featureOn(p, "grasp02");124+ if (f === "grasp06") return featureOn(p, "grasp");125+ return true;126+};115127 116128 export function featureFields(patch: Record<string, unknown>, cur: Features): Partial<Policy> {117129 if (!patch.features || typeof patch.features !== "object") return {};