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>

Changed files
+175 -248
src
lib
routes
+83 -224
README.md
··· 6 6 7 7 **live demo**: [sites.wisp.place/zzstoatzz.io/pds-message-poc](https://sites.wisp.place/zzstoatzz.io/pds-message-poc) 8 8 9 - ## architecture 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 10 18 11 - this demo uses a real PDS deployment: 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: 12 29 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 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` 17 33 18 34 ``` 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 - └───────────────┘ 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: ...} │ │ 45 55 ``` 46 56 47 - ## run locally 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: 48 65 49 66 ```bash 50 - git submodule update --init 51 - npm install 52 - npm run dev 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 53 78 ``` 54 79 55 - ## usage 80 + </details> 81 + 82 + <details> 83 + <summary>usage</summary> 56 84 57 85 - type a message, select sender → recipient 58 86 - **send** - initiates message (first message creates a request) ··· 60 88 - **reject** - recipient rejects request and blocks sender 61 89 - **spam** - labeler marks sender as spam (rejected by all PDSes) 62 90 63 - ## invitation flow 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. 64 92 65 - first contact requires acceptance (like DM requests): 93 + </details> 66 94 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 95 + <details> 96 + <summary>what's real</summary> 76 97 77 98 | component | implementation | 78 99 |-----------|----------------| 79 - | PDS | [pds.js](https://tangled.org/chadtmiller.com/pds.js) fork on Cloudflare Workers | 100 + | PDSes | two [pds.js](https://tangled.org/chadtmiller.com/pds.js) deployments on Cloudflare Workers | 80 101 | DIDs | real `did:plc` registered with [plc.directory](https://plc.directory) | 81 102 | service auth | server-side JWT signing via `com.atproto.server.getServiceAuth` | 82 103 | 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 104 + | labeler | real ATProto labeler with secp256k1 signing | 105 + | cross-PDS messaging | bob's PDS signs, alice's PDS verifies via DID resolution | 97 106 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 | 107 + </details> 103 108 104 - ## pds.js modifications 109 + <details> 110 + <summary>pds.js modifications</summary> 105 111 106 112 our fork adds: 107 - - `xyz.fake.inbox.*` XRPC endpoints (send, list, listRequests, accept, reject) 113 + - `xyz.fake.inbox.*` XRPC endpoints (send, list, listRequests, accept, reject, unblock, getState) 108 114 - inbox tables in SQLite schema 109 115 - PLC resolution for DID → public key during JWT verification 110 116 - `com.atproto.server.getServiceAuth` for server-side JWT signing 117 + - spam labeler check before message delivery 111 118 112 - ## prior art 119 + </details> 120 + 121 + <details> 122 + <summary>references</summary> 113 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 114 126 - [AT Protocol and SMTP](https://ngerakines.leaflet.pub/3lxxk3oahzc2f) - ngerakines on PDS as crypto service 115 127 - [bourbon protocol](https://blog.boscolo.co/3lzj5po423s2g) - invitation-based messaging 116 128 - [How Streamplace Works](https://stream.place/blog/how-streamplace-works-embedded-pds) - embedded PDS pattern 117 129 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 130 </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)
+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 1 /** 2 - * PDS client - wraps XRPC calls to the deployed pds.js instance 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 3 6 */ 4 7 5 - const PDS_URL = 'https://pds-message-demo.nate-8fe.workers.dev'; 6 8 const PDS_PASSWORD = 'pds-message-demo-2026'; 7 9 8 10 const CREDENTIALS = { 9 11 alice: { 10 12 handle: 'alice.pds-message-demo.nate-8fe.workers.dev', 11 - did: 'did:plc:cmadossymmii3izkabdbp5en' 13 + did: 'did:plc:cmadossymmii3izkabdbp5en', 14 + pdsUrl: 'https://pds-message-demo.nate-8fe.workers.dev' 12 15 }, 13 16 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' 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 20 } 21 21 }; 22 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 + 23 36 export class PDSClient { 24 37 constructor(name, creds) { 25 38 this.name = name; 26 39 this.did = creds.did; 27 40 this.handle = creds.handle; 41 + this.pdsUrl = creds.pdsUrl; 28 42 29 43 this.inbox = []; 30 44 this.pending = new Map(); ··· 35 49 } 36 50 37 51 async init() { 38 - const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createSession`, { 52 + const res = await fetch(`${this.pdsUrl}/xrpc/com.atproto.server.createSession`, { 39 53 method: 'POST', 40 54 headers: { 'Content-Type': 'application/json' }, 41 55 body: JSON.stringify({ ··· 54 68 } 55 69 56 70 async syncState() { 57 - const inboxRes = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.list`, { 71 + const inboxRes = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.list`, { 58 72 headers: { Authorization: `Bearer ${this.accessToken}` } 59 73 }); 60 74 if (inboxRes.ok) { ··· 66 80 })); 67 81 } 68 82 69 - const reqRes = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.listRequests`, { 83 + const reqRes = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.listRequests`, { 70 84 headers: { Authorization: `Bearer ${this.accessToken}` } 71 85 }); 72 86 if (reqRes.ok) { ··· 79 93 ); 80 94 } 81 95 82 - const stateRes = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.getState`, { 96 + const stateRes = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.getState`, { 83 97 headers: { Authorization: `Bearer ${this.accessToken}` } 84 98 }); 85 99 if (stateRes.ok) { ··· 93 107 const params = new URLSearchParams({ aud: audienceDid }); 94 108 if (lxm) params.set('lxm', lxm); 95 109 96 - const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.getServiceAuth?${params}`, { 110 + const res = await fetch(`${this.pdsUrl}/xrpc/com.atproto.server.getServiceAuth?${params}`, { 97 111 headers: { Authorization: `Bearer ${this.accessToken}` } 98 112 }); 99 113 ··· 106 120 } 107 121 108 122 async sendMessage(recipientDid, text) { 123 + // get service auth JWT from OUR PDS 109 124 const jwt = await this.getServiceAuth(recipientDid, 'xyz.fake.inbox.send'); 110 125 111 - const res = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.send`, { 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`, { 112 130 method: 'POST', 113 131 headers: { 114 132 'Content-Type': 'application/json', ··· 143 161 } 144 162 145 163 async acceptRequest(senderDid) { 146 - const res = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.accept`, { 164 + const res = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.accept`, { 147 165 method: 'POST', 148 166 headers: { 149 167 'Content-Type': 'application/json', ··· 161 179 } 162 180 163 181 async rejectRequest(senderDid) { 164 - const res = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.reject`, { 182 + const res = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.reject`, { 165 183 method: 'POST', 166 184 headers: { 167 185 'Content-Type': 'application/json', ··· 179 197 } 180 198 181 199 async unblockSender(senderDid) { 182 - const res = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.unblock`, { 200 + const res = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.unblock`, { 183 201 method: 'POST', 184 202 headers: { 185 203 'Content-Type': 'application/json',
+2
src/lib/components/PdsPanel.svelte
··· 56 56 padding: 1rem; 57 57 display: flex; 58 58 flex-direction: column; 59 + overflow: hidden; 60 + min-width: 0; 59 61 } 60 62 61 63 h2 {
+24 -6
src/routes/+page.svelte
··· 177 177 <select id="sender" bind:value={senderHandle}> 178 178 <option value="alice">alice</option> 179 179 <option value="bob">bob</option> 180 - <option value="charlie">charlie</option> 181 180 </select> 182 181 </div> 183 182 <button class="swap" onclick={swap} aria-label="swap sender and recipient">⇄</button> ··· 186 185 <select id="recipient" bind:value={recipientHandle}> 187 186 <option value="alice">alice</option> 188 187 <option value="bob">bob</option> 189 - <option value="charlie">charlie</option> 190 188 </select> 191 189 </div> 192 190 </div> ··· 250 248 251 249 <section class="infrastructure"> 252 250 <h3>infrastructure</h3> 253 - <p class="infra-desc">this demo is backed by real ATProto services</p> 251 + <p class="infra-desc">this demo uses two separate PDSes for real cross-server messaging</p> 254 252 <div class="infra-grid"> 255 253 <a href="https://pds-message-demo.nate-8fe.workers.dev/xrpc/com.atproto.server.describeServer" target="_blank" class="infra-card"> 256 254 <div class="infra-icon"> ··· 262 260 </svg> 263 261 </div> 264 262 <div class="infra-content"> 265 - <div class="infra-label">personal data server</div> 263 + <div class="infra-label">alice's pds</div> 266 264 <div class="infra-value">pds-message-demo.nate-8fe.workers.dev</div> 267 265 <div class="infra-detail">cloudflare worker + durable objects</div> 268 266 </div> 269 267 </a> 270 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 + 271 285 <a href="https://plc.directory/did:plc:x6io7svnbth4pikg2e63vvkx" target="_blank" class="infra-card"> 272 286 <div class="infra-icon"> 273 287 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> ··· 323 337 324 338 .container { 325 339 display: grid; 326 - grid-template-columns: 1fr 2fr 1fr; 340 + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr); 327 341 gap: 1rem; 328 342 max-width: 1000px; 329 343 margin: 0 auto; ··· 334 348 background: #111; 335 349 border: 1px solid #222; 336 350 padding: 1rem; 351 + overflow: hidden; 352 + min-width: 0; 337 353 } 338 354 .center h2 { 339 355 font-size: 11px; ··· 497 513 color: #444; 498 514 font-size: 12px; 499 515 align-self: stretch; 516 + overflow: hidden; 517 + min-width: 0; 500 518 } 501 519 502 520 .state-summary { ··· 548 566 } 549 567 .infra-grid { 550 568 display: grid; 551 - grid-template-columns: repeat(3, 1fr); 569 + grid-template-columns: repeat(2, 1fr); 552 570 gap: 1rem; 553 571 } 554 572 .infra-card {