pingerchips-js-server 2.1.1 → 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 +111 -39
  2. package/agent-session.js +212 -0
  3. package/index.js +748 -45
  4. package/package.json +7 -5
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # pingerchips-js-server
2
2
 
3
- Server-side SDK for Pingerchips. Trigger events and authenticate users for private/presence channels.
3
+ Server-side SDK for Pingerchips. Trigger events, send push notifications, read
4
+ and write Durable Objects, and issue capability tokens for browser clients.
4
5
 
5
6
  Requires Node.js 18+ (uses native `fetch`).
6
7
 
@@ -20,7 +21,12 @@ import PingerchipsServer from 'pingerchips-js-server';
20
21
  const pingerchips = new PingerchipsServer('app_key', 'app_secret');
21
22
  ```
22
23
 
23
- Defaults to `https://queue.pingerchips.com` in production (`NODE_ENV=production`) and `http://localhost:4000` otherwise. Override with `options.endpoint` or the `PINGERCHIPS_API_ENDPOINT` env var.
24
+ Defaults to `https://queue.pingerchips.com` in production (`NODE_ENV=production`)
25
+ and `http://localhost:4000` otherwise. Override with `options.endpoint` or the
26
+ `PINGERCHIPS_API_ENDPOINT` env var.
27
+
28
+ Every server call authenticates with `X-App-Key` / `X-App-Secret` headers. The
29
+ App Secret is sent only to the Pingerchips backend — never to a browser.
24
30
 
25
31
  ### Trigger events
26
32
 
