alexandr 0.2.2 → 0.3.1
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 +74 -1
- package/package.json +4 -3
- package/src/app/build.js +124 -0
- package/src/app/config.js +326 -0
- package/src/app/deploy.js +234 -0
- package/src/app/dev.js +177 -0
- package/src/app/entitle.js +139 -0
- package/src/app/index.js +125 -0
- package/src/app/link.js +187 -0
- package/src/app/multipart.js +53 -0
- package/src/app/publish.js +421 -0
- package/src/app/reach.js +100 -0
- package/src/app/rollback.js +72 -0
- package/src/app/signing.js +175 -0
- package/src/app/store.js +191 -0
- package/src/app/token.js +83 -0
- package/src/app/update.js +163 -0
- package/src/cli.js +8 -1
- package/src/commands.js +97 -12
- package/src/completion.js +15 -0
- package/src/consent.js +272 -0
- package/src/deps.js +1 -2
- package/src/instance.js +34 -1
- package/src/link.js +23 -270
- package/src/prompt.js +56 -0
- package/src/updater.js +276 -0
- package/templates/docker-compose.yml +33 -0
- package/templates/env.example +10 -0
package/src/consent.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// THE consent ceremony — one implementation, two callers.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from link.js 2026-09-05 (app-system-stage-1.md §2 WP-E). It was
|
|
4
|
+
// written for `alexandr link` (bind this box to an account) and is now also what
|
|
5
|
+
// `alexandr app link` runs, asking for a different SCOPE. Copying it would have
|
|
6
|
+
// meant two ceremonies drifting apart on the security-critical half of the CLI,
|
|
7
|
+
// so it moved here and grew one parameter.
|
|
8
|
+
//
|
|
9
|
+
// Two grant shapes, one ceremony:
|
|
10
|
+
// - DEVICE (RFC 8628 shape) on a headless machine: the CLI mints a grant,
|
|
11
|
+
// prints a short code + URL, and polls until a signed-in owner confirms from
|
|
12
|
+
// ANY device. No tunnel.
|
|
13
|
+
// - LOOPBACK (OAuth authorization-code + PKCE) on a desktop: the browser and
|
|
14
|
+
// the CLI share a machine, so the redirect lands instantly.
|
|
15
|
+
//
|
|
16
|
+
// SCOPE (WP-E). The CLI asks at the START and the owner sees it on the consent
|
|
17
|
+
// card; the CP records it on the grant and reads it back at redeem, never from
|
|
18
|
+
// the token request — a PKCE verifier (or a device code) proves possession, not
|
|
19
|
+
// authorization.
|
|
20
|
+
//
|
|
21
|
+
// (omitted) -> client "cli-link", 10 minutes. `alexandr link` uses it once.
|
|
22
|
+
// "app-dev" -> client "cli", 30 days sliding. `alexandr app link` keeps it.
|
|
23
|
+
//
|
|
24
|
+
// Zero dependencies. Plain Node, like the rest of this CLI.
|
|
25
|
+
|
|
26
|
+
import http from "node:http";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import crypto from "node:crypto";
|
|
29
|
+
import readline from "node:readline";
|
|
30
|
+
import { log, dim, bold, cyan, fail, step, sleep, openURL } from "./util.js";
|
|
31
|
+
|
|
32
|
+
const b64url = (buf) => buf.toString("base64url");
|
|
33
|
+
|
|
34
|
+
// The hosted control plane + the WEBSITE (the one web property — the web app
|
|
35
|
+
// retired, website-account-surface.md). Override for dev (e.g.
|
|
36
|
+
// http://localhost:47400 + http://localhost:47300) via env. The link-consent
|
|
37
|
+
// page (/cli-auth) is a website surface.
|
|
38
|
+
export const CP_URL = (process.env.ALEXANDR_CP_URL || "https://api.alexandr.so").replace(/\/+$/, "");
|
|
39
|
+
export const APP_URL = (process.env.ALEXANDR_APP_URL || "https://alexandr.so").replace(/\/+$/, "");
|
|
40
|
+
export const TIMEOUT_MS = 5 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run the ceremony this machine can actually complete and return the session it
|
|
44
|
+
* earned: `{ token, expiresAt, client, scope }` (older control planes answer
|
|
45
|
+
* with `token` alone, so read the rest defensively).
|
|
46
|
+
*
|
|
47
|
+
* Throws on any break in the chain — the caller decides between `fail()` and a
|
|
48
|
+
* soft skip.
|
|
49
|
+
*/
|
|
50
|
+
export async function consentSession({ host, name, intent = "link", scope, domain } = {}) {
|
|
51
|
+
return useDeviceFlow()
|
|
52
|
+
? deviceGrantSession({ host, name, intent, scope })
|
|
53
|
+
: loopbackGrantSession({ host, name, intent, scope, domain });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The DESKTOP grant — authorization-code + PKCE against a loopback redirect. */
|
|
57
|
+
export async function loopbackGrantSession({ host, name, intent, scope, domain }) {
|
|
58
|
+
const verifier = b64url(crypto.randomBytes(32));
|
|
59
|
+
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
60
|
+
const state = b64url(crypto.randomBytes(16));
|
|
61
|
+
|
|
62
|
+
const { port, done } = await startLoopback();
|
|
63
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
64
|
+
const authUrl =
|
|
65
|
+
`${APP_URL}/cli-auth?` +
|
|
66
|
+
new URLSearchParams({
|
|
67
|
+
redirect_uri: redirectUri,
|
|
68
|
+
state,
|
|
69
|
+
code_challenge: challenge,
|
|
70
|
+
code_challenge_method: "S256",
|
|
71
|
+
host: host ?? "",
|
|
72
|
+
name: name ?? "",
|
|
73
|
+
// ⚠ The consent page relays this into POST /cli-auth/authorize. WITHOUT it
|
|
74
|
+
// every PKCE `app-dev` link silently gets the 10-minute session instead.
|
|
75
|
+
...(scope ? { scope } : {}),
|
|
76
|
+
// Copy-only hint for the consent card ("refresh" renews the image
|
|
77
|
+
// credential and registers nothing) — the grant's power is identical.
|
|
78
|
+
...(intent && intent !== "link" ? { intent } : {}),
|
|
79
|
+
}).toString();
|
|
80
|
+
|
|
81
|
+
await presentAuthUrl(authUrl, { port, domain });
|
|
82
|
+
|
|
83
|
+
const cb = await done;
|
|
84
|
+
if (cb.state !== state) throw new Error("state mismatch (possible interception).");
|
|
85
|
+
|
|
86
|
+
const tok = await postJson(`${CP_URL}/cli-auth/token`, {
|
|
87
|
+
code: cb.code,
|
|
88
|
+
codeVerifier: verifier,
|
|
89
|
+
redirectUri,
|
|
90
|
+
});
|
|
91
|
+
if (!tok.data?.token) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`could not exchange the authorization code (${tok.error ?? "malformed response"}).`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return tok.data;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The HEADLESS ceremony — the device-authorization flow, because a remote box
|
|
101
|
+
* can't receive a loopback redirect and making the user build an ssh tunnel for
|
|
102
|
+
* a sign-in was backwards. Network blips just keep polling; a real rejection
|
|
103
|
+
* throws.
|
|
104
|
+
*/
|
|
105
|
+
export async function deviceGrantSession({ host, name, intent, scope }) {
|
|
106
|
+
const mint = await postJson(`${CP_URL}/cli-auth/device`, { host, name, intent, scope });
|
|
107
|
+
if (!mint.data?.deviceCode || !mint.data?.userCode) {
|
|
108
|
+
throw new Error(`couldn't start the sign-in (${mint.error ?? "malformed response"}).`);
|
|
109
|
+
}
|
|
110
|
+
const { deviceCode, userCode, expiresIn = 600, interval = 3 } = mint.data;
|
|
111
|
+
log("");
|
|
112
|
+
step("Open this link on any device — your computer or your phone — and confirm the code:");
|
|
113
|
+
log(` ${cyan(`${APP_URL}/cli-auth?code=${encodeURIComponent(userCode)}`)}`);
|
|
114
|
+
log("");
|
|
115
|
+
log(` Code: ${bold(userCode)}`);
|
|
116
|
+
log("");
|
|
117
|
+
log(dim(` (waiting for the confirmation — ${Math.round(expiresIn / 60)} minutes; this updates by itself)`));
|
|
118
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
await sleep(interval * 1000);
|
|
121
|
+
const res = await postJson(`${CP_URL}/cli-auth/device/token`, { deviceCode });
|
|
122
|
+
if (res.data?.token) return res.data;
|
|
123
|
+
if (res.data?.status === "pending") continue;
|
|
124
|
+
if (res.error?.startsWith("HTTP")) throw new Error(`the sign-in was rejected (${res.error}).`);
|
|
125
|
+
// Network blip — keep polling until the code's own deadline.
|
|
126
|
+
}
|
|
127
|
+
throw new Error("the code expired before it was confirmed — run the command again.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Which ceremony fits this machine. ALEXANDR_DEVICE_FLOW=1|0 overrides. */
|
|
131
|
+
export function useDeviceFlow() {
|
|
132
|
+
if (process.env.ALEXANDR_DEVICE_FLOW === "1") return true;
|
|
133
|
+
if (process.env.ALEXANDR_DEVICE_FLOW === "0") return false;
|
|
134
|
+
return isHeadless();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** No local browser to open — a Linux box with no display server. Pure for tests. */
|
|
138
|
+
export function isHeadless(platform = process.platform, env = process.env) {
|
|
139
|
+
return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Present the consent URL honestly, by what this machine can actually do:
|
|
144
|
+
* - HEADLESS (a server): never pretend a browser opened. Numbered steps, tunnel
|
|
145
|
+
* FIRST (the redirect lands on the desktop's loopback and must reach this box).
|
|
146
|
+
* - DESKTOP + TTY: ask before taking over the browser — the URL is printed
|
|
147
|
+
* either way, so "open it yourself" is always available.
|
|
148
|
+
* - DESKTOP non-TTY (scripts): print + best-effort open, nothing blocks.
|
|
149
|
+
*/
|
|
150
|
+
export async function presentAuthUrl(authUrl, { port, domain, headless = isHeadless() }) {
|
|
151
|
+
const sshTarget = `${process.env.USER || "root"}@${domain || os.hostname()}`;
|
|
152
|
+
if (headless) {
|
|
153
|
+
log("");
|
|
154
|
+
step("This machine has no browser — finish the sign-in from your computer:");
|
|
155
|
+
log(` 1. Forward the callback port ${dim("(keep this running until you're done)")}:`);
|
|
156
|
+
log(` ${bold(`ssh -L ${port}:127.0.0.1:${port} ${sshTarget}`)}`);
|
|
157
|
+
log(` 2. Open this link in a browser signed in to your alexandr account:`);
|
|
158
|
+
log(` ${cyan(authUrl)}`);
|
|
159
|
+
log(dim(` (waiting for the confirmation — ${TIMEOUT_MS / 60000} minutes)`));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
log(dim(authUrl));
|
|
163
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
164
|
+
await new Promise((resolve) => {
|
|
165
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
166
|
+
rl.question(
|
|
167
|
+
`${cyan("›")} Press Enter to open your browser and confirm ${dim("(or open the link above yourself)")} `,
|
|
168
|
+
() => {
|
|
169
|
+
rl.close();
|
|
170
|
+
resolve();
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
openURL(authUrl);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Start a loopback listener for the OAuth redirect. Resolves {code,state} when /callback is hit. */
|
|
179
|
+
export function startLoopback() {
|
|
180
|
+
let resolveFn, rejectFn;
|
|
181
|
+
const done = new Promise((res, rej) => {
|
|
182
|
+
resolveFn = res;
|
|
183
|
+
rejectFn = rej;
|
|
184
|
+
});
|
|
185
|
+
const server = http.createServer((req, res) => {
|
|
186
|
+
const u = new URL(req.url, "http://127.0.0.1");
|
|
187
|
+
// Reachability probe for the consent page: /cli-auth pings this before the
|
|
188
|
+
// user clicks, so a missing ssh tunnel becomes a guided notice instead of a
|
|
189
|
+
// dead browser error page after the click. The PNA header answers Chrome's
|
|
190
|
+
// public->loopback preflight; ACAO lets the page read the success.
|
|
191
|
+
if (u.pathname === "/ping") {
|
|
192
|
+
res.writeHead(204, {
|
|
193
|
+
"access-control-allow-origin": "*",
|
|
194
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
195
|
+
"access-control-allow-headers": "*",
|
|
196
|
+
"access-control-allow-private-network": "true",
|
|
197
|
+
});
|
|
198
|
+
res.end();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (u.pathname !== "/callback") {
|
|
202
|
+
res.writeHead(404);
|
|
203
|
+
res.end();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
207
|
+
res.end(
|
|
208
|
+
"<!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>",
|
|
209
|
+
);
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
setTimeout(() => server.close(), 200);
|
|
212
|
+
const code = u.searchParams.get("code");
|
|
213
|
+
const st = u.searchParams.get("state");
|
|
214
|
+
if (code && st) resolveFn({ code, state: st });
|
|
215
|
+
else rejectFn(new Error("no authorization code in the redirect"));
|
|
216
|
+
});
|
|
217
|
+
const timer = setTimeout(() => {
|
|
218
|
+
server.close();
|
|
219
|
+
rejectFn(new Error("timed out waiting for the browser confirmation"));
|
|
220
|
+
}, TIMEOUT_MS);
|
|
221
|
+
return new Promise((ready, readyErr) => {
|
|
222
|
+
server.once("error", (e) => {
|
|
223
|
+
rejectFn(e);
|
|
224
|
+
readyErr(e);
|
|
225
|
+
});
|
|
226
|
+
server.listen(0, "127.0.0.1", () => ready({ port: server.address().port, done }));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* POST json -> `{ data }` on success, `{ error }` on failure. The CP writes
|
|
232
|
+
* human-readable `error` strings (e.g. the closed-alpha 403 explains exactly who
|
|
233
|
+
* may register), so failures must carry WHY — a bare null renders as "could not
|
|
234
|
+
* register this runtime" with the real reason swallowed. Callers surface
|
|
235
|
+
* `error` in their fail/skip message.
|
|
236
|
+
*/
|
|
237
|
+
export async function postJson(url, body, headers = {}) {
|
|
238
|
+
try {
|
|
239
|
+
const res = await fetch(url, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "content-type": "application/json", ...headers },
|
|
242
|
+
body: JSON.stringify(body),
|
|
243
|
+
});
|
|
244
|
+
const data = await res.json().catch(() => null);
|
|
245
|
+
if (!res.ok) {
|
|
246
|
+
const detail = typeof data?.error === "string" && data.error ? ` — ${data.error}` : "";
|
|
247
|
+
return { error: `HTTP ${res.status}${detail}` };
|
|
248
|
+
}
|
|
249
|
+
return data == null ? { error: "malformed response" } : { data };
|
|
250
|
+
} catch (e) {
|
|
251
|
+
return { error: `couldn't reach ${new URL(url).origin}: ${e?.message ?? e}` };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** GET json -> `{ data }` / `{ error }`, the read half of postJson. */
|
|
256
|
+
export async function getJson(url, headers = {}) {
|
|
257
|
+
try {
|
|
258
|
+
const res = await fetch(url, { headers });
|
|
259
|
+
const data = await res.json().catch(() => null);
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
const detail = typeof data?.error === "string" && data.error ? ` — ${data.error}` : "";
|
|
262
|
+
return { error: `HTTP ${res.status}${detail}`, status: res.status };
|
|
263
|
+
}
|
|
264
|
+
return data == null ? { error: "malformed response" } : { data };
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return { error: `couldn't reach ${new URL(url).origin}: ${e?.message ?? e}` };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// `fail` is re-exported so callers that want the process-exiting form don't have
|
|
271
|
+
// to import two modules for one ceremony.
|
|
272
|
+
export { fail };
|
package/src/deps.js
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// LINUX box (the self-host case), it offers to install them right there — Docker's official
|
|
3
3
|
// convenience script (get.docker.com) + systemd start — instead of failing with a doc link.
|
|
4
4
|
// Interactive-only (a real TTY, an explicit yes), root or sudo. macOS/Windows stay
|
|
5
|
-
// guidance-only: Docker Desktop can't be installed silently
|
|
6
|
-
// ("On this Mac") uses the desktop app's own VM, never Docker.
|
|
5
|
+
// guidance-only: Docker Desktop can't be installed silently.
|
|
7
6
|
|
|
8
7
|
import { spawnSync } from "node:child_process";
|
|
9
8
|
import { log, ok, warn, step, dim, bold } from "./util.js";
|
package/src/instance.js
CHANGED
|
@@ -24,7 +24,12 @@ export function resolveInstance(flags = {}) {
|
|
|
24
24
|
const cwdProject = path.resolve(process.cwd(), "alexandr");
|
|
25
25
|
if (flags.dir) {
|
|
26
26
|
const dir = path.resolve(String(flags.dir));
|
|
27
|
-
|
|
27
|
+
// `--project` pins the compose project NAME beside `--dir`: the updater sidecar runs the
|
|
28
|
+
// recipe from inside a container where the instance dir is mounted at its host path, and
|
|
29
|
+
// the project it must drive is the one the host created — a name re-derived in there
|
|
30
|
+
// (a global instance's is not the dir hash) would make compose start a SECOND copy.
|
|
31
|
+
const pinned = typeof flags.project === "string" && flags.project.trim() ? flags.project.trim() : null;
|
|
32
|
+
return { dir, mode: "project", name: path.basename(dir), projectName: pinned ?? `alexandr-${sha8(dir)}` };
|
|
28
33
|
}
|
|
29
34
|
if (fs.existsSync(path.join(cwdProject, "docker-compose.yml"))) {
|
|
30
35
|
return { dir: cwdProject, mode: "project", name: path.basename(path.dirname(cwdProject)), projectName: `alexandr-${sha8(cwdProject)}` };
|
|
@@ -95,6 +100,34 @@ export function unsetEnv(dir, key) {
|
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
// The loopback kernel port for this instance (host side), honoring the .env.
|
|
103
|
+
/**
|
|
104
|
+
* Keep the newest `keep` pre-update snapshots in `<dir>/backups` and delete the rest
|
|
105
|
+
* (owner, 2026-09-08: keep 3). Every update — the terminal verb and the sidecar's button —
|
|
106
|
+
* snapshots /data first, and a box that is updated often would otherwise fill its disk with
|
|
107
|
+
* tarballs nobody will restore. Returns the paths removed. Only `pre-update-*.tgz` is touched:
|
|
108
|
+
* a snapshot the operator took with `alexandr backup` is theirs to keep.
|
|
109
|
+
*/
|
|
110
|
+
export function pruneSnapshots(dir, keep = 3) {
|
|
111
|
+
const backups = path.join(dir, "backups");
|
|
112
|
+
if (!fs.existsSync(backups)) return [];
|
|
113
|
+
const snaps = fs
|
|
114
|
+
.readdirSync(backups)
|
|
115
|
+
.filter((f) => /^pre-update-.*\.tgz$/.test(f))
|
|
116
|
+
.map((f) => ({ f, at: fs.statSync(path.join(backups, f)).mtimeMs }))
|
|
117
|
+
.sort((a, b) => b.at - a.at);
|
|
118
|
+
const removed = [];
|
|
119
|
+
for (const { f } of snaps.slice(Math.max(0, keep))) {
|
|
120
|
+
const p = path.join(backups, f);
|
|
121
|
+
try {
|
|
122
|
+
fs.unlinkSync(p);
|
|
123
|
+
removed.push(p);
|
|
124
|
+
} catch {
|
|
125
|
+
/* a snapshot that will not go is not worth failing an update over */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return removed;
|
|
129
|
+
}
|
|
130
|
+
|
|
98
131
|
export function kernelPort(dir) {
|
|
99
132
|
const v = readEnv(dir).ALEXANDR_KERNEL_PORT;
|
|
100
133
|
const n = v ? Number(v) : 3030;
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 =
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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 ${
|
|
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))));
|