@ttctl/core 0.0.0 → 0.1.0-rc.1

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 (195) hide show
  1. package/README.md +49 -9
  2. package/dist/__generated__/gateway.d.ts +4546 -0
  3. package/dist/__generated__/gateway.d.ts.map +1 -0
  4. package/dist/__generated__/gateway.js +9 -0
  5. package/dist/__generated__/gateway.js.map +1 -0
  6. package/dist/__generated__/talent-profile-zod-schemas.d.ts +1187 -0
  7. package/dist/__generated__/talent-profile-zod-schemas.d.ts.map +1 -0
  8. package/dist/__generated__/talent-profile-zod-schemas.js +1136 -0
  9. package/dist/__generated__/talent-profile-zod-schemas.js.map +1 -0
  10. package/dist/__generated__/talent-profile.d.ts +1397 -0
  11. package/dist/__generated__/talent-profile.d.ts.map +1 -0
  12. package/dist/__generated__/talent-profile.js +9 -0
  13. package/dist/__generated__/talent-profile.js.map +1 -0
  14. package/dist/__generated__/zod-schemas.d.ts +2895 -0
  15. package/dist/__generated__/zod-schemas.d.ts.map +1 -0
  16. package/dist/__generated__/zod-schemas.js +3121 -0
  17. package/dist/__generated__/zod-schemas.js.map +1 -0
  18. package/dist/__tests__/fixtures/profile/builders.d.ts +74 -0
  19. package/dist/__tests__/fixtures/profile/builders.d.ts.map +1 -0
  20. package/dist/__tests__/fixtures/profile/builders.js +196 -0
  21. package/dist/__tests__/fixtures/profile/builders.js.map +1 -0
  22. package/dist/__tests__/fixtures/profile/data.d.ts +39 -0
  23. package/dist/__tests__/fixtures/profile/data.d.ts.map +1 -0
  24. package/dist/__tests__/fixtures/profile/data.js +230 -0
  25. package/dist/__tests__/fixtures/profile/data.js.map +1 -0
  26. package/dist/__tests__/fixtures/profile/index.d.ts +9 -0
  27. package/dist/__tests__/fixtures/profile/index.d.ts.map +1 -0
  28. package/dist/__tests__/fixtures/profile/index.js +10 -0
  29. package/dist/__tests__/fixtures/profile/index.js.map +1 -0
  30. package/dist/__tests__/fixtures/profile/types.d.ts +53 -0
  31. package/dist/__tests__/fixtures/profile/types.d.ts.map +1 -0
  32. package/dist/__tests__/fixtures/profile/types.js +4 -0
  33. package/dist/__tests__/fixtures/profile/types.js.map +1 -0
  34. package/dist/auth/errors.d.ts +82 -0
  35. package/dist/auth/errors.d.ts.map +1 -0
  36. package/dist/auth/errors.js +68 -0
  37. package/dist/auth/errors.js.map +1 -0
  38. package/dist/auth.d.ts +192 -0
  39. package/dist/auth.d.ts.map +1 -0
  40. package/dist/auth.js +294 -0
  41. package/dist/auth.js.map +1 -0
  42. package/dist/config.d.ts +212 -0
  43. package/dist/config.d.ts.map +1 -0
  44. package/dist/config.js +349 -0
  45. package/dist/config.js.map +1 -0
  46. package/dist/configLock.d.ts +50 -0
  47. package/dist/configLock.d.ts.map +1 -0
  48. package/dist/configLock.js +88 -0
  49. package/dist/configLock.js.map +1 -0
  50. package/dist/configWriter.d.ts +97 -0
  51. package/dist/configWriter.d.ts.map +1 -0
  52. package/dist/configWriter.js +687 -0
  53. package/dist/configWriter.js.map +1 -0
  54. package/dist/index.d.ts +37 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +28 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/kill-switch.d.ts +161 -0
  59. package/dist/kill-switch.d.ts.map +1 -0
  60. package/dist/kill-switch.js +235 -0
  61. package/dist/kill-switch.js.map +1 -0
  62. package/dist/lib/date.d.ts +58 -0
  63. package/dist/lib/date.d.ts.map +1 -0
  64. package/dist/lib/date.js +104 -0
  65. package/dist/lib/date.js.map +1 -0
  66. package/dist/lib/diagnostic-log.d.ts +159 -0
  67. package/dist/lib/diagnostic-log.d.ts.map +1 -0
  68. package/dist/lib/diagnostic-log.js +186 -0
  69. package/dist/lib/diagnostic-log.js.map +1 -0
  70. package/dist/lib/package-version.d.ts +19 -0
  71. package/dist/lib/package-version.d.ts.map +1 -0
  72. package/dist/lib/package-version.js +38 -0
  73. package/dist/lib/package-version.js.map +1 -0
  74. package/dist/lib/redact.d.ts +153 -0
  75. package/dist/lib/redact.d.ts.map +1 -0
  76. package/dist/lib/redact.js +207 -0
  77. package/dist/lib/redact.js.map +1 -0
  78. package/dist/lib/text.d.ts +14 -0
  79. package/dist/lib/text.d.ts.map +1 -0
  80. package/dist/lib/text.js +21 -0
  81. package/dist/lib/text.js.map +1 -0
  82. package/dist/lib/wire-shape.d.ts +131 -0
  83. package/dist/lib/wire-shape.d.ts.map +1 -0
  84. package/dist/lib/wire-shape.js +376 -0
  85. package/dist/lib/wire-shape.js.map +1 -0
  86. package/dist/onepassword.d.ts +29 -0
  87. package/dist/onepassword.d.ts.map +1 -0
  88. package/dist/onepassword.js +112 -0
  89. package/dist/onepassword.js.map +1 -0
  90. package/dist/services/_shared/transport.d.ts +148 -0
  91. package/dist/services/_shared/transport.d.ts.map +1 -0
  92. package/dist/services/_shared/transport.js +102 -0
  93. package/dist/services/_shared/transport.js.map +1 -0
  94. package/dist/services/applications/index.d.ts +210 -0
  95. package/dist/services/applications/index.d.ts.map +1 -0
  96. package/dist/services/applications/index.js +240 -0
  97. package/dist/services/applications/index.js.map +1 -0
  98. package/dist/services/availability/index.d.ts +254 -0
  99. package/dist/services/availability/index.d.ts.map +1 -0
  100. package/dist/services/availability/index.js +310 -0
  101. package/dist/services/availability/index.js.map +1 -0
  102. package/dist/services/contracts/index.d.ts +132 -0
  103. package/dist/services/contracts/index.d.ts.map +1 -0
  104. package/dist/services/contracts/index.js +211 -0
  105. package/dist/services/contracts/index.js.map +1 -0
  106. package/dist/services/engagements/index.d.ts +504 -0
  107. package/dist/services/engagements/index.d.ts.map +1 -0
  108. package/dist/services/engagements/index.js +613 -0
  109. package/dist/services/engagements/index.js.map +1 -0
  110. package/dist/services/jobs/index.d.ts +490 -0
  111. package/dist/services/jobs/index.d.ts.map +1 -0
  112. package/dist/services/jobs/index.js +753 -0
  113. package/dist/services/jobs/index.js.map +1 -0
  114. package/dist/services/payments/index.d.ts +415 -0
  115. package/dist/services/payments/index.d.ts.map +1 -0
  116. package/dist/services/payments/index.js +636 -0
  117. package/dist/services/payments/index.js.map +1 -0
  118. package/dist/services/profile/__tests__/fixtures.d.ts +214 -0
  119. package/dist/services/profile/__tests__/fixtures.d.ts.map +1 -0
  120. package/dist/services/profile/__tests__/fixtures.js +176 -0
  121. package/dist/services/profile/__tests__/fixtures.js.map +1 -0
  122. package/dist/services/profile/basic/index.d.ts +390 -0
  123. package/dist/services/profile/basic/index.d.ts.map +1 -0
  124. package/dist/services/profile/basic/index.js +1007 -0
  125. package/dist/services/profile/basic/index.js.map +1 -0
  126. package/dist/services/profile/certifications/index.d.ts +74 -0
  127. package/dist/services/profile/certifications/index.d.ts.map +1 -0
  128. package/dist/services/profile/certifications/index.js +169 -0
  129. package/dist/services/profile/certifications/index.js.map +1 -0
  130. package/dist/services/profile/education/index.d.ts +73 -0
  131. package/dist/services/profile/education/index.d.ts.map +1 -0
  132. package/dist/services/profile/education/index.js +168 -0
  133. package/dist/services/profile/education/index.js.map +1 -0
  134. package/dist/services/profile/employment/index.d.ts +111 -0
  135. package/dist/services/profile/employment/index.d.ts.map +1 -0
  136. package/dist/services/profile/employment/index.js +202 -0
  137. package/dist/services/profile/employment/index.js.map +1 -0
  138. package/dist/services/profile/external/index.d.ts +219 -0
  139. package/dist/services/profile/external/index.d.ts.map +1 -0
  140. package/dist/services/profile/external/index.js +560 -0
  141. package/dist/services/profile/external/index.js.map +1 -0
  142. package/dist/services/profile/index.d.ts +24 -0
  143. package/dist/services/profile/index.d.ts.map +1 -0
  144. package/dist/services/profile/index.js +26 -0
  145. package/dist/services/profile/index.js.map +1 -0
  146. package/dist/services/profile/industries/index.d.ts +130 -0
  147. package/dist/services/profile/industries/index.d.ts.map +1 -0
  148. package/dist/services/profile/industries/index.js +292 -0
  149. package/dist/services/profile/industries/index.js.map +1 -0
  150. package/dist/services/profile/portfolio/index.d.ts +352 -0
  151. package/dist/services/profile/portfolio/index.d.ts.map +1 -0
  152. package/dist/services/profile/portfolio/index.js +833 -0
  153. package/dist/services/profile/portfolio/index.js.map +1 -0
  154. package/dist/services/profile/resume/index.d.ts +60 -0
  155. package/dist/services/profile/resume/index.d.ts.map +1 -0
  156. package/dist/services/profile/resume/index.js +212 -0
  157. package/dist/services/profile/resume/index.js.map +1 -0
  158. package/dist/services/profile/reviews/index.d.ts +137 -0
  159. package/dist/services/profile/reviews/index.d.ts.map +1 -0
  160. package/dist/services/profile/reviews/index.js +431 -0
  161. package/dist/services/profile/reviews/index.js.map +1 -0
  162. package/dist/services/profile/shared.d.ts +127 -0
  163. package/dist/services/profile/shared.d.ts.map +1 -0
  164. package/dist/services/profile/shared.js +155 -0
  165. package/dist/services/profile/shared.js.map +1 -0
  166. package/dist/services/profile/skills/index.d.ts +212 -0
  167. package/dist/services/profile/skills/index.d.ts.map +1 -0
  168. package/dist/services/profile/skills/index.js +461 -0
  169. package/dist/services/profile/skills/index.js.map +1 -0
  170. package/dist/services/profile/visas/index.d.ts +74 -0
  171. package/dist/services/profile/visas/index.d.ts.map +1 -0
  172. package/dist/services/profile/visas/index.js +306 -0
  173. package/dist/services/profile/visas/index.js.map +1 -0
  174. package/dist/services/timesheet/index.d.ts +326 -0
  175. package/dist/services/timesheet/index.d.ts.map +1 -0
  176. package/dist/services/timesheet/index.js +324 -0
  177. package/dist/services/timesheet/index.js.map +1 -0
  178. package/dist/services/translations.d.ts +79 -0
  179. package/dist/services/translations.d.ts.map +1 -0
  180. package/dist/services/translations.js +136 -0
  181. package/dist/services/translations.js.map +1 -0
  182. package/dist/transport-resilience.d.ts +136 -0
  183. package/dist/transport-resilience.d.ts.map +1 -0
  184. package/dist/transport-resilience.js +247 -0
  185. package/dist/transport-resilience.js.map +1 -0
  186. package/dist/transport.d.ts +408 -0
  187. package/dist/transport.d.ts.map +1 -0
  188. package/dist/transport.js +691 -0
  189. package/dist/transport.js.map +1 -0
  190. package/dist/types.d.ts +41 -0
  191. package/dist/types.d.ts.map +1 -0
  192. package/dist/types.js +18 -0
  193. package/dist/types.js.map +1 -0
  194. package/package.json +40 -12
  195. package/index.js +0 -7
