~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
703 lines · 23.5 KB Raw History
/**
 * 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,
          ),
        })),
      ),
    },
  };
}