chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
#!/usr/bin/env node
// @pdsjs/readonly/build-car - Build CAR files for read-only PDS
// Creates a signed repository CAR file from records
import fs from 'node:fs';
import { buildMst } from '@pdsjs/core';
import {
bytesToHex,
generateKeyPair,
hexToBytes,
importPrivateKey,
sign,
} from '@pdsjs/core/crypto';
import {
buildCarFile,
CID,
cborEncodeDagCbor,
cidToBytes,
cidToString,
createCid,
createTid,
} from '@pdsjs/core/repo';
/**
* @typedef {Object} RecordInput
* @property {string} collection - Collection NSID (e.g., "app.bsky.feed.post")
* @property {string} rkey - Record key
* @property {object} record - Record data
*/
/**
* Build a CAR file from records
* @param {Object} options
* @param {string} options.did - Repository DID
* @param {Uint8Array} options.privateKey - 32-byte P-256 private key
* @param {RecordInput[]} options.records - Records to include
* @param {string} [options.prevCommitCid] - Previous commit CID (for chaining)
* @returns {Promise<{carBytes: Uint8Array, commitCid: string, rev: string}>}
*/
export async function buildRepositoryCar({
did,
privateKey,
records,
prevCommitCid,
}) {
/** @type {Array<{cid: string, data: Uint8Array}>} */
const blocks = [];
// Encode records and build MST entries
/** @type {Array<{key: string, cid: string}>} */
const mstEntries = [];
for (const { collection, rkey, record } of records) {
const recordBytes = cborEncodeDagCbor(record);
const recordCid = cidToString(await createCid(recordBytes));
blocks.push({ cid: recordCid, data: recordBytes });
mstEntries.push({ key: `${collection}/${rkey}`, cid: recordCid });
}
// Sort entries for MST (required for deterministic tree structure)
mstEntries.sort((a, b) => a.key.localeCompare(b.key));
// Build MST
const mstRoot = await buildMst(mstEntries, async (cid, data) => {
blocks.push({ cid, data });
});
// Create revision ID
const rev = createTid();
// Build unsigned commit
const commit = {
did,
version: 3,
rev,
prev: prevCommitCid ? new CID(cidToBytes(prevCommitCid)) : null,
data: mstRoot ? new CID(cidToBytes(mstRoot)) : null,
};
// Sign commit
const signingKey = await importPrivateKey(privateKey);
const commitBytes = cborEncodeDagCbor(commit);
const sig = await sign(signingKey, commitBytes);
const signedCommit = { ...commit, sig };
// Encode signed commit
const signedCommitBytes = cborEncodeDagCbor(signedCommit);
const commitCid = cidToString(await createCid(signedCommitBytes));
blocks.push({ cid: commitCid, data: signedCommitBytes });
// Build CAR file
const carBytes = buildCarFile(commitCid, blocks);
return { carBytes, commitCid, rev };
}
/**
* Parse command line arguments
* @returns {{
* output: string,
* did: string,
* privateKey: Uint8Array | null,
* generateKey: boolean,
* collection: string | null,
* rkey: string | null,
* record: object | null,
* recordsFile: string | null,
* help: boolean
* }}
*/
function parseArgs() {
const args = process.argv.slice(2);
let output = '';
let did = '';
/** @type {Uint8Array | null} */
let privateKey = null;
let generateKey = false;
/** @type {string | null} */
let collection = null;
/** @type {string | null} */
let rkey = null;
/** @type {object | null} */
let record = null;
/** @type {string | null} */
let recordsFile = null;
let help = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--output' || arg === '-o') {
output = args[++i];
} else if (arg === '--did' || arg === '-d') {
did = args[++i];
} else if (arg === '--key' || arg === '-k') {
const keyArg = args[++i];
if (keyArg) {
// Support hex-encoded key or file path
if (keyArg.length === 64 && /^[0-9a-fA-F]+$/.test(keyArg)) {
privateKey = hexToBytes(keyArg);
} else if (fs.existsSync(keyArg)) {
const keyData = fs.readFileSync(keyArg);
// Support raw binary or hex file
if (keyData.length === 32) {
privateKey = new Uint8Array(keyData);
} else {
const hex = keyData.toString('utf-8').trim();
privateKey = hexToBytes(hex);
}
} else {
console.error(`Error: Key file not found or invalid hex: ${keyArg}`);
process.exit(1);
}
}
} else if (arg === '--generate-key' || arg === '-g') {
generateKey = true;
} else if (arg === '--collection' || arg === '-c') {
collection = args[++i];
} else if (arg === '--rkey' || arg === '-r') {
rkey = args[++i];
} else if (arg === '--record' || arg === '-R') {
const recordArg = args[++i];
if (recordArg) {
try {
record = JSON.parse(recordArg);
} catch (err) {
console.error(`Error: Invalid JSON for record: ${err}`);
process.exit(1);
}
}
} else if (arg === '--record-file' || arg === '-f') {
const filePath = args[++i];
if (filePath && fs.existsSync(filePath)) {
try {
record = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch (err) {
console.error(`Error: Failed to parse record file: ${err}`);
process.exit(1);
}
} else {
console.error(`Error: Record file not found: ${filePath}`);
process.exit(1);
}
} else if (arg === '--records' || arg === '-F') {
recordsFile = args[++i];
} else if (arg === '--help' || arg === '-h') {
help = true;
} else if (arg.startsWith('-')) {
console.error(`Unknown option: ${arg}`);
process.exit(1);
}
}
return {
output,
did,
privateKey,
generateKey,
collection,
rkey,
record,
recordsFile,
help,
};
}
function printHelp() {
console.log(`
build-car - Build a CAR file for read-only PDS
Usage:
build-car [options]
Options:
--output, -o <path> Output CAR file path (required)
--did, -d <did> Repository DID (required)
--key, -k <key> Private key (hex string or file path)
--generate-key, -g Generate a new key pair and print to stdout
--collection, -c <nsid> Collection NSID for single record mode
--rkey, -r <rkey> Record key for single record mode
--record, -R <json> Record JSON for single record mode
--record-file, -f <path> Path to JSON file containing record
--records, -F <path> Path to JSON file containing array of records
--help, -h Show this help message
Single Record Mode:
build-car -o repo.car -d did:plc:abc123 -k <key> \\
-c app.bsky.feed.post -r 3abc123 -R '{"text":"Hello"}'
Multiple Records Mode (JSON file format):
[
{"collection": "app.bsky.feed.post", "rkey": "3abc", "record": {"text": "Post 1"}},
{"collection": "app.bsky.feed.post", "rkey": "3def", "record": {"text": "Post 2"}}
]
build-car -o repo.car -d did:plc:abc123 -k <key> -F records.json
Key Generation:
build-car --generate-key
# Outputs: privateKey=<hex> publicKey=<hex>
Examples:
# Generate a new key
build-car -g > keys.txt
# Create a CAR with a single post
build-car -o my-repo.car -d did:plc:abc123 -k private.key \\
-c app.bsky.feed.post -r 3abc123def \\
-R '{"$type":"app.bsky.feed.post","text":"Hello World!","createdAt":"2024-01-01T00:00:00Z"}'
# Create a CAR from a records file
build-car -o my-repo.car -d did:plc:abc123 -k <hex-key> -F records.json
`);
}
async function main() {
const {
output,
did,
privateKey,
generateKey,
collection,
rkey,
record,
recordsFile,
help,
} = parseArgs();
if (help) {
printHelp();
process.exit(0);
}
// Handle key generation
if (generateKey) {
const { privateKey: privKey, publicKey: pubKey } = await generateKeyPair();
console.log(`privateKey=${bytesToHex(privKey)}`);
console.log(`publicKey=${bytesToHex(pubKey)}`);
console.log(`# Save the privateKey securely. Use it with --key option.`);
process.exit(0);
}
// Validate required arguments
if (!output) {
console.error('Error: --output is required');
printHelp();
process.exit(1);
}
if (!did) {
console.error('Error: --did is required');
printHelp();
process.exit(1);
}
if (!privateKey) {
console.error(
'Error: --key is required (or use --generate-key to create one)',
);
printHelp();
process.exit(1);
}
// Build records array
/** @type {RecordInput[]} */
let records = [];
if (recordsFile) {
// Multiple records from file
if (!fs.existsSync(recordsFile)) {
console.error(`Error: Records file not found: ${recordsFile}`);
process.exit(1);
}
try {
const data = JSON.parse(fs.readFileSync(recordsFile, 'utf-8'));
if (!Array.isArray(data)) {
console.error('Error: Records file must contain an array');
process.exit(1);
}
records = data;
} catch (err) {
console.error(`Error: Failed to parse records file: ${err}`);
process.exit(1);
}
} else if (collection && rkey && record) {
// Single record mode
records = [{ collection, rkey, record }];
} else {
console.error(
'Error: Either provide --collection, --rkey, and --record for single record,',
);
console.error(' or --records for multiple records from a file.');
printHelp();
process.exit(1);
}
if (records.length === 0) {
console.error('Error: No records provided');
process.exit(1);
}
// Validate records
for (const r of records) {
if (!r.collection || !r.rkey || !r.record) {
console.error(
'Error: Each record must have collection, rkey, and record fields',
);
process.exit(1);
}
}
console.log(`Building CAR file for ${did}...`);
console.log(`Records: ${records.length}`);
try {
const { carBytes, commitCid, rev } = await buildRepositoryCar({
did,
privateKey,
records,
});
fs.writeFileSync(output, carBytes);
console.log(`\nCAR file written: ${output}`);
console.log(`Commit CID: ${commitCid}`);
console.log(`Revision: ${rev}`);
console.log(`Size: ${carBytes.length} bytes`);
console.log(`\nRecords included:`);
for (const r of records) {
console.log(` ${r.collection}/${r.rkey}`);
}
} catch (err) {
console.error(`Error building CAR: ${err}`);
process.exit(1);
}
}
// Only run CLI if this is the main module
const isMainModule =
import.meta.url === `file://${process.argv[1]}` ||
process.argv[1]?.endsWith('/build-car.js') ||
process.argv[1]?.endsWith('/build-car');
if (isMainModule) {
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});
}