/** * The dev.pdsjs.git XRPC methods, over the hosting operations and the * browser. api-lexicon.js describes them. * * A read of the public repo is anonymous. A read naming a space, and every * procedure taken without a bearer token, run behind the owner's account * session. A procedure taken with a bearer token is authorized on the * repo scopes of the collections it writes, and the token's account must be * the repository's own. * * This module must stay free of node-only imports; it runs on Cloudflare * Workers. */ import { isSameOriginPost } from '@pdsjs/core/http'; import { ScopePermissions } from '@pdsjs/core/scope'; import { noDiscovery } from './discovery.js'; import { createIssueReader } from './issue-reader.js'; import { issueThreads } from './issues.js'; import { GIT_REPO_COLLECTION } from './lexicon.js'; import { createPullRequestReader } from './pull-request-reader.js'; import { GIT_CONFIG_COLLECTION } from './rules.js'; const GIT_CHECK_REQUEST_COLLECTION = 'dev.pdsjs.git.checkRequest'; /** A read under a current record cid never changes; the cid is in the URL. */ const IMMUTABLE = 'public, max-age=31536000, immutable'; const RAW_TEXT_TYPE = 'text/plain; charset=utf-8'; const RAW_BINARY_TYPE = 'application/octet-stream'; const DEFAULT_COMMITS = 50; const MAX_COMMITS = 500; /** * @typedef {ReturnType} GitBrowser */ /** * @typedef {Object} Located * @property {string} did * @property {string} name - what the repository is called, which is what a * reader asked for and what an answer says * @property {string} rkey - the record key, which is what every read below * addresses the repository by * @property {string|undefined} space * @property {string|undefined} from - the account whose copy is read, where * it is not this one's * @property {URLSearchParams} params * @property {URL} url */ /** * @param {string} name * @param {string} message * @param {number} status * @param {Record} [headers] */ const refusal = (name, message, status, headers) => Response.json({ error: name, message }, { status, headers }); /** * True when If-None-Match names the record's cid. * @param {Request} request * @param {string} cid */ function etagMatches(request, cid) { const raw = request.headers.get('if-none-match'); if (!raw) return false; return raw .split(',') .map((tag) => tag.trim()) .some((tag) => tag === '*' || tag.replace(/^W\//, '') === `"${cid}"`); } /** * The browser reports an unknown ref, path or commit and a path of the * wrong kind by message. Anything else is the server's own failure. * @param {unknown} err */ function isNotFound(err) { const message = err instanceof Error ? err.message : String(err); return message.startsWith('no such ') || message.startsWith('not a '); } /** @param {unknown} err */ const codeOf = (err) => /** @type {{code?: unknown}|null|undefined} */ (err)?.code; /** * @param {Object} ctx * @param {import('@pdsjs/core/ports').ExtensionHost} ctx.host * @param {GitBrowser|null} ctx.browser - Reads the bundle chain. Absent, the methods that need it are not registered, so describe omits them and a call answers MethodNotImplemented. * @param {ReturnType} ctx.hosting * @param {import('./discovery.js').DiscoveryPort} [ctx.discovery] - What finds the records about a repository in accounts the config does not name. Absent, only the listed voices are read. * @param {(who: import('./pull-requests.js').Contributor) => import('./pull-request-reader.js').CopyReader} [ctx.openCopy] - A browser over one contributor's server, in place of one over fetch. For tests. * @returns {Record} */ export function createGitMethods({ host, browser, hosting, discovery = noDiscovery(), openCopy, }) { const issues = createIssueReader({ resolveContributor: hosting.resolveContributor, readRecord: hosting.readRecord, discovery, }); /** * @param {Request} request * @param {string} did */ const ownerSession = async (request, did) => (await host.readAccountSession(request)) === did; /** * Whether the request carries the repository owner's identity: the account * session cookie, or a bearer token naming the account. A token only ever * lands here on a route marked auth: 'optional'. * @param {Request} request * @param {string} did * @param {{did?: string}|null} [auth] */ const isOwner = (request, did, auth) => auth ? auth.did === did : ownerSession(request, did); /** * A read of one repository. The handler gets the located repository and * answers the view, a Response of its own, or null for a repository the * browser cannot open. * @param {(located: Located) => Promise} handler * @param {Object} [options] * @param {string[]} [options.required] - parameters beyond `repo` * @param {boolean} [options.versioned] - whether the answer follows the record alone, so the record cid validates it. False for an answer that reads elsewhere too. * @returns {import('@pdsjs/core/ports').ExtensionRoute['handler']} */ function read(handler, { required = [], versioned = true } = {}) { return async (request, url, auth) => { const params = url.searchParams; for (const key of ['repo', ...required]) { if (!params.get(key)) { return refusal( 'InvalidRequest', `Missing required parameter: ${key}`, 400, ); } } const did = await host.getDid(); const name = /** @type {string} */ (params.get('repo')); const space = params.get('space') || undefined; // Another account's copy of this repository, read through this server // so a page talks to one address. A copy in a space is in the space, // which this server does not read. const asked = params.get('from') || undefined; const from = asked && asked !== did ? asked : undefined; if (from && space) { return refusal( 'InvalidRequest', 'A repository in a space has its copies in the space.', 400, ); } if (space && !(did && (await isOwner(request, did, auth)))) { return refusal( 'AuthenticationRequired', 'A repository in a space is reachable through the account session.', 401, ); } // One resolution per request, and the record's own version comes back // with it: the cid is the strong validator every read below is served // under, and any push writes a new one. const located = did ? await hosting.resolveRepo(did, name, space ?? null) : null; if (!did || !located) { return refusal('RepoNotFound', `No repository named ${name}.`, 404); } const { rkey, cid } = located; /** @type {Record} */ let headers = { 'Cache-Control': 'no-store' }; // The record cid validates a read of this server's own copy alone. if (versioned && !space && !from) { const wanted = params.get('cid'); if (wanted && wanted !== cid) { return refusal( 'RepoNotFound', `The repository is past version ${wanted}.`, 404, ); } headers = { 'Cache-Control': wanted ? IMMUTABLE : 'no-cache', ETag: `"${cid}"`, }; if (!wanted && etagMatches(request, cid)) { return new Response(null, { status: 304, headers }); } } /** @type {unknown} */ let body; try { body = await handler({ did, name, rkey, space, from, params, url }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (codeOf(err) === 'RepositoryTooLarge') { return refusal('RepositoryTooLarge', message, 400); } if (!isNotFound(err)) throw err; return refusal('NotFound', message, 404); } if (body === null) { return from ? refusal('NotFound', `No such copy at ${from}.`, 404) : refusal('RepoNotFound', `No repository named ${name}.`, 404); } if ( from && body && typeof body === 'object' && !(body instanceof Response) ) { // Whose copy answered, so a page can say so without another read. body = { ...body, copy: await hosting.resolveContributor(from) }; } if (body instanceof Response) { const merged = new Headers(body.headers); for (const [key, value] of Object.entries(headers)) { merged.set(key, value); } return new Response(body.body, { status: body.status, headers: merged, }); } return Response.json(body, { headers }); }; } /** * A write. With a bearer token, the token's repo scopes must cover every * grant; without one, the owner's session from a same-origin request. * @param {(did: string, body: Record) => Promise} handler * @param {Array<[collection: string, action: string]>} grants * @returns {import('@pdsjs/core/ports').ExtensionRoute['handler']} */ function procedure(handler, grants) { return async (request, url, auth) => { const did = await host.getDid(); if (!did) { return refusal('InvalidRequest', 'This server hosts no account.', 400); } if (auth) { if (auth.did !== did) { return refusal('Forbidden', 'The token names another account.', 403); } const permissions = new ScopePermissions(auth.scope); for (const [collection, action] of grants) { if (!permissions.allowsRepo(collection, action)) { return refusal( 'InvalidScope', `Missing required scope to ${action} ${collection}`, 403, ); } } } else { if (!(await ownerSession(request, did))) { return refusal( 'AuthenticationRequired', 'Authentication required', 401, ); } if (!isSameOriginPost(request, url)) { return refusal('Forbidden', 'Cross-origin request', 403); } } if (host.readOnly) { return refusal('AuthenticationRequired', 'This PDS is read-only', 401); } /** @type {unknown} */ let body; try { body = await host.readJsonBounded(request); } catch { return refusal('InvalidRequest', 'The body is not JSON.', 400); } if (body instanceof Response) return body; if (!body || typeof body !== 'object' || Array.isArray(body)) { return refusal('InvalidRequest', 'Expected a JSON object body.', 400); } const input = /** @type {Record} */ (body); try { return Response.json(await handler(did, input), { headers: { 'Cache-Control': 'no-store' }, }); } catch (err) { const code = codeOf(err); const name = typeof code === 'string' ? code : 'InvalidRequest'; return refusal( name, err instanceof Error ? err.message : String(err), name === 'RepoNotFound' ? 404 : 400, ); } }; } // The activity gauge reads the same window the card draws, 24 weeks, and a // bounded walk: the newest commits cover the window for any repository a // card gauge is drawn for, and the bound holds the cost of a busy one. const ACTIVITY_WINDOW_MS = 24 * 7 * 24 * 60 * 60 * 1000; const ACTIVITY_LIMIT = 2000; /** * The commit dates of one repository's default branch within the window. * @param {string} did * @param {{name: string, branches: number, defaultBranch: string|null}} view * @param {number} floor - the oldest date the gauge shows, in epoch ms * @returns {Promise} */ async function recentCommitDates(did, view, floor) { if (view.branches === 0 || !browser) return []; try { const log = await browser.listCommits( did, view.name, view.defaultBranch || 'main', ACTIVITY_LIMIT, ); const dates = []; for (const commit of log?.commits ?? []) { if (commit.committedAt >= floor) dates.push(commit.committedAt); } return dates; } catch { // A ref the record names but the store cannot open draws no gauge. return []; } } /** * dev.pdsjs.git.listRepos - the public repo's repositories for anyone; the * owner's session sees the repositories in spaces too. * @param {Request} request * @param {URL} url * @param {{did?: string}|null} [auth] */ async function listRepos(request, url, auth) { const did = await host.getDid(); if (!did) { return Response.json( { httpEnabled: false, repos: [] }, { headers: { 'Cache-Control': 'no-cache' } }, ); } const owner = auth ? auth.did === did : await ownerSession(request, did); const listing = await hosting.listRepos(did, url, { spaces: owner }); // The summary is a read per repository, so it runs only when the reader // asks, and only for the public repositories: a pull request and an issue // are discoverable there, and the commit walk opens the public store. A // repository in a space keeps its statements and its history there. if (url.searchParams.get('summary') === 'true') { const floor = Date.now() - ACTIVITY_WINDOW_MS; await Promise.all( listing.repos.map(async (repo) => { const view = /** @type {{space: string|null, name: string, branches: number, defaultBranch: string|null, openPulls?: number, openIssues?: number, activity?: number[]}} */ ( repo ); if (view.space) return; const [pullList, issueList, activity] = await Promise.all([ pulls.listPulls(did, view.name), issues.listIssues(did, view.name), recentCommitDates(did, view, floor), ]); let openIssues = 0; for (const thread of issueThreads(issueList?.statements ?? [], did)) { if (thread.state.state === 'open') openIssues++; } view.openPulls = pullList?.open.length ?? 0; view.openIssues = openIssues; view.activity = activity; }), ); } return Response.json(listing, { headers: { 'Cache-Control': owner ? 'no-store' : 'no-cache' }, }); } /** * dev.pdsjs.git.getAuthors * @param {Request} _request * @param {URL} url */ async function getAuthors(_request, url) { const did = await host.getDid(); return Response.json( did ? await hosting.listAuthors(did, url) : { owner: null, claims: [] }, { headers: { 'Cache-Control': 'public, max-age=300' } }, ); } /** @type {Record} */ const routes = { '/xrpc/dev.pdsjs.git.listRepos': { method: 'GET', auth: 'optional', handler: listRepos, }, '/xrpc/dev.pdsjs.git.getAuthors': { method: 'GET', handler: getAuthors }, '/xrpc/dev.pdsjs.git.getRepo': { method: 'GET', auth: 'optional', handler: read( async ({ did, name, space, url }) => { const { repos } = await hosting.listRepos(did, url, { spaces: Boolean(space), }); return ( repos.find( (repo) => repo.name === name && (repo.space ?? undefined) === space, ) ?? null ); }, { versioned: false }, ), }, '/xrpc/dev.pdsjs.git.listChecks': { method: 'GET', auth: 'optional', handler: read( ({ did, rkey, space }) => hosting.listChecks(did, rkey, space ?? null), { versioned: false }, ), }, // A repository in a space has its statements in the space, which the // discovery port cannot see. Until the space is read for them, it has // none to list. '/xrpc/dev.pdsjs.git.listCheckRuns': { method: 'GET', auth: 'optional', handler: read( ({ did, rkey, space }) => hosting.listCheckRuns(did, rkey, space ?? null), { versioned: false }, ), }, '/xrpc/dev.pdsjs.git.listIssues': { method: 'GET', auth: 'optional', handler: read( async ({ did, rkey, space }) => space ? { statements: [] } : issues.listIssues(did, rkey), { versioned: false }, ), }, '/xrpc/dev.pdsjs.git.createRepo': { method: 'POST', auth: 'optional', handler: procedure(hosting.createRepo, [[GIT_REPO_COLLECTION, 'create']]), }, '/xrpc/dev.pdsjs.git.deleteRepo': { method: 'POST', auth: 'optional', handler: procedure(hosting.deleteRepo, [ [GIT_REPO_COLLECTION, 'delete'], [GIT_CONFIG_COLLECTION, 'delete'], ]), }, '/xrpc/dev.pdsjs.git.renameRepo': { method: 'POST', auth: 'optional', handler: procedure(hosting.renameRepo, [ [GIT_REPO_COLLECTION, 'update'], [GIT_CONFIG_COLLECTION, 'update'], ]), }, '/xrpc/dev.pdsjs.git.updateSettings': { method: 'POST', auth: 'optional', handler: procedure(hosting.updateSettings, [ [GIT_REPO_COLLECTION, 'update'], [GIT_CONFIG_COLLECTION, 'create'], [GIT_CONFIG_COLLECTION, 'update'], ]), }, '/xrpc/dev.pdsjs.git.requestCheck': { method: 'POST', auth: 'optional', handler: procedure(hosting.requestCheck, [ [GIT_CHECK_REQUEST_COLLECTION, 'create'], [GIT_CHECK_REQUEST_COLLECTION, 'update'], ]), }, // A merge moves the canonical branch, which is the repository record's // own field, so it takes the scope that writes the record. '/xrpc/dev.pdsjs.git.mergePull': { method: 'POST', auth: 'optional', handler: procedure(hosting.mergePull, [[GIT_REPO_COLLECTION, 'update']]), }, }; if (!browser) return routes; const reader = browser; const pulls = createPullRequestReader({ resolveContributor: hosting.resolveContributor, readRecord: hosting.readRecord, browser: reader, discovery, openCopy, onNotice: (message) => console.warn(`[git] ${message}`), }); /** @param {URLSearchParams} params */ const refOf = (params) => params.get('ref') || undefined; /** * A read against whichever copy is asked for: this server's own, or one * the pull request reader holds a browser over. Null where the copy has no * server. * * The handler is given the record key **in the copy it reads**, which is * not the same key in each: a copy is its holder's own record, in their * repo, on their server. * @param {(at: GitBrowser, did: string, rkey: string, located: Located) => Promise} handler */ const ofCopy = (handler) => async (/** @type {Located} */ located) => { if (!located.from) { return handler(reader, located.did, located.rkey, located); } const at = await pulls.copy(located.from); if (!at) return null; const rkey = await pulls.copyKey(located.from, located.name); return rkey ? handler(at, located.from, rkey, located) : null; }; return { ...routes, // The copies of a repository in a space are in the space. Until they // are read from there, it has no pull requests to list. '/xrpc/dev.pdsjs.git.listPulls': { method: 'GET', auth: 'optional', handler: read( async ({ did, rkey, space }) => space ? { open: [], merged: [], collaborators: [], reviews: [], checks: [], configured: false, } : pulls.listPulls(did, rkey), { versioned: false }, ), }, '/xrpc/dev.pdsjs.git.getTree': { method: 'GET', auth: 'optional', handler: read( ofCopy((at, did, rkey, { space, params }) => at.listTree( did, rkey, refOf(params), params.get('path') || '', space, ), ), ), }, '/xrpc/dev.pdsjs.git.listFiles': { method: 'GET', auth: 'optional', handler: read( ofCopy((at, did, rkey, { space, params }) => at.listFiles(did, rkey, refOf(params), space), ), ), }, '/xrpc/dev.pdsjs.git.getFile': { method: 'GET', auth: 'optional', handler: read( ofCopy((at, did, rkey, { space, params }) => at.readFile( did, rkey, refOf(params), /** @type {string} */ (params.get('path')), space, ), ), { required: ['path'] }, ), }, '/xrpc/dev.pdsjs.git.getRaw': { method: 'GET', auth: 'optional', handler: read( ofCopy(async (at, did, rkey, { space, params }) => { const file = await at.readBytes( did, rkey, refOf(params), /** @type {string} */ (params.get('path')), space, ); if (!file) return null; return new Response(/** @type {BodyInit} */ (file.bytes), { headers: { 'Content-Type': file.mediaType ?? (file.binary ? RAW_BINARY_TYPE : RAW_TEXT_TYPE), 'Content-Length': String(file.size), 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox", }, }); }), { required: ['path'] }, ), }, '/xrpc/dev.pdsjs.git.listCommits': { method: 'GET', auth: 'optional', handler: read( ofCopy((at, did, rkey, { space, params }) => { const limit = Math.min( MAX_COMMITS, Math.max(1, Number(params.get('limit')) || DEFAULT_COMMITS), ); // A range is the commits a branch carries past its base: what a // pull request is made of. const range = params.get('range'); if (range?.includes('..')) { const [base, tip] = range.split('..'); return at.listRange(did, rkey, base, tip, limit, space); } return at.listCommits( did, rkey, refOf(params), limit, space, params.get('path') || '', ); }), ), }, '/xrpc/dev.pdsjs.git.getCommit': { method: 'GET', auth: 'optional', handler: read( ofCopy(async (at, did, rkey, { space, params }) => { const sha = /** @type {string} */ (params.get('sha')); // base..tip reads the changes across a range as one diff. const [base, tip] = sha.includes('..') ? sha.split('..') : ['', sha]; const commit = base ? await at.readRange(did, rkey, base, tip, space) : await at.readCommit(did, rkey, tip, space); if (!commit) return null; return { ...commit, files: commit.files.map( ({ oldImage: _old, newImage: _new, ...file }) => file, ), }; }), { required: ['sha'] }, ), }, '/xrpc/dev.pdsjs.git.getLastCommit': { method: 'GET', auth: 'optional', handler: read( ofCopy(async (at, did, rkey, { space, params }) => ({ commit: await at.lastCommit( did, rkey, refOf(params), params.get('path') || '', undefined, space, ), })), ), }, }; }