chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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<typeof import('./browser.js').createGitBrowser>} 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<string, string>} [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<typeof import('./hosting.js').createGitHosting>} 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<string, import('@pdsjs/core/ports').ExtensionRoute>}
*/
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<unknown>} 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<string, string>} */
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<string, any>) => Promise<unknown>} 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<string, any>} */ (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<number[]>}
*/
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<string, import('@pdsjs/core/ports').ExtensionRoute>} */
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<unknown>} 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,
),
})),
),
},
};
}