PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready. tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

Update TODO, more logging during blob migration

Changed files
+190 -31
frontend
src
+129 -22
TODO.md
··· 2 2 3 3 ## Active development 4 4 5 + ### Storage backend abstraction 6 + Make storage layers swappable via traits. 7 + 8 + filesystem blob storage 9 + - [ ] FilesystemBlobStorage implementation 10 + - [ ] directory structure (content-addressed like blobs/{cid} already used in objsto) 11 + - [ ] atomic writes (write to temp, rename) 12 + - [ ] config option to choose backend (env var or config flag) 13 + - [ ] also traitify BackupStorage (currently hardcoded to objsto) 14 + 15 + sqlite database backend 16 + - [ ] abstract db layer behind trait (queries, transactions, migrations) 17 + - [ ] sqlite implementation matching postgres behavior 18 + - [ ] handle sqlite's single-writer limitation (connection pooling strategy) 19 + - [ ] migrations system that works for both 20 + - [ ] testing: run full test suite against both backends 21 + - [ ] config option to choose backend (postgres vs sqlite) 22 + - [ ] document tradeoffs (sqlite for single-user/small, postgres for multi-user/scale) 23 + 5 24 ### Plugin system 6 - Extensible architecture allowing third-party plugins to add functionality. Going with wasm-based rather than scripting language. 25 + WASM component model plugins. Compile to wasm32-wasip2, sandboxed via wasmtime, capability-gated. Based on zed's extensions. 26 + 27 + WIT interface 28 + - [ ] record hooks before/after create, update, delete 29 + - [ ] blob hooks before/after upload, validate 30 + - [ ] xrpc hooks before/after (middleware), custom endpoint handler 31 + - [ ] firehose hook on_commit 32 + - [ ] host imports http client, kv store, logging, read records 33 + 34 + wasmtime host 35 + - [ ] engine with epoch interruption (kill runaway plugins) 36 + - [ ] plugin manifest (plugin.toml): id, version, capabilities, hooks 37 + - [ ] capability enforcement at runtime 38 + - [ ] plugin loader, lifecycle (enable/disable/reload) 39 + - [ ] resource limits (memory, time) 40 + - [ ] per-plugin fs sandbox 41 + 42 + capabilities 43 + - [ ] http:fetch with domain allowlist 44 + - [ ] kv:read, kv:write 45 + - [ ] record:read, blob:read 46 + - [ ] xrpc:register 47 + - [ ] firehose:subscribe 48 + 49 + pds-plugin-api (rust), MVP for plugin system 50 + - [ ] plugin trait with default impls 51 + - [ ] register_plugin! macro 52 + - [ ] typed host import wrappers 53 + - [ ] publish to crates.io 54 + - [ ] docs + example 55 + 56 + pds-plugin-api in golang, nice to have after the fact 57 + - [ ] wit-bindgen-go bindings 58 + - [ ] go wrappers 59 + - [ ] tinygo build instructions 60 + - [ ] example 61 + 62 + @pds/plugin-api in typescript, nice to have after the fact 63 + - [ ] jco/componentize-js bindings 64 + - [ ] typeScript types 65 + - [ ] build tooling 66 + - [ ] example 67 + 68 + example plugins 69 + - [ ] content filter 70 + - [ ] webhook notifier 71 + - [ ] objsto backup mirror 72 + - [ ] custom lexicon handler 73 + - [ ] better audit logger 74 + 75 + ### Misc 76 + 77 + migration handle preservation 78 + - [ ] allow users to keep their existing handle during migration (eg. lewis.moe instead of forcing lewis.newpds.com) 79 + - [ ] UI option to preserve external handle vs create new pds-subdomain handle 80 + - [ ] handle the DNS verification flow for external handles during migration 81 + 82 + cross-pds delegation 83 + when a client (eg. tangled.org) tries to log into a delegated account: 84 + - [ ] client starts oauth flow to delegated account's pds 85 + - [ ] delegated pds sees account is externally controlled, launches oauth to controller's pds (delegated pds acts as oauth client) 86 + - [ ] controller authenticates at their own pds 87 + - [ ] delegated pds verifies controller perms and scope from its local delegation grants 88 + - [ ] delegated pds issues session to client within the intersection of controller's granted scope and client's requested scope 89 + 90 + per-request "act as" 91 + - [ ] authed as user X, perform action as delegated user Y in single request 92 + - [ ] approach decision 93 + - [ ] option 1: `X-Act-As` header with target did, server verifies delegation grant 94 + - [ ] option 2: token exchange (RFC 8693) for short-lived delegated token 95 + - [ ] option 3 (lewis fav): extend existing `act` claim to support on-demand minting 96 + - [ ] something else? 97 + 98 + ### Private/encrypted data 99 + Records only authorized parties can see and decrypt. 100 + 101 + research 102 + - [ ] survey atproto discourse on private data 103 + - [ ] document bluesky team's likely approach. wait.. are they even gonna do this? whatever 104 + - [ ] look at matrix/signal for federated e2ee patterns 105 + 106 + key management 107 + - [ ] db schema for encryption keys (user_keys, key_grants, key_rotations) 108 + - [ ] per-user encryption keypair generation (separate from signing keys) 109 + - [ ] key derivation scheme (per-collection? per-record? both?) 110 + - [ ] key storage (encrypted at rest, hsm option?) 111 + - [ ] rotation and revocation flow 112 + 113 + storage layer 114 + - [ ] encrypted record format (encrypted cbor blob + metadata) 115 + - [ ] collection-level vs per-record encryption flag 116 + - [ ] how encrypted records appear in mst (hash of ciphertext? separate tree?) 117 + - [ ] blob encryption (same keys? separate?) 118 + 119 + api surface 120 + - [ ] xrpc getPublicKey, grantAccess, revokeAccess, listGrants 121 + - [ ] xrpc getEncryptedRecord (ciphertext for client-side decrypt) 122 + - [ ] or transparent server-side decrypt if requester has grant? 123 + - [ ] lexicon for key grant records 7 124 8 - - [ ] Plugin manifest format (name, version, deps, permissions, hooks) 9 - - [ ] Plugin loading and lifecycle (enable/disable/hot reload) 10 - - [ ] WASM host bindings for PDS APIs (database, storage, http, etc.) 11 - - [ ] Resource limits (memory, cpu time, capability restrictions) 12 - - [ ] Extension points: request middleware, record lifecycle hooks, custom XRPC endpoints 13 - - [ ] Extension points: custom lexicons, storage backends, auth providers, notification channels 14 - - [ ] Extension points: firehose consumers (react to repo events) 15 - - [ ] Plugin sdk crate with traits and helpers? 16 - - [ ] Example plugins: cdc, extra logging to 3rd party, content filter, better S3 backup 17 - - [ ] Plugin registry with signature verification? 125 + sync/federation 126 + - [ ] how encrypted records appear on firehose (ciphertext? omitted? placeholder?) 127 + - [ ] pds-to-pds key exchange protocol 128 + - [ ] appview behavior (can't index without grants) 129 + - [ ] relay behavior with encrypted commits 18 130 19 - ### Plugin: Private/encrypted data 20 - Records that only authorized parties can see and decrypt. Requires key federation between PDSes. Implemented as a plugin using the plugin system above. 131 + client integration 132 + - [ ] client-side encryption (pds never sees plaintext) vs server-side with trust 133 + - [ ] key backup/recovery (lose key = lose data) 21 134 22 - - [ ] Survey current ATProto discourse on private data 23 - - [ ] Document Bluesky team's likely approach 24 - - [ ] Design key management strategy 25 - - [ ] Per-user encryption keys (separate from signing keys) 26 - - [ ] Key derivation for per-record or per-collection encryption 27 - - [ ] Encrypted record storage format 28 - - [ ] Transparent encryption/decryption in repo operations 29 - - [ ] Protocol for sharing decryption keys between PDSes 30 - - [ ] Handle key rotation and revocation 135 + plugin hooks (once core exists) 136 + - [ ] on_access_grant_request for custom authorization 137 + - [ ] on_key_rotation to notify interested parties 31 138 32 139 --- 33 140
+4
frontend/src/lib/migration/atproto-client.ts
··· 48 48 return this.accessToken; 49 49 } 50 50 51 + getBaseUrl(): string { 52 + return this.baseUrl; 53 + } 54 + 51 55 setDPoPKeyPair(keyPair: DPoPKeyPair | null) { 52 56 this.dpopKeyPair = keyPair; 53 57 }
+14 -5
frontend/src/lib/migration/blob-migration.ts
··· 20 20 console.log("[blob-migration] Starting blob migration for", userDid); 21 21 console.log( 22 22 "[blob-migration] Source client:", 23 - sourceClient ? "available" : "NOT AVAILABLE", 23 + sourceClient ? `available (baseUrl: ${sourceClient.getBaseUrl()})` : "NOT AVAILABLE", 24 + ); 25 + console.log( 26 + "[blob-migration] Local client baseUrl:", 27 + localClient.getBaseUrl(), 28 + ); 29 + console.log( 30 + "[blob-migration] Local client has access token:", 31 + localClient.getAccessToken() ? "yes" : "NO", 24 32 ); 25 33 26 34 onProgress({ currentOperation: "Checking for missing blobs..." }); ··· 95 103 "contentType:", 96 104 contentType, 97 105 ); 98 - await localClient.uploadBlob(blobData, contentType); 106 + console.log("[blob-migration] Uploading blob", cid, "to local PDS..."); 107 + const uploadResult = await localClient.uploadBlob(blobData, contentType); 99 108 console.log( 100 - "[blob-migration] Uploaded blob", 109 + "[blob-migration] Upload response for", 101 110 cid, 102 - "with contentType:", 103 - contentType, 111 + ":", 112 + JSON.stringify(uploadResult), 104 113 ); 105 114 migrated++; 106 115 onProgress({ blobsMigrated: migrated });
+26 -2
frontend/src/lib/migration/flow.svelte.ts
··· 469 469 } 470 470 471 471 async function migrateBlobs(): Promise<void> { 472 - if (!sourceClient || !localClient) return; 472 + if (!sourceClient) { 473 + console.error("[migration] migrateBlobs: sourceClient is null, skipping blob migration"); 474 + migrationLog("migrateBlobs SKIPPED: sourceClient is null"); 475 + setProgress({ 476 + currentOperation: "Warning: Could not migrate blobs - source PDS connection lost", 477 + }); 478 + return; 479 + } 480 + if (!localClient) { 481 + console.error("[migration] migrateBlobs: localClient is null, skipping blob migration"); 482 + migrationLog("migrateBlobs SKIPPED: localClient is null"); 483 + setProgress({ 484 + currentOperation: "Warning: Could not migrate blobs - local PDS connection lost", 485 + }); 486 + return; 487 + } 488 + 489 + migrationLog("migrateBlobs: Starting blob migration", { 490 + sourceClientBaseUrl: sourceClient.getBaseUrl(), 491 + localClientBaseUrl: localClient.getBaseUrl(), 492 + localClientHasToken: !!localClient.getAccessToken(), 493 + }); 473 494 474 495 const result = await migrateBlobsUtil( 475 496 localClient, ··· 482 503 } 483 504 484 505 async function migratePreferences(): Promise<void> { 485 - if (!sourceClient || !localClient) return; 506 + if (!sourceClient || !localClient) { 507 + console.warn("[migration] migratePreferences: client missing, skipping"); 508 + return; 509 + } 486 510 487 511 try { 488 512 const prefs = await sourceClient.getPreferences();
+10 -1
src/api/error.rs
··· 427 427 error: self.error_name(), 428 428 message: self.message(), 429 429 }; 430 - (self.status_code(), Json(body)).into_response() 430 + let mut response = (self.status_code(), Json(body)).into_response(); 431 + if matches!(self, Self::ExpiredToken(_)) { 432 + response.headers_mut().insert( 433 + "WWW-Authenticate", 434 + "Bearer error=\"invalid_token\", error_description=\"Token has expired\"" 435 + .parse() 436 + .unwrap(), 437 + ); 438 + } 439 + response 431 440 } 432 441 } 433 442
+7 -1
src/lib.rs
··· 590 590 CorsLayer::new() 591 591 .allow_origin(Any) 592 592 .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) 593 - .allow_headers(Any), 593 + .allow_headers(Any) 594 + .expose_headers([ 595 + "WWW-Authenticate".parse().unwrap(), 596 + "DPoP-Nonce".parse().unwrap(), 597 + "atproto-repo-rev".parse().unwrap(), 598 + "atproto-content-labelers".parse().unwrap(), 599 + ]), 594 600 ) 595 601 .with_state(state); 596 602