import { ClientResponseError } from "@atcute/client"; import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { mintServiceAuth } from "#lib/auth/agent.ts"; import { attest } from "#lib/api/attestation.ts"; import { clear } from "#lib/api/cache.ts"; import { buildUrl, toResponseError } from "#lib/api/_request.ts"; import { atUriOf, cidOf, didOf, gitOidOf, nsidOf, recordKeyOf, type AtUri, type Cid, type Did, type KnotOrigin, type Nsid, type Rkey } from "#lib/api/syntax.ts"; export interface CobRef { uri: AtUri; cid: Cid; } 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"; export 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 export 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 class StaleEditError extends Error { constructor() { super("This changed since you opened it. Reload to see the latest version."); this.name = "StaleEditError"; } } const isStaleSwap = (cause: unknown) => cause instanceof ClientResponseError && cause.status === 409 && cause.error === "InvalidSwap"; // from is the version the page showed, a newer one on the knot is someone else's write export const readFresh = async (knot: KnotOrigin, from: CobRef): Promise> => { const current = await readCob(knot, from.uri); if (current.cid !== from.cid) throw new StaleEditError(); return current; }; // a bare uri edits whatever the knot has, last write wins export const updateCob = async ( agent: OAuthUserAgent, knot: KnotOrigin, from: AtUri | CobRef, mutate: (current: T) => T ): Promise> => { const current = typeof from === "string" ? await readCob(knot, from) : await readFresh(knot, from); const { [EDITOR_FIELD]: _, ...stored } = current.value as T & { [EDITOR_FIELD]?: string }; const value = mutate(stored as T); try { return { ...(await editCob(agent, knot, current, value)), value }; } catch (cause) { throw isStaleSwap(cause) ? new StaleEditError() : cause; } };