@vellumai/cli 0.8.11 → 0.8.12-dev.202606122337.5897832

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.
@@ -2,142 +2,164 @@ import { getPlatformUrl } from "./platform-client.js";
2
2
  import { DOCKERHUB_IMAGES } from "./docker.js";
3
3
  import type { ServiceName } from "./docker.js";
4
4
  import { loopbackSafeFetch } from "./loopback-fetch.js";
5
+ import { stripVersionPrefix } from "./version-compat.js";
5
6
 
6
7
  export interface ResolvedImageRefs {
7
8
  imageTags: Record<ServiceName, string>;
8
9
  source: "platform" | "dockerhub";
9
10
  }
10
11
 
12
+ export interface ReleaseListItem {
13
+ version: string;
14
+ is_stable?: boolean;
15
+ assistant_image_ref?: string | null;
16
+ gateway_image_ref?: string | null;
17
+ credential_executor_image_ref?: string | null;
18
+ }
19
+
11
20
  /**
12
- * Fetch the latest stable release version from the platform API.
13
- * Returns the version string (e.g. "0.7.0") or null if unavailable.
14
- * The releases endpoint returns entries ordered newest-first.
21
+ * The endpoint defaults `limit` to 10, which is too few to validate an
22
+ * explicit --version against ("absent from the list" is treated as a hard
23
+ * error) always request the documented maximum.
15
24
  */
