@37signals/basecamp 0.11.0 → 0.13.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 (304) hide show
  1. package/README.md +112 -16
  2. package/dist/.build-complete +0 -0
  3. package/dist/client.d.ts +65 -11
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +235 -215
  6. package/dist/client.js.map +1 -1
  7. package/dist/download.d.ts +7 -0
  8. package/dist/download.d.ts.map +1 -1
  9. package/dist/download.js +82 -22
  10. package/dist/download.js.map +1 -1
  11. package/dist/errors.d.ts +19 -1
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +128 -15
  14. package/dist/errors.js.map +1 -1
  15. package/dist/generated/metadata.d.ts.map +1 -1
  16. package/dist/generated/metadata.js +443 -133
  17. package/dist/generated/metadata.js.map +1 -1
  18. package/dist/generated/metadata.ts +443 -133
  19. package/dist/generated/openapi-stripped.json +8999 -4907
  20. package/dist/generated/path-mapping.d.ts.map +1 -1
  21. package/dist/generated/path-mapping.js +40 -16
  22. package/dist/generated/path-mapping.js.map +1 -1
  23. package/dist/generated/path-mapping.ts +40 -16
  24. package/dist/generated/schema.d.ts +5508 -2346
  25. package/dist/generated/services/bookmarks.d.ts +76 -0
  26. package/dist/generated/services/bookmarks.d.ts.map +1 -0
  27. package/dist/generated/services/bookmarks.js +114 -0
  28. package/dist/generated/services/bookmarks.js.map +1 -0
  29. package/dist/generated/services/bookmarks.ts +158 -0
  30. package/dist/generated/services/boosts.d.ts +10 -0
  31. package/dist/generated/services/boosts.d.ts.map +1 -1
  32. package/dist/generated/services/boosts.js +8 -0
  33. package/dist/generated/services/boosts.js.map +1 -1
  34. package/dist/generated/services/boosts.ts +12 -0
  35. package/dist/generated/services/calendars.d.ts +47 -0
  36. package/dist/generated/services/calendars.d.ts.map +1 -0
  37. package/dist/generated/services/calendars.js +73 -0
  38. package/dist/generated/services/calendars.js.map +1 -0
  39. package/dist/generated/services/calendars.ts +102 -0
  40. package/dist/generated/services/campfires.d.ts +54 -40
  41. package/dist/generated/services/campfires.d.ts.map +1 -1
  42. package/dist/generated/services/campfires.js +82 -65
  43. package/dist/generated/services/campfires.js.map +1 -1
  44. package/dist/generated/services/campfires.ts +107 -85
  45. package/dist/generated/services/cards.d.ts +5 -0
  46. package/dist/generated/services/cards.d.ts.map +1 -1
  47. package/dist/generated/services/cards.js +4 -0
  48. package/dist/generated/services/cards.js.map +1 -1
  49. package/dist/generated/services/cards.ts +6 -0
  50. package/dist/generated/services/checkins.d.ts +14 -0
  51. package/dist/generated/services/checkins.d.ts.map +1 -1
  52. package/dist/generated/services/checkins.js +14 -1
  53. package/dist/generated/services/checkins.js.map +1 -1
  54. package/dist/generated/services/checkins.ts +20 -0
  55. package/dist/generated/services/client-approvals.d.ts +6 -3
  56. package/dist/generated/services/client-approvals.d.ts.map +1 -1
  57. package/dist/generated/services/client-approvals.js +8 -5
  58. package/dist/generated/services/client-approvals.js.map +1 -1
  59. package/dist/generated/services/client-approvals.ts +10 -5
  60. package/dist/generated/services/client-correspondences.d.ts +6 -3
  61. package/dist/generated/services/client-correspondences.d.ts.map +1 -1
  62. package/dist/generated/services/client-correspondences.js +8 -5
  63. package/dist/generated/services/client-correspondences.js.map +1 -1
  64. package/dist/generated/services/client-correspondences.ts +10 -5
  65. package/dist/generated/services/client-replies.d.ts +11 -4
  66. package/dist/generated/services/client-replies.d.ts.map +1 -1
  67. package/dist/generated/services/client-replies.js +16 -8
  68. package/dist/generated/services/client-replies.js.map +1 -1
  69. package/dist/generated/services/client-replies.ts +18 -8
  70. package/dist/generated/services/cloud-files.d.ts +92 -0
  71. package/dist/generated/services/cloud-files.d.ts.map +1 -0
  72. package/dist/generated/services/cloud-files.js +121 -0
  73. package/dist/generated/services/cloud-files.js.map +1 -0
  74. package/dist/generated/services/cloud-files.ts +188 -0
  75. package/dist/generated/services/comments.d.ts +5 -0
  76. package/dist/generated/services/comments.d.ts.map +1 -1
  77. package/dist/generated/services/comments.js +4 -0
  78. package/dist/generated/services/comments.js.map +1 -1
  79. package/dist/generated/services/comments.ts +6 -0
  80. package/dist/generated/services/documents.d.ts +12 -7
  81. package/dist/generated/services/documents.d.ts.map +1 -1
  82. package/dist/generated/services/documents.js +10 -6
  83. package/dist/generated/services/documents.js.map +1 -1
  84. package/dist/generated/services/documents.ts +14 -8
  85. package/dist/generated/services/drafts.d.ts +38 -0
  86. package/dist/generated/services/drafts.d.ts.map +1 -0
  87. package/dist/generated/services/drafts.js +40 -0
  88. package/dist/generated/services/drafts.js.map +1 -0
  89. package/dist/generated/services/drafts.ts +67 -0
  90. package/dist/generated/services/events.d.ts +5 -0
  91. package/dist/generated/services/events.d.ts.map +1 -1
  92. package/dist/generated/services/events.js +4 -0
  93. package/dist/generated/services/events.js.map +1 -1
  94. package/dist/generated/services/events.ts +6 -0
  95. package/dist/generated/services/everything.d.ts +83 -16
  96. package/dist/generated/services/everything.d.ts.map +1 -1
  97. package/dist/generated/services/everything.js +23 -13
  98. package/dist/generated/services/everything.js.map +1 -1
  99. package/dist/generated/services/everything.ts +100 -25
  100. package/dist/generated/services/folders.d.ts +96 -0
  101. package/dist/generated/services/folders.d.ts.map +1 -0
  102. package/dist/generated/services/folders.js +140 -0
  103. package/dist/generated/services/folders.js.map +1 -0
  104. package/dist/generated/services/folders.ts +201 -0
  105. package/dist/generated/services/forwards.d.ts +7 -20
  106. package/dist/generated/services/forwards.d.ts.map +1 -1
  107. package/dist/generated/services/forwards.js +6 -35
  108. package/dist/generated/services/forwards.js.map +1 -1
  109. package/dist/generated/services/forwards.ts +10 -48
  110. package/dist/generated/services/gauges.d.ts +7 -0
  111. package/dist/generated/services/gauges.d.ts.map +1 -1
  112. package/dist/generated/services/gauges.js +5 -1
  113. package/dist/generated/services/gauges.js.map +1 -1
  114. package/dist/generated/services/gauges.ts +9 -1
  115. package/dist/generated/services/google-documents.d.ts +98 -0
  116. package/dist/generated/services/google-documents.d.ts.map +1 -0
  117. package/dist/generated/services/google-documents.js +123 -0
  118. package/dist/generated/services/google-documents.js.map +1 -0
  119. package/dist/generated/services/google-documents.ts +196 -0
  120. package/dist/generated/services/index.d.ts +12 -5
  121. package/dist/generated/services/index.d.ts.map +1 -1
  122. package/dist/generated/services/index.js +12 -5
  123. package/dist/generated/services/index.js.map +1 -1
  124. package/dist/generated/services/index.ts +12 -5
  125. package/dist/generated/services/messages.d.ts +2 -0
  126. package/dist/generated/services/messages.d.ts.map +1 -1
  127. package/dist/generated/services/messages.js +1 -1
  128. package/dist/generated/services/messages.js.map +1 -1
  129. package/dist/generated/services/messages.ts +3 -1
  130. package/dist/generated/services/my-assignments.d.ts +52 -0
  131. package/dist/generated/services/my-assignments.d.ts.map +1 -1
  132. package/dist/generated/services/my-assignments.js +71 -0
  133. package/dist/generated/services/my-assignments.js.map +1 -1
  134. package/dist/generated/services/my-assignments.ts +104 -0
  135. package/dist/generated/services/my-notes.d.ts +45 -0
  136. package/dist/generated/services/my-notes.d.ts.map +1 -0
  137. package/dist/generated/services/my-notes.js +62 -0
  138. package/dist/generated/services/my-notes.js.map +1 -0
  139. package/dist/generated/services/my-notes.ts +92 -0
  140. package/dist/generated/services/my-notifications.d.ts +4 -2
  141. package/dist/generated/services/my-notifications.d.ts.map +1 -1
  142. package/dist/generated/services/my-notifications.js.map +1 -1
  143. package/dist/generated/services/my-notifications.ts +4 -2
  144. package/dist/generated/services/people.d.ts +11 -1
  145. package/dist/generated/services/people.d.ts.map +1 -1
  146. package/dist/generated/services/people.js +13 -2
  147. package/dist/generated/services/people.js.map +1 -1
  148. package/dist/generated/services/people.ts +15 -1
  149. package/dist/generated/services/projects.d.ts +26 -0
  150. package/dist/generated/services/projects.d.ts.map +1 -1
  151. package/dist/generated/services/projects.js +49 -1
  152. package/dist/generated/services/projects.js.map +1 -1
  153. package/dist/generated/services/projects.ts +61 -1
  154. package/dist/generated/services/recordings.d.ts +2 -12
  155. package/dist/generated/services/recordings.d.ts.map +1 -1
  156. package/dist/generated/services/recordings.js +1 -26
  157. package/dist/generated/services/recordings.js.map +1 -1
  158. package/dist/generated/services/recordings.ts +3 -31
  159. package/dist/generated/services/reports.d.ts +8 -12
  160. package/dist/generated/services/reports.d.ts.map +1 -1
  161. package/dist/generated/services/reports.js +11 -5
  162. package/dist/generated/services/reports.js.map +1 -1
  163. package/dist/generated/services/reports.ts +13 -14
  164. package/dist/generated/services/schedules.d.ts +94 -15
  165. package/dist/generated/services/schedules.d.ts.map +1 -1
  166. package/dist/generated/services/schedules.js +19 -8
  167. package/dist/generated/services/schedules.js.map +1 -1
  168. package/dist/generated/services/schedules.ts +107 -17
  169. package/dist/generated/services/search.d.ts +2 -0
  170. package/dist/generated/services/search.d.ts.map +1 -1
  171. package/dist/generated/services/search.js +1 -1
  172. package/dist/generated/services/search.js.map +1 -1
  173. package/dist/generated/services/search.ts +3 -1
  174. package/dist/generated/services/templates.d.ts +2 -0
  175. package/dist/generated/services/templates.d.ts.map +1 -1
  176. package/dist/generated/services/templates.js +1 -1
  177. package/dist/generated/services/templates.js.map +1 -1
  178. package/dist/generated/services/templates.ts +3 -1
  179. package/dist/generated/services/timeline.d.ts +2 -0
  180. package/dist/generated/services/timeline.d.ts.map +1 -1
  181. package/dist/generated/services/timeline.js +1 -0
  182. package/dist/generated/services/timeline.js.map +1 -1
  183. package/dist/generated/services/timeline.ts +3 -0
  184. package/dist/generated/services/timesheets.d.ts +16 -0
  185. package/dist/generated/services/timesheets.d.ts.map +1 -1
  186. package/dist/generated/services/timesheets.js +26 -2
  187. package/dist/generated/services/timesheets.js.map +1 -1
  188. package/dist/generated/services/timesheets.ts +35 -2
  189. package/dist/generated/services/todolist-groups.d.ts +11 -6
  190. package/dist/generated/services/todolist-groups.d.ts.map +1 -1
  191. package/dist/generated/services/todolist-groups.js +7 -3
  192. package/dist/generated/services/todolist-groups.js.map +1 -1
  193. package/dist/generated/services/todolist-groups.ts +13 -7
  194. package/dist/generated/services/todolists.d.ts +15 -13
  195. package/dist/generated/services/todolists.d.ts.map +1 -1
  196. package/dist/generated/services/todolists.js +11 -8
  197. package/dist/generated/services/todolists.js.map +1 -1
  198. package/dist/generated/services/todolists.ts +19 -14
  199. package/dist/generated/services/todos.d.ts +35 -12
  200. package/dist/generated/services/todos.d.ts.map +1 -1
  201. package/dist/generated/services/todos.js +47 -25
  202. package/dist/generated/services/todos.js.map +1 -1
  203. package/dist/generated/services/todos.ts +74 -30
  204. package/dist/generated/services/uploads.d.ts +5 -0
  205. package/dist/generated/services/uploads.d.ts.map +1 -1
  206. package/dist/generated/services/uploads.js +4 -0
  207. package/dist/generated/services/uploads.js.map +1 -1
  208. package/dist/generated/services/uploads.ts +6 -0
  209. package/dist/generated/services/vaults.d.ts +5 -0
  210. package/dist/generated/services/vaults.d.ts.map +1 -1
  211. package/dist/generated/services/vaults.js +4 -0
  212. package/dist/generated/services/vaults.js.map +1 -1
  213. package/dist/generated/services/vaults.ts +6 -0
  214. package/dist/hooks/otel.d.ts +1 -1
  215. package/dist/hooks/otel.js +1 -1
  216. package/dist/index.d.ts +21 -11
  217. package/dist/index.d.ts.map +1 -1
  218. package/dist/index.js +24 -9
  219. package/dist/index.js.map +1 -1
  220. package/dist/oauth/discovery.d.ts.map +1 -1
  221. package/dist/oauth/discovery.js +1 -0
  222. package/dist/oauth/discovery.js.map +1 -1
  223. package/dist/pagination-utils.d.ts +46 -0
  224. package/dist/pagination-utils.d.ts.map +1 -1
  225. package/dist/pagination-utils.js +85 -2
  226. package/dist/pagination-utils.js.map +1 -1
  227. package/dist/pagination.d.ts +12 -1
  228. package/dist/pagination.d.ts.map +1 -1
  229. package/dist/pagination.js +1 -1
  230. package/dist/pagination.js.map +1 -1
  231. package/dist/retry.d.ts +110 -0
  232. package/dist/retry.d.ts.map +1 -0
  233. package/dist/retry.js +253 -0
  234. package/dist/retry.js.map +1 -0
  235. package/dist/services/base.d.ts.map +1 -1
  236. package/dist/services/base.js +59 -5
  237. package/dist/services/base.js.map +1 -1
  238. package/dist/services/cards-extensions.d.ts +26 -21
  239. package/dist/services/cards-extensions.d.ts.map +1 -1
  240. package/dist/services/cards-extensions.js +28 -31
  241. package/dist/services/cards-extensions.js.map +1 -1
  242. package/dist/services/documents-extensions.d.ts +122 -0
  243. package/dist/services/documents-extensions.d.ts.map +1 -0
  244. package/dist/services/documents-extensions.js +125 -0
  245. package/dist/services/documents-extensions.js.map +1 -0
  246. package/dist/services/merge-safe.d.ts +167 -0
  247. package/dist/services/merge-safe.d.ts.map +1 -0
  248. package/dist/services/merge-safe.js +217 -0
  249. package/dist/services/merge-safe.js.map +1 -0
  250. package/dist/services/schedules-extensions.d.ts +266 -0
  251. package/dist/services/schedules-extensions.d.ts.map +1 -0
  252. package/dist/services/schedules-extensions.js +268 -0
  253. package/dist/services/schedules-extensions.js.map +1 -0
  254. package/dist/services/todolists-extensions.d.ts +110 -0
  255. package/dist/services/todolists-extensions.d.ts.map +1 -0
  256. package/dist/services/todolists-extensions.js +262 -0
  257. package/dist/services/todolists-extensions.js.map +1 -0
  258. package/dist/services/todos-extensions.d.ts +12 -1
  259. package/dist/services/todos-extensions.d.ts.map +1 -1
  260. package/dist/services/todos-extensions.js +27 -8
  261. package/dist/services/todos-extensions.js.map +1 -1
  262. package/package.json +5 -4
  263. package/src/generated/metadata.ts +443 -133
  264. package/src/generated/openapi-stripped.json +8999 -4907
  265. package/src/generated/path-mapping.ts +40 -16
  266. package/src/generated/schema.d.ts +5508 -2346
  267. package/src/generated/services/bookmarks.ts +158 -0
  268. package/src/generated/services/boosts.ts +12 -0
  269. package/src/generated/services/calendars.ts +102 -0
  270. package/src/generated/services/campfires.ts +107 -85
  271. package/src/generated/services/cards.ts +6 -0
  272. package/src/generated/services/checkins.ts +20 -0
  273. package/src/generated/services/client-approvals.ts +10 -5
  274. package/src/generated/services/client-correspondences.ts +10 -5
  275. package/src/generated/services/client-replies.ts +18 -8
  276. package/src/generated/services/cloud-files.ts +188 -0
  277. package/src/generated/services/comments.ts +6 -0
  278. package/src/generated/services/documents.ts +14 -8
  279. package/src/generated/services/drafts.ts +67 -0
  280. package/src/generated/services/events.ts +6 -0
  281. package/src/generated/services/everything.ts +100 -25
  282. package/src/generated/services/folders.ts +201 -0
  283. package/src/generated/services/forwards.ts +10 -48
  284. package/src/generated/services/gauges.ts +9 -1
  285. package/src/generated/services/google-documents.ts +196 -0
  286. package/src/generated/services/index.ts +12 -5
  287. package/src/generated/services/messages.ts +3 -1
  288. package/src/generated/services/my-assignments.ts +104 -0
  289. package/src/generated/services/my-notes.ts +92 -0
  290. package/src/generated/services/my-notifications.ts +4 -2
  291. package/src/generated/services/people.ts +15 -1
  292. package/src/generated/services/projects.ts +61 -1
  293. package/src/generated/services/recordings.ts +3 -31
  294. package/src/generated/services/reports.ts +13 -14
  295. package/src/generated/services/schedules.ts +107 -17
  296. package/src/generated/services/search.ts +3 -1
  297. package/src/generated/services/templates.ts +3 -1
  298. package/src/generated/services/timeline.ts +3 -0
  299. package/src/generated/services/timesheets.ts +35 -2
  300. package/src/generated/services/todolist-groups.ts +13 -7
  301. package/src/generated/services/todolists.ts +19 -14
  302. package/src/generated/services/todos.ts +74 -30
  303. package/src/generated/services/uploads.ts +6 -0
  304. package/src/generated/services/vaults.ts +6 -0
