@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.
- package/README.md +49 -9
- package/dist/__generated__/gateway.d.ts +4546 -0
- package/dist/__generated__/gateway.d.ts.map +1 -0
- package/dist/__generated__/gateway.js +9 -0
- package/dist/__generated__/gateway.js.map +1 -0
- package/dist/__generated__/talent-profile-zod-schemas.d.ts +1187 -0
- package/dist/__generated__/talent-profile-zod-schemas.d.ts.map +1 -0
- package/dist/__generated__/talent-profile-zod-schemas.js +1136 -0
- package/dist/__generated__/talent-profile-zod-schemas.js.map +1 -0
- package/dist/__generated__/talent-profile.d.ts +1397 -0
- package/dist/__generated__/talent-profile.d.ts.map +1 -0
- package/dist/__generated__/talent-profile.js +9 -0
- package/dist/__generated__/talent-profile.js.map +1 -0
- package/dist/__generated__/zod-schemas.d.ts +2895 -0
- package/dist/__generated__/zod-schemas.d.ts.map +1 -0
- package/dist/__generated__/zod-schemas.js +3121 -0
- package/dist/__generated__/zod-schemas.js.map +1 -0
- package/dist/__tests__/fixtures/profile/builders.d.ts +74 -0
- package/dist/__tests__/fixtures/profile/builders.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/builders.js +196 -0
- package/dist/__tests__/fixtures/profile/builders.js.map +1 -0
- package/dist/__tests__/fixtures/profile/data.d.ts +39 -0
- package/dist/__tests__/fixtures/profile/data.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/data.js +230 -0
- package/dist/__tests__/fixtures/profile/data.js.map +1 -0
- package/dist/__tests__/fixtures/profile/index.d.ts +9 -0
- package/dist/__tests__/fixtures/profile/index.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/index.js +10 -0
- package/dist/__tests__/fixtures/profile/index.js.map +1 -0
- package/dist/__tests__/fixtures/profile/types.d.ts +53 -0
- package/dist/__tests__/fixtures/profile/types.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/types.js +4 -0
- package/dist/__tests__/fixtures/profile/types.js.map +1 -0
- package/dist/auth/errors.d.ts +82 -0
- package/dist/auth/errors.d.ts.map +1 -0
- package/dist/auth/errors.js +68 -0
- package/dist/auth/errors.js.map +1 -0
- package/dist/auth.d.ts +192 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +294 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +212 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +349 -0
- package/dist/config.js.map +1 -0
- package/dist/configLock.d.ts +50 -0
- package/dist/configLock.d.ts.map +1 -0
- package/dist/configLock.js +88 -0
- package/dist/configLock.js.map +1 -0
- package/dist/configWriter.d.ts +97 -0
- package/dist/configWriter.d.ts.map +1 -0
- package/dist/configWriter.js +687 -0
- package/dist/configWriter.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/kill-switch.d.ts +161 -0
- package/dist/kill-switch.d.ts.map +1 -0
- package/dist/kill-switch.js +235 -0
- package/dist/kill-switch.js.map +1 -0
- package/dist/lib/date.d.ts +58 -0
- package/dist/lib/date.d.ts.map +1 -0
- package/dist/lib/date.js +104 -0
- package/dist/lib/date.js.map +1 -0
- package/dist/lib/diagnostic-log.d.ts +159 -0
- package/dist/lib/diagnostic-log.d.ts.map +1 -0
- package/dist/lib/diagnostic-log.js +186 -0
- package/dist/lib/diagnostic-log.js.map +1 -0
- package/dist/lib/package-version.d.ts +19 -0
- package/dist/lib/package-version.d.ts.map +1 -0
- package/dist/lib/package-version.js +38 -0
- package/dist/lib/package-version.js.map +1 -0
- package/dist/lib/redact.d.ts +153 -0
- package/dist/lib/redact.d.ts.map +1 -0
- package/dist/lib/redact.js +207 -0
- package/dist/lib/redact.js.map +1 -0
- package/dist/lib/text.d.ts +14 -0
- package/dist/lib/text.d.ts.map +1 -0
- package/dist/lib/text.js +21 -0
- package/dist/lib/text.js.map +1 -0
- package/dist/lib/wire-shape.d.ts +131 -0
- package/dist/lib/wire-shape.d.ts.map +1 -0
- package/dist/lib/wire-shape.js +376 -0
- package/dist/lib/wire-shape.js.map +1 -0
- package/dist/onepassword.d.ts +29 -0
- package/dist/onepassword.d.ts.map +1 -0
- package/dist/onepassword.js +112 -0
- package/dist/onepassword.js.map +1 -0
- package/dist/services/_shared/transport.d.ts +148 -0
- package/dist/services/_shared/transport.d.ts.map +1 -0
- package/dist/services/_shared/transport.js +102 -0
- package/dist/services/_shared/transport.js.map +1 -0
- package/dist/services/applications/index.d.ts +210 -0
- package/dist/services/applications/index.d.ts.map +1 -0
- package/dist/services/applications/index.js +240 -0
- package/dist/services/applications/index.js.map +1 -0
- package/dist/services/availability/index.d.ts +254 -0
- package/dist/services/availability/index.d.ts.map +1 -0
- package/dist/services/availability/index.js +310 -0
- package/dist/services/availability/index.js.map +1 -0
- package/dist/services/contracts/index.d.ts +132 -0
- package/dist/services/contracts/index.d.ts.map +1 -0
- package/dist/services/contracts/index.js +211 -0
- package/dist/services/contracts/index.js.map +1 -0
- package/dist/services/engagements/index.d.ts +504 -0
- package/dist/services/engagements/index.d.ts.map +1 -0
- package/dist/services/engagements/index.js +613 -0
- package/dist/services/engagements/index.js.map +1 -0
- package/dist/services/jobs/index.d.ts +490 -0
- package/dist/services/jobs/index.d.ts.map +1 -0
- package/dist/services/jobs/index.js +753 -0
- package/dist/services/jobs/index.js.map +1 -0
- package/dist/services/payments/index.d.ts +415 -0
- package/dist/services/payments/index.d.ts.map +1 -0
- package/dist/services/payments/index.js +636 -0
- package/dist/services/payments/index.js.map +1 -0
- package/dist/services/profile/__tests__/fixtures.d.ts +214 -0
- package/dist/services/profile/__tests__/fixtures.d.ts.map +1 -0
- package/dist/services/profile/__tests__/fixtures.js +176 -0
- package/dist/services/profile/__tests__/fixtures.js.map +1 -0
- package/dist/services/profile/basic/index.d.ts +390 -0
- package/dist/services/profile/basic/index.d.ts.map +1 -0
- package/dist/services/profile/basic/index.js +1007 -0
- package/dist/services/profile/basic/index.js.map +1 -0
- package/dist/services/profile/certifications/index.d.ts +74 -0
- package/dist/services/profile/certifications/index.d.ts.map +1 -0
- package/dist/services/profile/certifications/index.js +169 -0
- package/dist/services/profile/certifications/index.js.map +1 -0
- package/dist/services/profile/education/index.d.ts +73 -0
- package/dist/services/profile/education/index.d.ts.map +1 -0
- package/dist/services/profile/education/index.js +168 -0
- package/dist/services/profile/education/index.js.map +1 -0
- package/dist/services/profile/employment/index.d.ts +111 -0
- package/dist/services/profile/employment/index.d.ts.map +1 -0
- package/dist/services/profile/employment/index.js +202 -0
- package/dist/services/profile/employment/index.js.map +1 -0
- package/dist/services/profile/external/index.d.ts +219 -0
- package/dist/services/profile/external/index.d.ts.map +1 -0
- package/dist/services/profile/external/index.js +560 -0
- package/dist/services/profile/external/index.js.map +1 -0
- package/dist/services/profile/index.d.ts +24 -0
- package/dist/services/profile/index.d.ts.map +1 -0
- package/dist/services/profile/index.js +26 -0
- package/dist/services/profile/index.js.map +1 -0
- package/dist/services/profile/industries/index.d.ts +130 -0
- package/dist/services/profile/industries/index.d.ts.map +1 -0
- package/dist/services/profile/industries/index.js +292 -0
- package/dist/services/profile/industries/index.js.map +1 -0
- package/dist/services/profile/portfolio/index.d.ts +352 -0
- package/dist/services/profile/portfolio/index.d.ts.map +1 -0
- package/dist/services/profile/portfolio/index.js +833 -0
- package/dist/services/profile/portfolio/index.js.map +1 -0
- package/dist/services/profile/resume/index.d.ts +60 -0
- package/dist/services/profile/resume/index.d.ts.map +1 -0
- package/dist/services/profile/resume/index.js +212 -0
- package/dist/services/profile/resume/index.js.map +1 -0
- package/dist/services/profile/reviews/index.d.ts +137 -0
- package/dist/services/profile/reviews/index.d.ts.map +1 -0
- package/dist/services/profile/reviews/index.js +431 -0
- package/dist/services/profile/reviews/index.js.map +1 -0
- package/dist/services/profile/shared.d.ts +127 -0
- package/dist/services/profile/shared.d.ts.map +1 -0
- package/dist/services/profile/shared.js +155 -0
- package/dist/services/profile/shared.js.map +1 -0
- package/dist/services/profile/skills/index.d.ts +212 -0
- package/dist/services/profile/skills/index.d.ts.map +1 -0
- package/dist/services/profile/skills/index.js +461 -0
- package/dist/services/profile/skills/index.js.map +1 -0
- package/dist/services/profile/visas/index.d.ts +74 -0
- package/dist/services/profile/visas/index.d.ts.map +1 -0
- package/dist/services/profile/visas/index.js +306 -0
- package/dist/services/profile/visas/index.js.map +1 -0
- package/dist/services/timesheet/index.d.ts +326 -0
- package/dist/services/timesheet/index.d.ts.map +1 -0
- package/dist/services/timesheet/index.js +324 -0
- package/dist/services/timesheet/index.js.map +1 -0
- package/dist/services/translations.d.ts +79 -0
- package/dist/services/translations.d.ts.map +1 -0
- package/dist/services/translations.js +136 -0
- package/dist/services/translations.js.map +1 -0
- package/dist/transport-resilience.d.ts +136 -0
- package/dist/transport-resilience.d.ts.map +1 -0
- package/dist/transport-resilience.js +247 -0
- package/dist/transport-resilience.js.map +1 -0
- package/dist/transport.d.ts +408 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +691 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/package.json +40 -12
- 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
|