@pingroom/sdk 0.8.0 → 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.
Files changed (179) hide show
  1. package/README.md +153 -41
  2. package/dist/client.d.ts +14 -348
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +15 -1300
  5. package/dist/client.js.map +1 -1
  6. package/dist/errors.d.ts +32 -3
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +47 -1
  9. package/dist/errors.js.map +1 -1
  10. package/dist/http.d.ts.map +1 -1
  11. package/dist/http.js +7 -1
  12. package/dist/http.js.map +1 -1
  13. package/dist/index.d.ts +3 -5
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/guards.d.ts +7 -0
  18. package/dist/internal/guards.d.ts.map +1 -1
  19. package/dist/internal/guards.js +1 -1
  20. package/dist/internal/guards.js.map +1 -1
  21. package/dist/internal/request.d.ts +41 -0
  22. package/dist/internal/request.d.ts.map +1 -0
  23. package/dist/internal/request.js +78 -0
  24. package/dist/internal/request.js.map +1 -0
  25. package/dist/internal/retry.d.ts +9 -2
  26. package/dist/internal/retry.d.ts.map +1 -1
  27. package/dist/internal/retry.js +24 -6
  28. package/dist/internal/retry.js.map +1 -1
  29. package/dist/internal/validate.d.ts +13 -0
  30. package/dist/internal/validate.d.ts.map +1 -0
  31. package/dist/internal/validate.js +36 -0
  32. package/dist/internal/validate.js.map +1 -0
  33. package/dist/liveStatus.d.ts +106 -11
  34. package/dist/liveStatus.d.ts.map +1 -1
  35. package/dist/liveStatus.js +20 -3
  36. package/dist/liveStatus.js.map +1 -1
  37. package/dist/mcp.d.ts.map +1 -1
  38. package/dist/mcp.js +30 -8
  39. package/dist/mcp.js.map +1 -1
  40. package/dist/namespaces/actions.d.ts +50 -0
  41. package/dist/namespaces/actions.d.ts.map +1 -0
  42. package/dist/namespaces/actions.js +219 -0
  43. package/dist/namespaces/actions.js.map +1 -0
  44. package/dist/namespaces/approvals.d.ts +23 -0
  45. package/dist/namespaces/approvals.d.ts.map +1 -0
  46. package/dist/namespaces/approvals.js +78 -0
  47. package/dist/namespaces/approvals.js.map +1 -0
  48. package/dist/namespaces/attachments.d.ts +37 -0
  49. package/dist/namespaces/attachments.d.ts.map +1 -0
  50. package/dist/namespaces/attachments.js +91 -0
  51. package/dist/namespaces/attachments.js.map +1 -0
  52. package/dist/namespaces/auth.d.ts +58 -0
  53. package/dist/namespaces/auth.d.ts.map +1 -0
  54. package/dist/namespaces/auth.js +242 -0
  55. package/dist/namespaces/auth.js.map +1 -0
  56. package/dist/namespaces/directory.d.ts +10 -0
  57. package/dist/namespaces/directory.d.ts.map +1 -0
  58. package/dist/namespaces/directory.js +19 -0
  59. package/dist/namespaces/directory.js.map +1 -0
  60. package/dist/namespaces/handoffs.d.ts +47 -0
  61. package/dist/namespaces/handoffs.d.ts.map +1 -0
  62. package/dist/namespaces/handoffs.js +126 -0
  63. package/dist/namespaces/handoffs.js.map +1 -0
  64. package/dist/namespaces/inbox.d.ts +17 -0
  65. package/dist/namespaces/inbox.d.ts.map +1 -0
  66. package/dist/namespaces/inbox.js +192 -0
  67. package/dist/namespaces/inbox.js.map +1 -0
  68. package/dist/namespaces/live.d.ts +57 -0
  69. package/dist/namespaces/live.d.ts.map +1 -0
  70. package/dist/namespaces/live.js +116 -0
  71. package/dist/namespaces/live.js.map +1 -0
  72. package/dist/namespaces/notifications.d.ts +25 -0
  73. package/dist/namespaces/notifications.d.ts.map +1 -0
  74. package/dist/namespaces/notifications.js +128 -0
  75. package/dist/namespaces/notifications.js.map +1 -0
  76. package/dist/namespaces/profile.d.ts +19 -0
  77. package/dist/namespaces/profile.d.ts.map +1 -0
  78. package/dist/namespaces/profile.js +22 -0
  79. package/dist/namespaces/profile.js.map +1 -0
  80. package/dist/namespaces/questions.d.ts +41 -0
  81. package/dist/namespaces/questions.d.ts.map +1 -0
  82. package/dist/namespaces/questions.js +107 -0
  83. package/dist/namespaces/questions.js.map +1 -0
  84. package/dist/namespaces/rooms.d.ts +13 -0
  85. package/dist/namespaces/rooms.d.ts.map +1 -0
  86. package/dist/namespaces/rooms.js +41 -0
  87. package/dist/namespaces/rooms.js.map +1 -0
  88. package/dist/namespaces/webhooks.d.ts +11 -0
  89. package/dist/namespaces/webhooks.d.ts.map +1 -0
  90. package/dist/namespaces/webhooks.js +32 -0
  91. package/dist/namespaces/webhooks.js.map +1 -0
  92. package/dist/types/approvals.d.ts +62 -0
  93. package/dist/types/approvals.d.ts.map +1 -0
  94. package/dist/types/approvals.js +2 -0
  95. package/dist/types/approvals.js.map +1 -0
  96. package/dist/types/auth.d.ts +185 -0
  97. package/dist/types/auth.d.ts.map +1 -0
  98. package/dist/types/auth.js +2 -0
  99. package/dist/types/auth.js.map +1 -0
  100. package/dist/types/common.d.ts +43 -0
  101. package/dist/types/common.d.ts.map +1 -0
  102. package/dist/types/common.js +2 -0
  103. package/dist/types/common.js.map +1 -0
  104. package/dist/types/handoffs.d.ts +121 -0
  105. package/dist/types/handoffs.d.ts.map +1 -0
  106. package/dist/types/handoffs.js +2 -0
  107. package/dist/types/handoffs.js.map +1 -0
  108. package/dist/types/inbox.d.ts +51 -0
  109. package/dist/types/inbox.d.ts.map +1 -0
  110. package/dist/types/inbox.js +2 -0
  111. package/dist/types/inbox.js.map +1 -0
  112. package/dist/types/notifications.d.ts +376 -0
  113. package/dist/types/notifications.d.ts.map +1 -0
  114. package/dist/types/notifications.js +2 -0
  115. package/dist/types/notifications.js.map +1 -0
  116. package/dist/types/profile.d.ts +30 -0
  117. package/dist/types/profile.d.ts.map +1 -0
  118. package/dist/types/profile.js +3 -0
  119. package/dist/types/profile.js.map +1 -0
  120. package/dist/types/questions.d.ts +210 -0
  121. package/dist/types/questions.d.ts.map +1 -0
  122. package/dist/types/questions.js +2 -0
  123. package/dist/types/questions.js.map +1 -0
  124. package/dist/types/rooms.d.ts +163 -0
  125. package/dist/types/rooms.d.ts.map +1 -0
  126. package/dist/types/rooms.js +3 -0
  127. package/dist/types/rooms.js.map +1 -0
  128. package/dist/types.d.ts +9 -1199
  129. package/dist/types.d.ts.map +1 -1
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/webhooks.d.ts +14 -4
  133. package/dist/webhooks.d.ts.map +1 -1
  134. package/dist/webhooks.js +30 -5
  135. package/dist/webhooks.js.map +1 -1
  136. package/package.json +4 -2
  137. package/src/client.ts +115 -0
  138. package/src/errors.ts +291 -0
  139. package/src/http.ts +225 -0
  140. package/src/index.ts +97 -0
  141. package/src/internal/async.ts +54 -0
  142. package/src/internal/guards.ts +129 -0
  143. package/src/internal/request.ts +97 -0
  144. package/src/internal/retry.ts +157 -0
  145. package/src/internal/url.ts +78 -0
  146. package/src/internal/validate.ts +42 -0
  147. package/src/linkPing.ts +56 -0
  148. package/src/liveStatus.ts +337 -0
  149. package/src/locationPing.ts +101 -0
  150. package/src/mcp.ts +161 -0
  151. package/src/namespaces/actions.ts +234 -0
  152. package/src/namespaces/approvals.ts +104 -0
  153. package/src/namespaces/attachments.ts +106 -0
  154. package/src/namespaces/auth.ts +284 -0
  155. package/src/namespaces/directory.ts +21 -0
  156. package/src/namespaces/handoffs.ts +160 -0
  157. package/src/namespaces/inbox.ts +253 -0
  158. package/src/namespaces/live.ts +134 -0
  159. package/src/namespaces/notifications.ts +152 -0
  160. package/src/namespaces/profile.ts +24 -0
  161. package/src/namespaces/questions.ts +133 -0
  162. package/src/namespaces/rooms.ts +53 -0
  163. package/src/namespaces/webhooks.ts +38 -0
  164. package/src/types/approvals.ts +67 -0
  165. package/src/types/auth.ts +202 -0
  166. package/src/types/common.ts +74 -0
  167. package/src/types/handoffs.ts +148 -0
  168. package/src/types/inbox.ts +64 -0
  169. package/src/types/notifications.ts +393 -0
  170. package/src/types/profile.ts +37 -0
  171. package/src/types/questions.ts +226 -0
  172. package/src/types/rooms.ts +169 -0
  173. package/src/types.ts +17 -0
  174. package/src/version.ts +2 -0
  175. package/src/webhooks.ts +373 -0
  176. package/dist/liveActivity.d.ts +0 -101
  177. package/dist/liveActivity.d.ts.map +0 -1
  178. package/dist/liveActivity.js +0 -83
  179. package/dist/liveActivity.js.map +0 -1
