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