@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.
- package/bun.lock +49 -56
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +1 -1
- package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
- package/package.json +3 -3
- package/src/__tests__/login-loopback.test.ts +71 -0
- package/src/__tests__/platform-client.test.ts +107 -0
- package/src/__tests__/platform-releases.test.ts +117 -0
- package/src/__tests__/upgrade-preflight.test.ts +203 -0
- package/src/__tests__/version-compat.test.ts +31 -0
- package/src/__tests__/wake.test.ts +15 -4
- package/src/__tests__/workos-pkce.test.ts +314 -0
- package/src/commands/login.ts +123 -59
- package/src/commands/upgrade.ts +303 -41
- package/src/commands/wake.ts +7 -5
- package/src/lib/platform-client.ts +68 -0
- package/src/lib/platform-releases.ts +125 -103
- package/src/lib/upgrade-lifecycle.ts +12 -21
- package/src/lib/upgrade-preflight.ts +127 -0
- package/src/lib/version-compat.ts +18 -0
- package/src/lib/workos-pkce.ts +160 -0
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
87
|
+
export async function resolveImageRefsDetailed(
|
|
42
88
|
version: string,
|
|
43
89
|
log?: (msg: string) => void,
|
|
44
|
-
): Promise<
|
|
90
|
+
): Promise<ImageRefResolution> {
|
|
45
91
|
log?.("Resolving image references...");
|
|
46
92
|
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
log?.("
|
|
93
|
+
const releases = await fetchReleases();
|
|
94
|
+
if (releases === null) {
|
|
95
|
+
log?.("Platform unreachable — falling back to DockerHub tags");
|
|
50
96
|
return {
|
|
51
|
-
|
|
52
|
-
|
|
97
|
+
status: "dockerhub-fallback",
|
|
98
|
+
imageTags: dockerhubImageTags(version),
|
|
53
99
|
};
|
|
54
100
|
}
|
|
55
101
|
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
*
|
|
144
|
+
* Resolve image references for a given version.
|
|
67
145
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
151
|
+
export async function resolveImageRefs(
|
|
73
152
|
version: string,
|
|
74
153
|
log?: (msg: string) => void,
|
|
75
|
-
): Promise<{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
log?.(
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
351
|
-
const normalizedCurrent = currentVersion.replace(/^v/, "");
|
|
345
|
+
const normalizedCurrent = stripVersionPrefix(currentVersion);
|
|
352
346
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
+
}
|