package/src/errors.ts ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Error types. PingRoomError carries the HTTP status and the API's machine
3
+ * `code` (e.g. `room_not_granted`, `cooldown_active`, `rate_limited`) so callers
4
+ * can branch without string-matching messages. Retry any 429 after
5
+ * `retryAfter` rather than matching one code: route throttles answer
6
+ * `rate_limited`, but public-room and quick-action limits carry their own
7
+ * (`public_room_rate_limited`, `cooldown_active`). The agent token is NEVER
8
+ * stored on or serialized through an error.
9
+ */
10
+
11
+ export interface ApiErrorBody {
12
+ code?: string;
13
+ error?: string;
14
+ message?: string;
15
+ retry_after?: number;
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ /**
20
+ * The codes the `agent.room` gate emits on every ROOM-SCOPED route — the ones
21
+ * whose path carries an invite code: `rooms.get()`, `actions.list()`,
22
+ * `actions.trigger()`, `broadcast()`, the webhook calls, and
23
+ * `liveStatus.get()`/`liveStatus.push()`. HTTP 403.
24
+ *
25
+ * NOT reachable from `handoffs.create()`: `POST /api/agent/handoffs` names no
26
+ * room in its path and so is not behind that gate.
27
+ *
28
+ * Server: `EnsureAgentRoomAccess` middleware.
29
+ */
30
+ export const ROOM_SCOPED_ERROR_CODES = [
31
+ // The room is outside the grant the human gave this agent when connecting.
32
+ 'room_not_granted',
33
+ ] as const;
34
+
35
+ /**
36
+ * The machine `code`s a handoff create/read can surface on a {@link PingRoomError}.
37
+ * Branch on `error.code` (and `error.status`) instead of the message.
38
+ *
39
+ * Authentication and schema-validation failures may have no machine code; use
40
+ * `status` for those responses. This union holds the codes specific to handoffs.
41
+ * Codes any route can answer (`rate_limited`, `feature_disabled`) are in
42
+ * {@link COMMON_ERROR_CODES}.
43
+ *
44
+ * Server: `AgentHandoffController`, `HandoffService`, `RequestIdempotency`, the
45
+ * `agent.scope`/`agent.quota` middleware, and the `RecipientNotReady` /
46
+ * `CapabilityCheckUnavailable` renderers in `bootstrap/app.php`.
47
+ */
48
+ export const HANDOFF_ERROR_CODES = [
49
+ 'feature_temporarily_unavailable',
50
+ 'handoff_room_unsupported',
51
+ 'invalid_target',
52
+ // The agent has no delivery room designated; the human must pick one.
53
+ 'no_room_configured',
54
+ // An agent may hand off only to the account that connected it.
55
+ 'target_not_permitted',
56
+ 'recipient_not_ready',
57
+ 'idempotency_conflict',
58
+ 'invalid_idempotency_key',
59
+ 'capability_check_unavailable',
60
+ 'insufficient_scope',
61
+ 'free_limit_reached',
62
+ ] as const;
63
+
64
+ /**
65
+ * The coded failures of the Inbox activation path (`inbox.ensure()` /
66
+ * `inbox.activate()`). All HTTP 409 except `feature_temporarily_unavailable`,
67
+ * which is 422.
68
+ *
69
+ * Server: `AgentInboxController`.
70
+ */
71
+ export const AGENT_INBOX_ERROR_CODES = [
72
+ // Already activated, but the original connection-test record is gone.
73
+ 'activation_evidence_unavailable',
74
+ // The human must pick the room this agent pings them in.
75
+ 'no_room_configured',
76
+ // The chosen room is muted, or the human is no longer a member of it.
77
+ 'activation_room_muted',
78
+ // No device could render the activation Question.
79
+ 'activation_delivery_unavailable',
80
+ 'feature_temporarily_unavailable',
81
+ ] as const;
82
+
83
+ /**
84
+ * The coded failures of the quick-action surface (`actions.*` and a
85
+ * `broadcast()` that names an `action_number`).
86
+ *
87
+ * Server: `Room::assertQuickActionAccessible/Configured`, `QuickActionDispatcher`
88
+ * and `QuickActionLayoutService`.
89
+ */
90
+ export const QUICK_ACTION_ERROR_CODES = [
91
+ // 422 — the action has an input_type and the press carried no matching detail.
92
+ 'quick_action_input_required',
93
+ // 422 — photo/pdf actions accept only jpg/png or pdf attachments.
94
+ 'quick_action_input_type',
95
+ // 404 — the slot is reserved but disabled (empty label and icon).
96
+ 'action_not_configured',
97
+ // 403 — slots 5–16 and layout edits need the ROOM OWNER on Pro; attachments need this account.
98
+ 'pro_required',
99
+ // 409 — base_action_ids / quick_action_id no longer match the stored layout.
100
+ 'quick_action_layout_changed',
101
+ // 409 — a time trigger, webhook, agent binding or running live update still uses the page.
102
+ 'quick_action_page_in_use',
103
+ // 403 — `actions.trigger()` with trigger_source 'location' from anyone but the room owner.
104
+ 'location_trigger_owner_only',
105
+ // 409 — `actions.trigger()` with trigger_source 'location' in a room with no enabled
106
+ // location trigger, or one that does not listen for the reported crossing.
107
+ 'location_trigger_inactive',
108
+ ] as const;
109
+
110
+ /**
111
+ * The coded refusals of the "Who's on it?" surface (`live.claim()`, and a
112
+ * `live.push()` that starts a `claim` stream).
113
+ *
114
+ * Server: `ClaimService`, `ClaimService::startingBlock()`.
115
+ */
116
+ export const LIVE_CLAIM_ERROR_CODES = [
117
+ // 409 — not allowed from the request's current state (someone else holds it,
118
+ // it already ended, or you are not who may do this). `body.claim` shows who.
119
+ 'claim_conflict',
120
+ // 422 — the asker tried to claim their own request.
121
+ 'claim_own_request',
122
+ // 422 — claim requests exist only in private standard rooms.
123
+ 'claim_private_rooms_only',
124
+ // 404 — no claim request with that notification id in this room.
125
+ 'claim_not_found',
126
+ // 422, on create — the request text is missing or over 120 characters.
127
+ 'claim_request_invalid',
128
+ // 422, on create — the first ping must be `running`.
129
+ 'claim_must_start_running',
130
+ // 422, on create — `deadline_at` is in the past or more than 8 hours away.
131
+ 'claim_deadline_out_of_range',
132
+ ] as const;
133
+
134
+ /**
135
+ * Codes any route can answer, whatever the surface, so they sit in no
136
+ * per-surface union.
137
+ *
138
+ * - `rate_limited` — 429, a route throttle (`throttle:N,1`). The body carries
139
+ * `retry_after`. Per-surface limits answer their own 429 code instead
140
+ * (`public_room_rate_limited`, `cooldown_active`), so retry any 429.
141
+ * - `feature_disabled` — 403, the operator switched the capability off for every
142
+ * client (attachments, links, locations, questions, public rooms, Location
143
+ * Pings, incoming webhooks, live progress, …). The body's `feature` names the
144
+ * switch (for example `sending.attachments`). Retrying cannot succeed.
145
+ *
146
+ * Server: the `ThrottleRequestsException` renderer in `bootstrap/app.php` and
147
+ * `ClientFeatureGate` (the `feature:` middleware and the request-level gates).
148
+ */
149
+ export const COMMON_ERROR_CODES = ['rate_limited', 'feature_disabled'] as const;
150
+
151
+ /**
152
+ * Request-size failures. The server answers every oversized `api/*` body with
153
+ * `413 {"code":"payload_too_large"}`; the SDK refuses a >5 MiB attachment
154
+ * locally with `attachment_too_large` before any bytes are sent.
155
+ */
156
+ export const REQUEST_LIMIT_ERROR_CODES = ['payload_too_large', 'attachment_too_large'] as const;
157
+
158
+ /** @see COMMON_ERROR_CODES */
159
+ export type CommonErrorCode = (typeof COMMON_ERROR_CODES)[number];
160
+
161
+ /** @see ROOM_SCOPED_ERROR_CODES */
162
+ export type RoomScopedErrorCode = (typeof ROOM_SCOPED_ERROR_CODES)[number];
163
+
164
+ /** @see QUICK_ACTION_ERROR_CODES */
165
+ export type QuickActionErrorCode = (typeof QUICK_ACTION_ERROR_CODES)[number];
166
+
167
+ /** @see REQUEST_LIMIT_ERROR_CODES */
168
+ export type RequestLimitErrorCode = (typeof REQUEST_LIMIT_ERROR_CODES)[number];
169
+
170
+ /** @see LIVE_CLAIM_ERROR_CODES */
171
+ export type LiveClaimErrorCode = (typeof LIVE_CLAIM_ERROR_CODES)[number];
172
+
173
+ /** @see HANDOFF_ERROR_CODES */
174
+ export type HandoffErrorCode = (typeof HANDOFF_ERROR_CODES)[number];
175
+
176
+ /** @see AGENT_INBOX_ERROR_CODES */
177
+ export type AgentInboxErrorCode = (typeof AGENT_INBOX_ERROR_CODES)[number];
178
+
179
+ /**
180
+ * Every machine `code` this SDK's typed unions cover, for callers that branch
181
+ * once across surfaces instead of per-endpoint.
182
+ */
183
+ export type AgentErrorCode =
184
+ | HandoffErrorCode
185
+ | RoomScopedErrorCode
186
+ | AgentInboxErrorCode
187
+ | QuickActionErrorCode
188
+ | LiveClaimErrorCode
189
+ | RequestLimitErrorCode
190
+ | CommonErrorCode;
191
+
192
+ export interface PingRoomErrorInit {
193
+ status?: number;
194
+ code?: string | null;
195
+ retryAfter?: number | null;
196
+ body?: unknown;
197
+ /** The underlying failure, exposed as the standard `error.cause`. */
198
+ cause?: unknown;
199
+ }
200
+
201
+ export class PingRoomError extends Error {
202
+ /** HTTP status, or 0 for client-side/network errors. */
203
+ readonly status: number;
204
+ /** Machine-readable code from the API body (`code` or `error`), or a client code. */
205
+ readonly code: string | null;
206
+ /** Seconds to wait before retrying, when the API or a Retry-After header provides it. */
207
+ readonly retryAfter: number | null;
208
+ /** The parsed response body (or raw text), for inspection. Contains no credentials. */
209
+ readonly body: unknown;
210
+
211
+ constructor(message: string, init: PingRoomErrorInit = {}) {
212
+ super(message);
213
+ this.name = 'PingRoomError';
214
+ this.status = init.status ?? 0;
215
+ this.code = init.code ?? null;
216
+ this.retryAfter = init.retryAfter ?? null;
217
+ this.body = init.body ?? null;
218
+ if (init.cause !== undefined) {
219
+ (this as { cause?: unknown }).cause = init.cause;
220
+ }
221
+ Object.setPrototypeOf(this, new.target.prototype);
222
+ }
223
+
224
+ static fromResponse(status: number, body: unknown, headers?: Headers): PingRoomError {
225
+ const b: ApiErrorBody = body && typeof body === 'object' ? (body as ApiErrorBody) : {};
226
+ const code =
227
+ (typeof b.code === 'string' && b.code) ||
228
+ (typeof b.error === 'string' && b.error) ||
229
+ null;
230
+ const message =
231
+ (typeof b.message === 'string' && b.message) ||
232
+ (code ? code : `HTTP ${status}`);
233
+
234
+ let retryAfter: number | null = typeof b.retry_after === 'number' ? b.retry_after : null;
235
+ if (retryAfter === null && headers) {
236
+ const h = headers.get('retry-after');
237
+ if (h && /^\d+$/.test(h.trim())) {
238
+ retryAfter = Number(h.trim());
239
+ }
240
+ }
241
+
242
+ return new PingRoomError(message, { status, code, retryAfter, body });
243
+ }
244
+ }
245
+
246
+ /** Thrown when a request exceeds its timeout. */
247
+ export class PingRoomTimeoutError extends PingRoomError {
248
+ constructor(message = 'Request timed out') {
249
+ super(message, { code: 'timeout' });
250
+ this.name = 'PingRoomTimeoutError';
251
+ }
252
+ }
253
+
254
+ /** Thrown when the underlying fetch fails (DNS, connection, TLS, etc.). */
255
+ export class PingRoomNetworkError extends PingRoomError {
256
+ constructor(message: string, cause?: unknown) {
257
+ super(message, { code: 'network_error' });
258
+ this.name = 'PingRoomNetworkError';
259
+ if (cause !== undefined) {
260
+ (this as { cause?: unknown }).cause = cause;
261
+ }
262
+ }
263
+ }
264
+
265
+ export type AgentInboxActivationIncompleteReason =
266
+ | 'deadline_exceeded'
267
+ | 'answered_without_completion'
268
+ | 'expired'
269
+ | 'cancelled';
270
+
271
+ /**
272
+ * The onboarding Question reached a non-activating terminal outcome, or the
273
+ * caller's overall activation deadline elapsed. The client's credential is not
274
+ * changed or revoked when this error is thrown.
275
+ */
276
+ export class PingRoomActivationIncompleteError extends PingRoomError {
277
+ readonly reason: AgentInboxActivationIncompleteReason;
278
+
279
+ constructor(
280
+ reason: AgentInboxActivationIncompleteReason,
281
+ message: string,
282
+ details: Record<string, unknown> = {},
283
+ ) {
284
+ super(message, {
285
+ code: 'inbox_activation_incomplete',
286
+ body: { reason, ...details },
287
+ });
288
+ this.name = 'PingRoomActivationIncompleteError';
289
+ this.reason = reason;
290
+ }
291
+ }
package/src/http.ts ADDED
@@ -0,0 +1,225 @@
1
+ import { PingRoomError, PingRoomNetworkError, PingRoomTimeoutError } from './errors.js';
2
+ import { combineSignals } from './internal/async.js';
3
+ import { assertSecureUrl, refuseRedirect } from './internal/url.js';
4
+ import { invalidSuccessResponse } from './internal/validate.js';
5
+ import type { FetchLike } from './types.js';
6
+ import { VERSION } from './version.js';
7
+
8
+ export interface HttpClientOptions {
9
+ baseUrl: string;
10
+ token?: string | null;
11
+ timeoutMs?: number;
12
+ fetch?: FetchLike;
13
+ userAgent?: string;
14
+ allowInsecure?: boolean;
15
+ }
16
+
17
+ export interface RequestOptions {
18
+ query?: Record<string, string | number | boolean | undefined>;
19
+ body?: unknown;
20
+ headers?: Record<string, string>;
21
+ /** Send the Authorization header (default true). Public endpoints pass false. */
22
+ auth?: boolean;
23
+ /** Per-call timeout override in ms. */
24
+ timeoutMs?: number;
25
+ signal?: AbortSignal;
26
+ }
27
+
28
+ const DEFAULT_TIMEOUT_MS = 30_000;
29
+
30
+ /** Node's util.inspect hook, named through the global registry so no import is needed. */
31
+ const INSPECT: unique symbol = Symbol.for('nodejs.util.inspect.custom');
32
+
33
+ /**
34
+ * Thin authenticated fetch wrapper. Holds the token privately, enforces https
35
+ * for credentialed traffic, applies a timeout, and maps non-2xx responses to a
36
+ * PingRoomError. The token is never written into thrown errors or logs.
37
+ */
38
+ export class HttpClient {
39
+ // An ES private field: TypeScript's `private` is compile-time only, so the
40
+ // token would otherwise reach JSON.stringify() and util.inspect() output.
41
+ #token: string | null;
42
+ private readonly baseUrl: string;
43
+ private readonly timeoutMs: number;
44
+ private readonly fetchImpl: FetchLike;
45
+ private readonly userAgent: string;
46
+
47
+ constructor(options: HttpClientOptions) {
48
+ const base = assertSecureUrl(options.baseUrl, options.allowInsecure ?? false);
49
+ // Normalise: keep origin + any path prefix, drop a trailing slash.
50
+ this.baseUrl = (base.origin + base.pathname).replace(/\/$/, '');
51
+ this.#token = options.token ?? null;
52
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
53
+ this.fetchImpl = options.fetch ?? defaultFetch();
54
+ this.userAgent = options.userAgent ?? `pingroom-sdk/${VERSION}`;
55
+ }
56
+
57
+ setToken(token: string | null): void {
58
+ this.#token = token;
59
+ }
60
+
61
+ getToken(): string | null {
62
+ return this.#token;
63
+ }
64
+
65
+ /** What a logger or JSON.stringify() sees: the API origin, never the credential. */
66
+ toJSON(): { baseUrl: string; token: '[redacted]' | null } {
67
+ return { baseUrl: this.baseUrl, token: this.#token === null ? null : '[redacted]' };
68
+ }
69
+
70
+ [INSPECT](): string {
71
+ return `HttpClient { baseUrl: ${JSON.stringify(this.baseUrl)}, token: ${this.#token === null ? 'null' : '[redacted]'} }`;
72
+ }
73
+
74
+ async request<T>(method: string, path: string, opts: RequestOptions = {}): Promise<T> {
75
+ return this.send(method, path, opts, (res) => this.parse<T>(res));
76
+ }
77
+
78
+ /**
79
+ * The undecoded Response, for endpoints that return bytes rather than JSON.
80
+ * Non-2xx still raises a PingRoomError so callers never read an error body
81
+ * as if it were content.
82
+ */
83
+ async raw(method: string, path: string, opts: RequestOptions = {}): Promise<Response> {
84
+ return this.send(method, path, {
85
+ ...opts,
86
+ headers: { Accept: '*/*', ...opts.headers },
87
+ }, async (res) => {
88
+ if (!res.ok) {
89
+ // Route the failure through the shared JSON error mapping.
90
+ await this.parse<unknown>(res);
91
+ }
92
+ return res;
93
+ });
94
+ }
95
+
96
+ private async send<T>(
97
+ method: string,
98
+ path: string,
99
+ opts: RequestOptions,
100
+ consume: (res: Response) => Promise<T>,
101
+ ): Promise<T> {
102
+ const url = this.buildUrl(path, opts.query);
103
+
104
+ const headers: Record<string, string> = {
105
+ Accept: 'application/json',
106
+ 'User-Agent': this.userAgent,
107
+ ...opts.headers,
108
+ };
109
+
110
+ if (opts.auth !== false) {
111
+ if (!this.#token) {
112
+ throw new PingRoomError(
113
+ 'This call requires an agent token. Pass `token` to the client or call setToken().',
114
+ { code: 'no_token' },
115
+ );
116
+ }
117
+ headers['Authorization'] = `Bearer ${this.#token}`;
118
+ }
119
+
120
+ // FormData is passed through untouched: the runtime has to generate the
121
+ // multipart boundary itself, so setting Content-Type here would corrupt it.
122
+ let payload: string | FormData | undefined;
123
+ if (opts.body instanceof FormData) {
124
+ payload = opts.body;
125
+ } else if (opts.body !== undefined) {
126
+ headers['Content-Type'] = 'application/json';
127
+ payload = JSON.stringify(opts.body);
128
+ }
129
+
130
+ const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
131
+ const controller = new AbortController();
132
+ const timer = setTimeout(() => controller.abort(new PingRoomTimeoutError()), timeoutMs);
133
+ const signal = combineSignals(controller.signal, opts.signal);
134
+
135
+ // Called as a plain function, never as a method of this client: browsers
136
+ // and Workers reject a fetch whose receiver is not the global object.
137
+ const fetchImpl = this.fetchImpl;
138
+ try {
139
+ // A 307/308 can forward pairing secrets or uploaded bytes to another
140
+ // origin even when fetch strips the Authorization header. 'manual' plus
141
+ // an explicit refusal, because Workers reject redirect: 'error'.
142
+ const res = await fetchImpl(url, { method, headers, body: payload, signal, redirect: 'manual' });
143
+ refuseRedirect(res);
144
+ // Keep the deadline active while reading JSON, including error bodies.
145
+ return await consume(res);
146
+ } catch (err) {
147
+ if (signal.aborted) {
148
+ throw signal.reason;
149
+ }
150
+ // Our own timeout reason propagates as the rejection.
151
+ if (err instanceof PingRoomError) {
152
+ throw err;
153
+ }
154
+ if (isAbortError(err) || opts.signal?.aborted) {
155
+ throw err;
156
+ }
157
+ throw new PingRoomNetworkError(`Network request failed: ${errMessage(err)}`, err);
158
+ } finally {
159
+ clearTimeout(timer);
160
+ }
161
+ }
162
+
163
+ private async parse<T>(res: Response): Promise<T> {
164
+ if (res.status === 204) {
165
+ return undefined as T;
166
+ }
167
+ const text = await res.text();
168
+ let json: unknown = null;
169
+ if (text.trim()) {
170
+ try {
171
+ json = JSON.parse(text);
172
+ } catch {
173
+ // A non-JSON error stays text for the error mapping below; a non-JSON
174
+ // success is a proxy's page, not data.
175
+ if (res.ok) {
176
+ throw invalidSuccessResponse(res.status, 'a JSON body', text);
177
+ }
178
+ json = text;
179
+ }
180
+ }
181
+ if (!res.ok) {
182
+ throw PingRoomError.fromResponse(res.status, json, res.headers);
183
+ }
184
+ return json as T;
185
+ }
186
+
187
+ private buildUrl(path: string, query?: RequestOptions['query']): string {
188
+ // Every call is relative to the base URL the token was configured for. An
189
+ // absolute URL would carry the Bearer token to another origin unchecked.
190
+ if (/^[a-z][a-z\d+.-]*:/i.test(path)) {
191
+ throw new PingRoomError('Request paths must be relative to the API base URL.', { code: 'invalid_request' });
192
+ }
193
+ const full = this.baseUrl + (path.startsWith('/') ? path : `/${path}`);
194
+ if (!query) {
195
+ return full;
196
+ }
197
+ const url = new URL(full);
198
+ for (const [k, v] of Object.entries(query)) {
199
+ if (v !== undefined) {
200
+ url.searchParams.set(k, String(v));
201
+ }
202
+ }
203
+ return url.toString();
204
+ }
205
+ }
206
+
207
+ /** The global fetch, bound so that calling it detached keeps a valid receiver. */
208
+ export function defaultFetch(): FetchLike {
209
+ const f = globalThis.fetch as unknown as FetchLike | undefined;
210
+ if (typeof f !== 'function') {
211
+ throw new PingRoomError(
212
+ 'No fetch implementation available. Use Node >= 20, a modern browser, or pass `fetch`.',
213
+ { code: 'no_fetch' },
214
+ );
215
+ }
216
+ return f.bind(globalThis);
217
+ }
218
+
219
+ function isAbortError(err: unknown): boolean {
220
+ return Boolean(err) && typeof err === 'object' && (err as { name?: string }).name === 'AbortError';
221
+ }
222
+
223
+ function errMessage(err: unknown): string {
224
+ return err instanceof Error ? err.message : String(err);
225
+ }
package/src/index.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @pingroom/sdk — official TypeScript/JavaScript SDK for the PingRoom agent API.
3
+ *
4
+ * import { PingRoom } from '@pingroom/sdk';
5
+ * const pr = new PingRoom({ token: process.env.PINGROOM_TOKEN });
6
+ * await pr.broadcast('ab12cd', { message: 'Deploy shipped ✅' });
7
+ */
8
+
9
+ import { PingRoom } from './client.js';
10
+
11
+ export { PingRoom };
12
+ /** Alias for {@link PingRoom}. */
13
+ export { PingRoom as PingRoomClient } from './client.js';
14
+
15
+ export { McpClient } from './mcp.js';
16
+ export type { JsonRpcError, McpInitializeResult, McpTool, McpToolResult } from './mcp.js';
17
+
18
+ export {
19
+ PingRoomError,
20
+ PingRoomTimeoutError,
21
+ PingRoomNetworkError,
22
+ PingRoomActivationIncompleteError,
23
+ AGENT_INBOX_ERROR_CODES,
24
+ COMMON_ERROR_CODES,
25
+ HANDOFF_ERROR_CODES,
26
+ LIVE_CLAIM_ERROR_CODES,
27
+ ROOM_SCOPED_ERROR_CODES,
28
+ QUICK_ACTION_ERROR_CODES,
29
+ REQUEST_LIMIT_ERROR_CODES,
30
+ } from './errors.js';
31
+ export type {
32
+ AgentErrorCode,
33
+ AgentInboxActivationIncompleteReason,
34
+ AgentInboxErrorCode,
35
+ ApiErrorBody,
36
+ CommonErrorCode,
37
+ HandoffErrorCode,
38
+ LiveClaimErrorCode,
39
+ PingRoomErrorInit,
40
+ QuickActionErrorCode,
41
+ RequestLimitErrorCode,
42
+ RoomScopedErrorCode,
43
+ } from './errors.js';
44
+
45
+ export {
46
+ verifyWebhookSignature,
47
+ sendIncomingWebhook,
48
+ WEBHOOK_SIGNATURE_HEADER,
49
+ WEBHOOK_SIGNATURE_V2_HEADER,
50
+ WEBHOOK_TIMESTAMP_HEADER,
51
+ WEBHOOK_DELIVERY_HEADER,
52
+ WEBHOOK_EVENT_HEADER,
53
+ INCOMING_WEBHOOK_FIELDS,
54
+ } from './webhooks.js';
55
+ export type {
56
+ VerifyWebhookSignatureOptions,
57
+ IncomingWebhookPayload,
58
+ SendIncomingWebhookOptions,
59
+ IncomingWebhookResult,
60
+ } from './webhooks.js';
61
+
62
+ export { linkPing } from './linkPing.js';
63
+ export type { LinkPingInput, LinkPingData } from './linkPing.js';
64
+
65
+ export { locationPing, extractLocationPing } from './locationPing.js';
66
+ export type {
67
+ LocationPingInput,
68
+ LocationPingLocation,
69
+ LocationPingData,
70
+ } from './locationPing.js';
71
+
72
+ export { liveStatus } from './liveStatus.js';
73
+ export type {
74
+ LiveActivityTemplate,
75
+ LiveStatus,
76
+ LiveStatusState,
77
+ LiveStatusCategory,
78
+ LiveStatusMetric,
79
+ LiveStatusOption,
80
+ LiveStatusSide,
81
+ LiveClaim,
82
+ LiveClaimAction,
83
+ LiveClaimParty,
84
+ LiveClaimRefusal,
85
+ LiveClaimResult,
86
+ LiveClaimStatus,
87
+ LiveClaimStreamState,
88
+ LiveStatusPing,
89
+ LiveStatusResult,
90
+ LiveStatusSnapshot,
91
+ } from './liveStatus.js';
92
+
93
+ export { VERSION } from './version.js';
94
+
95
+ export type * from './types.js';
96
+
97
+ export default PingRoom;
@@ -0,0 +1,54 @@
1
+ /** Merge two abort signals into one (uses AbortSignal.any when available). */
2
+ export function combineSignals(primary: AbortSignal, extra?: AbortSignal): AbortSignal {
3
+ if (!extra) {
4
+ return primary;
5
+ }
6
+ const anyFn = (AbortSignal as unknown as { any?: (signals: AbortSignal[]) => AbortSignal }).any;
7
+ if (typeof anyFn === 'function') {
8
+ return anyFn([primary, extra]);
9
+ }
10
+ const controller = new AbortController();
11
+ const forward = (s: AbortSignal): void => {
12
+ if (!controller.signal.aborted) {
13
+ controller.abort(s.reason);
14
+ }
15
+ };
16
+ for (const s of [primary, extra]) {
17
+ if (s.aborted) {
18
+ forward(s);
19
+ break;
20
+ }
21
+ s.addEventListener('abort', () => forward(s), { once: true });
22
+ }
23
+ return controller.signal;
24
+ }
25
+
26
+ /** Resolve after `ms`, or early (without rejecting) if `signal` aborts. */
27
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
28
+ return new Promise<void>((resolve) => {
29
+ if (signal?.aborted) {
30
+ resolve();
31
+ return;
32
+ }
33
+ const timer = setTimeout(() => {
34
+ cleanup();
35
+ resolve();
36
+ }, ms);
37
+ const onAbort = (): void => {
38
+ cleanup();
39
+ resolve();
40
+ };
41
+ const cleanup = (): void => {
42
+ clearTimeout(timer);
43
+ signal?.removeEventListener('abort', onAbort);
44
+ };
45
+ signal?.addEventListener('abort', onAbort, { once: true });
46
+ });
47
+ }
48
+
49
+ /** Throw the signal's reason once it has aborted. A missing signal never throws. */
50
+ export function throwIfAborted(signal?: AbortSignal): void {
51
+ if (signal?.aborted) {
52
+ throw signal.reason ?? new Error('aborted');
53
+ }
54
+ }