@youkno/edge-cli 1.21.312 → 1.21.313

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.
@@ -102,13 +102,55 @@ async function shellScript(cfg, authHeader, scriptText, options, scriptName = "i
102
102
  }
103
103
  process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
104
104
  }
105
+ const REPL_COMMANDS = new Map([
106
+ ["login", {
107
+ description: "re-authenticate this session (local; does not touch the server)",
108
+ // The reverse of `login --shell`, which logs in and then opens the REPL. Without this, a
109
+ // token expiring mid-session meant quitting, re-authenticating and starting over -- and
110
+ // since resolveAccessToken re-reads the token store on every command, the very next line
111
+ // picks up the new session with nothing to invalidate.
112
+ run: async (cfg) => {
113
+ const email = await (0, auth_1.login)(cfg);
114
+ process.stdout.write(`Successfully authenticated as ${email}\n`);
115
+ }
116
+ }],
117
+ ["help", {
118
+ description: "list these local commands",
119
+ run: async () => {
120
+ const width = Math.max(...[...REPL_COMMANDS.keys()].map((n) => n.length));
121
+ process.stdout.write("Local commands (handled by the CLI, never sent to the server):\n");
122
+ for (const [name, spec] of REPL_COMMANDS) {
123
+ process.stdout.write(` /${name.padEnd(width)} ${spec.description}\n`);
124
+ }
125
+ // Said explicitly because `help` and `/help` are different commands answering about
126
+ // different things, and nothing else in the REPL would tell you that.
127
+ process.stdout.write("\nFor server commands, use 'help' without the slash.\n");
128
+ }
129
+ }]
130
+ ]);
131
+ /**
132
+ * Runs one slash command. The caller routes every slash-prefixed line here, so this never has to
133
+ * decide whether the line was meant for it.
134
+ */
135
+ async function handleReplCommand(cfg, cmd) {
136
+ const name = cmd.slice(1).trim().toLowerCase();
137
+ const spec = REPL_COMMANDS.get(name);
138
+ if (spec) {
139
+ await spec.run(cfg);
140
+ return;
141
+ }
142
+ // Unknown slash command: say so rather than forwarding it to the server, which would answer
143
+ // with a confusing parse error about a command that was never meant for it.
144
+ const known = [...REPL_COMMANDS.keys()].map((n) => `/${n}`).join(", ");
145
+ process.stderr.write(`Unknown command: ${cmd}. Available: ${known}\n`);
146
+ }
105
147
  async function repl(cfg, options) {
106
148
  const rl = node_readline_1.default.createInterface({
107
149
  input: process.stdin,
108
150
  output: process.stdout,
109
151
  terminal: true
110
152
  });
111
- process.stdout.write("Type Ctrl-D to exit, 'help' for help\n");
153
+ process.stdout.write("Type Ctrl-D to exit, 'help' for server commands, '/help' for local ones\n");
112
154
  rl.setPrompt(`${cfg.env}> `);
113
155
  rl.prompt();
114
156
  rl.on("line", async (line) => {
@@ -117,6 +159,22 @@ async function repl(cfg, options) {
117
159
  rl.prompt();
118
160
  return;
119
161
  }
162
+ if (cmd.startsWith("/")) {
163
+ // Input is paused for the duration. readline does not await this handler, so without
164
+ // it a browser login -- which waits on a human -- would let lines typed meanwhile fire
165
+ // concurrently and run against the pre-login token, which is the state /login exists
166
+ // to get out of.
167
+ rl.pause();
168
+ try {
169
+ await handleReplCommand(cfg, cmd);
170
+ }
171
+ catch (err) {
172
+ process.stderr.write(`ERROR: ${err instanceof Error ? err.message : String(err)}\n`);
173
+ }
174
+ rl.resume();
175
+ rl.prompt();
176
+ return;
177
+ }
120
178
  try {
121
179
  const authHeader = await (0, auth_1.resolveAuthHeaderFast)(cfg);
122
180
  await shellCmd(cfg, authHeader, cmd, Boolean(options.json));
package/dist/lib/auth.js CHANGED
@@ -20,6 +20,31 @@ const AUTH_DIR = process.env.EDGE_CLI_AUTH_DIR
20
20
  : node_path_1.default.join(node_os_1.default.homedir(), ".edge-cli-auth");
21
21
  const AUTH_FILE = node_path_1.default.join(AUTH_DIR, "accounts.json");
22
22
  const DEFAULT_DEVICE_NAME = "edge-cli";
23
+ /**
24
+ * Value of the `scope` field sent at token exchange, so the server knows this token is for the
25
+ * shell rather than for an app session.
26
+ *
27
+ * Named EXCHANGE_SCOPE_* deliberately: "scope" already means something else in this file -- the
28
+ * product/env bucket of the local token store (`scopeKey`, `db.scopes`). These are unrelated, and
29
+ * the wire field keeps the OAuth-conventional name while the constant says which "scope" it is.
30
+ *
31
+ * `/api/v1/auth/exchange` is not a CLI endpoint -- it is the primary auth path for the mobile apps
32
+ * and the regulars web app too -- so the server cannot infer "this is the CLI" and cannot apply a
33
+ * stricter rule to everyone. This says so explicitly.
34
+ *
35
+ * Not derived from `deviceName`, which already reads "edge-cli": that is a display string for
36
+ * session lists, and quietly promoting cosmetic client input into an authorization input is how a
37
+ * bypass arrives unnoticed.
38
+ *
39
+ * Sent on **exchange only, never on refresh**. A refresh must re-derive the scope from the session
40
+ * the server persisted it on; honouring it from the request would let anyone escalate a non-shell
41
+ * session to a shell one by adding this field to a refresh call.
42
+ *
43
+ * Harmless against a server that predates the field: the API's ObjectMapper sets
44
+ * FAIL_ON_UNKNOWN_PROPERTIES=false, so an older deployment ignores it rather than rejecting the
45
+ * exchange.
46
+ */
47
+ const EXCHANGE_SCOPE_SHELL = "shell";
23
48
  function scopeKey(cfg) {
24
49
  return `${cfg.product}/${cfg.env}`;
25
50
  }
@@ -130,7 +155,8 @@ async function authExchangeFirebaseToken(cfg, firebaseToken) {
130
155
  const resp = await requestTokenEndpoint(cfg, "exchange", {
131
156
  firebaseToken,
132
157
  deviceId: authDeviceId(),
133
- deviceName: DEFAULT_DEVICE_NAME
158
+ deviceName: DEFAULT_DEVICE_NAME,
159
+ scope: EXCHANGE_SCOPE_SHELL
134
160
  });
135
161
  if (!resp?.accessToken) {
136
162
  throw new Error("Token exchange did not return accessToken");
@@ -164,9 +190,10 @@ async function authLogoutRefreshToken(cfg, refreshToken) {
164
190
  * Exchanging it gets a first-party access token plus a refresh token (30 days by default), which
165
191
  * resolveAccessToken already rotates automatically.
166
192
  *
167
- * Falls back to storing the token as-is when the exchange is unavailable -- an older server without
168
- * /auth/exchange, or one with auth.issueFirstPartyTokens disabled -- so login still works there,
169
- * with the same one-hour life it always had.
193
+ * There is NO fallback: a failed exchange fails the login. Storing the callback token as-is meant a
194
+ * server that answered authoritatively -- 401 "User doesn't exist" -- still reported
195
+ * "Successfully authenticated as ..." while storing a credential every API call rejects. See the
196
+ * commit that removed it for what the fallback used to cover and what dropping it costs.
170
197
  */
171
198
  async function exchangeCallbackToken(cfg, callbackToken) {
172
199
  try {
@@ -174,13 +201,14 @@ async function exchangeCallbackToken(cfg, callbackToken) {
174
201
  }
175
202
  catch (err) {
176
203
  const reason = err instanceof Error ? err.message : String(err);
177
- process.stdout.write(`Warning: could not exchange for a first-party token (${reason}).\n` +
178
- `Falling back to the raw callback token, which expires in about an hour and cannot be refreshed.\n`);
179
- return {
180
- accessToken: callbackToken,
181
- accessTokenExpiresInSec: 3600,
182
- refreshTokenExpiresInSec: 0
183
- };
204
+ // The identity hint only fits a 401. On a network error or a 5xx, telling someone to check
205
+ // which account they used is misdirection -- the same species of misleading report this
206
+ // whole change removes.
207
+ const hint = reason.includes("(401)")
208
+ ? `\nA 401 here usually means this account has no user record on ${cfg.product} -- ` +
209
+ `check you are signing in with the right identity for this product.`
210
+ : "";
211
+ throw new Error(`Login failed: could not exchange for a first-party token (${reason}).${hint}`, { cause: err });
184
212
  }
185
213
  }
186
214
  async function logout(cfg, email) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youkno/edge-cli",
3
- "version": "1.21.312",
3
+ "version": "1.21.313",
4
4
  "description": "Cross-platform Edge CLI",
5
5
  "bin": "dist/index.js",
6
6
  "publishConfig": {
@@ -4,7 +4,7 @@ import readline from "node:readline";
4
4
 
5
5
  import { Command } from "commander";
6
6
 
7
- import { resolveAuthHeader, resolveAuthHeaderFast } from "../lib/auth";
7
+ import { login, resolveAuthHeader, resolveAuthHeaderFast } from "../lib/auth";
8
8
  import { resolveEffectiveConfig } from "../lib/config";
9
9
  import { baseHeaders, checkedText } from "../lib/request";
10
10
  import { CliOptions, EffectiveConfig } from "../lib/types";
@@ -126,6 +126,71 @@ async function shellScript(
126
126
  process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
127
127
  }
128
128
 
129
+ /**
130
+ * Client-side REPL commands, distinguished by a leading slash.
131
+ *
132
+ * Slash-prefixed because no server command starts with one, so there is no spelling these can
133
+ * shadow -- every other line in the REPL still goes straight to the server untouched. The sigil
134
+ * also carries real information here: these act on the LOCAL token store, while every other
135
+ * command in this prompt acts on production data.
136
+ *
137
+ * A table rather than an if-chain, with only two entries, because the list is consumed three times
138
+ * -- dispatch, /help, and the unknown-command error. Those drifting apart is the failure this
139
+ * shape prevents; adding a third command updates all three by construction.
140
+ *
141
+ * A Map, not an object literal: the key comes from whatever the user typed, and a plain object
142
+ * would resolve /constructor and /toString through Object.prototype -- truthy, so they passed the
143
+ * lookup and then died on `spec.run is not a function` instead of answering "Unknown command".
144
+ * A Map has no prototype chain to fall through, which makes that class of answer impossible rather
145
+ * than guarded against.
146
+ */
147
+ type ReplCommand = { description: string; run: (cfg: EffectiveConfig) => Promise<void> };
148
+
149
+ const REPL_COMMANDS = new Map<string, ReplCommand>([
150
+ ["login", {
151
+ description: "re-authenticate this session (local; does not touch the server)",
152
+ // The reverse of `login --shell`, which logs in and then opens the REPL. Without this, a
153
+ // token expiring mid-session meant quitting, re-authenticating and starting over -- and
154
+ // since resolveAccessToken re-reads the token store on every command, the very next line
155
+ // picks up the new session with nothing to invalidate.
156
+ run: async (cfg) => {
157
+ const email = await login(cfg);
158
+ process.stdout.write(`Successfully authenticated as ${email}\n`);
159
+ }
160
+ }],
161
+ ["help", {
162
+ description: "list these local commands",
163
+ run: async () => {
164
+ const width = Math.max(...[...REPL_COMMANDS.keys()].map((n) => n.length));
165
+ process.stdout.write("Local commands (handled by the CLI, never sent to the server):\n");
166
+ for (const [name, spec] of REPL_COMMANDS) {
167
+ process.stdout.write(` /${name.padEnd(width)} ${spec.description}\n`);
168
+ }
169
+ // Said explicitly because `help` and `/help` are different commands answering about
170
+ // different things, and nothing else in the REPL would tell you that.
171
+ process.stdout.write("\nFor server commands, use 'help' without the slash.\n");
172
+ }
173
+ }]
174
+ ]);
175
+
176
+ /**
177
+ * Runs one slash command. The caller routes every slash-prefixed line here, so this never has to
178
+ * decide whether the line was meant for it.
179
+ */
180
+ async function handleReplCommand(cfg: EffectiveConfig, cmd: string): Promise<void> {
181
+ const name = cmd.slice(1).trim().toLowerCase();
182
+ const spec = REPL_COMMANDS.get(name);
183
+ if (spec) {
184
+ await spec.run(cfg);
185
+ return;
186
+ }
187
+
188
+ // Unknown slash command: say so rather than forwarding it to the server, which would answer
189
+ // with a confusing parse error about a command that was never meant for it.
190
+ const known = [...REPL_COMMANDS.keys()].map((n) => `/${n}`).join(", ");
191
+ process.stderr.write(`Unknown command: ${cmd}. Available: ${known}\n`);
192
+ }
193
+
129
194
  export async function repl(cfg: EffectiveConfig, options: ShellOptions) {
130
195
  const rl = readline.createInterface({
131
196
  input: process.stdin,
@@ -133,7 +198,7 @@ export async function repl(cfg: EffectiveConfig, options: ShellOptions) {
133
198
  terminal: true
134
199
  });
135
200
 
136
- process.stdout.write("Type Ctrl-D to exit, 'help' for help\n");
201
+ process.stdout.write("Type Ctrl-D to exit, 'help' for server commands, '/help' for local ones\n");
137
202
  rl.setPrompt(`${cfg.env}> `);
138
203
  rl.prompt();
139
204
 
@@ -143,6 +208,21 @@ export async function repl(cfg: EffectiveConfig, options: ShellOptions) {
143
208
  rl.prompt();
144
209
  return;
145
210
  }
211
+ if (cmd.startsWith("/")) {
212
+ // Input is paused for the duration. readline does not await this handler, so without
213
+ // it a browser login -- which waits on a human -- would let lines typed meanwhile fire
214
+ // concurrently and run against the pre-login token, which is the state /login exists
215
+ // to get out of.
216
+ rl.pause();
217
+ try {
218
+ await handleReplCommand(cfg, cmd);
219
+ } catch (err) {
220
+ process.stderr.write(`ERROR: ${err instanceof Error ? err.message : String(err)}\n`);
221
+ }
222
+ rl.resume();
223
+ rl.prompt();
224
+ return;
225
+ }
146
226
  try {
147
227
  const authHeader = await resolveAuthHeaderFast(cfg);
148
228
  await shellCmd(cfg, authHeader, cmd, Boolean(options.json));
package/src/lib/auth.ts CHANGED
@@ -12,6 +12,32 @@ const AUTH_DIR = process.env.EDGE_CLI_AUTH_DIR
12
12
  const AUTH_FILE = path.join(AUTH_DIR, "accounts.json");
13
13
  const DEFAULT_DEVICE_NAME = "edge-cli";
14
14
 
15
+ /**
16
+ * Value of the `scope` field sent at token exchange, so the server knows this token is for the
17
+ * shell rather than for an app session.
18
+ *
19
+ * Named EXCHANGE_SCOPE_* deliberately: "scope" already means something else in this file -- the
20
+ * product/env bucket of the local token store (`scopeKey`, `db.scopes`). These are unrelated, and
21
+ * the wire field keeps the OAuth-conventional name while the constant says which "scope" it is.
22
+ *
23
+ * `/api/v1/auth/exchange` is not a CLI endpoint -- it is the primary auth path for the mobile apps
24
+ * and the regulars web app too -- so the server cannot infer "this is the CLI" and cannot apply a
25
+ * stricter rule to everyone. This says so explicitly.
26
+ *
27
+ * Not derived from `deviceName`, which already reads "edge-cli": that is a display string for
28
+ * session lists, and quietly promoting cosmetic client input into an authorization input is how a
29
+ * bypass arrives unnoticed.
30
+ *
31
+ * Sent on **exchange only, never on refresh**. A refresh must re-derive the scope from the session
32
+ * the server persisted it on; honouring it from the request would let anyone escalate a non-shell
33
+ * session to a shell one by adding this field to a refresh call.
34
+ *
35
+ * Harmless against a server that predates the field: the API's ObjectMapper sets
36
+ * FAIL_ON_UNKNOWN_PROPERTIES=false, so an older deployment ignores it rather than rejecting the
37
+ * exchange.
38
+ */
39
+ const EXCHANGE_SCOPE_SHELL = "shell";
40
+
15
41
  type AuthAccount = {
16
42
  accessToken: string;
17
43
  refreshToken?: string;
@@ -163,7 +189,8 @@ async function authExchangeFirebaseToken(cfg: EffectiveConfig, firebaseToken: st
163
189
  const resp = await requestTokenEndpoint(cfg, "exchange", {
164
190
  firebaseToken,
165
191
  deviceId: authDeviceId(),
166
- deviceName: DEFAULT_DEVICE_NAME
192
+ deviceName: DEFAULT_DEVICE_NAME,
193
+ scope: EXCHANGE_SCOPE_SHELL
167
194
  });
168
195
  if (!resp?.accessToken) {
169
196
  throw new Error("Token exchange did not return accessToken");
@@ -200,24 +227,25 @@ async function authLogoutRefreshToken(cfg: EffectiveConfig, refreshToken: string
200
227
  * Exchanging it gets a first-party access token plus a refresh token (30 days by default), which
201
228
  * resolveAccessToken already rotates automatically.
202
229
  *
203
- * Falls back to storing the token as-is when the exchange is unavailable -- an older server without
204
- * /auth/exchange, or one with auth.issueFirstPartyTokens disabled -- so login still works there,
205
- * with the same one-hour life it always had.
230
+ * There is NO fallback: a failed exchange fails the login. Storing the callback token as-is meant a
231
+ * server that answered authoritatively -- 401 "User doesn't exist" -- still reported
232
+ * "Successfully authenticated as ..." while storing a credential every API call rejects. See the
233
+ * commit that removed it for what the fallback used to cover and what dropping it costs.
206
234
  */
207
235
  async function exchangeCallbackToken(cfg: EffectiveConfig, callbackToken: string): Promise<TokenResponse> {
208
236
  try {
209
237
  return await authExchangeFirebaseToken(cfg, callbackToken);
210
238
  } catch (err) {
211
239
  const reason = err instanceof Error ? err.message : String(err);
212
- process.stdout.write(
213
- `Warning: could not exchange for a first-party token (${reason}).\n` +
214
- `Falling back to the raw callback token, which expires in about an hour and cannot be refreshed.\n`
215
- );
216
- return {
217
- accessToken: callbackToken,
218
- accessTokenExpiresInSec: 3600,
219
- refreshTokenExpiresInSec: 0
220
- };
240
+ // The identity hint only fits a 401. On a network error or a 5xx, telling someone to check
241
+ // which account they used is misdirection -- the same species of misleading report this
242
+ // whole change removes.
243
+ const hint = reason.includes("(401)")
244
+ ? `\nA 401 here usually means this account has no user record on ${cfg.product} -- ` +
245
+ `check you are signing in with the right identity for this product.`
246
+ : "";
247
+ throw new Error(`Login failed: could not exchange for a first-party token (${reason}).${hint}`,
248
+ { cause: err });
221
249
  }
222
250
  }
223
251