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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
418 lines · 10.8 KB Raw History
// @pdsjs/readonly - Read-only PDS server for serving AT Protocol repositories
// Loads repositories from CAR files and serves read-only XRPC endpoints

import fs from 'node:fs';
import path from 'node:path';
import { createFsBlobs } from '@pdsjs/blobs-fs';
import { loadRepositoryFromCar, PersonalDataServer } from '@pdsjs/core';
import { createActorStorage, createSharedStorage } from '@pdsjs/storage-sqlite';

/**
 * @typedef {import('@pdsjs/storage-sqlite/driver').SQLiteDatabase} SQLiteDatabase
 */

/**
 * @typedef {Object} RepoInfo
 * @property {string} did - Repository DID
 * @property {string} commitCid - Commit CID
 * @property {string} rev - Revision string
 * @property {number} recordCount - Number of records
 * @property {number} blockCount - Number of blocks
 */

/**
 * @typedef {Object} MultiRepoManagerOptions
 * @property {string} dataDir - Directory for SQLite databases
 * @property {string} [blobsDir] - Optional directory for blobs
 * @property {(did: string) => SQLiteDatabase} createDb - Database factory function
 */

/**
 * Multi-repository manager for read-only PDS
 * Manages per-DID SQLite databases with a routing layer
 */
export class MultiRepoManager {
  /**
   * @param {MultiRepoManagerOptions} options
   */
  constructor({ dataDir, blobsDir, createDb }) {
    /** @type {Map<string, {db: SQLiteDatabase, storage: import('@pdsjs/core/ports').ActorStoragePort}>} */
    this.repos = new Map();
    this.dataDir = dataDir;
    this.blobsDir = blobsDir;
    this.createDb = createDb;
    /** @type {RepoInfo[]} */
    this.loadedRepos = [];
  }

  /**
   * Load a repository from a CAR file
   * @param {string} carPath - Path to CAR file
   * @returns {Promise<RepoInfo>}
   */
  async loadCar(carPath) {
    const carBytes = fs.readFileSync(carPath);

    // Create temporary storage to extract DID first
    const tempDb = this.createDb(':memory:');
    const tempStorage = createActorStorage(tempDb);

    // Load to get DID
    const result = await loadRepositoryFromCar(
      new Uint8Array(carBytes),
      tempStorage,
    );
    const did = result.did;

    // Check if already loaded
    if (this.repos.has(did)) {
      console.log(`Repository ${did} already loaded, skipping`);
      return result;
    }

    // Create persistent database for this DID
    const dbFileName = `${did.replace(/:/g, '_').replace(/\./g, '_')}.sqlite`;
    const dbPath = path.join(this.dataDir, dbFileName);
    const db = this.createDb(dbPath);
    const storage = createActorStorage(db);

    // Load repository into persistent storage
    const loadResult = await loadRepositoryFromCar(
      new Uint8Array(carBytes),
      storage,
    );

    // Register
    this.repos.set(did, { db, storage });
    this.loadedRepos.push(loadResult);

    console.log(
      `Loaded repository ${did}: ${loadResult.recordCount} records, ${loadResult.blockCount} blocks`,
    );

    return loadResult;
  }

  /**
   * Get storage for a specific DID
   * @param {string} did
   * @returns {import('@pdsjs/core/ports').ActorStoragePort | null}
   */
  getStorage(did) {
    const repo = this.repos.get(did);
    return repo?.storage || null;
  }

  /**
   * Get all loaded DIDs
   * @returns {string[]}
   */
  getLoadedDids() {
    return Array.from(this.repos.keys());
  }

  /**
   * Get info about all loaded repositories
   * @returns {RepoInfo[]}
   */
  getLoadedRepos() {
    return this.loadedRepos;
  }
}

/**
 * @typedef {Object} ReadOnlyServerOptions
 * @property {string} hostname - PDS hostname
 * @property {string} [jwtSecret] - JWT secret (optional for read-only)
 * @property {string} dataDir - Directory for SQLite databases
 * @property {string} [blobsDir] - Optional directory for blobs
 * @property {(did: string) => SQLiteDatabase} createDb - Database factory function
 */

/**
 * Create a read-only PDS server with multi-repo support
 * @param {ReadOnlyServerOptions} options
 * @returns {{ server: PersonalDataServer, manager: MultiRepoManager, loadCar: (path: string) => Promise<RepoInfo> }}
 */
export function createReadOnlyServer(options) {
  const {
    hostname,
    jwtSecret = 'readonly-jwt-secret',
    dataDir,
    blobsDir,
    createDb,
  } = options;

  // Ensure data directory exists
  if (!fs.existsSync(dataDir)) {
    fs.mkdirSync(dataDir, { recursive: true });
  }

  // Create multi-repo manager
  const manager = new MultiRepoManager({ dataDir, blobsDir, createDb });

  // Create a shared storage database
  const sharedDb = createDb(path.join(dataDir, 'shared.sqlite'));
  const sharedStorage = createSharedStorage(sharedDb);

  // Create blob storage if configured
  const blobs = blobsDir ? createFsBlobs(blobsDir) : createNullBlobStorage();

  // Create a routing actor storage that delegates to the correct repo
  const routingStorage = createRoutingStorage(manager);

  // Create the PDS in read-only mode
  const server = new PersonalDataServer({
    actorStorage: routingStorage,
    sharedStorage,
    blobs,
    jwtSecret,
    hostname,
    readOnly: true,
  });

  return {
    server,
    manager,
    loadCar: (carPath) => manager.loadCar(carPath),
  };
}