16
- export async function fetchLatestStableVersion(): Promise<string | null> {
25
+ const RELEASES_FETCH_LIMIT = 100;
26
+
27
+ /**
28
+ * Fetch the releases list from the platform API, optionally filtered by
29
+ * channel (the `channel` param takes precedence over `stable` server-side).
30
+ * `platformUrl` overrides the lockfile/env-resolved default — pass the
31
+ * target assistant's platform URL when it may differ from the active one.
32
+ * Returns `null` when the platform is unreachable or responds non-OK —
33
+ * distinct from `[]` (platform answered, no releases).
34
+ */
35
+ export async function fetchReleases(opts?: {
36
+ channel?: "stable" | "preview";
37
+ platformUrl?: string;
38
+ }): Promise<ReleaseListItem[] | null> {
17
39
  try {
18
- const platformUrl = getPlatformUrl();
19
- const response = await loopbackSafeFetch(`${platformUrl}/v1/releases/?stable=true`, {
20
- signal: AbortSignal.timeout(10_000),
21
- });
40
+ const platformUrl = opts?.platformUrl || getPlatformUrl();
41
+ const filter = opts?.channel ? `channel=${opts.channel}` : "stable=true";
42
+ const response = await loopbackSafeFetch(
43
+ `${platformUrl}/v1/releases/?${filter}&limit=${RELEASES_FETCH_LIMIT}`,
44
+ { signal: AbortSignal.timeout(10_000) },
45
+ );
22
46
  if (!response.ok) return null;
23
-
24
- const releases = (await response.json()) as Array<{
25
- version?: string;
26
- }>;
27
- const first = releases[0];
28
- return first?.version ?? null;
47
+ return (await response.json()) as ReleaseListItem[];
29
48
  } catch {
30
49
  return null;
31
50
  }
32
51
  }
33
52
 
34
53
  /**
35
- * Resolve image references for a given version.
54
+ * Fetch the latest stable release version from the platform API.
55
+ * Returns the version string (e.g. "0.7.0") or null if unavailable.
56
+ * The releases endpoint returns entries ordered newest-first.
57
+ */
58
+ export async function fetchLatestStableVersion(): Promise<string | null> {
59
+ const releases = await fetchReleases();
60
+ return releases?.[0]?.version ?? null;
61
+ }
62
+
63
+ export type ImageRefResolution =
64
+ | { status: "platform"; imageTags: Record<ServiceName, string> }
65
+ | { status: "dockerhub-fallback"; imageTags: Record<ServiceName, string> }
66
+ | { status: "version-not-found" };
67
+
68
+ function dockerhubImageTags(version: string): Record<ServiceName, string> {
69
+ return {
70
+ assistant: `${DOCKERHUB_IMAGES.assistant}:${version}`,
71
+ "credential-executor": `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`,
72
+ gateway: `${DOCKERHUB_IMAGES.gateway}:${version}`,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Resolve image references for a given version, distinguishing why the
78
+ * platform refs were unavailable:
36
79
  *
37
- * Tries the platform API first (returns GCR digest-based refs when available),
38
- * then falls back to DockerHub tag-based refs when the platform is unreachable
39
- * or the version is not found.
80
+ * - `platform` release found; GCR digest-based refs (credential-executor
81
+ * falls back to DockerHub per-service when its ref is null).
82
+ * - `dockerhub-fallback` platform unreachable or responded non-OK;
83
+ * tag-based DockerHub refs.
84
+ * - `version-not-found` — the platform answered but the version is absent
85
+ * from the releases list (likely a typo'd --version).
40
86
  */
41
- export async function resolveImageRefs(
87
+ export async function resolveImageRefsDetailed(
42
88
  version: string,
43
89
  log?: (msg: string) => void,
44
- ): Promise<ResolvedImageRefs> {
90
+ ): Promise<ImageRefResolution> {
45
91
  log?.("Resolving image references...");
46
92
 
47
- const platformRefs = await fetchPlatformImageRefs(version, log);
48
- if (platformRefs) {
49
- log?.("Resolved image refs from platform API");
93
+ const releases = await fetchReleases();
94
+ if (releases === null) {
95
+ log?.("Platform unreachable falling back to DockerHub tags");
50
96
  return {
51
- imageTags: platformRefs.imageTags,
52
- source: "platform",
97
+ status: "dockerhub-fallback",
98
+ imageTags: dockerhubImageTags(version),
53
99
  };
54
100
  }
55
101
 
56
- log?.("Falling back to DockerHub tags");
57
- const imageTags: Record<ServiceName, string> = {
58
- assistant: `${DOCKERHUB_IMAGES.assistant}:${version}`,
59
- "credential-executor": `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`,
60
- gateway: `${DOCKERHUB_IMAGES.gateway}:${version}`,
102
+ const normalizedVersion = stripVersionPrefix(version);
103
+ const release = releases.find(
104
+ (r) => stripVersionPrefix(r.version ?? "") === normalizedVersion,
105
+ );
106
+
107
+ if (!release) {
108
+ log?.(`Version ${version} not found in platform releases`);
109
+ return { status: "version-not-found" };
110
+ }
111
+
112
+ const assistantImage = release.assistant_image_ref;
113
+ const gatewayImage = release.gateway_image_ref;
114
+ let credentialExecutorImage = release.credential_executor_image_ref;
115
+
116
+ // Assistant and gateway images are required; a release missing them is a
117
+ // platform data gap, not a user typo — fall back to DockerHub tags.
118
+ if (!assistantImage || !gatewayImage) {
119
+ log?.("Platform release missing required image refs");
120
+ return {
121
+ status: "dockerhub-fallback",
122
+ imageTags: dockerhubImageTags(version),
123
+ };
124
+ }
125
+
126
+ if (!credentialExecutorImage) {
127
+ credentialExecutorImage = `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`;
128
+ log?.(
129
+ "credential-executor image not in platform release, using DockerHub fallback",
130
+ );
131
+ }
132
+
133
+ return {
134
+ status: "platform",
135
+ imageTags: {
136
+ assistant: assistantImage,
137
+ "credential-executor": credentialExecutorImage,
138
+ gateway: gatewayImage,
139
+ },
61
140
  };
62
- return { imageTags, source: "dockerhub" };
63
141
  }
64
142
 
65
143
  /**
66
- * Fetch image references from the platform releases API.
144
+ * Resolve image references for a given version.
67
145
  *
68
- * Returns a record of service name to image ref (GCR digest-based) for the
69
- * given version, or null if the platform is unreachable, the version is not
70
- * found, or any error occurs.
146
+ * Lenient wrapper around {@link resolveImageRefsDetailed}: maps
147
+ * `version-not-found` to the DockerHub fallback so existing callers
148
+ * (start flows, docker rollback) keep their permissive behavior. The
149
+ * upgrade command uses the detailed variant to fail fast on typos.
71
150
  */
72
- async function fetchPlatformImageRefs(
151
+ export async function resolveImageRefs(
73
152
  version: string,
74
153
  log?: (msg: string) => void,
75
- ): Promise<{
76
- imageTags: Record<ServiceName, string>;
77
- } | null> {
78
- try {
79
- const platformUrl = getPlatformUrl();
80
- const url = `${platformUrl}/v1/releases/?stable=true`;
81
-
82
- log?.(`Fetching releases from ${url}`);
83
-
84
- const response = await loopbackSafeFetch(url, {
85
- signal: AbortSignal.timeout(10_000),
86
- });
87
-
88
- if (!response.ok) {
89
- log?.(`Platform API returned ${response.status}`);
90
- return null;
91
- }
92
-
93
- const releases = (await response.json()) as Array<{
94
- version?: string;
95
- assistant_image_ref?: string | null;
96
- gateway_image_ref?: string | null;
97
- credential_executor_image_ref?: string | null;
98
- }>;
99
-
100
- // Strip leading "v" from the requested version for matching
101
- const normalizedVersion = version.replace(/^v/, "");
102
-
103
- const release = releases.find((r) => {
104
- const releaseVersion = (r.version ?? "").replace(/^v/, "");
105
- return releaseVersion === normalizedVersion;
106
- });
107
-
108
- if (!release) {
109
- log?.(`Version ${version} not found in platform releases`);
110
- return null;
111
- }
112
-
113
- const assistantImage = release.assistant_image_ref;
114
- const gatewayImage = release.gateway_image_ref;
115
- let credentialExecutorImage = release.credential_executor_image_ref;
116
-
117
- // Assistant and gateway images are required; credential-executor falls back to DockerHub
118
- if (!assistantImage || !gatewayImage) {
119
- log?.("Platform release missing required image refs");
120
- return null;
121
- }
122
-
123
- // Fall back to DockerHub for credential-executor if its image ref is null
124
- if (!credentialExecutorImage) {
125
- credentialExecutorImage = `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`;
126
- log?.(
127
- "credential-executor image not in platform release, using DockerHub fallback",
128
- );
129
- }
130
-
131
- return {
132
- imageTags: {
133
- assistant: assistantImage,
134
- "credential-executor": credentialExecutorImage,
135
- gateway: gatewayImage,
136
- },
137
- };
138
- } catch (err) {
139
- const message = err instanceof Error ? err.message : String(err);
140
- log?.(`Platform image ref resolution failed: ${message}`);
141
- return null;
154
+ ): Promise<ResolvedImageRefs> {
155
+ const resolution = await resolveImageRefsDetailed(version, log);
156
+ if (resolution.status === "platform") {
157
+ log?.("Resolved image refs from platform API");
158
+ return { imageTags: resolution.imageTags, source: "platform" };
159
+ }
160
+ if (resolution.status === "version-not-found") {
161
+ log?.("Falling back to DockerHub tags");
162
+ return { imageTags: dockerhubImageTags(version), source: "dockerhub" };
142
163
  }
164
+ return { imageTags: resolution.imageTags, source: "dockerhub" };
143
165
  }
@@ -19,14 +19,14 @@ import {
19
19
  import { getStateDir } from "./environments/paths.js";
20
20
  import { getCurrentEnvironment } from "./environments/resolve.js";
21
21
  import { loadGuardianToken } from "./guardian-token.js";
22
- import { resolveImageRefs } from "./platform-releases.js";
22
+ import { fetchReleases, resolveImageRefs } from "./platform-releases.js";
23
23
  import {
24
24
  getBuilderManagedEnvKeys,
25
25
  type DockerStatefulSetSpec,
26
26
  type ServiceName,
27
27
  } from "./statefulset.js";
28
28
  import { exec, execOutput } from "./step-runner.js";
29
- import { compareVersions } from "./version-compat.js";
29
+ import { compareVersions, stripVersionPrefix } from "./version-compat.js";
30
30
  import { loopbackSafeFetch } from "./loopback-fetch.js";
31
31
 
32
32
  // ---------------------------------------------------------------------------
@@ -339,27 +339,18 @@ export async function fetchPreviousVersion(
339
339
 
340
340
  // 2. Derive from releases list
341
341
  if (!currentVersion) return undefined;
342
- try {
343
- const { getPlatformUrl } = await import("./platform-client.js");
344
- const platformUrl = getPlatformUrl();
345
- const resp = await loopbackSafeFetch(`${platformUrl}/v1/releases/?stable=true`, {
346
- signal: AbortSignal.timeout(10_000),
347
- });
348
- if (!resp.ok) return undefined;
342
+ const releases = await fetchReleases();
343
+ if (!releases) return undefined;
349
344
 
350
- const releases = (await resp.json()) as Array<{ version?: string }>;
351
- const normalizedCurrent = currentVersion.replace(/^v/, "");
345
+ const normalizedCurrent = stripVersionPrefix(currentVersion);
352
346
 
353
- // Releases are ordered newest-first; find the entry right after the
354
- // current version (i.e. the one that was running before the upgrade).
355
- const idx = releases.findIndex(
356
- (r) => (r.version ?? "").replace(/^v/, "") === normalizedCurrent,
357
- );
358
- if (idx >= 0 && idx + 1 < releases.length) {
359
- return releases[idx + 1].version;
360
- }
361
- } catch {
362
- // Best-effort
347
+ // Releases are ordered newest-first; find the entry right after the
348
+ // current version (i.e. the one that was running before the upgrade).
349
+ const idx = releases.findIndex(
350
+ (r) => stripVersionPrefix(r.version ?? "") === normalizedCurrent,
351
+ );
352
+ if (idx >= 0 && idx + 1 < releases.length) {
353
+ return releases[idx + 1].version;
363
354
  }
364
355
  return undefined;
365
356
  }
@@ -0,0 +1,127 @@
1
+ import type { ReleaseListItem } from "./platform-releases.js";
2
+ import { compareVersions, versionsEqual } from "./version-compat.js";
3
+
4
+ export interface UpgradeTargetResolution {
5
+ kind: "ok" | "version-not-found" | "no-releases";
6
+ /** Resolved target version (kind "ok" only). */
7
+ target: string | null;
8
+ /** compareVersions(target, current); null when either side is unknown/unparseable. */
9
+ comparison: number | null;
10
+ isNoOp: boolean;
11
+ isDowngrade: boolean;
12
+ }
13
+
14
+ /**
15
+ * Resolve the upgrade target version from the releases list and compare it
16
+ * against the running version. Pure — callers fetch releases/current first.
17
+ *
18
+ * - An explicit version is validated against the releases list when one is
19
+ * available (`releases !== null`); absent from the list → "version-not-found".
20
+ * - No explicit version → latest release, skipping non-stable heads
21
+ * (mirrors the web UI: `releases.find(r => r.is_stable !== false) ?? releases[0]`).
22
+ * - `releases === null` (platform unreachable) with an explicit version
23
+ * trusts the explicit version; without one → "no-releases".
24
+ */
25
+ export function resolveUpgradeTarget(args: {
26
+ explicitVersion: string | null;
27
+ releases: ReleaseListItem[] | null;
28
+ currentVersion: string | undefined;
29
+ }): UpgradeTargetResolution {
30
+ const { explicitVersion, releases, currentVersion } = args;
31
+
32
+ let target: string | null = null;
33
+ if (explicitVersion) {
34
+ if (releases !== null) {
35
+ const found = releases.find((r) =>
36
+ versionsEqual(r.version ?? "", explicitVersion),
37
+ );
38
+ if (!found) {
39
+ return {
40
+ kind: "version-not-found",
41
+ target: null,
42
+ comparison: null,
43
+ isNoOp: false,
44
+ isDowngrade: false,
45
+ };
46
+ }
47
+ }
48
+ target = explicitVersion;
49
+ } else {
50
+ if (releases === null || releases.length === 0) {
51
+ return {
52
+ kind: "no-releases",
53
+ target: null,
54
+ comparison: null,
55
+ isNoOp: false,
56
+ isDowngrade: false,
57
+ };
58
+ }
59
+ target =
60
+ (releases.find((r) => r.is_stable !== false) ?? releases[0]).version;
61
+ }
62
+
63
+ const comparison = currentVersion
64
+ ? compareVersions(target, currentVersion)
65
+ : null;
66
+ const isNoOp = currentVersion
67
+ ? versionsEqual(target, currentVersion)
68
+ : false;
69
+
70
+ return {
71
+ kind: "ok",
72
+ target,
73
+ comparison,
74
+ isNoOp,
75
+ isDowngrade: comparison !== null && comparison < 0,
76
+ };
77
+ }
78
+
79
+ export interface UpgradePollState {
80
+ /** Resolved target; null when the server resolved "latest" without reporting it. */
81
+ targetVersion: string | null;
82
+ /** Version observed before the upgrade was triggered. */
83
+ initialVersion: string | null;
84
+ /** Latest current_release_version from the assistant detail endpoint. */
85
+ observedVersion: string | null;
86
+ /** Latest upgrade-status in_progress; null when the endpoint is unavailable. */
87
+ inProgress: boolean | null;
88
+ /** Whether in_progress === true was ever observed during this poll. */
89
+ sawInProgress: boolean;
90
+ }
91
+
92
+ /**
93
+ * Completion predicate for the platform upgrade poll loop. Pure.
94
+ *
95
+ * The primary signal is the DB-backed `current_release_version` (works while
96
+ * the service group restarts or the assistant sleeps); the upgrade-status
97
+ * lock is secondary, used only when the target version is unknown.
98
+ */
99
+ export function evaluateUpgradePoll(
100
+ state: UpgradePollState,
101
+ ): "pending" | "complete" {
102
+ const { targetVersion, initialVersion, observedVersion, inProgress, sawInProgress } =
103
+ state;
104
+
105
+ if (targetVersion) {
106
+ return observedVersion && versionsEqual(observedVersion, targetVersion)
107
+ ? "complete"
108
+ : "pending";
109
+ }
110
+
111
+ // Target unknown: a version change from the pre-upgrade value is
112
+ // definitive on its own — the upgrade can finish before the first poll,
113
+ // leaving in_progress false without sawInProgress ever being set.
114
+ if (
115
+ observedVersion &&
116
+ initialVersion &&
117
+ !versionsEqual(observedVersion, initialVersion)
118
+ ) {
119
+ return "complete";
120
+ }
121
+
122
+ // Otherwise rely on the in-progress lock releasing (e.g. when the
123
+ // pre-upgrade version was unknown).
124
+ if (sawInProgress && inProgress === false) return "complete";
125
+
126
+ return "pending";
127
+ }
@@ -86,6 +86,24 @@ export function compareVersions(a: string, b: string): number | null {
86
86
  return comparePreRelease(pa.pre!, pb.pre!);
87
87
  }
88
88
 
89
+ /**
90
+ * Strip an optional leading `v`/`V` prefix: "v0.7.0" → "0.7.0".
91
+ */
92
+ export function stripVersionPrefix(version: string): string {
93
+ return version.replace(/^[vV]/, "");
94
+ }
95
+
96
+ /**
97
+ * Check whether two version strings refer to the same version.
98
+ * Compares via semver when both parse; falls back to prefix-stripped
99
+ * string equality when either is unparseable.
100
+ */
101
+ export function versionsEqual(a: string, b: string): boolean {
102
+ const cmp = compareVersions(a, b);
103
+ if (cmp !== null) return cmp === 0;
104
+ return stripVersionPrefix(a) === stripVersionPrefix(b);
105
+ }
106
+
89
107
  /**
90
108
  * Check whether two version strings are compatible.
91
109
  * Compatibility requires matching major AND minor versions.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * App-held PKCE login against WorkOS User Management (RFC 8252 loopback).
3
+ */
4
+
5
+ import crypto from "node:crypto";
6
+
7
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
8
+
9
+ const WORKOS_API_BASE_URL = "https://api.workos.com";
10
+ const PROVIDER_ID = "workos";
11
+ const SCOPE = "openid profile email";
12
+
13
+ // Use a loopback callback: `http://127.0.0.1:*/auth/callback`
14
+ export const CALLBACK_PATH = "/auth/callback";
15
+
16
+ export interface PkcePair {
17
+ verifier: string;
18
+ challenge: string;
19
+ }
20
+
21
+ export function generatePkcePair(): PkcePair {
22
+ const verifier = crypto.randomBytes(32).toString("base64url");
23
+ const challenge = crypto
24
+ .createHash("sha256")
25
+ .update(verifier)
26
+ .digest("base64url");
27
+ return { verifier, challenge };
28
+ }
29
+
30
+ export interface AuthorizeUrlOptions {
31
+ clientId: string;
32
+ redirectUri: string;
33
+ challenge: string;
34
+ state: string;
35
+ loginHint?: string;
36
+ providerHint?: string;
37
+ }
38
+
39
+ export function buildAuthorizeUrl(options: AuthorizeUrlOptions): string {
40
+ const url = new URL("/user_management/authorize", WORKOS_API_BASE_URL);
41
+ url.searchParams.set("client_id", options.clientId);
42
+ url.searchParams.set("redirect_uri", options.redirectUri);
43
+ url.searchParams.set("response_type", "code");
44
+ url.searchParams.set("scope", SCOPE);
45
+ url.searchParams.set("code_challenge", options.challenge);
46
+ url.searchParams.set("code_challenge_method", "S256");
47
+ url.searchParams.set("state", options.state);
48
+ // No `prompt`: lets the browser's existing IdP session be reused.
49
+ url.searchParams.set("provider", options.providerHint || "authkit");
50
+ if (options.loginHint) url.searchParams.set("login_hint", options.loginHint);
51
+ return url.toString();
52
+ }
53
+
54
+ interface HeadlessProviderEntry {
55
+ id: string;
56
+ name?: string;
57
+ client_id?: string;
58
+ flows?: string[];
59
+ openid_configuration_url?: string;
60
+ }
61
+
62
+ /**
63
+ * Pick the OAuth2 WorkOS provider from the headless config. During the
64
+ * coexistence window two entries share the "workos-oidc" id; the usable one
65
+ * has token auth and no OIDC discovery URL. Null if none.
66
+ */
67
+ export function selectWorkosClientId(
68
+ providers: HeadlessProviderEntry[],
69
+ ): string | null {
70
+ const entry = providers.find(
71
+ (p) =>
72
+ !p.openid_configuration_url &&
73
+ (p.flows ?? []).includes("provider_token") &&
74
+ typeof p.client_id === "string",
75
+ );
76
+ return entry?.client_id ?? null;
77
+ }
78
+
79
+ export async function fetchWorkosClientId(
80
+ platformUrl: string,
81
+ ): Promise<string> {
82
+ const url = `${new URL(platformUrl).origin}/_allauth/app/v1/config`;
83
+ const response = await loopbackSafeFetch(url);
84
+ if (!response.ok) {
85
+ throw new Error(`Failed to fetch auth config (${response.status})`);
86
+ }
87
+ const body = (await response.json()) as {
88
+ data?: { socialaccount?: { providers?: HeadlessProviderEntry[] } };
89
+ };
90
+ const clientId = selectWorkosClientId(
91
+ body.data?.socialaccount?.providers ?? [],
92
+ );
93
+ if (!clientId) {
94
+ throw new Error(
95
+ "Platform does not advertise a token-auth WorkOS provider; cannot start PKCE login.",
96
+ );
97
+ }
98
+ return clientId;
99
+ }
100
+
101
+ /** Exchange the authorization code at WorkOS as a public client. */
102
+ export async function exchangeCodeWithWorkos(options: {
103
+ clientId: string;
104
+ code: string;
105
+ verifier: string;
106
+ }): Promise<string> {
107
+ const response = await loopbackSafeFetch(
108
+ `${WORKOS_API_BASE_URL}/user_management/authenticate`,
109
+ {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json" },
112
+ body: JSON.stringify({
113
+ client_id: options.clientId,
114
+ grant_type: "authorization_code",
115
+ code: options.code,
116
+ code_verifier: options.verifier,
117
+ }),
118
+ },
119
+ );
120
+ if (!response.ok) {
121
+ const body = await response.text();
122
+ throw new Error(
123
+ `WorkOS code exchange failed (${response.status}): ${body}`,
124
+ );
125
+ }
126
+ const data = (await response.json()) as { access_token?: string };
127
+ if (!data.access_token) {
128
+ throw new Error("WorkOS code exchange returned no access token.");
129
+ }
130
+ return data.access_token;
131
+ }
132
+
133
+ /** Exchange the WorkOS access token for a platform session token. */
134
+ export async function exchangeAccessTokenForSession(
135
+ platformUrl: string,
136
+ clientId: string,
137
+ accessToken: string,
138
+ ): Promise<string> {
139
+ const url = `${new URL(platformUrl).origin}/_allauth/app/v1/auth/provider/token`;
140
+ const response = await loopbackSafeFetch(url, {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json" },
143
+ body: JSON.stringify({
144
+ provider: PROVIDER_ID,
145
+ process: "login",
146
+ token: { client_id: clientId, access_token: accessToken },
147
+ }),
148
+ });
149
+ if (!response.ok) {
150
+ const body = await response.text();
151
+ throw new Error(`Session exchange failed (${response.status}): ${body}`);
152
+ }
153
+ const data = (await response.json()) as {
154
+ meta?: { session_token?: string };
155
+ };
156
+ if (!data.meta?.session_token) {
157
+ throw new Error("Session exchange returned no session token.");
158
+ }
159
+ return data.meta.session_token;
160
+ }