pingerchips-js-server 2.0.0 → 3.0.0
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/README.md +147 -61
- package/agent-session.js +212 -0
- package/index.js +866 -93
- package/package.json +5 -5
package/index.js
CHANGED
|
@@ -2,166 +2,939 @@ import fs from "fs";
|
|
|
2
2
|
import https from "https";
|
|
3
3
|
import crypto from "crypto";
|
|
4
4
|
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Unified capability tokens (ADR-0016)
|
|
7
|
+
//
|
|
8
|
+
// Every WebSocket join — PubSub channel, Durable Object, or chat thread —
|
|
9
|
+
// carries one `auth` token. Two forms:
|
|
10
|
+
//
|
|
11
|
+
// * fast-path token: "{appKey}.{payloadB64}.{sigB64}", a pure function of
|
|
12
|
+
// (appSecret, socketId, capabilities). No network round-trip, no expiry.
|
|
13
|
+
// Minted here for private/presence channels and Durable Objects.
|
|
14
|
+
//
|
|
15
|
+
// * JWT: issued by POST /api/auth. Scoped, short-lived, has a jti. Used for
|
|
16
|
+
// chat threads (authenticateChat) and any case that wants server-side
|
|
17
|
+
// issuance / auditing.
|
|
18
|
+
//
|
|
19
|
+
// Capability grammar: "product:verb:resource"
|
|
20
|
+
// product channel | object | chat
|
|
21
|
+
// verb channel: subscribe | publish | presence
|
|
22
|
+
// object: read
|
|
23
|
+
// chat: subscribe | publish:user_message | cancel_own | tool_approval
|
|
24
|
+
// resource an exact string, a "prefix*" wildcard, or "*"
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const b64url = (buf) => Buffer.from(buf).toString("base64url");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Mint a fast-path capability token bound to one socket.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} appKey
|
|
33
|
+
* @param {string} appSecret
|
|
34
|
+
* @param {string} socketId
|
|
35
|
+
* @param {string[]} capabilities - non-empty list of "product:verb:resource"
|
|
36
|
+
* @param {string} [clientId] - optional, echoed in the token payload
|
|
37
|
+
* @returns {string} "{appKey}.{payloadB64}.{sigB64}"
|
|
38
|
+
*/
|
|
39
|
+
function mintFastToken(appKey, appSecret, socketId, capabilities, clientId) {
|
|
40
|
+
if (!socketId || typeof socketId !== "string") {
|
|
41
|
+
throw new TypeError("socketId must be a non-empty string");
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(capabilities) || capabilities.length === 0) {
|
|
44
|
+
throw new TypeError("capabilities must be a non-empty array");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const payload = { socket_id: socketId, capabilities };
|
|
48
|
+
if (clientId != null) payload.client_id = clientId;
|
|
49
|
+
|
|
50
|
+
const payloadB64 = b64url(JSON.stringify(payload));
|
|
51
|
+
const signingInput = `${appKey}.${payloadB64}`;
|
|
52
|
+
const sigB64 = crypto
|
|
53
|
+
.createHmac("sha256", appSecret)
|
|
54
|
+
.update(signingInput)
|
|
55
|
+
.digest("base64url");
|
|
56
|
+
|
|
57
|
+
return `${appKey}.${payloadB64}.${sigB64}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
5
60
|
class PingerchipsServer {
|
|
6
|
-
|
|
7
|
-
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} appKey - App key used for authentication and routing
|
|
63
|
+
* @param {string} appSecret - App secret used for server authentication
|
|
64
|
+
* @param {object} options
|
|
65
|
+
*/
|
|
66
|
+
constructor(appKey, appSecret, options = {}) {
|
|
67
|
+
this.appKey = appKey;
|
|
8
68
|
this.appSecret = appSecret;
|
|
9
|
-
this.appKey = options.appKey || appId; // App key for signing
|
|
10
69
|
this.endpoint =
|
|
11
70
|
options.endpoint ||
|
|
12
71
|
process.env.PINGERCHIPS_API_ENDPOINT ||
|
|
13
|
-
process.env.NODE_ENV === "production"
|
|
14
|
-
? "https://
|
|
15
|
-
: "http://localhost:4000";
|
|
16
|
-
this.
|
|
72
|
+
(process.env.NODE_ENV === "production"
|
|
73
|
+
? "https://queue.pingerchips.com"
|
|
74
|
+
: "http://localhost:4000");
|
|
75
|
+
this.requestTimeout = options.requestTimeout || 10000;
|
|
76
|
+
this.retries = options.retries ?? 2; // retry 5xx up to N times
|
|
17
77
|
|
|
18
|
-
// mTLS configuration
|
|
19
78
|
this.mtls = {
|
|
20
79
|
enabled: options.mtls?.enabled || false,
|
|
21
80
|
cert: options.mtls?.cert,
|
|
22
81
|
key: options.mtls?.key,
|
|
23
82
|
ca: options.mtls?.ca,
|
|
24
|
-
rejectUnauthorized: options.mtls?.rejectUnauthorized
|
|
83
|
+
rejectUnauthorized: options.mtls?.rejectUnauthorized ?? true,
|
|
25
84
|
};
|
|
26
|
-
|
|
27
|
-
this.fetchPromise = import("node-fetch").then((mod) => mod.default);
|
|
28
85
|
}
|
|
29
86
|
|
|
30
87
|
_createHttpsAgent() {
|
|
31
|
-
if (!this.mtls.enabled)
|
|
32
|
-
return undefined;
|
|
33
|
-
}
|
|
88
|
+
if (!this.mtls.enabled) return undefined;
|
|
34
89
|
|
|
35
90
|
const agentOptions = {
|
|
36
91
|
rejectUnauthorized: this.mtls.rejectUnauthorized,
|
|
37
92
|
};
|
|
38
93
|
|
|
39
|
-
|
|
40
|
-
if (this.mtls.
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
if (this.mtls.key) {
|
|
44
|
-
agentOptions.key = this._loadCertificate(this.mtls.key);
|
|
45
|
-
}
|
|
46
|
-
if (this.mtls.ca) {
|
|
47
|
-
agentOptions.ca = this._loadCertificate(this.mtls.ca);
|
|
48
|
-
}
|
|
94
|
+
if (this.mtls.cert) agentOptions.cert = this._loadCertificate(this.mtls.cert);
|
|
95
|
+
if (this.mtls.key) agentOptions.key = this._loadCertificate(this.mtls.key);
|
|
96
|
+
if (this.mtls.ca) agentOptions.ca = this._loadCertificate(this.mtls.ca);
|
|
49
97
|
|
|
50
98
|
return new https.Agent(agentOptions);
|
|
51
99
|
}
|
|
52
100
|
|
|
53
101
|
_loadCertificate(certOrPath) {
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
typeof certOrPath === "string" &&
|
|
57
|
-
certOrPath.trim().startsWith("-----")
|
|
58
|
-
) {
|
|
102
|
+
if (typeof certOrPath === "string" && certOrPath.trim().startsWith("-----")) {
|
|
59
103
|
return certOrPath;
|
|
60
104
|
}
|
|
61
|
-
// Otherwise, treat as file path
|
|
62
105
|
return fs.readFileSync(certOrPath, "utf8");
|
|
63
106
|
}
|
|
64
107
|
|
|
108
|
+
_validateTriggerInput(channel, event, data) {
|
|
109
|
+
if (!channel || typeof channel !== "string") {
|
|
110
|
+
throw new TypeError("channel must be a non-empty string");
|
|
111
|
+
}
|
|
112
|
+
if (!event || typeof event !== "string") {
|
|
113
|
+
throw new TypeError("event must be a non-empty string");
|
|
114
|
+
}
|
|
115
|
+
if (channel.length > 200) {
|
|
116
|
+
throw new Error("channel name must be 200 characters or less");
|
|
117
|
+
}
|
|
118
|
+
if (event.length > 200) {
|
|
119
|
+
throw new Error("event name must be 200 characters or less");
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const serialized = JSON.stringify(data);
|
|
123
|
+
if (serialized.length > 65536) {
|
|
124
|
+
throw new Error("data payload must be 64KB or less");
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
throw new TypeError("data must be JSON serializable");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Headers every server-to-server call carries (ADR-0016). */
|
|
132
|
+
_authHeaders() {
|
|
133
|
+
return {
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
"X-App-Key": this.appKey,
|
|
136
|
+
"X-App-Secret": this.appSecret,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async _fetchWithRetry(url, requestOptions, retriesLeft) {
|
|
141
|
+
const controller = new AbortController();
|
|
142
|
+
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
143
|
+
const attempt = this.retries - retriesLeft;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const response = await fetch(url, {
|
|
147
|
+
...requestOptions,
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Retry on 5xx if retries remain
|
|
152
|
+
if (response.status >= 500 && retriesLeft > 0) {
|
|
153
|
+
clearTimeout(timeoutId);
|
|
154
|
+
const delay = Math.min(100 * 2 ** attempt, 5000);
|
|
155
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
156
|
+
return this._fetchWithRetry(url, requestOptions, retriesLeft - 1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return response;
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (err.name === "AbortError") {
|
|
162
|
+
throw new Error(`Request timeout after ${this.requestTimeout}ms`);
|
|
163
|
+
}
|
|
164
|
+
// Retry on network errors
|
|
165
|
+
if (retriesLeft > 0) {
|
|
166
|
+
const delay = Math.min(100 * 2 ** attempt, 5000);
|
|
167
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
168
|
+
return this._fetchWithRetry(url, requestOptions, retriesLeft - 1);
|
|
169
|
+
}
|
|
170
|
+
throw err;
|
|
171
|
+
} finally {
|
|
172
|
+
clearTimeout(timeoutId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Trigger an event on a channel. Authenticated with X-App-Key / X-App-Secret
|
|
178
|
+
* headers (ADR-0016).
|
|
179
|
+
*
|
|
180
|
+
* @param {string} channel
|
|
181
|
+
* @param {string} event
|
|
182
|
+
* @param {any} data - Must be JSON serializable, max 64KB
|
|
183
|
+
* @returns {Promise<object>}
|
|
184
|
+
*/
|
|
65
185
|
async trigger(channel, event, data) {
|
|
66
|
-
|
|
67
|
-
|
|
186
|
+
this._validateTriggerInput(channel, event, data);
|
|
187
|
+
|
|
188
|
+
const path = `/api/apps/${this.appKey}/trigger`;
|
|
189
|
+
const url = `${this.endpoint}${path}`;
|
|
190
|
+
const body = { channel, event, data };
|
|
68
191
|
|
|
69
192
|
const requestOptions = {
|
|
70
193
|
method: "POST",
|
|
71
|
-
headers:
|
|
72
|
-
|
|
73
|
-
token: this.token,
|
|
74
|
-
},
|
|
75
|
-
body: JSON.stringify({
|
|
76
|
-
app_id: this.appId,
|
|
77
|
-
app_secret: this.appSecret,
|
|
78
|
-
channel,
|
|
79
|
-
event,
|
|
80
|
-
data,
|
|
81
|
-
}),
|
|
194
|
+
headers: this._authHeaders(),
|
|
195
|
+
body: JSON.stringify(body),
|
|
82
196
|
};
|
|
83
197
|
|
|
84
|
-
// Add HTTPS agent if mTLS is enabled
|
|
85
198
|
if (this.mtls.enabled) {
|
|
86
199
|
requestOptions.agent = this._createHttpsAgent();
|
|
87
200
|
}
|
|
88
201
|
|
|
89
|
-
const response = await
|
|
202
|
+
const response = await this._fetchWithRetry(url, requestOptions, this.retries);
|
|
90
203
|
|
|
91
204
|
if (!response.ok) {
|
|
92
205
|
const error = await response.json().catch(() => ({}));
|
|
93
206
|
throw new Error(
|
|
94
|
-
`Failed to trigger event: ${response.statusText} - ${error.error || ""}
|
|
207
|
+
`Failed to trigger event: ${response.status} ${response.statusText} - ${error.error || ""}`,
|
|
95
208
|
);
|
|
96
209
|
}
|
|
97
210
|
|
|
98
|
-
return
|
|
211
|
+
return response.json();
|
|
99
212
|
}
|
|
100
213
|
|
|
101
214
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* This method is called by your auth endpoint to sign user data.
|
|
105
|
-
* The signature proves that your server authorized the user to join the channel.
|
|
215
|
+
* Send a push notification to a specific user.
|
|
106
216
|
*
|
|
107
|
-
* @param {string}
|
|
108
|
-
* @param {
|
|
109
|
-
* @param {
|
|
110
|
-
* @
|
|
217
|
+
* @param {string} userId
|
|
218
|
+
* @param {object} notification
|
|
219
|
+
* @param {string} notification.title
|
|
220
|
+
* @param {string} notification.body
|
|
221
|
+
* @param {object} [options]
|
|
222
|
+
* @param {"always"|"offline"} [options.trigger="offline"] - "offline" skips if user is connected
|
|
223
|
+
* @param {object} [options.data] - Extra key/value data sent with the notification
|
|
224
|
+
* @returns {Promise<object>}
|
|
225
|
+
*/
|
|
226
|
+
async notify(userId, { title, body }, options = {}) {
|
|
227
|
+
if (!userId) throw new TypeError("userId is required");
|
|
228
|
+
if (!title || !body) throw new TypeError("notification.title and notification.body are required");
|
|
229
|
+
|
|
230
|
+
const path = `/api/apps/${this.appKey}/push/notify`;
|
|
231
|
+
const url = `${this.endpoint}${path}`;
|
|
232
|
+
const reqBody = {
|
|
233
|
+
user_id: userId,
|
|
234
|
+
title,
|
|
235
|
+
body,
|
|
236
|
+
trigger: options.trigger ?? "offline",
|
|
237
|
+
data: options.data ?? {},
|
|
238
|
+
};
|
|
239
|
+
const response = await this._fetchWithRetry(
|
|
240
|
+
url,
|
|
241
|
+
{
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: this._authHeaders(),
|
|
244
|
+
body: JSON.stringify(reqBody),
|
|
245
|
+
},
|
|
246
|
+
this.retries
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
const error = await response.json().catch(() => ({}));
|
|
251
|
+
throw new Error(`notify failed: ${response.status} ${error.error || ""}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return response.json();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Register a push device token on behalf of a user.
|
|
259
|
+
* Useful for server-side token registration (e.g. from a mobile backend).
|
|
111
260
|
*
|
|
112
|
-
* @
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
261
|
+
* @param {string} userId
|
|
262
|
+
* @param {object} device
|
|
263
|
+
* @param {string} device.deviceId
|
|
264
|
+
* @param {"fcm"|"web_fcm"|"apns"|"web"} device.platform
|
|
265
|
+
* @param {string} [device.token] - FCM or APNs token
|
|
266
|
+
* @param {string} [device.endpoint] - Web Push endpoint
|
|
267
|
+
* @param {string} [device.p256dh] - Web Push p256dh key
|
|
268
|
+
* @param {string} [device.auth] - Web Push auth secret
|
|
269
|
+
*/
|
|
270
|
+
async registerPushToken(userId, { deviceId, platform, token, endpoint, p256dh, auth }) {
|
|
271
|
+
const path = `/api/apps/${this.appKey}/push/register`;
|
|
272
|
+
const url = `${this.endpoint}${path}`;
|
|
273
|
+
const reqBody = {
|
|
274
|
+
user_id: userId,
|
|
275
|
+
device_id: deviceId,
|
|
276
|
+
platform,
|
|
277
|
+
token,
|
|
278
|
+
endpoint,
|
|
279
|
+
p256dh,
|
|
280
|
+
auth,
|
|
281
|
+
};
|
|
282
|
+
const response = await this._fetchWithRetry(
|
|
283
|
+
url,
|
|
284
|
+
{
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: this._authHeaders(),
|
|
287
|
+
body: JSON.stringify(reqBody),
|
|
288
|
+
},
|
|
289
|
+
this.retries
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (!response.ok) {
|
|
293
|
+
const error = await response.json().catch(() => ({}));
|
|
294
|
+
throw new Error(`registerPushToken failed: ${response.status} ${error.error || ""}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return response.json();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Unregister a push device token.
|
|
302
|
+
*/
|
|
303
|
+
async unregisterPushToken(userId, deviceId) {
|
|
304
|
+
const path = `/api/apps/${this.appKey}/push/register`;
|
|
305
|
+
const url = `${this.endpoint}${path}`;
|
|
306
|
+
const reqBody = { user_id: userId, device_id: deviceId };
|
|
307
|
+
|
|
308
|
+
const response = await this._fetchWithRetry(
|
|
309
|
+
url,
|
|
310
|
+
{
|
|
311
|
+
method: "DELETE",
|
|
312
|
+
headers: this._authHeaders(),
|
|
313
|
+
body: JSON.stringify(reqBody),
|
|
314
|
+
},
|
|
315
|
+
this.retries
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
const error = await response.json().catch(() => ({}));
|
|
320
|
+
throw new Error(`unregisterPushToken failed: ${response.status} ${error.error || ""}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return response.json();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Trigger the same event on multiple channels in parallel.
|
|
116
328
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
329
|
+
* @param {string[]} channels
|
|
330
|
+
* @param {string} event
|
|
331
|
+
* @param {any} data
|
|
332
|
+
* @returns {Promise<object[]>} — one result per channel, in order
|
|
333
|
+
*/
|
|
334
|
+
async triggerBatch(channels, event, data) {
|
|
335
|
+
if (!Array.isArray(channels) || channels.length === 0) {
|
|
336
|
+
throw new TypeError("channels must be a non-empty array");
|
|
337
|
+
}
|
|
338
|
+
return Promise.all(channels.map((ch) => this.trigger(ch, event, data)));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Authenticate a client for a private or presence channel. Call this from
|
|
343
|
+
* your auth endpoint and return the result to the client SDK.
|
|
120
344
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* } : null;
|
|
345
|
+
* Mints a fast-path capability token (ADR-0016) granting
|
|
346
|
+
* `channel:subscribe:{channelName}` — and, for presence channels,
|
|
347
|
+
* `channel:presence:{channelName}` — bound to `socketId`. No network call;
|
|
348
|
+
* the App Secret never leaves your server.
|
|
126
349
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* }
|
|
350
|
+
* @param {string} socketId - Socket ID from the client SDK
|
|
351
|
+
* @param {string} channelName - Must start with "private-" or "presence-"
|
|
352
|
+
* @param {object|null} userData - Required for presence channels, must include user_id
|
|
353
|
+
* @returns {{ auth: string, channel_data?: string, user_data?: string }}
|
|
130
354
|
*/
|
|
131
355
|
authenticate(socketId, channelName, userData = null) {
|
|
132
|
-
|
|
133
|
-
|
|
356
|
+
if (!socketId || typeof socketId !== "string") {
|
|
357
|
+
throw new TypeError("socketId must be a non-empty string");
|
|
358
|
+
}
|
|
359
|
+
if (!channelName || typeof channelName !== "string") {
|
|
360
|
+
throw new TypeError("channelName must be a non-empty string");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const isPrivate = channelName.startsWith("private-");
|
|
364
|
+
const isPresence = channelName.startsWith("presence-");
|
|
365
|
+
|
|
366
|
+
if (!isPrivate && !isPresence) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
"Public channels do not require authentication. Only private-* and presence-* channels need auth.",
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (isPresence && !userData) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
"Presence channels require userData with at least user_id field",
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (userData) {
|
|
379
|
+
if (typeof userData !== "object" || userData === null) {
|
|
380
|
+
throw new TypeError("userData must be an object");
|
|
381
|
+
}
|
|
382
|
+
if (isPresence && !userData.user_id) {
|
|
383
|
+
throw new Error("userData must include user_id for presence channels");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const capabilities = [`channel:subscribe:${channelName}`];
|
|
388
|
+
if (isPresence) capabilities.push(`channel:presence:${channelName}`);
|
|
389
|
+
|
|
390
|
+
const auth = mintFastToken(
|
|
391
|
+
this.appKey,
|
|
392
|
+
this.appSecret,
|
|
393
|
+
socketId,
|
|
394
|
+
capabilities,
|
|
395
|
+
userData?.user_id,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
const result = { auth };
|
|
399
|
+
|
|
400
|
+
if (userData) {
|
|
401
|
+
const userDataString = JSON.stringify(userData);
|
|
402
|
+
if (isPresence) result.channel_data = userDataString;
|
|
403
|
+
else result.user_data = userDataString;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Mint a fast-path capability token for an arbitrary set of channels /
|
|
411
|
+
* objects / threads in one call. Use this when a client needs several
|
|
412
|
+
* subscriptions off one token.
|
|
413
|
+
*
|
|
414
|
+
* @param {string} socketId
|
|
415
|
+
* @param {{
|
|
416
|
+
* channels?: string[], // -> channel:subscribe:{name}
|
|
417
|
+
* presence?: string[], // -> channel:subscribe + channel:presence
|
|
418
|
+
* publish?: string[], // -> channel:publish:{name}
|
|
419
|
+
* objects?: Array<{type: string, key: string}>, // -> object:read:{type}/{key}
|
|
420
|
+
* threads?: string[], // -> chat:subscribe + chat:publish:user_message + chat:cancel_own + chat:tool_approval
|
|
421
|
+
* capabilities?: string[], // raw "product:verb:resource" strings, appended as-is
|
|
422
|
+
* }} grants
|
|
423
|
+
* @param {string} [clientId]
|
|
424
|
+
* @returns {{ auth: string }}
|
|
425
|
+
*/
|
|
426
|
+
authorize(socketId, grants = {}, clientId) {
|
|
427
|
+
const caps = [];
|
|
428
|
+
|
|
429
|
+
for (const name of grants.channels ?? []) caps.push(`channel:subscribe:${name}`);
|
|
430
|
+
for (const name of grants.presence ?? []) {
|
|
431
|
+
caps.push(`channel:subscribe:${name}`, `channel:presence:${name}`);
|
|
432
|
+
}
|
|
433
|
+
for (const name of grants.publish ?? []) caps.push(`channel:publish:${name}`);
|
|
434
|
+
for (const { type, key } of grants.objects ?? []) {
|
|
435
|
+
caps.push(`object:read:${type}/${key}`);
|
|
436
|
+
}
|
|
437
|
+
for (const threadId of grants.threads ?? []) {
|
|
438
|
+
caps.push(
|
|
439
|
+
`chat:subscribe:${threadId}`,
|
|
440
|
+
`chat:publish:user_message:${threadId}`,
|
|
441
|
+
`chat:cancel_own:${threadId}`,
|
|
442
|
+
`chat:tool_approval:${threadId}`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
for (const raw of grants.capabilities ?? []) caps.push(raw);
|
|
446
|
+
|
|
447
|
+
if (caps.length === 0) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
"authorize: pass at least one of channels / presence / publish / objects / threads / capabilities",
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
auth: mintFastToken(this.appKey, this.appSecret, socketId, caps, clientId),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Full client capability set for a chat thread (ADR-0016).
|
|
459
|
+
static chatThreadCapabilities(threadId) {
|
|
460
|
+
return [
|
|
461
|
+
`chat:subscribe:${threadId}`,
|
|
462
|
+
`chat:publish:user_message:${threadId}`,
|
|
463
|
+
`chat:cancel_own:${threadId}`,
|
|
464
|
+
`chat:tool_approval:${threadId}`,
|
|
465
|
+
];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Request a short-lived JWT capability token for a client joining a chat
|
|
470
|
+
* thread. Calls POST /api/auth with X-App-Key / X-App-Secret; the App Secret
|
|
471
|
+
* is sent only to the Pingerchips backend.
|
|
472
|
+
*
|
|
473
|
+
* @param {string} socketId
|
|
474
|
+
* @param {string} threadId
|
|
475
|
+
* @param {string} clientId
|
|
476
|
+
* @param {string[]} [verbs] - Optional subset of chat verbs to narrow the
|
|
477
|
+
* grant. Accepts short verbs ("subscribe", "publish:user_message",
|
|
478
|
+
* "cancel_own", "tool_approval") or already-qualified
|
|
479
|
+
* "chat:{verb}:{threadId}" strings. Omit for the full client set.
|
|
480
|
+
* @returns {Promise<{ auth: string }>}
|
|
481
|
+
*/
|
|
482
|
+
async authenticateChat(socketId, threadId, clientId, verbs) {
|
|
483
|
+
if (!socketId || !threadId || !clientId) {
|
|
484
|
+
throw new TypeError("socketId, threadId, and clientId are required");
|
|
485
|
+
}
|
|
486
|
+
if (verbs !== undefined && !Array.isArray(verbs)) {
|
|
487
|
+
throw new TypeError("verbs must be an array when provided");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const capabilities =
|
|
491
|
+
verbs === undefined
|
|
492
|
+
? PingerchipsServer.chatThreadCapabilities(threadId)
|
|
493
|
+
: verbs.map((v) =>
|
|
494
|
+
v.startsWith("chat:") ? v : `chat:${v}:${threadId}`,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const body = {
|
|
498
|
+
socket_id: socketId,
|
|
499
|
+
client_id: clientId,
|
|
500
|
+
capabilities,
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const response = await this._fetchWithRetry(
|
|
504
|
+
`${this.endpoint}/api/auth`,
|
|
505
|
+
{
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: this._authHeaders(),
|
|
508
|
+
body: JSON.stringify(body),
|
|
509
|
+
},
|
|
510
|
+
this.retries,
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
const result = await response.json().catch(() => null);
|
|
514
|
+
|
|
515
|
+
if (!response.ok) {
|
|
516
|
+
const detail =
|
|
517
|
+
result?.error || result?.errors?.[0]?.detail || response.statusText;
|
|
518
|
+
throw new Error(`Chat auth failed (${response.status}): ${detail}`);
|
|
519
|
+
}
|
|
134
520
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
let channelData = null;
|
|
521
|
+
return result;
|
|
522
|
+
}
|
|
138
523
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
524
|
+
/**
|
|
525
|
+
* Request a JWT capability token for an arbitrary set of grants via
|
|
526
|
+
* POST /api/auth. Same grant shape as `authorize()`, but server-issued
|
|
527
|
+
* (scoped, short-lived, has a jti) instead of a pure fast-path token.
|
|
528
|
+
*
|
|
529
|
+
* @param {string} socketId
|
|
530
|
+
* @param {string} clientId
|
|
531
|
+
* @param {object} grants - see `authorize()`
|
|
532
|
+
* @returns {Promise<{ auth: string }>}
|
|
533
|
+
*/
|
|
534
|
+
async issueToken(socketId, clientId, grants = {}) {
|
|
535
|
+
if (!socketId || !clientId) {
|
|
536
|
+
throw new TypeError("socketId and clientId are required");
|
|
146
537
|
}
|
|
147
538
|
|
|
148
|
-
//
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
.
|
|
539
|
+
// Reuse authorize()'s grant→capability mapping without minting a token.
|
|
540
|
+
const { auth: fastToken } = this.authorize(socketId, grants, clientId);
|
|
541
|
+
const payloadB64 = fastToken.split(".")[1];
|
|
542
|
+
const { capabilities } = JSON.parse(
|
|
543
|
+
Buffer.from(payloadB64, "base64url").toString("utf8"),
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
const response = await this._fetchWithRetry(
|
|
547
|
+
`${this.endpoint}/api/auth`,
|
|
548
|
+
{
|
|
549
|
+
method: "POST",
|
|
550
|
+
headers: this._authHeaders(),
|
|
551
|
+
body: JSON.stringify({
|
|
552
|
+
socket_id: socketId,
|
|
553
|
+
client_id: clientId,
|
|
554
|
+
capabilities,
|
|
555
|
+
}),
|
|
556
|
+
},
|
|
557
|
+
this.retries,
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const result = await response.json().catch(() => null);
|
|
561
|
+
|
|
562
|
+
if (!response.ok) {
|
|
563
|
+
const detail =
|
|
564
|
+
result?.error || result?.errors?.[0]?.detail || response.statusText;
|
|
565
|
+
throw new Error(`Token issuance failed (${response.status}): ${detail}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return result;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Returns a DurableObjectHandle for reading/writing a durable object.
|
|
573
|
+
*
|
|
574
|
+
* @param {string} type - Object type (e.g. "thread")
|
|
575
|
+
* @param {string} key - Object key (e.g. thread UUID)
|
|
576
|
+
* @returns {DurableObjectHandle}
|
|
577
|
+
*/
|
|
578
|
+
object(type, key) {
|
|
579
|
+
return new DurableObjectHandle(this, type, key);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Mint a fast-path capability token for a browser client to subscribe to a
|
|
584
|
+
* Durable Object over the socket. Call this from your auth endpoint — the
|
|
585
|
+
* App Secret never reaches the browser.
|
|
586
|
+
*
|
|
587
|
+
* The token grants `object:read:{type}/{key}` bound to one socket. A token
|
|
588
|
+
* for `order/order-42` cannot be used to subscribe to `order/order-99`.
|
|
589
|
+
*
|
|
590
|
+
* @param {string} socketId - The client's socket id.
|
|
591
|
+
* @param {string} objectType - Object type.
|
|
592
|
+
* @param {string} objectKey - Object key.
|
|
593
|
+
* @returns {{ auth: string }} - `{ auth: "{appKey}.{payloadB64}.{sigB64}" }`
|
|
594
|
+
*/
|
|
595
|
+
authenticateObject(socketId, objectType, objectKey) {
|
|
596
|
+
if (!socketId || typeof socketId !== "string") {
|
|
597
|
+
throw new TypeError("socketId must be a non-empty string");
|
|
598
|
+
}
|
|
599
|
+
if (!objectType || typeof objectType !== "string") {
|
|
600
|
+
throw new TypeError("objectType must be a non-empty string");
|
|
601
|
+
}
|
|
602
|
+
if (!objectKey || typeof objectKey !== "string") {
|
|
603
|
+
throw new TypeError("objectKey must be a non-empty string");
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
auth: mintFastToken(this.appKey, this.appSecret, socketId, [
|
|
608
|
+
`object:read:${objectType}/${objectKey}`,
|
|
609
|
+
]),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export { mintFastToken };
|
|
615
|
+
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
// DurableObjectHandle — HTTP client for /api/v1/objects
|
|
618
|
+
//
|
|
619
|
+
// Auth: X-App-Key + X-App-Secret headers (ADR-0016). The path's app_id
|
|
620
|
+
// segment may be the app key or the app id; the server resolves either.
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
class DurableObjectHandle {
|
|
624
|
+
constructor(server, type, key) {
|
|
625
|
+
this._server = server;
|
|
626
|
+
this._type = type;
|
|
627
|
+
this._key = key;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
_basePath() {
|
|
631
|
+
const s = this._server;
|
|
632
|
+
return `/api/v1/objects/${encodeURIComponent(s.appKey)}/${encodeURIComponent(this._type)}/${encodeURIComponent(this._key)}`;
|
|
633
|
+
}
|
|
153
634
|
|
|
154
|
-
|
|
635
|
+
async _req(method, path, body) {
|
|
636
|
+
const bodyStr = body !== undefined ? JSON.stringify(body) : undefined;
|
|
637
|
+
|
|
638
|
+
const res = await this._server._fetchWithRetry(
|
|
639
|
+
`${this._server.endpoint}${path}`,
|
|
640
|
+
{
|
|
641
|
+
method,
|
|
642
|
+
headers: this._server._authHeaders(),
|
|
643
|
+
body: bodyStr,
|
|
644
|
+
},
|
|
645
|
+
this._server.retries
|
|
646
|
+
);
|
|
155
647
|
|
|
156
|
-
|
|
157
|
-
const response = { auth };
|
|
648
|
+
if (res.status === 204) return null;
|
|
158
649
|
|
|
159
|
-
|
|
160
|
-
|
|
650
|
+
const json = await res.json().catch(() => null);
|
|
651
|
+
if (!res.ok) {
|
|
652
|
+
const detail = json?.error || res.statusText;
|
|
653
|
+
throw new Error(`DurableObject ${method} ${path} failed (${res.status}): ${detail}`);
|
|
161
654
|
}
|
|
655
|
+
return json;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Full state snapshot + log_id */
|
|
659
|
+
async state() {
|
|
660
|
+
return this._req("GET", this._basePath());
|
|
661
|
+
}
|
|
162
662
|
|
|
163
|
-
|
|
663
|
+
/** Get a single slot value */
|
|
664
|
+
async get(slot) {
|
|
665
|
+
const res = await this._req("GET", `${this._basePath()}/${encodeURIComponent(slot)}`);
|
|
666
|
+
return res?.value;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Set a single slot */
|
|
670
|
+
async set(slot, value) {
|
|
671
|
+
return this._req("PUT", `${this._basePath()}/${encodeURIComponent(slot)}`, { value });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Set multiple slots atomically */
|
|
675
|
+
async setAll(map) {
|
|
676
|
+
return this._req("PATCH", this._basePath(), map);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** POST /api/v1/objects/{appKey}/{type}/{key}/increment with { slot, by } */
|
|
680
|
+
async increment(slot, by = 1) {
|
|
681
|
+
return this._req("POST", `${this._basePath()}/increment`, { slot, by });
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** POST /api/v1/objects/{appKey}/{type}/{key}/append with { slot, value } */
|
|
685
|
+
async append(slot, value) {
|
|
686
|
+
return this._req("POST", `${this._basePath()}/append`, { slot, value });
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Delete a slot */
|
|
690
|
+
async delete(slot) {
|
|
691
|
+
return this._req("DELETE", `${this._basePath()}/${encodeURIComponent(slot)}`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Run a transaction.
|
|
696
|
+
* @param {Array<{op: "set"|"delete"|"increment"|"append", key: string, value?: any, by?: number}>} ops
|
|
697
|
+
*/
|
|
698
|
+
async transaction(ops) {
|
|
699
|
+
return this._req("POST", `${this._basePath()}/transaction`, ops);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Replay log entries after a given log_id.
|
|
704
|
+
* @param {number} [afterId=0]
|
|
705
|
+
*/
|
|
706
|
+
async log(afterId = 0) {
|
|
707
|
+
const path = `${this._basePath()}/log?after=${afterId}`;
|
|
708
|
+
return this._req("GET", path);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Purge the entire durable object (204 No Content) */
|
|
712
|
+
async purge() {
|
|
713
|
+
return this._req("DELETE", this._basePath());
|
|
164
714
|
}
|
|
165
715
|
}
|
|
166
716
|
|
|
167
717
|
export default PingerchipsServer;
|
|
718
|
+
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
720
|
+
// ChannelConfig — typed builder for flow_spec channel feature flags
|
|
721
|
+
//
|
|
722
|
+
// These flags are stored in the flow spec and read by the server on every
|
|
723
|
+
// broadcast. Pass the result as the `flow_spec` when creating/updating a flow.
|
|
724
|
+
//
|
|
725
|
+
// Flags (all optional, server defaults shown):
|
|
726
|
+
// durable false — WAL append + ClickHouse flush after broadcast
|
|
727
|
+
// replay true — buffer last N messages for reconnect recovery
|
|
728
|
+
// max_replay_messages 200 — ring buffer size (when durable=false)
|
|
729
|
+
// ordering strict — "best-effort" skips Horde, broadcasts directly
|
|
730
|
+
// flow_engine true — false = skip Executor, passthrough broadcast
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Build a channel config / flow_spec with durability and replay settings.
|
|
735
|
+
*
|
|
736
|
+
* @param {object} opts
|
|
737
|
+
* @param {boolean} [opts.durable=false] — Persist events to WAL + ClickHouse
|
|
738
|
+
* @param {boolean} [opts.replay=true] — Buffer messages for reconnect recovery
|
|
739
|
+
* @param {number} [opts.maxReplayMessages=200] — Replay buffer ring size
|
|
740
|
+
* @param {"strict"|"best-effort"} [opts.ordering="strict"]
|
|
741
|
+
* @param {boolean} [opts.flowEngine=true] — Enable flow/transform execution
|
|
742
|
+
* @returns {object} flow_spec-compatible config object
|
|
743
|
+
*/
|
|
744
|
+
export function channelConfig({
|
|
745
|
+
durable = false,
|
|
746
|
+
replay = true,
|
|
747
|
+
maxReplayMessages = 200,
|
|
748
|
+
ordering = "strict",
|
|
749
|
+
flowEngine = true,
|
|
750
|
+
} = {}) {
|
|
751
|
+
return {
|
|
752
|
+
durable,
|
|
753
|
+
replay,
|
|
754
|
+
max_replay_messages: maxReplayMessages,
|
|
755
|
+
ordering,
|
|
756
|
+
flow_engine: flowEngine,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Preset: durable channel — WAL + ClickHouse, strict ordering, full replay.
|
|
762
|
+
* Use for financial events, audit logs, or anything that must not be lost.
|
|
763
|
+
*/
|
|
764
|
+
export const durableChannelConfig = () =>
|
|
765
|
+
channelConfig({ durable: true, replay: true, ordering: "strict" });
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Preset: ephemeral channel — no WAL, no replay, best-effort ordering.
|
|
769
|
+
* Use for high-frequency metrics, cursor positions, typing indicators.
|
|
770
|
+
*/
|
|
771
|
+
export const ephemeralChannelConfig = () =>
|
|
772
|
+
channelConfig({ durable: false, replay: false, ordering: "best-effort", flowEngine: false });
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* PingerchipsServerChat — server-side chat REST API.
|
|
776
|
+
*
|
|
777
|
+
* Requires app_key + app_secret. Never use in client/browser code.
|
|
778
|
+
* Calls /api/chat with X-App-Key + X-App-Secret headers.
|
|
779
|
+
*
|
|
780
|
+
* Usage:
|
|
781
|
+
* const chat = new PingerchipsServerChat(appKey, appSecret);
|
|
782
|
+
* const thread = await chat.createThread({ title: "Support", createdBy: "user_1" });
|
|
783
|
+
* await chat.joinThread(thread.id, { userId: "user_1", type: "human" });
|
|
784
|
+
* await chat.sendMessage(thread.id, { userId: "user_1", sender: { name: "Alice" }, content: { text: "Hello" } });
|
|
785
|
+
*/
|
|
786
|
+
export class PingerchipsServerChat {
|
|
787
|
+
/**
|
|
788
|
+
* @param {string} appKey
|
|
789
|
+
* @param {string} appSecret
|
|
790
|
+
* @param {Object} [options]
|
|
791
|
+
* @param {string} [options.endpoint] - base URL, defaults to https://queue.pingerchips.com
|
|
792
|
+
*/
|
|
793
|
+
constructor(appKey, appSecret, options = {}) {
|
|
794
|
+
if (!appKey) throw new Error("appKey required");
|
|
795
|
+
if (!appSecret) throw new Error("appSecret required");
|
|
796
|
+
|
|
797
|
+
this.appKey = appKey;
|
|
798
|
+
this.appSecret = appSecret;
|
|
799
|
+
this.endpoint = (
|
|
800
|
+
options.endpoint ||
|
|
801
|
+
process.env.PINGERCHIPS_API_ENDPOINT ||
|
|
802
|
+
(process.env.NODE_ENV === "production"
|
|
803
|
+
? "https://queue.pingerchips.com"
|
|
804
|
+
: "http://localhost:4000")
|
|
805
|
+
).replace(/\/$/, "");
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
_headers() {
|
|
809
|
+
return {
|
|
810
|
+
"Content-Type": "application/vnd.api+json",
|
|
811
|
+
Accept: "application/vnd.api+json",
|
|
812
|
+
"X-App-Key": this.appKey,
|
|
813
|
+
"X-App-Secret": this.appSecret,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async _request(method, path, body) {
|
|
818
|
+
const res = await fetch(`${this.endpoint}/api/chat${path}`, {
|
|
819
|
+
method,
|
|
820
|
+
headers: this._headers(),
|
|
821
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
const json = await res.json().catch(() => null);
|
|
825
|
+
|
|
826
|
+
if (!res.ok) {
|
|
827
|
+
const detail = json?.errors?.[0]?.detail || json?.errors?.[0]?.title || res.statusText;
|
|
828
|
+
throw new Error(`Chat API ${method} ${path} failed (${res.status}): ${detail}`);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return json;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
_wrap(type, attrs) {
|
|
835
|
+
return { data: { type, attributes: attrs } };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
_unwrap(json) {
|
|
839
|
+
if (!json?.data) return json;
|
|
840
|
+
const d = json.data;
|
|
841
|
+
if (Array.isArray(d)) return d.map((r) => ({ id: r.id, ...r.attributes }));
|
|
842
|
+
return { id: d.id, ...d.attributes };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// ─── Threads ─────────────────────────────────────────────────────────────────
|
|
846
|
+
|
|
847
|
+
async createThread({ title, type = "group", createdBy, botId, retentionDays, metadata } = {}) {
|
|
848
|
+
const res = await this._request("POST", "/threads",
|
|
849
|
+
this._wrap("thread", { title, type, created_by: createdBy, bot_id: botId, retention_days: retentionDays, metadata }));
|
|
850
|
+
return this._unwrap(res);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async getThread(threadId) {
|
|
854
|
+
return this._unwrap(await this._request("GET", `/threads/${threadId}`));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async listThreads({ page } = {}) {
|
|
858
|
+
const q = page ? `?page=${encodeURIComponent(JSON.stringify(page))}` : "";
|
|
859
|
+
return this._unwrap(await this._request("GET", `/threads${q}`));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async updateThread(threadId, attrs) {
|
|
863
|
+
return this._unwrap(await this._request("PATCH", `/threads/${threadId}`, this._wrap("thread", attrs)));
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async resolveThread(threadId) {
|
|
867
|
+
return this._unwrap(await this._request("PATCH", `/threads/${threadId}/resolve`, this._wrap("thread", {})));
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async archiveThread(threadId) {
|
|
871
|
+
return this._unwrap(await this._request("PATCH", `/threads/${threadId}/archive`, this._wrap("thread", {})));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async handoffThread(threadId, assignedAgent) {
|
|
875
|
+
return this._unwrap(await this._request("PATCH", `/threads/${threadId}/handoff`, this._wrap("thread", { assigned_agent: assignedAgent })));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async assignBot(threadId, botId) {
|
|
879
|
+
return this._unwrap(await this._request("PATCH", `/threads/${threadId}/assign-bot`, this._wrap("thread", { bot_id: botId })));
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async deleteThread(threadId) {
|
|
883
|
+
return this._request("DELETE", `/threads/${threadId}`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ─── Participants ────────────────────────────────────────────────────────────
|
|
887
|
+
|
|
888
|
+
async joinThread(threadId, { userId, anonId, botId, type, role = "member", metadata } = {}) {
|
|
889
|
+
const res = await this._request("POST", `/threads/${threadId}/participants`,
|
|
890
|
+
this._wrap("participant", { thread_id: threadId, user_id: userId, anon_id: anonId, bot_id: botId, type, role, metadata }));
|
|
891
|
+
return this._unwrap(res);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async listParticipants(threadId, { page } = {}) {
|
|
895
|
+
const q = page ? `?page=${encodeURIComponent(JSON.stringify(page))}` : "";
|
|
896
|
+
return this._unwrap(await this._request("GET", `/threads/${threadId}/participants${q}`));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async markRead(threadId, participantId) {
|
|
900
|
+
return this._unwrap(await this._request("PATCH", `/threads/${threadId}/participants/${participantId}/mark-read`, this._wrap("participant", {})));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async updateParticipantRole(threadId, participantId, role) {
|
|
904
|
+
return this._unwrap(await this._request("PATCH", `/threads/${threadId}/participants/${participantId}/role`, this._wrap("participant", { role })));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async removeParticipant(threadId, participantId) {
|
|
908
|
+
return this._request("DELETE", `/threads/${threadId}/participants/${participantId}`);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ─── Messages ────────────────────────────────────────────────────────────────
|
|
912
|
+
|
|
913
|
+
async sendMessage(threadId, { userId, anonId, botId, sender, type = "text", content, parentId } = {}) {
|
|
914
|
+
const res = await this._request("POST", `/threads/${threadId}/messages`,
|
|
915
|
+
this._wrap("message", { thread_id: threadId, user_id: userId, anon_id: anonId, bot_id: botId, sender, type, content, parent_id: parentId }));
|
|
916
|
+
return this._unwrap(res);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async getMessage(threadId, messageId) {
|
|
920
|
+
return this._unwrap(await this._request("GET", `/threads/${threadId}/messages/${messageId}`));
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async listMessages(threadId, { page } = {}) {
|
|
924
|
+
const q = page ? `?page=${encodeURIComponent(JSON.stringify(page))}` : "";
|
|
925
|
+
return this._unwrap(await this._request("GET", `/threads/${threadId}/messages${q}`));
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
async listReplies(threadId, messageId, { page } = {}) {
|
|
929
|
+
const q = page ? `?page=${encodeURIComponent(JSON.stringify(page))}` : "";
|
|
930
|
+
return this._unwrap(await this._request("GET", `/threads/${threadId}/messages/${messageId}/replies${q}`));
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async editMessage(threadId, messageId, content) {
|
|
934
|
+
return this._unwrap(await this._request("PATCH", `/threads/${threadId}/messages/${messageId}/edit`, this._wrap("message", { content })));
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async deleteMessage(threadId, messageId) {
|
|
938
|
+
return this._unwrap(await this._request("PATCH", `/threads/${threadId}/messages/${messageId}/delete`, this._wrap("message", {})));
|
|
939
|
+
}
|
|
940
|
+
}
|