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.
- package/README.md +111 -39
- package/agent-session.js +212 -0
- package/index.js +748 -45
- 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
|
|
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`)
|
|
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
|
-
|
|
42
|
+
## Authentication (ADR-0016 — unified capability tokens)
|
|
37
43
|
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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,
|
|
73
|
+
? { user_id: user.id, name: user.name }
|
|
54
74
|
: null;
|
|
55
75
|
|
|
56
|
-
|
|
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 —
|
|
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` —
|
|
138
|
+
- `data` — JSON serializable, max 64KB
|
|
95
139
|
|
|
96
|
-
|
|
97
|
-
await pingerchips.trigger('room-42', 'message', { text: 'hi' });
|
|
98
|
-
```
|
|
140
|
+
### `notify(userId, { title, body }, options?)`
|
|
99
141
|
|
|
100
|
-
|
|
142
|
+
Send a push notification. `options.trigger` is `"offline"` (default, skipped if
|
|
143
|
+
the user is connected) or `"always"`.
|
|
101
144
|
|
|
102
|
-
|
|
145
|
+
### `registerPushToken(userId, device)` / `unregisterPushToken(userId, deviceId)`
|
|
103
146
|
|
|
104
|
-
-
|
|
105
|
-
- `userData` required for presence channels, must include `user_id`
|
|
147
|
+
Server-side device token management.
|
|
106
148
|
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/agent-session.js
ADDED
|
@@ -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
|
|
8
|
-
* @param {string} appSecret - App secret
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
170
|
-
*
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
453
|
+
return {
|
|
454
|
+
auth: mintFastToken(this.appKey, this.appSecret, socketId, caps, clientId),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
226
457
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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": "
|
|
4
|
-
"description": "Pingerchips server SDK for Node.js - trigger events and
|
|
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": "
|
|
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": ">=
|
|
32
|
+
"node": ">=18.0.0"
|
|
31
33
|
}
|
|
32
34
|
}
|