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.
Files changed (4) hide show
  1. package/README.md +147 -61
  2. package/agent-session.js +212 -0
  3. package/index.js +866 -93
  4. 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
- constructor(appId, appSecret, options = {}) {
7
- this.appId = appId;
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://pinger-processor.pingerchips.com"
15
- : "http://localhost:4000";
16
- this.token = options.token;
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 !== false,
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
- // Load cert, key, and ca - support both file paths and direct content
40
- if (this.mtls.cert) {
41
- agentOptions.cert = this._loadCertificate(this.mtls.cert);
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
- // If it looks like a certificate/key content (starts with -----), return as-is
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
- const fetch = await this.fetchPromise;
67
- const url = `${this.endpoint}/apps/${this.appId}/trigger`;
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
- "Content-Type": "application/json",
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 fetch(url, requestOptions);
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 await response.json();
211
+ return response.json();
99
212
  }
100
213
 
101
214
  /**
102
- * Authenticate a user for a private or presence channel
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} socketId - Socket ID from the client SDK
108
- * @param {string} channelName - Channel name (e.g., "private-chat" or "presence-lobby")
109
- * @param {object} userData - User data for presence channels (must include user_id)
110
- * @returns {object} - Signed authentication data: { auth, channel_data? }
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
- * @example
113
- * // In your Express.js auth endpoint:
114
- * app.post('/auth', (req, res) => {
115
- * const { socket_id, channel_name, auth_info } = req.body;
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
- * // Validate user from session/token
118
- * const user = req.session.user;
119
- * if (!user) return res.status(403).json({ error: 'Unauthorized' });
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
- * // For presence channels, provide user data
122
- * const userData = channel_name.startsWith('presence-') ? {
123
- * user_id: user.id,
124
- * user_info: { name: user.name, avatar: user.avatar }
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
- * const authData = pingerchips.authenticate(socket_id, channel_name, userData);
128
- * res.json(authData);
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
- // Build the full topic name
133
- const fullTopic = `app:${this.appKey}:room:${channelName}`;
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
- // Build string to sign based on channel type
136
- let stringToSign;
137
- let channelData = null;
521
+ return result;
522
+ }
138
523
 
139
- if (userData && channelName.startsWith("presence-")) {
140
- // Presence channel: include user data in signature
141
- channelData = JSON.stringify(userData);
142
- stringToSign = `${socketId}:${fullTopic}:${channelData}`;
143
- } else {
144
- // Private channel: just socket_id and topic
145
- stringToSign = `${socketId}:${fullTopic}`;
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
- // Generate HMAC-SHA256 signature using app_secret
149
- const signature = crypto
150
- .createHmac("sha256", this.appSecret)
151
- .update(stringToSign)
152
- .digest("hex");
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
- const auth = `${this.appKey}:${signature}`;
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
- // Build response
157
- const response = { auth };
648
+ if (res.status === 204) return null;
158
649
 
159
- if (channelData) {
160
- response.channel_data = channelData;
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
- return response;
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
+ }