@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
@@ -0,0 +1,129 @@
1
+ import { PingRoomError } from '../errors.js';
2
+
3
+ /** Mirrors the server's structured-`data` limits so oversized payloads fail fast, locally. */
4
+ export const MAX_DATA_BYTES = 8 * 1024;
5
+ export const MAX_DATA_KEYS = 25;
6
+ export const MAX_PRIVATE_PING_MESSAGE_LENGTH = 120;
7
+ export const MAX_PUBLIC_PING_MESSAGE_LENGTH = 160;
8
+ export const MAX_PING_TITLE_LENGTH = 40;
9
+
10
+ /** Count Unicode code points, matching Laravel's multibyte string validation. */
11
+ function characterLength(value: string): number {
12
+ return Array.from(value).length;
13
+ }
14
+
15
+ export function assertMaxLength(value: unknown, max: number, field: string): void {
16
+ if (value === undefined || value === null) {
17
+ return;
18
+ }
19
+ if (typeof value !== 'string') {
20
+ throw new PingRoomError(`\`${field}\` must be a string.`, { code: 'invalid_request' });
21
+ }
22
+ const length = characterLength(value);
23
+ if (length > max) {
24
+ throw new PingRoomError(
25
+ `\`${field}\` may have at most ${max} characters (got ${length}).`,
26
+ { code: 'invalid_request' },
27
+ );
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Bytes a JSON string takes as PHP's default json_encode() writes it, which is
33
+ * what the server's 8 KB cap measures: every non-ASCII UTF-16 unit becomes a
34
+ * six-byte `\uXXXX` escape and `/` becomes `\/`. Non-Latin text is several
35
+ * times larger there than its UTF-8 size.
36
+ */
37
+ export function serverJsonByteLength(json: string): number {
38
+ let bytes = 0;
39
+ for (let index = 0; index < json.length; index += 1) {
40
+ const code = json.charCodeAt(index);
41
+ if (code > 0x7f) bytes += 6;
42
+ else if (code === 0x2f) bytes += 2;
43
+ else bytes += 1;
44
+ }
45
+ return bytes;
46
+ }
47
+
48
+ export function assertStructuredData(data: unknown): void {
49
+ if (data === undefined || data === null) {
50
+ return;
51
+ }
52
+ if (typeof data !== 'object' || Array.isArray(data)) {
53
+ throw new PingRoomError('`data` must be a JSON object (not an array or scalar).', { code: 'invalid_data' });
54
+ }
55
+ const keys = Object.keys(data as object);
56
+ if (keys.length > MAX_DATA_KEYS) {
57
+ throw new PingRoomError(`\`data\` may have at most ${MAX_DATA_KEYS} keys (got ${keys.length}).`, { code: 'invalid_data' });
58
+ }
59
+ let encoded: string;
60
+ try {
61
+ encoded = JSON.stringify(data);
62
+ } catch {
63
+ throw new PingRoomError('`data` must be JSON-serializable.', { code: 'invalid_data' });
64
+ }
65
+ const bytes = serverJsonByteLength(encoded);
66
+ if (bytes > MAX_DATA_BYTES) {
67
+ throw new PingRoomError(`\`data\` is too large (${bytes} bytes, max ${MAX_DATA_BYTES}).`, { code: 'invalid_data' });
68
+ }
69
+ }
70
+
71
+ export function assertActionNumber(n: unknown): void {
72
+ if (n === undefined || n === null) {
73
+ return;
74
+ }
75
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > 16) {
76
+ throw new PingRoomError('action_number must be an integer 1–16.', { code: 'invalid_request' });
77
+ }
78
+ }
79
+
80
+ const MAX_IDEMPOTENCY_KEY_LENGTH = 255;
81
+ /** PHP trim()'s default set: what the server strips from both ends of a key. */
82
+ const SERVER_TRIM = /^[ \t\n\r\0\x0B]+|[ \t\n\r\0\x0B]+$/g;
83
+
84
+ /**
85
+ * Normalize an idempotency key the way the server reads it
86
+ * (`RequestIdempotency::keyFrom`): trimmed, then 1–255 characters. It travels
87
+ * as the `Idempotency-Key` header, so it must also be printable ASCII (space
88
+ * included): fetch throws on a character outside Latin-1, which surfaced as a
89
+ * retryable `network_error` that every retry repeated, and a Latin-1 byte above
90
+ * 0x7E reaches the server as invalid UTF-8.
91
+ */
92
+ export function normalizeIdempotencyKey(value: unknown, field = 'idempotencyKey'): string {
93
+ if (typeof value !== 'string') {
94
+ throw new PingRoomError(`\`${field}\` must be a string.`, { code: 'invalid_request' });
95
+ }
96
+ const key = value.replace(SERVER_TRIM, '');
97
+ if (key.length < 1 || key.length > MAX_IDEMPOTENCY_KEY_LENGTH || !/^[\x20-\x7E]+$/.test(key)) {
98
+ throw new PingRoomError(
99
+ `\`${field}\` must be 1–${MAX_IDEMPOTENCY_KEY_LENGTH} printable ASCII characters (letters, digits, punctuation or spaces).`,
100
+ { code: 'invalid_request' },
101
+ );
102
+ }
103
+ return key;
104
+ }
105
+
106
+ export function requireNonEmpty(value: unknown, field: string): void {
107
+ if (typeof value !== 'string' || value.trim() === '') {
108
+ throw new PingRoomError(`\`${field}\` is required.`, { code: 'invalid_request' });
109
+ }
110
+ }
111
+
112
+ /** Validate before uppercasing so Unicode case expansion cannot become a code. */
113
+ export function normalizeRedeemCode(value: unknown): string {
114
+ if (typeof value !== 'string' || !/^[A-Za-z0-9]{12}$/.test(value.trim())) {
115
+ throw new PingRoomError('The code must contain exactly 12 letters or digits.', { code: 'invalid_request' });
116
+ }
117
+ return value.trim().toUpperCase();
118
+ }
119
+
120
+ /**
121
+ * The field must be sent, but may be empty — the server rule is `present`,
122
+ * not `required`. A quick action's `label` is the case: a Ping can be named by
123
+ * its emoji alone, so `''` is a deliberate value rather than a missing one.
124
+ */
125
+ export function requirePresentString(value: unknown, field: string): void {
126
+ if (typeof value !== 'string') {
127
+ throw new PingRoomError(`\`${field}\` must be a string.`, { code: 'invalid_request' });
128
+ }
129
+ }
@@ -0,0 +1,97 @@
1
+ // Request building shared by the PingRoom namespaces: path segments, bodies,
2
+ // the Idempotency-Key header, ping input checks and long-poll timing.
3
+
4
+ import { PingRoomError } from '../errors.js';
5
+ import {
6
+ assertActionNumber,
7
+ assertMaxLength,
8
+ assertStructuredData,
9
+ MAX_PING_TITLE_LENGTH,
10
+ MAX_PUBLIC_PING_MESSAGE_LENGTH,
11
+ normalizeIdempotencyKey,
12
+ requireNonEmpty,
13
+ } from './guards.js';
14
+ import type { PingInput } from '../types.js';
15
+
16
+ /** Extra wall-clock allowance over the server's hold window for long-poll calls. */
17
+ const LONG_POLL_BUFFER_MS = 10_000;
18
+ const DEFAULT_WAIT_SECONDS = 20;
19
+
20
+ /**
21
+ * Minimum spacing between the starts of two handoff-wait polls. The route is
22
+ * throttled at 30 requests a minute per human; a short or zero `timeout`
23
+ * would otherwise let one loop exhaust it.
24
+ */
25
+ export const MIN_HANDOFF_POLL_INTERVAL_MS = 2100;
26
+ /**
27
+ * Minimum spacing between the starts of two `listen()` polls after an empty
28
+ * answer. `notifications/wait` is throttled at 30 requests a minute per human,
29
+ * shared with every other listener on the account (the CLI, other agents); a
30
+ * zero hold or a proxy that answers early would otherwise spin the loop and
31
+ * keep that bucket exhausted. Same floor as the handoff wait.
32
+ */
33
+ export const MIN_LISTEN_POLL_INTERVAL_MS = MIN_HANDOFF_POLL_INTERVAL_MS;
34
+ /**
35
+ * Minimum spacing between the starts of two approval or question wait polls.
36
+ * The server normally holds each one, but a proxy or a zero hold that answers
37
+ * `pending` at once would otherwise spin the loop and keep that route's
38
+ * 30-per-minute bucket for the human exhausted. Same floor as the handoff wait and the
39
+ * CLI (2100 ms keeps one loop under 30 a minute).
40
+ */
41
+ export const MIN_WAIT_POLL_INTERVAL_MS = MIN_HANDOFF_POLL_INTERVAL_MS;
42
+
43
+ export function enc(segment: string): string {
44
+ // encodeURIComponent leaves `.` and `..` alone, and URL parsing then
45
+ // resolves them as dot segments: `live.get('AB12', '..')` would request the
46
+ // room itself. No id is empty or a dot segment, so refuse them.
47
+ if (segment === '' || segment === '.' || segment === '..') {
48
+ throw new PingRoomError('A path parameter may not be empty, `.` or `..`.', { code: 'invalid_request' });
49
+ }
50
+ return encodeURIComponent(segment);
51
+ }
52
+
53
+ /**
54
+ * Lift the SDK-only `idempotencyKey` out of an input and into the
55
+ * `Idempotency-Key` header the server reads. The server trims the key and
56
+ * accepts 1–255 characters; as a header it must also be printable ASCII.
57
+ */
58
+ export function splitIdempotencyKey<T extends { idempotencyKey?: string }>(
59
+ input: T,
60
+ ): { body: Omit<T, 'idempotencyKey'>; headers?: Record<string, string> } {
61
+ const { idempotencyKey, ...body } = input;
62
+ if (idempotencyKey === undefined) {
63
+ return { body };
64
+ }
65
+ return { body, headers: { 'Idempotency-Key': normalizeIdempotencyKey(idempotencyKey) } };
66
+ }
67
+
68
+ /** `onRetry`, when given, must be callable: a TypeError mid-outage would hide the real failure. */
69
+ export function assertOnRetry(onRetry: unknown): void {
70
+ if (onRetry !== undefined && typeof onRetry !== 'function') {
71
+ throw new PingRoomError('`onRetry` must be a function.', { code: 'invalid_request' });
72
+ }
73
+ }
74
+
75
+ export function dropUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
76
+ const out: Record<string, unknown> = {};
77
+ for (const [k, v] of Object.entries(obj)) {
78
+ if (v !== undefined) {
79
+ out[k] = v;
80
+ }
81
+ }
82
+ return out as Partial<T>;
83
+ }
84
+
85
+ export function assertPing(ping: PingInput): void {
86
+ requireNonEmpty(ping.message, 'message');
87
+ // The invite code does not expose room visibility. Permit the public-room
88
+ // ceiling locally; Laravel applies 120 for private and 160 for public rooms.
89
+ assertMaxLength(ping.message, MAX_PUBLIC_PING_MESSAGE_LENGTH, 'message');
90
+ assertMaxLength(ping.title, MAX_PING_TITLE_LENGTH, 'title');
91
+ assertActionNumber(ping.action_number);
92
+ assertStructuredData(ping.data);
93
+ }
94
+
95
+ export function waitTimeoutMs(seconds: number | undefined): number {
96
+ return (seconds ?? DEFAULT_WAIT_SECONDS) * 1000 + LONG_POLL_BUFFER_MS;
97
+ }
@@ -0,0 +1,157 @@
1
+ import { PingRoomError } from '../errors.js';
2
+ import { sleep, throwIfAborted } from './async.js';
3
+
4
+ /** First retry delay after a transient failure, in ms. */
5
+ export const TRANSIENT_BACKOFF_BASE_MS = 1000;
6
+ /**
7
+ * The smallest backoff base, whatever a caller asks for. A persistent failure
8
+ * would otherwise retry in a tight loop (a base of 0 never grows at all)
9
+ * against wait routes limited to 30 requests a minute each.
10
+ */
11
+ export const MIN_TRANSIENT_BACKOFF_MS = 250;
12
+ /** Ceiling on the exponential backoff (a server Retry-After may ask for longer). */
13
+ export const TRANSIENT_BACKOFF_CAP_MS = 30_000;
14
+ /** setTimeout fires immediately above this, so no delay may exceed it. */
15
+ const MAX_TIMER_MS = 2_147_483_647;
16
+ /**
17
+ * How long past the last-seen `expires_at` a wait keeps retrying an
18
+ * unreachable API. The server reports `expired` once expires_at passes, so
19
+ * after this the only outcome left is one that cannot be read. Covers clock
20
+ * skew. Same bound as the CLI.
21
+ */
22
+ export const WAIT_EXPIRY_GRACE_MS = 5 * 60_000;
23
+ /** The bound on an unbroken outage while no response has carried `expires_at`. */
24
+ export const WAIT_UNKNOWN_EXPIRY_OUTAGE_MS = 15 * 60_000;
25
+
26
+ /**
27
+ * A failure a read loop may retry: a dropped connection, a request deadline,
28
+ * a 429 (each wait route has its own 30/min bucket per human), or a 5xx.
29
+ * Everything else — 401/403/404/422, a refused redirect, a malformed response —
30
+ * is permanent and must surface to the caller.
31
+ */
32
+ export function isTransientError(error: unknown): error is PingRoomError {
33
+ return (
34
+ error instanceof PingRoomError
35
+ && (error.code === 'network_error'
36
+ || error.code === 'timeout'
37
+ || error.status === 429
38
+ || error.status >= 500)
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Delay before retrying after the `failures`-th consecutive transient failure:
44
+ * exponential from `baseMs`, capped at 30 s. A server `retryAfter` replaces the
45
+ * exponential step, but is never waited for less than `baseMs`. A `baseMs`
46
+ * below {@link MIN_TRANSIENT_BACKOFF_MS} is raised to it.
47
+ */
48
+ export function transientDelayMs(
49
+ error: PingRoomError,
50
+ failures: number,
51
+ baseMs: number = TRANSIENT_BACKOFF_BASE_MS,
52
+ ): number {
53
+ const requested = Number.isFinite(baseMs) && baseMs >= 0 ? baseMs : TRANSIENT_BACKOFF_BASE_MS;
54
+ const base = Math.max(MIN_TRANSIENT_BACKOFF_MS, requested);
55
+ const retryAfter = error.retryAfter;
56
+ if (typeof retryAfter === 'number' && Number.isFinite(retryAfter) && retryAfter >= 0) {
57
+ return Math.min(MAX_TIMER_MS, Math.max(retryAfter * 1000, base));
58
+ }
59
+ // Enough doublings to pass the cap from any base; also keeps 2 ** step finite.
60
+ const step = Math.min(Math.max(0, failures - 1), 30);
61
+ return Math.min(MAX_TIMER_MS, Math.max(base, TRANSIENT_BACKOFF_CAP_MS), base * 2 ** step);
62
+ }
63
+
64
+ export interface PollUntilSettledOptions<T> {
65
+ /** One bounded long-poll. Its errors are retried only when transient. */
66
+ poll: () => Promise<unknown>;
67
+ /** Check the response's minimal shape; throw `invalid_response` otherwise. */
68
+ validate: (value: unknown) => T;
69
+ /** True once the validated value is terminal. */
70
+ settled: (value: T) => boolean;
71
+ signal?: AbortSignal;
72
+ /** Minimum time between the starts of two polls, in ms. Default 0. */
73
+ minIntervalMs?: number;
74
+ /** Read `expires_at` (an ISO time) from a validated response; it bounds an outage. */
75
+ expiresAt?: (value: T) => unknown;
76
+ /** Names what is waited on in the give-up message, e.g. `approval a1`. */
77
+ what: string;
78
+ /** Called before each retry of a transient failure; `attempt` counts consecutive failures from 1. */
79
+ onRetry?: (error: PingRoomError, attempt: number) => void;
80
+ }
81
+
82
+ function timeOf(value: unknown): number | null {
83
+ const at = typeof value === 'string' ? Date.parse(value) : NaN;
84
+ return Number.isFinite(at) ? at : null;
85
+ }
86
+
87
+ function unavailable(last: PingRoomError, since: number, what: string): PingRoomError {
88
+ const reason = last.status > 0 ? `HTTP ${last.status}` : (last.code ?? 'network error');
89
+ return new PingRoomError(
90
+ `PingRoom unavailable since ${new Date(since).toISOString()} (${reason}); stopped waiting for ${what}.`,
91
+ { code: 'unavailable', cause: last },
92
+ );
93
+ }
94
+
95
+ /**
96
+ * The shared human-in-the-loop wait loop. Polls until a validated response is
97
+ * terminal; retries transient failures with backoff; rejects with the
98
+ * signal's reason once it aborts. A response that fails validation is never
99
+ * returned as an outcome.
100
+ *
101
+ * The retries are bounded even without a signal. Once only failures have come
102
+ * back since the last valid response, and the last-seen `expires_at` plus
103
+ * {@link WAIT_EXPIRY_GRACE_MS} has passed (or, before any `expires_at` was
104
+ * seen, after {@link WAIT_UNKNOWN_EXPIRY_OUTAGE_MS} of unbroken failures), the
105
+ * wait rejects with `unavailable`, the last failure as its `cause`.
106
+ */
107
+ export async function pollUntilSettled<T>(options: PollUntilSettledOptions<T>): Promise<T> {
108
+ const { poll, validate, settled, signal, minIntervalMs = 0, expiresAt, what, onRetry } = options;
109
+ let failures = 0;
110
+ let failingSince: number | null = null;
111
+ // expires_at plus grace, from the last valid response that carried one.
112
+ let deadline: number | null = null;
113
+ for (;;) {
114
+ throwIfAborted(signal);
115
+ const startedAt = Date.now();
116
+ let value: T;
117
+ try {
118
+ value = validate(await poll());
119
+ } catch (error) {
120
+ throwIfAborted(signal);
121
+ if (!isTransientError(error)) {
122
+ throw error;
123
+ }
124
+ failures += 1;
125
+ failingSince ??= Date.now();
126
+ const remaining = (deadline ?? failingSince + WAIT_UNKNOWN_EXPIRY_OUTAGE_MS) - Date.now();
127
+ if (remaining <= 0) {
128
+ throw unavailable(error, failingSince, what);
129
+ }
130
+ onRetry?.(error, failures);
131
+ // A fast failure still consumes a poll. Keep retries spaced even when a
132
+ // pending response between failures resets the exponential backoff.
133
+ const delay = Math.max(transientDelayMs(error, failures), minIntervalMs - (Date.now() - startedAt));
134
+ // Do not retry earlier than the interval or Retry-After just to fit the
135
+ // outage bound. Wait out the budget, then stop without another request.
136
+ if (delay > remaining) {
137
+ await sleep(remaining, signal);
138
+ throwIfAborted(signal);
139
+ throw unavailable(error, failingSince, what);
140
+ }
141
+ await sleep(delay, signal);
142
+ continue;
143
+ }
144
+ failures = 0;
145
+ failingSince = null;
146
+ const expiry = expiresAt ? timeOf(expiresAt(value)) : null;
147
+ if (expiry !== null) {
148
+ deadline = expiry + WAIT_EXPIRY_GRACE_MS;
149
+ }
150
+ if (settled(value)) {
151
+ return value;
152
+ }
153
+ // An injected transport can resolve pending polls immediately. Yield
154
+ // between them so timer-driven cancellation and deadlines can run.
155
+ await sleep(Math.max(0, minIntervalMs - (Date.now() - startedAt)), signal);
156
+ }
157
+ }
@@ -0,0 +1,78 @@
1
+ import { PingRoomError } from '../errors.js';
2
+
3
+ export function isLoopbackHost(hostname: string): boolean {
4
+ const h = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
5
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost');
6
+ }
7
+
8
+ /**
9
+ * Refuse to carry a bearer credential over plain http to a non-loopback host —
10
+ * a token on the wire over http is a token leaked. https always passes; http is
11
+ * allowed only to loopback dev hosts, or anywhere when `allowInsecure` is set.
12
+ */
13
+ export function assertSecureUrl(rawUrl: string, allowInsecure: boolean): URL {
14
+ let url: URL;
15
+ try {
16
+ url = new URL(rawUrl);
17
+ } catch {
18
+ // Never echo the input: an incoming-webhook URL carries its secret in the
19
+ // path, and error messages end up in logs.
20
+ throw new PingRoomError('Invalid URL (value withheld because it may contain a secret).', {
21
+ code: 'invalid_url',
22
+ });
23
+ }
24
+ if (url.protocol === 'https:') {
25
+ return url;
26
+ }
27
+ if (url.protocol === 'http:' && (allowInsecure || isLoopbackHost(url.hostname))) {
28
+ return url;
29
+ }
30
+ throw new PingRoomError(
31
+ `Refusing to send credentials over an insecure URL (${url.protocol}//${url.host}). ` +
32
+ 'Use https, or set allowInsecure: true for trusted local development.',
33
+ { code: 'insecure_url' },
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Strip a URL out of an error message, keeping only its origin. An
39
+ * incoming-webhook URL carries its secret in the path, and fetch
40
+ * implementations (node-fetch, for one) quote the requested URL in their
41
+ * errors, so a message copied from one must not reach a caller's logs as-is.
42
+ */
43
+ export function redactUrlInMessage(message: string, url: URL, rawUrl: string): string {
44
+ const withheld = `${url.origin}/[redacted]`;
45
+ let out = message;
46
+ for (const full of [rawUrl, url.href]) {
47
+ if (full) out = out.split(full).join(withheld);
48
+ }
49
+ for (const tail of [`${url.pathname}${url.search}${url.hash}`, url.pathname]) {
50
+ if (tail.length > 1) out = out.split(tail).join('/[redacted]');
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Every request goes out with `redirect: 'manual'` (Cloudflare Workers throw
57
+ * on `'error'`), so a redirect comes back as a response instead of being
58
+ * followed. Refuse it: a 307/308 would replay pairing secrets, uploaded bytes
59
+ * or a webhook body to whatever origin `Location` names. Browsers report a
60
+ * manual redirect as an `opaqueredirect` with status 0; other runtimes return
61
+ * the 3xx itself.
62
+ */
63
+ export function refuseRedirect(res: Response): void {
64
+ if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
65
+ // Release the body, best effort. A custom `fetch` such as node-fetch hands
66
+ // back a Node Readable with no cancel(); calling it threw a TypeError that
67
+ // surfaced as a retryable network_error instead of this refusal.
68
+ try {
69
+ (res.body?.cancel?.() as Promise<void> | undefined)?.catch?.(() => {});
70
+ } catch {
71
+ // Ignored: the refusal below is what matters.
72
+ }
73
+ throw new PingRoomError('Refusing to follow a redirect; credentials and request bodies are never forwarded.', {
74
+ code: 'redirect_refused',
75
+ status: res.status,
76
+ });
77
+ }
78
+ }
@@ -0,0 +1,42 @@
1
+ // Shape checks for server responses the wait loops and Agent Inbox trust.
2
+
3
+ import { PingRoomError } from '../errors.js';
4
+
5
+ export function isRecord(value: unknown): value is Record<string, unknown> {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ }
8
+
9
+ export function isNonEmptyString(value: unknown): value is string {
10
+ return typeof value === 'string' && value.trim() !== '';
11
+ }
12
+
13
+ export function isNullableString(value: unknown): value is string | null {
14
+ return value === null || typeof value === 'string';
15
+ }
16
+
17
+ /** UUIDs are case-insensitive; a caller may hold an id in either case. */
18
+ export function sameId(received: unknown, expected: string): boolean {
19
+ return typeof received === 'string' && received.toLowerCase() === expected.toLowerCase();
20
+ }
21
+
22
+ export function invalidWaitResponse(subject: string, expectedId: string, value: unknown, stateField: string): never {
23
+ throw new PingRoomError(`PingRoom returned an invalid ${subject} wait response.`, {
24
+ code: 'invalid_response',
25
+ body: {
26
+ expected_id: expectedId,
27
+ received_id: isRecord(value) && typeof value['id'] === 'string' ? value['id'] : null,
28
+ [stateField]: isRecord(value) && typeof value[stateField] === 'string' ? value[stateField] : null,
29
+ },
30
+ });
31
+ }
32
+
33
+ /**
34
+ * A 2xx the PingRoom API never sends: a gateway's or captive portal's page, or
35
+ * a URL that is not PingRoom's. The body is kept short, for inspection only.
36
+ */
37
+ export function invalidSuccessResponse(status: number, expected: string, body: unknown): PingRoomError {
38
+ return new PingRoomError(`PingRoom returned an invalid response (HTTP ${status}): expected ${expected}.`, {
39
+ code: 'invalid_response',
40
+ body: typeof body === 'string' ? body.slice(0, 512) : body,
41
+ });
42
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Link pings — make a ping a tappable button that opens a URL.
3
+ *
4
+ * The server convention is two reserved keys inside the structured `data`
5
+ * object: `url` (absolute http(s), <= 2048 chars) and `button_label`
6
+ * (<= 26 chars). This helper builds that fragment with client-side
7
+ * validation so a bad link fails fast instead of at the API boundary:
8
+ *
9
+ * await pr.broadcast('ab12cd', {
10
+ * message: 'Build 512 ready',
11
+ * data: { commit: 'abc123', ...linkPing({ url: 'https://ci.example.com/b/512', buttonLabel: 'Open build' }) },
12
+ * });
13
+ */
14
+
15
+ export interface LinkPingInput {
16
+ /** Absolute http(s) URL opened when the recipient taps the link button. Max 2048 chars. */
17
+ url: string;
18
+ /** Text on the link button. Max 26 chars. Defaults to a generic label on-device. */
19
+ buttonLabel?: string;
20
+ }
21
+
22
+ export interface LinkPingData {
23
+ url: string;
24
+ button_label?: string;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ const URL_MAX = 2048;
29
+ const LABEL_MAX = 26;
30
+
31
+ /** Count code points, as the server's mb_strlen does — an emoji is one character, not two. */
32
+ function characterLength(value: string): number {
33
+ return Array.from(value).length;
34
+ }
35
+
36
+ /** Build the `data` fragment for a link ping. Spread it into a ping's `data` object. */
37
+ export function linkPing(input: LinkPingInput): LinkPingData {
38
+ let parsed: URL;
39
+ try {
40
+ parsed = new URL(input.url);
41
+ } catch {
42
+ throw new TypeError('linkPing: url is not a valid URL');
43
+ }
44
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
45
+ throw new TypeError('linkPing: url must be an absolute http(s) URL');
46
+ }
47
+ if (characterLength(input.url) > URL_MAX) {
48
+ throw new TypeError(`linkPing: url must be at most ${URL_MAX} characters`);
49
+ }
50
+ if (input.buttonLabel !== undefined && characterLength(input.buttonLabel) > LABEL_MAX) {
51
+ throw new TypeError(`linkPing: buttonLabel must be at most ${LABEL_MAX} characters`);
52
+ }
53
+ const out: LinkPingData = { url: input.url };
54
+ if (input.buttonLabel !== undefined) out.button_label = input.buttonLabel;
55
+ return out;
56
+ }