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 IPFS-first replication: CAR-over-libp2p protocol, self-replication on login, peer-first sync

Introduces /p2pds/repo-sync/1.0.0 libp2p protocol for peer-to-peer repo
transfer without centralized PDS servers. syncDid() now tries libp2p first
when peer info is available, falling back to HTTP PDS on failure.

- New libp2p-sync.ts: protocol handler (server) + fetchRepoFromPeer (client)
- Self-replication: OAuth login triggers addDid(own DID) to seed blockstore
- IpfsService: add dial() method for direct peer connections
- sync.ts: extract generateCarForDid() reusable by both HTTP and libp2p handlers
- start.ts: register repo sync protocol after IPFS + replication init
- admin.ts: rename "Replicated DIDs" → "Replicating Accounts", allow self-DID

+535 -170
+10
src/ipfs.ts
··· 516 516 getLibp2p(): unknown | null { 517 517 return this.helia?.libp2p ?? null; 518 518 } 519 + 520 + /** 521 + * Dial a specific multiaddr to establish a connection. 522 + * No-op when networking is disabled. 523 + */ 524 + async dial(multiaddr: string): Promise<void> { 525 + if (!this.helia) return; 526 + const { multiaddr: ma } = await import("@multiformats/multiaddr"); 527 + await this.helia.libp2p.dial(ma(multiaddr)); 528 + } 519 529 }
+10
src/oauth/routes.ts
··· 127 127 ); 128 128 } 129 129 130 + // Self-replicate: sync own repo to seed the local blockstore 131 + if (replicationManager && config.DID) { 132 + replicationManager.addDid(config.DID).catch((err) => { 133 + console.warn( 134 + "[oauth] Self-replication failed:", 135 + err instanceof Error ? err.message : String(err), 136 + ); 137 + }); 138 + } 139 + 130 140 return c.redirect("/xrpc/org.p2pds.admin.dashboard"); 131 141 } catch (err) { 132 142 const message = err instanceof Error ? err.message : String(err);
+253
src/replication/libp2p-sync.ts
··· 1 + /** 2 + * CAR-over-libp2p repo sync protocol. 3 + * 4 + * Mirrors `com.atproto.sync.getRepo` but runs over libp2p streams, 5 + * enabling peer-to-peer repo transfer without centralized PDS servers. 6 + * 7 + * Protocol ID: /p2pds/repo-sync/1.0.0 8 + * 9 + * Wire format (half-close request-response): 10 + * 1. Requester: send CBOR { did: string, since?: string }, then close() (half-close write) 11 + * 2. Responder: read request, generate CAR, send 1-byte status + CAR bytes, then close() 12 + * 3. Requester: read all response bytes — first byte is status, rest is CAR data 13 + * 14 + * Status codes: 0 = ok, 1 = not found, 2 = error 15 + */ 16 + 17 + import type { Libp2p, Stream } from "@libp2p/interface"; 18 + import { multiaddr } from "@multiformats/multiaddr"; 19 + import { encode as cborEncode, decode as cborDecode } from "../cbor-compat.js"; 20 + import type { BlockStore } from "../ipfs.js"; 21 + import type { SyncStorage } from "./sync-storage.js"; 22 + import { generateCarForDid } from "../xrpc/sync.js"; 23 + 24 + export const REPO_SYNC_PROTOCOL = "/p2pds/repo-sync/1.0.0"; 25 + 26 + /** Maximum request size (64 KB — just DID + since). */ 27 + const MAX_REQUEST_SIZE = 64 * 1024; 28 + 29 + /** Maximum response size (256 MB — full repo CARs can be large). */ 30 + const MAX_RESPONSE_SIZE = 256 * 1024 * 1024; 31 + 32 + /** Timeout for the entire sync operation (30 seconds). */ 33 + const SYNC_TIMEOUT_MS = 30_000; 34 + 35 + /** Status codes in the response. */ 36 + const STATUS_OK = 0; 37 + const STATUS_NOT_FOUND = 1; 38 + const STATUS_ERROR = 2; 39 + 40 + interface RepoSyncRequest { 41 + did: string; 42 + since?: string; 43 + } 44 + 45 + /** 46 + * Collect all chunks from a libp2p stream into a single Uint8Array. 47 + * Stream chunks may be Uint8Array or Uint8ArrayList; normalize via subarray(). 48 + * Throws if accumulated bytes exceed maxSize. 49 + */ 50 + async function collectStream( 51 + stream: AsyncIterable<Uint8Array | { subarray(): Uint8Array }>, 52 + maxSize: number, 53 + ): Promise<Uint8Array> { 54 + const chunks: Uint8Array[] = []; 55 + let totalSize = 0; 56 + for await (const chunk of stream) { 57 + const bytes = chunk instanceof Uint8Array ? chunk : chunk.subarray(); 58 + totalSize += bytes.length; 59 + if (totalSize > maxSize) { 60 + throw new Error(`Stream exceeded maximum size of ${maxSize} bytes`); 61 + } 62 + chunks.push(bytes); 63 + } 64 + if (chunks.length === 0) return new Uint8Array(0); 65 + if (chunks.length === 1) return chunks[0]!; 66 + const result = new Uint8Array(totalSize); 67 + let offset = 0; 68 + for (const c of chunks) { 69 + result.set(c, offset); 70 + offset += c.length; 71 + } 72 + return result; 73 + } 74 + 75 + /** 76 + * Register the repo sync protocol handler on a libp2p node (server side). 77 + * 78 + * When a peer requests a repo via this protocol, the handler: 79 + * 1. Reads the CBOR-encoded request (DID + optional since) 80 + * 2. Generates a CAR file from the local blockstore 81 + * 3. Sends status byte + CAR bytes back 82 + * 83 + * Note: `since` is accepted in the request but not yet used for incremental 84 + * CAR generation — the full repo is always served. Incremental support can 85 + * be added later by filtering blocks newer than `since`. 86 + */ 87 + export function registerRepoSyncProtocol( 88 + libp2p: Libp2p, 89 + blockStore: BlockStore, 90 + syncStorage: SyncStorage, 91 + ): void { 92 + libp2p.handle( 93 + REPO_SYNC_PROTOCOL, 94 + async (stream: Stream) => { 95 + try { 96 + // Read request 97 + const requestBytes = await collectStream( 98 + stream as unknown as AsyncIterable< 99 + Uint8Array | { subarray(): Uint8Array } 100 + >, 101 + MAX_REQUEST_SIZE, 102 + ); 103 + 104 + const request = cborDecode(requestBytes) as RepoSyncRequest; 105 + if (!request.did || typeof request.did !== "string") { 106 + const errorResponse = new Uint8Array([STATUS_ERROR]); 107 + stream.send(errorResponse); 108 + await stream.close(); 109 + return; 110 + } 111 + 112 + console.log( 113 + `[libp2p-sync] Serving repo for ${request.did}${request.since ? ` (since: ${request.since.slice(0, 8)}…)` : ""}`, 114 + ); 115 + 116 + // Generate CAR 117 + const carBytes = await generateCarForDid( 118 + request.did, 119 + blockStore, 120 + syncStorage, 121 + ); 122 + 123 + if (!carBytes) { 124 + const notFoundResponse = new Uint8Array([STATUS_NOT_FOUND]); 125 + stream.send(notFoundResponse); 126 + await stream.close(); 127 + return; 128 + } 129 + 130 + // Send status + CAR bytes 131 + const response = new Uint8Array(1 + carBytes.length); 132 + response[0] = STATUS_OK; 133 + response.set(carBytes, 1); 134 + stream.send(response); 135 + await stream.close(); 136 + 137 + console.log( 138 + `[libp2p-sync] Served ${(carBytes.length / 1024).toFixed(1)} KB for ${request.did}`, 139 + ); 140 + } catch (err) { 141 + stream.abort( 142 + err instanceof Error ? err : new Error(String(err)), 143 + ); 144 + } 145 + }, 146 + ); 147 + } 148 + 149 + /** 150 + * Unregister the repo sync protocol handler. 151 + */ 152 + export async function unregisterRepoSyncProtocol( 153 + libp2p: Libp2p, 154 + ): Promise<void> { 155 + await libp2p.unhandle(REPO_SYNC_PROTOCOL); 156 + } 157 + 158 + /** 159 + * Fetch a repo from a peer via the libp2p repo sync protocol (client side). 160 + * 161 + * Dials the peer at the given multiaddrs, sends a CBOR-encoded request, 162 + * and returns the CAR bytes. Throws on timeout, protocol error, or if 163 + * the peer doesn't have the repo. 164 + * 165 + * @param libp2p - The local libp2p node 166 + * @param multiaddrs - Peer's multiaddrs (from org.p2pds.peer record) 167 + * @param did - The DID to fetch 168 + * @param since - Optional rev for incremental sync 169 + * @returns CAR bytes for the repo 170 + */ 171 + export async function fetchRepoFromPeer( 172 + libp2p: Libp2p, 173 + multiaddrs: string[], 174 + did: string, 175 + since?: string, 176 + ): Promise<Uint8Array> { 177 + if (multiaddrs.length === 0) { 178 + throw new Error("No multiaddrs provided for peer"); 179 + } 180 + 181 + // Try each multiaddr until one works 182 + let lastError: Error | null = null; 183 + for (const addr of multiaddrs) { 184 + try { 185 + return await fetchRepoFromAddr(libp2p, addr, did, since); 186 + } catch (err) { 187 + lastError = err instanceof Error ? err : new Error(String(err)); 188 + } 189 + } 190 + 191 + throw lastError ?? new Error("No multiaddrs to try"); 192 + } 193 + 194 + async function fetchRepoFromAddr( 195 + libp2p: Libp2p, 196 + addr: string, 197 + did: string, 198 + since?: string, 199 + ): Promise<Uint8Array> { 200 + const ma = multiaddr(addr); 201 + 202 + // Apply timeout 203 + const controller = new AbortController(); 204 + const timeout = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS); 205 + 206 + try { 207 + const stream = await libp2p.dialProtocol(ma, REPO_SYNC_PROTOCOL, { 208 + signal: controller.signal, 209 + }); 210 + 211 + try { 212 + // Send request and half-close 213 + const request: RepoSyncRequest = { did }; 214 + if (since) request.since = since; 215 + const requestBytes = cborEncode(request); 216 + stream.send(requestBytes); 217 + await stream.close(); 218 + 219 + // Read response 220 + const responseBytes = await collectStream( 221 + stream as unknown as AsyncIterable< 222 + Uint8Array | { subarray(): Uint8Array } 223 + >, 224 + MAX_RESPONSE_SIZE, 225 + ); 226 + 227 + if (responseBytes.length === 0) { 228 + throw new Error("Empty response from peer"); 229 + } 230 + 231 + const status = responseBytes[0]; 232 + if (status === STATUS_NOT_FOUND) { 233 + throw new Error(`Peer does not have repo for ${did}`); 234 + } 235 + if (status === STATUS_ERROR) { 236 + throw new Error(`Peer returned error for ${did}`); 237 + } 238 + if (status !== STATUS_OK) { 239 + throw new Error(`Unknown status code ${status} from peer`); 240 + } 241 + 242 + // Rest of response is CAR bytes 243 + return responseBytes.slice(1); 244 + } catch (err) { 245 + stream.abort( 246 + err instanceof Error ? err : new Error(String(err)), 247 + ); 248 + throw err; 249 + } 250 + } finally { 251 + clearTimeout(timeout); 252 + } 253 + }
+67 -27
src/replication/replication-manager.ts
··· 39 39 import { ChallengeStorage, type ChallengeHistoryRow, type PeerReliabilityRow } from "./challenge-response/challenge-storage.js"; 40 40 import type { ChallengeTransport } from "./challenge-response/transport.js"; 41 41 import { OfferManager, type RecordWriter } from "./offer-manager.js"; 42 + import { fetchRepoFromPeer } from "./libp2p-sync.js"; 43 + import type { Libp2p } from "@libp2p/interface"; 42 44 43 45 /** How old cached peer info can be before re-fetching (1 hour). */ 44 46 const PEER_INFO_TTL_MS = 60 * 60 * 1000; ··· 72 74 /** Dedup set for gossipsub notifications, keyed by `${did}:${rev}`. */ 73 75 private recentNotifications: Set<string> = new Set(); 74 76 private notificationCleanupTimer: ReturnType<typeof setInterval> | null = null; 77 + /** libp2p node for peer-first repo sync. Set via setLibp2p(). */ 78 + private libp2p: Libp2p | null = null; 75 79 76 80 constructor( 77 81 db: Database.Database, ··· 144 148 did, 145 149 ); 146 150 } 151 + } 152 + 153 + /** 154 + * Set the libp2p node for peer-first repo sync. 155 + * Called from start.ts after IPFS is started. 156 + */ 157 + setLibp2p(libp2p: Libp2p): void { 158 + this.libp2p = libp2p; 147 159 } 148 160 149 161 /** ··· 593 605 594 606 // 3. Fetch repo (with incremental sync if we have a previous rev) 595 607 const since = state?.lastSyncRev ?? undefined; 596 - const syncEventId = this.syncStorage.startSyncEvent(did, "pds"); 597 - console.log(`[sync] ${did} — fetching repo from ${pdsEndpoint}${since ? ` (since: ${since.slice(0, 8)}…)` : ""}`); 598 - let carBytes: Uint8Array; 599 - try { 600 - carBytes = await this.repoFetcher.fetchRepo( 601 - pdsEndpoint, 602 - did, 603 - since, 604 - ); 605 - } catch (sourceErr) { 606 - // On failure, clear cached peer info and trigger re-discovery 607 - this.syncStorage.clearPeerInfo(did); 608 - this.peerDiscovery.discoverPeer(did).then((peerInfo) => { 609 - if (peerInfo) { 610 - this.syncStorage.updatePeerInfo(did, peerInfo.peerId, peerInfo.multiaddrs); 611 - } 612 - }).catch(() => {}); 613 - const err = sourceErr instanceof Error ? sourceErr : new Error(String(sourceErr)); 614 - if (since) { 615 - // Retry full sync from source, then fall back to peers 616 - try { 617 - carBytes = await this.repoFetcher.fetchRepo(pdsEndpoint, did); 618 - } catch { 608 + 609 + // 3a. Try libp2p peer-first sync if we have peer info 610 + let carBytes: Uint8Array | null = null; 611 + state = this.syncStorage.getState(did); // re-read for fresh peer info 612 + if (this.libp2p && state?.peerId && state.peerMultiaddrs && state.peerMultiaddrs.length > 0) { 613 + try { 614 + console.log(`[sync] ${did} — fetching repo from peer ${state.peerId.slice(0, 12)}… via libp2p`); 615 + carBytes = await fetchRepoFromPeer( 616 + this.libp2p, 617 + state.peerMultiaddrs, 618 + did, 619 + since, 620 + ); 621 + sourceType = "libp2p"; 622 + console.log(`[sync] ${did} — received ${(carBytes.length / 1024).toFixed(1)} KB from peer via libp2p`); 623 + } catch (err) { 624 + console.log( 625 + `[sync] ${did} — libp2p sync failed, falling back to HTTP: ${err instanceof Error ? err.message : String(err)}`, 626 + ); 627 + carBytes = null; 628 + } 629 + } 630 + 631 + // 3b. Fall back to HTTP PDS fetch 632 + if (!carBytes) { 633 + sourceType = "pds"; 634 + console.log(`[sync] ${did} — fetching repo from ${pdsEndpoint}${since ? ` (since: ${since.slice(0, 8)}…)` : ""}`); 635 + try { 636 + carBytes = await this.repoFetcher.fetchRepo( 637 + pdsEndpoint, 638 + did, 639 + since, 640 + ); 641 + } catch (sourceErr) { 642 + // On failure, clear cached peer info and trigger re-discovery 643 + this.syncStorage.clearPeerInfo(did); 644 + this.peerDiscovery.discoverPeer(did).then((peerInfo) => { 645 + if (peerInfo) { 646 + this.syncStorage.updatePeerInfo(did, peerInfo.peerId, peerInfo.multiaddrs); 647 + } 648 + }).catch(() => {}); 649 + const err = sourceErr instanceof Error ? sourceErr : new Error(String(sourceErr)); 650 + if (since) { 651 + // Retry full sync from source, then fall back to peers 652 + try { 653 + carBytes = await this.repoFetcher.fetchRepo(pdsEndpoint, did); 654 + } catch { 655 + sourceType = "peer_fallback"; 656 + carBytes = await this.fetchFromPeersOrThrow(did, since, err); 657 + } 658 + } else { 619 659 sourceType = "peer_fallback"; 620 - carBytes = await this.fetchFromPeersOrThrow(did, since, err); 660 + carBytes = await this.fetchFromPeersOrThrow(did, undefined, err); 621 661 } 622 - } else { 623 - sourceType = "peer_fallback"; 624 - carBytes = await this.fetchFromPeersOrThrow(did, undefined, err); 625 662 } 626 663 } 664 + 665 + // Start sync event after source is determined 666 + const syncEventId = this.syncStorage.startSyncEvent(did, sourceType); 627 667 628 668 try { 629 669 // 4. Parse CAR and store blocks
+15
src/start.ts
··· 35 35 import { createOAuthClient, type OAuthClientManager } from "./oauth/client.js"; 36 36 import { PdsClient } from "./oauth/pds-client.js"; 37 37 import type { PdsClientRef } from "./oauth/routes.js"; 38 + import { registerRepoSyncProtocol } from "./replication/libp2p-sync.js"; 38 39 39 40 export interface StartServerOpts { 40 41 /** Override DID resolver (e.g. mock resolver for tests). */ ··· 296 297 if (replicationManager) { 297 298 try { 298 299 await replicationManager.init(); 300 + 301 + // Register libp2p repo sync protocol and set libp2p on ReplicationManager 302 + const libp2pForSync = ipfsService?.getLibp2p(); 303 + if (libp2pForSync) { 304 + const syncStorage = replicationManager.getSyncStorage(); 305 + registerRepoSyncProtocol( 306 + libp2pForSync as Libp2p, 307 + ipfsService!, 308 + syncStorage, 309 + ); 310 + replicationManager.setLibp2p(libp2pForSync as Libp2p); 311 + console.log(pc.dim(` Repo sync: libp2p protocol registered`)); 312 + } 313 + 299 314 replicationManager.startPeriodicSync(); 300 315 const trackedDids = replicationManager.getReplicateDids(); 301 316 console.log(pc.dim(` Replication: tracking ${trackedDids.length} DIDs`));
+2 -5
src/xrpc/admin.test.ts
··· 718 718 expect(json.error).toBe("InvalidDid"); 719 719 }); 720 720 721 - it("returns 400 when adding own DID", async () => { 721 + it("allows adding own DID for self-replication", async () => { 722 722 const res = await authPost(app, "/xrpc/org.p2pds.admin.addDid", { did: "did:plc:test123" }); 723 - expect(res.status).toBe(400); 724 - const json = await res.json() as Record<string, unknown>; 725 - expect(json.error).toBe("InvalidDid"); 726 - expect(json.message).toContain("own DID"); 723 + expect(res.status).toBe(200); 727 724 }); 728 725 729 726 it("reports already_tracked for config DID", async () => {
+152 -126
src/xrpc/admin.ts
··· 155 155 <meta name="viewport" content="width=device-width, initial-scale=1"> 156 156 <title>P2PDS Admin</title> 157 157 <style> 158 + :root { 159 + --bg: #f0f0f0; --fg: #000; --card-bg: #fff; --card-border: transparent; 160 + --muted: #666; --faint: #999; --border: #eee; --row-hover: #f8f8f8; 161 + --metric-bg: #f8f8f8; --input-bg: #fff; --input-border: #ccc; 162 + --badge-bg: #000; --badge-fg: #fff; 163 + --detail-bg: #fafafa; --shadow: rgba(0,0,0,0.08); 164 + --acct-placeholder-bg: #e5e7eb; --selected-bg: #f8f8f8; --selected-border: #ddd; 165 + --dropdown-bg: #fff; --dropdown-border: #ddd; --dropdown-shadow: rgba(0,0,0,0.12); 166 + } 167 + @media (prefers-color-scheme: dark) { 168 + :root { 169 + --bg: #111; --fg: #e0e0e0; --card-bg: #1a1a1a; --card-border: #2a2a2a; 170 + --muted: #999; --faint: #666; --border: #2a2a2a; --row-hover: #222; 171 + --metric-bg: #222; --input-bg: #222; --input-border: #444; 172 + --badge-bg: #e0e0e0; --badge-fg: #111; 173 + --detail-bg: #1e1e1e; --shadow: rgba(0,0,0,0.3); 174 + --acct-placeholder-bg: #333; --selected-bg: #222; --selected-border: #444; 175 + --dropdown-bg: #1a1a1a; --dropdown-border: #333; --dropdown-shadow: rgba(0,0,0,0.4); 176 + } 177 + } 158 178 * { margin: 0; padding: 0; box-sizing: border-box; } 159 179 body { 160 180 font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; 161 - background: #f0f0f0; 162 - color: #000; 163 - padding: 1.5rem; 164 - font-size: 14px; 181 + background: var(--bg); color: var(--fg); padding: 0.8rem; font-size: 13px; 165 182 } 166 183 header { 167 - display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; 168 - margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 2px solid #000; 184 + display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; 185 + margin-bottom: 0.6rem; padding-bottom: 0.4rem; border-bottom: 2px solid var(--fg); 169 186 } 170 - header h1 { font-size: 1.4rem; letter-spacing: 0.1em; } 171 - .badge { font-size: 0.7rem; background: #000; color: #fff; padding: 2px 8px; border-radius: 3px; } 172 - .meta { margin-left: auto; font-size: 0.75rem; color: #666; display: flex; gap: 1rem; align-items: center; } 187 + header h1 { font-size: 1.1rem; letter-spacing: 0.1em; } 188 + .badge { font-size: 0.65rem; background: var(--badge-bg); color: var(--badge-fg); padding: 1px 6px; border-radius: 3px; } 189 + .meta { margin-left: auto; font-size: 0.7rem; color: var(--muted); display: flex; gap: 0.8rem; align-items: center; } 173 190 .meta label { cursor: pointer; } 191 + .top-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; } 174 192 .card { 175 - background: #fff; border-radius: 6px; padding: 1.2rem; margin-bottom: 1rem; 176 - box-shadow: 0 1px 3px rgba(0,0,0,0.08); 193 + background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 4px; 194 + padding: 0.6rem 0.8rem; margin-bottom: 0.5rem; 195 + box-shadow: 0 1px 2px var(--shadow); 177 196 } 178 - .card h2 { font-size: 1rem; margin-bottom: 0.8rem; border-bottom: 1px solid #eee; padding-bottom: 0.4rem; } 179 - .kv { display: grid; grid-template-columns: 160px 1fr; gap: 0.3rem 1rem; font-size: 0.85rem; } 180 - .kv dt { color: #666; } 197 + .card h2 { font-size: 0.85rem; margin-bottom: 0.4rem; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; } 198 + .kv { display: grid; grid-template-columns: 100px 1fr; gap: 0.15rem 0.6rem; font-size: 0.8rem; } 199 + .kv dt { color: var(--muted); } 181 200 .kv dd { word-break: break-all; } 182 201 .metrics-grid { 183 - display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.8rem; font-size: 0.85rem; 202 + display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 0.4rem; font-size: 0.8rem; 184 203 } 185 204 .metric-box { 186 - background: #f8f8f8; border-radius: 4px; padding: 0.7rem; text-align: center; 205 + background: var(--metric-bg); border-radius: 3px; padding: 0.35rem 0.4rem; text-align: center; 187 206 } 188 - .metric-box .value { font-size: 1.3rem; font-weight: 700; } 189 - .metric-box .label { color: #666; font-size: 0.72rem; margin-top: 0.2rem; } 190 - table { width: 100%; border-collapse: collapse; font-size: 0.82rem; } 191 - th { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 2px solid #eee; color: #666; font-weight: 600; } 192 - td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #f0f0f0; } 207 + .metric-box .value { font-size: 1rem; font-weight: 700; } 208 + .metric-box .label { color: var(--muted); font-size: 0.65rem; margin-top: 0.1rem; } 209 + table { width: 100%; border-collapse: collapse; font-size: 0.78rem; } 210 + th { text-align: left; padding: 0.25rem 0.4rem; border-bottom: 2px solid var(--border); color: var(--muted); font-weight: 600; } 211 + td { padding: 0.25rem 0.4rem; border-bottom: 1px solid var(--border); } 193 212 tr.clickable { cursor: pointer; } 194 - tr.clickable:hover { background: #f8f8f8; } 195 - .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; } 213 + tr.clickable:hover { background: var(--row-hover); } 214 + .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } 196 215 .dot-synced { background: #22c55e; } 197 216 .dot-syncing { background: #eab308; } 198 217 .dot-pending { background: #9ca3af; } 199 218 .dot-error { background: #ef4444; } 200 - .detail-row td { padding: 0.8rem; background: #fafafa; font-size: 0.8rem; } 201 - .detail-inner table { margin-top: 0.5rem; } 202 - .source-badge { font-size: 0.7rem; padding: 1px 6px; border-radius: 3px; } 219 + .detail-row td { padding: 0.5rem; background: var(--detail-bg); font-size: 0.75rem; } 220 + .detail-inner table { margin-top: 0.3rem; } 221 + .source-badge { font-size: 0.65rem; padding: 1px 5px; border-radius: 3px; } 203 222 .source-pds { background: #dbeafe; color: #1e40af; } 204 223 .source-firehose { background: #fef3c7; color: #92400e; } 205 224 .source-peer_fallback { background: #fce7f3; color: #9d174d; } 206 - .policy-list { list-style: none; font-size: 0.85rem; } 207 - .policy-list li { padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; } 225 + .policy-list { list-style: none; font-size: 0.8rem; } 226 + .policy-list li { padding: 0.25rem 0; border-bottom: 1px solid var(--border); } 208 227 .verify-pass { color: #22c55e; font-weight: 600; } 209 228 .verify-fail { color: #ef4444; font-weight: 600; } 210 - .loading { color: #999; font-style: italic; } 211 - .add-did-form { display: flex; gap: 0.5rem; margin-bottom: 0.8rem; } 229 + .loading { color: var(--faint); font-style: italic; } 230 + .add-did-form { display: flex; gap: 0.4rem; margin-bottom: 0.4rem; } 212 231 .add-did-form input { 213 - flex: 1; padding: 0.4rem 0.6rem; font-family: inherit; font-size: 0.85rem; 214 - border: 1px solid #ccc; border-radius: 4px; outline: none; 232 + flex: 1; padding: 0.3rem 0.5rem; font-family: inherit; font-size: 0.8rem; 233 + border: 1px solid var(--input-border); border-radius: 3px; outline: none; 234 + background: var(--input-bg); color: var(--fg); 215 235 } 216 - .add-did-form input:focus { border-color: #000; } 236 + .add-did-form input:focus { border-color: var(--fg); } 217 237 .add-did-form button, .btn-remove { 218 - padding: 0.4rem 0.8rem; font-family: inherit; font-size: 0.82rem; 219 - border: 1px solid #000; border-radius: 4px; cursor: pointer; background: #fff; 238 + padding: 0.3rem 0.6rem; font-family: inherit; font-size: 0.78rem; 239 + border: 1px solid var(--fg); border-radius: 3px; cursor: pointer; 240 + background: var(--card-bg); color: var(--fg); 220 241 } 221 - .add-did-form button:hover { background: #000; color: #fff; } 222 - .btn-remove { border-color: #ef4444; color: #ef4444; padding: 0.2rem 0.5rem; font-size: 0.75rem; } 242 + .add-did-form button:hover { background: var(--fg); color: var(--bg); } 243 + .btn-remove { border-color: #ef4444; color: #ef4444; padding: 0.15rem 0.4rem; font-size: 0.7rem; } 223 244 .btn-remove:hover { background: #ef4444; color: #fff; } 224 - .did-source { font-size: 0.7rem; padding: 1px 6px; border-radius: 3px; } 245 + .did-source { font-size: 0.65rem; padding: 1px 5px; border-radius: 3px; } 225 246 .did-source-config { background: #e0e7ff; color: #3730a3; } 226 247 .did-source-admin { background: #d1fae5; color: #065f46; } 227 248 .did-source-policy { background: #fef3c7; color: #92400e; } 228 - .did-source-unknown { background: #f3f4f6; color: #6b7280; } 229 - .add-did-error { color: #ef4444; font-size: 0.82rem; margin-bottom: 0.5rem; min-height: 1.2em; } 230 - .add-did-success { color: #22c55e; font-size: 0.82rem; margin-bottom: 0.5rem; min-height: 1.2em; } 249 + .did-source-unknown { background: var(--metric-bg); color: var(--muted); } 250 + .add-did-error { color: #ef4444; font-size: 0.78rem; margin-bottom: 0.3rem; min-height: 1em; } 251 + .add-did-success { color: #22c55e; font-size: 0.78rem; margin-bottom: 0.3rem; min-height: 1em; } 231 252 .account-search-wrap { position: relative; } 232 253 .account-search-wrap input { 233 - width: 100%; padding: 0.5rem 0.7rem 0.5rem 2rem; font-family: inherit; font-size: 0.85rem; 234 - border: 1px solid #ccc; border-radius: 4px; outline: none; background: #fff; 254 + width: 100%; padding: 0.3rem 0.5rem 0.3rem 1.6rem; font-family: inherit; font-size: 0.8rem; 255 + border: 1px solid var(--input-border); border-radius: 3px; outline: none; 256 + background: var(--input-bg); color: var(--fg); 235 257 } 236 - .account-search-wrap input:focus { border-color: #000; box-shadow: 0 0 0 2px rgba(0,0,0,0.06); } 258 + .account-search-wrap input:focus { border-color: var(--fg); box-shadow: 0 0 0 1px var(--border); } 237 259 .account-search-icon { 238 - position: absolute; left: 0.6rem; top: 50%; transform: translateY(-50%); 239 - color: #999; font-size: 0.85rem; pointer-events: none; line-height: 1; 260 + position: absolute; left: 0.4rem; top: 50%; transform: translateY(-50%); 261 + color: var(--faint); font-size: 0.8rem; pointer-events: none; line-height: 1; 240 262 } 241 263 .account-results { 242 - position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 100; 243 - background: #fff; border: 1px solid #ddd; border-radius: 6px; 244 - box-shadow: 0 4px 16px rgba(0,0,0,0.12); max-height: 280px; overflow-y: auto; 264 + position: absolute; top: calc(100% + 3px); left: 0; right: 0; z-index: 100; 265 + background: var(--dropdown-bg); border: 1px solid var(--dropdown-border); border-radius: 4px; 266 + box-shadow: 0 3px 12px var(--dropdown-shadow); max-height: 240px; overflow-y: auto; 245 267 display: none; 246 268 } 247 269 .account-results.visible { display: block; } 248 270 .account-result-item { 249 - display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.7rem; 250 - cursor: pointer; border-bottom: 1px solid #f0f0f0; transition: background 0.1s; 271 + display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; 272 + cursor: pointer; border-bottom: 1px solid var(--border); transition: background 0.1s; 251 273 } 252 274 .account-result-item:last-child { border-bottom: none; } 253 - .account-result-item:hover, .account-result-item.active { background: #f5f5f5; } 275 + .account-result-item:hover, .account-result-item.active { background: var(--row-hover); } 254 276 .account-result-avatar { 255 - width: 32px; height: 32px; border-radius: 50%; background: #e5e7eb; 277 + width: 26px; height: 26px; border-radius: 50%; background: var(--acct-placeholder-bg); 256 278 flex-shrink: 0; object-fit: cover; 257 279 } 258 280 .account-result-avatar-placeholder { 259 - width: 32px; height: 32px; border-radius: 50%; background: #e5e7eb; 281 + width: 26px; height: 26px; border-radius: 50%; background: var(--acct-placeholder-bg); 260 282 flex-shrink: 0; display: flex; align-items: center; justify-content: center; 261 - color: #9ca3af; font-size: 0.75rem; font-weight: 600; 283 + color: var(--faint); font-size: 0.65rem; font-weight: 600; 262 284 } 263 285 .account-result-info { flex: 1; min-width: 0; } 264 - .account-result-name { font-size: 0.85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 265 - .account-result-handle { font-size: 0.75rem; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 286 + .account-result-name { font-size: 0.8rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 287 + .account-result-handle { font-size: 0.7rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 266 288 .account-selected { 267 - display: flex; align-items: center; gap: 0.7rem; padding: 0.6rem 0.8rem; 268 - background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; margin-bottom: 0.6rem; 289 + display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; 290 + background: var(--selected-bg); border: 1px solid var(--selected-border); border-radius: 4px; margin-bottom: 0.4rem; 269 291 } 270 292 .account-selected-info { flex: 1; min-width: 0; } 271 - .account-selected-name { font-size: 0.9rem; font-weight: 600; } 272 - .account-selected-handle { font-size: 0.8rem; color: #666; } 293 + .account-selected-name { font-size: 0.8rem; font-weight: 600; } 294 + .account-selected-handle { font-size: 0.7rem; color: var(--muted); } 273 295 .account-selected-clear { 274 - padding: 0.2rem 0.5rem; font-family: inherit; font-size: 0.75rem; 275 - border: 1px solid #ccc; border-radius: 4px; cursor: pointer; background: #fff; color: #666; 276 - flex-shrink: 0; 296 + padding: 0.15rem 0.4rem; font-family: inherit; font-size: 0.7rem; 297 + border: 1px solid var(--input-border); border-radius: 3px; cursor: pointer; 298 + background: var(--card-bg); color: var(--muted); flex-shrink: 0; 277 299 } 278 - .account-selected-clear:hover { border-color: #999; color: #000; } 300 + .account-selected-clear:hover { border-color: var(--muted); color: var(--fg); } 279 301 .account-connect-btn { 280 - padding: 0.5rem 1.2rem; font-family: inherit; font-size: 0.85rem; 281 - border: 1px solid #000; border-radius: 4px; cursor: pointer; background: #000; color: #fff; 282 - font-weight: 600; transition: opacity 0.15s; 302 + padding: 0.35rem 0.8rem; font-family: inherit; font-size: 0.8rem; 303 + border: 1px solid var(--fg); border-radius: 3px; cursor: pointer; 304 + background: var(--fg); color: var(--bg); font-weight: 600; transition: opacity 0.15s; 283 305 } 284 306 .account-connect-btn:hover { opacity: 0.85; } 285 307 .account-connect-btn:disabled { opacity: 0.35; cursor: not-allowed; } 286 - .account-no-results { padding: 0.7rem; text-align: center; color: #999; font-size: 0.82rem; } 287 - .account-searching { padding: 0.7rem; text-align: center; color: #999; font-size: 0.82rem; } 308 + .account-no-results { padding: 0.5rem; text-align: center; color: var(--faint); font-size: 0.78rem; } 309 + .account-searching { padding: 0.5rem; text-align: center; color: var(--faint); font-size: 0.78rem; } 310 + .per-acct-metrics { display: inline-flex; gap: 0.6rem; font-size: 0.7rem; color: var(--muted); margin-top: 2px; } 311 + .per-acct-metrics span { white-space: nowrap; } 312 + @media (prefers-color-scheme: dark) { 313 + .source-pds { background: #1e3a5f; color: #93c5fd; } 314 + .source-firehose { background: #422006; color: #fcd34d; } 315 + .source-peer_fallback { background: #4a1035; color: #f9a8d4; } 316 + .did-source-config { background: #312e81; color: #a5b4fc; } 317 + .did-source-admin { background: #064e3b; color: #6ee7b7; } 318 + .did-source-policy { background: #422006; color: #fcd34d; } 319 + } 288 320 </style> 289 321 </head> 290 322 <body> ··· 297 329 </div> 298 330 </header> 299 331 332 + <div class="top-row"> 300 333 <section class="card" id="section-overview"> 301 - <h2>System Overview</h2> 334 + <h2>System</h2> 302 335 <div id="overview-content" class="loading">Loading...</div> 303 336 </section> 304 - 305 337 <section class="card" id="section-account"> 306 - <h2>Account Connection</h2> 338 + <h2>Account</h2> 307 339 <div id="account-content" class="loading">Loading...</div> 308 340 </section> 341 + </div> 309 342 310 343 <section class="card" id="section-metrics"> 311 344 <h2>Replication Summary</h2> ··· 313 346 </section> 314 347 315 348 <section class="card" id="section-replication"> 316 - <h2>Replicated DIDs</h2> 349 + <h2>Replicating Accounts</h2> 317 350 <div class="add-did-form"> 318 351 <div class="account-search-wrap" id="did-search-wrap" style="flex:1"> 319 352 <span class="account-search-icon">&#128269;</span> ··· 417 450 const el = document.getElementById("overview-content"); 418 451 const net = data.network || {}; 419 452 const fh = data.firehose || {}; 420 - var accountHtml = ""; 421 - if (cachedAccountStatus && cachedAccountStatus.authenticated) { 422 - var a = cachedAccountStatus; 423 - var avatarHtml = a.avatar 424 - ? '<img src="' + esc(a.avatar) + '" alt="" style="width:32px;height:32px;border-radius:50%;vertical-align:middle;margin-right:0.5rem">' 425 - : ''; 426 - accountHtml = '<dt>Account</dt><dd>' 427 - + avatarHtml 428 - + '<strong>' + esc(a.displayName || a.handle || a.did) + '</strong>' 429 - + (a.handle ? ' <span style="color:#666">@' + esc(a.handle) + '</span>' : '') 430 - + '</dd>'; 431 - } else { 432 - accountHtml = '<dt>Account</dt><dd style="color:#999">Not connected</dd>'; 433 - } 453 + var netHtml = net.peerId 454 + ? "<dt>Peer ID</dt><dd>" + esc(net.peerId) + "</dd>" 455 + + "<dt>Connections</dt><dd>" + esc(net.connections ?? 0) + "</dd>" 456 + : ''; 434 457 el.innerHTML = '<dl class="kv">' 435 - + accountHtml 436 458 + "<dt>DID</dt><dd>" + esc(data.did) + "</dd>" 437 - + "<dt>Version</dt><dd>" + esc(data.version) + "</dd>" 438 - + "<dt>Peer ID</dt><dd>" + esc(net.peerId) + "</dd>" 439 - + "<dt>Connections</dt><dd>" + esc(net.connections ?? 0) + "</dd>" 440 - + "<dt>Multiaddrs</dt><dd>" + esc((net.multiaddrs || []).length) + "</dd>" 441 - + "<dt>Firehose</dt><dd>" + (data.firehose ? esc(fh.url || "connected") : "disabled") + "</dd>" 459 + + netHtml 460 + + "<dt>Firehose</dt><dd>" + (data.firehose ? esc(fh.url || "connected") : '<span style="color:var(--faint)">disabled</span>') + "</dd>" 442 461 + "</dl>"; 443 462 document.getElementById("version-badge").textContent = "v" + data.version; 444 463 } ··· 472 491 function renderAccountCell(did, profile) { 473 492 if (!profile) return esc(did); 474 493 var av = profile.avatar 475 - ? '<img src="' + esc(profile.avatar) + '" alt="" style="width:24px;height:24px;border-radius:50%;vertical-align:middle;margin-right:0.4rem">' 476 - : '<span style="display:inline-block;width:24px;height:24px;border-radius:50%;background:#e0e0e0;text-align:center;line-height:24px;font-size:0.7rem;vertical-align:middle;margin-right:0.4rem">' 494 + ? '<img src="' + esc(profile.avatar) + '" alt="" style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:0.3rem">' 495 + : '<span style="display:inline-block;width:20px;height:20px;border-radius:50%;background:var(--acct-placeholder-bg);text-align:center;line-height:20px;font-size:0.6rem;vertical-align:middle;margin-right:0.3rem">' 477 496 + esc((profile.handle || did)[0].toUpperCase()) + '</span>'; 478 497 return av 479 - + '<strong style="font-size:0.85rem">' + esc(profile.displayName || profile.handle) + '</strong>' 480 - + ' <span style="color:#666;font-size:0.8rem">@' + esc(profile.handle) + '</span>' 481 - + '<div style="color:#999;font-size:0.7rem;margin-top:1px">' + esc(did) + '</div>'; 498 + + '<strong style="font-size:0.78rem">' + esc(profile.displayName || profile.handle) + '</strong>' 499 + + ' <span style="color:var(--muted);font-size:0.72rem">@' + esc(profile.handle) + '</span>' 500 + + '<div style="color:var(--faint);font-size:0.65rem;margin-top:1px">' + esc(did) + '</div>'; 482 501 } 483 502 484 503 function renderReplication(data) { ··· 488 507 const states = repl.syncStates || []; 489 508 const sources = repl.didSources || {}; 490 509 if (states.length === 0) { el.innerHTML = "No tracked DIDs"; return; } 491 - let html = "<table><thead><tr><th>Account</th><th>Source</th><th>Status</th><th>Last Sync</th><th>Error</th><th></th></tr></thead><tbody>"; 510 + let html = "<table><thead><tr><th>Account</th><th>Source</th><th>Status</th><th>Records</th><th>Blocks</th><th>Held</th><th>Last Sync</th><th></th></tr></thead><tbody>"; 492 511 for (const s of states) { 493 512 const st = s.status || "pending"; 494 513 const src = sources[s.did] || "unknown"; 495 514 const rid = "detail-" + s.did.replace(/[^a-zA-Z0-9]/g, "_"); 496 515 var cellId = "acct-" + s.did.replace(/[^a-zA-Z0-9]/g, "_"); 516 + var metricsId = "metrics-" + s.did.replace(/[^a-zA-Z0-9]/g, "_"); 497 517 html += '<tr class="clickable" data-did="' + esc(s.did) + '" data-rid="' + rid + '">' 498 - + '<td id="' + cellId + '" style="min-width:200px">' + esc(s.did) + '</td>' 518 + + '<td id="' + cellId + '" style="min-width:180px">' + esc(s.did) + '</td>' 499 519 + "<td>" + didSourceBadge(src) + "</td>" 500 520 + "<td>" + statusDot(st) + "</td>" 521 + + '<td id="' + metricsId + '-rec">-</td>' 522 + + '<td id="' + metricsId + '-blk">-</td>' 523 + + '<td id="' + metricsId + '-bytes">-</td>' 501 524 + "<td>" + timeAgo(s.lastSyncAt) + "</td>" 502 - + "<td>" + esc(s.errorMessage || "-") + "</td>" 503 525 + "<td>" + (src === "admin" ? '<button class="btn-remove" data-did="' + esc(s.did) + '">Remove</button>' : "") + "</td>" 504 526 + "</tr>"; 505 - html += '<tr class="detail-row" id="' + rid + '" style="display:none"><td colspan="6"><div class="detail-inner loading">Click to load...</div></td></tr>'; 527 + html += '<tr class="detail-row" id="' + rid + '" style="display:none"><td colspan="8"><div class="detail-inner loading">Click to load...</div></td></tr>'; 506 528 } 507 529 html += "</tbody></table>"; 508 530 el.innerHTML = html; 509 531 510 - // Async profile resolution for each DID row 532 + // Async profile + per-account metrics resolution 511 533 for (const s of states) { 512 534 (function(did) { 513 535 var cellId = "acct-" + did.replace(/[^a-zA-Z0-9]/g, "_"); 536 + var metricsId = "metrics-" + did.replace(/[^a-zA-Z0-9]/g, "_"); 514 537 fetchProfile(did).then(function(p) { 515 538 var cell = document.getElementById(cellId); 516 539 if (cell && p) cell.innerHTML = renderAccountCell(did, p); 517 540 }); 541 + apiFetch("org.p2pds.admin.getDidStatus", { did: did }).then(function(d) { 542 + var recEl = document.getElementById(metricsId + "-rec"); 543 + var blkEl = document.getElementById(metricsId + "-blk"); 544 + var bytesEl = document.getElementById(metricsId + "-bytes"); 545 + if (recEl) recEl.textContent = formatNumber(d.recordCount); 546 + if (blkEl) blkEl.textContent = formatNumber(d.blockCount); 547 + if (bytesEl) bytesEl.textContent = formatBytes(d.bytesHeld); 548 + }).catch(function() {}); 518 549 })(s.did); 519 550 } 520 551 ··· 597 628 598 629 function renderNetwork(data) { 599 630 const el = document.getElementById("network-content"); 631 + if (!data.peerId) { 632 + el.innerHTML = '<span style="color:var(--faint)">Networking disabled (local blockstore only)</span>'; 633 + return; 634 + } 600 635 el.innerHTML = '<dl class="kv">' 601 636 + "<dt>Peer ID</dt><dd>" + esc(data.peerId) + "</dd>" 602 637 + "<dt>Connections</dt><dd>" + esc(data.connections) + "</dd>" ··· 654 689 cachedAccountStatus = data; 655 690 if (data.authenticated) { 656 691 var avatarHtml = data.avatar 657 - ? '<img src="' + esc(data.avatar) + '" alt="" style="width:48px;height:48px;border-radius:50%;margin-right:1rem">' 658 - : '<div style="width:48px;height:48px;border-radius:50%;background:#e0e0e0;display:flex;align-items:center;justify-content:center;margin-right:1rem;font-size:1.2rem;font-weight:600">' 659 - + esc((data.handle || data.did || "?")[0].toUpperCase()) + '</div>'; 660 - el.innerHTML = '<div style="display:flex;align-items:center;margin-bottom:0.75rem">' 692 + ? '<img src="' + esc(data.avatar) + '" alt="" style="width:28px;height:28px;border-radius:50%;margin-right:0.5rem;vertical-align:middle">' 693 + : ''; 694 + el.innerHTML = '<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem">' 661 695 + avatarHtml 662 - + '<div>' 663 - + '<div style="font-weight:600;font-size:1rem">' + esc(data.displayName || data.handle || "Connected") + '</div>' 664 - + (data.handle ? '<div style="color:#666;font-size:0.85rem">@' + esc(data.handle) + '</div>' : '') 665 - + '<div style="color:#999;font-size:0.75rem;margin-top:0.15rem">' + esc(data.did) + '</div>' 666 - + '</div>' 696 + + '<strong style="font-size:0.85rem">' + esc(data.displayName || data.handle || "Connected") + '</strong>' 697 + + (data.handle ? ' <span style="color:var(--muted);font-size:0.8rem">@' + esc(data.handle) + '</span>' : '') 667 698 + '</div>' 668 - + '<span class="dot dot-synced"></span> Authenticated'; 699 + + '<div style="display:flex;align-items:center;gap:0.6rem;font-size:0.75rem">' 700 + + '<span class="dot dot-synced"></span>Authenticated' 701 + + '<form method="POST" action="/oauth/logout" style="margin:0"><button type="submit" class="btn-remove">Disconnect</button></form>' 702 + + '</div>'; 669 703 } else { 670 704 accountSearchState = { selectedHandle: null, selectedActor: null, activeIndex: -1 }; 671 705 el.innerHTML = '<div id="account-search-container">' 672 706 + '<div id="account-selected-display" style="display:none"></div>' 673 707 + '<div class="account-search-wrap" id="account-search-wrap">' 674 708 + '<span class="account-search-icon">&#128269;</span>' 675 - + '<input type="text" id="account-search-input" placeholder="Search for a Bluesky account..." autocomplete="off" spellcheck="false">' 709 + + '<input type="text" id="account-search-input" placeholder="Search Bluesky account..." autocomplete="off" spellcheck="false">' 676 710 + '<div class="account-results" id="account-results"></div>' 677 711 + '</div>' 678 - + '<div style="margin-top:0.6rem">' 679 - + '<button class="account-connect-btn" id="account-connect-btn" disabled>Connect Account</button>' 712 + + '<div style="margin-top:0.4rem">' 713 + + '<button class="account-connect-btn" id="account-connect-btn" disabled>Connect</button>' 680 714 + '</div>' 681 - + '</div>' 682 - + '<div style="font-size:0.8rem;color:#666;margin-top:0.5rem">Search for your Bluesky account to authenticate via OAuth and publish records to your PDS.</div>'; 715 + + '</div>'; 683 716 setupAccountSearch(); 684 717 } 685 718 } catch (e) { ··· 1051 1084 if (!isValidDid(did)) { 1052 1085 return c.json( 1053 1086 { error: "InvalidDid", message: "Invalid DID format" }, 1054 - 400, 1055 - ); 1056 - } 1057 - 1058 - if (did === nodeDid || did === c.env.DID) { 1059 - return c.json( 1060 - { error: "InvalidDid", message: "Cannot replicate own DID" }, 1061 1087 400, 1062 1088 ); 1063 1089 }
+26 -12
src/xrpc/sync.ts
··· 9 9 import { BlockMap, blocksToCarFile } from "@atproto/repo"; 10 10 import { CID } from "@atproto/lex-data"; 11 11 12 + /** 13 + * Generate CAR bytes for a replicated DID from the blockstore. 14 + * Returns null if the DID has no sync state or root CID. 15 + */ 16 + export async function generateCarForDid( 17 + did: string, 18 + blockStore: BlockStore, 19 + syncStorage: SyncStorage, 20 + ): Promise<Uint8Array | null> { 21 + const state = syncStorage.getState(did); 22 + if (!state?.rootCid) return null; 23 + 24 + const blockCids = syncStorage.getBlockCids(did); 25 + const blocks = new BlockMap(); 26 + for (const cidStr of blockCids) { 27 + const bytes = await blockStore.getBlock(cidStr); 28 + if (bytes) { 29 + blocks.set(CID.parse(cidStr), bytes); 30 + } 31 + } 32 + const root = CID.parse(state.rootCid); 33 + return blocksToCarFile(root, blocks); 34 + } 35 + 12 36 export async function getRepo( 13 37 c: Context<AppEnv>, 14 38 repoManager: RepoManager | undefined, ··· 45 69 46 70 // Replicated DID: serve from BlockStore 47 71 if (blockStore && syncStorage) { 48 - const state = syncStorage.getState(did); 49 - if (state?.rootCid) { 50 - const blockCids = syncStorage.getBlockCids(did); 51 - const blocks = new BlockMap(); 52 - for (const cidStr of blockCids) { 53 - const bytes = await blockStore.getBlock(cidStr); 54 - if (bytes) { 55 - blocks.set(CID.parse(cidStr), bytes); 56 - } 57 - } 58 - const root = CID.parse(state.rootCid); 59 - const carBytes = await blocksToCarFile(root, blocks); 72 + const carBytes = await generateCarForDid(did, blockStore, syncStorage); 73 + if (carBytes) { 60 74 return new Response(carBytes, { 61 75 status: 200, 62 76 headers: {