@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 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 the **device-trust flow**: `nustack login` registers this machine, you approve its code once in the browser (at `/device`, behind the studio's Google SSO), and the durable refresh material lands in your OS keychain — never in a file. Every authenticated command then rides a short-lived access token minted from it. Signing in on the CLI signs in the desktop workspace too: both clients share one keychain item and one `~/.nustack/device_id`.
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
 
@@ -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(baseUrl).catch(() => null);
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) {
@@ -1,14 +1,14 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { listDevices, resolveNuStackUrl } from "../lib/nustack_client.js";
3
- import { readGrant, revokeGrant } from "../lib/auth_grant.js";
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 grant = await readGrant();
71
- return grant ? { grant, accessToken: auth.accessToken } : null;
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
- authBaseUrl: grant.authBaseUrl,
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
- `auth service: ${grant.authBaseUrl}`,
117
- `resources: ${grant.grantedResources.join(", ") || "—"}`,
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 clientId = grant.clientId;
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" ? "The auth service refused to end the grant." : "Couldn't reach the auth service to end the grant.");
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) {
@@ -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(baseUrl).catch(() => null);
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)
@@ -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(baseUrl).catch(() => null);
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(baseUrl).catch(() => null);
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;
@@ -44,9 +44,8 @@ function waitForEnter() {
44
44
  process.stdin.resume();
45
45
  });
46
46
  }
47
- async function pasteFlow(ctx, flags) {
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(baseUrl).catch(() => null);
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 switch accounts).`);
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, flags) : await loopbackFlow(ctx, flags);
116
+ result = choice === "paste" ? await pasteFlow(ctx) : await loopbackFlow(ctx, flags);
119
117
  }
120
118
  else {
121
- result = useManual ? await pasteFlow(ctx, flags) : await loopbackFlow(ctx, flags);
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 grant is saved to ~/.nustack/credentials.json, readable only by you — no OS keychain here. Manage it with \`nustack device list\`.`);
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 grant is saved to your keychain — the desktop shares it. Manage it with `nustack device list`.");
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
  }
@@ -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, fetchAuthConfig, readGrant, revokeGrant } from "../lib/auth_grant.js";
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 grant = await readGrant();
18
- if (grant) {
19
- const clientId = grant.clientId ?? (await fetchAuthConfig(baseUrl).then((c) => c.clientId).catch(() => null));
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 grant at the auth service — it can no longer mint access.");
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 grant had already been ended at the auth service.");
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("The auth service refused to end the grant — clearing local material anyway. Revoke it from Profile › Devices.");
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 the auth service to end the grant — clearing local material anyway. Revoke it from Profile › Devices when you are back online.");
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(grant.deviceId);
32
+ const cleared = await deleteDeviceCredential(credential.deviceId);
34
33
  if (cleared)
35
- p.log.success(store.name === "keychain" ? "Cleared the keychain grant." : `Cleared the stored grant (${store.name}).`);
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(baseUrl).catch(() => null);
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(baseUrl) });
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) {
@@ -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(baseUrl).catch(() => null);
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(baseUrl).catch(() => null);
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(baseUrl).catch(() => null);
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), {
@@ -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(baseUrl).catch(() => null);
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 holds a grant, and ask for the account")
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")
@@ -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 MACHINE_SCOPE = "offline_access";
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 === "invalid_grant")
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 buildAuthorizeUrl(config, discovery, request) {
76
- const url = new URL(discovery.authorizationEndpoint);
77
- url.searchParams.set("response_type", "code");
78
- url.searchParams.set("client_id", config.clientId);
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
- for (const resource of config.resources)
85
- url.searchParams.append("resource", resource);
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
- export function cliCodePageUrl(authBaseUrl) {
91
- return `${authBaseUrl}${CLI_CODE_PAGE_PATH}`;
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/x-www-form-urlencoded", Accept: "application/json", "User-Agent": userAgent() },
106
- body: form.toString(),
107
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
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 (!response.ok || typeof payload?.access_token !== "string") {
111
- if (payload?.error === "invalid_grant")
112
- throw new Error("That code expired or was already used — run `nustack login` again.");
113
- throw new Error(payload?.error_description ?? payload?.error ?? `the auth service refused the code (HTTP ${response.status})`);
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 persistGrant(config, discovery, issued) {
137
- const deviceId = await getOrCreateDeviceId();
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, authBaseUrl: config.authBaseUrl };
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
- const redirectUri = cliCodePageUrl(ctx.config.authBaseUrl);
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
- const issued = await exchangeCode(ctx.config, ctx.discovery, { code, verifier: pkce.verifier, redirectUri });
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.config, ctx.discovery, { redirectUri: listener.redirectUri, pkce, force: steps.force }));
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 } = outcome;
186
- if (callback.iss !== null && callback.iss !== ctx.discovery.issuer) {
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 readAnswer(status, body) {
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 endpoint = await discover(input.authBaseUrl)
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: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": userAgent() },
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 >= 500)
274
- return "unreachable";
275
- const payload = (await response.json().catch(() => null));
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 readStoredGrant() {
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, grant: null, failure: failure === "older-schema" ? "older-schema" : null };
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
- grant: {
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 clientIdFor(grant, nustackBaseUrl) {
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, authBaseUrl: "" } };
170
+ return { kind: "ok", session: { accessToken: fromEnv.token, userEmail: "", deviceId } };
364
171
  }