@@ -0,0 +1,691 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Oleksii PELYKH
3
+ import { fetch as wreqFetch } from "node-wreq";
4
+ import { request as undiciRequest } from "undici";
5
+ import { TtctlError } from "./auth/errors.js";
6
+ import { logTransportRequest, logTransportResponse, logTransportRetry } from "./lib/diagnostic-log.js";
7
+ import { classifyTransportError, combineSignals, computeBackoffDelay, isRetryableStatus, parseRetryAfter, readTransportConfig, sleepUnlessAborted, TransportError, } from "./transport-resilience.js";
8
+ import { SURFACES_REQUIRING_IMPERSONATION, SURFACE_ENDPOINTS } from "./types.js";
9
+ /**
10
+ * Thrown when an impersonated surface returns HTTP 403.
11
+ *
12
+ * Empirically, Chrome TLS impersonation alone passes Cloudflare on the
13
+ * surfaces TTCtl currently uses (`talent-profile`, `scheduler`) — see
14
+ * `hq/engineering/adr/ADR-005-auth-model.md`. A 403 here therefore
15
+ * means Cloudflare has flipped a feature flag (e.g. activated a Turnstile
16
+ * challenge or a new bot-management heuristic) that we don't currently
17
+ * handle. There is no documented manual workaround; the user is asked to
18
+ * file an issue so we can investigate.
19
+ *
20
+ * Refined under issue #77 to extend `TtctlError`. Carries the stable
21
+ * `code = 'CF_403_CLEARANCE'` and a short `recovery` hint that the CLI /
22
+ * MCP surfaces render alongside the existing multi-line `message`.
23
+ *
24
+ * See also `Cf403PersistentError` (defined for future use when an explicit
25
+ * retry-with-fresh-clearance heuristic is added — currently TTCtl cannot
26
+ * distinguish "clearance expired" from "persistent block" at runtime, so
27
+ * `Cf403Error` is the only class actually thrown by the transport).
28
+ */
29
+ export class Cf403Error extends TtctlError {
30
+ surface;
31
+ endpoint;
32
+ name = "Cf403Error";
33
+ code = "CF_403_CLEARANCE";
34
+ recovery = "Cloudflare returned 403. Try the request again; if the block persists, file an issue at " +
35
+ "https://github.com/alexey-pelykh/ttctl/issues with the surface name and a timestamp.";
36
+ constructor(surface, endpoint) {
37
+ super(Cf403Error.formatMessage(surface, endpoint));
38
+ this.surface = surface;
39
+ this.endpoint = endpoint;
40
+ }
41
+ static formatMessage(surface, endpoint) {
42
+ return [
43
+ `Cloudflare returned HTTP 403 from surface "${surface}" (${endpoint}).`,
44
+ "",
45
+ "Empirically, Chrome TLS impersonation alone passes Cloudflare in the happy path on this surface. " +
46
+ "A 403 here means Cloudflare's bot-management has flipped a feature flag we don't currently handle.",
47
+ "",
48
+ `Please file an issue at https://github.com/alexey-pelykh/ttctl/issues with the surface name ("${surface}") ` +
49
+ "and a timestamp so we can investigate.",
50
+ ].join("\n");
51
+ }
52
+ }
53
+ /**
54
+ * Thrown when Cloudflare is *persistently* blocking an impersonated surface
55
+ * — i.e. clearance refresh and re-attempts have failed and the only
56
+ * remaining recovery is the cookie-jar break-glass path documented in
57
+ * `SECURITY.md`.
58
+ *
59
+ * **Currently defined for future use.** TTCtl's transport has no automated
60
+ * retry-with-fresh-clearance heuristic, so a single 403 cannot be
61
+ * distinguished from a persistent block at runtime. The transport throws
62
+ * the more general `Cf403Error`. When a future iteration adds retry +
63
+ * clearance refresh, that layer will re-classify a confirmed-persistent
64
+ * block to `Cf403PersistentError`. See issue #77 § Out of Scope.
65
+ */
66
+ export class Cf403PersistentError extends TtctlError {
67
+ surface;
68
+ endpoint;
69
+ name = "Cf403PersistentError";
70
+ code = "CF_403_PERSISTENT";
71
+ recovery = "Cloudflare is persistently blocking this surface. The cookie-jar auxiliary auth path is the only " +
72
+ "remaining recovery — see SECURITY.md for break-glass details, and file an issue at " +
73
+ "https://github.com/alexey-pelykh/ttctl/issues so we can investigate.";
74
+ constructor(surface, endpoint, message = `Cloudflare persistently blocked surface "${surface}" (${endpoint}).`) {
75
+ super(message);
76
+ this.surface = surface;
77
+ this.endpoint = endpoint;
78
+ }
79
+ }
80
+ /**
81
+ * Thrown when the scheduler bearer token has expired.
82
+ *
83
+ * **Scaffolded for post-v1 scheduler-surface coverage.** TTCtl currently
84
+ * has no scheduler operations wired in; transport routes scheduler →
85
+ * impersonated, but no service module issues scheduler GraphQL calls. When
86
+ * scheduler coverage lands (post-v1), this class will be thrown by the
87
+ * scheduler service module on bearer-expiry detection.
88
+ *
89
+ * `autoRecover = true` signals to the transport layer that an automated
90
+ * re-mint via `GetTopSchedulerToken` should be attempted once before
91
+ * surfacing this error. The auto-recovery contract is intentionally
92
+ * defined here so callers can plan against it; the actual re-mint
93
+ * orchestration ships with the scheduler surface implementation.
94
+ */
95
+ export class SchedulerBearerExpired extends TtctlError {
96
+ name = "SchedulerBearerExpired";
97
+ code = "SCHEDULER_BEARER_EXPIRED";
98
+ recovery = "Scheduler bearer token expired; will be re-minted automatically on next call.";
99
+ autoRecover = true;
100
+ constructor(message = "Scheduler bearer token expired.") {
101
+ super(message);
102
+ }
103
+ }
104
+ /**
105
+ * Thrown when a transport receives an HTTP 3xx redirect carrying a
106
+ * `Location` header.
107
+ *
108
+ * TTCtl talks to fixed GraphQL endpoints; none of them legitimately
109
+ * redirect. A 3xx is therefore an anomaly — most plausibly Cloudflare or
110
+ * Toptal's edge flipping an infrastructure flag (mirror of the
111
+ * {@link Cf403Error} case), or a misconfigured DNS / edge change.
112
+ *
113
+ * Defense-in-depth posture (issue #268): every transport entry point has a
114
+ * no-follow redirect policy — `redirect: "manual"` pinned explicitly on
115
+ * `node-wreq`, and structurally on `undici` (its `request()` on the
116
+ * default dispatcher never follows redirects; redirect following is an
117
+ * opt-in interceptor TTCtl does not install). A 3xx is therefore returned
118
+ * verbatim rather than followed, and {@link executeWithResilience}
119
+ * inspects the status and throws this typed error rather than handing the
120
+ * redirect body back to a caller that would not know how to interpret it.
121
+ *
122
+ * Refusing to follow is the security-relevant property: a followed
123
+ * cross-origin redirect would leak the request body (operation name +
124
+ * variables) to the redirect target even though `node-wreq` strips the
125
+ * `authorization` header on cross-origin hops. Pinning `node-wreq`'s
126
+ * policy keeps that guarantee from depending on a transitive library
127
+ * default (it ships `redirect: "follow"` by default).
128
+ *
129
+ * Carries `surface`, `endpoint`, `status`, and `location` — the
130
+ * `Location` header value is a URL, not a credential, so it is safe to
131
+ * surface in the message and in diagnostic traces for operator triage.
132
+ */
133
+ export class RedirectError extends TtctlError {
134
+ surface;
135
+ endpoint;
136
+ status;
137
+ location;
138
+ name = "RedirectError";
139
+ code = "REDIRECT_REFUSED";
140
+ recovery = "The Toptal API returned an unexpected HTTP redirect. GraphQL endpoints are not expected to redirect; " +
141
+ "this likely indicates a Toptal infrastructure change. File an issue at " +
142
+ "https://github.com/alexey-pelykh/ttctl/issues with the surface name, a timestamp, and the Location value " +
143
+ "from the error message.";
144
+ constructor(surface, endpoint, status, location) {
145
+ super(RedirectError.formatMessage(surface, endpoint, status, location));
146
+ this.surface = surface;
147
+ this.endpoint = endpoint;
148
+ this.status = status;
149
+ this.location = location;
150
+ }
151
+ static formatMessage(surface, endpoint, status, location) {
152
+ return [
153
+ `Transport refused to follow an HTTP ${status.toString()} redirect from surface "${surface}" (${endpoint}).`,
154
+ `Location: ${location}`,
155
+ "",
156
+ "TTCtl's GraphQL endpoints are not expected to redirect. Following the redirect is refused as a " +
157
+ "defense-in-depth measure — a followed redirect would carry the request body to the redirect target.",
158
+ "",
159
+ `Please file an issue at https://github.com/alexey-pelykh/ttctl/issues with the surface name ("${surface}"), ` +
160
+ "a timestamp, and the Location value above so we can investigate.",
161
+ ].join("\n");
162
+ }
163
+ }
164
+ /**
165
+ * HTTP status codes that signal a redirect when paired with a `Location`
166
+ * header. Matches `node-wreq`'s own `REDIRECT_STATUS_CODES` set
167
+ * (300, 301, 302, 303, 307, 308). `304 Not Modified` is intentionally
168
+ * excluded — it is a cache-validation response, not a redirect, and never
169
+ * carries a `Location`.
170
+ */
171
+ const REDIRECT_STATUS_CODES = new Set([300, 301, 302, 303, 307, 308]);
172
+ /**
173
+ * Case-insensitive header lookup over a plain header object. HTTP header
174
+ * names are case-insensitive (RFC 9110 § 5.1), and the two transports
175
+ * normalise differently: `undici` lowercases response header keys, while
176
+ * `node-wreq`'s `Headers.toObject()` preserves the server's original
177
+ * casing. The redirect check must find `Location` regardless of which
178
+ * transport produced the response object.
179
+ */
180
+ function getHeaderInsensitive(headers, name) {
181
+ const lower = name.toLowerCase();
182
+ for (const [key, value] of Object.entries(headers)) {
183
+ if (key.toLowerCase() === lower)
184
+ return value;
185
+ }
186
+ return undefined;
187
+ }
188
+ /**
189
+ * Return the `Location` value if `status` + `headers` describe an HTTP
190
+ * redirect — a {@link REDIRECT_STATUS_CODES} status carrying a `Location`
191
+ * header — and `undefined` otherwise.
192
+ *
193
+ * Shared by {@link executeWithResilience} and the photo-upload path's
194
+ * hand-rolled `node-wreq` fetch (`multipartImpersonatedFetch` in
195
+ * `services/profile/basic/index.ts`) so every transport path enforces the
196
+ * same no-follow posture (issue #268). A 3xx WITHOUT a `Location` header
197
+ * is not a redirect — there is nothing to follow — so it returns
198
+ * `undefined` and the caller returns the response verbatim.
199
+ *
200
+ * The caller decides what to do with a positive result: throw a
201
+ * {@link RedirectError}. Detection and reaction are split so each call
202
+ * site can emit its own diagnostic-trace record before throwing, matching
203
+ * how each already handles the {@link Cf403Error} case.
204
+ */
205
+ export function getRedirectLocation(status, headers) {
206
+ if (!REDIRECT_STATUS_CODES.has(status))
207
+ return undefined;
208
+ return getHeaderInsensitive(headers, "location");
209
+ }
210
+ /**
211
+ * TLS-impersonation profile. Pinned as a coupled pair with `USER_AGENT` —
212
+ * see the `tls-fingerprinting` skill on identity-catalog freshness: WAFs
213
+ * cross-validate the User-Agent string against the JA4 hash, so the profile
214
+ * and UA must both name the same Chrome version. Bump them together when
215
+ * `node-wreq` publishes a newer profile.
216
+ *
217
+ * Currently `chrome_145` because that is the freshest profile published in
218
+ * `node-wreq@2.2.1`. The Rust upstream `wreq` crate has `chrome_146` in
219
+ * its release-candidate stream but the Node bindings have not yet shipped a
220
+ * matching release. Track upstream and bump.
221
+ */
222
+ export const IMPERSONATE_PROFILE = "chrome_145";
223
+ const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
224
+ "(KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
225
+ const COMMON_HEADERS = {
226
+ accept: "*/*",
227
+ "accept-language": "en-US,en;q=0.9",
228
+ "content-type": "application/json",
229
+ origin: "https://talent.toptal.com",
230
+ referer: "https://talent.toptal.com/",
231
+ "sec-fetch-site": "same-site",
232
+ "user-agent": USER_AGENT,
233
+ // Mobile-app fingerprint alignment: the official Toptal mobile client sets
234
+ // this on every gateway call. Not load-bearing for auth (empirically the
235
+ // Token header alone is sufficient — see issue #59), but copying it into
236
+ // outgoing requests reduces fingerprint divergence over time and limits the
237
+ // surface area for header-shape heuristics to flag this client.
238
+ "x-toptal-analytics-origin": "mobile",
239
+ };
240
+ /**
241
+ * Token redaction marker used by {@link buildDryRunPreview} when an
242
+ * `authToken` is present on the source request. Exposed as a constant so
243
+ * tests assert on the exact wire shape and so future code never reaches
244
+ * for the bearer literal by mistake.
245
+ */
246
+ export const DRY_RUN_REDACTED_AUTHORIZATION = "Token token=<redacted>";
247
+ /**
248
+ * Build a {@link DryRunPreview} from a {@link TransportRequest} without
249
+ * invoking any transport. Pure — no I/O, no allocations beyond the
250
+ * returned object.
251
+ *
252
+ * The headers projection mirrors what {@link stockTransport} and
253
+ * {@link impersonatedTransport} would have set (`COMMON_HEADERS` plus
254
+ * `authorization` when `authToken` is present), with the bearer value
255
+ * redacted to {@link DRY_RUN_REDACTED_AUTHORIZATION}. The transport
256
+ * classification is derived from
257
+ * {@link SURFACES_REQUIRING_IMPERSONATION} so changes to that set
258
+ * propagate automatically.
259
+ *
260
+ * Mutation entry points call this AFTER they've populated their
261
+ * `body.variables` with placeholder substitutions for any fields that
262
+ * would have been resolved at execution time (e.g. `profileId`) —
263
+ * keeping the read-side transport call out of the dry-run path entirely.
264
+ * See `set()` in `services/profile/basic/index.ts` for the pattern.
265
+ */
266
+ export function buildDryRunPreview(req) {
267
+ const surface = req.surface;
268
+ const transport = SURFACES_REQUIRING_IMPERSONATION.has(surface) ? "impersonated" : "stock";
269
+ const headers = { ...COMMON_HEADERS };
270
+ if (req.authToken !== undefined) {
271
+ headers["authorization"] = DRY_RUN_REDACTED_AUTHORIZATION;
272
+ }
273
+ return {
274
+ surface,
275
+ transport,
276
+ endpoint: SURFACE_ENDPOINTS[surface],
277
+ operationName: req.body.operationName,
278
+ variables: req.body.variables ?? {},
279
+ headers,
280
+ };
281
+ }
282
+ /**
283
+ * Choose transport per surface. The mobile gateway accepts stock TLS;
284
+ * `talent-profile` and `scheduler` require Chrome TLS-fingerprint impersonation
285
+ * to clear Cloudflare's bot-management.
286
+ */
287
+ export async function callSurface(req) {
288
+ if (SURFACES_REQUIRING_IMPERSONATION.has(req.surface)) {
289
+ return impersonatedTransport(req);
290
+ }
291
+ return stockTransport(req);
292
+ }
293
+ /**
294
+ * Stock HTTP via undici. Used for the mobile gateway endpoint, which doesn't
295
+ * gate on TLS fingerprint.
296
+ *
297
+ * Resilience (#229): wraps the network call in a retry loop that handles
298
+ * HTTP 429 (with `Retry-After` honoring) and 5xx with bounded exponential
299
+ * backoff, applies a per-attempt timeout, and propagates the caller's
300
+ * `AbortSignal` so an MCP client cancel actually tears down the in-flight
301
+ * request. Final failures surface as a typed {@link TransportError}.
302
+ */
303
+ export async function stockTransport(req) {
304
+ const url = SURFACE_ENDPOINTS[req.surface];
305
+ const headers = { ...COMMON_HEADERS };
306
+ if (req.authToken)
307
+ headers["authorization"] = `Token token=${req.authToken}`;
308
+ // Diagnostic log hook (issue #139). No-op when --verbose/--debug
309
+ // are absent; otherwise emits a redacted request line/record to
310
+ // stderr. Records start time outside the disabled-fast-path so
311
+ // performance.now() is paid only when a logger is active.
312
+ logTransportRequest({
313
+ surface: req.surface,
314
+ endpoint: url,
315
+ transport: "stock",
316
+ method: "POST",
317
+ operationName: req.body.operationName,
318
+ headers,
319
+ body: req.body,
320
+ });
321
+ const body = JSON.stringify(req.body);
322
+ // No-follow redirect policy (issue #268). `undici.request()` with the
323
+ // default global dispatcher does NOT follow redirects — redirect
324
+ // following is an opt-in interceptor (`undici.interceptors.redirect`)
325
+ // that TTCtl never installs. The guarantee is structural, not a
326
+ // default-value that a future major could flip, so there is no explicit
327
+ // `redirect` / `maxRedirections` option to pin here (the request-level
328
+ // options type has none). A 3xx response is surfaced verbatim and
329
+ // rejected by `executeWithResilience` as a typed `RedirectError`.
330
+ return executeWithResilience(req, url, async (signal) => {
331
+ const res = await undiciRequest(url, {
332
+ method: "POST",
333
+ headers,
334
+ body,
335
+ signal,
336
+ headersTimeout: readTransportConfig().timeoutMs,
337
+ bodyTimeout: readTransportConfig().timeoutMs,
338
+ });
339
+ const text = await res.body.text();
340
+ let parsed;
341
+ try {
342
+ parsed = JSON.parse(text);
343
+ }
344
+ catch {
345
+ parsed = text;
346
+ }
347
+ const responseHeaders = {};
348
+ for (const [k, v] of Object.entries(res.headers)) {
349
+ if (Array.isArray(v)) {
350
+ responseHeaders[k] = v.join(", ");
351
+ }
352
+ else if (typeof v === "string") {
353
+ responseHeaders[k] = v;
354
+ }
355
+ }
356
+ return { status: res.statusCode, headers: responseHeaders, body: parsed };
357
+ });
358
+ }
359
+ /**
360
+ * Impersonated HTTP via `node-wreq` (Rust + BoringSSL). Used for the
361
+ * Cloudflare-protected `talent-profile` and `scheduler` surfaces.
362
+ *
363
+ * The `browser` option drives node-wreq's TLS ClientHello and HTTP/2
364
+ * SETTINGS frame to match the bundled Chrome profile (see `IMPERSONATE_PROFILE`).
365
+ * Empirically this fingerprint alone clears Cloudflare on the surfaces TTCtl
366
+ * uses; no `cf_clearance` cookie is required in the happy path.
367
+ *
368
+ * Header-tuple ordering is left as a future tightening — currently we pass
369
+ * a plain `Record<string, string>` matching `stockTransport`'s shape so the
370
+ * two transports stay symmetric. JA4H header-name ordering is a secondary
371
+ * detection vector relative to JA4 / Akamai HTTP/2; revisit if empirical
372
+ * blocks indicate it matters.
373
+ *
374
+ * Resilience (#229): wraps the network call in the same retry / timeout /
375
+ * abort loop as {@link stockTransport}. `Cf403Error` propagates as a
376
+ * non-retryable typed error — the 403 is signalled to Cloudflare's
377
+ * bot-management WAF, not a transient condition.
378
+ */
379
+ export async function impersonatedTransport(req) {
380
+ const url = SURFACE_ENDPOINTS[req.surface];
381
+ const headers = { ...COMMON_HEADERS };
382
+ if (req.authToken)
383
+ headers["authorization"] = `Token token=${req.authToken}`;
384
+ // Diagnostic log hook (issue #139); see stockTransport for the
385
+ // disabled-fast-path rationale.
386
+ logTransportRequest({
387
+ surface: req.surface,
388
+ endpoint: url,
389
+ transport: "impersonated",
390
+ method: "POST",
391
+ operationName: req.body.operationName,
392
+ headers,
393
+ body: req.body,
394
+ });
395
+ const body = JSON.stringify(req.body);
396
+ return executeWithResilience(req, url, async (signal, startMs) => {
397
+ const res = await wreqFetch(url, {
398
+ method: "POST",
399
+ headers,
400
+ body,
401
+ browser: IMPERSONATE_PROFILE,
402
+ signal,
403
+ timeout: readTransportConfig().timeoutMs,
404
+ connectTimeout: readTransportConfig().connectMs,
405
+ // No-follow redirect policy (issue #268). `node-wreq` defaults to
406
+ // `redirect: "follow"` (up to 20 hops). It does strip the
407
+ // `authorization` header on cross-origin hops, but a followed
408
+ // redirect would still carry the request body (operation name +
409
+ // variables) to the redirect target. Pinning `"manual"` returns the
410
+ // 3xx verbatim so `executeWithResilience` can reject it as a typed
411
+ // `RedirectError` — and keeps the no-leak guarantee from depending
412
+ // on a transitive library default.
413
+ redirect: "manual",
414
+ });
415
+ const responseHeaders = res.headers.toObject();
416
+ if (res.status === 403) {
417
+ // Capture the 403 response shape in the diagnostic trace BEFORE
418
+ // throwing — the caller's exception handler does not see the wire
419
+ // details and operators investigating a Cloudflare block need the
420
+ // headers (cf-ray, cf-mitigated) to paste into a triage issue.
421
+ logTransportResponse({
422
+ surface: req.surface,
423
+ endpoint: url,
424
+ operationName: req.body.operationName,
425
+ status: 403,
426
+ headers: responseHeaders,
427
+ body: null,
428
+ elapsedMs: performance.now() - startMs,
429
+ });
430
+ throw new Cf403Error(req.surface, url);
431
+ }
432
+ const text = await res.text();
433
+ let parsed;
434
+ try {
435
+ parsed = JSON.parse(text);
436
+ }
437
+ catch {
438
+ parsed = text;
439
+ }
440
+ return {
441
+ status: res.status,
442
+ headers: responseHeaders,
443
+ body: parsed,
444
+ };
445
+ });
446
+ }
447
+ /**
448
+ * Build a `globalThis.FormData` payload conforming to the GraphQL multipart
449
+ * request spec (https://github.com/jaydenseric/graphql-multipart-request-spec).
450
+ * The wire layout is:
451
+ *
452
+ * ```
453
+ * --boundary
454
+ * Content-Disposition: form-data; name="operations"
455
+ * <JSON-encoded { operationName, query, variables }>
456
+ *
457
+ * --boundary
458
+ * Content-Disposition: form-data; name="map"
459
+ * <JSON-encoded { "0": ["variables.input.file"], ... }>
460
+ *
461
+ * --boundary
462
+ * Content-Disposition: form-data; name="0"; filename="<filename>"
463
+ * Content-Type: <contentType>
464
+ * <binary>
465
+ *
466
+ * --boundary--
467
+ * ```
468
+ *
469
+ * `node-wreq`'s `BodyInit` accepts `FormData` directly (verified against
470
+ * `node-wreq@2.2.1`'s `dist/types/shared.d.ts` — `BodyInit` includes
471
+ * `FormData`). When the body is a `FormData`, the runtime sets the
472
+ * `Content-Type: multipart/form-data; boundary=...` header automatically;
473
+ * the caller should NOT pre-set a JSON content-type or it will be
474
+ * overwritten with the boundary-aware multipart one.
475
+ *
476
+ * Pure function — no I/O. Tests construct expected `FormData` instances
477
+ * and inspect the entries via `for-of` iteration.
478
+ */
479
+ export function buildGraphQLMultipart(body, files, map) {
480
+ const form = new FormData();
481
+ form.append("operations", JSON.stringify(body));
482
+ form.append("map", JSON.stringify(map));
483
+ for (const [slot, file] of Object.entries(files)) {
484
+ const blob = new Blob([new Uint8Array(file.content)], {
485
+ type: file.contentType ?? "application/octet-stream",
486
+ });
487
+ form.append(slot, blob, file.filename);
488
+ }
489
+ return form;
490
+ }
491
+ /**
492
+ * Multipart variant of {@link impersonatedTransport}. Sends a
493
+ * GraphQL-multipart-spec request through the impersonated transport so
494
+ * file-upload mutations (`uploadResume`, `uploadPortfolioCover`,
495
+ * `uploadPortfolioFile`) clear Cloudflare on the `talent-profile` surface.
496
+ *
497
+ * Why a separate function rather than overloading `impersonatedTransport`:
498
+ * the JSON path sets `content-type: application/json` and stringifies the
499
+ * body; the multipart path lets the runtime supply the multipart
500
+ * content-type with its own boundary and passes the `FormData` through
501
+ * unchanged. The two paths have different body wire formats and different
502
+ * header expectations, so they are kept as separate functions for clarity.
503
+ *
504
+ * Errors:
505
+ * - `Cf403Error` on HTTP 403 (Cloudflare bot-management has tightened — see
506
+ * the `Cf403Error` doc-comment for the recovery hint).
507
+ * - All other transport-level failures propagate as the underlying
508
+ * `node-wreq` error; service callers wrap them in their domain-specific
509
+ * `*Error` with `code: 'NETWORK_ERROR'`.
510
+ */
511
+ export async function impersonatedMultipartTransport(req) {
512
+ const url = SURFACE_ENDPOINTS[req.surface];
513
+ const headers = { ...COMMON_HEADERS };
514
+ // Strip the JSON content-type — when the body is a `FormData`, node-wreq
515
+ // (like the platform `fetch`) sets the multipart/form-data content-type
516
+ // with its own boundary parameter. Leaving the JSON one would either
517
+ // get overwritten silently or, worse, lock the runtime onto a header
518
+ // that doesn't match the wire body and Cloudflare flags as malformed.
519
+ delete headers["content-type"];
520
+ if (req.authToken)
521
+ headers["authorization"] = `Token token=${req.authToken}`;
522
+ // Diagnostic log hook (issue #139). Multipart binary payloads are
523
+ // intentionally NOT logged — only the file slot labels + map are
524
+ // surfaced, which is enough to understand what was uploaded without
525
+ // dumping arbitrary bytes into a terminal.
526
+ logTransportRequest({
527
+ surface: req.surface,
528
+ endpoint: url,
529
+ transport: "impersonated-multipart",
530
+ method: "POST",
531
+ operationName: req.body.operationName,
532
+ headers,
533
+ body: req.body,
534
+ multipart: { files: Object.keys(req.files), map: req.map },
535
+ });
536
+ return executeWithResilience(req, url, async (signal, startMs) => {
537
+ // FormData is rebuilt per attempt — the underlying `Blob` slices read
538
+ // a fresh stream each time, so a retry of an aborted upload does not
539
+ // attempt to replay an already-consumed body.
540
+ const formData = buildGraphQLMultipart(req.body, req.files, req.map);
541
+ const res = await wreqFetch(url, {
542
+ method: "POST",
543
+ headers,
544
+ body: formData,
545
+ browser: IMPERSONATE_PROFILE,
546
+ signal,
547
+ timeout: readTransportConfig().timeoutMs,
548
+ connectTimeout: readTransportConfig().connectMs,
549
+ // No-follow redirect policy (issue #268). File-upload mutations are
550
+ // the highest-impact body-exfiltration vector if redirect handling
551
+ // weakens — see the rationale on the JSON `impersonatedTransport`
552
+ // call site above.
553
+ redirect: "manual",
554
+ });
555
+ const responseHeaders = res.headers.toObject();
556
+ if (res.status === 403) {
557
+ logTransportResponse({
558
+ surface: req.surface,
559
+ endpoint: url,
560
+ operationName: req.body.operationName,
561
+ status: 403,
562
+ headers: responseHeaders,
563
+ body: null,
564
+ elapsedMs: performance.now() - startMs,
565
+ });
566
+ throw new Cf403Error(req.surface, url);
567
+ }
568
+ const text = await res.text();
569
+ let parsed;
570
+ try {
571
+ parsed = JSON.parse(text);
572
+ }
573
+ catch {
574
+ parsed = text;
575
+ }
576
+ return {
577
+ status: res.status,
578
+ headers: responseHeaders,
579
+ body: parsed,
580
+ };
581
+ });
582
+ }
583
+ /**
584
+ * Shared retry / timeout / abort loop used by all three transport entry
585
+ * points ({@link stockTransport}, {@link impersonatedTransport},
586
+ * {@link impersonatedMultipartTransport}).
587
+ *
588
+ * `attemptFn` performs a single network attempt against the supplied
589
+ * combined signal and returns the parsed {@link TransportResponse}. The
590
+ * loop:
591
+ *
592
+ * 1. Composes the caller's signal with a per-attempt timeout signal.
593
+ * 2. Calls `attemptFn`.
594
+ * 3. On retryable status (429, 5xx), backs off per the issue #229 policy
595
+ * and re-attempts up to the configured `maxRetries`.
596
+ * 4. On per-attempt timeout, surfaces as a {@link TransportError} with
597
+ * code `TIMEOUT` (no retry — the wedge is likely persistent).
598
+ * 5. On caller abort, surfaces as a {@link TransportError} with code
599
+ * `ABORTED`.
600
+ * 6. On non-retryable errors ({@link Cf403Error}, network failure),
601
+ * re-throws.
602
+ *
603
+ * Every successful attempt invokes the response logger; the final attempt
604
+ * also logs the surfaced status code so an operator inspecting a stderr
605
+ * trace can correlate retry events with the eventual outcome.
606
+ */
607
+ async function executeWithResilience(req, url, attemptFn) {
608
+ const config = readTransportConfig();
609
+ let attempt = 0;
610
+ let lastStatus;
611
+ let lastRetryAfterMs;
612
+ for (;;) {
613
+ const { signal, dispose } = combineSignals(req.signal, config.timeoutMs);
614
+ const startMs = performance.now();
615
+ let res;
616
+ try {
617
+ res = await attemptFn(signal, startMs);
618
+ }
619
+ catch (err) {
620
+ dispose();
621
+ const classification = classifyTransportError(err, req.signal);
622
+ if (classification === "aborted-by-caller") {
623
+ throw new TransportError("ABORTED", req.surface, url, attempt + 1, `Request to ${req.surface} cancelled by caller.`, lastStatus, lastRetryAfterMs, { cause: err });
624
+ }
625
+ if (classification === "timeout") {
626
+ throw new TransportError("TIMEOUT", req.surface, url, attempt + 1, `Request to ${req.surface} timed out after ${config.timeoutMs.toString()}ms.`, lastStatus, lastRetryAfterMs, { cause: err });
627
+ }
628
+ // Cf403Error, TtctlError subclasses, and arbitrary network errors
629
+ // propagate as-is — they are caller-visible failures, not transient
630
+ // conditions the retry loop should silently swallow.
631
+ throw err;
632
+ }
633
+ dispose();
634
+ logTransportResponse({
635
+ surface: req.surface,
636
+ endpoint: url,
637
+ operationName: req.body.operationName,
638
+ status: res.status,
639
+ headers: res.headers,
640
+ body: res.body,
641
+ elapsedMs: performance.now() - startMs,
642
+ });
643
+ // Redirect anomaly (issue #268). The transports have a no-follow
644
+ // policy (`redirect: "manual"` on node-wreq; structural on undici, see
645
+ // stockTransport), so a genuine 3xx-with-Location lands here verbatim
646
+ // instead of being followed. GraphQL endpoints are not expected to
647
+ // redirect; refuse to hand the redirect body back to a caller that
648
+ // cannot interpret it and surface a typed RedirectError for operator
649
+ // triage. The response is already captured in the diagnostic trace
650
+ // above. A 3xx WITHOUT a `Location` header is not a redirect — it
651
+ // falls through to the normal (non-retryable) return path so the
652
+ // caller's GraphQL response handler sees it verbatim.
653
+ const redirectLocation = getRedirectLocation(res.status, res.headers);
654
+ if (redirectLocation !== undefined) {
655
+ throw new RedirectError(req.surface, url, res.status, redirectLocation);
656
+ }
657
+ if (!isRetryableStatus(res.status)) {
658
+ return res;
659
+ }
660
+ // Retryable status — decide whether to retry or surface.
661
+ lastStatus = res.status;
662
+ if (attempt >= config.maxRetries) {
663
+ const code = res.status === 429 ? "RATE_LIMITED" : "SERVER_ERROR";
664
+ throw new TransportError(code, req.surface, url, attempt + 1, `Transport ${req.surface} returned HTTP ${res.status.toString()} after ${(attempt + 1).toString()} attempts.`, lastStatus, lastRetryAfterMs);
665
+ }
666
+ const reason = res.status === 429 ? "rate-limit" : "server-error";
667
+ const retryAfterRaw = res.headers["retry-after"];
668
+ const retryAfterMs = parseRetryAfter(retryAfterRaw);
669
+ lastRetryAfterMs = retryAfterMs;
670
+ const delayMs = retryAfterMs ?? computeBackoffDelay(reason, attempt);
671
+ logTransportRetry({
672
+ surface: req.surface,
673
+ endpoint: url,
674
+ operationName: req.body.operationName,
675
+ attempt: attempt + 1,
676
+ reason,
677
+ status: res.status,
678
+ delayMs,
679
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
680
+ });
681
+ try {
682
+ await sleepUnlessAborted(delayMs, req.signal);
683
+ }
684
+ catch (err) {
685
+ // Caller aborted during backoff sleep.
686
+ throw new TransportError("ABORTED", req.surface, url, attempt + 1, `Request to ${req.surface} cancelled by caller during backoff.`, lastStatus, lastRetryAfterMs, { cause: err });
687
+ }
688
+ attempt += 1;
689
+ }
690
+ }
691
+ //# sourceMappingURL=transport.js.map