clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

main
bindws / src / grasp-policy.ts
grasp-policy.ts 10.2K
1// Pure GRASP-01 policy helpers. Runtime code supplies accepted events and2// applies the returned decisions to the relay and Git object store.34import { KIND_REPO, KIND_REPO_STATE } from "./kinds.ts";56export interface GraspEvent {7 id: string;8 pubkey: string;9 kind: number;10 created_at: number;11 tags: string[][];12}1314export interface RepositoryAnnouncement {15 id: string;16 owner: string;17 identifier: string;18 clone: string[];19 relays: string[];20 maintainers: string[];21 private: boolean;22}2324export interface RepositoryState {25 id: string;26 owner: string;27 created_at: number;28 identifier: string;29 refs: Record<string, string>;30 head: string | null;31}3233export interface ParseResult<T> {34 value?: T;35 error?: string;36}3738const HEX64 = /^[0-9a-f]{64}$/;39const SHA1 = /^[0-9a-f]{40}$/;40const UTF8 = new TextEncoder();4142const identifierBytes = (identifier: string): Uint8Array | null => {43 if (!identifier || identifier === "." || identifier === "..") return null;44 for (let i = 0; i < identifier.length; i++) {45 const code = identifier.charCodeAt(i);46 if (code >= 0xd800 && code <= 0xdbff) {47 if (i + 1 >= identifier.length || identifier.charCodeAt(i + 1) < 0xdc00 || identifier.charCodeAt(i + 1) > 0xdfff) return null;48 i++;49 } else if (code >= 0xdc00 && code <= 0xdfff) return null;50 }51 for (const character of identifier) {52 const code = character.codePointAt(0)!;53 if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return null;54 }55 const bytes = UTF8.encode(identifier);56 return bytes.length <= 256 ? bytes : null;57};5859const validIdentifier = (identifier: string): boolean => identifierBytes(identifier) !== null;6061const values = (event: GraspEvent, name: string): string[] => event.tags.filter((tag) => tag[0] === name).flatMap((tag) => tag.slice(1)).filter((value): value is string => typeof value === "string");6263const singleton = (event: GraspEvent, name: string): string | null => {64 const tags = event.tags.filter((tag) => tag[0] === name);65 if (tags.length !== 1 || tags[0].length !== 2) return null;66 return tags[0][1] ?? null;67};6869const validRef = (ref: string): boolean => {70 if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) return false;71 if (ref.length <= "refs/heads/".length || ref.includes("..") || ref.includes("@{") || ref === "@") return false;72 if (/[\u0000-\u0020\u007f~^:?*[\\]/u.test(ref) || ref.endsWith("/") || ref.endsWith(".") || ref.includes("//")) return false;73 return ref.split("/").every((part) => part !== "." && part !== ".." && !part.endsWith(".lock"));74};7576// parseRepositoryAnnouncement extracts the NIP-34 repository record and77// rejects malformed identifiers, URLs, keys, and duplicate singleton tags.78export function parseRepositoryAnnouncement(event: GraspEvent): ParseResult<RepositoryAnnouncement> {79 if (event.kind !== KIND_REPO) return { error: "invalid: expected kind 30617" };80 if (!HEX64.test(event.id) || !HEX64.test(event.pubkey)) return { error: "invalid: bad event identity" };81 const identifier = singleton(event, "d");82 if (identifier === null || !validIdentifier(identifier)) return { error: "invalid: bad repository identifier" };83 const clone = values(event, "clone");84 const relays = values(event, "relays");85 if (clone.some((url) => !isCanonicalCloneURL(url))) return { error: "invalid: bad clone URL" };86 if (relays.some((url) => !isWebSocketURL(url))) return { error: "invalid: relays must be WebSocket URLs" };87 const maintainers = [...new Set(values(event, "maintainers"))];88 if (maintainers.some((key) => !HEX64.test(key))) return { error: "invalid: bad maintainer pubkey" };89 return { value: { id: event.id, owner: event.pubkey, identifier, clone, relays, maintainers, private: values(event, "private").includes("true") } };90}9192// isCanonicalCloneURL accepts standard NIP-34 Git clone URL schemes. The93// service-specific HTTPS URL is checked separately by serviceListed.94export function isCanonicalCloneURL(raw: string): boolean {95 try {96 const url = new URL(raw);97 return ["https:", "http:", "ssh:", "git:", "nostr:"].includes(url.protocol) && !url.hash && !!url.hostname;98 } catch {99 return /^git@[^:]+:.+$/u.test(raw);100 }101}102103// isWebSocketURL validates a relay URL without allowing credentials or104// fragments that would make service matching ambiguous.105export function isWebSocketURL(raw: string): boolean {106 try {107 const url = new URL(raw);108 return (url.protocol === "wss:" || url.protocol === "ws:") && !url.username && !url.password && !url.hash;109 } catch {110 return false;111 }112}113114// serviceListed checks the exact clone and relay URLs advertised by a115// repository, after URL normalization that ignores a trailing slash only.116export function serviceListed(announcement: RepositoryAnnouncement, cloneURL: string, relayURL: string): boolean {117 return announcement.clone.some((url) => normalizeURL(url) === normalizeURL(cloneURL)) && announcement.relays.some((url) => normalizeURL(url) === normalizeURL(relayURL));118}119120const normalizeURL = (raw: string): string => {121 try {122 const url = new URL(raw);123 url.pathname = url.pathname.replace(/\/$/u, "");124 return url.href;125 } catch {126 return raw;127 }128};129130// repositoryCoordinate returns the NIP-34 address used by a and q tags.131export function repositoryCoordinate(announcement: Pick<RepositoryAnnouncement, "owner" | "identifier">): string {132 return `30617:${announcement.owner}:${announcement.identifier}`;133}134135// gitRepositoryPath constructs the GRASP HTTP path. The caller supplies the136// npub because key encoding belongs at the transport boundary.137export function gitRepositoryPath(npub: string, identifier: string): string {138 const bytes = identifierBytes(identifier);139 if (!npub.startsWith("npub1") || !bytes) throw new Error("invalid repository path");140 let encoded = "";141 for (const byte of bytes) {142 const character = String.fromCharCode(byte);143 if ((byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39) || "-._~".includes(character)) encoded += character;144 else encoded += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;145 }146 return `/${npub}/${encoded}.git`;147}148149// parseRepositoryState parses the exact ref vocabulary defined by NIP-34.150export function parseRepositoryState(event: GraspEvent): ParseResult<RepositoryState> {151 if (event.kind !== KIND_REPO_STATE) return { error: "invalid: expected kind 30618" };152 if (!HEX64.test(event.id) || !HEX64.test(event.pubkey)) return { error: "invalid: bad event identity" };153 const identifier = singleton(event, "d");154 if (identifier === null || !validIdentifier(identifier)) return { error: "invalid: bad repository identifier" };155 const refs: Record<string, string> = {};156 let head: string | null = null;157 for (const tag of event.tags) {158 if (tag[0] === "HEAD") {159 if (head !== null || tag.length !== 2 || !tag[1]?.startsWith("ref: ") || !validRef(tag[1].slice(5))) return { error: "invalid: bad HEAD" };160 head = tag[1].slice(5);161 } else if (tag[0]?.startsWith("refs/")) {162 if (tag[0].endsWith("^{}")) continue;163 if (!validRef(tag[0]) || !tag[1] || !SHA1.test(tag[1]) || /^0+$/u.test(tag[1]) || refs[tag[0]]) return { error: "invalid: bad or duplicate ref" };164 refs[tag[0]] = tag[1];165 }166 }167 return { value: { id: event.id, owner: event.pubkey, created_at: event.created_at, identifier, refs, head } };168}169170// recursiveMaintainers computes the bounded fixed point of maintainer171// announcements. A maintainer can extend the set only through its own record.172export function recursiveMaintainers(root: RepositoryAnnouncement, announcements: RepositoryAnnouncement[], maxDepth = 8, maxMaintainers = 64): Set<string> | null {173 const result = new Set<string>([root.owner, ...root.maintainers]);174 if (result.size > maxMaintainers) return null;175 let frontier = new Set<string>([root.owner, ...root.maintainers]);176 for (let depth = 0; depth < maxDepth && frontier.size > 0 && result.size < maxMaintainers; depth++) {177 const next = new Set<string>();178 for (const author of frontier) {179 for (const announcement of announcements) {180 if (announcement.owner !== author || announcement.identifier !== root.identifier) continue;181 for (const maintainer of announcement.maintainers) {182 if (!result.has(maintainer)) {183 if (result.size >= maxMaintainers) return null;184 result.add(maintainer); next.add(maintainer);185 }186 }187 }188 }189 frontier = next;190 }191 return result;192}193194// latestState applies NIP-34 addressable-event ordering: newest timestamp,195// then lexicographically smallest event ID for an exact timestamp tie.196export function latestState(events: RepositoryState[], owner: string, identifier: string, maintainers: Set<string>): RepositoryState | null {197 return events.filter((state) => state.identifier === identifier && (state.owner === owner || maintainers.has(state.owner))).sort((a, b) => b.created_at - a.created_at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0] ?? null;198}199200// stateAllowsRefs requires the post-push ref map and HEAD to equal the201// accepted state event exactly.202export function stateAllowsRefs(state: RepositoryState, refs: Record<string, string>, head: string | null): boolean {203 const keys = Object.keys(refs);204 const expected = Object.keys(state.refs);205 return head === state.head && keys.length === expected.length && expected.every((key) => refs[key] === state.refs[key]);206}207208// nostrRefEventID validates the GRASP PR ref namespace. Unknown event IDs are209// intentionally syntactically valid; the runtime may hold them briefly.210export function nostrRefEventID(ref: string): string | null {211 const match = /^refs\/nostr\/([0-9a-f]{64})$/u.exec(ref);212 return match?.[1] ?? null;213}214215// relatedRepositoryCoordinates lists valid NIP-34 repository a-tags.216export function relatedRepositoryCoordinates(event: GraspEvent): string[] {217 return values(event, "a").filter((coordinate) => {218 const parts = coordinate.split(":");219 return parts.length >= 3 && parts[0] === "30617" && HEX64.test(parts[1]) && validIdentifier(parts.slice(2).join(":"));220 });221}222223// eventReferencesRepository checks whether an event is anchored to a repo.224export function eventReferencesRepository(event: GraspEvent, coordinate: string): boolean {225 return relatedRepositoryCoordinates(event).includes(coordinate);226}