@2kw/ai 5.2.0-dev.8 → 5.3.0-dev.5

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.
@@ -1,12 +1,69 @@
1
+ import { type Middleware } from "openapi-fetch";
1
2
  import type { paths } from "../generated/openapi.js";
3
+ import { type ResolvedConfig } from "./config.js";
4
+ import { ensureJwt } from "./auth-session.js";
2
5
  import type { Command } from "commander";
6
+ /**
7
+ * Marks a request that has already been replayed once after a 401, so a
8
+ * server that keeps rejecting a freshly minted JWT cannot spin the retry.
9
+ */
10
+ export declare const RETRY_MARKER = "x-2kw-auth-retried";
11
+ type SessionConfig = Extract<ResolvedConfig, {
12
+ kind: "session";
13
+ }>;
14
+ type ApiKeyConfig = Extract<ResolvedConfig, {
15
+ kind: "apiKey";
16
+ }>;
17
+ /** Test seams for the session middleware; both default to the real thing. */
18
+ export interface SessionAuthDeps {
19
+ /** Session-to-JWT resolution; defaults to {@link ensureJwt}. */
20
+ ensureJwtFn?: typeof ensureJwt;
21
+ /** Used only for the 401 replay; defaults to global `fetch`. */
22
+ fetchFn?: typeof fetch;
23
+ }
24
+ /**
25
+ * Error-handling middleware: intercepts non-ok responses and throws BackboneApiError.
26
+ */
27
+ export declare const errorMiddleware: Middleware;
28
+ /**
29
+ * API-key contexts carry a long-lived credential, so the header is static and
30
+ * a 401 is final — there is nothing to refresh.
31
+ */
32
+ export declare function apiKeyAuthMiddleware(config: ApiKeyConfig): Middleware;
33
+ /**
34
+ * Session contexts authenticate with a short-lived org JWT minted from the
35
+ * stored session token. On a 401 we force one re-mint and retry — but only
36
+ * for GET requests: mutation bodies are consumed by the time onResponse
37
+ * fires and cannot be replayed safely.
38
+ */
39
+ export declare function sessionAuthMiddleware(config: SessionConfig, deps?: SessionAuthDeps): Middleware;
40
+ /**
41
+ * The middleware stack for a resolved config, in registration order.
42
+ *
43
+ * Order is load-bearing: openapi-fetch runs `onRequest` forwards but
44
+ * `onResponse` in reverse, so the error middleware is registered FIRST to make
45
+ * its throw happen LAST. Registered last it would raise on a 401 before the
46
+ * auth middleware ever saw the response, and the retry would be dead code.
47
+ */
48
+ export declare function buildMiddleware(config: ResolvedConfig, deps?: SessionAuthDeps): Middleware[];
49
+ /**
50
+ * Resolve the Authorization header value for code that performs raw fetch
51
+ * calls outside the openapi-fetch client (multipart uploads, downloads).
52
+ * API-key contexts are synchronous passthrough; session contexts mint/reuse
53
+ * a JWT. No 401 retry here — raw-fetch callers surface errors directly.
54
+ */
55
+ export declare function resolveAuthHeader(config: ResolvedConfig, deps?: Pick<SessionAuthDeps, "ensureJwtFn">): Promise<string>;
3
56
  /**
4
57
  * Create a typed openapi-fetch client from the resolved config.
5
58
  * Attaches Bearer auth and error middleware automatically.
59
+ *
60
+ * Stays synchronous — a JWT is minted lazily inside the request middleware, so
61
+ * none of the ~90 call sites has to await client construction.
6
62
  */
7
63
  export declare function getClient(command: Command): import("openapi-fetch").Client<paths, `${string}/${string}`>;
8
64
  /**
9
65
  * Convenience: run an async action with consistent error handling.
10
66
  */
11
67
  export declare function runAction(command: Command, action: () => Promise<void>): Promise<void>;
68
+ export {};
12
69
  //# sourceMappingURL=client.d.ts.map
@@ -1,10 +1,16 @@
1
1
  import createClient from "openapi-fetch";
2
2
  import { resolveConfig, isJsonOutput } from "./config.js";
3
+ import { ensureJwt } from "./auth-session.js";
3
4
  import { BackboneApiError, handleError } from "./errors.js";
