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 consent-gated replication: "Add" publishes an offer instead of syncing immediately

Dashboard "Add" now calls offerDid() which publishes an offer record and stores in
offered_dids table without triggering sync. Replication begins only when mutual consent
is detected during periodic offer discovery. Offered DIDs display with purple "offered"
status in the dashboard and can be revoked.

+284 -9
+6
src/index.ts
··· 672 672 app.post("/xrpc/org.p2pds.app.addDid", requireAuth, (c) => 673 673 app_routes.addDid(c, getConfigDid(), replicationManager), 674 674 ); 675 + app.post("/xrpc/org.p2pds.app.offerDid", requireAuth, (c) => 676 + app_routes.offerDid(c, replicationManager), 677 + ); 678 + app.post("/xrpc/org.p2pds.app.removeOfferedDid", requireAuth, (c) => 679 + app_routes.removeOfferedDid(c, replicationManager), 680 + ); 675 681 app.post("/xrpc/org.p2pds.app.removeDid", requireAuth, (c) => 676 682 app_routes.removeDid(c, replicationManager), 677 683 );
+106 -4
src/replication/replication-manager.ts
··· 250 250 } 251 251 252 252 /** 253 + * Offer to replicate a DID (consent-gated). 254 + * Publishes an offer record and stores in offered_dids, but does NOT sync. 255 + * Replication begins only when mutual consent is detected during offer discovery. 256 + */ 257 + async offerDid(did: string): Promise<{ status: "offered" | "already_tracked" | "already_offered"; source?: string }> { 258 + // Already fully tracked? 259 + if (this.config.REPLICATE_DIDS.includes(did)) { 260 + return { status: "already_tracked", source: "config" }; 261 + } 262 + if (this.syncStorage.isAdminDid(did)) { 263 + return { status: "already_tracked", source: "admin" }; 264 + } 265 + if (this.policyEngine?.getExplicitDids().includes(did)) { 266 + return { status: "already_tracked", source: "policy" }; 267 + } 268 + 269 + // Already offered? 270 + if (this.syncStorage.isOfferedDid(did)) { 271 + return { status: "already_offered" }; 272 + } 273 + 274 + // Resolve PDS endpoint 275 + const pdsEndpoint = await this.repoFetcher.resolvePds(did); 276 + 277 + // Publish offer record if OfferManager is available 278 + if (this.offerManager) { 279 + await this.offerManager.publishOffer(did); 280 + } 281 + 282 + // Store in offered_dids (does NOT create replication_state or trigger sync) 283 + this.syncStorage.addOfferedDid(did, pdsEndpoint ?? null); 284 + 285 + return { status: "offered" }; 286 + } 287 + 288 + /** 289 + * Remove an offered DID: revoke the offer and remove from offered_dids. 290 + */ 291 + async removeOfferedDid(did: string): Promise<{ status: "removed" | "not_found" }> { 292 + if (!this.syncStorage.isOfferedDid(did)) { 293 + return { status: "not_found" }; 294 + } 295 + 296 + // Revoke offer record if OfferManager is available 297 + if (this.offerManager) { 298 + await this.offerManager.revokeOffer(did); 299 + } 300 + 301 + this.syncStorage.removeOfferedDid(did); 302 + return { status: "removed" }; 303 + } 304 + 305 + /** 253 306 * Remove an admin-added DID. 254 307 * Returns error info if the DID cannot be removed (config/policy origin). 255 308 * If purgeData is true, deletes all associated data. Otherwise pauses. ··· 353 406 } 354 407 355 408 /** 356 - * Run offer discovery: gather peers from sync state and discover agreements. 409 + * Run offer discovery: gather peers from sync state AND offered_dids, 410 + * then discover mutual agreements. 411 + * When a mutual agreement is detected for an offered DID, 412 + * promotes it to full replication via addDid() and removes from offered_dids. 357 413 * Non-fatal: errors are logged but don't block sync. 358 414 */ 359 415 private async runOfferDiscovery(): Promise<void> { 360 416 if (!this.offerManager) return; 361 417 362 418 try { 419 + // Existing: peers from replication_state 363 420 const states = this.syncStorage.getAllStates(); 364 - const peers = states 421 + const syncPeers = states 365 422 .filter((s) => s.pdsEndpoint) 366 423 .map((s) => ({ did: s.did, pdsEndpoint: s.pdsEndpoint })); 367 424 368 - if (peers.length > 0) { 369 - await this.offerManager.discoverAndSync(peers); 425 + // New: peers from offered_dids 426 + const offeredDids = this.syncStorage.getOfferedDids(); 427 + const offeredPeers = offeredDids 428 + .filter((o) => o.pdsEndpoint) 429 + .map((o) => ({ did: o.did, pdsEndpoint: o.pdsEndpoint! })); 430 + 431 + // Merge and deduplicate by DID 432 + const seenDids = new Set<string>(); 433 + const allPeers: Array<{ did: string; pdsEndpoint: string }> = []; 434 + for (const peer of [...syncPeers, ...offeredPeers]) { 435 + if (!seenDids.has(peer.did)) { 436 + seenDids.add(peer.did); 437 + allPeers.push(peer); 438 + } 439 + } 440 + 441 + if (allPeers.length > 0) { 442 + const agreements = await this.offerManager.discoverAgreements(allPeers); 443 + 444 + // Check if any offered DIDs now have mutual agreements 445 + for (const offered of offeredDids) { 446 + const hasAgreement = agreements.some( 447 + (a) => a.localOffer.subject === offered.did || a.remoteOffer.subject === offered.did, 448 + ); 449 + if (hasAgreement) { 450 + console.log(`[replication] Mutual agreement detected for offered DID ${offered.did} — promoting to replication`); 451 + try { 452 + await this.addDid(offered.did); 453 + this.syncStorage.removeOfferedDid(offered.did); 454 + } catch (err) { 455 + console.error( 456 + `[replication] Failed to promote offered DID ${offered.did}:`, 457 + err instanceof Error ? err.message : String(err), 458 + ); 459 + } 460 + } 461 + } 462 + 463 + // Still run full discoverAndSync for sync-state peers 464 + await this.offerManager.discoverAndSync(syncPeers); 370 465 } 371 466 } catch (err) { 372 467 console.error( ··· 1561 1656 */ 1562 1657 getSyncStates(): SyncState[] { 1563 1658 return this.syncStorage.getAllStates(); 1659 + } 1660 + 1661 + /** 1662 + * Get all offered DIDs (awaiting mutual consent). 1663 + */ 1664 + getOfferedDids(): Array<{ did: string; pdsEndpoint: string | null; offeredAt: string }> { 1665 + return this.syncStorage.getOfferedDids(); 1564 1666 } 1565 1667 1566 1668 /**
+63
src/replication/sync-storage.ts
··· 84 84 ); 85 85 `); 86 86 87 + // Offered DIDs table: tracks DIDs we've offered to replicate 88 + // but don't yet have mutual consent for. 89 + this.db.exec(` 90 + CREATE TABLE IF NOT EXISTS offered_dids ( 91 + did TEXT PRIMARY KEY, 92 + pds_endpoint TEXT, 93 + offered_at TEXT NOT NULL DEFAULT (datetime('now')) 94 + ); 95 + `); 96 + 87 97 // Sync history table: logs each sync event with metrics. 88 98 this.db.exec(` 89 99 CREATE TABLE IF NOT EXISTS sync_history ( ··· 599 609 isAdminDid(did: string): boolean { 600 610 const row = this.db 601 611 .prepare("SELECT 1 FROM admin_tracked_dids WHERE did = ?") 612 + .get(did); 613 + return row !== undefined; 614 + } 615 + 616 + // ============================================ 617 + // Offered DID management 618 + // ============================================ 619 + 620 + /** 621 + * Add a DID to the offered list (idempotent). 622 + */ 623 + addOfferedDid(did: string, pdsEndpoint: string | null): void { 624 + this.db 625 + .prepare( 626 + `INSERT INTO offered_dids (did, pds_endpoint) 627 + VALUES (?, ?) 628 + ON CONFLICT(did) DO UPDATE SET 629 + pds_endpoint = COALESCE(excluded.pds_endpoint, offered_dids.pds_endpoint)`, 630 + ) 631 + .run(did, pdsEndpoint); 632 + } 633 + 634 + /** 635 + * Get all offered DIDs with their PDS endpoints. 636 + */ 637 + getOfferedDids(): Array<{ did: string; pdsEndpoint: string | null; offeredAt: string }> { 638 + const rows = this.db 639 + .prepare("SELECT did, pds_endpoint, offered_at FROM offered_dids ORDER BY offered_at") 640 + .all() as Array<{ did: string; pds_endpoint: string | null; offered_at: string }>; 641 + return rows.map((r) => ({ 642 + did: r.did, 643 + pdsEndpoint: r.pds_endpoint, 644 + offeredAt: r.offered_at, 645 + })); 646 + } 647 + 648 + /** 649 + * Remove a DID from the offered list. 650 + * Returns true if the DID was actually removed. 651 + */ 652 + removeOfferedDid(did: string): boolean { 653 + const result = this.db 654 + .prepare("DELETE FROM offered_dids WHERE did = ?") 655 + .run(did); 656 + return result.changes > 0; 657 + } 658 + 659 + /** 660 + * Check if a DID is in the offered list. 661 + */ 662 + isOfferedDid(did: string): boolean { 663 + const row = this.db 664 + .prepare("SELECT 1 FROM offered_dids WHERE did = ?") 602 665 .get(did); 603 666 return row !== undefined; 604 667 }
+109 -5
src/xrpc/app.ts
··· 29 29 let firehose: Record<string, unknown> | null = null; 30 30 let policy: Record<string, unknown> | null = null; 31 31 let verification: { results: unknown[] } = { results: [] }; 32 + let offeredDids: Array<{ did: string; pdsEndpoint: string | null; offeredAt: string }> = []; 32 33 33 34 if (replicationManager) { 34 35 const syncStates = replicationManager.getSyncStates(); ··· 38 39 for (const s of syncStates) { 39 40 didSources[s.did] = replicationManager.getDidSource(s.did) ?? "unknown"; 40 41 } 42 + offeredDids = replicationManager.getOfferedDids(); 41 43 replication = { 42 44 enabled: true, 43 45 trackedDids: syncStates.map((s) => s.did), ··· 73 75 firehose, 74 76 policy, 75 77 verification, 78 + offeredDids, 76 79 }); 77 80 } 78 81 ··· 218 221 .dot-syncing { background: #eab308; } 219 222 .dot-pending { background: #9ca3af; } 220 223 .dot-error { background: #ef4444; } 224 + .dot-offered { background: #8b5cf6; } 221 225 .detail-row td { padding: 0.5rem; background: var(--detail-bg); font-size: 0.75rem; } 222 226 .detail-inner table { margin-top: 0.3rem; } 223 227 .source-badge { font-size: 0.65rem; padding: 1px 5px; border-radius: 3px; } ··· 260 264 .did-source-admin { background: #d1fae5; color: #065f46; } 261 265 .did-source-policy { background: #fef3c7; color: #92400e; } 262 266 .did-source-unknown { background: var(--metric-bg); color: var(--muted); } 267 + .did-source-offered { background: #ede9fe; color: #5b21b6; } 263 268 .add-did-error { color: #ef4444; font-size: 0.78rem; margin-bottom: 0.3rem; min-height: 1em; } 264 269 .add-did-success { color: #22c55e; font-size: 0.78rem; margin-bottom: 0.3rem; min-height: 1em; } 265 270 .account-search-wrap { position: relative; } ··· 344 349 .did-source-config { background: #312e81; color: #a5b4fc; } 345 350 .did-source-admin { background: #064e3b; color: #6ee7b7; } 346 351 .did-source-policy { background: #422006; color: #fcd34d; } 352 + .did-source-offered { background: #2e1065; color: #c4b5fd; } 347 353 .trigger-firehose { background: #422006; color: #fcd34d; } 348 354 .trigger-firehose-resync { background: #431407; color: #fdba74; } 349 355 .trigger-gossipsub { background: #2e1065; color: #c4b5fd; } ··· 389 395 <input type="text" id="add-did-input" placeholder="Search account or paste did:plc:..." autocomplete="off" spellcheck="false"> 390 396 <div class="account-results" id="did-search-results"></div> 391 397 </div> 398 + <div id="add-did-selected" style="display:none;flex:1"></div> 392 399 <button id="add-did-btn">Add</button> 393 400 </div> 394 - <div id="add-did-selected" style="display:none;margin-bottom:0.5rem"></div> 395 401 <div id="add-did-msg" class="add-did-error"></div> 396 402 <div id="replication-content" class="loading">Loading...</div> 397 403 </section> ··· 456 462 const cls = status === "synced" ? "dot-synced" 457 463 : status === "syncing" ? "dot-syncing" 458 464 : status === "error" ? "dot-error" 465 + : status === "offered" ? "dot-offered" 459 466 : "dot-pending"; 460 467 return '<span class="dot ' + cls + '"></span>' + esc(status); 461 468 } ··· 547 554 if (!repl || !repl.enabled) { el.innerHTML = "Replication disabled"; return; } 548 555 const states = repl.syncStates || []; 549 556 const sources = repl.didSources || {}; 550 - if (states.length === 0) { el.innerHTML = "No tracked DIDs"; return; } 557 + const offered = data.offeredDids || []; 558 + if (states.length === 0 && offered.length === 0 && !data.nodeDid) { el.innerHTML = "No tracked DIDs"; return; } 559 + if (states.length === 0 && offered.length === 0) { el.innerHTML = "Syncing…"; return; } 551 560 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>"; 552 561 for (const s of states) { 553 562 const st = s.status || "pending"; ··· 567 576 + "</tr>"; 568 577 html += '<tr class="detail-row" id="' + rid + '" style="display:none"><td colspan="8"><div class="detail-inner loading">Click to load...</div></td></tr>'; 569 578 } 579 + // Render offered DIDs (awaiting mutual consent) 580 + for (const o of offered) { 581 + var oCellId = "acct-" + o.did.replace(/[^a-zA-Z0-9]/g, "_"); 582 + html += '<tr>' 583 + + '<td id="' + oCellId + '" style="min-width:180px">' + esc(o.did) + '</td>' 584 + + "<td>" + didSourceBadge("offered") + "</td>" 585 + + "<td>" + statusDot("offered") + "</td>" 586 + + "<td>-</td><td>-</td><td>-</td>" 587 + + "<td>" + timeAgo(o.offeredAt) + "</td>" 588 + + '<td><button class="btn-remove btn-remove-offer" data-did="' + esc(o.did) + '">Revoke</button></td>' 589 + + "</tr>"; 590 + } 570 591 html += "</tbody></table>"; 571 592 el.innerHTML = html; 572 593 ··· 590 611 })(s.did); 591 612 } 592 613 614 + // Async profile resolution for offered DIDs 615 + for (const o of offered) { 616 + (function(did) { 617 + var cellId = "acct-" + did.replace(/[^a-zA-Z0-9]/g, "_"); 618 + fetchProfile(did).then(function(p) { 619 + var cell = document.getElementById(cellId); 620 + if (cell && p) cell.innerHTML = renderAccountCell(did, p); 621 + }); 622 + })(o.did); 623 + } 624 + 593 625 el.querySelectorAll("tr.clickable").forEach(function(row) { 594 626 row.addEventListener("click", async function() { 595 627 const did = this.dataset.did; ··· 638 670 }); 639 671 640 672 // Remove button handlers (stop propagation to prevent row click) 641 - el.querySelectorAll(".btn-remove").forEach(function(btn) { 673 + el.querySelectorAll(".btn-remove:not(.btn-remove-offer)").forEach(function(btn) { 642 674 btn.addEventListener("click", async function(e) { 643 675 e.stopPropagation(); 644 676 var did = this.dataset.did; ··· 648 680 var result = await apiPost("org.p2pds.app.removeDid", { did: did }); 649 681 if (result.error) { msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; } 650 682 else { msgEl.className = "add-did-success"; msgEl.textContent = "Removed " + did; refresh(); } 683 + } catch (err) { msgEl.className = "add-did-error"; msgEl.textContent = "Error: " + err.message; } 684 + }); 685 + }); 686 + 687 + // Revoke offer button handlers 688 + el.querySelectorAll(".btn-remove-offer").forEach(function(btn) { 689 + btn.addEventListener("click", async function(e) { 690 + e.stopPropagation(); 691 + var did = this.dataset.did; 692 + if (!confirm("Revoke offer for " + did + "?")) return; 693 + var msgEl = document.getElementById("add-did-msg"); 694 + try { 695 + var result = await apiPost("org.p2pds.app.removeOfferedDid", { did: did }); 696 + if (result.error) { msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; } 697 + else { msgEl.className = "add-did-success"; msgEl.textContent = "Revoked offer for " + did; refresh(); } 651 698 } catch (err) { msgEl.className = "add-did-error"; msgEl.textContent = "Error: " + err.message; } 652 699 }); 653 700 }); ··· 1018 1065 if (!did) { msgEl.className = "add-did-error"; msgEl.textContent = "Search for an account or paste a DID"; return; } 1019 1066 msgEl.className = ""; msgEl.textContent = ""; 1020 1067 try { 1021 - var result = await apiPost("org.p2pds.app.addDid", { did: did }); 1068 + var result = await apiPost("org.p2pds.app.offerDid", { did: did }); 1022 1069 if (result.error) { 1023 1070 msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; 1024 1071 } else { 1025 1072 msgEl.className = "add-did-success"; 1026 1073 msgEl.textContent = result.status === "already_tracked" 1027 1074 ? did + " already tracked (source: " + result.source + ")" 1028 - : "Added " + did; 1075 + : result.status === "already_offered" 1076 + ? did + " already offered" 1077 + : "Offered to replicate " + did; 1029 1078 input.value = ""; 1030 1079 didSearchState.selectedDid = null; 1031 1080 selectedEl.style.display = "none"; ··· 1183 1232 } 1184 1233 1185 1234 const result = await replicationManager.addDid(did); 1235 + return c.json({ did, ...result }); 1236 + } 1237 + 1238 + export async function offerDid( 1239 + c: Context<AuthedAppEnv>, 1240 + replicationManager: ReplicationManager | undefined, 1241 + ): Promise<Response> { 1242 + if (!replicationManager) { 1243 + return c.json( 1244 + { error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 1245 + 400, 1246 + ); 1247 + } 1248 + 1249 + const body = await c.req.json<{ did?: string }>().catch(() => ({}) as { did?: string }); 1250 + const did = body.did; 1251 + if (!did || typeof did !== "string") { 1252 + return c.json( 1253 + { error: "MissingParameter", message: "did is required" }, 1254 + 400, 1255 + ); 1256 + } 1257 + 1258 + if (!isValidDid(did)) { 1259 + return c.json( 1260 + { error: "InvalidDid", message: "Invalid DID format" }, 1261 + 400, 1262 + ); 1263 + } 1264 + 1265 + const result = await replicationManager.offerDid(did); 1266 + return c.json({ did, ...result }); 1267 + } 1268 + 1269 + export async function removeOfferedDid( 1270 + c: Context<AuthedAppEnv>, 1271 + replicationManager: ReplicationManager | undefined, 1272 + ): Promise<Response> { 1273 + if (!replicationManager) { 1274 + return c.json( 1275 + { error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 1276 + 400, 1277 + ); 1278 + } 1279 + 1280 + const body = await c.req.json<{ did?: string }>().catch(() => ({}) as { did?: string }); 1281 + const did = body.did; 1282 + if (!did || typeof did !== "string") { 1283 + return c.json( 1284 + { error: "MissingParameter", message: "did is required" }, 1285 + 400, 1286 + ); 1287 + } 1288 + 1289 + const result = await replicationManager.removeOfferedDid(did); 1186 1290 return c.json({ did, ...result }); 1187 1291 } 1188 1292