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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
194 lines · 6.5 KB Raw History
// Repository settings, written as the owner.
//
// The settings live in two records. The description and the default branch
// are fields of the repository record, which every push rewrites. The
// protected branches, the check runner and the linked packages are in the
// config record beside it, which no push touches. One request carries both,
// and a field the request leaves out keeps the value it has, so a form saves
// the group it edits alone.
//
// A write runs as the owner, on the owner's own PDS, under the owner's OAuth
// session. The server accepts it only from a token whose account is the
// site's, so nobody else's session configures a repository here.

import { XrpcError } from '@pdsjs/git/client';
import { NPM_PACKAGE_COLLECTION } from '@pdsjs/npm/lexicon';
import { OCI_REPO_COLLECTION } from '@pdsjs/oci/lexicon';
import { account, forgetConfig } from '#/lib/git.js';
import { authedFetch } from '#/lib/oauth.js';

/**
 * One package a repository builds, in the pair that names it. A name repeats
 * across registries, so neither half identifies one on its own.
 * @typedef {Object} LinkedPackage
 * @property {string} registry - npm or oci
 * @property {string} name
 */

/**
 * What one save carries. Every field is optional and an absent one keeps the
 * value it has.
 * @typedef {Object} SettingsChange
 * @property {string} [description] - empty clears it
 * @property {string} [defaultBranch] - empty clears it
 * @property {string[]} [protectedBranches]
 * @property {string|null} [runner] - null clears it
 * @property {LinkedPackage[]} [packages] - an empty list clears them
 */

/** What one linked package is keyed by: the pair, since names repeat. */
export const packageKey = (/** @type {LinkedPackage} */ entry) =>
  `${entry.registry}:${entry.name}`;

/** Record pages one registry walk reads before it stops. */
const MAX_PAGES = 10;

/**
 * Write repository settings.
 * @param {import('#/lib/oauth.js').Session} session - the owner's own
 * @param {string} repo - repository name on this site
 * @param {string} space - the space holding a private repository, '' for one
 *   in the account's own repo
 * @param {SettingsChange} changes
 * @returns {Promise<void>}
 */
export async function updateSettings(session, repo, space, changes) {
  const response = await authedFetch(
    session,
    `${account.pds}/xrpc/dev.pdsjs.git.updateSettings`,
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        name: repo,
        ...(space ? { space } : {}),
        ...changes,
      }),
    },
  );
  const body = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new XrpcError(
      response.status,
      body.error ?? 'Unknown',
      body.message ?? `The save failed (${response.status}).`,
    );
  }
  // The tabs read the config record to decide which sections a repository
  // has, and this write changed it.
  forgetConfig(repo);
}

/**
 * Rename a repository. The record key is its identity, so every issue, pull
 * request and check still finds it; what moves is the address, which a clone
 * remote and this page's own path carry.
 * @param {import('#/lib/oauth.js').Session} session - the owner's own
 * @param {string} repo - repository name on this site
 * @param {string} to - the name to give it
 * @returns {Promise<void>}
 */
export async function renameRepo(session, repo, to) {
  const response = await authedFetch(
    session,
    `${account.pds}/xrpc/dev.pdsjs.git.renameRepo`,
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ name: repo, newName: to }),
    },
  );
  const body = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new XrpcError(
      response.status,
      body.error ?? 'Unknown',
      body.message ?? `The rename failed (${response.status}).`,
    );
  }
  forgetConfig(repo);
}

/**
 * Delete a repository: the record, the config record beside it, and the
 * bundle blobs neither of them holds any more. A repository holding a
 * protected branch refuses, and the server says so.
 * @param {import('#/lib/oauth.js').Session} session - the owner's own
 * @param {string} repo - repository name on this site
 * @param {string} space - the space holding a private repository, '' for one
 *   in the account's own repo
 * @returns {Promise<void>}
 */
export async function deleteRepo(session, repo, space) {
  const response = await authedFetch(
    session,
    `${account.pds}/xrpc/dev.pdsjs.git.deleteRepo`,
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ name: repo, ...(space ? { space } : {}) }),
    },
  );
  const body = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new XrpcError(
      response.status,
      body.error ?? 'Unknown',
      body.message ?? `The delete failed (${response.status}).`,
    );
  }
  forgetConfig(repo);
}

/**
 * The names one collection in the account's repo holds. Reading is anonymous
 * because these records are public, and a form offers them before anything
 * is written.
 * @param {string} collection
 * @returns {Promise<string[]>}
 */
async function publishedNames(collection) {
  /** @type {string[]} */
  const names = [];
  let cursor = '';
  for (let page = 0; page < MAX_PAGES; page += 1) {
    const params = new URLSearchParams({
      repo: account.did,
      collection,
      limit: '100',
    });
    if (cursor) params.set('cursor', cursor);
    const response = await fetch(
      `${account.pds}/xrpc/com.atproto.repo.listRecords?${params}`,
    );
    if (!response.ok) return names;
    const body =
      /** @type {{records?: Array<{value?: {name?: unknown}}>, cursor?: string}} */ (
        await response.json()
      );
    for (const record of body.records ?? []) {
      const name = record.value?.name;
      if (typeof name === 'string' && name) names.push(name);
    }
    if (!body.cursor) break;
    cursor = body.cursor;
  }
  return names;
}

/**
 * Every package this account publishes, which is what a repository may link
 * to. A registry the server does not serve holds no records, so it reads the
 * same as a registry nobody has published to: nothing to link.
 * @returns {Promise<LinkedPackage[]>}
 */
export async function publishedPackages() {
  const [npm, oci] = await Promise.all([
    publishedNames(NPM_PACKAGE_COLLECTION).catch(() => []),
    publishedNames(OCI_REPO_COLLECTION).catch(() => []),
  ]);
  return [
    ...npm.map((name) => ({ registry: 'npm', name })),
    ...oci.map((name) => ({ registry: 'oci', name })),
  ];
}