chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Repository settings, written as the owner.
//
// The settings live in two records. The description and the default branch
// are fields of the repository record, which every push rewrites. The
// protected branches, the check runner and the linked packages are in the
// config record beside it, which no push touches. One request carries both,
// and a field the request leaves out keeps the value it has, so a form saves
// the group it edits alone.
//
// A write runs as the owner, on the owner's own PDS, under the owner's OAuth
// session. The server accepts it only from a token whose account is the
// site's, so nobody else's session configures a repository here.
import { XrpcError } from '@pdsjs/git/client';
import { NPM_PACKAGE_COLLECTION } from '@pdsjs/npm/lexicon';
import { OCI_REPO_COLLECTION } from '@pdsjs/oci/lexicon';
import { account, forgetConfig } from '#/lib/git.js';
import { authedFetch } from '#/lib/oauth.js';
/**
* One package a repository builds, in the pair that names it. A name repeats
* across registries, so neither half identifies one on its own.
* @typedef {Object} LinkedPackage
* @property {string} registry - npm or oci
* @property {string} name
*/
/**
* What one save carries. Every field is optional and an absent one keeps the
* value it has.
* @typedef {Object} SettingsChange
* @property {string} [description] - empty clears it
* @property {string} [defaultBranch] - empty clears it
* @property {string[]} [protectedBranches]
* @property {string|null} [runner] - null clears it
* @property {LinkedPackage[]} [packages] - an empty list clears them
*/
/** What one linked package is keyed by: the pair, since names repeat. */
export const packageKey = (/** @type {LinkedPackage} */ entry) =>
`${entry.registry}:${entry.name}`;
/** Record pages one registry walk reads before it stops. */
const MAX_PAGES = 10;
/**
* Write repository settings.
* @param {import('#/lib/oauth.js').Session} session - the owner's own
* @param {string} repo - repository name on this site
* @param {string} space - the space holding a private repository, '' for one
* in the account's own repo
* @param {SettingsChange} changes
* @returns {Promise<void>}
*/
export async function updateSettings(session, repo, space, changes) {
const response = await authedFetch(
session,
`${account.pds}/xrpc/dev.pdsjs.git.updateSettings`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: repo,
...(space ? { space } : {}),
...changes,
}),
},
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new XrpcError(
response.status,
body.error ?? 'Unknown',
body.message ?? `The save failed (${response.status}).`,
);
}
// The tabs read the config record to decide which sections a repository
// has, and this write changed it.
forgetConfig(repo);
}
/**
* Rename a repository. The record key is its identity, so every issue, pull
* request and check still finds it; what moves is the address, which a clone
* remote and this page's own path carry.
* @param {import('#/lib/oauth.js').Session} session - the owner's own
* @param {string} repo - repository name on this site
* @param {string} to - the name to give it
* @returns {Promise<void>}
*/
export async function renameRepo(session, repo, to) {
const response = await authedFetch(
session,
`${account.pds}/xrpc/dev.pdsjs.git.renameRepo`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: repo, newName: to }),
},
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new XrpcError(
response.status,
body.error ?? 'Unknown',
body.message ?? `The rename failed (${response.status}).`,
);
}
forgetConfig(repo);
}
/**
* Delete a repository: the record, the config record beside it, and the
* bundle blobs neither of them holds any more. A repository holding a
* protected branch refuses, and the server says so.
* @param {import('#/lib/oauth.js').Session} session - the owner's own
* @param {string} repo - repository name on this site
* @param {string} space - the space holding a private repository, '' for one
* in the account's own repo
* @returns {Promise<void>}
*/
export async function deleteRepo(session, repo, space) {
const response = await authedFetch(
session,
`${account.pds}/xrpc/dev.pdsjs.git.deleteRepo`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: repo, ...(space ? { space } : {}) }),
},
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new XrpcError(
response.status,
body.error ?? 'Unknown',
body.message ?? `The delete failed (${response.status}).`,
);
}
forgetConfig(repo);
}
/**
* The names one collection in the account's repo holds. Reading is anonymous
* because these records are public, and a form offers them before anything
* is written.
* @param {string} collection
* @returns {Promise<string[]>}
*/
async function publishedNames(collection) {
/** @type {string[]} */
const names = [];
let cursor = '';
for (let page = 0; page < MAX_PAGES; page += 1) {
const params = new URLSearchParams({
repo: account.did,
collection,
limit: '100',
});
if (cursor) params.set('cursor', cursor);
const response = await fetch(
`${account.pds}/xrpc/com.atproto.repo.listRecords?${params}`,
);
if (!response.ok) return names;
const body =
/** @type {{records?: Array<{value?: {name?: unknown}}>, cursor?: string}} */ (
await response.json()
);
for (const record of body.records ?? []) {
const name = record.value?.name;
if (typeof name === 'string' && name) names.push(name);
}
if (!body.cursor) break;
cursor = body.cursor;
}
return names;
}
/**
* Every package this account publishes, which is what a repository may link
* to. A registry the server does not serve holds no records, so it reads the
* same as a registry nobody has published to: nothing to link.
* @returns {Promise<LinkedPackage[]>}
*/
export async function publishedPackages() {
const [npm, oci] = await Promise.all([
publishedNames(NPM_PACKAGE_COLLECTION).catch(() => []),
publishedNames(OCI_REPO_COLLECTION).catch(() => []),
]);
return [
...npm.map((name) => ({ registry: 'npm', name })),
...oci.map((name) => ({ registry: 'oci', name })),
];
}