@botcord/botcord 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,752 @@
1
+ /**
2
+ * HTTP client for BotCord Hub REST API.
3
+ * Handles JWT token lifecycle and request signing.
4
+ */
5
+ import { randomBytes, randomUUID } from "node:crypto";
6
+ import { buildSignedEnvelope, signChallenge } from "./crypto.js";
7
+ import { normalizeAndValidateHubUrl } from "./hub-url.js";
8
+ import type {
9
+ BotCordAccountConfig,
10
+ BotCordMessageEnvelope,
11
+ InboxPollResponse,
12
+ SendResponse,
13
+ RoomInfo,
14
+ AgentInfo,
15
+ ContactInfo,
16
+ ContactRequestInfo,
17
+ FileUploadResponse,
18
+ MessageAttachment,
19
+ WalletSummary,
20
+ WalletTransaction,
21
+ WalletLedgerResponse,
22
+ TopupResponse,
23
+ WithdrawalResponse,
24
+ SubscriptionProduct,
25
+ Subscription,
26
+ } from "./types.js";
27
+
28
+ const MAX_RETRIES = 2;
29
+ const RETRY_BASE_MS = 1000;
30
+
31
+ export class BotCordClient {
32
+ private hubUrl: string;
33
+ private agentId: string;
34
+ private keyId: string;
35
+ private privateKey: string;
36
+ private jwtToken: string | null = null;
37
+ private tokenExpiresAt = 0;
38
+
39
+ constructor(config: BotCordAccountConfig) {
40
+ if (!config.hubUrl || !config.agentId || !config.keyId || !config.privateKey) {
41
+ throw new Error("BotCord client requires hubUrl, agentId, keyId, and privateKey");
42
+ }
43
+ this.hubUrl = normalizeAndValidateHubUrl(config.hubUrl);
44
+ this.agentId = config.agentId;
45
+ this.keyId = config.keyId;
46
+ this.privateKey = config.privateKey;
47
+ }
48
+
49
+ // ── Token management ──────────────────────────────────────────
50
+
51
+ async ensureToken(): Promise<string> {
52
+ if (this.jwtToken && Date.now() / 1000 < this.tokenExpiresAt - 60) {
53
+ return this.jwtToken;
54
+ }
55
+ return this.refreshToken();
56
+ }
57
+
58
+ private async refreshToken(): Promise<string> {
59
+ // POST /registry/agents/{id}/token/refresh with nonce signature
60
+ // Generate a random 32-byte nonce as base64 (matches Hub expectation)
61
+ const nonce = randomBytes(32).toString("base64");
62
+ const sig = signChallenge(this.privateKey, nonce);
63
+
64
+ const resp = await fetch(`${this.hubUrl}/registry/agents/${this.agentId}/token/refresh`, {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify({
68
+ key_id: this.keyId,
69
+ nonce,
70
+ sig,
71
+ }),
72
+ signal: AbortSignal.timeout(10000),
73
+ });
74
+
75
+ if (!resp.ok) {
76
+ const body = await resp.text().catch(() => "");
77
+ throw new Error(`Token refresh failed: ${resp.status} ${body}`);
78
+ }
79
+
80
+ const data = (await resp.json()) as { agent_token: string; token?: string; expires_at?: number };
81
+ this.jwtToken = data.agent_token || data.token!;
82
+ // Default 24h expiry if not provided
83
+ this.tokenExpiresAt = data.expires_at ?? Date.now() / 1000 + 86400;
84
+ return this.jwtToken;
85
+ }
86
+
87
+ // ── Authenticated fetch with rate-limit retry ─────────────────
88
+
89
+ private async hubFetch(path: string, init: RequestInit = {}): Promise<Response> {
90
+ const token = await this.ensureToken();
91
+
92
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
93
+ const headers: Record<string, string> = {
94
+ Authorization: `Bearer ${token}`,
95
+ ...((init.headers as Record<string, string>) ?? {}),
96
+ };
97
+ // Set Content-Type for JSON bodies, but not for FormData (browser/node sets boundary automatically)
98
+ if (init.body && !(init.body instanceof FormData)) {
99
+ headers["Content-Type"] = "application/json";
100
+ }
101
+
102
+ const resp = await fetch(`${this.hubUrl}${path}`, {
103
+ ...init,
104
+ headers,
105
+ signal: AbortSignal.timeout(30000),
106
+ });
107
+
108
+ if (resp.ok) return resp;
109
+
110
+ // Token expired — refresh and retry
111
+ if (resp.status === 401 && attempt === 0) {
112
+ await this.refreshToken();
113
+ continue;
114
+ }
115
+
116
+ // Rate limited — retry with backoff
117
+ if (resp.status === 429 && attempt < MAX_RETRIES) {
118
+ const retryAfter = parseInt(resp.headers.get("Retry-After") || "", 10);
119
+ const delayMs = retryAfter > 0 ? retryAfter * 1000 : RETRY_BASE_MS * (attempt + 1);
120
+ await new Promise((r) => setTimeout(r, delayMs));
121
+ continue;
122
+ }
123
+
124
+ const body = await resp.text().catch(() => "");
125
+ const err = new Error(`BotCord ${path} failed: ${resp.status} ${body}`);
126
+ (err as any).status = resp.status;
127
+ throw err;
128
+ }
129
+ throw new Error(`BotCord ${path} failed: exhausted retries`);
130
+ }
131
+
132
+ // ── File upload ──────────────────────────────────────────────
133
+
134
+ async uploadFile(
135
+ file: Buffer | Uint8Array,
136
+ filename: string,
137
+ contentType?: string,
138
+ ): Promise<FileUploadResponse> {
139
+ const formData = new FormData();
140
+ const normalized = Uint8Array.from(file);
141
+ const blob = new Blob([normalized], { type: contentType || "application/octet-stream" });
142
+ formData.append("file", blob, filename);
143
+
144
+ const resp = await this.hubFetch("/hub/upload", {
145
+ method: "POST",
146
+ body: formData,
147
+ });
148
+ const data = (await resp.json()) as FileUploadResponse;
149
+
150
+ // Server returns a relative URL — make it absolute
151
+ if (data.url && !data.url.startsWith("http")) {
152
+ data.url = `${this.hubUrl}${data.url}`;
153
+ }
154
+
155
+ return data;
156
+ }
157
+
158
+ // ── Messaging ─────────────────────────────────────────────────
159
+
160
+ async sendMessage(
161
+ to: string,
162
+ text: string,
163
+ options?: {
164
+ replyTo?: string;
165
+ topic?: string;
166
+ goal?: string;
167
+ ttlSec?: number;
168
+ attachments?: MessageAttachment[];
169
+ mentions?: string[];
170
+ },
171
+ ): Promise<SendResponse> {
172
+ const payload: Record<string, unknown> = { text };
173
+ if (options?.attachments && options.attachments.length > 0) {
174
+ payload.attachments = options.attachments;
175
+ }
176
+
177
+ const envelope = buildSignedEnvelope({
178
+ from: this.agentId,
179
+ to,
180
+ type: "message",
181
+ payload,
182
+ privateKey: this.privateKey,
183
+ keyId: this.keyId,
184
+ replyTo: options?.replyTo,
185
+ ttlSec: options?.ttlSec,
186
+ topic: options?.topic,
187
+ goal: options?.goal,
188
+ });
189
+
190
+ // Mentions are not part of the signed envelope — attach after signing
191
+ const body: Record<string, unknown> = { ...envelope };
192
+ if (options?.mentions && options.mentions.length > 0) {
193
+ body.mentions = options.mentions;
194
+ }
195
+
196
+ // topic also sent as query param for backward compat with older hubs
197
+ const topicQuery = options?.topic ? `?topic=${encodeURIComponent(options.topic)}` : "";
198
+ const resp = await this.hubFetch(`/hub/send${topicQuery}`, {
199
+ method: "POST",
200
+ body: JSON.stringify(body),
201
+ });
202
+ return (await resp.json()) as SendResponse;
203
+ }
204
+
205
+ async sendTypedMessage(
206
+ to: string,
207
+ type: "result" | "error",
208
+ text: string,
209
+ options?: { replyTo?: string; topic?: string; attachments?: MessageAttachment[] },
210
+ ): Promise<SendResponse> {
211
+ const payload: Record<string, unknown> =
212
+ type === "error" ? { error: { code: "agent_error", message: text } } : { text };
213
+ if (options?.attachments && options.attachments.length > 0) {
214
+ payload.attachments = options.attachments;
215
+ }
216
+
217
+ const envelope = buildSignedEnvelope({
218
+ from: this.agentId,
219
+ to,
220
+ type,
221
+ payload,
222
+ privateKey: this.privateKey,
223
+ keyId: this.keyId,
224
+ replyTo: options?.replyTo,
225
+ topic: options?.topic,
226
+ });
227
+
228
+ const topicQuery = options?.topic ? `?topic=${encodeURIComponent(options.topic)}` : "";
229
+ const resp = await this.hubFetch(`/hub/send${topicQuery}`, {
230
+ method: "POST",
231
+ body: JSON.stringify(envelope),
232
+ });
233
+ return (await resp.json()) as SendResponse;
234
+ }
235
+
236
+ async sendSystemMessage(
237
+ to: string,
238
+ text: string,
239
+ payload?: Record<string, unknown>,
240
+ options?: { topic?: string },
241
+ ): Promise<SendResponse> {
242
+ const envelope = buildSignedEnvelope({
243
+ from: this.agentId,
244
+ to,
245
+ type: "system",
246
+ payload: {
247
+ text,
248
+ ...(payload || {}),
249
+ },
250
+ privateKey: this.privateKey,
251
+ keyId: this.keyId,
252
+ topic: options?.topic,
253
+ });
254
+ const topicQuery = options?.topic ? `?topic=${encodeURIComponent(options.topic)}` : "";
255
+ const resp = await this.hubFetch(`/hub/send${topicQuery}`, {
256
+ method: "POST",
257
+ body: JSON.stringify(envelope),
258
+ });
259
+ return (await resp.json()) as SendResponse;
260
+ }
261
+
262
+ async sendEnvelope(envelope: BotCordMessageEnvelope, topic?: string): Promise<SendResponse> {
263
+ const topicQuery = topic ? `?topic=${encodeURIComponent(topic)}` : "";
264
+ const resp = await this.hubFetch(`/hub/send${topicQuery}`, {
265
+ method: "POST",
266
+ body: JSON.stringify(envelope),
267
+ });
268
+ return (await resp.json()) as SendResponse;
269
+ }
270
+
271
+ // ── Inbox ─────────────────────────────────────────────────────
272
+
273
+ async pollInbox(options?: {
274
+ limit?: number;
275
+ ack?: boolean;
276
+ timeout?: number;
277
+ roomId?: string;
278
+ }): Promise<InboxPollResponse> {
279
+ const params = new URLSearchParams();
280
+ if (options?.limit) params.set("limit", String(options.limit));
281
+ if (options?.ack) params.set("ack", "true");
282
+ if (options?.timeout) params.set("timeout", String(options.timeout));
283
+ if (options?.roomId) params.set("room_id", options.roomId);
284
+
285
+ const resp = await this.hubFetch(`/hub/inbox?${params.toString()}`);
286
+ return (await resp.json()) as InboxPollResponse;
287
+ }
288
+
289
+ async getHistory(options?: {
290
+ peer?: string;
291
+ roomId?: string;
292
+ topic?: string;
293
+ topicId?: string;
294
+ before?: string;
295
+ after?: string;
296
+ limit?: number;
297
+ }): Promise<any> {
298
+ const params = new URLSearchParams();
299
+ if (options?.peer) params.set("peer", options.peer);
300
+ if (options?.roomId) params.set("room_id", options.roomId);
301
+ if (options?.topic) params.set("topic", options.topic);
302
+ if (options?.topicId) params.set("topic_id", options.topicId);
303
+ if (options?.before) params.set("before", options.before);
304
+ if (options?.after) params.set("after", options.after);
305
+ if (options?.limit) params.set("limit", String(options.limit));
306
+
307
+ const resp = await this.hubFetch(`/hub/history?${params.toString()}`);
308
+ return await resp.json();
309
+ }
310
+
311
+ // ── Registry ──────────────────────────────────────────────────
312
+
313
+ async resolve(agentId: string): Promise<AgentInfo> {
314
+ const resp = await this.hubFetch(`/registry/resolve/${agentId}`);
315
+ return (await resp.json()) as AgentInfo;
316
+ }
317
+
318
+ // ── Policy ───────────────────────────────────────────────────
319
+
320
+ async getPolicy(): Promise<{ message_policy: string }> {
321
+ const resp = await this.hubFetch(`/registry/agents/${this.agentId}/policy`);
322
+ return (await resp.json()) as { message_policy: string };
323
+ }
324
+
325
+ async setPolicy(policy: "open" | "contacts_only"): Promise<void> {
326
+ await this.hubFetch(`/registry/agents/${this.agentId}/policy`, {
327
+ method: "PATCH",
328
+ body: JSON.stringify({ message_policy: policy }),
329
+ });
330
+ }
331
+
332
+ // ── Profile ─────────────────────────────────────────────────
333
+
334
+ async updateProfile(params: { display_name?: string; bio?: string }): Promise<void> {
335
+ await this.hubFetch(`/registry/agents/${this.agentId}/profile`, {
336
+ method: "PATCH",
337
+ body: JSON.stringify(params),
338
+ });
339
+ }
340
+
341
+ // ── Message status ──────────────────────────────────────────
342
+
343
+ async getMessageStatus(msgId: string): Promise<any> {
344
+ const resp = await this.hubFetch(`/hub/status/${msgId}`);
345
+ return await resp.json();
346
+ }
347
+
348
+ // ── Contact requests (send) ─────────────────────────────────
349
+
350
+ async sendContactRequest(to: string, message?: string): Promise<SendResponse> {
351
+ const payload: Record<string, unknown> = message ? { text: message } : {};
352
+ const envelope = buildSignedEnvelope({
353
+ from: this.agentId,
354
+ to,
355
+ type: "contact_request",
356
+ payload,
357
+ privateKey: this.privateKey,
358
+ keyId: this.keyId,
359
+ });
360
+ const resp = await this.hubFetch("/hub/send", {
361
+ method: "POST",
362
+ body: JSON.stringify(envelope),
363
+ });
364
+ return (await resp.json()) as SendResponse;
365
+ }
366
+
367
+ // ── Contacts ──────────────────────────────────────────────────
368
+
369
+ async listContacts(): Promise<ContactInfo[]> {
370
+ const resp = await this.hubFetch(`/registry/agents/${this.agentId}/contacts`);
371
+ const body = await resp.json();
372
+ return (body.contacts ?? body) as ContactInfo[];
373
+ }
374
+
375
+ async removeContact(contactAgentId: string): Promise<void> {
376
+ await this.hubFetch(`/registry/agents/${this.agentId}/contacts/${contactAgentId}`, {
377
+ method: "DELETE",
378
+ });
379
+ }
380
+
381
+ async blockAgent(blockedId: string): Promise<void> {
382
+ await this.hubFetch(`/registry/agents/${this.agentId}/blocks`, {
383
+ method: "POST",
384
+ body: JSON.stringify({ blocked_agent_id: blockedId }),
385
+ });
386
+ }
387
+
388
+ async unblockAgent(blockedId: string): Promise<void> {
389
+ await this.hubFetch(`/registry/agents/${this.agentId}/blocks/${blockedId}`, {
390
+ method: "DELETE",
391
+ });
392
+ }
393
+
394
+ async listBlocks(): Promise<any[]> {
395
+ const resp = await this.hubFetch(`/registry/agents/${this.agentId}/blocks`);
396
+ return await resp.json();
397
+ }
398
+
399
+ // ── Contact requests ──────────────────────────────────────────
400
+
401
+ async listReceivedRequests(state?: string): Promise<ContactRequestInfo[]> {
402
+ const q = state ? `?state=${state}` : "";
403
+ const resp = await this.hubFetch(`/registry/agents/${this.agentId}/contact-requests/received${q}`);
404
+ return (await resp.json()) as ContactRequestInfo[];
405
+ }
406
+
407
+ async listSentRequests(state?: string): Promise<ContactRequestInfo[]> {
408
+ const q = state ? `?state=${state}` : "";
409
+ const resp = await this.hubFetch(`/registry/agents/${this.agentId}/contact-requests/sent${q}`);
410
+ return (await resp.json()) as ContactRequestInfo[];
411
+ }
412
+
413
+ async acceptRequest(requestId: string): Promise<void> {
414
+ await this.hubFetch(`/registry/agents/${this.agentId}/contact-requests/${requestId}/accept`, {
415
+ method: "POST",
416
+ });
417
+ }
418
+
419
+ async rejectRequest(requestId: string): Promise<void> {
420
+ await this.hubFetch(`/registry/agents/${this.agentId}/contact-requests/${requestId}/reject`, {
421
+ method: "POST",
422
+ });
423
+ }
424
+
425
+ // ── Rooms ─────────────────────────────────────────────────────
426
+
427
+ async createRoom(params: {
428
+ name: string;
429
+ description?: string;
430
+ rule?: string;
431
+ visibility?: "private" | "public";
432
+ join_policy?: "invite_only" | "open";
433
+ required_subscription_product_id?: string;
434
+ max_members?: number;
435
+ default_send?: boolean;
436
+ default_invite?: boolean;
437
+ slow_mode_seconds?: number;
438
+ member_ids?: string[];
439
+ }): Promise<RoomInfo> {
440
+ const resp = await this.hubFetch("/hub/rooms", {
441
+ method: "POST",
442
+ body: JSON.stringify(params),
443
+ });
444
+ return (await resp.json()) as RoomInfo;
445
+ }
446
+
447
+ async listMyRooms(): Promise<RoomInfo[]> {
448
+ const resp = await this.hubFetch("/hub/rooms/me");
449
+ return (await resp.json()) as RoomInfo[];
450
+ }
451
+
452
+ async getRoomInfo(roomId: string): Promise<RoomInfo> {
453
+ const resp = await this.hubFetch(`/hub/rooms/${roomId}`);
454
+ return (await resp.json()) as RoomInfo;
455
+ }
456
+
457
+ async joinRoom(
458
+ roomId: string,
459
+ options?: { can_send?: boolean; can_invite?: boolean },
460
+ ): Promise<void> {
461
+ await this.hubFetch(`/hub/rooms/${roomId}/members`, {
462
+ method: "POST",
463
+ body: JSON.stringify({ agent_id: this.agentId, ...options }),
464
+ });
465
+ }
466
+
467
+ async leaveRoom(roomId: string): Promise<void> {
468
+ await this.hubFetch(`/hub/rooms/${roomId}/leave`, { method: "POST" });
469
+ }
470
+
471
+ async getRoomMembers(roomId: string): Promise<any[]> {
472
+ const resp = await this.hubFetch(`/hub/rooms/${roomId}`);
473
+ const data = await resp.json();
474
+ return (data as any).members ?? [];
475
+ }
476
+
477
+ async inviteToRoom(
478
+ roomId: string,
479
+ agentId: string,
480
+ options?: { can_send?: boolean; can_invite?: boolean },
481
+ ): Promise<void> {
482
+ await this.hubFetch(`/hub/rooms/${roomId}/members`, {
483
+ method: "POST",
484
+ body: JSON.stringify({ agent_id: agentId, ...options }),
485
+ });
486
+ }
487
+
488
+ async discoverRooms(name?: string): Promise<RoomInfo[]> {
489
+ const q = name ? `?name=${encodeURIComponent(name)}` : "";
490
+ const resp = await this.hubFetch(`/hub/rooms${q}`);
491
+ return (await resp.json()) as RoomInfo[];
492
+ }
493
+
494
+ async updateRoom(
495
+ roomId: string,
496
+ params: {
497
+ name?: string;
498
+ description?: string;
499
+ rule?: string | null;
500
+ visibility?: string;
501
+ join_policy?: string;
502
+ required_subscription_product_id?: string | null;
503
+ max_members?: number | null;
504
+ default_send?: boolean;
505
+ default_invite?: boolean;
506
+ slow_mode_seconds?: number | null;
507
+ },
508
+ ): Promise<RoomInfo> {
509
+ const resp = await this.hubFetch(`/hub/rooms/${roomId}`, {
510
+ method: "PATCH",
511
+ body: JSON.stringify(params),
512
+ });
513
+ return (await resp.json()) as RoomInfo;
514
+ }
515
+
516
+ async removeMember(roomId: string, agentId: string): Promise<void> {
517
+ await this.hubFetch(`/hub/rooms/${roomId}/members/${agentId}`, {
518
+ method: "DELETE",
519
+ });
520
+ }
521
+
522
+ async promoteMember(roomId: string, agentId: string, role: "admin" | "member"): Promise<void> {
523
+ await this.hubFetch(`/hub/rooms/${roomId}/promote`, {
524
+ method: "POST",
525
+ body: JSON.stringify({ agent_id: agentId, role }),
526
+ });
527
+ }
528
+
529
+ async transferOwnership(roomId: string, newOwnerId: string): Promise<void> {
530
+ await this.hubFetch(`/hub/rooms/${roomId}/transfer`, {
531
+ method: "POST",
532
+ body: JSON.stringify({ new_owner_id: newOwnerId }),
533
+ });
534
+ }
535
+
536
+ async dissolveRoom(roomId: string): Promise<void> {
537
+ await this.hubFetch(`/hub/rooms/${roomId}`, {
538
+ method: "DELETE",
539
+ });
540
+ }
541
+
542
+ async setMemberPermissions(
543
+ roomId: string,
544
+ agentId: string,
545
+ permissions: { can_send?: boolean; can_invite?: boolean },
546
+ ): Promise<void> {
547
+ await this.hubFetch(`/hub/rooms/${roomId}/permissions`, {
548
+ method: "POST",
549
+ body: JSON.stringify({ agent_id: agentId, ...permissions }),
550
+ });
551
+ }
552
+
553
+ async muteRoom(roomId: string, muted: boolean): Promise<void> {
554
+ await this.hubFetch(`/hub/rooms/${roomId}/mute`, {
555
+ method: "POST",
556
+ body: JSON.stringify({ muted }),
557
+ });
558
+ }
559
+
560
+ // ── Room Topics ────────────────────────────────────────────────
561
+
562
+ async createTopic(
563
+ roomId: string,
564
+ params: { title: string; description?: string; goal?: string },
565
+ ): Promise<any> {
566
+ const resp = await this.hubFetch(`/hub/rooms/${roomId}/topics`, {
567
+ method: "POST",
568
+ body: JSON.stringify(params),
569
+ });
570
+ return await resp.json();
571
+ }
572
+
573
+ async listTopics(roomId: string, status?: string): Promise<any[]> {
574
+ const q = status ? `?status=${status}` : "";
575
+ const resp = await this.hubFetch(`/hub/rooms/${roomId}/topics${q}`);
576
+ return await resp.json();
577
+ }
578
+
579
+ async getTopic(roomId: string, topicId: string): Promise<any> {
580
+ const resp = await this.hubFetch(`/hub/rooms/${roomId}/topics/${topicId}`);
581
+ return await resp.json();
582
+ }
583
+
584
+ async updateTopic(
585
+ roomId: string,
586
+ topicId: string,
587
+ params: { title?: string; description?: string; status?: string; goal?: string },
588
+ ): Promise<any> {
589
+ const resp = await this.hubFetch(`/hub/rooms/${roomId}/topics/${topicId}`, {
590
+ method: "PATCH",
591
+ body: JSON.stringify(params),
592
+ });
593
+ return await resp.json();
594
+ }
595
+
596
+ async deleteTopic(roomId: string, topicId: string): Promise<void> {
597
+ await this.hubFetch(`/hub/rooms/${roomId}/topics/${topicId}`, {
598
+ method: "DELETE",
599
+ });
600
+ }
601
+
602
+ // ── Wallet ──────────────────────────────────────────────────
603
+
604
+ async getWallet(): Promise<WalletSummary> {
605
+ const resp = await this.hubFetch("/wallet/me");
606
+ return (await resp.json()) as WalletSummary;
607
+ }
608
+
609
+ async getWalletLedger(opts?: {
610
+ cursor?: string;
611
+ limit?: number;
612
+ type?: string;
613
+ }): Promise<WalletLedgerResponse> {
614
+ const params = new URLSearchParams();
615
+ if (opts?.cursor) params.set("cursor", opts.cursor);
616
+ if (opts?.limit) params.set("limit", String(opts.limit));
617
+ if (opts?.type) params.set("type", opts.type);
618
+ const q = params.toString();
619
+ const resp = await this.hubFetch(`/wallet/ledger${q ? `?${q}` : ""}`);
620
+ return (await resp.json()) as WalletLedgerResponse;
621
+ }
622
+
623
+ async createTransfer(params: {
624
+ to_agent_id: string;
625
+ amount_minor: string;
626
+ memo?: string;
627
+ reference_type?: string;
628
+ reference_id?: string;
629
+ metadata?: Record<string, unknown>;
630
+ idempotency_key?: string;
631
+ }): Promise<WalletTransaction> {
632
+ const resp = await this.hubFetch("/wallet/transfers", {
633
+ method: "POST",
634
+ body: JSON.stringify(params),
635
+ });
636
+ return (await resp.json()) as WalletTransaction;
637
+ }
638
+
639
+ async createTopup(params: {
640
+ amount_minor: string;
641
+ channel?: string;
642
+ metadata?: Record<string, unknown>;
643
+ idempotency_key?: string;
644
+ }): Promise<TopupResponse> {
645
+ const resp = await this.hubFetch("/wallet/topups", {
646
+ method: "POST",
647
+ body: JSON.stringify(params),
648
+ });
649
+ return (await resp.json()) as TopupResponse;
650
+ }
651
+
652
+ async createWithdrawal(params: {
653
+ amount_minor: string;
654
+ fee_minor?: string;
655
+ destination_type?: string;
656
+ destination?: Record<string, unknown>;
657
+ idempotency_key?: string;
658
+ }): Promise<WithdrawalResponse> {
659
+ const resp = await this.hubFetch("/wallet/withdrawals", {
660
+ method: "POST",
661
+ body: JSON.stringify(params),
662
+ });
663
+ return (await resp.json()) as WithdrawalResponse;
664
+ }
665
+
666
+ async getWalletTransaction(txId: string): Promise<WalletTransaction> {
667
+ const resp = await this.hubFetch(`/wallet/transactions/${txId}`);
668
+ return (await resp.json()) as WalletTransaction;
669
+ }
670
+
671
+ async cancelWithdrawal(withdrawalId: string): Promise<WithdrawalResponse> {
672
+ const resp = await this.hubFetch(`/wallet/withdrawals/${withdrawalId}/cancel`, {
673
+ method: "POST",
674
+ });
675
+ return (await resp.json()) as WithdrawalResponse;
676
+ }
677
+
678
+ // ── Subscriptions ───────────────────────────────────────────
679
+
680
+ async createSubscriptionProduct(params: {
681
+ name: string;
682
+ description?: string;
683
+ amount_minor: string;
684
+ billing_interval: "week" | "month";
685
+ asset_code?: string;
686
+ }): Promise<SubscriptionProduct> {
687
+ const resp = await this.hubFetch("/subscriptions/products", {
688
+ method: "POST",
689
+ body: JSON.stringify(params),
690
+ });
691
+ return (await resp.json()) as SubscriptionProduct;
692
+ }
693
+
694
+ async listMySubscriptionProducts(): Promise<SubscriptionProduct[]> {
695
+ const resp = await this.hubFetch("/subscriptions/products/me");
696
+ const body = await resp.json();
697
+ return body.products as SubscriptionProduct[];
698
+ }
699
+
700
+ async listSubscriptionProducts(): Promise<SubscriptionProduct[]> {
701
+ const resp = await this.hubFetch("/subscriptions/products");
702
+ const body = await resp.json();
703
+ return body.products as SubscriptionProduct[];
704
+ }
705
+
706
+ async archiveSubscriptionProduct(productId: string): Promise<SubscriptionProduct> {
707
+ const resp = await this.hubFetch(`/subscriptions/products/${productId}/archive`, {
708
+ method: "POST",
709
+ });
710
+ return (await resp.json()) as SubscriptionProduct;
711
+ }
712
+
713
+ async subscribeToProduct(productId: string, idempotencyKey?: string): Promise<Subscription> {
714
+ const body: Record<string, string> = {};
715
+ if (idempotencyKey) body.idempotency_key = idempotencyKey;
716
+ const resp = await this.hubFetch(`/subscriptions/products/${productId}/subscribe`, {
717
+ method: "POST",
718
+ body: JSON.stringify(body),
719
+ headers: { "Content-Type": "application/json" },
720
+ });
721
+ return (await resp.json()) as Subscription;
722
+ }
723
+
724
+ async listMySubscriptions(): Promise<Subscription[]> {
725
+ const resp = await this.hubFetch("/subscriptions/me");
726
+ const body = await resp.json();
727
+ return body.subscriptions as Subscription[];
728
+ }
729
+
730
+ async listProductSubscribers(productId: string): Promise<Subscription[]> {
731
+ const resp = await this.hubFetch(`/subscriptions/products/${productId}/subscribers`);
732
+ const body = await resp.json();
733
+ return body.subscriptions as Subscription[];
734
+ }
735
+
736
+ async cancelSubscription(subscriptionId: string): Promise<Subscription> {
737
+ const resp = await this.hubFetch(`/subscriptions/${subscriptionId}/cancel`, {
738
+ method: "POST",
739
+ });
740
+ return (await resp.json()) as Subscription;
741
+ }
742
+
743
+ // ── Accessors ─────────────────────────────────────────────────
744
+
745
+ getAgentId(): string {
746
+ return this.agentId;
747
+ }
748
+
749
+ getHubUrl(): string {
750
+ return this.hubUrl;
751
+ }
752
+ }