alexandr 0.2.2 → 0.3.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/src/link.js CHANGED
@@ -10,22 +10,19 @@
10
10
  // step one (an unlinked box refuses to serve), and `alexandr link` remains the explicit
11
11
  // re-link/repair verb.
12
12
 
13
- import http from "node:http";
14
- import os from "node:os";
15
- import crypto from "node:crypto";
16
- import readline from "node:readline";
17
- import { log, dim, bold, cyan, fail, step, ok, warn, openURL, sleep } from "./util.js";
13
+ import { log, dim, fail, step, ok, warn } from "./util.js";
18
14
  import { resolveInstance, isMaterialized, readEnv, kernelPort, setEnv, unsetEnv } from "./instance.js";
19
15
  import { kernelUrl, health, waitPosture } from "./probe.js";
20
16
  import { compose, exec } from "./docker.js";
17
+ // ⚠ THE CEREMONY MOVED to ./consent.js 2026-09-05 (app-system-stage-1.md §2 WP-E)
18
+ // so `alexandr app link` could run the same one with a different SCOPE. Copying
19
+ // it would have left two drifting implementations of the security-critical half
20
+ // of this CLI. Nothing about the verbs below changed: they ask for no scope, so
21
+ // they still get today's 10-minute `cli-link` session.
22
+ import { APP_URL, CP_URL, consentSession, isHeadless, postJson, presentAuthUrl, startLoopback } from "./consent.js";
21
23
 
22
- const b64url = (buf) => buf.toString("base64url");
23
- // Default to the hosted control plane + the WEBSITE (the one web property — the web app retired,
24
- // website-account-surface.md); override for dev (e.g. http://localhost:4000 + http://localhost:3000)
25
- // via env. The link-consent page (/cli-auth) + the account page are website surfaces now.
26
- const CP_URL = (process.env.ALEXANDR_CP_URL || "https://api.alexandr.so").replace(/\/+$/, "");
27
- const APP_URL = (process.env.ALEXANDR_APP_URL || "https://alexandr.so").replace(/\/+$/, "");
28
- const TIMEOUT_MS = 5 * 60 * 1000;
24
+ // Re-exported: these were this module's public surface before the extraction.
25
+ export { isHeadless, presentAuthUrl, startLoopback };
29
26
 
30
27
  /** Whether this instance's .env already carries the connected credential trio. */