@@ -33,16 +39,30 @@ await pingerchips.trigger('lobby', 'new-message', {
33
39
 
34
40
  Failed requests are retried on 5xx and network errors (default: 2 retries).
35
41
 
36
- ### Authenticate users (private/presence channels)
42
+ ## Authentication (ADR-0016 — unified capability tokens)
37
43
 
38
- Implement an auth endpoint on your server:
44
+ Every WebSocket join a private/presence channel, a Durable Object, or a chat
45
+ thread — carries one `auth` token. Your backend mints it and returns it to the
46
+ client SDK, which forwards it in the join params.
39
47
 
40
- ```javascript
41
- import express from 'express';
48
+ Two token forms:
49
+
50
+ | Form | Minted by | Network call | Lifetime |
51
+ |---|---|---|---|
52
+ | **fast-path** `{appKey}.{payloadB64}.{sigB64}` | `authenticate`, `authenticateObject`, `authorize` | none (pure function) | no expiry |
53
+ | **JWT** | `authenticateChat`, `issueToken` (`POST /api/auth`) | one round-trip | short-lived, has a `jti` |
54
+
55
+ Capability grammar: `product:verb:resource`
42
56
 
43
- const app = express();
44
- app.use(express.json());
57
+ | product | verbs | resource |
58
+ |---|---|---|
59
+ | `channel` | `subscribe`, `publish`, `presence` | channel name, `prefix*`, or `*` |
60
+ | `object` | `read` | `{type}/{key}`, `{type}/*`, or `*` |
61
+ | `chat` | `subscribe`, `publish:user_message`, `cancel_own`, `tool_approval` | thread id, `prefix*`, or `*` |
45
62
 
63
+ ### Private / presence channels
64
+
65
+ ```javascript
46
66
  app.post('/auth', (req, res) => {
47
67
  const { socket_id, channel_name, auth_info } = req.body;
48
68
 
@@ -50,22 +70,49 @@ app.post('/auth', (req, res) => {
50
70
  if (!user) return res.status(403).json({ error: 'Unauthorized' });
51
71
 
52
72
  const userData = channel_name.startsWith('presence-')
53
- ? { user_id: user.id, user_info: { name: user.name } }
73
+ ? { user_id: user.id, name: user.name }
54
74
  : null;
55
75
 
56
- const authData = pingerchips.authenticate(socket_id, channel_name, userData);
57
- res.json(authData);
76
+ res.json(pingerchips.authenticate(socket_id, channel_name, userData));
58
77
  });
59
78
  ```
60
79
 
61
- Configure the client to use it:
62
-
63
80
  ```javascript
64
81
  const client = new Pingerchips('app_key', {
65
82
  authEndpoint: 'https://your-server.com/auth'
66
83
  });
67
84
  ```
68
85
 
86
+ ### Many grants on one token
87
+
88
+ ```javascript
89
+ const { auth } = pingerchips.authorize(socketId, {
90
+ channels: ['orders', 'shipments'],
91
+ presence: ['lobby'],
92
+ objects: [{ type: 'order', key: 'order-42' }],
93
+ threads: ['thread-abc'],
94
+ }, clientId);
95
+ ```
96
+
97
+ ### Chat threads (server-issued JWT)
98
+
99
+ ```javascript
100
+ // full client capability set for the thread
101
+ const { auth } = await pingerchips.authenticateChat(socketId, threadId, clientId);
102
+
103
+ // narrowed — read-only
104
+ const { auth } = await pingerchips.authenticateChat(
105
+ socketId, threadId, clientId, ['subscribe']
106
+ );
107
+ ```
108
+
109
+ ### Durable Objects (browser subscription token)
110
+
111
+ ```javascript
112
+ const { auth } = pingerchips.authenticateObject(socketId, 'order', 'order-42');
113
+ // grants object:read:order/order-42 bound to socketId
114
+ ```
115
+
69
116
  ## API
70
117
 
71
118
  ### `new PingerchipsServer(appKey, appSecret, options?)`
@@ -73,7 +120,7 @@ const client = new Pingerchips('app_key', {
73
120
  | Parameter | Type | Description |
74
121
  |---|---|---|
75
122
  | `appKey` | string | Your app key |
76
- | `appSecret` | string | Your app secret — used for HMAC signing, never sent over the wire |
123
+ | `appSecret` | string | Your app secret — sent only to the Pingerchips backend |
77
124
 
78
125
  **Options:**
79
126
 
@@ -82,44 +129,57 @@ const client = new Pingerchips('app_key', {
82
129
  | `endpoint` | string | auto | API base URL |
83
130
  | `requestTimeout` | number | `10000` | Request timeout in ms |
84
131
  | `retries` | number | `2` | Retries on 5xx / network error |
85
- | `token` | string | — | Internal API token (legacy) |
86
132
  | `mtls` | object | — | mTLS config (see below) |
87
133
 
88
134
  ### `trigger(channel, event, data)`
89
135
 
90
- Trigger an event on a channel.
91
-
92
136
  - `channel` — max 200 chars
93
137
  - `event` — max 200 chars
94
- - `data` — must be JSON serializable, max 64KB
138
+ - `data` — JSON serializable, max 64KB
95
139
 
96
- ```javascript
97
- await pingerchips.trigger('room-42', 'message', { text: 'hi' });
98
- ```
140
+ ### `notify(userId, { title, body }, options?)`
99
141
 
100
- ### `authenticate(socketId, channelName, userData?)`
142
+ Send a push notification. `options.trigger` is `"offline"` (default, skipped if
143
+ the user is connected) or `"always"`.
101
144
 
102
- Sign a channel auth request. Call from your auth endpoint.
145
+ ### `registerPushToken(userId, device)` / `unregisterPushToken(userId, deviceId)`
103
146
 
104
- - `channelName` must start with `private-` or `presence-`
105
- - `userData` required for presence channels, must include `user_id`
147
+ Server-side device token management.
106
148
 
107
- ```javascript
108
- // Private channel
109
- const auth = pingerchips.authenticate(socketId, 'private-chat');
110
- // → { auth: "app_key:hmac_signature" }
111
-
112
- // Presence channel
113
- const auth = pingerchips.authenticate(socketId, 'presence-lobby', {
114
- user_id: '123',
115
- user_info: { name: 'Alice' }
116
- });
117
- // → { auth: "app_key:hmac_signature", channel_data: "{\"user_id\":\"123\",...}" }
118
- ```
149
+ ### `authenticate(socketId, channelName, userData?)` → `{ auth, channel_data? }`
119
150
 
120
- ## mTLS
151
+ Fast-path token for one `private-` / `presence-` channel.
152
+
153
+ ### `authorize(socketId, grants, clientId?)` → `{ auth }`
154
+
155
+ Fast-path token for many grants. `grants`: `{ channels?, presence?, publish?,
156
+ objects?, threads?, capabilities? }`.
157
+
158
+ ### `authenticateChat(socketId, threadId, clientId, verbs?)` → `Promise<{ auth }>`
159
+
160
+ Server-issued JWT via `POST /api/auth` for a chat thread. `verbs` narrows the
161
+ grant (short verbs or fully-qualified `chat:{verb}:{threadId}` strings).
121
162
 
122
- For secure server-to-server communication:
163
+ ### `issueToken(socketId, clientId, grants)` → `Promise<{ auth }>`
164
+
165
+ Server-issued JWT for an arbitrary grant set (same shape as `authorize`).
166
+
167
+ ### `authenticateObject(socketId, objectType, objectKey)` → `{ auth }`
168
+
169
+ Fast-path token granting `object:read:{type}/{key}`.
170
+
171
+ ### `object(type, key)` → `DurableObjectHandle`
172
+
173
+ HTTP client for a Durable Object: `.state()`, `.get(slot)`, `.set(slot, value)`,
174
+ `.setAll(map)`, `.increment(slot, by?)`, `.append(slot, value)`,
175
+ `.delete(slot)`, `.transaction(ops)`, `.log(afterId?)`, `.purge()`.
176
+
177
+ ### `PingerchipsServerChat`
178
+
179
+ Server-side chat REST API (threads, participants, messages). Authenticates with
180
+ `X-App-Key` / `X-App-Secret`.
181
+
182
+ ## mTLS
123
183
 
124
184
  ```javascript
125
185
  const pingerchips = new PingerchipsServer('app_key', 'app_secret', {
@@ -133,6 +193,18 @@ const pingerchips = new PingerchipsServer('app_key', 'app_secret', {
133
193
  });
134
194
  ```
135
195
 
196
+ ## Migrating from 2.x
197
+
198
+ - App credentials moved from request bodies / HMAC signatures to `X-App-Key` /
199
+ `X-App-Secret` headers. No code change if you only call `trigger` / `notify`.
200
+ - `authenticate()` now returns a capability token instead of an
201
+ `{appKey}:{hmac}` string. The client SDK forwards it unchanged — no client
202
+ change needed.
203
+ - `authenticateChat()` calls `POST /api/auth` (was `/api/chat/auth`) and takes
204
+ chat *verbs* to narrow, not raw capability strings.
205
+ - `authenticateObject()` returns a capability token, not an HMAC string.
206
+ - New: `authorize()` and `issueToken()` for multi-grant tokens.
207
+
136
208
  ## License
137
209
 
138
210
  MIT
@@ -0,0 +1,212 @@
1
+ import { Socket } from "phoenix";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // AgentSession
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /**
8
+ * AgentSession — connects an agent process to a chat thread over WebSocket.
9
+ *
10
+ * Usage (in your agent HTTP handler):
11
+ *
12
+ * import { AgentSession } from "pingerchips-js-server/agent-session.js";
13
+ *
14
+ * const agent = new AgentSession(appKey, appSecret);
15
+ * const session = await agent.connect(threadId, "agent-1");
16
+ * const run = session.createRun(runId); // runId from HTTP invocation body
17
+ *
18
+ * await run.start();
19
+ * const messages = await run.loadConversation();
20
+ * // ... call LLM, stream tokens ...
21
+ * await run.write(accumulatedContent);
22
+ * await run.end("complete");
23
+ *
24
+ * session.close();
25
+ */
26
+ export class AgentSession {
27
+ constructor(appKey, appSecret, options = {}) {
28
+ if (!appKey) throw new Error("appKey required");
29
+ if (!appSecret) throw new Error("appSecret required");
30
+
31
+ this._appKey = appKey;
32
+ this._appSecret = appSecret;
33
+ this._options = options;
34
+ }
35
+
36
+ /**
37
+ * @param {string} threadId
38
+ * @param {string} agentId
39
+ * @returns {Promise<AgentThreadSession>}
40
+ */
41
+ async connect(threadId, agentId) {
42
+ if (!agentId) throw new Error("agentId required");
43
+
44
+ const endpoint = this._options.endpoint ||
45
+ process.env.PINGERCHIPS_ENDPOINT ||
46
+ (process.env.NODE_ENV === "production"
47
+ ? "wss://queue.pingerchips.com/socket"
48
+ : "ws://localhost:4000/socket");
49
+
50
+ const socket = new Socket(endpoint, { params: { app_key: this._appKey } });
51
+ socket.connect();
52
+
53
+ const topic = `chat:v1:app:${this._appKey}:thread:${threadId}`;
54
+ const joinParams = { secret: this._appSecret, agent_id: agentId };
55
+ const channel = socket.channel(topic, joinParams);
56
+
57
+ return new Promise((resolve, reject) => {
58
+ channel
59
+ .join()
60
+ .receive("ok", (resp) => {
61
+ resolve(new AgentThreadSession(channel, threadId, socket));
62
+ })
63
+ .receive("error", (resp) => {
64
+ socket.disconnect();
65
+ reject(new Error(`AgentSession join failed: ${JSON.stringify(resp)}`));
66
+ });
67
+ });
68
+ }
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // AgentThreadSession
73
+ // ---------------------------------------------------------------------------
74
+
75
+ class AgentThreadSession {
76
+ constructor(channel, threadId, socket) {
77
+ this._channel = channel;
78
+ this._threadId = threadId;
79
+ this._socket = socket;
80
+ this._runs = new Set();
81
+ }
82
+
83
+ /**
84
+ * Create a Run handle for the given runId (from the HTTP invocation body).
85
+ * @param {string} runId
86
+ * @returns {Run}
87
+ */
88
+ createRun(runId) {
89
+ if (!runId) throw new Error("runId required");
90
+ const run = new Run(runId, this._channel, this._threadId, () => {
91
+ this._runs.delete(run);
92
+ });
93
+ this._runs.add(run);
94
+ return run;
95
+ }
96
+
97
+ close() {
98
+ for (const run of this._runs) run.close();
99
+ this._runs.clear();
100
+ this._channel.leave();
101
+ this._socket.disconnect();
102
+ }
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Run
107
+ // ---------------------------------------------------------------------------
108
+
109
+ class Run {
110
+ constructor(runId, channel, threadId, onClose) {
111
+ this.runId = runId;
112
+ this._channel = channel;
113
+ this._threadId = threadId;
114
+ this._abortCtrl = new AbortController();
115
+ this._onClose = onClose;
116
+
117
+ // Agent listens for cancel pushes from clients on the run-specific PubSub topic.
118
+ // The channel forwards tool_approval via PubSub — handled separately.
119
+ this._runEndRef = channel.on("run:end", ({ runId: endedRunId }) => {
120
+ if (endedRunId !== runId) return;
121
+ this._abortCtrl.abort();
122
+ this.close();
123
+ });
124
+ }
125
+
126
+ /** AbortSignal that fires when this run is cancelled. */
127
+ get abortSignal() {
128
+ return this._abortCtrl.signal;
129
+ }
130
+
131
+ /**
132
+ * Mark the run as active. Transitions pending → active in WAL.
133
+ * @param {string} ownerClientId — the client who sent the message
134
+ * @returns {Promise<void>}
135
+ */
136
+ start(ownerClientId) {
137
+ return this._push("run:start", { runId: this.runId, ownerClientId });
138
+ }
139
+
140
+ /**
141
+ * Load the active branch of the conversation for passing to the LLM.
142
+ * Returns messages in the current branch (follows parentId from last_message_id).
143
+ * @returns {Promise<object[]>}
144
+ */
145
+ loadConversation() {
146
+ return new Promise((resolve, reject) => {
147
+ this._channel
148
+ .push("load_older", { beforeMessageId: "__all__", limit: 500 })
149
+ .receive("ok", ({ messages }) => resolve(messages))
150
+ .receive("error", (e) => reject(new Error(e.reason || "loadConversation failed")));
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Write accumulated token content to the stream slot.
156
+ * Pass the full accumulated string on every call, not just the new token.
157
+ * @param {string} content — full accumulated content so far
158
+ * @returns {Promise<void>}
159
+ */
160
+ write(content) {
161
+ return this._push("run:token", { runId: this.runId, content });
162
+ }
163
+
164
+ /**
165
+ * End the run and trigger GC compaction.
166
+ * @param {"complete"|"cancelled"|"error"} reason
167
+ * @returns {Promise<void>}
168
+ */
169
+ async end(reason = "complete") {
170
+ try {
171
+ return await this._push("run:end", { runId: this.runId, reason });
172
+ } finally {
173
+ this.close();
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Suspend the run pending human tool approval.
179
+ * @param {{ toolCallId: string, toolName: string, args: object }} toolInfo
180
+ * @returns {Promise<void>}
181
+ */
182
+ suspend({ toolCallId, toolName, args }) {
183
+ return this._push("run:suspend", { runId: this.runId, toolCallId, toolName, args });
184
+ }
185
+
186
+ /**
187
+ * Resume a suspended run after tool approval.
188
+ * @returns {Promise<void>}
189
+ */
190
+ resume() {
191
+ return this._push("run:resume", { runId: this.runId });
192
+ }
193
+
194
+ _push(event, payload) {
195
+ return new Promise((resolve, reject) => {
196
+ this._channel
197
+ .push(event, payload)
198
+ .receive("ok", resolve)
199
+ .receive("error", (e) => reject(new Error(e.reason || `${event} failed`)));
200
+ });
201
+ }
202
+
203
+ close() {
204
+ if (this._runEndRef == null) return;
205
+ this._channel.off("run:end", this._runEndRef);
206
+ this._runEndRef = null;
207
+ this._onClose?.();
208
+ this._onClose = null;
209
+ }
210
+ }
211
+
212
+ export { AgentThreadSession, Run };
package/index.js CHANGED
@@ -2,10 +2,65 @@ 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
61
  /**
7
- * @param {string} appKey - App key (used for signing and routing)
8
- * @param {string} appSecret - App secret (used for HMAC signing)
62
+ * @param {string} appKey - App key used for authentication and routing
63
+ * @param {string} appSecret - App secret used for server authentication
9
64
  * @param {object} options
10
65
  */
11
66
  constructor(appKey, appSecret, options = {}) {
@@ -17,7 +72,6 @@ class PingerchipsServer {
17
72
  (process.env.NODE_ENV === "production"
18
73
  ? "https://queue.pingerchips.com"
19
74
  : "http://localhost:4000");
20
- this.token = options.token;
21
75
  this.requestTimeout = options.requestTimeout || 10000;
22
76
  this.retries = options.retries ?? 2; // retry 5xx up to N times
23
77
 
@@ -74,22 +128,19 @@ class PingerchipsServer {
74
128
  }
75
129
  }
76
130
 
77
- _generateSignature(method, path, body) {
78
- const timestamp = Math.floor(Date.now() / 1000);
79
- const bodyString = JSON.stringify(body);
80
- const stringToSign = `${method}\n${path}\n${timestamp}\n${bodyString}`;
81
-
82
- const signature = crypto
83
- .createHmac("sha256", this.appSecret)
84
- .update(stringToSign)
85
- .digest("hex");
86
-
87
- return { signature, timestamp };
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
+ };
88
138
  }
89
139
 
90
140
  async _fetchWithRetry(url, requestOptions, retriesLeft) {
91
141
  const controller = new AbortController();
92
142
  const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
143
+ const attempt = this.retries - retriesLeft;
93
144
 
94
145
  try {
95
146
  const response = await fetch(url, {
@@ -99,6 +150,9 @@ class PingerchipsServer {
99
150
 
100
151
  // Retry on 5xx if retries remain
101
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));
102
156
  return this._fetchWithRetry(url, requestOptions, retriesLeft - 1);
103
157
  }
104
158
 
@@ -109,6 +163,8 @@ class PingerchipsServer {
109
163
  }
110
164
  // Retry on network errors
111
165
  if (retriesLeft > 0) {
166
+ const delay = Math.min(100 * 2 ** attempt, 5000);
167
+ await new Promise((r) => setTimeout(r, delay));
112
168
  return this._fetchWithRetry(url, requestOptions, retriesLeft - 1);
113
169
  }
114
170
  throw err;
@@ -118,8 +174,8 @@ class PingerchipsServer {
118
174
  }
119
175
 
120
176
  /**
121
- * Trigger an event on a channel.
122
- * Uses HMAC-SHA256 signature authentication.
177
+ * Trigger an event on a channel. Authenticated with X-App-Key / X-App-Secret
178
+ * headers (ADR-0016).
123
179
  *
124
180
  * @param {string} channel
125
181
  * @param {string} event
@@ -132,23 +188,13 @@ class PingerchipsServer {
132
188
  const path = `/api/apps/${this.appKey}/trigger`;
133
189
  const url = `${this.endpoint}${path}`;
134
190
  const body = { channel, event, data };
135
- const { signature, timestamp } = this._generateSignature("POST", path, body);
136
191
 
137
192
  const requestOptions = {
138
193
  method: "POST",
139
- headers: {
140
- "Content-Type": "application/json",
141
- "X-App-Key": this.appKey,
142
- "X-Signature": signature,
143
- "X-Timestamp": timestamp.toString(),
144
- },
194
+ headers: this._authHeaders(),
145
195
  body: JSON.stringify(body),
146
196
  };
147
197
 
148
- if (this.token) {
149
- requestOptions.headers["token"] = this.token;
150
- }
151
-
152
198
  if (this.mtls.enabled) {
153
199
  requestOptions.agent = this._createHttpsAgent();
154
200
  }
@@ -166,8 +212,140 @@ class PingerchipsServer {
166
212
  }
167
213
 
168
214
  /**
169
- * Authenticate a user for a private or presence channel.
170
- * Call this from your auth endpoint.
215
+ * Send a push notification to a specific user.
216
+ *
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).
260
+ *
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.
328
+ *
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.
344
+ *
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.
171
349
  *
172
350
  * @param {string} socketId - Socket ID from the client SDK
173
351
  * @param {string} channelName - Must start with "private-" or "presence-"
@@ -206,32 +384,557 @@ class PingerchipsServer {
206
384
  }
207
385
  }
208
386
 
209
- const fullTopic = `app:${this.appKey}:room:${channelName}`;
210
- let userDataString = null;
211
- let stringToSign;
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 };
212
399
 
213
400
  if (userData) {
214
- userDataString = JSON.stringify(userData);
215
- stringToSign = `${socketId}:${fullTopic}:${userDataString}`;
216
- } else {
217
- stringToSign = `${socketId}:${fullTopic}`;
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}`);
218
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);
219
446
 
220
- const signature = crypto
221
- .createHmac("sha256", this.appSecret)
222
- .update(stringToSign)
223
- .digest("hex");
447
+ if (caps.length === 0) {
448
+ throw new Error(
449
+ "authorize: pass at least one of channels / presence / publish / objects / threads / capabilities",
450
+ );
451
+ }
224
452
 
225
- const result = { auth: `${this.appKey}:${signature}` };
453
+ return {
454
+ auth: mintFastToken(this.appKey, this.appSecret, socketId, caps, clientId),
455
+ };
456
+ }
226
457
 
227
- if (isPresence && userDataString) {
228
- result.channel_data = userDataString;
229
- } else if (userDataString) {
230
- result.user_data = userDataString;
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}`);
231
519
  }
232
520
 
233
521
  return result;
234
522
  }
523
+
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");
537
+ }
538
+
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
+ }
634
+
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
+ );
647
+
648
+ if (res.status === 204) return null;
649
+
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}`);
654
+ }
655
+ return json;
656
+ }
657
+
658
+ /** Full state snapshot + log_id */
659
+ async state() {
660
+ return this._req("GET", this._basePath());
661
+ }
662
+
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());
714
+ }
235
715
  }
236
716
 
237
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
+ }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "pingerchips-js-server",
3
- "version": "2.1.1",
4
- "description": "Pingerchips server SDK for Node.js - trigger events and authenticate users",
3
+ "version": "3.0.0",
4
+ "description": "Pingerchips server SDK for Node.js - trigger events and issue capability tokens",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "scripts": {
8
- "test": "echo \"Error: no test specified\" && exit 1"
8
+ "test": "node --test test/*.test.js"
9
9
  },
10
10
  "keywords": [
11
11
  "pingerchips",
@@ -17,7 +17,9 @@
17
17
  ],
18
18
  "author": "Pingerchips",
19
19
  "license": "MIT",
20
- "dependencies": {},
20
+ "dependencies": {
21
+ "phoenix": "^1.7.0"
22
+ },
21
23
  "repository": {
22
24
  "type": "git",
23
25
  "url": "https://github.com/pingerchips/pingerchips-js-server"
@@ -27,6 +29,6 @@
27
29
  },
28
30
  "homepage": "https://pingerchips.com",
29
31
  "engines": {
30
- "node": ">=14.0.0"
32
+ "node": ">=18.0.0"
31
33
  }
32
34
  }