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 <T>(
agent: OAuthUserAgent,
knot: KnotOrigin,
repo: Did,
nsid: Nsid,
body: BodyInit,
contentType?: string,
init?: { signal?: AbortSignal; invalidate?: boolean }
): Promise<T> => {
// 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 <T>(
agent: OAuthUserAgent,
knot: KnotOrigin,
repo: Did,
nsid: Nsid,
input: Record<string, unknown>,
init?: { signal?: AbortSignal; invalidate?: boolean }
): Promise<T> =>
knotPost<T>(
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 <T extends { $type: string }>(
agent: OAuthUserAgent,
knot: KnotOrigin,
repo: Did,
collection: Nsid,
record: T,
rkey?: Rkey
): Promise<CobRef> =>
attested(
agent,
knot,
await knotProcedure<KnotWrite>(agent, knot, repo, CREATE_NSID, {
collection,
...(rkey ? { rkey } : {}),
record
})
);
export const editCob = async <T extends { $type: string }>(
agent: OAuthUserAgent,
knot: KnotOrigin,
from: CobRef,
record: T
): Promise<CobRef> => {
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<KnotWrite>(agent, knot, didOf(repo), PUT_NSID, input)
);
};
export const eraseCob = async (
agent: OAuthUserAgent,
knot: KnotOrigin,
from: CobRef
): Promise<void> => {
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<void> => {
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<void> => {
await knotProcedure(agent, knot, repo, DELETE_NSID, { collection, rkey: subject });
};
export const readCob = async <T>(knot: KnotOrigin, uri: AtUri): Promise<CobRecord<T>> => {
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<T> 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<string, unknown>;
const ref = candidate?.ref as Record<string, unknown> | 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<KnotBlobReceipt> =>
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 <T>(knot: KnotOrigin, from: CobRef): Promise<CobRecord<T>> => {
const current = await readCob<T>(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 <T extends { $type: string }>(
agent: OAuthUserAgent,
knot: KnotOrigin,
from: AtUri | CobRef,
mutate: (current: T) => T
): Promise<CobRecord<T>> => {
const current =
typeof from === "string" ? await readCob<T>(knot, from) : await readFresh<T>(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;
}
};