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.
@@ -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
- constructor(baseUrl, callBaseUrl, token, monitoringAccountId) {
20
- this.token = token;
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 (this.token)
32
- headers.Authorization = `Bearer ${this.token}`;
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
- res = await fetch(`${base}${path}`, {
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 }); }
@@ -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
- /** Store a session for `email` and make it the active account. */
235
- function saveAccount(email, session) {
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
- writeJson(accountPath(slug), { ...session, email, savedAt: new Date().toISOString(), ...(dashboard ? { dashboard } : {}) });
242
- setActiveAccount(email);
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. A dashboard session is a
327
- // separate credential and is left in place — `cku firebase logout` removes it.
328
- writeJson(path, { email, savedAt, ...(dashboard ? { dashboard } : {}) });
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 { api: new api_1.Api(backendUrl(), callUrl(), cfg.token, monitoringAccountId(cfg)), cfg };
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
  }