@monster-orange/den 0.4.4 → 0.4.5

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +10 -9
  3. package/docs/distributions.md +223 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/auth/sudo-vault.ts +115 -11
  7. package/src/shared/cli-command.ts +186 -0
  8. package/src/shared/confirm-code.ts +34 -0
  9. package/src/shared/den-config.ts +7 -0
  10. package/src/shared/dev-metrics/emit.ts +31 -1
  11. package/src/shared/edge/config.ts +39 -0
  12. package/src/shared/edge/render.ts +92 -18
  13. package/src/shared/edge/runtime.ts +57 -1
  14. package/src/shared/edge/types.ts +11 -1
  15. package/src/shared/github-app.ts +94 -0
  16. package/src/shared/github-device.ts +286 -0
  17. package/src/shared/secrets/onepassword.ts +27 -4
  18. package/src/shared/web-owner.ts +91 -0
  19. package/src/shared/web-session.ts +135 -1
  20. package/src/ui/cli/flag-parser.ts +1 -1
  21. package/src/ui/cli/web-command.ts +512 -33
  22. package/src/ui/server/device-flows.ts +274 -0
  23. package/src/ui/server/edge-exposure.ts +16 -4
  24. package/src/ui/server/github-routes.ts +321 -0
  25. package/src/ui/server/http-errors.ts +10 -0
  26. package/src/ui/server/index.ts +463 -27
  27. package/src/ui/server/login-waits.ts +203 -0
  28. package/src/ui/server/pair-requests.ts +237 -0
  29. package/src/ui/server/pair-routes.ts +196 -0
  30. package/src/ui/server/public/error-page.js +452 -40
  31. package/src/ui/server/public/error.html +59 -0
  32. package/src/ui/server/public/i18n/es.js +74 -8
  33. package/src/ui/server/public/i18n/ja.js +74 -8
  34. package/src/ui/server/public/local-dev.html +16 -4
  35. package/src/ui/server/public/qr.js +317 -0
  36. package/src/ui/server/public/settings-page.js +10 -3
  37. package/src/ui/server/public/settings-registry.js +392 -1
  38. package/src/ui/server/public/shell.js +123 -10
  39. package/src/ui/server/public/topbar.js +7 -3
  40. package/src/ui/server/public/workspace-picker.js +1 -1
  41. package/src/ui/server/request-guard.ts +102 -35
  42. package/src/ui/server/route-classes.ts +102 -2
  43. package/src/ui/server/ui-assets.ts +2 -0
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import { execFileSync, spawn, spawnSync } from "child_process";
3
3
  import * as fs from "fs";
4
4
  import * as http from "http";
5
+ import * as net from "net";
5
6
  import * as path from "path";
6
7
  import { pathToFileURL } from "url";
7
8
  import { CLI_SPAWN_TIMEOUT_MS, DEFAULT_PORT } from "../../shared/constants.js";
@@ -20,7 +21,9 @@ import {
20
21
  readWebSecret,
21
22
  rotateWebSecret,
22
23
  } from "../../shared/web-secret.js";
