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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
287 lines · 10.0 KB Raw History
// Deploy the repository browser to the account's own site.
//
// The build is one self-contained document, so the site record names one
// file and a visitor holding cached HTML can never ask for an asset the
// record no longer carries.
//
// Usage: PDS_URL=… PDS_DID=… PDS_APP_PASSWORD=… npm run deploy --workspace @pdsjs/git-ui

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { cidToString, createBlobCid } from '@pdsjs/core/repo';
import { SCOPE } from '../src/lib/scope.js';

const PDS = process.env.PDS_URL;
const DID = process.env.PDS_DID;
if (!PDS || !DID) throw new Error('Set PDS_URL and PDS_DID.');
const SITE = process.env.GIT_SITE || 'git';
const SITES = 'dev.pdsjs.site.deploy';
const DEFS = 'dev.pdsjs.query.def';
const REPOS = 'dev.pdsjs.git.repo';
const IDENTS = 'dev.pdsjs.git.identity';
const QUERY = 'git-repos';
const SORT = { field: 'updatedAt', direction: 'desc' };

async function xrpc(path, init = {}) {
  const response = await fetch(`${PDS}/xrpc/${path}`, init);
  if (!response.ok) {
    throw new Error(
      `${path.split('?')[0]} ${response.status}: ${(await response.text()).slice(0, 200)}`,
    );
  }
  return response.json();
}

const password = process.env.PDS_APP_PASSWORD;
if (!password) throw new Error('Set PDS_APP_PASSWORD.');
const session = await xrpc('com.atproto.server.createSession', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ identifier: DID, password }),
});
const auth = { Authorization: `Bearer ${session.accessJwt}` };

/** The fields a rebuild moves on its own, which say nothing about the app. */
const TIMESTAMPS = ['createdAt', 'updatedAt'];

/**
 * A record as one string, with the keys in one order and the timestamps
 * blanked. The PDS returns a record decoded from CBOR, so its key order is
 * its own and never the order this file writes.
 * @param {unknown} value
 * @returns {string}
 */
function comparable(value) {
  if (Array.isArray(value)) return `[${value.map(comparable).join(',')}]`;
  if (value === null || typeof value !== 'object') return JSON.stringify(value);
  const entries = Object.entries(value)
    .filter(([key]) => !TIMESTAMPS.includes(key))
    .sort(([a], [b]) => (a < b ? -1 : 1));
  return `{${entries.map(([k, v]) => `${k}:${comparable(v)}`).join(',')}}`;
}

/** @typedef {{mimeType: string, bytes: Uint8Array}} PendingBlob */

/** Blobs the next record names, held until that record is known to be new. */
const pending = /** @type {PendingBlob[]} */ ([]);

/**
 * The blob object a record names, hashed here rather than by the server. A
 * deploy that changes nothing sends none of these bytes, and the PDS already
 * holds every one of them under the record it is about to keep.
 * @param {string} mimeType
 * @param {string} content
 * @returns {Promise<Record<string, unknown>>}
 */
async function blobFor(mimeType, content) {
  const bytes = new TextEncoder().encode(content);
  pending.push({ mimeType, bytes });
  return {
    $type: 'blob',
    ref: { $link: cidToString(await createBlobCid(bytes)) },
    mimeType,
    size: bytes.length,
  };
}

/**
 * Write the record, unless the one already there differs only in its
 * timestamps. An install pins the manifest by CID and offers an update when
 * that CID moves, so a rebuild that changes nothing has to write nothing.
 * @param {string} collection @param {string} rkey @param {Record<string, unknown>} record
 */
async function put(collection, rkey, record) {
  const value = { $type: collection, ...record };
  const current = await fetch(
    `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=${collection}&rkey=${rkey}`,
  );
  if (current.ok) {
    const existing = (await current.json()).value;
    if (comparable(existing) === comparable(value)) {
      pending.length = 0;
      console.error(`keep ${collection}/${rkey}`);
      return;
    }
  }
  // The PDS rejects a record naming a blob it does not hold, so the queue
  // drains first.
  for (const upload of pending.splice(0)) {
    const res = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, {
      method: 'POST',
      headers: { ...auth, 'Content-Type': upload.mimeType },
      body: upload.bytes,
    });
    if (!res.ok) throw new Error(`uploadBlob ${res.status}`);
  }
  await xrpc('com.atproto.repo.putRecord', {
    method: 'POST',
    headers: { ...auth, 'Content-Type': 'application/json' },
    body: JSON.stringify({ repo: DID, collection, rkey, record: value }),
  });
  console.error(`put ${collection}/${rkey}`);
}

// ---- the query the app reads ----

await put(DEFS, QUERY, {
  collection: REPOS,
  sort: SORT,
  createdAt: new Date().toISOString(),
});

// ---- the account's own git identities ----

