chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* Git hosting operations over the host's storage: the repository listing,
* creation and settings, the check reads and the check requests. The
* dev.pdsjs.git XRPC methods serve these.
*
* This module must stay free of node-only imports; it runs on Cloudflare
* Workers.
*/
import {
cborDecode,
cborEncodeDagCbor,
cidToString,
createBlobCid,
createCid,
toDisplayValue,
} from '@pdsjs/core/repo';
import { parseIdentityRecord } from './authors.js';
import { resolveRepoLocation } from './identity.js';
import { GIT_IDENTITY_COLLECTION, GIT_SPACE_TYPE } from './lexicon.js';
import { createGitMerger } from './merger.js';
import {
createRepoKey,
isValidRepoName,
repoNameOf,
rkeyOf,
} from './record.js';
import { linkedPackagesOf, PACKAGE_REGISTRIES, runnerOf } from './rules.js';
/** Where profiles are read from, absent a configured AppView. */
const DEFAULT_APP_VIEW = 'https://public.api.bsky.app';
/** The AppView answers at most this many actors per call. */
const PROFILE_BATCH = 25;
/** How long a profile read stands before the AppView is asked again. */
const PROFILE_TTL_MS = 5 * 60 * 1000;
/** A profile read slower than this costs the face, not the answer. */
const PROFILE_READ_TIMEOUT_MS = 5000;
/**
* The config record a repository settings write produces.
*
* The record carries every repository setting, and one request covers one
* setting. Fields the request leaves out are copied from the record it
* replaces, including fields this server does not recognize.
* @param {Object|null} existing - the config record being replaced
* @param {Object} changes
* @param {string} changes.name
* @param {string[]} [changes.protectedBranches]
* @param {string|null} [changes.runner] - null clears the field
* @param {Array<{registry: string, name: string}>} [changes.packages] - an
* empty list clears the field
* @returns {Record<string, unknown>}
*/
export function mergeGitConfig(existing, changes) {
const previous = /** @type {{createdAt?: unknown}} */ (existing ?? {});
/** @type {Record<string, unknown>} */
const config = {
...previous,
$type: 'dev.pdsjs.git.config',
name: changes.name,
createdAt:
typeof previous.createdAt === 'string'
? previous.createdAt
: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
if (changes.protectedBranches !== undefined) {
config.protectedBranches = changes.protectedBranches;
}
if (changes.runner === null) delete config.runner;
else if (changes.runner !== undefined) config.runner = changes.runner;
if (changes.packages !== undefined) {
if (changes.packages.length === 0) delete config.packages;
else config.packages = changes.packages;
}
return config;
}
/**
* A refusal the XRPC methods name by code; the account routes report the
* message alone.
* @param {string} code
* @param {string} message
*/
const refuse = (code, message) => Object.assign(new Error(message), { code });
/** Characters an atproto record key permits. */
const RKEY_SAFE = /[^A-Za-z0-9._~:-]/g;
/** Branches one repository page asks the runner about. */
const MAX_CHECKED_BRANCHES = 20;
/** Repositories read per page when a name is resolved. */
const MAX_REPOS = 100;
/** Pages of them read before the index stands as it is. */
const MAX_REPO_PAGES = 20;
/** A runner that answers slower than this costs its badge, not the page. */
const CHECK_READ_TIMEOUT_MS = 5000;
/**
* The record key holding the latest check for one ref of one repository.
* `latestCheckKey` in @pdsjs/git-ci is the same derivation, and is the copy
* the runner writes with. Both must agree or a check never resolves.
* @param {string} did - repository owner
* @param {string} repoName
* @param {string} ref - full ref name
* @returns {string}
*/
export function latestCheckKey(did, repoName, ref) {
return `${did}:${repoName}:${ref}`.replace(RKEY_SAFE, '_');
}
/**
* The per-workflow entries of a latest-check record. A record written
* before entries existed carries one workflow's fields at the top level;
* it reads as one entry. `latestCheckEntries` in @pdsjs/git-ci is the same
* normalization, and is the copy the runner writes against.
* @param {Record<string, unknown>} value
* @returns {Record<string, unknown>[]}
*/
export function latestCheckEntries(value) {
if (Array.isArray(value.checks)) {
return /** @type {Record<string, unknown>[]} */ (value.checks);
}
if (typeof value.status !== 'string') return [];
return [{ workflow: 'ci', ...value }];
}
/**
* The record key of a check request for one ref of one repository. The
* request is in this account's own repo, so only the repository and the ref
* name it. `checkRequestKey` in @pdsjs/git-ci is the same derivation, and is
* the copy the runner reads with. Both must agree or asking twice leaves two
* records and the runner checks twice.
* @param {string} repoName
* @param {string} ref - full ref name
* @returns {string}
*/
export function checkRequestKey(repoName, ref) {
return `${repoName}:${ref}`.replace(RKEY_SAFE, '_');
}
/**
* The atproto PDS endpoint a DID document names.
* @param {unknown} doc
* @returns {string|null}
*/
export function pdsEndpointOf(doc) {
const services = /** @type {{service?: unknown}} */ (doc ?? {}).service;
if (!Array.isArray(services)) return null;
for (const entry of services) {
const service = /** @type {Record<string, unknown>} */ (entry ?? {});
const id = typeof service.id === 'string' ? service.id : '';
if (
id.endsWith('#atproto_pds') &&
typeof service.serviceEndpoint === 'string'
) {
return service.serviceEndpoint.replace(/\/+$/, '');
}
}
return null;
}
/**
* The handle a DID document publishes, or the DID where it publishes none.
* @param {unknown} doc
* @param {string} did
* @returns {string}
*/
export function handleOf(doc, did) {
const aka = /** @type {{alsoKnownAs?: unknown}|null} */ (doc)?.alsoKnownAs;
const name = (Array.isArray(aka) ? aka : []).find((entry) =>
String(entry).startsWith('at://'),
);
return name ? String(name).slice('at://'.length) : did;
}
/** Pages of the runner's runs read before the rest are left. */
const CHECK_RUN_PAGES = 10;
/**
* The runner's runs about one repository, newest first, each carrying the
* record key that addresses its page. The runner keys runs by TID in one
* collection, so the listing is filtered here rather than by its server.
* @param {unknown[]} records - listRecords entries from the runner
* @param {string} subjectUri - the repository record's AT-URI
* @returns {Array<Record<string, unknown>>}
*/
export function checkRunsOf(records, subjectUri) {
/** @type {Array<Record<string, unknown> & {startedAt: string}>} */
const runs = [];
for (const entry of records) {
const row = /** @type {{value?: unknown, uri?: unknown}|null} */ (entry);
const check = /** @type {Record<string, unknown>} */ (row?.value ?? {});
const subject = /** @type {{uri?: unknown}} */ (check.subject ?? {});
if (subject.uri !== subjectUri) continue;
if (typeof check.sha !== 'string' || typeof check.startedAt !== 'string') {
continue;
}
const rkey = String(row?.uri ?? '')
.split('/')
.pop();
runs.push(
/** @type {Record<string, unknown> & {startedAt: string}} */ (
rkey ? { ...check, rkey } : check
),
);
}
return runs.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
}
/** How long a resolved account is trusted before its document is read again. */
const CONTRIBUTOR_TTL_MS = 5 * 60 * 1000;
/**
* The part of the space admin port git hosting needs.
* @typedef {Pick<import('@pdsjs/core/ports').SpaceAdminPort, 'createSpace'|'createRecord'|'putRecord'|'getRecord'|'deleteRecord'|'removeMember'>} GitSpaceAdmin
*/
/**
* @param {Object} ctx
* @param {import('@pdsjs/core/ports').ExtensionHost} ctx.host
* @param {boolean} ctx.httpEnabled - Whether smart HTTP serves under /git/, so the listing can show plain-git clone URLs
* @param {import('@pdsjs/core/ports').SpaceBrowserPort|null} ctx.spaceBrowser - Lists repositories held in spaces. Absent, only the public repo is listed.
* @param {GitSpaceAdmin|null} ctx.spaceAdmin - Creates and configures repositories in spaces. Absent, those requests refuse.
* @param {string} [ctx.appView] - the AppView profiles are read from
*/
export function createGitHosting({
host,
httpEnabled,
spaceBrowser,
spaceAdmin,
appView = DEFAULT_APP_VIEW,
}) {
const { actorStorage, blobs, blobUploadLimit, createCommit, plcUrl } = host;
const reapOrphanedBlobs = host.reapOrphanedBlobs;
// The server's composed write guard, which the git rules join. Every write
// a git method makes into the public repo consults it, so a merge and a
// settings change refuse what a protected branch refuses.
const writeGuard = host.writeGuard;
/**
* @typedef {{did: string, handle: string, displayName: string, avatar: string}} Profile
*/
/** Profiles by DID, each with when it was read. A DID the AppView does not know is kept as null, so it is not asked again for the TTL. */
/** @type {Map<string, {profile: Profile|null, at: number}>} */
const profiles = new Map();
/**
* The profiles behind a set of DIDs, from the cache or the AppView.
* @param {string[]} dids
* @returns {Promise<Map<string, Profile|null>>}
*/
async function readProfiles(dids) {
const now = Date.now();
/** @type {Map<string, Profile|null>} */
const found = new Map();
const wanted = [];
for (const did of new Set(dids)) {
const held = profiles.get(did);
if (held && now - held.at < PROFILE_TTL_MS) found.set(did, held.profile);
else wanted.push(did);
}
for (let at = 0; at < wanted.length; at += PROFILE_BATCH) {
const batch = wanted.slice(at, at + PROFILE_BATCH);
const params = new URLSearchParams();
for (const did of batch) params.append('actors', did);
/** @type {Array<{did?: unknown, handle?: unknown, displayName?: unknown, avatar?: unknown}>} */
let answered = [];
try {
const res = await fetch(
`${appView}/xrpc/app.bsky.actor.getProfiles?${params}`,
{ signal: AbortSignal.timeout(PROFILE_READ_TIMEOUT_MS) },
);
if (res.ok) {
answered =
/** @type {{profiles?: typeof answered}} */ (await res.json())
.profiles ?? [];
}
} catch {
// An AppView that does not answer costs the faces, not the answer.
continue;
}
for (const did of batch) found.set(did, null);
for (const entry of answered) {
if (typeof entry.did !== 'string') continue;
found.set(entry.did, {
did: entry.did,
handle: typeof entry.handle === 'string' ? entry.handle : '',
displayName:
typeof entry.displayName === 'string' ? entry.displayName : '',
avatar: typeof entry.avatar === 'string' ? entry.avatar : '',
});
}
for (const did of batch) {
profiles.set(did, { profile: found.get(did) ?? null, at: now });
}
}
return found;
}
/**
* The list-page summary of one git repo record's display value.
* @param {{description?: unknown, defaultBranch?: unknown, refs?: unknown, bundles?: unknown, createdAt?: unknown, updatedAt?: unknown}} value
*/
function summarizeGitRecord(value) {
const refs = /** @type {Array<{name?: unknown, sha?: unknown}>} */ (
Array.isArray(value.refs) ? value.refs : []
).filter(
(ref) => typeof ref.name === 'string' && typeof ref.sha === 'string',
);
const bundles = /** @type {Array<{parts?: Array<{size?: unknown}>}>} */ (
Array.isArray(value.bundles) ? value.bundles : []
);
let size = 0;
let parts = 0;
for (const bundle of bundles) {
for (const part of bundle.parts ?? []) {
parts += 1;
if (typeof part.size === 'number') size += part.size;
}
}
return {
description:
typeof value.description === 'string' ? value.description : null,
defaultBranch:
typeof value.defaultBranch === 'string' ? value.defaultBranch : null,
refs,
branches: refs.filter((ref) =>
/** @type {string} */ (ref.name).startsWith('refs/heads/'),
).length,
tags: refs.filter((ref) =>
/** @type {string} */ (ref.name).startsWith('refs/tags/'),
).length,
bundles: bundles.length,
parts,
size,
createdAt: typeof value.createdAt === 'string' ? value.createdAt : null,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : null,
};
}
/**
* The protected branch names a config record carries, for display; the
* write guard is what enforces them.
* @param {unknown} config
* @returns {string[]}
*/
function protectedBranchesOf(config) {
const list = /** @type {{protectedBranches?: unknown}} */ (config ?? {})
.protectedBranches;
if (!Array.isArray(list)) return [];
return list.filter((branch) => typeof branch === 'string');
}
/**
* The config record's settings, as a repository listing reports them.
* @param {unknown} config
* @returns {{protectedBranches: string[], runner: string|null, packages: Array<{registry: string, name: string}>}}
*/
function configSummary(config) {
return {
protectedBranches: protectedBranchesOf(config),
runner: runnerOf(config),
packages: linkedPackagesOf(config),
};
}
/**
* A record from the public repo, decoded raw.
* @param {string} did @param {string} collection @param {string} rkey
* @returns {Promise<Object|null>}
*/
async function readPublicRecord(did, collection, rkey) {
const record = await actorStorage.getRecord(
`at://${did}/${collection}/${rkey}`,
);
if (!record) return null;
return /** @type {Object} */ (
cborDecode(
record.value instanceof Uint8Array
? record.value
: new Uint8Array(record.value),
)
);
}
/**
* Write a record into the public repo through the ordinary commit path.
* @param {string} did @param {string} collection @param {string} rkey
* @param {Object} record
* @returns {Promise<void>}
*/
async function writePublicRecord(did, collection, rkey, record) {
const uri = `at://${did}/${collection}/${rkey}`;
const held = await actorStorage.getRecord(uri);
// The guard reads the transition the way a bearer-token write would be
// read, so a merge onto a protected branch is held to the same rule: a
// fast-forward, never a force-push or a delete.
if (writeGuard?.collections.includes(collection)) {
const prevValue =
held && held.value
? cborDecode(
held.value instanceof Uint8Array
? held.value
: new Uint8Array(held.value),
)
: null;
await writeGuard.check({
space: null,
collection,
rkey,
prevValue,
nextValue: record,
});
}
const action = held ? 'update' : 'create';
const encoded = cborEncodeDagCbor(record);
const cid = cidToString(await createCid(encoded));
await actorStorage.putBlock(cid, encoded);
await actorStorage.putRecord(uri, cid, collection, rkey, encoded);
await createCommit(did, [{ action, uri, cid }]);
}
/**
* A DID's document, from the directory this server resolves against.
* @param {string} did
* @returns {Promise<unknown|null>}
*/
async function readDidDocument(did) {
const url = did.startsWith('did:web:')
? `https://${decodeURIComponent(did.slice('did:web:'.length))}/.well-known/did.json`
: `${plcUrl}/${encodeURIComponent(did)}`;
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(CHECK_READ_TIMEOUT_MS),
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
/**
* The PDS a DID publishes, from its DID document.
* @param {string} did
* @returns {Promise<string|null>}
*/
async function resolvePdsEndpoint(did) {
return pdsEndpointOf(await readDidDocument(did));
}
/** @type {Map<string, {at: number, who: import('./pull-requests.js').Contributor|null}>} */
const contributors = new Map();
/**
* Where one account's records and blobs are read from, and what it is
* called. An account with no document, or one naming no PDS, is null.
* @param {string} did
* @returns {Promise<import('./pull-requests.js').Contributor|null>}
*/
async function resolveContributor(did) {
const held = contributors.get(did);
if (held && Date.now() - held.at < CONTRIBUTOR_TTL_MS) return held.who;
const doc = await readDidDocument(did);
const pds = pdsEndpointOf(doc);
const who = pds ? { did, pds, handle: handleOf(doc, did) } : null;
contributors.set(did, { at: Date.now(), who });
return who;
}
/**
* The record key of every repository this account holds, by name. Filled
* from a listing, so it can be a name short and never wrong about one: a
* repository the helper pushed never passes through this module.
* @type {Map<string, string>}
*/
const keysByName = new Map();
/**
* Read the listing and index it by name. Every page, because a name this
* misses is a repository that answers as missing, where a listing that
* stops short only shows fewer.
*/
async function indexRepos() {
/** @type {Map<string, string>} */
const found = new Map();
let cursor = null;
for (let page = 0; page < MAX_REPO_PAGES; page += 1) {
/** @type {{records: import('@pdsjs/core/ports').RecordResult[], cursor: string|null}} */
const read = await actorStorage.listRecords(
'dev.pdsjs.git.repo',
cursor,
MAX_REPOS,
);
for (const record of read.records) {
const rkey = rkeyOf(record.uri);
found.set(
repoNameOf(
cborDecode(
record.value instanceof Uint8Array
? record.value
: new Uint8Array(record.value),
),
rkey,
),
rkey,
);
}
if (!read.cursor) break;
cursor = read.cursor;
}
keysByName.clear();
for (const [name, rkey] of found) keysByName.set(name, rkey);
}
/**
* The repository one name addresses: its record key, its version and its
* value.
*
* The name is an address and the key is an identity. Every public surface
* takes a name; this is the one place that turns one into a key, and
* everything below it works in keys. A rename moves the name and leaves
* the key alone, so nothing that named the repository is rewritten.
* @param {string} did
* @param {string} name
* @param {string|null} [space]
* @returns {Promise<{rkey: string, cid: string, value: Object}|null>}
*/
async function resolveRepo(did, name, space) {
if (!name) return null;
if (space) {
if (!spaceBrowser) return null;
const { records } = await spaceBrowser.listRecords(
space,
'dev.pdsjs.git.repo',
MAX_REPOS,
);
const found = records.find(
(record) => repoNameOf(record.value, record.rkey) === name,
);
return found
? {
rkey: found.rkey,
cid: found.cid,
value: /** @type {Object} */ (found.value),
}
: null;
}
// Two passes: the index answers, and a name it does not hold, or holds
// against a record that has since moved, is looked up again once.
for (let pass = 0; pass < 2; pass += 1) {
const rkey = keysByName.get(name);
if (rkey !== undefined) {
const record = await actorStorage.getRecord(
`at://${did}/dev.pdsjs.git.repo/${rkey}`,
);
if (record) {
const value = /** @type {Object} */ (
cborDecode(
record.value instanceof Uint8Array
? record.value
: new Uint8Array(record.value),
)
);
if (repoNameOf(value, rkey) === name) {
return { rkey, cid: record.cid, value };
}
}
}
if (pass === 0) await indexRepos();
}
return null;
}
/**
* Git repositories hosted in this account: dev.pdsjs.git.repo records
* summarized for a listing, with clone URLs derived from the handle and
* the request origin.
* @param {string} did
* @param {URL} url - the request URL, whose origin names the HTTP clone host
* @param {Object} [options]
* @param {boolean} [options.spaces] - whether to list the repositories held in spaces (default on)
*/
async function listRepos(did, url, { spaces = true } = {}) {
const authority = (await actorStorage.getHandle()) || did;
const page = await actorStorage.listRecords(
'dev.pdsjs.git.repo',
null,
100,
);
const repos = [];
for (const record of page.records) {
const value = /** @type {{name?: unknown}} */ (
toDisplayValue(
cborDecode(
record.value instanceof Uint8Array
? record.value
: new Uint8Array(record.value),
),
)
);
const rkey = rkeyOf(record.uri);
const name = repoNameOf(value, rkey);
repos.push({
name,
uri: record.uri,
cid: record.cid,
space: null,
spaceOwned: false,
...summarizeGitRecord(/** @type {any} */ (value)),
// The config sits at the repository's own key, so the pair stays
// together when the name moves.
...configSummary(
await readPublicRecord(did, 'dev.pdsjs.git.config', rkey),
),
cloneUrl: `atproto://${authority}/${name}`,
httpCloneUrl: httpEnabled
? `${url.protocol}//${url.host}/git/${authority}/${name}`
: null,
});
}
// Repositories in spaces: this account's own record in each space it
// holds, whether it is the space's authority or a member with a copy.
// The remote URL names the space authority, which for a member copy is
// another account, so it appears by DID rather than a handle.
if (spaces && spaceBrowser) {
for (const space of await spaceBrowser.listSpaces()) {
if (space.deletedAt) continue;
const { records } = await spaceBrowser.listRecords(
space.uri,
'dev.pdsjs.git.repo',
100,
);
const configs = await spaceBrowser.listRecords(
space.uri,
'dev.pdsjs.git.config',
100,
);
const configByRkey = new Map(
configs.records.map((config) => [config.rkey, config.value]),
);
const spaceAuthority =
space.spaceDid === did ? authority : space.spaceDid;
for (const record of records) {
const name = repoNameOf(record.value, record.rkey);
repos.push({
name,
uri: `${space.uri}/${did}/dev.pdsjs.git.repo/${record.rkey}`,
space: space.uri,
spaceOwned: space.isOwner,
...summarizeGitRecord(/** @type {any} */ (record.value)),
...configSummary(configByRkey.get(record.rkey)),
cloneUrl: `atproto://${spaceAuthority}/${name}`,
httpCloneUrl: null,
});
}
}
}
repos.sort((a, b) =>
(b.updatedAt ?? b.createdAt ?? '').localeCompare(
a.updatedAt ?? a.createdAt ?? '',
),
);
return { httpEnabled: Boolean(httpEnabled), repos };
}
/**
* Create an empty repository, the way the hosted forges do: the record
* exists at once with no refs and no bundles, so a page can hold the setup
* instructions, and the first push fills it. A body naming a space, or a
* type to create one from, makes it private.
* @param {string} did
* @param {Record<string, any>} body
*/
async function createRepo(did, body) {
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!isValidRepoName(name)) {
throw new Error('A repository name uses letters, digits, and . _ ~ -');
}
const description =
typeof body.description === 'string' ? body.description.trim() : '';
// The name is the address a clone URL and a page path carry, so one
// repository holds it. The key is minted and says nothing about it.
if (await nameTaken(did, name)) {
throw refuse('RepoExists', `A repository named ${name} already exists.`);
}
const record = {
$type: 'dev.pdsjs.git.repo',
name,
...(description ? { description } : {}),
refs: [],
bundles: [],
createdAt: new Date().toISOString(),
};
// A private repository is addressed by its space, which is keyed by the
// name, so its record takes the name too and the pair is reachable by
// anyone who knows it. A public one takes a minted key.
if (body.private) {
if (!spaceAdmin) throw new Error('Spaces are not enabled.');
const space = await privateSpaceOf(did, name);
await spaceAdmin.createRecord(space, 'dev.pdsjs.git.repo', name, record);
return { name, space };
}
const rkey = createRepoKey();
await writePublicRecord(did, 'dev.pdsjs.git.repo', rkey, record);
keysByName.set(name, rkey);
return { name, space: null };
}
/**
* Whether this account already holds a repository of that name, in its own
* repo or in a space.
* @param {string} did
* @param {string} name
* @returns {Promise<boolean>}
*/
async function nameTaken(did, name) {
if (await resolveRepo(did, name, null)) return true;
for (const space of (await spaceBrowser?.listSpaces()) ?? []) {
if (space.deletedAt) continue;
if (await resolveRepo(did, name, space.uri)) return true;
}
return false;
}
/**
* Rename a repository: one field on its record.
*
* The key is the identity, so every statement about the repository, every
* fork that names it upstream and every check still find it. What moves is
* the address: a clone remote and a page path carry the name, so both are
* re-pointed by hand.
*
* A repository in a space keeps the name it has. The space is what makes it
* private and what an address resolves to, it is keyed by the name, and a
* space key cannot move.
* @param {string} did
* @param {Record<string, any>} body
*/
async function renameRepo(did, body) {
const name = typeof body.name === 'string' ? body.name.trim() : '';
const to = typeof body.newName === 'string' ? body.newName.trim() : '';
if (!name || !to) {
throw new Error('Name the repository and the name to give it.');
}
if (!isValidRepoName(to)) {
throw new Error('A repository name uses letters, digits, and . _ ~ -');
}
if (typeof body.space === 'string' && body.space) {
throw refuse(
'SpaceRepository',
'A private repository is addressed by the space holding it, which is keyed by its name, so the name stays.',
);
}
const located = await resolveRepo(did, name, null);
if (!located) throw refuse('RepoNotFound', `No repository named ${name}.`);
if (to === name) return { name: to, rkey: located.rkey };
if (await nameTaken(did, to)) {
throw refuse('RepoExists', `A repository named ${to} already exists.`);
}
const { rkey } = located;
await writePublicRecord(did, 'dev.pdsjs.git.repo', rkey, {
...located.value,
name: to,
});
// The config carries the name it was written under, so the pair agrees.
const config = await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);
if (config) {
await writePublicRecord(did, 'dev.pdsjs.git.config', rkey, {
...config,
name: to,
});
}
keysByName.delete(name);
keysByName.set(to, rkey);
return { name: to, rkey };
}
/**
* Delete a repository: the record, the config record beside it, and the
* bundle blobs that neither of them holds any more.
*
* The guard reads the config for the branches it protects, so it is
* consulted before either record goes. A repository holding a protected
* branch refuses, and its owner drops the protection first.
* @param {string} did
* @param {Record<string, any>} body
*/
async function deleteRepo(did, body) {
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) throw new Error('Name the repository to delete.');
const space =
typeof body.space === 'string' && body.space ? body.space : null;
if (space && !spaceAdmin) throw new Error('Spaces are not enabled.');
const admin = /** @type {NonNullable<typeof spaceAdmin>} */ (spaceAdmin);
const located = await resolveRepo(did, name, space);
if (!located) throw refuse('RepoNotFound', `No repository named ${name}.`);
const { rkey } = located;
if (writeGuard?.collections.includes('dev.pdsjs.git.repo')) {
await writeGuard.check({
space,
collection: 'dev.pdsjs.git.repo',
rkey,
prevValue: located.value,
nextValue: null,
});
}
const config = space
? await admin.getRecord(space, 'dev.pdsjs.git.config', rkey)
: await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);
if (space) {
await admin.deleteRecord(space, 'dev.pdsjs.git.repo', rkey);
if (config) await admin.deleteRecord(space, 'dev.pdsjs.git.config', rkey);
// The space holds this repository alone and is keyed by its name, so
// creating that name again reuses it. The member list goes with the
// repository, or the next repository of that name admits people its
// owner never added.
for (const member of (await spaceBrowser?.listMembers(space)) ?? []) {
if (member.did !== did) await admin.removeMember(space, member.did);
}
} else {
/** @type {Array<{action: string, uri: string}>} */
const ops = [];
for (const collection of config
? ['dev.pdsjs.git.repo', 'dev.pdsjs.git.config']
: ['dev.pdsjs.git.repo']) {
const uri = `at://${did}/${collection}/${rkey}`;
await actorStorage.unlinkBlobsFromRecord(uri);
await actorStorage.deleteRecord(uri);
ops.push({ action: 'delete', uri });
}
await createCommit(did, ops);
}
// The bundle chain was this repository's alone. Freeing it here rather
// than at the hourly sweep is what makes the storage bar move when the
// owner deletes something large.
await reapOrphanedBlobs(did, Date.now() + 1000);
keysByName.delete(name);
return { name, space };
}
/**
* The space a private repository lives in: one of the git type, keyed by
* the repository's own name. That is the whole of what makes a repository
* private, so a reader never has to know what a space is and the helper
* finds it from the name alone. A space of that key that already exists is
* reused, so a create that failed after making the space can be retried.
* @param {string} did
* @param {string} name
* @returns {Promise<string>}
*/
async function privateSpaceOf(did, name) {
const type = GIT_SPACE_TYPE;
try {
return (
await /** @type {NonNullable<typeof spaceAdmin>} */ (
spaceAdmin
).createSpace({ type, skey: name })
).uri;
} catch (err) {
// The key is the whole address, so an existing space of that key reads
// as the same one. The message is spaces' admin's own.
const message = err instanceof Error ? err.message : String(err);
const existing = `at://${did}/space/${type}/${name}`;
if (message === `space already exists: ${existing}`) return existing;
throw err;
}
}
/**
* Repository settings: description and default branch live on the repo
* record; protected branches, the check runner and the linked packages
* live in the config record beside it, which pushes never touch and the
* write guard enforces.
* @param {string} did
* @param {Record<string, any>} body
*/
async function updateSettings(did, body) {
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) throw new Error('Name the repository to configure.');
const space =
typeof body.space === 'string' && body.space ? body.space : null;
if (space && !spaceAdmin) throw new Error('Spaces are not enabled.');
// What the body says is checked before anything is read, so a malformed
// request is refused whether or not the repository is there.
/** @type {string[]|undefined} */
let protectedBranches;
/** @type {string|null|undefined} */
let runner;
/** @type {Array<{registry: string, name: string}>|undefined} */
let packages;
const configured =
body.protectedBranches !== undefined ||
body.runner !== undefined ||
body.packages !== undefined;
if (body.protectedBranches !== undefined) {
if (
!Array.isArray(body.protectedBranches) ||
body.protectedBranches.some(
(/** @type {unknown} */ branch) =>
typeof branch !== 'string' ||
branch.length === 0 ||
/[\s*?[\\~^:]/.test(branch),
)
) {
throw new Error('protectedBranches must be a list of branch names.');
}
protectedBranches = [...new Set(body.protectedBranches)].sort();
}
if (body.runner !== undefined) {
if (
body.runner !== null &&
(typeof body.runner !== 'string' || !body.runner.startsWith('did:'))
) {
throw new Error('runner must be a DID, or null to clear it.');
}
runner = body.runner;
}
if (body.packages !== undefined) {
if (!Array.isArray(body.packages)) {
throw new Error('packages must be a list.');
}
packages = body.packages.map((/** @type {unknown} */ entry) => {
const { registry, name: packageName } =
/** @type {{registry?: unknown, name?: unknown}} */ (entry ?? {});
if (
typeof registry !== 'string' ||
!PACKAGE_REGISTRIES.includes(registry) ||
typeof packageName !== 'string' ||
!packageName.trim()
) {
throw new Error(
`Each package names a registry (${PACKAGE_REGISTRIES.join(' or ')}) and a package name.`,
);
}
return { registry, name: packageName.trim() };
});
}
const located = await resolveRepo(did, name, space);
if (!located) throw refuse('RepoNotFound', `No repository named ${name}.`);
const { rkey } = located;
if (body.description !== undefined || body.defaultBranch !== undefined) {
const patched = /** @type {Record<string, unknown>} */ ({
...located.value,
});
if (body.description !== undefined) {
const description =
typeof body.description === 'string' ? body.description.trim() : '';
if (description) patched.description = description;
else delete patched.description;
}
if (body.defaultBranch !== undefined) {
const branch =
typeof body.defaultBranch === 'string'
? body.defaultBranch.trim()
: '';
if (branch) patched.defaultBranch = branch;
else delete patched.defaultBranch;
}
if (space) {
await /** @type {NonNullable<typeof spaceAdmin>} */ (
spaceAdmin
).putRecord(space, 'dev.pdsjs.git.repo', rkey, patched);
} else {
await writePublicRecord(did, 'dev.pdsjs.git.repo', rkey, patched);
}
}
if (configured) {
// The config sits at the repository's own key, so a rename moves the
// name and leaves the pair together.
const existing = space
? await /** @type {NonNullable<typeof spaceAdmin>} */ (
spaceAdmin
).getRecord(space, 'dev.pdsjs.git.config', rkey)
: await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);
const config = mergeGitConfig(existing, {
name,
protectedBranches,
runner,
packages,
});
if (space) {
await /** @type {NonNullable<typeof spaceAdmin>} */ (
spaceAdmin
).putRecord(space, 'dev.pdsjs.git.config', rkey, config);
} else {
await writePublicRecord(did, 'dev.pdsjs.git.config', rkey, config);
}
}
return { name, space, protectedBranches, runner, packages };
}
/**
* The latest check on each branch of one repository.
*
* Checks live in the runner's repo on the runner's PDS, and atproto has no
* reverse index, so this reads them where the config record says they are.
* @param {string} did
* @param {string} rkey - the repository's record key
* @param {string|null} space
*/
async function listChecks(did, rkey, space) {
if (!rkey) throw new Error('Name the repository.');
const config = space
? await /** @type {NonNullable<typeof spaceAdmin>} */ (
spaceAdmin
)?.getRecord(space, 'dev.pdsjs.git.config', rkey)
: await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);
const runner = runnerOf(config);
if (!runner) return { runner: null, checks: [] };
const service = await resolvePdsEndpoint(runner);
if (!service) return { runner, checks: [], unreachable: true };
const repo = space
? await /** @type {NonNullable<typeof spaceAdmin>} */ (
spaceAdmin
)?.getRecord(space, 'dev.pdsjs.git.repo', rkey)
: await readPublicRecord(did, 'dev.pdsjs.git.repo', rkey);
const refs = /** @type {{refs?: unknown}} */ (repo ?? {}).refs;
/** @type {string[]} */
const branches = [];
for (const entry of Array.isArray(refs) ? refs : []) {
if (branches.length === MAX_CHECKED_BRANCHES) break;
const ref = /** @type {{name?: unknown}} */ (entry ?? {}).name;
if (typeof ref === 'string' && ref.startsWith('refs/heads/')) {
branches.push(ref);
}
}
const checks = await Promise.all(
branches.map(async (ref) => {
const params = new URLSearchParams({
repo: runner,
collection: 'dev.pdsjs.git.latestCheck',
rkey: latestCheckKey(did, rkey, ref),
});
try {
const res = await fetch(
`${service}/xrpc/com.atproto.repo.getRecord?${params}`,
{ signal: AbortSignal.timeout(CHECK_READ_TIMEOUT_MS) },
);
if (!res.ok) return null;
const body = /** @type {{value?: Record<string, unknown>}} */ (
await res.json()
);
if (!body.value) return null;
return latestCheckEntries(body.value).map((entry) => ({
...entry,
ref,
}));
} catch {
// A runner whose PDS is down costs the badge, not the page.
return null;
}
}),
);
return { runner, service, checks: checks.filter(Boolean).flat() };
}
/**
* Ask the runner to check a ref again.
*
* The runner reads the firehose and listens on no port, so the request is a
* record in this account's own repo. Writing it is the whole operation: the
* commit reaches the runner, and the check appears in listChecks when the
* run publishes.
* @param {string} did
* @param {Record<string, any>} body
*/
async function requestCheck(did, body) {
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) throw new Error('Name the repository to check.');
const ref = typeof body.ref === 'string' ? body.ref.trim() : '';
if (!ref.startsWith('refs/heads/')) {
throw new Error('Name a branch to check, as refs/heads/<name>.');
}
if (body.space) {
throw new Error(
'A repository in a space has no public clone URL, so a runner cannot check it.',
);
}
const located = await resolveRepo(did, name, null);
if (!located) throw refuse('RepoNotFound', `No repository named ${name}.`);
const { rkey } = located;
const config = await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);
const runner = runnerOf(config);
if (!runner) {
throw refuse(
'NoRunner',
"Set a check runner in this repository's settings.",
);
}
await writePublicRecord(
did,
'dev.pdsjs.git.checkRequest',
checkRequestKey(rkey, ref),
{
$type: 'dev.pdsjs.git.checkRequest',
repo: rkey,
ref,
// The runner tells a fresh request from a replayed one by the
// record version. Asking twice must therefore change the record.
requestedAt: new Date().toISOString(),
},
);
return { runner, ref };
}
/**
* The identities this account claims, each with the profile behind it,
* and the account's own profile.
* @param {string} did
* @param {URL} url - the request URL, whose origin serves a claim's own face
*/
async function listAuthors(did, url) {
const record = await readPublicRecord(did, GIT_IDENTITY_COLLECTION, 'self');
const claims = record ? parseIdentityRecord(record) : [];
const resolved = await readProfiles([
did,
...claims.map((claim) => claim.did).filter(Boolean),
]);
return {
owner: resolved.get(did) ?? null,
claims: claims.map((claim) => ({
name: claim.name,
email: claim.email,
label: claim.label,
did: claim.did,
avatar: claim.avatarCid
? `${url.origin}/xrpc/com.atproto.sync.getBlob?${new URLSearchParams({ did, cid: claim.avatarCid })}`
: '',
profile: claim.did ? (resolved.get(claim.did) ?? null) : null,
})),
};
}
/**
* Put a pull request's work on this repository's canonical branch.
*
* The work is a branch in another account's copy, on their own server. Its
* objects are read across the network and the ones this copy lacks ride in
* a new bundle. Nothing is written to their repository.
*
* The merger is built here rather than by the platform: the pack format is
* this package's, and everything it needs of the server is on the host.
* The record write goes through the ordinary commit path, so the write
* guard sees the merge and a protected branch still refuses anything but a
* fast-forward.
* @param {string} did @param {Record<string, unknown>} body
*/
async function mergePull(did, body) {
const name = typeof body.name === 'string' ? body.name.trim() : '';
const from = typeof body.did === 'string' ? body.did.trim() : '';
const ref = typeof body.ref === 'string' ? body.ref.trim() : '';
if (!name || !from || !ref) {
throw new Error('Name the repository, the account and the branch.');
}
const located = await resolveRepo(did, name, null);
if (!located) throw refuse('RepoNotFound', `No repository named ${name}.`);
const merger = createGitMerger({
readRecord: readPublicRecord,
writeRecord: writePublicRecord,
readBlob: async (owner, cid) => {
const held = await blobs.get(owner, cid);
if (!held) return null;
return held.data instanceof Uint8Array
? held.data
: new Uint8Array(/** @type {ArrayBuffer} */ (held.data));
},
writeBlob: async (owner, data, mimeType) => {
const cid = cidToString(await createBlobCid(data));
await blobs.put(owner, cid, data, mimeType);
await actorStorage.putBlob(cid, mimeType, data.length);
return { ref: { $link: cid }, mimeType, size: data.length };
},
linkBlob: (cid, uri) => actorStorage.linkBlobToRecord(cid, uri),
chunkSize: blobUploadLimit,
// This server's own PLC, not the ambient environment's. The other
// account's DID document is what says where their copy lives.
locate: (owner) =>
resolveRepoLocation(
owner,
plcUrl ? { ATPROTO_GIT_PLC_URL: plcUrl } : {},
),
handle: () => actorStorage.getHandle(),
});
return merger.merge({
did,
rkey: located.rkey,
name,
from,
ref,
mode: body.mode === 'squash' ? 'squash' : 'merge',
handle: typeof body.handle === 'string' ? body.handle : '',
subject: typeof body.subject === 'string' ? body.subject : '',
body: typeof body.body === 'string' ? body.body : '',
});
}
/**
* Every run the runner has published about one repository, newest first.
* What the checks page lists; listChecks answers the latest per branch.
* A repository in a space has no runner that can clone it, and no runs.
* @param {string} did @param {string} rkey - the repository's record key
* @param {string|null} space
*/
async function listCheckRuns(did, rkey, space) {
if (!rkey) throw new Error('Name the repository.');
if (space) return { runner: null, runs: [] };
const config = await readPublicRecord(did, 'dev.pdsjs.git.config', rkey);
const runner = runnerOf(config);
if (!runner) return { runner: null, runs: [] };
const service = await resolvePdsEndpoint(runner);
if (!service) return { runner, runs: [], unreachable: true };
/** @type {unknown[]} */
const records = [];
let cursor = '';
for (let page = 0; page < CHECK_RUN_PAGES; page++) {
const params = new URLSearchParams({
repo: runner,
collection: 'dev.pdsjs.git.check',
limit: '100',
});
if (cursor) params.set('cursor', cursor);
try {
const res = await fetch(
`${service}/xrpc/com.atproto.repo.listRecords?${params}`,
{ signal: AbortSignal.timeout(CHECK_READ_TIMEOUT_MS) },
);
if (!res.ok) break;
const body = /** @type {{records?: unknown[], cursor?: unknown}} */ (
await res.json()
);
records.push(...(body.records ?? []));
cursor = typeof body.cursor === 'string' ? body.cursor : '';
if (!cursor) break;
} catch {
break;
}
}
return {
runner,
service,
runs: checkRunsOf(records, `at://${did}/dev.pdsjs.git.repo/${rkey}`),
};
}
return {
listRepos,
createRepo,
deleteRepo,
renameRepo,
resolveRepo,
updateSettings,
listChecks,
listCheckRuns,
requestCheck,
listAuthors,
mergePull,
resolveContributor,
readRecord: readPublicRecord,
};
}