24
+ import { newConfirmCode } from "../../shared/confirm-code.js";
23
25
  import {
26
+ checkLoginCode,
24
27
  ignoredConfigWarning,
25
28
  localhostOrigin,
26
29
  loginLink,
@@ -35,6 +38,24 @@ import { revokeAllActorTokens } from "../../shared/actor-token.js";
35
38
  import { engineBin, engineModuleFile } from "../../shared/engine.js";
36
39
  import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
37
40
  import { webRouteHosts } from "../../shared/edge/render.js";
41
+ import {
42
+ expiringTokenWarning,
43
+ GITHUB_DEVICE_PAGE,
44
+ githubClientId,
45
+ githubEndpoints,
46
+ noClientIdMessage,
47
+ } from "../../shared/github-app.js";
48
+ import {
49
+ githubDeviceClient,
50
+ githubPrincipal,
51
+ runDeviceFlow,
52
+ type GithubDeviceClient,
53
+ } from "../../shared/github-device.js";
54
+ import {
55
+ clearWebOwner,
56
+ readWebOwner,
57
+ writeWebOwner,
58
+ } from "../../shared/web-owner.js";
38
59
  import {
39
60
  ownServer,
40
61
  processCwd,
@@ -225,6 +246,24 @@ export function buildDenWebUrl(
225
246
  return `http://${resolveDenWebHost(hostsContent)}:${port}`;
226
247
  }
227
248
 
249
+ /**
250
+ * The address `web up` names: the bound one when DEN_BIND_HOST is a
251
+ * specific address beyond loopback (a LAN-only bind doesn't answer on
252
+ * localhost), else buildDenWebUrl's.
253
+ */
254
+ export function webUpUrl(
255
+ port: number,
256
+ bind: string | undefined = process.env.DEN_BIND_HOST,
257
+ hostsContent?: string,
258
+ ): string {
259
+ const to = webProbeTarget(bind, port);
260
+ const loopback =
261
+ to.host === "::1" || /^127\./.test(to.host) || to.host === "localhost";
262
+ return loopback
263
+ ? buildDenWebUrl(port, hostsContent)
264
+ : `http://${to.hostHeader}`;
265
+ }
266
+
228
267
  export function denWebUrlAliases(port: number): string[] {
229
268
  return [
230
269
  `http://${product().cli}:${port}`,
@@ -252,6 +291,29 @@ export function isDenWebTabUrl(rawUrl: string, port: number): boolean {
252
291
  }
253
292
  }
254
293
 
294
+ /**
295
+ * Where `web up`'s probes (readiness, the CLI's own calls) reach the
296
+ * server, and the Host they send: the address DEN_BIND_HOST binds. Unset,
297
+ * 0.0.0.0 or `::` answer on loopback, so 127.0.0.1; one specific address
298
+ * (a LAN IP with web.allow_plain_http_remote, or ::1) answers only there.
299
+ * The Host names that address (an IPv6 one bracketed): the Host allowlist
300
+ * accepts it (a loopback name, or the bound LAN address).
301
+ */
302
+ export function webProbeTarget(
303
+ bind: string | undefined,
304
+ port: number,
305
+ ): { host: string; hostHeader: string } {
306
+ const b = (bind ?? "")
307
+ .trim()
308
+ .toLowerCase()
309
+ .replace(/^\[|\]$/g, "");
310
+ const host = b === "" || b === "0.0.0.0" || b === "::" ? "127.0.0.1" : b;
311
+ return {
312
+ host,
313
+ hostHeader: `${net.isIPv6(host) ? `[${host}]` : host}:${port}`,
314
+ };
315
+ }
316
+
255
317
  async function getHttpText(
256
318
  port: number,
257
319
  requestPath: string,
@@ -266,12 +328,18 @@ async function getHttpText(
266
328
  resolve(value);
267
329
  };
268
330
 
331
+ const to = webProbeTarget(process.env.DEN_BIND_HOST, port);
269
332
  const req = http.get(
270
333
  {
271
- host: "127.0.0.1",
334
+ host: to.host,
272
335
  port,
273
336
  path: requestPath,
274
337
  timeout: 1_500,
338
+ agent: false,
339
+ setHost: false,
340
+ // The CLI's bearer, as serverCall sends: an address beyond loopback
341
+ // answers an unsigned probe 401 signin_required, never ready.
342
+ headers: { host: to.hostHeader, ...cliAuthHeaders() },
275
343
  },
276
344
  (res) => {
277
345
  let body = "";
@@ -1072,6 +1140,8 @@ export const WEB_SUBCOMMANDS_WITH_HELP = [
1072
1140
  "login",
1073
1141
  "sessions",
1074
1142
  "logout",
1143
+ "owner",
1144
+ "approve",
1075
1145
  ] as const;
1076
1146
 
1077
1147
  export function displayWebHelp(): void {
@@ -1082,12 +1152,16 @@ export function displayWebHelp(): void {
1082
1152
  console.log(` ${product().cli} web --setup-browser-access`);
1083
1153
  console.log(` ${product().cli} web token --print`);
1084
1154
  console.log(
1085
- ` ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--port <port>]`,
1155
+ ` ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--no-wait] [--port <port>]`,
1086
1156
  );
1087
1157
  console.log(` ${product().cli} web sessions [--json] [--port <port>]`);
1088
1158
  console.log(
1089
1159
  ` ${product().cli} web logout [<id prefix>] [--all] [--port <port>]`,
1090
1160
  );
1161
+ console.log(` ${product().cli} web owner [link|show|unlink] [--json]`);
1162
+ console.log(
1163
+ ` ${product().cli} web approve <code> | --list [--json] [--port <port>]`,
1164
+ );
1091
1165
  console.log();
1092
1166
  console.log(chalk.bold("Actions:"));
1093
1167
  console.log(
@@ -1101,7 +1175,7 @@ export function displayWebHelp(): void {
1101
1175
  " open Start if needed, and sign this browser in (a new tab)",
1102
1176
  );
1103
1177
  console.log(
1104
- " login Print a one-time sign-in link (60 s), for another browser",
1178
+ " login Print a one-time sign-in link (60 s) and its code, then wait until it's used",
1105
1179
  );
1106
1180
  console.log(" sessions List the signed-in browsers");
1107
1181
  console.log(
@@ -1110,6 +1184,12 @@ export function displayWebHelp(): void {
1110
1184
  console.log(
1111
1185
  " token --print Print your CLI token, for curl (Authorization: Bearer …)",
1112
1186
  );
1187
+ console.log(
1188
+ " owner link Link your GitHub account as this Den's owner (Sign in with GitHub)",
1189
+ );
1190
+ console.log(
1191
+ " approve <code> Let in a computer whose signed-out page shows this code",
1192
+ );
1113
1193
  console.log(" (start/stop remain accepted as legacy aliases for up/down)");
1114
1194
  console.log();
1115
1195
  console.log(chalk.bold("Options:"));
@@ -1174,7 +1254,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
1174
1254
  return;
1175
1255
  }
1176
1256
  if (args[0] === "login") {
1177
- process.exitCode = cmdWebLogin(args.slice(1));
1257
+ process.exitCode = await cmdWebLoginWait(args.slice(1));
1178
1258
  return;
1179
1259
  }
1180
1260
  if (args[0] === "sessions") {
@@ -1185,6 +1265,14 @@ export async function cmdWeb(args: string[]): Promise<void> {
1185
1265
  process.exitCode = await cmdWebLogout(args.slice(1));
1186
1266
  return;
1187
1267
  }
1268
+ if (args[0] === "owner") {
1269
+ process.exitCode = await cmdWebOwner(args.slice(1));
1270
+ return;
1271
+ }
1272
+ if (args[0] === "approve") {
1273
+ process.exitCode = await cmdWebApprove(args.slice(1));
1274
+ return;
1275
+ }
1188
1276
  if (args.includes("--help") || args.includes("-h")) {
1189
1277
  displayWebHelp();
1190
1278
  return;
@@ -1196,7 +1284,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
1196
1284
  return;
1197
1285
  }
1198
1286
 
1199
- const url = buildDenWebUrl(options.port);
1287
+ const url = webUpUrl(options.port);
1200
1288
 
1201
1289
  // Never act on another distribution's server: not "already running", and
1202
1290
  // never a signal to it (down, restart).
@@ -1392,31 +1480,19 @@ function takePort(args: string[]): { port: number; rest: string[] } {
1392
1480
  return { port, rest };
1393
1481
  }
1394
1482
 
1395
- /**
1396
- * `<cli> web login [--next <path>] [--host <name> | --origin <url>]`: a fresh sign-in link
1397
- * on stdout, alone (the only place a login code is ever printed); a note on
1398
- * stderr. `--host` for an edge hostname (its URL from the edge config),
1399
- * `--origin` for any allowed origin (a LAN name), where the cookie must be
1400
- * set (the signed-out page there says which).
1401
- */
1402
- export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1403
- const usage = `Usage: ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--port <port>]\n`;
1404
- if (isHelp(args)) {
1405
- io.out(
1406
- usage +
1407
- " Prints a one-time sign-in link (it works once, within 60 s) for one browser.\n" +
1408
- " On localhost nothing asks for a sign-in; it is needed when this server is\n" +
1409
- " reached from another machine (pass the edge hostname as --host, or a LAN\n" +
1410
- " address as --origin).\n" +
1411
- " --next <path> where the browser lands after signing in (same origin only)\n" +
1412
- " --host <name> an edge hostname that routes here (an edge domain or alias,\n" +
1413
- " a VPN name among them): the link uses its edge URL\n" +
1414
- " --origin <url> the address the browser uses, http(s)://host[:port]\n",
1415
- );
1416
- return 0;
1417
- }
1483
+ const LOGIN_USAGE = () =>
1484
+ `Usage: ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--no-wait] [--port <port>]\n`;
1485
+
1486
+ /** `web login`'s arguments, or an exit code (usage or a bad value, already said). */
1487
+ function parseLoginArgs(
1488
+ args: string[],
1489
+ io: CliIo,
1490
+ ):
1491
+ | { port: number; next: string; origin: string | null; noWait: boolean }
1492
+ | number {
1418
1493
  let next = "/";
1419
1494
  let origin: string | null = null;
1495
+ let noWait = false;
1420
1496
  let port: number;
1421
1497
  let rest: string[];
1422
1498
  try {
@@ -1433,6 +1509,10 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1433
1509
  : a.startsWith(`${flag}=`)
1434
1510
  ? a.slice(flag.length + 1)
1435
1511
  : undefined;
1512
+ if (a === "--no-wait") {
1513
+ noWait = true;
1514
+ continue;
1515
+ }
1436
1516
  const n = val("--next");
1437
1517
  const o = val("--origin");
1438
1518
  const h = val("--host");
@@ -1453,14 +1533,46 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1453
1533
  return 2;
1454
1534
  }
1455
1535
  } else {
1456
- io.err(usage);
1536
+ io.err(LOGIN_USAGE());
1457
1537
  return 2;
1458
1538
  }
1459
1539
  }
1540
+ return { port, next, origin, noWait };
1541
+ }
1542
+
1543
+ const LOGIN_HELP = () =>
1544
+ LOGIN_USAGE() +
1545
+ " Prints a one-time sign-in link (open it within 60 s) for one browser, and a\n" +
1546
+ " code the page behind the link asks for, then waits until the link is used:\n" +
1547
+ ' "Signed in: <browser> (<address>) at <time>", or "Expired: nobody used the\n' +
1548
+ ' link" (exit 1). On localhost nothing asks for a sign-in; it is needed when\n' +
1549
+ " this server is reached from another machine (pass the edge hostname as\n" +
1550
+ " --host, or a LAN address as --origin).\n" +
1551
+ " --next <path> where the browser lands after signing in (same origin only)\n" +
1552
+ " --host <name> an edge hostname that routes here (an edge domain or alias,\n" +
1553
+ " a VPN name among them): the link uses its edge URL\n" +
1554
+ " --origin <url> the address the browser uses, http(s)://host[:port]\n" +
1555
+ " --no-wait print the link and exit; the link signs in by itself, no\n" +
1556
+ " code (for scripts)\n";
1557
+
1558
+ /**
1559
+ * `<cli> web login --no-wait`: a fresh sign-in link on stdout, alone (the
1560
+ * only place a login code is ever printed); a note on stderr. `--host` for
1561
+ * an edge hostname (its URL from the edge config), `--origin` for any
1562
+ * allowed origin (a LAN name), where the cookie must be set (the
1563
+ * signed-out page there says which). The link signs in when opened.
1564
+ */
1565
+ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1566
+ if (isHelp(args)) {
1567
+ io.out(LOGIN_HELP());
1568
+ return 0;
1569
+ }
1570
+ const a = parseLoginArgs(args, io);
1571
+ if (typeof a === "number") return a;
1460
1572
  const link = freshLoginLink(
1461
- port,
1462
- next,
1463
- origin ?? localhostOrigin(port),
1573
+ a.port,
1574
+ a.next,
1575
+ a.origin ?? localhostOrigin(a.port),
1464
1576
  io.stateDir ?? getStateDir(),
1465
1577
  );
1466
1578
  io.err(
@@ -1470,6 +1582,121 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1470
1582
  return 0;
1471
1583
  }
1472
1584
 
1585
+ /** "10:42" in this machine's time. */
1586
+ function clockTime(iso: string): string {
1587
+ const d = new Date(iso);
1588
+ return Number.isNaN(d.getTime())
1589
+ ? iso
1590
+ : `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1591
+ }
1592
+
1593
+ /**
1594
+ * `<cli> web login` (SEC-2a): the link and an 8-character code, then a
1595
+ * wait until the link is used or expires. The page behind the link asks
1596
+ * for the code before it signs anything in. The CLI registers the link's
1597
+ * nonce and the code with the running server (its bearer) and polls that
1598
+ * wait; the code is shown here only, never logged. With no server running
1599
+ * (or `--no-wait`) it prints the link alone, as before.
1600
+ */
1601
+ export async function cmdWebLoginWait(
1602
+ args: string[],
1603
+ io: CliIo = stdIo,
1604
+ opts: { pollMs?: number; maxWaitMs?: number } = {},
1605
+ ): Promise<number> {
1606
+ if (isHelp(args)) {
1607
+ io.out(LOGIN_HELP());
1608
+ return 0;
1609
+ }
1610
+ const a = parseLoginArgs(args, io);
1611
+ if (typeof a === "number") return a;
1612
+ if (a.noWait)
1613
+ return cmdWebLogin(
1614
+ args.filter((x) => x !== "--no-wait"),
1615
+ io,
1616
+ );
1617
+ const stateDir = io.stateDir ?? getStateDir();
1618
+ const secret = ensureWebSecret(stateDir);
1619
+ const now = Date.now();
1620
+ const loginCode = mintLoginCode(secret, now);
1621
+ const checked = checkLoginCode(loginCode, secret, now);
1622
+ if (!checked.ok) {
1623
+ io.err(`${product().cli}: could not make a sign-in link\n`);
1624
+ return 1;
1625
+ }
1626
+ const confirm = newConfirmCode();
1627
+ const reg = await serverCall(stateDir, a.port, "POST", "/api/web/logins", {
1628
+ nonce: checked.nonce,
1629
+ code: confirm,
1630
+ expiresAt: checked.expiresAt,
1631
+ });
1632
+ const link = loginLink(
1633
+ a.origin ?? localhostOrigin(a.port),
1634
+ loginCode,
1635
+ a.next,
1636
+ );
1637
+ if (!reg) {
1638
+ io.err(
1639
+ `${product().cli}: no ${product().title} web server answers on port ${a.port}, so this ` +
1640
+ "doesn't wait and the link asks for no code (as --no-wait). Start the server " +
1641
+ `(\`${product().cli} web up\`), then open this link within 60 seconds; it signs in one browser, once.\n`,
1642
+ );
1643
+ io.out(`${link}\n`);
1644
+ return 0;
1645
+ }
1646
+ const id = (reg.json as { id?: unknown } | null)?.id;
1647
+ if (reg.status !== 200 || typeof id !== "string") {
1648
+ io.err(`${product().cli}: ${apiError(reg)}\n`);
1649
+ return 1;
1650
+ }
1651
+ io.err(
1652
+ `${product().cli}: open this link within 60 seconds, in the browser to sign in. ` +
1653
+ `It asks for this code: ${confirm}\n`,
1654
+ );
1655
+ io.out(`${link}\n`);
1656
+ io.err(`${product().cli}: waiting for the link to be used…\n`);
1657
+ const pollMs = opts.pollMs ?? 1000;
1658
+ const deadline = Date.now() + (opts.maxWaitMs ?? 7 * 60_000);
1659
+ for (;;) {
1660
+ await new Promise((r) => setTimeout(r, pollMs));
1661
+ const r = await serverCall(
1662
+ stateDir,
1663
+ a.port,
1664
+ "GET",
1665
+ `/api/web/logins/${id}`,
1666
+ );
1667
+ const j = (r?.json ?? {}) as {
1668
+ state?: string;
1669
+ device?: string;
1670
+ client?: string;
1671
+ usedAt?: string;
1672
+ };
1673
+ if (r && j.state === "used") {
1674
+ io.out(
1675
+ `Signed in: ${j.device ?? "a browser"}${j.client ? ` (${j.client})` : ""}` +
1676
+ ` at ${clockTime(j.usedAt ?? new Date().toISOString())}\n`,
1677
+ );
1678
+ return 0;
1679
+ }
1680
+ if (r && j.state === "refused") {
1681
+ io.err("Refused: too many wrong codes; the link no longer works\n");
1682
+ return 1;
1683
+ }
1684
+ if (
1685
+ (r && (j.state === "expired" || r.status === 404)) ||
1686
+ Date.now() > deadline
1687
+ ) {
1688
+ io.err("Expired: nobody used the link\n");
1689
+ return 1;
1690
+ }
1691
+ if (!r) {
1692
+ io.err(
1693
+ `${product().cli}: the server stopped answering; the link can't be used now\n`,
1694
+ );
1695
+ return 1;
1696
+ }
1697
+ }
1698
+ }
1699
+
1473
1700
  /**
1474
1701
  * `web login --host <name>`: the origin a browser uses for an edge hostname
1475
1702
  * that routes to this server (edge.routes under any edge domain or alias),
@@ -1523,14 +1750,17 @@ function serverCall(
1523
1750
  ): Promise<{ status: number; json: unknown } | null> {
1524
1751
  return new Promise((resolve) => {
1525
1752
  const payload = body === undefined ? undefined : JSON.stringify(body);
1753
+ const to = webProbeTarget(process.env.DEN_BIND_HOST, port);
1526
1754
  const req = http.request(
1527
1755
  {
1528
- host: "127.0.0.1",
1756
+ host: to.host,
1529
1757
  port,
1530
1758
  path: p,
1531
1759
  method,
1532
1760
  timeout: 10_000,
1761
+ setHost: false,
1533
1762
  headers: {
1763
+ host: to.hostHeader,
1534
1764
  ...cliAuthHeaders(stateDir),
1535
1765
  ...(payload !== undefined
1536
1766
  ? {
@@ -1737,3 +1967,252 @@ export async function cmdWebLogout(
1737
1967
  );
1738
1968
  return 0;
1739
1969
  }
1970
+
1971
+ // ── SEC-2a: approve this computer with a code (`web approve`) ──────────────
1972
+
1973
+ interface WaitingRow {
1974
+ code: string;
1975
+ device: string;
1976
+ client?: string;
1977
+ createdAt: string;
1978
+ expiresAt: string;
1979
+ }
1980
+
1981
+ /** "3 min ago" for a list: whole minutes, or "just now". */
1982
+ function agoText(iso: string, now = Date.now()): string {
1983
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
1984
+ return s < 60 ? "just now" : `${Math.round(s / 60)} min ago`;
1985
+ }
1986
+
1987
+ /**
1988
+ * `<cli> web approve <code> | --list [--json]`: let in a computer whose
1989
+ * signed-out page shows `<code>` (case and dashes don't matter), or list
1990
+ * the ones waiting. It asks the running server, with this CLI's bearer:
1991
+ * the requests live in its memory only, so with no server there is
1992
+ * nothing to approve.
1993
+ */
1994
+ export async function cmdWebApprove(
1995
+ args: string[],
1996
+ io: CliIo = stdIo,
1997
+ ): Promise<number> {
1998
+ let port: number;
1999
+ let rest: string[];
2000
+ try {
2001
+ ({ port, rest } = takePort(args));
2002
+ } catch (e) {
2003
+ io.err(`${(e as Error).message}
2004
+ `);
2005
+ return 2;
2006
+ }
2007
+ const usage = `Usage: ${product().cli} web approve <code> | --list [--json] [--port <port>]\n`;
2008
+ if (isHelp(rest)) {
2009
+ io.out(
2010
+ usage +
2011
+ " Lets in the computer whose signed-out page shows <code> (like K7Q-42M; case\n" +
2012
+ " and dashes don't matter). That page then signs in by itself. A code lasts\n" +
2013
+ " 10 minutes and works once.\n" +
2014
+ " --list the computers waiting: code, browser, address, age\n" +
2015
+ " --json with --list: as JSON\n",
2016
+ );
2017
+ return 0;
2018
+ }
2019
+ const list = rest.includes("--list");
2020
+ const asJson = rest.includes("--json");
2021
+ const codes = rest.filter((a) => a !== "--list" && a !== "--json");
2022
+ if (
2023
+ codes.some((a) => a.startsWith("-")) ||
2024
+ (list ? codes.length !== 0 : codes.length !== 1 || asJson)
2025
+ ) {
2026
+ io.err(usage);
2027
+ return 2;
2028
+ }
2029
+ const stateDir = io.stateDir ?? getStateDir();
2030
+ const r = list
2031
+ ? await serverCall(stateDir, port, "GET", "/api/web/waiting")
2032
+ : await serverCall(stateDir, port, "POST", "/api/web/approve", {
2033
+ code: codes[0],
2034
+ });
2035
+ if (!r) {
2036
+ io.err(
2037
+ `${product().cli}: no ${product().title} web server answers on port ${port}. ` +
2038
+ "A waiting computer's code lives in the running server: start it " +
2039
+ `(\`${product().cli} web up\`) and reload the page on the other computer.\n`,
2040
+ );
2041
+ return 1;
2042
+ }
2043
+ if (r.status !== 200) {
2044
+ io.err(`${product().cli}: ${apiError(r)}\n`);
2045
+ return 1;
2046
+ }
2047
+ if (list) {
2048
+ const rows = ((r.json as { waiting?: WaitingRow[] }).waiting ?? []).map(
2049
+ (w) => ({ ...w }),
2050
+ );
2051
+ if (asJson) {
2052
+ io.out(`${JSON.stringify(rows, null, 2)}\n`);
2053
+ return 0;
2054
+ }
2055
+ if (rows.length === 0) {
2056
+ io.out("No computers are waiting for approval.\n");
2057
+ return 0;
2058
+ }
2059
+ const table = [
2060
+ ["CODE", "BROWSER", "ADDRESS", "WAITING"],
2061
+ ...rows.map((w) => [
2062
+ w.code,
2063
+ w.device,
2064
+ w.client ?? "",
2065
+ agoText(w.createdAt),
2066
+ ]),
2067
+ ];
2068
+ const widths = table[0].map((_, c) =>
2069
+ Math.max(...table.map((row) => row[c].length)),
2070
+ );
2071
+ for (const row of table)
2072
+ io.out(
2073
+ `${row
2074
+ .map((cell, c) =>
2075
+ c === row.length - 1 ? cell : cell.padEnd(widths[c]),
2076
+ )
2077
+ .join(" ")
2078
+ .trimEnd()}\n`,
2079
+ );
2080
+ return 0;
2081
+ }
2082
+ const w = r.json as WaitingRow;
2083
+ io.out(
2084
+ `Approved: ${w.device}${w.client ? ` (${w.client})` : ""}. ` +
2085
+ "Its page signs in by itself.\n",
2086
+ );
2087
+ return 0;
2088
+ }
2089
+
2090
+ // ── SEC-2a: the owner (`web owner link`, `show`, `unlink`) ─────────────────
2091
+
2092
+ /**
2093
+ * `<cli> web owner [link|show|unlink] [--json]`: the GitHub account this
2094
+ * Den belongs to, which Sign in with GitHub admits.
2095
+ * - link: GitHub's device flow, here in the terminal: the code and
2096
+ * github.com/login/device, then a wait while you approve it. Den keeps
2097
+ * `github:<id>` and the login (a 0600 file in the state dir) and drops
2098
+ * the token at once. Refused while an owner is linked.
2099
+ * - show (the default): who it is.
2100
+ * - unlink: forget it. Signed-in browsers stay signed in.
2101
+ * `client` and `sleep` are a test's.
2102
+ */
2103
+ export async function cmdWebOwner(
2104
+ args: string[],
2105
+ io: CliIo & {
2106
+ client?: GithubDeviceClient;
2107
+ sleep?: (ms: number) => Promise<void>;
2108
+ clientId?: string;
2109
+ } = stdIo,
2110
+ ): Promise<number> {
2111
+ const usage = `Usage: ${product().cli} web owner [link|show|unlink] [--json]\n`;
2112
+ if (isHelp(args)) {
2113
+ io.out(
2114
+ usage +
2115
+ " link link your GitHub account as this Den's owner (GitHub's device flow:\n" +
2116
+ ` enter the code it shows at ${GITHUB_DEVICE_PAGE}); only its id is kept\n` +
2117
+ " show who the owner is (the default)\n" +
2118
+ " unlink forget the owner; signed-in browsers stay signed in\n" +
2119
+ " The owner can then sign in from any device: the signed-out page's\n" +
2120
+ " Sign in with GitHub. No token is stored.\n",
2121
+ );
2122
+ return 0;
2123
+ }
2124
+ const asJson = args.includes("--json");
2125
+ const rest = args.filter((a) => a !== "--json");
2126
+ const sub = rest[0] ?? "show";
2127
+ if (rest.length > 1 || !["link", "show", "unlink"].includes(sub)) {
2128
+ io.err(usage);
2129
+ return 2;
2130
+ }
2131
+ const stateDir = io.stateDir ?? getStateDir();
2132
+ const cli = product().cli;
2133
+ if (sub === "show") {
2134
+ const o = readWebOwner(stateDir);
2135
+ if (asJson) io.out(`${JSON.stringify({ owner: o }, null, 2)}\n`);
2136
+ else
2137
+ io.out(
2138
+ o
2139
+ ? `Owner: @${o.login} (${o.owner}), linked ${o.linkedAt.replace("T", " ").replace(/\.\d+Z$/, "Z")}.\n`
2140
+ : `No owner is linked. Run \`${cli} web owner link\`.\n`,
2141
+ );
2142
+ return 0;
2143
+ }
2144
+ if (sub === "unlink") {
2145
+ const was = clearWebOwner(stateDir);
2146
+ if (asJson) io.out(`${JSON.stringify({ unlinked: was }, null, 2)}\n`);
2147
+ else
2148
+ io.out(
2149
+ was
2150
+ ? `Unlinked @${was.login}. Signed-in browsers stay signed in (\`${cli} web logout\` ends them).\n`
2151
+ : "No owner was linked.\n",
2152
+ );
2153
+ return 0;
2154
+ }
2155
+ const linked = readWebOwner(stateDir);
2156
+ if (linked) {
2157
+ io.err(
2158
+ `${cli}: this Den's owner is @${linked.login} already; \`${cli} web owner unlink\` first.\n`,
2159
+ );
2160
+ return 1;
2161
+ }
2162
+ let clientId = io.clientId;
2163
+ if (clientId === undefined) {
2164
+ try {
2165
+ clientId = githubClientId(
2166
+ readWebAuthConfig(getDistRoot(), product().configFile).githubClientId,
2167
+ );
2168
+ } catch (e) {
2169
+ io.err(`${cli}: ${(e as Error).message}\n`);
2170
+ return 1;
2171
+ }
2172
+ }
2173
+ if (!clientId) {
2174
+ io.err(`${cli}: ${noClientIdMessage(cli)}\n`);
2175
+ return 1;
2176
+ }
2177
+ const ep = githubEndpoints();
2178
+ let outcome;
2179
+ try {
2180
+ outcome = await runDeviceFlow({
2181
+ clientId,
2182
+ client: io.client ?? githubDeviceClient(ep),
2183
+ ...(io.sleep ? { sleep: io.sleep } : {}),
2184
+ minIntervalMs: ep.fake || io.client ? 0 : 1000,
2185
+ onCode: (c) =>
2186
+ io.out(
2187
+ `Open ${GITHUB_DEVICE_PAGE} and enter this code:\n\n ${c.userCode}\n\n` +
2188
+ `Waiting for you to approve it on GitHub (it asks for no permissions; ` +
2189
+ `the code lasts ${Math.round(c.expiresIn / 60)} min)…\n`,
2190
+ ),
2191
+ });
2192
+ } catch (e) {
2193
+ io.err(`${cli}: ${(e as Error).message}\n`);
2194
+ return 1;
2195
+ }
2196
+ if (outcome.kind !== "ok") {
2197
+ io.err(
2198
+ `${cli}: ${
2199
+ outcome.kind === "expired"
2200
+ ? "the code expired before it was approved; run it again"
2201
+ : outcome.kind === "denied"
2202
+ ? "the request was cancelled on GitHub"
2203
+ : outcome.message
2204
+ }\n`,
2205
+ );
2206
+ return 1;
2207
+ }
2208
+ const rec = writeWebOwner(outcome.identity, stateDir);
2209
+ if (outcome.expiringToken)
2210
+ io.err(`${product().cli}: warning: ${expiringTokenWarning()}\n`);
2211
+ if (asJson) io.out(`${JSON.stringify({ owner: rec }, null, 2)}\n`);
2212
+ else
2213
+ io.out(
2214
+ `Linked @${rec.login} (${githubPrincipal(outcome.identity.id)}) as this Den's owner; ` +
2215
+ "the token was not kept. Sign in with GitHub from your other devices now works.\n",
2216
+ );
2217
+ return 0;
2218
+ }