@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,1007 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Oleksii PELYKH
3
+ import { fetch as wreqFetch } from "node-wreq";
4
+ import { AuthRevokedError, TtctlError } from "../../../auth/errors.js";
5
+ import { logTransportRequest, logTransportResponse } from "../../../lib/diagnostic-log.js";
6
+ import { buildDryRunPreview, Cf403Error, getRedirectLocation, IMPERSONATE_PROFILE, impersonatedTransport, RedirectError, stockTransport, } from "../../../transport.js";
7
+ import { SURFACE_ENDPOINTS } from "../../../types.js";
8
+ import { isAuthRevokedExtensionCode } from "../shared.js";
9
+ /**
10
+ * Full-document `ProfileShow` query string.
11
+ *
12
+ * Mirrors `research/graphql/gateway/operations/mobile/ProfileShow.graphql`. Sent as a
13
+ * full-document GraphQL query (not a persisted query) because pinning a
14
+ * sha256 hash against an unstable persisted-query catalog (it changes on
15
+ * every portal client release) costs more in churn than it saves in
16
+ * bandwidth. Keep this in sync with the .graphql file if either is edited;
17
+ * the codegen smoke test in `__tests__/codegen.test.ts` catches structural
18
+ * drift between operation documents and the generated TypeScript types.
19
+ *
20
+ * The selection set is a rich, profile-comprehensive shape adapted from the
21
+ * portal `GetViewer` operation (see `research/graphql/gateway/operations/
22
+ * portal/GetViewer.graphql` and `research/notes/13-getviewer-empirical-shape.md`)
23
+ * trimmed to fields ttctl actually surfaces. Deliberately excluded:
24
+ * `codeOfConduct.body` and `termsOfService.body` (~25 KB combined per the
25
+ * empirical capture in note 13 — CLI does not render legal text); the
26
+ * full `pendingSurveys`/`pendingQuizzes`/`jobActivityList`/operational-state
27
+ * scopes (out of profile-show scope); and fields the SDL types as `Unknown`
28
+ * (no actionable typing — codegen produces `unknown`).
29
+ */
30
+ const PROFILE_SHOW_QUERY = `query ProfileShow {
31
+ viewer {
32
+ __typename
33
+ id
34
+ appliedAt
35
+ hasSearchSubscription
36
+ availabilityRequestTalentCardEnabled
37
+ coachingEligibility
38
+ referralUrl {
39
+ __typename
40
+ legacySlug
41
+ pathSuffix
42
+ shortenedUrl
43
+ url
44
+ }
45
+ hireMeBanner {
46
+ __typename
47
+ enabled
48
+ submitted
49
+ experimentVariant
50
+ referralUrl
51
+ personalWebsiteUrl
52
+ verificationStatus
53
+ verifiedCount
54
+ }
55
+ codeOfConduct {
56
+ __typename
57
+ id
58
+ acceptedAt
59
+ title
60
+ revisedOn
61
+ }
62
+ termsOfService {
63
+ __typename
64
+ id
65
+ title
66
+ revisedOn
67
+ requiredAction
68
+ }
69
+ preliminarySearchSetting {
70
+ __typename
71
+ enabled
72
+ }
73
+ viewerRole {
74
+ __typename
75
+ activatedAt
76
+ askExpertMenuVisible
77
+ blockedStatus { __typename isBlocked }
78
+ roleId
79
+ profileId
80
+ availability
81
+ allocatedHours
82
+ hiredHours
83
+ fullName
84
+ firstName
85
+ phoneNumber
86
+ email
87
+ toptalEmail
88
+ toptalEmailSuspended
89
+ sendNotificationsToPrivateEmail
90
+ specializationType
91
+ specializations {
92
+ __typename
93
+ id
94
+ slug
95
+ title
96
+ deliveryModel { __typename id identifier }
97
+ }
98
+ photo { __typename large small }
99
+ postActivationStepsStatus
100
+ publicResumeUrl
101
+ timeZone {
102
+ __typename
103
+ name
104
+ value
105
+ location
106
+ utcOffset
107
+ stdOffset
108
+ }
109
+ hourlyRate { __typename verbose decimal }
110
+ isPassThroughTalent
111
+ isFakeSession
112
+ availableShiftRangeFrom
113
+ availableShiftRangeTo
114
+ workingTimeFrom
115
+ workingTimeTo
116
+ contactFields {
117
+ __typename
118
+ communitySlackId
119
+ email
120
+ phoneNumber
121
+ skype
122
+ }
123
+ talentVerticals {
124
+ __typename
125
+ isApiAllowed
126
+ name
127
+ roleId
128
+ slug
129
+ }
130
+ vertical {
131
+ __typename
132
+ name
133
+ slug
134
+ hasSingleSpecialization
135
+ isMarketplaceAccessEnabled
136
+ profileHandbookUrl
137
+ minPortfolioItems
138
+ marketCondition { __typename condition }
139
+ globalMarketCondition {
140
+ __typename
141
+ condition
142
+ conditionVerbose
143
+ conditionColor
144
+ reportUrl
145
+ }
146
+ talentJobApplicationConfig {
147
+ __typename
148
+ portfolioRequired
149
+ careerHighlightRequired
150
+ highlightFields
151
+ }
152
+ }
153
+ lastAllocatedHoursChangeRequest {
154
+ __typename
155
+ id
156
+ allocatedHours
157
+ comment
158
+ reviewedManually
159
+ statusV2 { __typename value verbose }
160
+ }
161
+ lastMobileAccess { __typename deviceType startedAt }
162
+ rateInsight {
163
+ __typename
164
+ hourly {
165
+ __typename
166
+ currentRateCompetitive
167
+ recentApplicationRate
168
+ recommendedRate
169
+ }
170
+ }
171
+ operations {
172
+ __typename
173
+ createRateChangeRequest { __typename callable }
174
+ startSearchSubscription { __typename callable }
175
+ promoteGigs { __typename callable }
176
+ }
177
+ permissions {
178
+ __typename
179
+ canApplyToJobs
180
+ canFillInAdvancedProfile
181
+ canHaveReferrals
182
+ canViewAskAnExpert
183
+ canViewCoachingRequests
184
+ canViewCommunity
185
+ canViewConsultations
186
+ canViewEligibleJobs
187
+ canViewPayments
188
+ canViewRateInsights
189
+ canViewRecognitionBadges
190
+ canViewRecommendedJobs
191
+ canViewSlackCommunity
192
+ canViewSpecializations
193
+ }
194
+ profile {
195
+ __typename
196
+ id
197
+ fullName
198
+ city
199
+ photo { __typename large }
200
+ skillSets {
201
+ __typename
202
+ nodes {
203
+ __typename
204
+ id
205
+ experience
206
+ rating
207
+ public
208
+ skill { __typename id name }
209
+ }
210
+ }
211
+ }
212
+ }
213
+ }
214
+ }`;
215
+ export class ProfileError extends Error {
216
+ code;
217
+ name = "ProfileError";
218
+ constructor(code, message, options) {
219
+ super(message, options);
220
+ this.code = code;
221
+ }
222
+ }
223
+ /**
224
+ * Fetch the signed-in user's profile from the mobile-gateway GraphQL
225
+ * surface (`https://www.toptal.com/gateway/graphql/talent/graphql`).
226
+ *
227
+ * Authenticates via `Authorization: Token token=<token>` (the canonical
228
+ * Toptal auth mechanism — see `hq/engineering/adr/ADR-005-auth-model.md`).
229
+ * The mobile-gateway is plain HTTPS — no Cloudflare, no TLS impersonation
230
+ * required (empirically validated in `research/notes/13-getviewer-empirical-shape.md`).
231
+ *
232
+ * The returned shape is profile-comprehensive: identity (email, fullName,
233
+ * phoneNumber, photo), role (allocatedHours, hiredHours, availability,
234
+ * specializations, vertical, hourlyRate, timeZone, permissions, contact
235
+ * fields), profile sub-object (id, fullName, city, photo, skillSets), and
236
+ * operational metadata (codeOfConduct/termsOfService acceptance state,
237
+ * hireMeBanner, lastAllocatedHoursChangeRequest, rateInsight). Caller may
238
+ * project as needed for display.
239
+ *
240
+ * Note: `Profile.about` (bio) and `Profile.quote` (headline) are NOT on
241
+ * mobile-gateway's `Profile` type. They are write-side fields surfaced by
242
+ * `set()`'s response payload via the talent-profile surface. If a
243
+ * read-side bio/headline display becomes needed, that requires a follow-up
244
+ * issue to add a second talent-profile call.
245
+ *
246
+ * Errors:
247
+ * - `AuthRevokedError` when the surface returns 401, OR the GraphQL
248
+ * response carries `extensions.code` matching `isAuthRevokedExtensionCode`
249
+ * (`'UNAUTHENTICATED'`, `'AUTHENTICATION_REQUIRED'`, or `'UNAUTHORIZED'`
250
+ * — see `services/profile/shared.ts` for per-code surface attribution and
251
+ * empirical history; #89 added `'UNAUTHORIZED'` for mobile-gateway).
252
+ * Caller-agnostic — the CLI / MCP surfaces render `error.recovery`
253
+ * verbatim ("Run `ttctl auth signin` to re-authenticate.").
254
+ * - `ProfileError` with code `NO_VIEWER` when the response is 200 but
255
+ * `data.viewer` is `null` (the API contract says this means the token
256
+ * does not bind to a viewer).
257
+ * - `ProfileError` with code `GRAPHQL_ERROR` when the response carries a
258
+ * non-empty `errors` array (other than auth-revoked).
259
+ * - `ProfileError` with code `NETWORK_ERROR` when the transport itself
260
+ * throws (DNS, connection reset, etc).
261
+ */
262
+ export async function show(token) {
263
+ let res;
264
+ try {
265
+ res = await stockTransport({
266
+ surface: "mobile-gateway",
267
+ authToken: token,
268
+ body: {
269
+ operationName: "ProfileShow",
270
+ query: PROFILE_SHOW_QUERY,
271
+ },
272
+ });
273
+ }
274
+ catch (err) {
275
+ throw new ProfileError("NETWORK_ERROR", `Profile request failed: ${err.message}`, { cause: err });
276
+ }
277
+ if (res.status === 401) {
278
+ throw new AuthRevokedError("Session is invalid or expired.");
279
+ }
280
+ if (res.status < 200 || res.status >= 300) {
281
+ throw new ProfileError("UNKNOWN", `Profile request returned HTTP ${res.status.toString()}`);
282
+ }
283
+ const body = res.body;
284
+ if (body && Array.isArray(body.errors) && body.errors.length > 0) {
285
+ const first = body.errors[0];
286
+ const message = first?.message ?? "GraphQL error";
287
+ // Toptal returns HTTP 200 with `errors[0].extensions.code` set for
288
+ // missing/expired/invalid sessions. Auth-revoked codes collapse to
289
+ // `AuthRevokedError` (see `isAuthRevokedExtensionCode` for the list and
290
+ // empirical history).
291
+ if (isAuthRevokedExtensionCode(first?.extensions?.code)) {
292
+ throw new AuthRevokedError("Session is invalid or expired.");
293
+ }
294
+ throw new ProfileError("GRAPHQL_ERROR", `Profile query failed: ${message}`);
295
+ }
296
+ if (!body?.data) {
297
+ throw new ProfileError("UNKNOWN", "Profile response had no `data` field");
298
+ }
299
+ if (body.data.viewer === null) {
300
+ throw new ProfileError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
301
+ }
302
+ return body.data;
303
+ }
304
+ /**
305
+ * Full-document `GET_BASIC_INFO` query string. Trimmed subset of the
306
+ * canonical bundle-extracted operation
307
+ * (`research/graphql/talent_profile/operations/GET_BASIC_INFO.graphql`):
308
+ * we ask only for the read-display-relevant fields surfaced by
309
+ * {@link BasicInfo} — `about`, `quote`, `languages.nodes` — and skip
310
+ * the `ProfileRecommendations`, `softwareSkills`, social URL, and
311
+ * top-level `countries` / `languages` catalog fields that the canonical
312
+ * operation also fetches (out of scope for the read-display surface; the
313
+ * social URLs are owned by the `external` sub-domain, the catalog
314
+ * payloads are autocomplete-tier).
315
+ *
316
+ * Operation name `GET_BASIC_INFO` (SCREAMING_CASE) matches the bundle-
317
+ * extracted document so the server's literal `operationName` allowlist
318
+ * matches our request — same rationale as `UPDATE_BASIC_INFO` below.
319
+ */
320
+ const GET_BASIC_INFO_QUERY = `query GET_BASIC_INFO($profileId: ID!) {
321
+ profile(id: $profileId) {
322
+ id
323
+ about
324
+ quote
325
+ languages {
326
+ nodes {
327
+ id
328
+ name
329
+ }
330
+ }
331
+ }
332
+ }`;
333
+ /**
334
+ * Fetch the read-side `talent_profile`-only basic-info fields that
335
+ * complement {@link show} — `bio` (→ `Profile.about`), `headline` (→
336
+ * `Profile.quote`), and `languages`.
337
+ *
338
+ * Routed against `https://www.toptal.com/api/talent_profile/graphql` via
339
+ * {@link impersonatedTransport} (Cloudflare-protected; Chrome TLS
340
+ * fingerprint required). Internally calls {@link show} first to obtain
341
+ * the `profileId` required by the `profile(id: ID!)` field — same
342
+ * pattern as {@link photoShow}.
343
+ *
344
+ * Returns a typed {@link BasicInfo} projection — `null` for fields the
345
+ * user hasn't set, an empty array for `languages` when none.
346
+ *
347
+ * Errors:
348
+ * - `Cf403Error` propagates from the talent-profile transport.
349
+ * - `AuthRevokedError` on token expiry (HTTP 401, or any GraphQL
350
+ * `extensions.code` matching `isAuthRevokedExtensionCode`).
351
+ * - `ProfileError` with code `NO_VIEWER` when no viewer is bound.
352
+ * - `ProfileError` with code `USER_ERROR` when the profile id doesn't
353
+ * resolve (server returns `data.profile === null`).
354
+ * - `ProfileError` with code `GRAPHQL_ERROR` on top-level GraphQL errors
355
+ * (other than auth-revoked).
356
+ * - `ProfileError` with code `NETWORK_ERROR` on transport-level throws.
357
+ * - `ProfileError` with code `UNKNOWN` on unexpected non-2xx statuses or
358
+ * missing `data` field.
359
+ */
360
+ export async function getBasicInfo(token) {
361
+ const profileResp = await show(token);
362
+ const profileId = profileResp.viewer?.viewerRole.profileId;
363
+ if (profileId === undefined) {
364
+ throw new ProfileError("NO_VIEWER", "Cannot fetch basic info: viewer or profile id missing from the session response.");
365
+ }
366
+ let res;
367
+ try {
368
+ res = await impersonatedTransport({
369
+ surface: "talent-profile",
370
+ authToken: token,
371
+ body: { operationName: "GET_BASIC_INFO", query: GET_BASIC_INFO_QUERY, variables: { profileId } },
372
+ });
373
+ }
374
+ catch (err) {
375
+ if (err instanceof TtctlError)
376
+ throw err;
377
+ throw new ProfileError("NETWORK_ERROR", `Basic info request failed: ${err.message}`, { cause: err });
378
+ }
379
+ if (res.status === 401) {
380
+ throw new AuthRevokedError("Session is invalid or expired.");
381
+ }
382
+ if (res.status < 200 || res.status >= 300) {
383
+ throw new ProfileError("UNKNOWN", `Basic info request returned HTTP ${res.status.toString()}`);
384
+ }
385
+ const body = res.body;
386
+ if (body && Array.isArray(body.errors) && body.errors.length > 0) {
387
+ const first = body.errors[0];
388
+ if (isAuthRevokedExtensionCode(first?.extensions?.code)) {
389
+ throw new AuthRevokedError("Session is invalid or expired.");
390
+ }
391
+ throw new ProfileError("GRAPHQL_ERROR", `Basic info query failed: ${first?.message ?? "GraphQL error"}`);
392
+ }
393
+ if (!body?.data) {
394
+ throw new ProfileError("UNKNOWN", "Basic info response had no `data` field");
395
+ }
396
+ if (!body.data.profile) {
397
+ throw new ProfileError("USER_ERROR", `No profile found with id "${profileId}".`);
398
+ }
399
+ const p = body.data.profile;
400
+ const rawNodes = p.languages?.nodes ?? [];
401
+ const languages = [];
402
+ for (const node of rawNodes) {
403
+ if (node === null || typeof node !== "object")
404
+ continue;
405
+ if (typeof node.id !== "string" || node.id.length === 0)
406
+ continue;
407
+ if (typeof node.name !== "string")
408
+ continue;
409
+ languages.push({ id: node.id, name: node.name });
410
+ }
411
+ return {
412
+ profileId: typeof p.id === "string" && p.id.length > 0 ? p.id : profileId,
413
+ bio: typeof p.about === "string" ? p.about : null,
414
+ headline: typeof p.quote === "string" ? p.quote : null,
415
+ languages,
416
+ };
417
+ }
418
+ /**
419
+ * Full-document `UPDATE_BASIC_INFO` mutation string.
420
+ *
421
+ * The Toptal `talent_profile/graphql` surface does not publish a persisted-query
422
+ * catalog — every operation is sent as a full document. This is a SIMPLIFIED
423
+ * version of the bundle-extracted `UPDATE_BASIC_INFO` mutation
424
+ * (`research/graphql/talent_profile/operations/UPDATE_BASIC_INFO.graphql`): we ask only
425
+ * for the response fields we actually use (id, about, quote, success, notice,
426
+ * errors), avoiding the bundle version's dependency on five fragments
427
+ * (`RealTimeFields`, `ProfileCompletion`, `SkillsReadiness`,
428
+ * `ProfileRecommendations`, `UserErrorFragment`) that haven't been wired into
429
+ * codegen.
430
+ *
431
+ * Operation name is `UPDATE_BASIC_INFO` (SCREAMING_CASE), matching the
432
+ * bundle-extracted document. Per `research/notes/05-talent-profile-api.md`,
433
+ * the server matches `operationName` against the request body literally and
434
+ * the React app sends the SCREAMING_CASE form — keeping the same shape avoids
435
+ * any chance of server-side allowlist drift.
436
+ */
437
+ const UPDATE_BASIC_INFO_MUTATION = `mutation UPDATE_BASIC_INFO($input: UpdateBasicInfoInput!) {
438
+ updateBasicInfo(input: $input) {
439
+ success
440
+ notice
441
+ errors {
442
+ code
443
+ key
444
+ message
445
+ }
446
+ profile {
447
+ id
448
+ about
449
+ quote
450
+ }
451
+ }
452
+ }`;
453
+ /**
454
+ * Placeholder string substituted for fields that the apply-path would
455
+ * normally resolve via a sibling read (e.g. `profileId` from `show()`).
456
+ * Surfaced verbatim in the dry-run preview's variables payload so
457
+ * downstream consumers can see the request structure without TTCtl
458
+ * having fired any network I/O.
459
+ *
460
+ * Public (re-exported via `index.ts`) so MCP / future CLI tooling can
461
+ * recognize the placeholder when surfacing the preview.
462
+ */
463
+ export const DRY_RUN_PROFILE_ID_PLACEHOLDER = "<resolved at send-time from session token>";
464
+ /**
465
+ * Update a subset of the signed-in user's basic-info fields (currently
466
+ * `bio` → `about` and `headline` → `quote`) via the Cloudflare-protected
467
+ * `talent_profile/graphql` surface.
468
+ *
469
+ * Authenticates via `Authorization: Token token=<token>` (the canonical
470
+ * Toptal auth mechanism). Cookies are NOT load-bearing — Chrome TLS
471
+ * impersonation alone passes Cloudflare. Internally calls `show()`
472
+ * (against mobile-gateway) first to obtain the `profileId` required by the
473
+ * mutation input, then issues the typed `UpdateBasicInfo` mutation against
474
+ * talent-profile via `impersonatedTransport`. Returns the server-confirmed
475
+ * updated values wrapped in a {@link SetOutcomeApplied} discriminator.
476
+ *
477
+ * Dry-run path (issue #52): when invoked with `options.dryRun === true`,
478
+ * builds a {@link DryRunPreview} of the WRITE request without invoking
479
+ * any transport (read OR write) and returns it wrapped in {@link
480
+ * SetOutcomePreview}. The preview substitutes a placeholder string
481
+ * ({@link DRY_RUN_PROFILE_ID_PLACEHOLDER}) for `profileId` because the
482
+ * apply-path resolves it via `show()` (a stock-transport read call) and
483
+ * the dry-run AC requires zero transport invocations. The bearer token
484
+ * is redacted in the preview's `headers.authorization` per the security
485
+ * contract documented on {@link DryRunPreview}.
486
+ *
487
+ * Errors:
488
+ * - `ProfileError` with code `VALIDATION_ERROR` when neither `bio` nor
489
+ * `headline` is supplied — the contract requires at least one. Fires
490
+ * in BOTH the apply-path and the dry-run path.
491
+ * - `Cf403Error` propagates from the talent-profile transport when
492
+ * Cloudflare returns 403. Apply-path only.
493
+ * - `AuthRevokedError` on token expiry (HTTP 401, or any GraphQL
494
+ * `extensions.code` matching `isAuthRevokedExtensionCode` — currently
495
+ * `'UNAUTHENTICATED'`, `'AUTHENTICATION_REQUIRED'`, or `'UNAUTHORIZED'`).
496
+ * Apply-path only.
497
+ * - `ProfileError` with code `NO_VIEWER` when no viewer is bound.
498
+ * Apply-path only — dry-run skips the read entirely.
499
+ * - `ProfileError` with code `USER_ERROR` when the mutation returns a
500
+ * non-empty `errors` array (validation failures from the server, e.g., a
501
+ * bio that exceeds the platform's length limit). Apply-path only.
502
+ * - `ProfileError` with code `GRAPHQL_ERROR` on top-level GraphQL errors.
503
+ * Apply-path only.
504
+ * - `ProfileError` with code `NETWORK_ERROR` on transport-level throws.
505
+ * Apply-path only.
506
+ */
507
+ export async function set(token, changes, options = {}) {
508
+ if (changes.bio === undefined && changes.headline === undefined) {
509
+ throw new ProfileError("VALIDATION_ERROR", "Profile update requires at least one of `bio` or `headline`.");
510
+ }
511
+ const profileFields = {};
512
+ if (changes.bio !== undefined)
513
+ profileFields.about = changes.bio;
514
+ if (changes.headline !== undefined)
515
+ profileFields.quote = changes.headline;
516
+ // Dry-run short-circuit: build the WRITE request shape with a
517
+ // placeholder `profileId` and return a preview without any transport
518
+ // call. Apply-path resolves `profileId` via `show()` (a mobile-gateway
519
+ // read), but dry-run skips that step so neither transport is invoked
520
+ // — the AC for issue #52 reads "transport never called" in the
521
+ // singular and the helper honors it for both directions.
522
+ if (options.dryRun === true) {
523
+ const previewInput = {
524
+ profileId: DRY_RUN_PROFILE_ID_PLACEHOLDER,
525
+ profile: profileFields,
526
+ };
527
+ return {
528
+ kind: "preview",
529
+ preview: buildDryRunPreview({
530
+ surface: "talent-profile",
531
+ authToken: token,
532
+ body: {
533
+ operationName: "UPDATE_BASIC_INFO",
534
+ query: UPDATE_BASIC_INFO_MUTATION,
535
+ variables: { input: previewInput },
536
+ },
537
+ }),
538
+ };
539
+ }
540
+ // Need profileId for the mutation input — fetch the current profile first.
541
+ // Errors from show() (ProfileError) propagate verbatim: a write attempt
542
+ // that can't read its own profile is unrecoverable, and surfacing the
543
+ // read-side error gives the user the same actionable message they'd get
544
+ // from `ttctl profile show`.
545
+ const profile = await show(token);
546
+ const profileId = profile.viewer?.viewerRole.profileId;
547
+ if (profileId === undefined) {
548
+ throw new ProfileError("NO_VIEWER", "Cannot update profile: viewer or profile id missing from the session response.");
549
+ }
550
+ let res;
551
+ try {
552
+ res = await impersonatedTransport({
553
+ surface: "talent-profile",
554
+ authToken: token,
555
+ body: {
556
+ operationName: "UPDATE_BASIC_INFO",
557
+ query: UPDATE_BASIC_INFO_MUTATION,
558
+ variables: { input: { profileId, profile: profileFields } },
559
+ },
560
+ });
561
+ }
562
+ catch (err) {
563
+ // Typed-error subclasses (Cf403Error, AuthRevokedError, …) propagate as-is so
564
+ // the CLI / MCP surfaces can render their `recovery` hints. Anything else is
565
+ // a transport-level failure and surfaces as a domain ProfileError.
566
+ if (err instanceof TtctlError)
567
+ throw err;
568
+ throw new ProfileError("NETWORK_ERROR", `Profile update request failed: ${err.message}`, { cause: err });
569
+ }
570
+ if (res.status === 401) {
571
+ throw new AuthRevokedError("Session is invalid or expired.");
572
+ }
573
+ if (res.status < 200 || res.status >= 300) {
574
+ throw new ProfileError("UNKNOWN", `Profile update returned HTTP ${res.status.toString()}`);
575
+ }
576
+ const body = res.body;
577
+ if (body && Array.isArray(body.errors) && body.errors.length > 0) {
578
+ const first = body.errors[0];
579
+ const message = first?.message ?? "GraphQL error";
580
+ if (isAuthRevokedExtensionCode(first?.extensions?.code)) {
581
+ throw new AuthRevokedError("Session is invalid or expired.");
582
+ }
583
+ throw new ProfileError("GRAPHQL_ERROR", `Profile update failed: ${message}`);
584
+ }
585
+ const payload = body?.data?.updateBasicInfo;
586
+ if (!payload) {
587
+ throw new ProfileError("UNKNOWN", "Profile update response had no `data.updateBasicInfo` field");
588
+ }
589
+ if (Array.isArray(payload.errors) && payload.errors.length > 0) {
590
+ const first = payload.errors[0];
591
+ const fieldHint = first?.key ? ` (${first.key})` : "";
592
+ throw new ProfileError("USER_ERROR", `Profile update rejected${fieldHint}: ${first?.message ?? "unknown error"}`);
593
+ }
594
+ if (payload.success === false) {
595
+ throw new ProfileError("USER_ERROR", `Profile update reported success=false${payload.notice ? `: ${payload.notice}` : ""}`);
596
+ }
597
+ if (!payload.profile) {
598
+ throw new ProfileError("UNKNOWN", "Profile update succeeded but response had no profile payload");
599
+ }
600
+ return {
601
+ kind: "applied",
602
+ result: {
603
+ profile: {
604
+ id: payload.profile.id,
605
+ about: payload.profile.about ?? null,
606
+ quote: payload.profile.quote ?? null,
607
+ },
608
+ notice: payload.notice ?? null,
609
+ },
610
+ };
611
+ }
612
+ // =======================================================================
613
+ // Photo: show + upload
614
+ // =======================================================================
615
+ //
616
+ // Both operations target the Cloudflare-protected `talent_profile/graphql`
617
+ // surface (the mobile gateway exposes only `viewer.viewerRole.profile.photo`
618
+ // as a flat URL, not the full `Photo` shape with original/transformations).
619
+ //
620
+ // `photoShow` is a vanilla query — same transport pattern as `set` above.
621
+ // `photoUpload` is the special case: GraphQL multipart-upload-spec
622
+ // (https://github.com/jaydenseric/graphql-multipart-request-spec). It can't
623
+ // share `impersonatedTransport()` directly because that helper hardcodes
624
+ // `Content-Type: application/json`; instead it builds a `FormData` body and
625
+ // dispatches via `node-wreq`'s `fetch` with the same TLS profile.
626
+ // =======================================================================
627
+ const GET_PHOTO_QUERY = `query GET_PHOTO($profileId: ID!) {
628
+ profile(id: $profileId) {
629
+ id
630
+ photo {
631
+ default
632
+ original
633
+ small
634
+ transformations {
635
+ cropped { height width x y }
636
+ }
637
+ }
638
+ profileReadiness {
639
+ isPhotoResolutionSatisfied
640
+ }
641
+ }
642
+ }`;
643
+ const UPLOAD_PROFILE_PHOTO_MUTATION = `mutation UploadProfilePhoto($input: UpdatePhotoInput!) {
644
+ updatePhoto(input: $input) {
645
+ success
646
+ notice
647
+ errors { code key message }
648
+ profile {
649
+ id
650
+ photo {
651
+ default
652
+ original
653
+ small
654
+ transformations {
655
+ cropped { height width x y }
656
+ }
657
+ }
658
+ }
659
+ }
660
+ }`;
661
+ function normalisePhoto(profile) {
662
+ const photo = profile.photo;
663
+ const cropped = photo?.transformations?.cropped ?? null;
664
+ return {
665
+ default: photo?.default ?? null,
666
+ original: photo?.original ?? null,
667
+ small: photo?.small ?? null,
668
+ cropped: cropped,
669
+ isResolutionSatisfied: profile.profileReadiness.isPhotoResolutionSatisfied,
670
+ };
671
+ }
672
+ /**
673
+ * Fetch the URLs of the signed-in user's profile photo (default / original
674
+ * / small variants plus the server's recommended crop rectangle and the
675
+ * "is the resolution satisfactory?" boolean from `profileReadiness`).
676
+ *
677
+ * Routed against `talent_profile/graphql` via `impersonatedTransport`
678
+ * (Cloudflare-protected) because the mobile gateway exposes only a single
679
+ * flat URL on `Profile.photo` — not the variant shape we surface.
680
+ *
681
+ * Internally calls `show()` first to get the `profileId` (same pattern
682
+ * as `set()` — the talent-profile surface keys `profile(id: ID!)` rather
683
+ * than resolving from the auth token), then fires the typed query.
684
+ *
685
+ * Errors:
686
+ * - `Cf403Error` propagates from the talent-profile transport.
687
+ * - `AuthRevokedError` on token expiry (HTTP 401, or any auth-revoked
688
+ * `extensions.code` — see `isAuthRevokedExtensionCode` in
689
+ * `services/profile/shared.ts`).
690
+ * - `ProfileError` `NO_VIEWER` when no viewer is bound.
691
+ * - `ProfileError` `GRAPHQL_ERROR` on top-level GraphQL errors.
692
+ * - `ProfileError` `NETWORK_ERROR` on transport-level throws.
693
+ * - `ProfileError` `USER_ERROR` when the profile id doesn't resolve
694
+ * (server returns `data.profile === null`).
695
+ */
696
+ export async function photoShow(token) {
697
+ const profileResp = await show(token);
698
+ const profileId = profileResp.viewer?.viewerRole.profileId;
699
+ if (profileId === undefined) {
700
+ throw new ProfileError("NO_VIEWER", "Cannot fetch photo: viewer or profile id missing from the session response.");
701
+ }
702
+ let res;
703
+ try {
704
+ res = await impersonatedTransport({
705
+ surface: "talent-profile",
706
+ authToken: token,
707
+ body: { operationName: "GET_PHOTO", query: GET_PHOTO_QUERY, variables: { profileId } },
708
+ });
709
+ }
710
+ catch (err) {
711
+ if (err instanceof TtctlError)
712
+ throw err;
713
+ throw new ProfileError("NETWORK_ERROR", `Photo request failed: ${err.message}`, { cause: err });
714
+ }
715
+ if (res.status === 401) {
716
+ throw new AuthRevokedError("Session is invalid or expired.");
717
+ }
718
+ if (res.status < 200 || res.status >= 300) {
719
+ throw new ProfileError("UNKNOWN", `Photo request returned HTTP ${res.status.toString()}`);
720
+ }
721
+ const body = res.body;
722
+ if (body && Array.isArray(body.errors) && body.errors.length > 0) {
723
+ const first = body.errors[0];
724
+ if (isAuthRevokedExtensionCode(first?.extensions?.code)) {
725
+ throw new AuthRevokedError("Session is invalid or expired.");
726
+ }
727
+ throw new ProfileError("GRAPHQL_ERROR", `Photo query failed: ${first?.message ?? "GraphQL error"}`);
728
+ }
729
+ if (!body?.data) {
730
+ throw new ProfileError("UNKNOWN", "Photo response had no `data` field");
731
+ }
732
+ if (!body.data.profile) {
733
+ throw new ProfileError("USER_ERROR", `No profile found with id "${profileId}".`);
734
+ }
735
+ return normalisePhoto(body.data.profile);
736
+ }
737
+ const DEFAULT_PHOTO_CONTENT_TYPE = "image/jpeg";
738
+ const DEFAULT_PHOTO_FILENAME = "photo.jpg";
739
+ const DEFAULT_PHOTO_TRANSFORMATION = {
740
+ cropped: { x: 0, y: 0, width: 0, height: 0 },
741
+ };
742
+ function inferContentType(filename) {
743
+ const lower = filename.toLowerCase();
744
+ if (lower.endsWith(".png"))
745
+ return "image/png";
746
+ if (lower.endsWith(".gif"))
747
+ return "image/gif";
748
+ if (lower.endsWith(".webp"))
749
+ return "image/webp";
750
+ if (lower.endsWith(".jpeg") || lower.endsWith(".jpg"))
751
+ return "image/jpeg";
752
+ return DEFAULT_PHOTO_CONTENT_TYPE;
753
+ }
754
+ /**
755
+ * Upload a new profile photo. Implements the GraphQL multipart-upload
756
+ * spec — the request body is a `multipart/form-data` envelope with three
757
+ * named parts (`operations`, `map`, and the file payload at field `0`),
758
+ * NOT the JSON envelope every other operation in this module uses. The
759
+ * transport hand-rolls a `node-wreq` fetch call rather than going through
760
+ * `impersonatedTransport()` because that helper hardcodes
761
+ * `Content-Type: application/json`; both transports use the same Chrome
762
+ * TLS profile so Cloudflare treats them uniformly.
763
+ *
764
+ * `input.file` accepts either a path string or a Buffer. Path strings
765
+ * are read with `node:fs/promises` and the content-type is inferred from
766
+ * the extension; Buffer callers may override `contentType` / `filename`.
767
+ *
768
+ * Errors:
769
+ * - `Cf403Error` propagates from the multipart transport call.
770
+ * - `AuthRevokedError` on token expiry (HTTP 401, or auth-revoked
771
+ * `extensions.code` on the GraphQL response).
772
+ * - `ProfileError` `VALIDATION_ERROR` when `input.file` is empty / missing.
773
+ * - `ProfileError` `NO_VIEWER` when no viewer is bound.
774
+ * - `ProfileError` `USER_ERROR` when the mutation returns user errors
775
+ * (e.g., resolution too low, file format unsupported).
776
+ * - Standard transport-error path.
777
+ */
778
+ export async function photoUpload(token, input) {
779
+ // Resolve the binary first so input failures surface BEFORE any
780
+ // network call — same UX principle the CLI uses for `--bio` / `--headline`.
781
+ const { fileBuffer, filename, contentType } = await resolvePhotoBinary(input);
782
+ if (fileBuffer.byteLength === 0) {
783
+ throw new ProfileError("VALIDATION_ERROR", "Photo file is empty.");
784
+ }
785
+ const profileResp = await show(token);
786
+ const profileId = profileResp.viewer?.viewerRole.profileId;
787
+ if (profileId === undefined) {
788
+ throw new ProfileError("NO_VIEWER", "Cannot upload photo: viewer or profile id missing from the session response.");
789
+ }
790
+ const operations = JSON.stringify({
791
+ operationName: "UploadProfilePhoto",
792
+ query: UPLOAD_PROFILE_PHOTO_MUTATION,
793
+ variables: {
794
+ input: {
795
+ profileId,
796
+ transformation: input.transformation ?? DEFAULT_PHOTO_TRANSFORMATION,
797
+ file: null,
798
+ },
799
+ },
800
+ });
801
+ const map = JSON.stringify({ "0": ["variables.input.file"] });
802
+ const form = new FormData();
803
+ form.set("operations", operations);
804
+ form.set("map", map);
805
+ // `Blob` is provided globally on Node 18+. The cast through Uint8Array
806
+ // is a TS-side shim because `BlobPart` doesn't accept `Buffer` directly;
807
+ // a Buffer is structurally a Uint8Array, so the conversion is zero-copy.
808
+ const blob = new Blob([new Uint8Array(fileBuffer)], { type: contentType });
809
+ form.set("0", blob, filename);
810
+ // Diagnostic-log context (issue #139): hand over the parsed operation
811
+ // envelope + multipart map so `multipartImpersonatedFetch` can emit a
812
+ // truthful debug trace with the full GraphQL body (operationName,
813
+ // query, variables) and the actual slot label / variable-path mapping
814
+ // that goes on the wire. Without this, the debug log would show
815
+ // body=null and a fabricated multipart map — accurate-shape data is
816
+ // what makes the trace useful for `paste-into-issue` debugging.
817
+ const operationEnvelope = JSON.parse(operations);
818
+ const slotMap = JSON.parse(map);
819
+ let res;
820
+ try {
821
+ res = await multipartImpersonatedFetch(token, form, { operationEnvelope, slotMap });
822
+ }
823
+ catch (err) {
824
+ if (err instanceof TtctlError)
825
+ throw err;
826
+ throw new ProfileError("NETWORK_ERROR", `Photo upload request failed: ${err.message}`, { cause: err });
827
+ }
828
+ if (res.status === 401) {
829
+ throw new AuthRevokedError("Session is invalid or expired.");
830
+ }
831
+ if (res.status < 200 || res.status >= 300) {
832
+ throw new ProfileError("UNKNOWN", `Photo upload returned HTTP ${res.status.toString()}`);
833
+ }
834
+ const body = res.body;
835
+ if (body && Array.isArray(body.errors) && body.errors.length > 0) {
836
+ const first = body.errors[0];
837
+ if (isAuthRevokedExtensionCode(first?.extensions?.code)) {
838
+ throw new AuthRevokedError("Session is invalid or expired.");
839
+ }
840
+ throw new ProfileError("GRAPHQL_ERROR", `Photo upload failed: ${first?.message ?? "GraphQL error"}`);
841
+ }
842
+ const payload = body?.data?.updatePhoto;
843
+ if (!payload) {
844
+ throw new ProfileError("UNKNOWN", "Photo upload response had no `data.updatePhoto` field");
845
+ }
846
+ if (Array.isArray(payload.errors) && payload.errors.length > 0) {
847
+ const first = payload.errors[0];
848
+ const fieldHint = first?.key ? ` (${first.key})` : "";
849
+ throw new ProfileError("USER_ERROR", `Photo upload rejected${fieldHint}: ${first?.message ?? "unknown error"}`);
850
+ }
851
+ if (payload.success === false) {
852
+ throw new ProfileError("USER_ERROR", `Photo upload reported success=false${payload.notice ? `: ${payload.notice}` : ""}`);
853
+ }
854
+ if (!payload.profile) {
855
+ throw new ProfileError("UNKNOWN", "Photo upload succeeded but response had no profile payload");
856
+ }
857
+ return normalisePhoto(payload.profile);
858
+ }
859
+ /**
860
+ * Resolve the caller-supplied `file` (Buffer or path) into a Buffer plus
861
+ * the content-type / filename to use in the multipart envelope. Pulls
862
+ * `node:fs/promises` lazily so the module can still be imported in
863
+ * environments where the upload path isn't exercised (e.g., a future
864
+ * browser bundle that wraps the read APIs only).
865
+ */
866
+ async function resolvePhotoBinary(input) {
867
+ if (typeof input.file === "string") {
868
+ // Lazy import keeps the module tree-shakable for downstream bundlers
869
+ // that don't need the upload path. Top-level `import` would pull
870
+ // node:fs into every consumer of `profile.basic`.
871
+ const { readFile } = await import("node:fs/promises");
872
+ const { basename } = await import("node:path");
873
+ let buffer;
874
+ try {
875
+ buffer = await readFile(input.file);
876
+ }
877
+ catch (err) {
878
+ throw new ProfileError("VALIDATION_ERROR", `Photo file not readable: ${err.message}`, { cause: err });
879
+ }
880
+ const filename = input.filename ?? basename(input.file);
881
+ return {
882
+ fileBuffer: buffer,
883
+ filename,
884
+ contentType: input.contentType ?? inferContentType(filename),
885
+ };
886
+ }
887
+ return {
888
+ fileBuffer: input.file,
889
+ filename: input.filename ?? DEFAULT_PHOTO_FILENAME,
890
+ contentType: input.contentType ?? DEFAULT_PHOTO_CONTENT_TYPE,
891
+ };
892
+ }
893
+ async function multipartImpersonatedFetch(token, form, logContext) {
894
+ const url = SURFACE_ENDPOINTS["talent-profile"];
895
+ const fetchImpl = multipartFetchOverride ?? wreqFetch;
896
+ // Mirror COMMON_HEADERS minus the JSON content-type; node-wreq's FormData
897
+ // body sets multipart/form-data; boundary=... itself. The "x-toptal-..."
898
+ // header preserves fingerprint alignment with the rest of the surface.
899
+ const headers = {
900
+ accept: "*/*",
901
+ "accept-language": "en-US,en;q=0.9",
902
+ authorization: `Token token=${token}`,
903
+ origin: "https://talent.toptal.com",
904
+ referer: "https://talent.toptal.com/",
905
+ "sec-fetch-site": "same-site",
906
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
907
+ "(KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
908
+ "x-toptal-analytics-origin": "mobile",
909
+ };
910
+ // Diagnostic log hook (issue #139). This path is photo-upload-specific;
911
+ // duplicating the `impersonatedMultipartTransport` hook here keeps both
912
+ // paths covered without adding a circular dep between transport.ts and
913
+ // this module. The operation envelope and slot map come from the
914
+ // caller so the trace reflects the actual GraphQL operation +
915
+ // wire-shape map (the binary file content is intentionally NOT in
916
+ // the body — it carries no diagnostic value and binary in a terminal
917
+ // would be useless).
918
+ logTransportRequest({
919
+ surface: "talent-profile",
920
+ endpoint: url,
921
+ transport: "impersonated-multipart",
922
+ method: "POST",
923
+ operationName: logContext.operationEnvelope.operationName,
924
+ headers,
925
+ body: logContext.operationEnvelope,
926
+ multipart: { files: Object.keys(logContext.slotMap), map: logContext.slotMap },
927
+ });
928
+ const startMs = performance.now();
929
+ const res = await fetchImpl(url, {
930
+ method: "POST",
931
+ headers,
932
+ body: form,
933
+ browser: IMPERSONATE_PROFILE,
934
+ // No-follow redirect policy (issue #268). This hand-rolled fetch
935
+ // mirrors `impersonatedMultipartTransport` and must carry the same
936
+ // posture — file-upload is the highest-impact body-exfiltration
937
+ // vector if redirect handling weakens. `node-wreq` defaults to
938
+ // `redirect: "follow"`; pinning `"manual"` returns a 3xx verbatim so
939
+ // the check below can reject it.
940
+ redirect: "manual",
941
+ });
942
+ const responseHeaders = res.headers.toObject();
943
+ if (res.status === 403) {
944
+ logTransportResponse({
945
+ surface: "talent-profile",
946
+ endpoint: url,
947
+ operationName: logContext.operationEnvelope.operationName,
948
+ status: 403,
949
+ headers: responseHeaders,
950
+ body: null,
951
+ elapsedMs: performance.now() - startMs,
952
+ });
953
+ throw new Cf403Error("talent-profile", url);
954
+ }
955
+ // Redirect anomaly (issue #268) — same no-follow posture as the
956
+ // transport.ts entry points. Capture the response in the diagnostic
957
+ // trace before rejecting so an operator sees the redirect target.
958
+ const redirectLocation = getRedirectLocation(res.status, responseHeaders);
959
+ if (redirectLocation !== undefined) {
960
+ logTransportResponse({
961
+ surface: "talent-profile",
962
+ endpoint: url,
963
+ operationName: logContext.operationEnvelope.operationName,
964
+ status: res.status,
965
+ headers: responseHeaders,
966
+ body: null,
967
+ elapsedMs: performance.now() - startMs,
968
+ });
969
+ throw new RedirectError("talent-profile", url, res.status, redirectLocation);
970
+ }
971
+ const text = await res.text();
972
+ let parsed;
973
+ try {
974
+ parsed = JSON.parse(text);
975
+ }
976
+ catch {
977
+ parsed = text;
978
+ }
979
+ logTransportResponse({
980
+ surface: "talent-profile",
981
+ endpoint: url,
982
+ operationName: logContext.operationEnvelope.operationName,
983
+ status: res.status,
984
+ headers: responseHeaders,
985
+ body: parsed,
986
+ elapsedMs: performance.now() - startMs,
987
+ });
988
+ return {
989
+ status: res.status,
990
+ headers: responseHeaders,
991
+ body: parsed,
992
+ };
993
+ }
994
+ let multipartFetchOverride = null;
995
+ /**
996
+ * Test-only: replace the multipart-fetch implementation used by
997
+ * {@link photoUpload}. Pass `null` to restore the default. The override
998
+ * receives the same arguments as `node-wreq`'s `fetch` and must return a
999
+ * shape compatible with its `Response` (we only rely on `.status`,
1000
+ * `.text()`, and `.headers.toObject()`).
1001
+ *
1002
+ * @internal
1003
+ */
1004
+ export function _setMultipartFetchForTesting(impl) {
1005
+ multipartFetchOverride = impl;
1006
+ }
1007
+ //# sourceMappingURL=index.js.map