5
+ /**
6
+ * Marks a request that has already been replayed once after a 401, so a
7
+ * server that keeps rejecting a freshly minted JWT cannot spin the retry.
8
+ */
9
+ export const RETRY_MARKER = "x-2kw-auth-retried";
4
10
  /**
5
11
  * Error-handling middleware: intercepts non-ok responses and throws BackboneApiError.
6
12
  */
7
- const errorMiddleware = {
13
+ export const errorMiddleware = {
8
14
  async onResponse({ response }) {
9
15
  if (response.ok)
10
16
  return undefined;
@@ -23,19 +29,89 @@ const errorMiddleware = {
23
29
  });
24
30
  },
25
31
  };
32
+ /**
33
+ * API-key contexts carry a long-lived credential, so the header is static and
34
+ * a 401 is final — there is nothing to refresh.
35
+ */
36
+ export function apiKeyAuthMiddleware(config) {
37
+ return {
38
+ onRequest({ request }) {
39
+ request.headers.set("Authorization", `Bearer ${config.apiKey}`);
40
+ return request;
41
+ },
42
+ };
43
+ }
44
+ /**
45
+ * Session contexts authenticate with a short-lived org JWT minted from the
46
+ * stored session token. On a 401 we force one re-mint and retry — but only
47
+ * for GET requests: mutation bodies are consumed by the time onResponse
48
+ * fires and cannot be replayed safely.
49
+ */
50
+ export function sessionAuthMiddleware(config, deps = {}) {
51
+ const doEnsure = deps.ensureJwtFn ?? ensureJwt;
52
+ const doFetch = deps.fetchFn ?? fetch;
53
+ return {
54
+ async onRequest({ request }) {
55
+ request.headers.set("Authorization", `Bearer ${await doEnsure(config)}`);
56
+ return request;
57
+ },
58
+ async onResponse({ request, response }) {
59
+ if (response.status !== 401 ||
60
+ request.method !== "GET" ||
61
+ request.headers.has(RETRY_MARKER)) {
62
+ return undefined;
63
+ }
64
+ const jwt = await doEnsure(config, { force: true });
65
+ const retry = new Request(request.url, {
66
+ method: "GET",
67
+ headers: new Headers(request.headers),
68
+ });
69
+ retry.headers.set("Authorization", `Bearer ${jwt}`);
70
+ retry.headers.set(RETRY_MARKER, "1");
71
+ return doFetch(retry);
72
+ },
73
+ };
74
+ }
75
+ /**
76
+ * The middleware stack for a resolved config, in registration order.
77
+ *
78
+ * Order is load-bearing: openapi-fetch runs `onRequest` forwards but
79
+ * `onResponse` in reverse, so the error middleware is registered FIRST to make
80
+ * its throw happen LAST. Registered last it would raise on a 401 before the
81
+ * auth middleware ever saw the response, and the retry would be dead code.
82
+ */
83
+ export function buildMiddleware(config, deps = {}) {
84
+ return [
85
+ errorMiddleware, // MUST stay first — see docblock
86
+ config.kind === "apiKey"
87
+ ? apiKeyAuthMiddleware(config)
88
+ : sessionAuthMiddleware(config, deps),
89
+ ];
90
+ }
91
+ /**
92
+ * Resolve the Authorization header value for code that performs raw fetch
93
+ * calls outside the openapi-fetch client (multipart uploads, downloads).
94
+ * API-key contexts are synchronous passthrough; session contexts mint/reuse
95
+ * a JWT. No 401 retry here — raw-fetch callers surface errors directly.
96
+ */
97
+ export async function resolveAuthHeader(config, deps = {}) {
98
+ if (config.kind === "apiKey")
99
+ return `Bearer ${config.apiKey}`;
100
+ return `Bearer ${await (deps.ensureJwtFn ?? ensureJwt)(config)}`;
101
+ }
26
102
  /**
27
103
  * Create a typed openapi-fetch client from the resolved config.
28
104
  * Attaches Bearer auth and error middleware automatically.
105
+ *
106
+ * Stays synchronous — a JWT is minted lazily inside the request middleware, so
107
+ * none of the ~90 call sites has to await client construction.
29
108
  */