package/dist/client.js CHANGED
@@ -10,15 +10,16 @@ import metadata from "./generated/metadata.js";
10
10
  import { PATH_TO_OPERATION } from "./generated/path-mapping.js";
11
11
  import { BasecampError } from "./errors.js";
12
12
  import { isLocalhost, requireSameOrigin } from "./security.js";
13
- import { parseNextLink, resolveURL, isSameOrigin } from "./pagination-utils.js";
13
+ import { parseNextLink, resolveURL, isSameOrigin, DEFAULT_MAX_PAGES, assertValidMaxPages } from "./pagination-utils.js";
14
14
  import { bearerAuth } from "./auth-strategy.js";
15
15
  import { createDownloadURL } from "./download.js";
16
+ import { DEFAULT_RETRY_CONFIG, NO_RETRY_CONFIG, TerminalRetryError, executeWithRetry, } from "./retry.js";
16
17
  // ============================================================================
17
18
  // Services - Generated from OpenAPI spec (spec-driven, not hand-written)
18
19
  // ============================================================================
19
20
  import { ProjectsService } from "./generated/services/projects.js";
20
21
  import { TodosService } from "./services/todos-extensions.js";
21
- import { TodolistsService } from "./generated/services/todolists.js";
22
+ import { TodolistsService } from "./services/todolists-extensions.js";
22
23
  import { TodosetsService } from "./generated/services/todosets.js";