/**
 * Create a routing storage that delegates to the correct repo based on DID
 * @param {MultiRepoManager} manager
 * @returns {import('@pdsjs/core/ports').ActorStoragePort}
 */
function createRoutingStorage(manager) {
  // Default to first loaded repo for single-repo compatibility
  const getDefaultStorage = () => {
    const dids = manager.getLoadedDids();
    if (dids.length === 0) return null;
    return manager.getStorage(dids[0]);
  };

  return {
    async getRecord(uri) {
      // Extract DID from uri: at://did:xxx/collection/rkey
      const did = uri.replace('at://', '').split('/')[0];
      const storage = manager.getStorage(did) || getDefaultStorage();
      return storage?.getRecord(uri) || null;
    },

    async listRecords(collection, cursor, limit) {
      // For listRecords, use default storage
      const storage = getDefaultStorage();
      return (
        storage?.listRecords(collection, cursor, limit) || {
          records: [],
          cursor: null,
        }
      );
    },

    async listAllRecords() {
      const storage = getDefaultStorage();
      return storage?.listAllRecords() || [];
    },

    async putRecord() {
      throw new Error('Read-only: putRecord not allowed');
    },

    async deleteRecord() {
      throw new Error('Read-only: deleteRecord not allowed');
    },

    async getBlock(cid) {
      // Try all repos
      for (const did of manager.getLoadedDids()) {
        const storage = manager.getStorage(did);
        const block = await storage?.getBlock(cid);
        if (block) return block;
      }
      return null;
    },

    async putBlock() {
      throw new Error('Read-only: putBlock not allowed');
    },

    async getLatestCommit() {
      const storage = getDefaultStorage();
      return storage?.getLatestCommit() || null;
    },

    async putCommit() {
      throw new Error('Read-only: putCommit not allowed');
    },

    async getEvents(cursor, limit) {
      const storage = getDefaultStorage();
      return storage?.getEvents(cursor, limit) || { events: [], cursor: 0 };
    },

    async putEvent() {
      throw new Error('Read-only: putEvent not allowed');
    },

    async getLatestSeq() {
      const storage = getDefaultStorage();
      return storage?.getLatestSeq() ?? 0;
    },

    async getBlob(cid) {
      const storage = getDefaultStorage();
      return storage?.getBlob(cid) || null;
    },

    async putBlob() {
      throw new Error('Read-only: putBlob not allowed');
    },

    async deleteBlob() {
      throw new Error('Read-only: deleteBlob not allowed');
    },

    async listBlobs(cursor, limit) {
      const storage = getDefaultStorage();
      return storage?.listBlobs(cursor, limit) || { cids: [], cursor: null };
    },

    async listBlobDetails(cursor, limit, options) {
      const storage = getDefaultStorage();
      return (
        storage?.listBlobDetails?.(cursor, limit, options) || {
          blobs: [],
          cursor: null,
        }
      );
    },

    async getBlobFacets() {
      const storage = getDefaultStorage();
      return (
        storage?.getBlobFacets?.() || {
          total: { count: 0, bytes: 0 },
          unused: { count: 0, bytes: 0 },
          kinds: [],
          apps: [],
        }
      );
    },

    async getOrphanedBlobs() {
      return [];
    },

    async linkBlobToRecord() {
      throw new Error('Read-only: linkBlobToRecord not allowed');
    },

    async listBlobRecordUris() {
      // A backup snapshot carries no link table; with no space records either,
      // every blob it holds is public.
      return [];
    },

    async unlinkBlobsFromRecord() {
      throw new Error('Read-only: unlinkBlobsFromRecord not allowed');
    },

    async getDid() {
      const storage = getDefaultStorage();
      return storage?.getDid() || null;
    },

    async setDid() {
      throw new Error('Read-only: setDid not allowed');
    },

    async getHandle() {
      const storage = getDefaultStorage();
      return storage?.getHandle() || null;
    },

    async getAccountStatus() {
      return 'active';
    },

    async setAccountStatus() {
      throw new Error('Read-only: setAccountStatus not allowed');
    },

    async getEmail() {
      // A CAR mirror carries repo contents, never account state like this
      return null;
    },

    async setEmail() {
      throw new Error('Read-only: setEmail not allowed');
    },

    async getAccountPassword() {
      return null;
    },

    async setAccountPassword() {
      throw new Error('Read-only: setAccountPassword not allowed');
    },

    async getPlcToken() {
      return null;
    },

    async setPlcToken() {
      throw new Error('Read-only: setPlcToken not allowed');
    },

    async setHandle() {
      throw new Error('Read-only: setHandle not allowed');
    },

    async getPrivateKey() {
      return null; // No private key in read-only mode
    },

    async setPrivateKey() {
      throw new Error('Read-only: setPrivateKey not allowed');
    },

    async getKemKey() {
      return null;
    },

    async setKemKey() {
      throw new Error('Read-only: setKemKey not allowed');
    },

    async getPreferences() {
      const storage = getDefaultStorage();
      return storage?.getPreferences() || [];
    },

    async setPreferences() {
      throw new Error('Read-only: setPreferences not allowed');
    },
  };
}

/**
 * Create a null blob storage that returns empty results
 * @returns {import('@pdsjs/core/ports').BlobPort}
 */
function createNullBlobStorage() {
  return {
    async put() {
      throw new Error('Read-only: put not allowed');
    },
    async get() {
      return null;
    },
    async delete() {
      throw new Error('Read-only: delete not allowed');
    },
  };
}

export { loadRepositoryFromCar } from '@pdsjs/core';
export { buildRepositoryCar } from './build-car.js';