1// GRASP's accepted Nostr authority and purgatory. Git objects and refs live2// in the SQLite backend; these tables record visibility and cleanup deadlines.3import { MAX_GIT_LIMITS } from "./git-limits.ts";4import { now, tag, tagValues, type Event } from "./event.ts";5import { KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR, KIND_GIT_PR_UPDATE } from "./kinds.ts";6import { featureOn } from "./settings.ts";7import { parseRepositoryAnnouncement, parseRepositoryState, recursiveMaintainers, latestState, serviceListed, gitRepositoryPath, relatedRepositoryCoordinates, repositoryCoordinate, type RepositoryAnnouncement } from "./grasp-policy.ts";8import { npubEncode } from "nostr-tools/nip19";9import type { Relay } from "./relay.ts";101112export const PURGATORY_SECONDS = 1800;13export const PR_REF_SECONDS = 1200;14export const GRASP_SCHEMA = `15CREATE TABLE IF NOT EXISTS grasp_hosted (id TEXT PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE);16CREATE TABLE IF NOT EXISTS grasp_pending (id TEXT PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE, until INTEGER NOT NULL);17CREATE TRIGGER IF NOT EXISTS grasp_removed AFTER DELETE ON events BEGIN DELETE FROM grasp_pending WHERE id=old.id; DELETE FROM grasp_hosted WHERE id=old.id; END;18CREATE TABLE IF NOT EXISTS grasp_tick (id INTEGER PRIMARY KEY CHECK(id=1), coordinate TEXT NOT NULL);19CREATE TABLE IF NOT EXISTS grasp_pr_refs (repo TEXT NOT NULL, ref TEXT NOT NULL, until INTEGER NOT NULL, PRIMARY KEY(repo,ref));20CREATE 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 '');21CREATE 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 '');22CREATE INDEX IF NOT EXISTS grasp_event_sync_due ON grasp_event_sync(due,id);23CREATE TABLE IF NOT EXISTS grasp_sync_status (id INTEGER PRIMARY KEY CHECK(id=1), partial INTEGER NOT NULL DEFAULT 0);24`;2526// graspEvents includes purgatory for write authority, but never expired or27// moderated records. A pending signed state authorizes the data it awaits.28export function graspEvents(relay: Relay, kind: number): Event[] {29 return relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=? AND (expires=0 OR expires>?) AND NOT EXISTS (SELECT 1 FROM grasp_pending WHERE grasp_pending.id=events.id AND until<=?) ORDER BY created_at DESC,id ASC LIMIT ?`, kind, now(), now(), MAX_GIT_LIMITS.maxRepositories * 4 + 1).toArray().map((r) => JSON.parse(r.raw) as Event).filter((e) => !relay.settings.isEventHidden(e.id) && !relay.settings.isEventBanned(e.id) && !relay.settings.isBanned(e.pubkey));30}31export const announcements = (relay: Relay): RepositoryAnnouncement[] => graspEvents(relay, KIND_REPO).flatMap((e) => { const p = parseRepositoryAnnouncement(e); return p.value ? [p.value] : []; });32// Query admitted repositories directly: companion announcements or a lower33// intake quota must not evict an existing repository from the discovery window.34export const hostedAnnouncements = (relay: Relay): RepositoryAnnouncement[] => relay.sql.exec<{ raw: string }>(`SELECT events.raw FROM grasp_hosted JOIN events ON events.id=grasp_hosted.id WHERE events.kind=? AND (events.expires=0 OR events.expires>?) ORDER BY events.created_at DESC,events.id`, KIND_REPO, now()).toArray().flatMap(row => {35 const event = JSON.parse(row.raw) as Event;36 if (relay.settings.isEventHidden(event.id) || relay.settings.isEventBanned(event.id) || relay.settings.isBanned(event.pubkey)) return [];37 const parsed = parseRepositoryAnnouncement(event);38 return parsed.value ? [parsed.value] : [];39});40export const repository = (relay: Relay, owner: string, identifier: string) => hostedAnnouncements(relay).find((r) => r.owner === owner && r.identifier === identifier) ?? null;41export function repositoryState(relay: Relay, repo: RepositoryAnnouncement) {42 const states = graspEvents(relay, KIND_REPO_STATE).flatMap((e) => { const p = parseRepositoryState(e); return p.value ? [p.value] : []; });43 return latestState(states, repo.owner, repo.identifier, recursiveMaintainers(repo, announcements(relay)) ?? new Set());44}4546// eventRepository finds the repository named by a PR or by its parent PR.47export function eventRepository(relay: Relay, e: Event): RepositoryAnnouncement | null {48 const all = hostedAnnouncements(relay);49 const coordinates = relatedRepositoryCoordinates(e);50 for (const parent of [...tagValues(e, "e"), ...tagValues(e, "E")].slice(0, 16)) {51 const row = relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE id=? AND kind=? AND (expires=0 OR expires>?)`, parent, KIND_GIT_PR, now()).toArray()[0];52 if (row) {53 const parent = JSON.parse(row.raw) as Event;54 if (!relay.settings.isEventHidden(parent.id) && !relay.settings.isEventBanned(parent.id) && !relay.settings.isBanned(parent.pubkey)) coordinates.push(...relatedRepositoryCoordinates(parent));55 }56 }57 return all.find((r) => coordinates.includes(repositoryCoordinate(r))) ?? null;58}5960// graspGate validates authority before ordinary storage. Relay write policy,61// bans, proof of work and member quotas continue to apply to these events.62export function graspGate(relay: Relay, e: Event, host: string): string {63 if (!featureOn(relay.settings.policy, "grasp")) return "";64 if (e.kind === KIND_REPO) {65 if (relay.settings.policy.reads !== "open") return "restricted: GRASP-01 hosting requires open reads";66 const p = parseRepositoryAnnouncement(e);67 if (!p.value) return p.error!;68 const r = p.value;69 const origin = relay.webURL(host || `${relay.slug}.${relay.domain}`);70 const listed = serviceListed(r, origin + gitRepositoryPath(npubEncode(e.pubkey), r.identifier), origin.replace(/^http/, "ws"));71 const all = announcements(relay);72 const related = hostedAnnouncements(relay).some((root) => root.owner !== e.pubkey && root.identifier === r.identifier && recursiveMaintainers(root, all)?.has(e.pubkey));73 if (!listed && !related && !featureOn(relay.settings.policy, "grasp05")) return "restricted: repository clone and relays tags must name this service";74 if ((listed || featureOn(relay.settings.policy, "grasp05")) && !repository(relay, r.owner, r.identifier) && hostedAnnouncements(relay).length >= relay.settings.policy.git.maxRepositories) return `restricted: at most ${relay.settings.policy.git.maxRepositories} repositories per relay`;75 }76 if (e.kind === KIND_REPO_STATE) {77 const p = parseRepositoryState(e);78 if (!p.value) return p.error!;79 const all = announcements(relay);80 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";81 }82 if (e.kind === KIND_GIT_PR || e.kind === KIND_GIT_PR_UPDATE) {83 // GRASP-06 permits a contributor to publish a PR before the target84 // repository announcement reaches this relay. Keep the repository85 // coordinate mandatory; the PR namespace will authorize its matching86 // objects once the event is stored.87 const hosted = eventRepository(relay, e);88 if (!hosted && !featureOn(relay.settings.policy, "grasp06")) return "restricted: pull request must name an accepted repository";89 if (!hosted && !relatedRepositoryCoordinates(e).some((c) => {90 const p = c.split(":");91 return p.length >= 3 && p[0] === "30617" && /^[0-9a-f]{64}$/.test(p[1]) && !!p.slice(2).join(":");92 })) return "restricted: pull request must name a repository";93 if (!hosted) {94 const origin = relay.webURL(host || `${relay.slug}.${relay.domain}`);95 const paths = relatedRepositoryCoordinates(e).map(c => origin + "/prs" + gitRepositoryPath(npubEncode(e.pubkey), c.split(":").slice(2).join(":")));96 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; } });97 if (!listed) return "restricted: pull request clone must name this service's signer PR path";98 }99 const tips = tagValues(e, "c");100 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";101 }102 return "";103}104105// holdGrasp hides events whose Git objects have not been checked yet. The106// durable row survives restarts and follows NIP-09/40 deletion via its FK.107export function holdGrasp(relay: Relay, e: Event, host: string): boolean {108 if (!featureOn(relay.settings.policy, "grasp") || ![KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR, KIND_GIT_PR_UPDATE].includes(e.kind)) return false;109 if (e.kind === KIND_REPO) {110 const r = parseRepositoryAnnouncement(e).value!;111 const origin = relay.webURL(host || `${relay.slug}.${relay.domain}`);112 const listed = serviceListed(r, origin + gitRepositoryPath(npubEncode(e.pubkey), r.identifier), origin.replace(/^http/, "ws"));113 if (!listed && !featureOn(relay.settings.policy, "grasp05")) return false;114 relay.sql.exec(`INSERT OR REPLACE INTO grasp_hosted(id) VALUES(?)`, e.id);115 }116 relay.sql.exec(`INSERT OR REPLACE INTO grasp_pending(id,until) VALUES(?,?)`, e.id, now() + PURGATORY_SECONDS);117 return true;118}119export const graspVisible = (relay: Relay, id: string) => relay.sql.exec(`SELECT 1 FROM grasp_pending WHERE id=?`, id).toArray().length === 0;120121// requiredObjects returns the tips an event needs before it leaves purgatory.122export function requiredObjects(relay: Relay, e: Event): string[] | null {123 if (e.kind === KIND_REPO_STATE) return Object.values(parseRepositoryState(e).value?.refs ?? {});124 if (e.kind === KIND_GIT_PR || e.kind === KIND_GIT_PR_UPDATE) return [tag(e, "c")];125 const r = parseRepositoryAnnouncement(e).value;126 const state = r && repositoryState(relay, r);127 return state ? Object.values(state.refs) : null;128}