23
24
  import { HillChartsService } from "./generated/services/hill-charts.js";
24
25
  import { PeopleService } from "./generated/services/people.js";
@@ -38,12 +39,19 @@ import { ClientApprovalsService } from "./generated/services/client-approvals.js
38
39
  import { ClientCorrespondencesService } from "./generated/services/client-correspondences.js";
39
40
  import { ClientRepliesService } from "./generated/services/client-replies.js";
40
41
  import { WebhooksService } from "./generated/services/webhooks.js";
42
+ import { BookmarksService } from "./generated/services/bookmarks.js";
43
+ import { FoldersService } from "./generated/services/folders.js";
44
+ import { DraftsService } from "./generated/services/drafts.js";
45
+ import { CalendarsService } from "./generated/services/calendars.js";
46
+ import { MyNotesService } from "./generated/services/my-notes.js";
41
47
  import { SubscriptionsService } from "./generated/services/subscriptions.js";
42
48
  import { AttachmentsService } from "./generated/services/attachments.js";
43
49
  import { VaultsService } from "./generated/services/vaults.js";
44
- import { DocumentsService } from "./generated/services/documents.js";
50
+ import { DocumentsService } from "./services/documents-extensions.js";
45
51
  import { UploadsService } from "./services/uploads-extensions.js";
