Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231import { 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<string, unknown>; const typed = subject as Record<string, unknown> | 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);
const isQueued = (entries: readonly PendingAttestation[], subject: AttestationSubject): boolean => entries.some((entry) => entry.subject.uri === subject.uri && entry.subject.cid === subject.cid);
const isSettled = (did: Did, subject: AttestationSubject): boolean => isQueued(readSettled(did), subject);
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(); });
const changeIdOf = async ( knot: KnotOrigin, subject: AttestationSubject): Promise<GitOid | undefined> => { const deadline = Date.now() + ENRICHMENT_DEADLINE_MS; 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; }; const match = page.versions?.find((version) => version.cid === subject.cid); if (match?.changeId !== undefined) return gitOidOf(match.changeId); const next = page.cursor === undefined ? undefined : Number(page.cursor); if (next === undefined || Number.isNaN(next) || next <= offset) return undefined; offset = next; } return undefined;};
const flushing = new Set<Did>();
export const flushAttestations = async (agent: OAuthUserAgent): Promise<string[]> => { const did = didOf(agent.sub); if (flushing.has(did)) return []; flushing.add(did); try { const failures: string[] = []; const attempted = new Set<Tid>(); 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.subject)) { 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): void => { const did = didOf(agent.sub); if (!browser) return; if (isSettled(did, subject)) return; if (isQueued(read(did), subject)) return; storePending(did, { rkey: tidOf(tidNow()), subject, ...(knot ? { knot } : {}), createdAt: datetimeOf(new Date().toISOString()) }); void flushAttestations(agent).catch(() => undefined);};