Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263import { browser } from "$app/env";import { now as tidNow } from "@atcute/tid";import type { OAuthUserAgent } from "@atcute/oauth-browser-client";import { putRecord } from "#lib/api/write.ts";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.ts";
export const ATTESTATION_COLLECTION = nsidOf("org.tangled.graph.attestation");
const LIST_RECORD_VERSIONS = nsidOf("org.tangled.repo.listRecordVersions");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; failedAt?: 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, failedAt } = 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), ...(typeof failedAt === "string" ? { failedAt: datetimeOf(failedAt) } : {}) }; } 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<PendingAttestation, "subject" | "change">;
// 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 claimedconst 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);
// every write queues before it flushes, so the queue alone is mostly in-flight work;// only entries a flush gave up on are worth surfacingexport const failedAttestations = (did: Did): readonly PendingAttestation[] => read(did).filter((entry) => entry.failedAt !== undefined);
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 markFailed = (did: Did, entry: PendingAttestation): void => guarded(() => { const key = `${under(STORAGE_PREFIX, did)}${entry.rkey}`; if (localStorage.getItem(key) === null) return; const failed = { ...entry, failedAt: datetimeOf(new Date().toISOString()) }; localStorage.setItem(key, JSON.stringify(failed)); 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 firstconst changeIdOf = async ( knot: KnotOrigin, subject: AttestationSubject): Promise<GitOid | undefined> => { 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_RECORD_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<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)) { 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) { markFailed(did, entry); 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);};