46
- import { SchedulesService } from "./generated/services/schedules.js";
52
+ import { CloudFilesService } from "./generated/services/cloud-files.js";
53
+ import { GoogleDocumentsService } from "./generated/services/google-documents.js";
54
+ import { SchedulesService } from "./services/schedules-extensions.js";
47
55
  import { EventsService } from "./generated/services/events.js";
48
56
  import { RecordingsService } from "./generated/services/recordings.js";
49
57
  import { SearchService } from "./generated/services/search.js";
@@ -72,14 +80,14 @@ import { AuthorizationService } from "./services/authorization.js";
72
80
  * a TimeoutOverflowWarning rather than honored.
73
81
  */
74
82
  const MAX_TIMEOUT_MS = 2_147_483_647;
75
- export const VERSION = "0.11.0";
76
- export const API_VERSION = "2026-07-30";
83
+ export const VERSION = "0.13.0";
84
+ export const API_VERSION = "2026-08-05";
77
85
  const DEFAULT_USER_AGENT = `basecamp-sdk-ts/${VERSION} (api:${API_VERSION})`;
78
86
  /**
79
87
  * Creates a type-safe Basecamp API client with built-in middleware for:
80
88
  * - Authentication (Bearer token)
81
89
  * - Retry with exponential backoff (respects Retry-After header)
82
- * - ETag-based HTTP caching
90
+ * - ETag-based HTTP caching, opt-in via `enableCache` (defaults to false)
83
91
  *
84
92
  * @example
85
93
  * ```ts
@@ -115,6 +123,24 @@ export function createBasecampClient(options) {
115
123
  requestTimeoutMs > MAX_TIMEOUT_MS) {
116
124
  throw new BasecampError("usage", `'requestTimeoutMs' must be an integer between 0 and ${MAX_TIMEOUT_MS}, got ${requestTimeoutMs}`);
117
125
  }
126
+ // The cap the standalone helpers validate is the same cap the config carries,
127
+ // and this is the wider door: `maxPages` here is handed to every service and
128
+ // reached only later, inside `BaseService`'s `page < this.maxPages` loops, far
129
+ // from the call that misconfigured it. Same predicate, same error, checked
130
+ // once at construction — see assertValidMaxPages for what each rejected value
131
+ // does to the bound. Only when the caller supplied one: an absent cap must
132
+ // keep falling through to DEFAULT_MAX_PAGES.
133
+ //
134
+ // `!= null`, matching BaseService's guard exactly. This value is not consumed
135
+ // here — it is handed straight to every service below, where `maxPages ??
136
+ // DEFAULT_MAX_PAGES` treats an explicit `null` as absent. A `!== undefined`
137
+ // test here made the two doors disagree on that single value: the factory
138
+ // threw where the equivalent direct service construction defaulted. Whichever
139
+ // way "not supplied" is defined, both guards have to define it the same way,
140
+ // because the same `??` is downstream of both.
141
+ if (maxPages != null) {
142
+ assertValidMaxPages(maxPages);
143
+ }
118
144
  const authStrategy = auth ?? bearerAuth(accessToken);
119
145
  // Validate configuration (skip HTTPS check for localhost in dev/test)
120
146
  if (baseUrl) {
@@ -130,28 +156,31 @@ export function createBasecampClient(options) {
130
156
  throw new BasecampError("usage", `Invalid base URL: ${baseUrl}`);
131
157
  }
132
158
  }
133
- const client = createClient({ baseUrl });
134
- // Apply middleware in order: auth, lifecycle, cache, retry.
159
+ // One lifecycle, shared between the retrying fetch and the lifecycle
160
+ // middleware: the fetch owns every attempt's start and each abandoned
161
+ // attempt's end; the middleware finalizes the terminal outcome.
162
+ const lifecycle = new RequestLifecycle(hooks);
163
+ // The retry loop lives BENEATH the middleware chain, as the client's custom
164
+ // fetch: openapi-fetch calls it exactly once per logical request, after every
165
+ // onRequest middleware and before every onError/onResponse. Installed
166
+ // unconditionally — with retry disabled it degenerates to a single attempt —
167
+ // so hook emission is single-sourced regardless of config.
168
+ const client = createClient({
169
+ baseUrl,
170
+ fetch: createRetryingFetch(lifecycle, authStrategy, enableRetry),
171
+ });
172
+ // Apply middleware in order: auth, lifecycle, cache.
135
173
  // onRequest runs in this order; onResponse and onError run in reverse, so the
136
- // retry middleware sees a response first and the lifecycle middleware last.
174
+ // lifecycle middleware finalizes the terminal attempt only after the cache
175
+ // middleware has had its chance to transform the response.
137
176
  client.use(createAuthMiddleware(authStrategy, userAgent, requestTimeoutMs, baseUrl));
138
- // One lifecycle, shared with the retry middleware: the retry loop lives inside a
139
- // single middleware callback, so per-attempt hooks have to be emitted from there
140
- // rather than by a middleware that is only visited once per request.
141
- const lifecycle = new RequestLifecycle(hooks);
142
177
  // Registered even when no hooks are configured. It emits nothing in that case
143
- // (every call is optional-chained), but it still owns releasing per-request
144
- // state — and the retry middleware records an attempt whether or not anyone is
145
- // listening, so skipping this would strand one map entry per retried request.
178
+ // (every call is optional-chained), but registering it unconditionally keeps
179
+ // the finalize path identical whether or not anyone is listening.
146
180
  client.use(createLifecycleMiddleware(lifecycle));
147
181
  if (enableCache) {
148
182
  client.use(createCacheMiddleware());
149
183
  }
150
- // Registered last so that on the reverse-order response pass it runs first,
151
- // before the cache and lifecycle middleware see the response.
152
- if (enableRetry) {
153
- client.use(createRetryMiddleware(lifecycle, authStrategy));
154
- }
155
184
  // Create enhanced client with additional properties
156
185
  const enhancedClient = client;
157
186
  Object.defineProperty(enhancedClient, "raw", {
@@ -207,7 +236,7 @@ export function createBasecampClient(options) {
207
236
  // Wire downloadURL — raw fetch, not openapi-fetch (like fetchPage).
208
237
  // Defined before service factories so UploadsService can inject it.
209
238
  const downloadURLFn = createDownloadURL({
210
- authStrategy, userAgent, baseUrl, hooks, requestTimeoutMs,
239
+ authStrategy, userAgent, baseUrl, hooks, requestTimeoutMs, enableRetry,
211
240
  });
212
241
  Object.defineProperty(enhancedClient, "downloadURL", {
213
242
  value: downloadURLFn,
@@ -237,10 +266,17 @@ export function createBasecampClient(options) {
237
266
  defineService("clientCorrespondences", () => new ClientCorrespondencesService(client, hooks, fetchPage, maxPages));
238
267
  defineService("clientReplies", () => new ClientRepliesService(client, hooks, fetchPage, maxPages));
239
268
  defineService("webhooks", () => new WebhooksService(client, hooks, fetchPage, maxPages));
269
+ defineService("bookmarks", () => new BookmarksService(client, hooks, fetchPage, maxPages));
270
+ defineService("folders", () => new FoldersService(client, hooks, fetchPage, maxPages));
271
+ defineService("drafts", () => new DraftsService(client, hooks, fetchPage, maxPages));
272
+ defineService("calendars", () => new CalendarsService(client, hooks, fetchPage, maxPages));
273
+ defineService("myNotes", () => new MyNotesService(client, hooks, fetchPage, maxPages));
240
274
  defineService("subscriptions", () => new SubscriptionsService(client, hooks, fetchPage, maxPages));
241
275
  defineService("attachments", () => new AttachmentsService(client, hooks, fetchPage, maxPages));
242
276
  defineService("vaults", () => new VaultsService(client, hooks, fetchPage, maxPages));
243
277
  defineService("documents", () => new DocumentsService(client, hooks, fetchPage, maxPages));
278
+ defineService("cloudFiles", () => new CloudFilesService(client, hooks, fetchPage, maxPages));
279
+ defineService("googleDocuments", () => new GoogleDocumentsService(client, hooks, fetchPage, maxPages));
244
280
  defineService("uploads", () =>
245
281
  // Positional args mirror BaseService (incl. authenticatedFetch/baseUrl slots the
246
282
  // factory leaves unset), with downloadURLFn appended at the end.
@@ -301,45 +337,41 @@ function createAuthMiddleware(authStrategy, userAgent, requestTimeoutMs, baseUrl
301
337
  };
302
338
  }
303
339
  /**
304
- * Owns the onRequestStart / onRequestEnd / onRetry lifecycle for every attempt of
305
- * every in-flight request, keyed by openapi-fetch's per-request `id`.
340
+ * Owns the onRequestStart / onRequestEnd / onRetry lifecycle for every attempt
341
+ * of every in-flight request, keyed by the final Request object openapi-fetch
342
+ * hands to its custom fetch.
306
343
  *
307
- * The id is minted once per logical request (one client.GET/POST/... call) and is
308
- * passed unchanged to onRequest, onResponse and onError, so it survives a
309
- * middleware replacing the Request object. It deliberately does NOT distinguish
310
- * attempts — per-attempt data lives nested under it here — and it never goes on
311
- * the wire, which is why this replaces the old `X-SDK-Request-Id` and
312
- * `X-Request-Id` correlation headers. (`X-Request-Id` in particular is a
313
- * spec-reserved BC3 *response* header; sending an SDK-internal value under that
314
- * name was a collision.)
344
+ * openapi-fetch passes that same object to onResponse and onError, so identity
345
+ * holds from the retrying fetch (which begins every attempt) through to the
346
+ * lifecycle middleware (which finalizes the terminal one). Nothing ever goes on
347
+ * the wire — this keying replaced the old `X-SDK-Request-Id` and `X-Request-Id`
348
+ * correlation headers. (`X-Request-Id` in particular is a spec-reserved BC3
349
+ * *response* header; sending an SDK-internal value under that name was a
350
+ * collision.)
315
351
  *
316
- * Shared with the retry middleware because the retry loop lives inside
317
- * one middleware callback: openapi-fetch visits each middleware once per request,
318
- * so per-attempt hooks cannot be emitted by a separate middleware.
352
+ * A WeakMap makes the release bookkeeping structural: once a request settles
353
+ * and its Request is unreachable, the state is collectable — there is no
354
+ * release() to forget.
319
355
  */