365
- const { deviceId, grant, failure } = await readStoredGrant();
366
- if (!grant) {
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
- const audience = resource ?? grant.grantedResources[0];
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(nustackBaseUrl, resource) {
392
- const outcome = await resolveAccessToken(nustackBaseUrl, resource);
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 refreshAccessTokenAfterRefusal(staleToken, nustackBaseUrl, resource) {
184
+ export async function forgetRefusedToken(staleToken) {
396
185
  if (envToken())
397
- return null;
398
- const { deviceId, grant } = await readStoredGrant();
399
- if (!grant)
400
- return null;
401
- const audience = resource ?? grant.grantedResources[0];
402
- if (!audience)
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 reach the auth service to refresh this machine's sign-in — try again in a moment.", retryable: true };
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.kind === "needs_reauthorization")
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(baseUrl);
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(baseUrl).catch(() => {
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 = 3;
4
- const REQUIRED_FIELDS = ["refresh_token", "auth_base_url", "issuer", "device_id", "user_email"];
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(baseUrl) {
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(baseUrl);
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(options.baseUrl);
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;
@@ -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(`The auth service refused the sign-in (${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, iss: url.searchParams.get("iss") });
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, knownDeviceId, refreshAccessTokenAfterRefusal, TokenRefusedError, userAgent } from "./auth_grant.js";
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 || isStreamBody(init.body))
13
+ if (response.status !== 401 || !bearer)
14
14
  return response;
15
15
  if (envToken())
16
16
  throw new TokenRefusedError();
17
- const fresh = await refreshAccessTokenAfterRefusal(bearer).catch(() => null);
18
- if (!fresh)
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 new Error(errorMessage(payload, response.status, "exchange failed", KEYS_PLANE));
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 new Error(errorMessage(payload, response.status, `projection for '${params.service}' failed`, KEYS_PLANE));
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 new Error(errorMessage(payload, response.status, "integration list failed", KEYS_PLANE));
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 new Error(errorMessage(payload, response.status, "broker fetch failed", KEYS_PLANE));
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 new Error(errorMessage(payload, response.status, "project search failed"));
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 new Error(errorMessage(payload, response.status, "handoff manifest fetch failed"));
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 new Error(errorMessage(payload, response.status, "binding events fetch failed"));
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 new Error(errorMessage(payload, response.status, "cursor acknowledgement failed"));
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 new Error(errorMessage(payload, response.status, "sync whitelist fetch failed"));
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 new Error(errorMessage(payload, response.status, "decision report failed"));
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 new Error(errorMessage(payload, response.status, label));
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 new Error(errorMessage(payload, response.status, "organization list failed"));
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 new Error(errorMessage(payload, response.status, "organization create failed"));
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 new Error(errorMessage(payload, response.status, "project list failed"));
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 new Error(errorMessage(payload, response.status, "project fetch failed"));
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 new Error(errorMessage(payload, response.status, "project create failed"));
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 new Error(errorMessage(payload, response.status, `project ${action} failed`));
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 new Error(errorMessage(payload, response.status, "document list failed"));
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 new Error(errorMessage(payload, response.status, "document fetch failed"));
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 new Error(errorMessage(payload, response.status, "handoff list failed"));
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 new Error(errorMessage(payload, response.status, "handoff fetch failed"));
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 new Error(errorMessage(payload, response.status, "binding create failed"));
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 new Error(errorMessage(payload, response.status, "binding fetch failed"));
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 new Error(errorMessage(payload, response.status, "binding remove failed"));
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 new Error(errorMessage(payload, response.status, "binding health failed"));
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 new Error(errorMessage(payload, response.status, "device list failed"));
586
+ throw apiError(payload, response.status, "device list failed");
587
587
  }
588
588
  return payload.data.items;
589
589
  }
@@ -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(doorUrl).catch(() => null);
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 new Error(errorMessage(payload, response.status, label));
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: (baseUrl) => ensureAccessToken(baseUrl),
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 new Error(errorMessage(payload, response.status, label));
18
+ throw apiError(payload, response.status, label);
22
19
  }
23
20
  return { status: response.status, session: payload.data };
24
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.20.4",
3
+ "version": "0.21.0",
4
4
  "description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,