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 recovery key management, CAR export, and updated README

- Recovery key dialog UI with BIP-39 mnemonic generation
- PLC rotation key management (request token, add key, get status)
- CAR export endpoint for disaster recovery downloads
- Rewritten README with clearer framing and architecture overview
- PLC mirror design doc in docs/research/

+988 -23
+96 -23
README.md
··· 1 1 # P2PDS 2 2 3 - Peer-to-peer replication infrastructure for AT Protocol. Backs up and serves atproto account data over IPFS, acting on behalf of authenticated users. 3 + 🚨 WARNING: very experimental 4 + 5 + Peer-to-peer backup and archiving for AT Protocol accounts. 6 + 7 + Your atproto data — posts, follows, likes, profile, media — lives on a PDS run by someone else. If that PDS shuts down, gets acquired, or just loses your data, it's gone. P2PDS gives you a complete, continuously-synced copy of your account that you own and control. If the worst happens, you have everything you need to migrate to a new PDS and resume where you left off. 8 + 9 + **For individuals:** Log in with your atproto account. P2PDS syncs your entire repo and all your blobs to local storage. That's it — you have a backup. Want to back up a friend's data too? Add their handle, they accept on their end, and both of you are now archiving each other's accounts. No technical setup, no command line, no servers to manage. 10 + 11 + **For communities and organizations:** Groups can collectively archive each other's data. A neighborhood association, an activist collective, a research lab — any group of people who want to ensure their records survive regardless of what happens to any single PDS or hosting provider. Everyone archives everyone, creating resilient redundancy across the group. 12 + 13 + **For public accountability:** Not all archiving requires consent. Public figures, government accounts, and institutional records can be archived unilaterally — the same way the Wayback Machine archives the public web. Researchers, journalists, and watchdog organizations can maintain independent copies of public atproto data without needing permission from the account holder. 4 14 5 - P2PDS is infrastructure — like a torrent client for atproto data. It does not have its own identity. Users authenticate with their own atproto accounts, and coordination records are published to the user's own repo via their PDS. 15 + **Consent-driven by default:** When two users want to archive each other, the flow is consent-based. You publish an offer, the other person sees it and accepts (or doesn't). Mutual acceptance triggers automatic replication. Either party can revoke at any time by deleting their offer — the agreement dissolves and the data is purged. Users can also publish a blanket consent record saying "anyone can archive me," enabling one-way archiving by peers who check for it. 16 + 17 + P2PDS is infrastructure — like a torrent client for atproto data. It has no identity of its own. Users authenticate with their own atproto accounts, and all coordination happens through standard atproto records published to the user's own repo. 6 18 7 19 ## How it works 8 20 ··· 10 22 11 23 ### Architecture 12 24 13 - P2PDS is fully decoupled from any specific PDS. It connects to the user's PDS via OAuth and uses standard atproto APIs for everything: 25 + P2PDS is built as four loosely coupled layers. Each layer has a single responsibility and communicates with adjacent layers through narrow interfaces, so any layer can be replaced without affecting the others. 14 26 15 27 ``` 16 - User's atproto account (any PDS) 17 - 18 - ▼ OAuth + com.atproto.sync.* 19 - ┌─────────┐ 20 - │ p2pds │ replication infrastructure 21 - │ │ 22 - │ SQLite │ blocks, blobs, sync state, peer routing 23 - │ Helia │ IPFS blockstore, peer connections 24 - │ Hono │ XRPC endpoints, web UI 25 - └─────────┘ 26 - 27 - ▼ libp2p / HTTP 28 - Other p2pds nodes 28 + ┌──────────────────────────────────────────────────────┐ 29 + │ Policy Engine │ 30 + │ │ 31 + │ Offer negotiation, agreement detection, lifecycle, │ 32 + │ consent tracking, merge rules, sync scheduling │ 33 + │ │ 34 + │ Speaks: atproto (lexicon records on user's PDS) │ 35 + │ Knows nothing about: blocks, CIDs, libp2p, SQLite │ 36 + ├──────────────────────────────────────────────────────┤ 37 + │ Replication Engine │ 38 + │ │ 39 + │ Sync repos, fetch blobs, verify block integrity, │ 40 + │ challenge-response proofs, firehose subscription │ 41 + │ │ 42 + │ Asks policy: "which DIDs, how often, what priority?"│ 43 + │ Asks storage: "put/get/has block" │ 44 + │ Asks network: "announce CIDs, notify peers" │ 45 + │ Knows nothing about: offers, consent, libp2p, SQL │ 46 + ├───────────────────────┬──────────────────────────────┤ 47 + │ Storage │ Network │ 48 + │ │ │ 49 + │ BlockStore interface │ NetworkService interface │ 50 + │ put/get/has block │ provide, announce, pubsub │ 51 + │ SyncStorage (state) │ │ 52 + │ │ Currently: Helia/libp2p │ 53 + │ Currently: SQLite │ Could be: Iroh, HTTP-only, │ 54 + │ Could be: LevelDB, │ Hyperswarm, or anything │ 55 + │ filesystem, S3 │ that moves bytes between │ 56 + │ │ peers │ 57 + └───────────────────────┴──────────────────────────────┘ 29 58 ``` 59 + 60 + **Why this separation:** 61 + 62 + - **Policy is protocol-native.** All negotiation happens through atproto records published to the user's own PDS — standard lexicons, standard APIs. If you swapped IPFS for a different content-addressed network, policy wouldn't change at all. If you swapped atproto for a different identity system, only policy needs updating. 63 + 64 + - **Replication is protocol-agnostic.** It receives a list of DIDs and sync parameters from policy, fetches bytes, stores them, and verifies integrity. It doesn't know how agreements were formed or how blocks are physically stored. It talks to storage and network through interfaces (`BlockStore`, `NetworkService`), never concrete implementations. 65 + 66 + - **Storage is an interface.** `BlockStore` has four methods: `putBlock`, `getBlock`, `hasBlock`, `deleteBlock`. The current implementation is SQLite-backed, but any key-value store that maps CID strings to byte arrays would work. Replication and policy never import SQLite directly. 67 + 68 + - **Network is an interface.** `NetworkService` handles CID announcement, pubsub notifications, and peer connectivity. The current implementation wraps Helia/libp2p, but the interface is transport-agnostic — HTTP-only, Iroh, or Hyperswarm could implement it without touching replication or policy code. 69 + 70 + **Other design principles:** 30 71 31 72 - **No node identity** — p2pds acts on behalf of users, not as its own entity 32 73 - **Lazy identity** — starts without a DID; identity established on first OAuth login ··· 42 83 | `org.p2pds.peer` | `self` | Binds DID → libp2p PeerID + multiaddrs + p2pds endpoint URL | Peer discovery: nodes read this to find each other's transport addresses and HTTP endpoints | 43 84 | `org.p2pds.replication.offer` | DID (colons→hyphens) | Declares willingness to replicate a specific DID | Offer negotiation: mutual offers trigger automatic replication agreements | 44 85 | `org.p2pds.replication.consent` | `self` | Opt-in: "I consent to being archived" | Consensual archive: peers check this before archiving without reciprocal offer | 86 + 87 + There is no "accepted" or "rejected" record type. Agreement is implicit: if Alice has an offer targeting Bob *and* Bob has an offer targeting Alice, both nodes independently detect the mutual offers and begin replicating. Revoking an offer (deleting the record) dissolves the agreement. This keeps the protocol surface minimal — one record type handles proposing, accepting, and revoking — and the state is verifiable by either party at any time by reading the other's repo. 45 88 46 89 Schemas are in `lexicons/` and validated by `src/lexicons.ts`. 47 90 ··· 69 112 70 113 | Layer | Method | 71 114 |-------|--------| 72 - | L0 | Commit root — fetch repo root CID via RASL from source PDS | 73 - | L1 | RASL sampling — fetch random blocks via HTTP, compare with local | 115 + | L0 | Commit root — compare local root CID with source PDS via `getHead` | 116 + | L1 | Local block sampling — verify random blocks exist in local blockstore | 74 117 | L2 | Block-sample challenge — challenge peers to produce specific blocks | 75 118 | L3 | MST proof challenge — challenge peers to produce Merkle path proofs | 76 119 ··· 87 130 88 131 ### Policy engine 89 132 90 - Declarative, deterministic policy system operating on atproto accounts: 133 + Every replication relationship is backed by a policy object with lifecycle state, consent tracking, and merge rules. Policies are created automatically from offer negotiation or manually via config/UI. 134 + 135 + **Policy types:** 136 + - **Reciprocal** — auto-generated when mutual offers are detected between peers 137 + - **Archive** — user-added DIDs via the web UI 138 + - **Config** — DIDs from the `REPLICATE_DIDS` environment variable 139 + 140 + **Lifecycle:** proposed → active → suspended → terminated → purged. Consent status tracked per-policy (reciprocal, consented, unconsented, revoked). 141 + 142 + **Merge rules** when multiple policies match a DID: max(minCopies), min(intervalSec), max(retention), union(preferredPeers). All policies are SQLite-persisted and survive restarts. 143 + 144 + ### Recovery 145 + 146 + P2PDS keeps a complete, continuously-synced copy of your account data — every repo block, every blob. If your PDS disappears, you can export this data and import it to a new PDS. But **having a copy of your data is not enough to recover your account.** There is a critical prerequisite that is outside p2pds's control. 147 + 148 + **What p2pds gives you:** 149 + - A CAR file containing your complete repo (all commits, MST nodes, records) 150 + - All your blobs (images, media) 151 + - Export endpoints: `exportRepo` (CAR download), `exportBlobs` (blob listing), `getBlob` (individual blob download) 152 + 153 + **What you also need: a rotation key.** 154 + 155 + AT Protocol identities (did:plc) are controlled by rotation keys — cryptographic keys that can update where your DID points. To move your account to a new PDS, you need to sign a PLC operation that says "my DID now lives at this new PDS." Only a rotation key can do that. 156 + 157 + Your PDS holds rotation keys for your DID. If the PDS is gone, those keys are gone too — unless you independently hold one. Without a rotation key, you have all your data but no way to prove you own the identity. You cannot update your DID document, cannot point it at a new PDS, and cannot resume as the same account. 158 + 159 + **Recovery steps (if you hold a rotation key):** 160 + 1. Export your repo as a CAR file and download your blobs from p2pds 161 + 2. Create an account on a new PDS using your existing DID 162 + 3. Import the CAR file via `com.atproto.repo.importRepo` 163 + 4. Re-upload blobs 164 + 5. Sign a PLC operation (with your rotation key) pointing your DID at the new PDS 165 + 6. Activate the account 91 166 92 - - **Mutual aid** — N-of-M redundancy between cooperating peers 93 - - **SaaS** — SLA compliance with minimum copy counts and sync intervals 94 - - **Group governance** — Multi-party replication agreements 167 + **Handle survival:** If you used a custom domain handle (verified via DNS), it survives migration — the DNS record still points to your DID. If you used a `.bsky.social` handle, it's gone — that subdomain is controlled by Bluesky. 95 168 96 - Policies drive sync intervals, priority ordering, and filtering. P2P policies are auto-generated from mutual offer records. 169 + **The honest reality for most Bluesky users today:** Bluesky has not yet shipped user-facing rotation key management. Most users don't independently hold a rotation key. If Bluesky's PDS infrastructure disappeared tomorrow, those users would have their data (thanks to p2pds) but could not recover their identity. This is an upstream gap in the atproto ecosystem, not something p2pds can solve — but it's important to understand. The data preservation is still valuable: your posts, social graph, and media survive, even if reattaching them to the same DID requires key management tooling that doesn't exist yet. 97 170 98 171 ## Stack 99 172
+325
docs/research/plc-mirror.md
··· 1 + # Distributed PLC Mirror via p2pds Network 2 + 3 + ## Problem 4 + 5 + The PLC directory (`plc.directory`) is the single authority for `did:plc` resolution. It's run by Bluesky PBC. If it goes away, every `did:plc` identity becomes unresolvable — users can't prove they own their accounts, can't migrate PDSes, can't recover. 6 + 7 + p2pds already preserves account *data* (repos, blobs). This proposal completes the picture: preserve account *identity* (PLC operation logs) too, distributed across the p2pds network. 8 + 9 + ## What We Get (and Don't Get) 10 + 11 + **We get:** A distributed, content-addressed archive of PLC operation logs for every DID that any p2pds node in the network cares about. Any node can independently verify the signature chain. If plc.directory disappears, the network collectively holds the last-known-good state for potentially millions of DIDs. 12 + 13 + **We don't get:** Consensus on new operations. PLC's real job is ordering — if two valid operations arrive simultaneously, who wins? That requires a sequencer. This proposal doesn't replace that. It provides the raw material for a community-run PLC successor to bootstrap from. 14 + 15 + ## PLC Directory API (What We're Mirroring) 16 + 17 + ### Per-DID Audit Log 18 + 19 + ``` 20 + GET https://plc.directory/{did}/log/audit 21 + ``` 22 + 23 + Returns a JSON array of all operations for a DID: 24 + 25 + ```json 26 + [ 27 + { 28 + "did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 29 + "operation": { 30 + "sig": "lza4at...", 31 + "prev": null, 32 + "type": "plc_operation", 33 + "services": { "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://bsky.social" } }, 34 + "alsoKnownAs": ["at://atprotocol.bsky.social"], 35 + "rotationKeys": ["did:key:zQ3sh...", "did:key:zQ3sh..."], 36 + "verificationMethods": { "atproto": "did:key:zQ3sh..." } 37 + }, 38 + "cid": "bafyrei...", 39 + "nullified": false, 40 + "createdAt": "2023-04-26T06:19:25.508Z" 41 + } 42 + ] 43 + ``` 44 + 45 + Each operation is self-certifying: signed by a rotation key from the previous state. The genesis op's DID derivation can be independently verified: `did:plc:<base32(sha256(dagCbor(signedGenesisOp)))[0:24]>`. 46 + 47 + ### Bulk Export 48 + 49 + ``` 50 + GET https://plc.directory/export?count=1000&after={cursor} 51 + ``` 52 + 53 + JSONL stream. Paginate with `after` = last entry's `createdAt`. For live tailing, a WebSocket endpoint exists at `/export/stream` with sequence-based cursoring. 54 + 55 + ### Operation Types 56 + 57 + | Type | Fields | Notes | 58 + |------|--------|-------| 59 + | `plc_operation` | rotationKeys[], verificationMethods{}, alsoKnownAs[], services{}, prev, sig | Current format | 60 + | `plc_tombstone` | prev, sig | Deactivation | 61 + | `create` | signingKey, recoveryKey, handle, service, prev(null), sig | Legacy v1 genesis only | 62 + 63 + ### Signature Format 64 + 65 + 1. Strip `sig` from operation 66 + 2. DAG-CBOR encode 67 + 3. SHA-256 hash 68 + 4. ECDSA sign (secp256k1 or P-256) with low-S normalization 69 + 5. 64 bytes (r || s) → base64url no padding 70 + 71 + ### 72-Hour Recovery Window 72 + 73 + Rotation keys are ordered by descending authority. A higher-priority key can retroactively nullify operations signed by lower-priority keys within 72 hours. This creates forks — the PLC server marks nullified ops with `nullified: true`. A mirror must track and honor this. 74 + 75 + ## Architecture 76 + 77 + ### Overview 78 + 79 + ``` 80 + plc.directory 81 + | 82 + fetch audit logs per DID 83 + | 84 + +--------------------+--------------------+ 85 + | | | 86 + [p2pds node A] [p2pds node B] [p2pds node C] 87 + tracks: alice, tracks: alice, tracks: carol, 88 + bob carol dave 89 + | | | 90 + +--- DHT provider records (DID → CID) ---+ 91 + | | 92 + +---- bitswap / XRPC for log exchange ----+ 93 + ``` 94 + 95 + Each node fetches PLC logs for the DIDs it already replicates. Stores them in IPFS. Advertises via DHT. Other nodes can discover and fetch logs for DIDs they care about from the network, even if plc.directory is down. 96 + 97 + ### Storage Model 98 + 99 + For each tracked DID, store the audit log as a single dag-cbor block: 100 + 101 + ```typescript 102 + interface PlcLogBlock { 103 + did: string; 104 + operations: IndexedOperation[]; // full audit log 105 + fetchedAt: string; // ISO 8601 106 + sourceSeq?: number; // last known export sequence 107 + } 108 + ``` 109 + 110 + One block per DID. Updated in-place when new operations arrive (CID changes, old block becomes garbage). Typical size: 1-5 KB per DID (most accounts have 1-3 operations). 111 + 112 + **Why one block per DID instead of per-operation:** Simplicity. The audit log for a single DID is tiny. Linking individual ops adds complexity (linked list traversal, partial fetches) without meaningful benefit. A single block is atomic — you either have the full verified chain or you don't. 113 + 114 + ### DHT Integration 115 + 116 + #### Enabling kadDHT 117 + 118 + The `@libp2p/kad-dht` package is already installed. Enable in **client mode only** (queries the DHT but doesn't serve it): 119 + 120 + ```typescript 121 + import { kadDHT, removePrivateAddressesMapper } from '@libp2p/kad-dht' 122 + 123 + // In libp2p services config: 124 + aminoDHT: kadDHT({ 125 + protocol: '/ipfs/kad/1.0.0', 126 + peerInfoMapper: removePrivateAddressesMapper, 127 + clientMode: true, 128 + }) 129 + ``` 130 + 131 + Client mode means: can `provide()` and `findProviders()`, but won't store records for other nodes or respond to inbound DHT queries. Minimal overhead. 132 + 133 + #### DID-to-CID Mapping 134 + 135 + Any node can deterministically compute a "discovery CID" from a DID string: 136 + 137 + ```typescript 138 + const bytes = new TextEncoder().encode(did) // e.g. "did:plc:abc123" 139 + const digest = await sha256.digest(bytes) 140 + const discoveryCid = CID.createV1(0x55, digest) // raw codec 141 + ``` 142 + 143 + This CID doesn't point to actual block content — it's purely a DHT rendezvous key. Any node that knows the DID string computes the same CID, so `findProviders(discoveryCid)` discovers all nodes that have called `provide(discoveryCid)` for that DID. 144 + 145 + #### Provider Record Lifecycle 146 + 147 + - **Publish:** When a node fetches/stores a PLC log, it calls `provide(discoveryCid)` for that DID. 148 + - **TTL:** DHT provider records expire after 48 hours. Helia auto-republishes every ~22 hours. 149 + - **Scale:** js-libp2p lacks Provide Sweep optimization. Each `provide()` does a full DHT walk (~2-10s). Practical limit: ~1,000-3,000 provider records per node. Since we publish one record per DID (not per block), this easily covers typical p2pds usage (tens to hundreds of tracked DIDs). 150 + - **Lookup:** `findProviders()` returns results incrementally. First result often arrives in <1s; full query takes 1-5s median, up to 10s at P90. 151 + 152 + ### Sync Flow 153 + 154 + #### On DID Add (or Node Start) 155 + 156 + ``` 157 + 1. Fetch audit log from plc.directory/{did}/log/audit 158 + 2. Validate signature chain (genesis derivation → each op's sig) 159 + 3. DAG-CBOR encode → store in Helia blockstore 160 + 4. Compute discovery CID from DID string 161 + 5. provide(discoveryCid) on DHT 162 + 6. Store metadata in SQLite: did, logCid, lastFetchedAt, opCount 163 + ``` 164 + 165 + #### Periodic Refresh 166 + 167 + Every 6 hours (configurable), re-fetch audit logs for tracked DIDs from plc.directory. Compare with stored version. If new operations exist, re-validate, update block, re-provide. 168 + 169 + #### Fallback: Peer Discovery 170 + 171 + If plc.directory is unreachable: 172 + 173 + ``` 174 + 1. Compute discovery CID from DID string 175 + 2. findProviders(discoveryCid) on DHT 176 + 3. For each provider: connect via libp2p, fetch log block via bitswap (or XRPC fallback) 177 + 4. Validate signature chain independently 178 + 5. Accept if valid; if multiple versions exist, prefer the one with more operations (longer valid chain) 179 + ``` 180 + 181 + #### Conflict Resolution (Multiple Versions) 182 + 183 + If two nodes have different audit logs for the same DID: 184 + 185 + 1. Both must have valid signature chains from the same genesis 186 + 2. The longer valid chain wins (more operations = more recent) 187 + 3. If chains diverge (fork due to nullification), prefer the chain where nullified ops are already marked — this means the node has seen the recovery operation 188 + 4. If genuinely ambiguous, flag for manual review 189 + 190 + This is a heuristic, not consensus. It works for the disaster recovery use case (recovering last-known-good state) but wouldn't be sufficient for live identity resolution during a contested key rotation. 191 + 192 + ### New Module: `src/identity/plc-mirror.ts` 193 + 194 + ``` 195 + fetchAndStoreAuditLog(did, helia, db) 196 + → fetches from plc.directory, validates, stores in blockstore, records in SQLite 197 + 198 + validateOperationChain(operations, did) 199 + → verifies genesis derivation + each op's signature chain 200 + → returns { valid: boolean, error?: string } 201 + 202 + publishPlcProvider(did, helia) 203 + → computes discovery CID, calls helia.routing.provide() 204 + 205 + findPlcProviders(did, helia) 206 + → computes discovery CID, calls helia.routing.findProviders() 207 + → returns AsyncIterable of peer info 208 + 209 + fetchLogFromPeer(did, peerId, helia) 210 + → fetches the PlcLogBlock via bitswap using the log's CID 211 + → validates before accepting 212 + 213 + getStoredLog(did, db) 214 + → returns stored audit log from SQLite metadata + blockstore 215 + ``` 216 + 217 + ### SQLite Schema Addition 218 + 219 + ```sql 220 + CREATE TABLE IF NOT EXISTS plc_mirror ( 221 + did TEXT PRIMARY KEY, 222 + log_cid TEXT NOT NULL, -- CID of the stored PlcLogBlock 223 + op_count INTEGER NOT NULL, -- number of operations in the log 224 + last_fetched_at TEXT NOT NULL, -- ISO 8601 225 + last_op_created_at TEXT, -- createdAt of most recent operation 226 + validated INTEGER NOT NULL DEFAULT 1 -- 0 if validation failed 227 + ); 228 + ``` 229 + 230 + ### API Endpoints 231 + 232 + | Endpoint | Method | Purpose | 233 + |----------|--------|---------| 234 + | `org.p2pds.app.getPlcLog` | GET | Return stored PLC audit log for a DID | 235 + | `org.p2pds.app.getPlcMirrorStatus` | GET | Summary: how many DIDs mirrored, total ops, last refresh | 236 + 237 + No write endpoints — mirroring is automatic for tracked DIDs. 238 + 239 + ### UI 240 + 241 + Add to the existing DID detail view (or account card): 242 + 243 + - "PLC Log" expandable section showing operation count, last fetched, validation status 244 + - Green checkmark if chain validates, yellow warning if stale (>24h since fetch), red if validation failed 245 + 246 + ### Integration with Existing Systems 247 + 248 + **Triggered by DID tracking:** When `replicationManager.addDid(did)` is called, also trigger `plcMirror.fetchAndStoreAuditLog(did)`. When a DID is removed, optionally clean up the PLC log too (or keep it — it's small and might be useful). 249 + 250 + **Firehose events:** When the firehose sees an identity event for a tracked DID, refresh its PLC log (the DID may have rotated keys or changed PDS). 251 + 252 + **DHT bootstrap:** On node start, after Helia is ready, `provide()` discovery CIDs for all tracked DIDs. This happens in the background — doesn't block startup. 253 + 254 + ## Verification Approach 255 + 256 + PLC operations use the same cryptographic primitives we already have in the dependency tree: 257 + 258 + - **secp256k1:** `@noble/curves/secp256k1` (already used for rotation keys) 259 + - **P-256:** `@noble/curves/p256` (may need to add) 260 + - **DAG-CBOR:** `@ipld/dag-cbor` (already used by Helia) 261 + - **SHA-256:** `multiformats/hashes/sha2` (already used) 262 + - **did:key parsing:** decode multicodec prefix to determine curve, extract raw public key 263 + 264 + The validation algorithm: 265 + 266 + ``` 267 + 1. Parse genesis op 268 + 2. DAG-CBOR encode signed genesis → SHA-256 → base32 → truncate 24 → verify = claimed DID 269 + 3. For each subsequent non-nullified op: 270 + a. Get rotationKeys from previous state 271 + b. Strip sig from op → DAG-CBOR encode → SHA-256 272 + c. Try verifying against each rotation key until one matches 273 + d. Reject if none match 274 + 4. Return final state 275 + ``` 276 + 277 + ## Design Decisions 278 + 279 + 1. **Per-DID fetch for individual nodes, full export for supernodes.** Individual p2pds nodes (especially Tauri desktop) fetch `/log/audit` only for DIDs they track. Community infrastructure "supernodes" can run full `/export` pagination + WebSocket tailing to archive the entire PLC directory. The SQLite schema and storage model are the same for both — supernodes just have more rows. This minimum-viable-agency approach gives individuals what they need while keeping the door open for community-run infrastructure that covers everything. 280 + 281 + 2. **Always share PLC logs, even for DIDs you don't replicate.** PLC logs are public, tiny (1-5KB), and already published by plc.directory with no access control. Serve them unconditionally to any peer that asks. Keep providing on DHT even for DIDs you no longer track (until node restart or explicit purge). This maximizes archive coverage at zero meaningful cost. 282 + 283 + 3. **XRPC primary, bitswap secondary.** DHT `findProviders()` tells you *who* has a DID's log but not the CID of their block (the discovery CID is derived from the DID string, not the log content). So the flow is: DHT → discover peer → XRPC `org.p2pds.plc.getLog?did=...` → get log. Bitswap could supplement this later via a DID→logCID mapping in DHT key-value store, but that's complexity for later phases. 284 + 285 + 4. **"Community infrastructure" opt-in toggle in UI.** DHT participation (and broader community functions like serving PLC logs for non-tracked DIDs, acting as a relay, etc.) is gated behind a UI toggle: "Participate in community infrastructure" or similar. Off by default — individual nodes just do their own thing. When enabled: kadDHT client mode activates, the node provides discovery CIDs for all mirrored DIDs, and serves PLC logs to any peer. This cleanly separates "me and my friends' stuff" from "I want to help the network." Config flag: `COMMUNITY_INFRA=true`. 286 + 287 + 5. **P-256 required.** Bluesky PBC's rotation keys are P-256. Without `@noble/curves/p256`, we can't validate chains for most DIDs. Add the explicit import — same author/API as secp256k1, already a transitive dep. 288 + 289 + ## Node Roles 290 + 291 + The design supports a spectrum of participation: 292 + 293 + | Role | PLC Mirror | DHT | Serves Logs | Full Export | Typical Deployment | 294 + |------|-----------|-----|-------------|-------------|-------------------| 295 + | **Personal** | Tracked DIDs only | No | No | No | Tauri desktop app | 296 + | **Community** | Tracked DIDs only | Client mode | Yes, any DID | No | Personal toggle enabled | 297 + | **Supernode** | All DIDs | Client or server mode | Yes, any DID | Yes, full `/export` | Dedicated server, community-run | 298 + 299 + Personal nodes get disaster recovery for their own accounts. Community nodes strengthen the network. Supernodes provide comprehensive coverage. All three use the same codebase — the difference is configuration. 300 + 301 + ## Phased Implementation 302 + 303 + ### Phase 1: Local PLC Archive 304 + - Fetch and store audit logs for tracked DIDs 305 + - Validate signature chains (secp256k1 + P-256) 306 + - SQLite metadata table 307 + - Refresh on identity events + periodic 308 + - UI status indicator in DID detail / account card 309 + - No DHT, no cross-node sharing 310 + - Works for all node roles (everyone gets local archiving) 311 + 312 + ### Phase 2: Community Infrastructure Toggle 313 + - "Participate in community infrastructure" UI toggle + `COMMUNITY_INFRA` config 314 + - Enable kadDHT in client mode when toggled on 315 + - Publish DHT provider records for all mirrored DIDs 316 + - XRPC endpoint `org.p2pds.plc.getLog` for cross-node log serving 317 + - `findProviders()` fallback when plc.directory is unreachable 318 + - Serve PLC logs to any peer, not just tracked DIDs 319 + 320 + ### Phase 3: Supernodes 321 + - Full `/export` pagination for bulk PLC directory mirroring 322 + - WebSocket tailing (`/export/stream`) for real-time updates 323 + - Cross-node log gossip (push newer logs to peers who also track that DID) 324 + - Aggregate network statistics: "the p2pds network collectively mirrors N DIDs" 325 + - Optional kadDHT server mode for supernodes (serve DHT queries, not just issue them)
+77
package-lock.json
··· 35 35 "@libp2p/kad-dht": "^16.1.3", 36 36 "@libp2p/ping": "^3.0.10", 37 37 "@preact/signals-core": "^1.13.0", 38 + "@scure/bip32": "^2.0.1", 39 + "@scure/bip39": "^2.0.1", 38 40 "bcryptjs": "^3.0.3", 39 41 "better-sqlite3": "^11.8.1", 40 42 "blockstore-fs": "^3.0.2", ··· 4262 4264 "os": [ 4263 4265 "win32" 4264 4266 ] 4267 + }, 4268 + "node_modules/@scure/base": { 4269 + "version": "2.0.0", 4270 + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", 4271 + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", 4272 + "license": "MIT", 4273 + "funding": { 4274 + "url": "https://paulmillr.com/funding/" 4275 + } 4276 + }, 4277 + "node_modules/@scure/bip32": { 4278 + "version": "2.0.1", 4279 + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", 4280 + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", 4281 + "license": "MIT", 4282 + "dependencies": { 4283 + "@noble/curves": "2.0.1", 4284 + "@noble/hashes": "2.0.1", 4285 + "@scure/base": "2.0.0" 4286 + }, 4287 + "funding": { 4288 + "url": "https://paulmillr.com/funding/" 4289 + } 4290 + }, 4291 + "node_modules/@scure/bip32/node_modules/@noble/curves": { 4292 + "version": "2.0.1", 4293 + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", 4294 + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", 4295 + "license": "MIT", 4296 + "dependencies": { 4297 + "@noble/hashes": "2.0.1" 4298 + }, 4299 + "engines": { 4300 + "node": ">= 20.19.0" 4301 + }, 4302 + "funding": { 4303 + "url": "https://paulmillr.com/funding/" 4304 + } 4305 + }, 4306 + "node_modules/@scure/bip32/node_modules/@noble/hashes": { 4307 + "version": "2.0.1", 4308 + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", 4309 + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", 4310 + "license": "MIT", 4311 + "engines": { 4312 + "node": ">= 20.19.0" 4313 + }, 4314 + "funding": { 4315 + "url": "https://paulmillr.com/funding/" 4316 + } 4317 + }, 4318 + "node_modules/@scure/bip39": { 4319 + "version": "2.0.1", 4320 + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", 4321 + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", 4322 + "license": "MIT", 4323 + "dependencies": { 4324 + "@noble/hashes": "2.0.1", 4325 + "@scure/base": "2.0.0" 4326 + }, 4327 + "funding": { 4328 + "url": "https://paulmillr.com/funding/" 4329 + } 4330 + }, 4331 + "node_modules/@scure/bip39/node_modules/@noble/hashes": { 4332 + "version": "2.0.1", 4333 + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", 4334 + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", 4335 + "license": "MIT", 4336 + "engines": { 4337 + "node": ">= 20.19.0" 4338 + }, 4339 + "funding": { 4340 + "url": "https://paulmillr.com/funding/" 4341 + } 4265 4342 }, 4266 4343 "node_modules/@sinclair/typebox": { 4267 4344 "version": "0.27.10",
+2
package.json
··· 50 50 "@libp2p/kad-dht": "^16.1.3", 51 51 "@libp2p/ping": "^3.0.10", 52 52 "@preact/signals-core": "^1.13.0", 53 + "@scure/bip32": "^2.0.1", 54 + "@scure/bip39": "^2.0.1", 53 55 "bcryptjs": "^3.0.3", 54 56 "better-sqlite3": "^11.8.1", 55 57 "blockstore-fs": "^3.0.2",
+95
src/identity/rotation-keys.ts
··· 1 + /** 2 + * PLC rotation key management helpers. 3 + * 4 + * These functions use the authenticated PDS client to interact with 5 + * PLC operations — requesting tokens, reading credentials, and 6 + * submitting signed PLC operations to add rotation keys. 7 + */ 8 + 9 + import type { PdsClient } from "../oauth/pds-client.js"; 10 + 11 + interface RotationKeyStatus { 12 + rotationKeyCount: number; 13 + hasCustomKey: boolean; 14 + rotationKeys: string[]; 15 + } 16 + 17 + /** 18 + * Request a PLC operation signature token (sent via email). 19 + */ 20 + export async function requestPlcToken(pdsClient: PdsClient): Promise<void> { 21 + const agent = await pdsClient.getAgent(); 22 + await agent.com.atproto.identity.requestPlcOperationSignature(); 23 + } 24 + 25 + /** 26 + * Get the current rotation key status for the authenticated user. 27 + * A user with more than 1 rotation key is assumed to have a custom key. 28 + */ 29 + export async function getRotationKeyStatus( 30 + pdsClient: PdsClient, 31 + ): Promise<RotationKeyStatus> { 32 + const agent = await pdsClient.getAgent(); 33 + const result = 34 + await agent.com.atproto.identity.getRecommendedDidCredentials(); 35 + const rotationKeys = (result.data.rotationKeys as string[] | undefined) ?? []; 36 + return { 37 + rotationKeyCount: rotationKeys.length, 38 + hasCustomKey: rotationKeys.length > 1, 39 + rotationKeys, 40 + }; 41 + } 42 + 43 + /** 44 + * Add a new rotation key to the user's PLC document. 45 + * Prepends the key so it has highest priority, then signs and submits. 46 + */ 47 + export async function addRotationKey( 48 + pdsClient: PdsClient, 49 + token: string, 50 + publicKeyDidKey: string, 51 + ): Promise<void> { 52 + const agent = await pdsClient.getAgent(); 53 + 54 + // Get current recommended credentials 55 + const creds = 56 + await agent.com.atproto.identity.getRecommendedDidCredentials(); 57 + 58 + const currentRotationKeys = 59 + (creds.data.rotationKeys as string[] | undefined) ?? []; 60 + 61 + // Prepend the new key (highest priority) 62 + const rotationKeys = [publicKeyDidKey, ...currentRotationKeys]; 63 + 64 + // Sign the PLC operation with the email token 65 + const signed = await agent.com.atproto.identity.signPlcOperation({ 66 + token, 67 + rotationKeys, 68 + alsoKnownAs: creds.data.alsoKnownAs as string[] | undefined, 69 + verificationMethods: creds.data.verificationMethods as 70 + | Record<string, string> 71 + | undefined, 72 + services: creds.data.services as 73 + | Record<string, { type: string; endpoint: string }> 74 + | undefined, 75 + }); 76 + 77 + // Submit the signed operation 78 + await agent.com.atproto.identity.submitPlcOperation({ 79 + operation: signed.data.operation, 80 + }); 81 + } 82 + 83 + /** 84 + * Fetch rotation keys directly from PLC directory (no auth required). 85 + */ 86 + export async function fetchPlcRotationKeys( 87 + did: string, 88 + ): Promise<string[]> { 89 + const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}/data`); 90 + if (!res.ok) { 91 + throw new Error(`PLC directory returned ${res.status}`); 92 + } 93 + const data = (await res.json()) as { rotationKeys?: string[] }; 94 + return data.rotationKeys ?? []; 95 + }
+71
src/replication/car-export.ts
··· 1 + /** 2 + * Recovery export functions for replicated atproto account data. 3 + * 4 + * Provides CAR file export (for repo import to a new PDS) and blob 5 + * export for disaster recovery scenarios. 6 + */ 7 + 8 + import { BlockMap, blocksToCarFile } from "@atproto/repo"; 9 + import { CID } from "@atproto/lex-data"; 10 + import type { SyncStorage } from "./sync-storage.js"; 11 + import type { BlockStore } from "../ipfs.js"; 12 + 13 + /** 14 + * Assemble a CAR v1 file from all replicated blocks for a DID. 15 + * 16 + * Uses @atproto/repo's blocksToCarFile which produces spec-compliant 17 + * CAR v1 with the commit root as the single root CID. 18 + * 19 + * Returns null if the DID has no sync state or root CID. 20 + */ 21 + export async function exportRepoAsCar( 22 + did: string, 23 + syncStorage: SyncStorage, 24 + blockStore: BlockStore, 25 + ): Promise<{ car: Uint8Array; rootCid: string; blockCount: number } | null> { 26 + const state = syncStorage.getState(did); 27 + if (!state?.rootCid) return null; 28 + 29 + const blockCids = syncStorage.getBlockCids(did); 30 + const blocks = new BlockMap(); 31 + let blockCount = 0; 32 + 33 + for (const cidStr of blockCids) { 34 + const bytes = await blockStore.getBlock(cidStr); 35 + if (bytes) { 36 + blocks.set(CID.parse(cidStr), bytes); 37 + blockCount++; 38 + } 39 + } 40 + 41 + if (blockCount === 0) return null; 42 + 43 + const root = CID.parse(state.rootCid); 44 + const car = await blocksToCarFile(root, blocks); 45 + 46 + return { car, rootCid: state.rootCid, blockCount }; 47 + } 48 + 49 + /** 50 + * Export all blob bytes for a DID. 51 + * 52 + * Returns an array of { cid, bytes } for every tracked blob CID 53 + * that has data in the blockstore. Blobs without data are skipped. 54 + */ 55 + export async function exportBlobs( 56 + did: string, 57 + syncStorage: SyncStorage, 58 + blockStore: BlockStore, 59 + ): Promise<Array<{ cid: string; bytes: Uint8Array }>> { 60 + const blobCids = syncStorage.getBlobCids(did); 61 + const results: Array<{ cid: string; bytes: Uint8Array }> = []; 62 + 63 + for (const cid of blobCids) { 64 + const bytes = await blockStore.getBlock(cid); 65 + if (bytes) { 66 + results.push({ cid, bytes }); 67 + } 68 + } 69 + 70 + return results; 71 + }
+1
src/ui/app.ts
··· 20 20 import "./components/policies-card"; 21 21 import "./components/verification-card"; 22 22 import "./components/confirm-dialog"; 23 + import "./components/recovery-key-dialog";
+284
src/ui/components/recovery-key-dialog.ts
··· 1 + import { LitElement, html, nothing } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + import { generateMnemonic, mnemonicToSeedSync } from "@scure/bip39"; 4 + import { wordlist } from "@scure/bip39/wordlists/english"; 5 + import { HDKey } from "@scure/bip32"; 6 + import { secp256k1 } from "@noble/curves/secp256k1"; 7 + import { base58btc } from "multiformats/bases/base58"; 8 + import { apiFetch, apiPost } from "../state/api.js"; 9 + 10 + type DialogStep = 11 + | "idle" 12 + | "show-mnemonic" 13 + | "request-token" 14 + | "enter-token" 15 + | "registering" 16 + | "success" 17 + | "error"; 18 + 19 + /** 20 + * Derive a did:key from a BIP39 mnemonic using BIP32 path m/44'/0'/0'. 21 + * Returns compressed secp256k1 public key encoded as did:key. 22 + */ 23 + function deriveDidKey(mnemonic: string): string { 24 + const seed = mnemonicToSeedSync(mnemonic); 25 + const master = HDKey.fromMasterSeed(seed); 26 + const child = master.derive("m/44'/0'/0'"); 27 + if (!child.privateKey) { 28 + throw new Error("Failed to derive private key"); 29 + } 30 + // Get compressed public key (33 bytes) 31 + const pubkey = secp256k1.getPublicKey(child.privateKey, true); 32 + 33 + // Multicodec prefix for secp256k1-pub: 0xe7 0x01 (varint) 34 + const multicodecPrefix = new Uint8Array([0xe7, 0x01]); 35 + const keyBytes = new Uint8Array(multicodecPrefix.length + pubkey.length); 36 + keyBytes.set(multicodecPrefix); 37 + keyBytes.set(pubkey, multicodecPrefix.length); 38 + 39 + // base58btc encode with 'z' multibase prefix 40 + const encoded = base58btc.encode(keyBytes); 41 + 42 + return `did:key:${encoded}`; 43 + } 44 + 45 + @customElement("p2p-recovery-key-dialog") 46 + export class RecoveryKeyDialog extends LitElement { 47 + createRenderRoot() { 48 + return this; 49 + } 50 + 51 + @state() private _step: DialogStep = "idle"; 52 + @state() private _mnemonic: string = ""; 53 + @state() private _publicKeyDidKey: string = ""; 54 + @state() private _confirmed = false; 55 + @state() private _token: string = ""; 56 + @state() private _errorMessage: string = ""; 57 + 58 + open() { 59 + const mnemonic = generateMnemonic(wordlist, 128); 60 + this._mnemonic = mnemonic; 61 + this._publicKeyDidKey = deriveDidKey(mnemonic); 62 + this._confirmed = false; 63 + this._token = ""; 64 + this._errorMessage = ""; 65 + this._step = "show-mnemonic"; 66 + 67 + this.requestUpdate(); 68 + requestAnimationFrame(() => { 69 + const dialog = this.querySelector("dialog") as HTMLDialogElement; 70 + dialog?.showModal(); 71 + }); 72 + } 73 + 74 + private _close() { 75 + const dialog = this.querySelector("dialog") as HTMLDialogElement; 76 + dialog?.close(); 77 + // Zero out sensitive data 78 + this._mnemonic = ""; 79 + this._publicKeyDidKey = ""; 80 + this._token = ""; 81 + this._step = "idle"; 82 + } 83 + 84 + private _handleCancel() { 85 + this._close(); 86 + } 87 + 88 + private _handleConfirmToggle(e: Event) { 89 + this._confirmed = (e.target as HTMLInputElement).checked; 90 + } 91 + 92 + private _continueToRequestToken() { 93 + this._step = "request-token"; 94 + } 95 + 96 + private async _requestToken() { 97 + try { 98 + await apiPost("org.p2pds.app.requestPlcToken", {}); 99 + this._step = "enter-token"; 100 + } catch (err) { 101 + this._errorMessage = 102 + err instanceof Error ? err.message : "Failed to request token"; 103 + this._step = "error"; 104 + } 105 + } 106 + 107 + private _handleTokenInput(e: Event) { 108 + this._token = (e.target as HTMLInputElement).value.trim(); 109 + } 110 + 111 + private async _registerKey() { 112 + if (!this._token || !this._publicKeyDidKey) return; 113 + this._step = "registering"; 114 + 115 + try { 116 + const result = await apiPost("org.p2pds.app.addRotationKey", { 117 + token: this._token, 118 + publicKeyDidKey: this._publicKeyDidKey, 119 + }); 120 + if (result.error) { 121 + this._errorMessage = result.message || "Registration failed"; 122 + this._step = "error"; 123 + return; 124 + } 125 + this._step = "success"; 126 + } catch (err) { 127 + this._errorMessage = 128 + err instanceof Error ? err.message : "Registration failed"; 129 + this._step = "error"; 130 + } 131 + } 132 + 133 + private _done() { 134 + this._close(); 135 + this.dispatchEvent( 136 + new CustomEvent("refresh-requested", { bubbles: true, composed: true }), 137 + ); 138 + } 139 + 140 + private _retry() { 141 + this._errorMessage = ""; 142 + this._step = "enter-token"; 143 + } 144 + 145 + render() { 146 + return html` 147 + <dialog @cancel=${this._handleCancel}> 148 + ${this._step === "show-mnemonic" ? this._renderMnemonic() : nothing} 149 + ${this._step === "request-token" 150 + ? this._renderRequestToken() 151 + : nothing} 152 + ${this._step === "enter-token" ? this._renderEnterToken() : nothing} 153 + ${this._step === "registering" ? this._renderRegistering() : nothing} 154 + ${this._step === "success" ? this._renderSuccess() : nothing} 155 + ${this._step === "error" ? this._renderError() : nothing} 156 + </dialog> 157 + `; 158 + } 159 + 160 + private _renderMnemonic() { 161 + const words = this._mnemonic.split(" "); 162 + return html` 163 + <h3 style="margin-bottom: 0.6rem">Recovery Key</h3> 164 + <div class="recovery-warning"> 165 + Write down these 12 words in order and store them securely offline. This 166 + is the only time they will be shown. Anyone with these words can control 167 + your identity. 168 + </div> 169 + <div class="mnemonic-grid"> 170 + ${words.map( 171 + (word, i) => html` 172 + <div class="mnemonic-word"> 173 + <span class="mnemonic-num">${i + 1}.</span> ${word} 174 + </div> 175 + `, 176 + )} 177 + </div> 178 + <label 179 + style="display:flex;align-items:center;gap:0.4rem;margin-top:0.75rem;font-size:0.8rem;cursor:pointer" 180 + > 181 + <input 182 + type="checkbox" 183 + .checked=${this._confirmed} 184 + @change=${this._handleConfirmToggle} 185 + /> 186 + I have written down these words 187 + </label> 188 + <div class="recovery-actions"> 189 + <button @click=${this._handleCancel}>Cancel</button> 190 + <button 191 + class="btn-primary" 192 + .disabled=${!this._confirmed} 193 + @click=${this._continueToRequestToken} 194 + > 195 + Continue 196 + </button> 197 + </div> 198 + `; 199 + } 200 + 201 + private _renderRequestToken() { 202 + return html` 203 + <h3 style="margin-bottom: 0.6rem">Verify via Email</h3> 204 + <p style="font-size: 0.82rem; margin-bottom: 0.75rem; color: var(--muted)"> 205 + To add a recovery key, your PDS will send a verification token to your 206 + email address. Click below to request it. 207 + </p> 208 + <div class="recovery-actions"> 209 + <button @click=${this._handleCancel}>Cancel</button> 210 + <button class="btn-primary" @click=${this._requestToken}> 211 + Send Verification Email 212 + </button> 213 + </div> 214 + `; 215 + } 216 + 217 + private _renderEnterToken() { 218 + return html` 219 + <h3 style="margin-bottom: 0.6rem">Enter Verification Token</h3> 220 + <p style="font-size: 0.82rem; margin-bottom: 0.75rem; color: var(--muted)"> 221 + Check your email for the verification token and enter it below. 222 + </p> 223 + <input 224 + class="recovery-token-input" 225 + type="text" 226 + placeholder="Paste token here" 227 + .value=${this._token} 228 + @input=${this._handleTokenInput} 229 + /> 230 + <div class="recovery-actions"> 231 + <button @click=${this._handleCancel}>Cancel</button> 232 + <button 233 + class="btn-primary" 234 + .disabled=${!this._token} 235 + @click=${this._registerKey} 236 + > 237 + Register Key 238 + </button> 239 + </div> 240 + `; 241 + } 242 + 243 + private _renderRegistering() { 244 + return html` 245 + <h3 style="margin-bottom: 0.6rem">Registering Key...</h3> 246 + <p style="font-size: 0.82rem; color: var(--muted)"> 247 + Submitting your rotation key to the PLC directory. This may take a 248 + moment. 249 + </p> 250 + `; 251 + } 252 + 253 + private _renderSuccess() { 254 + return html` 255 + <h3 style="margin-bottom: 0.6rem">Key Registered</h3> 256 + <p style="font-size: 0.82rem; margin-bottom: 0.75rem; color: var(--muted)"> 257 + Your recovery rotation key has been successfully added to your PLC 258 + document. You can now recover your identity using your 12-word phrase. 259 + </p> 260 + <div class="recovery-actions"> 261 + <button class="btn-primary" @click=${this._done}>Done</button> 262 + </div> 263 + `; 264 + } 265 + 266 + private _renderError() { 267 + return html` 268 + <h3 style="margin-bottom: 0.6rem; color: #ef4444">Error</h3> 269 + <p style="font-size: 0.82rem; margin-bottom: 0.75rem; color: var(--muted)"> 270 + ${this._errorMessage} 271 + </p> 272 + <div class="recovery-actions"> 273 + <button @click=${this._handleCancel}>Close</button> 274 + <button class="btn-primary" @click=${this._retry}>Retry</button> 275 + </div> 276 + `; 277 + } 278 + } 279 + 280 + declare global { 281 + interface HTMLElementTagNameMap { 282 + "p2p-recovery-key-dialog": RecoveryKeyDialog; 283 + } 284 + }
+37
src/ui/styles/theme.ts
··· 290 290 .trigger-tombstone-recovery { background: #4a1035; color: #f9a8d4; } 291 291 .trigger-gc { background: #1f2937; color: #9ca3af; } 292 292 } 293 + p2p-recovery-key-dialog dialog { 294 + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; 295 + background: var(--card-bg); color: var(--fg); border: 1px solid var(--card-border); 296 + border-radius: 6px; padding: 1.2rem; max-width: 480px; width: 90vw; font-size: 0.85rem; 297 + box-shadow: 0 4px 24px var(--shadow); 298 + position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0; 299 + } 300 + p2p-recovery-key-dialog dialog::backdrop { background: rgba(0,0,0,0.4); } 301 + .mnemonic-grid { 302 + display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.4rem; 303 + margin: 0.75rem 0; 304 + } 305 + .mnemonic-word { 306 + background: var(--metric-bg); border: 1px solid var(--border); border-radius: 4px; 307 + padding: 0.4rem 0.5rem; font-size: 0.82rem; font-family: inherit; 308 + text-align: center; user-select: all; 309 + } 310 + .mnemonic-num { color: var(--faint); font-size: 0.7rem; margin-right: 0.2rem; } 311 + .recovery-warning { 312 + background: #fef3c7; color: #92400e; border: 1px solid #f59e0b; 313 + border-radius: 4px; padding: 0.6rem 0.75rem; font-size: 0.78rem; 314 + line-height: 1.4; margin-bottom: 0.5rem; 315 + } 316 + .recovery-token-input { 317 + display: block; width: 100%; padding: 0.5rem; font-family: inherit; 318 + font-size: 0.9rem; text-align: center; letter-spacing: 0.05em; 319 + border: 1px solid var(--input-border); border-radius: 4px; 320 + background: var(--input-bg); color: var(--fg); outline: none; 321 + margin-bottom: 0.75rem; 322 + } 323 + .recovery-token-input:focus { border-color: var(--fg); } 324 + .recovery-actions { 325 + display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.75rem; 326 + } 327 + @media (prefers-color-scheme: dark) { 328 + .recovery-warning { background: #422006; color: #fcd34d; border-color: #92400e; } 329 + } 293 330 p2p-confirm-dialog dialog { 294 331 font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; 295 332 background: var(--card-bg); color: var(--fg); border: 1px solid var(--card-border);