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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
190 lines · 6.5 KB Raw History
// Deploy the space browser to the account's own site.
//
// Two files go out. The page is one self-contained document, for the reason
// git-ui's is: the site record names each file by path and the HTML caches for
// minutes, so a build split across hashed assets would leave a visitor holding
// old HTML asking for a script the record no longer names.
//
// The second file is client-metadata.json, which the page never reads. An
// atproto OAuth client is identified by a URL serving its registration, and
// this is that URL's document. It names the origin it is served from, so a
// deploy has to be told where the site answers.
//
// Usage: PDS_URL=… PDS_DID=… PDS_APP_PASSWORD=… SPACES_ORIGIN=… \
//          npm run deploy --workspace @pdsjs/spaces-ui

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { cidToString, createBlobCid } from '@pdsjs/core/repo';
import { READ_SCOPE, WRITE_SCOPE } from '../src/lib/scopes.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.SPACES_SITE || 'spaces';
const ORIGIN = (process.env.SPACES_ORIGIN || '').replace(/\/$/, '');
if (!ORIGIN.startsWith('https://')) {
  throw new Error(
    `Set SPACES_ORIGIN to the https origin this site answers on, e.g. https://${SITE}.example.com. The OAuth client document names it, and the authorization server refuses a document that names anywhere else.`,
  );
}

const SITES = 'dev.pdsjs.site.deploy';

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'];

/**
 * 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(',')}}`;
}

/** @type {Array<{mimeType: string, bytes: Uint8Array}>} */
const pending = [];

/**
 * The blob object a record names, hashed here rather than by the server. A
 * deploy that changes nothing sends none of these bytes.
 * @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.
 * @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 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 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. Both scopes are listed because the sign-in screen offers a choice
// between them.
const clientMetadata = {
  client_id: `${ORIGIN}/client-metadata.json`,
  client_name: 'Spaces',
  client_uri: ORIGIN,
  redirect_uris: [`${ORIGIN}/callback`],
  scope: `atproto ${READ_SCOPE} ${WRITE_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 page = await blobFor('text/html; charset=utf-8', html);
const metadata = await blobFor(
  'application/json',
  `${JSON.stringify(clientMetadata, null, 2)}\n`,
);

await put(SITES, SITE, {
  files: [
    {
      path: 'index.html',
      blob: page,
      contentType: 'text/html; charset=utf-8',
      cacheControl: 'public, max-age=300',
    },
    {
      path: 'client-metadata.json',
      blob: metadata,
      contentType: 'application/json',
      // Read by an authorization server rather than by a browser, and it is
      // the one file whose staleness breaks sign-in outright.
      cacheControl: 'public, max-age=60',
    },
  ],
  fallback: 'index.html',
  createdAt: new Date().toISOString(),
});

console.error(
  `${SITE}: ${Math.round(Number(page.size) / 1024)}KB page, signing in as ${clientMetadata.client_id}`,
);