@nurix/nustack 0.20.4 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/commands/deploy.js +1 -1
- package/dist/commands/device.js +10 -13
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/init.js +2 -2
- package/dist/commands/login.js +7 -9
- package/dist/commands/logout.js +10 -11
- package/dist/commands/project-sync.js +2 -2
- package/dist/commands/review.js +2 -2
- package/dist/commands/sessions.js +1 -1
- package/dist/commands/sync.js +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/auth_grant.js +53 -291
- package/dist/lib/auth_recovery.js +4 -9
- package/dist/lib/device_keychain.js +2 -2
- package/dist/lib/git-sync/outbound/index.js +3 -3
- package/dist/lib/loopback.js +2 -2
- package/dist/lib/nustack_client.js +36 -36
- package/dist/lib/records/door.js +1 -1
- package/dist/lib/review/client.js +2 -1
- package/dist/lib/session_telemetry.js +1 -1
- package/dist/lib/sessions/client.js +2 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ npx @nurix/nustack@latest --help # one-shot, no auth
|
|
|
13
13
|
npm install -g @nurix/nustack # or install the `nustack` binary globally
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
The tarball ships no secrets. Authentication is
|
|
16
|
+
The tarball ships no secrets. Authentication is a browser sign-in at Studio: `nustack login` opens Studio's `/cli/authorize` (or, with `--manual`, shows a code to paste), you sign in with Google or your enterprise email, and the device token Studio mints for this machine lands in your OS keychain — never in a file. Every authenticated command sends that token as-is until you revoke it with `nustack logout` or from the Devices tab. Signing in on the CLI signs in the desktop workspace too: both clients share one keychain item and one `~/.nustack/device_id`.
|
|
17
17
|
|
|
18
18
|
A dead credential is never a dead end: if the server refuses this device's sign-in (revoked, stale), the CLI clears the dead keychain item and drops you straight back into `nustack login` — you never edit a config file by hand.
|
|
19
19
|
|
package/dist/commands/deploy.js
CHANGED
|
@@ -173,7 +173,7 @@ export async function runStatus(options, globals) {
|
|
|
173
173
|
return;
|
|
174
174
|
}
|
|
175
175
|
const baseUrl = resolveNuStackUrl(globals);
|
|
176
|
-
const auth = await ensureAccessToken(
|
|
176
|
+
const auth = await ensureAccessToken().catch(() => null);
|
|
177
177
|
const overview = await readProjectOverview(project.projectId, cwd, baseUrl, auth?.accessToken ?? null);
|
|
178
178
|
let hosting = null;
|
|
179
179
|
if (auth && overview.cloudProject) {
|
package/dist/commands/device.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
2
|
import { listDevices, resolveNuStackUrl } from "../lib/nustack_client.js";
|
|
3
|
-
import {
|
|
3
|
+
import { readStoredCredential, revokeDeviceToken } from "../lib/auth_grant.js";
|
|
4
4
|
import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
|
|
5
5
|
import { deleteDeviceCredential } from "../lib/device_keychain.js";
|
|
6
6
|
import { isInteractive } from "../lib/interactive.js";
|
|
7
7
|
import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
|
|
8
8
|
function clientLabel(client) {
|
|
9
|
-
if (client === "nustack-cli")
|
|
9
|
+
if (client === "cli" || client === "nustack-cli")
|
|
10
10
|
return "NuStack CLI";
|
|
11
|
-
if (client === "nustack-desktop")
|
|
11
|
+
if (client === "desktop" || client === "nustack-desktop")
|
|
12
12
|
return "NuStack desktop";
|
|
13
13
|
if (client === "browser")
|
|
14
14
|
return "Browser";
|
|
@@ -67,8 +67,8 @@ async function liveGrant(baseUrl, json) {
|
|
|
67
67
|
const auth = await ensureAuthWithRecovery(baseUrl, json);
|
|
68
68
|
if (!auth)
|
|
69
69
|
return null;
|
|
70
|
-
const
|
|
71
|
-
return
|
|
70
|
+
const { credential } = await readStoredCredential();
|
|
71
|
+
return credential ? { grant: credential, accessToken: auth.accessToken } : null;
|
|
72
72
|
}
|
|
73
73
|
export async function runDeviceList(options, globals) {
|
|
74
74
|
const baseUrl = resolveNuStackUrl(globals);
|
|
@@ -102,8 +102,7 @@ export async function runDeviceStatus(options, globals) {
|
|
|
102
102
|
printJsonSuccess("device status", {
|
|
103
103
|
deviceId: grant.deviceId,
|
|
104
104
|
email: grant.userEmail,
|
|
105
|
-
|
|
106
|
-
resources: grant.grantedResources,
|
|
105
|
+
studioBaseUrl: grant.studioBaseUrl,
|
|
107
106
|
keychainMaterial: true,
|
|
108
107
|
server: current,
|
|
109
108
|
});
|
|
@@ -113,9 +112,8 @@ export async function runDeviceStatus(options, globals) {
|
|
|
113
112
|
p.note([
|
|
114
113
|
`device id: ${grant.deviceId}`,
|
|
115
114
|
`signed in as: ${grant.userEmail || "—"}`,
|
|
116
|
-
`
|
|
117
|
-
`
|
|
118
|
-
`keychain grant: present`,
|
|
115
|
+
`NuStack: ${grant.studioBaseUrl}`,
|
|
116
|
+
`stored sign-in: present`,
|
|
119
117
|
`server record: ${current ? `${current.id} (${clientLabel(current.client)})` : "not found"}`,
|
|
120
118
|
`last active: ${current?.lastActiveAt ?? "—"}`,
|
|
121
119
|
].join("\n"), "This device");
|
|
@@ -145,10 +143,9 @@ export async function runDeviceRevoke(deviceRefArg, options, globals) {
|
|
|
145
143
|
return;
|
|
146
144
|
}
|
|
147
145
|
}
|
|
148
|
-
const
|
|
149
|
-
const outcome = clientId ? await revokeGrant({ authBaseUrl: grant.authBaseUrl, refreshToken: grant.grantToken, clientId }) : "unreachable";
|
|
146
|
+
const outcome = await revokeDeviceToken({ baseUrl: grant.studioBaseUrl || baseUrl, deviceToken: grant.deviceToken, deviceId: grant.deviceId });
|
|
150
147
|
if (outcome === "refused" || outcome === "unreachable") {
|
|
151
|
-
return fail("device revoke", json, "REVOKE_FAILED", outcome === "refused" ? "
|
|
148
|
+
return fail("device revoke", json, "REVOKE_FAILED", outcome === "refused" ? "NuStack refused to end this machine's sign-in." : "Couldn't reach NuStack to end this machine's sign-in.");
|
|
152
149
|
}
|
|
153
150
|
await deleteDeviceCredential(grant.deviceId).catch(() => false);
|
|
154
151
|
if (json) {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -58,7 +58,7 @@ export async function runDoctor(options) {
|
|
|
58
58
|
rows.push({ area, check, status, detail });
|
|
59
59
|
};
|
|
60
60
|
const fromEnv = envToken();
|
|
61
|
-
const deviceAuth = await ensureAccessToken(
|
|
61
|
+
const deviceAuth = await ensureAccessToken().catch(() => null);
|
|
62
62
|
if (fromEnv?.kind === "malformed")
|
|
63
63
|
add("identity", "Device sign-in", "error", fromEnv.message);
|
|
64
64
|
else if (fromEnv)
|
package/dist/commands/init.js
CHANGED
|
@@ -35,7 +35,7 @@ export async function runInit(options) {
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
export async function ensureSignedIn(baseUrl, cwd) {
|
|
38
|
-
const deviceAuth = await ensureAccessToken(
|
|
38
|
+
const deviceAuth = await ensureAccessToken().catch(() => null);
|
|
39
39
|
if (deviceAuth) {
|
|
40
40
|
p.log.success(`Signed in as ${deviceAuth.userEmail}`);
|
|
41
41
|
return deviceAuth.accessToken;
|
|
@@ -45,7 +45,7 @@ export async function ensureSignedIn(baseUrl, cwd) {
|
|
|
45
45
|
process.exitCode = 1;
|
|
46
46
|
return null;
|
|
47
47
|
}
|
|
48
|
-
const refreshed = await ensureAccessToken(
|
|
48
|
+
const refreshed = await ensureAccessToken().catch(() => null);
|
|
49
49
|
if (!refreshed) {
|
|
50
50
|
p.outro("Sign-in did not complete — run `nustack login` and retry.");
|
|
51
51
|
process.exitCode = 1;
|
package/dist/commands/login.js
CHANGED
|
@@ -44,9 +44,8 @@ function waitForEnter() {
|
|
|
44
44
|
process.stdin.resume();
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
|
-
async function pasteFlow(ctx
|
|
47
|
+
async function pasteFlow(ctx) {
|
|
48
48
|
return loginWithPastedCode(ctx, {
|
|
49
|
-
force: flags.force,
|
|
50
49
|
onAuthorizeUrl: (url) => p.note(`${url}\n\nSign in there, then copy the code the page shows.`, "Open this URL in any browser"),
|
|
51
50
|
promptForCode,
|
|
52
51
|
});
|
|
@@ -56,7 +55,6 @@ async function loopbackFlow(ctx, flags) {
|
|
|
56
55
|
const switchToPaste = waitForEnter();
|
|
57
56
|
try {
|
|
58
57
|
return await loginWithLoopback(ctx, {
|
|
59
|
-
force: flags.force,
|
|
60
58
|
onAuthorizeUrl: (url) => {
|
|
61
59
|
p.note(`${url}\n\n${isInteractive() ? "Press Enter to paste a code instead." : ""}`.trim(), "Sign in with your browser");
|
|
62
60
|
if (flags.open !== false)
|
|
@@ -92,9 +90,9 @@ export async function performDeviceLogin(baseUrl, _cwd = process.cwd(), flags =
|
|
|
92
90
|
return false;
|
|
93
91
|
}
|
|
94
92
|
if (!flags.force && !flags.reauthorize) {
|
|
95
|
-
const live = await ensureAccessToken(
|
|
93
|
+
const live = await ensureAccessToken().catch(() => null);
|
|
96
94
|
if (live) {
|
|
97
|
-
p.log.success(`Signed in as ${live.userEmail || "this account"} — already signed in on this machine (use --force to
|
|
95
|
+
p.log.success(`Signed in as ${live.userEmail || "this account"} — already signed in on this machine (use --force to sign in again).`);
|
|
98
96
|
return true;
|
|
99
97
|
}
|
|
100
98
|
}
|
|
@@ -115,10 +113,10 @@ export async function performDeviceLogin(baseUrl, _cwd = process.cwd(), flags =
|
|
|
115
113
|
p.log.warn("Sign-in cancelled.");
|
|
116
114
|
return false;
|
|
117
115
|
}
|
|
118
|
-
result = choice === "paste" ? await pasteFlow(ctx
|
|
116
|
+
result = choice === "paste" ? await pasteFlow(ctx) : await loopbackFlow(ctx, flags);
|
|
119
117
|
}
|
|
120
118
|
else {
|
|
121
|
-
result = useManual ? await pasteFlow(ctx
|
|
119
|
+
result = useManual ? await pasteFlow(ctx) : await loopbackFlow(ctx, flags);
|
|
122
120
|
}
|
|
123
121
|
}
|
|
124
122
|
catch (error) {
|
|
@@ -127,10 +125,10 @@ export async function performDeviceLogin(baseUrl, _cwd = process.cwd(), flags =
|
|
|
127
125
|
}
|
|
128
126
|
p.log.success(`Signed in as ${result.userEmail || "this account"}`);
|
|
129
127
|
if (store.name === "file") {
|
|
130
|
-
p.log.info(`This machine's
|
|
128
|
+
p.log.info(`This machine's sign-in is saved to ~/.nustack/credentials.json, readable only by you — no OS keychain here. Manage it with \`nustack device list\`.`);
|
|
131
129
|
}
|
|
132
130
|
else {
|
|
133
|
-
p.log.info("This machine's
|
|
131
|
+
p.log.info("This machine's sign-in is saved to your keychain — the desktop shares it. Manage it with `nustack device list`.");
|
|
134
132
|
}
|
|
135
133
|
return true;
|
|
136
134
|
}
|
package/dist/commands/logout.js
CHANGED
|
@@ -3,7 +3,7 @@ import { resolveNuStackUrl } from "../lib/nustack_client.js";
|
|
|
3
3
|
import { clearDeviceId } from "../lib/device_identity.js";
|
|
4
4
|
import { deleteDeviceCredential } from "../lib/device_keychain.js";
|
|
5
5
|
import { selectCredentialStore } from "../lib/credential_store.js";
|
|
6
|
-
import { ENV_TOKEN_VARIABLE, envToken,
|
|
6
|
+
import { ENV_TOKEN_VARIABLE, envToken, readStoredCredential, revokeDeviceToken } from "../lib/auth_grant.js";
|
|
7
7
|
const SHARED_SIGNOUT_NOTE = "This signs out both the CLI and the desktop on this machine.";
|
|
8
8
|
export async function runLogout(options, flags = {}) {
|
|
9
9
|
p.intro("nustack logout");
|
|
@@ -14,25 +14,24 @@ export async function runLogout(options, flags = {}) {
|
|
|
14
14
|
return;
|
|
15
15
|
}
|
|
16
16
|
const store = selectCredentialStore();
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
19
|
-
const
|
|
20
|
-
const outcome = clientId ? await revokeGrant({ authBaseUrl: grant.authBaseUrl, refreshToken: grant.grantToken, clientId }) : "unreachable";
|
|
17
|
+
const { credential } = await readStoredCredential();
|
|
18
|
+
if (credential) {
|
|
19
|
+
const outcome = await revokeDeviceToken({ baseUrl: credential.studioBaseUrl || baseUrl, deviceToken: credential.deviceToken, deviceId: credential.deviceId });
|
|
21
20
|
if (outcome === "revoked") {
|
|
22
|
-
p.log.success("Ended this machine's
|
|
21
|
+
p.log.success("Ended this machine's sign-in at NuStack — its token no longer works.");
|
|
23
22
|
}
|
|
24
23
|
else if (outcome === "already_ended") {
|
|
25
|
-
p.log.info("This machine's
|
|
24
|
+
p.log.info("This machine's sign-in had already been ended at NuStack.");
|
|
26
25
|
}
|
|
27
26
|
else if (outcome === "refused") {
|
|
28
|
-
p.log.warn("
|
|
27
|
+
p.log.warn("NuStack refused to end this machine's sign-in — clearing local material anyway. Revoke it from Profile › Devices.");
|
|
29
28
|
}
|
|
30
29
|
else {
|
|
31
|
-
p.log.warn("Couldn't reach
|
|
30
|
+
p.log.warn("Couldn't reach NuStack to end this machine's sign-in — clearing local material anyway. Revoke it from Profile › Devices when you are back online.");
|
|
32
31
|
}
|
|
33
|
-
const cleared = await deleteDeviceCredential(
|
|
32
|
+
const cleared = await deleteDeviceCredential(credential.deviceId);
|
|
34
33
|
if (cleared)
|
|
35
|
-
p.log.success(store.name === "keychain" ? "Cleared the keychain
|
|
34
|
+
p.log.success(store.name === "keychain" ? "Cleared the keychain sign-in." : `Cleared the stored sign-in (${store.name}).`);
|
|
36
35
|
}
|
|
37
36
|
else {
|
|
38
37
|
p.log.info(store.name === "keychain" ? "No keychain credential to clear on this machine." : `No stored credential to clear on this machine (${store.name}).`);
|
|
@@ -92,7 +92,7 @@ export function registerProjectSyncCommands(projectCommand, getGlobalOptions) {
|
|
|
92
92
|
}
|
|
93
93
|
async function buildInboundRuntime(store, globals) {
|
|
94
94
|
const baseUrl = resolveNuStackUrl(globals);
|
|
95
|
-
const auth = await ensureAccessToken(
|
|
95
|
+
const auth = await ensureAccessToken().catch(() => null);
|
|
96
96
|
if (!auth)
|
|
97
97
|
return null;
|
|
98
98
|
const deviceState = readDeviceState(store);
|
|
@@ -465,7 +465,7 @@ async function runReconcileHistory(globals, options) {
|
|
|
465
465
|
continue;
|
|
466
466
|
}
|
|
467
467
|
const baseUrl = resolveNuStackUrl(globals);
|
|
468
|
-
const client = createUploadClient({ baseUrl, getAccessToken: deviceTokenProvider(
|
|
468
|
+
const client = createUploadClient({ baseUrl, getAccessToken: deviceTokenProvider() });
|
|
469
469
|
if (options.publish) {
|
|
470
470
|
const result = await publishCurrentState(store, binding, repo, { client });
|
|
471
471
|
if (result.ok) {
|
package/dist/commands/review.js
CHANGED
|
@@ -56,7 +56,7 @@ export async function runReviewCheck(options, globals) {
|
|
|
56
56
|
process.exitCode = EXIT_FOR[state];
|
|
57
57
|
};
|
|
58
58
|
const baseUrl = resolveNuStackUrl(globals);
|
|
59
|
-
const deviceAuth = await ensureAccessToken(
|
|
59
|
+
const deviceAuth = await ensureAccessToken().catch(() => null);
|
|
60
60
|
if (!deviceAuth)
|
|
61
61
|
return report("unreachable");
|
|
62
62
|
try {
|
|
@@ -124,7 +124,7 @@ export async function runReviewMirror(options, globals) {
|
|
|
124
124
|
if (!sessionId)
|
|
125
125
|
return mirrorReport(json, "skipped", null, "--session is required");
|
|
126
126
|
const baseUrl = resolveNuStackUrl(globals);
|
|
127
|
-
const deviceAuth = await ensureAccessToken(
|
|
127
|
+
const deviceAuth = await ensureAccessToken().catch(() => null);
|
|
128
128
|
if (!deviceAuth)
|
|
129
129
|
return mirrorReport(json, "skipped", null, "signed out");
|
|
130
130
|
const client = makeReviewClient(baseUrl, deviceAuth.accessToken);
|
|
@@ -69,7 +69,7 @@ export async function runSessionsRecord(options, globals) {
|
|
|
69
69
|
const transcripts = readTranscripts(payload?.sessions);
|
|
70
70
|
if (transcripts.length === 0)
|
|
71
71
|
return report("sessions record", EMPTY, json, "nothing to record");
|
|
72
|
-
const deviceAuth = await ensureAccessToken(
|
|
72
|
+
const deviceAuth = await ensureAccessToken().catch(() => null);
|
|
73
73
|
if (!deviceAuth)
|
|
74
74
|
return report("sessions record", EMPTY, json, "signed out");
|
|
75
75
|
const summary = await recordOutsideSessions(makeSessionsClient(baseUrl, deviceAuth.accessToken), {
|
package/dist/commands/sync.js
CHANGED
|
@@ -14,7 +14,7 @@ export async function runSync(options) {
|
|
|
14
14
|
const json = options.json === true;
|
|
15
15
|
if (!json)
|
|
16
16
|
p.intro("nustack sync");
|
|
17
|
-
const deviceAuth = await ensureAccessToken(
|
|
17
|
+
const deviceAuth = await ensureAccessToken().catch(() => null);
|
|
18
18
|
if (!deviceAuth)
|
|
19
19
|
return failSync(json, "SIGNED_OUT", signedOut(), "The managed block was not touched.");
|
|
20
20
|
const accessToken = deviceAuth.accessToken;
|
package/dist/index.js
CHANGED
|
@@ -53,7 +53,7 @@ program
|
|
|
53
53
|
.description("Sign in with your browser; this machine's grant is saved to your OS keychain")
|
|
54
54
|
.option("--no-open", "don't auto-open the browser; just print the sign-in URL")
|
|
55
55
|
.option("--manual", "sign in anywhere and paste the code here instead of using a browser on this machine")
|
|
56
|
-
.option("--force", "sign in again even if this machine already
|
|
56
|
+
.option("--force", "sign in again even if this machine is already signed in")
|
|
57
57
|
.action(async (options) => runLogin(program.opts(), options));
|
|
58
58
|
program
|
|
59
59
|
.command("logout")
|
package/dist/lib/auth_grant.js
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { assertSafeNuStackUrl } from "./guards.js";
|
|
4
3
|
import { getOrCreateDeviceId } from "./device_identity.js";
|
|
5
4
|
import { deleteDeviceCredential, readCredentialFailure, readDeviceCredential, writeDeviceCredential } from "./device_keychain.js";
|
|
6
5
|
import { openLoopbackListener } from "./loopback.js";
|
|
7
6
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
8
7
|
const BOOTSTRAP_TIMEOUT_MS = 25_000;
|
|
9
|
-
const ACCESS_TOKEN_SKEW_MS = 30_000;
|
|
10
8
|
const LOOPBACK_TIMEOUT_MS = 10 * 60_000;
|
|
11
|
-
const
|
|
12
|
-
export const CLI_CODE_PAGE_PATH = "/cli-code";
|
|
9
|
+
export const PASTE_REDIRECT = "paste";
|
|
13
10
|
export const DEVICE_HEADER = "X-NuStack-Device";
|
|
14
11
|
export function deadCredentialCopy(reason) {
|
|
15
12
|
if (reason === "older-schema")
|
|
16
13
|
return "This machine's sign-in predates the current NuStack CLI — run `nustack login` to sign in again.";
|
|
17
|
-
if (reason === "
|
|
14
|
+
if (reason === "revoked")
|
|
18
15
|
return "Your sign-in on this device is no longer valid — run `nustack login` to reconnect.";
|
|
19
16
|
return "Signed out on this device — run `nustack login` first.";
|
|
20
17
|
}
|
|
@@ -37,142 +34,61 @@ export function signedOutReason() {
|
|
|
37
34
|
export function signedOutCopy() {
|
|
38
35
|
return deadCredentialCopy(lastDeadReason);
|
|
39
36
|
}
|
|
40
|
-
export async function fetchAuthConfig(nustackBaseUrl) {
|
|
41
|
-
const response = await fetch(`${nustackBaseUrl}/api/v1/auth/config`, {
|
|
42
|
-
headers: { Accept: "application/json", "User-Agent": userAgent() },
|
|
43
|
-
signal: AbortSignal.timeout(BOOTSTRAP_TIMEOUT_MS),
|
|
44
|
-
});
|
|
45
|
-
const payload = (await response.json().catch(() => null));
|
|
46
|
-
if (!response.ok || typeof payload?.auth_base_url !== "string" || typeof payload?.client_id !== "string") {
|
|
47
|
-
throw new Error(`this NuStack does not describe its auth service (HTTP ${response.status}) — it may predate machine grants`);
|
|
48
|
-
}
|
|
49
|
-
const resources = Array.isArray(payload.resources) ? payload.resources.filter((r) => typeof r === "string") : [];
|
|
50
|
-
if (resources.length === 0)
|
|
51
|
-
throw new Error("this NuStack names no resource for a machine to hold — its auth configuration is incomplete");
|
|
52
|
-
return { authBaseUrl: assertSafeNuStackUrl(payload.auth_base_url), clientId: payload.client_id, resources };
|
|
53
|
-
}
|
|
54
|
-
export async function discover(authBaseUrl) {
|
|
55
|
-
const response = await fetch(`${authBaseUrl}/.well-known/openid-configuration`, {
|
|
56
|
-
headers: { Accept: "application/json", "User-Agent": userAgent() },
|
|
57
|
-
signal: AbortSignal.timeout(BOOTSTRAP_TIMEOUT_MS),
|
|
58
|
-
});
|
|
59
|
-
const payload = (await response.json().catch(() => null));
|
|
60
|
-
if (!response.ok || !payload?.issuer || !payload.authorization_endpoint || !payload.token_endpoint) {
|
|
61
|
-
throw new Error(`the auth service at ${authBaseUrl} published no usable discovery document (HTTP ${response.status})`);
|
|
62
|
-
}
|
|
63
|
-
return {
|
|
64
|
-
issuer: payload.issuer,
|
|
65
|
-
authorizationEndpoint: payload.authorization_endpoint,
|
|
66
|
-
tokenEndpoint: payload.token_endpoint,
|
|
67
|
-
revocationEndpoint: payload.revocation_endpoint ?? `${authBaseUrl}/oauth2/revoke`,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
37
|
export function mintPkce() {
|
|
71
38
|
const verifier = randomBytes(32).toString("base64url");
|
|
72
39
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
73
40
|
return { verifier, challenge, state: randomBytes(16).toString("base64url") };
|
|
74
41
|
}
|
|
75
|
-
export function
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
42
|
+
export async function prepareLogin(nustackBaseUrl) {
|
|
43
|
+
const deviceId = await getOrCreateDeviceId();
|
|
44
|
+
resolvedDeviceId = deviceId;
|
|
45
|
+
return { baseUrl: nustackBaseUrl.replace(/\/+$/, ""), deviceId };
|
|
46
|
+
}
|
|
47
|
+
export function buildAuthorizeUrl(ctx, request) {
|
|
48
|
+
const url = new URL(`${ctx.baseUrl}/cli/authorize`);
|
|
79
49
|
url.searchParams.set("redirect_uri", request.redirectUri);
|
|
80
|
-
url.searchParams.set("scope", MACHINE_SCOPE);
|
|
81
50
|
url.searchParams.set("state", request.pkce.state);
|
|
82
51
|
url.searchParams.set("code_challenge", request.pkce.challenge);
|
|
83
52
|
url.searchParams.set("code_challenge_method", "S256");
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (request.force)
|
|
87
|
-
url.searchParams.set("prompt", "login");
|
|
53
|
+
url.searchParams.set("device", ctx.deviceId);
|
|
54
|
+
url.searchParams.set("client", "cli");
|
|
88
55
|
return url.toString();
|
|
89
56
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
export async function exchangeCode(config, discovery, input) {
|
|
94
|
-
const form = new URLSearchParams({
|
|
95
|
-
grant_type: "authorization_code",
|
|
96
|
-
code: input.code,
|
|
97
|
-
code_verifier: input.verifier,
|
|
98
|
-
redirect_uri: input.redirectUri,
|
|
99
|
-
client_id: config.clientId,
|
|
100
|
-
});
|
|
101
|
-
for (const resource of config.resources)
|
|
102
|
-
form.append("resource", resource);
|
|
103
|
-
const response = await fetch(discovery.tokenEndpoint, {
|
|
57
|
+
const PREDATES = "This NuStack predates the CLI's sign-in — update the server, or point the CLI at one that matches.";
|
|
58
|
+
export async function exchangeCode(ctx, input) {
|
|
59
|
+
const response = await fetch(`${ctx.baseUrl}/api/v1/cli/token`, {
|
|
104
60
|
method: "POST",
|
|
105
|
-
headers: { "Content-Type": "application/
|
|
106
|
-
body:
|
|
107
|
-
signal: AbortSignal.timeout(
|
|
61
|
+
headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": userAgent() },
|
|
62
|
+
body: JSON.stringify({ code: input.code, code_verifier: input.verifier, redirect_uri: input.redirectUri, device: ctx.deviceId, client: "cli" }),
|
|
63
|
+
signal: AbortSignal.timeout(BOOTSTRAP_TIMEOUT_MS),
|
|
108
64
|
});
|
|
109
65
|
const payload = (await response.json().catch(() => null));
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
throw new Error(payload?.
|
|
114
|
-
}
|
|
115
|
-
if (typeof payload.refresh_token !== "string" || !payload.refresh_token) {
|
|
116
|
-
throw new Error("The auth service issued no refresh token — the sign-in cannot be kept on this machine.");
|
|
117
|
-
}
|
|
118
|
-
return {
|
|
119
|
-
accessToken: payload.access_token,
|
|
120
|
-
refreshToken: payload.refresh_token,
|
|
121
|
-
expiresInSeconds: typeof payload.expires_in === "number" ? payload.expires_in : 0,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
export function decodeJwtPayload(token) {
|
|
125
|
-
const segment = token.split(".")[1];
|
|
126
|
-
if (!segment)
|
|
127
|
-
return null;
|
|
128
|
-
try {
|
|
129
|
-
const parsed = JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
|
|
130
|
-
return parsed && typeof parsed === "object" ? parsed : null;
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return null;
|
|
66
|
+
if (response.status === 404 || payload === null)
|
|
67
|
+
throw new Error(PREDATES);
|
|
68
|
+
if (!response.ok || typeof payload.data?.token !== "string") {
|
|
69
|
+
throw new Error(payload.error?.message ?? `NuStack refused the sign-in code (HTTP ${response.status}).`);
|
|
134
70
|
}
|
|
71
|
+
return { token: payload.data.token, email: typeof payload.data.email === "string" ? payload.data.email : "" };
|
|
135
72
|
}
|
|
136
|
-
async function
|
|
137
|
-
|
|
138
|
-
resolvedDeviceId = deviceId;
|
|
139
|
-
const claims = decodeJwtPayload(issued.accessToken);
|
|
140
|
-
const userEmail = typeof claims?.email === "string" ? claims.email : "";
|
|
141
|
-
const apiResource = config.resources[0];
|
|
142
|
-
await writeDeviceCredential({
|
|
143
|
-
refresh_token: issued.refreshToken,
|
|
144
|
-
auth_base_url: config.authBaseUrl,
|
|
145
|
-
issuer: discovery.issuer,
|
|
146
|
-
device_id: deviceId,
|
|
147
|
-
user_email: userEmail,
|
|
148
|
-
granted_resources: config.resources,
|
|
149
|
-
client_id: config.clientId,
|
|
150
|
-
access_tokens: { [apiResource]: { token: issued.accessToken, expires_at_ms: Date.now() + issued.expiresInSeconds * 1000 } },
|
|
151
|
-
});
|
|
73
|
+
async function persistToken(ctx, issued) {
|
|
74
|
+
await writeDeviceCredential({ device_token: issued.token, studio_base_url: ctx.baseUrl, device_id: ctx.deviceId, user_email: issued.email });
|
|
152
75
|
lastDeadReason = null;
|
|
153
|
-
return { userEmail, deviceId
|
|
154
|
-
}
|
|
155
|
-
export async function prepareLogin(nustackBaseUrl) {
|
|
156
|
-
const config = await fetchAuthConfig(nustackBaseUrl);
|
|
157
|
-
const discovery = await discover(config.authBaseUrl);
|
|
158
|
-
return { config, discovery };
|
|
76
|
+
return { userEmail: issued.email, deviceId: ctx.deviceId };
|
|
159
77
|
}
|
|
160
78
|
export async function loginWithPastedCode(ctx, steps) {
|
|
161
79
|
const pkce = mintPkce();
|
|
162
|
-
|
|
163
|
-
steps.onAuthorizeUrl(buildAuthorizeUrl(ctx.config, ctx.discovery, { redirectUri, pkce, force: steps.force }));
|
|
80
|
+
steps.onAuthorizeUrl(buildAuthorizeUrl(ctx, { redirectUri: PASTE_REDIRECT, pkce }));
|
|
164
81
|
const code = (await steps.promptForCode()).trim();
|
|
165
82
|
if (!code)
|
|
166
83
|
throw new Error("No code was entered — run `nustack login` again.");
|
|
167
|
-
|
|
168
|
-
return persistGrant(ctx.config, ctx.discovery, issued);
|
|
84
|
+
return persistToken(ctx, await exchangeCode(ctx, { code, verifier: pkce.verifier, redirectUri: PASTE_REDIRECT }));
|
|
169
85
|
}
|
|
170
86
|
export async function loginWithLoopback(ctx, steps) {
|
|
171
87
|
const pkce = mintPkce();
|
|
172
88
|
const listener = await openLoopbackListener(pkce.state);
|
|
173
89
|
let timer = null;
|
|
174
90
|
try {
|
|
175
|
-
steps.onAuthorizeUrl(buildAuthorizeUrl(ctx
|
|
91
|
+
steps.onAuthorizeUrl(buildAuthorizeUrl(ctx, { redirectUri: listener.redirectUri, pkce }));
|
|
176
92
|
const timeout = new Promise((_, reject) => {
|
|
177
93
|
timer = setTimeout(() => reject(new Error("Timed out waiting for the browser — run `nustack login` again, or `nustack login --manual`.")), steps.timeoutMs ?? LOOPBACK_TIMEOUT_MS);
|
|
178
94
|
});
|
|
@@ -182,12 +98,8 @@ export async function loginWithLoopback(ctx, steps) {
|
|
|
182
98
|
await listener.close();
|
|
183
99
|
return loginWithPastedCode(ctx, steps);
|
|
184
100
|
}
|
|
185
|
-
const { callback
|
|
186
|
-
|
|
187
|
-
throw new Error(`The sign-in came back from ${callback.iss}, not from ${ctx.discovery.issuer} — refusing it.`);
|
|
188
|
-
}
|
|
189
|
-
const issued = await exchangeCode(ctx.config, ctx.discovery, { code: callback.code, verifier: pkce.verifier, redirectUri: listener.redirectUri });
|
|
190
|
-
return persistGrant(ctx.config, ctx.discovery, issued);
|
|
101
|
+
const issued = await exchangeCode(ctx, { code: outcome.callback.code, verifier: pkce.verifier, redirectUri: listener.redirectUri });
|
|
102
|
+
return persistToken(ctx, issued);
|
|
191
103
|
}
|
|
192
104
|
finally {
|
|
193
105
|
if (timer)
|
|
@@ -195,85 +107,18 @@ export async function loginWithLoopback(ctx, steps) {
|
|
|
195
107
|
await listener.close().catch(() => undefined);
|
|
196
108
|
}
|
|
197
109
|
}
|
|
198
|
-
export function
|
|
199
|
-
const record = body && typeof body === "object" ? body : null;
|
|
200
|
-
const str = (key) => {
|
|
201
|
-
const value = record?.[key];
|
|
202
|
-
return typeof value === "string" && value.length > 0 ? value : null;
|
|
203
|
-
};
|
|
204
|
-
if (status >= 200 && status < 300) {
|
|
205
|
-
return { kind: "tokens", hasAccessToken: str("access_token") !== null, hasRefreshToken: str("refresh_token") !== null };
|
|
206
|
-
}
|
|
207
|
-
if (status >= 500)
|
|
208
|
-
return { kind: "unusable" };
|
|
209
|
-
return { kind: "refusal", code: str("error") };
|
|
210
|
-
}
|
|
211
|
-
export function classifyAnswer(answer) {
|
|
212
|
-
switch (answer.kind) {
|
|
213
|
-
case "tokens":
|
|
214
|
-
return answer.hasAccessToken ? "signed_in" : "transport_error";
|
|
215
|
-
case "refusal":
|
|
216
|
-
if (answer.code === "invalid_grant")
|
|
217
|
-
return "credential_dead";
|
|
218
|
-
if (answer.code === "invalid_target")
|
|
219
|
-
return "needs_reauthorization";
|
|
220
|
-
return "transport_error";
|
|
221
|
-
default:
|
|
222
|
-
return "transport_error";
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
export function classifyRefresh(status, body) {
|
|
226
|
-
return classifyAnswer(readAnswer(status, body));
|
|
227
|
-
}
|
|
228
|
-
export async function refreshGrant(tokenEndpoint, refreshToken, resource, clientId) {
|
|
229
|
-
const form = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId, resource });
|
|
230
|
-
const response = await fetch(tokenEndpoint, {
|
|
231
|
-
method: "POST",
|
|
232
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": userAgent() },
|
|
233
|
-
body: form.toString(),
|
|
234
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
235
|
-
});
|
|
236
|
-
const payload = (await response.json().catch(() => null));
|
|
237
|
-
switch (classifyRefresh(response.status, payload)) {
|
|
238
|
-
case "signed_in":
|
|
239
|
-
return {
|
|
240
|
-
kind: "minted",
|
|
241
|
-
accessToken: payload.access_token,
|
|
242
|
-
expiresInSeconds: typeof payload.expires_in === "number" ? payload.expires_in : 0,
|
|
243
|
-
...(typeof payload.refresh_token === "string" ? { rotatedRefreshToken: payload.refresh_token } : {}),
|
|
244
|
-
};
|
|
245
|
-
case "credential_dead":
|
|
246
|
-
return { kind: "dead", reason: "invalid_grant" };
|
|
247
|
-
case "needs_reauthorization":
|
|
248
|
-
return { kind: "needs_reauthorization", resource };
|
|
249
|
-
default:
|
|
250
|
-
throw new Error(`token refresh failed: HTTP ${response.status}`);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
function isAlreadyEnded(status, payload) {
|
|
254
|
-
if (status !== 400 || !payload)
|
|
255
|
-
return false;
|
|
256
|
-
if (payload.error === "invalid_grant")
|
|
257
|
-
return true;
|
|
258
|
-
return payload.error === "invalid_request" && /revoked|not found|expired|invalid/i.test(payload.error_description ?? "");
|
|
259
|
-
}
|
|
260
|
-
export async function revokeGrant(input) {
|
|
110
|
+
export async function revokeDeviceToken(input) {
|
|
261
111
|
try {
|
|
262
|
-
const
|
|
263
|
-
.then((d) => d.revocationEndpoint)
|
|
264
|
-
.catch(() => `${input.authBaseUrl}/oauth2/revoke`);
|
|
265
|
-
const response = await fetch(endpoint, {
|
|
112
|
+
const response = await fetch(`${input.baseUrl.replace(/\/+$/, "")}/api/v2/devices/self/revoke`, {
|
|
266
113
|
method: "POST",
|
|
267
|
-
headers: {
|
|
268
|
-
body: new URLSearchParams({ token: input.refreshToken, token_type_hint: "refresh_token", client_id: input.clientId }).toString(),
|
|
114
|
+
headers: { Authorization: `Bearer ${input.deviceToken}`, [DEVICE_HEADER]: input.deviceId, Accept: "application/json", "User-Agent": userAgent() },
|
|
269
115
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
270
116
|
});
|
|
271
117
|
if (response.ok)
|
|
272
118
|
return "revoked";
|
|
273
|
-
if (response.status
|
|
274
|
-
return "
|
|
275
|
-
|
|
276
|
-
return isAlreadyEnded(response.status, payload) ? "already_ended" : "refused";
|
|
119
|
+
if (response.status === 401)
|
|
120
|
+
return "already_ended";
|
|
121
|
+
return response.status >= 500 ? "unreachable" : "refused";
|
|
277
122
|
}
|
|
278
123
|
catch {
|
|
279
124
|
return "unreachable";
|
|
@@ -301,130 +146,47 @@ export class TokenRefusedError extends Error {
|
|
|
301
146
|
this.name = "TokenRefusedError";
|
|
302
147
|
}
|
|
303
148
|
}
|
|
304
|
-
async function
|
|
149
|
+
export async function readStoredCredential() {
|
|
305
150
|
const deviceId = await getOrCreateDeviceId();
|
|
306
151
|
resolvedDeviceId = deviceId;
|
|
307
152
|
const credential = await readDeviceCredential(deviceId);
|
|
308
153
|
if (!credential) {
|
|
309
154
|
const failure = await readCredentialFailure(deviceId);
|
|
310
|
-
return { deviceId,
|
|
155
|
+
return { deviceId, credential: null, failure: failure === "older-schema" ? "older-schema" : null };
|
|
311
156
|
}
|
|
312
157
|
return {
|
|
313
158
|
deviceId,
|
|
314
159
|
failure: null,
|
|
315
|
-
|
|
316
|
-
deviceId,
|
|
317
|
-
refreshToken: credential.refresh_token,
|
|
318
|
-
authBaseUrl: credential.auth_base_url,
|
|
319
|
-
issuer: credential.issuer,
|
|
320
|
-
userEmail: credential.user_email,
|
|
321
|
-
grantedResources: Array.isArray(credential.granted_resources) ? credential.granted_resources : [],
|
|
322
|
-
clientId: typeof credential.client_id === "string" ? credential.client_id : null,
|
|
323
|
-
accessTokens: credential.access_tokens ?? {},
|
|
324
|
-
},
|
|
160
|
+
credential: { deviceToken: credential.device_token, studioBaseUrl: credential.studio_base_url, deviceId, userEmail: credential.user_email },
|
|
325
161
|
};
|
|
326
162
|
}
|
|
327
|
-
async function
|
|
328
|
-
if (grant.clientId)
|
|
329
|
-
return grant.clientId;
|
|
330
|
-
if (!nustackBaseUrl)
|
|
331
|
-
throw new Error("this machine's grant names no client and no NuStack URL was given to ask");
|
|
332
|
-
return (await fetchAuthConfig(nustackBaseUrl)).clientId;
|
|
333
|
-
}
|
|
334
|
-
async function refreshAndPersist(grant, audience, nustackBaseUrl) {
|
|
335
|
-
const clientId = await clientIdFor(grant, nustackBaseUrl);
|
|
336
|
-
const refreshed = await refreshGrant(`${grant.authBaseUrl}/oauth2/token`, grant.refreshToken, audience, clientId);
|
|
337
|
-
if (refreshed.kind !== "minted")
|
|
338
|
-
return refreshed;
|
|
339
|
-
const claims = decodeJwtPayload(refreshed.accessToken);
|
|
340
|
-
const userEmail = (typeof claims?.email === "string" && claims.email) || grant.userEmail;
|
|
341
|
-
await writeDeviceCredential({
|
|
342
|
-
refresh_token: refreshed.rotatedRefreshToken ?? grant.refreshToken,
|
|
343
|
-
auth_base_url: grant.authBaseUrl,
|
|
344
|
-
issuer: grant.issuer,
|
|
345
|
-
device_id: grant.deviceId,
|
|
346
|
-
user_email: userEmail,
|
|
347
|
-
granted_resources: grant.grantedResources,
|
|
348
|
-
client_id: clientId,
|
|
349
|
-
access_tokens: {
|
|
350
|
-
...grant.accessTokens,
|
|
351
|
-
[audience]: { token: refreshed.accessToken, expires_at_ms: Date.now() + refreshed.expiresInSeconds * 1000 },
|
|
352
|
-
},
|
|
353
|
-
});
|
|
354
|
-
return refreshed;
|
|
355
|
-
}
|
|
356
|
-
export async function resolveAccessToken(nustackBaseUrl, resource) {
|
|
163
|
+
export async function resolveAccessToken() {
|
|
357
164
|
const fromEnv = envToken();
|
|
358
165
|
if (fromEnv) {
|
|
359
166
|
if (fromEnv.kind === "malformed")
|
|
360
167
|
return { kind: "malformed_token", message: fromEnv.message };
|
|
361
168
|
const deviceId = resolvedDeviceId ?? (await getOrCreateDeviceId());
|
|
362
169
|
resolvedDeviceId = deviceId;
|
|
363
|
-
return { kind: "ok", session: { accessToken: fromEnv.token, userEmail: "", deviceId
|
|
170
|
+
return { kind: "ok", session: { accessToken: fromEnv.token, userEmail: "", deviceId } };
|
|
364
171
|
}
|
|
365
|
-
const { deviceId,
|
|
366
|
-
if (!
|
|
172
|
+
const { deviceId, credential, failure } = await readStoredCredential();
|
|
173
|
+
if (!credential) {
|
|
367
174
|
if (failure)
|
|
368
175
|
lastDeadReason = failure;
|
|
369
176
|
return { kind: "none", reason: failure };
|
|
370
177
|
}
|
|
371
|
-
|
|
372
|
-
if (!audience)
|
|
373
|
-
return { kind: "none", reason: null };
|
|
374
|
-
const cached = grant.accessTokens[audience];
|
|
375
|
-
if (cached && cached.expires_at_ms - ACCESS_TOKEN_SKEW_MS > Date.now()) {
|
|
376
|
-
return { kind: "ok", session: { accessToken: cached.token, userEmail: grant.userEmail, deviceId, authBaseUrl: grant.authBaseUrl } };
|
|
377
|
-
}
|
|
378
|
-
const refreshed = await refreshAndPersist(grant, audience, nustackBaseUrl);
|
|
379
|
-
if (refreshed.kind === "dead") {
|
|
380
|
-
lastDeadReason = refreshed.reason;
|
|
381
|
-
await deleteDeviceCredential(deviceId).catch(() => false);
|
|
382
|
-
return { kind: "dead", reason: refreshed.reason };
|
|
383
|
-
}
|
|
384
|
-
if (refreshed.kind === "needs_reauthorization")
|
|
385
|
-
return { kind: "needs_reauthorization", resource: audience };
|
|
386
|
-
lastDeadReason = null;
|
|
387
|
-
const claims = decodeJwtPayload(refreshed.accessToken);
|
|
388
|
-
const userEmail = (typeof claims?.email === "string" && claims.email) || grant.userEmail;
|
|
389
|
-
return { kind: "ok", session: { accessToken: refreshed.accessToken, userEmail, deviceId, authBaseUrl: grant.authBaseUrl } };
|
|
178
|
+
return { kind: "ok", session: { accessToken: credential.deviceToken, userEmail: credential.userEmail, deviceId } };
|
|
390
179
|
}
|
|
391
|
-
export async function ensureAccessToken(
|
|
392
|
-
const outcome = await resolveAccessToken(
|
|
180
|
+
export async function ensureAccessToken() {
|
|
181
|
+
const outcome = await resolveAccessToken();
|
|
393
182
|
return outcome.kind === "ok" ? { accessToken: outcome.session.accessToken, userEmail: outcome.session.userEmail } : null;
|
|
394
183
|
}
|
|
395
|
-
export async function
|
|
184
|
+
export async function forgetRefusedToken(staleToken) {
|
|
396
185
|
if (envToken())
|
|
397
|
-
return
|
|
398
|
-
const { deviceId,
|
|
399
|
-
if (!
|
|
400
|
-
return
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
return null;
|
|
404
|
-
const cached = grant.accessTokens[audience];
|
|
405
|
-
if (cached && cached.token !== staleToken)
|
|
406
|
-
return cached.token;
|
|
407
|
-
const refreshed = await refreshAndPersist(grant, audience, nustackBaseUrl);
|
|
408
|
-
if (refreshed.kind === "dead") {
|
|
409
|
-
lastDeadReason = refreshed.reason;
|
|
410
|
-
await deleteDeviceCredential(deviceId).catch(() => false);
|
|
411
|
-
return null;
|
|
412
|
-
}
|
|
413
|
-
if (refreshed.kind === "needs_reauthorization")
|
|
414
|
-
return null;
|
|
415
|
-
return refreshed.accessToken;
|
|
416
|
-
}
|
|
417
|
-
export async function readGrant() {
|
|
418
|
-
const { grant } = await readStoredGrant();
|
|
419
|
-
if (!grant)
|
|
420
|
-
return null;
|
|
421
|
-
return {
|
|
422
|
-
grantToken: grant.refreshToken,
|
|
423
|
-
authBaseUrl: grant.authBaseUrl,
|
|
424
|
-
issuer: grant.issuer,
|
|
425
|
-
deviceId: grant.deviceId,
|
|
426
|
-
userEmail: grant.userEmail,
|
|
427
|
-
grantedResources: grant.grantedResources,
|
|
428
|
-
clientId: grant.clientId,
|
|
429
|
-
};
|
|
186
|
+
return;
|
|
187
|
+
const { deviceId, credential } = await readStoredCredential();
|
|
188
|
+
if (!credential || credential.deviceToken !== staleToken)
|
|
189
|
+
return;
|
|
190
|
+
lastDeadReason = "revoked";
|
|
191
|
+
await deleteDeviceCredential(deviceId).catch(() => false);
|
|
430
192
|
}
|
|
@@ -8,19 +8,14 @@ export function lastAuthFailure() {
|
|
|
8
8
|
}
|
|
9
9
|
function describeFailure(outcome) {
|
|
10
10
|
if (outcome === "unreachable") {
|
|
11
|
-
return { code: "AUTH_UNAVAILABLE", message: "Couldn't
|
|
12
|
-
}
|
|
13
|
-
if (outcome.kind === "needs_reauthorization") {
|
|
14
|
-
return { code: "REAUTHORIZATION_REQUIRED", message: `This machine isn't approved for ${outcome.resource} yet — run \`nustack login\` to approve it.`, retryable: false };
|
|
11
|
+
return { code: "AUTH_UNAVAILABLE", message: "Couldn't read this machine's sign-in — try again in a moment.", retryable: true };
|
|
15
12
|
}
|
|
16
13
|
if (outcome.kind === "malformed_token")
|
|
17
14
|
return { code: "TOKEN_MALFORMED", message: outcome.message, retryable: false };
|
|
18
15
|
return { code: "SIGNED_OUT", message: deadCredentialCopy(outcome.reason), retryable: false };
|
|
19
16
|
}
|
|
20
17
|
function reconnectingCopy(outcome) {
|
|
21
|
-
if (outcome.
|
|
22
|
-
return `This machine isn't approved for ${outcome.resource} yet. Opening the browser to sign in…`;
|
|
23
|
-
if (outcome.kind === "dead")
|
|
18
|
+
if (outcome.reason === "revoked")
|
|
24
19
|
return "This machine's access was revoked. Opening the browser to sign in…";
|
|
25
20
|
if (outcome.reason === "older-schema")
|
|
26
21
|
return "This machine's sign-in predates the current NuStack CLI. Opening the browser to sign in…";
|
|
@@ -29,7 +24,7 @@ function reconnectingCopy(outcome) {
|
|
|
29
24
|
export async function ensureAuthWithRecovery(baseUrl, json) {
|
|
30
25
|
let outcome;
|
|
31
26
|
try {
|
|
32
|
-
outcome = await resolveAccessToken(
|
|
27
|
+
outcome = await resolveAccessToken();
|
|
33
28
|
}
|
|
34
29
|
catch {
|
|
35
30
|
lastFailure = describeFailure("unreachable");
|
|
@@ -44,7 +39,7 @@ export async function ensureAuthWithRecovery(baseUrl, json) {
|
|
|
44
39
|
const didLogin = await performDeviceLogin(baseUrl, process.cwd(), { reauthorize: true });
|
|
45
40
|
if (!didLogin)
|
|
46
41
|
return null;
|
|
47
|
-
return ensureAccessToken(
|
|
42
|
+
return ensureAccessToken().catch(() => {
|
|
48
43
|
lastFailure = describeFailure("unreachable");
|
|
49
44
|
return null;
|
|
50
45
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { selectCredentialStore } from "./credential_store.js";
|
|
2
2
|
export { isKeychainAvailable } from "./credential_store.js";
|
|
3
|
-
export const KEYCHAIN_SCHEMA_VERSION =
|
|
4
|
-
const REQUIRED_FIELDS = ["
|
|
3
|
+
export const KEYCHAIN_SCHEMA_VERSION = 4;
|
|
4
|
+
const REQUIRED_FIELDS = ["device_token", "studio_base_url", "device_id", "user_email"];
|
|
5
5
|
export function decodeCredential(raw) {
|
|
6
6
|
let parsed;
|
|
7
7
|
try {
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { ensureAccessToken } from "../../auth_grant.js";
|
|
2
2
|
import { createUploadClient } from "./upload_client.js";
|
|
3
3
|
import { runOutboundPublisher } from "./publisher.js";
|
|
4
|
-
export function deviceTokenProvider(
|
|
4
|
+
export function deviceTokenProvider() {
|
|
5
5
|
let cached = null;
|
|
6
6
|
return async (forceRefresh) => {
|
|
7
7
|
if (cached && !forceRefresh)
|
|
8
8
|
return cached;
|
|
9
|
-
const result = await ensureAccessToken(
|
|
9
|
+
const result = await ensureAccessToken();
|
|
10
10
|
cached = result?.accessToken ?? null;
|
|
11
11
|
return cached;
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
export function registerOutbound(engine, options) {
|
|
15
|
-
const getAccessToken = options.getAccessToken ?? deviceTokenProvider(
|
|
15
|
+
const getAccessToken = options.getAccessToken ?? deviceTokenProvider();
|
|
16
16
|
const client = createUploadClient({ baseUrl: options.baseUrl, getAccessToken, fetchImpl: options.fetchImpl });
|
|
17
17
|
engine.registerOutboundPublisher((ctx) => runOutboundPublisher(ctx, { client, now: options.now }));
|
|
18
18
|
return client;
|
package/dist/lib/loopback.js
CHANGED
|
@@ -34,7 +34,7 @@ export async function openLoopbackListener(expectedState) {
|
|
|
34
34
|
if (error) {
|
|
35
35
|
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
36
36
|
response.end(page("Sign-in did not complete", url.searchParams.get("error_description") ?? error));
|
|
37
|
-
settle?.reject(new Error(`
|
|
37
|
+
settle?.reject(new Error(`NuStack refused the sign-in (${error}).`));
|
|
38
38
|
settle = null;
|
|
39
39
|
void closeServer(server);
|
|
40
40
|
return;
|
|
@@ -47,7 +47,7 @@ export async function openLoopbackListener(expectedState) {
|
|
|
47
47
|
}
|
|
48
48
|
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
49
49
|
response.end(page("Signed in", "You can close this tab and return to the terminal."));
|
|
50
|
-
settle?.resolve({ code
|
|
50
|
+
settle?.resolve({ code });
|
|
51
51
|
settle = null;
|
|
52
52
|
void closeServer(server);
|
|
53
53
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assertSafeNuStackUrl, assertSafeOpaqueId } from "./guards.js";
|
|
2
2
|
import { createSseParser } from "./sse_framing.js";
|
|
3
|
-
import { DEVICE_HEADER, envToken,
|
|
3
|
+
import { deadCredentialCopy, DEVICE_HEADER, envToken, forgetRefusedToken, knownDeviceId, TokenRefusedError, userAgent } from "./auth_grant.js";
|
|
4
4
|
import { getOrCreateDeviceId } from "./device_identity.js";
|
|
5
5
|
const DEFAULT_NUSTACK_URL = "https://apps.nustack.ai";
|
|
6
6
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
@@ -10,20 +10,20 @@ export async function authorizedFetch(input, init = {}) {
|
|
|
10
10
|
headers.set("User-Agent", userAgent());
|
|
11
11
|
const response = await globalThis.fetch(input, { ...init, headers });
|
|
12
12
|
const bearer = headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
|
|
13
|
-
if (response.status !== 401 || !bearer
|
|
13
|
+
if (response.status !== 401 || !bearer)
|
|
14
14
|
return response;
|
|
15
15
|
if (envToken())
|
|
16
16
|
throw new TokenRefusedError();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return response;
|
|
20
|
-
headers.set("Authorization", `Bearer ${fresh}`);
|
|
21
|
-
return globalThis.fetch(input, { ...init, headers });
|
|
22
|
-
}
|
|
23
|
-
function isStreamBody(body) {
|
|
24
|
-
return body != null && typeof body !== "string" && !(body instanceof ArrayBuffer) && !ArrayBuffer.isView(body) && !(body instanceof URLSearchParams);
|
|
17
|
+
await forgetRefusedToken(bearer).catch(() => undefined);
|
|
18
|
+
return response;
|
|
25
19
|
}
|
|
26
20
|
const KEYS_PLANE = "the credential plane (/api/v1/keys)";
|
|
21
|
+
export function apiError(payload, status, label, plane) {
|
|
22
|
+
if (status === 401 && !envToken())
|
|
23
|
+
return Object.assign(new Error(deadCredentialCopy("revoked")), { code: "SIGNED_OUT" });
|
|
24
|
+
const code = typeof payload?.error === "object" && typeof payload.error?.code === "string" ? payload.error.code : "UNEXPECTED";
|
|
25
|
+
return Object.assign(new Error(errorMessage(payload, status, label, plane)), { code });
|
|
26
|
+
}
|
|
27
27
|
export function errorMessage(payload, status, label, plane) {
|
|
28
28
|
if (plane && status === 404 && typeof payload?.error === "string") {
|
|
29
29
|
return `${label}: this NuStack deployment does not serve ${plane} — the server half of this command is not deployed, so it cannot succeed against this host.`;
|
|
@@ -67,7 +67,7 @@ export async function exchangeServiceCredential(baseUrl, accessToken, params) {
|
|
|
67
67
|
});
|
|
68
68
|
const payload = (await response.json().catch(() => null));
|
|
69
69
|
if (!response.ok || !payload?.serviceKey) {
|
|
70
|
-
throw
|
|
70
|
+
throw apiError(payload, response.status, "exchange failed", KEYS_PLANE);
|
|
71
71
|
}
|
|
72
72
|
return narrowProjection(payload, params.service);
|
|
73
73
|
}
|
|
@@ -81,7 +81,7 @@ export async function fetchServiceCredential(baseUrl, accessToken, params) {
|
|
|
81
81
|
});
|
|
82
82
|
const payload = (await response.json().catch(() => null));
|
|
83
83
|
if (!response.ok || !payload?.serviceKey) {
|
|
84
|
-
throw
|
|
84
|
+
throw apiError(payload, response.status, `projection for '${params.service}' failed`, KEYS_PLANE);
|
|
85
85
|
}
|
|
86
86
|
return narrowProjection(payload, params.service);
|
|
87
87
|
}
|
|
@@ -94,7 +94,7 @@ export async function listProjectIntegrations(baseUrl, accessToken, projectId) {
|
|
|
94
94
|
});
|
|
95
95
|
const payload = (await response.json().catch(() => null));
|
|
96
96
|
if (!response.ok || !Array.isArray(payload?.integrations)) {
|
|
97
|
-
throw
|
|
97
|
+
throw apiError(payload, response.status, "integration list failed", KEYS_PLANE);
|
|
98
98
|
}
|
|
99
99
|
return payload.integrations;
|
|
100
100
|
}
|
|
@@ -108,7 +108,7 @@ export async function fetchBrokeredCredential(baseUrl, accessToken, params) {
|
|
|
108
108
|
});
|
|
109
109
|
const payload = (await response.json().catch(() => null));
|
|
110
110
|
if (!response.ok || !payload?.key || !payload?.grantId) {
|
|
111
|
-
throw
|
|
111
|
+
throw apiError(payload, response.status, "broker fetch failed", KEYS_PLANE);
|
|
112
112
|
}
|
|
113
113
|
return { url: payload.url ?? null, key: payload.key, meta: payload.meta ?? null, grantId: payload.grantId };
|
|
114
114
|
}
|
|
@@ -122,7 +122,7 @@ export async function searchProjects(baseUrl, query, opts = {}) {
|
|
|
122
122
|
const response = await authorizedFetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
123
123
|
const payload = (await response.json().catch(() => null));
|
|
124
124
|
if (!response.ok || !Array.isArray(payload?.data?.items)) {
|
|
125
|
-
throw
|
|
125
|
+
throw apiError(payload, response.status, "project search failed");
|
|
126
126
|
}
|
|
127
127
|
return payload.data.items;
|
|
128
128
|
}
|
|
@@ -150,7 +150,7 @@ export async function fetchHandoffManifest(baseUrl, accessToken, params) {
|
|
|
150
150
|
});
|
|
151
151
|
const payload = (await response.json().catch(() => null));
|
|
152
152
|
if (!response.ok || !payload?.data?.manifestSha256) {
|
|
153
|
-
throw
|
|
153
|
+
throw apiError(payload, response.status, "handoff manifest fetch failed");
|
|
154
154
|
}
|
|
155
155
|
return payload.data;
|
|
156
156
|
}
|
|
@@ -179,7 +179,7 @@ export async function fetchBindingEvents(baseUrl, accessToken, params) {
|
|
|
179
179
|
});
|
|
180
180
|
const payload = (await response.json().catch(() => null));
|
|
181
181
|
if (!response.ok || !Array.isArray(payload?.data?.items)) {
|
|
182
|
-
throw
|
|
182
|
+
throw apiError(payload, response.status, "binding events fetch failed");
|
|
183
183
|
}
|
|
184
184
|
return payload.data.items;
|
|
185
185
|
}
|
|
@@ -193,7 +193,7 @@ export async function acknowledgeBindingCursor(baseUrl, accessToken, params) {
|
|
|
193
193
|
});
|
|
194
194
|
const payload = (await response.json().catch(() => null));
|
|
195
195
|
if (!response.ok || typeof payload?.data?.lastAppliedProjectSequence !== "number") {
|
|
196
|
-
throw
|
|
196
|
+
throw apiError(payload, response.status, "cursor acknowledgement failed");
|
|
197
197
|
}
|
|
198
198
|
return payload.data.lastAppliedProjectSequence;
|
|
199
199
|
}
|
|
@@ -205,7 +205,7 @@ export async function fetchSyncWhitelist(baseUrl, accessToken, bindingId) {
|
|
|
205
205
|
});
|
|
206
206
|
const payload = (await response.json().catch(() => null));
|
|
207
207
|
if (!response.ok || !payload?.data?.manifestSha256) {
|
|
208
|
-
throw
|
|
208
|
+
throw apiError(payload, response.status, "sync whitelist fetch failed");
|
|
209
209
|
}
|
|
210
210
|
return payload.data;
|
|
211
211
|
}
|
|
@@ -220,7 +220,7 @@ export async function reportApplicationDecision(baseUrl, accessToken, report) {
|
|
|
220
220
|
return { ok: false, code: "HANDOFF_SUPERSEDED" };
|
|
221
221
|
}
|
|
222
222
|
if (!response.ok || payload?.data?.recorded !== true) {
|
|
223
|
-
throw
|
|
223
|
+
throw apiError(payload, response.status, "decision report failed");
|
|
224
224
|
}
|
|
225
225
|
return { ok: true };
|
|
226
226
|
}
|
|
@@ -260,7 +260,7 @@ function postApplicationReport(baseUrl, accessToken, report, action, body) {
|
|
|
260
260
|
async function assertReportRecorded(response, label) {
|
|
261
261
|
const payload = (await response.json().catch(() => null));
|
|
262
262
|
if (!response.ok || payload?.data?.recorded !== true) {
|
|
263
|
-
throw
|
|
263
|
+
throw apiError(payload, response.status, label);
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
function authHeaders(accessToken) {
|
|
@@ -280,7 +280,7 @@ export async function listOrganizations(baseUrl, accessToken) {
|
|
|
280
280
|
});
|
|
281
281
|
const payload = (await response.json().catch(() => null));
|
|
282
282
|
if (!response.ok || !Array.isArray(payload?.data?.items)) {
|
|
283
|
-
throw
|
|
283
|
+
throw apiError(payload, response.status, "organization list failed");
|
|
284
284
|
}
|
|
285
285
|
return payload.data.items;
|
|
286
286
|
}
|
|
@@ -333,7 +333,7 @@ export async function createOrganization(baseUrl, accessToken, params, idempoten
|
|
|
333
333
|
});
|
|
334
334
|
const payload = (await response.json().catch(() => null));
|
|
335
335
|
if (!response.ok || !payload?.data?.id) {
|
|
336
|
-
throw
|
|
336
|
+
throw apiError(payload, response.status, "organization create failed");
|
|
337
337
|
}
|
|
338
338
|
return payload.data;
|
|
339
339
|
}
|
|
@@ -345,7 +345,7 @@ export async function listProjects(baseUrl, accessToken, organizationId) {
|
|
|
345
345
|
});
|
|
346
346
|
const payload = (await response.json().catch(() => null));
|
|
347
347
|
if (!response.ok || !Array.isArray(payload?.data?.items)) {
|
|
348
|
-
throw
|
|
348
|
+
throw apiError(payload, response.status, "project list failed");
|
|
349
349
|
}
|
|
350
350
|
return payload.data.items;
|
|
351
351
|
}
|
|
@@ -357,7 +357,7 @@ export async function getProject(baseUrl, accessToken, projectId) {
|
|
|
357
357
|
});
|
|
358
358
|
const payload = (await response.json().catch(() => null));
|
|
359
359
|
if (!response.ok || !payload?.data?.id) {
|
|
360
|
-
throw
|
|
360
|
+
throw apiError(payload, response.status, "project fetch failed");
|
|
361
361
|
}
|
|
362
362
|
return payload.data;
|
|
363
363
|
}
|
|
@@ -417,7 +417,7 @@ export async function createProjectViaAsk(baseUrl, accessToken, organizationId,
|
|
|
417
417
|
});
|
|
418
418
|
if (!response.ok || !response.body) {
|
|
419
419
|
const payload = (await response.json().catch(() => null));
|
|
420
|
-
throw
|
|
420
|
+
throw apiError(payload, response.status, "project create failed");
|
|
421
421
|
}
|
|
422
422
|
let started = false;
|
|
423
423
|
let turnId = null;
|
|
@@ -456,7 +456,7 @@ async function postProjectPublication(baseUrl, accessToken, projectId, action, b
|
|
|
456
456
|
});
|
|
457
457
|
const payload = (await response.json().catch(() => null));
|
|
458
458
|
if (!response.ok || payload?.data === undefined) {
|
|
459
|
-
throw
|
|
459
|
+
throw apiError(payload, response.status, `project ${action} failed`);
|
|
460
460
|
}
|
|
461
461
|
return payload.data;
|
|
462
462
|
}
|
|
@@ -474,7 +474,7 @@ export async function listDocuments(baseUrl, accessToken, projectId) {
|
|
|
474
474
|
});
|
|
475
475
|
const payload = (await response.json().catch(() => null));
|
|
476
476
|
if (!response.ok || !Array.isArray(payload?.data?.items)) {
|
|
477
|
-
throw
|
|
477
|
+
throw apiError(payload, response.status, "document list failed");
|
|
478
478
|
}
|
|
479
479
|
return payload.data.items;
|
|
480
480
|
}
|
|
@@ -486,7 +486,7 @@ export async function getDocument(baseUrl, accessToken, documentId) {
|
|
|
486
486
|
});
|
|
487
487
|
const payload = (await response.json().catch(() => null));
|
|
488
488
|
if (!response.ok || !payload?.data?.document?.id) {
|
|
489
|
-
throw
|
|
489
|
+
throw apiError(payload, response.status, "document fetch failed");
|
|
490
490
|
}
|
|
491
491
|
return payload.data;
|
|
492
492
|
}
|
|
@@ -498,7 +498,7 @@ export async function listHandoffs(baseUrl, accessToken, projectId) {
|
|
|
498
498
|
});
|
|
499
499
|
const payload = (await response.json().catch(() => null));
|
|
500
500
|
if (!response.ok || !Array.isArray(payload?.data?.items)) {
|
|
501
|
-
throw
|
|
501
|
+
throw apiError(payload, response.status, "handoff list failed");
|
|
502
502
|
}
|
|
503
503
|
return payload.data.items;
|
|
504
504
|
}
|
|
@@ -510,7 +510,7 @@ export async function getHandoff(baseUrl, accessToken, handoffId) {
|
|
|
510
510
|
});
|
|
511
511
|
const payload = (await response.json().catch(() => null));
|
|
512
512
|
if (!response.ok || !payload?.data?.id) {
|
|
513
|
-
throw
|
|
513
|
+
throw apiError(payload, response.status, "handoff fetch failed");
|
|
514
514
|
}
|
|
515
515
|
return payload.data;
|
|
516
516
|
}
|
|
@@ -533,7 +533,7 @@ export async function createBinding(baseUrl, accessToken, projectId, input, idem
|
|
|
533
533
|
});
|
|
534
534
|
const payload = (await response.json().catch(() => null));
|
|
535
535
|
if (!response.ok || !payload?.data?.id) {
|
|
536
|
-
throw
|
|
536
|
+
throw apiError(payload, response.status, "binding create failed");
|
|
537
537
|
}
|
|
538
538
|
return payload.data;
|
|
539
539
|
}
|
|
@@ -545,7 +545,7 @@ export async function getBinding(baseUrl, accessToken, bindingId) {
|
|
|
545
545
|
});
|
|
546
546
|
const payload = (await response.json().catch(() => null));
|
|
547
547
|
if (!response.ok || !payload?.data?.id) {
|
|
548
|
-
throw
|
|
548
|
+
throw apiError(payload, response.status, "binding fetch failed");
|
|
549
549
|
}
|
|
550
550
|
return payload.data;
|
|
551
551
|
}
|
|
@@ -558,7 +558,7 @@ export async function deleteBinding(baseUrl, accessToken, bindingId) {
|
|
|
558
558
|
});
|
|
559
559
|
const payload = (await response.json().catch(() => null));
|
|
560
560
|
if (!response.ok || !payload?.data?.id) {
|
|
561
|
-
throw
|
|
561
|
+
throw apiError(payload, response.status, "binding remove failed");
|
|
562
562
|
}
|
|
563
563
|
return { id: payload.data.id, status: payload.data.status ?? "removed" };
|
|
564
564
|
}
|
|
@@ -572,7 +572,7 @@ export async function postBindingHealth(baseUrl, accessToken, bindingId, report)
|
|
|
572
572
|
});
|
|
573
573
|
const payload = (await response.json().catch(() => null));
|
|
574
574
|
if (!response.ok || !payload?.data?.id) {
|
|
575
|
-
throw
|
|
575
|
+
throw apiError(payload, response.status, "binding health failed");
|
|
576
576
|
}
|
|
577
577
|
return payload.data;
|
|
578
578
|
}
|
|
@@ -583,7 +583,7 @@ export async function listDevices(baseUrl, accessToken) {
|
|
|
583
583
|
});
|
|
584
584
|
const payload = (await response.json().catch(() => null));
|
|
585
585
|
if (!response.ok || !Array.isArray(payload?.data?.items)) {
|
|
586
|
-
throw
|
|
586
|
+
throw apiError(payload, response.status, "device list failed");
|
|
587
587
|
}
|
|
588
588
|
return payload.data.items;
|
|
589
589
|
}
|
package/dist/lib/records/door.js
CHANGED
|
@@ -43,7 +43,7 @@ export async function resolveDoor(root, globals, projectFlag) {
|
|
|
43
43
|
export class DoorRefusedError extends Error {
|
|
44
44
|
}
|
|
45
45
|
export async function postRecords(doorUrl, projectId, kind, rows) {
|
|
46
|
-
const auth = await ensureAccessToken(
|
|
46
|
+
const auth = await ensureAccessToken().catch(() => null);
|
|
47
47
|
if (!auth)
|
|
48
48
|
throw new DoorRefusedError("this device is signed out");
|
|
49
49
|
let response;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { apiError } from "../nustack_client.js";
|
|
1
2
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
2
3
|
export class ReviewUnreachableError extends Error {
|
|
3
4
|
constructor(message) {
|
|
@@ -31,7 +32,7 @@ export function makeReviewClient(baseUrl, accessToken) {
|
|
|
31
32
|
if (response.status === 401 || response.status === 403) {
|
|
32
33
|
throw new ReviewUnreachableError(errorMessage(payload, response.status, label));
|
|
33
34
|
}
|
|
34
|
-
throw
|
|
35
|
+
throw apiError(payload, response.status, label);
|
|
35
36
|
}
|
|
36
37
|
return payload?.data ?? null;
|
|
37
38
|
}
|
|
@@ -30,7 +30,7 @@ function defaultSeams() {
|
|
|
30
30
|
restartService: () => restartService(),
|
|
31
31
|
unregisterService: () => unregisterService(),
|
|
32
32
|
fetchImpl: fetch,
|
|
33
|
-
ensureAccessToken: (
|
|
33
|
+
ensureAccessToken: () => ensureAccessToken(),
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
function centralReloadContext(seams, configDir) {
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { assertSafeOpaqueId } from "../guards.js";
|
|
2
|
+
import { apiError } from "../nustack_client.js";
|
|
2
3
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
3
|
-
function errorMessage(payload, status, label) {
|
|
4
|
-
const enveloped = typeof payload?.error === "object" ? payload.error?.message : undefined;
|
|
5
|
-
return enveloped ?? `${label}: HTTP ${status}`;
|
|
6
|
-
}
|
|
7
4
|
export function makeSessionsClient(baseUrl, accessToken) {
|
|
8
5
|
const headers = {
|
|
9
6
|
Authorization: `Bearer ${accessToken}`,
|
|
@@ -18,7 +15,7 @@ export function makeSessionsClient(baseUrl, accessToken) {
|
|
|
18
15
|
});
|
|
19
16
|
const payload = (await response.json().catch(() => null));
|
|
20
17
|
if (!response.ok || !payload?.data?.id) {
|
|
21
|
-
throw
|
|
18
|
+
throw apiError(payload, response.status, label);
|
|
22
19
|
}
|
|
23
20
|
return { status: response.status, session: payload.data };
|
|
24
21
|
}
|