atproto user agency toolkit for individuals and groups
8
fork

Configure Feed

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

Add real-account E2E libp2p test, dashboard UI improvements, rename admin→app

- Real bidirectional replication test (scripts/real-bidir-test.ts):
event-driven OAuth+sync flow, IPFS_NETWORKING=true, libp2p cross-sync
assertions, session reuse for fast re-runs, data-aware self-sync detection
- Dashboard: gate add-DID during self-sync with spinner, activity spinner,
disabled input/button styling, hide self-DID remove button
- Rename xrpc/admin → xrpc/app (routes, tests, e2e tests)
- Gossipsub shutdown error handling in replication-manager
- Tauri desktop sidecar process management
- Memory: NEXT-STEPS.md with reactive sync roadmap

+891 -160
+3 -3
README.md
··· 123 123 124 124 Policies drive sync intervals, priority ordering, and `shouldReplicate` filtering in the replication manager. P2P policies are auto-generated from mutual offer records with `p2p:` prefixed IDs. 125 125 126 - ## Admin 126 + ## App 127 127 128 - - **Dashboard**: Server-rendered HTML at `/xrpc/org.p2pds.admin.dashboard` (auto-refresh) 128 + - **Dashboard**: Server-rendered HTML at `/` (auto-refresh) 129 129 - **API**: Authenticated XRPC endpoints for overview, per-DID status, network status, policies, sync history 130 130 - **DID management**: Add/remove DIDs at runtime via `addDid`/`removeDid` endpoints 131 131 - **Rate limiting**: Per-IP and per-DID limits across HTTP, gossipsub, and libp2p 132 132 133 133 ## Desktop App 134 134 135 - Optional Tauri v2 wrapper at `apps/desktop/`. Spawns p2pds as a sidecar process and loads the admin dashboard in a webview. 135 + Optional Tauri v2 wrapper at `apps/desktop/`. Spawns p2pds as a sidecar process and loads the dashboard in a webview. 136 136 137 137 ``` 138 138 cd apps/desktop
+60 -9
apps/desktop/src/main.ts
··· 1 1 import { Command, type Child } from "@tauri-apps/plugin-shell"; 2 2 3 - const HEALTH_URL = "http://127.0.0.1:3000/xrpc/_health"; 4 - const DASHBOARD_URL = "http://127.0.0.1:3000/xrpc/org.p2pds.admin.dashboard"; 3 + const FALLBACK_PORT = 3000; 5 4 const POLL_INTERVAL_MS = 500; 6 5 const STARTUP_TIMEOUT_MS = 30_000; 7 6 ··· 21 20 if (errorMsg) errorMsg.textContent = message; 22 21 } 23 22 24 - async function waitForServer(): Promise<void> { 23 + function healthUrl(port: number): string { 24 + return `http://127.0.0.1:${port}/xrpc/_health`; 25 + } 26 + 27 + function dashboardUrl(port: number): string { 28 + return `http://127.0.0.1:${port}/`; 29 + } 30 + 31 + /** 32 + * Parse the P2PDS_READY line from sidecar stdout. 33 + * Format: `P2PDS_READY {"port":12345,"url":"http://localhost:12345"}` 34 + */ 35 + function parseReadyLine(line: string): { port: number; url: string } | null { 36 + const prefix = "P2PDS_READY "; 37 + if (!line.startsWith(prefix)) return null; 38 + try { 39 + return JSON.parse(line.slice(prefix.length)); 40 + } catch { 41 + return null; 42 + } 43 + } 44 + 45 + async function waitForServer(port: number): Promise<void> { 25 46 const deadline = Date.now() + STARTUP_TIMEOUT_MS; 26 47 27 48 while (Date.now() < deadline) { 28 49 try { 29 - const res = await fetch(HEALTH_URL); 50 + const res = await fetch(healthUrl(port)); 30 51 if (res.ok) { 31 52 return; 32 53 } ··· 42 63 ); 43 64 } 44 65 45 - async function spawnSidecar(): Promise<Child> { 66 + /** 67 + * Spawn the sidecar and wait for the P2PDS_READY line to detect the port. 68 + * Returns the detected port, or FALLBACK_PORT if detection fails. 69 + */ 70 + async function spawnSidecar(): Promise<{ child: Child; port: number }> { 46 71 const command = Command.sidecar("binaries/p2pds"); 47 72 73 + let detectedPort: number | null = null; 74 + let resolvePort: ((port: number) => void) | null = null; 75 + const portPromise = new Promise<number>((resolve) => { 76 + resolvePort = resolve; 77 + }); 78 + 48 79 command.stdout.on("data", (line: string) => { 49 80 console.log(`[p2pds] ${line}`); 81 + if (detectedPort === null) { 82 + const ready = parseReadyLine(line); 83 + if (ready) { 84 + detectedPort = ready.port; 85 + resolvePort!(ready.port); 86 + } 87 + } 50 88 }); 51 89 52 90 command.stderr.on("data", (line: string) => { ··· 66 104 }); 67 105 68 106 const child = await command.spawn(); 69 - return child; 107 + 108 + // Wait for port detection with timeout, fallback to default 109 + const port = await Promise.race([ 110 + portPromise, 111 + new Promise<number>((resolve) => 112 + setTimeout(() => { 113 + console.warn(`[p2pds] P2PDS_READY not detected, falling back to port ${FALLBACK_PORT}`); 114 + resolve(FALLBACK_PORT); 115 + }, STARTUP_TIMEOUT_MS) 116 + ), 117 + ]); 118 + 119 + return { child, port }; 70 120 } 71 121 72 122 async function start(): Promise<void> { 73 123 try { 74 124 setStatus("Starting p2pds..."); 75 - sidecarProcess = await spawnSidecar(); 125 + const { child, port } = await spawnSidecar(); 126 + sidecarProcess = child; 76 127 77 128 setStatus("Waiting for server..."); 78 - await waitForServer(); 129 + await waitForServer(port); 79 130 80 131 setStatus("Redirecting to dashboard..."); 81 - window.location.href = DASHBOARD_URL; 132 + window.location.href = dashboardUrl(port); 82 133 } catch (err) { 83 134 const message = err instanceof Error ? err.message : String(err); 84 135 showError(message);
+5
memory/NEXT-STEPS.md
··· 1 + # Next Steps 2 + 3 + ## Reactive sync for watched accounts 4 + 5 + For tracked DIDs, respond to changes from whichever source fires first: firehose (centralized relay, has full blocks for direct apply) or gossipsub (peer notification, triggers peer-first fetch). Both paths exist independently with dedup. Next: ensure both are always active for all tracked DIDs, unify the signal, add per-DID metrics for which source won.
+1 -1
scripts/demo-replication.ts
··· 199 199 } 200 200 } 201 201 202 - console.log(pc.bold(pc.green(`\n✓ Dashboard ready at: http://127.0.0.1:${PORT_B}/xrpc/org.p2pds.admin.dashboard`))); 202 + console.log(pc.bold(pc.green(`\n✓ Dashboard ready at: http://127.0.0.1:${PORT_B}/`))); 203 203 console.log(pc.dim(" Auth token: demo-token")); 204 204 console.log(pc.dim(" Press Ctrl+C to stop\n")); 205 205
+485
scripts/real-bidir-test.ts
··· 1 + /** 2 + * Real OAuth Bidirectional Replication Smoke Test 3 + * 4 + * Starts two p2pds servers with IPFS networking enabled, guides user 5 + * through OAuth in the browser, then automates: self-sync, peer dial, 6 + * cross-sync via libp2p, verify cross-serving, cleanup. 7 + * 8 + * No polling or timeouts — uses property interception for OAuth identity 9 + * events, promise capture for syncDid calls, and deterministic dial(). 10 + * 11 + * Usage: npx tsx scripts/real-bidir-test.ts alice.bsky.social bob.bsky.social [--clean] 12 + * 13 + * Sessions persist in stable data dirs — re-runs skip OAuth and self-sync. 14 + * Use --clean to wipe data dirs and disconnect after the test. 15 + */ 16 + 17 + import { mkdirSync, rmSync, existsSync } from "node:fs"; 18 + import { tmpdir } from "node:os"; 19 + import { join } from "node:path"; 20 + import { execSync } from "node:child_process"; 21 + import { startServer, type ServerHandle } from "../src/start.js"; 22 + import type { Config } from "../src/config.js"; 23 + import type { ReplicationManager } from "../src/replication/replication-manager.js"; 24 + import type { IpfsService } from "../src/ipfs.js"; 25 + 26 + // --------------------------------------------------------------------------- 27 + // Shared state for signal-safe cleanup 28 + // --------------------------------------------------------------------------- 29 + 30 + let serverA: ServerHandle | undefined; 31 + let serverB: ServerHandle | undefined; 32 + let tmpA: string | undefined; 33 + let tmpB: string | undefined; 34 + let cleanDirs = false; 35 + 36 + async function cleanup() { 37 + log("Shutting down servers..."); 38 + if (serverA) { await serverA.close().catch(() => {}); serverA = undefined; } 39 + if (serverB) { await serverB.close().catch(() => {}); serverB = undefined; } 40 + if (cleanDirs) { 41 + if (tmpA) { log("Cleaning up data dirs..."); rmSync(tmpA, { recursive: true, force: true }); tmpA = undefined; } 42 + if (tmpB) { rmSync(tmpB, { recursive: true, force: true }); tmpB = undefined; } 43 + } 44 + 45 + // Brief settle for async gossipsub teardown 46 + await new Promise((r) => setTimeout(r, 500)); 47 + log("Done."); 48 + } 49 + 50 + // Suppress gossipsub StreamStateError (async background streams during peer connect/disconnect) 51 + process.on("uncaughtException", (err) => { 52 + if (err?.constructor?.name === "StreamStateError") return; 53 + console.error("Uncaught:", err); 54 + cleanup().finally(() => process.exit(1)); 55 + }); 56 + 57 + // Handle SIGINT/SIGTERM for clean shutdown when killed 58 + for (const sig of ["SIGINT", "SIGTERM"] as const) { 59 + process.on(sig, () => { 60 + log(`Caught ${sig}, cleaning up...`); 61 + cleanup().finally(() => process.exit(1)); 62 + }); 63 + } 64 + 65 + // --------------------------------------------------------------------------- 66 + // Helpers 67 + // --------------------------------------------------------------------------- 68 + 69 + function log(msg: string) { 70 + console.log(`\x1b[36m[test]\x1b[0m ${msg}`); 71 + } 72 + 73 + function fail(msg: string): never { 74 + console.error(`\x1b[31m[FAIL]\x1b[0m ${msg}`); 75 + cleanup().finally(() => process.exit(1)); 76 + throw new Error("unreachable"); 77 + } 78 + 79 + function ok(msg: string) { 80 + console.log(`\x1b[32m[ OK ]\x1b[0m ${msg}`); 81 + } 82 + 83 + /** 84 + * Intercept config.DID setter so we get an event-driven promise 85 + * that resolves when the OAuth callback establishes identity. 86 + * No polling — the setter fires synchronously inside the callback. 87 + */ 88 + function withIdentityPromise(config: Config): Promise<string> { 89 + let _did = config.DID; 90 + let _resolve: ((did: string) => void) | undefined; 91 + 92 + const promise = new Promise<string>((resolve) => { 93 + if (_did) { resolve(_did); return; } 94 + _resolve = resolve; 95 + }); 96 + 97 + Object.defineProperty(config, "DID", { 98 + get: () => _did, 99 + set: (v: string | undefined) => { 100 + _did = v; 101 + if (v && _resolve) { 102 + _resolve(v); 103 + _resolve = undefined; 104 + } 105 + }, 106 + enumerable: true, 107 + configurable: true, 108 + }); 109 + 110 + return promise; 111 + } 112 + 113 + /** 114 + * Intercept syncDid on a ReplicationManager to capture per-DID promises. 115 + * 116 + * Returns an `awaitSync(did)` function that: 117 + * - Returns the stored promise if syncDid was already called for that DID 118 + * - Otherwise waits (event-driven, no polling) until syncDid is called 119 + */ 120 + function interceptSyncDid(rm: ReplicationManager) { 121 + const captured = new Map<string, Promise<void>>(); 122 + const waiters = new Map<string, (syncPromise: Promise<void>) => void>(); 123 + 124 + const origSyncDid = rm.syncDid.bind(rm); 125 + (rm as any).syncDid = (did: string) => { 126 + const p = origSyncDid(did); 127 + captured.set(did, p); 128 + const waiter = waiters.get(did); 129 + if (waiter) { 130 + waiter(p); 131 + waiters.delete(did); 132 + } 133 + return p; 134 + }; 135 + 136 + return { 137 + awaitSync(did: string): Promise<void> { 138 + if (captured.has(did)) return captured.get(did)!; 139 + return new Promise<void>((resolve, reject) => { 140 + waiters.set(did, (p) => p.then(resolve, reject)); 141 + }); 142 + }, 143 + }; 144 + } 145 + 146 + function makeConfig(dataDir: string, port: number): Config { 147 + return { 148 + PDS_HOSTNAME: `localhost:${port}`, 149 + AUTH_TOKEN: "smoke-test-token", 150 + JWT_SECRET: "smoke-jwt-secret", 151 + PASSWORD_HASH: "$2a$10$test", 152 + DATA_DIR: dataDir, 153 + PORT: port, 154 + IPFS_ENABLED: true, 155 + IPFS_NETWORKING: true, 156 + REPLICATE_DIDS: [], 157 + FIREHOSE_URL: "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos", 158 + FIREHOSE_ENABLED: false, 159 + RATE_LIMIT_ENABLED: false, 160 + RATE_LIMIT_READ_PER_MIN: 300, 161 + RATE_LIMIT_SYNC_PER_MIN: 30, 162 + RATE_LIMIT_SESSION_PER_MIN: 10, 163 + RATE_LIMIT_WRITE_PER_MIN: 200, 164 + RATE_LIMIT_CHALLENGE_PER_MIN: 20, 165 + RATE_LIMIT_MAX_CONNECTIONS: 100, 166 + RATE_LIMIT_FIREHOSE_PER_IP: 3, 167 + OAUTH_ENABLED: true, 168 + }; 169 + } 170 + 171 + async function fetchJson<T>(url: string, opts?: RequestInit): Promise<T> { 172 + const res = await fetch(url, opts); 173 + if (!res.ok) { 174 + const text = await res.text(); 175 + throw new Error(`HTTP ${res.status}: ${text}`); 176 + } 177 + return res.json() as Promise<T>; 178 + } 179 + 180 + // --------------------------------------------------------------------------- 181 + // Main 182 + // --------------------------------------------------------------------------- 183 + 184 + const RECORD_CEILING = 10; 185 + 186 + async function main() { 187 + const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); 188 + const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith("--"))); 189 + cleanDirs = flags.has("--clean"); 190 + 191 + if (args.length < 2) { 192 + console.log("Usage: npx tsx scripts/real-bidir-test.ts <handle-a> <handle-b> [--clean]"); 193 + console.log(" --clean Remove data dirs after test (default: keep for fast re-runs)"); 194 + console.log("Example: npx tsx scripts/real-bidir-test.ts alice.bsky.social bob.bsky.social"); 195 + process.exit(1); 196 + } 197 + 198 + const [handleA, handleB] = args; 199 + const portA = 3100; 200 + const portB = 3101; 201 + 202 + // 1. Stable data dirs (reused across runs to keep OAuth sessions) 203 + tmpA = join(tmpdir(), "p2pds-smoke-a"); 204 + tmpB = join(tmpdir(), "p2pds-smoke-b"); 205 + mkdirSync(tmpA, { recursive: true }); 206 + mkdirSync(tmpB, { recursive: true }); 207 + log(`Data dirs: ${tmpA}, ${tmpB}`); 208 + 209 + try { 210 + // 2. Start servers with identity interception 211 + const configA = makeConfig(tmpA, portA); 212 + const configB = makeConfig(tmpB, portB); 213 + const identityA = withIdentityPromise(configA); 214 + const identityB = withIdentityPromise(configB); 215 + 216 + log("Starting Node A on port 3100..."); 217 + serverA = await startServer(configA); 218 + log("Starting Node B on port 3101..."); 219 + serverB = await startServer(configB); 220 + ok("Both servers started"); 221 + 222 + const rmA = serverA.replicationManager; 223 + const rmB = serverB.replicationManager; 224 + const ipfsA = serverA.ipfsService; 225 + const ipfsB = serverB.ipfsService; 226 + if (!rmA || !rmB) fail("ReplicationManager not available on one or both servers"); 227 + if (!ipfsA || !ipfsB) fail("IpfsService not available on one or both servers"); 228 + 229 + // 3. Stop periodic sync — we control sync explicitly in this test 230 + // Set stopped=true to block the initial 5s delayed syncAll() too 231 + (rmA as any).stopped = true; 232 + (rmB as any).stopped = true; 233 + if ((rmA as any).syncTimer) { clearInterval((rmA as any).syncTimer); (rmA as any).syncTimer = null; } 234 + if ((rmB as any).syncTimer) { clearInterval((rmB as any).syncTimer); (rmB as any).syncTimer = null; } 235 + 236 + // Purge leftover cross-DIDs from previous runs 237 + const selfDidA = configA.DID; 238 + const selfDidB = configB.DID; 239 + if (selfDidA) { 240 + for (const did of rmA.getReplicateDids()) { 241 + if (did !== selfDidA) { await rmA.removeDid(did, true); log(`Purged leftover ${did} from Node A`); } 242 + } 243 + } 244 + if (selfDidB) { 245 + for (const did of rmB.getReplicateDids()) { 246 + if (did !== selfDidB) { await rmB.removeDid(did, true); log(`Purged leftover ${did} from Node B`); } 247 + } 248 + } 249 + 250 + // 4. Set up syncDid interception BEFORE OAuth — captures self-sync + cross-sync promises 251 + const captureA = interceptSyncDid(rmA); 252 + const captureB = interceptSyncDid(rmB); 253 + 254 + // 5. Skip blob sync — blocks-only is sufficient for smoke test 255 + const noopBlobs = async () => ({ fetched: 0, skipped: 0, errors: 0, totalBytes: 0 }); 256 + (rmA as any).syncBlobs = noopBlobs; 257 + (rmB as any).syncBlobs = noopBlobs; 258 + 259 + // 5. Check if already authenticated (identity loaded from DB on startup) 260 + const alreadyAuthedA = !!configA.DID; 261 + const alreadyAuthedB = !!configB.DID; 262 + 263 + if (alreadyAuthedA && alreadyAuthedB) { 264 + ok(`Reusing sessions: A=${configA.DID} (@${configA.HANDLE ?? "?"}), B=${configB.DID} (@${configB.HANDLE ?? "?"})`); 265 + } else { 266 + const urlA = `http://localhost:${portA}/oauth/login?handle=${encodeURIComponent(handleA!)}`; 267 + const urlB = `http://localhost:${portB}/oauth/login?handle=${encodeURIComponent(handleB!)}`; 268 + 269 + console.log(); 270 + console.log("\x1b[1m=== Opening OAuth login in browser ===\x1b[0m"); 271 + console.log(); 272 + if (!alreadyAuthedA) console.log(` Node A (${handleA}): ${urlA}`); 273 + if (!alreadyAuthedB) console.log(` Node B (${handleB}): ${urlB}`); 274 + console.log(); 275 + 276 + try { 277 + if (!alreadyAuthedA) execSync(`open ${JSON.stringify(urlA)}`); 278 + if (!alreadyAuthedB) execSync(`open ${JSON.stringify(urlB)}`); 279 + log("Opened login URLs in your browser. Please authenticate."); 280 + } catch { 281 + log("Could not auto-open browser. Please open the URLs above manually."); 282 + } 283 + 284 + log("Waiting for authentication (Ctrl+C to abort)..."); 285 + } 286 + 287 + // 6. Await identity events (resolves immediately if already authed) 288 + const [didA, didB] = await Promise.all([identityA, identityB]); 289 + 290 + ok(`Node A: ${didA} (@${configA.HANDLE ?? "unknown"})`); 291 + ok(`Node B: ${didB} (@${configB.HANDLE ?? "unknown"})`); 292 + 293 + // 7. Self-sync: if fresh OAuth, wait for the auto-triggered sync. 294 + // If session reused but data is missing (cleared), trigger sync explicitly. 295 + const selfStateA = rmA.getSyncStorage().getState(didA); 296 + const selfStateB = rmB.getSyncStorage().getState(didB); 297 + const needSyncA = !selfStateA || selfStateA.status !== "synced"; 298 + const needSyncB = !selfStateB || selfStateB.status !== "synced"; 299 + 300 + if (needSyncA || needSyncB) { 301 + log("Self-sync needed..."); 302 + const waits: Promise<void>[] = []; 303 + if (needSyncA && alreadyAuthedA) { 304 + // Session reused but data cleared — trigger self-sync explicitly 305 + rmA.addDid(didA).catch(() => {}); 306 + } 307 + if (needSyncB && alreadyAuthedB) { 308 + rmB.addDid(didB).catch(() => {}); 309 + } 310 + if (needSyncA) waits.push(captureA.awaitSync(didA)); 311 + if (needSyncB) waits.push(captureB.awaitSync(didB)); 312 + await Promise.all(waits); 313 + ok("Self-sync complete on both nodes"); 314 + } else { 315 + ok("Self-sync data already present — skipping"); 316 + } 317 + 318 + // 8. Dial peers to establish libp2p connections (use local TCP, not relay) 319 + const addrsA = ipfsA.getMultiaddrs(); 320 + const addrsB = ipfsB.getMultiaddrs(); 321 + const pickLocal = (addrs: typeof addrsA) => 322 + addrs.find((a) => { 323 + const s = a.toString(); 324 + return s.includes("/ip4/127.0.0.1/tcp/") && !s.includes("/ws/") && !s.includes("/p2p-circuit/"); 325 + }); 326 + const localA = pickLocal(addrsA); 327 + const localB = pickLocal(addrsB); 328 + if (!localA || !localB) { 329 + fail(`No local TCP addr (A: ${!!localA}, B: ${!!localB})`); 330 + } 331 + log(`Node A local: ${localA}`); 332 + log(`Node B local: ${localB}`); 333 + 334 + await Promise.all([ 335 + ipfsA.dial(localB), 336 + ipfsB.dial(localA), 337 + ]); 338 + ok("Peer connections established via libp2p"); 339 + 340 + // 9. Add cross-DIDs — triggers syncDid (stopped=true only blocks syncAll, not syncDid) 341 + log("Adding cross-DIDs and syncing via libp2p..."); 342 + await rmA.addDid(didB); 343 + ok(`Node A now tracking ${didB}`); 344 + await rmB.addDid(didA); 345 + ok(`Node B now tracking ${didA}`); 346 + 347 + // 10. Wait for cross-sync completion 348 + await Promise.all([ 349 + captureA.awaitSync(didB), 350 + captureB.awaitSync(didA), 351 + ]); 352 + ok("Cross-sync complete"); 353 + 354 + // 11. Verify cross-sync used libp2p (not HTTP PDS fallback) 355 + const historyA = rmA.getSyncStorage().getSyncHistory(didB, 1); 356 + const historyB = rmB.getSyncStorage().getSyncHistory(didA, 1); 357 + 358 + if (historyA.length === 0) fail("No sync history on Node A for cross-sync"); 359 + if (historyB.length === 0) fail("No sync history on Node B for cross-sync"); 360 + 361 + const sourceA = historyA[0]!.sourceType; 362 + const sourceB = historyB[0]!.sourceType; 363 + 364 + if (sourceA !== "libp2p") { 365 + fail(`Node A cross-sync used "${sourceA}" instead of "libp2p"`); 366 + } 367 + ok(`Node A cross-synced ${didB} via libp2p`); 368 + 369 + if (sourceB !== "libp2p") { 370 + fail(`Node B cross-sync used "${sourceB}" instead of "libp2p"`); 371 + } 372 + ok(`Node B cross-synced ${didA} via libp2p`); 373 + 374 + // 12. Verify sync state 375 + const syncStateA = rmA.getSyncStorage().getState(didB); 376 + if (!syncStateA || syncStateA.status !== "synced") { 377 + fail(`Node A sync status for ${didB}: ${syncStateA?.status ?? "missing"}`); 378 + } 379 + ok(`Node A synced ${didB}`); 380 + 381 + const syncStateB = rmB.getSyncStorage().getState(didA); 382 + if (!syncStateB || syncStateB.status !== "synced") { 383 + fail(`Node B sync status for ${didA}: ${syncStateB?.status ?? "missing"}`); 384 + } 385 + ok(`Node B synced ${didA}`); 386 + 387 + // 13. Verify cross-serving via HTTP reads (one-shot, not polling) 388 + log("Verifying cross-serving..."); 389 + 390 + // Node A describes Node B's repo 391 + const descA = await fetchJson<{ did: string; collections: string[] }>( 392 + `http://localhost:${portA}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(didB)}`, 393 + ); 394 + if (descA.did !== didB) fail(`describeRepo DID mismatch: ${descA.did} !== ${didB}`); 395 + ok(`Node A describes ${didB}: ${descA.collections.length} collections`); 396 + 397 + // Node B describes Node A's repo 398 + const descB = await fetchJson<{ did: string; collections: string[] }>( 399 + `http://localhost:${portB}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(didA)}`, 400 + ); 401 + if (descB.did !== didA) fail(`describeRepo DID mismatch: ${descB.did} !== ${didA}`); 402 + ok(`Node B describes ${didA}: ${descB.collections.length} collections`); 403 + 404 + // Node A lists records from Node B (ceiling of 10 per collection, first 5 collections) 405 + let totalRecsA = 0; 406 + for (const coll of descA.collections.slice(0, 5)) { 407 + const recs = await fetchJson<{ records: Array<{ uri: string }> }>( 408 + `http://localhost:${portA}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(didB)}&collection=${encodeURIComponent(coll)}&limit=${RECORD_CEILING}`, 409 + ); 410 + totalRecsA += recs.records.length; 411 + if (recs.records.length > 0) { 412 + log(` ${coll}: ${recs.records.length} records`); 413 + } 414 + } 415 + if (totalRecsA === 0) fail("Node A served 0 records for Node B"); 416 + ok(`Node A serves ${totalRecsA} records for ${didB}`); 417 + 418 + // Node B lists records from Node A (ceiling of 10 per collection, first 5 collections) 419 + let totalRecsB = 0; 420 + for (const coll of descB.collections.slice(0, 5)) { 421 + const recs = await fetchJson<{ records: Array<{ uri: string }> }>( 422 + `http://localhost:${portB}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(didA)}&collection=${encodeURIComponent(coll)}&limit=${RECORD_CEILING}`, 423 + ); 424 + totalRecsB += recs.records.length; 425 + if (recs.records.length > 0) { 426 + log(` ${coll}: ${recs.records.length} records`); 427 + } 428 + } 429 + if (totalRecsB === 0) fail("Node B served 0 records for Node A"); 430 + ok(`Node B serves ${totalRecsB} records for ${didA}`); 431 + 432 + // Node A serves Node B's repo via getRepo 433 + const getRepoA = await fetch( 434 + `http://localhost:${portA}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(didB)}`, 435 + ); 436 + if (getRepoA.status !== 200) fail(`getRepo failed: ${getRepoA.status}`); 437 + const carA = new Uint8Array(await getRepoA.arrayBuffer()); 438 + ok(`Node A serves ${didB} repo CAR (${(carA.length / 1024).toFixed(1)} KB)`); 439 + 440 + // Node B serves Node A's repo via getRepo 441 + const getRepoB = await fetch( 442 + `http://localhost:${portB}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(didA)}`, 443 + ); 444 + if (getRepoB.status !== 200) fail(`getRepo failed: ${getRepoB.status}`); 445 + const carB = new Uint8Array(await getRepoB.arrayBuffer()); 446 + ok(`Node B serves ${didA} repo CAR (${(carB.length / 1024).toFixed(1)} KB)`); 447 + 448 + // 14. Summary 449 + console.log(); 450 + console.log("\x1b[1m=== Summary ===\x1b[0m"); 451 + console.log(` Node A (@${configA.HANDLE ?? "?"}): verified ${totalRecsA} records served for @${configB.HANDLE ?? "?"}`); 452 + console.log(` Node B (@${configB.HANDLE ?? "?"}): verified ${totalRecsB} records served for @${configA.HANDLE ?? "?"}`); 453 + console.log(` Cross-sync transport: libp2p (both directions)`); 454 + console.log(); 455 + 456 + // 15. Purge cross-sync data (keep self-sync for fast re-runs) 457 + log("Purging cross-replicated data..."); 458 + await rmA.removeDid(didB, true); 459 + ok(`Node A purged ${didB}`); 460 + await rmB.removeDid(didA, true); 461 + ok(`Node B purged ${didA}`); 462 + 463 + // 16. Logout only if --clean (otherwise keep sessions for re-runs) 464 + if (cleanDirs) { 465 + log("Disconnecting identities..."); 466 + await fetch(`http://localhost:${portA}/oauth/logout?disconnect=true`, { method: "POST" }); 467 + ok("Node A disconnected"); 468 + await fetch(`http://localhost:${portB}/oauth/logout?disconnect=true`, { method: "POST" }); 469 + ok("Node B disconnected"); 470 + } else { 471 + log("Keeping sessions for re-runs (use --clean to disconnect)"); 472 + } 473 + 474 + console.log(); 475 + ok("All checks passed!"); 476 + 477 + } catch (err) { 478 + const msg = err instanceof Error ? err.stack ?? err.message : String(err); 479 + console.error(`\x1b[31m[FAIL]\x1b[0m ${msg}`); 480 + } finally { 481 + await cleanup(); 482 + } 483 + } 484 + 485 + main();
+6 -6
scripts/smoke-test.sh
··· 112 112 "200" \ 113 113 '"status":"ok"' 114 114 115 - check "admin.dashboard" \ 116 - "$BASE_URL/xrpc/org.p2pds.admin.dashboard" \ 115 + check "app.dashboard" \ 116 + "$BASE_URL/" \ 117 117 "200" \ 118 118 "P2PDS" 119 119 120 - check "admin.getOverview" \ 121 - "$BASE_URL/xrpc/org.p2pds.admin.getOverview" \ 120 + check "app.getOverview" \ 121 + "$BASE_URL/xrpc/org.p2pds.app.getOverview" \ 122 122 "200" \ 123 123 '"version"' \ 124 124 "smoke-test-token" 125 125 126 - check "admin.getNetworkStatus" \ 127 - "$BASE_URL/xrpc/org.p2pds.admin.getNetworkStatus" \ 126 + check "app.getNetworkStatus" \ 127 + "$BASE_URL/xrpc/org.p2pds.app.getNetworkStatus" \ 128 128 "200" \ 129 129 "" \ 130 130 "smoke-test-token"
+4 -4
src/bidirectional-replication.test.ts
··· 245 245 expect(handleB.replicationManager).toBeDefined(); 246 246 247 247 // Node A adds Node B's DID, Node B adds Node A's DID 248 - const addBToA = await fetch(`${handleA.url}/xrpc/org.p2pds.admin.addDid`, { 248 + const addBToA = await fetch(`${handleA.url}/xrpc/org.p2pds.app.addDid`, { 249 249 method: "POST", 250 250 headers: { Authorization: `Bearer ${configA.AUTH_TOKEN}`, "Content-Type": "application/json" }, 251 251 body: JSON.stringify({ did: DID_NODE_B }), 252 252 }); 253 253 expect(addBToA.status).toBe(200); 254 254 255 - const addAToB = await fetch(`${handleB.url}/xrpc/org.p2pds.admin.addDid`, { 255 + const addAToB = await fetch(`${handleB.url}/xrpc/org.p2pds.app.addDid`, { 256 256 method: "POST", 257 257 headers: { Authorization: `Bearer ${configB.AUTH_TOKEN}`, "Content-Type": "application/json" }, 258 258 body: JSON.stringify({ did: DID_NODE_A }), ··· 274 274 275 275 // Verify Node A synced Bob's data 276 276 const statusA = await fetch( 277 - `${handleA.url}/xrpc/org.p2pds.admin.getDidStatus?did=${DID_NODE_B}`, 277 + `${handleA.url}/xrpc/org.p2pds.app.getDidStatus?did=${DID_NODE_B}`, 278 278 { headers: { Authorization: `Bearer ${configA.AUTH_TOKEN}` } }, 279 279 ); 280 280 const dsA = (await statusA.json()) as { did: string; blockCount: number; syncState: { status: string } }; ··· 284 284 285 285 // Verify Node B synced Alice's data 286 286 const statusB = await fetch( 287 - `${handleB.url}/xrpc/org.p2pds.admin.getDidStatus?did=${DID_NODE_A}`, 287 + `${handleB.url}/xrpc/org.p2pds.app.getDidStatus?did=${DID_NODE_A}`, 288 288 { headers: { Authorization: `Bearer ${configB.AUTH_TOKEN}` } }, 289 289 ); 290 290 const dsB = (await statusB.json()) as { did: string; blockCount: number; syncState: { status: string } };
+1 -1
src/config.ts
··· 86 86 */ 87 87 export function loadConfig(envPath?: string): Config { 88 88 // Load .env file if it exists 89 - const dotenvPath = envPath ?? resolve(process.cwd(), ".env"); 89 + const dotenvPath = envPath ?? process.env.DOTENV_PATH ?? resolve(process.cwd(), ".env"); 90 90 loadDotEnv(dotenvPath); 91 91 92 92 // Validate required variables
+3 -3
src/didless-startup.test.ts
··· 86 86 const config = didlessConfig(tmpDir); 87 87 handle = await startServer(config); 88 88 89 - const res = await fetch(`${handle.url}/xrpc/org.p2pds.admin.dashboard`); 89 + const res = await fetch(`${handle.url}/`); 90 90 expect(res.status).toBe(200); 91 91 const html = await res.text(); 92 92 expect(html).toContain("P2PDS"); ··· 126 126 handle = await startServer(config, { didResolver: mockResolver }); 127 127 128 128 // Add DID via admin API 129 - const addRes = await fetch(`${handle.url}/xrpc/org.p2pds.admin.addDid`, { 129 + const addRes = await fetch(`${handle.url}/xrpc/org.p2pds.app.addDid`, { 130 130 method: "POST", 131 131 headers: { 132 132 Authorization: `Bearer ${config.AUTH_TOKEN}`, ··· 147 147 await new Promise((r) => setTimeout(r, 2000)); 148 148 149 149 // Verify replication state 150 - const overviewRes = await fetch(`${handle.url}/xrpc/org.p2pds.admin.getOverview`, { 150 + const overviewRes = await fetch(`${handle.url}/xrpc/org.p2pds.app.getOverview`, { 151 151 headers: { Authorization: `Bearer ${config.AUTH_TOKEN}` }, 152 152 }); 153 153 expect(overviewRes.status).toBe(200);
+24 -66
src/index.ts
··· 19 19 import * as sync from "./xrpc/sync.js"; 20 20 import * as repo from "./xrpc/repo.js"; 21 21 import * as server from "./xrpc/server.js"; 22 - import * as admin from "./xrpc/admin.js"; 22 + import * as app_routes from "./xrpc/app.js"; 23 23 import { respondToChallenge } from "./replication/challenge-response/challenge-responder.js"; 24 24 import { serializeResponse } from "./replication/challenge-response/http-transport.js"; 25 25 import type { StorageChallenge } from "./replication/challenge-response/types.js"; ··· 159 159 }), 160 160 ); 161 161 162 - // Admin endpoints 163 - const adminRL = rateLimitMiddleware(rateLimiter, { 164 - pool: "admin", 162 + // App endpoints 163 + const appRL = rateLimitMiddleware(rateLimiter, { 164 + pool: "app", 165 165 rule: { maxRequests: 300, windowMs: w }, 166 166 }); 167 - app.use("/xrpc/org.p2pds.admin.*", adminRL); 167 + app.use("/xrpc/org.p2pds.app.*", appRL); 168 168 } 169 169 170 170 // Body size limits (always active, independent of rate limiting) ··· 258 258 } 259 259 }); 260 260 261 - // Homepage 262 - app.get("/", (c) => { 263 - const handleHtml = config.HANDLE 264 - ? `<div class="handle"><a href="https://bsky.app/profile/${config.HANDLE}" target="_blank">@${config.HANDLE}</a></div>` 265 - : config.DID 266 - ? `<div class="handle">${config.DID}</div>` 267 - : ""; 268 - const html = `<!DOCTYPE html> 269 - <html lang="en"> 270 - <head> 271 - <meta charset="utf-8"> 272 - <meta name="viewport" content="width=device-width, initial-scale=1"> 273 - <title>P2PDS</title> 274 - <style> 275 - * { margin: 0; padding: 0; box-sizing: border-box; } 276 - body { 277 - min-height: 100vh; 278 - display: flex; 279 - flex-direction: column; 280 - justify-content: center; 281 - align-items: center; 282 - font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; 283 - background: #f0f0f0; 284 - color: #000; 285 - padding: 2rem; 286 - } 287 - .name { font-size: clamp(1.5rem, 5vw, 3rem); font-weight: 700; letter-spacing: 0.2em; margin: 1rem 0; } 288 - .what { font-size: clamp(0.8rem, 2vw, 1rem); color: #666; max-width: 300px; text-align: center; } 289 - .handle { font-size: clamp(0.9rem, 2.5vw, 1.2rem); margin-top: 2rem; padding: 0.5rem 1rem; border: 2px solid #000; } 290 - .handle a { color: inherit; text-decoration: none; } 291 - .handle a:hover { text-decoration: underline; } 292 - .version { position: fixed; bottom: 1rem; right: 1rem; font-size: 0.7rem; color: #999; } 293 - </style> 294 - </head> 295 - <body> 296 - <div class="name">P2PDS</div> 297 - <div class="what">a personal data server for the atmosphere</div> 298 - ${handleHtml} 299 - <div class="version">v${VERSION}</div> 300 - </body> 301 - </html>`; 302 - return c.html(html); 303 - }); 261 + // Dashboard UI at root 262 + app.get("/", (c) => 263 + app_routes.getDashboard(c, networkService, replicationManager), 264 + ); 304 265 305 266 // ============================================ 306 267 // Sync endpoints (federation) ··· 689 650 }); 690 651 691 652 // ============================================ 692 - // Admin monitoring 653 + // App monitoring 693 654 // ============================================ 694 - app.get("/xrpc/org.p2pds.admin.getOverview", requireAuth, (c) => 695 - admin.getOverview(c, configDid, networkService, replicationManager), 655 + app.get("/xrpc/org.p2pds.app.getOverview", requireAuth, (c) => 656 + app_routes.getOverview(c, configDid, networkService, replicationManager), 696 657 ); 697 - app.get("/xrpc/org.p2pds.admin.getDidStatus", requireAuth, (c) => 698 - admin.getDidStatus(c, replicationManager), 658 + app.get("/xrpc/org.p2pds.app.getDidStatus", requireAuth, (c) => 659 + app_routes.getDidStatus(c, replicationManager), 699 660 ); 700 - app.get("/xrpc/org.p2pds.admin.getNetworkStatus", requireAuth, (c) => 701 - admin.getNetworkStatus(c, networkService), 661 + app.get("/xrpc/org.p2pds.app.getNetworkStatus", requireAuth, (c) => 662 + app_routes.getNetworkStatus(c, networkService), 702 663 ); 703 - app.get("/xrpc/org.p2pds.admin.getPolicies", requireAuth, (c) => 704 - admin.getPolicies(c, replicationManager), 705 - ); 706 - app.get("/xrpc/org.p2pds.admin.getSyncHistory", requireAuth, (c) => 707 - admin.getSyncHistory(c, replicationManager), 664 + app.get("/xrpc/org.p2pds.app.getPolicies", requireAuth, (c) => 665 + app_routes.getPolicies(c, replicationManager), 708 666 ); 709 - app.post("/xrpc/org.p2pds.admin.addDid", requireAuth, (c) => 710 - admin.addDid(c, configDid, replicationManager), 667 + app.get("/xrpc/org.p2pds.app.getSyncHistory", requireAuth, (c) => 668 + app_routes.getSyncHistory(c, replicationManager), 711 669 ); 712 - app.post("/xrpc/org.p2pds.admin.removeDid", requireAuth, (c) => 713 - admin.removeDid(c, replicationManager), 670 + app.post("/xrpc/org.p2pds.app.addDid", requireAuth, (c) => 671 + app_routes.addDid(c, configDid, replicationManager), 714 672 ); 715 - app.get("/xrpc/org.p2pds.admin.dashboard", (c) => 716 - admin.getDashboard(c, networkService, replicationManager), 673 + app.post("/xrpc/org.p2pds.app.removeDid", requireAuth, (c) => 674 + app_routes.removeDid(c, replicationManager), 717 675 ); 718 676 719 677 // ============================================
+4 -4
src/oauth/routes.ts
··· 137 137 }); 138 138 } 139 139 140 - return c.redirect("/xrpc/org.p2pds.admin.dashboard"); 140 + return c.redirect("/"); 141 141 } catch (err) { 142 142 const message = err instanceof Error ? err.message : String(err); 143 143 return c.html(errorPage("Authentication Failed", message), 500); ··· 241 241 config.HANDLE = undefined; 242 242 } 243 243 244 - return c.redirect("/xrpc/org.p2pds.admin.dashboard"); 244 + return c.redirect("/"); 245 245 } catch (err) { 246 246 const message = err instanceof Error ? err.message : String(err); 247 247 return c.json({ error: "LogoutFailed", message }, 500); ··· 293 293 <div class="card"> 294 294 <div class="status">Connected</div> 295 295 <div class="did">${escapeHtml(did)}</div> 296 - <a href="/xrpc/org.p2pds.admin.dashboard">Back to Dashboard</a> 296 + <a href="/">Back to Dashboard</a> 297 297 </div> 298 298 </body> 299 299 </html>`; ··· 325 325 <div class="card"> 326 326 <div class="status">${escapeHtml(title)}</div> 327 327 <div class="message">${escapeHtml(message)}</div> 328 - <a href="/xrpc/org.p2pds.admin.dashboard">Back to Dashboard</a> 328 + <a href="/">Back to Dashboard</a> 329 329 </div> 330 330 </body> 331 331 </html>`;
+5 -1
src/replication/replication-manager.ts
··· 1481 1481 clearInterval(this.notificationCleanupTimer); 1482 1482 this.notificationCleanupTimer = null; 1483 1483 } 1484 - this.networkService.unsubscribeIdentityTopics(this.getReplicateDids()); 1484 + try { 1485 + this.networkService.unsubscribeIdentityTopics(this.getReplicateDids()); 1486 + } catch { 1487 + // Gossipsub streams may already be closed during shutdown 1488 + } 1485 1489 } 1486 1490 1487 1491 /**
+4 -4
src/server-startup.test.ts
··· 87 87 const config = testConfig(tmpDir); 88 88 handle = await startServer(config); 89 89 90 - const res = await fetch(`${handle.url}/xrpc/org.p2pds.admin.dashboard`); 90 + const res = await fetch(`${handle.url}/`); 91 91 expect(res.status).toBe(200); 92 92 const html = await res.text(); 93 93 expect(html).toContain("P2PDS"); ··· 98 98 const config = testConfig(tmpDir); 99 99 handle = await startServer(config); 100 100 101 - const res = await fetch(`${handle.url}/xrpc/org.p2pds.admin.getOverview`, { 101 + const res = await fetch(`${handle.url}/xrpc/org.p2pds.app.getOverview`, { 102 102 headers: { Authorization: `Bearer ${config.AUTH_TOKEN}` }, 103 103 }); 104 104 expect(res.status).toBe(200); ··· 121 121 handle = await startServer(config, { didResolver: mockResolver }); 122 122 123 123 // Add the DID via admin API 124 - const addRes = await fetch(`${handle.url}/xrpc/org.p2pds.admin.addDid`, { 124 + const addRes = await fetch(`${handle.url}/xrpc/org.p2pds.app.addDid`, { 125 125 method: "POST", 126 126 headers: { 127 127 Authorization: `Bearer ${config.AUTH_TOKEN}`, ··· 142 142 await new Promise((r) => setTimeout(r, 2000)); 143 143 144 144 // Check overview — the DID should appear in replication state 145 - const overviewRes = await fetch(`${handle.url}/xrpc/org.p2pds.admin.getOverview`, { 145 + const overviewRes = await fetch(`${handle.url}/xrpc/org.p2pds.app.getOverview`, { 146 146 headers: { Authorization: `Bearer ${config.AUTH_TOKEN}` }, 147 147 }); 148 148 expect(overviewRes.status).toBe(200);
+166
src/sidecar-process.test.ts
··· 1 + /** 2 + * Sidecar process integration test. 3 + * 4 + * Spawns `src/server.ts` as a child process (the same way Tauri does), 5 + * validates the full sidecar contract: 6 + * 1. Parse P2PDS_READY from stdout to get the port 7 + * 2. GET /xrpc/_health returns 200 {"status":"ok"} 8 + * 3. GET / returns 200 HTML 9 + * 4. SIGTERM → process exits with code 0 10 + */ 11 + 12 + import { describe, it, expect, afterEach } from "vitest"; 13 + import { spawn, type ChildProcess } from "node:child_process"; 14 + import { mkdtempSync, rmSync } from "node:fs"; 15 + import { tmpdir } from "node:os"; 16 + import { join } from "node:path"; 17 + 18 + const STARTUP_TIMEOUT_MS = 30_000; 19 + const POLL_INTERVAL_MS = 200; 20 + 21 + interface ReadyInfo { 22 + port: number; 23 + url: string; 24 + } 25 + 26 + function parseReadyLine(line: string): ReadyInfo | null { 27 + const prefix = "P2PDS_READY "; 28 + if (!line.startsWith(prefix)) return null; 29 + try { 30 + return JSON.parse(line.slice(prefix.length)) as ReadyInfo; 31 + } catch { 32 + return null; 33 + } 34 + } 35 + 36 + function waitForReady(proc: ChildProcess, timeoutMs: number): Promise<ReadyInfo> { 37 + return new Promise((resolve, reject) => { 38 + const timer = setTimeout(() => { 39 + reject(new Error(`P2PDS_READY not seen within ${timeoutMs}ms`)); 40 + }, timeoutMs); 41 + 42 + let buffer = ""; 43 + proc.stdout?.on("data", (chunk: Buffer) => { 44 + buffer += chunk.toString(); 45 + const lines = buffer.split("\n"); 46 + buffer = lines.pop()!; // keep incomplete trailing line 47 + for (const line of lines) { 48 + const info = parseReadyLine(line); 49 + if (info) { 50 + clearTimeout(timer); 51 + resolve(info); 52 + return; 53 + } 54 + } 55 + }); 56 + 57 + proc.on("error", (err) => { 58 + clearTimeout(timer); 59 + reject(err); 60 + }); 61 + 62 + proc.on("exit", (code) => { 63 + clearTimeout(timer); 64 + reject(new Error(`Process exited with code ${code} before P2PDS_READY`)); 65 + }); 66 + }); 67 + } 68 + 69 + async function pollHealth(url: string, timeoutMs: number): Promise<void> { 70 + const deadline = Date.now() + timeoutMs; 71 + while (Date.now() < deadline) { 72 + try { 73 + const res = await fetch(`${url}/xrpc/_health`); 74 + if (res.ok) return; 75 + } catch { 76 + // not ready yet 77 + } 78 + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); 79 + } 80 + throw new Error(`Health check did not pass within ${timeoutMs}ms`); 81 + } 82 + 83 + describe("sidecar process", () => { 84 + let proc: ChildProcess | undefined; 85 + let tmpDir: string | undefined; 86 + 87 + afterEach(async () => { 88 + if (proc && !proc.killed) { 89 + proc.kill("SIGTERM"); 90 + // Wait for exit (up to 5s) 91 + await new Promise<void>((resolve) => { 92 + const timer = setTimeout(() => { 93 + proc?.kill("SIGKILL"); 94 + resolve(); 95 + }, 5_000); 96 + proc!.on("exit", () => { 97 + clearTimeout(timer); 98 + resolve(); 99 + }); 100 + }); 101 + } 102 + proc = undefined; 103 + if (tmpDir) { 104 + rmSync(tmpDir, { recursive: true, force: true }); 105 + tmpDir = undefined; 106 + } 107 + }); 108 + 109 + it("full sidecar lifecycle: ready → health → dashboard → shutdown", async () => { 110 + tmpDir = mkdtempSync(join(tmpdir(), "sidecar-test-")); 111 + 112 + proc = spawn("npx", ["tsx", "src/server.ts"], { 113 + env: { 114 + ...process.env, 115 + PORT: "0", 116 + DATA_DIR: tmpDir, 117 + IPFS_ENABLED: "true", 118 + IPFS_NETWORKING: "false", 119 + FIREHOSE_ENABLED: "false", 120 + RATE_LIMIT_ENABLED: "false", 121 + OAUTH_ENABLED: "false", 122 + PDS_HOSTNAME: "localhost", 123 + AUTH_TOKEN: "test-token", 124 + SIGNING_KEY: "0000000000000000000000000000000000000000000000000000000000000001", 125 + SIGNING_KEY_PUBLIC: "zQ3shP2mWsZYWgvZM9GJ3EvMfRXQJwuTh6BdXLvJB9gFhT3Lr", 126 + JWT_SECRET: "test-secret", 127 + PASSWORD_HASH: "$2a$10$test", 128 + DID: "did:plc:sidecartest", 129 + }, 130 + stdio: ["ignore", "pipe", "pipe"], 131 + }); 132 + 133 + // Collect stderr for debugging 134 + let stderr = ""; 135 + proc.stderr?.on("data", (chunk: Buffer) => { 136 + stderr += chunk.toString(); 137 + }); 138 + 139 + // 1. Parse P2PDS_READY from stdout 140 + const ready = await waitForReady(proc, STARTUP_TIMEOUT_MS); 141 + expect(ready.port).toBeGreaterThan(0); 142 + expect(ready.url).toMatch(/^http:\/\/localhost:\d+$/); 143 + 144 + // 2. Poll health endpoint 145 + await pollHealth(ready.url, 5_000); 146 + const healthRes = await fetch(`${ready.url}/xrpc/_health`); 147 + expect(healthRes.status).toBe(200); 148 + const healthBody = (await healthRes.json()) as { status: string }; 149 + expect(healthBody.status).toBe("ok"); 150 + 151 + // 3. Dashboard returns HTML 152 + const dashRes = await fetch(`${ready.url}/`); 153 + expect(dashRes.status).toBe(200); 154 + const html = await dashRes.text(); 155 + expect(html).toContain("P2PDS"); 156 + 157 + // 4. Graceful shutdown via SIGTERM 158 + const exitPromise = new Promise<number | null>((resolve) => { 159 + proc!.on("exit", (code) => resolve(code)); 160 + }); 161 + proc.kill("SIGTERM"); 162 + const exitCode = await exitPromise; 163 + expect(exitCode).toBe(0); 164 + proc = undefined; // prevent double-kill in afterEach 165 + }, STARTUP_TIMEOUT_MS + 10_000); 166 + });
+9 -9
src/two-node-didless.test.ts
··· 124 124 dbB.close(); 125 125 126 126 // Verify identity shows up in overview 127 - const overviewA = await fetch(`${handleA.url}/xrpc/org.p2pds.admin.getOverview`, { 127 + const overviewA = await fetch(`${handleA.url}/xrpc/org.p2pds.app.getOverview`, { 128 128 headers: { Authorization: `Bearer ${configA.AUTH_TOKEN}` }, 129 129 }); 130 130 expect(overviewA.status).toBe(200); ··· 132 132 expect(ovA.did).toBe("did:plc:node-a-identity"); 133 133 134 134 // Node A replicates Alice's account 135 - const addAlice = await fetch(`${handleA.url}/xrpc/org.p2pds.admin.addDid`, { 135 + const addAlice = await fetch(`${handleA.url}/xrpc/org.p2pds.app.addDid`, { 136 136 method: "POST", 137 137 headers: { 138 138 Authorization: `Bearer ${configA.AUTH_TOKEN}`, ··· 143 143 expect(addAlice.status).toBe(200); 144 144 145 145 // Node B replicates Bob's account 146 - const addBob = await fetch(`${handleB.url}/xrpc/org.p2pds.admin.addDid`, { 146 + const addBob = await fetch(`${handleB.url}/xrpc/org.p2pds.app.addDid`, { 147 147 method: "POST", 148 148 headers: { 149 149 Authorization: `Bearer ${configB.AUTH_TOKEN}`, ··· 168 168 169 169 // Verify Node A synced Alice's data 170 170 const statusA = await fetch( 171 - `${handleA.url}/xrpc/org.p2pds.admin.getDidStatus?did=${DID_ALICE}`, 171 + `${handleA.url}/xrpc/org.p2pds.app.getDidStatus?did=${DID_ALICE}`, 172 172 { headers: { Authorization: `Bearer ${configA.AUTH_TOKEN}` } }, 173 173 ); 174 174 expect(statusA.status).toBe(200); ··· 183 183 184 184 // Verify Node B synced Bob's data 185 185 const statusB = await fetch( 186 - `${handleB.url}/xrpc/org.p2pds.admin.getDidStatus?did=${DID_BOB}`, 186 + `${handleB.url}/xrpc/org.p2pds.app.getDidStatus?did=${DID_BOB}`, 187 187 { headers: { Authorization: `Bearer ${configB.AUTH_TOKEN}` } }, 188 188 ); 189 189 expect(statusB.status).toBe(200); ··· 222 222 db1.close(); 223 223 224 224 // Add and sync 225 - await fetch(`${handleA.url}/xrpc/org.p2pds.admin.addDid`, { 225 + await fetch(`${handleA.url}/xrpc/org.p2pds.app.addDid`, { 226 226 method: "POST", 227 227 headers: { 228 228 Authorization: `Bearer ${config1.AUTH_TOKEN}`, ··· 238 238 239 239 // Verify sync worked 240 240 const status1 = await fetch( 241 - `${handleA.url}/xrpc/org.p2pds.admin.getDidStatus?did=${DID_ALICE}`, 241 + `${handleA.url}/xrpc/org.p2pds.app.getDidStatus?did=${DID_ALICE}`, 242 242 { headers: { Authorization: `Bearer ${config1.AUTH_TOKEN}` } }, 243 243 ); 244 244 const ds1 = (await status1.json()) as { blockCount: number }; ··· 258 258 expect(config2.HANDLE).toBe("persistent.test"); 259 259 260 260 // Replication state should persist — DID_ALICE is still tracked 261 - const overview = await fetch(`${handleA.url}/xrpc/org.p2pds.admin.getOverview`, { 261 + const overview = await fetch(`${handleA.url}/xrpc/org.p2pds.app.getOverview`, { 262 262 headers: { Authorization: `Bearer ${config2.AUTH_TOKEN}` }, 263 263 }); 264 264 const ov = (await overview.json()) as { ··· 270 270 271 271 // Blocks should still be in IPFS (persisted to disk) 272 272 const status2 = await fetch( 273 - `${handleA.url}/xrpc/org.p2pds.admin.getDidStatus?did=${DID_ALICE}`, 273 + `${handleA.url}/xrpc/org.p2pds.app.getDidStatus?did=${DID_ALICE}`, 274 274 { headers: { Authorization: `Bearer ${config2.AUTH_TOKEN}` } }, 275 275 ); 276 276 const ds2 = (await status2.json()) as {
+6 -6
src/xrpc/admin-e2e.test.ts src/xrpc/app-e2e.test.ts
··· 207 207 } 208 208 209 209 it("getOverview shows synced replication state with aggregate metrics", async () => { 210 - const res = await fetchB("/xrpc/org.p2pds.admin.getOverview"); 210 + const res = await fetchB("/xrpc/org.p2pds.app.getOverview"); 211 211 expect(res.status).toBe(200); 212 212 213 213 const json = (await res.json()) as Record<string, unknown>; ··· 252 252 253 253 it("getDidStatus shows blocks, records, and sync history for replicated DID", async () => { 254 254 const res = await fetchB( 255 - `/xrpc/org.p2pds.admin.getDidStatus?did=${NODE_A_DID}`, 255 + `/xrpc/org.p2pds.app.getDidStatus?did=${NODE_A_DID}`, 256 256 ); 257 257 expect(res.status).toBe(200); 258 258 ··· 276 276 it("dashboard returns HTML with expected structure", async () => { 277 277 // Dashboard doesn't require auth header 278 278 const res = await fetch( 279 - `http://127.0.0.1:${portB}/xrpc/org.p2pds.admin.dashboard`, 279 + `http://127.0.0.1:${portB}/`, 280 280 ); 281 281 expect(res.status).toBe(200); 282 282 expect(res.headers.get("content-type")).toContain("text/html"); 283 283 284 284 const html = await res.text(); 285 - expect(html).toContain("P2PDS Admin"); 285 + expect(html).toContain("P2PDS"); 286 286 expect(html).toContain('id="section-overview"'); 287 287 expect(html).toContain('id="section-metrics"'); 288 288 expect(html).toContain('id="section-replication"'); ··· 292 292 }); 293 293 294 294 it("getSyncHistory returns sync events after replication", async () => { 295 - const res = await fetchB("/xrpc/org.p2pds.admin.getSyncHistory"); 295 + const res = await fetchB("/xrpc/org.p2pds.app.getSyncHistory"); 296 296 expect(res.status).toBe(200); 297 297 298 298 const json = (await res.json()) as { history: Array<Record<string, unknown>> }; ··· 308 308 }); 309 309 310 310 it("getNetworkStatus returns valid response", async () => { 311 - const res = await fetchB("/xrpc/org.p2pds.admin.getNetworkStatus"); 311 + const res = await fetchB("/xrpc/org.p2pds.app.getNetworkStatus"); 312 312 expect(res.status).toBe(200); 313 313 314 314 const json = (await res.json()) as Record<string, unknown>;
+31 -31
src/xrpc/admin.test.ts src/xrpc/app.test.ts
··· 97 97 98 98 it("returns 401 for all admin endpoints without auth", async () => { 99 99 const getEndpoints = [ 100 - "/xrpc/org.p2pds.admin.getOverview", 101 - "/xrpc/org.p2pds.admin.getDidStatus?did=did:plc:test", 102 - "/xrpc/org.p2pds.admin.getNetworkStatus", 103 - "/xrpc/org.p2pds.admin.getPolicies", 104 - "/xrpc/org.p2pds.admin.getSyncHistory", 100 + "/xrpc/org.p2pds.app.getOverview", 101 + "/xrpc/org.p2pds.app.getDidStatus?did=did:plc:test", 102 + "/xrpc/org.p2pds.app.getNetworkStatus", 103 + "/xrpc/org.p2pds.app.getPolicies", 104 + "/xrpc/org.p2pds.app.getSyncHistory", 105 105 ]; 106 106 107 107 for (const endpoint of getEndpoints) { ··· 110 110 } 111 111 112 112 const postEndpoints = [ 113 - "/xrpc/org.p2pds.admin.addDid", 114 - "/xrpc/org.p2pds.admin.removeDid", 113 + "/xrpc/org.p2pds.app.addDid", 114 + "/xrpc/org.p2pds.app.removeDid", 115 115 ]; 116 116 117 117 for (const endpoint of postEndpoints) { ··· 146 146 const firehose = new Firehose(repoManager); 147 147 const app = createApp(config, firehose, undefined, undefined, undefined, undefined, undefined, repoManager); 148 148 149 - const res = await authGet(app, "/xrpc/org.p2pds.admin.getOverview"); 149 + const res = await authGet(app, "/xrpc/org.p2pds.app.getOverview"); 150 150 expect(res.status).toBe(200); 151 151 152 152 const json = await res.json() as Record<string, unknown>; ··· 211 211 repoManager, 212 212 ); 213 213 214 - const res = await authGet(app, "/xrpc/org.p2pds.admin.getOverview"); 214 + const res = await authGet(app, "/xrpc/org.p2pds.app.getOverview"); 215 215 expect(res.status).toBe(200); 216 216 217 217 const json = await res.json() as Record<string, unknown>; ··· 303 303 }); 304 304 305 305 it("returns 400 when did param is missing", async () => { 306 - const res = await authGet(app, "/xrpc/org.p2pds.admin.getDidStatus"); 306 + const res = await authGet(app, "/xrpc/org.p2pds.app.getDidStatus"); 307 307 expect(res.status).toBe(400); 308 308 const json = await res.json() as Record<string, unknown>; 309 309 expect(json.error).toBe("MissingParameter"); ··· 319 319 320 320 const res = await authGet( 321 321 app, 322 - `/xrpc/org.p2pds.admin.getDidStatus?did=${trackedDid}`, 322 + `/xrpc/org.p2pds.app.getDidStatus?did=${trackedDid}`, 323 323 ); 324 324 expect(res.status).toBe(200); 325 325 ··· 341 341 it("returns nulls for an untracked DID", async () => { 342 342 const res = await authGet( 343 343 app, 344 - "/xrpc/org.p2pds.admin.getDidStatus?did=did:plc:unknown", 344 + "/xrpc/org.p2pds.app.getDidStatus?did=did:plc:unknown", 345 345 ); 346 346 expect(res.status).toBe(200); 347 347 ··· 378 378 const firehose = new Firehose(repoManager); 379 379 const app = createApp(config, firehose, undefined, undefined, undefined, undefined, undefined, repoManager); 380 380 381 - const res = await authGet(app, "/xrpc/org.p2pds.admin.getNetworkStatus"); 381 + const res = await authGet(app, "/xrpc/org.p2pds.app.getNetworkStatus"); 382 382 expect(res.status).toBe(200); 383 383 384 384 const json = await res.json() as Record<string, unknown>; ··· 420 420 repoManager, 421 421 ); 422 422 423 - const res = await authGet(app, "/xrpc/org.p2pds.admin.getNetworkStatus"); 423 + const res = await authGet(app, "/xrpc/org.p2pds.app.getNetworkStatus"); 424 424 expect(res.status).toBe(200); 425 425 426 426 const json = await res.json() as Record<string, unknown>; ··· 455 455 const firehose = new Firehose(repoManager); 456 456 const app = createApp(config, firehose, undefined, undefined, undefined, undefined, undefined, repoManager); 457 457 458 - const res = await authGet(app, "/xrpc/org.p2pds.admin.getPolicies"); 458 + const res = await authGet(app, "/xrpc/org.p2pds.app.getPolicies"); 459 459 expect(res.status).toBe(200); 460 460 461 461 const json = await res.json() as Record<string, unknown>; ··· 507 507 repoManager, 508 508 ); 509 509 510 - const res = await authGet(app, "/xrpc/org.p2pds.admin.getPolicies"); 510 + const res = await authGet(app, "/xrpc/org.p2pds.app.getPolicies"); 511 511 expect(res.status).toBe(200); 512 512 513 513 const json = await res.json() as Record<string, unknown>; ··· 577 577 repoManager, 578 578 ); 579 579 580 - const res = await authGet(app, "/xrpc/org.p2pds.admin.getPolicies"); 580 + const res = await authGet(app, "/xrpc/org.p2pds.app.getPolicies"); 581 581 expect(res.status).toBe(200); 582 582 583 583 const json = await res.json() as Record<string, unknown>; ··· 615 615 const firehose = new Firehose(repoManager); 616 616 const app = createApp(config, firehose, undefined, undefined, undefined, undefined, undefined, repoManager); 617 617 618 - const res = await noAuthGet(app, "/xrpc/org.p2pds.admin.dashboard"); 618 + const res = await noAuthGet(app, "/"); 619 619 expect(res.status).toBe(200); 620 620 expect(res.headers.get("content-type")).toContain("text/html"); 621 621 622 622 const html = await res.text(); 623 - expect(html).toContain("P2PDS Admin"); 623 + expect(html).toContain("P2PDS"); 624 624 expect(html).toContain('id="section-overview"'); 625 625 expect(html).toContain('id="section-metrics"'); 626 626 expect(html).toContain('id="section-replication"'); ··· 705 705 }); 706 706 707 707 it("returns 400 when did is missing", async () => { 708 - const res = await authPost(app, "/xrpc/org.p2pds.admin.addDid", {}); 708 + const res = await authPost(app, "/xrpc/org.p2pds.app.addDid", {}); 709 709 expect(res.status).toBe(400); 710 710 const json = await res.json() as Record<string, unknown>; 711 711 expect(json.error).toBe("MissingParameter"); 712 712 }); 713 713 714 714 it("returns 400 for invalid DID format", async () => { 715 - const res = await authPost(app, "/xrpc/org.p2pds.admin.addDid", { did: "not-a-did" }); 715 + const res = await authPost(app, "/xrpc/org.p2pds.app.addDid", { did: "not-a-did" }); 716 716 expect(res.status).toBe(400); 717 717 const json = await res.json() as Record<string, unknown>; 718 718 expect(json.error).toBe("InvalidDid"); 719 719 }); 720 720 721 721 it("allows adding own DID for self-replication", async () => { 722 - const res = await authPost(app, "/xrpc/org.p2pds.admin.addDid", { did: "did:plc:test123" }); 722 + const res = await authPost(app, "/xrpc/org.p2pds.app.addDid", { did: "did:plc:test123" }); 723 723 expect(res.status).toBe(200); 724 724 }); 725 725 726 726 it("reports already_tracked for config DID", async () => { 727 - const res = await authPost(app, "/xrpc/org.p2pds.admin.addDid", { did: configDid }); 727 + const res = await authPost(app, "/xrpc/org.p2pds.app.addDid", { did: configDid }); 728 728 expect(res.status).toBe(200); 729 729 const json = await res.json() as Record<string, unknown>; 730 730 expect(json.status).toBe("already_tracked"); ··· 733 733 734 734 it("adds a new DID", async () => { 735 735 const newDid = "did:plc:newdid123"; 736 - const res = await authPost(app, "/xrpc/org.p2pds.admin.addDid", { did: newDid }); 736 + const res = await authPost(app, "/xrpc/org.p2pds.app.addDid", { did: newDid }); 737 737 expect(res.status).toBe(200); 738 738 const json = await res.json() as Record<string, unknown>; 739 739 expect(json.status).toBe("added"); ··· 748 748 749 749 it("idempotent: re-adding returns already_tracked", async () => { 750 750 const newDid = "did:plc:idempotent1"; 751 - await authPost(app, "/xrpc/org.p2pds.admin.addDid", { did: newDid }); 752 - const res = await authPost(app, "/xrpc/org.p2pds.admin.addDid", { did: newDid }); 751 + await authPost(app, "/xrpc/org.p2pds.app.addDid", { did: newDid }); 752 + const res = await authPost(app, "/xrpc/org.p2pds.app.addDid", { did: newDid }); 753 753 expect(res.status).toBe(200); 754 754 const json = await res.json() as Record<string, unknown>; 755 755 expect(json.status).toBe("already_tracked"); ··· 757 757 }); 758 758 759 759 it("cannot remove a config DID", async () => { 760 - const res = await authPost(app, "/xrpc/org.p2pds.admin.removeDid", { did: configDid }); 760 + const res = await authPost(app, "/xrpc/org.p2pds.app.removeDid", { did: configDid }); 761 761 expect(res.status).toBe(400); 762 762 const json = await res.json() as Record<string, unknown>; 763 763 expect(json.error).toBe("CannotRemove"); ··· 765 765 766 766 it("removes an admin-added DID", async () => { 767 767 const newDid = "did:plc:removable1"; 768 - await authPost(app, "/xrpc/org.p2pds.admin.addDid", { did: newDid }); 768 + await authPost(app, "/xrpc/org.p2pds.app.addDid", { did: newDid }); 769 769 770 - const res = await authPost(app, "/xrpc/org.p2pds.admin.removeDid", { did: newDid }); 770 + const res = await authPost(app, "/xrpc/org.p2pds.app.removeDid", { did: newDid }); 771 771 expect(res.status).toBe(200); 772 772 const json = await res.json() as Record<string, unknown>; 773 773 expect(json.status).toBe("removed"); ··· 776 776 777 777 it("removes with purgeData deletes all tracking data", async () => { 778 778 const newDid = "did:plc:purgeable1"; 779 - await authPost(app, "/xrpc/org.p2pds.admin.addDid", { did: newDid }); 779 + await authPost(app, "/xrpc/org.p2pds.app.addDid", { did: newDid }); 780 780 781 781 // Add some tracking data 782 782 const syncStorage = replicationManager.getSyncStorage(); 783 783 syncStorage.trackBlocks(newDid, ["bafyblock1"]); 784 784 syncStorage.trackRecordPaths(newDid, ["app.bsky.feed.post/abc"]); 785 785 786 - const res = await authPost(app, "/xrpc/org.p2pds.admin.removeDid", { did: newDid, purgeData: true }); 786 + const res = await authPost(app, "/xrpc/org.p2pds.app.removeDid", { did: newDid, purgeData: true }); 787 787 expect(res.status).toBe(200); 788 788 const json = await res.json() as Record<string, unknown>; 789 789 expect(json.status).toBe("removed");
+74 -12
src/xrpc/admin.ts src/xrpc/app.ts
··· 153 153 <head> 154 154 <meta charset="utf-8"> 155 155 <meta name="viewport" content="width=device-width, initial-scale=1"> 156 - <title>P2PDS Admin</title> 156 + <title>P2PDS</title> 157 157 <style> 158 158 :root { 159 159 --bg: #f0f0f0; --fg: #000; --card-bg: #fff; --card-border: transparent; ··· 239 239 border: 1px solid var(--fg); border-radius: 3px; cursor: pointer; 240 240 background: var(--card-bg); color: var(--fg); 241 241 } 242 - .add-did-form button:hover { background: var(--fg); color: var(--bg); } 242 + .add-did-form input:disabled { opacity: 0.4; cursor: not-allowed; } 243 + .add-did-form button:disabled { opacity: 0.4; cursor: not-allowed; } 244 + .add-did-form button:hover:not(:disabled) { background: var(--fg); color: var(--bg); } 243 245 .btn-remove { border-color: #ef4444; color: #ef4444; padding: 0.15rem 0.4rem; font-size: 0.7rem; } 244 246 .btn-remove:hover { background: #ef4444; color: #fff; } 245 247 .did-source { font-size: 0.65rem; padding: 1px 5px; border-radius: 3px; } ··· 309 311 .account-searching { padding: 0.5rem; text-align: center; color: var(--faint); font-size: 0.78rem; } 310 312 .per-acct-metrics { display: inline-flex; gap: 0.6rem; font-size: 0.7rem; color: var(--muted); margin-top: 2px; } 311 313 .per-acct-metrics span { white-space: nowrap; } 314 + @keyframes spin { to { transform: rotate(360deg); } } 315 + .activity-spinner { 316 + display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); 317 + border-top-color: var(--fg); border-radius: 50%; vertical-align: middle; 318 + animation: spin 0.8s linear infinite; opacity: 0; transition: opacity 0.2s; 319 + } 320 + .activity-spinner.active { opacity: 1; } 321 + .sync-gate-msg { 322 + display: inline-flex; align-items: center; gap: 0.4rem; 323 + color: #eab308; font-size: 0.8rem; font-weight: 600; 324 + } 325 + .sync-gate-spinner { 326 + display: inline-block; width: 10px; height: 10px; border: 2px solid #eab30844; 327 + border-top-color: #eab308; border-radius: 50%; animation: spin 0.8s linear infinite; 328 + } 312 329 @media (prefers-color-scheme: dark) { 313 330 .source-pds { background: #1e3a5f; color: #93c5fd; } 314 331 .source-firehose { background: #422006; color: #fcd34d; } ··· 324 341 <h1>P2PDS</h1> 325 342 <span class="badge" id="version-badge">v-</span> 326 343 <div class="meta"> 344 + <span class="activity-spinner" id="activity-spinner"></span> 327 345 <span id="last-refresh">-</span> 328 346 <label><input type="checkbox" id="auto-refresh" checked> auto-refresh</label> 329 347 </div> ··· 457 475 el.innerHTML = '<dl class="kv">' 458 476 + "<dt>DID</dt><dd>" + esc(data.did) + "</dd>" 459 477 + netHtml 460 - + "<dt>Firehose</dt><dd>" + (data.firehose ? esc(fh.url || "connected") : '<span style="color:var(--faint)">disabled</span>') + "</dd>" 461 478 + "</dl>"; 462 479 document.getElementById("version-badge").textContent = "v" + data.version; 463 480 } ··· 522 539 + '<td id="' + metricsId + '-blk">-</td>' 523 540 + '<td id="' + metricsId + '-bytes">-</td>' 524 541 + "<td>" + timeAgo(s.lastSyncAt) + "</td>" 525 - + "<td>" + (src === "admin" ? '<button class="btn-remove" data-did="' + esc(s.did) + '">Remove</button>' : "") + "</td>" 542 + + "<td>" + (src === "admin" && s.did !== data.did ? '<button class="btn-remove" data-did="' + esc(s.did) + '">Remove</button>' : "") + "</td>" 526 543 + "</tr>"; 527 544 html += '<tr class="detail-row" id="' + rid + '" style="display:none"><td colspan="8"><div class="detail-inner loading">Click to load...</div></td></tr>'; 528 545 } ··· 538 555 var cell = document.getElementById(cellId); 539 556 if (cell && p) cell.innerHTML = renderAccountCell(did, p); 540 557 }); 541 - apiFetch("org.p2pds.admin.getDidStatus", { did: did }).then(function(d) { 558 + apiFetch("org.p2pds.app.getDidStatus", { did: did }).then(function(d) { 542 559 var recEl = document.getElementById(metricsId + "-rec"); 543 560 var blkEl = document.getElementById(metricsId + "-blk"); 544 561 var bytesEl = document.getElementById(metricsId + "-bytes"); ··· 558 575 const inner = detailRow.querySelector(".detail-inner"); 559 576 inner.innerHTML = "Loading..."; 560 577 try { 561 - const d = await apiFetch("org.p2pds.admin.getDidStatus", { did: did }); 578 + const d = await apiFetch("org.p2pds.app.getDidStatus", { did: did }); 562 579 let h = '<div class="metrics-grid" style="margin-bottom:0.6rem">' 563 580 + '<div class="metric-box"><div class="value">' + formatNumber(d.recordCount) + '</div><div class="label">Records</div></div>' 564 581 + '<div class="metric-box"><div class="value">' + formatNumber(d.blockCount) + '</div><div class="label">Blocks</div></div>' ··· 597 614 if (!confirm("Remove " + did + "?\\n\\nData will be kept (paused). To purge data, use the API with purgeData: true.")) return; 598 615 var msgEl = document.getElementById("add-did-msg"); 599 616 try { 600 - var result = await apiPost("org.p2pds.admin.removeDid", { did: did }); 617 + var result = await apiPost("org.p2pds.app.removeDid", { did: did }); 601 618 if (result.error) { msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; } 602 619 else { msgEl.className = "add-did-success"; msgEl.textContent = "Removed " + did; refresh(); } 603 620 } catch (err) { msgEl.className = "add-did-error"; msgEl.textContent = "Error: " + err.message; } ··· 882 899 }); 883 900 } 884 901 902 + function gateAddDid(overview) { 903 + var input = document.getElementById("add-did-input"); 904 + var btn = document.getElementById("add-did-btn"); 905 + var msgEl = document.getElementById("add-did-msg"); 906 + if (!input || !btn) return; 907 + var nodeDid = overview.did; 908 + if (!nodeDid) return; 909 + var states = (overview.replication && overview.replication.syncStates) || []; 910 + var selfState = null; 911 + for (var i = 0; i < states.length; i++) { 912 + if (states[i].did === nodeDid) { selfState = states[i]; break; } 913 + } 914 + // Gate when: self-DID not yet in sync states (entry not created yet) 915 + // OR self-DID is pending/syncing 916 + var selfSyncing = !selfState || selfState.status === "pending" || selfState.status === "syncing"; 917 + if (selfSyncing) { 918 + input.disabled = true; 919 + btn.disabled = true; 920 + if (msgEl) { 921 + msgEl.className = ""; 922 + msgEl.innerHTML = '<span class="sync-gate-msg"><span class="sync-gate-spinner"></span>Syncing your account\u2026</span>'; 923 + } 924 + } else { 925 + input.disabled = false; 926 + btn.disabled = false; 927 + if (msgEl && msgEl.querySelector(".sync-gate-msg")) { 928 + msgEl.className = ""; 929 + msgEl.innerHTML = ""; 930 + } 931 + } 932 + } 933 + 934 + function setActivity(active) { 935 + var sp = document.getElementById("activity-spinner"); 936 + if (sp) sp.classList.toggle("active", active); 937 + } 938 + 885 939 async function refresh() { 940 + setActivity(true); 886 941 try { 887 942 const [overview, network, policies, syncHistory] = await Promise.all([ 888 - apiFetch("org.p2pds.admin.getOverview"), 889 - apiFetch("org.p2pds.admin.getNetworkStatus"), 890 - apiFetch("org.p2pds.admin.getPolicies"), 891 - apiFetch("org.p2pds.admin.getSyncHistory", { limit: "20" }), 943 + apiFetch("org.p2pds.app.getOverview"), 944 + apiFetch("org.p2pds.app.getNetworkStatus"), 945 + apiFetch("org.p2pds.app.getPolicies"), 946 + apiFetch("org.p2pds.app.getSyncHistory", { limit: "20" }), 892 947 ]); 893 948 await refreshAccount(); 894 949 renderOverview(overview); ··· 898 953 renderNetwork(network); 899 954 renderPolicies(policies); 900 955 renderVerification(overview); 956 + gateAddDid(overview); 901 957 document.getElementById("last-refresh").textContent = "Updated: " + new Date().toLocaleTimeString(); 958 + // Keep spinner active if any DID is still syncing 959 + var anySyncing = ((overview.replication && overview.replication.syncStates) || []).some(function(s) { 960 + return s.status === "syncing" || s.status === "pending"; 961 + }); 962 + setActivity(anySyncing); 902 963 } catch (e) { 964 + setActivity(false); 903 965 console.error("Dashboard refresh error:", e); 904 966 } 905 967 } ··· 924 986 if (!did) { msgEl.className = "add-did-error"; msgEl.textContent = "Search for an account or paste a DID"; return; } 925 987 msgEl.className = ""; msgEl.textContent = ""; 926 988 try { 927 - var result = await apiPost("org.p2pds.admin.addDid", { did: did }); 989 + var result = await apiPost("org.p2pds.app.addDid", { did: did }); 928 990 if (result.error) { 929 991 msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; 930 992 } else {