@cantinasecurity/apex-cli 0.1.16 → 0.1.18

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.
@@ -0,0 +1,661 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { loginWithoutPersist } from "./auth.js";
3
+ import { isClaimProofAvailable } from "./claim-proof.js";
4
+ import { ApiError, getApiErrorCode, redactSensitiveText, } from "./api-client.js";
5
+ import { clearCredentialsIfCurrent, clearGuestFieldsIfClaimTokenMatches, getOrCreateConfigString, loadConfig, loadCredentials, replaceCredentialsIfCurrent, updateConfig, withCredentialLifecycleLock, } from "./config.js";
6
+ import { APEX_CLI_VERSION } from "./version.js";
7
+ import { loadWorkspaceBinding, saveWorkspaceBinding } from "./workspace-binding.js";
8
+ const ONBOARDING_CODE_PATTERN = /^SPBT-[A-Z0-9]{2,24}-[A-Z0-9]{8}$/;
9
+ const ONBOARDING_FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
10
+ const DEFAULT_CLIENT_NAME = "apex";
11
+ const HOSTED_APEX_ORIGIN = "https://ai.cantina.xyz";
12
+ function assertHostedOnboardingOrigin(baseUrl) {
13
+ const configuredUrl = new URL(baseUrl);
14
+ if (configuredUrl.origin !== HOSTED_APEX_ORIGIN) {
15
+ throw new Error(`Trial onboarding only works against ${HOSTED_APEX_ORIGIN} — this CLI is configured for ${configuredUrl.host}`);
16
+ }
17
+ }
18
+ function isGuestClaimLandingUrl(value, baseUrl) {
19
+ if (!value)
20
+ return false;
21
+ try {
22
+ const pathname = new URL(value, baseUrl).pathname.replace(/\/+$/u, "");
23
+ return pathname === "/apex/claim";
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function parseCliVersion(version) {
30
+ const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/u);
31
+ if (!match)
32
+ return null;
33
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
34
+ }
35
+ export function compareCliVersions(left, right) {
36
+ const leftParts = parseCliVersion(left);
37
+ const rightParts = parseCliVersion(right);
38
+ if (!leftParts || !rightParts) {
39
+ throw new Error("Apex returned an invalid minimum CLI version requirement.");
40
+ }
41
+ for (let index = 0; index < leftParts.length; index += 1) {
42
+ const difference = leftParts[index] - rightParts[index];
43
+ if (difference !== 0)
44
+ return Math.sign(difference);
45
+ }
46
+ return 0;
47
+ }
48
+ function assertSupportedCliVersion(minimumCliVersion) {
49
+ if (!minimumCliVersion)
50
+ return;
51
+ if (compareCliVersions(APEX_CLI_VERSION, minimumCliVersion) < 0) {
52
+ throw new Error(`This trial requires Apex CLI ${minimumCliVersion} or newer (current: ${APEX_CLI_VERSION}). Upgrade with \`npm install -g @cantinasecurity/apex-cli@latest\`, then run \`apex redeem\` again.`);
53
+ }
54
+ }
55
+ export function resolveOnboardingCode(code) {
56
+ const normalized = (code ?? process.env.APEX_ONBOARDING_CODE ?? "")
57
+ .trim()
58
+ .toUpperCase();
59
+ if (!normalized) {
60
+ throw new Error("A Cantina trial code is required. Pass it to `apex redeem` or set APEX_ONBOARDING_CODE.");
61
+ }
62
+ if (!ONBOARDING_CODE_PATTERN.test(normalized)) {
63
+ throw new Error("The Cantina trial code format is invalid.");
64
+ }
65
+ return normalized;
66
+ }
67
+ export function getLocalTrialStatus(credentials, nowMs = Date.now()) {
68
+ return {
69
+ guest: credentials?.guest === true,
70
+ guestSessionExpiresAt: credentials?.guestSessionExpiresAt ?? null,
71
+ claimDeadline: credentials?.claimExpiresAt ?? null,
72
+ claimAvailable: isClaimProofAvailable(credentials, nowMs),
73
+ };
74
+ }
75
+ export function isProofOnlyOnboardingCredentials(credentials) {
76
+ return Boolean(credentials?.guest !== true &&
77
+ credentials?.claimToken?.trim() &&
78
+ !credentials.accessToken?.trim() &&
79
+ !credentials.refreshToken?.trim());
80
+ }
81
+ function hasStoredAccountSession(credentials) {
82
+ return Boolean(credentials &&
83
+ credentials.guest !== true &&
84
+ (credentials.accessToken.trim() || credentials.refreshToken.trim()));
85
+ }
86
+ export async function getOrCreateOnboardingFingerprint() {
87
+ return getOrCreateConfigString("onboardingFingerprint", (value) => {
88
+ const normalized = typeof value === "string"
89
+ ? value.trim().toLowerCase()
90
+ : "";
91
+ return ONBOARDING_FINGERPRINT_PATTERN.test(normalized)
92
+ ? normalized
93
+ : null;
94
+ }, () => randomBytes(32).toString("hex"));
95
+ }
96
+ async function clearRevokedGuestCredentials(credentials) {
97
+ if (await clearCredentialsIfCurrent(credentials))
98
+ return;
99
+ const claimToken = credentials.claimToken?.trim() ?? "";
100
+ if (claimToken) {
101
+ await clearGuestFieldsIfClaimTokenMatches(claimToken);
102
+ }
103
+ throw new Error("Apex credentials changed while the guest trial was being revoked. The server trial was revoked and the newer credentials were kept; retry the command before continuing.");
104
+ }
105
+ async function revokeGuestOnboardingSessionUnlocked(client, options = {}) {
106
+ const credentials = options.expectedCredentials ?? await loadCredentials();
107
+ if (credentials?.guest !== true && !credentials?.claimToken?.trim()) {
108
+ return false;
109
+ }
110
+ const claimToken = credentials.claimToken?.trim() ?? "";
111
+ const config = await loadConfig();
112
+ assertHostedOnboardingOrigin(config.baseUrl);
113
+ const fingerprint = config.onboardingFingerprint?.trim().toLowerCase() ?? "";
114
+ const proof = claimToken && ONBOARDING_FINGERPRINT_PATTERN.test(fingerprint)
115
+ ? { claimToken, fingerprint }
116
+ : null;
117
+ const proofOnly = isProofOnlyOnboardingCredentials(credentials);
118
+ let bearerTerminalError = null;
119
+ if (credentials.guest === true) {
120
+ try {
121
+ await client.request("/api/cli/v1/onboarding/revoke", {
122
+ method: "POST",
123
+ auth: false,
124
+ bearerToken: credentials.accessToken,
125
+ retryOnUnauthorized: false,
126
+ telemetryAttribution: false,
127
+ requestBaseUrl: config.baseUrl,
128
+ ...(proof ? { json: proof } : {}),
129
+ });
130
+ await clearRevokedGuestCredentials(credentials);
131
+ return true;
132
+ }
133
+ catch (error) {
134
+ if (error instanceof ApiError &&
135
+ [
136
+ "GUEST_SESSION_EXPIRED",
137
+ "GUEST_SESSION_REVOKED",
138
+ "GUEST_SESSION_CLAIMED",
139
+ ].includes(error.code ?? "")) {
140
+ bearerTerminalError = error;
141
+ }
142
+ else {
143
+ throw error;
144
+ }
145
+ }
146
+ }
147
+ if (bearerTerminalError?.code === "GUEST_SESSION_REVOKED") {
148
+ await clearRevokedGuestCredentials(credentials);
149
+ return true;
150
+ }
151
+ if (proof &&
152
+ (credentials.guest !== true ||
153
+ bearerTerminalError?.code === "GUEST_SESSION_EXPIRED")) {
154
+ try {
155
+ await client.request("/api/cli/v1/onboarding/revoke", {
156
+ method: "POST",
157
+ auth: false,
158
+ retryOnUnauthorized: false,
159
+ telemetryAttribution: false,
160
+ requestBaseUrl: config.baseUrl,
161
+ json: proof,
162
+ });
163
+ }
164
+ catch (error) {
165
+ if (!((proofOnly || bearerTerminalError?.code === "GUEST_SESSION_EXPIRED") &&
166
+ options.clearLocalOnTerminal &&
167
+ error instanceof ApiError &&
168
+ error.code === "GUEST_SESSION_INVALID")) {
169
+ throw error;
170
+ }
171
+ const cleared = await clearCredentialsIfCurrent(credentials);
172
+ if (!cleared) {
173
+ throw new Error("Apex credentials changed while the stale trial proof was being cleared. The newer credentials were kept; retry logout before continuing.");
174
+ }
175
+ return false;
176
+ }
177
+ await clearRevokedGuestCredentials(credentials);
178
+ return true;
179
+ }
180
+ if (proofOnly && options.clearLocalOnTerminal) {
181
+ // Explicitly confirmed logout may discard an unverifiable proof-only stub.
182
+ // This changes local state only; server retention remains authoritative.
183
+ const cleared = await clearCredentialsIfCurrent(credentials);
184
+ if (!cleared) {
185
+ throw new Error("Apex credentials changed while the unverifiable trial proof was being cleared. The newer credentials were kept; retry logout before continuing.");
186
+ }
187
+ return false;
188
+ }
189
+ if (bearerTerminalError && options.clearLocalOnTerminal) {
190
+ // Explicit logout may clear unusable local bearer state when there is no
191
+ // proof available for a server-side revoke. The generation check protects
192
+ // any login that completed while the revoke request was in flight.
193
+ const cleared = await clearCredentialsIfCurrent(credentials, bearerTerminalError.code === "GUEST_SESSION_EXPIRED"
194
+ ? { preserveClaimProof: true }
195
+ : {});
196
+ if (!cleared) {
197
+ throw new Error("Apex credentials changed while terminal guest access was being cleared. The newer credentials were kept; retry logout before continuing.");
198
+ }
199
+ return false;
200
+ }
201
+ if (bearerTerminalError)
202
+ throw bearerTerminalError;
203
+ throw new Error("The stored trial claim proof cannot be verified on this machine. Run `apex claim` before replacing the trial.");
204
+ }
205
+ export function revokeGuestOnboardingSession(client, options = {}) {
206
+ return withCredentialLifecycleLock(() => revokeGuestOnboardingSessionUnlocked(client, options));
207
+ }
208
+ class RedeemedGuestRecoveryFailure extends Error {
209
+ constructor(message) {
210
+ super(message);
211
+ this.name = "RedeemedGuestRecoveryFailure";
212
+ }
213
+ }
214
+ function sanitizedError(error) {
215
+ return new Error(redactSensitiveText(error instanceof Error ? error.message : String(error)));
216
+ }
217
+ function buildRedeemedGuestCredentials(redeemed, codeHash, claimToken) {
218
+ return {
219
+ accessToken: redeemed.accessToken,
220
+ refreshToken: redeemed.refreshToken,
221
+ accessTokenExpiresAt: redeemed.accessTokenExpiresAt,
222
+ refreshTokenExpiresAt: redeemed.refreshTokenExpiresAt,
223
+ issuerOrigin: HOSTED_APEX_ORIGIN,
224
+ guest: true,
225
+ guestCompanyId: redeemed.company.id,
226
+ ...(claimToken ? { claimToken } : {}),
227
+ claimExpiresAt: redeemed.claimDeadline,
228
+ guestSessionExpiresAt: redeemed.guestSessionExpiresAt,
229
+ onboardingCodeHash: codeHash,
230
+ };
231
+ }
232
+ async function revokeRedeemedGuestByBearer(client, redeemed, requestBaseUrl) {
233
+ await client.request("/api/cli/v1/onboarding/revoke", {
234
+ method: "POST",
235
+ auth: false,
236
+ bearerToken: redeemed.accessToken,
237
+ retryOnUnauthorized: false,
238
+ telemetryAttribution: false,
239
+ requestBaseUrl,
240
+ });
241
+ }
242
+ /**
243
+ * A successful redeem creates the server guest before the CLI can validate the
244
+ * version gate or mint a claim proof. Preserve that bearer generation so the
245
+ * user can finish setup (or explicitly replace/revoke it). If another auth
246
+ * lifecycle won the local CAS, revoke this response instead; a same-guest
247
+ * winner is already a locally actionable handle to the one server lifecycle.
248
+ */
249
+ async function recoverRedeemedGuestAfterSetupFailure(client, expectedCredentials, redeemed, codeHash, requestBaseUrl) {
250
+ const recoveryCredentials = buildRedeemedGuestCredentials(redeemed, codeHash);
251
+ let committed;
252
+ try {
253
+ committed = await replaceCredentialsIfCurrent(expectedCredentials, recoveryCredentials);
254
+ }
255
+ catch {
256
+ // A write can throw after its atomic rename (for example, on the final
257
+ // chmod). Reload before deciding whether the response guest lacks a local
258
+ // handle. Never revoke here: if storage truly failed before persistence,
259
+ // the still-active server guest remains recoverable by redeeming the same
260
+ // campaign code again on this fingerprint.
261
+ const persisted = await loadCredentials().catch(() => null);
262
+ const persistedGuestCompanyId = persisted?.guestCompanyId?.trim() ?? "";
263
+ if (persisted?.guest === true &&
264
+ persisted.refreshToken === recoveryCredentials.refreshToken &&
265
+ persistedGuestCompanyId === redeemed.company.id) {
266
+ await updateConfig({ defaultCompanyId: redeemed.company.id }).catch(() => undefined);
267
+ return "saved";
268
+ }
269
+ if (persisted?.guest === true &&
270
+ (persistedGuestCompanyId.length === 0 ||
271
+ persistedGuestCompanyId === redeemed.company.id)) {
272
+ return "newer_guest_kept";
273
+ }
274
+ let staleProofCleanupFailed = false;
275
+ if (hasStoredAccountSession(persisted)) {
276
+ const staleClaimToken = expectedCredentials?.claimToken?.trim() ?? "";
277
+ if (staleClaimToken) {
278
+ try {
279
+ await clearGuestFieldsIfClaimTokenMatches(staleClaimToken);
280
+ }
281
+ catch {
282
+ staleProofCleanupFailed = true;
283
+ }
284
+ }
285
+ }
286
+ if (hasStoredAccountSession(persisted)) {
287
+ throw new RedeemedGuestRecoveryFailure(staleProofCleanupFailed
288
+ ? "A newer Cantina account session was kept, but Apex could not remove its stale trial proof after local guest persistence failed. The server guest remains active. Run `apex logout`, then retry the same `apex redeem <code>` command; contact support if recovery still fails."
289
+ : "A newer Cantina account session was kept while local guest persistence failed. The server guest remains active. Finish or log out of that account session, then retry the same `apex redeem <code>` command; contact support if recovery still fails.");
290
+ }
291
+ if (persisted?.guest === true) {
292
+ throw new RedeemedGuestRecoveryFailure("A different local guest session was kept while persistence failed. The server guest created by this redeem remains active. Finish or log out of the local guest, then retry the same `apex redeem <code>` command; contact support if recovery still fails.");
293
+ }
294
+ try {
295
+ // If a different proof lifecycle remains, fail closed rather than
296
+ // deleting it. The same-code server lifecycle is still recoverable.
297
+ if (expectedCredentials?.claimToken?.trim()) {
298
+ await clearGuestFieldsIfClaimTokenMatches(expectedCredentials.claimToken.trim());
299
+ }
300
+ }
301
+ catch {
302
+ throw new RedeemedGuestRecoveryFailure("Local guest persistence failed and Apex could not clear the stale trial proof. The server guest remains active. Run `apex logout`, then retry the same `apex redeem <code>` command; contact support if recovery still fails.");
303
+ }
304
+ throw new RedeemedGuestRecoveryFailure("Local guest persistence failed, but the server guest remains active. Retry the same `apex redeem <code>` command to recover it; contact support if recovery still fails.");
305
+ }
306
+ if (committed.saved) {
307
+ // The credential file is the recovery invariant. A best-effort default
308
+ // company update must not make an actionable guest look orphaned or cause
309
+ // cleanup to revoke the session we just saved.
310
+ await updateConfig({ defaultCompanyId: redeemed.company.id }).catch(() => undefined);
311
+ return "saved";
312
+ }
313
+ const winnerIsGuest = committed.credentials?.guest === true;
314
+ const winnerGuestCompanyId = committed.credentials?.guestCompanyId?.trim() ?? "";
315
+ if (winnerIsGuest &&
316
+ (winnerGuestCompanyId.length === 0 ||
317
+ winnerGuestCompanyId === redeemed.company.id)) {
318
+ return "newer_guest_kept";
319
+ }
320
+ if (!winnerIsGuest) {
321
+ const staleClaimToken = expectedCredentials?.claimToken?.trim() ?? "";
322
+ if (staleClaimToken) {
323
+ try {
324
+ await clearGuestFieldsIfClaimTokenMatches(staleClaimToken);
325
+ }
326
+ catch {
327
+ // Keep the response guest active when local proof cleanup cannot be
328
+ // made durable. Once storage is writable, logging out the account and
329
+ // re-redeeming the same code can rotate the invalid proof safely.
330
+ throw new RedeemedGuestRecoveryFailure("A newer Cantina account session was kept, but Apex could not remove its stale trial proof. The server guest remains active. Repair local Apex config permissions, finish or log out of the account, then retry the same `apex redeem <code>` command; contact support if recovery still fails.");
331
+ }
332
+ }
333
+ }
334
+ try {
335
+ await revokeRedeemedGuestByBearer(client, redeemed, requestBaseUrl);
336
+ }
337
+ catch {
338
+ throw new RedeemedGuestRecoveryFailure("A newer local account or different guest session was kept, and Apex could not confirm cleanup of the incomplete server guest. Do not assume this trial code is reusable. Finish or log out of the newer local session, then use the Cantina account or contact support to recover or clear the guest before trying another code.");
339
+ }
340
+ return "revoked";
341
+ }
342
+ function redeemRecoveryError(error, outcome) {
343
+ const originalMessage = redactSensitiveText(error instanceof Error ? error.message : String(error));
344
+ let guidance;
345
+ if (outcome === "saved") {
346
+ guidance =
347
+ "The guest session was saved locally; retry the same `apex redeem <code>` command to finish claim-proof setup, or use `--replace` with a different code to revoke it first.";
348
+ }
349
+ else if (outcome === "newer_guest_kept") {
350
+ guidance =
351
+ "Newer local guest credentials were kept; retry the redeem command from that credential state.";
352
+ }
353
+ else {
354
+ guidance =
355
+ "The incomplete guest session was revoked. This trial code is consumed on this machine; use a Cantina account, another trial code, or contact support.";
356
+ }
357
+ return new Error(`${originalMessage} ${guidance}`);
358
+ }
359
+ async function throwAfterRedeemedGuestSetupFailure(setupError, client, expectedCredentials, redeemed, codeHash, requestBaseUrl) {
360
+ let recovery;
361
+ try {
362
+ recovery = await recoverRedeemedGuestAfterSetupFailure(client, expectedCredentials, redeemed, codeHash, requestBaseUrl);
363
+ }
364
+ catch (recoveryError) {
365
+ const setupMessage = redactSensitiveText(setupError instanceof Error ? setupError.message : String(setupError));
366
+ const recoveryGuidance = recoveryError instanceof RedeemedGuestRecoveryFailure
367
+ ? redactSensitiveText(recoveryError.message)
368
+ : "Apex could not save the guest or confirm server cleanup. Do not assume this trial code is reusable; use a Cantina account or contact support before trying another code.";
369
+ throw new AggregateError([sanitizedError(setupError), sanitizedError(recoveryError)], `${setupMessage} ${recoveryGuidance}`);
370
+ }
371
+ throw redeemRecoveryError(setupError, recovery);
372
+ }
373
+ async function throwAfterTerminalClaimTokenFailure(error, expectedCredentials) {
374
+ const staleClaimToken = expectedCredentials?.claimToken?.trim() ?? "";
375
+ let clearedExpectedGeneration = false;
376
+ if (expectedCredentials) {
377
+ try {
378
+ // An active guest must lose its terminal bearer pair as one unit. Merely
379
+ // stripping guest metadata would leave revoked tokens looking like a
380
+ // Cantina account session to hasStoredAccountSession().
381
+ clearedExpectedGeneration = await clearCredentialsIfCurrent(expectedCredentials);
382
+ }
383
+ catch {
384
+ throw new Error("The guest became terminal before Apex could mint a new claim proof, and its exact local credential generation could not be removed. Repair local Apex config permissions, run `apex logout`, then log in with the claimed Cantina account or contact support; this trial code cannot be redeemed again on this machine.");
385
+ }
386
+ }
387
+ if (!clearedExpectedGeneration && staleClaimToken) {
388
+ try {
389
+ // A newer account login can win after the observed guest generation and
390
+ // preserve only its stale proof fields. Strip those fields without
391
+ // touching the winning account token pair.
392
+ await clearGuestFieldsIfClaimTokenMatches(staleClaimToken);
393
+ }
394
+ catch {
395
+ throw new Error("The guest became terminal before Apex could mint a new claim proof, and its stale proof could not be removed from newer account credentials. Repair local Apex config permissions, then log in with the claimed Cantina account or contact support; this trial code cannot be redeemed again on this machine.");
396
+ }
397
+ }
398
+ const originalMessage = redactSensitiveText(error instanceof Error ? error.message : String(error));
399
+ const code = getApiErrorCode(error);
400
+ const guidance = code === "GUEST_SESSION_CLAIMED"
401
+ ? "The guest was claimed before claim-proof setup finished. Run `apex login` to use the claimed Cantina account; this trial code cannot be redeemed again on this machine."
402
+ : "The guest expired or was revoked before claim-proof setup finished. This trial code cannot be redeemed again on this machine; use a Cantina account, another trial code, or contact support.";
403
+ throw new Error(`${originalMessage} ${guidance}`);
404
+ }
405
+ async function clearTerminalClaimState(expectedCredentials, claimToken) {
406
+ if (expectedCredentials?.guest === true &&
407
+ await clearCredentialsIfCurrent(expectedCredentials)) {
408
+ return;
409
+ }
410
+ // A concurrent account login may have preserved this proof while replacing
411
+ // the guest bearer pair. In that case remove only the consumed proof; never
412
+ // clear the newer account generation.
413
+ await clearGuestFieldsIfClaimTokenMatches(claimToken);
414
+ }
415
+ async function redeemOnboardingCodeUnlocked(client, options = {}) {
416
+ const code = resolveOnboardingCode(options.code);
417
+ const config = await loadConfig();
418
+ assertHostedOnboardingOrigin(config.baseUrl);
419
+ const codeHash = createHash("sha256").update(code).digest("hex");
420
+ const fingerprint = await getOrCreateOnboardingFingerprint();
421
+ const existingCredentials = await loadCredentials();
422
+ const hasGuestCredentials = existingCredentials?.guest === true;
423
+ const claimProofAvailable = isClaimProofAvailable(existingCredentials);
424
+ if (hasStoredAccountSession(existingCredentials)) {
425
+ return {
426
+ outcome: "already_authenticated",
427
+ redeemed: false,
428
+ warning: "the next scan would use your account's paid credits",
429
+ };
430
+ }
431
+ if ((hasGuestCredentials || claimProofAvailable) &&
432
+ existingCredentials?.onboardingCodeHash !== codeHash &&
433
+ options.replace !== true) {
434
+ throw new Error(claimProofAvailable
435
+ ? "This machine still holds an unclaimed Apex trial. Run `apex claim` to keep it, or re-run with `--replace` to revoke it and queue its data for deletion."
436
+ : "This machine still holds an Apex guest trial without a usable local claim proof. Re-run its original code to recover it, or re-run this different code with `--replace` to revoke the guest and queue its data for deletion.");
437
+ }
438
+ if (options.replace === true &&
439
+ existingCredentials?.onboardingCodeHash === codeHash) {
440
+ throw new Error("`--replace` is only for a different trial code. Re-run this same code without `--replace` for an idempotent retry.");
441
+ }
442
+ let expectedCredentials = existingCredentials;
443
+ if (options.replace === true &&
444
+ (existingCredentials?.guest === true || existingCredentials?.claimToken?.trim())) {
445
+ await revokeGuestOnboardingSessionUnlocked(client, {
446
+ expectedCredentials: existingCredentials,
447
+ });
448
+ expectedCredentials = null;
449
+ }
450
+ const redeemed = await client.request("/api/cli/v1/onboarding/redeem", {
451
+ method: "POST",
452
+ auth: false,
453
+ retryOnUnauthorized: false,
454
+ telemetryAttribution: false,
455
+ json: {
456
+ code,
457
+ fingerprint,
458
+ clientName: options.clientName ?? DEFAULT_CLIENT_NAME,
459
+ clientVersion: options.clientVersion ?? APEX_CLI_VERSION,
460
+ },
461
+ requestBaseUrl: config.baseUrl,
462
+ });
463
+ try {
464
+ assertSupportedCliVersion(redeemed.minimumCliVersion);
465
+ }
466
+ catch (error) {
467
+ return throwAfterRedeemedGuestSetupFailure(error, client, expectedCredentials, redeemed, codeHash, config.baseUrl);
468
+ }
469
+ let claimToken;
470
+ try {
471
+ const claim = await client.request("/api/cli/v1/onboarding/claim-token", {
472
+ method: "POST",
473
+ auth: false,
474
+ bearerToken: redeemed.accessToken,
475
+ retryOnUnauthorized: false,
476
+ telemetryAttribution: false,
477
+ requestBaseUrl: config.baseUrl,
478
+ });
479
+ claimToken = claim.claimToken;
480
+ }
481
+ catch (error) {
482
+ if ([
483
+ "GUEST_SESSION_REVOKED",
484
+ "GUEST_SESSION_CLAIMED",
485
+ "GUEST_SESSION_EXPIRED",
486
+ ].includes(getApiErrorCode(error) ?? "")) {
487
+ return throwAfterTerminalClaimTokenFailure(error, expectedCredentials);
488
+ }
489
+ return throwAfterRedeemedGuestSetupFailure(error, client, expectedCredentials, redeemed, codeHash, config.baseUrl);
490
+ }
491
+ const nextCredentials = buildRedeemedGuestCredentials(redeemed, codeHash, claimToken);
492
+ const committed = await replaceCredentialsIfCurrent(expectedCredentials, nextCredentials);
493
+ if (!committed.saved) {
494
+ const winnerIsGuest = committed.credentials?.guest === true;
495
+ const winnerGuestCompanyId = committed.credentials?.guestCompanyId?.trim() ?? "";
496
+ const shouldRevokeDiscardedGuest = !winnerIsGuest ||
497
+ (winnerGuestCompanyId.length > 0 &&
498
+ winnerGuestCompanyId !== redeemed.company.id);
499
+ if (!winnerIsGuest) {
500
+ const staleClaimToken = expectedCredentials?.claimToken?.trim() ?? "";
501
+ if (staleClaimToken) {
502
+ await clearGuestFieldsIfClaimTokenMatches(staleClaimToken);
503
+ }
504
+ }
505
+ // Same-code idempotent redeems share a guest tenant/session even though
506
+ // claim proofs rotate. Revoke the discarded proof only when the winner is
507
+ // an account or has a known, different guest tenant identity.
508
+ if (shouldRevokeDiscardedGuest) {
509
+ await client.request("/api/cli/v1/onboarding/revoke", {
510
+ method: "POST",
511
+ auth: false,
512
+ retryOnUnauthorized: false,
513
+ telemetryAttribution: false,
514
+ requestBaseUrl: config.baseUrl,
515
+ json: { claimToken, fingerprint },
516
+ });
517
+ }
518
+ if (winnerIsGuest) {
519
+ throw new Error(shouldRevokeDiscardedGuest
520
+ ? "Guest credentials changed while this code was being redeemed. The discarded guest trial was revoked and this trial code is consumed; use the newer guest, another trial code, or contact support."
521
+ : "Guest credentials changed while this code was being redeemed. The newer credentials were kept; retry after that operation finishes.");
522
+ }
523
+ else {
524
+ throw new Error("Apex credentials changed while the trial was being redeemed. The new guest trial was revoked and this trial code is consumed; use the winning Cantina account, another trial code, or contact support.");
525
+ }
526
+ }
527
+ // Credentials and claim proof are already durable. Default-company
528
+ // convenience must not turn a successful redemption into a reported failure.
529
+ await updateConfig({ defaultCompanyId: redeemed.company.id }).catch(() => undefined);
530
+ return {
531
+ outcome: redeemed.outcome,
532
+ redeemed: true,
533
+ guestSessionExpiresAt: redeemed.guestSessionExpiresAt,
534
+ claimDeadline: redeemed.claimDeadline,
535
+ company: redeemed.company,
536
+ grant: redeemed.grant,
537
+ termsUrl: redeemed.termsUrl,
538
+ notice: redeemed.notice,
539
+ ...(redeemed.minimumCliVersion
540
+ ? { minimumCliVersion: redeemed.minimumCliVersion }
541
+ : {}),
542
+ };
543
+ }
544
+ export function redeemOnboardingCode(client, options = {}) {
545
+ return withCredentialLifecycleLock(() => redeemOnboardingCodeUnlocked(client, options));
546
+ }
547
+ async function claimOnboardingSessionUnlocked(client, cwd, options = {}) {
548
+ let expectedCredentials = await loadCredentials();
549
+ const config = await loadConfig();
550
+ assertHostedOnboardingOrigin(config.baseUrl);
551
+ const claimToken = expectedCredentials?.claimToken?.trim() ?? "";
552
+ const fingerprint = config.onboardingFingerprint?.trim().toLowerCase() ?? "";
553
+ if (!claimToken || !ONBOARDING_FINGERPRINT_PATTERN.test(fingerprint)) {
554
+ throw new Error("No Apex trial claim proof is stored on this machine. If the guest session is still active, run `apex redeem <code>` again here, then run `apex claim`.");
555
+ }
556
+ let accountAccessToken = null;
557
+ if (hasStoredAccountSession(expectedCredentials)) {
558
+ accountAccessToken = await client.getAccessToken(expectedCredentials);
559
+ const refreshedCredentials = await loadCredentials();
560
+ if (!refreshedCredentials ||
561
+ refreshedCredentials?.claimToken?.trim() !== claimToken) {
562
+ throw new Error("Apex credentials changed before the trial claim started. Re-run `apex claim`.");
563
+ }
564
+ if (accountAccessToken && !hasStoredAccountSession(refreshedCredentials)) {
565
+ throw new Error("Apex credentials changed before the trial claim started. Re-run `apex claim`.");
566
+ }
567
+ if (accountAccessToken &&
568
+ refreshedCredentials.accessToken !== accountAccessToken) {
569
+ throw new Error("Apex credentials changed before the trial claim started. Re-run `apex claim`.");
570
+ }
571
+ // getAccessToken may rotate the account token pair. Compare-and-swap from
572
+ // that refreshed (or proof-only) generation so claim cannot overwrite a
573
+ // later login.
574
+ expectedCredentials = refreshedCredentials;
575
+ }
576
+ if (!accountAccessToken) {
577
+ const accountCredentials = await loginWithoutPersist(client, {
578
+ noOpen: options.noOpen,
579
+ quiet: options.quiet,
580
+ onVerification: options.onVerification,
581
+ requestBaseUrl: config.baseUrl,
582
+ });
583
+ accountAccessToken = accountCredentials.accessToken;
584
+ }
585
+ let claimed;
586
+ try {
587
+ claimed = await client.request("/api/cli/v1/onboarding/claim", {
588
+ method: "POST",
589
+ auth: false,
590
+ bearerToken: accountAccessToken,
591
+ retryOnUnauthorized: false,
592
+ telemetryAttribution: false,
593
+ requestBaseUrl: config.baseUrl,
594
+ json: {
595
+ claimToken,
596
+ fingerprint,
597
+ },
598
+ });
599
+ }
600
+ catch (error) {
601
+ if (["CLAIM_EXPIRED", "ALREADY_CLAIMED", "CLAIM_INVALID"].includes(getApiErrorCode(error) ?? "")) {
602
+ await clearTerminalClaimState(expectedCredentials, claimToken);
603
+ }
604
+ throw error;
605
+ }
606
+ const claimedCredentials = {
607
+ accessToken: claimed.accessToken,
608
+ refreshToken: claimed.refreshToken,
609
+ accessTokenExpiresAt: claimed.accessTokenExpiresAt,
610
+ refreshTokenExpiresAt: claimed.refreshTokenExpiresAt,
611
+ issuerOrigin: HOSTED_APEX_ORIGIN,
612
+ };
613
+ const persisted = await replaceCredentialsIfCurrent(expectedCredentials, claimedCredentials);
614
+ if (!persisted.saved) {
615
+ // The server claim is committed. Remove only the consumed proof from a
616
+ // concurrently installed account generation; never overwrite its tokens.
617
+ await clearGuestFieldsIfClaimTokenMatches(claimToken);
618
+ throw new Error("The trial was claimed, but local Apex credentials changed while it completed. Run `apex login` to load the claimed account.");
619
+ }
620
+ // The server claim and local account credentials are already durable. These
621
+ // convenience writes must not turn that successful claim into a reported
622
+ // failure; later commands can rediscover the default company and binding.
623
+ await updateConfig({ defaultCompanyId: claimed.company.id }).catch(() => undefined);
624
+ const currentBinding = await loadWorkspaceBinding(cwd).catch(() => null);
625
+ const claimedCurrentWorkspace = currentBinding &&
626
+ (currentBinding.companyId === claimed.company.id ||
627
+ (expectedCredentials?.guestCompanyId &&
628
+ currentBinding.companyId === expectedCredentials.guestCompanyId) ||
629
+ (claimed.workspaceId &&
630
+ currentBinding.workspaceId === claimed.workspaceId))
631
+ ? currentBinding
632
+ : null;
633
+ const workspaceId = claimedCurrentWorkspace?.workspaceId ?? claimed.workspaceId;
634
+ const storedScanUrl = claimedCurrentWorkspace?.lastScanUrl ?? null;
635
+ const storedScanUrlIsGuestClaimLanding = isGuestClaimLandingUrl(storedScanUrl, config.baseUrl);
636
+ const scanUrl = claimedCurrentWorkspace
637
+ ? (!storedScanUrlIsGuestClaimLanding ? storedScanUrl : null) ??
638
+ (claimedCurrentWorkspace.workspaceId === claimed.workspaceId
639
+ ? claimed.scanUrl
640
+ : new URL(`/workspace/${encodeURIComponent(claimedCurrentWorkspace.workspaceId)}`, config.baseUrl).toString())
641
+ : claimed.scanUrl;
642
+ if (claimedCurrentWorkspace) {
643
+ await Promise.resolve(saveWorkspaceBinding(cwd, {
644
+ ...claimedCurrentWorkspace,
645
+ companyId: claimed.company.id,
646
+ ...(storedScanUrlIsGuestClaimLanding ? { lastScanUrl: scanUrl } : {}),
647
+ lastSyncedAt: new Date().toISOString(),
648
+ })).catch(() => undefined);
649
+ }
650
+ return {
651
+ outcome: "claimed",
652
+ claimed: true,
653
+ company: claimed.company,
654
+ workspaceId,
655
+ scanUrl,
656
+ ...(claimed.discountCode ? { discountCode: claimed.discountCode } : {}),
657
+ };
658
+ }
659
+ export function claimOnboardingSession(client, cwd, options = {}) {
660
+ return withCredentialLifecycleLock(() => claimOnboardingSessionUnlocked(client, cwd, options));
661
+ }