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}