atproto user agency toolkit for individuals and groups
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add DID-less startup tests and fix eager schema initialization

9 tests verify: server starts without DID, dashboard/health work,
replication manager exists, can add/sync DIDs, node_identity table
persists identity across restarts, env DID overrides stored identity.

Fixes: SyncStorage/ChallengeStorage schemas now init eagerly in
ReplicationManager constructor (not deferred to async init()), and
backfillIpfs skips when no RepoManager exists.

+251 -2
+247
src/didless-startup.test.ts
··· 1 + /** 2 + * Tests for DID-less server startup and lazy identity establishment. 3 + * 4 + * Verifies that a p2pds node can start with no DID/SIGNING_KEY, 5 + * serve its dashboard and health check, replicate data, and 6 + * establish identity via the node_identity table. 7 + */ 8 + 9 + import { describe, it, expect, afterEach } from "vitest"; 10 + import { mkdtempSync, rmSync } from "node:fs"; 11 + import { tmpdir } from "node:os"; 12 + import { join } from "node:path"; 13 + import Database from "better-sqlite3"; 14 + import { resolve } from "node:path"; 15 + 16 + import type { Config } from "./config.js"; 17 + import { startServer, type ServerHandle } from "./start.js"; 18 + import { 19 + createTestRepo, 20 + startMockPds, 21 + createMockDidResolver, 22 + type MockPds, 23 + } from "./replication/test-helpers.js"; 24 + 25 + const TEST_DID = "did:plc:testuser1"; 26 + 27 + /** Config with no DID, no SIGNING_KEY — replication-only node. */ 28 + function didlessConfig(dataDir: string, replicateDids: string[] = []): Config { 29 + return { 30 + // No DID, no HANDLE, no SIGNING_KEY 31 + PDS_HOSTNAME: "local.test", 32 + AUTH_TOKEN: "test-auth-token", 33 + JWT_SECRET: "test-jwt-secret", 34 + PASSWORD_HASH: "$2a$10$test", 35 + DATA_DIR: dataDir, 36 + PORT: 0, 37 + IPFS_ENABLED: true, 38 + IPFS_NETWORKING: false, 39 + REPLICATE_DIDS: replicateDids, 40 + FIREHOSE_URL: "wss://localhost/xrpc/com.atproto.sync.subscribeRepos", 41 + FIREHOSE_ENABLED: false, 42 + RATE_LIMIT_ENABLED: false, 43 + RATE_LIMIT_READ_PER_MIN: 300, 44 + RATE_LIMIT_SYNC_PER_MIN: 30, 45 + RATE_LIMIT_SESSION_PER_MIN: 10, 46 + RATE_LIMIT_WRITE_PER_MIN: 200, 47 + RATE_LIMIT_CHALLENGE_PER_MIN: 20, 48 + RATE_LIMIT_MAX_CONNECTIONS: 100, 49 + RATE_LIMIT_FIREHOSE_PER_IP: 3, 50 + OAUTH_ENABLED: false, 51 + }; 52 + } 53 + 54 + describe("DID-less server startup", () => { 55 + let tmpDir: string; 56 + let handle: ServerHandle | undefined; 57 + let mockPds: MockPds | undefined; 58 + 59 + afterEach(async () => { 60 + if (handle) { 61 + await handle.close(); 62 + handle = undefined; 63 + } 64 + if (mockPds) { 65 + await mockPds.close(); 66 + mockPds = undefined; 67 + } 68 + if (tmpDir) { 69 + rmSync(tmpDir, { recursive: true, force: true }); 70 + } 71 + }); 72 + 73 + it("starts without DID and returns healthy", async () => { 74 + tmpDir = mkdtempSync(join(tmpdir(), "didless-startup-")); 75 + const config = didlessConfig(tmpDir); 76 + handle = await startServer(config); 77 + 78 + const res = await fetch(`${handle.url}/xrpc/_health`); 79 + expect(res.status).toBe(200); 80 + const body = (await res.json()) as { status: string }; 81 + expect(body.status).toBe("ok"); 82 + }); 83 + 84 + it("dashboard loads without DID", async () => { 85 + tmpDir = mkdtempSync(join(tmpdir(), "didless-startup-")); 86 + const config = didlessConfig(tmpDir); 87 + handle = await startServer(config); 88 + 89 + const res = await fetch(`${handle.url}/xrpc/org.p2pds.admin.dashboard`); 90 + expect(res.status).toBe(200); 91 + const html = await res.text(); 92 + expect(html).toContain("P2PDS"); 93 + }); 94 + 95 + it("homepage loads without DID", async () => { 96 + tmpDir = mkdtempSync(join(tmpdir(), "didless-startup-")); 97 + const config = didlessConfig(tmpDir); 98 + handle = await startServer(config); 99 + 100 + const res = await fetch(`${handle.url}/`); 101 + expect(res.status).toBe(200); 102 + const html = await res.text(); 103 + expect(html).toContain("P2PDS"); 104 + }); 105 + 106 + it("replication manager exists without DID", async () => { 107 + tmpDir = mkdtempSync(join(tmpdir(), "didless-startup-")); 108 + const config = didlessConfig(tmpDir); 109 + handle = await startServer(config); 110 + 111 + // ReplicationManager should exist (IPFS enabled, no repoManager needed) 112 + expect(handle.replicationManager).toBeDefined(); 113 + }); 114 + 115 + it("can add DID and sync without local repo", async () => { 116 + tmpDir = mkdtempSync(join(tmpdir(), "didless-startup-")); 117 + 118 + // Create mock PDS with test data 119 + const carBytes = await createTestRepo(TEST_DID, [ 120 + { collection: "app.bsky.feed.post", rkey: "abc123", record: { text: "hello", createdAt: new Date().toISOString() } }, 121 + ]); 122 + mockPds = await startMockPds([{ did: TEST_DID, carBytes }]); 123 + const mockResolver = createMockDidResolver({ [TEST_DID]: mockPds.url }); 124 + 125 + const config = didlessConfig(tmpDir); 126 + handle = await startServer(config, { didResolver: mockResolver }); 127 + 128 + // Add DID via admin API 129 + const addRes = await fetch(`${handle.url}/xrpc/org.p2pds.admin.addDid`, { 130 + method: "POST", 131 + headers: { 132 + Authorization: `Bearer ${config.AUTH_TOKEN}`, 133 + "Content-Type": "application/json", 134 + }, 135 + body: JSON.stringify({ did: TEST_DID }), 136 + }); 137 + expect(addRes.status).toBe(200); 138 + 139 + // Trigger sync 140 + const syncRes = await fetch(`${handle.url}/xrpc/org.p2pds.replication.syncNow`, { 141 + method: "POST", 142 + headers: { Authorization: `Bearer ${config.AUTH_TOKEN}` }, 143 + }); 144 + expect(syncRes.status).toBe(200); 145 + 146 + // Wait for async sync 147 + await new Promise((r) => setTimeout(r, 2000)); 148 + 149 + // Verify replication state 150 + const overviewRes = await fetch(`${handle.url}/xrpc/org.p2pds.admin.getOverview`, { 151 + headers: { Authorization: `Bearer ${config.AUTH_TOKEN}` }, 152 + }); 153 + expect(overviewRes.status).toBe(200); 154 + const overview = (await overviewRes.json()) as { 155 + replication: { trackedDids: string[] }; 156 + }; 157 + expect(overview.replication.trackedDids).toContain(TEST_DID); 158 + }, 15_000); 159 + 160 + it("node_identity table is created and loadable", async () => { 161 + tmpDir = mkdtempSync(join(tmpdir(), "didless-startup-")); 162 + const config = didlessConfig(tmpDir); 163 + handle = await startServer(config); 164 + 165 + // Open the same database and verify node_identity table exists 166 + const dbPath = resolve(tmpDir, "pds.db"); 167 + const db = new Database(dbPath); 168 + const tables = db 169 + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='node_identity'") 170 + .all() as Array<{ name: string }>; 171 + expect(tables).toHaveLength(1); 172 + 173 + // Table should be empty (no OAuth login occurred) 174 + const rows = db.prepare("SELECT * FROM node_identity").all(); 175 + expect(rows).toHaveLength(0); 176 + 177 + db.close(); 178 + }); 179 + 180 + it("loads stored identity on restart", async () => { 181 + tmpDir = mkdtempSync(join(tmpdir(), "didless-startup-")); 182 + 183 + // First: start and stop to create the database 184 + const config1 = didlessConfig(tmpDir); 185 + handle = await startServer(config1); 186 + expect(config1.DID).toBeUndefined(); 187 + await handle.close(); 188 + handle = undefined; 189 + 190 + // Simulate OAuth having stored an identity 191 + const dbPath = resolve(tmpDir, "pds.db"); 192 + const db = new Database(dbPath); 193 + db.prepare("INSERT INTO node_identity (did, handle) VALUES (?, ?)").run( 194 + "did:plc:stored-identity", 195 + "stored.test", 196 + ); 197 + db.close(); 198 + 199 + // Second: restart — should pick up the stored identity 200 + const config2 = didlessConfig(tmpDir); 201 + handle = await startServer(config2); 202 + expect(config2.DID).toBe("did:plc:stored-identity"); 203 + expect(config2.HANDLE).toBe("stored.test"); 204 + }); 205 + 206 + it("env DID overrides stored identity", async () => { 207 + tmpDir = mkdtempSync(join(tmpdir(), "didless-startup-")); 208 + 209 + // Pre-populate node_identity 210 + const dbPath = resolve(tmpDir, "pds.db"); 211 + const db = new Database(dbPath); 212 + db.pragma("journal_mode = WAL"); 213 + db.exec(` 214 + CREATE TABLE IF NOT EXISTS node_identity ( 215 + did TEXT PRIMARY KEY, 216 + handle TEXT, 217 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 218 + ) 219 + `); 220 + db.prepare("INSERT INTO node_identity (did, handle) VALUES (?, ?)").run( 221 + "did:plc:stored-identity", 222 + "stored.test", 223 + ); 224 + db.close(); 225 + 226 + // Start with env DID set — should NOT be overridden by stored identity 227 + const config = didlessConfig(tmpDir); 228 + config.DID = "did:plc:env-identity"; 229 + config.SIGNING_KEY = "0000000000000000000000000000000000000000000000000000000000000001"; 230 + handle = await startServer(config); 231 + expect(config.DID).toBe("did:plc:env-identity"); 232 + }); 233 + 234 + it("graceful shutdown works without DID", async () => { 235 + tmpDir = mkdtempSync(join(tmpdir(), "didless-startup-")); 236 + const config = didlessConfig(tmpDir); 237 + handle = await startServer(config); 238 + 239 + // Verify running 240 + const res = await fetch(`${handle.url}/xrpc/_health`); 241 + expect(res.status).toBe(200); 242 + 243 + // Close cleanly 244 + await handle.close(); 245 + handle = undefined; 246 + }); 247 + });
+2
src/replication/replication-manager.ts
··· 86 86 pdsClient?: RecordWriter, 87 87 ) { 88 88 this.syncStorage = new SyncStorage(db); 89 + this.syncStorage.initSchema(); 89 90 this.challengeStorage = new ChallengeStorage(db); 91 + this.challengeStorage.initSchema(); 90 92 this.repoFetcher = new RepoFetcher(didResolver); 91 93 this.peerDiscovery = new PeerDiscovery(this.repoFetcher); 92 94 this.verifier = new BlockVerifier(blockStore);
+2 -2
src/start.ts
··· 228 228 } 229 229 }); 230 230 231 - // Backfill existing blocks to IPFS 231 + // Backfill existing blocks to IPFS (only when RepoManager created the blocks table) 232 232 async function backfillIpfs(): Promise<void> { 233 - if (!ipfsService) return; 233 + if (!ipfsService || !repoManager) return; 234 234 235 235 const rows = db 236 236 .prepare("SELECT cid, bytes FROM blocks")