@2kw/ai 5.2.0-dev.6 → 5.2.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 +79 -33
- package/dist/commands/auth.d.ts +91 -1
- package/dist/commands/auth.js +344 -54
- 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/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 +18 -0
- package/dist/lib/errors.js +105 -3
- package/dist/lib/redact.d.ts +35 -0
- package/dist/lib/redact.js +54 -0
- package/dist/lib/update-notifier.js +1 -1
- package/package.json +21 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stateful half of browser login: the RFC 8628 polling loop that waits for
|
|
3
|
+
* the user to approve a device code, and the cache that keeps the short-lived
|
|
4
|
+
* organization JWT out of the auth service between rapid commands.
|
|
5
|
+
*
|
|
6
|
+
* auth-service.ts owns single requests; this module owns what happens across
|
|
7
|
+
* several of them, so it is where the retry policy lives. It keeps the three
|
|
8
|
+
* exits that module documents apart: an {@link AuthServiceError} means the
|
|
9
|
+
* service answered and said no (replaying it would only repeat the answer),
|
|
10
|
+
* while a raw fetch `TypeError` means the request never landed (worth retrying).
|
|
11
|
+
*/
|
|
12
|
+
import { fetchJwt, type DeviceTokenResult, type OrgSummary } from "./auth-service.js";
|
|
13
|
+
import { type ResolvedConfig } from "./config.js";
|
|
14
|
+
/**
|
|
15
|
+
* The user's own answer to a device login: they refused it, or they left it
|
|
16
|
+
* long enough to lapse.
|
|
17
|
+
*
|
|
18
|
+
* Typed rather than a bare Error because the login command has to tell these
|
|
19
|
+
* two outcomes apart from everything else the polling loop can raise (a refused
|
|
20
|
+
* client id, a dead network). Those are faults and belong in the shared error
|
|
21
|
+
* rendering; these are answers, and get a plain line and a non-zero exit.
|
|
22
|
+
*/
|
|
23
|
+
export declare class DeviceApprovalError extends Error {
|
|
24
|
+
readonly reason: "denied" | "expired";
|
|
25
|
+
constructor(message: string, reason: "denied" | "expired");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The stored session is gone — revoked, or expired server-side. Distinct from
|
|
29
|
+
* AuthServiceError so callers can offer the one fix that works (sign in again)
|
|
30
|
+
* instead of reporting a bare 401.
|
|
31
|
+
*/
|
|
32
|
+
export declare class SessionExpiredError extends Error {
|
|
33
|
+
constructor();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read a JWT's `exp` claim as epoch millis.
|
|
37
|
+
*
|
|
38
|
+
* Only the payload is decoded and nothing is verified — the CLI is not the
|
|
39
|
+
* audience for this token, it merely needs to know when to stop reusing it.
|
|
40
|
+
* Anything that is not a three-segment JWT with a numeric `exp` throws, because
|
|
41
|
+
* a silently-assumed expiry would either hand a dead token to the API or
|
|
42
|
+
* re-exchange on every single command.
|
|
43
|
+
*/
|
|
44
|
+
export declare function decodeJwtExpMs(jwt: string): number;
|
|
45
|
+
/**
|
|
46
|
+
* Whether a cached JWT has enough life left to be worth reusing — an unknown
|
|
47
|
+
* expiry counts as stale, so a cache entry written by an older CLI is simply
|
|
48
|
+
* re-exchanged rather than trusted.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isJwtFresh(expMs: number | undefined, nowMs: number): boolean;
|
|
51
|
+
/** Inputs to {@link runDevicePolling}; `poll` and `sleep` are injectable for tests. */
|
|
52
|
+
export interface DevicePollingOptions {
|
|
53
|
+
/** One device-token poll — normally a bound `pollDeviceToken`. */
|
|
54
|
+
poll: () => Promise<DeviceTokenResult>;
|
|
55
|
+
/** Seconds between polls, per the device-code response. Defaults to 5. */
|
|
56
|
+
intervalSec?: number;
|
|
57
|
+
/** Total seconds the device code stays valid. Defaults to 1800. */
|
|
58
|
+
expiresInSec?: number;
|
|
59
|
+
/** Delay implementation; defaults to a real timer. */
|
|
60
|
+
sleep?: (ms: number) => Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Poll until the user approves the login in their browser, and return the
|
|
64
|
+
* session token.
|
|
65
|
+
*
|
|
66
|
+
* Failure handling follows the auth-service taxonomy. A transport error is
|
|
67
|
+
* retried (up to {@link MAX_CONSECUTIVE_POLL_ERRORS} in a row, the count reset
|
|
68
|
+
* by any poll that completes) because a laptop that drops its Wi-Fi mid-login
|
|
69
|
+
* should not have to start over. An {@link AuthServiceError} is not retried —
|
|
70
|
+
* the service answered, and asking again gets the same answer.
|
|
71
|
+
*
|
|
72
|
+
* The deadline is spent from the poll budget rather than read off the wall
|
|
73
|
+
* clock, so an injected sleep makes the whole loop deterministic.
|
|
74
|
+
*
|
|
75
|
+
* @throws {DeviceApprovalError} when the user denies the login, or the code
|
|
76
|
+
* expires — either because the service said so or because the budget ran out.
|
|
77
|
+
* @throws {AuthServiceError} when the service refused the exchange outright.
|
|
78
|
+
* @throws {TypeError} the last transport error, once retrying is pointless.
|
|
79
|
+
*/
|
|
80
|
+
export declare function runDevicePolling(opts: DevicePollingOptions): Promise<string>;
|
|
81
|
+
/** Asks one question and resolves with what the user typed. */
|
|
82
|
+
export type Asker = (question: string) => Promise<string>;
|
|
83
|
+
/** Injection points for {@link selectOrganization}; both default to the real thing. */
|
|
84
|
+
export interface SelectOrgDeps {
|
|
85
|
+
/** Question function — tests (and any non-readline caller) supply their own;
|
|
86
|
+
* the default reads a line from stdin. */
|
|
87
|
+
ask?: Asker;
|
|
88
|
+
/** Whether stdin can be prompted; defaults to `process.stdin.isTTY`. */
|
|
89
|
+
isTTY?: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Decide which organization the new session should act as.
|
|
93
|
+
*
|
|
94
|
+
* A single membership needs no ceremony, so it is chosen silently; several
|
|
95
|
+
* memberships get a numbered prompt that repeats until the answer names one of
|
|
96
|
+
* them. Lives here rather than in the login command because org switching
|
|
97
|
+
* reuses the identical choice.
|
|
98
|
+
*
|
|
99
|
+
* Two guards keep the prompt from becoming a hang. Without a terminal there is
|
|
100
|
+
* nobody to answer, and readline on a closed or piped stdin resolves nothing
|
|
101
|
+
* and never ends — so that case is refused up front, with the fix in the
|
|
102
|
+
* message. The attempt cap covers what the first guard cannot see: an input
|
|
103
|
+
* that does answer, always invalidly (a pipe that yields "" forever).
|
|
104
|
+
*
|
|
105
|
+
* @throws {Error} when the account has no organizations at all, when there is
|
|
106
|
+
* no terminal to prompt, or when the attempts run out.
|
|
107
|
+
*/
|
|
108
|
+
export declare function selectOrganization(orgs: OrgSummary[], deps?: SelectOrgDeps): Promise<OrgSummary>;
|
|
109
|
+
/** Injection points for {@link ensureJwt}; all default to the real thing. */
|
|
110
|
+
export interface EnsureJwtDeps {
|
|
111
|
+
/** Session-to-JWT exchange; defaults to {@link fetchJwt}. */
|
|
112
|
+
fetchJwtFn?: typeof fetchJwt;
|
|
113
|
+
/** Cache writer; defaults to {@link updateContext}. */
|
|
114
|
+
persist?: (contextName: string, patch: {
|
|
115
|
+
cachedJwt: string;
|
|
116
|
+
cachedJwtExp: number;
|
|
117
|
+
}) => void;
|
|
118
|
+
/** Clock; defaults to `Date.now`. */
|
|
119
|
+
now?: () => number;
|
|
120
|
+
/** Skip the cache and always exchange — e.g. right after switching org. */
|
|
121
|
+
force?: boolean;
|
|
122
|
+
}
|
|
123
|
+
type SessionConfig = Extract<ResolvedConfig, {
|
|
124
|
+
kind: "session";
|
|
125
|
+
}>;
|
|
126
|
+
/**
|
|
127
|
+
* Return an organization JWT for a session context, exchanging the session for
|
|
128
|
+
* a new one whenever the cached token is missing, stale, or explicitly bypassed.
|
|
129
|
+
*
|
|
130
|
+
* A fresh token is written both to the config store and back onto `cfg`: a
|
|
131
|
+
* single command may reach here more than once (a retry after a 401, say), and
|
|
132
|
+
* the in-memory copy is what those later reads see. Caching is best-effort — a
|
|
133
|
+
* token that cannot be cached is still a token that works.
|
|
134
|
+
*
|
|
135
|
+
* @throws {SessionExpiredError} when the stored session is no longer valid.
|
|
136
|
+
*/
|
|
137
|
+
export declare function ensureJwt(cfg: SessionConfig, deps?: EnsureJwtDeps): Promise<string>;
|
|
138
|
+
export {};
|
|
139
|
+
//# sourceMappingURL=auth-session.d.ts.map
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stateful half of browser login: the RFC 8628 polling loop that waits for
|
|
3
|
+
* the user to approve a device code, and the cache that keeps the short-lived
|
|
4
|
+
* organization JWT out of the auth service between rapid commands.
|
|
5
|
+
*
|
|
6
|
+
* auth-service.ts owns single requests; this module owns what happens across
|
|
7
|
+
* several of them, so it is where the retry policy lives. It keeps the three
|
|
8
|
+
* exits that module documents apart: an {@link AuthServiceError} means the
|
|
9
|
+
* service answered and said no (replaying it would only repeat the answer),
|
|
10
|
+
* while a raw fetch `TypeError` means the request never landed (worth retrying).
|
|
11
|
+
*/
|
|
12
|
+
import { Buffer } from "node:buffer";
|
|
13
|
+
import { createInterface } from "node:readline/promises";
|
|
14
|
+
import chalk from "chalk";
|
|
15
|
+
import { AuthServiceError, fetchJwt, } from "./auth-service.js";
|
|
16
|
+
import { updateContext } from "./config.js";
|
|
17
|
+
/**
|
|
18
|
+
* How much life a cached JWT must have left to be reused. Also absorbs modest
|
|
19
|
+
* client-clock skew: a token minted against a server clock a few seconds ahead
|
|
20
|
+
* of ours must not be handed to the API a moment before it becomes valid.
|
|
21
|
+
*/
|
|
22
|
+
const JWT_FRESHNESS_MARGIN_MS = 30_000;
|
|
23
|
+
/** Consecutive-transport-failure count at which the loop gives up: two are
|
|
24
|
+
* ridden out, the third is rethrown. */
|
|
25
|
+
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
|
|
26
|
+
/** RFC 8628 §3.2 recommends 5 s when the service states no interval. */
|
|
27
|
+
const DEFAULT_POLL_INTERVAL_SEC = 5;
|
|
28
|
+
/** Ceiling on the whole login wait when the service states no expiry. */
|
|
29
|
+
const DEFAULT_POLL_EXPIRES_SEC = 1800;
|
|
30
|
+
/** RFC 8628 §3.5: each slow_down widens the polling interval by 5 s. */
|
|
31
|
+
const SLOW_DOWN_BACKOFF_MS = 5_000;
|
|
32
|
+
const DENIED_MESSAGE = "Login was denied in the browser.";
|
|
33
|
+
const EXPIRED_MESSAGE = 'Login attempt expired — run "2kw auth login" again.';
|
|
34
|
+
/** Nothing to select from, and nothing the CLI can do about it — orgs are
|
|
35
|
+
* created in the web app. */
|
|
36
|
+
const NO_ORG_MESSAGE = "Your account belongs to no organization yet — create one in the web app first.";
|
|
37
|
+
/**
|
|
38
|
+
* The user's own answer to a device login: they refused it, or they left it
|
|
39
|
+
* long enough to lapse.
|
|
40
|
+
*
|
|
41
|
+
* Typed rather than a bare Error because the login command has to tell these
|
|
42
|
+
* two outcomes apart from everything else the polling loop can raise (a refused
|
|
43
|
+
* client id, a dead network). Those are faults and belong in the shared error
|
|
44
|
+
* rendering; these are answers, and get a plain line and a non-zero exit.
|
|
45
|
+
*/
|
|
46
|
+
export class DeviceApprovalError extends Error {
|
|
47
|
+
reason;
|
|
48
|
+
constructor(message, reason) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.reason = reason;
|
|
51
|
+
this.name = "DeviceApprovalError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The stored session is gone — revoked, or expired server-side. Distinct from
|
|
56
|
+
* AuthServiceError so callers can offer the one fix that works (sign in again)
|
|
57
|
+
* instead of reporting a bare 401.
|
|
58
|
+
*/
|
|
59
|
+
export class SessionExpiredError extends Error {
|
|
60
|
+
constructor() {
|
|
61
|
+
super('Session expired or revoked — run "2kw auth login" to sign in again.');
|
|
62
|
+
this.name = "SessionExpiredError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Wait `ms`, used when the caller injects no sleep of its own. */
|
|
66
|
+
function defaultSleep(ms) {
|
|
67
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Read a JWT's `exp` claim as epoch millis.
|
|
71
|
+
*
|
|
72
|
+
* Only the payload is decoded and nothing is verified — the CLI is not the
|
|
73
|
+
* audience for this token, it merely needs to know when to stop reusing it.
|
|
74
|
+
* Anything that is not a three-segment JWT with a numeric `exp` throws, because
|
|
75
|
+
* a silently-assumed expiry would either hand a dead token to the API or
|
|
76
|
+
* re-exchange on every single command.
|
|
77
|
+
*/
|
|
78
|
+
export function decodeJwtExpMs(jwt) {
|
|
79
|
+
const parts = jwt.split(".");
|
|
80
|
+
if (parts.length !== 3) {
|
|
81
|
+
throw new Error("Malformed token: expected three dot-separated JWT segments.");
|
|
82
|
+
}
|
|
83
|
+
let payload;
|
|
84
|
+
try {
|
|
85
|
+
payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error("Malformed token: the JWT payload is not valid JSON.");
|
|
89
|
+
}
|
|
90
|
+
const exp = payload !== null && typeof payload === "object"
|
|
91
|
+
? payload.exp
|
|
92
|
+
: undefined;
|
|
93
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) {
|
|
94
|
+
throw new Error("Malformed token: the JWT payload carries no numeric exp claim.");
|
|
95
|
+
}
|
|
96
|
+
return exp * 1000;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether a cached JWT has enough life left to be worth reusing — an unknown
|
|
100
|
+
* expiry counts as stale, so a cache entry written by an older CLI is simply
|
|
101
|
+
* re-exchanged rather than trusted.
|
|
102
|
+
*/
|
|
103
|
+
export function isJwtFresh(expMs, nowMs) {
|
|
104
|
+
return expMs !== undefined && expMs - nowMs > JWT_FRESHNESS_MARGIN_MS;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Poll until the user approves the login in their browser, and return the
|
|
108
|
+
* session token.
|
|
109
|
+
*
|
|
110
|
+
* Failure handling follows the auth-service taxonomy. A transport error is
|
|
111
|
+
* retried (up to {@link MAX_CONSECUTIVE_POLL_ERRORS} in a row, the count reset
|
|
112
|
+
* by any poll that completes) because a laptop that drops its Wi-Fi mid-login
|
|
113
|
+
* should not have to start over. An {@link AuthServiceError} is not retried —
|
|
114
|
+
* the service answered, and asking again gets the same answer.
|
|
115
|
+
*
|
|
116
|
+
* The deadline is spent from the poll budget rather than read off the wall
|
|
117
|
+
* clock, so an injected sleep makes the whole loop deterministic.
|
|
118
|
+
*
|
|
119
|
+
* @throws {DeviceApprovalError} when the user denies the login, or the code
|
|
120
|
+
* expires — either because the service said so or because the budget ran out.
|
|
121
|
+
* @throws {AuthServiceError} when the service refused the exchange outright.
|
|
122
|
+
* @throws {TypeError} the last transport error, once retrying is pointless.
|
|
123
|
+
*/
|
|
124
|
+
export async function runDevicePolling(opts) {
|
|
125
|
+
const { poll, sleep = defaultSleep } = opts;
|
|
126
|
+
// A zero or negative interval would spin the loop without ever spending the
|
|
127
|
+
// budget, so treat anything non-positive as "not stated".
|
|
128
|
+
const intervalSec = opts.intervalSec !== undefined && opts.intervalSec > 0
|
|
129
|
+
? opts.intervalSec
|
|
130
|
+
: DEFAULT_POLL_INTERVAL_SEC;
|
|
131
|
+
let intervalMs = intervalSec * 1000;
|
|
132
|
+
let remainingMs = (opts.expiresInSec ?? DEFAULT_POLL_EXPIRES_SEC) * 1000;
|
|
133
|
+
let consecutiveErrors = 0;
|
|
134
|
+
for (;;) {
|
|
135
|
+
let result;
|
|
136
|
+
try {
|
|
137
|
+
result = await poll();
|
|
138
|
+
consecutiveErrors = 0;
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
// Exit #2: the service answered. Retrying just replays the refusal.
|
|
142
|
+
if (err instanceof AuthServiceError)
|
|
143
|
+
throw err;
|
|
144
|
+
// Exit #3: the request never completed. Retry-worthy, within reason.
|
|
145
|
+
consecutiveErrors += 1;
|
|
146
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_POLL_ERRORS)
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
switch (result?.status) {
|
|
150
|
+
case "ok":
|
|
151
|
+
return result.accessToken;
|
|
152
|
+
case "denied":
|
|
153
|
+
throw new DeviceApprovalError(DENIED_MESSAGE, "denied");
|
|
154
|
+
case "expired":
|
|
155
|
+
throw new DeviceApprovalError(EXPIRED_MESSAGE, "expired");
|
|
156
|
+
case "slow_down":
|
|
157
|
+
intervalMs += SLOW_DOWN_BACKOFF_MS;
|
|
158
|
+
break;
|
|
159
|
+
default:
|
|
160
|
+
// "pending", or a tolerated transport error: wait and ask again.
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
remainingMs -= intervalMs;
|
|
164
|
+
if (remainingMs <= 0)
|
|
165
|
+
throw new DeviceApprovalError(EXPIRED_MESSAGE, "expired");
|
|
166
|
+
await sleep(intervalMs);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Attempts allowed at the prompt before the loop is called a lost cause. */
|
|
170
|
+
const MAX_ORG_PROMPT_ATTEMPTS = 5;
|
|
171
|
+
const NO_TTY_MESSAGE = "Cannot prompt for an organization: no interactive terminal. " +
|
|
172
|
+
"Pass an org explicitly or run in a terminal.";
|
|
173
|
+
const TOO_MANY_ATTEMPTS_MESSAGE = `No organization chosen after ${MAX_ORG_PROMPT_ATTEMPTS} attempts.`;
|
|
174
|
+
/**
|
|
175
|
+
* Decide which organization the new session should act as.
|
|
176
|
+
*
|
|
177
|
+
* A single membership needs no ceremony, so it is chosen silently; several
|
|
178
|
+
* memberships get a numbered prompt that repeats until the answer names one of
|
|
179
|
+
* them. Lives here rather than in the login command because org switching
|
|
180
|
+
* reuses the identical choice.
|
|
181
|
+
*
|
|
182
|
+
* Two guards keep the prompt from becoming a hang. Without a terminal there is
|
|
183
|
+
* nobody to answer, and readline on a closed or piped stdin resolves nothing
|
|
184
|
+
* and never ends — so that case is refused up front, with the fix in the
|
|
185
|
+
* message. The attempt cap covers what the first guard cannot see: an input
|
|
186
|
+
* that does answer, always invalidly (a pipe that yields "" forever).
|
|
187
|
+
*
|
|
188
|
+
* @throws {Error} when the account has no organizations at all, when there is
|
|
189
|
+
* no terminal to prompt, or when the attempts run out.
|
|
190
|
+
*/
|
|
191
|
+
export async function selectOrganization(orgs, deps = {}) {
|
|
192
|
+
const { ask, isTTY = Boolean(process.stdin.isTTY) } = deps;
|
|
193
|
+
if (orgs.length === 0)
|
|
194
|
+
throw new Error(NO_ORG_MESSAGE);
|
|
195
|
+
if (orgs.length === 1)
|
|
196
|
+
return orgs[0];
|
|
197
|
+
// Checked before anything is printed: an injected asker owns its own input,
|
|
198
|
+
// but the default one is about to open readline on a stdin nobody will type
|
|
199
|
+
// into.
|
|
200
|
+
if (!ask && !isTTY)
|
|
201
|
+
throw new Error(NO_TTY_MESSAGE);
|
|
202
|
+
console.log("");
|
|
203
|
+
console.log("Select an organization:");
|
|
204
|
+
orgs.forEach((org, i) => {
|
|
205
|
+
console.log(` ${i + 1}) ${org.name}`);
|
|
206
|
+
});
|
|
207
|
+
// Only open a real readline when we have to: an injected asker means the
|
|
208
|
+
// caller owns the input, and an unused interface would hold stdin open.
|
|
209
|
+
const rl = ask ? undefined : createInterface({ input: process.stdin, output: process.stdout });
|
|
210
|
+
const askFn = ask ?? ((question) => rl.question(question));
|
|
211
|
+
try {
|
|
212
|
+
for (let attempt = 0; attempt < MAX_ORG_PROMPT_ATTEMPTS; attempt++) {
|
|
213
|
+
const answer = (await askFn(`Organization [1-${orgs.length}]: `)).trim();
|
|
214
|
+
// Digits only — parseInt would happily read "2 or so" as 2.
|
|
215
|
+
if (/^\d+$/.test(answer)) {
|
|
216
|
+
const choice = Number(answer);
|
|
217
|
+
if (choice >= 1 && choice <= orgs.length)
|
|
218
|
+
return orgs[choice - 1];
|
|
219
|
+
}
|
|
220
|
+
console.log(chalk.yellow(`Enter a number between 1 and ${orgs.length}.`));
|
|
221
|
+
}
|
|
222
|
+
throw new Error(TOO_MANY_ATTEMPTS_MESSAGE);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
rl?.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Return an organization JWT for a session context, exchanging the session for
|
|
230
|
+
* a new one whenever the cached token is missing, stale, or explicitly bypassed.
|
|
231
|
+
*
|
|
232
|
+
* A fresh token is written both to the config store and back onto `cfg`: a
|
|
233
|
+
* single command may reach here more than once (a retry after a 401, say), and
|
|
234
|
+
* the in-memory copy is what those later reads see. Caching is best-effort — a
|
|
235
|
+
* token that cannot be cached is still a token that works.
|
|
236
|
+
*
|
|
237
|
+
* @throws {SessionExpiredError} when the stored session is no longer valid.
|
|
238
|
+
*/
|
|
239
|
+
export async function ensureJwt(cfg, deps = {}) {
|
|
240
|
+
const { fetchJwtFn = fetchJwt, persist = updateContext, now = Date.now, force = false } = deps;
|
|
241
|
+
if (!force && cfg.cachedJwt && isJwtFresh(cfg.cachedJwtExp, now())) {
|
|
242
|
+
return cfg.cachedJwt;
|
|
243
|
+
}
|
|
244
|
+
let jwt;
|
|
245
|
+
try {
|
|
246
|
+
jwt = await fetchJwtFn(cfg.authUrl, cfg.sessionToken);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
// Only a 401 means "this session is done". Every other failure — a 502, a
|
|
250
|
+
// dead network — says nothing about the session, so it must not tell the
|
|
251
|
+
// user to sign in again.
|
|
252
|
+
if (err instanceof AuthServiceError && err.status === 401) {
|
|
253
|
+
// The stale cache is dropped where this lands rather than here:
|
|
254
|
+
// handleError in errors.ts clears cachedJwt/cachedJwtExp for the active
|
|
255
|
+
// context, so every command path gets it, including the raw-fetch ones.
|
|
256
|
+
throw new SessionExpiredError();
|
|
257
|
+
}
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const expMs = decodeJwtExpMs(jwt);
|
|
262
|
+
cfg.cachedJwtExp = expMs;
|
|
263
|
+
cfg.cachedJwt = jwt;
|
|
264
|
+
persist(cfg.contextName, { cachedJwt: jwt, cachedJwtExp: expMs });
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// An unparseable token or a read-only config store must not fail a command
|
|
268
|
+
// that holds a working credential — skip the cache and use it this once.
|
|
269
|
+
// Cleared as a pair so a half-written entry can never be read back as fresh.
|
|
270
|
+
cfg.cachedJwt = undefined;
|
|
271
|
+
cfg.cachedJwtExp = undefined;
|
|
272
|
+
}
|
|
273
|
+
return jwt;
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=auth-session.js.map
|
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
|
/**
|