31
28
  export function isLinked(dir) {
@@ -103,14 +100,11 @@ export async function runLinkCeremony(inst, flags) {
103
100
 
104
101
  step(`Link ${boxUrl} to your alexandr account`);
105
102
  let sessionToken;
106
- if (useDeviceFlow()) {
107
- try {
108
- sessionToken = await deviceGrantToken(boxUrl, name, "link");
109
- } catch (e) {
110
- fail(`Link aborted: ${e.message}`);
111
- }
112
- } else {
113
- sessionToken = await loopbackGrantToken({ boxUrl, name, domain });
103
+ try {
104
+ // No scope: this ceremony wants today's short single-use `cli-link` session.
105
+ sessionToken = (await consentSession({ host: boxUrl, name, intent: "link", domain })).token;
106
+ } catch (e) {
107
+ fail(`Link aborted: ${e.message}`);
114
108
  }
115
109
  await registerAndPersist(inst, { boxUrl, name, sessionToken });
116
110
  }
@@ -126,93 +120,6 @@ function instanceCoords(inst, flags) {
126
120
  return { domain, boxUrl };
127
121
  }
128
122
 
129
- /** The DESKTOP grant — OAuth authorization-code + PKCE against a loopback redirect the
130
- * browser on THIS machine can reach. Returns the short-lived session token. */
131
- async function loopbackGrantToken({ boxUrl, name, domain, intent }) {
132
- // PKCE (S256) + a CSRF state for the loopback redirect.
133
- const verifier = b64url(crypto.randomBytes(32));
134
- const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
135
- const state = b64url(crypto.randomBytes(16));
136
-
137
- const { port, done } = await startLoopback();
138
- const redirectUri = `http://127.0.0.1:${port}/callback`;
139
- const authUrl =
140
- `${APP_URL}/cli-auth?` +
141
- new URLSearchParams({
142
- redirect_uri: redirectUri,
143
- state,
144
- code_challenge: challenge,
145
- code_challenge_method: "S256",
146
- host: boxUrl,
147
- name,
148
- // Copy-only hint for the consent card ("refresh" renews the image credential and
149
- // registers nothing) — the grant's power is identical either way.
150
- ...(intent && intent !== "link" ? { intent } : {}),
151
- }).toString();
152
-
153
- await presentAuthUrl(authUrl, { port, domain });
154
-
155
- let cb;
156
- try {
157
- cb = await done;
158
- } catch (e) {
159
- fail(`Link aborted: ${e.message}`);
160
- }
161
- if (cb.state !== state) fail("Link aborted: state mismatch (possible interception).");
162
-
163
- // Exchange the code (+ PKCE verifier) for a short-lived session, then register this runtime.
164
- const tok = await postJson(`${CP_URL}/cli-auth/token`, {
165
- code: cb.code,
166
- codeVerifier: verifier,
167
- redirectUri,
168
- });
169
- if (!tok.data?.token) {
170
- fail(`Link failed: could not exchange the authorization code (${tok.error ?? "malformed response"}).`);
171
- }
172
- return tok.data.token;
173
- }
174
-
175
- /**
176
- * The HEADLESS ceremony — the device-authorization flow (RFC 8628 shape), because a remote
177
- * box can't receive a loopback redirect and making the user build an ssh tunnel for a
178
- * sign-in was backwards. The CLI mints a grant, shows a short code + URL (any browser on
179
- * any device), and polls until the signed-in owner confirms. Throws on fatal (the caller
180
- * decides between fail() and best-effort skip); network blips just keep polling.
181
- */
182
- async function deviceGrantToken(host, name, intent) {
183
- const mint = await postJson(`${CP_URL}/cli-auth/device`, { host, name, intent });
184
- if (!mint.data?.deviceCode || !mint.data?.userCode) {
185
- throw new Error(`couldn't start the sign-in (${mint.error ?? "malformed response"}).`);
186
- }
187
- const { deviceCode, userCode, expiresIn = 600, interval = 3 } = mint.data;
188
- log("");
189
- step("Open this link on any device — your computer or your phone — and confirm the code:");
190
- log(` ${cyan(`${APP_URL}/cli-auth?code=${encodeURIComponent(userCode)}`)}`);
191
- log("");
192
- log(` Code: ${bold(userCode)}`);
193
- log("");
194
- log(dim(` (waiting for the confirmation — ${Math.round(expiresIn / 60)} minutes; this updates by itself)`));
195
- const deadline = Date.now() + expiresIn * 1000;
196
- while (Date.now() < deadline) {
197
- await sleep(interval * 1000);
198
- const res = await postJson(`${CP_URL}/cli-auth/device/token`, { deviceCode });
199
- if (res.data?.token) return res.data.token;
200
- if (res.data?.status === "pending") continue;
201
- if (res.error?.startsWith("HTTP")) throw new Error(`the sign-in was rejected (${res.error}).`);
202
- // Network blip — keep polling until the code's own deadline.
203
- }
204
- throw new Error("the code expired before it was confirmed — run the command again.");
205
- }
206
-
207
- /** Which ceremony fits this machine: the device flow wherever a local browser can't
208
- * receive the redirect (headless servers), the instant loopback redirect elsewhere.
209
- * ALEXANDR_DEVICE_FLOW=1|0 overrides either way. */
210
- function useDeviceFlow() {
211
- if (process.env.ALEXANDR_DEVICE_FLOW === "1") return true;
212
- if (process.env.ALEXANDR_DEVICE_FLOW === "0") return false;
213
- return isHeadless();
214
- }
215
-
216
123
  /** Register the runtime with a freshly-granted session and persist the credential trio. */
217
124
  async function registerAndPersist(inst, { boxUrl, name, sessionToken }) {
218
125
  const reg = await postJson(
@@ -310,9 +217,7 @@ export async function refreshRegistryLogin(inst, flags) {
310
217
  const { domain, boxUrl } = instanceCoords(inst, flags);
311
218
  const name = (readEnv(inst.dir).ALEXANDR_WORKSPACE_NAME || "").trim() || "Self-hosted runtime";
312
219
  step("Sign in to refresh this runtime's image credential…");
313
- const token = useDeviceFlow()
314
- ? await deviceGrantToken(boxUrl, name, "refresh")
315
- : await loopbackGrantToken({ boxUrl, name, domain, intent: "refresh" });
220
+ const { token } = await consentSession({ host: boxUrl, name, intent: "refresh", domain });
316
221
  return (await registryLogin(token)) === "done";
317
222
  }
318
223
 
@@ -329,62 +234,23 @@ export async function unlinkFromAccount(inst, flags) {
329
234
  return false;
330
235
  }
331
236
  step("Sign in to remove this runtime from your account…");
332
- if (useDeviceFlow()) {
333
- let token;
334
- try {
335
- token = await deviceGrantToken(kernelUrl(kernelPort(inst.dir)), "Unlink this runtime", "unlink");
336
- } catch (e) {
337
- log(dim(` (unlink skipped: ${e.message})`));
338
- return false;
339
- }
340
- try {
341
- const res = await fetch(`${CP_URL}/instances/${encodeURIComponent(workspaceId)}`, {
342
- method: "DELETE",
343
- headers: { authorization: `Bearer ${token}` },
344
- });
345
- if (res.ok) scrubCredentials(inst.dir);
346
- return res.ok;
347
- } catch {
348
- return false;
349
- }
350
- }
351
- const verifier = b64url(crypto.randomBytes(32));
352
- const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
353
- const state = b64url(crypto.randomBytes(16));
354
- const { port, done } = await startLoopback();
355
- const redirectUri = `http://127.0.0.1:${port}/callback`;
356
- const authUrl =
357
- `${APP_URL}/cli-auth?` +
358
- new URLSearchParams({
359
- redirect_uri: redirectUri,
360
- state,
361
- code_challenge: challenge,
362
- code_challenge_method: "S256",
237
+ let token;
238
+ try {
239
+ // No scope — a one-shot `cli-link` session is exactly what a DELETE needs.
240
+ ({ token } = await consentSession({
363
241
  host: kernelUrl(kernelPort(inst.dir)),
364
242
  name: "Unlink this runtime",
365
- }).toString();
366
- await presentAuthUrl(authUrl, { port, domain: (env.ALEXANDR_DOMAIN || "").trim() || undefined });
367
- let cb;
368
- try {
369
- cb = await done;
243
+ intent: "unlink",
244
+ domain: (env.ALEXANDR_DOMAIN || "").trim() || undefined,
245
+ }));
370
246
  } catch (e) {
371
247
  log(dim(` (unlink skipped: ${e.message})`));
372
248
  return false;
373
249
  }
374
- if (cb.state !== state) return false;
375
- const tok = await postJson(`${CP_URL}/cli-auth/token`, {
376
- code: cb.code,
377
- codeVerifier: verifier,
378
- redirectUri,
379
- });
380
- if (!tok.data?.token) {
381
- log(dim(` (unlink skipped: ${tok.error ?? "malformed response"})`));
382
- return false;
383
- }
384
250
  try {
385
251
  const res = await fetch(`${CP_URL}/instances/${encodeURIComponent(workspaceId)}`, {
386
252
  method: "DELETE",
387
- headers: { authorization: `Bearer ${tok.data.token}` },
253
+ headers: { authorization: `Bearer ${token}` },
388
254
  });
389
255
  if (res.ok) scrubCredentials(inst.dir);
390
256
  return res.ok;
@@ -402,119 +268,6 @@ function scrubCredentials(dir) {
402
268
  unsetEnv(dir, "ALEXANDR_WORKSPACE_ID");
403
269
  }
404
270
 
405
- /** No local browser to open — a Linux box with no display server. The PRIMARY self-host
406
- * case, so it must be first-class, not a dim afterthought. Pure for tests. */
407
- export function isHeadless(platform = process.platform, env = process.env) {
408
- return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY;
409
- }
410
-
411
- /**
412
- * Present the consent URL honestly, by what this machine can actually do:
413
- * - HEADLESS (a server): never pretend a browser opened. Numbered steps, tunnel FIRST
414
- * (the redirect lands on the desktop's loopback and must reach this box), URL bright.
415
- * - DESKTOP + TTY: ask before taking over the browser — the URL is printed either way,
416
- * so "open it yourself" is always available.
417
- * - DESKTOP non-TTY (scripts): old behavior — print + best-effort open, nothing blocks.
418
- */
419
- export async function presentAuthUrl(authUrl, { port, domain, headless = isHeadless() }) {
420
- const sshTarget = `${process.env.USER || "root"}@${domain || os.hostname()}`;
421
- if (headless) {
422
- log("");
423
- step("This machine has no browser — finish the sign-in from your computer:");
424
- log(` 1. Forward the callback port ${dim("(keep this running until you're done)")}:`);
425
- log(` ${bold(`ssh -L ${port}:127.0.0.1:${port} ${sshTarget}`)}`);
426
- log(` 2. Open this link in a browser signed in to your alexandr account:`);
427
- log(` ${cyan(authUrl)}`);
428
- log(dim(` (waiting for the confirmation — ${TIMEOUT_MS / 60000} minutes)`));
429
- return;
430
- }
431
- log(dim(authUrl));
432
- if (process.stdin.isTTY && process.stdout.isTTY) {
433
- await new Promise((resolve) => {
434
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
435
- rl.question(`${cyan("›")} Press Enter to open your browser and confirm ${dim("(or open the link above yourself)")} `, () => {
436
- rl.close();
437
- resolve();
438
- });
439
- });
440
- }
441
- openURL(authUrl);
442
- }
443
-
444
- /** Start a loopback listener for the OAuth redirect. Resolves {code,state} when /callback is hit. */
445
- export function startLoopback() {
446
- let resolveFn, rejectFn;
447
- const done = new Promise((res, rej) => {
448
- resolveFn = res;
449
- rejectFn = rej;
450
- });
451
- const server = http.createServer((req, res) => {
452
- const u = new URL(req.url, "http://127.0.0.1");
453
- // Reachability probe for the consent page: /cli-auth pings this before the user
454
- // clicks Link, so a missing ssh tunnel becomes a guided "start the tunnel" notice
455
- // instead of a dead browser error page after the click. The PNA header answers
456
- // Chrome's public→loopback preflight; ACAO lets the page read the success.
457
- if (u.pathname === "/ping") {
458
- res.writeHead(204, {
459
- "access-control-allow-origin": "*",
460
- "access-control-allow-methods": "GET, OPTIONS",
461
- "access-control-allow-headers": "*",
462
- "access-control-allow-private-network": "true",
463
- });
464
- res.end();
465
- return;
466
- }
467
- if (u.pathname !== "/callback") {
468
- res.writeHead(404);
469
- res.end();
470
- return;
471
- }
472
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
473
- res.end(
474
- "<!doctype html><meta charset=utf-8><body style='font:16px system-ui;padding:3rem;text-align:center'>Linked — you can close this tab and return to your terminal.</body>",
475
- );
476
- clearTimeout(timer);
477
- setTimeout(() => server.close(), 200);
478
- const code = u.searchParams.get("code");
479
- const st = u.searchParams.get("state");
480
- if (code && st) resolveFn({ code, state: st });
481
- else rejectFn(new Error("no authorization code in the redirect"));
482
- });
483
- const timer = setTimeout(() => {
484
- server.close();
485
- rejectFn(new Error("timed out waiting for the browser confirmation"));
486
- }, TIMEOUT_MS);
487
- return new Promise((ready, readyErr) => {
488
- server.once("error", (e) => {
489
- rejectFn(e);
490
- readyErr(e);
491
- });
492
- server.listen(0, "127.0.0.1", () => ready({ port: server.address().port, done }));
493
- });
494
- }
495
-
496
- /** POST json → `{ data }` on success, `{ error }` on failure. The CP writes human-readable
497
- * `error` strings (e.g. the closed-alpha 403 explains exactly who may register), so failures
498
- * must carry WHY — a bare null renders as "could not register this runtime" with the real
499
- * reason swallowed. Callers surface `error` in their fail/skip message. */
500
- async function postJson(url, body, headers = {}) {
501
- try {
502
- const res = await fetch(url, {
503
- method: "POST",
504
- headers: { "content-type": "application/json", ...headers },
505
- body: JSON.stringify(body),
506
- });
507
- const data = await res.json().catch(() => null);
508
- if (!res.ok) {
509
- const detail = typeof data?.error === "string" && data.error ? ` — ${data.error}` : "";
510
- return { error: `HTTP ${res.status}${detail}` };
511
- }
512
- return data == null ? { error: "malformed response" } : { data };
513
- } catch (e) {
514
- return { error: `couldn't reach ${new URL(url).origin}: ${e?.message ?? e}` };
515
- }
516
- }
517
-
518
271
  async function isRunning(dir) {
519
272
  try {
520
273
  return !!(await health(kernelUrl(kernelPort(dir))));
package/src/prompt.js CHANGED
@@ -114,3 +114,59 @@ export async function ask(question, { def = "", validate, input = process.stdin,
114
114
  return value;
115
115
  }
116
116
  }
117
+
118
+ /**
119
+ * A HIDDEN one-line input — for a secret, and only for a secret.
120
+ *
121
+ * ⚠⚠ THE VALUE NEVER TOUCHES ARGV. A command line is in the shell's history, in
122
+ * `ps` output for every user on the box while the process lives, and in any CI
123
+ * log that echoes the command — so `alexandr app secrets set NAME value` is
124
+ * refused by the parser, and this is what replaces it. Nothing is echoed and
125
+ * nothing is re-printed on the confirmation line.
126
+ *
127
+ * ⚠ Windows-safe by construction: pure `readline` with the output muted, no
128
+ * `stty`, no `/bin/sh`. `readline`'s own terminal handling raw-modes the TTY on
129
+ * both platforms; the muted stream swallows every write it makes while the
130
+ * question is up, so the keystrokes leave no trace on screen. `terminal: true`
131
+ * is what makes it use that handling rather than plain line buffering.
132
+ *
133
+ * Returns the raw string, untrimmed — a secret may legitimately end in
134
+ * whitespace, and it is the caller that decides whether an empty one is a
135
+ * refusal.
136
+ */
137
+ export function secret(question, { input = process.stdin, output = process.stdout } = {}) {
138
+ return new Promise((resolve) => {
139
+ let muted = false;
140
+ // A thin write-only proxy over the real stream: readline draws its prompt
141
+ // through this, and once the question is on screen every echo is dropped.
142
+ const masked = Object.create(output);
143
+ masked.write = (chunk, ...rest) => (muted ? true : output.write(chunk, ...rest));
144
+
145
+ const rl = readline.createInterface({ input, output: masked, terminal: true });
146
+ rl.on("SIGINT", () => abort(output));
147
+ rl.question(`${cyan("›")} ${bold(question)} `, (answer) => {
148
+ muted = false;
149
+ rl.close();
150
+ // The prompt line is overwritten rather than left with a blank tail —
151
+ // there is nothing to confirm back, so it says only that it was read.
152
+ output.write(`\x1b[2K\r${green("✓")} ${question} ${dim("·")} read from the prompt\n`);
153
+ resolve(answer ?? "");
154
+ });
155
+ muted = true;
156
+ });
157
+ }
158
+
159
+ /**
160
+ * A secret from a PIPE — `printf %s "$KEY" | alexandr app secrets set NAME`.
161
+ *
162
+ * ⚠ ONE TRAILING NEWLINE IS STRIPPED AND NOTHING ELSE IS. `echo` adds one and
163
+ * every shell user expects it gone; a second one, or leading whitespace, is
164
+ * part of the value the person piped and removing it would corrupt a key that
165
+ * legitimately holds it (a PEM block ends in a newline).
166
+ */
167
+ export async function readPipedSecret(input = process.stdin) {
168
+ const chunks = [];
169
+ for await (const chunk of input) chunks.push(chunk);
170
+ const raw = Buffer.concat(chunks.map((c) => (typeof c === "string" ? Buffer.from(c) : c))).toString("utf8");
171
+ return raw.endsWith("\r\n") ? raw.slice(0, -2) : raw.endsWith("\n") ? raw.slice(0, -1) : raw;
172
+ }