30
109
  export function getClient(command) {
31
110
  const config = resolveConfig(command);
32
111
  const client = createClient({
33
112
  baseUrl: config.baseUrl.replace(/\/+$/, ""),
34
- headers: {
35
- Authorization: `Bearer ${config.apiKey}`,
36
- },
37
113
  });
38
- client.use(errorMiddleware);
114
+ client.use(...buildMiddleware(config));
39
115
  return client;
40
116
  }
41
117
  /**
@@ -2,16 +2,41 @@ import Conf from "conf";
2
2
  import type { Command } from "commander";
3
3
  export interface ContextEntry {
4
4
  baseUrl: string;
5
- apiKey: string;
5
+ /** Present on API-key contexts (CI/scripts and manual logins). */
6
+ apiKey?: string;
7
+ /** Present on session contexts created by the browser device-flow login. */
8
+ sessionToken?: string;
9
+ /** Auth service base URL for this context (e.g. https://auth.2kw.ai). */
10
+ authUrl?: string;
11
+ /** Active organization chosen at login / via set-org. */
12
+ organizationId?: string;
13
+ /** Short-lived org JWT cache — avoids one auth round-trip between rapid commands. */
14
+ cachedJwt?: string;
15
+ /** Epoch millis when cachedJwt expires. */
16
+ cachedJwtExp?: number;
6
17
  }
7
18
  export interface BackboneConfigStore {
8
19
  activeContext: string;
9
20
  contexts: Record<string, ContextEntry>;
10
21
  }
11
- export interface BackboneConfig {
22
+ /**
23
+ * What a command actually authenticates with. Either a raw API key, or a
24
+ * browser session that a later step exchanges for a short-lived org JWT.
25
+ */
26
+ export type ResolvedConfig = {
27
+ kind: "apiKey";
12
28
  apiKey: string;
13
29
  baseUrl: string;
14
- }
30
+ } | {
31
+ kind: "session";
32
+ contextName: string;
33
+ sessionToken: string;
34
+ authUrl: string;
35
+ baseUrl: string;
36
+ organizationId?: string;
37
+ cachedJwt?: string;
38
+ cachedJwtExp?: number;
39
+ };
15
40
  /**
16
41
  * Fallback base URL when the user has no context, env var, or local config.
17
42
  * Override at runtime by setting AI_2KW_DEFAULT_BASE_URL (or the legacy
@@ -19,6 +44,12 @@ export interface BackboneConfig {
19
44
  * deployment.
20
45
  */
21
46
  export declare const DEFAULT_BASE_URL: string;
47
+ /**
48
+ * Fallback auth-service URL used when the API host is not one we recognise.
49
+ * Override at runtime with AI_2KW_DEFAULT_AUTH_URL.
50
+ */
51
+ export declare const DEFAULT_AUTH_URL: string;
52
+ export declare function defaultAuthUrlFor(baseUrl: string): string;
22
53
  declare const store: Conf<BackboneConfigStore>;
23
54
  export { store };
24
55
  export declare function validateContextName(name: string): void;
@@ -27,18 +58,46 @@ export declare function getActiveContext(): ContextEntry | undefined;
27
58
  export declare function getAllContexts(): Record<string, ContextEntry>;
28
59
  export declare function getContextCount(): number;
29
60
  export declare function setContext(name: string, entry: ContextEntry): void;
61
+ /**
62
+ * Merge a partial update into an existing context, leaving unmentioned fields
63
+ * intact.
64
+ *
65
+ * Note the spread semantics: a key present in `patch` with the value
66
+ * `undefined` CLEARS that field rather than being ignored. That is deliberate
67
+ * and relied upon — e.g. `updateContext(name, { cachedJwt: undefined,
68
+ * cachedJwtExp: undefined })` drops a stale cached JWT.
69
+ */
70
+ export declare function updateContext(name: string, patch: Partial<ContextEntry>): void;
30
71
  export declare function deleteContext(name: string): void;
31
72
  export declare function renameContext(oldName: string, newName: string): void;
32
73
  export declare function setActiveContext(name: string): void;
