@37signals/basecamp 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -14
- package/dist/client.d.ts +16 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +272 -161
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/generated/metadata.d.ts.map +1 -1
- package/dist/generated/metadata.js +178 -17
- package/dist/generated/metadata.js.map +1 -1
- package/dist/generated/metadata.ts +178 -17
- package/dist/generated/openapi-stripped.json +2126 -309
- package/dist/generated/path-mapping.d.ts.map +1 -1
- package/dist/generated/path-mapping.js +13 -1
- package/dist/generated/path-mapping.js.map +1 -1
- package/dist/generated/path-mapping.ts +13 -1
- package/dist/generated/schema.d.ts +1416 -176
- package/dist/generated/services/bookmarks.d.ts +76 -0
- package/dist/generated/services/bookmarks.d.ts.map +1 -0
- package/dist/generated/services/bookmarks.js +114 -0
- package/dist/generated/services/bookmarks.js.map +1 -0
- package/dist/generated/services/bookmarks.ts +158 -0
- package/dist/generated/services/calendars.d.ts +47 -0
- package/dist/generated/services/calendars.d.ts.map +1 -0
- package/dist/generated/services/calendars.js +73 -0
- package/dist/generated/services/calendars.js.map +1 -0
- package/dist/generated/services/calendars.ts +102 -0
- package/dist/generated/services/drafts.d.ts +38 -0
- package/dist/generated/services/drafts.d.ts.map +1 -0
- package/dist/generated/services/drafts.js +40 -0
- package/dist/generated/services/drafts.js.map +1 -0
- package/dist/generated/services/drafts.ts +67 -0
- package/dist/generated/services/everything.d.ts +69 -22
- package/dist/generated/services/everything.d.ts.map +1 -1
- package/dist/generated/services/everything.js +23 -35
- package/dist/generated/services/everything.js.map +1 -1
- package/dist/generated/services/everything.ts +86 -49
- package/dist/generated/services/index.d.ts +6 -2
- package/dist/generated/services/index.d.ts.map +1 -1
- package/dist/generated/services/index.js +6 -2
- package/dist/generated/services/index.js.map +1 -1
- package/dist/generated/services/index.ts +6 -2
- package/dist/generated/services/my-assignments.d.ts +52 -0
- package/dist/generated/services/my-assignments.d.ts.map +1 -1
- package/dist/generated/services/my-assignments.js +71 -0
- package/dist/generated/services/my-assignments.js.map +1 -1
- package/dist/generated/services/my-assignments.ts +104 -0
- package/dist/generated/services/my-notes.d.ts +45 -0
- package/dist/generated/services/my-notes.d.ts.map +1 -0
- package/dist/generated/services/my-notes.js +62 -0
- package/dist/generated/services/my-notes.js.map +1 -0
- package/dist/generated/services/my-notes.ts +92 -0
- package/dist/generated/services/todos.d.ts +33 -0
- package/dist/generated/services/todos.d.ts.map +1 -1
- package/dist/generated/services/todos.js +46 -0
- package/dist/generated/services/todos.js.map +1 -1
- package/dist/generated/services/todos.ts +71 -0
- package/dist/hooks/otel.d.ts +1 -1
- package/dist/hooks/otel.js +1 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +4 -1
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +12 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/oauth/device-errors.d.ts +29 -0
- package/dist/oauth/device-errors.d.ts.map +1 -0
- package/dist/oauth/device-errors.js +52 -0
- package/dist/oauth/device-errors.js.map +1 -0
- package/dist/oauth/device-login.d.ts +50 -0
- package/dist/oauth/device-login.d.ts.map +1 -0
- package/dist/oauth/device-login.js +118 -0
- package/dist/oauth/device-login.js.map +1 -0
- package/dist/oauth/device.d.ts +112 -0
- package/dist/oauth/device.d.ts.map +1 -0
- package/dist/oauth/device.js +746 -0
- package/dist/oauth/device.js.map +1 -0
- package/dist/oauth/discovery.d.ts.map +1 -1
- package/dist/oauth/discovery.js +1 -0
- package/dist/oauth/discovery.js.map +1 -1
- package/dist/oauth/exchange.d.ts.map +1 -1
- package/dist/oauth/exchange.js +75 -9
- package/dist/oauth/exchange.js.map +1 -1
- package/dist/oauth/index.d.ts +4 -1
- package/dist/oauth/index.d.ts.map +1 -1
- package/dist/oauth/index.js +4 -0
- package/dist/oauth/index.js.map +1 -1
- package/dist/oauth/limits.d.ts +15 -0
- package/dist/oauth/limits.d.ts.map +1 -0
- package/dist/oauth/limits.js +15 -0
- package/dist/oauth/limits.js.map +1 -0
- package/dist/oauth/token-manager.d.ts.map +1 -1
- package/dist/oauth/token-manager.js +15 -4
- package/dist/oauth/token-manager.js.map +1 -1
- package/dist/oauth/token-store.d.ts.map +1 -1
- package/dist/oauth/token-store.js +6 -0
- package/dist/oauth/token-store.js.map +1 -1
- package/dist/oauth/types.d.ts +35 -3
- package/dist/oauth/types.d.ts.map +1 -1
- package/dist/pagination.d.ts +6 -2
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +1 -1
- package/dist/pagination.js.map +1 -1
- package/dist/services/base.d.ts +3 -1
- package/dist/services/base.d.ts.map +1 -1
- package/dist/services/base.js +16 -5
- package/dist/services/base.js.map +1 -1
- package/package.json +1 -1
- package/src/generated/metadata.ts +178 -17
- package/src/generated/openapi-stripped.json +2126 -309
- package/src/generated/path-mapping.ts +13 -1
- package/src/generated/schema.d.ts +1416 -176
- package/src/generated/services/bookmarks.ts +158 -0
- package/src/generated/services/calendars.ts +102 -0
- package/src/generated/services/drafts.ts +67 -0
- package/src/generated/services/everything.ts +86 -49
- package/src/generated/services/index.ts +6 -2
- package/src/generated/services/my-assignments.ts +104 -0
- package/src/generated/services/my-notes.ts +92 -0
- package/src/generated/services/todos.ts +71 -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
|