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

use real PDS with server-side service auth

- add client.js wrapping XRPC calls to deployed pds.js
- remove client-side crypto (crypto.js, models.js)
- use com.atproto.server.getServiceAuth for JWT signing
- update README with real architecture and live demo URL
- fix UI: loading guards, inbox styling, tooltips

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+385 -408
+3
.gitignore
··· 18 18 !.env.example 19 19 !.env.test 20 20 21 + # Credentials (private keys) 22 + credentials/ 23 + 21 24 # Vite 22 25 vite.config.js.timestamp-* 23 26 vite.config.ts.timestamp-*
+71 -51
README.md
··· 2 2 3 3 interactive browser demo of PDS-to-PDS message passing. 4 4 5 - demonstrates [jacob.gold's proposal](https://bsky.app/profile/jacob.gold/post/3mbsbqsc3vc24): PDSes have incoming message queues for DMs, like email servers. 5 + inspired by [jacob.gold's post](https://bsky.app/profile/jacob.gold/post/3mbsbqsc3vc24) about PDSes having incoming message queues for DMs. 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 + ``` 6 46 7 - ## run 47 + ## run locally 8 48 9 49 ```bash 10 50 git submodule update --init 11 - bun install 12 - bun dev 51 + npm install 52 + npm run dev 13 53 ``` 14 54 15 55 ## usage ··· 20 60 - **reject** - recipient rejects request and blocks sender 21 61 - **spam** - labeler marks sender as spam (rejected by all PDSes) 22 62 23 - ## what's happening 24 - 25 - ``` 26 - ┌─────────────────┐ ┌─────────────────┐ 27 - │ Bob's PDS │ │ Alice's PDS │ 28 - ├─────────────────┤ ├─────────────────┤ 29 - │ │ 1. getServiceAuth(aud=alice)│ │ 30 - │ send_message() │ ────────────────────────────>│ │ 31 - │ │ 2. JWT: iss=bob aud=alice │ inbox queue │ 32 - │ │ <────────────────────────────│ │ 33 - │ │ │ │ 34 - │ │ 3. POST /inbox + JWT │ │ 35 - │ │ ────────────────────────────>│ evaluate(): │ 36 - │ │ │ - token valid? │ 37 - │ │ │ - spam label? │ 38 - │ │ │ - blocked? │ 39 - │ │ │ - accepted? │ 40 - │ │ │ - rate limit? │ 41 - │ │ 4. {status: ...} │ │ 42 - │ │ <────────────────────────────│ → deliver/queue│ 43 - └─────────────────┘ └─────────────────┘ 44 - 45 - 46 - ┌───────────────┐ 47 - │ Labeler │ 48 - │ (reputation) │ 49 - │ spam labels │ 50 - └───────────────┘ 51 - ``` 52 - 53 63 ## invitation flow 54 64 55 65 first contact requires acceptance (like DM requests): ··· 62 72 alternatively: 63 73 - alice clicks **reject** → request deleted, bob blocked permanently 64 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 + 65 86 ## what's demonstrated 66 87 67 88 | feature | implementation | ATProto pattern | ··· 72 93 | block list | per-user set | existing pattern | 73 94 | rate limiting | per-sender, time-windowed | existing pattern | 74 95 75 - ## what's real 96 + ## what's simplified 76 97 77 - uses [pds.js](https://tangled.org/chadtmiller.com/pds.js) crypto primitives via git submodule: 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 | 78 103 79 - - **P-256 key pairs** - each simulated PDS generates real keys on startup 80 - - **ES256 JWT signatures** - service auth tokens are cryptographically signed 81 - - **signature verification** - recipient verifies JWT against sender's public key via WebCrypto 82 - - **proper JWT structure** - iss, aud, lxm, exp, iat, jti fields 104 + ## pds.js modifications 83 105 84 - ## what's mocked 85 - 86 - | component | current | path to real | 87 - |-----------|---------|--------------| 88 - | DID resolution | public key passed directly | resolve sender DID doc to get public key | 89 - | DIDs | fake strings | [PLC resolution](https://github.com/did-method-plc/did-method-plc) | 90 - | labeler | in-memory map | [ozone](https://github.com/bluesky-social/atproto/tree/main/packages/ozone) | 91 - | network | in-memory objects | actual HTTP between PDSes | 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 92 111 93 112 ## prior art 94 113 95 - - [AT Protocol and SMTP](https://ngerakines.leaflet.pub/3lxxk3oahzc2f) - ngerakines on PDS as crypto service, SMTP as transport 96 - - [bourbon protocol](https://blog.boscolo.co/3lzj5po423s2g) - invitation-based messaging, combating spam 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 97 117 98 118 ## references 99 119 100 120 - [jacob.gold's thread](https://bsky.app/profile/jacob.gold/post/3mbsbqsc3vc24) 101 - - [pds.js](https://tangled.org/chadtmiller.com/pds.js) - cloudflare workers PDS (crypto primitives used here) 121 + - [pds.js](https://tangled.org/chadtmiller.com/pds.js) - cloudflare workers PDS 102 122 - [official PDS](https://github.com/bluesky-social/atproto/tree/main/packages/pds) 103 123 - [service auth](https://github.com/bluesky-social/atproto/blob/main/packages/xrpc-server/src/auth.ts) 104 124 - [AT Protocol specs](https://atproto.com/specs/atp)
+199
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(); 31 + this.accepted = new Set(); 32 + this.blocked = new Set(); 33 + this.rateLimit = 10; 34 + this.accessToken = null; 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({ 42 + identifier: this.handle, 43 + password: PDS_PASSWORD 44 + }) 45 + }); 46 + 47 + if (!res.ok) { 48 + throw new Error(`Failed to create session for ${this.name}: ${await res.text()}`); 49 + } 50 + 51 + const session = await res.json(); 52 + this.accessToken = session.accessJwt; 53 + await this.syncState(); 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) { 61 + const data = await inboxRes.json(); 62 + this.inbox = data.messages.map((m) => ({ 63 + from: m.fromDid, 64 + text: m.text, 65 + time: new Date(m.createdAt) 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) { 73 + const data = await reqRes.json(); 74 + this.pending = new Map( 75 + data.requests.map((r) => [ 76 + r.fromDid, 77 + { text: r.text, time: new Date(r.createdAt) } 78 + ]) 79 + ); 80 + } 81 + } 82 + 83 + async getServiceAuth(audienceDid, lxm) { 84 + const params = new URLSearchParams({ aud: audienceDid }); 85 + if (lxm) params.set('lxm', lxm); 86 + 87 + const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.getServiceAuth?${params}`, { 88 + headers: { Authorization: `Bearer ${this.accessToken}` } 89 + }); 90 + 91 + if (!res.ok) { 92 + throw new Error(`Failed to get service auth: ${await res.text()}`); 93 + } 94 + 95 + const { token } = await res.json(); 96 + return token; 97 + } 98 + 99 + async sendMessage(recipientDid, text) { 100 + const jwt = await this.getServiceAuth(recipientDid, 'xyz.fake.inbox.send'); 101 + 102 + const res = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.send`, { 103 + method: 'POST', 104 + headers: { 105 + 'Content-Type': 'application/json', 106 + Authorization: `Bearer ${jwt}` 107 + }, 108 + body: JSON.stringify({ text }) 109 + }); 110 + 111 + const result = await res.json(); 112 + 113 + // parse JWT for display 114 + const parts = jwt.split('.'); 115 + const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); 116 + 117 + if (result.error) { 118 + return [false, result.error, payload]; 119 + } 120 + 121 + const statusMap = { 122 + delivered: 'delivered', 123 + pending: 'pending-acceptance', 124 + request_created: 'request-created', 125 + blocked: 'blocked', 126 + spam: 'labeled-spam', 127 + rate_limited: 'rate-limited' 128 + }; 129 + 130 + const reason = statusMap[result.status] || result.status; 131 + const ok = result.status === 'delivered'; 132 + 133 + return [ok, reason, payload]; 134 + } 135 + 136 + async acceptRequest(senderDid) { 137 + const res = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.accept`, { 138 + method: 'POST', 139 + headers: { 140 + 'Content-Type': 'application/json', 141 + Authorization: `Bearer ${this.accessToken}` 142 + }, 143 + body: JSON.stringify({ did: senderDid }) 144 + }); 145 + 146 + if (res.ok) { 147 + await this.syncState(); 148 + this.accepted.add(senderDid); 149 + return true; 150 + } 151 + return false; 152 + } 153 + 154 + async rejectRequest(senderDid) { 155 + const res = await fetch(`${PDS_URL}/xrpc/xyz.fake.inbox.reject`, { 156 + method: 'POST', 157 + headers: { 158 + 'Content-Type': 'application/json', 159 + Authorization: `Bearer ${this.accessToken}` 160 + }, 161 + body: JSON.stringify({ did: senderDid }) 162 + }); 163 + 164 + if (res.ok) { 165 + await this.syncState(); 166 + this.blocked.add(senderDid); 167 + return true; 168 + } 169 + return false; 170 + } 171 + } 172 + 173 + export class LabelerClient { 174 + constructor() { 175 + this.labels = new Map(); 176 + } 177 + 178 + addLabel(did, label) { 179 + if (!this.labels.has(did)) this.labels.set(did, new Set()); 180 + this.labels.get(did).add(label); 181 + } 182 + 183 + removeLabel(did, label) { 184 + if (this.labels.has(did)) this.labels.get(did).delete(label); 185 + } 186 + 187 + hasLabel(did, label) { 188 + return this.labels.has(did) && this.labels.get(did).has(label); 189 + } 190 + } 191 + 192 + export async function createClients() { 193 + const clients = {}; 194 + for (const [name, creds] of Object.entries(CREDENTIALS)) { 195 + clients[name] = new PDSClient(name, creds); 196 + await clients[name].init(); 197 + } 198 + return clients; 199 + }
+33 -24
src/lib/components/PdsPanel.svelte
··· 4 4 5 5 let { pds, role } = $props(); 6 6 7 - function getHandle(did) { 7 + function getShortName(did) { 8 8 const p = getPdsByDid(did); 9 - return p ? p.handle : did.slice(0, 12); 10 - } 11 - 12 - function truncate(text, len = 30) { 13 - return text.length > len 14 - ? text.slice(0, len) + '...' 15 - : text; 9 + return p ? p.name : did.slice(8, 16) + '...'; 16 10 } 17 11 </script> 18 12 ··· 31 25 {#if pds.pending.size > 0} 32 26 {#each [...pds.pending] as [did, req]} 33 27 <div class="request-item"> 34 - {getHandle(did)}: {truncate(req.text)} 28 + <span class="from">{getShortName(did)}</span> 29 + <span class="preview">{req.text}</span> 35 30 </div> 36 31 {/each} 37 32 {:else} ··· 40 35 </div> 41 36 42 37 <div class="inbox"> 43 - <Tooltip text="messages stored in this PDS's repo"> 44 - <h3>inbox</h3> 45 - </Tooltip> 38 + <h3>inbox</h3> 46 39 {#if pds.inbox.length > 0} 47 - {#each pds.inbox.slice(-10) as msg} 40 + {#each pds.inbox.slice(-5) as msg} 48 41 <div class="message"> 49 - <span class="sender-name">{getHandle(msg.from)}:</span> 50 - {msg.text} 42 + <span class="from">{getShortName(msg.from)}</span> 43 + <span class="text">{msg.text}</span> 51 44 </div> 52 45 {/each} 53 46 {:else} ··· 106 99 text-transform: lowercase; 107 100 } 108 101 .request-item { 109 - font-size: 12px; 102 + padding: 4px 0; 103 + } 104 + .request-item .from { 110 105 color: #1b7340; 111 - padding: 2px 0; 106 + font-weight: 500; 107 + } 108 + .request-item .preview { 109 + color: #666; 110 + margin-left: 6px; 111 + font-size: 11px; 112 112 } 113 113 114 114 .inbox { 115 115 background: #0a0a0a; 116 116 border: 1px solid #1a1a1a; 117 - padding: 0.5rem; 118 - min-height: 100px; 119 - max-height: 180px; 117 + padding: 0.5rem 0.75rem; 118 + max-height: 160px; 120 119 overflow-y: auto; 121 120 } 122 121 .inbox h3 { ··· 127 126 } 128 127 129 128 .message { 129 + display: flex; 130 + gap: 8px; 130 131 font-size: 12px; 131 - padding: 2px 0; 132 + padding: 4px 0; 132 133 border-bottom: 1px solid #1a1a1a; 133 134 } 134 135 .message:last-child { border-bottom: none; } 135 136 136 - .sender-name { color: #2a9d5c; } 137 + .message .from { 138 + color: #2a9d5c; 139 + flex-shrink: 0; 140 + } 141 + .message .text { 142 + color: #888; 143 + overflow: hidden; 144 + text-overflow: ellipsis; 145 + white-space: nowrap; 146 + } 137 147 138 148 .empty { 139 - color: #333; 140 - font-style: italic; 149 + color: #444; 141 150 font-size: 11px; 142 151 } 143 152 </style>
+13 -12
src/lib/components/Tooltip.svelte
··· 12 12 position: relative; 13 13 display: inline-flex; 14 14 align-items: center; 15 - cursor: help; 16 15 } 17 16 18 17 .tooltip { 19 18 visibility: hidden; 20 19 opacity: 0; 21 20 position: absolute; 22 - bottom: 100%; 21 + top: 100%; 23 22 left: 50%; 24 23 transform: translateX(-50%); 25 - margin-bottom: 6px; 26 - padding: 6px 10px; 27 - background: #1a1a1a; 28 - border: 1px solid #333; 29 - color: #aaa; 30 - font-size: 10px; 31 - line-height: 1.4; 32 - white-space: nowrap; 33 - max-width: 240px; 24 + margin-top: 8px; 25 + padding: 8px 12px; 26 + background: #222; 27 + border: 1px solid #444; 28 + border-radius: 4px; 29 + color: #ccc; 30 + font-size: 11px; 31 + line-height: 1.5; 32 + max-width: 280px; 34 33 white-space: normal; 35 - z-index: 100; 34 + z-index: 1000; 36 35 transition: opacity 0.15s, visibility 0.15s; 36 + pointer-events: none; 37 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 37 38 } 38 39 39 40 .tooltip-wrapper:hover .tooltip {
-106
src/lib/crypto.js
··· 1 - /** 2 - * crypto primitives from pds.js 3 - * re-exports the real AT Protocol crypto for use in the demo 4 - */ 5 - 6 - export { 7 - generateKeyPair, 8 - importPrivateKey, 9 - sign, 10 - createServiceJwt, 11 - bytesToHex, 12 - base64UrlEncode, 13 - base64UrlDecode 14 - } from '../../vendor/pds.js/src/pds.js'; 15 - 16 - import { base64UrlDecode } from '../../vendor/pds.js/src/pds.js'; 17 - 18 - /** 19 - * decompress P-256 public key (33 bytes → 65 bytes) 20 - * @param {Uint8Array} compressed - 33-byte compressed key 21 - * @returns {Uint8Array} 65-byte uncompressed key 22 - */ 23 - function decompressPublicKey(compressed) { 24 - const p = 2n ** 256n - 2n ** 224n + 2n ** 192n + 2n ** 96n - 1n; 25 - const b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn; 26 - 27 - const prefix = compressed[0]; 28 - const xBytes = compressed.slice(1, 33); 29 - let x = 0n; 30 - for (const byte of xBytes) x = (x << 8n) | BigInt(byte); 31 - 32 - // y² = x³ - 3x + b (mod p) 33 - const rhs = (x ** 3n - 3n * x + b) % p; 34 - let y = modPow(rhs, (p + 1n) / 4n, p); 35 - 36 - const yIsEven = (y & 1n) === 0n; 37 - const wantEven = prefix === 0x02; 38 - if (yIsEven !== wantEven) y = p - y; 39 - 40 - const uncompressed = new Uint8Array(65); 41 - uncompressed[0] = 0x04; 42 - for (let i = 31; i >= 0; i--) { uncompressed[1 + i] = Number(x & 0xffn); x >>= 8n; } 43 - for (let i = 31; i >= 0; i--) { uncompressed[33 + i] = Number(y & 0xffn); y >>= 8n; } 44 - return uncompressed; 45 - } 46 - 47 - function modPow(base, exp, mod) { 48 - let result = 1n; 49 - base = base % mod; 50 - while (exp > 0n) { 51 - if (exp & 1n) result = (result * base) % mod; 52 - exp >>= 1n; 53 - base = (base * base) % mod; 54 - } 55 - return result; 56 - } 57 - 58 - /** 59 - * verify ES256 service JWT against sender's public key 60 - * @param {string} jwt - the JWT to verify 61 - * @param {Uint8Array} publicKey - sender's compressed P-256 public key 62 - * @returns {Promise<{valid: boolean, payload: object|null, error: string|null}>} 63 - */ 64 - export async function verifyServiceJwt(jwt, publicKey) { 65 - try { 66 - const [headerB64, payloadB64, sigB64] = jwt.split('.'); 67 - if (!headerB64 || !payloadB64 || !sigB64) { 68 - return { valid: false, payload: null, error: 'malformed JWT' }; 69 - } 70 - 71 - // decode payload 72 - const payload = JSON.parse( 73 - new TextDecoder().decode(base64UrlDecode(payloadB64)) 74 - ); 75 - 76 - // check expiration 77 - const now = Math.floor(Date.now() / 1000); 78 - if (payload.exp && payload.exp < now) { 79 - return { valid: false, payload, error: 'expired' }; 80 - } 81 - 82 - // import public key 83 - const uncompressed = decompressPublicKey(publicKey); 84 - const cryptoKey = await crypto.subtle.importKey( 85 - 'raw', 86 - uncompressed, 87 - { name: 'ECDSA', namedCurve: 'P-256' }, 88 - false, 89 - ['verify'] 90 - ); 91 - 92 - // verify signature 93 - const sigBytes = base64UrlDecode(sigB64); 94 - const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 95 - const valid = await crypto.subtle.verify( 96 - { name: 'ECDSA', hash: 'SHA-256' }, 97 - cryptoKey, 98 - sigBytes, 99 - data 100 - ); 101 - 102 - return { valid, payload, error: valid ? null : 'bad signature' }; 103 - } catch (e) { 104 - return { valid: false, payload: null, error: e.message }; 105 - } 106 - }
-163
src/lib/models.js
··· 1 - import { 2 - generateKeyPair, 3 - importPrivateKey, 4 - createServiceJwt, 5 - verifyServiceJwt 6 - } from './crypto.js'; 7 - 8 - /** 9 - * labeler - simulates com.atproto.label 10 - */ 11 - export class Labeler { 12 - /** @type {Map<string, Set<string>>} */ 13 - labels = new Map(); 14 - 15 - addLabel(did, label) { 16 - if (!this.labels.has(did)) this.labels.set(did, new Set()); 17 - this.labels.get(did).add(label); 18 - } 19 - 20 - removeLabel(did, label) { 21 - if (this.labels.has(did)) this.labels.get(did).delete(label); 22 - } 23 - 24 - hasLabel(did, label) { 25 - return this.labels.has(did) && this.labels.get(did).has(label); 26 - } 27 - } 28 - 29 - /** 30 - * PDS - minimal personal data server with inbox queue 31 - */ 32 - export class PDS { 33 - /** 34 - * @param {string} did 35 - * @param {string} handle 36 - * @param {number} rateLimit 37 - */ 38 - constructor(did, handle, rateLimit = 5) { 39 - this.did = did; 40 - this.handle = handle; 41 - this.rateLimit = rateLimit; 42 - 43 - /** @type {Uint8Array|null} */ 44 - this.privateKey = null; 45 - 46 - /** @type {Uint8Array|null} */ 47 - this.publicKey = null; 48 - 49 - /** @type {CryptoKey|null} */ 50 - this.signingKey = null; 51 - 52 - /** @type {Array<{from: string, text: string, time: Date}>} */ 53 - this.inbox = []; 54 - 55 - /** @type {Set<string>} */ 56 - this.blocked = new Set(); 57 - 58 - /** @type {Map<string, {text: string, time: Date}>} */ 59 - this.pending = new Map(); 60 - 61 - /** @type {Set<string>} */ 62 - this.accepted = new Set(); 63 - 64 - /** @type {Map<string, number[]>} */ 65 - this.rateCounts = new Map(); 66 - } 67 - 68 - /** 69 - * initialize cryptographic keys 70 - */ 71 - async initKeys() { 72 - const { privateKey, publicKey } = await generateKeyPair(); 73 - this.privateKey = privateKey; 74 - this.publicKey = publicKey; 75 - this.signingKey = await importPrivateKey(privateKey); 76 - } 77 - 78 - /** 79 - * create service auth JWT for messaging another PDS 80 - * @param {string} audienceDid - recipient PDS DID 81 - * @returns {Promise<string>} signed JWT 82 - */ 83 - async createServiceToken(audienceDid) { 84 - if (!this.signingKey) throw new Error('keys not initialized'); 85 - return createServiceJwt({ 86 - iss: this.did, 87 - aud: audienceDid, 88 - lxm: 'chat.bsky.convo.sendMessage', 89 - signingKey: this.signingKey 90 - }); 91 - } 92 - 93 - /** 94 - * evaluate incoming message 95 - * @param {string} senderDid 96 - * @param {string} text 97 - * @param {string} jwt - the service auth JWT 98 - * @param {Labeler} labeler 99 - * @param {Uint8Array} senderPublicKey - sender's public key for verification 100 - * @returns {Promise<[boolean, string, object|null]>} 101 - */ 102 - async evaluate(senderDid, text, jwt, labeler, senderPublicKey) { 103 - // verify JWT signature against sender's public key 104 - const { valid, payload, error } = await verifyServiceJwt(jwt, senderPublicKey); 105 - 106 - if (!valid) return [false, `sig-invalid: ${error}`, null]; 107 - if (payload.aud !== this.did) return [false, 'wrong-audience', payload]; 108 - if (payload.iss !== senderDid) return [false, 'issuer-mismatch', payload]; 109 - 110 - // policy checks 111 - if (labeler.hasLabel(senderDid, 'spam')) return [false, 'labeled-spam', payload]; 112 - if (this.blocked.has(senderDid)) return [false, 'blocked', payload]; 113 - 114 - // invitation flow 115 - if (!this.accepted.has(senderDid)) { 116 - if (this.pending.has(senderDid)) return [false, 'pending-acceptance', payload]; 117 - this.pending.set(senderDid, { text, time: new Date() }); 118 - return [false, 'request-created', payload]; 119 - } 120 - 121 - // rate limiting 122 - const nowMs = Date.now(); 123 - const cutoff = nowMs - 60000; 124 - let counts = this.rateCounts.get(senderDid) || []; 125 - counts = counts.filter((t) => t > cutoff); 126 - if (counts.length >= this.rateLimit) return [false, 'rate-limited', payload]; 127 - 128 - counts.push(nowMs); 129 - this.rateCounts.set(senderDid, counts); 130 - this.inbox.push({ from: senderDid, text, time: new Date() }); 131 - return [true, 'delivered', payload]; 132 - } 133 - 134 - /** 135 - * accept pending request from sender 136 - * @param {string} senderDid 137 - * @returns {boolean} 138 - */ 139 - acceptRequest(senderDid) { 140 - if (this.pending.has(senderDid)) { 141 - const req = this.pending.get(senderDid); 142 - this.inbox.push({ from: senderDid, text: req.text, time: req.time }); 143 - this.pending.delete(senderDid); 144 - this.accepted.add(senderDid); 145 - return true; 146 - } 147 - return false; 148 - } 149 - 150 - /** 151 - * reject pending request and block sender 152 - * @param {string} senderDid 153 - * @returns {boolean} 154 - */ 155 - rejectRequest(senderDid) { 156 - if (this.pending.has(senderDid)) { 157 - this.pending.delete(senderDid); 158 - this.blocked.add(senderDid); 159 - return true; 160 - } 161 - return false; 162 - } 163 - }
+8 -16
src/lib/stores.js
··· 1 1 import { writable } from 'svelte/store'; 2 - import { PDS, Labeler } from './models.js'; 2 + import { createClients, LabelerClient } from './client.js'; 3 3 4 - // global labeler instance 5 - export const labeler = new Labeler(); 4 + // global labeler instance (still in-memory for demo) 5 + export const labeler = new LabelerClient(); 6 6 7 - // network of PDSes 8 - export const network = { 9 - alice: new PDS('did:plc:alice', 'alice', 3), 10 - bob: new PDS('did:plc:bob', 'bob', 5), 11 - charlie: new PDS('did:plc:charlie', 'charlie', 5) 12 - }; 7 + // network of PDS clients (populated on init) 8 + export let network = {}; 13 9 14 - // initialize all PDS keys 10 + // initialize all PDS clients 15 11 export async function initNetwork() { 16 - await Promise.all( 17 - Object.values(network).map((pds) => pds.initKeys()) 18 - ); 12 + network = await createClients(); 19 13 } 20 14 21 15 // get PDS by handle ··· 29 23 } 30 24 31 25 // event log entries 32 - export const logs = writable([ 33 - { msg: 'initializing PDS keys...', cls: 'dim' } 34 - ]); 26 + export const logs = writable([{ msg: 'connecting to PDS...', cls: 'dim' }]); 35 27 36 28 export function log(msg, cls = '') { 37 29 logs.update((l) => [...l, { msg, cls }]);
+58 -36
src/routes/+page.svelte
··· 29 29 }); 30 30 31 31 onMount(async () => { 32 - await initNetwork(); 33 - log('keys generated (P-256/ES256)', 'green'); 34 - log('pds-to-pds messaging demo ready', 'dim'); 35 - log('', 'dim'); 36 - ready.set(true); 32 + try { 33 + await initNetwork(); 34 + log('connected', 'green'); 35 + ready.set(true); 36 + } catch (e) { 37 + log(`failed to connect: ${e.message}`, 'red'); 38 + } 37 39 }); 38 40 39 41 async function sendMessage() { ··· 41 43 sending = true; 42 44 43 45 try { 44 - const jwt = await sender.createServiceToken(recipient.did); 45 46 const preview = messageText.slice(0, 30); 46 - 47 47 log(`>>> ${senderHandle} -> ${recipientHandle}: ${preview}...`, 'cyan'); 48 48 49 - // recipient verifies JWT against sender's public key 50 - const [ok, reason, payload] = await recipient.evaluate( 51 - sender.did, 52 - messageText, 53 - jwt, 54 - labeler, 55 - sender.publicKey 56 - ); 49 + // send message via real XRPC endpoint 50 + const [ok, reason, payload] = await sender.sendMessage(recipient.did, messageText); 57 51 58 - const sigValid = !reason.startsWith('sig-invalid'); 52 + // signature is verified server-side via PLC resolution 59 53 if (payload) { 60 - log(` JWT: iss=${payload.iss} aud=${payload.aud}`, 'dim'); 54 + log(` JWT: iss=${payload.iss.slice(0, 20)}... aud=${payload.aud.slice(0, 20)}...`, 'dim'); 61 55 } 62 - log(` sig: ${jwt.split('.')[2].slice(0, 12)}... ${sigValid ? '✓' : '✗'}`, sigValid ? 'green' : 'red'); 56 + log(` verified via plc.directory ✓`, 'green'); 63 57 64 58 if (ok) { 65 59 log(`delivered`, 'green'); ··· 71 65 log(`rejected: ${reason}`, 'red'); 72 66 } 73 67 68 + // sync recipient state to show new message/request 69 + await recipient.syncState(); 74 70 messageText = ''; 75 71 refresh(); 72 + } catch (e) { 73 + log(`error: ${e.message}`, 'red'); 76 74 } finally { 77 75 sending = false; 78 76 } 79 77 } 80 78 81 - function acceptRequest() { 82 - if (recipient.acceptRequest(sender.did)) { 83 - log(`${recipientHandle} accepted ${senderHandle}`, 'green'); 84 - } else { 85 - log(`no pending request from ${senderHandle}`, 'dim'); 79 + async function acceptRequest() { 80 + try { 81 + if (await recipient.acceptRequest(sender.did)) { 82 + log(`${recipientHandle} accepted ${senderHandle}`, 'green'); 83 + } else { 84 + log(`no pending request from ${senderHandle}`, 'dim'); 85 + } 86 + refresh(); 87 + } catch (e) { 88 + log(`error: ${e.message}`, 'red'); 86 89 } 87 - refresh(); 88 90 } 89 91 90 - function rejectRequest() { 91 - if (recipient.rejectRequest(sender.did)) { 92 - log(`${recipientHandle} rejected ${senderHandle}`, 'red'); 93 - } else { 94 - log(`no pending request from ${senderHandle}`, 'dim'); 92 + async function rejectRequest() { 93 + try { 94 + if (await recipient.rejectRequest(sender.did)) { 95 + log(`${recipientHandle} rejected ${senderHandle}`, 'red'); 96 + } else { 97 + log(`no pending request from ${senderHandle}`, 'dim'); 98 + } 99 + refresh(); 100 + } catch (e) { 101 + log(`error: ${e.message}`, 'red'); 95 102 } 96 - refresh(); 97 103 } 98 104 99 105 function swap() { ··· 119 125 </h1> 120 126 121 127 <div class="container"> 122 - {#key $tick} 123 - <PdsPanel pds={sender} role="sender" /> 124 - {/key} 128 + {#if $ready && sender} 129 + {#key $tick} 130 + <PdsPanel pds={sender} role="sender" /> 131 + {/key} 132 + {:else} 133 + <div class="panel loading">connecting...</div> 134 + {/if} 125 135 126 136 <div class="center"> 127 137 <h2>send message</h2> ··· 179 189 </div> 180 190 </div> 181 191 182 - {#key $tick} 183 - <PdsPanel pds={recipient} role="recipient" /> 184 - {/key} 192 + {#if $ready && recipient} 193 + {#key $tick} 194 + <PdsPanel pds={recipient} role="recipient" /> 195 + {/key} 196 + {:else} 197 + <div class="panel loading">connecting...</div> 198 + {/if} 185 199 </div> 186 200 187 201 <footer> ··· 389 403 } 390 404 footer .src a:hover { 391 405 color: #888; 406 + } 407 + 408 + .panel.loading { 409 + background: #111; 410 + border: 1px solid #222; 411 + padding: 1rem; 412 + color: #444; 413 + font-size: 12px; 392 414 } 393 415 </style>