74
+ /**
75
+ * Every input resolution considers, made explicit so the precedence rules can
76
+ * be exercised without touching the store, the environment, or the cwd.
77
+ *
78
+ * Flags, env vars, and the local file are API-key-only by design: session
79
+ * credentials are issued by the browser login and only ever live in the store.
80
+ */
81
+ export interface ConfigSources {
82
+ flagApiKey?: string;
83
+ flagBaseUrl?: string;
84
+ envApiKey?: string;
85
+ envBaseUrl?: string;
86
+ localApiKey?: string;
87
+ localBaseUrl?: string;
88
+ activeContextName: string;
89
+ activeContext?: ContextEntry;
90
+ }
91
+ export declare function resolveConfigFromSources(s: ConfigSources): ResolvedConfig;
33
92
  /**
34
93
  * Resolve configuration with priority:
35
94
  * 1. CLI flags (--api-key, --base-url)
36
95
  * 2. Environment variables (AI_2KW_API_KEY then legacy BACKBONE_API_KEY,
37
96
  * same for AI_2KW_BASE_URL / BACKBONE_BASE_URL)
38
97
  * 3. Local .2kw file (or legacy .backbone)
39
- * 4. Active context from config store
98
+ * 4. Active context from config store (API key or browser session)
40
99
  */
41
- export declare function resolveConfig(command: Command): BackboneConfig;
100
+ export declare function resolveConfig(command: Command): ResolvedConfig;
42
101
  /** Check if --json flag is set on root command */
43
102
  export declare function isJsonOutput(command: Command): boolean;
44
103
  //# sourceMappingURL=config.d.ts.map
@@ -10,6 +10,32 @@ import { resolve } from "node:path";
10
10
  export const DEFAULT_BASE_URL = process.env.AI_2KW_DEFAULT_BASE_URL ??
11
11
  process.env.BACKBONE_DEFAULT_BASE_URL ??
12
12
  "https://backbone.manfred-kunze.dev/api";
13
+ /**
14
+ * Fallback auth-service URL used when the API host is not one we recognise.
15
+ * Override at runtime with AI_2KW_DEFAULT_AUTH_URL.
16
+ */
17
+ export const DEFAULT_AUTH_URL = process.env.AI_2KW_DEFAULT_AUTH_URL ?? "https://auth.2kw.ai";
18
+ /** Known API-host → auth-service mappings for interactive login defaults. */
19
+ const AUTH_URL_BY_API_HOST = {
20
+ "api.2kw.ai": "https://auth.2kw.ai",
21
+ "api-dev.2kw.ai": "https://auth-dev.2kw.ai",
22
+ "backbone.manfred-kunze.dev": "https://auth.2kw.ai",
23
+ "localhost:8080": "http://localhost:3001",
24
+ "127.0.0.1:8080": "http://localhost:3001",
25
+ };
26
+ export function defaultAuthUrlFor(baseUrl) {
27
+ try {
28
+ const host = new URL(baseUrl).host;
29
+ // hasOwn, not a bare index: a host like "constructor" or "toString" would
30
+ // otherwise resolve through Object.prototype and yield a bogus auth URL.
31
+ return Object.hasOwn(AUTH_URL_BY_API_HOST, host)
32
+ ? AUTH_URL_BY_API_HOST[host]
33
+ : DEFAULT_AUTH_URL;
34
+ }
35
+ catch {
36
+ return DEFAULT_AUTH_URL;
37
+ }
38
+ }
13
39
  // One-shot flag so we only print the deprecation warning once per process,
14
40
  // even if resolveConfig is called multiple times across commands.
15
41
  let legacyEnvWarned = false;
