chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
#!/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}`);