~james/core

core/e2e/harness/api.ts -rw-r--r-- 15.8 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
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
	});
};