@@ -30,9 +56,17 @@ function warnLegacyEnv(legacyName, canonicalName) {
30
56
  * fully self-contained.
31
57
  */
32
58
  const CONFIG_DIR_OVERRIDE = process.env.AI_2KW_CONFIG_DIR;
59
+ /**
60
+ * Owner-only, like an ssh private key: this file holds live credentials — an
61
+ * API key, or a session token and the org JWT minted from it. conf defaults to
62
+ * 0o666, which on any shared POSIX box makes every account on it a reader.
63
+ * Windows largely ignores mode bits, where this is simply inert.
64
+ */
65
+ const CONFIG_FILE_MODE = 0o600;
33
66
  const store = new Conf({
34
67
  projectName: "2kw",
35
68
  projectSuffix: "",
69
+ configFileMode: CONFIG_FILE_MODE,
36
70
  defaults: {
37
71
  activeContext: "default",
38
72
  contexts: {},
@@ -56,6 +90,9 @@ const store = new Conf({
56
90
  const legacy = new Conf({
57
91
  projectName: "backbone",
58
92
  projectSuffix: "",
93
+ // Same credentials, same restriction — this instance only computes a path
94
+ // today, but it must not be the one that widens a file if it ever writes.
95
+ configFileMode: CONFIG_FILE_MODE,
59
96
  defaults: { activeContext: "default", contexts: {} },
60
97
  ...(CONFIG_DIR_OVERRIDE ? { cwd: CONFIG_DIR_OVERRIDE } : {}),
61
98
  });
@@ -117,6 +154,24 @@ export function setContext(name, entry) {
117
154
  contexts[name] = entry;
118
155
  store.set("contexts", contexts);
119
156
  }
157
+ /**
158
+ * Merge a partial update into an existing context, leaving unmentioned fields
159
+ * intact.
160
+ *
161
+ * Note the spread semantics: a key present in `patch` with the value
162
+ * `undefined` CLEARS that field rather than being ignored. That is deliberate
163
+ * and relied upon — e.g. `updateContext(name, { cachedJwt: undefined,
164
+ * cachedJwtExp: undefined })` drops a stale cached JWT.
165
+ */
166
+ export function updateContext(name, patch) {
167
+ const contexts = getAllContexts();
168
+ const existing = contexts[name];
169
+ if (!existing) {
170
+ throw new Error(`Context "${name}" does not exist.`);
171
+ }
172
+ contexts[name] = { ...existing, ...patch };
173
+ store.set("contexts", contexts);
174
+ }
120
175
  export function deleteContext(name) {
121
176
  const contexts = getAllContexts();
122
177
  if (!contexts[name]) {
@@ -186,32 +241,67 @@ function readEnv(canonical, legacy) {
186
241
  }
187
242
  return undefined;
188
243
  }
244
+ const NO_AUTH_ERROR = 'No credentials configured. Run "2kw auth login" (browser sign-in), ' +
245
+ '"2kw auth login --api-key <key>", or set AI_2KW_API_KEY.';
246
+ export function resolveConfigFromSources(s) {
247
+ const baseUrl = s.flagBaseUrl ??
248
+ s.envBaseUrl ??
249
+ s.localBaseUrl ??
250
+ s.activeContext?.baseUrl ??
251
+ DEFAULT_BASE_URL;
252
+ // Explicit API keys (flag > env > local file) always win — they are the
253
+ // CI/script escape hatch and must never be shadowed by a stored session.
254
+ const explicitApiKey = s.flagApiKey ?? s.envApiKey ?? s.localApiKey;
255
+ if (explicitApiKey) {
256
+ return { kind: "apiKey", apiKey: explicitApiKey, baseUrl };
257
+ }
258
+ const ctx = s.activeContext;
259
+ if (ctx?.sessionToken) {
260
+ // A session without an auth URL cannot be exchanged for a JWT — treat it
261
+ // as unusable rather than failing later with an opaque request error.
262
+ if (!ctx.authUrl)
263
+ throw new Error(NO_AUTH_ERROR);
264
+ return {
265
+ kind: "session",
266
+ contextName: s.activeContextName,
267
+ sessionToken: ctx.sessionToken,
268
+ authUrl: ctx.authUrl,
269
+ baseUrl,
270
+ organizationId: ctx.organizationId,
271
+ cachedJwt: ctx.cachedJwt,
272
+ cachedJwtExp: ctx.cachedJwtExp,
273
+ };
274
+ }
275
+ if (ctx?.apiKey) {
276
+ return { kind: "apiKey", apiKey: ctx.apiKey, baseUrl };
277
+ }
278
+ throw new Error(NO_AUTH_ERROR);
279
+ }
189
280
  /**
190
281
  * Resolve configuration with priority:
191
282
  * 1. CLI flags (--api-key, --base-url)
192
283
  * 2. Environment variables (AI_2KW_API_KEY then legacy BACKBONE_API_KEY,
193
284
  * same for AI_2KW_BASE_URL / BACKBONE_BASE_URL)
194
285
  * 3. Local .2kw file (or legacy .backbone)
195
- * 4. Active context from config store
286
+ * 4. Active context from config store (API key or browser session)
196
287
  */
197
288
  export function resolveConfig(command) {
198
289
  const root = getRootCommand(command);
199
290
  const opts = root.opts();
200
291
  const local = readLocalFile();
201
- const activeCtx = getActiveContext();
202
- const apiKey = opts.apiKey ??
203
- readEnv("AI_2KW_API_KEY", "BACKBONE_API_KEY") ??
204
- local.apiKey ??
205
- activeCtx?.apiKey;
206
- const baseUrl = opts.baseUrl ??
207
- readEnv("AI_2KW_BASE_URL", "BACKBONE_BASE_URL") ??
208
- local.baseUrl ??
209
- activeCtx?.baseUrl ??
210
- DEFAULT_BASE_URL;
211
- if (!apiKey) {
212
- throw new Error('No API key configured. Run "2kw auth login" (or "backbone auth login") or set AI_2KW_API_KEY.');
213
- }
214
- return { apiKey, baseUrl };
292
+ // readEnv now runs even when a flag overrides it, so the BACKBONE_* legacy
293
+ // deprecation warning can fire in cases the old short-circuit chain silenced.
294
+ // Accepted: the variable IS set and IS deprecated, flag or no flag.
295
+ return resolveConfigFromSources({
296
+ flagApiKey: opts.apiKey,
297
+ flagBaseUrl: opts.baseUrl,
298
+ envApiKey: readEnv("AI_2KW_API_KEY", "BACKBONE_API_KEY"),
299
+ envBaseUrl: readEnv("AI_2KW_BASE_URL", "BACKBONE_BASE_URL"),
300
+ localApiKey: local.apiKey,
301
+ localBaseUrl: local.baseUrl,
302
+ activeContextName: getActiveContextName(),
303
+ activeContext: getActiveContext(),
304
+ });
215
305
  }
216
306
  /** Walk up Commander's parent chain to find root program */
217
307
  function getRootCommand(cmd) {
@@ -19,7 +19,8 @@ export type AuthFailureKind = "UNAUTHORIZED" | "FORBIDDEN" | "UNREACHABLE" | "UN
19
19
  /**
20
20
  * Classify why a credential check failed, from the thrown error alone.
21
21
  *
22
- * - An HTTP rejection arrives as {@link BackboneApiError}: 401 UNAUTHORIZED,
22
+ * - An HTTP rejection arrives as {@link BackboneApiError} (the API) or
23
+ * {@link AuthServiceError} (the auth service): 401 → UNAUTHORIZED,
23
24
  * 403 → FORBIDDEN, anything else → UNKNOWN.
24
25
  * - A network fault (fetch throws before any response) → UNREACHABLE, detected
25
26
  * by undici's `fetch failed` TypeError or a known network error code on the
@@ -1,4 +1,7 @@
1
1
  import chalk from "chalk";
2
+ import { AuthServiceError } from "./auth-service.js";
3
+ import { SessionExpiredError } from "./auth-session.js";
4
+ import { getActiveContext, getActiveContextName, updateContext } from "./config.js";
2
5
  export class BackboneApiError extends Error {
3
6
  status;
4
7
  errorType;
@@ -23,10 +26,20 @@ const NETWORK_ERROR_CODES = new Set([
23
26
  "ENETUNREACH",
24
27
  "EHOSTUNREACH",
25
28
  ]);
29
+ /** Shared status → kind mapping: the two rejection types agree on what a 401
30
+ * and a 403 mean, and differ only in who sent them. */
31
+ function classifyStatus(status) {
32
+ if (status === 401)
33
+ return "UNAUTHORIZED";
34
+ if (status === 403)
35
+ return "FORBIDDEN";
36
+ return "UNKNOWN";
37
+ }
26
38
  /**
27
39
  * Classify why a credential check failed, from the thrown error alone.
28
40
  *
29
- * - An HTTP rejection arrives as {@link BackboneApiError}: 401 UNAUTHORIZED,
41
+ * - An HTTP rejection arrives as {@link BackboneApiError} (the API) or
42
+ * {@link AuthServiceError} (the auth service): 401 → UNAUTHORIZED,
30
43
  * 403 → FORBIDDEN, anything else → UNKNOWN.
31
44
  * - A network fault (fetch throws before any response) → UNREACHABLE, detected
32
45
  * by undici's `fetch failed` TypeError or a known network error code on the
@@ -34,13 +47,10 @@ const NETWORK_ERROR_CODES = new Set([
34
47
  * - Everything else → UNKNOWN.
35
48
  */
36
49
  export function classifyAuthFailure(err) {
37
- if (err instanceof BackboneApiError) {
38
- if (err.status === 401)
39
- return "UNAUTHORIZED";
40
- if (err.status === 403)
41
- return "FORBIDDEN";
42
- return "UNKNOWN";
43
- }
50
+ if (err instanceof BackboneApiError)
51
+ return classifyStatus(err.status);
52
+ if (err instanceof AuthServiceError)
53
+ return classifyStatus(err.status);
44
54
  if (isNetworkError(err))
45
55
  return "UNREACHABLE";
46
56
  return "UNKNOWN";
@@ -59,7 +69,7 @@ function isNetworkError(err) {
59
69
  return typeof causeCode === "string" && NETWORK_ERROR_CODES.has(causeCode);
60
70
  }
61
71
  const HINTS = {
62
- 401: 'Invalid or missing API key. Run "backbone auth login" to configure credentials.',
72
+ 401: 'Invalid or missing API key. Run "2kw auth login" to configure credentials.',
63
73
  402: "Billing limit reached. Check your plan limits or upgrade at the dashboard.",
64
74
  403: "You don't have permission for this action. Check your organization role.",
65
75
  404: "Resource not found. Verify the ID is correct.",
@@ -67,8 +77,39 @@ const HINTS = {
67
77
  422: "Validation error. Check the input values.",
68
78
  429: "Rate limit exceeded. Wait a moment and try again.",
69
79
  };
80
+ /**
81
+ * Drop the cached org JWT of the active session context.
82
+ *
83
+ * Called when the session behind that JWT turns out to be dead: keeping the
84
+ * token would only make the next command spend a request discovering the same
85
+ * 401. Best-effort by design — a read-only store is not worth a second error on
86
+ * top of the one being reported.
87
+ */
88
+ function clearCachedJwt() {
89
+ try {
90
+ const ctx = getActiveContext();
91
+ // API-key contexts have no session and no cache to clear.
92
+ if (!ctx?.sessionToken)
93
+ return;
94
+ updateContext(getActiveContextName(), { cachedJwt: undefined, cachedJwtExp: undefined });
95
+ }
96
+ catch {
97
+ // Nothing actionable: the message below is still the one the user needs.
98
+ }
99
+ }
70
100
  export function handleError(err, json) {
71
- if (err instanceof BackboneApiError) {
101
+ if (err instanceof SessionExpiredError) {
102
+ clearCachedJwt();
103
+ // Reached by every command once the stored session dies, so it gets the
104
+ // one line that names the fix — never a stack trace.
105
+ if (json) {
106
+ console.error(JSON.stringify({ error: "session_expired", message: err.message }));
107
+ }
108
+ else {
109
+ console.error(chalk.yellow(err.message));
110
+ }
111
+ }
112
+ else if (err instanceof BackboneApiError) {
72
113
  if (json) {
73
114
  console.error(JSON.stringify({
74
115
  error: err.errorType,
@@ -87,7 +128,21 @@ export function handleError(err, json) {
87
128
  }
88
129
  else if (err instanceof Error) {
89
130
  if (json) {
90
- console.error(JSON.stringify({ error: err.name, message: err.message }));
131
+ // Errors from outside the typed client (AuthServiceError, undici network
132
+ // faults) carry their own status/code. Passing them through is what lets
133
+ // a scripted caller tell an "invalid_client" apart from a dead DNS name
134
+ // instead of string-matching the message.
135
+ const status = err.status;
136
+ // undici hangs the real code off `cause`, so read both — same pair
137
+ // isNetworkError checks above.
138
+ const code = err.code ??
139
+ err.cause?.code;
140
+ console.error(JSON.stringify({
141
+ error: err.name,
142
+ message: err.message,
143
+ ...(typeof status === "number" ? { status } : {}),
144
+ ...(typeof code === "string" ? { code } : {}),
145
+ }));
91
146
  }
92
147
  else {
93
148
  console.error(chalk.red(`Error: ${err.message}`));
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The one place that decides how a stored credential is allowed to appear —
3
+ * on screen, and in `--json` output.
4
+ *
5
+ * Every surface that prints a {@link ContextEntry} goes through here (`context
6
+ * list`, `context current`, `config list`), because a secret that reaches a
7
+ * terminal also reaches scrollback, CI logs, and pasted bug reports. Keeping
8
+ * the rules next to the shape they redact is what stops the next surface from
9
+ * re-deriving them and forgetting a field.
10
+ */
11
+ import type { ContextEntry } from "./config.js";
12
+ /**
13
+ * Enough of an API key to recognise which one it is, never enough to use it.
14
+ * A key too short to keep both ends of is blanked outright rather than
15
+ * printed with its middle "hidden".
16
+ */
17
+ export declare function maskApiKey(key: string | undefined): string;
18
+ /**
19
+ * Session tokens are opaque and long; the leading characters are enough to
20
+ * tell two apart in a bug report, and are useless on their own.
21
+ */
22
+ export declare function maskSessionToken(token: string): string;
23
+ /** The credential column of `context list` / `context current`, either kind. */
24
+ export declare function credentialSummary(ctx: ContextEntry): string;
25
+ /**
26
+ * A {@link ContextEntry} safe to serialize.
27
+ *
28
+ * The shape is otherwise the stored one so scripts reading `context list
29
+ * --json` keep working: `apiKey` and `sessionToken` are masked in place,
30
+ * `cachedJwt` is dropped entirely (it is a bearer token for the API and has no
31
+ * identifying value at all), and `cachedJwtExp` stays — knowing when the cache
32
+ * turns over is useful and expiries are not secret.
33
+ */
34
+ export declare function redactContext(ctx: ContextEntry): Record<string, unknown>;
35
+ //# sourceMappingURL=redact.d.ts.map
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The one place that decides how a stored credential is allowed to appear —
3
+ * on screen, and in `--json` output.
4
+ *
5
+ * Every surface that prints a {@link ContextEntry} goes through here (`context
6
+ * list`, `context current`, `config list`), because a secret that reaches a
7
+ * terminal also reaches scrollback, CI logs, and pasted bug reports. Keeping
8
+ * the rules next to the shape they redact is what stops the next surface from
9
+ * re-deriving them and forgetting a field.
10
+ */
11
+ /**
12
+ * Enough of an API key to recognise which one it is, never enough to use it.
13
+ * A key too short to keep both ends of is blanked outright rather than
14
+ * printed with its middle "hidden".
15
+ */
16
+ export function maskApiKey(key) {
17
+ if (!key)
18
+ return "-";
19
+ if (key.length <= 11)
20
+ return "****";
21
+ return key.slice(0, 7) + "..." + key.slice(-4);
22
+ }
23
+ /**
24
+ * Session tokens are opaque and long; the leading characters are enough to
25
+ * tell two apart in a bug report, and are useless on their own.
26
+ */
27
+ export function maskSessionToken(token) {
28
+ return token.length <= 8 ? "****" : token.slice(0, 8) + "...";
29
+ }
30
+ /** The credential column of `context list` / `context current`, either kind. */
31
+ export function credentialSummary(ctx) {
32
+ if (ctx.sessionToken)
33
+ return `session ${maskSessionToken(ctx.sessionToken)}`;
34
+ return maskApiKey(ctx.apiKey);
35
+ }
36
+ /**
37
+ * A {@link ContextEntry} safe to serialize.
38
+ *
39
+ * The shape is otherwise the stored one so scripts reading `context list
40
+ * --json` keep working: `apiKey` and `sessionToken` are masked in place,
41
+ * `cachedJwt` is dropped entirely (it is a bearer token for the API and has no
42
+ * identifying value at all), and `cachedJwtExp` stays — knowing when the cache
43
+ * turns over is useful and expiries are not secret.
44
+ */
45
+ export function redactContext(ctx) {
46
+ const { apiKey, sessionToken, cachedJwt, ...rest } = ctx;
47
+ void cachedJwt; // destructured only to keep it out of `rest`.
48
+ return {
49
+ ...rest,
50
+ ...(apiKey !== undefined ? { apiKey: maskApiKey(apiKey) } : {}),
51
+ ...(sessionToken !== undefined ? { sessionToken: maskSessionToken(sessionToken) } : {}),
52
+ };
53
+ }
54
+ //# sourceMappingURL=redact.js.map