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 local PLC operation log archiving for tracked DIDs

Phase 1 of distributed PLC mirror: fetches, cryptographically validates,
and stores PLC audit logs for every tracked did:plc DID. Validates full
operation chain (genesis DID derivation, secp256k1/P-256 ECDSA signatures,
prev-CID integrity). Refreshes on identity events, firehose account
changes, and a 6-hour periodic timer. Adds API endpoints and UI indicator.

+1281 -2
+244
src/identity/plc-mirror.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + validateOperationChain, 4 + computeOperationCid, 5 + parseDidKey, 6 + verifyPlcSignature, 7 + normalizeCreateOp, 8 + type IndexedOperation, 9 + type PlcOperation, 10 + } from "./plc-mirror.js"; 11 + // @ts-expect-error — transitive dep, no types exported for subpath 12 + import { sha256 } from "multiformats/hashes/sha2"; 13 + // @ts-expect-error — transitive dep, no types exported for NodeNext 14 + import { encode as dagCborEncode } from "@ipld/dag-cbor"; 15 + 16 + // Real test vector from plc.directory (did:plc:ewvi7nxzyoun6zhxrhs64oiz = @atproto.com) 17 + const ATPROTO_DID = "did:plc:ewvi7nxzyoun6zhxrhs64oiz"; 18 + const ATPROTO_GENESIS: IndexedOperation = { 19 + did: ATPROTO_DID, 20 + operation: { 21 + sig: "lza4at_jCtGo_TYgL5PC1ZNP7lhF4DV8H50LWHhvdHcB143x1wEwqZ43xvV36Pws6OOnJLJrkibEUFDFqkhIhg", 22 + prev: null, 23 + type: "plc_operation", 24 + services: { 25 + atproto_pds: { 26 + type: "AtprotoPersonalDataServer", 27 + endpoint: "https://bsky.social", 28 + }, 29 + }, 30 + alsoKnownAs: ["at://atprotocol.bsky.social"], 31 + rotationKeys: [ 32 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 33 + "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK", 34 + ], 35 + verificationMethods: { 36 + atproto: 37 + "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF", 38 + }, 39 + }, 40 + cid: "bafyreibfvkh3n6odvdpwj54j4xxdsgnn4zo5utbyf7z7nfbyikhtygzjcq", 41 + nullified: false, 42 + createdAt: "2023-04-26T06:19:25.508Z", 43 + }; 44 + 45 + const ATPROTO_OP2: IndexedOperation = { 46 + did: ATPROTO_DID, 47 + operation: { 48 + sig: "lRDLz1RRcauzDos9LZ0Q5bi3YzJXbpgrUpZ51e__tdg89xYgHiWWtnKcrAJanBMkgW0uloD40TYWMVXyZWi4mw", 49 + prev: "bafyreibfvkh3n6odvdpwj54j4xxdsgnn4zo5utbyf7z7nfbyikhtygzjcq", 50 + type: "plc_operation", 51 + services: { 52 + atproto_pds: { 53 + type: "AtprotoPersonalDataServer", 54 + endpoint: "https://bsky.social", 55 + }, 56 + }, 57 + alsoKnownAs: ["at://atproto.com"], 58 + rotationKeys: [ 59 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 60 + "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK", 61 + ], 62 + verificationMethods: { 63 + atproto: 64 + "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF", 65 + }, 66 + }, 67 + cid: "bafyreihljrd4zlm6egppxzwq52fgzdrh7hv2bkjnbgyjteqasmpjgqmryi", 68 + nullified: false, 69 + createdAt: "2023-04-26T17:16:46.827Z", 70 + }; 71 + 72 + describe("plc-mirror", () => { 73 + describe("parseDidKey", () => { 74 + it("should parse secp256k1 did:key", () => { 75 + const result = parseDidKey( 76 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 77 + ); 78 + expect(result.curve).toBe("secp256k1"); 79 + expect(result.publicKey).toBeInstanceOf(Uint8Array); 80 + // secp256k1 compressed pubkey = 33 bytes 81 + expect(result.publicKey.length).toBe(33); 82 + }); 83 + 84 + it("should throw on invalid did:key prefix", () => { 85 + expect(() => parseDidKey("did:web:example.com")).toThrow( 86 + "Invalid did:key format", 87 + ); 88 + }); 89 + }); 90 + 91 + describe("normalizeCreateOp", () => { 92 + it("should convert legacy create to plc_operation format", () => { 93 + const legacy: PlcOperation = { 94 + type: "create", 95 + sig: "abc123", 96 + prev: null, 97 + signingKey: "did:key:zSigningKey", 98 + recoveryKey: "did:key:zRecoveryKey", 99 + handle: "test.bsky.social", 100 + service: "https://bsky.social", 101 + }; 102 + 103 + const normalized = normalizeCreateOp(legacy); 104 + expect(normalized.type).toBe("plc_operation"); 105 + expect(normalized.sig).toBe("abc123"); 106 + expect(normalized.prev).toBeNull(); 107 + expect(normalized.rotationKeys).toEqual([ 108 + "did:key:zRecoveryKey", 109 + "did:key:zSigningKey", 110 + ]); 111 + expect(normalized.verificationMethods).toEqual({ 112 + atproto: "did:key:zSigningKey", 113 + }); 114 + expect(normalized.alsoKnownAs).toEqual([ 115 + "at://test.bsky.social", 116 + ]); 117 + expect(normalized.services).toEqual({ 118 + atproto_pds: { 119 + type: "AtprotoPersonalDataServer", 120 + endpoint: "https://bsky.social", 121 + }, 122 + }); 123 + }); 124 + 125 + it("should pass through plc_operation unchanged", () => { 126 + const op: PlcOperation = { 127 + type: "plc_operation", 128 + sig: "abc", 129 + prev: null, 130 + rotationKeys: ["did:key:z1"], 131 + }; 132 + expect(normalizeCreateOp(op)).toBe(op); 133 + }); 134 + }); 135 + 136 + describe("computeOperationCid", () => { 137 + it("should compute CID for genesis op matching known CID", async () => { 138 + const cid = await computeOperationCid(ATPROTO_GENESIS.operation); 139 + expect(cid).toBe(ATPROTO_GENESIS.cid); 140 + }); 141 + 142 + it("should compute CID for second op matching known CID", async () => { 143 + const cid = await computeOperationCid(ATPROTO_OP2.operation); 144 + expect(cid).toBe(ATPROTO_OP2.cid); 145 + }); 146 + }); 147 + 148 + describe("validateOperationChain", () => { 149 + it("should validate genesis DID derivation for @atproto.com", async () => { 150 + const result = await validateOperationChain( 151 + [ATPROTO_GENESIS], 152 + ATPROTO_DID, 153 + ); 154 + expect(result.valid).toBe(true); 155 + }); 156 + 157 + it("should validate multi-op chain", async () => { 158 + const result = await validateOperationChain( 159 + [ATPROTO_GENESIS, ATPROTO_OP2], 160 + ATPROTO_DID, 161 + ); 162 + expect(result.valid).toBe(true); 163 + }); 164 + 165 + it("should reject empty operations", async () => { 166 + const result = await validateOperationChain([], ATPROTO_DID); 167 + expect(result.valid).toBe(false); 168 + expect(result.error).toContain("No active operations"); 169 + }); 170 + 171 + it("should skip nullified operations", async () => { 172 + const nullifiedOps: IndexedOperation[] = [ 173 + { ...ATPROTO_GENESIS, nullified: true }, 174 + ]; 175 + const result = await validateOperationChain( 176 + nullifiedOps, 177 + ATPROTO_DID, 178 + ); 179 + expect(result.valid).toBe(false); 180 + expect(result.error).toContain("No active operations"); 181 + }); 182 + 183 + it("should reject wrong DID", async () => { 184 + const result = await validateOperationChain( 185 + [ATPROTO_GENESIS], 186 + "did:plc:wrongsuffix", 187 + ); 188 + expect(result.valid).toBe(false); 189 + expect(result.error).toContain("Genesis DID derivation mismatch"); 190 + }); 191 + 192 + it("should reject broken prev chain", async () => { 193 + const brokenOp: IndexedOperation = { 194 + ...ATPROTO_OP2, 195 + operation: { 196 + ...ATPROTO_OP2.operation, 197 + prev: "bafyreiwrongcid", 198 + }, 199 + }; 200 + const result = await validateOperationChain( 201 + [ATPROTO_GENESIS, brokenOp], 202 + ATPROTO_DID, 203 + ); 204 + expect(result.valid).toBe(false); 205 + expect(result.error).toContain("Prev CID mismatch"); 206 + }); 207 + }); 208 + 209 + describe("verifyPlcSignature", () => { 210 + it("should verify second op's signature against genesis rotation keys", async () => { 211 + const op = ATPROTO_OP2.operation; 212 + const { sig, ...unsignedOp } = op; 213 + const opBytes = dagCborEncode(unsignedOp); 214 + const opHash = await sha256.digest(opBytes); 215 + 216 + // Try against first rotation key from genesis 217 + const { curve, publicKey } = parseDidKey( 218 + ATPROTO_GENESIS.operation.rotationKeys![0]!, 219 + ); 220 + const result = verifyPlcSignature( 221 + opHash.digest, 222 + sig, 223 + publicKey, 224 + curve, 225 + ); 226 + // It should verify against one of the rotation keys 227 + // (we don't know which one signed it, but the chain validates) 228 + if (!result) { 229 + const key2 = parseDidKey( 230 + ATPROTO_GENESIS.operation.rotationKeys![1]!, 231 + ); 232 + const result2 = verifyPlcSignature( 233 + opHash.digest, 234 + sig, 235 + key2.publicKey, 236 + key2.curve, 237 + ); 238 + expect(result2).toBe(true); 239 + } else { 240 + expect(result).toBe(true); 241 + } 242 + }); 243 + }); 244 + });
+469
src/identity/plc-mirror.ts
··· 1 + /** 2 + * PLC operation log mirroring and validation. 3 + * 4 + * Fetches, validates, and stores PLC audit logs for tracked DIDs. 5 + * Provides cryptographic verification of the full operation chain: 6 + * genesis DID derivation, signature verification (secp256k1 + P-256), 7 + * and prev-CID chain integrity. 8 + */ 9 + 10 + // @ts-expect-error — transitive dep, no types exported for NodeNext 11 + import { encode as dagCborEncode } from "@ipld/dag-cbor"; 12 + // @ts-expect-error — transitive dep, no types exported for subpath 13 + import { sha256 } from "multiformats/hashes/sha2"; 14 + // @ts-expect-error — transitive dep, no types exported for subpath 15 + import { CID } from "multiformats/cid"; 16 + // @ts-expect-error — transitive dep, no types exported for subpath 17 + import { base32 } from "multiformats/bases/base32"; 18 + import { secp256k1 } from "@noble/curves/secp256k1"; 19 + import { p256 } from "@noble/curves/p256"; 20 + import type Database from "better-sqlite3"; 21 + 22 + // dag-cbor codec code 23 + const DAG_CBOR_CODE = 0x71; 24 + 25 + // ============================================ 26 + // Types 27 + // ============================================ 28 + 29 + /** A PLC operation (plc_operation, plc_tombstone, or legacy create). */ 30 + export interface PlcOperation { 31 + type: "plc_operation" | "plc_tombstone" | "create"; 32 + sig: string; 33 + prev: string | null; 34 + rotationKeys?: string[]; 35 + verificationMethods?: Record<string, string>; 36 + alsoKnownAs?: string[]; 37 + services?: Record<string, { type: string; endpoint: string }>; 38 + // Legacy create fields 39 + signingKey?: string; 40 + recoveryKey?: string; 41 + handle?: string; 42 + service?: string; 43 + } 44 + 45 + /** An indexed operation from the PLC audit log. */ 46 + export interface IndexedOperation { 47 + did: string; 48 + operation: PlcOperation; 49 + cid: string; 50 + nullified: boolean; 51 + createdAt: string; 52 + } 53 + 54 + /** Status of the PLC log for a DID. */ 55 + export interface PlcLogStatus { 56 + did: string; 57 + opCount: number; 58 + lastFetchedAt: string; 59 + lastOpCreatedAt: string | null; 60 + validated: boolean; 61 + isTombstoned: boolean; 62 + } 63 + 64 + /** Aggregate stats for the PLC mirror. */ 65 + export interface PlcMirrorStats { 66 + mirroredDids: number; 67 + totalOps: number; 68 + lastRefresh: string | null; 69 + } 70 + 71 + // ============================================ 72 + // Public functions 73 + // ============================================ 74 + 75 + /** 76 + * Fetch the full audit log for a DID from plc.directory. 77 + */ 78 + export async function fetchAuditLog(did: string): Promise<IndexedOperation[]> { 79 + const url = `https://plc.directory/${encodeURIComponent(did)}/log/audit`; 80 + const res = await fetch(url); 81 + if (!res.ok) { 82 + throw new Error(`PLC directory returned ${res.status} for ${did}`); 83 + } 84 + return (await res.json()) as IndexedOperation[]; 85 + } 86 + 87 + /** 88 + * Validate the full operation chain for a DID. 89 + * 90 + * 1. Filter nullified ops 91 + * 2. Genesis: DAG-CBOR encode signed op → SHA-256 → base32 → truncate 24 → verify = DID suffix 92 + * 3. Each subsequent op: verify signature against rotation keys from previous state 93 + * 4. Verify prev CID chain integrity 94 + */ 95 + export async function validateOperationChain( 96 + operations: IndexedOperation[], 97 + did: string, 98 + ): Promise<{ valid: boolean; error?: string }> { 99 + const activeOps = operations.filter((op) => !op.nullified); 100 + if (activeOps.length === 0) { 101 + return { valid: false, error: "No active operations" }; 102 + } 103 + 104 + // 1. Verify genesis DID derivation 105 + const genesis = activeOps[0]!; 106 + const genesisOp = genesis.operation.type === "create" 107 + ? normalizeCreateOp(genesis.operation) 108 + : genesis.operation; 109 + 110 + const genesisBytes = dagCborEncode(genesisOp); 111 + const genesisHash = await sha256.digest(genesisBytes); 112 + const genesisB32 = base32.encode(genesisHash.digest).slice("b".length); // strip multibase prefix 113 + const expectedSuffix = genesisB32.slice(0, 24); 114 + const actualSuffix = did.split(":")[2]; 115 + 116 + if (expectedSuffix !== actualSuffix) { 117 + return { 118 + valid: false, 119 + error: `Genesis DID derivation mismatch: expected ${expectedSuffix}, got ${actualSuffix}`, 120 + }; 121 + } 122 + 123 + // 2. Verify signature chain for subsequent ops 124 + let prevRotationKeys = getRotationKeysFromOp(genesisOp); 125 + 126 + for (let i = 1; i < activeOps.length; i++) { 127 + const indexedOp = activeOps[i]!; 128 + const op = indexedOp.operation; 129 + 130 + // Verify prev CID chain 131 + const prevOp = activeOps[i - 1]!; 132 + if (op.prev !== prevOp.cid) { 133 + return { 134 + valid: false, 135 + error: `Prev CID mismatch at op ${i}: expected ${prevOp.cid}, got ${op.prev}`, 136 + }; 137 + } 138 + 139 + // Strip sig, encode, hash 140 + const { sig, ...unsignedOp } = op; 141 + const opBytes = dagCborEncode(unsignedOp); 142 + const opHash = await sha256.digest(opBytes); 143 + 144 + // Try each rotation key 145 + let verified = false; 146 + for (const keyDid of prevRotationKeys) { 147 + try { 148 + const { curve, publicKey } = parseDidKey(keyDid); 149 + if (verifyPlcSignature(opHash.digest, sig, publicKey, curve)) { 150 + verified = true; 151 + break; 152 + } 153 + } catch { 154 + // Skip invalid keys 155 + } 156 + } 157 + 158 + if (!verified) { 159 + return { 160 + valid: false, 161 + error: `Signature verification failed at op ${i} (cid: ${indexedOp.cid})`, 162 + }; 163 + } 164 + 165 + // Update rotation keys for next iteration 166 + if (op.type !== "plc_tombstone") { 167 + prevRotationKeys = getRotationKeysFromOp(op); 168 + } 169 + } 170 + 171 + return { valid: true }; 172 + } 173 + 174 + /** 175 + * Compute the CID of a PLC operation. 176 + * DAG-CBOR encode the signed operation → SHA-256 → CIDv1 dag-cbor. 177 + */ 178 + export async function computeOperationCid(operation: PlcOperation): Promise<string> { 179 + const bytes = dagCborEncode(operation); 180 + const hash = await sha256.digest(bytes); 181 + const cid = CID.createV1(DAG_CBOR_CODE, hash); 182 + return cid.toString(base32); 183 + } 184 + 185 + /** 186 + * Orchestrator: fetch audit log, validate, store in SQLite. 187 + */ 188 + export async function fetchAndValidateLog( 189 + db: Database.Database, 190 + did: string, 191 + ): Promise<PlcLogStatus> { 192 + const operations = await fetchAuditLog(did); 193 + const validation = await validateOperationChain(operations, did); 194 + 195 + const opCount = operations.length; 196 + const lastOpCreatedAt = operations.length > 0 197 + ? operations[operations.length - 1]!.createdAt 198 + : null; 199 + const isTombstoned = operations.some( 200 + (op) => !op.nullified && op.operation.type === "plc_tombstone", 201 + ); 202 + const now = new Date().toISOString(); 203 + 204 + storePlcLog(db, { 205 + did, 206 + operationsJson: JSON.stringify(operations), 207 + opCount, 208 + lastFetchedAt: now, 209 + lastOpCreatedAt, 210 + validated: validation.valid, 211 + isTombstoned, 212 + }); 213 + 214 + return { 215 + did, 216 + opCount, 217 + lastFetchedAt: now, 218 + lastOpCreatedAt, 219 + validated: validation.valid, 220 + isTombstoned, 221 + }; 222 + } 223 + 224 + /** 225 + * Get the stored PLC log for a DID. 226 + */ 227 + export function getStoredLog( 228 + db: Database.Database, 229 + did: string, 230 + ): { operations: IndexedOperation[]; status: PlcLogStatus } | null { 231 + const row = db 232 + .prepare("SELECT * FROM plc_mirror WHERE did = ?") 233 + .get(did) as Record<string, unknown> | undefined; 234 + if (!row) return null; 235 + 236 + return { 237 + operations: JSON.parse(row.operations_json as string) as IndexedOperation[], 238 + status: { 239 + did: row.did as string, 240 + opCount: row.op_count as number, 241 + lastFetchedAt: row.last_fetched_at as string, 242 + lastOpCreatedAt: (row.last_op_created_at as string) ?? null, 243 + validated: (row.validated as number) === 1, 244 + isTombstoned: (row.is_tombstoned as number) === 1, 245 + }, 246 + }; 247 + } 248 + 249 + /** 250 + * Get aggregate PLC mirror stats. 251 + */ 252 + export function getPlcMirrorStats(db: Database.Database): PlcMirrorStats { 253 + const countRow = db 254 + .prepare("SELECT COUNT(*) as count, COALESCE(SUM(op_count), 0) as total_ops FROM plc_mirror") 255 + .get() as { count: number; total_ops: number }; 256 + 257 + const lastRow = db 258 + .prepare("SELECT MAX(last_fetched_at) as last_refresh FROM plc_mirror") 259 + .get() as { last_refresh: string | null }; 260 + 261 + return { 262 + mirroredDids: countRow.count, 263 + totalOps: countRow.total_ops, 264 + lastRefresh: lastRow.last_refresh, 265 + }; 266 + } 267 + 268 + /** 269 + * Get all DIDs that have PLC mirror entries. 270 + */ 271 + export function getAllPlcMirrorDids(db: Database.Database): string[] { 272 + const rows = db 273 + .prepare("SELECT did FROM plc_mirror") 274 + .all() as Array<{ did: string }>; 275 + return rows.map((r) => r.did); 276 + } 277 + 278 + // ============================================ 279 + // SQLite storage helpers 280 + // ============================================ 281 + 282 + function storePlcLog( 283 + db: Database.Database, 284 + data: { 285 + did: string; 286 + operationsJson: string; 287 + opCount: number; 288 + lastFetchedAt: string; 289 + lastOpCreatedAt: string | null; 290 + validated: boolean; 291 + isTombstoned: boolean; 292 + }, 293 + ): void { 294 + db.prepare( 295 + `INSERT INTO plc_mirror (did, operations_json, op_count, last_fetched_at, last_op_created_at, validated, is_tombstoned) 296 + VALUES (?, ?, ?, ?, ?, ?, ?) 297 + ON CONFLICT(did) DO UPDATE SET 298 + operations_json = excluded.operations_json, 299 + op_count = excluded.op_count, 300 + last_fetched_at = excluded.last_fetched_at, 301 + last_op_created_at = excluded.last_op_created_at, 302 + validated = excluded.validated, 303 + is_tombstoned = excluded.is_tombstoned`, 304 + ).run( 305 + data.did, 306 + data.operationsJson, 307 + data.opCount, 308 + data.lastFetchedAt, 309 + data.lastOpCreatedAt, 310 + data.validated ? 1 : 0, 311 + data.isTombstoned ? 1 : 0, 312 + ); 313 + } 314 + 315 + // ============================================ 316 + // Crypto helpers 317 + // ============================================ 318 + 319 + /** 320 + * Parse a did:key string into curve type and raw public key bytes. 321 + * did:key:z... → decode base58btc → strip multicodec prefix → return curve + pubkey 322 + */ 323 + export function parseDidKey(didKey: string): { 324 + curve: "secp256k1" | "p256"; 325 + publicKey: Uint8Array; 326 + } { 327 + if (!didKey.startsWith("did:key:z")) { 328 + throw new Error(`Invalid did:key format: ${didKey}`); 329 + } 330 + 331 + // Import base58btc dynamically-compatible way 332 + const multibaseStr = didKey.slice("did:key:".length); 333 + const bytes = base58btcDecode(multibaseStr); 334 + 335 + // Check multicodec prefix 336 + if (bytes[0] === 0xe7 && bytes[1] === 0x01) { 337 + // secp256k1 (0xe701) 338 + return { curve: "secp256k1", publicKey: bytes.slice(2) }; 339 + } 340 + if (bytes[0] === 0x80 && bytes[1] === 0x24) { 341 + // P-256 (0x8024) 342 + return { curve: "p256", publicKey: bytes.slice(2) }; 343 + } 344 + 345 + throw new Error( 346 + `Unknown multicodec prefix: 0x${bytes[0]!.toString(16)}${bytes[1]!.toString(16)}`, 347 + ); 348 + } 349 + 350 + /** 351 + * Verify an ECDSA signature against a hash. 352 + */ 353 + export function verifyPlcSignature( 354 + hash: Uint8Array, 355 + sigBase64url: string, 356 + publicKey: Uint8Array, 357 + curve: "secp256k1" | "p256", 358 + ): boolean { 359 + const sigBytes = base64urlDecode(sigBase64url); 360 + if (sigBytes.length !== 64) return false; 361 + 362 + // Extract r and s (32 bytes each) 363 + const r = BigInt("0x" + bytesToHex(sigBytes.slice(0, 32))); 364 + const s = BigInt("0x" + bytesToHex(sigBytes.slice(32))); 365 + 366 + // Low-S check 367 + const curveLib = curve === "secp256k1" ? secp256k1 : p256; 368 + const halfOrder = curveLib.CURVE.n >> 1n; 369 + if (s > halfOrder) return false; 370 + 371 + // Construct compact signature (r || s, 64 bytes) 372 + const sig = new curveLib.Signature(r, s); 373 + 374 + try { 375 + return curveLib.verify(sig.toCompactRawBytes(), hash, publicKey); 376 + } catch { 377 + return false; 378 + } 379 + } 380 + 381 + /** 382 + * Normalize a legacy 'create' operation to plc_operation format. 383 + */ 384 + export function normalizeCreateOp(op: PlcOperation): PlcOperation { 385 + if (op.type !== "create") return op; 386 + 387 + return { 388 + type: "plc_operation", 389 + sig: op.sig, 390 + prev: null, 391 + rotationKeys: [op.recoveryKey!, op.signingKey!].filter(Boolean), 392 + verificationMethods: { atproto: op.signingKey! }, 393 + alsoKnownAs: op.handle ? [`at://${op.handle}`] : [], 394 + services: op.service 395 + ? { 396 + atproto_pds: { 397 + type: "AtprotoPersonalDataServer", 398 + endpoint: op.service, 399 + }, 400 + } 401 + : {}, 402 + }; 403 + } 404 + 405 + /** 406 + * Extract rotation keys from an operation (handling both plc_operation and legacy create). 407 + */ 408 + function getRotationKeysFromOp(op: PlcOperation): string[] { 409 + if (op.rotationKeys) return op.rotationKeys; 410 + if (op.type === "create") { 411 + return [op.recoveryKey!, op.signingKey!].filter(Boolean); 412 + } 413 + return []; 414 + } 415 + 416 + // ============================================ 417 + // Encoding utilities 418 + // ============================================ 419 + 420 + const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; 421 + 422 + function base58btcDecode(str: string): Uint8Array { 423 + // Strip multibase prefix 'z' 424 + const input = str.startsWith("z") ? str.slice(1) : str; 425 + 426 + let num = 0n; 427 + for (const char of input) { 428 + const idx = BASE58_ALPHABET.indexOf(char); 429 + if (idx === -1) throw new Error(`Invalid base58 character: ${char}`); 430 + num = num * 58n + BigInt(idx); 431 + } 432 + 433 + // Count leading zeros 434 + let leadingZeros = 0; 435 + for (const char of input) { 436 + if (char === "1") leadingZeros++; 437 + else break; 438 + } 439 + 440 + // Convert to bytes 441 + const hex = num.toString(16).padStart(2, "0"); 442 + const hexPadded = hex.length % 2 ? "0" + hex : hex; 443 + const bytes = new Uint8Array(leadingZeros + hexPadded.length / 2); 444 + 445 + for (let i = 0; i < hexPadded.length / 2; i++) { 446 + bytes[leadingZeros + i] = parseInt(hexPadded.slice(i * 2, i * 2 + 2), 16); 447 + } 448 + 449 + return bytes; 450 + } 451 + 452 + function base64urlDecode(str: string): Uint8Array { 453 + // Add padding if needed 454 + const padded = str + "=".repeat((4 - (str.length % 4)) % 4); 455 + // Convert base64url to base64 456 + const b64 = padded.replace(/-/g, "+").replace(/_/g, "/"); 457 + const binary = atob(b64); 458 + const bytes = new Uint8Array(binary.length); 459 + for (let i = 0; i < binary.length; i++) { 460 + bytes[i] = binary.charCodeAt(i); 461 + } 462 + return bytes; 463 + } 464 + 465 + function bytesToHex(bytes: Uint8Array): string { 466 + return Array.from(bytes) 467 + .map((b) => b.toString(16).padStart(2, "0")) 468 + .join(""); 469 + }
+30
src/index.ts
··· 757 757 app_routes.streamSyncProgress(c, replicationManager), 758 758 ); 759 759 760 + // PLC mirror 761 + app.get("/xrpc/org.p2pds.app.getPlcLog", requireAuth, (c) => 762 + app_routes.getPlcLog(c, replicationManager), 763 + ); 764 + app.get("/xrpc/org.p2pds.app.getPlcMirrorStatus", requireAuth, (c) => 765 + app_routes.getPlcMirrorStatus(c, replicationManager), 766 + ); 767 + 768 + // Rotation key management 769 + app.post("/xrpc/org.p2pds.app.requestPlcToken", requireAuth, (c) => 770 + app_routes.requestPlcToken(c, pdsClientRef), 771 + ); 772 + app.post("/xrpc/org.p2pds.app.addRotationKey", requireAuth, (c) => 773 + app_routes.addRotationKey(c, pdsClientRef), 774 + ); 775 + app.get("/xrpc/org.p2pds.app.getRotationKeys", requireAuth, (c) => 776 + app_routes.getRotationKeys(c, pdsClientRef, getConfigDid()), 777 + ); 778 + 779 + // Recovery export endpoints 780 + app.get("/xrpc/org.p2pds.app.exportRepo", requireAuth, (c) => 781 + app_routes.exportRepo(c, replicationManager, blockStore), 782 + ); 783 + app.get("/xrpc/org.p2pds.app.exportBlobs", requireAuth, (c) => 784 + app_routes.exportBlobsList(c, replicationManager), 785 + ); 786 + app.get("/xrpc/org.p2pds.app.getBlob", requireAuth, (c) => 787 + app_routes.getExportBlob(c, blockStore), 788 + ); 789 + 760 790 // ============================================ 761 791 // MST Proof serving 762 792 // ============================================
+133 -1
src/replication/replication-manager.ts
··· 99 99 private publishPeerRecordFn?: () => Promise<void>; 100 100 /** Sync progress event subscribers for live UI updates via SSE. */ 101 101 private progressCallbacks: Set<(event: SyncProgressEvent) => void> = new Set(); 102 + /** Timer for periodic PLC mirror refresh (6 hours). */ 103 + private plcRefreshTimer: ReturnType<typeof setInterval> | null = null; 102 104 103 105 constructor( 104 - db: Database.Database, 106 + private db: Database.Database, 105 107 private config: Config, 106 108 private repoManager: RepoManager | undefined, 107 109 private blockStore: BlockStore, ··· 327 329 this.syncDid(did, "manual").catch((err) => { 328 330 console.error(`[replication] Initial sync for admin-added ${did} failed:`, err); 329 331 }); 332 + 333 + // Fetch PLC log in background (fire-and-forget) 334 + if (did.startsWith("did:plc:")) { 335 + this.fetchPlcLog(did).catch((err) => { 336 + console.warn(`[replication] PLC log fetch for ${did} failed:`, err instanceof Error ? err.message : String(err)); 337 + }); 338 + } 330 339 331 340 return { status: "added" }; 332 341 } ··· 1489 1498 } 1490 1499 }, tickMs); 1491 1500 1501 + // Start periodic PLC mirror refresh (6 hours) 1502 + const PLC_REFRESH_MS = 6 * 60 * 60 * 1000; 1503 + this.plcRefreshTimer = setInterval(() => { 1504 + if (!this.stopped) { 1505 + this.refreshAllPlcLogs().catch((err) => { 1506 + console.error("[replication] PLC mirror refresh error:", err); 1507 + }); 1508 + } 1509 + }, PLC_REFRESH_MS); 1510 + 1511 + // Initial PLC log fetch for all tracked did:plc DIDs (fire-and-forget) 1512 + this.refreshAllPlcLogs().catch((err) => { 1513 + console.error("[replication] Initial PLC mirror fetch error:", err); 1514 + }); 1515 + 1492 1516 // Run verification once on startup, then on a timer 1493 1517 this.runVerification().catch((err) => { 1494 1518 console.error("Initial verification error:", err); ··· 1638 1662 if (!replicateDids.includes(did)) return; 1639 1663 1640 1664 if (!event.active || event.status === "deleted" || event.status === "takendown") { 1665 + // Refresh PLC log to capture tombstone operation 1666 + if (did.startsWith("did:plc:")) { 1667 + this.fetchPlcLog(did).catch((err) => { 1668 + console.warn(`[replication] PLC log refresh on tombstone for ${did} failed:`, err instanceof Error ? err.message : String(err)); 1669 + }); 1670 + } 1671 + 1641 1672 // Mark as tombstoned 1642 1673 this.syncStorage.markTombstoned(did); 1643 1674 ··· 1667 1698 const message = err instanceof Error ? err.message : String(err); 1668 1699 this.syncStorage.updateStatus(did, "error", message); 1669 1700 }); 1701 + // Refresh PLC log on reactivation 1702 + if (did.startsWith("did:plc:")) { 1703 + this.fetchPlcLog(did).catch((err) => { 1704 + console.warn(`[replication] PLC log refresh on reactivation for ${did} failed:`, err instanceof Error ? err.message : String(err)); 1705 + }); 1706 + } 1670 1707 } 1671 1708 } 1672 1709 } ··· 1944 1981 1945 1982 // Update peer info immediately 1946 1983 this.syncStorage.updatePeerInfo(did, notification.peerId, notification.multiaddrs); 1984 + 1985 + // Refresh PLC log on identity change (key rotation, PDS migration, etc.) 1986 + if (did.startsWith("did:plc:")) { 1987 + this.fetchPlcLog(did).catch((err) => { 1988 + console.warn(`[replication] PLC log refresh on identity change for ${did} failed:`, err instanceof Error ? err.message : String(err)); 1989 + }); 1990 + } 1947 1991 } 1948 1992 1949 1993 /** ··· 1968 2012 this.saveFirehoseCursor(); 1969 2013 this.firehoseSubscription.stop(); 1970 2014 this.firehoseSubscription = null; 2015 + } 2016 + // Stop PLC mirror refresh 2017 + if (this.plcRefreshTimer) { 2018 + clearInterval(this.plcRefreshTimer); 2019 + this.plcRefreshTimer = null; 1971 2020 } 1972 2021 // Stop challenge scheduler 1973 2022 if (this.challengeScheduler) { ··· 2276 2325 }).catch(() => {}); 2277 2326 } 2278 2327 } 2328 + } 2329 + 2330 + // ============================================ 2331 + // PLC mirror 2332 + // ============================================ 2333 + 2334 + /** 2335 + * Fetch and validate the PLC audit log for a single DID. 2336 + */ 2337 + private async fetchPlcLog(did: string): Promise<void> { 2338 + const { fetchAndValidateLog } = await import("../identity/plc-mirror.js"); 2339 + await fetchAndValidateLog(this.db, did); 2340 + } 2341 + 2342 + /** 2343 + * Refresh PLC logs for all tracked did:plc DIDs. 2344 + */ 2345 + private async refreshAllPlcLogs(): Promise<void> { 2346 + const { getAllPlcMirrorDids, fetchAndValidateLog } = await import("../identity/plc-mirror.js"); 2347 + 2348 + // Refresh existing mirrored DIDs 2349 + const mirroredDids = getAllPlcMirrorDids(this.db); 2350 + for (const did of mirroredDids) { 2351 + if (this.stopped) break; 2352 + try { 2353 + await fetchAndValidateLog(this.db, did); 2354 + } catch (err) { 2355 + console.warn( 2356 + `[replication] PLC log refresh for ${did} failed:`, 2357 + err instanceof Error ? err.message : String(err), 2358 + ); 2359 + } 2360 + } 2361 + 2362 + // Also fetch for any tracked did:plc DIDs that don't have a mirror entry yet 2363 + const trackedDids = this.getReplicateDids().filter((d) => d.startsWith("did:plc:")); 2364 + const mirroredSet = new Set(mirroredDids); 2365 + for (const did of trackedDids) { 2366 + if (this.stopped) break; 2367 + if (mirroredSet.has(did)) continue; 2368 + try { 2369 + await fetchAndValidateLog(this.db, did); 2370 + } catch (err) { 2371 + console.warn( 2372 + `[replication] PLC log fetch for ${did} failed:`, 2373 + err instanceof Error ? err.message : String(err), 2374 + ); 2375 + } 2376 + } 2377 + } 2378 + 2379 + /** 2380 + * Get PLC log status for a single DID. 2381 + */ 2382 + getPlcLogStatus(did: string): { opCount: number; lastFetchedAt: string; lastOpCreatedAt: string | null; validated: boolean; isTombstoned: boolean } | null { 2383 + const row = this.db 2384 + .prepare("SELECT * FROM plc_mirror WHERE did = ?") 2385 + .get(did) as Record<string, unknown> | undefined; 2386 + if (!row) return null; 2387 + return { 2388 + opCount: row.op_count as number, 2389 + lastFetchedAt: row.last_fetched_at as string, 2390 + lastOpCreatedAt: (row.last_op_created_at as string) ?? null, 2391 + validated: (row.validated as number) === 1, 2392 + isTombstoned: (row.is_tombstoned as number) === 1, 2393 + }; 2394 + } 2395 + 2396 + /** 2397 + * Get aggregate PLC mirror stats. 2398 + */ 2399 + getPlcMirrorStatus(): { mirroredDids: number; totalOps: number; lastRefresh: string | null } { 2400 + const countRow = this.db 2401 + .prepare("SELECT COUNT(*) as count, COALESCE(SUM(op_count), 0) as total_ops FROM plc_mirror") 2402 + .get() as { count: number; total_ops: number }; 2403 + const lastRow = this.db 2404 + .prepare("SELECT MAX(last_fetched_at) as last_refresh FROM plc_mirror") 2405 + .get() as { last_refresh: string | null }; 2406 + return { 2407 + mirroredDids: countRow.count, 2408 + totalOps: countRow.total_ops, 2409 + lastRefresh: lastRow.last_refresh, 2410 + }; 2279 2411 } 2280 2412 2281 2413 /**
+14
src/replication/sync-storage.ts
··· 99 99 ); 100 100 `); 101 101 102 + // PLC mirror table: stores PLC operation logs for tracked DIDs. 103 + this.db.exec(` 104 + CREATE TABLE IF NOT EXISTS plc_mirror ( 105 + did TEXT PRIMARY KEY, 106 + operations_json TEXT NOT NULL, 107 + op_count INTEGER NOT NULL, 108 + last_fetched_at TEXT NOT NULL, 109 + last_op_created_at TEXT, 110 + validated INTEGER NOT NULL DEFAULT 1, 111 + is_tombstoned INTEGER NOT NULL DEFAULT 0 112 + ); 113 + `); 114 + 102 115 // Offered DIDs table: tracks DIDs we've offered to replicate 103 116 // but don't yet have mutual consent for. 104 117 this.db.exec(` ··· 1155 1168 this.db.prepare("DELETE FROM incoming_offers").run(); 1156 1169 this.db.prepare("DELETE FROM sync_history").run(); 1157 1170 this.db.prepare("DELETE FROM firehose_cursor").run(); 1171 + this.db.prepare("DELETE FROM plc_mirror").run(); 1158 1172 }); 1159 1173 purge(); 1160 1174 }
+83
src/ui/components/account-card.ts
··· 2 2 import { customElement, property, state } from "lit/decorators.js"; 3 3 import { apiFetch, apiPost } from "../state/api.js"; 4 4 import type { ConfirmDialog } from "./confirm-dialog.js"; 5 + import type { RecoveryKeyDialog } from "./recovery-key-dialog.js"; 5 6 import "./account-search.js"; 7 + import "./recovery-key-dialog.js"; 6 8 7 9 @customElement("p2p-account-card") 8 10 export class AccountCard extends LitElement { ··· 15 17 @state() private _consentEnabled: boolean | null = null; 16 18 @state() private _consentLoading = false; 17 19 @state() private _connectHandle: string | null = null; 20 + @state() private _hasCustomKey: boolean | null = null; 21 + @state() private _plcLogStatus: { 22 + archived: boolean; 23 + opCount?: number; 24 + lastFetchedAt?: string; 25 + validated?: boolean; 26 + } | null = null; 18 27 19 28 connectedCallback() { 20 29 super.connectedCallback(); 21 30 if (this.authStatus?.authenticated) { 22 31 this._loadConsent(); 32 + this._loadRotationKeyStatus(); 33 + this._loadPlcLogStatus(); 23 34 } 24 35 } 25 36 26 37 updated(changed: Map<string, unknown>) { 27 38 if (changed.has("authStatus") && this.authStatus?.authenticated) { 28 39 this._loadConsent(); 40 + this._loadRotationKeyStatus(); 41 + this._loadPlcLogStatus(); 29 42 } 30 43 } 31 44 ··· 36 49 } catch { 37 50 // ignore 38 51 } 52 + } 53 + 54 + private async _loadPlcLogStatus() { 55 + const auth = this.authStatus; 56 + if (!auth?.did) return; 57 + try { 58 + const data = await apiFetch("org.p2pds.app.getPlcLog", { did: auth.did }); 59 + this._plcLogStatus = data; 60 + } catch { 61 + // ignore 62 + } 63 + } 64 + 65 + private async _loadRotationKeyStatus() { 66 + try { 67 + const data = await apiFetch("org.p2pds.app.getRotationKeys"); 68 + this._hasCustomKey = data.hasCustomKey ?? false; 69 + } catch { 70 + // ignore 71 + } 72 + } 73 + 74 + private _openRecoveryDialog() { 75 + const dialog = this.querySelector("p2p-recovery-key-dialog") as RecoveryKeyDialog | null; 76 + dialog?.open(); 77 + } 78 + 79 + private _handleRefreshRequested() { 80 + this._loadRotationKeyStatus(); 39 81 } 40 82 41 83 private async _toggleConsent(e: Event) { ··· 114 156 /> 115 157 Publicly consent to be archived 116 158 </label> 159 + ` 160 + : ""} 161 + ${this._hasCustomKey !== null 162 + ? html` 163 + <div style="margin-top:0.75rem;border-top:1px solid var(--border);padding-top:0.75rem"> 164 + <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.4rem"> 165 + <strong style="font-size:0.85rem">Identity Security</strong> 166 + </div> 167 + <div style="display:flex;align-items:center;gap:0.5rem"> 168 + <span class="dot ${this._hasCustomKey ? "dot-synced" : "dot-syncing"}"></span> 169 + <span style="font-size:0.8rem">${this._hasCustomKey ? "Recovery key registered" : "No recovery key"}</span> 170 + <button 171 + class="btn-small" 172 + @click=${this._openRecoveryDialog} 173 + > 174 + ${this._hasCustomKey ? "Manage" : "Secure your identity"} 175 + </button> 176 + </div> 177 + </div> 178 + <p2p-recovery-key-dialog 179 + @refresh-requested=${this._handleRefreshRequested} 180 + ></p2p-recovery-key-dialog> 181 + ` 182 + : ""} 183 + ${this._plcLogStatus !== null 184 + ? html` 185 + <div style="margin-top:0.75rem;border-top:1px solid var(--border);padding-top:0.75rem"> 186 + <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.4rem"> 187 + <strong style="font-size:0.85rem">Identity Log</strong> 188 + </div> 189 + <div style="display:flex;align-items:center;gap:0.5rem"> 190 + ${this._plcLogStatus.archived 191 + ? this._plcLogStatus.validated === false 192 + ? html`<span class="dot dot-error"></span><span style="font-size:0.8rem">Validation failed</span>` 193 + : html`<span class="dot dot-synced"></span><span style="font-size:0.8rem">${this._plcLogStatus.opCount} operation${this._plcLogStatus.opCount === 1 ? "" : "s"} archived</span>` 194 + : html`<span class="dot dot-syncing"></span><span style="font-size:0.8rem">Not yet archived</span>`} 195 + </div> 196 + ${this._plcLogStatus.lastFetchedAt 197 + ? html`<div style="font-size:0.7rem;color:var(--faint);margin-top:0.2rem">Last checked: ${new Date(this._plcLogStatus.lastFetchedAt).toLocaleString()}</div>` 198 + : ""} 199 + </div> 117 200 ` 118 201 : ""} 119 202 </div>
+308 -1
src/xrpc/app.ts
··· 3 3 import type { AppEnv, AuthedAppEnv } from "../types.js"; 4 4 import type { ReplicationManager } from "../replication/replication-manager.js"; 5 5 import type { SyncProgressEvent } from "../replication/replication-manager.js"; 6 - import type { NetworkService, IpfsService } from "../ipfs.js"; 6 + import type { NetworkService, IpfsService, BlockStore } from "../ipfs.js"; 7 7 import type { PdsClientRef } from "../oauth/routes.js"; 8 8 import { CONSENT_NSID } from "../replication/types.js"; 9 + import { exportRepoAsCar, exportBlobs } from "../replication/car-export.js"; 10 + import { detectContentType } from "../format.js"; 11 + import * as rotationKeys from "../identity/rotation-keys.js"; 9 12 10 13 const VERSION = "0.1.0"; 11 14 ··· 826 829 827 830 return c.json({ consented: body.enabled }); 828 831 } 832 + 833 + // ============================================ 834 + // Recovery export endpoints 835 + // ============================================ 836 + 837 + /** 838 + * Export a replicated DID's repo as a CAR file for disaster recovery. 839 + * Returns the full repo as a downloadable CAR v1 file. 840 + */ 841 + export async function exportRepo( 842 + c: Context<AuthedAppEnv>, 843 + replicationManager: ReplicationManager | undefined, 844 + blockStore: BlockStore | undefined, 845 + ): Promise<Response> { 846 + const did = c.req.query("did"); 847 + if (!did || !isValidDid(did)) { 848 + return c.json( 849 + { error: "MissingParameter", message: "did is required and must be valid" }, 850 + 400, 851 + ); 852 + } 853 + 854 + if (!replicationManager || !blockStore) { 855 + return c.json( 856 + { error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 857 + 400, 858 + ); 859 + } 860 + 861 + const syncStorage = replicationManager.getSyncStorage(); 862 + const result = await exportRepoAsCar(did, syncStorage, blockStore); 863 + 864 + if (!result) { 865 + return c.json( 866 + { error: "NotFound", message: `No replicated data found for DID: ${did}` }, 867 + 404, 868 + ); 869 + } 870 + 871 + // Sanitize DID for filename: did:plc:abc123 → did-plc-abc123 872 + const safeName = did.replace(/:/g, "-"); 873 + 874 + return new Response(result.car, { 875 + status: 200, 876 + headers: { 877 + "Content-Type": "application/vnd.ipld.car", 878 + "Content-Disposition": `attachment; filename="${safeName}.car"`, 879 + "Content-Length": result.car.length.toString(), 880 + }, 881 + }); 882 + } 883 + 884 + /** 885 + * List all blob CIDs for a replicated DID. 886 + * Returns a JSON array of CIDs that can be fetched individually via getBlob. 887 + */ 888 + export function exportBlobsList( 889 + c: Context<AuthedAppEnv>, 890 + replicationManager: ReplicationManager | undefined, 891 + ): Response { 892 + const did = c.req.query("did"); 893 + if (!did || !isValidDid(did)) { 894 + return c.json( 895 + { error: "MissingParameter", message: "did is required and must be valid" }, 896 + 400, 897 + ); 898 + } 899 + 900 + if (!replicationManager) { 901 + return c.json( 902 + { error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 903 + 400, 904 + ); 905 + } 906 + 907 + const syncStorage = replicationManager.getSyncStorage(); 908 + const state = syncStorage.getState(did); 909 + 910 + if (!state) { 911 + return c.json( 912 + { error: "NotFound", message: `No replicated data found for DID: ${did}` }, 913 + 404, 914 + ); 915 + } 916 + 917 + const blobCids = syncStorage.getBlobCids(did); 918 + 919 + return c.json({ did, blobs: blobCids, count: blobCids.length }); 920 + } 921 + 922 + /** 923 + * Download a single blob by CID from the blockstore. 924 + * Content type is auto-detected from the blob bytes. 925 + */ 926 + export async function getExportBlob( 927 + c: Context<AuthedAppEnv>, 928 + blockStore: BlockStore | undefined, 929 + ): Promise<Response> { 930 + const cid = c.req.query("cid"); 931 + if (!cid) { 932 + return c.json( 933 + { error: "MissingParameter", message: "cid is required" }, 934 + 400, 935 + ); 936 + } 937 + 938 + if (!blockStore) { 939 + return c.json( 940 + { error: "BlockStoreNotAvailable", message: "Block store is not available" }, 941 + 400, 942 + ); 943 + } 944 + 945 + const bytes = await blockStore.getBlock(cid); 946 + if (!bytes) { 947 + return c.json( 948 + { error: "BlobNotFound", message: `Blob not found: ${cid}` }, 949 + 404, 950 + ); 951 + } 952 + 953 + const contentType = detectContentType(bytes) || "application/octet-stream"; 954 + 955 + return new Response(bytes, { 956 + status: 200, 957 + headers: { 958 + "Content-Type": contentType, 959 + "Content-Length": bytes.length.toString(), 960 + "Cache-Control": "public, max-age=31536000, immutable", 961 + ETag: `"${cid}"`, 962 + }, 963 + }); 964 + } 965 + 966 + // ============================================ 967 + // PLC mirror 968 + // ============================================ 969 + 970 + /** 971 + * Get stored PLC log for a DID. 972 + */ 973 + export function getPlcLog( 974 + c: Context<AuthedAppEnv>, 975 + replicationManager: ReplicationManager | undefined, 976 + ): Response { 977 + const did = c.req.query("did"); 978 + if (!did) { 979 + return c.json( 980 + { error: "MissingParameter", message: "did is required" }, 981 + 400, 982 + ); 983 + } 984 + 985 + if (!replicationManager) { 986 + return c.json( 987 + { error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 988 + 400, 989 + ); 990 + } 991 + 992 + const status = replicationManager.getPlcLogStatus(did); 993 + if (!status) { 994 + return c.json({ did, archived: false }); 995 + } 996 + 997 + return c.json({ 998 + did, 999 + archived: true, 1000 + ...status, 1001 + }); 1002 + } 1003 + 1004 + /** 1005 + * Get aggregate PLC mirror status. 1006 + */ 1007 + export function getPlcMirrorStatus( 1008 + c: Context<AuthedAppEnv>, 1009 + replicationManager: ReplicationManager | undefined, 1010 + ): Response { 1011 + if (!replicationManager) { 1012 + return c.json( 1013 + { error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 1014 + 400, 1015 + ); 1016 + } 1017 + 1018 + return c.json(replicationManager.getPlcMirrorStatus()); 1019 + } 1020 + 1021 + // ============================================ 1022 + // PLC rotation key management 1023 + // ============================================ 1024 + 1025 + /** 1026 + * Request a PLC operation signature token (triggers email). 1027 + */ 1028 + export async function requestPlcToken( 1029 + c: Context<AuthedAppEnv>, 1030 + pdsClientRef: PdsClientRef | undefined, 1031 + ): Promise<Response> { 1032 + if (!pdsClientRef?.current) { 1033 + return c.json( 1034 + { error: "NotAuthenticated", message: "No authenticated session" }, 1035 + 401, 1036 + ); 1037 + } 1038 + 1039 + try { 1040 + await rotationKeys.requestPlcToken(pdsClientRef.current); 1041 + return c.json({ status: "requested" }); 1042 + } catch (err) { 1043 + const message = err instanceof Error ? err.message : String(err); 1044 + return c.json({ error: "RequestFailed", message }, 500); 1045 + } 1046 + } 1047 + 1048 + /** 1049 + * Add a rotation key to the user's PLC document. 1050 + */ 1051 + export async function addRotationKey( 1052 + c: Context<AuthedAppEnv>, 1053 + pdsClientRef: PdsClientRef | undefined, 1054 + ): Promise<Response> { 1055 + if (!pdsClientRef?.current) { 1056 + return c.json( 1057 + { error: "NotAuthenticated", message: "No authenticated session" }, 1058 + 401, 1059 + ); 1060 + } 1061 + 1062 + const body = await c.req 1063 + .json<{ token?: string; publicKeyDidKey?: string }>() 1064 + .catch(() => ({}) as { token?: string; publicKeyDidKey?: string }); 1065 + 1066 + if (!body.token || typeof body.token !== "string") { 1067 + return c.json( 1068 + { error: "MissingParameter", message: "token is required" }, 1069 + 400, 1070 + ); 1071 + } 1072 + 1073 + if (!body.publicKeyDidKey || typeof body.publicKeyDidKey !== "string") { 1074 + return c.json( 1075 + { error: "MissingParameter", message: "publicKeyDidKey is required" }, 1076 + 400, 1077 + ); 1078 + } 1079 + 1080 + try { 1081 + await rotationKeys.addRotationKey( 1082 + pdsClientRef.current, 1083 + body.token, 1084 + body.publicKeyDidKey, 1085 + ); 1086 + return c.json({ status: "added" }); 1087 + } catch (err) { 1088 + const message = err instanceof Error ? err.message : String(err); 1089 + return c.json({ error: "AddRotationKeyFailed", message }, 500); 1090 + } 1091 + } 1092 + 1093 + /** 1094 + * Get rotation keys for the authenticated user (or a specified DID). 1095 + */ 1096 + export async function getRotationKeys( 1097 + c: Context<AuthedAppEnv>, 1098 + pdsClientRef: PdsClientRef | undefined, 1099 + configDid: string, 1100 + ): Promise<Response> { 1101 + const did = c.req.query("did") || configDid; 1102 + 1103 + if (!did) { 1104 + return c.json( 1105 + { error: "MissingParameter", message: "did is required (no configured DID)" }, 1106 + 400, 1107 + ); 1108 + } 1109 + 1110 + try { 1111 + const plcKeys = await rotationKeys.fetchPlcRotationKeys(did); 1112 + 1113 + let status: { rotationKeyCount: number; hasCustomKey: boolean } | null = 1114 + null; 1115 + if (pdsClientRef?.current) { 1116 + try { 1117 + status = await rotationKeys.getRotationKeyStatus( 1118 + pdsClientRef.current, 1119 + ); 1120 + } catch { 1121 + // PDS status is best-effort 1122 + } 1123 + } 1124 + 1125 + return c.json({ 1126 + did, 1127 + rotationKeys: plcKeys, 1128 + rotationKeyCount: status?.rotationKeyCount ?? plcKeys.length, 1129 + hasCustomKey: status?.hasCustomKey ?? plcKeys.length > 1, 1130 + }); 1131 + } catch (err) { 1132 + const message = err instanceof Error ? err.message : String(err); 1133 + return c.json({ error: "FetchFailed", message }, 500); 1134 + } 1135 + }