this repo has no description sites.wisp.place/zzstoatzz.io/pds-message-poc
pds messaging
at main 282 lines 7.2 kB view raw
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 8const PDS_PASSWORD = 'pds-message-demo-2026'; 9 10const 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) 24async 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 36export 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(); 45 this.accepted = new Set(); 46 this.blocked = new Set(); 47 this.accessToken = null; 48 } 49 50 async init() { 51 const res = await fetch(`${this.pdsUrl}/xrpc/com.atproto.server.createSession`, { 52 method: 'POST', 53 headers: { 'Content-Type': 'application/json' }, 54 body: JSON.stringify({ 55 identifier: this.handle, 56 password: PDS_PASSWORD 57 }) 58 }); 59 60 if (!res.ok) { 61 throw new Error(`Failed to create session for ${this.name}: ${await res.text()}`); 62 } 63 64 const session = await res.json(); 65 this.accessToken = session.accessJwt; 66 await this.syncState(); 67 } 68 69 async syncState() { 70 const inboxRes = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.list`, { 71 headers: { Authorization: `Bearer ${this.accessToken}` } 72 }); 73 if (inboxRes.ok) { 74 const data = await inboxRes.json(); 75 this.inbox = data.messages.map((m) => ({ 76 from: m.fromDid, 77 text: m.text, 78 time: new Date(m.createdAt) 79 })); 80 } 81 82 const reqRes = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.listRequests`, { 83 headers: { Authorization: `Bearer ${this.accessToken}` } 84 }); 85 if (reqRes.ok) { 86 const data = await reqRes.json(); 87 this.pending = new Map( 88 data.requests.map((r) => [ 89 r.fromDid, 90 { text: r.text, time: new Date(r.createdAt) } 91 ]) 92 ); 93 } 94 95 const stateRes = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.getState`, { 96 headers: { Authorization: `Bearer ${this.accessToken}` } 97 }); 98 if (stateRes.ok) { 99 const data = await stateRes.json(); 100 this.accepted = new Set(data.accepted); 101 this.blocked = new Set(data.blocked); 102 } 103 } 104 105 async getServiceAuth(audienceDid, lxm) { 106 const params = new URLSearchParams({ aud: audienceDid }); 107 if (lxm) params.set('lxm', lxm); 108 109 const res = await fetch(`${this.pdsUrl}/xrpc/com.atproto.server.getServiceAuth?${params}`, { 110 headers: { Authorization: `Bearer ${this.accessToken}` } 111 }); 112 113 if (!res.ok) { 114 throw new Error(`Failed to get service auth: ${await res.text()}`); 115 } 116 117 const { token } = await res.json(); 118 return token; 119 } 120 121 async sendMessage(recipientDid, text) { 122 // get service auth JWT from OUR PDS 123 const jwt = await this.getServiceAuth(recipientDid, 'xyz.fake.inbox.send'); 124 125 // resolve recipient's PDS and send the message THERE (cross-PDS!) 126 const recipientPdsUrl = await resolvePdsUrl(recipientDid); 127 128 const res = await fetch(`${recipientPdsUrl}/xrpc/xyz.fake.inbox.send`, { 129 method: 'POST', 130 headers: { 131 'Content-Type': 'application/json', 132 Authorization: `Bearer ${jwt}` 133 }, 134 body: JSON.stringify({ text }) 135 }); 136 137 const result = await res.json(); 138 139 // parse JWT for display 140 const parts = jwt.split('.'); 141 const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); 142 143 if (result.error) { 144 return [false, result.error, payload]; 145 } 146 147 const statusMap = { 148 delivered: 'delivered', 149 pending: 'pending-acceptance', 150 request_created: 'request-created', 151 blocked: 'blocked', 152 spam: 'labeled-spam', 153 rate_limited: 'rate-limited' 154 }; 155 156 const reason = statusMap[result.status] || result.status; 157 const ok = result.status === 'delivered'; 158 159 return [ok, reason, payload]; 160 } 161 162 async acceptRequest(senderDid) { 163 const res = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.accept`, { 164 method: 'POST', 165 headers: { 166 'Content-Type': 'application/json', 167 Authorization: `Bearer ${this.accessToken}` 168 }, 169 body: JSON.stringify({ did: senderDid }) 170 }); 171 172 if (res.ok) { 173 await this.syncState(); 174 this.accepted.add(senderDid); 175 return true; 176 } 177 return false; 178 } 179 180 async rejectRequest(senderDid) { 181 const res = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.reject`, { 182 method: 'POST', 183 headers: { 184 'Content-Type': 'application/json', 185 Authorization: `Bearer ${this.accessToken}` 186 }, 187 body: JSON.stringify({ did: senderDid }) 188 }); 189 190 if (res.ok) { 191 await this.syncState(); 192 this.blocked.add(senderDid); 193 return true; 194 } 195 return false; 196 } 197 198 async unblockSender(senderDid) { 199 const res = await fetch(`${this.pdsUrl}/xrpc/xyz.fake.inbox.unblock`, { 200 method: 'POST', 201 headers: { 202 'Content-Type': 'application/json', 203 Authorization: `Bearer ${this.accessToken}` 204 }, 205 body: JSON.stringify({ did: senderDid }) 206 }); 207 208 if (res.ok) { 209 await this.syncState(); 210 this.blocked.delete(senderDid); 211 return true; 212 } 213 return false; 214 } 215} 216 217const LABELER_URL = 'https://spam-labeler.nate-8fe.workers.dev'; 218 219export class LabelerClient { 220 constructor() { 221 // cache of spam-labeled DIDs (synced with remote) 222 this.spamDids = new Set(); 223 } 224 225 async addLabel(did, label) { 226 if (label !== 'spam') return; 227 try { 228 const res = await fetch(`${LABELER_URL}/xrpc/xyz.fake.labeler.addSpam`, { 229 method: 'POST', 230 headers: { 'Content-Type': 'application/json' }, 231 body: JSON.stringify({ did }) 232 }); 233 if (res.ok) { 234 this.spamDids.add(did); 235 } 236 } catch (e) { 237 console.error('labeler addLabel failed:', e); 238 } 239 } 240 241 async removeLabel(did, label) { 242 if (label !== 'spam') return; 243 try { 244 const res = await fetch(`${LABELER_URL}/xrpc/xyz.fake.labeler.removeSpam`, { 245 method: 'POST', 246 headers: { 'Content-Type': 'application/json' }, 247 body: JSON.stringify({ did }) 248 }); 249 if (res.ok) { 250 this.spamDids.delete(did); 251 } 252 } catch (e) { 253 console.error('labeler removeLabel failed:', e); 254 } 255 } 256 257 hasLabel(did, label) { 258 if (label !== 'spam') return false; 259 return this.spamDids.has(did); 260 } 261 262 async sync() { 263 try { 264 const res = await fetch(`${LABELER_URL}/xrpc/xyz.fake.labeler.listSpam`); 265 if (res.ok) { 266 const { dids } = await res.json(); 267 this.spamDids = new Set(dids); 268 } 269 } catch (e) { 270 console.error('labeler sync failed:', e); 271 } 272 } 273} 274 275export async function createClients() { 276 const clients = {}; 277 for (const [name, creds] of Object.entries(CREDENTIALS)) { 278 clients[name] = new PDSClient(name, creds); 279 await clients[name].init(); 280 } 281 return clients; 282}