@vellumai/cli 0.8.12 → 0.9.0-staging.2
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__/status.test.ts +224 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
- package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
- package/package.json +3 -3
- package/src/__tests__/assistant-config.test.ts +1 -2
- package/src/__tests__/device-id.test.ts +6 -14
- package/src/__tests__/helpers/os-mock.ts +27 -0
- package/src/__tests__/login-loopback.test.ts +71 -0
- package/src/__tests__/multi-local.test.ts +2 -10
- package/src/__tests__/nginx-ingress-command.test.ts +69 -0
- package/src/__tests__/nginx-ingress.test.ts +403 -0
- package/src/__tests__/sleep.test.ts +4 -0
- package/src/__tests__/teleport.test.ts +6 -9
- package/src/__tests__/tunnel.test.ts +164 -0
- package/src/__tests__/wake.test.ts +15 -4
- package/src/__tests__/workos-pkce.test.ts +314 -0
- package/src/commands/flags.ts +1 -22
- package/src/commands/hatch.ts +90 -9
- package/src/commands/login.ts +123 -59
- package/src/commands/nginx-ingress.ts +291 -0
- package/src/commands/rollback.ts +0 -6
- package/src/commands/sleep.ts +17 -0
- package/src/commands/teleport.ts +23 -36
- package/src/commands/tunnel.ts +69 -11
- package/src/commands/upgrade.ts +0 -2
- package/src/commands/wake.ts +7 -5
- package/src/commands/workflows.ts +301 -0
- package/src/index.ts +8 -0
- package/src/lib/arg-utils.ts +48 -0
- package/src/lib/assistant-client.ts +2 -0
- package/src/lib/assistant-config.ts +0 -7
- package/src/lib/cloudflare-tunnel.ts +15 -2
- package/src/lib/docker.ts +103 -49
- package/src/lib/feature-flags.test.ts +157 -0
- package/src/lib/feature-flags.ts +38 -0
- package/src/lib/hatch-local.ts +0 -1
- package/src/lib/local.ts +5 -0
- package/src/lib/nginx-ingress.ts +576 -0
- package/src/lib/ngrok.ts +26 -4
- package/src/lib/platform-client.ts +0 -1
- package/src/lib/retire-local.ts +5 -0
- package/src/lib/statefulset.ts +73 -21
- package/src/lib/sync-cloud-assistants.ts +4 -17
- package/src/lib/upgrade-lifecycle.ts +1 -2
- package/src/lib/workos-pkce.ts +160 -0
package/src/lib/statefulset.ts
CHANGED
|
@@ -16,22 +16,29 @@ import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
|
16
16
|
|
|
17
17
|
const AVATAR_DEVICE_ENV_VAR = "VELLUM_AVATAR_DEVICE";
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* In-container path the assistant's extra CA bundle is mounted at when
|
|
21
|
+
* `assistantCaCertPath` is supplied. Read-only and outside the
|
|
22
|
+
* `update-ca-certificates` directory so it's consumed purely via
|
|
23
|
+
* `NODE_EXTRA_CA_CERTS` without touching the system trust store.
|
|
24
|
+
*/
|
|
25
|
+
const ASSISTANT_EXTRA_CA_TARGET = "/etc/vellum/extra-ca-cert.pem";
|
|
26
|
+
|
|
19
27
|
/** Logical service name used throughout the CLI. */
|
|
20
28
|
export type ServiceName = "assistant" | "gateway" | "credential-executor";
|
|
21
29
|
|
|
22
30
|
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
31
|
+
* The four fields from `dockerResourceNames()` that the builder actually uses.
|
|
32
|
+
* Container/network names come from here; volume names are generated by the
|
|
33
|
+
* `volumeClaimTemplates` in the spec. `ReturnType<typeof dockerResourceNames>`
|
|
34
|
+
* structurally satisfies this interface (it has all these fields plus more).
|
|
35
|
+
*/
|
|
36
|
+
export interface DockerResourceNames {
|
|
37
|
+
assistantContainer: string;
|
|
38
|
+
cesContainer: string;
|
|
39
|
+
gatewayContainer: string;
|
|
40
|
+
network: string;
|
|
41
|
+
}
|
|
35
42
|
|
|
36
43
|
// ---------------------------------------------------------------------------
|
|
37
44
|
// Types
|
|
@@ -133,6 +140,7 @@ export interface DockerRunSecrets {
|
|
|
133
140
|
// Spec
|
|
134
141
|
// ---------------------------------------------------------------------------
|
|
135
142
|
|
|
143
|
+
// prettier-ignore
|
|
136
144
|
export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
|
|
137
145
|
startOrder: ["assistant", "gateway", "credential-executor"],
|
|
138
146
|
|
|
@@ -260,6 +268,21 @@ export interface BuildServiceRunArgsOpts extends DockerRunSecrets {
|
|
|
260
268
|
extraGatewayEnv?: Record<string, string>;
|
|
261
269
|
/** Avatar device path, if available. Injected by `docker.ts` after resolving. */
|
|
262
270
|
avatarDevicePath?: string;
|
|
271
|
+
/**
|
|
272
|
+
* Name of an existing container whose network namespace every service
|
|
273
|
+
* joins via `--network=container:<name>`, instead of the assistant owning
|
|
274
|
+
* the namespace. When set, the assistant publishes no host ports — the
|
|
275
|
+
* namespace owner is responsible for port publishing — and no per-instance
|
|
276
|
+
* Docker network is referenced.
|
|
277
|
+
*/
|
|
278
|
+
netnsContainer?: string;
|
|
279
|
+
/**
|
|
280
|
+
* Host path to a PEM CA bundle to bind-mount into the assistant container
|
|
281
|
+
* and trust at process start via `NODE_EXTRA_CA_CERTS`. Used when the
|
|
282
|
+
* assistant's outbound TLS is terminated by a proxy whose certificate is
|
|
283
|
+
* signed by a CA outside the default trust set.
|
|
284
|
+
*/
|
|
285
|
+
assistantCaCertPath?: string;
|
|
263
286
|
}
|
|
264
287
|
|
|
265
288
|
interface BuilderManagedEnvKeys {
|
|
@@ -283,13 +306,17 @@ export function getBuilderManagedEnvKeys(
|
|
|
283
306
|
spec = DOCKER_STATEFUL_SET_SPEC,
|
|
284
307
|
): BuilderManagedEnvKeys {
|
|
285
308
|
const container = spec.containers.find((c) => c.internalName === service);
|
|
286
|
-
if (!container)
|
|
309
|
+
if (!container)
|
|
310
|
+
throw new Error(`docker-statefulset: unknown service "${service}"`);
|
|
287
311
|
|
|
288
312
|
const always = new Set<string>(["PATH"]);
|
|
289
313
|
const hostForwarded: Array<{ name: string; hostVar: string }> = [];
|
|
290
314
|
for (const entry of container.env) {
|
|
291
315
|
if (entry.kind === "host") {
|
|
292
|
-
hostForwarded.push({
|
|
316
|
+
hostForwarded.push({
|
|
317
|
+
name: entry.name,
|
|
318
|
+
hostVar: entry.hostVar ?? entry.name,
|
|
319
|
+
});
|
|
293
320
|
} else {
|
|
294
321
|
always.add(entry.name);
|
|
295
322
|
}
|
|
@@ -311,7 +338,8 @@ function resolveVolume(
|
|
|
311
338
|
volumeName: string,
|
|
312
339
|
): string {
|
|
313
340
|
const claim = spec.volumeClaimTemplates.find((v) => v.name === volumeName);
|
|
314
|
-
if (!claim)
|
|
341
|
+
if (!claim)
|
|
342
|
+
throw new Error(`docker-statefulset: unknown volume "${volumeName}"`);
|
|
315
343
|
return claim.dockerVolume(instanceName);
|
|
316
344
|
}
|
|
317
345
|
|
|
@@ -331,6 +359,8 @@ export function buildServiceRunArgs(
|
|
|
331
359
|
extraAssistantEnv,
|
|
332
360
|
extraGatewayEnv,
|
|
333
361
|
avatarDevicePath,
|
|
362
|
+
netnsContainer,
|
|
363
|
+
assistantCaCertPath,
|
|
334
364
|
} = opts;
|
|
335
365
|
|
|
336
366
|
const result = {} as Record<ServiceName, () => string[]>;
|
|
@@ -356,7 +386,11 @@ export function buildServiceRunArgs(
|
|
|
356
386
|
}
|
|
357
387
|
|
|
358
388
|
// Network
|
|
359
|
-
if (
|
|
389
|
+
if (netnsContainer) {
|
|
390
|
+
// Every service joins an externally-owned namespace. The owner
|
|
391
|
+
// publishes host ports, so nothing is published here.
|
|
392
|
+
args.push(`--network=container:${netnsContainer}`);
|
|
393
|
+
} else if (container.network === "bridge") {
|
|
360
394
|
args.push(`--network=${res.network}`);
|
|
361
395
|
for (const port of container.ports ?? []) {
|
|
362
396
|
const hostSide =
|
|
@@ -374,7 +408,12 @@ export function buildServiceRunArgs(
|
|
|
374
408
|
// Volume mounts
|
|
375
409
|
for (const mount of container.volumeMounts) {
|
|
376
410
|
const vol = resolveVolume(spec, instanceName, mount.volumeName);
|
|
377
|
-
args.push(
|
|
411
|
+
args.push(
|
|
412
|
+
"-v",
|
|
413
|
+
mount.readOnly
|
|
414
|
+
? `${vol}:${mount.mountPath}:ro`
|
|
415
|
+
: `${vol}:${mount.mountPath}`,
|
|
416
|
+
);
|
|
378
417
|
}
|
|
379
418
|
|
|
380
419
|
// Env vars from spec
|
|
@@ -401,8 +440,10 @@ export function buildServiceRunArgs(
|
|
|
401
440
|
// Assistant-only computed / optional additions
|
|
402
441
|
if (svc === "assistant") {
|
|
403
442
|
args.push(
|
|
404
|
-
"-e",
|
|
405
|
-
|
|
443
|
+
"-e",
|
|
444
|
+
`VELLUM_ASSISTANT_NAME=${instanceName}`,
|
|
445
|
+
"-e",
|
|
446
|
+
`GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
|
|
406
447
|
);
|
|
407
448
|
|
|
408
449
|
if (extraAssistantEnv) {
|
|
@@ -413,8 +454,19 @@ export function buildServiceRunArgs(
|
|
|
413
454
|
|
|
414
455
|
if (avatarDevicePath && existsSync(avatarDevicePath)) {
|
|
415
456
|
args.push(
|
|
416
|
-
"--device",
|
|
417
|
-
|
|
457
|
+
"--device",
|
|
458
|
+
`${avatarDevicePath}:${avatarDevicePath}`,
|
|
459
|
+
"-e",
|
|
460
|
+
`${AVATAR_DEVICE_ENV_VAR}=${avatarDevicePath}`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (assistantCaCertPath) {
|
|
465
|
+
args.push(
|
|
466
|
+
"-v",
|
|
467
|
+
`${assistantCaCertPath}:${ASSISTANT_EXTRA_CA_TARGET}:ro`,
|
|
468
|
+
"-e",
|
|
469
|
+
`NODE_EXTRA_CA_CERTS=${ASSISTANT_EXTRA_CA_TARGET}`,
|
|
418
470
|
);
|
|
419
471
|
}
|
|
420
472
|
}
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
loadAllAssistants,
|
|
18
|
-
normalizeVersion,
|
|
19
18
|
removeAssistantEntry,
|
|
20
19
|
saveAssistantEntry,
|
|
21
20
|
} from "./assistant-config.js";
|
|
@@ -23,7 +22,6 @@ import {
|
|
|
23
22
|
fetchCurrentUser,
|
|
24
23
|
fetchPlatformAssistants,
|
|
25
24
|
getPlatformUrl,
|
|
26
|
-
type HatchedAssistant,
|
|
27
25
|
} from "./platform-client.js";
|
|
28
26
|
|
|
29
27
|
export type SyncLogger = (message: string) => void;
|
|
@@ -84,7 +82,7 @@ export async function syncCloudAssistants(
|
|
|
84
82
|
}
|
|
85
83
|
}
|
|
86
84
|
|
|
87
|
-
let platformAssistants:
|
|
85
|
+
let platformAssistants: { id: string; name: string; status: string }[];
|
|
88
86
|
try {
|
|
89
87
|
log?.("Fetching platform assistants…");
|
|
90
88
|
platformAssistants = await fetchPlatformAssistants(token);
|
|
@@ -128,33 +126,22 @@ export async function syncCloudAssistants(
|
|
|
128
126
|
const existing = existingCloudById.get(pa.id);
|
|
129
127
|
const assistantName = pa.name.trim();
|
|
130
128
|
const nameFields = assistantName ? { name: assistantName } : {};
|
|
131
|
-
// undefined when the platform reports no release — written through on
|
|
132
|
-
// update so a stale cached version is cleared, not preserved.
|
|
133
|
-
const version =
|
|
134
|
-
pa.current_release_version != null
|
|
135
|
-
? normalizeVersion(pa.current_release_version)
|
|
136
|
-
: undefined;
|
|
137
129
|
if (!existing) {
|
|
138
130
|
log?.(`Adding ${pa.name || pa.id} to lockfile`);
|
|
139
131
|
saveAssistantEntry({
|
|
140
132
|
assistantId: pa.id,
|
|
141
133
|
...nameFields,
|
|
142
|
-
...(version && { version }),
|
|
143
134
|
runtimeUrl: getPlatformUrl(),
|
|
144
135
|
cloud: "vellum",
|
|
145
136
|
species: "vellum",
|
|
146
137
|
hatchedAt: new Date().toISOString(),
|
|
147
138
|
});
|
|
148
139
|
added++;
|
|
149
|
-
} else if (
|
|
150
|
-
(
|
|
151
|
-
existing.version !== version
|
|
152
|
-
) {
|
|
153
|
-
log?.(`Updating ${pa.id} from platform`);
|
|
140
|
+
} else if (assistantName && existing.name !== assistantName) {
|
|
141
|
+
log?.(`Updating ${pa.id} name to ${assistantName}`);
|
|
154
142
|
saveAssistantEntry({
|
|
155
143
|
...existing,
|
|
156
|
-
|
|
157
|
-
version,
|
|
144
|
+
name: assistantName,
|
|
158
145
|
});
|
|
159
146
|
updated++;
|
|
160
147
|
}
|
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
|
|
6
6
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
7
|
-
import {
|
|
7
|
+
import { saveAssistantEntry } from "./assistant-config.js";
|
|
8
8
|
import { createBackup, pruneOldBackups, restoreBackup } from "./backup-ops.js";
|
|
9
9
|
import { emitCliError } from "./cli-error.js";
|
|
10
10
|
import { getOrCreateHostDeviceId } from "./device-id.js";
|
|
@@ -779,7 +779,6 @@ export async function performDockerRollback(
|
|
|
779
779
|
previousContainerInfo: entry.containerInfo,
|
|
780
780
|
previousDbMigrationVersion: preMigrationState.dbVersion,
|
|
781
781
|
previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
|
|
782
|
-
version: normalizeVersion(targetVersion),
|
|
783
782
|
preUpgradeBackupPath: undefined,
|
|
784
783
|
};
|
|
785
784
|
saveAssistantEntry(updatedEntry);
|
|
@@ -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
|
+
}
|