callkaro-cli-unofficial 0.5.0 → 0.6.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 +14 -0
- package/dist/commands/accounts.js +45 -4
- package/dist/commands/admin.js +517 -0
- package/dist/commands/dashboardApiCmd.js +5 -1
- package/dist/index.js +147 -0
- package/dist/lib/adminApi.js +323 -0
- package/dist/lib/adminAuth.js +388 -0
- package/dist/lib/api.js +41 -14
- package/dist/lib/config.js +143 -9
- package/dist/lib/looseJson.js +43 -0
- package/package.json +1 -1
|
@@ -0,0 +1,388 @@
|
|
|
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
|
+
return { token: polled.token, email: adminEmail };
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Re-mint an impersonated token. Used by the renewal path: an impersonated
|
|
311
|
+
* account has no password, so it renews by impersonating again.
|
|
312
|
+
*/
|
|
313
|
+
async function reImpersonate(impersonated) {
|
|
314
|
+
try {
|
|
315
|
+
const { token } = await impersonateClient(impersonated.clientId, { adminEmail: impersonated.adminEmail });
|
|
316
|
+
return token;
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// --- commands --------------------------------------------------------------
|
|
323
|
+
/** `cku admin login` — sign in to the internal admin console. */
|
|
324
|
+
async function adminLoginCommand(opts) {
|
|
325
|
+
try {
|
|
326
|
+
(0, ui_1.needTty)('Admin login is interactive; sign in from a terminal.');
|
|
327
|
+
const answers = await (0, prompts_1.default)([
|
|
328
|
+
{ type: opts.email ? null : 'text', name: 'email', message: 'Admin email' },
|
|
329
|
+
{ type: 'password', name: 'password', message: 'Password' },
|
|
330
|
+
], { onCancel: () => process.exit(1) });
|
|
331
|
+
const email = opts.email || answers.email;
|
|
332
|
+
const password = answers.password;
|
|
333
|
+
if (!email || !password)
|
|
334
|
+
return (0, ui_1.handleError)(new Error('Email and password are required'));
|
|
335
|
+
const session = await adminLogin(email, password);
|
|
336
|
+
// Kept by default so the session renews itself every 24h; --no-save-password
|
|
337
|
+
// opts out and brings back a daily re-login.
|
|
338
|
+
const keep = opts.savePassword !== false;
|
|
339
|
+
saveAdmin(email, keep ? { ...session, password } : session);
|
|
340
|
+
(0, ui_1.success)(`Admin console session stored for ${session.email} (role ${session.role || 'unknown'}).`);
|
|
341
|
+
if (keep) {
|
|
342
|
+
(0, ui_1.info)(' Password saved (plain text, mode 0600) so the session renews itself when it lapses.');
|
|
343
|
+
(0, ui_1.info)(' Remove it with `cku admin logout`, or sign in with --no-save-password.');
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
(0, ui_1.info)(' Password not saved. The session cannot be renewed, so re-run this once it expires.');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
(0, ui_1.handleError)(err);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** `cku admin whoami` — prove the stored admin session still works. */
|
|
354
|
+
async function adminWhoamiCommand(opts) {
|
|
355
|
+
try {
|
|
356
|
+
const { adminApi } = require('./adminApi');
|
|
357
|
+
const res = await adminApi().getAdmin();
|
|
358
|
+
if (opts.json)
|
|
359
|
+
return void console.log(JSON.stringify(res, null, 2));
|
|
360
|
+
const user = res?.user || res;
|
|
361
|
+
(0, ui_1.success)(`Signed in to the admin console as ${user?.email || 'unknown'} (role ${user?.role || 'unknown'}).`);
|
|
362
|
+
if (user?.isSuperAdmin)
|
|
363
|
+
(0, ui_1.info)(' Super admin — every route is available.');
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
(0, ui_1.handleError)(err);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** `cku admin logout` — forget the admin session, keep the ck_ token. */
|
|
370
|
+
async function adminLogoutCommand(opts) {
|
|
371
|
+
try {
|
|
372
|
+
// The session may be filed on any account, so find where it actually is.
|
|
373
|
+
const slug = (0, config_1.activeAccount)();
|
|
374
|
+
const active = slug ? (0, config_1.loadAccount)(slug).email : undefined;
|
|
375
|
+
const held = active && hasAdmin(active) ? active : adminSessions()[0]?.email;
|
|
376
|
+
if (!held)
|
|
377
|
+
return void (0, ui_1.info)('No admin console session stored.');
|
|
378
|
+
if (!(await (0, ui_1.confirm)(`Forget the admin console session for ${held}? (your ck_ token is kept)`, opts.yes)))
|
|
379
|
+
return;
|
|
380
|
+
if (forgetAdmin(held))
|
|
381
|
+
(0, ui_1.success)(`Admin console session removed for ${held}.`);
|
|
382
|
+
else
|
|
383
|
+
(0, ui_1.fail)('Nothing to remove.');
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
(0, ui_1.handleError)(err);
|
|
387
|
+
}
|
|
388
|
+
}
|
package/dist/lib/api.js
CHANGED
|
@@ -12,30 +12,64 @@ const types_1 = require("./types");
|
|
|
12
12
|
* logic. Public methods map 1:1 to the `/cli/*` endpoints.
|
|
13
13
|
*/
|
|
14
14
|
class Api {
|
|
15
|
-
token;
|
|
16
15
|
monitoringAccountId;
|
|
16
|
+
renewToken;
|
|
17
17
|
baseUrl;
|
|
18
18
|
callBaseUrl;
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
/** Mutable: a renewed token replaces the rejected one for later requests. */
|
|
20
|
+
token;
|
|
21
|
+
constructor(baseUrl, callBaseUrl, token, monitoringAccountId,
|
|
22
|
+
/**
|
|
23
|
+
* Re-issue a rejected token. The `ck_` token is opaque and has no refresh
|
|
24
|
+
* route, so the only way back in is to sign in again — which needs the
|
|
25
|
+
* account's password. Returns the new token, or undefined when the account
|
|
26
|
+
* cannot renew itself.
|
|
27
|
+
*/
|
|
28
|
+
renewToken) {
|
|
21
29
|
this.monitoringAccountId = monitoringAccountId;
|
|
30
|
+
this.renewToken = renewToken;
|
|
22
31
|
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
23
32
|
this.callBaseUrl = (callBaseUrl || baseUrl).replace(/\/+$/, '');
|
|
33
|
+
this.token = token;
|
|
24
34
|
}
|
|
25
35
|
request(method, path, body, includeAccount = true) {
|
|
26
36
|
return this.requestTo(this.baseUrl, method, path, body, includeAccount);
|
|
27
37
|
}
|
|
28
38
|
/** Same as request(), but against a specific base URL (e.g. the call cloud). */
|
|
29
39
|
async requestTo(base, method, path, body, includeAccount = true) {
|
|
40
|
+
let res = await this.send(base, method, path, body, includeAccount, this.token);
|
|
41
|
+
// A rejected token is not a failure the user can act on by retrying: the
|
|
42
|
+
// same token gets the same answer. Renew once, silently, when the account
|
|
43
|
+
// has the means to renew itself. The renewed token then serves the rest of
|
|
44
|
+
// the command, so one renewal covers every request in it.
|
|
45
|
+
if (res.status === 401 && this.renewToken) {
|
|
46
|
+
const fresh = await this.renewToken();
|
|
47
|
+
if (fresh) {
|
|
48
|
+
this.token = fresh;
|
|
49
|
+
res = await this.send(base, method, path, body, includeAccount, fresh);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
const data = text ? safeJson(text) : undefined;
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw new types_1.ApiError(res.status, res.status === 401
|
|
56
|
+
? `${data?.msg || 'Your session is no longer valid'} — the stored token was rejected and could not be renewed.\n` +
|
|
57
|
+
' Run `cku login` to sign in again.'
|
|
58
|
+
: data?.msg || data?.message || (typeof data?.error === 'string' ? data.error : undefined) ||
|
|
59
|
+
`Request failed (${res.status})`, parseErrorDetails(data?.errors ?? data?.data?.errors ?? invalidDetails(data?.invalid)));
|
|
60
|
+
}
|
|
61
|
+
return data;
|
|
62
|
+
}
|
|
63
|
+
/** One HTTP round trip, with the given bearer token. */
|
|
64
|
+
async send(base, method, path, body, includeAccount, token) {
|
|
30
65
|
const headers = { 'Content-Type': 'application/json' };
|
|
31
|
-
if (
|
|
32
|
-
headers.Authorization = `Bearer ${
|
|
66
|
+
if (token)
|
|
67
|
+
headers.Authorization = `Bearer ${token}`;
|
|
33
68
|
// Monitoring mode: every request, writes included, acts on the client account.
|
|
34
69
|
if (includeAccount && this.monitoringAccountId)
|
|
35
70
|
headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
|
|
36
|
-
let res;
|
|
37
71
|
try {
|
|
38
|
-
|
|
72
|
+
return await fetch(`${base}${path}`, {
|
|
39
73
|
method,
|
|
40
74
|
headers,
|
|
41
75
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
@@ -44,13 +78,6 @@ class Api {
|
|
|
44
78
|
catch (err) {
|
|
45
79
|
throw new types_1.ApiError(0, `Cannot reach ${base} (${err.message})`);
|
|
46
80
|
}
|
|
47
|
-
const text = await res.text();
|
|
48
|
-
const data = text ? safeJson(text) : undefined;
|
|
49
|
-
if (!res.ok) {
|
|
50
|
-
throw new types_1.ApiError(res.status, data?.msg || data?.message || (typeof data?.error === 'string' ? data.error : undefined) ||
|
|
51
|
-
`Request failed (${res.status})`, parseErrorDetails(data?.errors ?? data?.data?.errors ?? invalidDetails(data?.invalid)));
|
|
52
|
-
}
|
|
53
|
-
return data;
|
|
54
81
|
}
|
|
55
82
|
// --- auth endpoints -------------------------------------------------------
|
|
56
83
|
registerStart(email) { return this.request('POST', '/cli/auth/register/start', { email }); }
|
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;
|
|
@@ -30,6 +32,7 @@ exports.saveLogin = saveLogin;
|
|
|
30
32
|
exports.monitoringAccountId = monitoringAccountId;
|
|
31
33
|
exports.setMonitoring = setMonitoring;
|
|
32
34
|
exports.getClient = getClient;
|
|
35
|
+
exports.renewTokenFor = renewTokenFor;
|
|
33
36
|
const fs_1 = require("fs");
|
|
34
37
|
const os_1 = require("os");
|
|
35
38
|
const path_1 = require("path");
|
|
@@ -231,15 +234,34 @@ function loadAccount(slug) {
|
|
|
231
234
|
return {};
|
|
232
235
|
return readJson(accountPath(slug), {});
|
|
233
236
|
}
|
|
234
|
-
/**
|
|
235
|
-
|
|
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 = {}) {
|
|
236
245
|
const slug = slugFor(email);
|
|
237
246
|
// Carry the dashboard session across. `saveLogin` passes only { token, user },
|
|
238
247
|
// so without this a later `cku login` would silently drop a dashboard session
|
|
239
248
|
// the account already holds.
|
|
240
249
|
const dashboard = session.dashboard ?? loadAccount(slug).dashboard;
|
|
241
|
-
|
|
242
|
-
|
|
250
|
+
// The admin console session is a third credential, carried the same way.
|
|
251
|
+
const admin = session.admin ?? loadAccount(slug).admin;
|
|
252
|
+
// The password is the one field carried across a re-login: it is what lets a
|
|
253
|
+
// rejected token be re-issued without the user signing in again.
|
|
254
|
+
const password = session.password ?? loadAccount(slug).password;
|
|
255
|
+
writeJson(accountPath(slug), {
|
|
256
|
+
...session,
|
|
257
|
+
email,
|
|
258
|
+
savedAt: new Date().toISOString(),
|
|
259
|
+
...(dashboard ? { dashboard } : {}),
|
|
260
|
+
...(admin ? { admin } : {}),
|
|
261
|
+
...(password ? { password } : {}),
|
|
262
|
+
});
|
|
263
|
+
if (opts.activate !== false)
|
|
264
|
+
setActiveAccount(email);
|
|
243
265
|
return slug;
|
|
244
266
|
}
|
|
245
267
|
/**
|
|
@@ -275,6 +297,38 @@ function clearDashboardSession(email) {
|
|
|
275
297
|
writeJson(accountPath(slug), rest);
|
|
276
298
|
return true;
|
|
277
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
|
+
}
|
|
278
332
|
function removeAccount(slug) {
|
|
279
333
|
const path = accountPath(slug);
|
|
280
334
|
if (!(0, fs_1.existsSync)(path))
|
|
@@ -322,10 +376,18 @@ function clearSession() {
|
|
|
322
376
|
const path = accountPath(slug);
|
|
323
377
|
if (!(0, fs_1.existsSync)(path))
|
|
324
378
|
return undefined;
|
|
325
|
-
const { email, savedAt, dashboard } = loadAccount(slug);
|
|
326
|
-
// Dropping the ck_ token is what logout means.
|
|
327
|
-
//
|
|
328
|
-
|
|
379
|
+
const { email, savedAt, dashboard, admin } = loadAccount(slug);
|
|
380
|
+
// Dropping the ck_ token is what logout means. The password goes with it: it
|
|
381
|
+
// exists only to re-issue that token, so keeping it would let a logged-out
|
|
382
|
+
// account sign itself back in. A dashboard session is a separate credential
|
|
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
|
+
});
|
|
329
391
|
return email;
|
|
330
392
|
}
|
|
331
393
|
/**
|
|
@@ -365,5 +427,77 @@ function setMonitoring(monitoring) {
|
|
|
365
427
|
/** Build the API client from the endpoint URLs + resolved token. */
|
|
366
428
|
function getClient() {
|
|
367
429
|
const cfg = loadConfig();
|
|
368
|
-
return {
|
|
430
|
+
return {
|
|
431
|
+
api: new api_1.Api(backendUrl(), callUrl(), cfg.token, monitoringAccountId(cfg), renewTokenFor(cfg)),
|
|
432
|
+
cfg,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Sign in again for an account that stored its password, and persist the new
|
|
437
|
+
* token.
|
|
438
|
+
*
|
|
439
|
+
* The `ck_` token is opaque and has no refresh route, so a token the server
|
|
440
|
+
* stops accepting leaves no way back in except a fresh sign-in. An account that
|
|
441
|
+
* stored its password renews itself; one that did not cannot, and the caller
|
|
442
|
+
* gets undefined so the error can name `cku login`.
|
|
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
|
+
*
|
|
448
|
+
* Under CK_TOKEN there is no stored account in effect, so nothing is renewed:
|
|
449
|
+
* the environment owns the credential.
|
|
450
|
+
*/
|
|
451
|
+
function renewTokenFor(cfg = loadConfig()) {
|
|
452
|
+
if (process.env.CK_TOKEN)
|
|
453
|
+
return undefined;
|
|
454
|
+
const email = cfg.email;
|
|
455
|
+
const password = cfg.password;
|
|
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)
|
|
483
|
+
return undefined;
|
|
484
|
+
return async () => {
|
|
485
|
+
try {
|
|
486
|
+
const res = await fetch(`${backendUrl()}/cli/auth/login`, {
|
|
487
|
+
method: 'POST',
|
|
488
|
+
headers: { 'Content-Type': 'application/json' },
|
|
489
|
+
body: JSON.stringify({ email, password }),
|
|
490
|
+
});
|
|
491
|
+
const data = (await res.json().catch(() => ({})));
|
|
492
|
+
if (!res.ok || !data.token)
|
|
493
|
+
return undefined;
|
|
494
|
+
// Persist the new token, keeping everything else the account holds.
|
|
495
|
+
const current = loadAccount(slug);
|
|
496
|
+
writeJson(accountPath(slug), { ...current, email, token: data.token, savedAt: new Date().toISOString() });
|
|
497
|
+
return data.token;
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
};
|
|
369
503
|
}
|