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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
389 lines · 10.6 KB Raw History
#!/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);
  });
}