// @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} */ 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} */ 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 }} */ 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';