320
356
  class RequestLifecycle {
321
357
  hooks;
322
- states = new Map();
358
+ states = new WeakMap();
323
359
  constructor(hooks) {
324
360
  this.hooks = hooks;
325
361
  }
326
362
  /** Begin an attempt and fire onRequestStart for it. */
327
- begin(id, method, url, attempt) {
328
- this.states.set(id, { startTime: performance.now(), attempt, finalized: false });
363
+ begin(request, method, url, attempt) {
364
+ this.states.set(request, { startTime: performance.now(), attempt, finalized: false });
329
365
  this.emit(() => this.hooks?.onRequestStart?.({ method, url, attempt }));
330
366
  }
331
- /** The attempt currently in flight, or 1 if we have no record. */
332
- attemptOf(id) {
333
- return this.states.get(id)?.attempt ?? 1;
334
- }
335
367
  /**
336
368
  * Fire onRequestEnd for the in-flight attempt, exactly once.
337
369
  *
338
370
  * A network failure or timeout carries statusCode 0 and the originating error,
339
371
  * matching the multipart transport in services/base.ts and SPEC section 7.
340
372
  */
341
- finalize(id, method, url, outcome) {
342
- const state = this.states.get(id);
373
+ finalize(request, method, url, outcome) {
374
+ const state = this.states.get(request);
343
375
  if (!state || state.finalized)
344
376
  return;
345
377
  state.finalized = true;
@@ -359,13 +391,9 @@ class RequestLifecycle {
359
391
  * just FAILED, while the standalone argument is the UPCOMING attempt. Go,
360
392
  * Python, Ruby and Kotlin all pass (failed, failed + 1).
361
393
  */
362
- retrying(id, method, url, failedAttempt, error, delayMs) {
394
+ retrying(method, url, failedAttempt, error, delayMs) {
363
395
  this.emit(() => this.hooks?.onRetry?.({ method, url, attempt: failedAttempt }, failedAttempt + 1, error, delayMs));
364
396
  }
365
- /** Drop all state for a logical request. Safe to call more than once. */
366
- release(id) {
367
- this.states.delete(id);
368
- }
369
397
  emit(fn) {
370
398
  try {
371
399
  fn();
@@ -377,37 +405,32 @@ class RequestLifecycle {
377
405
  }
378
406
  function createLifecycleMiddleware(lifecycle) {
379
407
  return {
380
- async onRequest({ request, id }) {
381
- lifecycle.begin(id, request.method, request.url, 1);
382
- return request;
383
- },
384
- async onResponse({ request, response, id }) {
385
- // The authoritative end for whichever attempt is in flight: attempt 1 for a
386
- // single-attempt request, or attempt 2 after a retry — the retry middleware
387
- // deliberately does not finalize a successful attempt, so that the cache
388
- // middleware (which runs between the two) can transform the response first.
389
- // finalize stays idempotent as a backstop.
408
+ async onResponse({ request, response }) {
409
+ // The authoritative end for the terminal attempt: the retrying fetch
410
+ // begins every attempt but deliberately does not finalize the response it
411
+ // returns, so that this pass can record the outcome the cache middleware
412
+ // has since transformed (a 304 rewritten into a cached 200). finalize
413
+ // stays idempotent as a backstop.
390
414
  //
391
415
  // fromCache means "served out of the ETag cache", and only the header the
392
416
  // cache middleware sets proves that. A bare 304 does NOT: it reaches here
393
417
  // when the cache is disabled, or is enabled but holds no entry for this key,
394
418
  // and in both cases the caller's own conditional request went to the server.
395
419
  const fromCache = response.headers.get("X-From-Cache") === "1";
396
- lifecycle.finalize(id, request.method, request.url, {
420
+ lifecycle.finalize(request, request.method, request.url, {
397
421
  statusCode: response.status,
398
422
  fromCache,
399
423
  });
400
- lifecycle.release(id);
401
424
  return response;
402
425
  },
403
- async onError({ request, error, id }) {
404
- // openapi-fetch skips onResponse entirely when the initial fetch rejects,
405
- // so this is the only place a network error or timeout can be observed.
406
- lifecycle.finalize(id, request.method, request.url, {
426
+ async onError({ request, error }) {
427
+ // A terminal throw from the retrying fetch — a network error, a timeout,
428
+ // or a retry's auth refresh — skips onResponse entirely, so this is the
429
+ // only place it can be observed.
430
+ lifecycle.finalize(request, request.method, request.url, {
407
431
  statusCode: 0,
408
432
  error: error instanceof Error ? error : new Error(String(error)),
409
433
  });
410
- lifecycle.release(id);
411
434
  // Returning nothing preserves the original error's identity and rethrows it.
412
435
  return undefined;
413
436
  },
@@ -523,21 +546,12 @@ function createCacheMiddleware() {
523
546
  function getCacheKey(url, tokenHash) {
524
547
  return `${tokenHash}:${url}`;
525
548
  }
526
- /** Default retry config used when no operation-specific config is available */
527
- const DEFAULT_RETRY_CONFIG = {
528
- maxAttempts: 3,
529
- baseDelayMs: 1000,
530
- backoff: "exponential",
531
- retryOn: [429, 503],
532
- };
533
- /** No-retry config for non-idempotent POST operations */
534
- const NO_RETRY_CONFIG = {
535
- maxAttempts: 1,
536
- baseDelayMs: 0,
537
- backoff: "constant",
538
- retryOn: [],
539
- };
540
- const MAX_JITTER_MS = 100;
549
+ // =============================================================================
550
+ // Retrying Fetch (the retry loop, beneath the middleware chain)
551
+ // =============================================================================
552
+ // The retry loop itself lives in retry.ts (executeWithRetry) so the raw-fetch
553
+ // download hop 1 shares it; this section owns what is client-specific — the
554
+ // operation-metadata config resolution and the openapi-fetch integration.
541
555
  // PATH_TO_OPERATION is imported from generated/path-mapping.js
542
556
  /**
543
557
  * Normalizes a URL path by replacing numeric IDs with placeholder tokens.
@@ -577,6 +591,8 @@ export function normalizeUrlPath(url) {
577
591
  comments: "{commentId}",
578
592
  tools: "{toolId}", // dock/tools/{toolId}
579
593
  documents: "{documentId}",
594
+ cloud_files: "{cloudFileId}",
595
+ google_documents: "{googleDocumentId}",
580
596
  inbox_forwards: "{forwardId}",
581
597
  inboxes: "{inboxId}",
582
598
  message_boards: "{boardId}",
@@ -679,148 +695,111 @@ function getRetryConfigForRequest(method, url) {
679
695
  }
680
696
  return DEFAULT_RETRY_CONFIG;
681
697
  }
682
- function createRetryMiddleware(lifecycle, authStrategy) {
683
- // Serialized request bodies, keyed by openapi-fetch's per-request id, because
684
- // Request.body can only be read once and the retry needs to replay it. Keyed on
685
- // the id rather than method+url+timestamp: two concurrent mutations to the same
686
- // URL used to collide on one slot and replay each other's bytes.
687
- const bodyCache = new Map();
688
- return {
689
- async onRequest({ request, id }) {
690
- // For methods that may have a body, clone it before the initial fetch
691
- // so we can use it for retries. Request.body can only be consumed once.
692
- const method = request.method.toUpperCase();
693
- if (method === "POST" || method === "PUT" || method === "PATCH") {
694
- if (request.body) {
695
- // Clone the body before it gets consumed
696
- const cloned = request.clone();
697
- bodyCache.set(id, await cloned.arrayBuffer());
698
- }
699
- else {
700
- bodyCache.set(id, null);
701
- }
702
- }
703
- return request;
704
- },
705
- async onResponse({ request, response, id }) {
706
- const { method, url } = request;
707
- // Get operation-specific retry config from metadata
708
- const retryConfig = getRetryConfigForRequest(method, url);
709
- // Not retrying: let the lifecycle middleware end the attempt, and drop the body.
710
- if (!retryConfig.retryOn.includes(response.status)) {
711
- bodyCache.delete(id);
712
- return response;
713
- }
714
- const failedAttempt = lifecycle.attemptOf(id);
715
- // maxAttempts is a total attempt count, so attempt N is terminal when it
716
- // equals the cap. TypeScript additionally chains at most one retry, since
717
- // the retry below leaves the middleware chain (SPEC waiver 2B.1).
718
- if (failedAttempt >= retryConfig.maxAttempts) {
719
- bodyCache.delete(id);
720
- return response;
721
- }
722
- // For 429, respect Retry-After; otherwise back off.
723
- let delay;
724
- const retryAfter = response.status === 429 ? response.headers.get("Retry-After") : null;
725
- const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : NaN;
726
- if (!isNaN(retryAfterSeconds)) {
727
- delay = retryAfterSeconds * 1000;
728
- }
729
- else {
730
- delay = calculateBackoffDelay(retryConfig, failedAttempt - 1);
731
- }
732
- const statusError = new Error(`HTTP ${response.status}: ${response.statusText || "Request failed"}`);
733
- try {
734
- // End the failed attempt before sleeping, so a slow backoff cannot leave
735
- // an attempt open, then announce the upcoming one.
736
- lifecycle.finalize(id, method, url, { statusCode: response.status });
737
- lifecycle.retrying(id, method, url, failedAttempt, statusError, delay);
738
- // This response is being discarded, so release its stream before we sleep
739
- // rather than leaving it open across the backoff — otherwise a throttled
740
- // client holds a connection per in-flight retry and cannot reuse any of
741
- // them. The multipart transport in services/base.ts already does this.
742
- // Errors are ignored: the body may already be consumed or closed.
743
- void response.body?.cancel().catch(() => { });
744
- await sleep(delay);
745
- // The raw fetch below bypasses the middleware chain, so no downstream
746
- // middleware sees this attempt begin — start it here.
747
- //
748
- // Started after the backoff but before any work that can throw. After, so
749
- // the attempt's duration measures the request rather than the sleep;
750
- // before, so that if the auth refresh or the fetch throws, the catch has a
751
- // live attempt to finalize. Starting it later would let onRetry announce
752
- // attempt 2 and then never account for it.
753
- lifecycle.begin(id, method, url, failedAttempt + 1);
754
- const body = bodyCache.get(id) ?? null;
755
- const retryRequest = new Request(url, {
698
+ /**
699
+ * The retry loop, installed beneath the middleware chain as the client's custom
700
+ * fetch (createClient's `fetch` option). openapi-fetch calls it exactly once
701
+ * per logical request, after every onRequest middleware and before every
702
+ * onError/onResponse — so the loop runs to the operation's full declared
703
+ * maxAttempts, and no attempt ever leaves the chain, because the chain sits
704
+ * above the loop.
705
+ *
706
+ * Division of labor with the lifecycle middleware: the loop begins EVERY
707
+ * attempt and finalizes the attempts it abandons (before the backoff sleep);
708
+ * the terminal outcome — the response it returns, or the error it throws — is
709
+ * deliberately NOT finalized here. It flows up through the cache middleware,
710
+ * which may rewrite a 304 into a cached 200 + X-From-Cache, to the lifecycle
711
+ * middleware, which records the post-transform result. Finalizing in the loop
712
+ * would freeze the pre-transformation status, and the idempotent finalize
713
+ * would keep the hooks pass from correcting it.
714
+ *
715
+ * With retry disabled the loop degenerates to a single attempt but still owns
716
+ * hook emission, so start/end events are single-sourced regardless of config.
717
+ */
718
+ function createRetryingFetch(lifecycle, authStrategy, enableRetry) {
719
+ return async (request) => {
720
+ const { method, url } = request;
721
+ const retryConfig = getRetryConfigForRequest(method, url);
722
+ const effectiveConfig = {
723
+ ...retryConfig,
724
+ maxAttempts: enableRetry ? retryConfig.maxAttempts : 1,
725
+ };
726
+ // Serialize the body once, before the first send, because Request.body is
727
+ // a stream that can only be consumed once and a retry needs to replay it.
728
+ // clone() tees the stream, so attempt 1 sends the original untouched and
729
+ // replays read from this buffer. Requests that can never retry — including
730
+ // every non-idempotent POST, via NO_RETRY_CONFIG — skip the buffering.
731
+ const upperMethod = method.toUpperCase();
732
+ let bodyBuffer = null;
733
+ if (effectiveConfig.maxAttempts > 1 &&
734
+ (upperMethod === "POST" || upperMethod === "PUT" || upperMethod === "PATCH") &&
735
+ request.body) {
736
+ bodyBuffer = await request.clone().arrayBuffer();
737
+ }
738
+ let attemptRequest = request;
739
+ const makeAttempt = async (attempt) => {
740
+ if (attempt > 1) {
741
+ // Rebuild from the original request: the headers carry everything the
742
+ // onRequest middleware attached (auth, User-Agent, If-None-Match), and
743
+ // the signal keeps the caller's cancellation and the per-request
744
+ // timeout — one budget shared by all attempts and backoffs.
745
+ attemptRequest = new Request(url, {
756
746
  method,
757
747
  headers: new Headers(request.headers),
758
- body,
748
+ body: bodyBuffer,
759
749
  signal: request.signal,
760
750
  });
761
- // Refresh auth header for retry (token may have been refreshed since initial request)
762
- if (authStrategy) {
763
- await authStrategy.authenticate(retryRequest.headers);
751
+ // Refresh auth (the token may have rotated since the last attempt).
752
+ // The attempt is already begun, so a throwing refresh lands on a live
753
+ // attempt; the terminal marker carries it raw past the loop's retry
754
+ // classification to the lifecycle middleware's onError.
755
+ try {
756
+ await authStrategy.authenticate(attemptRequest.headers);
757
+ }
758
+ catch (error) {
759
+ throw new TerminalRetryError(error);
764
760
  }
765
- // Deliberately NOT finalized here. The returned response still flows
766
- // through the cache middleware, which may rewrite a 304 into a cached 200;
767
- // finalizing now would freeze the pre-transformation status and
768
- // fromCache: false, and an idempotent finalize means the hooks pass could
769
- // not correct it. The lifecycle middleware ends this attempt instead, reading
770
- // attempt 2 from the state begun above.
771
- return await fetch(retryRequest);
772
- }
773
- catch (error) {
774
- // openapi-fetch routes only the INITIAL fetch through onError, so anything
775
- // thrown in here — the retry fetch, the auth refresh, the sleep — would
776
- // otherwise escape with the attempt still open and its state stranded.
777
- lifecycle.finalize(id, method, url, {
778
- statusCode: 0,
779
- error: error instanceof Error ? error : new Error(String(error)),
780
- });
781
- lifecycle.release(id);
782
- // Rethrow the original value so its identity survives.
783
- throw error;
784
- }
785
- finally {
786
- bodyCache.delete(id);
787
761
  }
788
- },
789
- async onError({ id }) {
790
- // The initial fetch rejected, so onResponse never ran and the cached body
791
- // would leak. The lifecycle middleware owns ending the attempt.
792
- bodyCache.delete(id);
793
- return undefined;
794
- },
762
+ // globalThis.fetch resolved per attempt rather than captured at client
763
+ // creation, so test interceptors that patch it (MSW) are honored.
764
+ return globalThis.fetch(attemptRequest);
765
+ };
766
+ const emit = {
767
+ begin: (attempt) => lifecycle.begin(request, method, url, attempt),
768
+ finalize: (outcome) => lifecycle.finalize(request, method, url, outcome),
769
+ retrying: (failedAttempt, error, delayMs) => lifecycle.retrying(method, url, failedAttempt, error, delayMs),
770
+ };
771
+ try {
772
+ return await executeWithRetry(makeAttempt, effectiveConfig, emit, request.signal);
773
+ }
774
+ catch (error) {
775
+ // Unwrap the terminal marker so the original error's identity survives
776
+ // to the lifecycle middleware's onError (and to the caller).
777
+ throw error instanceof TerminalRetryError ? error.reason : error;
778
+ }
795
779
  };
796
780
  }
797
- function calculateBackoffDelay(config, attempt) {
798
- const base = config.baseDelayMs;
799
- let delay;
800
- switch (config.backoff) {
801
- case "exponential":
802
- delay = base * Math.pow(2, attempt);
803
- break;
804
- case "linear":
805
- delay = base * (attempt + 1);
806
- break;
807
- case "constant":
808
- default:
809
- delay = base;
810
- }
811
- // Add jitter (0-100ms)
812
- const jitter = Math.random() * MAX_JITTER_MS;
813
- return delay + jitter;
814
- }
815
- function sleep(ms) {
816
- return new Promise((resolve) => setTimeout(resolve, ms));
817
- }
818
781
  // =============================================================================
819
782
  // Pagination Helper
820
783
  // =============================================================================
821
784
  /**
822
785
  * Fetches all pages of a paginated resource using Link header pagination.
823
- * Automatically follows rel="next" links until no more pages exist.
786
+ * Automatically follows rel="next" links until no more pages exist, or until
787
+ * `maxPages` pages have been consumed.
788
+ *
789
+ * The cap is not optional in spirit: a Link header naming the page it was
790
+ * served from makes "until no more pages exist" never true, and the header is
791
+ * attacker-influenced (that is why `isSameOrigin` is checked below). Every
792
+ * other pagination loop in this SDK, and in the other five, is bounded the
793
+ * same way. Reaching the cap stops quietly — there is no meta channel on a
794
+ * bare `T[]` to report it, and throwing would break callers who legitimately
795
+ * have that many pages.
796
+ *
797
+ * @param maxPages - Safety cap on pages CONSUMED. Must be a positive integer;
798
+ * `0`, negatives, `NaN`, `Infinity` and non-integers all throw
799
+ * `BasecampError("usage")` before any request is made. Defaults to
800
+ * `DEFAULT_MAX_PAGES`.
801
+ * @throws {BasecampError} with `code: "usage"` if `maxPages` is not a positive
802
+ * integer.
824
803
  *
825
804
  * @example
826
805
  * ```ts
@@ -832,12 +811,22 @@ function sleep(ms) {
832
811
  * );
833
812
  * ```
834
813
  */
835
- export async function fetchAllPages(initialResponse, parse, authHeader) {
814
+ export async function fetchAllPages(initialResponse, parse, authHeader, maxPages = DEFAULT_MAX_PAGES) {
815
+ assertValidMaxPages(maxPages);
836
816
  const results = [];
837
817
  let response = initialResponse;
838
- while (true) {
818
+ for (let page = 1; page <= maxPages; page++) {
839
819
  const items = await parse(response.clone());
840
820
  results.push(...items);
821
+ // Before reading the Link header, not after fetching it. The loop consumes
822
+ // the current page at the head of the iteration and fetches the next at the
823
+ // tail, so testing the cap only in the `for` condition would let the final
824
+ // allowed iteration issue a request whose response is never parsed and
825
+ // never returned — and that request goes to a URL taken from an
826
+ // attacker-influenceable header. `maxPages` counts pages CONSUMED, matching
827
+ // BaseService.followPagination.
828
+ if (page === maxPages)
829
+ break;
841
830
  const rawNextUrl = parseNextLink(response.headers.get("Link"));
842
831
  if (!rawNextUrl)
843
832
  break;
@@ -859,6 +848,23 @@ export async function fetchAllPages(initialResponse, parse, authHeader) {
859
848
  * Async generator that yields pages of results one at a time.
860
849
  * Useful for processing large datasets without loading everything into memory.
861
850
  *
851
+ * Bounded by `maxPages` for the same reason as {@link fetchAllPages}: a Link
852
+ * header that points at its own page would otherwise yield forever.
853
+ *
854
+ * Validation is EAGER, which is why this is a plain function returning a
855
+ * generator rather than an `async function*`. The body of an `async function*`
856
+ * does not run until something iterates it, so a check written inside one would
857
+ * surface a bad `maxPages` at some later `for await` — possibly in a different
858
+ * function, on a generator that was passed along. A usage error is a programmer
859
+ * error and belongs at the call site that made it.
860
+ *
861
+ * @param maxPages - Safety cap on pages CONSUMED. Must be a positive integer;
862
+ * `0`, negatives, `NaN`, `Infinity` and non-integers all throw
863
+ * `BasecampError("usage")` from this call, before the generator is created
864
+ * and before any request is made. Defaults to `DEFAULT_MAX_PAGES`.
865
+ * @throws {BasecampError} with `code: "usage"` if `maxPages` is not a positive
866
+ * integer.
867
+ *
862
868
  * @example
863
869
  * ```ts
864
870
  * for await (const page of paginateAll(response.response, (r) => r.json() as Promise<any[]>)) {
@@ -866,11 +872,25 @@ export async function fetchAllPages(initialResponse, parse, authHeader) {
866
872
  * }
867
873
  * ```
868
874
  */
869
- export async function* paginateAll(initialResponse, parse, authHeader) {
875
+ export function paginateAll(initialResponse, parse, authHeader, maxPages = DEFAULT_MAX_PAGES) {
876
+ assertValidMaxPages(maxPages);
877
+ return paginatePages(initialResponse, parse, authHeader, maxPages);
878
+ }
879
+ /**
880
+ * The generator body behind {@link paginateAll}. Split out only so the cap can
881
+ * be validated before the generator object exists; `maxPages` is already known
882
+ * good here.
883
+ */
884
+ async function* paginatePages(initialResponse, parse, authHeader, maxPages) {
870
885
  let response = initialResponse;
871
- while (true) {
886
+ for (let page = 1; page <= maxPages; page++) {
872
887
  const items = await parse(response.clone());
873
888
  yield items;
889
+ // See {@link fetchAllPages}: breaking here rather than relying on the loop
890
+ // condition is what keeps the last allowed iteration from issuing a fetch
891
+ // whose response is never yielded.
892
+ if (page === maxPages)
893
+ break;
874
894
  const rawNextUrl = parseNextLink(response.headers.get("Link"));
875
895
  if (!rawNextUrl)
876
896
  break;