// GIT_IDENTS is a comma-separated list of commit author addresses, or bare
// names for a history written without one. Append `=did:plc:...` to say the
// author is somebody other than this account, which is how a project account
// attributes commits to the people who wrote them.
//
//   GIT_IDENTS='me@example.com,them@example.com=did:plc:...'
//
// The record is the account's, not the site's: it holds for every repository
// the account serves. Leaving GIT_IDENTS unset leaves whatever the account
// already claims untouched.
const idents = (process.env.GIT_IDENTS || '')
  .split(',')
  .map((entry) => entry.trim())
  .filter(Boolean)
  .map((entry) => {
    const [subject, did] = entry.split('=').map((part) => part.trim());
    const claim = subject.includes('@')
      ? { email: subject }
      : { name: subject };
    return did ? { ...claim, did } : claim;
  });

if (idents.length > 0) {
  const claimed = await fetch(
    `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=${IDENTS}&rkey=self`,
  );
  const createdAt = claimed.ok
    ? ((await claimed.json()).value.createdAt ?? new Date().toISOString())
    : new Date().toISOString();
  await put(IDENTS, 'self', {
    idents,
    createdAt,
    updatedAt: new Date().toISOString(),
  });
  console.error(
    `claimed ${idents.length} git ${idents.length === 1 ? 'ident' : 'idents'}.`,
  );
}

// ---- the built document ----

const distIndex = fileURLToPath(new URL('../dist/index.html', import.meta.url));
let html;
try {
  html = readFileSync(distIndex, 'utf8');
} catch {
  throw new Error('No build found. Run `npm run build` first.');
}

// ---- the OAuth registration ----

// A visitor signs in to review, and their authorization server reads this
// app's registration from the site itself. A public client: no secret to
// keep, so PKCE and DPoP carry the whole burden of proving that the browser
// redeeming a code is the browser that asked for one.
//
// The site's origin is the account's own arrangement, and the authorization
// server refuses a registration that names anywhere else. The deploy cannot
// know it, so the registration names a token the server fills in with the
// origin each request arrives on. One deploy then signs in on any origin the
// site is served from, and an install that copies these files carries a
// registration that is right for wherever it lands.
const ORIGIN_TOKEN = '__PDSJS_ORIGIN__';
const clientMetadata = {
  client_id: `${ORIGIN_TOKEN}/client-metadata.json`,
  client_name: 'Repositories',
  client_uri: ORIGIN_TOKEN,
  redirect_uris: [`${ORIGIN_TOKEN}/callback`],
  scope: SCOPE,
  grant_types: ['authorization_code', 'refresh_token'],
  response_types: ['code'],
  application_type: 'web',
  token_endpoint_auth_method: 'none',
  dpop_bound_access_tokens: true,
};

// ---- deploy ----

const blob = await blobFor('text/html; charset=utf-8', html);
const files = [
  {
    path: 'index.html',
    blob,
    contentType: 'text/html; charset=utf-8',
    cacheControl: 'public, max-age=300',
  },
];
files.push({
  path: 'client-metadata.json',
  blob: await blobFor(
    'application/json',
    `${JSON.stringify(clientMetadata, null, 2)}\n`,
  ),
  contentType: 'application/json',
  // The server fills the origin token in per request, so the bytes differ
  // by origin; a short cache holds, keyed by the origin's own URL.
  cacheControl: 'public, max-age=60',
  originTemplate: true,
});
await put(SITES, SITE, {
  files,
  fallback: 'index.html',
  createdAt: new Date().toISOString(),
});
console.error(
  `${SITE}: ${Math.round(Number(blob.size) / 1024)}KB, ${files.length} file${files.length === 1 ? '' : 's'}.`,
);

// ---- publish it as an installable app ----

const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0a0a0c"/>
<path d="M22 16v20a8 8 0 0 0 8 8h6" fill="none" stroke="#6d6df0" stroke-width="5" stroke-linecap="round"/>
<circle cx="22" cy="14" r="6" fill="#6d6df0"/>
<circle cx="42" cy="44" r="6" fill="#6d6df0"/>
<circle cx="22" cy="50" r="6" fill="#6d6df0"/>
</svg>`;
const icon = await blobFor('image/svg+xml', ICON);

const deployed = await xrpc(
  `com.atproto.repo.getRecord?repo=${DID}&collection=${SITES}&rkey=${SITE}`,
);
await put('dev.pdsjs.app.manifest', 'git', {
  name: 'Git',
  description:
    'A public forge for the git repositories in your account. Visitors browse trees, files, history and commit diffs with no sign-in, because the page reads the repository records through a query and opens the bundle blobs itself.',
  icon,
  site: { uri: deployed.uri, cid: deployed.cid },
  suggestedSite: 'git',
  primitives: ['serve', 'query'],
  external: false,
  queries: [{ name: QUERY, collection: REPOS, sort: SORT }],
  // The page asks the account's own server what it serves. Both settings
  // override that answer, for a deployment behind a proxy that withholds the
  // open origin, or an owner who wants one of the two links hidden.
  settings: [
    {
      key: 'httpClone',
      label: 'Clone over https',
      type: 'select',
      options: ['auto', 'on', 'off'],
      default: 'auto',
    },
    {
      key: 'rawFiles',
      label: 'Link to raw file paths',
      type: 'select',
      options: ['auto', 'on', 'off'],
      default: 'auto',
    },
  ],
  createdAt: new Date().toISOString(),
});
console.error(`Installable at at://${DID}/dev.pdsjs.app.manifest/git`);