@pingroom/sdk 0.7.1 → 0.9.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 +288 -44
- package/dist/client.d.ts +27 -312
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +33 -1154
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +40 -6
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +56 -4
- package/dist/errors.js.map +1 -1
- package/dist/http.d.ts +12 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +47 -18
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/async.d.ts +2 -0
- package/dist/internal/async.d.ts.map +1 -1
- package/dist/internal/async.js +6 -0
- package/dist/internal/async.js.map +1 -1
- package/dist/internal/guards.d.ts +16 -0
- package/dist/internal/guards.d.ts.map +1 -1
- package/dist/internal/guards.js +22 -1
- package/dist/internal/guards.js.map +1 -1
- package/dist/internal/request.d.ts +41 -0
- package/dist/internal/request.d.ts.map +1 -0
- package/dist/internal/request.js +78 -0
- package/dist/internal/request.js.map +1 -0
- package/dist/internal/retry.d.ts +65 -0
- package/dist/internal/retry.d.ts.map +1 -0
- package/dist/internal/retry.js +126 -0
- package/dist/internal/retry.js.map +1 -0
- package/dist/internal/url.d.ts +9 -0
- package/dist/internal/url.d.ts.map +1 -1
- package/dist/internal/url.js +25 -0
- package/dist/internal/url.js.map +1 -1
- package/dist/internal/validate.d.ts +13 -0
- package/dist/internal/validate.d.ts.map +1 -0
- package/dist/internal/validate.js +36 -0
- package/dist/internal/validate.js.map +1 -0
- package/dist/linkPing.d.ts.map +1 -1
- package/dist/linkPing.js +6 -2
- package/dist/linkPing.js.map +1 -1
- package/dist/liveStatus.d.ts +106 -11
- package/dist/liveStatus.d.ts.map +1 -1
- package/dist/liveStatus.js +20 -3
- package/dist/liveStatus.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +40 -10
- package/dist/mcp.js.map +1 -1
- package/dist/namespaces/actions.d.ts +50 -0
- package/dist/namespaces/actions.d.ts.map +1 -0
- package/dist/namespaces/actions.js +219 -0
- package/dist/namespaces/actions.js.map +1 -0
- package/dist/namespaces/approvals.d.ts +23 -0
- package/dist/namespaces/approvals.d.ts.map +1 -0
- package/dist/namespaces/approvals.js +78 -0
- package/dist/namespaces/approvals.js.map +1 -0
- package/dist/namespaces/attachments.d.ts +37 -0
- package/dist/namespaces/attachments.d.ts.map +1 -0
- package/dist/namespaces/attachments.js +91 -0
- package/dist/namespaces/attachments.js.map +1 -0
- package/dist/namespaces/auth.d.ts +58 -0
- package/dist/namespaces/auth.d.ts.map +1 -0
- package/dist/namespaces/auth.js +242 -0
- package/dist/namespaces/auth.js.map +1 -0
- package/dist/namespaces/directory.d.ts +10 -0
- package/dist/namespaces/directory.d.ts.map +1 -0
- package/dist/namespaces/directory.js +19 -0
- package/dist/namespaces/directory.js.map +1 -0
- package/dist/namespaces/handoffs.d.ts +47 -0
- package/dist/namespaces/handoffs.d.ts.map +1 -0
- package/dist/namespaces/handoffs.js +126 -0
- package/dist/namespaces/handoffs.js.map +1 -0
- package/dist/namespaces/inbox.d.ts +17 -0
- package/dist/namespaces/inbox.d.ts.map +1 -0
- package/dist/namespaces/inbox.js +192 -0
- package/dist/namespaces/inbox.js.map +1 -0
- package/dist/namespaces/live.d.ts +57 -0
- package/dist/namespaces/live.d.ts.map +1 -0
- package/dist/namespaces/live.js +116 -0
- package/dist/namespaces/live.js.map +1 -0
- package/dist/namespaces/notifications.d.ts +25 -0
- package/dist/namespaces/notifications.d.ts.map +1 -0
- package/dist/namespaces/notifications.js +128 -0
- package/dist/namespaces/notifications.js.map +1 -0
- package/dist/namespaces/profile.d.ts +19 -0
- package/dist/namespaces/profile.d.ts.map +1 -0
- package/dist/namespaces/profile.js +22 -0
- package/dist/namespaces/profile.js.map +1 -0
- package/dist/namespaces/questions.d.ts +41 -0
- package/dist/namespaces/questions.d.ts.map +1 -0
- package/dist/namespaces/questions.js +107 -0
- package/dist/namespaces/questions.js.map +1 -0
- package/dist/namespaces/rooms.d.ts +13 -0
- package/dist/namespaces/rooms.d.ts.map +1 -0
- package/dist/namespaces/rooms.js +41 -0
- package/dist/namespaces/rooms.js.map +1 -0
- package/dist/namespaces/webhooks.d.ts +11 -0
- package/dist/namespaces/webhooks.d.ts.map +1 -0
- package/dist/namespaces/webhooks.js +32 -0
- package/dist/namespaces/webhooks.js.map +1 -0
- package/dist/types/approvals.d.ts +62 -0
- package/dist/types/approvals.d.ts.map +1 -0
- package/dist/types/approvals.js +2 -0
- package/dist/types/approvals.js.map +1 -0
- package/dist/types/auth.d.ts +185 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/auth.js +2 -0
- package/dist/types/auth.js.map +1 -0
- package/dist/types/common.d.ts +43 -0
- package/dist/types/common.d.ts.map +1 -0
- package/dist/types/common.js +2 -0
- package/dist/types/common.js.map +1 -0
- package/dist/types/handoffs.d.ts +121 -0
- package/dist/types/handoffs.d.ts.map +1 -0
- package/dist/types/handoffs.js +2 -0
- package/dist/types/handoffs.js.map +1 -0
- package/dist/types/inbox.d.ts +51 -0
- package/dist/types/inbox.d.ts.map +1 -0
- package/dist/types/inbox.js +2 -0
- package/dist/types/inbox.js.map +1 -0
- package/dist/types/notifications.d.ts +376 -0
- package/dist/types/notifications.d.ts.map +1 -0
- package/dist/types/notifications.js +2 -0
- package/dist/types/notifications.js.map +1 -0
- package/dist/types/profile.d.ts +30 -0
- package/dist/types/profile.d.ts.map +1 -0
- package/dist/types/profile.js +3 -0
- package/dist/types/profile.js.map +1 -0
- package/dist/types/questions.d.ts +210 -0
- package/dist/types/questions.d.ts.map +1 -0
- package/dist/types/questions.js +2 -0
- package/dist/types/questions.js.map +1 -0
- package/dist/types/rooms.d.ts +163 -0
- package/dist/types/rooms.d.ts.map +1 -0
- package/dist/types/rooms.js +3 -0
- package/dist/types/rooms.js.map +1 -0
- package/dist/types.d.ts +9 -1035
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhooks.d.ts +32 -5
- package/dist/webhooks.d.ts.map +1 -1
- package/dist/webhooks.js +44 -14
- package/dist/webhooks.js.map +1 -1
- package/package.json +4 -2
- package/src/client.ts +115 -0
- package/src/errors.ts +291 -0
- package/src/http.ts +225 -0
- package/src/index.ts +97 -0
- package/src/internal/async.ts +54 -0
- package/src/internal/guards.ts +129 -0
- package/src/internal/request.ts +97 -0
- package/src/internal/retry.ts +157 -0
- package/src/internal/url.ts +78 -0
- package/src/internal/validate.ts +42 -0
- package/src/linkPing.ts +56 -0
- package/src/liveStatus.ts +337 -0
- package/src/locationPing.ts +101 -0
- package/src/mcp.ts +161 -0
- package/src/namespaces/actions.ts +234 -0
- package/src/namespaces/approvals.ts +104 -0
- package/src/namespaces/attachments.ts +106 -0
- package/src/namespaces/auth.ts +284 -0
- package/src/namespaces/directory.ts +21 -0
- package/src/namespaces/handoffs.ts +160 -0
- package/src/namespaces/inbox.ts +253 -0
- package/src/namespaces/live.ts +134 -0
- package/src/namespaces/notifications.ts +152 -0
- package/src/namespaces/profile.ts +24 -0
- package/src/namespaces/questions.ts +133 -0
- package/src/namespaces/rooms.ts +53 -0
- package/src/namespaces/webhooks.ts +38 -0
- package/src/types/approvals.ts +67 -0
- package/src/types/auth.ts +202 -0
- package/src/types/common.ts +74 -0
- package/src/types/handoffs.ts +148 -0
- package/src/types/inbox.ts +64 -0
- package/src/types/notifications.ts +393 -0
- package/src/types/profile.ts +37 -0
- package/src/types/questions.ts +226 -0
- package/src/types/rooms.ts +169 -0
- package/src/types.ts +17 -0
- package/src/version.ts +2 -0
- package/src/webhooks.ts +373 -0
- package/dist/liveActivity.d.ts +0 -101
- package/dist/liveActivity.d.ts.map +0 -1
- package/dist/liveActivity.js +0 -83
- package/dist/liveActivity.js.map +0 -1
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ One typed client for everything an agent does: authenticate, create and join roo
|
|
|
12
12
|
npm install @pingroom/sdk
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
The package is ESM-only. `import` it, or `await import('@pingroom/sdk')` from CommonJS. `require('@pingroom/sdk')` also works on Node ≥ 20.19 and ≥ 22.12, which can require an ES module; on an older Node 20.x it fails with `ERR_REQUIRE_ESM`.
|
|
16
|
+
|
|
15
17
|
## Quick start
|
|
16
18
|
|
|
17
19
|
```ts
|
|
@@ -37,8 +39,30 @@ await pr.broadcast('ab12cd', {
|
|
|
37
39
|
is_urgent: true,
|
|
38
40
|
});
|
|
39
41
|
|
|
40
|
-
// Listen for what comes back
|
|
41
|
-
|
|
42
|
+
// Listen for what comes back. listen() reads the current head first, then
|
|
43
|
+
// long-polls forward; stop it with an AbortSignal.
|
|
44
|
+
const ac = new AbortController();
|
|
45
|
+
for await (const ping of pr.notifications.listen({ signal: ac.signal })) {
|
|
46
|
+
console.log(ping.message);
|
|
47
|
+
ac.abort();
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A send that times out may still have been delivered. Pass an `idempotencyKey`
|
|
52
|
+
to `broadcast()`, `actions.trigger()`, `approvals.request()` or
|
|
53
|
+
`questions.ask()` and reuse the same key when you retry: the server replays
|
|
54
|
+
the first result instead of pinging the human twice. The SDK sends it as the
|
|
55
|
+
`Idempotency-Key` header, never in the body. A key is 1–255 printable ASCII
|
|
56
|
+
characters, spaces included, and surrounding whitespace is trimmed as the
|
|
57
|
+
server does. Anything else, such as an emoji or Cyrillic, is refused before
|
|
58
|
+
sending with `invalid_request`, because it cannot travel as a header. An empty
|
|
59
|
+
key (`''`) is `invalid_request` on `broadcast()`, `actions.trigger()`,
|
|
60
|
+
`approvals.request()` and `questions.ask()`; on `handoffs.*` and
|
|
61
|
+
`sendIncomingWebhook()` it is kept as it always was, meaning "no key", so pass
|
|
62
|
+
`undefined` rather than an empty string when you have none.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
await pr.broadcast('ab12cd', { message: 'Deploy shipped ✅', idempotencyKey: 'deploy-1.4.0-shipped' });
|
|
42
66
|
```
|
|
43
67
|
|
|
44
68
|
Ping titles are limited to 40 characters. Visible bodies are limited to 120
|
|
@@ -48,11 +72,6 @@ incoming-webhook URLs do not expose visibility, so the SDK validates the
|
|
|
48
72
|
limit. Question and Handoff prompts, Live Status fields, and structured `data`
|
|
49
73
|
use separate contracts.
|
|
50
74
|
|
|
51
|
-
> **`pr.agents.ping()` is retired.** Handle-addressed pings across accounts
|
|
52
|
-
> always fail with `410 cross_account_ping_retired`. An agent reaches only the
|
|
53
|
-
> account that connected it — to reach another agent or person, share a room
|
|
54
|
-
> (invite them, or join one they post in) and `pr.broadcast()` into it.
|
|
55
|
-
|
|
56
75
|
### Link pings
|
|
57
76
|
|
|
58
77
|
A ping becomes a tappable link button when its `data` carries the reserved
|
|
@@ -95,7 +114,9 @@ Latitude must be within -90..90 and longitude within -180..180. `label` is
|
|
|
95
114
|
limited to 100 Unicode characters and `address` to 255. Recipients can share
|
|
96
115
|
the point or open it in an installed maps app. When reading pings,
|
|
97
116
|
`extractLocationPing(ping.data)` returns the validated location or `null` for
|
|
98
|
-
legacy or malformed data.
|
|
117
|
+
legacy or malformed data. (This is a point carried in a ping's `data`, not the
|
|
118
|
+
geofence source `trigger_source: 'location'` described under
|
|
119
|
+
[Rooms & quick actions](#rooms--quick-actions).)
|
|
99
120
|
|
|
100
121
|
> Security note: an agent credential is a bearer token. Keep it server-side. Do not embed a long-lived token in a browser bundle or a public client.
|
|
101
122
|
|
|
@@ -165,6 +186,11 @@ principal; it does not impersonate the owner's personal PingRoom profile.
|
|
|
165
186
|
|
|
166
187
|
`waitForPairing()` tolerates short network outages and stops when the app link
|
|
167
188
|
expires. Pass an `AbortSignal` when your process needs its own cancellation.
|
|
189
|
+
If the client already held a credential when you called `startPairing()`, that
|
|
190
|
+
credential is put back when the link expires or the wait is aborted, so a
|
|
191
|
+
re-pairing that never completes does not leave the client on the dead pending
|
|
192
|
+
credential. Calling `waitForPairing()` again after an abort resumes the same
|
|
193
|
+
pairing.
|
|
168
194
|
Pairing grants are server-authoritative. `startPairing()` sends no scope array,
|
|
169
195
|
so an older installed SDK cannot accidentally mint a partial credential when
|
|
170
196
|
the service adds a capability. The deprecated `scopes` option is accepted for
|
|
@@ -197,6 +223,18 @@ pr.setToken(fresh.credential);
|
|
|
197
223
|
await pr.auth.revoke();
|
|
198
224
|
```
|
|
199
225
|
|
|
226
|
+
Check what a credential is connected to before sending, and disconnect it when
|
|
227
|
+
you are done:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
const info = await pr.auth.connection();
|
|
231
|
+
console.log(info.owner.id, info.owner.name, info.handle, info.home_room?.invite_code);
|
|
232
|
+
|
|
233
|
+
// Revokes this credential and its refresh credentials.
|
|
234
|
+
await pr.auth.disconnect();
|
|
235
|
+
pr.setToken(null);
|
|
236
|
+
```
|
|
237
|
+
|
|
200
238
|
Generic MCP clients (Cursor, Claude Desktop, Claude Code) should use the standard OAuth 2.1 lane at `/.well-known/oauth-authorization-server` instead — see [pingroom.io/connect-mcp.md](https://pingroom.io/connect-mcp.md).
|
|
201
239
|
|
|
202
240
|
## Rooms & quick actions
|
|
@@ -218,10 +256,8 @@ await pr.actions.updateMany('ab12cd', [
|
|
|
218
256
|
{ action_number: 2, label: 'Reject', icon: '❌' },
|
|
219
257
|
]);
|
|
220
258
|
|
|
221
|
-
// One-shot modifiers on a single press.
|
|
222
|
-
// or `location`; `webhook`/`system` are stamped server-side and rejected here.
|
|
259
|
+
// One-shot modifiers on a single press.
|
|
223
260
|
await pr.actions.trigger('ab12cd', 1, {
|
|
224
|
-
trigger_source: 'location',
|
|
225
261
|
is_urgent: true,
|
|
226
262
|
requires_ack: true,
|
|
227
263
|
});
|
|
@@ -232,6 +268,31 @@ only **add** an acknowledgement requirement for that one press — passing `fals
|
|
|
232
268
|
never removes one from an action already configured to require it. Neither
|
|
233
269
|
modifier edits the action's saved configuration.
|
|
234
270
|
|
|
271
|
+
A `requires_ack` result from `broadcast()` or `trigger()` carries
|
|
272
|
+
`confirmation_reach`. In a private room it names who has no device that can show
|
|
273
|
+
Confirm: they get a plain alert, so they may never confirm. `{ checked: false }`
|
|
274
|
+
means nothing was checked (public rooms, rooms over 100 recipients, a failed
|
|
275
|
+
lookup), not that everyone is reachable.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
const sent = await pr.broadcast(code, { message: 'Confirm', requires_ack: true });
|
|
279
|
+
if (sent.confirmation_reach?.checked) {
|
|
280
|
+
console.log(sent.confirmation_reach.unavailable_user_ids);
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`trigger_source` is `manual` (the default) or `location`; `webhook`/`system` are
|
|
285
|
+
stamped server-side and rejected here. **Leave it unset to press a Quick Ping.**
|
|
286
|
+
`location` is the device-geofence source, not a press modifier: it is for the
|
|
287
|
+
**room owner only** and sends the owner's Location Ping (an "Arrived at
|
|
288
|
+
{place}" or "Left {place}" Ping to every member, with no action number)
|
|
289
|
+
**instead of** the quick action in that slot, so the slot's label, sound and
|
|
290
|
+
acknowledgement policy are not used. A caller who is not the room owner gets
|
|
291
|
+
`403 location_trigger_owner_only`; a room with no enabled location trigger (or
|
|
292
|
+
none that listens for the reported crossing) gets `409 location_trigger_inactive`;
|
|
293
|
+
the operator's Location Pings switch being off gets `403 feature_disabled`. In
|
|
294
|
+
none of those cases does the slot's quick action fire.
|
|
295
|
+
|
|
235
296
|
### Quick actions that need a detail
|
|
236
297
|
|
|
237
298
|
An action can require a detail from whoever presses it: `input_type` is `none`
|
|
@@ -242,7 +303,8 @@ refused with `422 quick_action_input_required` and nothing is sent.
|
|
|
242
303
|
```ts
|
|
243
304
|
await pr.actions.update('ab12cd', 2, { label: 'Where are you?', icon: '📍', input_type: 'location' });
|
|
244
305
|
|
|
245
|
-
const
|
|
306
|
+
const action = (await pr.actions.list('ab12cd')).find((a) => a.action_number === 2);
|
|
307
|
+
if (!action) throw new Error('Slot 2 is not configured.'); // action.input_type === 'location'
|
|
246
308
|
await pr.actions.trigger('ab12cd', 2, {
|
|
247
309
|
quick_action_id: action.id, // refused with 409 quick_action_layout_changed if the slot moved pages
|
|
248
310
|
data: { location: { latitude: 25.2048, longitude: 55.2708, label: 'Dubai Mall' } },
|
|
@@ -309,9 +371,10 @@ Updating with `regenerate_secret: true` returns a new URL and revokes the old on
|
|
|
309
371
|
## Attachments
|
|
310
372
|
|
|
311
373
|
Send the file itself, not a link to it. Upload first, then pass the ids to any
|
|
312
|
-
send. Bytes never travel
|
|
374
|
+
send. Bytes never travel inside a JSON ping body, and the returned metadata
|
|
313
375
|
never carries a permanent URL — recipients read the content back through an
|
|
314
|
-
authenticated endpoint.
|
|
376
|
+
authenticated endpoint. (The MCP `upload_attachment` tool takes small files as
|
|
377
|
+
base64, up to about 90 KiB; this method has no such limit below 5 MiB.)
|
|
315
378
|
|
|
316
379
|
```ts
|
|
317
380
|
const report = await pr.attachments.upload({
|
|
@@ -353,10 +416,26 @@ const ac = new AbortController();
|
|
|
353
416
|
|
|
354
417
|
for await (const ping of pr.notifications.listen({ signal: ac.signal })) {
|
|
355
418
|
console.log(ping.sender?.name, ping.message, ping.data);
|
|
356
|
-
if (ping.message === 'stop') ac.abort();
|
|
419
|
+
if (ping.message === 'stop') ac.abort(); // sent by another member of the room
|
|
357
420
|
}
|
|
358
421
|
```
|
|
359
422
|
|
|
423
|
+
`listen()` delivers what the other members of your rooms send. It leaves out
|
|
424
|
+
everything sent as the connected account: your own sends, your human's own
|
|
425
|
+
pings and replies, other agents on the same account, and incoming-webhook and
|
|
426
|
+
Scheduled Pings in rooms the account owns (those are sent as the owner). So the
|
|
427
|
+
`stop` above has to come from someone else. To let your human stop the loop,
|
|
428
|
+
ask them, and abort when they approve:
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
const stop = await pr.questions.ask('ab12cd', { prompt: 'Stop listening?', ttl: 86400 });
|
|
432
|
+
|
|
433
|
+
pr.questions
|
|
434
|
+
.waitForAnswer(stop.id, { signal: ac.signal })
|
|
435
|
+
.then((q) => { if (q.state === 'answered' && q.answer?.value === 'approve') ac.abort(); })
|
|
436
|
+
.catch(() => {}); // rejects with the abort reason once the loop has stopped
|
|
437
|
+
```
|
|
438
|
+
|
|
360
439
|
A listened ping carries two fields worth branching on. `ping.is_handoff` is true
|
|
361
440
|
when the ping is a handoff rather than an ordinary ping, and `ping.question` is
|
|
362
441
|
the embedded `Question` (the same wire shape `pr.questions` returns) when the
|
|
@@ -372,7 +451,23 @@ for await (const ping of pr.notifications.listen({ signal: ac.signal })) {
|
|
|
372
451
|
}
|
|
373
452
|
```
|
|
374
453
|
|
|
375
|
-
|
|
454
|
+
`listen()` retries transient failures (network errors, request timeouts, 429
|
|
455
|
+
and 5xx) with a backoff based on `backoffMs` (default 1 s, minimum 250
|
|
456
|
+
ms: a smaller value is raised to 250 ms), doubles up to 30 s, and waits out a
|
|
457
|
+
server `retryAfter`. Pass `onError` to observe those retries. Any other failure,
|
|
458
|
+
such as a 401 after the credential is revoked or a 403, ends the iterator by
|
|
459
|
+
throwing, with or without `onError`. A 2xx that is not a wait answer (a captive
|
|
460
|
+
portal's page, say) ends it with `invalid_response`.
|
|
461
|
+
|
|
462
|
+
Retries and polls following an empty forward poll start at least 2.1 seconds
|
|
463
|
+
after the previous poll started, even when `backoffMs` is smaller. This paces
|
|
464
|
+
one loop's empty-response and retry traffic. The initial head read and nonempty
|
|
465
|
+
batch draining remain immediate, and all listeners (the CLI, other agents)
|
|
466
|
+
share the human's 30-requests-per-minute limit on that route. The default 20-second
|
|
467
|
+
hold is unaffected.
|
|
468
|
+
|
|
469
|
+
Or a single long-poll. Without `after`, `wait()` answers at once with no pings
|
|
470
|
+
and the current head cursor, so pass the cursor from a previous call:
|
|
376
471
|
|
|
377
472
|
```ts
|
|
378
473
|
const { notifications, cursor } = await pr.notifications.wait({ after: lastCursor, timeout: 20 });
|
|
@@ -413,6 +508,7 @@ const approval = await pr.approvals.request('ab12cd', {
|
|
|
413
508
|
question: 'Ship release 1.4.0 to production?',
|
|
414
509
|
options: ['Ship it', 'Hold'],
|
|
415
510
|
correlation_id: 'deploy-1.4.0',
|
|
511
|
+
idempotencyKey: 'deploy-1.4.0-approval', // reuse when retrying this request
|
|
416
512
|
});
|
|
417
513
|
|
|
418
514
|
const decided = await pr.approvals.waitForDecision(approval.id);
|
|
@@ -421,13 +517,44 @@ if (decided.status === 'decided' && decided.decision === 'Ship it') {
|
|
|
421
517
|
}
|
|
422
518
|
```
|
|
423
519
|
|
|
520
|
+
`status` is `pending`, `decided`, `expired` or `cancelled`. Gate on the exact
|
|
521
|
+
approving `decision`, never on `decision !== 'Hold'`.
|
|
522
|
+
|
|
523
|
+
`waitForDecision()`, `questions.waitForAnswer()` and `handoffs.waitForResult()`
|
|
524
|
+
return only a response for the id you asked about, in a known state. Anything
|
|
525
|
+
else, such as an HTML page from a proxy, `{}`, or another approval, rejects with
|
|
526
|
+
`invalid_response` instead of being returned as an outcome. The wait routes
|
|
527
|
+
each have their own 30-requests-per-minute limit per human, shared by every waiter on that route, so these loops retry a 429
|
|
528
|
+
after its `retryAfter`, and retry network errors and 5xx with a backoff capped
|
|
529
|
+
at 30 s. Approval, question and handoff polls start at least 2.1 s apart (one
|
|
530
|
+
loop stays under the limit), even if something in between answers `pending` at
|
|
531
|
+
once.
|
|
532
|
+
|
|
533
|
+
The retries are bounded without a signal. Once only failures have come back
|
|
534
|
+
since the last valid response, a wait gives up 5 minutes after the last
|
|
535
|
+
`expires_at` it read, or after 15 minutes of unbroken failures if it has not
|
|
536
|
+
read one yet. It then rejects with code `unavailable`, and the last failure as
|
|
537
|
+
`error.cause`. The CLI uses the same bound. Pass `onRetry(error, attempt)` to
|
|
538
|
+
see each retry (`attempt` counts consecutive failures from 1), and an
|
|
539
|
+
`AbortSignal` to stop sooner. Throwing from `onRetry` ends the wait with that
|
|
540
|
+
error.
|
|
541
|
+
|
|
542
|
+
```ts
|
|
543
|
+
const decided = await pr.approvals.waitForDecision(approval.id, {
|
|
544
|
+
signal: AbortSignal.timeout(30 * 60_000),
|
|
545
|
+
onRetry: (err, attempt) => {
|
|
546
|
+
if (attempt === 3) console.warn(`PingRoom unreachable (${err.status || err.code}); still waiting`);
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
```
|
|
550
|
+
|
|
424
551
|
For a runnable example that binds approval to a file's SHA-256, handles timeouts,
|
|
425
552
|
and refuses a changed artifact, see [release approval](examples/release-approval/README.md).
|
|
426
553
|
The demo runs locally with simulated decisions and needs no account or credentials.
|
|
427
554
|
|
|
428
555
|
## Human-in-the-loop questions
|
|
429
556
|
|
|
430
|
-
A **question** is the general form of an approval: 2–4 options (or a short typed answer), delivered as a push with tappable buttons, resolving to exactly one answer — first tap wins. Reach for `pr.questions` when you need more than yes/no, a typed reply, or want *
|
|
557
|
+
A **question** is the general form of an approval: 2–4 options (or a short typed answer), delivered as a push with tappable buttons, resolving to exactly one answer — first tap wins. Reach for `pr.questions` when you need more than yes/no, a typed reply, want *anyone in the room* to answer, or want *everyone in the room* to vote ([polls](#polls)).
|
|
431
558
|
|
|
432
559
|
```ts
|
|
433
560
|
// Ask — omit `options` for a binary Approve/Deny; ttl defaults to 1h (30s–24h).
|
|
@@ -449,8 +576,8 @@ const answered = await pr.questions.waitForAnswer(q.id);
|
|
|
449
576
|
|
|
450
577
|
switch (answered.state) {
|
|
451
578
|
case 'answered':
|
|
452
|
-
console.log(`${answered.answer
|
|
453
|
-
// → answered.answer
|
|
579
|
+
console.log(`${answered.answer?.responder?.display_name} chose ${answered.answer?.value}`);
|
|
580
|
+
// → answered.answer?.value is 'prod' | 'staging' | 'cancel'
|
|
454
581
|
break;
|
|
455
582
|
case 'expired': /* nobody answered in time */ break;
|
|
456
583
|
case 'cancelled': /* the asker withdrew it */ break;
|
|
@@ -467,6 +594,36 @@ await pr.questions.cancel(q.id); // withdraw a pending one
|
|
|
467
594
|
|
|
468
595
|
> `pr.approvals` is the ergonomic two-option shortcut and stays fully supported — it routes through the same machinery server-side. Use it for plain yes/no gates; use `pr.questions` for everything richer.
|
|
469
596
|
|
|
597
|
+
### Polls
|
|
598
|
+
|
|
599
|
+
Polls need SDK 0.8.0 or newer: 0.7.1's `QuestionInput` has no `resolution_policy` and its `Question` type has no `results`.
|
|
600
|
+
|
|
601
|
+
Set `resolution_policy: 'all'` to poll the room instead of taking the first answer. Everyone who is in the room when you ask — except your own bound human — gets one final vote. The poll closes `answered` once nobody who can still vote owes a vote, or at its deadline if anyone voted; with no votes it expires. Someone who left the room, was removed, deleted their account, or is now barred by a block (with your bound human, the room owner or an admin) stops holding it open; the server checks on every vote and once a minute, so such a poll closes within about a minute. A poll nobody voted in still waits for its deadline.
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
const poll = await pr.questions.ask('ab12cd', {
|
|
605
|
+
prompt: 'Which release name?',
|
|
606
|
+
options: ['otter', 'heron'],
|
|
607
|
+
resolution_policy: 'all', // implies responder_scope: 'room'
|
|
608
|
+
ttl: 3600,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const closed = await pr.questions.waitForAnswer(poll.id);
|
|
612
|
+
if (closed.state === 'answered') {
|
|
613
|
+
console.log(closed.answer?.value ?? 'tie'); // the leading option, or null on a tie
|
|
614
|
+
for (const o of closed.results?.options ?? []) console.log(o.value, o.count);
|
|
615
|
+
console.log(`${closed.results?.response_count} of ${closed.results?.audience_count} voted`);
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
- `results` is on every read of a poll, so `questions.get()` shows the running tally while it is `pending`. It is `null` on a first-answer Question.
|
|
620
|
+
- A poll's `answer` has `responder: null` (the room chose it); who voted what is in `results.responses` (up to the 100 most recent votes, oldest first; the counts stay exact).
|
|
621
|
+
- A closed poll's `answer` is the leader stored when it closed and never changes. `results` stays live, so its counts can drop if a voter later deletes their account.
|
|
622
|
+
- `results.viewer` is always `null` for the SDK: it reads the agent API, where no individual viewer is set.
|
|
623
|
+
- A poll is options-only: `ask()` throws `invalid_request` before sending if you combine `resolution_policy: 'all'` with `responder_scope: 'direct'`, `target_user_id`, or `text_input`.
|
|
624
|
+
- If your bound human is the room's only member, the server asks them a plain `direct` question instead, and the returned Question reads `resolution_policy: 'first'`. A retry with the same `idempotencyKey` and `resolution_policy: 'first'` replays it; any other policy change under a reused key is `409 idempotency_conflict`.
|
|
625
|
+
- A poll nobody in the room could vote in (every member besides your bound human is barred by a block) is refused: `ask()` rejects with a `PingRoomError` whose `status` is 422 (a validation error on `resolution_policy`, with no machine `code`).
|
|
626
|
+
|
|
470
627
|
## Handoffs (Agent → one human)
|
|
471
628
|
|
|
472
629
|
A **handoff** is the direct, private version of the human-in-the-loop: it always targets **exactly one human** and is never readable by anyone else in the room. Reach for `pr.handoffs` when an agent needs to hand a task to *its* human (or one specific person) and block on the outcome — no room to pick, no room-scope answering. It's backed by the ack and question primitives, so a handoff is one of two `kind`s:
|
|
@@ -518,7 +675,9 @@ callback cannot rewrite the required receipt-before-answer sequence. It throws
|
|
|
518
675
|
`answered_without_completion`. Expiry and cancellation throw the same domain
|
|
519
676
|
error with their corresponding reasons. Caller aborts reject with the signal's
|
|
520
677
|
reason. None of these paths revoke or change the client's active credential.
|
|
521
|
-
|
|
678
|
+
A transient wait failure (a dropped connection, a request timeout, a 429 or a
|
|
679
|
+
5xx) is retried with backoff until `overallTimeoutMs`; any other API error
|
|
680
|
+
remains a `PingRoomError`. Use
|
|
522
681
|
`pr.inbox.ensure()` directly when you only want to create or inspect the
|
|
523
682
|
onboarding Question without waiting. Call `activate()` again after an expired,
|
|
524
683
|
cancelled, or `answered_without_completion` result; the server replays a viable
|
|
@@ -553,7 +712,7 @@ if (answered.state === 'answered') {
|
|
|
553
712
|
}
|
|
554
713
|
```
|
|
555
714
|
|
|
556
|
-
`waitForResult` loops the bounded long-poll until a terminal state lands, so `expired`/`cancelled` and a negative answer all come back as normal results — nothing is thrown for the outcome. Full options: `prompt`, `target` (`'me'
|
|
715
|
+
`waitForResult` loops the bounded long-poll until a terminal state lands, so `expired`/`cancelled` and a negative answer all come back as normal results — nothing is thrown for the outcome. Full options: `prompt`, `target` (`'me'`, the default; an agent can hand off only to the human who connected it, and any other id is `403 target_not_permitted`), `expiresIn` (120–86400s, default 900), `urgency` (`'active'` | `'passive'`), `correlationId`, `replyTo`, `data`, `idempotencyKey`.
|
|
557
716
|
|
|
558
717
|
```ts
|
|
559
718
|
await pr.handoffs.get(id); // current state (polling / audit)
|
|
@@ -561,6 +720,9 @@ await pr.handoffs.list({ state: 'open' }); // your open handoffs
|
|
|
561
720
|
await pr.handoffs.list({ state: 'all' }); // recent history (up to 200 per kind)
|
|
562
721
|
```
|
|
563
722
|
|
|
723
|
+
`waitForResult` starts polls at least 2.1 seconds apart, so a short `timeout`
|
|
724
|
+
cannot use up the wait route's 30-per-minute limit.
|
|
725
|
+
|
|
564
726
|
Create/read map coded failures onto `PingRoomError` — branch on `error.code` (see [`HandoffErrorCode`](#errors)). The exported union covers target policy (`target_not_permitted`), room designation (`no_room_configured`, `handoff_room_unsupported`), scope/quota, idempotency, feature, and capability failures; `capability_check_unavailable` is retryable. Authentication and schema-validation failures may have no code, so also inspect `error.status`. `room_not_granted` is **not** in this union: `POST /handoffs` names no room, so it is not behind the room-grant gate — see `RoomScopedErrorCode`.
|
|
565
727
|
|
|
566
728
|
## Incoming webhooks (no token needed)
|
|
@@ -570,7 +732,10 @@ A room's incoming-webhook URL carries its own secret — ideal for CI/deploy hoo
|
|
|
570
732
|
```ts
|
|
571
733
|
import { sendIncomingWebhook } from '@pingroom/sdk';
|
|
572
734
|
|
|
573
|
-
|
|
735
|
+
const webhookUrl = process.env.PINGROOM_WEBHOOK_URL;
|
|
736
|
+
if (!webhookUrl) throw new Error('Set PINGROOM_WEBHOOK_URL to the room webhook URL.');
|
|
737
|
+
|
|
738
|
+
await sendIncomingWebhook(webhookUrl, {
|
|
574
739
|
message: 'Build #42 passed',
|
|
575
740
|
data: { commit: 'abc123' },
|
|
576
741
|
requires_ack: true,
|
|
@@ -583,7 +748,10 @@ await sendIncomingWebhook(process.env.PINGROOM_WEBHOOK_URL, {
|
|
|
583
748
|
See [Live Activities](#live-activities) below for the full picture. From a webhook, put `live_status` at the **top level** of the payload:
|
|
584
749
|
|
|
585
750
|
```ts
|
|
586
|
-
|
|
751
|
+
const webhookUrl = process.env.PINGROOM_WEBHOOK_URL;
|
|
752
|
+
if (!webhookUrl) throw new Error('Set PINGROOM_WEBHOOK_URL to the room webhook URL.');
|
|
753
|
+
|
|
754
|
+
await sendIncomingWebhook(webhookUrl, {
|
|
587
755
|
message: 'Deploying v1.4.0',
|
|
588
756
|
correlation_id: 'deploy-42',
|
|
589
757
|
live_status: { state: 'running', template: 'progress', progress: 0.4 },
|
|
@@ -596,7 +764,7 @@ await sendIncomingWebhook(process.env.PINGROOM_WEBHOOK_URL, {
|
|
|
596
764
|
|
|
597
765
|
A **live-status stream** is one thing being tracked, keyed by your `correlation_id`. The first ping starts a self-updating card on every room member's Lock Screen (iOS Live Activity / Dynamic Island, Android ongoing live update, and a full inline card in the app). Later `running` pings move it **silently** — no new notification. The first `done`/`failed` sends one completion alert and ends it.
|
|
598
766
|
|
|
599
|
-
Protocol: <https://pingroom.io/liveactivities.md> (v1.
|
|
767
|
+
Protocol: <https://pingroom.io/liveactivities.md> (v1.4).
|
|
600
768
|
|
|
601
769
|
### Two ways to send one
|
|
602
770
|
|
|
@@ -613,8 +781,10 @@ await pr.live.push('AB12CD', liveStatus.progress('deploy-42', {
|
|
|
613
781
|
|
|
614
782
|
// B. A room's incoming webhook (Pro) — no token. The builder returns
|
|
615
783
|
// { correlation_id, live_status }, which is exactly the webhook body.
|
|
784
|
+
const webhookUrl = process.env.PINGROOM_WEBHOOK_URL;
|
|
785
|
+
if (!webhookUrl) throw new Error('Set PINGROOM_WEBHOOK_URL to the room webhook URL.');
|
|
616
786
|
await sendIncomingWebhook(
|
|
617
|
-
|
|
787
|
+
webhookUrl,
|
|
618
788
|
liveStatus.progress('deploy-42', {
|
|
619
789
|
state: 'running',
|
|
620
790
|
message: 'Building image',
|
|
@@ -632,12 +802,13 @@ There is a third producer — a person driving a stream from the app composer ov
|
|
|
632
802
|
| Builder | Renders |
|
|
633
803
|
|---|---|
|
|
634
804
|
| `liveStatus.status` | Title, message, optional progress |
|
|
635
|
-
| `liveStatus.steps` | Segmented tracker; `steps` (2–8 labels) required on the first ping and immutable after |
|
|
805
|
+
| `liveStatus.steps` | Segmented tracker; `steps` (2–8 labels) required on the first ping and immutable after: later pings resend the identical list or omit it, and a different list on a `running` ping throws `PingRoomError` `steps_immutable` (422); a `done`/`failed` ping ignores it and still ends the stream |
|
|
636
806
|
| `liveStatus.progress` | Determinate bar with optional `eta_at` |
|
|
637
807
|
| `liveStatus.metrics` | Up to 3 `{label, value}` counters |
|
|
638
|
-
| `liveStatus.countdown` | Large live timer to `deadline_at` |
|
|
808
|
+
| `liveStatus.countdown` | Large live timer to `deadline_at`. A later ping may move the deadline (send the absolute epoch); `duration_seconds` is read on the first ping only and moves by the same amount as the deadline |
|
|
639
809
|
| `liveStatus.question` | `prompt` + up to 4 `{value, label}` options on the lock screen |
|
|
640
810
|
| `liveStatus.matchup` | `left` / `center` / `right` — a score or A-vs-B |
|
|
811
|
+
| `liveStatus.claim` | "Who's on it?": a request one room member takes on. See [Who's on it?](#whos-on-it) |
|
|
641
812
|
|
|
642
813
|
```ts
|
|
643
814
|
await pr.live.push('AB12CD', liveStatus.steps('release-7', {
|
|
@@ -655,6 +826,58 @@ await pr.live.push('AB12CD', liveStatus.matchup('game-3', {
|
|
|
655
826
|
}));
|
|
656
827
|
```
|
|
657
828
|
|
|
829
|
+
### Who's on it?
|
|
830
|
+
|
|
831
|
+
A **claim request** asks the room "who's on it?" and lets one member take it. It is template `claim`, and it works in **private standard rooms only** (`422 claim_private_rooms_only` in a public or personal room). Pass the request text as `title` (1 to 120 characters, or `live_status.message` without a title). An optional `deadline_at` must be in the future and at most 8 hours away. The first ping must be `running`. The server writes the claim itself and ignores any `claim` block you send.
|
|
832
|
+
|
|
833
|
+
```ts
|
|
834
|
+
// The asking credential acts for the room owner.
|
|
835
|
+
// The claiming credential must act for a different human member of that room.
|
|
836
|
+
const asker = new PingRoom({ token: process.env.ASKER_AGENT_TOKEN });
|
|
837
|
+
const member = new PingRoom({ token: process.env.MEMBER_AGENT_TOKEN });
|
|
838
|
+
const { notification_id } = await asker.live.push('AB12CD', liveStatus.claim(
|
|
839
|
+
'milk-1',
|
|
840
|
+
{ state: 'running', deadline_at: Math.floor(Date.now() / 1000) + 3600 },
|
|
841
|
+
{ title: 'Buy milk' },
|
|
842
|
+
));
|
|
843
|
+
|
|
844
|
+
// The claiming credential acts for another member, who is not the asker.
|
|
845
|
+
const taken = await member.live.claim('AB12CD', notification_id, 'claim');
|
|
846
|
+
console.log(taken.claim.status, taken.claim.by?.name, taken.idempotent);
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
`pr.live.claim(inviteCode, notificationId, action)` takes `'claim' | 'release' | 'done' | 'cancel'` and resolves `{ notification_id, correlation_id, claim, state, idempotent }`. It needs the `pingroom:live:write` scope.
|
|
850
|
+
|
|
851
|
+
| Action | Who | From | To |
|
|
852
|
+
|---|---|---|---|
|
|
853
|
+
| `claim` | any member but the asker | open | claimed |
|
|
854
|
+
| `release` | the claimer | claimed | open, or expired past the deadline |
|
|
855
|
+
| `done` | the claimer, or the asker at any time | open (asker only) or claimed | done |
|
|
856
|
+
| `cancel` | the asker, or a room owner or admin | open or claimed | cancelled |
|
|
857
|
+
|
|
858
|
+
`release` returns the request to `open` only while it can still be taken. Past its deadline (or 4 hours after it was made, when it has none) it ends the request as `expired` instead, and the asker hears "No one took it". That is a 200, not an error: `claim.status` is `'expired'`, `state` is `'failed'`, and `claim.by` and `claim.at` are `null`. A retry resolves the same way with `idempotent: true`.
|
|
859
|
+
|
|
860
|
+
The asker is the stream's sender, or the room owner for an agent or webhook stream. Two claims at the same moment have one winner. The other throws `PingRoomError` `claim_conflict` (409), whose `body.claim` names who holds it. Repeating your own last action resolves with `idempotent: true` and sends nothing. A malformed or mismatched success receipt throws `invalid_response`; the SDK does not automatically repeat the POST.
|
|
861
|
+
|
|
862
|
+
```ts
|
|
863
|
+
import { PingRoomError } from '@pingroom/sdk';
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
await member.live.claim('AB12CD', notification_id, 'claim');
|
|
867
|
+
} catch (error) {
|
|
868
|
+
if (error instanceof PingRoomError && error.code === 'claim_conflict') {
|
|
869
|
+
const { claim } = error.body as { claim: { by?: { name: string } } };
|
|
870
|
+
console.log(`${claim.by?.name} got it first`);
|
|
871
|
+
} else throw error;
|
|
872
|
+
}
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
Refusals: `claim_conflict` (409), `claim_own_request` (422), `claim_private_rooms_only` (422), `claim_not_found` (404) and `forbidden` (403). Creation can also fail with `claim_request_invalid`, `claim_must_start_running` and `claim_deadline_out_of_range` (all 422). `LIVE_CLAIM_ERROR_CODES` lists the claim codes.
|
|
876
|
+
|
|
877
|
+
A webhook has no member identity, so it cannot claim or release. It ends its own request with `liveStatus.done()` (done) or `liveStatus.failed()` (cancelled). An ordinary `running` leg on an existing request changes nothing.
|
|
878
|
+
|
|
879
|
+
A request ends by itself. An open one expires at its deadline, or after 4 hours without one. A claimed one stays live past its deadline, marked `late`, and ends as `unfinished` at 7 h 55 m. `pr.live.get()` returns `title` and `claim` for a claim request. Claim support is newer than SDK 0.8.0.
|
|
880
|
+
|
|
658
881
|
### Ending and reading back
|
|
659
882
|
|
|
660
883
|
```ts
|
|
@@ -664,23 +887,20 @@ await pr.live.push('AB12CD', liveStatus.done('deploy-42', 'Shipped v1.4.0'));
|
|
|
664
887
|
// ...or liveStatus.failed('deploy-42', 'Rollback triggered')
|
|
665
888
|
|
|
666
889
|
// Reconcile after a restart instead of opening a duplicate.
|
|
667
|
-
// Returns null for a 404
|
|
668
|
-
// other failure throws, including a
|
|
890
|
+
// Returns null ONLY for a 404 `stream_not_found`: no such stream in the last
|
|
891
|
+
// 24 hours. Every other failure throws, including a 404 for an unknown or
|
|
892
|
+
// deleted room and a 403 `room_not_granted`, so a wrong room or a permission
|
|
669
893
|
// problem is never mistaken for an absent stream.
|
|
670
894
|
const snap = await pr.live.get('AB12CD', 'deploy-42');
|
|
671
895
|
if (snap) console.log(snap.template, snap.current_step, snap.updated_at);
|
|
672
896
|
```
|
|
673
897
|
|
|
674
|
-
`get()` returns **every** stored field (`state`, `progress`, `message`, `category`, `template`, `agent_id`, `accent_override`, `eta_at`, `deadline_at`, `duration_seconds`, `metrics`, `prompt`, `options`, `left`, `right`, `center`, `steps`, `current_step`) plus `action_state` and `updated_at`. Fields you never set come back as `null`, not omitted.
|
|
898
|
+
`get()` returns **every** stored field (`state`, `progress`, `message`, `category`, `template`, `agent_id`, `accent_override`, `eta_at`, `deadline_at`, `duration_seconds`, `metrics`, `prompt`, `options`, `left`, `right`, `center`, `steps`, `current_step`) plus `action_state` and `updated_at`. Fields you never set come back as `null`, not omitted. A claim request adds `title` and `claim`.
|
|
675
899
|
|
|
676
900
|
### Ownership and limits
|
|
677
901
|
|
|
678
902
|
A stream belongs to the credential that started it. An agent can never advance a webhook's stream or another registration's, even under the same `correlation_id`. Updates are throttled to ~6/min per correlation and 10 new streams/min per producer; free agent accounts get 5 new streams/day (`402 free_limit_reached`). Creation is the only charged leg.
|
|
679
903
|
|
|
680
|
-
### `liveActivity.*` is not this
|
|
681
|
-
|
|
682
|
-
The exported `liveActivity.*` builders produce the frozen **native** `{ attributes, content_state }` push-layer shape for tests and tooling. No endpoint accepts them as input, and a `live_activity` block inside a `broadcast()` `data` object is carried as opaque structured data — it starts nothing. Use `liveStatus.*` to drive a real activity.
|
|
683
|
-
|
|
684
904
|
## Verifying outgoing webhooks
|
|
685
905
|
|
|
686
906
|
When PingRoom POSTs an event to *your* server, verify it before trusting it. New deliveries carry two lower-case hex HMAC-SHA256 headers:
|
|
@@ -690,6 +910,8 @@ When PingRoom POSTs an event to *your* server, verify it before trusting it. New
|
|
|
690
910
|
|
|
691
911
|
`timestamp` is the Unix-seconds value in `X-PingRoom-Timestamp`, and `deliveryId` is the exact `X-PingRoom-Delivery` value. **Verify over the exact raw request body; re-serializing parsed JSON can change the bytes and invalidate the signature.** If v2 is present, receivers MUST verify it and reject the request on failure. The helper accepts legacy v1 only when v2 is absent. It rejects timestamps outside a five-minute replay window by default; set `maxAgeSeconds` to customize that window.
|
|
692
912
|
|
|
913
|
+
Pass `requireV2: true`. Every delivery PingRoom sends carries v2, and v1 does not sign `X-PingRoom-Delivery`: a captured body replayed without its v2 header and under a new delivery id still passes v1. With `requireV2: true` such a request fails, so a receiver can dedupe on `X-PingRoom-Delivery`. Without it, the helper still falls back to v1 for receivers that predate v2; never dedupe on the delivery id in that mode.
|
|
914
|
+
|
|
693
915
|
```ts
|
|
694
916
|
import {
|
|
695
917
|
verifyWebhookSignature,
|
|
@@ -699,15 +921,21 @@ import {
|
|
|
699
921
|
WEBHOOK_DELIVERY_HEADER,
|
|
700
922
|
} from '@pingroom/sdk';
|
|
701
923
|
|
|
702
|
-
|
|
703
|
-
|
|
924
|
+
const signingSecret = process.env.PINGROOM_SIGNING_SECRET;
|
|
925
|
+
if (!signingSecret) throw new Error('Set PINGROOM_SIGNING_SECRET to the webhook signing_secret.');
|
|
926
|
+
|
|
927
|
+
// Express example. PingRoom sends `Content-Type: application/json`, which
|
|
928
|
+
// express.raw() ignores unless you name the type, so pass it on this route and
|
|
929
|
+
// keep it ahead of any express.json().
|
|
930
|
+
app.post('/pingroom', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
704
931
|
const ok = await verifyWebhookSignature({
|
|
705
|
-
payload: req.body, // a Buffer
|
|
932
|
+
payload: req.body, // a Buffer of the RAW body
|
|
706
933
|
signature: req.get(WEBHOOK_SIGNATURE_HEADER),
|
|
707
934
|
signatureV2: req.get(WEBHOOK_SIGNATURE_V2_HEADER),
|
|
708
935
|
timestamp: req.get(WEBHOOK_TIMESTAMP_HEADER),
|
|
709
936
|
deliveryId: req.get(WEBHOOK_DELIVERY_HEADER),
|
|
710
|
-
secret:
|
|
937
|
+
secret: signingSecret,
|
|
938
|
+
requireV2: true, // refuse the legacy v1-only form
|
|
711
939
|
});
|
|
712
940
|
if (!ok) return res.status(401).end();
|
|
713
941
|
// ... handle the verified event
|
|
@@ -715,6 +943,13 @@ app.post('/pingroom', async (req, res) => {
|
|
|
715
943
|
});
|
|
716
944
|
```
|
|
717
945
|
|
|
946
|
+
`verifyWebhookSignature` returns `false` for a signature that does not match, and
|
|
947
|
+
**throws a `TypeError`** when `payload` is not a string, `ArrayBuffer` or
|
|
948
|
+
`Buffer`/typed array. That is what a body parser that did not capture the raw
|
|
949
|
+
bytes leaves behind (`{}` on Express 4, `undefined` on Express 5, a parsed
|
|
950
|
+
object after `express.json()`), and it is a bug in the receiver, not a forged
|
|
951
|
+
delivery, so it is not reported as `false`.
|
|
952
|
+
|
|
718
953
|
## Redeem a gift or promotional code
|
|
719
954
|
|
|
720
955
|
```ts
|
|
@@ -761,20 +996,28 @@ const profile = await pr.directory.get('agt_deploybell');
|
|
|
761
996
|
|
|
762
997
|
## Errors
|
|
763
998
|
|
|
764
|
-
Every failure rejects with a `PingRoomError` carrying the HTTP `status` and the API's machine `code` (e.g. `
|
|
999
|
+
Every failure rejects with a `PingRoomError` carrying the HTTP `status` and the API's machine `code` (e.g. `room_not_granted`, `cooldown_active`, `rate_limited`), plus `retryAfter` when present.
|
|
1000
|
+
|
|
1001
|
+
Retry on the status, not on one code. Every 429 should be retried after `retryAfter`: route throttles answer `rate_limited`, but a public room's per-minute limit answers `public_room_rate_limited` and a quick action's cooldown answers `cooldown_active`. Reuse the same `idempotencyKey` on the retry:
|
|
765
1002
|
|
|
766
1003
|
```ts
|
|
767
1004
|
import { PingRoomError } from '@pingroom/sdk';
|
|
768
1005
|
|
|
1006
|
+
const send = () => pr.broadcast('ab12cd', { message: 'hi', idempotencyKey: 'hello-1' });
|
|
769
1007
|
try {
|
|
770
|
-
await
|
|
1008
|
+
await send();
|
|
771
1009
|
} catch (err) {
|
|
772
|
-
if (err instanceof PingRoomError && err.
|
|
1010
|
+
if (err instanceof PingRoomError && err.status === 429) {
|
|
773
1011
|
await new Promise((r) => setTimeout(r, (err.retryAfter ?? 1) * 1000));
|
|
1012
|
+
await send();
|
|
1013
|
+
} else {
|
|
1014
|
+
throw err;
|
|
774
1015
|
}
|
|
775
1016
|
}
|
|
776
1017
|
```
|
|
777
1018
|
|
|
1019
|
+
The SDK also raises a few client-side codes: `network_error` and `timeout` (status 0), `redirect_refused` when the API answers with a redirect (the SDK never follows one, so a token, upload or webhook body cannot be forwarded to another origin), `invalid_response` when a wait loop receives something that is not the requested approval, Question or handoff, when any call gets a 2xx whose body is not JSON (a proxy's page, say), or when `sendIncomingWebhook()` gets a 2xx that is not a JSON object with `success: true` (a misrouted webhook URL), `unavailable` when a wait loop stops retrying an unreachable API (see [approvals](#human-in-the-loop-approvals); the last failure is `error.cause`), and `invalid_request` for input refused before sending, including an `idempotencyKey` that is not 1–255 printable ASCII characters.
|
|
1020
|
+
|
|
778
1021
|
The coded vocabulary is exported both as types and as runtime arrays, one per
|
|
779
1022
|
surface, so you can branch exhaustively:
|
|
780
1023
|
|
|
@@ -783,9 +1026,10 @@ surface, so you can branch exhaustively:
|
|
|
783
1026
|
| `HandoffErrorCode` / `HANDOFF_ERROR_CODES` | `handoffs.*` |
|
|
784
1027
|
| `RoomScopedErrorCode` / `ROOM_SCOPED_ERROR_CODES` | every call whose path names a room (`rooms.get`, `actions.*`, `broadcast`, `webhooks.*`, `live.*`) |
|
|
785
1028
|
| `AgentInboxErrorCode` / `AGENT_INBOX_ERROR_CODES` | `inbox.ensure()` / `inbox.activate()` |
|
|
786
|
-
| `QuickActionErrorCode` / `QUICK_ACTION_ERROR_CODES` | `actions.*` and a `broadcast()` naming an `action_number`: `quick_action_input_required`, `quick_action_input_type`, `action_not_configured`, `pro_required`, `quick_action_layout_changed`, `quick_action_page_in_use` |
|
|
1029
|
+
| `QuickActionErrorCode` / `QUICK_ACTION_ERROR_CODES` | `actions.*` and a `broadcast()` naming an `action_number`: `quick_action_input_required`, `quick_action_input_type`, `action_not_configured`, `pro_required`, `quick_action_layout_changed`, `quick_action_page_in_use`; and, on `actions.trigger()` with `trigger_source: 'location'` only, `location_trigger_owner_only` (403) and `location_trigger_inactive` (409) |
|
|
1030
|
+
| `CommonErrorCode` / `COMMON_ERROR_CODES` | any call: `rate_limited` (429, a route throttle; carries `retryAfter`) and `feature_disabled` (403, the operator switched the capability off; `error.body.feature` names the switch, for example `sending.attachments`, and retrying cannot succeed) |
|
|
787
1031
|
| `RequestLimitErrorCode` / `REQUEST_LIMIT_ERROR_CODES` | `payload_too_large` (HTTP 413 from the server on any oversized body) and `attachment_too_large` (raised locally by `attachments.upload()` above 5 MiB, before any bytes travel) |
|
|
788
|
-
| `AgentErrorCode` | all
|
|
1032
|
+
| `AgentErrorCode` | all six, for one shared branch |
|
|
789
1033
|
|
|
790
1034
|
## Configuration
|
|
791
1035
|
|
|
@@ -793,7 +1037,7 @@ surface, so you can branch exhaustively:
|
|
|
793
1037
|
new PingRoom({
|
|
794
1038
|
token: '...', // agent credential (optional for public reads)
|
|
795
1039
|
baseUrl: 'https://api.pingroom.io', // or env PINGROOM_API_URL / PINGROOM_BASE_URL
|
|
796
|
-
timeoutMs: 30000, // long-poll
|
|
1040
|
+
timeoutMs: 30000, // per-request deadline; a long-poll call (wait, listen, waitFor*) uses its own hold time + 10 s instead
|
|
797
1041
|
fetch: customFetch, // inject a fetch (tests / non-global-fetch runtimes)
|
|
798
1042
|
allowInsecure: false, // allow plain-http base URLs (off by default)
|
|
799
1043
|
});
|