this repo has no description sites.wisp.place/zzstoatzz.io/pds-message-poc
pds messaging

two separate PDSes for real cross-server verification

- alice on pds-message-demo, bob on pds-message-demo-2
- messages route to recipient's PDS (actual cross-PDS auth)
- bob's PDS signs JWT, alice's PDS verifies via plc.directory
- add wrangler configs to demo repo
- fix CSS so panes don't resize based on content

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+175 -248
+83 -224
README.md
··· 6 7 **live demo**: [sites.wisp.place/zzstoatzz.io/pds-message-poc](https://sites.wisp.place/zzstoatzz.io/pds-message-poc) 8 9 - ## architecture 10 11 - this demo uses a real PDS deployment: 12 13 - - **pds.js fork** deployed to Cloudflare Workers at `pds-message-demo.nate-8fe.workers.dev` 14 - - **real DIDs** registered with [plc.directory](https://plc.directory) 15 - - **real service auth** - JWTs signed server-side via `com.atproto.server.getServiceAuth` 16 - - **real signature verification** - recipient PDS resolves sender DID via PLC to get public key 17 18 ``` 19 - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” 20 - โ”‚ Browser โ”‚ โ”‚ PDS Worker โ”‚ 21 - โ”‚ (demo UI) โ”‚ โ”‚ (Cloudflare) โ”‚ 22 - โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค 23 - โ”‚ โ”‚ 1. createSession(bob) โ”‚ โ”‚ 24 - โ”‚ โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ bob's DO โ”‚ 25 - โ”‚ โ”‚ โ† accessJwt โ”‚ โ”‚ 26 - โ”‚ โ”‚ โ”‚ โ”‚ 27 - โ”‚ โ”‚ 2. getServiceAuth(aud=alice)โ”‚ โ”‚ 28 - โ”‚ โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ signs JWT โ”‚ 29 - โ”‚ โ”‚ โ† service JWT โ”‚ server-side โ”‚ 30 - โ”‚ โ”‚ โ”‚ โ”‚ 31 - โ”‚ โ”‚ 3. inbox.send + JWT โ”‚ โ”‚ 32 - โ”‚ โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ alice's DO: โ”‚ 33 - โ”‚ โ”‚ โ”‚ - resolve DID โ”‚ 34 - โ”‚ โ”‚ โ”‚ - verify sig โ”‚ 35 - โ”‚ โ”‚ โ”‚ - check spam โ”‚ 36 - โ”‚ โ”‚ โ”‚ - deliver/queueโ”‚ 37 - โ”‚ โ”‚ โ† {status: ...} โ”‚ โ”‚ 38 - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 39 - โ”‚ 40 - โ–ผ 41 - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” 42 - โ”‚ plc.directory โ”‚ 43 - โ”‚ (DID โ†’ pubkey)โ”‚ 44 - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 45 ``` 46 47 - ## run locally 48 49 ```bash 50 - git submodule update --init 51 - npm install 52 - npm run dev 53 ``` 54 55 - ## usage 56 57 - type a message, select sender โ†’ recipient 58 - **send** - initiates message (first message creates a request) ··· 60 - **reject** - recipient rejects request and blocks sender 61 - **spam** - labeler marks sender as spam (rejected by all PDSes) 62 63 - ## invitation flow 64 65 - first contact requires acceptance (like DM requests): 66 67 - 1. bob sends message to alice โ†’ creates **request** (message held) 68 - 2. alice sees request in her "requests" section 69 - 3. alice clicks **accept** โ†’ original message delivered, bob now accepted 70 - 4. subsequent messages from bob deliver immediately (subject to rate limits) 71 - 72 - alternatively: 73 - - alice clicks **reject** โ†’ request deleted, bob blocked permanently 74 - 75 - ## what's real 76 77 | component | implementation | 78 |-----------|----------------| 79 - | PDS | [pds.js](https://tangled.org/chadtmiller.com/pds.js) fork on Cloudflare Workers | 80 | DIDs | real `did:plc` registered with [plc.directory](https://plc.directory) | 81 | service auth | server-side JWT signing via `com.atproto.server.getServiceAuth` | 82 | signature verification | PLC resolution โ†’ public key โ†’ ES256 verify | 83 - | invitation flow | persistent in Durable Object SQLite | 84 - | block list | persistent per-user | 85 - 86 - ## what's demonstrated 87 - 88 - | feature | implementation | ATProto pattern | 89 - |---------|----------------|-----------------| 90 - | service auth | JWT with iss/aud/exp/lxm | [com.atproto.server.getServiceAuth](https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/server/getServiceAuth.json) | 91 - | invitation flow | pending/accepted sets | similar to `chat.bsky.convo` request status | 92 - | reputation | labeler with spam labels | [com.atproto.label](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/label) | 93 - | block list | per-user set | existing pattern | 94 - | rate limiting | per-sender, time-windowed | existing pattern | 95 - 96 - ## what's simplified 97 98 - | component | current | path to production | 99 - |-----------|---------|-------------------| 100 - | labeler | in-memory (browser) | [ozone](https://github.com/bluesky-social/atproto/tree/main/packages/ozone) | 101 - | accounts | 3 demo users (alice/bob/charlie) | real account creation | 102 - | encryption | none (messages in plaintext) | E2EE layer | 103 104 - ## pds.js modifications 105 106 our fork adds: 107 - - `xyz.fake.inbox.*` XRPC endpoints (send, list, listRequests, accept, reject) 108 - inbox tables in SQLite schema 109 - PLC resolution for DID โ†’ public key during JWT verification 110 - `com.atproto.server.getServiceAuth` for server-side JWT signing 111 112 - ## prior art 113 114 - [AT Protocol and SMTP](https://ngerakines.leaflet.pub/3lxxk3oahzc2f) - ngerakines on PDS as crypto service 115 - [bourbon protocol](https://blog.boscolo.co/3lzj5po423s2g) - invitation-based messaging 116 - [How Streamplace Works](https://stream.place/blog/how-streamplace-works-embedded-pds) - embedded PDS pattern 117 118 - <details> 119 - <summary><strong>how it actually works</strong></summary> 120 - 121 - ### the setup 122 - 123 - three users exist on the same PDS deployment: 124 - 125 - ``` 126 - alice.pds-message-demo.nate-8fe.workers.dev โ†’ did:plc:cmadossymmii3izkabdbp5en 127 - bob.pds-message-demo.nate-8fe.workers.dev โ†’ did:plc:deeom7pq4ynuigyr2p562vxz 128 - charlie.pds-message-demo.nate-8fe.workers.dev โ†’ did:plc:c6qmjdpyg6uoqnb6uoxt5omb 129 - ``` 130 - 131 - each DID is registered with [plc.directory](https://plc.directory), which maps DIDs to public keys. this is real - you can verify them: 132 - 133 - ```bash 134 - curl https://plc.directory/did:plc:deeom7pq4ynuigyr2p562vxz 135 - ``` 136 - 137 - ### what happens when bob sends a message to alice 138 - 139 - ``` 140 - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” 141 - โ”‚ browser โ”‚ โ”‚ cloudflare worker โ”‚ โ”‚ plc.directoryโ”‚ 142 - โ”‚ (demo UI) โ”‚ โ”‚ (pds.js) โ”‚ โ”‚ โ”‚ 143 - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 144 - โ”‚ โ”‚ โ”‚ 145 - โ”‚ 1. createSession(bob) โ”‚ โ”‚ 146 - โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ 147 - โ”‚ โ† accessJwt โ”‚ โ”‚ 148 - โ”‚ โ”‚ โ”‚ 149 - โ”‚ 2. getServiceAuth โ”‚ โ”‚ 150 - โ”‚ (aud=alice's DID) โ”‚ โ”‚ 151 - โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ 152 - โ”‚ โ”‚ bob's Durable Object: โ”‚ 153 - โ”‚ โ”‚ - looks up bob's private keyโ”‚ 154 - โ”‚ โ”‚ - signs JWT with ES256 โ”‚ 155 - โ”‚ โ† service JWT โ”‚ - iss=bob, aud=alice โ”‚ 156 - โ”‚ (signed by bob) โ”‚ โ”‚ 157 - โ”‚ โ”‚ โ”‚ 158 - โ”‚ 3. xyz.fake.inbox.send โ”‚ โ”‚ 159 - โ”‚ + service JWT โ”‚ โ”‚ 160 - โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ 161 - โ”‚ โ”‚ routed to alice's DO: โ”‚ 162 - โ”‚ โ”‚ โ”‚ 163 - โ”‚ โ”‚ 4. resolve sender DID โ”‚ 164 - โ”‚ โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ 165 - โ”‚ โ”‚ โ† bob's public key โ”‚ 166 - โ”‚ โ”‚ โ”‚ 167 - โ”‚ โ”‚ 5. verify JWT signature โ”‚ 168 - โ”‚ โ”‚ (ES256 with bob's pubkey)โ”‚ 169 - โ”‚ โ”‚ โ”‚ 170 - โ”‚ โ”‚ 6. check inbox rules: โ”‚ 171 - โ”‚ โ”‚ - is bob blocked? no โ”‚ 172 - โ”‚ โ”‚ - is bob accepted? no โ”‚ 173 - โ”‚ โ”‚ โ†’ create request โ”‚ 174 - โ”‚ โ”‚ โ”‚ 175 - โ”‚ โ† {status: "request_created"} โ”‚ 176 - โ”‚ โ”‚ โ”‚ 177 - ``` 178 - 179 - ### the key insight: PDS as cryptographic service 180 - 181 - following ngerakines' framing, the PDS acts as a "cryptographic service" - it holds private keys and signs on behalf of users. the browser never sees private keys. 182 - 183 - ``` 184 - browser knows: PDS knows: 185 - - handle - handle 186 - - DID - DID 187 - - session token - session token 188 - - private signing key โ† this is the important part 189 - ``` 190 - 191 - when bob wants to prove his identity to alice, he asks his PDS to sign a JWT. alice's PDS verifies it by: 192 - 1. parsing the `iss` claim to get bob's DID 193 - 2. resolving bob's DID via plc.directory to get his public key 194 - 3. verifying the signature 195 - 196 - this is exactly how service auth works in AT Protocol - we just applied it to messaging. 197 - 198 - ### durable objects as isolated PDSes 199 - 200 - [pds.js](https://tangled.org/chadtmiller.com/pds.js) uses Cloudflare Durable Objects, where each user gets their own isolated DO with its own SQLite database: 201 - 202 - ``` 203 - cloudflare worker 204 - โ”œโ”€โ”€ alice's DO 205 - โ”‚ โ””โ”€โ”€ SQLite: inbox_messages, inbox_accepted, inbox_blocked, inbox_requests 206 - โ”œโ”€โ”€ bob's DO 207 - โ”‚ โ””โ”€โ”€ SQLite: inbox_messages, inbox_accepted, inbox_blocked, inbox_requests 208 - โ””โ”€โ”€ charlie's DO 209 - โ””โ”€โ”€ SQLite: inbox_messages, inbox_accepted, inbox_blocked, inbox_requests 210 - ``` 211 - 212 - when a message arrives at `xyz.fake.inbox.send`, the worker routes it to the recipient's DO based on the `aud` claim in the JWT. each DO is effectively an independent PDS with its own state. 213 - 214 - ### the invitation flow 215 - 216 - first contact creates a request (like DM requests on most platforms): 217 - 218 - ```sql 219 - -- alice's DO when bob sends first message 220 - INSERT INTO inbox_requests (fromDid, text, createdAt) VALUES ('did:plc:bob...', 'hey alice', '...') 221 - ``` 222 - 223 - when alice accepts: 224 - 225 - ```sql 226 - -- move to accepted list 227 - INSERT INTO inbox_accepted (did) VALUES ('did:plc:bob...') 228 - -- deliver held message 229 - INSERT INTO inbox_messages (fromDid, text, createdAt) SELECT fromDid, text, createdAt FROM inbox_requests WHERE fromDid = 'did:plc:bob...' 230 - -- remove request 231 - DELETE FROM inbox_requests WHERE fromDid = 'did:plc:bob...' 232 - ``` 233 - 234 - subsequent messages from bob deliver immediately (skipping the request stage). 235 - 236 - ### XRPC endpoints we added 237 - 238 - ``` 239 - xyz.fake.inbox.send POST send message (requires service auth JWT) 240 - xyz.fake.inbox.list GET list inbox messages (requires session auth) 241 - xyz.fake.inbox.listRequests GET list pending requests (requires session auth) 242 - xyz.fake.inbox.accept POST accept a request (requires session auth) 243 - xyz.fake.inbox.reject POST reject and block (requires session auth) 244 - ``` 245 - 246 - plus the standard AT Protocol endpoint: 247 - 248 - ``` 249 - com.atproto.server.getServiceAuth GET get a signed JWT for service-to-service auth 250 - ``` 251 - 252 - ### why this matters 253 - 254 - the demo shows that PDS-to-PDS messaging is possible with existing AT Protocol primitives: 255 - 256 - 1. **identity** - DIDs provide portable, cryptographically-verifiable identity 257 - 2. **service auth** - JWTs let services prove who's making requests 258 - 3. **PLC resolution** - public key discovery without trusting the sender's PDS 259 - 4. **durable objects** - each user's inbox is isolated and persistent 260 - 261 - no new cryptography needed. no blockchain. just the existing AT Protocol stack applied to a new use case. 262 - 263 </details> 264 - 265 - ## references 266 - 267 - - [jacob.gold's thread](https://bsky.app/profile/jacob.gold/post/3mbsbqsc3vc24) 268 - - [pds.js](https://tangled.org/chadtmiller.com/pds.js) - cloudflare workers PDS 269 - - [official PDS](https://github.com/bluesky-social/atproto/tree/main/packages/pds) 270 - - [service auth](https://github.com/bluesky-social/atproto/blob/main/packages/xrpc-server/src/auth.ts) 271 - - [AT Protocol specs](https://atproto.com/specs/atp)
··· 6 7 **live demo**: [sites.wisp.place/zzstoatzz.io/pds-message-poc](https://sites.wisp.place/zzstoatzz.io/pds-message-poc) 8 9 + ## what it does 10 + 11 + - two separate PDSes exchange messages via custom XRPC endpoints (`xyz.fake.inbox.*`) 12 + - first contact requires acceptance, like DM requests 13 + - sender's PDS signs messages with service auth JWTs 14 + - recipient's PDS verifies by resolving sender's DID via plc.directory 15 + - separate labeler service can mark senders as spam 16 + 17 + ## run locally 18 19 + ```bash 20 + git submodule update --init 21 + bun install 22 + bun dev 23 + ``` 24 + 25 + <details> 26 + <summary>architecture</summary> 27 + 28 + this demo uses **two separate PDS deployments** for real cross-server messaging: 29 30 + - **alice's PDS**: `pds-message-demo.nate-8fe.workers.dev` 31 + - **bob's PDS**: `pds-message-demo-2.nate-8fe.workers.dev` 32 + - **spam labeler**: `did:plc:x6io7svnbth4pikg2e63vvkx` 33 34 ``` 35 + browser bob's PDS alice's PDS 36 + โ”‚ โ”‚ โ”‚ 37 + โ”‚ 1. createSession(bob) โ”‚ โ”‚ 38 + โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ 39 + โ”‚ <- accessJwt โ”‚ โ”‚ 40 + โ”‚ โ”‚ โ”‚ 41 + โ”‚ 2. getServiceAuth โ”‚ โ”‚ 42 + โ”‚ (aud=alice's DID) โ”‚ โ”‚ 43 + โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ 44 + โ”‚ <- service JWT โ”‚ (signs w/ bob's key) โ”‚ 45 + โ”‚ โ”‚ โ”‚ 46 + โ”‚ 3. xyz.fake.inbox.send โ”‚ โ”‚ 47 + โ”‚ + service JWT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ 48 + โ”‚ โ”‚ โ”‚ 49 + โ”‚ โ”‚ 4. resolve bob's DID โ”‚ 50 + โ”‚ โ”‚ via plc.directory โ”‚ 51 + โ”‚ โ”‚ 5. verify JWT โ”‚ 52 + โ”‚ โ”‚ 6. check spam label โ”‚ 53 + โ”‚ โ”‚ 7. deliver/queue โ”‚ 54 + โ”‚ <- {status: ...} โ”‚ โ”‚ 55 ``` 56 57 + alice's PDS verifies bob's identity by resolving his DID via plc.directory - no way to forge the sender. 58 + 59 + </details> 60 + 61 + <details> 62 + <summary>deploy</summary> 63 + 64 + each PDS worker requires two secrets set via wrangler: 65 66 ```bash 67 + wrangler secret put PDS_PASSWORD --config pds-alice.toml 68 + wrangler secret put JWT_SECRET --config pds-alice.toml 69 + wrangler secret put PDS_PASSWORD --config pds-bob.toml 70 + wrangler secret put JWT_SECRET --config pds-bob.toml 71 + ``` 72 + 73 + then deploy: 74 + 75 + ```bash 76 + wrangler deploy --config pds-alice.toml 77 + wrangler deploy --config pds-bob.toml 78 ``` 79 80 + </details> 81 + 82 + <details> 83 + <summary>usage</summary> 84 85 - type a message, select sender โ†’ recipient 86 - **send** - initiates message (first message creates a request) ··· 88 - **reject** - recipient rejects request and blocks sender 89 - **spam** - labeler marks sender as spam (rejected by all PDSes) 90 91 + **invitation flow**: first contact requires acceptance (like DM requests). bob sends to alice โ†’ request created โ†’ alice accepts โ†’ message delivered. subsequent messages from bob deliver immediately. 92 93 + </details> 94 95 + <details> 96 + <summary>what's real</summary> 97 98 | component | implementation | 99 |-----------|----------------| 100 + | PDSes | two [pds.js](https://tangled.org/chadtmiller.com/pds.js) deployments on Cloudflare Workers | 101 | DIDs | real `did:plc` registered with [plc.directory](https://plc.directory) | 102 | service auth | server-side JWT signing via `com.atproto.server.getServiceAuth` | 103 | signature verification | PLC resolution โ†’ public key โ†’ ES256 verify | 104 + | labeler | real ATProto labeler with secp256k1 signing | 105 + | cross-PDS messaging | bob's PDS signs, alice's PDS verifies via DID resolution | 106 107 + </details> 108 109 + <details> 110 + <summary>pds.js modifications</summary> 111 112 our fork adds: 113 + - `xyz.fake.inbox.*` XRPC endpoints (send, list, listRequests, accept, reject, unblock, getState) 114 - inbox tables in SQLite schema 115 - PLC resolution for DID โ†’ public key during JWT verification 116 - `com.atproto.server.getServiceAuth` for server-side JWT signing 117 + - spam labeler check before message delivery 118 119 + </details> 120 + 121 + <details> 122 + <summary>references</summary> 123 124 + - [jacob.gold's thread](https://bsky.app/profile/jacob.gold/post/3mbsbqsc3vc24) 125 + - [pds.js](https://tangled.org/chadtmiller.com/pds.js) - cloudflare workers PDS 126 - [AT Protocol and SMTP](https://ngerakines.leaflet.pub/3lxxk3oahzc2f) - ngerakines on PDS as crypto service 127 - [bourbon protocol](https://blog.boscolo.co/3lzj5po423s2g) - invitation-based messaging 128 - [How Streamplace Works](https://stream.place/blog/how-streamplace-works-embedded-pds) - embedded PDS pattern 129 130 </details>
+15
pds-alice.toml
···
··· 1 + name = "pds-message-demo" 2 + main = "vendor/pds.js/src/pds.js" 3 + compatibility_date = "2024-01-01" 4 + 5 + [[durable_objects.bindings]] 6 + name = "PDS" 7 + class_name = "PersonalDataServer" 8 + 9 + [[migrations]] 10 + tag = "v1" 11 + new_sqlite_classes = [ "PersonalDataServer" ] 12 + 13 + [[r2_buckets]] 14 + binding = "BLOBS" 15 + bucket_name = "pds-blobs"
+15
pds-bob.toml
···
··· 1 + name = "pds-message-demo-2" 2 + main = "vendor/pds.js/src/pds.js" 3 + compatibility_date = "2024-01-01" 4 + 5 + [[durable_objects.bindings]] 6 + name = "PDS" 7 + class_name = "PersonalDataServer" 8 + 9 + [[migrations]] 10 + tag = "v1" 11 + new_sqlite_classes = [ "PersonalDataServer" ] 12 + 13 + [[r2_buckets]] 14 + binding = "BLOBS" 15 + bucket_name = "pds-message-demo-2-blobs"
+36 -18
src/lib/client.js
··· 1 /** 2 - * PDS client - wraps XRPC calls to the deployed pds.js instance 3 */ 4 5 - const PDS_URL = 'https://pds-message-demo.nate-8fe.workers.dev'; 6 const PDS_PASSWORD = 'pds-message-demo-2026'; 7 8 const CREDENTIALS = { 9 alice: { 10 handle: 'alice.pds-message-demo.nate-8fe.workers.dev', 11 - did: 'did:plc:cmadossymmii3izkabdbp5en' 12 }, 13 bob: { 14 - handle: 'bob.pds-message-demo.nate-8fe.workers.dev', 15 - did: 'did:plc:deeom7pq4ynuigyr2p562vxz' 16 - }, 17 - charlie: { 18 - handle: 'charlie.pds-message-demo.nate-8fe.workers.dev', 19 - did: 'did:plc:c6qmjdpyg6uoqnb6uoxt5omb' 20 } 21 }; 22 23 export class PDSClient { 24 constructor(name, creds) { 25 this.name = name; 26 this.did = creds.did; 27 this.handle = creds.handle; 28 29 this.inbox = []; 30 this.pending = new Map(); ··· 35 } 36 37 async init() { 38 - const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createSession`, { 39 method: 'POST', 40 headers: { 'Content-Type': 'application/json' }, 41 body: JSON.stringify({ ··· 54 } 55 56 async syncState() { 57 - const inboxRes = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.list`, { 58 headers: { Authorization: `Bearer ${this.accessToken}` } 59 }); 60 if (inboxRes.ok) { ··· 66 })); 67 } 68 69 - const reqRes = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.listRequests`, { 70 headers: { Authorization: `Bearer ${this.accessToken}` } 71 }); 72 if (reqRes.ok) { ··· 79 ); 80 } 81 82 - const stateRes = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.getState`, { 83 headers: { Authorization: `Bearer ${this.accessToken}` } 84 }); 85 if (stateRes.ok) { ··· 93 const params = new URLSearchParams({ aud: audienceDid }); 94 if (lxm) params.set('lxm', lxm); 95 96 - const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.getServiceAuth?${params}`, { 97 headers: { Authorization: `Bearer ${this.accessToken}` } 98 }); 99 ··· 106 } 107 108 async sendMessage(recipientDid, text) { 109 const jwt = await this.getServiceAuth(recipientDid, 'xyz.fake.inbox.send'); 110 111 - const res = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.send`, { 112 method: 'POST', 113 headers: { 114 'Content-Type': 'application/json', ··· 143 } 144 145 async acceptRequest(senderDid) { 146 - const res = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.accept`, { 147 method: 'POST', 148 headers: { 149 'Content-Type': 'application/json', ··· 161 } 162 163 async rejectRequest(senderDid) { 164 - const res = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.reject`, { 165 method: 'POST', 166 headers: { 167 'Content-Type': 'application/json', ··· 179 } 180 181 async unblockSender(senderDid) { 182 - const res = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.unblock`, { 183 method: 'POST', 184 headers: { 185 'Content-Type': 'application/json',
··· 1 /** 2 + * PDS client - wraps XRPC calls to deployed pds.js instances 3 + * 4 + * alice is on pds-message-demo, bob is on pds-message-demo-2 5 + * this demonstrates real PDS-to-PDS messaging across different servers 6 */ 7 8 const PDS_PASSWORD = 'pds-message-demo-2026'; 9 10 const CREDENTIALS = { 11 alice: { 12 handle: 'alice.pds-message-demo.nate-8fe.workers.dev', 13 + did: 'did:plc:cmadossymmii3izkabdbp5en', 14 + pdsUrl: 'https://pds-message-demo.nate-8fe.workers.dev' 15 }, 16 bob: { 17 + handle: 'bob.pds-message-demo-2.nate-8fe.workers.dev', 18 + did: 'did:plc:deeom7pq4ynuigyr2p562vxz', 19 + pdsUrl: 'https://pds-message-demo-2.nate-8fe.workers.dev' 20 } 21 }; 22 23 + // resolve DID to PDS URL (for cross-PDS messaging) 24 + async function resolvePdsUrl(did) { 25 + // check local cache first 26 + for (const creds of Object.values(CREDENTIALS)) { 27 + if (creds.did === did) return creds.pdsUrl; 28 + } 29 + // fallback: resolve via plc.directory 30 + const res = await fetch(`https://plc.directory/${did}`); 31 + if (!res.ok) throw new Error(`Failed to resolve DID: ${did}`); 32 + const doc = await res.json(); 33 + return doc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint; 34 + } 35 + 36 export class PDSClient { 37 constructor(name, creds) { 38 this.name = name; 39 this.did = creds.did; 40 this.handle = creds.handle; 41 + this.pdsUrl = creds.pdsUrl; 42 43 this.inbox = []; 44 this.pending = new Map(); ··· 49 } 50 51 async init() { 52 + const res = await fetch(`${this.pdsUrl}/xrpc/com.atproto.server.createSession`, { 53 method: 'POST', 54 headers: { 'Content-Type': 'application/json' }, 55 body: JSON.stringify({ ··· 68 } 69 70 async syncState() { 71 + const inboxRes = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.list`, { 72 headers: { Authorization: `Bearer ${this.accessToken}` } 73 }); 74 if (inboxRes.ok) { ··· 80 })); 81 } 82 83 + const reqRes = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.listRequests`, { 84 headers: { Authorization: `Bearer ${this.accessToken}` } 85 }); 86 if (reqRes.ok) { ··· 93 ); 94 } 95 96 + const stateRes = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.getState`, { 97 headers: { Authorization: `Bearer ${this.accessToken}` } 98 }); 99 if (stateRes.ok) { ··· 107 const params = new URLSearchParams({ aud: audienceDid }); 108 if (lxm) params.set('lxm', lxm); 109 110 + const res = await fetch(`${this.pdsUrl}/xrpc/com.atproto.server.getServiceAuth?${params}`, { 111 headers: { Authorization: `Bearer ${this.accessToken}` } 112 }); 113 ··· 120 } 121 122 async sendMessage(recipientDid, text) { 123 + // get service auth JWT from OUR PDS 124 const jwt = await this.getServiceAuth(recipientDid, 'xyz.fake.inbox.send'); 125 126 + // resolve recipient's PDS and send the message THERE (cross-PDS!) 127 + const recipientPdsUrl = await resolvePdsUrl(recipientDid); 128 + 129 + const res = await fetch(`${recipientPdsUrl}/xrpc/xyz.fake.inbox.send`, { 130 method: 'POST', 131 headers: { 132 'Content-Type': 'application/json', ··· 161 } 162 163 async acceptRequest(senderDid) { 164 + const res = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.accept`, { 165 method: 'POST', 166 headers: { 167 'Content-Type': 'application/json', ··· 179 } 180 181 async rejectRequest(senderDid) { 182 + const res = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.reject`, { 183 method: 'POST', 184 headers: { 185 'Content-Type': 'application/json', ··· 197 } 198 199 async unblockSender(senderDid) { 200 + const res = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.unblock`, { 201 method: 'POST', 202 headers: { 203 'Content-Type': 'application/json',
+2
src/lib/components/PdsPanel.svelte
··· 56 padding: 1rem; 57 display: flex; 58 flex-direction: column; 59 } 60 61 h2 {
··· 56 padding: 1rem; 57 display: flex; 58 flex-direction: column; 59 + overflow: hidden; 60 + min-width: 0; 61 } 62 63 h2 {
+24 -6
src/routes/+page.svelte
··· 177 <select id="sender" bind:value={senderHandle}> 178 <option value="alice">alice</option> 179 <option value="bob">bob</option> 180 - <option value="charlie">charlie</option> 181 </select> 182 </div> 183 <button class="swap" onclick={swap} aria-label="swap sender and recipient">โ‡„</button> ··· 186 <select id="recipient" bind:value={recipientHandle}> 187 <option value="alice">alice</option> 188 <option value="bob">bob</option> 189 - <option value="charlie">charlie</option> 190 </select> 191 </div> 192 </div> ··· 250 251 <section class="infrastructure"> 252 <h3>infrastructure</h3> 253 - <p class="infra-desc">this demo is backed by real ATProto services</p> 254 <div class="infra-grid"> 255 <a href="https://pds-message-demo.nate-8fe.workers.dev/xrpc/com.atproto.server.describeServer" target="_blank" class="infra-card"> 256 <div class="infra-icon"> ··· 262 </svg> 263 </div> 264 <div class="infra-content"> 265 - <div class="infra-label">personal data server</div> 266 <div class="infra-value">pds-message-demo.nate-8fe.workers.dev</div> 267 <div class="infra-detail">cloudflare worker + durable objects</div> 268 </div> 269 </a> 270 271 <a href="https://plc.directory/did:plc:x6io7svnbth4pikg2e63vvkx" target="_blank" class="infra-card"> 272 <div class="infra-icon"> 273 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> ··· 323 324 .container { 325 display: grid; 326 - grid-template-columns: 1fr 2fr 1fr; 327 gap: 1rem; 328 max-width: 1000px; 329 margin: 0 auto; ··· 334 background: #111; 335 border: 1px solid #222; 336 padding: 1rem; 337 } 338 .center h2 { 339 font-size: 11px; ··· 497 color: #444; 498 font-size: 12px; 499 align-self: stretch; 500 } 501 502 .state-summary { ··· 548 } 549 .infra-grid { 550 display: grid; 551 - grid-template-columns: repeat(3, 1fr); 552 gap: 1rem; 553 } 554 .infra-card {
··· 177 <select id="sender" bind:value={senderHandle}> 178 <option value="alice">alice</option> 179 <option value="bob">bob</option> 180 </select> 181 </div> 182 <button class="swap" onclick={swap} aria-label="swap sender and recipient">โ‡„</button> ··· 185 <select id="recipient" bind:value={recipientHandle}> 186 <option value="alice">alice</option> 187 <option value="bob">bob</option> 188 </select> 189 </div> 190 </div> ··· 248 249 <section class="infrastructure"> 250 <h3>infrastructure</h3> 251 + <p class="infra-desc">this demo uses two separate PDSes for real cross-server messaging</p> 252 <div class="infra-grid"> 253 <a href="https://pds-message-demo.nate-8fe.workers.dev/xrpc/com.atproto.server.describeServer" target="_blank" class="infra-card"> 254 <div class="infra-icon"> ··· 260 </svg> 261 </div> 262 <div class="infra-content"> 263 + <div class="infra-label">alice's pds</div> 264 <div class="infra-value">pds-message-demo.nate-8fe.workers.dev</div> 265 <div class="infra-detail">cloudflare worker + durable objects</div> 266 </div> 267 </a> 268 269 + <a href="https://pds-message-demo-2.nate-8fe.workers.dev/xrpc/com.atproto.server.describeServer" target="_blank" class="infra-card"> 270 + <div class="infra-icon"> 271 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 272 + <rect x="2" y="3" width="20" height="18" rx="2"/> 273 + <line x1="2" y1="9" x2="22" y2="9"/> 274 + <circle cx="6" cy="6" r="1" fill="currentColor"/> 275 + <circle cx="10" cy="6" r="1" fill="currentColor"/> 276 + </svg> 277 + </div> 278 + <div class="infra-content"> 279 + <div class="infra-label">bob's pds</div> 280 + <div class="infra-value">pds-message-demo-2.nate-8fe.workers.dev</div> 281 + <div class="infra-detail">cloudflare worker + durable objects</div> 282 + </div> 283 + </a> 284 + 285 <a href="https://plc.directory/did:plc:x6io7svnbth4pikg2e63vvkx" target="_blank" class="infra-card"> 286 <div class="infra-icon"> 287 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> ··· 337 338 .container { 339 display: grid; 340 + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr); 341 gap: 1rem; 342 max-width: 1000px; 343 margin: 0 auto; ··· 348 background: #111; 349 border: 1px solid #222; 350 padding: 1rem; 351 + overflow: hidden; 352 + min-width: 0; 353 } 354 .center h2 { 355 font-size: 11px; ··· 513 color: #444; 514 font-size: 12px; 515 align-self: stretch; 516 + overflow: hidden; 517 + min-width: 0; 518 } 519 520 .state-summary { ··· 566 } 567 .infra-grid { 568 display: grid; 569 + grid-template-columns: repeat(2, 1fr); 570 gap: 1rem; 571 } 572 .infra-card {