@37signals/basecamp 0.9.0 → 0.11.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 (122) hide show
  1. package/README.md +147 -15
  2. package/dist/client.d.ts +8 -5
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +237 -144
  5. package/dist/client.js.map +1 -1
  6. package/dist/generated/metadata.d.ts.map +1 -1
  7. package/dist/generated/metadata.js +280 -12
  8. package/dist/generated/metadata.js.map +1 -1
  9. package/dist/generated/metadata.ts +280 -12
  10. package/dist/generated/openapi-stripped.json +4403 -2124
  11. package/dist/generated/path-mapping.d.ts.map +1 -1
  12. package/dist/generated/path-mapping.js +22 -5
  13. package/dist/generated/path-mapping.js.map +1 -1
  14. package/dist/generated/path-mapping.ts +22 -5
  15. package/dist/generated/schema.d.ts +2983 -1220
  16. package/dist/generated/services/boosts.d.ts +13 -10
  17. package/dist/generated/services/boosts.d.ts.map +1 -1
  18. package/dist/generated/services/boosts.js +5 -5
  19. package/dist/generated/services/boosts.js.map +1 -1
  20. package/dist/generated/services/boosts.ts +12 -10
  21. package/dist/generated/services/cards.d.ts +4 -4
  22. package/dist/generated/services/cards.d.ts.map +1 -1
  23. package/dist/generated/services/cards.js +2 -2
  24. package/dist/generated/services/cards.js.map +1 -1
  25. package/dist/generated/services/cards.ts +4 -4
  26. package/dist/generated/services/everything.d.ts +303 -0
  27. package/dist/generated/services/everything.d.ts.map +1 -0
  28. package/dist/generated/services/everything.js +359 -0
  29. package/dist/generated/services/everything.js.map +1 -0
  30. package/dist/generated/services/everything.ts +594 -0
  31. package/dist/generated/services/index.d.ts +2 -1
  32. package/dist/generated/services/index.d.ts.map +1 -1
  33. package/dist/generated/services/index.js +2 -1
  34. package/dist/generated/services/index.js.map +1 -1
  35. package/dist/generated/services/index.ts +2 -1
  36. package/dist/generated/services/message-types.d.ts +15 -10
  37. package/dist/generated/services/message-types.d.ts.map +1 -1
  38. package/dist/generated/services/message-types.js +35 -18
  39. package/dist/generated/services/message-types.js.map +1 -1
  40. package/dist/generated/services/message-types.ts +34 -18
  41. package/dist/generated/services/my-notifications.d.ts +27 -0
  42. package/dist/generated/services/my-notifications.d.ts.map +1 -1
  43. package/dist/generated/services/my-notifications.js +23 -1
  44. package/dist/generated/services/my-notifications.js.map +1 -1
  45. package/dist/generated/services/my-notifications.ts +46 -1
  46. package/dist/generated/services/recordings.d.ts +2 -2
  47. package/dist/generated/services/recordings.d.ts.map +1 -1
  48. package/dist/generated/services/recordings.js +1 -1
  49. package/dist/generated/services/recordings.js.map +1 -1
  50. package/dist/generated/services/recordings.ts +2 -2
  51. package/dist/generated/services/schedules.d.ts +9 -1
  52. package/dist/generated/services/schedules.d.ts.map +1 -1
  53. package/dist/generated/services/schedules.js.map +1 -1
  54. package/dist/generated/services/schedules.ts +9 -1
  55. package/dist/generated/services/tools.d.ts +2 -0
  56. package/dist/generated/services/tools.d.ts.map +1 -1
  57. package/dist/generated/services/tools.js +1 -0
  58. package/dist/generated/services/tools.js.map +1 -1
  59. package/dist/generated/services/tools.ts +3 -0
  60. package/dist/hooks.d.ts.map +1 -1
  61. package/dist/hooks.js +4 -1
  62. package/dist/hooks.js.map +1 -1
  63. package/dist/index.d.ts +9 -3
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +13 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/oauth/device-errors.d.ts +29 -0
  68. package/dist/oauth/device-errors.d.ts.map +1 -0
  69. package/dist/oauth/device-errors.js +52 -0
  70. package/dist/oauth/device-errors.js.map +1 -0
  71. package/dist/oauth/device-login.d.ts +50 -0
  72. package/dist/oauth/device-login.d.ts.map +1 -0
  73. package/dist/oauth/device-login.js +118 -0
  74. package/dist/oauth/device-login.js.map +1 -0
  75. package/dist/oauth/device.d.ts +112 -0
  76. package/dist/oauth/device.d.ts.map +1 -0
  77. package/dist/oauth/device.js +746 -0
  78. package/dist/oauth/device.js.map +1 -0
  79. package/dist/oauth/exchange.d.ts.map +1 -1
  80. package/dist/oauth/exchange.js +75 -9
  81. package/dist/oauth/exchange.js.map +1 -1
  82. package/dist/oauth/index.d.ts +4 -1
  83. package/dist/oauth/index.d.ts.map +1 -1
  84. package/dist/oauth/index.js +4 -0
  85. package/dist/oauth/index.js.map +1 -1
  86. package/dist/oauth/limits.d.ts +15 -0
  87. package/dist/oauth/limits.d.ts.map +1 -0
  88. package/dist/oauth/limits.js +15 -0
  89. package/dist/oauth/limits.js.map +1 -0
  90. package/dist/oauth/token-manager.d.ts.map +1 -1
  91. package/dist/oauth/token-manager.js +15 -4
  92. package/dist/oauth/token-manager.js.map +1 -1
  93. package/dist/oauth/token-store.d.ts.map +1 -1
  94. package/dist/oauth/token-store.js +6 -0
  95. package/dist/oauth/token-store.js.map +1 -1
  96. package/dist/oauth/types.d.ts +35 -3
  97. package/dist/oauth/types.d.ts.map +1 -1
  98. package/dist/pagination.d.ts +5 -1
  99. package/dist/pagination.d.ts.map +1 -1
  100. package/dist/pagination.js.map +1 -1
  101. package/dist/services/base.d.ts +3 -1
  102. package/dist/services/base.d.ts.map +1 -1
  103. package/dist/services/base.js +16 -5
  104. package/dist/services/base.js.map +1 -1
  105. package/dist/services/cards-extensions.d.ts +76 -0
  106. package/dist/services/cards-extensions.d.ts.map +1 -0
  107. package/dist/services/cards-extensions.js +67 -0
  108. package/dist/services/cards-extensions.js.map +1 -0
  109. package/package.json +2 -2
  110. package/src/generated/metadata.ts +280 -12
  111. package/src/generated/openapi-stripped.json +4403 -2124
  112. package/src/generated/path-mapping.ts +22 -5
  113. package/src/generated/schema.d.ts +2983 -1220
  114. package/src/generated/services/boosts.ts +12 -10
  115. package/src/generated/services/cards.ts +4 -4
  116. package/src/generated/services/everything.ts +594 -0
  117. package/src/generated/services/index.ts +2 -1
  118. package/src/generated/services/message-types.ts +34 -18
  119. package/src/generated/services/my-notifications.ts +46 -1
  120. package/src/generated/services/recordings.ts +2 -2
  121. package/src/generated/services/schedules.ts +9 -1
  122. package/src/generated/services/tools.ts +3 -0
