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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
169 lines · 4.9 KB Raw History
#!/usr/bin/env node
/**
 * Local-dev seed for the space browser: a space alice hosts, a second member
 * writing into it from a PDS of his own, records in two collections, and one
 * record naming a blob.
 *
 * The second writer is what makes this worth seeding. A space is one dataset
 * spread over the repos of everyone who writes to it, and reading somebody
 * else's repo is the whole delegation-and-credential exchange, which nothing
 * short of two servers exercises.
 *
 * Both accounts must already exist; `just dev-spaces` runs the recipes that
 * make them and then starts the UI.
 *
 * Usage: node scripts/dev-spaces-demo.mjs
 * Env:   PDS_DEV_URL       (default http://localhost:2471)
 *        PDS_DEV_BOB_URL   (default http://localhost:2472)
 *        PDS_DEV_PASSWORD  (default test-password)
 */

const ALICE = process.env.PDS_DEV_URL || 'http://localhost:2471';
const BOB = process.env.PDS_DEV_BOB_URL || 'http://localhost:2472';
const PASSWORD = process.env.PDS_DEV_PASSWORD || 'test-password';

const SPACE_TYPE = 'dev.pdsjs.demo.board';
const SKEY = 'general';
const NOTES = 'dev.pdsjs.demo.note';
const PINS = 'dev.pdsjs.demo.pin';

/**
 * @param {string} base
 * @param {string} method
 * @param {object} [init]
 * @returns {Promise<any>}
 */
async function xrpc(base, method, init = {}) {
  const response = await fetch(`${base}/xrpc/${method}`, init);
  const text = await response.text();
  if (!response.ok) {
    throw new Error(`${method} ${response.status}: ${text.slice(0, 300)}`);
  }
  return text ? JSON.parse(text) : {};
}

/**
 * The account one dev PDS serves, with a session on it.
 * @param {string} base
 * @returns {Promise<{base: string, did: string, headers: Record<string, string>}>}
 */
async function account(base) {
  const did = (await (await fetch(`${base}/.well-known/atproto-did`)).text())
    .trim()
    .replace(/^"|"$/g, '');
  if (!did.startsWith('did:')) {
    throw new Error(`${base} serves no account yet.`);
  }
  const session = await xrpc(base, 'com.atproto.server.createSession', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ identifier: did, password: PASSWORD }),
  });
  return {
    base,
    did,
    headers: {
      authorization: `Bearer ${session.accessJwt}`,
      'content-type': 'application/json',
    },
  };
}

/**
 * @param {{base: string, headers: Record<string, string>}} actor
 * @param {string} method
 * @param {Record<string, unknown>} input
 */
function post(actor, method, input) {
  return xrpc(actor.base, method, {
    method: 'POST',
    headers: actor.headers,
    body: JSON.stringify(input),
  });
}

const alice = await account(ALICE);
const bob = await account(BOB);

// ---- the space ----

const space = `at://${alice.did}/space/${SPACE_TYPE}/${SKEY}`;
try {
  await post(alice, 'com.atproto.space.createSpace', {
    spaceType: SPACE_TYPE,
    skey: SKEY,
    policy: { $type: 'com.atproto.memberspace.policy' },
  });
  console.error(`created ${space}`);
} catch (error) {
  // A re-run finds the space already there, which is not a failure.
  if (!String(error.message).includes('SpaceAlreadyExists')) throw error;
  console.error(`keep ${space}`);
}

await post(alice, 'com.atproto.memberspace.putMember', {
  space,
  did: bob.did,
  read: true,
  write: true,
});
console.error(`member ${bob.did}`);

// ---- a blob, and the record that names it ----

// A 1x1 PNG, so the browser has an image to display rather than a download.
const pngBytes = Uint8Array.from(
  atob(
    'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
  ),
  (character) => character.charCodeAt(0),
);
const upload = await xrpc(ALICE, 'com.atproto.repo.uploadBlob', {
  method: 'POST',
  headers: { ...alice.headers, 'content-type': 'image/png' },
  body: pngBytes,
});

// ---- records, from both members ----

/**
 * @param {{base: string, headers: Record<string, string>, did: string}} actor
 * @param {string} collection
 * @param {Record<string, unknown>} record
 */
async function write(actor, collection, record) {
  const { uri } = await post(actor, 'com.atproto.space.createRecord', {
    space,
    repo: actor.did,
    collection,
    record: {
      $type: collection,
      createdAt: new Date().toISOString(),
      ...record,
    },
  });
  console.error(`wrote ${uri}`);
}

await write(alice, NOTES, {
  text: 'The board is open. Anyone on the member list can write here.',
});
await write(alice, NOTES, {
  text: 'Records live in each writer’s own repo, on their own PDS.',
});
await write(alice, PINS, { label: 'Read me first', order: 1 });
await write(alice, PINS, {
  label: 'With an attachment',
  order: 2,
  image: upload.blob,
});

await write(bob, NOTES, {
  text: 'Bob writing from a second server. Opening this repo takes a credential.',
});
await write(bob, PINS, { label: "Bob's pin", order: 3 });

console.error('');
console.error(`space: ${space}`);
console.error(`alice: ${alice.did}`);
console.error(`bob:   ${bob.did}`);