@2kw/ai 5.2.0-dev.8 → 5.3.0-dev.11
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 +31 -6
- package/dist/commands/auth.d.ts +91 -1
- package/dist/commands/auth.js +326 -53
- package/dist/commands/config.d.ts +16 -0
- package/dist/commands/config.js +52 -19
- package/dist/commands/context.d.ts +25 -0
- package/dist/commands/context.js +108 -14
- package/dist/commands/convert.js +3 -2
- package/dist/commands/docs.js +10 -6
- package/dist/commands/schemas.js +9 -2
- package/dist/commands/transcribe.js +3 -1
- package/dist/lib/auth-service.d.ts +131 -0
- package/dist/lib/auth-service.js +240 -0
- package/dist/lib/auth-session.d.ts +139 -0
- package/dist/lib/auth-session.js +275 -0
- package/dist/lib/client.d.ts +57 -0
- package/dist/lib/client.js +81 -5
- package/dist/lib/config.d.ts +64 -5
- package/dist/lib/config.js +105 -15
- package/dist/lib/errors.d.ts +2 -1
- package/dist/lib/errors.js +66 -11
- package/dist/lib/redact.d.ts +35 -0
- package/dist/lib/redact.js +54 -0
- package/package.json +2 -1
package/dist/lib/client.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/client.js
CHANGED
|
@@ -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(
|
|
114
|
+
client.use(...buildMiddleware(config));
|
|
39
115
|
return client;
|
|
40
116
|
}
|
|
41
117
|
/**
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
package/dist/lib/config.js
CHANGED
|
@@ -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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
readEnv("
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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) {
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -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}
|
|
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
|
package/dist/lib/errors.js
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 "
|
|
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
|
|
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
|
-
|
|
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
|