chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* Server-side branch protection: the write guard a PDS applies to git repo
* records before it accepts them.
*
* Rules live in a dev.pdsjs.git.config record beside the repo record, under
* the same rkey. A separate record rather than a field: every push rebuilds
* the repo record whole, so a field would vanish on the first push, and the
* config must outlive them all. The guard reads the config at write time and
* refuses a repo-record write that force-pushes, deletes, or drops a
* protected branch. Rules bind the copy on the enforcing PDS; a member's
* copy on their own server answers to that server.
*
* The same record holds the settings no guard enforces, the check runner and
* the linked packages, so their readers are here too.
*
* Workers-clean: pack parsing only, no node imports.
*/
import { parseBundle } from './bundle.js';
import { indexPacks, parseCommit, resolveToCommit } from './objects.js';
import { concatChunks, indexPartsOf, parseRepoRecord } from './record.js';
import { createLazyStore } from './store.js';
export const GIT_CONFIG_COLLECTION = 'dev.pdsjs.git.config';
// A walk from the new tip must find the old tip within this many commits for
// a push to count as fast-forward. A history deeper than this between two
// pushes is outside personal scale; refusing keeps the check bounded.
const MAX_WALK = 100_000;
export class GitRuleError extends Error {
/**
* @param {string} message
* @param {string} [code] - machine-readable error name for the response
*/
constructor(message, code = 'ProtectedBranch') {
super(message);
this.name = 'GitRuleError';
this.code = code;
}
}
/**
* The protected branch names a config value carries. Anything malformed
* counts as unprotected rather than failing the write it gates.
* @param {unknown} config
* @returns {string[]}
*/
export function protectedBranchesOf(config) {
const list = /** @type {{protectedBranches?: unknown}} */ (config ?? {})
.protectedBranches;
if (!Array.isArray(list)) return [];
return list.filter(
(branch) => typeof branch === 'string' && branch.length > 0,
);
}
/**
* The DID a config value names as the repository's check runner. A reader
* asks that account's PDS for check records and trusts no other. Anything
* malformed counts as no runner.
* @param {unknown} config
* @returns {string|null}
*/
export function runnerOf(config) {
const runner = /** @type {{runner?: unknown}} */ (config ?? {}).runner;
return typeof runner === 'string' && runner.startsWith('did:')
? runner
: null;
}
/**
* Every runner DID a config value names: the `runners` list, or the single
* `runner` where there is no list.
*
* One runner can forge nothing, because a reader follows its index back to
* the records it names. What one runner can do is omit. Naming several means
* a pull request is found if any one of them indexed it, and each is trusted
* no further than the checks already are.
* @param {unknown} config
* @returns {string[]}
*/
export function runnersOf(config) {
const listed = didList(
/** @type {{runners?: unknown}} */ (config ?? {}).runners,
);
if (listed.length > 0) return listed;
const single = runnerOf(config);
return single ? [single] : [];
}
/**
* The DIDs a config value names as collaborators: the accounts holding a copy
* of this repository whose branches count as work on it. atproto has no
* reverse index and a copy names no upstream, so this list is the only thing
* connecting the canonical repository to the copies on other servers.
*
* The account holding the config is not in the list. It works on its own
* repository by definition, and a reader adds it.
* @param {unknown} config
* @returns {string[]}
*/
export function collaboratorsOf(config) {
return didList(
/** @type {{collaborators?: unknown}} */ (config ?? {}).collaborators,
);
}
/**
* The DIDs a config value names as reviewers, whose review records a reader
* shows. A review is a statement by an account about a commit, published in
* that account's own repo, so anyone may write one about any public
* repository. This list is the repository's answer to whose statements it
* puts on screen.
* @param {unknown} config
* @returns {string[]}
*/
export function reviewersOf(config) {
return didList(/** @type {{reviewers?: unknown}} */ (config ?? {}).reviewers);
}
/**
* @param {unknown} list
* @returns {string[]} the DIDs in it, in order, without repeats
*/
function didList(list) {
if (!Array.isArray(list)) return [];
const dids = list.filter(
(did) => typeof did === 'string' && did.startsWith('did:'),
);
return [...new Set(dids)];
}
/** The registries a repository may name a package in. */
export const PACKAGE_REGISTRIES = ['npm', 'oci'];
/**
* @typedef {Object} LinkedPackage
* @property {string} registry - one of PACKAGE_REGISTRIES
* @property {string} name - the package name in that registry
*/
/**
* The packages a config value names as built from this repository. atproto
* has no reverse index and a package record holds no repository name, so the
* link is this list and nothing else. An entry naming a registry this build
* has no words for is dropped, which is how an older reader treats a
* registry added later.
* @param {unknown} config
* @returns {LinkedPackage[]}
*/
export function linkedPackagesOf(config) {
const list = /** @type {{packages?: unknown}} */ (config ?? {}).packages;
if (!Array.isArray(list)) return [];
/** @type {LinkedPackage[]} */
const packages = [];
for (const entry of list) {
const { registry, name } =
/** @type {{registry?: unknown, name?: unknown}} */ (entry ?? {});
if (typeof registry !== 'string' || typeof name !== 'string') continue;
if (!PACKAGE_REGISTRIES.includes(registry) || name.length === 0) continue;
packages.push({ registry, name });
}
return packages;
}
/**
* @param {unknown} value - a git repo record value
* @returns {Map<string, string>} ref name to sha
*/
function refMap(value) {
try {
const record = parseRepoRecord(value);
return new Map(record.refs.map((ref) => [ref.name, ref.sha]));
} catch {
return new Map();
}
}
/**
* The commit graph, read through the object index so the server holds the
* commits alone. A chain whose bundles all carry an index, on a store that
* reads ranges, costs the index blobs and the commits; every other chain
* downloads whole.
* @param {import('./record.js').GitRepoRecord} record
* @param {(cid: string) => Promise<Uint8Array|null>} getBlob
* @param {(cid: string, start: number, end: number) => Promise<Uint8Array|null>} getBlobRange
* @returns {Promise<Map<string, import('./objects.js').GitObject>|null>} the
* commits and tags by id, or null if the chain has no index to read
*/
async function loadCommitsLazily(record, getBlob, getBlobRange) {
if (record.bundles.length === 0) return null;
if (record.bundles.some((bundle) => indexPartsOf(bundle).length === 0)) {
return null;
}
/** @param {string} cid @param {number} [start] @param {number} [end] */
const read = async (cid, start, end) => {
const bytes =
start === undefined || end === undefined
? await getBlob(cid)
: await getBlobRange(cid, start, end);
if (!bytes) {
throw new GitRuleError(
`bundle chunk ${cid} is not in blob storage`,
'InvalidRecord',
);
}
return bytes;
};
const store = await createLazyStore(
record,
(cid) => read(cid),
(cid, start, end) => read(cid, start, end),
);
const shas = await store.prefetchCommits();
/** @type {Map<string, import('./objects.js').GitObject>} */
const objects = new Map();
for (const sha of shas) {
const object = store.peek(sha);
if (object) objects.set(sha, object);
}
return objects;
}
/**
* @param {unknown} value - the new repo record, whose chain holds the graph
* @param {(cid: string) => Promise<Uint8Array|null>} getBlob
* @param {((cid: string, start: number, end: number) => Promise<Uint8Array|null>)} [getBlobRange]
* @returns {Promise<Map<string, import('./objects.js').GitObject>>} the
* commits and tags the chain holds, by id
*/
async function loadObjects(value, getBlob, getBlobRange) {
const record = parseRepoRecord(value);
if (getBlobRange) {
const lazy = await loadCommitsLazily(record, getBlob, getBlobRange);
if (lazy) return lazy;
}
/** @type {Uint8Array[]} */
const packs = [];
for (const bundle of record.bundles) {
/** @type {Uint8Array[]} */
const chunks = [];
for (const part of bundle.parts) {
const bytes = await getBlob(part.ref.$link);
if (!bytes) {
throw new GitRuleError(
`bundle chunk ${part.ref.$link} is not in blob storage`,
'InvalidRecord',
);
}
chunks.push(bytes);
}
packs.push(parseBundle(concatChunks(chunks)).pack);
}
// The guard walks commit parents, so trees and blobs are needed only as
// delta bases while the pack is read.
return await indexPacks(packs, undefined, undefined, { retain: 'commits' });
}
/**
* Whether old is an ancestor of new: walk parents from the new tip. A parent
* outside the pack index is a bundle prerequisite, reachable only through
* history already accepted, so the walk stops there.
* @param {Map<string, import('./objects.js').GitObject>} objects
* @param {string} oldSha
* @param {string} newSha
* @returns {boolean}
*/
function isAncestorOf(objects, oldSha, newSha) {
const frontier = [resolveToCommit(objects, newSha)];
const seen = new Set(frontier);
let walked = 0;
while (frontier.length > 0) {
const sha = /** @type {string} */ (frontier.pop());
if (sha === oldSha) return true;
if (walked++ > MAX_WALK) return false;
const object = objects.get(sha);
if (object?.type !== 'commit') continue;
for (const parent of parseCommit(object.data).parents) {
if (!seen.has(parent)) {
seen.add(parent);
frontier.push(parent);
}
}
}
return false;
}
/**
* @param {unknown} value - a config record value about to be written
*/
function assertValidConfig(value) {
const { protectedBranches: list, packages } =
/** @type {{protectedBranches?: unknown, packages?: unknown}} */ (
value ?? {}
);
if (list !== undefined) {
if (
!Array.isArray(list) ||
list.some(
(branch) =>
typeof branch !== 'string' ||
branch.length === 0 ||
/[\s*?[\\~^:]/.test(branch),
)
) {
throw new GitRuleError(
'protectedBranches must be a list of branch names',
'InvalidRecord',
);
}
}
if (packages === undefined) return;
// The registry is checked for shape, not against the list this build knows.
// A registry added after this server was deployed stays writable, and a
// reader that has no words for it drops the entry.
if (
!Array.isArray(packages) ||
packages.some((entry) => {
const { registry, name } =
/** @type {{registry?: unknown, name?: unknown}} */ (entry ?? {});
return (
typeof registry !== 'string' ||
registry.length === 0 ||
typeof name !== 'string' ||
name.length === 0 ||
/\s/.test(name)
);
})
) {
throw new GitRuleError(
'packages must be a list of {registry, name} entries',
'InvalidRecord',
);
}
}
/**
* One write under inspection. Structural rather than imported from core: this
* package has no dependency on core, only a shape they agree on.
* @typedef {Object} GuardedWrite
* @property {string|null} space - space AT-URI, or null for the public repo
* @property {string} collection
* @property {string} rkey
* @property {unknown|null} prevValue - the stored record, null on creation
* @property {unknown|null} nextValue - the incoming record, null on deletion
*/
/**
* Build the write guard the platforms hand to the record write paths.
*
* @param {Object} ctx
* @param {(space: string|null, collection: string, rkey: string) => Promise<unknown|null>} ctx.getRecord -
* a decoded record value from the public repo (space null) or a space
* @param {(cid: string) => Promise<Uint8Array|null>} ctx.getBlob
* @param {(cid: string, start: number, end: number) => Promise<Uint8Array|null>} [ctx.getBlobRange] -
* Optional: one byte range of a blob, end exclusive. A store that reads
* ranges lets the ancestry check read commits out of the chain rather
* than downloading it whole.
* @returns {{collections: string[], check: (write: GuardedWrite) => Promise<void>}}
*/
export function createGitWriteGuard({ getRecord, getBlob, getBlobRange }) {
return {
collections: ['dev.pdsjs.git.repo', GIT_CONFIG_COLLECTION],
async check({ space, collection, rkey, prevValue, nextValue }) {
if (collection === GIT_CONFIG_COLLECTION) {
// Deleting the config removes the rules, which their owner may do.
if (nextValue !== null) assertValidConfig(nextValue);
return;
}
const config = await getRecord(space, GIT_CONFIG_COLLECTION, rkey);
const branches = protectedBranchesOf(config);
// No rules, or the repo record's creation: nothing to hold a ref
// transition against.
if (branches.length === 0 || prevValue === null) return;
const prevRefs = refMap(prevValue);
if (nextValue === null) {
const held = branches.filter((branch) =>
prevRefs.has(`refs/heads/${branch}`),
);
if (held.length > 0) {
throw new GitRuleError(
`the repository holds protected branches (${held.join(', ')}); remove the protection before deleting it`,
);
}
return;
}
const nextRefs = refMap(nextValue);
/** @type {Map<string, import('./objects.js').GitObject>|null} */
let objects = null;
for (const branch of branches) {
const ref = `refs/heads/${branch}`;
const oldSha = prevRefs.get(ref);
if (!oldSha) continue;
const newSha = nextRefs.get(ref);
if (!newSha) {
throw new GitRuleError(
`${branch} is protected and cannot be deleted`,
);
}
if (newSha === oldSha) continue;
objects ??= await loadObjects(nextValue, getBlob, getBlobRange);
if (!isAncestorOf(objects, oldSha, newSha)) {
throw new GitRuleError(
`${branch} is protected; only fast-forward pushes are accepted`,
);
}
}
},
};
}