@youkno/edge-cli 1.21.316 → 1.21.318

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/.yarnrc.yml CHANGED
@@ -1,3 +1,14 @@
1
+ # The cache lives inside the repo, not in ~/.yarn/berry/cache.
2
+ #
3
+ # .pnp.cjs is committed and stores package locations as paths RELATIVE TO ITSELF. With a global
4
+ # cache those paths have to climb out of the repo to reach $HOME, so the number of `../` encodes how
5
+ # deep the checkout happens to be on the machine that ran `yarn install`. The committed file carried
6
+ # six levels; a checkout three levels deep resolves them to nothing and every CLI invocation dies
7
+ # with "the locator that owns .../commander/ can't be found inside the dependency tree".
8
+ #
9
+ # A repo-local cache makes those paths `./.yarn/cache/...`, which means the same for everyone.
10
+ enableGlobalCache: false
11
+
1
12
  approvedGitRepositories:
2
13
  - "**"
3
14
 
package/README.md CHANGED
@@ -48,6 +48,10 @@ edge-cli login
48
48
 
49
49
  This opens your browser to the admin sign-in page. After signing in, tokens are saved to `~/.edge-cli-auth/accounts.json`.
50
50
 
51
+ Login creates **two** sessions from the one sign-in: a shell session that drives `edge-cli shell`, and an API-only session that `edge-cli token` prints. Token scope is fixed when the session is created and a refresh re-derives it from the session, so a shell token cannot be narrowed afterwards — the second session is what makes an API-only token possible without a second sign-in.
52
+
53
+ Prefer the API-only token anywhere a token leaves the CLI (Postman, scripts, a colleague's terminal): a `--scope shell` token can run any shell command your account is allowed to, against whatever environment it was issued for. If you logged in before this existed, `edge-cli token` will tell you to log in again rather than hand back the shell-capable one.
54
+
51
55
  ```bash
52
56
  edge-cli login:list # see saved accounts
53
57
  edge-cli login:use <email> # switch default account
@@ -65,9 +69,10 @@ edge-cli health-check
65
69
  edge-cli config
66
70
 
67
71
  # Get an API token (useful for Postman or curl)
68
- edge-cli token
69
- edge-cli token --refresh # force token refresh
70
- edge-cli token --header # print as Authorization header
72
+ edge-cli token # API-only token (aud: edge-api)
73
+ edge-cli token --scope shell # also carries edge-shell; can run shell commands
74
+ edge-cli token --refresh # force token refresh
75
+ edge-cli token --header # print as Authorization header
71
76
 
72
77
  # Run a shell command
73
78
  edge-cli shell "<command>"
@@ -3,17 +3,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerToken = registerToken;
4
4
  const auth_1 = require("../lib/auth");
5
5
  const config_1 = require("../lib/config");
6
+ const SCOPES = ["api", "shell"];
6
7
  function registerToken(program) {
7
8
  program
8
9
  .command("token")
9
10
  .description("Print a JWT access token for API calls (e.g. Postman)")
10
11
  .option("--refresh", "Force refresh using refresh token when available")
11
12
  .option("--header", "Print as Authorization header line")
13
+ .option("--scope <scope>", `Which token to print: ${SCOPES.join(" | ")}. 'api' carries the edge-api audience only; ` +
14
+ `'shell' also carries edge-shell and can run shell commands`, "api")
12
15
  .action(async function tokenAction(commandOptions) {
13
16
  const parent = this.parent;
14
17
  const opts = (parent?.opts() ?? {});
15
18
  const cfg = await (0, config_1.resolveEffectiveConfig)(opts);
16
- const token = await (0, auth_1.resolveAccessToken)(cfg, Boolean(commandOptions.refresh));
19
+ const requested = (commandOptions.scope ?? "api").toLowerCase();
20
+ if (!SCOPES.includes(requested)) {
21
+ // Refused rather than defaulted: printing a token of a scope nobody asked for is
22
+ // how a shell-capable credential ends up somewhere it was never meant to go.
23
+ throw new Error(`Unknown --scope: ${commandOptions.scope} (expected ${SCOPES.join(" or ")})`);
24
+ }
25
+ const token = requested === "shell"
26
+ ? await (0, auth_1.resolveAccessToken)(cfg, Boolean(commandOptions.refresh))
27
+ : await (0, auth_1.resolveApiAccessToken)(cfg, Boolean(commandOptions.refresh));
17
28
  if (commandOptions.header) {
18
29
  process.stdout.write(`Authorization: Bearer ${token}\n`);
19
30
  return;
package/dist/lib/auth.js CHANGED
@@ -8,6 +8,7 @@ exports.setDefaultLogin = setDefaultLogin;
8
8
  exports.logout = logout;
9
9
  exports.login = login;
10
10
  exports.resolveAccessToken = resolveAccessToken;
11
+ exports.resolveApiAccessToken = resolveApiAccessToken;
11
12
  exports.resolveAuthHeader = resolveAuthHeader;
12
13
  exports.resolveAuthHeaderFast = resolveAuthHeaderFast;
13
14
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -45,6 +46,16 @@ const DEFAULT_DEVICE_NAME = "edge-cli";
45
46
  * exchange.
46
47
  */
47
48
  const EXCHANGE_SCOPE_SHELL = "shell";
49
+ /**
50
+ * The app scope is the *absence* of a scope field -- `TokenScope.fromWire` maps blank to `APP` --
51
+ * so an API-only session is requested by omitting it, not by sending a name for it.
52
+ *
53
+ * `null`, never `undefined`: a default parameter fires when an argument is `undefined`, so passing
54
+ * an `undefined` sentinel to `authExchangeFirebaseToken` silently selected the shell default and
55
+ * every "API-only" session came back with the `edge-shell` audience. `f(null)` does not trigger the
56
+ * default.
57
+ */
58
+ const EXCHANGE_SCOPE_APP = null;
48
59
  function scopeKey(cfg) {
49
60
  return `${cfg.product}/${cfg.env}`;
50
61
  }
@@ -124,6 +135,24 @@ function decodeJwtPayload(token) {
124
135
  return {};
125
136
  }
126
137
  }
138
+ /**
139
+ * The extra audience `TokenScope.SHELL` stamps. Matched by name rather than by "anything that is
140
+ * not the API audience", because the API audience is server-configurable (`api.jwt.audience`) and
141
+ * the CLI cannot know what it was set to.
142
+ */
143
+ const SHELL_AUDIENCE = "edge-shell";
144
+ /**
145
+ * Whether a token can drive the shell.
146
+ *
147
+ * Read from the token rather than inferred from what was requested. Asking for an app-scoped
148
+ * session and checking nothing is how every "API-only" token shipped carrying `edge-shell`: the
149
+ * exchange succeeded, so nothing looked wrong, and the CLI handed out shell credentials for a day.
150
+ */
151
+ function carriesShellAudience(accessToken) {
152
+ const aud = decodeJwtPayload(accessToken).aud;
153
+ const list = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
154
+ return list.some((entry) => entry === SHELL_AUDIENCE);
155
+ }
127
156
  function extractEmailFromToken(accessToken) {
128
157
  const payload = decodeJwtPayload(accessToken);
129
158
  const email = payload.email;
@@ -151,12 +180,12 @@ async function requestTokenEndpoint(cfg, endpoint, payload) {
151
180
  }
152
181
  return (await response.json());
153
182
  }
154
- async function authExchangeFirebaseToken(cfg, firebaseToken) {
183
+ async function authExchangeFirebaseToken(cfg, firebaseToken, scope = EXCHANGE_SCOPE_SHELL) {
155
184
  const resp = await requestTokenEndpoint(cfg, "exchange", {
156
185
  firebaseToken,
157
186
  deviceId: authDeviceId(),
158
187
  deviceName: DEFAULT_DEVICE_NAME,
159
- scope: EXCHANGE_SCOPE_SHELL
188
+ ...(scope === null ? {} : { scope })
160
189
  });
161
190
  if (!resp?.accessToken) {
162
191
  throw new Error("Token exchange did not return accessToken");
@@ -222,9 +251,14 @@ async function logout(cfg, email) {
222
251
  if (!account) {
223
252
  throw new Error(`Account not found for ${cfg.product}/${cfg.env}: ${target}`);
224
253
  }
225
- if (account.refreshToken) {
254
+ // Both sessions, or logout leaves the API-only one alive on the server after the CLI has
255
+ // forgotten it -- a session nobody can see and nobody will revoke.
256
+ for (const refreshToken of [account.refreshToken, account.api?.refreshToken]) {
257
+ if (!refreshToken) {
258
+ continue;
259
+ }
226
260
  try {
227
- await authLogoutRefreshToken(cfg, account.refreshToken);
261
+ await authLogoutRefreshToken(cfg, refreshToken);
228
262
  }
229
263
  catch {
230
264
  // best effort
@@ -238,19 +272,44 @@ async function logout(cfg, email) {
238
272
  writeDb(db);
239
273
  return target;
240
274
  }
241
- function storeTokens(cfg, email, tokenResp) {
242
- const db = readDb();
243
- const scope = getScope(db, cfg);
244
- const now = nowEpoch();
245
- scope.accounts[email] = {
275
+ function toSession(tokenResp, now) {
276
+ return {
246
277
  accessToken: tokenResp.accessToken,
247
278
  refreshToken: tokenResp.refreshToken,
248
279
  accessExpEpoch: now + (tokenResp.accessTokenExpiresInSec ?? 0),
249
280
  refreshExpEpoch: tokenResp.refreshToken ? now + (tokenResp.refreshTokenExpiresInSec ?? 0) : 0
250
281
  };
282
+ }
283
+ /**
284
+ * @param apiResp the API-only session, when this call has a fresh one. Omitted on a shell refresh,
285
+ * which must leave any existing `api` session in place -- rewriting the account wholesale would
286
+ * silently drop it and send `edge-cli token` back to "run login again" after every refresh.
287
+ */
288
+ function storeTokens(cfg, email, tokenResp, apiResp) {
289
+ const db = readDb();
290
+ const scope = getScope(db, cfg);
291
+ const now = nowEpoch();
292
+ const existingApi = scope.accounts[email]?.api;
293
+ scope.accounts[email] = {
294
+ ...toSession(tokenResp, now),
295
+ api: apiResp ? toSession(apiResp, now) : existingApi
296
+ };
251
297
  scope.default = email;
252
298
  writeDb(db);
253
299
  }
300
+ /**
301
+ * Replace only the API session, leaving the shell session untouched.
302
+ */
303
+ function storeApiTokens(cfg, email, apiResp) {
304
+ const db = readDb();
305
+ const scope = getScope(db, cfg);
306
+ const account = scope.accounts[email];
307
+ if (!account) {
308
+ return;
309
+ }
310
+ account.api = toSession(apiResp, nowEpoch());
311
+ writeDb(db);
312
+ }
254
313
  function parseCallbackQuery(queryString) {
255
314
  const query = queryString.startsWith("?") ? queryString.slice(1) : queryString;
256
315
  const params = new URLSearchParams(query);
@@ -401,8 +460,13 @@ async function login(cfg) {
401
460
  throw new Error("Authentication callback returned code-based response, exchange not implemented.");
402
461
  }
403
462
  let tokenResp;
463
+ // Kept so the API-only session can be exchanged from the same credential below. The
464
+ // already-first-party branch leaves it undefined: there is no Firebase token to re-exchange,
465
+ // so that path yields a shell session only.
466
+ let firebaseToken;
404
467
  if (parsed.firebaseToken) {
405
- tokenResp = await authExchangeFirebaseToken(cfg, parsed.firebaseToken);
468
+ firebaseToken = parsed.firebaseToken;
469
+ tokenResp = await authExchangeFirebaseToken(cfg, firebaseToken);
406
470
  }
407
471
  else if (parsed.accessToken && parsed.refreshToken) {
408
472
  // Already a first-party pair; nothing to exchange.
@@ -415,15 +479,47 @@ async function login(cfg) {
415
479
  }
416
480
  else if (parsed.accessToken) {
417
481
  // No refresh token alongside it means this is the console's `?jwt=` Firebase token.
418
- tokenResp = await exchangeCallbackToken(cfg, parsed.accessToken);
482
+ firebaseToken = parsed.accessToken;
483
+ tokenResp = await exchangeCallbackToken(cfg, firebaseToken);
419
484
  }
420
485
  if (!tokenResp?.accessToken) {
421
486
  throw new Error("Authentication callback did not include usable token data.");
422
487
  }
423
488
  const email = parsed.email ?? extractEmailFromToken(tokenResp.accessToken);
424
- storeTokens(cfg, email, tokenResp);
489
+ storeTokens(cfg, email, tokenResp, await exchangeApiSession(cfg, firebaseToken));
425
490
  return email;
426
491
  }
492
+ /**
493
+ * Exchange the same Firebase token a second time for an API-only session.
494
+ *
495
+ * Failure is reported and swallowed rather than failing the login: the shell session -- the reason
496
+ * anyone runs this CLI -- already succeeded by this point, and taking the whole login down over the
497
+ * optional half would turn a working setup into a broken one. `edge-cli token` says what to do when
498
+ * the session is missing.
499
+ */
500
+ async function exchangeApiSession(cfg, firebaseToken) {
501
+ if (!firebaseToken) {
502
+ return undefined;
503
+ }
504
+ try {
505
+ const resp = await authExchangeFirebaseToken(cfg, firebaseToken, EXCHANGE_SCOPE_APP);
506
+ if (carriesShellAudience(resp.accessToken)) {
507
+ // Storing it would put a shell-capable token behind `edge-cli token`, which is the one
508
+ // outcome this whole feature exists to prevent. Better no API session than a mislabelled
509
+ // one.
510
+ process.stderr.write("Warning: the server returned a shell-scoped token for an app-scoped exchange; " +
511
+ "discarding it.\n'edge-cli token' will need --scope shell until this is resolved.\n");
512
+ return undefined;
513
+ }
514
+ return resp;
515
+ }
516
+ catch (err) {
517
+ const reason = err instanceof Error ? err.message : String(err);
518
+ process.stderr.write(`Warning: could not create an API-only session (${reason}).\n` +
519
+ `'edge-cli token' will need --scope shell until the next successful login.\n`);
520
+ return undefined;
521
+ }
522
+ }
427
523
  async function validateAccessToken(cfg, accessToken) {
428
524
  const url = new URL(`${cfg.baseUrl.replace(/\/$/, "")}/shell`);
429
525
  url.searchParams.set("cmd", "help");
@@ -496,6 +592,60 @@ async function resolveAccessToken(cfg, forceRefresh = false, skipValidate = fals
496
592
  }
497
593
  return accessToken;
498
594
  }
595
+ /**
596
+ * The account's API-only access token, refreshed when it is at or near expiry.
597
+ *
598
+ * Deliberately does no liveness probe. `validateAccessToken` asks `GET /shell?cmd=help`, which this
599
+ * token is *supposed* to be refused by -- running it here would read a correctly-scoped token as a
600
+ * broken one and refresh it on every call.
601
+ *
602
+ * A missing API session is an error rather than a silent fall back to the shell token: handing back
603
+ * a token with more audiences than the caller asked for is the same silent substitution the server
604
+ * refuses when it will not downgrade a shell request to APP.
605
+ */
606
+ async function resolveApiAccessToken(cfg, forceRefresh = false) {
607
+ const db = readDb();
608
+ const scope = getScope(db, cfg);
609
+ const email = scope.default;
610
+ if (!email) {
611
+ throw new Error(`No JWT session for ${cfg.product}/${cfg.env}. Run 'edge-cli login'.`);
612
+ }
613
+ const account = scope.accounts[email];
614
+ if (!account) {
615
+ throw new Error(`No access token for ${email}. Run 'edge-cli login'.`);
616
+ }
617
+ const api = account.api;
618
+ if (!api?.accessToken) {
619
+ throw new Error(`No API-only session for ${email} on ${cfg.product}/${cfg.env}.\n` +
620
+ `Run 'edge-cli login' to create one, or 'edge-cli token --scope shell' to print the ` +
621
+ `shell-capable token instead (it also drives the shell, so do not paste it elsewhere).`);
622
+ }
623
+ // Checked on read, not only on write, so a session stored by a build that asked for the wrong
624
+ // scope is refused rather than handed out. Those tokens are already on disk in the wild.
625
+ if (carriesShellAudience(api.accessToken)) {
626
+ throw new Error(`The stored API session for ${email} carries the ${SHELL_AUDIENCE} audience, so it is ` +
627
+ `not API-only. Run 'edge-cli login' to replace it.`);
628
+ }
629
+ const expiry = api.accessExpEpoch ?? 0;
630
+ const stale = expiry > 0 && expiry <= nowEpoch() + 60;
631
+ if ((forceRefresh || stale) && api.refreshToken) {
632
+ try {
633
+ const refreshed = await authRefreshToken(cfg, api.refreshToken);
634
+ const merged = {
635
+ accessToken: refreshed.accessToken,
636
+ refreshToken: refreshed.refreshToken || api.refreshToken,
637
+ accessTokenExpiresInSec: refreshed.accessTokenExpiresInSec,
638
+ refreshTokenExpiresInSec: refreshed.refreshTokenExpiresInSec
639
+ };
640
+ storeApiTokens(cfg, email, merged);
641
+ return merged.accessToken;
642
+ }
643
+ catch {
644
+ // Fall through to the stored token; it may still have life left in it.
645
+ }
646
+ }
647
+ return api.accessToken;
648
+ }
499
649
  async function resolveAuthHeader(cfg) {
500
650
  const accessToken = await resolveAccessToken(cfg);
501
651
  return `Bearer ${accessToken}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youkno/edge-cli",
3
- "version": "1.21.316",
3
+ "version": "1.21.318",
4
4
  "description": "Cross-platform Edge CLI",
5
5
  "bin": "dist/index.js",
6
6
  "publishConfig": {
@@ -1,20 +1,43 @@
1
1
  import { Command } from "commander";
2
2
 
3
- import { resolveAccessToken } from "../lib/auth";
3
+ import { resolveAccessToken, resolveApiAccessToken } from "../lib/auth";
4
4
  import { resolveEffectiveConfig } from "../lib/config";
5
5
  import { CliOptions } from "../lib/types";
6
6
 
7
+ const SCOPES = ["api", "shell"] as const;
8
+ type TokenScope = (typeof SCOPES)[number];
9
+
7
10
  export function registerToken(program: Command): void {
8
11
  program
9
12
  .command("token")
10
13
  .description("Print a JWT access token for API calls (e.g. Postman)")
11
14
  .option("--refresh", "Force refresh using refresh token when available")
12
15
  .option("--header", "Print as Authorization header line")
13
- .action(async function tokenAction(this: Command, commandOptions: { refresh?: boolean; header?: boolean }) {
16
+ .option(
17
+ "--scope <scope>",
18
+ `Which token to print: ${SCOPES.join(" | ")}. 'api' carries the edge-api audience only; ` +
19
+ `'shell' also carries edge-shell and can run shell commands`,
20
+ "api"
21
+ )
22
+ .action(async function tokenAction(
23
+ this: Command,
24
+ commandOptions: { refresh?: boolean; header?: boolean; scope?: string }
25
+ ) {
14
26
  const parent = this.parent;
15
27
  const opts = (parent?.opts() ?? {}) as CliOptions;
16
28
  const cfg = await resolveEffectiveConfig(opts);
17
- const token = await resolveAccessToken(cfg, Boolean(commandOptions.refresh));
29
+
30
+ const requested = (commandOptions.scope ?? "api").toLowerCase();
31
+ if (!SCOPES.includes(requested as TokenScope)) {
32
+ // Refused rather than defaulted: printing a token of a scope nobody asked for is
33
+ // how a shell-capable credential ends up somewhere it was never meant to go.
34
+ throw new Error(`Unknown --scope: ${commandOptions.scope} (expected ${SCOPES.join(" or ")})`);
35
+ }
36
+
37
+ const token =
38
+ requested === "shell"
39
+ ? await resolveAccessToken(cfg, Boolean(commandOptions.refresh))
40
+ : await resolveApiAccessToken(cfg, Boolean(commandOptions.refresh));
18
41
 
19
42
  if (commandOptions.header) {
20
43
  process.stdout.write(`Authorization: Bearer ${token}\n`);