import { browser } from "$app/environment"; import { now as tidNow } from "@atcute/tid"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { putRecord } from "$lib/api/write"; import { atUriOf, cidOf, datetimeOf, didOf, gitOidOf, knotOf, nsidOf, recordKeyOf, tidOf, type AtUri, type Cid, type Datetime, type Did, type GitOid, type KnotOrigin, type Tid } from "$lib/api/syntax"; export const ATTESTATION_COLLECTION = nsidOf("org.tangled.graph.attestation"); const LIST_COB_VERSIONS = nsidOf("sh.tangled.repo.listCobVersions"); const STORAGE_PREFIX = "tangled:attestations:"; const SETTLED_PREFIX = "tangled:attestations:settled:"; const SETTLED_LIMIT = 200; const PAGE_SIZE = 500; const ENRICHMENT_DEADLINE_MS = 10_000; const REQUEST_TIMEOUT_MS = 4_000; const ATTESTATIONS_CHANGED = "tangled:attestations"; export interface AttestationSubject { uri: AtUri; cid: Cid; } export interface PendingAttestation { rkey: Tid; subject: AttestationSubject; knot?: KnotOrigin; change?: GitOid; createdAt: Datetime; } const parsePending = (raw: string | null): PendingAttestation | undefined => { let value: unknown; try { value = JSON.parse(raw ?? ""); } catch { return undefined; } if (typeof value !== "object" || value === null) return undefined; const { rkey, subject, knot, change, createdAt } = value as Record; const typed = subject as Record | null; if (typeof rkey !== "string" || typeof createdAt !== "string") return undefined; if (typeof typed?.uri !== "string" || typeof typed.cid !== "string") return undefined; try { return { rkey: tidOf(rkey), subject: { uri: atUriOf(typed.uri), cid: cidOf(typed.cid) }, ...(typeof knot === "string" ? { knot: knotOf(knot) } : {}), ...(typeof change === "string" ? { change: gitOidOf(change) } : {}), createdAt: datetimeOf(createdAt) }; } catch { return undefined; } }; const guarded = (run: () => void): void => { if (!browser) return; try { run(); } catch { return; } }; const under = (namespace: string, did: Did): string => `${namespace}${did}:`; const entriesUnder = (prefix: string): PendingAttestation[] => { const found: PendingAttestation[] = []; guarded(() => { for (let index = 0; index < localStorage.length; index += 1) { const key = localStorage.key(index); if (key === null || !key.startsWith(prefix)) continue; const parsed = parsePending(localStorage.getItem(key)); if (parsed !== undefined) found.push(parsed); } }); return found; }; const read = (did: Did): PendingAttestation[] => browser ? entriesUnder(under(STORAGE_PREFIX, did)) : []; const readSettled = (did: Did): PendingAttestation[] => entriesUnder(under(SETTLED_PREFIX, did)); export const pendingAttestations = (did: Did): readonly PendingAttestation[] => read(did); type Claim = Pick; // a cid is only the content, so reopening an issue repeats an old cid under a new change, // while an answer with no change is a replay of a version that is already claimed const isQueued = (entries: readonly Claim[], claim: Claim): boolean => entries.some( (entry) => entry.subject.uri === claim.subject.uri && entry.subject.cid === claim.subject.cid && (claim.change === undefined || entry.change === claim.change) ); const isSettled = (did: Did, claim: Claim): boolean => isQueued(readSettled(did), claim); const announce = (): void => guarded(() => window.dispatchEvent(new CustomEvent(ATTESTATIONS_CHANGED))); export const dismissAttestations = (did: Did): void => guarded(() => { for (let index = localStorage.length - 1; index >= 0; index -= 1) { const key = localStorage.key(index); if (key !== null && key.startsWith(under(STORAGE_PREFIX, did))) localStorage.removeItem(key); } announce(); }); const storePending = (did: Did, entry: PendingAttestation): void => guarded(() => { localStorage.setItem(`${under(STORAGE_PREFIX, did)}${entry.rkey}`, JSON.stringify(entry)); announce(); }); const settle = (did: Did, entry: PendingAttestation): void => guarded(() => { localStorage.setItem(`${under(SETTLED_PREFIX, did)}${entry.rkey}`, JSON.stringify(entry)); localStorage.removeItem(`${under(STORAGE_PREFIX, did)}${entry.rkey}`); for (const old of readSettled(did) .sort((one, other) => other.rkey.localeCompare(one.rkey)) .slice(SETTLED_LIMIT)) localStorage.removeItem(`${under(SETTLED_PREFIX, did)}${old.rkey}`); announce(); }); // only knots that don't send the change get here, and they list oldest first const changeIdOf = async ( knot: KnotOrigin, subject: AttestationSubject ): Promise => { const deadline = Date.now() + ENRICHMENT_DEADLINE_MS; let found: string | undefined; for (let offset = 0; Date.now() < deadline;) { const url = new URL(`https://${knot}/xrpc/${LIST_COB_VERSIONS}`); url.searchParams.set("uri", subject.uri); url.searchParams.set("limit", String(PAGE_SIZE)); url.searchParams.set("cursor", String(offset)); const response = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }).catch(() => undefined); if (!response?.ok) return undefined; const page = (await response.json()) as { versions?: { cid?: string; changeId?: string }[]; cursor?: string; }; // a cid can come back under a newer change, so the last match wins and the walk has to finish const match = page.versions?.filter((version) => version.cid === subject.cid).at(-1); if (match?.changeId !== undefined) found = match.changeId; const next = page.cursor === undefined ? undefined : Number(page.cursor); if (next === undefined) return found === undefined ? undefined : gitOidOf(found); if (Number.isNaN(next) || next <= offset) return undefined; offset = next; } return undefined; }; const flushing = new Set(); export const flushAttestations = async (agent: OAuthUserAgent): Promise => { const did = didOf(agent.sub); if (flushing.has(did)) return []; flushing.add(did); try { const failures: string[] = []; const attempted = new Set(); let finding = read(did); while (finding.some((entry) => !attempted.has(entry.rkey))) { for (const entry of finding) { if (attempted.has(entry.rkey)) continue; attempted.add(entry.rkey); try { if (!read(did).some((live) => live.rkey === entry.rkey)) continue; if (isSettled(did, entry)) { guarded(() => { localStorage.removeItem(`${under(STORAGE_PREFIX, did)}${entry.rkey}`); announce(); }); continue; } const change = entry.change ?? (entry.knot ? await changeIdOf(entry.knot, entry.subject).catch(() => undefined) : undefined); await putRecord(agent, ATTESTATION_COLLECTION, recordKeyOf(entry.rkey), { $type: ATTESTATION_COLLECTION, subject: { uri: entry.subject.uri, cid: entry.subject.cid }, ...(change ? { change } : {}), createdAt: entry.createdAt }); settle(did, entry); } catch (cause) { failures.push(cause instanceof Error ? cause.message : String(cause)); } } finding = read(did); } return failures; } finally { flushing.delete(did); } }; export const attest = ( agent: OAuthUserAgent, subject: AttestationSubject, knot?: KnotOrigin, change?: GitOid ): void => { const did = didOf(agent.sub); if (!browser) return; const claim: Claim = { subject, ...(change ? { change } : {}) }; if (isSettled(did, claim)) return; if (isQueued(read(did), claim)) return; storePending(did, { rkey: tidOf(tidNow()), subject, ...(knot ? { knot } : {}), ...(change ? { change } : {}), createdAt: datetimeOf(new Date().toISOString()) }); void flushAttestations(agent).catch(() => undefined); };