/** * Git hosting as a server extension: the platform passes the factory to * core's `extensions` option. The extension always guards writes to repo * and config records (branch protection) and serves the dev.pdsjs.git XRPC * methods; file browsing and read-only smart HTTP under /git/ are the parts * the options turn on and off. * * This module must stay free of node-only imports; it runs on Cloudflare * Workers. */ import { cborDecode, cborDecodeJson } from '@pdsjs/core/repo'; /** @typedef {import('./hosting.js').GitSpaceAdmin} GitSpaceAdmin */ import { createGitBrowser } from './browser.js'; import { createConstellationDiscovery, noDiscovery } from './discovery.js'; import { createGitHosting } from './hosting.js'; import { createGitHttp } from './http.js'; import { GIT_IDENTITY_COLLECTION, GIT_REPO_COLLECTION } from './lexicon.js'; import { createGitMethods } from './methods.js'; import { createGitWriteGuard, GIT_CONFIG_COLLECTION } from './rules.js'; /** * The part of the space admin port git hosting needs, built on first use * over the host's space storage from @pdsjs/spaces, an optional peer * imported only then. * @param {import('@pdsjs/core/ports').SpaceStoragePort} spaceStorage * @param {() => Promise} getDid * @returns {GitSpaceAdmin} */ function lazySpaceAdmin(spaceStorage, getDid) { /** @type {Promise|null} */ let admin = null; const load = () => { admin ??= import('@pdsjs/spaces/admin').then(({ createSpaceAdmin }) => createSpaceAdmin({ spaceStorage, getDid }), ); return admin; }; return { createSpace: async (opts) => (await load()).createSpace(opts), createRecord: async (space, collection, rkey, record) => (await load()).createRecord(space, collection, rkey, record), putRecord: async (space, collection, rkey, record) => (await load()).putRecord(space, collection, rkey, record), getRecord: async (space, collection, rkey) => (await load()).getRecord(space, collection, rkey), deleteRecord: async (space, collection, rkey) => (await load()).deleteRecord(space, collection, rkey), removeMember: async (space, did) => (await load()).removeMember(space, did), }; } /** The collections every git extension owns, guard or full. */ const GIT_COLLECTIONS = [ GIT_REPO_COLLECTION, GIT_CONFIG_COLLECTION, GIT_IDENTITY_COLLECTION, 'dev.pdsjs.git.checkRequest', ]; /** * The largest bundle chain the server downloads whole to answer a read. A * repository pushed before object indexes existed opens this way; one with * indexes reads ranges and is never held whole. */ const DEFAULT_EAGER_LIMIT = 64 * 1024 * 1024; /** Opened repositories the server keeps between reads. */ const OPEN_REPOSITORIES = 4; /** * The blob readers over the host's blob store, whole and ranged, that the * guard's ancestry check and the browser's space reads share. * @param {import('@pdsjs/core/ports').ExtensionHost} host * @returns {{ * getBlob: (cid: string) => Promise, * getBlobRange: ((cid: string, start: number, end: number) => Promise)|undefined, * }} */ function buildBlobReaders(host) { const { blobs } = host; /** @param {string} cid */ const getBlob = async (cid) => { const did = await host.getDid(); if (!did) return null; const result = await blobs.get(did, cid); if (!result) return null; return result.data instanceof Uint8Array ? result.data : new Uint8Array(/** @type {ArrayBuffer} */ (result.data)); }; // A store that reads ranges lets the guard's ancestry check and the // browser read a repository lazily. getStream takes inclusive positions. /** @type {((cid: string, start: number, end: number) => Promise)|undefined} */ const getBlobRange = blobs.getStream ? async (cid, start, end) => { const did = await host.getDid(); if (!did) return null; const found = await blobs.getStream?.(did, cid, { start, end: end - 1, }); if (!found) return null; return new Uint8Array(await new Response(found.body).arrayBuffer()); } : undefined; return { getBlob, getBlobRange }; } /** * The branch-protection write guard, built over the host's storage. The * ancestry check reads the repository's own records and bundle blobs, so * the guard needs reads, not writes. * @param {import('@pdsjs/core/ports').ExtensionHost} host */ function buildWriteGuard(host) { const { actorStorage, spaceStorage } = host; const { getBlob, getBlobRange } = buildBlobReaders(host); return createGitWriteGuard({ getRecord: async (space, collection, rkey) => { if (space) { if (!spaceStorage) return null; const row = await spaceStorage.getSpaceRecord(space, collection, rkey); return row ? cborDecodeJson(row.value) : null; } const did = await host.getDid(); if (!did) return null; const record = await actorStorage.getRecord( `at://${did}/${collection}/${rkey}`, ); if (!record) return null; return cborDecode( record.value instanceof Uint8Array ? record.value : new Uint8Array(record.value), ); }, getBlob, getBlobRange, }); } /** * The branch-protection write guard alone, without the routes, the smart * HTTP endpoint or the file browser. A server whose git hosting runs * elsewhere mounts this and keeps the enforceable half: protected branches * refuse force pushes and deletion at the record write, wherever the reads * are served from. * @returns {import('@pdsjs/core/ports').ExtensionFactory} */ export function createGitGuardExtension() { return (host) => ({ name: 'git', collections: GIT_COLLECTIONS, writeGuard: buildWriteGuard(host), features: { git: true, gitBrowse: false }, }); } /** * @param {Object} [options] * @param {boolean} [options.http] - serve read-only git smart HTTP under /git/ * @param {boolean} [options.browse] - serve the reads of the bundle chain (default on) * @param {number} [options.eagerLimit] - the largest bundle chain to download whole for a read, in bytes; a larger repository without indexes answers RepositoryTooLarge * @param {import('./discovery.js').DiscoveryPort|null} [options.discovery] - what finds the pull requests, reviews and issues from accounts a repository's config does not name. By default the public Constellation index; null to read only the listed voices. * @returns {import('@pdsjs/core/ports').ExtensionFactory} */ export function createGitExtension({ http = false, browse = true, eagerLimit = DEFAULT_EAGER_LIMIT, discovery, } = {}) { return (host) => { const { spaceStorage } = host; // The account page is the owner's own surface, so a repository held in // a space reads from storage rather than the authenticated space // endpoints. A store that reads ranges lets the browser read a space // repository lazily; getStream takes inclusive positions. const browser = browse ? createGitBrowser({ xrpc: host.xrpc, cacheSize: OPEN_REPOSITORIES, maxEagerBytes: eagerLimit, spaceReader: spaceStorage ? { getRecord: async (space, rkey) => { const row = await spaceStorage.getSpaceRecord( space, 'dev.pdsjs.git.repo', rkey, ); return row ? { cid: row.cid, value: cborDecodeJson(row.value) } : null; }, ...buildBlobReaders(host), } : undefined, }) : null; // Reads go through the server's own fetch, so the handler needs no // storage access of its own. const gitHttp = http ? createGitHttp({ xrpc: host.xrpc }) : null; const hosting = createGitHosting({ host, httpEnabled: http, spaceBrowser: host.spaceBrowser ?? null, spaceAdmin: spaceStorage ? lazySpaceAdmin(spaceStorage, host.getDid) : null, }); return { name: 'git', collections: GIT_COLLECTIONS, writeGuard: buildWriteGuard(host), routes: createGitMethods({ host, browser, hosting, discovery: discovery === null ? noDiscovery() : (discovery ?? createConstellationDiscovery({ userAgent: `pds.js @pdsjs/git (${host.hostname ?? 'a pds.js server'})`, })), }), features: { git: true, gitBrowse: Boolean(browser) }, stats: (count) => ({ gitRepos: count(GIT_REPO_COLLECTION) }), ...(gitHttp ? { prefixes: ['/git'], handle: (request) => gitHttp.handle(request) } : {}), }; }; }