/** * 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} */ export function mergeGitConfig(existing, changes) { const previous = /** @type {{createdAt?: unknown}} */ (existing ?? {}); /** @type {Record} */ 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} value * @returns {Record[]} */ export function latestCheckEntries(value) { if (Array.isArray(value.checks)) { return /** @type {Record[]} */ (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} */ (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>} */ export function checkRunsOf(records, subjectUri) { /** @type {Array & {startedAt: string}>} */ const runs = []; for (const entry of records) { const row = /** @type {{value?: unknown, uri?: unknown}|null} */ (entry); const check = /** @type {Record} */ (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 & {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} 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} */ const profiles = new Map(); /** * The profiles behind a set of DIDs, from the cache or the AppView. * @param {string[]} dids * @returns {Promise>} */ async function readProfiles(dids) { const now = Date.now(); /** @type {Map} */ 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} */ 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} */ 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} */ 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} */ async function resolvePdsEndpoint(did) { return pdsEndpointOf(await readDidDocument(did)); } /** @type {Map} */ 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} */ 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} */ 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} */ 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} 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} */ 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} 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} 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} */ (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} */ async function privateSpaceOf(did, name) { const type = GIT_SPACE_TYPE; try { return ( await /** @type {NonNullable} */ ( 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} 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} */ ({ ...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} */ ( 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} */ ( 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} */ ( 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} */ ( 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} */ ( 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}} */ ( 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} 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/.'); } 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} 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, }; }