import type { Did, ResourceUri } from "@atcute/lexicons/syntax"; import { config, handleOf, seeded, type Account } from "./config"; import type { Comment, Declaration, LabelDefinition, Profile, Ticket } from "./lexicons"; import { get, ok, partsOf, recordUrl, request, retry429, selfUri, stamp, xrpc, type Written } from "./net"; export interface RepoOptions { spindle?: string; forkOf?: string; importFrom?: string; } const sessions = new Map>(); export const sessionOf = (who: Account) => { if (!sessions.has(who.did)) { const login = request(`${config.pdsUrl}/xrpc/com.atproto.server.createSession`, { method: "POST", json: { identifier: who.handle, password: config.password } }).then(({ status, body }) => { if (status !== 200) throw new Error( `pds refused ${who.name}'s login: ${status} ${JSON.stringify(body)}` ); return body.accessJwt as string; }); // otherwise a refused login stays cached and every call after it fails login.catch(() => sessions.delete(who.did)); sessions.set(who.did, login); } return sessions.get(who.did)!; }; // 401 on login means the account is gone, the other texts are not-found answers const GONE = /not ?found|does not exist|no .* to erase|pds refused .*login: 401/i; export const alreadyGone = (cause: unknown) => GONE.test(String(cause)); export const getOk = async (url: string) => ok(url, await get(url)); export const tokenFor = async (who: Account, aud: string, lxm: string): Promise => { const auth = await request( xrpc(config.pdsUrl, "com.atproto.server.getServiceAuth", { aud, lxm }), { token: await sessionOf(who) } ); return ok("pds getServiceAuth", auth).token; }; // calls an xrpc on a service using service auth export const serviceDo = async ( who: Account, base: string, aud: string, nsid: string, { json, params }: { json?: object; params?: Record } = {} ) => request(xrpc(base, nsid, params), { method: json === undefined ? "GET" : "POST", token: await tokenFor(who, aud, nsid), json }); export const spindleGet = (who: Account, nsid: string, params: Record) => serviceDo(who, config.spindleUrl, config.spindleDid, nsid, { params }); export const spindlePost = (who: Account, nsid: string, json: object) => serviceDo(who, config.spindleUrl, config.spindleDid, nsid, { json }); const QUOTA = "org.tangled.temp.spindle.quota"; const spindleAdmin = seeded.alice; export interface QuotaUsage { scope: "user" | "repo"; did: string; resource: string; used: number; } export const quotas = { set: async (did: string, resource: string, limit: number) => ok( `spindle ${QUOTA}.set`, await spindlePost(spindleAdmin, `${QUOTA}.set`, { did, resource, limit }) ), unset: async (did: string, resource: string) => ok( `spindle ${QUOTA}.unset`, await spindlePost(spindleAdmin, `${QUOTA}.unset`, { did, resource }) ), usage: async (did: string) => (ok(`spindle ${QUOTA}.usage`, await spindleGet(spindleAdmin, `${QUOTA}.usage`, { did })) .usages as QuotaUsage[]) ?? [] }; // the knot takes a service token once, so every retry mints a new one const serviceReply = (who: Account, aud: string, nsid: string, input: object) => retry429(() => serviceDo(who, config.knotUrl, aud, nsid, { json: input })); export const asKnot = (who: Account, nsid: string, input: object) => serviceReply(who, config.knotDid, nsid, input); const serviceCall = async (who: Account, aud: string, nsid: string, input: object) => ok(`knot ${nsid}`, await serviceReply(who, aud, nsid, input)); const knotCall = (who: Account, repo: string, nsid: string, input: object) => serviceCall(who, repo, nsid, { repo, ...input }); // unlike knotCall this doesn't throw on a refusal, the caller checks the status export const knotReply = (who: Account, repo: string, nsid: string, input: object) => serviceReply(who, repo, nsid, { repo, ...input }); const IMPORT_STATUS = "org.tangled.repo.getImportStatus"; // the token is addressed to the repo, like web's, not to the knot export const importStatus = async (repo: string, who?: Account) => request( xrpc(config.knotUrl, IMPORT_STATUS, { repo }), who ? { token: await tokenFor(who, repo, IMPORT_STATUS) } : {} ); // knotmirror forwards the token to the knot's receive-pack, so it's minted for that export const mirrorMerge = async ( who: Account, repo: string, branch: string, target: string, source: string ) => request(xrpc(config.bobbinUrl, "sh.tangled.git.mergeCommit"), { method: "POST", token: await tokenFor(who, config.knotDid, "org.tangled.git.receivePack"), json: { target: { $type: "sh.tangled.git.mergeCommit#target", repo, branch, commit: target }, source: { $type: "sh.tangled.git.mergeCommit#source", repo, commit: source }, style: "rebase" } }); export type Policy = "anyone" | "collaborators" | "members"; const MANIFEST = "org.tangled.repo.manifest"; // the policy has no endpoint of its own because it lives on the manifest export const setPolicy = async (who: Account, repo: string, policy: Policy) => { const manifest = await knotRecord(selfUri(repo, MANIFEST)); if (!manifest) throw new Error(`the knot has no manifest for ${repo}`); await knotCall(who, repo, "com.atproto.repo.putRecord", { collection: MANIFEST, rkey: "self", record: { ...manifest.value, contributionPolicy: policy }, swapRecord: manifest.cid }); }; export const pdsCall = async (who: Account, nsid: string, input: object) => ok( `pds ${nsid}`, await retry429(async () => request(`${config.pdsUrl}/xrpc/${nsid}`, { method: "POST", token: await sessionOf(who), json: { repo: who.did, ...input } }) ) ); const admin = async (nsid: string, json: object) => { if (!config.pdsAdminPassword) throw new Error("set E2E_PDS_ADMIN_PASSWORD, localinfra/pds.env has none to read"); return ok( `pds ${nsid}`, await request(`${config.pdsUrl}/xrpc/${nsid}`, { method: "POST", basic: `admin:${config.pdsAdminPassword}`, json }) ); }; export const preauthorize = (did: string, requestUri: string) => request(`${config.pdsUrl}/_preauthorize`, { method: "POST", basic: `admin:${config.pdsAdminPassword}`, json: { did, requestUri } }); const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, "-"); // no other test shares an account's stars, tickets or rate limits // membership is a record in the knot's own repo, keyed by the member's did const KNOT_MEMBER = "org.tangled.knot.member"; export const accounts = { create: async (label: string): Promise => { const name = `${slug(label)}-${stamp()}`; const handle = handleOf(name); const { code } = await admin("com.atproto.server.createInviteCode", { useCount: 1 }); const made = ok( "pds createAccount", await request(`${config.pdsUrl}/xrpc/com.atproto.server.createAccount`, { method: "POST", json: { email: `${name}@pds.tngl.boltless.dev`, handle, password: config.password, inviteCode: code } }) ); const account = { name, handle, did: made.did as string }; // hydrant only indexes an account once it writes a record in a signal collection try { await pdsCall(account, "com.atproto.repo.putRecord", { collection: "org.tangled.actor.profile", rkey: "self", record: { $type: "org.tangled.actor.profile" } satisfies Profile }); } catch (cause) { // the caller never gets the account back, so nothing else would clean it up await admin("com.atproto.admin.deleteAccount", { did: account.did }).catch(() => {}); throw cause; } return account; }, // the knot is closed to anyone it hasn't been told about makeMember: (who: Account) => serviceCall(seeded.alice, config.knotDid, "com.atproto.repo.createRecord", { repo: config.knotDid, collection: KNOT_MEMBER, rkey: who.did, record: { $type: KNOT_MEMBER, createdAt: new Date().toISOString() } }), removeMember: (who: Account) => serviceCall(seeded.alice, config.knotDid, "com.atproto.repo.deleteRecord", { repo: config.knotDid, collection: KNOT_MEMBER, rkey: who.did }), remove: async (who: Account) => { await admin("com.atproto.admin.deleteAccount", { did: who.did }); sessions.delete(who.did); } }; // the knot answers ok for a repo it doesn't know, so a half-made one is safe to delete const deleteKnotRepo = (who: Account, repo: { did: string }) => serviceCall(who, config.knotDid, "org.tangled.repo.deleteRepo", { repo: repo.did }); // the knot refuses to delete a repo while its declaration is still on the owner's pds, so that goes first const removeRepo = async (who: Account, repo: { did: string; name: string }) => { await pdsCall(who, "com.atproto.repo.deleteRecord", { collection: "org.tangled.repo.declaration", rkey: repo.name }); await deleteKnotRepo(who, repo); }; export const repos = { create: async (who: Account, repoName: string, options: RepoOptions = {}) => { const createdAt = new Date().toISOString(); const made = await serviceCall(who, config.knotDid, "org.tangled.repo.createRepo", { name: repoName, createdAt, ...(options.spindle ? { spindle: options.spindle } : {}), ...(options.forkOf ? { source: { $type: "org.tangled.repo.defs#sourceTangled", did: options.forkOf } } : {}), ...(options.importFrom ? { source: { $type: "org.tangled.repo.defs#sourceExternal", uri: options.importFrom } } : {}) }); const declaration = await pdsCall(who, "com.atproto.repo.createRecord", { collection: "org.tangled.repo.declaration", rkey: repoName, record: { $type: "org.tangled.repo.declaration", repo: made.did, createdAt } satisfies Declaration }).catch(async (cause) => { // the caller never gets this repo, so nothing else would remove the knot's half await deleteKnotRepo(who, { did: made.did }).catch(() => {}); throw cause; }); return { did: made.did as Did, name: repoName, declaration: { uri: declaration.uri as string, cid: declaration.cid as string } }; }, remove: removeRepo }; const TICKET = "org.tangled.track.ticket"; // the sites worker keeps claims and site configs itself, behind service auth const sitesDo = async ( who: Account, nsid: string, input: { json?: object; params?: Record } ) => ok(`sites ${nsid}`, await serviceDo(who, config.sitesUrl, config.sitesDid, nsid, input)); export const sites = { claim: (who: Account, domain: string) => sitesDo(who, "org.tangled.temp.site.claimDomain", { json: { domain } }), config: async (who: Account, repoDid: string) => (await sitesDo(who, "org.tangled.temp.repo.getSiteConfig", { params: { repoDid } })).config, history: async (who: Account, repoDid: string) => (await sitesDo(who, "org.tangled.temp.repo.getDeployHistory", { params: { repoDid } })) .deploys as { status: string; error: string }[], // returns the reply instead of throwing on a refusal saveReply: (who: Account, input: object) => serviceDo(who, config.sitesUrl, config.sitesDid, "org.tangled.temp.repo.updateSiteConfig", { json: input }) }; export const decodeRefs = (value: { refs: { $bytes: string } }) => Buffer.from(value.refs.$bytes, "base64") .toString() .split("\n") .filter(Boolean) .map((line) => line.split(" ") as [sha: string, ref: string]); // a missing record or repo is a 404 or a RecordNotFound/RepoNotFound body export const knotRecord = async (uri: string) => { const { status, body } = await get(recordUrl(config.knotUrl, uri)); if (status === 404 || ["RecordNotFound", "RepoNotFound"].includes(body?.error)) return undefined; return ok("knot getRecord", { status, body }); }; export const knotRecords = async (repoDid: string, collection: string) => ( await getOk( xrpc(config.knotUrl, "com.atproto.repo.listRecords", { repo: repoDid, collection, limit: "100" }) ) ).records as { uri: string; cid: string; value: any }[]; export const refsOf = async ( uri: string ): Promise<{ cid: string; named: ReturnType } | undefined> => { const record = await knotRecord(uri); return record && { cid: record.cid, named: decodeRefs(record.value) }; }; export const knotRecordFor = async (uri: string, doing: string) => { const found = await knotRecord(uri); if (!found) throw new Error(`the knot has no ${uri} to ${doing}`); return found; }; // the knot only takes an edit that names the version it was read at export const editCob = async ( who: Account, uri: string, doing: string, change: (stored: Record) => Record ) => { const { repo, collection, rkey } = partsOf(uri); const current = await knotRecordFor(uri, doing); // the knot sets x-tngl-editor itself, so it can't be sent back const { "x-tngl-editor": _editor, ...stored } = current.value; return knotReply(who, repo, "com.atproto.repo.putRecord", { collection, rkey, swapRecord: current.cid, record: change(stored) }); }; export const ticketRecord = (title: string, more: Partial = {}) => ({ $type: TICKET, title: { $type: "org.tangled.markup.markdown#inline", text: title }, state: "open", createdAt: new Date().toISOString(), ...more }) satisfies Ticket; export const tickets = { open: async (who: Account, repo: string, title: string): Promise => { const made = await knotCall(who, repo, "com.atproto.repo.createRecord", { collection: TICKET, record: ticketRecord(title) }); return { uri: made.uri, cid: made.cid }; }, setState: async (who: Account, ticket: Written, state: "open" | "closed"): Promise => { const made = ok( "knot com.atproto.repo.putRecord", await editCob(who, ticket.uri, "change", (stored) => ({ ...stored, state })) ); return { uri: made.uri, cid: made.cid }; } }; export const pulls = { open: async ( who: Account, repo: Did, title: string, targetBranch: string, range: { base: string; head: string }, sourceBranch: string ): Promise<{ ticket: Written; patch: Written }> => { const ticket = await knotCall(who, repo, "com.atproto.repo.createRecord", { collection: TICKET, record: ticketRecord(title, { targetBranch }) }); // the knot only takes patches through submitPatch, not createRecord const patch = await knotCall(who, repo, "org.tangled.review.submitPatch", { patch: { ticket: partsOf(ticket.uri).rkey, base: { oid: range.base }, head: { oid: range.head }, sourceRepo: repo, // web resubmits from the source branch, a patch without one has no resubmit button sourceBranch } }); return { ticket: { uri: ticket.uri, cid: ticket.cid }, patch: { uri: patch.uri, cid: patch.cid } }; } }; const COMMENT = "org.tangled.feed.comment"; // the subject's cid must be its current version or the knot refuses it export const comments = { add: (who: Account, repo: string, subject: Written, text: string) => knotReply(who, repo, "com.atproto.repo.createRecord", { collection: COMMENT, record: { $type: COMMENT, subject: { $type: "com.atproto.repo.strongRef", uri: subject.uri as ResourceUri, cid: subject.cid }, body: { $type: "org.tangled.markup.markdown", text }, createdAt: new Date().toISOString() } satisfies Comment }) }; const LABEL = "org.tangled.label.definition"; // the knot only takes a client rkey on label definitions, and this one is the name export const labels = { define: (who: Account, repo: string, name: string) => knotReply(who, repo, "com.atproto.repo.createRecord", { collection: LABEL, rkey: name, record: { $type: LABEL, name, color: "#a855f7", createdAt: new Date().toISOString() } satisfies LabelDefinition }) }; export const erase = async (who: Account, record: Pick) => { const { repo, collection, rkey } = partsOf(record.uri); if (repo === who.did) { await pdsCall(who, "com.atproto.repo.deleteRecord", { collection, rkey }); return; } const current = await knotRecordFor(record.uri, "erase"); await knotCall(who, repo, "com.atproto.repo.deleteRecord", { collection, rkey, swapRecord: current.cid }); };