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<string, Promise<string>>();
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<string> => {
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<string, string> } = {}
) =>
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<string, string>) =>
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<Account> => {
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<string, string> }
) => 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<typeof decodeRefs> } | 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<string, any>) => Record<string, unknown>
) => {
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<Ticket> = {}) =>
({
$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<Written> => {
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<Written> => {
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<Written, "uri">) => {
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
});
};