import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { mintServiceAuth } from "$lib/auth/agent"; import { attest } from "$lib/api/attestation"; import { clear } from "$lib/api/cache"; import { buildUrl, toResponseError } from "$lib/api/_request"; import { atUriOf, cidOf, didOf, gitOidOf, nsidOf, recordKeyOf, type AtUri, type Cid, type Did, type KnotOrigin, type Nsid, type Rkey } from "$lib/api/syntax"; import { rkeyFromUri } from "$lib/api/uri"; export interface CobRef { uri: AtUri; cid: Cid; } export interface Written { uri: AtUri; cid: Cid; rkey: Rkey; } export const withRkey = (saved: { uri: string; cid: string }): Written => ({ uri: atUriOf(saved.uri), cid: cidOf(saved.cid), rkey: rkeyFromUri(saved.uri) }); const CREATE_NSID = nsidOf("com.atproto.repo.createRecord"); const PUT_NSID = nsidOf("com.atproto.repo.putRecord"); const DELETE_NSID = nsidOf("com.atproto.repo.deleteRecord"); const GET_NSID = nsidOf("com.atproto.repo.getRecord"); const UPLOAD_BLOB_NSID = nsidOf("com.atproto.repo.uploadBlob"); const EDITOR_FIELD = "x-tngl-editor"; const CHANGE_FIELD = "x-tngl-change"; interface KnotWrite { uri: string; cid: string; [CHANGE_FIELD]?: string; } export const cobRefOf = (view: { uri: string; cid?: string | null }): CobRef => { if (!view.cid) throw new Error(`${view.uri} is missing its cid, so it can't be changed`); return { uri: atUriOf(view.uri), cid: cidOf(view.cid) }; }; const knotPost = async ( agent: OAuthUserAgent, knot: KnotOrigin, repo: Did, nsid: Nsid, body: BodyInit, contentType?: string, init?: { signal?: AbortSignal; invalidate?: boolean } ): Promise => { // the knot checks aud against the repo did, not its own did:web const token = await mintServiceAuth(agent, { aud: repo, lxm: nsid }); const response = await fetch(buildUrl(`https://${knot}`, nsid), { method: "POST", headers: { ...(contentType ? { "content-type": contentType } : {}), accept: "application/json", authorization: `Bearer ${token}` }, body, signal: init?.signal }); if (!response.ok) throw await toResponseError(response); if (init?.invalidate !== false) clear(); return (await response.json()) as T; }; export const knotProcedure = async ( agent: OAuthUserAgent, knot: KnotOrigin, repo: Did, nsid: Nsid, input: Record, init?: { signal?: AbortSignal; invalidate?: boolean } ): Promise => knotPost( agent, knot, repo, nsid, JSON.stringify({ repo, ...input }), "application/json", init ); // the write already landed, so a bad id falls back to the lookup instead of failing it const changeOf = (saved: KnotWrite) => { try { return saved[CHANGE_FIELD] ? gitOidOf(saved[CHANGE_FIELD]) : undefined; } catch { return undefined; } }; // every create and edit comes through here, so call sites never attest themselves const attested = (agent: OAuthUserAgent, knot: KnotOrigin, saved: KnotWrite): CobRef => { const ref = { uri: atUriOf(saved.uri), cid: cidOf(saved.cid) }; attest(agent, ref, knot, changeOf(saved)); return ref; }; export const createCob = async ( agent: OAuthUserAgent, knot: KnotOrigin, repo: Did, collection: Nsid, record: T, rkey?: Rkey ): Promise => attested( agent, knot, await knotProcedure(agent, knot, repo, CREATE_NSID, { collection, ...(rkey ? { rkey } : {}), record }) ); export const editCob = async ( agent: OAuthUserAgent, knot: KnotOrigin, from: CobRef, record: T ): Promise => { const { repo, collection, rkey } = parseCanonicalResourceUri(from.uri); const input = { collection: nsidOf(collection), rkey: recordKeyOf(rkey), swapRecord: from.cid, record }; return attested( agent, knot, await knotProcedure(agent, knot, didOf(repo), PUT_NSID, input) ); }; export const eraseCob = async ( agent: OAuthUserAgent, knot: KnotOrigin, from: CobRef ): Promise => { const { repo, collection, rkey } = parseCanonicalResourceUri(from.uri); await knotProcedure(agent, knot, didOf(repo), DELETE_NSID, { collection: nsidOf(collection), rkey: recordKeyOf(rkey), swapRecord: from.cid }); }; // a grant write answers with an empty body, so unlike a cob there's no uri to attest export const createGrant = async ( agent: OAuthUserAgent, knot: KnotOrigin, repo: Did, collection: Nsid, subject: Did ): Promise => { await knotProcedure(agent, knot, repo, CREATE_NSID, { collection, rkey: subject, record: { $type: collection, createdAt: new Date().toISOString() } }); }; export const eraseGrant = async ( agent: OAuthUserAgent, knot: KnotOrigin, repo: Did, collection: Nsid, subject: Did ): Promise => { await knotProcedure(agent, knot, repo, DELETE_NSID, { collection, rkey: subject }); }; export const readCob = async (knot: KnotOrigin, uri: AtUri): Promise> => { const { repo, collection, rkey } = parseCanonicalResourceUri(uri); const response = await fetch( buildUrl(`https://${knot}`, GET_NSID, { repo, collection, rkey }), { headers: { accept: "application/json" } } ); if (!response.ok) throw await toResponseError(response); const read = (await response.json()) as { uri: string; cid?: string; value: T }; return { uri: atUriOf(read.uri), cid: cidOf(read.cid ?? ""), value: read.value }; }; export interface CobRecord extends CobRef { value: T; } export interface KnotBlobReceipt { $type: "blob"; ref: { $link: Cid }; mimeType: string; size: number; } const blobReceiptOf = (value: unknown): KnotBlobReceipt => { const candidate = value as Record; const ref = candidate?.ref as Record | undefined; if ( typeof candidate?.$type !== "string" || typeof ref?.$link !== "string" || typeof candidate.mimeType !== "string" || typeof candidate.size !== "number" ) throw new Error("The blobject returned by the knot is malformed."); return { $type: "blob", ref: { $link: cidOf(ref.$link) }, mimeType: candidate.mimeType, size: candidate.size }; }; export const uploadBlobToKnot = async ( agent: OAuthUserAgent, knot: KnotOrigin, repo: Did, bytes: BufferSource, contentType?: string ): Promise => blobReceiptOf( ( await knotPost<{ blob?: unknown }>( agent, knot, repo, UPLOAD_BLOB_NSID, bytes as BodyInit, contentType ) ).blob ); export const updateCob = async ( agent: OAuthUserAgent, knot: KnotOrigin, uri: AtUri, mutate: (current: T) => T ): Promise> => { const current = await readCob(knot, uri); const { [EDITOR_FIELD]: _, ...stored } = current.value as T & { [EDITOR_FIELD]?: string }; const value = mutate(stored as T); return { ...(await editCob(agent, knot, current, value)), value }; };