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 disconnect confirmation prompt and full data purge

Replace the disconnect form POST with a confirm() dialog warning users
that all data will be deleted. On confirm, the server now purges all
replicated data: sync state, blocks, blobs, challenges, policies, IPFS
blockstore, auth token, and identity — leaving a clean slate instead of
tombstoned entries.

+1001 -120
+21 -5
src/index.ts
··· 12 12 import type { RepoManager } from "./repo-manager.js"; 13 13 import type { Firehose } from "./firehose.js"; 14 14 import type { Config } from "./config.js"; 15 - import type { BlockStore, NetworkService } from "./ipfs.js"; 15 + import type { BlockStore, NetworkService, IpfsService } from "./ipfs.js"; 16 16 import type { BlobStore } from "./blobs.js"; 17 17 import type { ReplicationManager } from "./replication/replication-manager.js"; 18 18 import type { ReplicatedRepoReader } from "./replication/replicated-repo-reader.js"; ··· 49 49 pdsClientRef?: PdsClientRef, 50 50 db?: Database.Database, 51 51 onIdentityEstablished?: () => Promise<void>, 52 + ipfsService?: IpfsService, 52 53 ) { 53 54 // Live getter — config.DID may be set later by OAuth callback 54 55 const getConfigDid = () => config.DID ?? ""; ··· 78 79 // OAuth routes (before auth middleware — these handle browser redirect flow) 79 80 // ============================================ 80 81 if (oauthClientManager && pdsClientRef && db) { 81 - registerOAuthRoutes(app, config, oauthClientManager.client, pdsClientRef, db, networkService, oauthClientManager.sessionStore, replicationManager, onIdentityEstablished); 82 + registerOAuthRoutes(app, config, oauthClientManager.client, pdsClientRef, db, networkService, oauthClientManager.sessionStore, replicationManager, onIdentityEstablished, ipfsService); 82 83 } 83 84 84 85 // ============================================ ··· 161 162 }), 162 163 ); 163 164 164 - // Offer notification endpoint (unauthenticated, rate-limited) 165 + // Offer/revoke notification endpoints (unauthenticated, rate-limited) 165 166 app.use( 166 167 "/xrpc/org.p2pds.replication.notifyOffer", 167 168 rateLimitMiddleware(rateLimiter, { ··· 169 170 rule: { maxRequests: config.RATE_LIMIT_CHALLENGE_PER_MIN, windowMs: w }, 170 171 }), 171 172 ); 173 + app.use( 174 + "/xrpc/org.p2pds.replication.notifyRevoke", 175 + rateLimitMiddleware(rateLimiter, { 176 + pool: "notifyRevoke", 177 + rule: { maxRequests: config.RATE_LIMIT_CHALLENGE_PER_MIN, windowMs: w }, 178 + }), 179 + ); 172 180 173 181 // App endpoints 174 182 const appRL = rateLimitMiddleware(rateLimiter, { ··· 269 277 } 270 278 }); 271 279 272 - // Dashboard UI at root 280 + // App UI at root 273 281 app.get("/", (c) => 274 - app_routes.getDashboard(c, networkService, replicationManager), 282 + app_routes.getApp(c, networkService, replicationManager), 275 283 ); 276 284 277 285 // ============================================ ··· 649 657 app_routes.notifyOffer(c, getConfigDid(), replicationManager), 650 658 ); 651 659 660 + // Incoming revocation notification (unauthenticated) 661 + app.post("/xrpc/org.p2pds.replication.notifyRevoke", (c) => 662 + app_routes.notifyRevoke(c, getConfigDid(), replicationManager), 663 + ); 664 + 652 665 app.post("/xrpc/org.p2pds.replication.revokeOffer", requireAuth, async (c) => { 653 666 if (!replicationManager) { 654 667 return c.json({ error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 400); ··· 694 707 ); 695 708 app.post("/xrpc/org.p2pds.app.removeDid", requireAuth, (c) => 696 709 app_routes.removeDid(c, replicationManager), 710 + ); 711 + app.post("/xrpc/org.p2pds.app.revokeReplication", requireAuth, (c) => 712 + app_routes.revokeReplication(c, replicationManager), 697 713 ); 698 714 app.post("/xrpc/org.p2pds.app.acceptOffer", requireAuth, (c) => 699 715 app_routes.acceptOffer(c, replicationManager),
+10
src/ipfs.ts
··· 507 507 .map((conn) => conn.remoteAddr.toString()); 508 508 } 509 509 510 + /** 511 + * Delete all blocks from the underlying blockstore. 512 + * Used during full disconnect to wipe the node clean. 513 + */ 514 + clearBlockstore(): void { 515 + if (this.blockstore) { 516 + this.blockstore.clear(); 517 + } 518 + } 519 + 510 520 isRunning(): boolean { 511 521 return this.running; 512 522 }
+48 -5
src/oauth/routes.ts
··· 9 9 import type { Hono } from "hono"; 10 10 import type { NodeOAuthClient } from "@atproto/oauth-client-node"; 11 11 import type Database from "better-sqlite3"; 12 + import { rmSync } from "node:fs"; 13 + import { join } from "node:path"; 12 14 import type { Config } from "../config.js"; 13 15 import { PdsClient } from "./pds-client.js"; 14 - import type { NetworkService } from "../ipfs.js"; 16 + import type { NetworkService, IpfsService } from "../ipfs.js"; 15 17 import type { ReplicationManager } from "../replication/replication-manager.js"; 16 18 17 19 /** ··· 33 35 sessionStore?: import("./stores.js").OAuthSessionStore, 34 36 replicationManager?: ReplicationManager, 35 37 onIdentityEstablished?: () => Promise<void>, 38 + ipfsService?: IpfsService, 36 39 ): void { 37 40 /** 38 41 * Start OAuth login flow. ··· 170 173 }); 171 174 172 175 /** 173 - * Session status endpoint for dashboard polling. 176 + * Session status endpoint for app polling. 174 177 * Includes profile info (avatar, displayName, handle) from public API. 175 178 */ 176 179 app.get("/oauth/status", async (c) => { ··· 252 255 } 253 256 } 254 257 258 + // Full disconnect: stop replication and purge all data 259 + if (disconnect && replicationManager) { 260 + replicationManager.stop(); 261 + 262 + try { 263 + replicationManager.getSyncStorage().purgeAllData(); 264 + } catch (err) { 265 + console.warn("[disconnect] Failed to purge sync data:", err instanceof Error ? err.message : String(err)); 266 + } 267 + 268 + try { 269 + replicationManager.getChallengeStorage().purgeAll(); 270 + } catch (err) { 271 + console.warn("[disconnect] Failed to purge challenge data:", err instanceof Error ? err.message : String(err)); 272 + } 273 + 274 + try { 275 + replicationManager.getPolicyEngine()?.clear(); 276 + } catch (err) { 277 + console.warn("[disconnect] Failed to purge policies:", err instanceof Error ? err.message : String(err)); 278 + } 279 + } 280 + 281 + if (disconnect && ipfsService) { 282 + try { 283 + ipfsService.clearBlockstore(); 284 + } catch (err) { 285 + console.warn("[disconnect] Failed to purge IPFS blocks:", err instanceof Error ? err.message : String(err)); 286 + } 287 + } 288 + 289 + if (disconnect) { 290 + try { 291 + rmSync(join(config.DATA_DIR, "blobs"), { recursive: true, force: true }); 292 + } catch (err) { 293 + console.warn("[disconnect] Failed to purge blobs:", err instanceof Error ? err.message : String(err)); 294 + } 295 + } 296 + 255 297 // Clear OAuth session 256 298 if (did && sessionStore) { 257 299 await sessionStore.del(did); ··· 259 301 pdsClientRef.current?.clearAgent(); 260 302 pdsClientRef.current = undefined; 261 303 262 - // Full disconnect: unbind node from DID 304 + // Full disconnect: purge auth token + unbind node from DID 263 305 if (disconnect) { 306 + db.prepare("DELETE FROM node_settings").run(); 264 307 db.prepare("DELETE FROM node_identity").run(); 265 308 config.DID = undefined; 266 309 config.HANDLE = undefined; ··· 320 363 <div class="card"> 321 364 <div class="status">Connected</div> 322 365 <div class="did">${escapeHtml(did)}</div> 323 - <a href="/">Back to Dashboard</a> 366 + <a href="/">Back to App</a> 324 367 </div> 325 368 </body> 326 369 </html>`; ··· 352 395 <div class="card"> 353 396 <div class="status">${escapeHtml(title)}</div> 354 397 <div class="message">${escapeHtml(message)}</div> 355 - <a href="/">Back to Dashboard</a> 398 + <a href="/">Back to App</a> 356 399 </div> 357 400 </body> 358 401 </html>`;
+138 -4
src/policy/engine.ts
··· 2 2 * Declarative, deterministic, transport-agnostic policy engine. 3 3 * 4 4 * Loads a set of policies and evaluates them against DIDs to produce 5 - * effective (merged) replication configurations. Pure evaluation — 6 - * no side effects, no I/O. 5 + * effective (merged) replication configurations. Optionally backed by 6 + * PolicyStorage for SQLite persistence. 7 7 */ 8 8 9 9 import type { ··· 14 14 ReplicationGoals, 15 15 SyncConfig, 16 16 RetentionConfig, 17 + StoredPolicy, 18 + PolicyState, 17 19 } from "./types.js"; 18 20 import { 19 21 DEFAULT_REPLICATION, 20 22 DEFAULT_SYNC, 21 23 DEFAULT_RETENTION, 22 24 } from "./types.js"; 25 + import type { PolicyStorage } from "./storage.js"; 23 26 24 27 export class PolicyEngine { 25 28 private policies: Policy[] = []; 29 + private storage: PolicyStorage | null = null; 26 30 27 - constructor(policySet?: PolicySet) { 31 + constructor(policySet?: PolicySet, storage?: PolicyStorage) { 32 + if (storage) { 33 + this.storage = storage; 34 + } 28 35 if (policySet) { 29 36 this.load(policySet); 30 37 } ··· 47 54 48 55 /** 49 56 * Add a single policy. Throws if a policy with the same ID already exists. 57 + * If storage is present and policy is a StoredPolicy, auto-persists to DB. 50 58 */ 51 59 addPolicy(policy: Policy): void { 52 60 if (this.policies.some((p) => p.id === policy.id)) { 53 61 throw new Error(`Duplicate policy ID: ${policy.id}`); 54 62 } 55 63 this.policies.push(policy); 64 + if (this.storage && isStoredPolicy(policy)) { 65 + this.storage.upsertPolicy(policy); 66 + } 56 67 } 57 68 58 69 /** 59 70 * Remove a policy by ID. Returns true if removed, false if not found. 71 + * Auto-deletes from DB if storage is present. 60 72 */ 61 73 removePolicy(id: string): boolean { 62 74 const before = this.policies.length; 63 75 this.policies = this.policies.filter((p) => p.id !== id); 64 - return this.policies.length < before; 76 + const removed = this.policies.length < before; 77 + if (removed && this.storage) { 78 + this.storage.deletePolicy(id); 79 + } 80 + return removed; 65 81 } 66 82 67 83 /** ··· 182 198 } 183 199 return [...dids]; 184 200 } 201 + 202 + // ============================================ 203 + // Persistence 204 + // ============================================ 205 + 206 + /** 207 + * Load all active policies from the database into memory. 208 + * Merges with any policies already in memory (deduplicating by ID). 209 + */ 210 + loadFromDb(): void { 211 + if (!this.storage) return; 212 + const stored = this.storage.loadActivePolicies(); 213 + const existingIds = new Set(this.policies.map((p) => p.id)); 214 + for (const p of stored) { 215 + if (!existingIds.has(p.id)) { 216 + this.policies.push(p); 217 + existingIds.add(p.id); 218 + } 219 + } 220 + } 221 + 222 + /** 223 + * Persist all in-memory StoredPolicy objects to the database. 224 + */ 225 + persistAll(): void { 226 + if (!this.storage) return; 227 + for (const p of this.policies) { 228 + if (isStoredPolicy(p)) { 229 + this.storage.upsertPolicy(p); 230 + } 231 + } 232 + } 233 + 234 + /** 235 + * Transition a policy's state and update both in-memory and DB. 236 + * Returns false if the policy was not found. 237 + */ 238 + transitionPolicy(id: string, newState: PolicyState): boolean { 239 + const policy = this.policies.find((p) => p.id === id); 240 + if (!policy || !isStoredPolicy(policy)) return false; 241 + 242 + policy.state = newState; 243 + const now = new Date().toISOString(); 244 + switch (newState) { 245 + case "active": 246 + policy.activatedAt = now; 247 + break; 248 + case "suspended": 249 + policy.suspendedAt = now; 250 + break; 251 + case "terminated": 252 + policy.terminatedAt = now; 253 + break; 254 + } 255 + 256 + if (this.storage) { 257 + this.storage.transitionState(id, newState); 258 + } 259 + return true; 260 + } 261 + 262 + /** 263 + * Get a stored policy by ID (with lifecycle metadata). 264 + * Returns null if the policy is not found or is not a StoredPolicy. 265 + */ 266 + getStoredPolicy(id: string): StoredPolicy | null { 267 + const policy = this.policies.find((p) => p.id === id); 268 + if (policy && isStoredPolicy(policy)) return policy; 269 + // Fall back to DB if available 270 + if (this.storage) { 271 + return this.storage.getPolicy(id); 272 + } 273 + return null; 274 + } 275 + 276 + /** 277 + * Get all DIDs from active, enabled, list-target policies. 278 + * This is the single source of truth for "which DIDs should be replicated". 279 + */ 280 + getActiveDids(): string[] { 281 + const dids = new Set<string>(); 282 + for (const p of this.policies) { 283 + if (!p.enabled) continue; 284 + // For StoredPolicy, check that state is active 285 + if (isStoredPolicy(p) && p.state !== "active") continue; 286 + if (p.target.type === "list") { 287 + for (const did of p.target.dids) { 288 + dids.add(did); 289 + } 290 + } 291 + } 292 + return [...dids]; 293 + } 294 + 295 + /** 296 + * Clear all policies from memory and storage. 297 + * Used during full disconnect to wipe the node clean. 298 + */ 299 + clear(): void { 300 + this.policies = []; 301 + if (this.storage) { 302 + this.storage.deleteAll(); 303 + } 304 + } 305 + 306 + /** 307 + * Get the underlying storage, if any. 308 + */ 309 + getStorage(): PolicyStorage | null { 310 + return this.storage; 311 + } 185 312 } 186 313 187 314 // ============================================ ··· 226 353 // Most frequent wins (smallest interval) 227 354 const intervalSec = Math.min(...configs.map((c) => c.intervalSec)); 228 355 return { intervalSec }; 356 + } 357 + 358 + /** 359 + * Type guard: is this Policy actually a StoredPolicy with lifecycle fields? 360 + */ 361 + function isStoredPolicy(policy: Policy): policy is StoredPolicy { 362 + return "state" in policy && "type" in policy && "source" in policy && "createdAt" in policy; 229 363 } 230 364 231 365 function mergeRetention(configs: RetentionConfig[]): RetentionConfig {
+278
src/policy/storage.ts
··· 1 + /** 2 + * SQLite persistence for StoredPolicy objects. 3 + * 4 + * Stores policies in a `policies` table with JSON columns for complex 5 + * nested fields (target, replication, sync, retention). 6 + */ 7 + 8 + import type Database from "better-sqlite3"; 9 + import type { 10 + StoredPolicy, 11 + PolicyState, 12 + PolicyType, 13 + PolicySource, 14 + ConsentStatus, 15 + PolicyTarget, 16 + ReplicationGoals, 17 + SyncConfig, 18 + RetentionConfig, 19 + } from "./types.js"; 20 + 21 + export class PolicyStorage { 22 + constructor(private db: Database.Database) {} 23 + 24 + /** 25 + * Create the policies table if it doesn't exist. 26 + */ 27 + initSchema(): void { 28 + this.db.exec(` 29 + CREATE TABLE IF NOT EXISTS policies ( 30 + id TEXT PRIMARY KEY, 31 + name TEXT NOT NULL, 32 + type TEXT NOT NULL, 33 + state TEXT NOT NULL DEFAULT 'active', 34 + source TEXT NOT NULL DEFAULT 'manual', 35 + consent TEXT NOT NULL DEFAULT 'unconsented', 36 + target TEXT NOT NULL, 37 + replication TEXT NOT NULL, 38 + sync TEXT NOT NULL, 39 + retention TEXT NOT NULL, 40 + priority INTEGER NOT NULL DEFAULT 50, 41 + enabled INTEGER NOT NULL DEFAULT 1, 42 + created_by TEXT NOT NULL DEFAULT 'system', 43 + counterparty_did TEXT, 44 + local_offer_uri TEXT, 45 + remote_offer_uri TEXT, 46 + created_at TEXT NOT NULL DEFAULT (datetime('now')), 47 + activated_at TEXT, 48 + suspended_at TEXT, 49 + terminated_at TEXT, 50 + expires_at TEXT 51 + ) 52 + `); 53 + } 54 + 55 + /** 56 + * Insert or update a policy. 57 + */ 58 + upsertPolicy(policy: StoredPolicy): void { 59 + this.db.prepare(` 60 + INSERT INTO policies ( 61 + id, name, type, state, source, consent, 62 + target, replication, sync, retention, 63 + priority, enabled, created_by, 64 + counterparty_did, local_offer_uri, remote_offer_uri, 65 + created_at, activated_at, suspended_at, terminated_at, expires_at 66 + ) VALUES ( 67 + ?, ?, ?, ?, ?, ?, 68 + ?, ?, ?, ?, 69 + ?, ?, ?, 70 + ?, ?, ?, 71 + ?, ?, ?, ?, ? 72 + ) 73 + ON CONFLICT(id) DO UPDATE SET 74 + name = excluded.name, 75 + type = excluded.type, 76 + state = excluded.state, 77 + source = excluded.source, 78 + consent = excluded.consent, 79 + target = excluded.target, 80 + replication = excluded.replication, 81 + sync = excluded.sync, 82 + retention = excluded.retention, 83 + priority = excluded.priority, 84 + enabled = excluded.enabled, 85 + created_by = excluded.created_by, 86 + counterparty_did = excluded.counterparty_did, 87 + local_offer_uri = excluded.local_offer_uri, 88 + remote_offer_uri = excluded.remote_offer_uri, 89 + activated_at = excluded.activated_at, 90 + suspended_at = excluded.suspended_at, 91 + terminated_at = excluded.terminated_at, 92 + expires_at = excluded.expires_at 93 + `).run( 94 + policy.id, 95 + policy.name, 96 + policy.type, 97 + policy.state, 98 + policy.source, 99 + policy.consent, 100 + JSON.stringify(policy.target), 101 + JSON.stringify(policy.replication), 102 + JSON.stringify(policy.sync), 103 + JSON.stringify(policy.retention), 104 + policy.priority, 105 + policy.enabled ? 1 : 0, 106 + policy.createdBy, 107 + policy.counterpartyDid ?? null, 108 + policy.localOfferUri ?? null, 109 + policy.remoteOfferUri ?? null, 110 + policy.createdAt, 111 + policy.activatedAt, 112 + policy.suspendedAt, 113 + policy.terminatedAt, 114 + policy.expiresAt, 115 + ); 116 + } 117 + 118 + /** 119 + * Load policies, optionally filtered by state(s). 120 + */ 121 + loadPolicies(states?: PolicyState[]): StoredPolicy[] { 122 + let rows: PolicyRow[]; 123 + if (states && states.length > 0) { 124 + const placeholders = states.map(() => "?").join(", "); 125 + rows = this.db 126 + .prepare(`SELECT * FROM policies WHERE state IN (${placeholders})`) 127 + .all(...states) as PolicyRow[]; 128 + } else { 129 + rows = this.db.prepare("SELECT * FROM policies").all() as PolicyRow[]; 130 + } 131 + return rows.map(rowToStoredPolicy); 132 + } 133 + 134 + /** 135 + * Load only active policies (state = 'active'). 136 + */ 137 + loadActivePolicies(): StoredPolicy[] { 138 + return this.loadPolicies(["active"]); 139 + } 140 + 141 + /** 142 + * Get a single policy by ID. 143 + */ 144 + getPolicy(id: string): StoredPolicy | null { 145 + const row = this.db 146 + .prepare("SELECT * FROM policies WHERE id = ?") 147 + .get(id) as PolicyRow | undefined; 148 + return row ? rowToStoredPolicy(row) : null; 149 + } 150 + 151 + /** 152 + * Delete a policy by ID. 153 + */ 154 + deletePolicy(id: string): boolean { 155 + const result = this.db 156 + .prepare("DELETE FROM policies WHERE id = ?") 157 + .run(id); 158 + return result.changes > 0; 159 + } 160 + 161 + /** 162 + * Transition a policy to a new state, updating the corresponding timestamp. 163 + */ 164 + transitionState(id: string, newState: PolicyState): boolean { 165 + const now = new Date().toISOString(); 166 + let timestampCol: string | null = null; 167 + switch (newState) { 168 + case "active": 169 + timestampCol = "activated_at"; 170 + break; 171 + case "suspended": 172 + timestampCol = "suspended_at"; 173 + break; 174 + case "terminated": 175 + timestampCol = "terminated_at"; 176 + break; 177 + } 178 + 179 + let sql: string; 180 + if (timestampCol) { 181 + sql = `UPDATE policies SET state = ?, ${timestampCol} = ? WHERE id = ?`; 182 + const result = this.db.prepare(sql).run(newState, now, id); 183 + return result.changes > 0; 184 + } else { 185 + sql = "UPDATE policies SET state = ? WHERE id = ?"; 186 + const result = this.db.prepare(sql).run(newState, id); 187 + return result.changes > 0; 188 + } 189 + } 190 + 191 + /** 192 + * Check if a policy with the given ID exists. 193 + */ 194 + hasPolicy(id: string): boolean { 195 + const row = this.db 196 + .prepare("SELECT 1 FROM policies WHERE id = ?") 197 + .get(id); 198 + return row !== undefined; 199 + } 200 + 201 + /** 202 + * Delete all policies. 203 + * Used during full disconnect to wipe the node clean. 204 + */ 205 + deleteAll(): void { 206 + this.db.prepare("DELETE FROM policies").run(); 207 + } 208 + 209 + /** 210 + * Count policies, optionally filtered by state. 211 + */ 212 + count(state?: PolicyState): number { 213 + if (state) { 214 + const row = this.db 215 + .prepare("SELECT COUNT(*) as cnt FROM policies WHERE state = ?") 216 + .get(state) as { cnt: number }; 217 + return row.cnt; 218 + } 219 + const row = this.db 220 + .prepare("SELECT COUNT(*) as cnt FROM policies") 221 + .get() as { cnt: number }; 222 + return row.cnt; 223 + } 224 + } 225 + 226 + // ============================================ 227 + // Internal row mapping 228 + // ============================================ 229 + 230 + interface PolicyRow { 231 + id: string; 232 + name: string; 233 + type: string; 234 + state: string; 235 + source: string; 236 + consent: string; 237 + target: string; 238 + replication: string; 239 + sync: string; 240 + retention: string; 241 + priority: number; 242 + enabled: number; 243 + created_by: string; 244 + counterparty_did: string | null; 245 + local_offer_uri: string | null; 246 + remote_offer_uri: string | null; 247 + created_at: string; 248 + activated_at: string | null; 249 + suspended_at: string | null; 250 + terminated_at: string | null; 251 + expires_at: string | null; 252 + } 253 + 254 + function rowToStoredPolicy(row: PolicyRow): StoredPolicy { 255 + return { 256 + id: row.id, 257 + name: row.name, 258 + type: row.type as PolicyType, 259 + state: row.state as PolicyState, 260 + source: row.source as PolicySource, 261 + consent: row.consent as ConsentStatus, 262 + target: JSON.parse(row.target) as PolicyTarget, 263 + replication: JSON.parse(row.replication) as ReplicationGoals, 264 + sync: JSON.parse(row.sync) as SyncConfig, 265 + retention: JSON.parse(row.retention) as RetentionConfig, 266 + priority: row.priority, 267 + enabled: row.enabled === 1, 268 + createdBy: row.created_by, 269 + counterpartyDid: row.counterparty_did ?? undefined, 270 + localOfferUri: row.local_offer_uri ?? undefined, 271 + remoteOfferUri: row.remote_offer_uri ?? undefined, 272 + createdAt: row.created_at, 273 + activatedAt: row.activated_at, 274 + suspendedAt: row.suspended_at, 275 + terminatedAt: row.terminated_at, 276 + expiresAt: row.expires_at, 277 + }; 278 + }
+9
src/replication/challenge-response/challenge-storage.ts
··· 162 162 } 163 163 164 164 /** 165 + * Delete all challenge history and peer reliability data. 166 + * Used during full disconnect to wipe the node clean. 167 + */ 168 + purgeAll(): void { 169 + this.db.prepare("DELETE FROM challenge_history").run(); 170 + this.db.prepare("DELETE FROM peer_reliability").run(); 171 + } 172 + 173 + /** 165 174 * Get all peer reliability scores, sorted by reliability descending. 166 175 */ 167 176 getAllReliability(): PeerReliabilityRow[] {
+231 -61
src/replication/replication-manager.ts
··· 172 172 } 173 173 174 174 /** 175 - * Get the merged list of DIDs to replicate. 176 - * Combines config.REPLICATE_DIDS, admin-added DIDs, and policy engine explicit DIDs (deduplicated). 177 - * When a policy engine is present, filters out DIDs where shouldReplicate is false, 178 - * but config DIDs and admin DIDs always replicate. 175 + * Get the list of DIDs to replicate. 176 + * 177 + * When a PolicyEngine is present, uses getActiveDids() as the single source of truth. 178 + * Falls back to legacy three-source merge when no PolicyEngine is configured. 179 179 */ 180 180 getReplicateDids(): string[] { 181 - const allDids = new Set(this.config.REPLICATE_DIDS); 181 + if (this.policyEngine) { 182 + return this.policyEngine.getActiveDids(); 183 + } 182 184 183 - // Source 2: Admin-added DIDs from SQLite 185 + // Legacy fallback: merge config + admin DIDs (no policy engine) 186 + const allDids = new Set(this.config.REPLICATE_DIDS); 184 187 for (const did of this.syncStorage.getAdminDids()) { 185 188 allDids.add(did); 186 189 } 187 - 188 - if (this.policyEngine) { 189 - // Source 3: Policy engine explicit DIDs 190 - for (const did of this.policyEngine.getExplicitDids()) { 191 - allDids.add(did); 192 - } 193 - 194 - // Filter: only include DIDs where policy says to replicate, 195 - // but always include config DIDs and admin DIDs (they replicate regardless) 196 - const result: string[] = []; 197 - for (const did of allDids) { 198 - if ( 199 - this.config.REPLICATE_DIDS.includes(did) || 200 - this.syncStorage.isAdminDid(did) || 201 - this.policyEngine.shouldReplicate(did) 202 - ) { 203 - result.push(did); 204 - } 205 - } 206 - return result; 207 - } 208 - 209 190 return [...allDids]; 210 191 } 211 192 212 193 /** 213 - * Determine the source of a tracked DID. 194 + * Determine the source of a tracked DID by looking up its policy. 214 195 */ 215 - getDidSource(did: string): "config" | "admin" | "policy" | null { 196 + getDidSource(did: string): "config" | "user" | "policy" | null { 197 + if (this.policyEngine) { 198 + const configPolicy = this.policyEngine.getStoredPolicy(`config:${did}`); 199 + if (configPolicy) return "config"; 200 + const archivePolicy = this.policyEngine.getStoredPolicy(`archive:${did}`); 201 + if (archivePolicy) return "user"; 202 + const p2pPolicy = this.policyEngine.getStoredPolicy(`p2p:${did}`); 203 + if (p2pPolicy) return "policy"; 204 + // Check if any other policy covers this DID 205 + if (this.policyEngine.shouldReplicate(did)) return "policy"; 206 + } 207 + // Legacy fallback 216 208 if (this.config.REPLICATE_DIDS.includes(did)) return "config"; 217 - if (this.syncStorage.isAdminDid(did)) return "admin"; 218 - if (this.policyEngine?.getExplicitDids().includes(did)) return "policy"; 209 + if (this.syncStorage.isAdminDid(did)) return "user"; 219 210 return null; 220 211 } 221 212 222 213 /** 223 214 * Add a DID via the admin interface. 224 - * Persists to SQLite, creates manifest + sync state, subscribes gossipsub, updates firehose. 215 + * Creates an archive policy (+ dual-write to admin_tracked_dids for rollback safety), 216 + * creates manifest + sync state, subscribes gossipsub, updates firehose. 225 217 * Returns the status and source if already tracked. 226 218 */ 227 219 async addDid(did: string): Promise<{ status: "added" | "already_tracked"; source?: string }> { 228 - if (this.config.REPLICATE_DIDS.includes(did)) { 229 - return { status: "already_tracked", source: "config" }; 220 + const existingSource = this.getDidSource(did); 221 + if (existingSource) { 222 + return { status: "already_tracked", source: existingSource }; 230 223 } 231 - if (this.syncStorage.isAdminDid(did)) { 232 - return { status: "already_tracked", source: "admin" }; 224 + 225 + // Check if already tracked via getReplicateDids (covers pattern/all policies) 226 + if (this.getReplicateDids().includes(did)) { 227 + return { status: "already_tracked", source: "policy" }; 233 228 } 234 - if (this.policyEngine?.getExplicitDids().includes(did)) { 235 - return { status: "already_tracked", source: "policy" }; 229 + 230 + // Create archive policy in PolicyEngine (auto-persists to DB) 231 + if (this.policyEngine) { 232 + const policyId = `archive:${did}`; 233 + if (!this.policyEngine.getPolicies().some((p) => p.id === policyId)) { 234 + const { archive } = await import("../policy/presets.js"); 235 + this.policyEngine.addPolicy(archive(did)); 236 + } 236 237 } 237 238 239 + // Dual-write to admin_tracked_dids for rollback safety 238 240 this.syncStorage.addAdminDid(did); 241 + 239 242 await this.syncManifestForDid(did); 240 243 this.networkService.subscribeCommitTopics([did]); 241 244 this.networkService.subscribeIdentityTopics([did]); ··· 256 259 */ 257 260 async offerDid(did: string): Promise<{ status: "offered" | "already_tracked" | "already_offered"; source?: string }> { 258 261 // 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" }; 262 + const existingSource = this.getDidSource(did); 263 + if (existingSource) { 264 + return { status: "already_tracked", source: existingSource }; 267 265 } 268 266 269 267 // Already offered? ··· 271 269 return { status: "already_offered" }; 272 270 } 273 271 272 + // Require active session to publish offer record 273 + if (!this.offerManager) { 274 + throw new Error("No active session — log in first to publish offers"); 275 + } 276 + 274 277 // Resolve PDS endpoint 275 278 const pdsEndpoint = await this.repoFetcher.resolvePds(did); 276 279 277 - // Publish offer record if OfferManager is available 278 - if (this.offerManager) { 279 - await this.offerManager.publishOffer(did); 280 - } 280 + // Publish offer record to user's PDS 281 + await this.offerManager.publishOffer(did); 281 282 282 283 // Store in offered_dids (does NOT create replication_state or trigger sync) 283 284 this.syncStorage.addOfferedDid(did, pdsEndpoint ?? null); 284 285 285 286 // Push notification: tell the target node about this offer (fire-and-forget) 286 - this.pushOfferNotification(did).catch((err) => { 287 - console.warn( 288 - `[replication] Failed to push offer notification to ${did}:`, 289 - err instanceof Error ? err.message : String(err), 290 - ); 291 - }); 287 + // Small delay to ensure PDS has committed the record before the peer verifies it 288 + setTimeout(() => { 289 + this.pushOfferNotification(did).catch((err) => { 290 + console.warn( 291 + `[replication] Failed to push offer notification to ${did}:`, 292 + err instanceof Error ? err.message : String(err), 293 + ); 294 + }); 295 + }, 2000); 292 296 293 297 return { status: "offered" }; 294 298 } 295 299 296 300 /** 297 - * Remove an offered DID: revoke the offer and remove from offered_dids. 301 + * Remove an offered DID: revoke the offer, notify the peer, and remove from offered_dids. 298 302 */ 299 303 async removeOfferedDid(did: string): Promise<{ status: "removed" | "not_found" }> { 300 304 if (!this.syncStorage.isOfferedDid(did)) { ··· 307 311 } 308 312 309 313 this.syncStorage.removeOfferedDid(did); 314 + 315 + // Notify peer that the offer was revoked (fire-and-forget) 316 + this.pushRevokeNotification(did).catch((err) => { 317 + console.warn(`[replication] Failed to push revoke notification to ${did}:`, err instanceof Error ? err.message : String(err)); 318 + }); 319 + 310 320 return { status: "removed" }; 311 321 } 312 322 313 323 /** 324 + * Revoke consent and remove a DID that was promoted to active replication via mutual offer. 325 + * Revokes the offer record, purges all data, and notifies the peer. 326 + */ 327 + async revokeReplication(did: string): Promise<{ status: "revoked" | "not_found" | "error"; error?: string }> { 328 + // Must be an actively tracked DID 329 + const source = this.getDidSource(did); 330 + const hasState = this.syncStorage.getAllStates().some((s) => s.did === did); 331 + if (!source && !hasState) { 332 + return { status: "not_found" }; 333 + } 334 + 335 + // Revoke offer record on PDS 336 + if (this.offerManager) { 337 + try { 338 + await this.offerManager.revokeOffer(did); 339 + } catch { 340 + // Non-fatal: offer record may not exist 341 + } 342 + } 343 + 344 + // Transition P2P policy to terminated 345 + if (this.policyEngine) { 346 + const policyId = `p2p:${did}`; 347 + this.policyEngine.transitionPolicy(policyId, "terminated"); 348 + } 349 + 350 + // Remove from offered_dids if present 351 + if (this.syncStorage.isOfferedDid(did)) { 352 + this.syncStorage.removeOfferedDid(did); 353 + } 354 + 355 + // Notify peer BEFORE purging (purge clears peer endpoints needed for notification) 356 + try { 357 + await this.pushRevokeNotification(did); 358 + } catch (err) { 359 + console.warn(`[replication] Failed to push revoke notification to ${did}:`, err instanceof Error ? err.message : String(err)); 360 + } 361 + 362 + // Remove from replication with full data purge 363 + const result = await this.removeDid(did, true); 364 + if (result.status === "error") { 365 + return { status: "error", error: result.error }; 366 + } 367 + 368 + return { status: "revoked" }; 369 + } 370 + 371 + /** 372 + * Handle a revocation from a remote peer: stop replicating their DID and purge data. 373 + */ 374 + async handleRemoteRevocation(revokerDid: string): Promise<void> { 375 + // Remove from incoming offers if present 376 + const incomingOffers = this.syncStorage.getIncomingOffers(); 377 + for (const offer of incomingOffers) { 378 + if (offer.offererDid === revokerDid) { 379 + this.syncStorage.removeIncomingOffer(revokerDid, offer.subjectDid); 380 + } 381 + } 382 + 383 + // Remove from offered_dids if present 384 + if (this.syncStorage.isOfferedDid(revokerDid)) { 385 + if (this.offerManager) { 386 + try { await this.offerManager.revokeOffer(revokerDid); } catch { /* non-fatal */ } 387 + } 388 + this.syncStorage.removeOfferedDid(revokerDid); 389 + } 390 + 391 + // Transition P2P policy to terminated 392 + if (this.policyEngine) { 393 + const policyId = `p2p:${revokerDid}`; 394 + if (this.policyEngine.transitionPolicy(policyId, "terminated")) { 395 + console.log(`[replication] Terminated P2P policy ${policyId}`); 396 + } 397 + } 398 + 399 + // Remove from active replication with data purge 400 + const isTracked = this.getDidSource(revokerDid) !== null 401 + || this.syncStorage.getAllStates().some((s) => s.did === revokerDid); 402 + if (isTracked) { 403 + console.log(`[replication] Remote revocation from ${revokerDid} — removing DID and purging data`); 404 + await this.removeDid(revokerDid, true); 405 + } 406 + } 407 + 408 + /** 314 409 * Push an offer notification to the target node's p2pds endpoint. 315 410 * Resolves the target's org.p2pds.peer record to find their endpoint URL, 316 411 * then POSTs to their notifyOffer XRPC method. ··· 349 444 } 350 445 351 446 /** 447 + * Push a revocation notification to the target node's p2pds endpoint. 448 + */ 449 + private async pushRevokeNotification(targetDid: string): Promise<void> { 450 + const peerInfo = await this.peerDiscovery.discoverPeer(targetDid); 451 + if (!peerInfo?.endpoint) { 452 + console.log(`[replication] No p2pds endpoint found for ${targetDid}, skipping revoke notification`); 453 + return; 454 + } 455 + 456 + const url = `${peerInfo.endpoint}/xrpc/org.p2pds.replication.notifyRevoke`; 457 + const body = { 458 + revokerDid: this.config.DID, 459 + subjectDid: targetDid, 460 + }; 461 + 462 + const res = await fetch(url, { 463 + method: "POST", 464 + headers: { "Content-Type": "application/json" }, 465 + body: JSON.stringify(body), 466 + }); 467 + 468 + if (!res.ok) { 469 + const text = await res.text().catch(() => ""); 470 + console.warn(`[replication] Revoke notification to ${targetDid} failed (${res.status}): ${text}`); 471 + } else { 472 + console.log(`[replication] Revoke notification sent to ${targetDid} at ${peerInfo.endpoint}`); 473 + } 474 + } 475 + 476 + /** 352 477 * Accept an incoming offer: create a reciprocal offer and remove from incoming_offers. 353 478 */ 354 479 async acceptOffer(offererDid: string): Promise<{ status: "accepted" | "not_found" | "error"; error?: string }> { ··· 372 497 // Remove from incoming_offers 373 498 this.syncStorage.removeIncomingOffer(offererDid, offer.subjectDid); 374 499 500 + // Run offer discovery immediately to detect mutual agreement and start replication 501 + this.triggerOfferDiscovery(); 502 + 375 503 return { status: "accepted" }; 376 504 } 377 505 ··· 390 518 } 391 519 392 520 /** 521 + * Trigger offer discovery asynchronously (fire-and-forget). 522 + * Used after accepting an offer or receiving a push notification 523 + * to immediately detect mutual agreements. 524 + */ 525 + triggerOfferDiscovery(): void { 526 + this.runOfferDiscovery().catch((err) => { 527 + console.warn("[replication] Triggered offer discovery failed:", err instanceof Error ? err.message : String(err)); 528 + }); 529 + } 530 + 531 + /** 393 532 * Get all incoming offers (from other nodes wanting to replicate our data). 394 533 */ 395 534 getIncomingOffers(): Array<{ ··· 429 568 purged: boolean; 430 569 error?: string; 431 570 }> { 432 - if (this.config.REPLICATE_DIDS.includes(did)) { 571 + // Check if it's a config-origin DID (not removable via UI) 572 + const configPolicyId = `config:${did}`; 573 + if (this.policyEngine?.getStoredPolicy(configPolicyId) || this.config.REPLICATE_DIDS.includes(did)) { 433 574 return { status: "error", purged: false, error: "Cannot remove config-origin DID. Remove from REPLICATE_DIDS env var." }; 434 575 } 435 - if (this.policyEngine?.getExplicitDids().includes(did) && !this.syncStorage.isAdminDid(did)) { 436 - return { status: "error", purged: false, error: "Cannot remove policy-managed DID. Update the policy instead." }; 576 + 577 + // Transition archive/p2p policies to purged state 578 + if (this.policyEngine) { 579 + const archivePolicyId = `archive:${did}`; 580 + this.policyEngine.transitionPolicy(archivePolicyId, "purged"); 581 + const p2pPolicyId = `p2p:${did}`; 582 + this.policyEngine.transitionPolicy(p2pPolicyId, "purged"); 437 583 } 438 584 585 + // Dual-write: also remove from legacy table 439 586 this.syncStorage.removeAdminDid(did); 440 587 this.networkService.unsubscribeCommitTopics([did]); 441 588 this.networkService.unsubscribeIdentityTopics([did]); ··· 573 720 `[replication] Failed to promote offered DID ${offered.did}:`, 574 721 err instanceof Error ? err.message : String(err), 575 722 ); 723 + } 724 + } 725 + } 726 + 727 + // Detect broken agreements: P2P-origin DIDs whose remote offer was revoked 728 + const activePeerDids = new Set(agreements.map((a) => a.counterpartyDid)); 729 + for (const state of states) { 730 + // Only check P2P-origin DIDs (those that were promoted from offers) 731 + const policyId = `p2p:${state.did}`; 732 + const hasP2pPolicy = this.policyEngine?.getPolicies().some((p) => p.id === policyId); 733 + if (hasP2pPolicy && !activePeerDids.has(state.did)) { 734 + console.log(`[replication] Agreement broken for ${state.did} (remote offer revoked) — removing and purging data`); 735 + try { 736 + await this.removeDid(state.did, true); 737 + } catch (err) { 738 + console.error(`[replication] Failed to remove broken-agreement DID ${state.did}:`, err instanceof Error ? err.message : String(err)); 576 739 } 577 740 } 578 741 } ··· 1759 1922 */ 1760 1923 getSyncStorage(): SyncStorage { 1761 1924 return this.syncStorage; 1925 + } 1926 + 1927 + /** 1928 + * Get the underlying ChallengeStorage instance. 1929 + */ 1930 + getChallengeStorage(): ChallengeStorage { 1931 + return this.challengeStorage; 1762 1932 } 1763 1933 1764 1934 /**
+20
src/replication/sync-storage.ts
··· 1139 1139 return row?.root_cid ?? null; 1140 1140 } 1141 1141 1142 + /** 1143 + * Delete all data from all replication tables in a single transaction. 1144 + * Used during full disconnect to wipe the node clean. 1145 + */ 1146 + purgeAllData(): void { 1147 + const purge = this.db.transaction(() => { 1148 + this.db.prepare("DELETE FROM replication_blocks").run(); 1149 + this.db.prepare("DELETE FROM replication_blobs").run(); 1150 + this.db.prepare("DELETE FROM replication_record_paths").run(); 1151 + this.db.prepare("DELETE FROM replication_state").run(); 1152 + this.db.prepare("DELETE FROM peer_endpoints").run(); 1153 + this.db.prepare("DELETE FROM admin_tracked_dids").run(); 1154 + this.db.prepare("DELETE FROM offered_dids").run(); 1155 + this.db.prepare("DELETE FROM incoming_offers").run(); 1156 + this.db.prepare("DELETE FROM sync_history").run(); 1157 + this.db.prepare("DELETE FROM firehose_cursor").run(); 1158 + }); 1159 + purge(); 1160 + } 1161 + 1142 1162 private rowToSyncHistory(row: Record<string, unknown>): SyncHistoryRow { 1143 1163 return { 1144 1164 id: row.id as number,
+8
src/sqlite-blockstore.ts
··· 75 75 } 76 76 } 77 77 78 + /** 79 + * Delete all blocks from the blockstore. 80 + * Used during full disconnect to wipe the node clean. 81 + */ 82 + clear(): void { 83 + this.db.prepare("DELETE FROM ipfs_blocks").run(); 84 + } 85 + 78 86 async * getAll(): AsyncGenerator<{ cid: CID; block: Uint8Array }> { 79 87 const rows = this.db 80 88 .prepare("SELECT cid, bytes FROM ipfs_blocks")
+46 -8
src/start.ts
··· 26 26 import { ReplicationManager } from "./replication/replication-manager.js"; 27 27 import { ReplicatedRepoReader } from "./replication/replicated-repo-reader.js"; 28 28 import { PolicyEngine } from "./policy/engine.js"; 29 + import { PolicyStorage } from "./policy/storage.js"; 30 + import { migrateToNewPolicies } from "./policy/migrate.js"; 29 31 import { HttpChallengeTransport } from "./replication/challenge-response/http-transport.js"; 30 32 import { Libp2pChallengeTransport } from "./replication/challenge-response/libp2p-transport.js"; 31 33 import { FailoverChallengeTransport } from "./replication/challenge-response/failover-transport.js"; ··· 80 82 ) 81 83 `); 82 84 85 + // Persist AUTH_TOKEN so the UI token survives restarts 86 + db.exec(` 87 + CREATE TABLE IF NOT EXISTS node_settings ( 88 + key TEXT PRIMARY KEY, 89 + value TEXT NOT NULL 90 + ) 91 + `); 92 + if (!process.env.AUTH_TOKEN) { 93 + const storedToken = db.prepare("SELECT value FROM node_settings WHERE key = 'auth_token'").get() as 94 + | { value: string } 95 + | undefined; 96 + if (storedToken) { 97 + config.AUTH_TOKEN = storedToken.value; 98 + } else { 99 + db.prepare("INSERT OR REPLACE INTO node_settings (key, value) VALUES ('auth_token', ?)").run(config.AUTH_TOKEN); 100 + } 101 + } 102 + 83 103 // Load stored identity from previous OAuth login (if not overridden by env) 84 104 const storedIdentity = db.prepare("SELECT did, handle FROM node_identity LIMIT 1").get() as 85 105 | { did: string; handle: string | null } ··· 117 137 didCache: new InMemoryDidCache(), 118 138 }); 119 139 120 - // Load policy engine if configured 121 - let policyEngine: PolicyEngine | undefined; 122 - if (config.POLICY_FILE) { 123 - const policySet = loadPolicies(config); 124 - if (policySet) { 125 - policyEngine = new PolicyEngine(policySet); 126 - console.log(pc.dim(` Policies: loaded ${policySet.policies.length} from ${config.POLICY_FILE}`)); 127 - } 140 + // Initialize policy storage and engine (always created — needed for offer management) 141 + const policyStorage = new PolicyStorage(db); 142 + policyStorage.initSchema(); 143 + 144 + const policySet = config.POLICY_FILE ? loadPolicies(config) : null; 145 + const policyEngine = new PolicyEngine( 146 + policySet ?? { version: 1, policies: [] }, 147 + policyStorage, 148 + ); 149 + 150 + // Seed from POLICY_FILE only if DB is empty (first run with a policy file) 151 + if (policySet && policyStorage.count() === 0) { 152 + policyEngine.persistAll(); 153 + console.log(pc.dim(` Policies: seeded ${policySet.policies.length} from ${config.POLICY_FILE}`)); 154 + } else if (policySet) { 155 + console.log(pc.dim(` Policies: loaded ${policySet.policies.length} from ${config.POLICY_FILE}`)); 156 + } 157 + 158 + // Load persisted policies from DB (merges with any file-loaded policies) 159 + policyEngine.loadFromDb(); 160 + 161 + // Run one-time migration: config DIDs + admin_tracked_dids → policy engine 162 + const migrated = migrateToNewPolicies(db, policyStorage, policyEngine, config.REPLICATE_DIDS); 163 + if (migrated > 0) { 164 + console.log(pc.dim(` Policies: migrated ${migrated} legacy DIDs`)); 128 165 } 129 166 130 167 // Initialize OAuth client (if enabled) ··· 265 302 pdsClientRef, 266 303 db, 267 304 startIpfsReplication, 305 + ipfsService, 268 306 ); 269 307 270 308 // Create HTTP server using @hono/node-server's request listener
+192 -37
src/xrpc/app.ts
··· 72 72 version: VERSION, 73 73 nodeDid, 74 74 did: c.env.DID ?? nodeDid, 75 + handle: c.env.HANDLE ?? null, 75 76 network, 76 77 replication, 77 78 firehose, ··· 148 149 }); 149 150 } 150 151 151 - export function getDashboard( 152 + export function getApp( 152 153 c: Context<AppEnv>, 153 154 networkService: NetworkService | undefined, 154 155 replicationManager: ReplicationManager | undefined, ··· 190 191 } 191 192 header { 192 193 display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; 193 - margin-bottom: 0.6rem; padding-bottom: 0.4rem; border-bottom: 2px solid var(--fg); 194 + margin-bottom: 0.6rem; 194 195 } 195 196 header h1 { font-size: 1.1rem; letter-spacing: 0.1em; } 196 197 .badge { font-size: 0.65rem; background: var(--badge-bg); color: var(--badge-fg); padding: 1px 6px; border-radius: 3px; } ··· 203 204 box-shadow: 0 1px 2px var(--shadow); 204 205 } 205 206 .card h2 { font-size: 0.85rem; margin-bottom: 0.4rem; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; } 207 + .card details { margin-bottom: 0; } 208 + .card details > summary { font-size: 0.85rem; font-weight: 700; cursor: pointer; user-select: none; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; margin-bottom: 0.4rem; list-style: none; display: flex; align-items: center; gap: 0.3rem; } 209 + .card details > summary::-webkit-details-marker { display: none; } 210 + .card details > summary::before { content: "\\25B6"; font-size: 0.55rem; transition: transform 0.15s; display: inline-block; } 211 + .card details[open] > summary::before { transform: rotate(90deg); } 212 + .card details > summary .count-badge { font-weight: 400; font-size: 0.7rem; color: var(--muted); margin-left: auto; } 213 + .scroll-container { max-height: 240px; overflow-y: auto; } 206 214 .kv { display: grid; grid-template-columns: 100px 1fr; gap: 0.15rem 0.6rem; font-size: 0.8rem; } 207 215 .kv dt { color: var(--muted); } 208 216 .kv dd { word-break: break-all; } ··· 264 272 .btn-remove:hover { background: #ef4444; color: #fff; } 265 273 .did-source { font-size: 0.65rem; padding: 1px 5px; border-radius: 3px; } 266 274 .did-source-config { background: #e0e7ff; color: #3730a3; } 267 - .did-source-admin { background: #d1fae5; color: #065f46; } 275 + .did-source-user { background: #d1fae5; color: #065f46; } 268 276 .did-source-policy { background: #fef3c7; color: #92400e; } 269 277 .did-source-unknown { background: var(--metric-bg); color: var(--muted); } 270 278 .did-source-offered { background: #ede9fe; color: #5b21b6; } ··· 369 377 .source-firehose { background: #422006; color: #fcd34d; } 370 378 .source-peer_fallback { background: #4a1035; color: #f9a8d4; } 371 379 .did-source-config { background: #312e81; color: #a5b4fc; } 372 - .did-source-admin { background: #064e3b; color: #6ee7b7; } 380 + .did-source-user { background: #064e3b; color: #6ee7b7; } 373 381 .did-source-policy { background: #422006; color: #fcd34d; } 374 382 .did-source-offered { background: #2e1065; color: #c4b5fd; } 375 383 .trigger-firehose { background: #422006; color: #fcd34d; } ··· 405 413 </div> 406 414 407 415 <section class="card" id="section-incoming-offers" style="display:none"> 408 - <h2>Incoming Offers</h2> 409 - <div id="incoming-offers-content"></div> 416 + <details open> 417 + <summary>Incoming Offers <span class="count-badge" id="incoming-offers-count"></span></summary> 418 + <div class="scroll-container" id="incoming-offers-content"></div> 419 + </details> 410 420 </section> 411 421 412 422 <section class="card" id="section-metrics"> ··· 415 425 </section> 416 426 417 427 <section class="card" id="section-replication"> 418 - <h2>Replicating Accounts</h2> 428 + <h2>Replications</h2> 419 429 <div class="add-did-form"> 420 430 <div class="account-search-wrap" id="did-search-wrap" style="flex:1"> 421 431 <span class="account-search-icon">&#128269;</span> ··· 426 436 <button id="add-did-btn">Add</button> 427 437 </div> 428 438 <div id="add-did-msg" class="add-did-error"></div> 429 - <div id="replication-content" class="loading">Loading...</div> 439 + <div class="scroll-container" id="replication-content" class="loading">Loading...</div> 430 440 </section> 431 441 432 442 <section class="card" id="section-sync-history"> 433 - <h2>Sync History</h2> 434 - <div id="sync-history-content" class="loading">Loading...</div> 443 + <details> 444 + <summary>Sync History <span class="count-badge" id="sync-history-count"></span></summary> 445 + <div class="scroll-container" id="sync-history-content" class="loading">Loading...</div> 446 + </details> 435 447 </section> 436 448 437 449 <section class="card" id="section-network"> ··· 525 537 function renderOverview(data) { 526 538 const el = document.getElementById("overview-content"); 527 539 const net = data.network || {}; 528 - const fh = data.firehose || {}; 529 - var netHtml = net.peerId 530 - ? "<dt>Peer ID</dt><dd>" + esc(net.peerId) + "</dd>" 531 - + "<dt>Connections</dt><dd>" + esc(net.connections ?? 0) + "</dd>" 532 - : ''; 533 540 el.innerHTML = '<dl class="kv">' 534 541 + "<dt>DID</dt><dd>" + esc(data.did) + "</dd>" 535 - + netHtml 542 + + "<dt>Handle</dt><dd>" + esc(data.handle) + "</dd>" 543 + + "<dt>Peer ID</dt><dd>" + esc(net.peerId) + "</dd>" 544 + + "<dt>Connections</dt><dd>" + esc(net.peerId ? (net.connections ?? 0) : null) + "</dd>" 536 545 + "</dl>"; 537 546 document.getElementById("version-badge").textContent = "v" + data.version; 538 547 } ··· 555 564 556 565 var profileCache = {}; 557 566 567 + function displayName(did) { 568 + var p = profileCache[did]; 569 + if (p && p.handle) return "@" + p.handle; 570 + return did; 571 + } 572 + 558 573 function fetchProfile(did) { 559 574 if (profileCache[did]) return Promise.resolve(profileCache[did]); 560 575 return fetch("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=" + encodeURIComponent(did)) ··· 599 614 + '<td id="' + metricsId + '-blk">-</td>' 600 615 + '<td id="' + metricsId + '-bytes">-</td>' 601 616 + "<td>" + timeAgo(s.lastSyncAt) + "</td>" 602 - + "<td>" + (src === "admin" && s.did !== data.did ? '<button class="btn-remove" data-did="' + esc(s.did) + '">Remove</button>' : "") + "</td>" 617 + + "<td>" + (src === "user" && s.did !== data.did ? '<button class="btn-remove btn-revoke-replication" data-did="' + esc(s.did) + '">Revoke</button>' : "") + "</td>" 603 618 + "</tr>"; 604 619 html += '<tr class="detail-row" id="' + rid + '" style="display:none"><td colspan="8"><div class="detail-inner loading">Click to load...</div></td></tr>'; 605 620 } ··· 696 711 }); 697 712 }); 698 713 699 - // Remove button handlers (stop propagation to prevent row click) 700 - el.querySelectorAll(".btn-remove:not(.btn-remove-offer)").forEach(function(btn) { 714 + // Revoke replication button handlers (stop propagation to prevent row click) 715 + el.querySelectorAll(".btn-revoke-replication").forEach(function(btn) { 701 716 btn.addEventListener("click", async function(e) { 702 717 e.stopPropagation(); 703 718 var did = this.dataset.did; 704 - if (!confirm("Remove " + did + "?\\n\\nData will be kept (paused). To purge data, use the API with purgeData: true.")) return; 719 + if (!confirm("Revoke replication for " + displayName(did) + "?\\n\\nThis will revoke your offer, purge all stored data, and notify the peer.")) return; 705 720 var msgEl = document.getElementById("add-did-msg"); 706 721 try { 707 - var result = await apiPost("org.p2pds.app.removeDid", { did: did }); 722 + var result = await apiPost("org.p2pds.app.revokeReplication", { did: did }); 708 723 if (result.error) { msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; } 709 - else { msgEl.className = "add-did-success"; msgEl.textContent = "Removed " + did; refresh(); } 724 + else { msgEl.className = "add-did-success"; msgEl.textContent = "Revoked replication for " + displayName(did); refresh(); } 710 725 } catch (err) { msgEl.className = "add-did-error"; msgEl.textContent = "Error: " + err.message; } 711 726 }); 712 727 }); ··· 716 731 btn.addEventListener("click", async function(e) { 717 732 e.stopPropagation(); 718 733 var did = this.dataset.did; 719 - if (!confirm("Revoke offer for " + did + "?")) return; 734 + if (!confirm("Revoke offer for " + displayName(did) + "?")) return; 720 735 var msgEl = document.getElementById("add-did-msg"); 721 736 try { 722 737 var result = await apiPost("org.p2pds.app.removeOfferedDid", { did: did }); 723 738 if (result.error) { msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; } 724 - else { msgEl.className = "add-did-success"; msgEl.textContent = "Revoked offer for " + did; refresh(); } 739 + else { msgEl.className = "add-did-success"; msgEl.textContent = "Revoked offer for " + displayName(did); refresh(); } 725 740 } catch (err) { msgEl.className = "add-did-error"; msgEl.textContent = "Error: " + err.message; } 726 741 }); 727 742 }); ··· 729 744 730 745 function renderSyncHistory(history) { 731 746 const el = document.getElementById("sync-history-content"); 747 + var countBadge = document.getElementById("sync-history-count"); 732 748 var items = (history && history.history) || []; 749 + if (countBadge) countBadge.textContent = items.length > 0 ? "(" + items.length + ")" : ""; 733 750 if (items.length === 0) { el.innerHTML = "No sync events recorded"; return; } 734 751 let html = '<table><thead><tr><th>Time</th><th>DID</th><th>Source</th><th>Trigger</th><th>Status</th><th>Blocks+</th><th>Duration</th><th>Bytes</th></tr></thead><tbody>'; 735 752 for (var i = 0; i < items.length; i++) { ··· 821 838 + '</div>' 822 839 + '<div style="display:flex;align-items:center;gap:0.6rem;font-size:0.75rem">' 823 840 + '<span class="dot dot-synced"></span>Authenticated' 824 - + '<form method="POST" action="/oauth/logout" style="margin:0"><button type="submit" class="btn-remove">Disconnect</button></form>' 841 + + '<button class="btn-remove" onclick="if(confirm(\'Are you sure you want to disconnect?\\n\\nThis will delete all replicated data, revoke all offers, and remove your identity from this node. This cannot be undone.\')){fetch(\'/oauth/logout?disconnect=true\',{method:\'POST\'}).then(function(){location.href=\'/\';});}">Disconnect</button>' 825 842 + '</div>'; 826 843 } else { 827 844 accountSearchState = { selectedHandle: null, selectedActor: null, activeIndex: -1 }; ··· 1009 1026 var section = document.getElementById("section-incoming-offers"); 1010 1027 var el = document.getElementById("incoming-offers-content"); 1011 1028 var offers = data.incomingOffers || []; 1029 + var countBadge = document.getElementById("incoming-offers-count"); 1012 1030 if (offers.length === 0) { section.style.display = "none"; return; } 1013 1031 section.style.display = ""; 1032 + if (countBadge) countBadge.textContent = "(" + offers.length + ")"; 1014 1033 var html = ""; 1015 1034 for (var i = 0; i < offers.length; i++) { 1016 1035 var o = offers[i]; ··· 1018 1037 html += '<div class="incoming-offer-row" id="' + rowId + '">' 1019 1038 + '<div class="incoming-offer-info" id="' + rowId + '-info">' 1020 1039 + '<span>' + esc(o.offererDid) + '</span>' 1021 - + ' <span style="color:var(--muted)">wants to replicate your data</span>' 1040 + + ' <span style="color:var(--muted)">offered to replicate your data</span>' 1022 1041 + '</div>' 1023 1042 + '<div class="incoming-offer-actions">' 1024 - + '<button class="btn-accept" data-did="' + esc(o.offererDid) + '">Accept</button>' 1043 + + '<button class="btn-accept" data-did="' + esc(o.offererDid) + '">Accept &amp; Replicate</button>' 1025 1044 + '<button class="btn-reject" data-did="' + esc(o.offererDid) + '">Reject</button>' 1026 1045 + '</div></div>'; 1027 1046 } ··· 1040 1059 infoEl.innerHTML = av 1041 1060 + '<strong>' + esc(p.displayName || p.handle) + '</strong>' 1042 1061 + ' <span style="color:var(--muted)">@' + esc(p.handle) + '</span>' 1043 - + ' <span style="color:var(--muted)">wants to replicate your data</span>'; 1062 + + ' <span style="color:var(--muted)">offered to replicate your data</span>'; 1044 1063 } 1045 1064 }); 1046 1065 })(offers[j]); ··· 1050 1069 el.querySelectorAll(".btn-accept").forEach(function(btn) { 1051 1070 btn.addEventListener("click", async function() { 1052 1071 var did = this.dataset.did; 1072 + if (!confirm("Accept and replicate " + displayName(did) + "?\\n\\nThis will replicate their data on your node and share your data with them.")) return; 1053 1073 try { 1054 1074 var result = await apiPost("org.p2pds.app.acceptOffer", { offererDid: did }); 1055 1075 if (result.error) { alert("Error: " + (result.message || result.error)); } ··· 1062 1082 el.querySelectorAll(".btn-reject").forEach(function(btn) { 1063 1083 btn.addEventListener("click", async function() { 1064 1084 var did = this.dataset.did; 1065 - if (!confirm("Reject offer from " + did + "?")) return; 1085 + if (!confirm("Reject offer from " + displayName(did) + "?")) return; 1066 1086 try { 1067 1087 var result = await apiPost("org.p2pds.app.rejectOffer", { offererDid: did }); 1068 1088 if (result.error) { alert("Error: " + (result.message || result.error)); } ··· 1136 1156 setActivity(anySyncing); 1137 1157 } catch (e) { 1138 1158 setActivity(false); 1139 - console.error("Dashboard refresh error:", e); 1159 + console.error("App refresh error:", e); 1140 1160 } 1141 1161 } 1142 1162 ··· 1165 1185 msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; 1166 1186 } else { 1167 1187 msgEl.className = "add-did-success"; 1188 + var dn = displayName(did); 1168 1189 msgEl.textContent = result.status === "already_tracked" 1169 - ? did + " already tracked (source: " + result.source + ")" 1190 + ? dn + " already tracked (source: " + result.source + ")" 1170 1191 : result.status === "already_offered" 1171 - ? did + " already offered" 1172 - : "Offered to replicate " + did; 1192 + ? dn + " already offered" 1193 + : "Offered to replicate " + dn; 1173 1194 input.value = ""; 1174 1195 didSearchState.selectedDid = null; 1175 1196 selectedEl.style.display = "none"; ··· 1274 1295 </body> 1275 1296 </html>`; 1276 1297 1298 + // Prevent browser from caching the page (auth token is embedded) 1299 + c.header("Cache-Control", "no-store"); 1277 1300 return c.html(html); 1278 1301 } 1279 1302 ··· 1357 1380 ); 1358 1381 } 1359 1382 1360 - const result = await replicationManager.offerDid(did); 1361 - return c.json({ did, ...result }); 1383 + try { 1384 + const result = await replicationManager.offerDid(did); 1385 + return c.json({ did, ...result }); 1386 + } catch (err) { 1387 + const message = err instanceof Error ? err.message : String(err); 1388 + return c.json({ error: "OfferFailed", message }, 400); 1389 + } 1362 1390 } 1363 1391 1364 1392 export async function removeOfferedDid( ··· 1527 1555 // Discover peer info for the endpoint URL 1528 1556 const peerInfo = await peerDiscovery.discoverPeer(offererDid); 1529 1557 1530 - // Store in incoming_offers 1531 1558 const syncStorage = replicationManager.getSyncStorage(); 1532 - const params = (body.params as Record<string, unknown>) ?? {}; 1559 + 1560 + // If we already have an outgoing offer for this DID, mutual agreement exists — 1561 + // skip storing as incoming offer and trigger discovery to promote immediately 1562 + if (syncStorage.isOfferedDid(offererDid)) { 1563 + console.log(`[replication] Mutual offer detected with ${offererDid} (we already offered), running offer discovery`); 1564 + replicationManager.triggerOfferDiscovery(); 1565 + return c.json({ status: "mutual_detected" }); 1566 + } 1567 + 1568 + // Store in incoming_offers for user to accept/reject 1533 1569 syncStorage.addIncomingOffer({ 1534 1570 offererDid, 1535 1571 subjectDid, ··· 1632 1668 1633 1669 return c.json({ status: "rejected", offererDid }); 1634 1670 } 1671 + 1672 + /** 1673 + * Handle a revocation notification from a remote peer (unauthenticated). 1674 + * The remote peer has revoked their offer — stop replicating and purge data. 1675 + */ 1676 + export async function notifyRevoke( 1677 + c: Context<AppEnv>, 1678 + nodeDid: string, 1679 + replicationManager: ReplicationManager | undefined, 1680 + ): Promise<Response> { 1681 + if (!replicationManager) { 1682 + return c.json( 1683 + { error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 1684 + 400, 1685 + ); 1686 + } 1687 + 1688 + if (!nodeDid) { 1689 + return c.json( 1690 + { error: "NoIdentity", message: "This node has no identity yet" }, 1691 + 400, 1692 + ); 1693 + } 1694 + 1695 + const body = await c.req.json<{ 1696 + revokerDid?: string; 1697 + subjectDid?: string; 1698 + }>().catch(() => ({}) as Record<string, unknown>); 1699 + 1700 + const revokerDid = body.revokerDid as string | undefined; 1701 + const subjectDid = body.subjectDid as string | undefined; 1702 + 1703 + if (!revokerDid || typeof revokerDid !== "string" || !isValidDid(revokerDid)) { 1704 + return c.json( 1705 + { error: "MissingParameter", message: "revokerDid is required" }, 1706 + 400, 1707 + ); 1708 + } 1709 + 1710 + if (!subjectDid || typeof subjectDid !== "string" || !isValidDid(subjectDid)) { 1711 + return c.json( 1712 + { error: "MissingParameter", message: "subjectDid is required" }, 1713 + 400, 1714 + ); 1715 + } 1716 + 1717 + if (subjectDid !== nodeDid) { 1718 + return c.json( 1719 + { error: "NotForUs", message: "This revocation is not for this node" }, 1720 + 400, 1721 + ); 1722 + } 1723 + 1724 + // Verify the offer is actually gone from their repo (prevents spoofed revocations) 1725 + const peerDiscovery = replicationManager.getPeerDiscovery(); 1726 + const repoFetcher = replicationManager.getRepoFetcher(); 1727 + const revokerPdsEndpoint = await repoFetcher.resolvePds(revokerDid); 1728 + 1729 + if (revokerPdsEndpoint) { 1730 + try { 1731 + const offers = await peerDiscovery.discoverOffers(revokerDid, revokerPdsEndpoint); 1732 + const stillExists = offers.some((o) => o.subject === subjectDid); 1733 + if (stillExists) { 1734 + return c.json( 1735 + { error: "OfferStillExists", message: "Offer still exists in revoker's repo — revocation rejected" }, 1736 + 400, 1737 + ); 1738 + } 1739 + } catch { 1740 + // If we can't verify, accept the revocation (fail-open for cleanup) 1741 + } 1742 + } 1743 + 1744 + console.log(`[replication] Received revocation from ${revokerDid} — removing data`); 1745 + await replicationManager.handleRemoteRevocation(revokerDid); 1746 + 1747 + return c.json({ status: "revoked" }); 1748 + } 1749 + 1750 + /** 1751 + * Revoke consent for an actively replicated DID (authenticated). 1752 + * Revokes the offer, purges all data, and notifies the peer. 1753 + */ 1754 + export async function revokeReplication( 1755 + c: Context<AuthedAppEnv>, 1756 + replicationManager: ReplicationManager | undefined, 1757 + ): Promise<Response> { 1758 + if (!replicationManager) { 1759 + return c.json( 1760 + { error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 1761 + 400, 1762 + ); 1763 + } 1764 + 1765 + const body = await c.req.json<{ did?: string }>().catch(() => ({}) as { did?: string }); 1766 + const did = body.did; 1767 + if (!did || typeof did !== "string" || !isValidDid(did)) { 1768 + return c.json( 1769 + { error: "MissingParameter", message: "did is required and must be a valid DID" }, 1770 + 400, 1771 + ); 1772 + } 1773 + 1774 + const result = await replicationManager.revokeReplication(did); 1775 + if (result.status === "not_found") { 1776 + return c.json( 1777 + { error: "NotFound", message: "DID is not being actively replicated" }, 1778 + 404, 1779 + ); 1780 + } 1781 + if (result.status === "error") { 1782 + return c.json( 1783 + { error: "RevokeFailed", message: result.error }, 1784 + 500, 1785 + ); 1786 + } 1787 + 1788 + return c.json({ status: "revoked", did }); 1789 + }