pingerchips-js-server 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +147 -61
  2. package/agent-session.js +212 -0
  3. package/index.js +866 -93
  4. package/package.json +5 -5
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
- # Pingerchips Server SDK
1
+ # pingerchips-js-server
2
2
 
3
- Server-side SDK for triggering events and authenticating users with Pingerchips.
3
+ Server-side SDK for Pingerchips. Trigger events, send push notifications, read
4
+ and write Durable Objects, and issue capability tokens for browser clients.
5
+
6
+ Requires Node.js 18+ (uses native `fetch`).
4
7
 
5
8
  ## Installation
6
9
 
@@ -15,110 +18,193 @@ npm install pingerchips-js-server
15
18
  ```javascript
16
19
  import PingerchipsServer from 'pingerchips-js-server';
17
20
 
18
- const pingerchips = new PingerchipsServer('app_id', 'app_secret', {
19
- appKey: 'app_key',
20
- endpoint: 'https://pinger-processor.pingerchips.com/api'
21
- });
21
+ const pingerchips = new PingerchipsServer('app_key', 'app_secret');
22
22
  ```
23
23
 
24
- ### Trigger Events
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.
25
30
 
26
- Send events to channels from your server:
31
+ ### Trigger events
27
32
 
28
33
  ```javascript
29
- await pingerchips.trigger('lobby', 'message', {
34
+ await pingerchips.trigger('lobby', 'new-message', {
30
35
  text: 'Hello from server!',
31
36
  timestamp: Date.now()
32
37
  });
33
38
  ```
34
39
 
35
- ### Authenticate Users (Private/Presence Channels)
40
+ Failed requests are retried on 5xx and network errors (default: 2 retries).
36
41
 
37
- Implement an auth endpoint on your server:
42
+ ## Authentication (ADR-0016 unified capability tokens)
38
43
 
39
- ```javascript
40
- import express from 'express';
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.
47
+
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` |
41
54
 
42
- const app = express();
43
- app.use(express.json());
55
+ Capability grammar: `product:verb:resource`
44
56
 
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 `*` |
62
+
63
+ ### Private / presence channels
64
+
65
+ ```javascript
45
66
  app.post('/auth', (req, res) => {
46
67
  const { socket_id, channel_name, auth_info } = req.body;
47
68
 
48
- // Validate user from session/token
49
69
  const user = validateUser(auth_info);
50
- if (!user) {
51
- return res.status(403).json({ error: 'Unauthorized' });
52
- }
70
+ if (!user) return res.status(403).json({ error: 'Unauthorized' });
71
+
72
+ const userData = channel_name.startsWith('presence-')
73
+ ? { user_id: user.id, name: user.name }
74
+ : null;
53
75
 
54
- // For presence channels, provide user data
55
- const userData = channel_name.startsWith('presence-') ? {
56
- user_id: user.id,
57
- user_info: {
58
- name: user.name,
59
- avatar: user.avatar
60
- }
61
- } : null;
62
-
63
- // Sign authentication with Pingerchips
64
- const authData = pingerchips.authenticate(socket_id, channel_name, userData);
65
- res.json(authData);
76
+ res.json(pingerchips.authenticate(socket_id, channel_name, userData));
66
77
  });
67
78
  ```
68
79
 
69
- ## API Reference
80
+ ```javascript
81
+ const client = new Pingerchips('app_key', {
82
+ authEndpoint: 'https://your-server.com/auth'
83
+ });
84
+ ```
70
85
 
71
- ### `new PingerchipsServer(appId, appSecret, options)`
86
+ ### Many grants on one token
72
87
 
73
- Create a new server instance.
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
+ ```
74
96
 
75
- **Options:**
76
- - `appKey` - Your app key (required for authentication)
77
- - `endpoint` - API endpoint URL
78
- - `token` - API token for internal endpoints
79
- - `mtls` - mTLS configuration for secure connections
97
+ ### Chat threads (server-issued JWT)
80
98
 
81
- ### `trigger(channel, event, data)`
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
+ ```
82
108
 
83
- Send an event to a channel.
109
+ ### Durable Objects (browser subscription token)
84
110
 
85
111
  ```javascript
86
- await pingerchips.trigger('my-channel', 'my-event', { message: 'Hello' });
112
+ const { auth } = pingerchips.authenticateObject(socketId, 'order', 'order-42');
113
+ // grants object:read:order/order-42 bound to socketId
87
114
  ```
88
115
 
89
- ### `authenticate(socketId, channelName, userData?)`
116
+ ## API
90
117
 
91
- Generate signed authentication for private/presence channels.
118
+ ### `new PingerchipsServer(appKey, appSecret, options?)`
92
119
 
93
- **Parameters:**
94
- - `socketId` - Socket ID from client
95
- - `channelName` - Channel name (e.g., "private-chat" or "presence-lobby")
96
- - `userData` - User data for presence channels (must include `user_id`)
120
+ | Parameter | Type | Description |
121
+ |---|---|---|
122
+ | `appKey` | string | Your app key |
123
+ | `appSecret` | string | Your app secret sent only to the Pingerchips backend |
97
124
 
98
- **Returns:**
99
- ```javascript
100
- {
101
- auth: "app_key:hmac_signature",
102
- channel_data: "{\"user_id\":\"123\",...}" // presence channels only
103
- }
104
- ```
125
+ **Options:**
126
+
127
+ | Option | Type | Default | Description |
128
+ |---|---|---|---|
129
+ | `endpoint` | string | auto | API base URL |
130
+ | `requestTimeout` | number | `10000` | Request timeout in ms |
131
+ | `retries` | number | `2` | Retries on 5xx / network error |
132
+ | `mtls` | object | — | mTLS config (see below) |
133
+
134
+ ### `trigger(channel, event, data)`
135
+
136
+ - `channel` — max 200 chars
137
+ - `event` — max 200 chars
138
+ - `data` — JSON serializable, max 64KB
139
+
140
+ ### `notify(userId, { title, body }, options?)`
141
+
142
+ Send a push notification. `options.trigger` is `"offline"` (default, skipped if
143
+ the user is connected) or `"always"`.
144
+
145
+ ### `registerPushToken(userId, device)` / `unregisterPushToken(userId, deviceId)`
146
+
147
+ Server-side device token management.
148
+
149
+ ### `authenticate(socketId, channelName, userData?)` → `{ auth, channel_data? }`
150
+
151
+ Fast-path token for one `private-` / `presence-` channel.
152
+
153
+ ### `authorize(socketId, grants, clientId?)` → `{ auth }`
105
154
 
106
- ## mTLS Support
155
+ Fast-path token for many grants. `grants`: `{ channels?, presence?, publish?,
156
+ objects?, threads?, capabilities? }`.
107
157
 
108
- For secure server-to-server communication:
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).
162
+
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
109
183
 
110
184
  ```javascript
111
- const pingerchips = new PingerchipsServer('app_id', 'app_secret', {
112
- endpoint: 'https://pinger-processor.pingerchips.com/api',
185
+ const pingerchips = new PingerchipsServer('app_key', 'app_secret', {
113
186
  mtls: {
114
187
  enabled: true,
115
- cert: '/path/to/client-cert.pem',
188
+ cert: '/path/to/client-cert.pem', // or PEM string directly
116
189
  key: '/path/to/client-key.pem',
117
- ca: '/path/to/ca-cert.pem'
190
+ ca: '/path/to/ca-cert.pem',
191
+ rejectUnauthorized: true // default — do not disable
118
192
  }
119
193
  });
120
194
  ```
121
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
+
122
208
  ## License
123
209
 
124
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 };