callkaro-cli-unofficial 0.5.1 → 0.6.1
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 +14 -0
- package/dist/commands/accounts.js +16 -2
- package/dist/commands/admin.js +519 -0
- package/dist/commands/dashboardApiCmd.js +5 -1
- package/dist/index.js +146 -0
- package/dist/lib/adminApi.js +323 -0
- package/dist/lib/adminAuth.js +395 -0
- package/dist/lib/config.js +87 -8
- package/dist/lib/dashboardApi.js +5 -1
- package/dist/lib/dashboardAuth.js +18 -1
- package/dist/lib/looseJson.js +43 -0
- package/package.json +1 -1
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.expiryLabel = void 0;
|
|
7
|
+
exports.cookieJar = cookieJar;
|
|
8
|
+
exports.cookieHeader = cookieHeader;
|
|
9
|
+
exports.adminLogin = adminLogin;
|
|
10
|
+
exports.saveAdmin = saveAdmin;
|
|
11
|
+
exports.adminSession = adminSession;
|
|
12
|
+
exports.adminSessions = adminSessions;
|
|
13
|
+
exports.hasAdmin = hasAdmin;
|
|
14
|
+
exports.forgetAdmin = forgetAdmin;
|
|
15
|
+
exports.requireAdmin = requireAdmin;
|
|
16
|
+
exports.requireAdminFor = requireAdminFor;
|
|
17
|
+
exports.impersonateClient = impersonateClient;
|
|
18
|
+
exports.reImpersonate = reImpersonate;
|
|
19
|
+
exports.adminLoginCommand = adminLoginCommand;
|
|
20
|
+
exports.adminWhoamiCommand = adminWhoamiCommand;
|
|
21
|
+
exports.adminLogoutCommand = adminLogoutCommand;
|
|
22
|
+
const prompts_1 = __importDefault(require("prompts"));
|
|
23
|
+
const ui_1 = require("./ui");
|
|
24
|
+
const config_1 = require("./config");
|
|
25
|
+
const dashboardAuth_1 = require("./dashboardAuth");
|
|
26
|
+
Object.defineProperty(exports, "expiryLabel", { enumerable: true, get: function () { return dashboardAuth_1.expiryLabel; } });
|
|
27
|
+
/**
|
|
28
|
+
* Sign-in to the internal admin console — `https://intadmin.callkaro.ai`.
|
|
29
|
+
*
|
|
30
|
+
* ## The credential
|
|
31
|
+
*
|
|
32
|
+
* `POST /monitor/auth/login {email, password}` returns a cookie named
|
|
33
|
+
* `access_token_monitor`: a JWT with a 24h lifetime and **no refresh endpoint**.
|
|
34
|
+
* It reaches the `/monitor/*` routes and nothing else — the `ck_` token and the
|
|
35
|
+
* dashboard JWT are both rejected there, and this one is rejected by theirs.
|
|
36
|
+
*
|
|
37
|
+
* So the session is stored like the dashboard session (0600, password optional)
|
|
38
|
+
* and renews by signing in again, silently, when a password was kept.
|
|
39
|
+
*
|
|
40
|
+
* ## The cookie jar
|
|
41
|
+
*
|
|
42
|
+
* The login response sets `access_token_monitor` **four times**: three headers
|
|
43
|
+
* that clear it (empty value, epoch expiry) and then the real one. Taking the
|
|
44
|
+
* first, or joining the headers naively, produces a cookie header the server
|
|
45
|
+
* rejects. So the jar keeps the last non-empty value per name.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Collect Set-Cookie headers into name→value, keeping the last non-empty value
|
|
49
|
+
* for each name.
|
|
50
|
+
*
|
|
51
|
+
* A response can repeat a cookie to clear it (empty value, past expiry) and
|
|
52
|
+
* then set it for real. Taking the last non-empty value handles both orders.
|
|
53
|
+
*/
|
|
54
|
+
function cookieJar(headers) {
|
|
55
|
+
const jar = new Map();
|
|
56
|
+
const list = typeof headers.getSetCookie === 'function'
|
|
57
|
+
? headers.getSetCookie()
|
|
58
|
+
: [headers.get('set-cookie') || ''];
|
|
59
|
+
for (const raw of list) {
|
|
60
|
+
const [pair] = raw.split(';');
|
|
61
|
+
const i = pair.indexOf('=');
|
|
62
|
+
if (i < 0)
|
|
63
|
+
continue;
|
|
64
|
+
const name = pair.slice(0, i).trim();
|
|
65
|
+
const value = pair.slice(i + 1).trim();
|
|
66
|
+
if (value)
|
|
67
|
+
jar.set(name, value);
|
|
68
|
+
}
|
|
69
|
+
return jar;
|
|
70
|
+
}
|
|
71
|
+
/** `name=value; name=value` for a Cookie header. */
|
|
72
|
+
function cookieHeader(jar) {
|
|
73
|
+
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Sign in to the admin console with email + password.
|
|
77
|
+
*
|
|
78
|
+
* Returns the session without the password; the caller decides whether to keep
|
|
79
|
+
* it. The admin's own id comes from the same response, because
|
|
80
|
+
* `GET /monitor/users/:id` — the route that lists the client accounts — is
|
|
81
|
+
* keyed by it.
|
|
82
|
+
*/
|
|
83
|
+
async function adminLogin(email, password) {
|
|
84
|
+
const res = await fetch(`${(0, config_1.backendUrl)()}/monitor/auth/login`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({ email, password }),
|
|
88
|
+
});
|
|
89
|
+
const text = await res.text();
|
|
90
|
+
let data;
|
|
91
|
+
try {
|
|
92
|
+
data = text ? JSON.parse(text) : {};
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error(`Admin login returned a non-JSON response (HTTP ${res.status}).`);
|
|
96
|
+
}
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
throw new Error(data.msg || `Admin login failed (HTTP ${res.status})`);
|
|
99
|
+
}
|
|
100
|
+
const jar = cookieJar(res.headers);
|
|
101
|
+
const token = jar.get('access_token_monitor');
|
|
102
|
+
if (!token) {
|
|
103
|
+
throw new Error('Admin login succeeded but no access_token_monitor cookie came back, so the session is incomplete.\n' +
|
|
104
|
+
' Without it, every `cku admin` command will 401.\n' +
|
|
105
|
+
' Usually a proxy or browser extension stripping Set-Cookie.');
|
|
106
|
+
}
|
|
107
|
+
const payload = (0, dashboardAuth_1.decodeJwt)(token) || {};
|
|
108
|
+
const user = data.user || payload.user || {};
|
|
109
|
+
return {
|
|
110
|
+
email: user.email || data.user?.email || email,
|
|
111
|
+
token,
|
|
112
|
+
userId: user._id || data.user?._id || payload.id || '',
|
|
113
|
+
role: user.role || data.user?.role || payload.role,
|
|
114
|
+
expiresAt: typeof payload.exp === 'number' ? new Date(payload.exp * 1000).toISOString() : undefined,
|
|
115
|
+
savedAt: new Date().toISOString(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// --- account binding -------------------------------------------------------
|
|
119
|
+
/** Store on the account, keeping whatever else it holds. */
|
|
120
|
+
function saveAdmin(email, session) {
|
|
121
|
+
return (0, config_1.saveAdminSession)(email, session);
|
|
122
|
+
}
|
|
123
|
+
function adminSession(email) {
|
|
124
|
+
if (!email)
|
|
125
|
+
return undefined;
|
|
126
|
+
return (0, config_1.loadAccount)((0, config_1.slugFor)(email)).admin;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Every stored account that holds an admin console session, newest first.
|
|
130
|
+
*
|
|
131
|
+
* The admin console session is a *person's* credential, not an account's: one
|
|
132
|
+
* admin, one session, whichever client account happens to be active. So the
|
|
133
|
+
* gate looks past the active account when the active account has none.
|
|
134
|
+
*/
|
|
135
|
+
function adminSessions() {
|
|
136
|
+
return (0, config_1.listAccounts)()
|
|
137
|
+
.filter((a) => a.admin?.token)
|
|
138
|
+
.map((a) => ({ email: a.email || '', session: a.admin }));
|
|
139
|
+
}
|
|
140
|
+
/** Does this account hold an admin console session locally? A hint, not a guarantee. */
|
|
141
|
+
function hasAdmin(email) {
|
|
142
|
+
return Boolean(email && (0, config_1.loadAccount)((0, config_1.slugFor)(email)).admin?.token);
|
|
143
|
+
}
|
|
144
|
+
function forgetAdmin(email) {
|
|
145
|
+
return (0, config_1.clearAdminSession)(email);
|
|
146
|
+
}
|
|
147
|
+
// --- the auth gate ---------------------------------------------------------
|
|
148
|
+
/**
|
|
149
|
+
* The restricted-endpoint gate for the `/monitor/*` surface, the counterpart to
|
|
150
|
+
* `session.ts`'s `requireApi()` and `dashboardAuth.ts`'s `requireDashboard()`.
|
|
151
|
+
*
|
|
152
|
+
* Deliberately separate: no `/cli/*` command may depend on this credential, and
|
|
153
|
+
* no admin command on the `ck_` token.
|
|
154
|
+
*
|
|
155
|
+
* The console session has no refresh endpoint, so a lapsed one is re-issued by
|
|
156
|
+
* signing in again. With a stored password that happens here, silently;
|
|
157
|
+
* without one this fails with the concrete next command.
|
|
158
|
+
*/
|
|
159
|
+
async function requireAdmin() {
|
|
160
|
+
const slug = (0, config_1.activeAccount)();
|
|
161
|
+
const email = slug ? (0, config_1.loadAccount)(slug).email : undefined;
|
|
162
|
+
// The console session belongs to a *person*, not to a client account: one
|
|
163
|
+
// admin holds one session, filed on whichever account they signed in with.
|
|
164
|
+
// So the active account's session is used when it has one, and otherwise the
|
|
165
|
+
// admin's session is found wherever it was filed — the admin signs in with
|
|
166
|
+
// their own login, but acts on client accounts they are assigned.
|
|
167
|
+
if (email && adminSession(email)?.token)
|
|
168
|
+
return requireAdminFor(email);
|
|
169
|
+
const others = adminSessions();
|
|
170
|
+
if (others.length)
|
|
171
|
+
return requireAdminFor(others[0].email);
|
|
172
|
+
throw new Error(`No admin console session for ${email || 'the account in effect'}.\n` +
|
|
173
|
+
' The /monitor/* routes need an admin console sign-in, not a ck_ token.\n' +
|
|
174
|
+
' Run: cku admin login');
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The same gate, for a named account rather than the active one.
|
|
178
|
+
*
|
|
179
|
+
* Needed because an impersonated client account renews by impersonating again,
|
|
180
|
+
* and the admin session that allows that lives on the **admin's** account — not
|
|
181
|
+
* on the client account that happens to be active while the renewal runs.
|
|
182
|
+
*/
|
|
183
|
+
async function requireAdminFor(email) {
|
|
184
|
+
const session = adminSession(email);
|
|
185
|
+
if (!session?.token) {
|
|
186
|
+
throw new Error(`No admin console session for ${email || 'the account in effect'}.\n` +
|
|
187
|
+
' The /monitor/* routes need an admin console sign-in, not a ck_ token.\n' +
|
|
188
|
+
' Run: cku admin login');
|
|
189
|
+
}
|
|
190
|
+
// Expiry is read off the cookie token. A session stored before the role
|
|
191
|
+
// fields existed still works; only the token matters here.
|
|
192
|
+
const left = (0, dashboardAuth_1.msUntilExpiry)(session.token);
|
|
193
|
+
if (left !== undefined && left <= (session.password ? 60_000 : 0)) {
|
|
194
|
+
if (!session.password) {
|
|
195
|
+
const why = `Admin console session for ${email} expired ${new Date(Date.now() + left).toLocaleString('en-US')}.\n` +
|
|
196
|
+
' There is no refresh endpoint — the token lives 24h and must be re-issued.';
|
|
197
|
+
throw new Error(`${why}\n Run: cku admin login`);
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const renewed = await adminLogin(session.email || email, session.password);
|
|
201
|
+
saveAdmin(email, { ...renewed, password: session.password });
|
|
202
|
+
return { token: renewed.token, email: renewed.email, role: renewed.role, userId: renewed.userId };
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
throw new Error(`Admin console session for ${email} expired and re-login with the stored password failed: ${err.message}\n` +
|
|
206
|
+
' If the password changed, run: cku admin login');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { token: session.token, email: session.email, role: session.role, userId: session.userId };
|
|
210
|
+
}
|
|
211
|
+
// --- impersonation ---------------------------------------------------------
|
|
212
|
+
/**
|
|
213
|
+
* Mint a real `ck_` token for a client account, through the admin console.
|
|
214
|
+
*
|
|
215
|
+
* ## Why this is not one call
|
|
216
|
+
*
|
|
217
|
+
* The console's "Login as this user" button returns a **dashboard JWT** with an
|
|
218
|
+
* `actAs` claim — not a `ck_` token. Probed: that JWT gets 401 on
|
|
219
|
+
* `/cli/auth/me`, and so does the admin cookie. The missing link is
|
|
220
|
+
* `POST /cli/auth/browser/approve`, which is what the web app's own login page
|
|
221
|
+
* calls after the browser signs in. The console's browser flow therefore
|
|
222
|
+
* produces the same thing a normal sign-in does: a `ck_` token.
|
|
223
|
+
*
|
|
224
|
+
* The chain, all four hops confirmed live:
|
|
225
|
+
*
|
|
226
|
+
* 1. GET /monitor/clients/:id/impersonate -> { "auth-token": <JWT> }
|
|
227
|
+
* 2. POST /auth/apply-admin-session {token} -> cookie `access_token`
|
|
228
|
+
* 3. POST /cli/auth/browser/start -> { code, pollSecret }
|
|
229
|
+
* POST /cli/auth/browser/approve {code} -> 200
|
|
230
|
+
* 4. GET /cli/auth/browser/poll?code&secret -> { status:'approved', token:'ck_…' }
|
|
231
|
+
*
|
|
232
|
+
* The resulting token carries the *admin's* identity, so on its own it reaches
|
|
233
|
+
* no agents; `X-CallKaro-Account-Id` scopes it to the client. That is exactly
|
|
234
|
+
* the `cku monitor` mechanism, so the caller stores it with `monitoring` set.
|
|
235
|
+
*/
|
|
236
|
+
async function impersonateClient(clientId, opts = {}) {
|
|
237
|
+
const base = (0, config_1.backendUrl)();
|
|
238
|
+
// The stored admin console session is the credential for hop 1. It is looked
|
|
239
|
+
// up on the *admin's* account, which is not the active one when a client
|
|
240
|
+
// account is renewing itself.
|
|
241
|
+
const { token: monitorToken, email: adminEmail } = await requireAdminFor(opts.adminEmail);
|
|
242
|
+
const imp = await fetch(`${base}/monitor/clients/${encodeURIComponent(clientId)}/impersonate`, {
|
|
243
|
+
headers: { Cookie: `access_token_monitor=${monitorToken}` },
|
|
244
|
+
});
|
|
245
|
+
const impText = await imp.text();
|
|
246
|
+
if (!imp.ok) {
|
|
247
|
+
const msg = (() => {
|
|
248
|
+
try {
|
|
249
|
+
return JSON.parse(impText).msg;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return impText.slice(0, 160);
|
|
253
|
+
}
|
|
254
|
+
})();
|
|
255
|
+
throw new Error(`Could not impersonate ${clientId} (HTTP ${imp.status}): ${msg}\n` +
|
|
256
|
+
' The client account must be assigned to your admin user.');
|
|
257
|
+
}
|
|
258
|
+
const impersonationToken = (JSON.parse(impText)['auth-token'] || '');
|
|
259
|
+
if (!impersonationToken)
|
|
260
|
+
throw new Error('Impersonation returned no auth-token.');
|
|
261
|
+
// 2. Trade it for a session cookie. This is the dashboard's `access_token`,
|
|
262
|
+
// not the console's — a different cookie, for the impersonated identity.
|
|
263
|
+
const apply = await fetch(`${base}/auth/apply-admin-session`, {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: { 'Content-Type': 'application/json' },
|
|
266
|
+
body: JSON.stringify({ token: impersonationToken }),
|
|
267
|
+
});
|
|
268
|
+
if (!apply.ok) {
|
|
269
|
+
throw new Error(`Could not apply the admin session (HTTP ${apply.status}).`);
|
|
270
|
+
}
|
|
271
|
+
const sessionJar = cookieJar(apply.headers);
|
|
272
|
+
const sessionCookie = cookieHeader(sessionJar);
|
|
273
|
+
if (!sessionJar.has('access_token')) {
|
|
274
|
+
throw new Error('The admin session was applied but no access_token cookie came back.\n' +
|
|
275
|
+
' Without it, the browser-approve exchange cannot be signed.');
|
|
276
|
+
}
|
|
277
|
+
const sessionHeaders = { Cookie: sessionCookie };
|
|
278
|
+
// 3. Start the browser-approval handshake and approve it ourselves.
|
|
279
|
+
const start = await fetch(`${base}/cli/auth/browser/start`, {
|
|
280
|
+
method: 'POST',
|
|
281
|
+
headers: { 'Content-Type': 'application/json', ...sessionHeaders },
|
|
282
|
+
});
|
|
283
|
+
const startText = await start.text();
|
|
284
|
+
if (!start.ok)
|
|
285
|
+
throw new Error(`Could not start the approval session (HTTP ${start.status}).`);
|
|
286
|
+
const { code, pollSecret } = JSON.parse(startText);
|
|
287
|
+
if (!code || !pollSecret)
|
|
288
|
+
throw new Error('The approval session came back without a code.');
|
|
289
|
+
const approve = await fetch(`${base}/cli/auth/browser/approve`, {
|
|
290
|
+
method: 'POST',
|
|
291
|
+
headers: { 'Content-Type': 'application/json', ...sessionHeaders },
|
|
292
|
+
body: JSON.stringify({ code }),
|
|
293
|
+
});
|
|
294
|
+
if (!approve.ok) {
|
|
295
|
+
throw new Error(`Could not approve the session (HTTP ${approve.status}).`);
|
|
296
|
+
}
|
|
297
|
+
// 4. Poll for the token.
|
|
298
|
+
const poll = await fetch(`${base}/cli/auth/browser/poll?code=${encodeURIComponent(code)}&secret=${encodeURIComponent(pollSecret)}`, { headers: sessionHeaders });
|
|
299
|
+
const pollText = await poll.text();
|
|
300
|
+
if (!poll.ok) {
|
|
301
|
+
throw new Error(`Could not collect the token (HTTP ${poll.status}): ${pollText.slice(0, 160)}`);
|
|
302
|
+
}
|
|
303
|
+
const polled = JSON.parse(pollText);
|
|
304
|
+
if (polled.status !== 'approved' || !polled.token) {
|
|
305
|
+
throw new Error(`The approval session ended with status "${polled.status}" instead of an approved token.`);
|
|
306
|
+
}
|
|
307
|
+
// Hop 2 also produced a dashboard session for the client: the body JWT is the
|
|
308
|
+
// `/v2/agents/` bearer and the `access_token` cookie serves the rest. Kept,
|
|
309
|
+
// because function code only saves through `PUT /v2/agents/:id?version=`.
|
|
310
|
+
return {
|
|
311
|
+
token: polled.token,
|
|
312
|
+
email: adminEmail,
|
|
313
|
+
dashboard: { token: impersonationToken, sessionToken: sessionJar.get('access_token') },
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Re-mint an impersonated token. Used by the renewal path: an impersonated
|
|
318
|
+
* account has no password, so it renews by impersonating again.
|
|
319
|
+
*/
|
|
320
|
+
async function reImpersonate(impersonated) {
|
|
321
|
+
try {
|
|
322
|
+
const { token } = await impersonateClient(impersonated.clientId, { adminEmail: impersonated.adminEmail });
|
|
323
|
+
return token;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// --- commands --------------------------------------------------------------
|
|
330
|
+
/** `cku admin login` — sign in to the internal admin console. */
|
|
331
|
+
async function adminLoginCommand(opts) {
|
|
332
|
+
try {
|
|
333
|
+
(0, ui_1.needTty)('Admin login is interactive; sign in from a terminal.');
|
|
334
|
+
const answers = await (0, prompts_1.default)([
|
|
335
|
+
{ type: opts.email ? null : 'text', name: 'email', message: 'Admin email' },
|
|
336
|
+
{ type: 'password', name: 'password', message: 'Password' },
|
|
337
|
+
], { onCancel: () => process.exit(1) });
|
|
338
|
+
const email = opts.email || answers.email;
|
|
339
|
+
const password = answers.password;
|
|
340
|
+
if (!email || !password)
|
|
341
|
+
return (0, ui_1.handleError)(new Error('Email and password are required'));
|
|
342
|
+
const session = await adminLogin(email, password);
|
|
343
|
+
// Kept by default so the session renews itself every 24h; --no-save-password
|
|
344
|
+
// opts out and brings back a daily re-login.
|
|
345
|
+
const keep = opts.savePassword !== false;
|
|
346
|
+
saveAdmin(email, keep ? { ...session, password } : session);
|
|
347
|
+
(0, ui_1.success)(`Admin console session stored for ${session.email} (role ${session.role || 'unknown'}).`);
|
|
348
|
+
if (keep) {
|
|
349
|
+
(0, ui_1.info)(' Password saved (plain text, mode 0600) so the session renews itself when it lapses.');
|
|
350
|
+
(0, ui_1.info)(' Remove it with `cku admin logout`, or sign in with --no-save-password.');
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
(0, ui_1.info)(' Password not saved. The session cannot be renewed, so re-run this once it expires.');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
(0, ui_1.handleError)(err);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/** `cku admin whoami` — prove the stored admin session still works. */
|
|
361
|
+
async function adminWhoamiCommand(opts) {
|
|
362
|
+
try {
|
|
363
|
+
const { adminApi } = require('./adminApi');
|
|
364
|
+
const res = await adminApi().getAdmin();
|
|
365
|
+
if (opts.json)
|
|
366
|
+
return void console.log(JSON.stringify(res, null, 2));
|
|
367
|
+
const user = res?.user || res;
|
|
368
|
+
(0, ui_1.success)(`Signed in to the admin console as ${user?.email || 'unknown'} (role ${user?.role || 'unknown'}).`);
|
|
369
|
+
if (user?.isSuperAdmin)
|
|
370
|
+
(0, ui_1.info)(' Super admin — every route is available.');
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
(0, ui_1.handleError)(err);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/** `cku admin logout` — forget the admin session, keep the ck_ token. */
|
|
377
|
+
async function adminLogoutCommand(opts) {
|
|
378
|
+
try {
|
|
379
|
+
// The session may be filed on any account, so find where it actually is.
|
|
380
|
+
const slug = (0, config_1.activeAccount)();
|
|
381
|
+
const active = slug ? (0, config_1.loadAccount)(slug).email : undefined;
|
|
382
|
+
const held = active && hasAdmin(active) ? active : adminSessions()[0]?.email;
|
|
383
|
+
if (!held)
|
|
384
|
+
return void (0, ui_1.info)('No admin console session stored.');
|
|
385
|
+
if (!(await (0, ui_1.confirm)(`Forget the admin console session for ${held}? (your ck_ token is kept)`, opts.yes)))
|
|
386
|
+
return;
|
|
387
|
+
if (forgetAdmin(held))
|
|
388
|
+
(0, ui_1.success)(`Admin console session removed for ${held}.`);
|
|
389
|
+
else
|
|
390
|
+
(0, ui_1.fail)('Nothing to remove.');
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
(0, ui_1.handleError)(err);
|
|
394
|
+
}
|
|
395
|
+
}
|
package/dist/lib/config.js
CHANGED
|
@@ -23,6 +23,8 @@ exports.loadAccount = loadAccount;
|
|
|
23
23
|
exports.saveAccount = saveAccount;
|
|
24
24
|
exports.saveDashboardSession = saveDashboardSession;
|
|
25
25
|
exports.clearDashboardSession = clearDashboardSession;
|
|
26
|
+
exports.saveAdminSession = saveAdminSession;
|
|
27
|
+
exports.clearAdminSession = clearAdminSession;
|
|
26
28
|
exports.removeAccount = removeAccount;
|
|
27
29
|
exports.loadConfig = loadConfig;
|
|
28
30
|
exports.clearSession = clearSession;
|
|
@@ -232,13 +234,21 @@ function loadAccount(slug) {
|
|
|
232
234
|
return {};
|
|
233
235
|
return readJson(accountPath(slug), {});
|
|
234
236
|
}
|
|
235
|
-
/**
|
|
236
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Store a session for `email` and make it the active account.
|
|
239
|
+
*
|
|
240
|
+
* `activate: false` stores it without moving the global pointer — what
|
|
241
|
+
* `cku admin impersonate` needs, since minting a client account's token must
|
|
242
|
+
* not silently redirect the admin's own commands.
|
|
243
|
+
*/
|
|
244
|
+
function saveAccount(email, session, opts = {}) {
|
|
237
245
|
const slug = slugFor(email);
|
|
238
246
|
// Carry the dashboard session across. `saveLogin` passes only { token, user },
|
|
239
247
|
// so without this a later `cku login` would silently drop a dashboard session
|
|
240
248
|
// the account already holds.
|
|
241
249
|
const dashboard = session.dashboard ?? loadAccount(slug).dashboard;
|
|
250
|
+
// The admin console session is a third credential, carried the same way.
|
|
251
|
+
const admin = session.admin ?? loadAccount(slug).admin;
|
|
242
252
|
// The password is the one field carried across a re-login: it is what lets a
|
|
243
253
|
// rejected token be re-issued without the user signing in again.
|
|
244
254
|
const password = session.password ?? loadAccount(slug).password;
|
|
@@ -247,9 +257,11 @@ function saveAccount(email, session) {
|
|
|
247
257
|
email,
|
|
248
258
|
savedAt: new Date().toISOString(),
|
|
249
259
|
...(dashboard ? { dashboard } : {}),
|
|
260
|
+
...(admin ? { admin } : {}),
|
|
250
261
|
...(password ? { password } : {}),
|
|
251
262
|
});
|
|
252
|
-
|
|
263
|
+
if (opts.activate !== false)
|
|
264
|
+
setActiveAccount(email);
|
|
253
265
|
return slug;
|
|
254
266
|
}
|
|
255
267
|
/**
|
|
@@ -285,6 +297,38 @@ function clearDashboardSession(email) {
|
|
|
285
297
|
writeJson(accountPath(slug), rest);
|
|
286
298
|
return true;
|
|
287
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Store the admin console session on an account, keeping whatever else it holds.
|
|
302
|
+
* Merging matters: signing in to the console must not disturb a `ck_` token or a
|
|
303
|
+
* dashboard session the account already holds.
|
|
304
|
+
*/
|
|
305
|
+
function saveAdminSession(email, admin) {
|
|
306
|
+
const slug = slugFor(email);
|
|
307
|
+
const current = loadAccount(slug);
|
|
308
|
+
// Built field by field, so stray fields a caller passes are not written to disk.
|
|
309
|
+
// `password` is the one deliberate exception.
|
|
310
|
+
const stored = {
|
|
311
|
+
email: admin.email || email,
|
|
312
|
+
token: admin.token,
|
|
313
|
+
savedAt: admin.savedAt || new Date().toISOString(),
|
|
314
|
+
...(admin.userId ? { userId: admin.userId } : {}),
|
|
315
|
+
...(admin.role ? { role: admin.role } : {}),
|
|
316
|
+
...(admin.expiresAt ? { expiresAt: admin.expiresAt } : {}),
|
|
317
|
+
...(admin.password ? { password: admin.password } : {}),
|
|
318
|
+
};
|
|
319
|
+
writeJson(accountPath(slug), { ...current, email, admin: stored, savedAt: new Date().toISOString() });
|
|
320
|
+
return slug;
|
|
321
|
+
}
|
|
322
|
+
/** Forget only the admin console session; the `ck_` token is left alone. */
|
|
323
|
+
function clearAdminSession(email) {
|
|
324
|
+
const slug = slugFor(email);
|
|
325
|
+
const current = loadAccount(slug);
|
|
326
|
+
if (!current.admin)
|
|
327
|
+
return false;
|
|
328
|
+
const { admin, ...rest } = current;
|
|
329
|
+
writeJson(accountPath(slug), rest);
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
288
332
|
function removeAccount(slug) {
|
|
289
333
|
const path = accountPath(slug);
|
|
290
334
|
if (!(0, fs_1.existsSync)(path))
|
|
@@ -332,12 +376,18 @@ function clearSession() {
|
|
|
332
376
|
const path = accountPath(slug);
|
|
333
377
|
if (!(0, fs_1.existsSync)(path))
|
|
334
378
|
return undefined;
|
|
335
|
-
const { email, savedAt, dashboard } = loadAccount(slug);
|
|
379
|
+
const { email, savedAt, dashboard, admin } = loadAccount(slug);
|
|
336
380
|
// Dropping the ck_ token is what logout means. The password goes with it: it
|
|
337
381
|
// exists only to re-issue that token, so keeping it would let a logged-out
|
|
338
382
|
// account sign itself back in. A dashboard session is a separate credential
|
|
339
|
-
// and is left in place — `cku dashboard logout` removes it.
|
|
340
|
-
|
|
383
|
+
// and is left in place — `cku dashboard logout` removes it. Same for the
|
|
384
|
+
// admin console session — `cku admin logout` removes that.
|
|
385
|
+
writeJson(path, {
|
|
386
|
+
email,
|
|
387
|
+
savedAt,
|
|
388
|
+
...(dashboard ? { dashboard } : {}),
|
|
389
|
+
...(admin ? { admin } : {}),
|
|
390
|
+
});
|
|
341
391
|
return email;
|
|
342
392
|
}
|
|
343
393
|
/**
|
|
@@ -391,6 +441,10 @@ function getClient() {
|
|
|
391
441
|
* stored its password renews itself; one that did not cannot, and the caller
|
|
392
442
|
* gets undefined so the error can name `cku login`.
|
|
393
443
|
*
|
|
444
|
+
* An account that was *impersonated* has no password: its token was minted by
|
|
445
|
+
* the admin console. It renews by impersonating again, which needs the admin
|
|
446
|
+
* console session rather than a password.
|
|
447
|
+
*
|
|
394
448
|
* Under CK_TOKEN there is no stored account in effect, so nothing is renewed:
|
|
395
449
|
* the environment owns the credential.
|
|
396
450
|
*/
|
|
@@ -399,7 +453,33 @@ function renewTokenFor(cfg = loadConfig()) {
|
|
|
399
453
|
return undefined;
|
|
400
454
|
const email = cfg.email;
|
|
401
455
|
const password = cfg.password;
|
|
402
|
-
|
|
456
|
+
const impersonated = cfg.impersonated;
|
|
457
|
+
if (!email)
|
|
458
|
+
return undefined;
|
|
459
|
+
// Persist to the slot the session was read from, not to one derived from the
|
|
460
|
+
// email: the account file is what every later command resolves. Writing the
|
|
461
|
+
// new token somewhere else renews the command but leaves the slot dead, and
|
|
462
|
+
// the next invocation fails exactly as before.
|
|
463
|
+
const slug = activeAccount() || slugFor(email);
|
|
464
|
+
if (impersonated) {
|
|
465
|
+
return async () => {
|
|
466
|
+
try {
|
|
467
|
+
// Lazy require: adminAuth imports this module, so a top-level import
|
|
468
|
+
// would be a cycle. Same pattern as refreshModelCatalog in dashboardAuth.
|
|
469
|
+
const { reImpersonate } = require('./adminAuth');
|
|
470
|
+
const fresh = await reImpersonate(impersonated);
|
|
471
|
+
if (!fresh)
|
|
472
|
+
return undefined;
|
|
473
|
+
const current = loadAccount(slug);
|
|
474
|
+
writeJson(accountPath(slug), { ...current, email, token: fresh, savedAt: new Date().toISOString() });
|
|
475
|
+
return fresh;
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (!password)
|
|
403
483
|
return undefined;
|
|
404
484
|
return async () => {
|
|
405
485
|
try {
|
|
@@ -412,7 +492,6 @@ function renewTokenFor(cfg = loadConfig()) {
|
|
|
412
492
|
if (!res.ok || !data.token)
|
|
413
493
|
return undefined;
|
|
414
494
|
// Persist the new token, keeping everything else the account holds.
|
|
415
|
-
const slug = slugFor(email);
|
|
416
495
|
const current = loadAccount(slug);
|
|
417
496
|
writeJson(accountPath(slug), { ...current, email, token: data.token, savedAt: new Date().toISOString() });
|
|
418
497
|
return data.token;
|
package/dist/lib/dashboardApi.js
CHANGED
|
@@ -250,7 +250,11 @@ function dashboardApi() {
|
|
|
250
250
|
* accept either token everywhere, and guessing produces an unexplained 401.
|
|
251
251
|
*/
|
|
252
252
|
function usesCookieAuth(path) {
|
|
253
|
-
|
|
253
|
+
// Only the list route takes the bearer. `/v2/agents/:id…` — including the
|
|
254
|
+
// version save `PUT /v2/agents/:id?version=` — takes the cookie and answers
|
|
255
|
+
// the bearer with 401 (probed 21 October 2026 with getAgentVersions).
|
|
256
|
+
const route = path.split('?')[0];
|
|
257
|
+
return route !== '/v2/agents/' && route !== '/v2/agents';
|
|
254
258
|
}
|
|
255
259
|
/** `?a=1&b=2`, skipping undefined and empty. Mirrors the one in api.ts. */
|
|
256
260
|
function qs(params) {
|
|
@@ -153,7 +153,24 @@ async function requireDashboard() {
|
|
|
153
153
|
// With a stored password, renew a minute early so the token cannot lapse
|
|
154
154
|
// mid-request; without one a token that still has time left is still valid.
|
|
155
155
|
const left = session.sessionToken ? msUntilExpiry(session.sessionToken) : 0;
|
|
156
|
-
|
|
156
|
+
const impersonated = slug ? (0, config_1.loadAccount)(slug).impersonated : undefined;
|
|
157
|
+
if (left !== undefined && left <= (session.password || impersonated ? 60_000 : 0)) {
|
|
158
|
+
// A session minted by `cku admin impersonate` has no password; it renews by
|
|
159
|
+
// impersonating again, which also re-issues the account's ck_ token.
|
|
160
|
+
if (impersonated) {
|
|
161
|
+
try {
|
|
162
|
+
const { impersonateClient } = require('./adminAuth');
|
|
163
|
+
const fresh = await impersonateClient(impersonated.clientId, { adminEmail: impersonated.adminEmail });
|
|
164
|
+
const renewed = { email: session.email, ...fresh.dashboard, savedAt: new Date().toISOString() };
|
|
165
|
+
saveDashboard(email, renewed);
|
|
166
|
+
return { token: renewed.token, sessionToken: renewed.sessionToken, email: renewed.email };
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
throw new Error(`Dashboard session for ${email} expired and re-impersonating failed: ${err.message}
|
|
170
|
+
` +
|
|
171
|
+
' Run: cku admin login, then cku admin impersonate ' + impersonated.clientId);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
157
174
|
if (!session.password) {
|
|
158
175
|
const why = session.sessionToken
|
|
159
176
|
? `Dashboard session for ${email} expired ${new Date(Date.now() + left).toLocaleString('en-US')}.\n` +
|
package/dist/lib/looseJson.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.readJsonFile = readJsonFile;
|
|
4
4
|
exports.stripBom = stripBom;
|
|
5
|
+
exports.repairPosixPath = repairPosixPath;
|
|
5
6
|
exports.parseLooseJson = parseLooseJson;
|
|
6
7
|
const fs_1 = require("fs");
|
|
7
8
|
/**
|
|
@@ -25,6 +26,48 @@ const NUMBER = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/;
|
|
|
25
26
|
/** Characters that end a bare value / a bare key at the current nesting level. */
|
|
26
27
|
const VALUE_STOP = ',}]';
|
|
27
28
|
const KEY_STOP = ':,}';
|
|
29
|
+
/**
|
|
30
|
+
* Repair a POSIX path argument that Git Bash turned into a Windows path.
|
|
31
|
+
*
|
|
32
|
+
* MSYS2 (Git Bash on Windows) rewrites an argument that looks like an absolute
|
|
33
|
+
* POSIX path into a Windows one, so
|
|
34
|
+
*
|
|
35
|
+
* ck dashboard api GET /v1/call-analyzers
|
|
36
|
+
*
|
|
37
|
+
* arrives in `process.argv` as `C:/Program Files/Git/v1/call-analyzers` — the
|
|
38
|
+
* leading slash is replaced by the shell's own root and the original path
|
|
39
|
+
* follows it. The argument is then refused with "path must start with /",
|
|
40
|
+
* which reads like a CLI bug rather than a shell quirk.
|
|
41
|
+
*
|
|
42
|
+
* The repair finds the known route segment as a whole path segment and takes
|
|
43
|
+
* the path from there. Boundaries matter: `monitoring` must not match the
|
|
44
|
+
* segment `monitor`, and a segment must not match in the middle of a name.
|
|
45
|
+
*
|
|
46
|
+
* Only the listed segments are repaired: a path that contains none of them is
|
|
47
|
+
* left alone, and the caller's own "must start with /" check then reports it.
|
|
48
|
+
* Guessing a split point would turn a wrong path into a different wrong path,
|
|
49
|
+
* silently.
|
|
50
|
+
*/
|
|
51
|
+
function repairPosixPath(raw, segments) {
|
|
52
|
+
if (raw.startsWith('/'))
|
|
53
|
+
return raw;
|
|
54
|
+
for (const segment of segments) {
|
|
55
|
+
let from = 0;
|
|
56
|
+
for (;;) {
|
|
57
|
+
const i = raw.indexOf(segment, from);
|
|
58
|
+
if (i < 0)
|
|
59
|
+
break;
|
|
60
|
+
const before = raw[i - 1];
|
|
61
|
+
const after = raw[i + segment.length];
|
|
62
|
+
const leftOk = i === 0 || before === '/' || before === '\\' || before === ':';
|
|
63
|
+
const rightOk = after === undefined || after === '/' || after === '?' || after === '&';
|
|
64
|
+
if (leftOk && rightOk)
|
|
65
|
+
return '/' + raw.slice(i);
|
|
66
|
+
from = i + 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return raw;
|
|
70
|
+
}
|
|
28
71
|
/**
|
|
29
72
|
* Parse `raw` as JSON, repairing shell-stripped quotes if the strict parse fails.
|
|
30
73
|
*
|