@@ -0,0 +1,746 @@
1
+ /**
2
+ * RFC 8628 device authorization grant — request + poll.
3
+ *
4
+ * `requestDeviceAuthorization` obtains a device/user code pair;
5
+ * `pollDeviceToken` runs the §3.5 polling loop against the token endpoint. Both
6
+ * are TLS-guarded. The polling clock is monotonic and injectable for tests.
7
+ */
8
+ import { BasecampError, truncateErrorMessage } from "../errors.js";
9
+ import { requireSecureEndpoint } from "../security.js";
10
+ import { readBodyBounded } from "./discovery.js";
11
+ import { DeviceFlowError } from "./device-errors.js";
12
+ import { MAX_TOKEN_LIFETIME_SECONDS } from "./limits.js";
13
+ // Re-exported for existing importers; the declaration lives in limits.ts so
14
+ // non-device consumers need not pull this module.
15
+ export { MAX_TOKEN_LIFETIME_SECONDS };
16
+ /** URN grant type for the device authorization grant. */
17
+ export const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
18
+ /** Default polling interval when the server omits `interval` (RFC 8628 §3.2). */
19
+ const DEFAULT_INTERVAL_SECONDS = 5;
20
+ /**
21
+ * Upper bound (seconds) for `expires_in` / `interval`: 2_147_483 s (~24.8 days),
22
+ * the largest whole-second duration whose millisecond form (2_147_483_000 ms)
23
+ * fits a 32-bit signed timer. Beyond it a `setTimeout` silently clamps an
24
+ * out-of-range delay to 1 ms (hot poll loop) and deadline arithmetic can
25
+ * overflow. Far above any real device-code lifetime. Shared across all five SDKs
26
+ * (SPEC.md §16).
27
+ */
28
+ const MAX_DEVICE_SECONDS = 2_147_483;
29
+ /** Default per-request timeout (ms) for every device-flow HTTP round-trip. */
30
+ const DEFAULT_DEVICE_TIMEOUT_MS = 30_000;
31
+ /**
32
+ * Ceiling (ms) for a caller-supplied per-request timeout: the shared 3600 s
33
+ * bound (Go's maxDeviceRequestTimeout, Python's _MAX_DEVICE_REQUEST_TIMEOUT,
34
+ * Ruby's Fetcher::MAX_REQUEST_TIMEOUT). A large finite value — up to the
35
+ * ~24.8-day MAX_DEVICE_SECONDS timer bound this previously allowed — would
36
+ * hold a stalled request open for weeks, defeating the bounded-request
37
+ * guarantee.
38
+ */
39
+ const MAX_DEVICE_REQUEST_TIMEOUT_MS = 3600 * 1000;
40
+ /**
41
+ * Coerce a caller-supplied request timeout (ms) to a finite, positive, timer-safe
42
+ * value no greater than the shared ceiling. `setTimeout` silently coerces a
43
+ * non-finite delay (NaN/Infinity) or one beyond its 32-bit range to ~1 ms — an
44
+ * immediate abort that would masquerade as a `DeviceFlowError("transport")`
45
+ * (and, in the poll loop, as repeated timeout backoffs). Fall back to the
46
+ * default instead, mirroring how the other SDKs normalize an invalid device
47
+ * timeout.
48
+ */
49
+ function resolveDeviceTimeoutMs(timeoutMs) {
50
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_DEVICE_REQUEST_TIMEOUT_MS) {
51
+ return DEFAULT_DEVICE_TIMEOUT_MS;
52
+ }
53
+ // Whole milliseconds, at least 1: timers truncate fractional delays toward
54
+ // 0, so 0.5 would become an immediate abort.
55
+ return Math.max(1, Math.floor(timeoutMs));
56
+ }
57
+ /** slow_down bumps the interval by this many seconds, sustained (RFC 8628 §3.5). */
58
+ const SLOW_DOWN_INCREMENT_SECONDS = 5;
59
+ /** Cap on exponential backoff after connection timeouts. */
60
+ const MAX_BACKOFF_SECONDS = 60;
61
+ /** Cap on a device-auth / token response body (1 MiB) — these docs are tiny. */
62
+ const MAX_DEVICE_BODY_BYTES = 1 * 1024 * 1024;
63
+ /**
64
+ * Default monotonic clock (ms): `performance.now()`, which every supported
65
+ * Node/browser runtime provides. The `Date.now()` branch is a last resort for
66
+ * exotic runtimes without `performance` — wall-clock, so not strictly monotonic;
67
+ * inject a real monotonic clock there if NTP steps matter.
68
+ */
69
+ export const defaultClock = () => typeof performance !== "undefined" ? performance.now() : Date.now();
70
+ /**
71
+ * Validate one sample from an injected clock seam. `Number.isFinite` performs
72
+ * no coercion, so a bigint, string, or Symbol sample fails exactly like a
73
+ * NaN/Infinity one — the typed usage error, never a raw TypeError out of later
74
+ * deadline arithmetic. Shared by the poller and the login orchestrator so both
75
+ * validate EVERY sample the same way.
76
+ */
77
+ export function validatedClockSample(sample, entry) {
78
+ if (typeof sample !== "number" || !Number.isFinite(sample)) {
79
+ throw new BasecampError("usage", `${entry}: clock must return a finite number of milliseconds`);
80
+ }
81
+ return sample;
82
+ }
83
+ /**
84
+ * Requests a device/user code pair (RFC 8628 §3.1–3.2).
85
+ *
86
+ * @throws DeviceFlowError("cancelled") when the signal aborts;
87
+ * DeviceFlowError("transport") on a network failure; BasecampError on
88
+ * validation / non-2xx.
89
+ */
90
+ export async function requestDeviceAuthorization(params) {
91
+ const { deviceAuthorizationEndpoint, clientId, scope, fetch: customFetch = globalThis.fetch, timeoutMs = DEFAULT_DEVICE_TIMEOUT_MS, signal } = params;
92
+ // An already-aborted signal makes no request at all — reject before the
93
+ // fetch path so a direct caller cannot send (or a recording customFetch
94
+ // observe) a device-code request post-cancellation. performDeviceLogin
95
+ // checks at its own entry too; this covers direct callers.
96
+ throwIfAborted(signal);
97
+ requireSecureEndpoint(deviceAuthorizationEndpoint, "device authorization endpoint");
98
+ if (!clientId) {
99
+ throw new BasecampError("validation", "Client ID is required for device authorization");
100
+ }
101
+ const body = new URLSearchParams();
102
+ body.set("client_id", clientId);
103
+ // Omit scope entirely when unset so the server applies its default (`read`).
104
+ if (scope)
105
+ body.set("scope", scope);
106
+ const controller = new AbortController();
107
+ const timeoutId = setTimeout(() => controller.abort(), resolveDeviceTimeoutMs(timeoutMs));
108
+ // Chain the caller's cancellation into the request like postDeviceToken:
109
+ // an already-aborted signal must not fire the POST at all, and an abort
110
+ // mid-request surfaces as the contractual cancelled — never transport.
111
+ const onAbort = () => controller.abort();
112
+ signal?.addEventListener("abort", onAbort, { once: true });
113
+ if (signal?.aborted)
114
+ controller.abort();
115
+ let response;
116
+ let text;
117
+ try {
118
+ // The whole round trip runs INSIDE a race against the controller's signal:
119
+ // a cooperative fetch rejects on abort anyway, but a custom fetch that
120
+ // ignores its AbortSignal must not hold this call past its timeout or hand
121
+ // back a code pair after cancellation — the race rejects the moment the
122
+ // timeout or the caller's abort fires, and a late settlement is discarded.
123
+ ({ response, text } = await raceAbort(controller.signal, async () => {
124
+ let raced;
125
+ try {
126
+ raced = await customFetch(deviceAuthorizationEndpoint, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
129
+ body: body.toString(),
130
+ signal: controller.signal,
131
+ // Never chase an attacker-influenced Location: a 3xx surfaces below as a
132
+ // non-2xx api_error rather than a followed request.
133
+ redirect: "manual",
134
+ });
135
+ }
136
+ catch (err) {
137
+ if (isAbort(err) && signal?.aborted) {
138
+ throw new DeviceFlowError("cancelled", "Device flow cancelled");
139
+ }
140
+ throw new DeviceFlowError("transport", `Device authorization request failed: ${errMessage(err)}`, {
141
+ cause: err instanceof Error ? err : undefined,
142
+ });
143
+ }
144
+ // A non-2xx (including a suppressed 3xx) is a hard failure whose body is
145
+ // unused, so surface it by status BEFORE draining the body. Otherwise a
146
+ // slow/never-ending error body could time out mid-read and be misclassified
147
+ // as a retryable transport failure instead of the api_error it is.
148
+ if (raced.status < 200 || raced.status >= 300) {
149
+ // Release the unread stream (non-blocking) so repeated failures don't retain
150
+ // sockets / connection-pool resources; the status error surfaces immediately.
151
+ void raced.body?.cancel().catch(() => { });
152
+ throw new BasecampError("api_error", `Device authorization failed with status ${raced.status}`, {
153
+ httpStatus: raced.status,
154
+ });
155
+ }
156
+ // Bounded/streaming read: an oversized body aborts before it is fully
157
+ // buffered. The abort timer stays armed until the read completes, so a
158
+ // stalled response STREAM times out just like a stalled request; an
159
+ // oversized body is already api_error, and any other stream failure
160
+ // (including the timeout's AbortError) maps to transport rather than
161
+ // escaping raw.
162
+ let racedText;
163
+ try {
164
+ racedText = await readBodyBounded(raced, MAX_DEVICE_BODY_BYTES, "device authorization");
165
+ }
166
+ catch (err) {
167
+ if (err instanceof BasecampError)
168
+ throw err;
169
+ if (isAbort(err) && signal?.aborted) {
170
+ throw new DeviceFlowError("cancelled", "Device flow cancelled");
171
+ }
172
+ throw new DeviceFlowError("transport", `Device authorization response read failed: ${errMessage(err)}`, {
173
+ cause: err instanceof Error ? err : undefined,
174
+ });
175
+ }
176
+ return { response: raced, text: racedText };
177
+ }));
178
+ }
179
+ catch (err) {
180
+ // Already-classified failures from the raced work pass through; a raw
181
+ // AbortError here means the race itself lost — the caller aborted, or the
182
+ // timeout fired while a non-cooperative fetch refused to settle.
183
+ if (err instanceof DeviceFlowError || err instanceof BasecampError)
184
+ throw err;
185
+ if (isAbort(err) && signal?.aborted) {
186
+ throw new DeviceFlowError("cancelled", "Device flow cancelled");
187
+ }
188
+ if (isAbort(err)) {
189
+ throw new DeviceFlowError("transport", "Device authorization request failed: timed out");
190
+ }
191
+ throw err;
192
+ }
193
+ finally {
194
+ clearTimeout(timeoutId);
195
+ signal?.removeEventListener("abort", onAbort);
196
+ }
197
+ let data;
198
+ try {
199
+ data = JSON.parse(text);
200
+ }
201
+ catch {
202
+ throw new BasecampError("api_error", "Failed to parse device authorization response", {
203
+ httpStatus: response.status,
204
+ });
205
+ }
206
+ // A valid-JSON-but-non-object body (null, array, number, string) is malformed —
207
+ // fail as api_error before any property deref, never a raw TypeError.
208
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
209
+ throw new BasecampError("api_error", "Device authorization response is not a JSON object", {
210
+ httpStatus: response.status,
211
+ });
212
+ }
213
+ // Re-check after the round-trip: a custom fetch that ignores its
214
+ // AbortSignal can complete after the abort, and a direct caller must get
215
+ // cancelled — never a code minted post-cancellation. (performDeviceLogin
216
+ // re-checks too; this covers direct callers.)
217
+ throwIfAborted(signal);
218
+ return validateDeviceAuthorization(data, response.status);
219
+ }
220
+ /**
221
+ * True iff `value` is a positive integer. Rejects fractional numbers, booleans
222
+ * (`typeof true === "boolean"`), NaN, Infinity, and undefined — narrowing to
223
+ * `number` so validated fields carry a definite type downstream.
224
+ */
225
+ function isPositiveInteger(value) {
226
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
227
+ }
228
+ /** True iff `value` is a non-empty string. Rejects numbers/booleans/null so a
229
+ * malformed server response can't smuggle a non-string code/URI past validation. */
230
+ function isNonEmptyString(value) {
231
+ return typeof value === "string" && value.length > 0;
232
+ }
233
+ function validateDeviceAuthorization(data, status) {
234
+ // Every validation error carries the (2xx) status so a malformed success body
235
+ // is diagnosable as such — uniform with the token-poll raises and the other SDKs.
236
+ // Type-check, not just truthiness: a JSON number is truthy but is not a usable
237
+ // code/URI, so it must fail as api_error rather than flow into the poll loop.
238
+ if (!isNonEmptyString(data.device_code) ||
239
+ !isNonEmptyString(data.user_code) ||
240
+ !isNonEmptyString(data.verification_uri)) {
241
+ throw new BasecampError("api_error", "Invalid device authorization response: missing or non-string required fields", {
242
+ httpStatus: status,
243
+ });
244
+ }
245
+ // expires_in drives a monotonic deadline; interval drives poll delays. Both
246
+ // must be positive integers within MAX_DEVICE_SECONDS — a fractional value
247
+ // (0.5) yields a sub-second delay and a huge value overflows the ms timer
248
+ // (Node clamps an out-of-range setTimeout to 1 ms → hot poll loop). Booleans
249
+ // and non-int-valued numbers are likewise rejected.
250
+ if (!isPositiveInteger(data.expires_in) || data.expires_in > MAX_DEVICE_SECONDS) {
251
+ throw new BasecampError("api_error", `Invalid device authorization response: expires_in must be a positive integer no greater than ${MAX_DEVICE_SECONDS}`, { httpStatus: status });
252
+ }
253
+ let interval = DEFAULT_INTERVAL_SECONDS;
254
+ // A JSON `null` interval is treated as ABSENT (cross-SDK contract: the Go and
255
+ // Kotlin decoders cannot distinguish null from absent), so it takes the default.
256
+ if (data.interval !== undefined && data.interval !== null) {
257
+ if (!isPositiveInteger(data.interval) || data.interval > MAX_DEVICE_SECONDS) {
258
+ throw new BasecampError("api_error", `Invalid device authorization response: interval must be a positive integer no greater than ${MAX_DEVICE_SECONDS}`, { httpStatus: status });
259
+ }
260
+ interval = data.interval;
261
+ }
262
+ // Optional; when present it must be a string. A JSON `null` is treated as
263
+ // ABSENT (cross-SDK contract: the Go and Kotlin decoders cannot distinguish
264
+ // null from absent) and normalized to undefined below — a non-string value
265
+ // (number/array) is still rejected as a malformed shape.
266
+ if (data.verification_uri_complete !== undefined &&
267
+ data.verification_uri_complete !== null &&
268
+ typeof data.verification_uri_complete !== "string") {
269
+ throw new BasecampError("api_error", "Invalid device authorization response: verification_uri_complete must be a string", {
270
+ httpStatus: status,
271
+ });
272
+ }
273
+ return {
274
+ deviceCode: data.device_code,
275
+ userCode: data.user_code,
276
+ verificationUri: data.verification_uri,
277
+ verificationUriComplete: data.verification_uri_complete ?? undefined,
278
+ expiresIn: data.expires_in,
279
+ interval,
280
+ };
281
+ }
282
+ /**
283
+ * Polls the token endpoint until the user approves, denies, or the codes expire
284
+ * (RFC 8628 §3.4–3.5). Handles authorization_pending, sustained slow_down (+5s),
285
+ * a monotonic expiry deadline, exponential backoff on connection timeouts, and
286
+ * cooperative cancellation.
287
+ */
288
+ export async function pollDeviceToken(params) {
289
+ const { tokenEndpoint, clientId, deviceCode, expiresIn, signal, clock = defaultClock, fetch: customFetch = globalThis.fetch, timeoutMs = DEFAULT_DEVICE_TIMEOUT_MS, sleepFn = sleep, } = params;
290
+ requireSecureEndpoint(tokenEndpoint, "token endpoint");
291
+ // Caller-input sanity (usage, not the RFC response validation): a non-finite
292
+ // or oversized duration builds a broken deadline or an unschedulable wait.
293
+ // expiresIn MAY be fractional — performDeviceLogin legitimately passes a
294
+ // fractional remaining lifetime after deducting display-hook time — but the
295
+ // polling interval is whole seconds (RFC 8628), matching the response
296
+ // validation and the integer-typed Go/Kotlin APIs: a fractional interval
297
+ // (0.001) would otherwise permit ~1000 polls per second.
298
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > MAX_DEVICE_SECONDS) {
299
+ throw new BasecampError("usage", `pollDeviceToken: expiresIn must be a positive number of seconds no greater than ${MAX_DEVICE_SECONDS}`);
300
+ }
301
+ if (!Number.isInteger(params.interval) || params.interval <= 0 || params.interval > MAX_DEVICE_SECONDS) {
302
+ throw new BasecampError("usage", `pollDeviceToken: interval must be a positive whole number of seconds no greater than ${MAX_DEVICE_SECONDS}`);
303
+ }
304
+ // Same caller-input sanity for the optional absolute deadline: Infinity polls
305
+ // forever, NaN defeats every deadline comparison and wait clamp, and a
306
+ // deadline later than the VALIDATED expiresIn would extend polling past the
307
+ // server-issued code lifetime. The bound is expiresIn from now — the
308
+ // issuance-anchored deadline performDeviceLogin passes is always at or
309
+ // before it (the clock only advances after issuance). A deadline at/below
310
+ // "now" is legal — it surfaces as device_flow_expired, matching an
311
+ // issuance-anchored deadline fully consumed by a slow display hook.
312
+ // EVERY sample of the injected clock is validated, not just the first: a
313
+ // NaN/Infinity sample poisons the deadline math, and setTimeout(NaN)
314
+ // coerces to 0 — a tight poll loop instead of a fast usage failure. The
315
+ // wrapper preserves scripted-clock step counts (one underlying call per
316
+ // sample).
317
+ const safeClock = () => validatedClockSample(clock(), "pollDeviceToken");
318
+ const nowMs = safeClock();
319
+ if (params.deadlineAtMs !== undefined &&
320
+ (!Number.isFinite(params.deadlineAtMs) || params.deadlineAtMs > nowMs + expiresIn * 1000)) {
321
+ throw new BasecampError("usage", "pollDeviceToken: deadlineAtMs must be a finite timestamp no later than expiresIn seconds from now");
322
+ }
323
+ // Normalize the per-request timeout ONCE at entry: the remaining-lifetime
324
+ // clamp below takes min() against it, and an invalid runtime value (NaN,
325
+ // Infinity, non-positive) would otherwise poison the min into the 30s
326
+ // default INSIDE postDeviceToken — letting a near-expiry poll run the full
327
+ // default budget past the deadline.
328
+ const effectiveTimeoutMs = resolveDeviceTimeoutMs(timeoutMs);
329
+ // Server-driven poll interval (initial + sustained slow_down bumps), tracked
330
+ // SEPARATELY from the transient-timeout backoff: the wait is the larger of the
331
+ // two, so intermittent timeouts never permanently inflate the poll cadence.
332
+ let intervalSeconds = params.interval;
333
+ let backoffSeconds = intervalSeconds;
334
+ // One-shot next-wait override from a 429 too_many_requests Retry-After
335
+ // (SPEC §16): consumed by the next wait, never inflating the slow_down
336
+ // interval. 0 = none.
337
+ let overrideWaitSeconds = 0;
338
+ const deadline = params.deadlineAtMs ?? nowMs + expiresIn * 1000;
339
+ const body = new URLSearchParams();
340
+ body.set("grant_type", DEVICE_CODE_GRANT_TYPE);
341
+ body.set("device_code", deviceCode);
342
+ body.set("client_id", clientId);
343
+ for (;;) {
344
+ throwIfAborted(signal);
345
+ // Read the clock ONCE per iteration and reuse it for both the deadline check
346
+ // and the remaining-lifetime clamp: two separate reads could straddle the
347
+ // deadline and yield a negative wait for the (possibly injected) sleep seam.
348
+ const now = safeClock();
349
+ // Check the deadline before sleeping so a long display hook, a stalled prior
350
+ // request, or a long backoff cannot carry us past expiry undetected.
351
+ // Sub-millisecond remainders count as expired: performance.now() is
352
+ // fractional, and a <1ms residue would truncate into 0ms timers — a tight
353
+ // loop right at expiry.
354
+ if (deadline - now < 1) {
355
+ throw new DeviceFlowError("expired", "Device code expired before authorization completed");
356
+ }
357
+ // Wait the larger of the server interval and the timeout backoff, clamped
358
+ // to the remaining lifetime (guaranteed >= 1ms here) so the wait never
359
+ // overshoots the monotonic deadline. FLOOR to whole milliseconds: ceil
360
+ // would round a fractional remainder past the deadline, and the <1ms
361
+ // guard above keeps the floor at >= 1.
362
+ const remainingMs = deadline - now;
363
+ // max(1, floor(...)): floor stays inside the remaining lifetime, and the
364
+ // 1ms floor keeps a caller-supplied sub-millisecond interval from
365
+ // degrading into a 0ms hot loop.
366
+ const waitMs = Math.max(1, Math.floor(Math.min(Math.max(intervalSeconds, backoffSeconds, overrideWaitSeconds) * 1000, remainingMs)));
367
+ overrideWaitSeconds = 0; // one-shot: consumed by this wait, then gone
368
+ try {
369
+ // Race the injected sleep against the signal: a custom sleepFn that
370
+ // ignores its signal argument must not hold a cancelled poll open until
371
+ // its timer fires (or forever, for a never-settling seam).
372
+ await (signal ? raceAbort(signal, () => sleepFn(waitMs, signal)) : sleepFn(waitMs, signal));
373
+ }
374
+ catch (err) {
375
+ // The caller aborted the signal mid-wait: surface the contractual
376
+ // cancellation, never let a raw AbortError/DOMException escape.
377
+ if (isAbort(err)) {
378
+ throw new DeviceFlowError("cancelled", "Device flow cancelled");
379
+ }
380
+ throw err;
381
+ }
382
+ const postRemainingMs = deadline - safeClock();
383
+ if (postRemainingMs < 1) {
384
+ throw new DeviceFlowError("expired", "Device code expired before authorization completed");
385
+ }
386
+ let result;
387
+ try {
388
+ // Bound the request by the REMAINING code lifetime as well as the
389
+ // per-request timeout: near expiry, a stalled token POST must not hold
390
+ // the flow past the monotonic deadline for the full request budget.
391
+ result = await postDeviceToken(tokenEndpoint, body, customFetch, Math.min(effectiveTimeoutMs, postRemainingMs), signal);
392
+ }
393
+ catch (err) {
394
+ if (isAbort(err) && signal?.aborted) {
395
+ throw new DeviceFlowError("cancelled", "Device flow cancelled");
396
+ }
397
+ if (isAbort(err)) {
398
+ // Our own per-request timeout fired → connection timeout: back off and
399
+ // retry. The server interval is left untouched so recovery is instant.
400
+ backoffSeconds = Math.min(backoffSeconds * 2, MAX_BACKOFF_SECONDS);
401
+ continue;
402
+ }
403
+ // An already-typed BasecampError (e.g. a malformed 2xx token response, an
404
+ // oversized body, or a redirect) is a server/API fault — propagate it
405
+ // unchanged rather than mislabeling it a retryable transport failure.
406
+ if (err instanceof BasecampError)
407
+ throw err;
408
+ // Any other transport failure ends the flow.
409
+ throw new DeviceFlowError("transport", `Device token poll failed: ${errMessage(err)}`, {
410
+ cause: err instanceof Error ? err : undefined,
411
+ });
412
+ }
413
+ // ANY completed HTTP round-trip (token, authorization_pending, slow_down,
414
+ // other OAuth error) resets the timeout backoff to the server interval.
415
+ backoffSeconds = intervalSeconds;
416
+ // A custom fetch that ignores its AbortSignal can complete a 200 after the
417
+ // caller aborted: re-check before handing back the credential — never a
418
+ // token returned after the caller asked to stop (Go's success branch does
419
+ // the same via ctx.Err()).
420
+ if (result.kind === "token") {
421
+ throwIfAborted(signal);
422
+ return result.token;
423
+ }
424
+ switch (result.error) {
425
+ case "authorization_pending":
426
+ continue;
427
+ case "too_many_requests":
428
+ // Retryable ONLY as the exact 429 + too_many_requests pair (SPEC §16).
429
+ // The next wait honors a positive integral Retry-After delta via a
430
+ // one-shot max(interval, Retry-After) override — a missing/malformed
431
+ // header falls back to the current interval, and the override decays
432
+ // after one wait.
433
+ if (result.status !== 429) {
434
+ throw new BasecampError("api_error", `Device token request failed: ${result.error}`, {
435
+ httpStatus: result.status,
436
+ });
437
+ }
438
+ overrideWaitSeconds = parseRetryAfterSeconds(result.retryAfter);
439
+ continue;
440
+ case "slow_down":
441
+ intervalSeconds += SLOW_DOWN_INCREMENT_SECONDS;
442
+ backoffSeconds = intervalSeconds;
443
+ continue;
444
+ case "access_denied":
445
+ throw new DeviceFlowError("access_denied", "The authorization request was denied");
446
+ case "expired_token":
447
+ throw new DeviceFlowError("expired", "Device code expired before authorization completed");
448
+ default:
449
+ throw new BasecampError("api_error", `Device token request failed: ${result.error}`, {
450
+ httpStatus: result.status,
451
+ });
452
+ }
453
+ }
454
+ }
455
+ /**
456
+ * Validates a Retry-After delta for the 429 poll contract (SPEC §16): a
457
+ * positive integral number of seconds. A representable delta beyond
458
+ * MAX_DEVICE_SECONDS (the shared 32-bit-ms timer bound) CLAMPS to the
459
+ * ceiling — the wait rule clips to the remaining code lifetime, honoring the
460
+ * throttle. Anything else — missing, an HTTP-date, fractional, non-positive,
461
+ * or unrepresentable (beyond 2^53) — returns 0 so the caller falls back to
462
+ * the current interval. Trimming is ASCII SP/HTAB only (RFC 9110
463
+ * OWS) — NOT String.prototype.trim(), whose Unicode whitespace (NBSP above
464
+ * all) would trim a malformed value into validity.
465
+ */
466
+ export function parseRetryAfterSeconds(header) {
467
+ if (!header)
468
+ return 0;
469
+ const trimmed = header.replace(/^[ \t]+|[ \t]+$/g, "");
470
+ if (!/^\d+$/.test(trimmed))
471
+ return 0;
472
+ // The shared 10-significant-digit bound (Python/Ruby mirror it; Go/Kotlin
473
+ // get it from bounded int parses): strip leading zeros first so a padded
474
+ // in-range delta ("00000000030") is honored, then treat longer strings as
475
+ // unrepresentable — interval fallback — instead of feeding parseInt an
476
+ // unbounded digit string. Comfortably covers MAX_DEVICE_SECONDS (7 digits).
477
+ const significant = trimmed.replace(/^0+/, "") || "0";
478
+ if (significant.length > 10)
479
+ return 0;
480
+ const parsed = parseInt(significant, 10);
481
+ // Safe-integer, not merely integer: parseInt("9".repeat(20)) yields an
482
+ // integer-valued double past 2^53 — unrepresentable → interval fallback. A
483
+ // representable delta beyond the shared device ceiling CLAMPS instead: the
484
+ // wait rule clamps to the remaining code lifetime anyway, so an over-ceiling
485
+ // throttle waits out the rest of the lifetime rather than resending before
486
+ // the server's throttle.
487
+ if (!Number.isSafeInteger(parsed) || parsed <= 0)
488
+ return 0;
489
+ return Math.min(parsed, MAX_DEVICE_SECONDS);
490
+ }
491
+ async function postDeviceToken(tokenEndpoint, body, customFetch, timeoutMs, signal) {
492
+ const controller = new AbortController();
493
+ const timeoutId = setTimeout(() => controller.abort(), resolveDeviceTimeoutMs(timeoutMs));
494
+ const onAbort = () => controller.abort();
495
+ signal?.addEventListener("abort", onAbort, { once: true });
496
+ // If the signal was ALREADY aborted, the "abort" event has fired and the
497
+ // once-listener never runs — abort the controller directly so the fetch below
498
+ // does not proceed (and even return a token) after cancellation.
499
+ if (signal?.aborted)
500
+ controller.abort();
501
+ try {
502
+ // The whole round trip runs INSIDE a race against the controller's signal
503
+ // (which fires on the per-request timeout AND the caller's abort): a custom
504
+ // fetch that ignores its AbortSignal must not hold the poll past its
505
+ // timeout or hand back a token after cancellation. The race's AbortError
506
+ // classifies in the poll loop exactly like a cooperative fetch's — caller
507
+ // abort → cancelled, timeout → transient backoff.
508
+ return await raceAbort(controller.signal, async () => {
509
+ const response = await customFetch(tokenEndpoint, {
510
+ method: "POST",
511
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
512
+ body: body.toString(),
513
+ signal: controller.signal,
514
+ // Never chase a Location: a redirected token poll is treated as an
515
+ // api_error below rather than followed.
516
+ redirect: "manual",
517
+ });
518
+ // A suppressed 3xx is never a valid OAuth response and its body is unused —
519
+ // fail by status BEFORE draining the body. Otherwise a redirect that slowly
520
+ // streams its body could time out mid-read and be misclassified as a
521
+ // connection timeout, which the poll loop would back off and retry (until the
522
+ // device code expires) instead of surfacing the api_error now.
523
+ if (response.status >= 300 && response.status < 400) {
524
+ // Release the unread stream (non-blocking) so a redirecting endpoint under a
525
+ // long poll can't retain sockets / connection-pool resources.
526
+ void response.body?.cancel().catch(() => { });
527
+ throw new BasecampError("api_error", `Device token endpoint returned redirect status ${response.status}`, {
528
+ httpStatus: response.status,
529
+ });
530
+ }
531
+ // Every remaining status outside 200 and 4xx is terminal WITHOUT its body
532
+ // (only a 200 carries the token and only a 4xx the OAuth error code) —
533
+ // classify it before the read, like the 3xx above, so a 201/500 that
534
+ // stalls while streaming its body cannot abort mid-read and be retried as
535
+ // a transient timeout until the code expires.
536
+ if (response.status !== 200 && !(response.status >= 400 && response.status < 500)) {
537
+ void response.body?.cancel().catch(() => { });
538
+ throw new BasecampError("api_error", `Device token request failed with status ${response.status}`, {
539
+ httpStatus: response.status,
540
+ });
541
+ }
542
+ // Bounded/streaming read: an oversized body aborts before it is fully buffered.
543
+ // A 4xx still reads the body — that is how authorization_pending/slow_down and
544
+ // other OAuth errors are carried.
545
+ const text = await readBodyBounded(response, MAX_DEVICE_BODY_BYTES, "device token");
546
+ let parsed;
547
+ try {
548
+ parsed = JSON.parse(text);
549
+ }
550
+ catch {
551
+ throw new BasecampError("api_error", "Failed to parse device token response", {
552
+ httpStatus: response.status,
553
+ });
554
+ }
555
+ // A valid-JSON-but-non-object body (null, array, number, string) is a
556
+ // malformed OAuth response — fail as api_error before any property deref,
557
+ // never a raw crash on `data.access_token`/`data.error`.
558
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
559
+ throw new BasecampError("api_error", "Device token response is not a JSON object", {
560
+ httpStatus: response.status,
561
+ });
562
+ }
563
+ const data = parsed;
564
+ // Exactly HTTP 200, not response.ok (any 2xx): RFC 8628/6749 token
565
+ // responses are 200, and SPEC §16 pins the contract. A nonstandard 201/202
566
+ // carrying an access_token must not prematurely complete polling — it
567
+ // falls through to the OAuth-error path and terminates as api_error.
568
+ if (response.status === 200) {
569
+ const token = data;
570
+ // Non-empty string, not merely truthy: a numeric access_token is not a
571
+ // usable credential and must fail as api_error, not be returned downstream.
572
+ if (!isNonEmptyString(token.access_token)) {
573
+ throw new BasecampError("api_error", "Device token response missing or non-string access_token", {
574
+ httpStatus: response.status,
575
+ });
576
+ }
577
+ // expires_in is optional (RFC 6749 §5.1), but when present it must be a
578
+ // finite positive WHOLE number within MAX_TOKEN_LIFETIME_SECONDS. A
579
+ // non-finite value (1e400 → Infinity) or a very large finite one would flow
580
+ // into Date arithmetic and yield an Invalid Date whose getTime() is NaN, so
581
+ // expiry checks downstream would treat the token as never expiring. Whole
582
+ // seconds match the device-duration rule — every SDK validates the decoded
583
+ // numeric value explicitly to reject a fractional lifetime; an
584
+ // integer-valued float (3600.0) is still accepted.
585
+ if (token.expires_in != null &&
586
+ (typeof token.expires_in !== "number" || !Number.isInteger(token.expires_in) ||
587
+ token.expires_in <= 0 || token.expires_in > MAX_TOKEN_LIFETIME_SECONDS)) {
588
+ throw new BasecampError("api_error", `Device token response expires_in must be a finite positive whole number no greater than ${MAX_TOKEN_LIFETIME_SECONDS} seconds`, { httpStatus: response.status });
589
+ }
590
+ // token_type/refresh_token/scope are optional strings — a non-string value
591
+ // is a malformed response, not a usable credential field.
592
+ if (token.token_type != null && !isNonEmptyString(token.token_type)) {
593
+ throw new BasecampError("api_error", "Device token response token_type must be a non-empty string", {
594
+ httpStatus: response.status,
595
+ });
596
+ }
597
+ if (token.refresh_token != null && typeof token.refresh_token !== "string") {
598
+ throw new BasecampError("api_error", "Device token response refresh_token must be a string", {
599
+ httpStatus: response.status,
600
+ });
601
+ }
602
+ if (token.scope != null && typeof token.scope !== "string") {
603
+ throw new BasecampError("api_error", "Device token response scope must be a string", {
604
+ httpStatus: response.status,
605
+ });
606
+ }
607
+ // resource: absent and JSON null are unset; when present it must be a
608
+ // non-empty string (SPEC §16) — an empty binding is not a binding.
609
+ if (token.resource != null && !isNonEmptyString(token.resource)) {
610
+ throw new BasecampError("api_error", "Device token response resource must be a non-empty string when present", {
611
+ httpStatus: response.status,
612
+ });
613
+ }
614
+ return {
615
+ kind: "token",
616
+ token: {
617
+ accessToken: token.access_token,
618
+ // `?? undefined`: validation admits JSON null (absent per SPEC),
619
+ // but the public type is `string | undefined` — never leak null.
620
+ refreshToken: token.refresh_token ?? undefined,
621
+ tokenType: token.token_type || "Bearer",
622
+ expiresIn: token.expires_in ?? undefined,
623
+ expiresAt: token.expires_in != null ? new Date(Date.now() + token.expires_in * 1000) : undefined,
624
+ scope: token.scope ?? undefined,
625
+ resource: token.resource ?? undefined,
626
+ },
627
+ };
628
+ }
629
+ // Recognize OAuth protocol error codes ONLY on a 4xx (RFC 8628 §3.5 error
630
+ // responses are 400-class): a nonstandard 2xx (201/202) or a 5xx carrying
631
+ // a crafted authorization_pending body must not keep the loop polling —
632
+ // only a 200 can produce a token and only a 4xx a protocol state. The
633
+ // `error` must also be a non-empty string: a non-string (`{"error": 123}`)
634
+ // is not an OAuth error code. Everything else falls back to http_<status>,
635
+ // which the loop terminates as api_error.
636
+ const rawError = data.error;
637
+ // truncateErrorMessage at extraction (SPEC §9's 500-unit cap): the server
638
+ // controls this string and an unrecognized value is interpolated into the
639
+ // api_error message. Real protocol codes are short, so classification is
640
+ // unaffected.
641
+ let error = response.status >= 400 && response.status < 500 && typeof rawError === "string" && rawError !== ""
642
+ ? truncateErrorMessage(rawError)
643
+ : `http_${response.status}`;
644
+ // A 429 recognizes ONLY too_many_requests (the exact retryable pair): a
645
+ // throttling endpoint whose body parrots authorization_pending/slow_down
646
+ // must not keep the loop polling until code expiry.
647
+ if (response.status === 429 && error !== "too_many_requests") {
648
+ error = `http_${response.status}`;
649
+ }
650
+ return { kind: "error", error, status: response.status, retryAfter: response.headers.get("Retry-After") };
651
+ });
652
+ }
653
+ finally {
654
+ clearTimeout(timeoutId);
655
+ signal?.removeEventListener("abort", onAbort);
656
+ }
657
+ }
658
+ // A plain Error tagged "AbortError" rather than `new DOMException(...)`:
659
+ // DOMException is not guaranteed in every JS runtime that can run this SDK
660
+ // (referencing it there throws ReferenceError). isAbort() matches on
661
+ // `name === "AbortError"`, so cancellation stays runtime-agnostic.
662
+ function abortError() {
663
+ const err = new Error("Aborted");
664
+ err.name = "AbortError";
665
+ return err;
666
+ }
667
+ /**
668
+ * Races `run` against `signal`: rejects with AbortError the moment the signal
669
+ * fires, even if the underlying promise NEVER settles. A cooperative fetch
670
+ * already rejects on abort — this enforces the same contract on a custom fetch
671
+ * that ignores its AbortSignal, so a late 200 cannot hand back a result and a
672
+ * never-settling fetch cannot hold the public call past its timeout. An
673
+ * already-aborted signal rejects without invoking `run`. A late settlement is
674
+ * discarded (settling an already-settled promise is a no-op), and its
675
+ * rejection path stays handled — no unhandled rejection escapes.
676
+ */
677
+ function raceAbort(signal, run) {
678
+ return new Promise((resolve, reject) => {
679
+ const onAbort = () => reject(abortError());
680
+ if (signal.aborted) {
681
+ onAbort();
682
+ return;
683
+ }
684
+ signal.addEventListener("abort", onAbort, { once: true });
685
+ // Microtask wrapper: a user-provided seam (custom fetch/sleepFn) can
686
+ // throw SYNCHRONOUSLY despite the TS type — without this, that throw
687
+ // would escape before the handlers attach and strand the listener.
688
+ Promise.resolve()
689
+ .then(() => {
690
+ // The abort can win between entry and this microtask (the outer
691
+ // promise has already rejected) — never invoke the seam post-abort;
692
+ // an AbortSignal-ignoring fetch would still send the POST.
693
+ if (signal.aborted)
694
+ throw abortError();
695
+ return run();
696
+ })
697
+ .then((value) => {
698
+ signal.removeEventListener("abort", onAbort);
699
+ resolve(value);
700
+ }, (err) => {
701
+ signal.removeEventListener("abort", onAbort);
702
+ reject(err);
703
+ });
704
+ });
705
+ }
706
+ function sleep(ms, signal) {
707
+ return new Promise((resolve, reject) => {
708
+ if (signal?.aborted) {
709
+ reject(abortError());
710
+ return;
711
+ }
712
+ // Declare the handler before the timer so neither forward-references the
713
+ // other's binding (avoids "used before declaration"); `timer` is assigned
714
+ // immediately below, before either callback can run.
715
+ let timer;
716
+ const onAbort = () => {
717
+ clearTimeout(timer);
718
+ reject(abortError());
719
+ };
720
+ timer = setTimeout(() => {
721
+ signal?.removeEventListener("abort", onAbort);
722
+ resolve();
723
+ }, ms);
724
+ signal?.addEventListener("abort", onAbort, { once: true });
725
+ // Race: the signal can abort between the `signal?.aborted` check above and
726
+ // attaching this listener. With { once }, that abort event is already spent, so
727
+ // the listener would never fire and the promise would only settle at the full
728
+ // timeout. Re-check and handle it manually, dropping the now-dead listener.
729
+ if (signal?.aborted) {
730
+ signal.removeEventListener("abort", onAbort);
731
+ onAbort();
732
+ }
733
+ });
734
+ }
735
+ export function throwIfAborted(signal) {
736
+ if (signal?.aborted) {
737
+ throw new DeviceFlowError("cancelled", "Device flow cancelled");
738
+ }
739
+ }
740
+ function isAbort(err) {
741
+ return err instanceof Error && err.name === "AbortError";
742
+ }
743
+ function errMessage(err) {
744
+ return err instanceof Error ? err.message : String(err);
745
+ }
746
+ //# sourceMappingURL=device.js.map