import { 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 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);
// every write queues before it flushes, so the queue alone is mostly in-flight work;
// only entries a flush gave up on are worth surfacing
export 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 first
const 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);
};