@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
@@ -211,8 +211,13 @@ import {
211
211
  } from "../../shared/actor-token.js";
212
212
  import {
213
213
  checkLoginCode,
214
+ deviceName,
215
+ loginCodeNonce,
214
216
  ignoredConfigWarning,
215
217
  localhostOrigin,
218
+ loginLink,
219
+ LOGIN_CODE_TTL_MS,
220
+ mintLoginCode,
216
221
  readWebAuthConfig,
217
222
  safeNext,
218
223
  SESSION_COOKIE_MAX_AGE_S,
@@ -222,6 +227,32 @@ import {
222
227
  type WebAuthConfig,
223
228
  } from "../../shared/web-session.js";
224
229
  import { serveUiAsset } from "./ui-assets.js";
230
+ import {
231
+ expiringTokenWarning,
232
+ githubClientId,
233
+ githubEndpoints,
234
+ noClientIdMessage,
235
+ } from "../../shared/github-app.js";
236
+ import { githubDeviceClient } from "../../shared/github-device.js";
237
+ import { readWebOwner } from "../../shared/web-owner.js";
238
+ import { DeviceFlows } from "./device-flows.js";
239
+ import {
240
+ decideFlow,
241
+ handleGithubRoutes,
242
+ isGithubRoute,
243
+ } from "./github-routes.js";
244
+ import { PairRequests } from "./pair-requests.js";
245
+ import { LoginWaits } from "./login-waits.js";
246
+ import { handlePairRoutes, isPairRoute } from "./pair-routes.js";
247
+ import {
248
+ cliCommand,
249
+ cliPrefix,
250
+ isIpLiteral,
251
+ machineLabel,
252
+ parseMachineName,
253
+ sshCommand,
254
+ sshTarget,
255
+ } from "../../shared/cli-command.js";
225
256
  import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
226
257
  import { graphPageHtml } from "./graph-page.js";
227
258
  import { mergeShellPrefs, shellPrefs, storedShell } from "./shell-prefs.js";
@@ -562,7 +593,7 @@ setHtmlFill((html) => {
562
593
  out = out.replace(
563
594
  '<div id="den-shell"',
564
595
  () =>
565
- `<div id="den-shell" data-edge-exposed="${escAttr(EDGE_EXPOSED_MESSAGE)}"`,
596
+ `<div id="den-shell" data-edge-exposed="${escAttr(edgeExposedMessage(WEB_AUTH.edgeUnattested))}"`,
566
597
  );
567
598
  // MD-2: every page says when the engine on disk is not the one running.
568
599
  const engine = engineCheck();
@@ -799,7 +830,7 @@ import {
799
830
  webRouteHosts,
800
831
  } from "../../shared/edge/render.js";
801
832
  import {
802
- EDGE_EXPOSED_MESSAGE,
833
+ edgeExposedMessage,
803
834
  edgeExposure,
804
835
  edgeNetworkSubnets,
805
836
  } from "./edge-exposure.js";
@@ -1088,13 +1119,154 @@ const GUARD: GuardContext = {
1088
1119
  peerIsLoopback: (req) => isLoopbackAddress(req.socket?.remoteAddress),
1089
1120
  // The edge hostname only from the edge's own peers (loopback, Docker).
1090
1121
  peerIsEdge: (req) => isEdgePeer(req.socket?.remoteAddress),
1091
- // 0.4.3: the edge from this machine counts as local (edgeClientIsLocal).
1122
+ // This machine's addresses and bridges, for edgeClient (SEC-2a).
1092
1123
  localNet: currentLocalNet,
1093
1124
  // A deliberate exposure (DEN_BIND_HOST beyond loopback, other than the
1094
1125
  // Linux edge's Docker bridge): the edge needs a sign-in too.
1095
1126
  exposed: !isLoopbackBind(BIND_HOST) && !isDockerBridgeAddress(BIND_HOST),
1127
+ // SEC-2a: an edge client Den can't place signs in (web.edge_unattested).
1128
+ edgeUnattested: WEB_AUTH.edgeUnattested,
1096
1129
  };
1097
1130
 
1131
+ // ── SEC-2a: Sign in with GitHub, the owner, pairing ──────────────────────────
1132
+ const GITHUB_CLIENT_ID = githubClientId(WEB_AUTH.githubClientId);
1133
+ const GITHUB = githubEndpoints();
1134
+ const DEVICE_FLOWS = new DeviceFlows({
1135
+ client: githubDeviceClient(GITHUB),
1136
+ clientId: () => GITHUB_CLIENT_ID,
1137
+ decide: decideFlow(getStateDir, (l) => serverLog(l, "lifecycle")),
1138
+ // A test's fake GitHub may answer interval 0; the real one never does.
1139
+ minIntervalMs: GITHUB.fake ? 50 : 1000,
1140
+ onExpiringToken: noteExpiringToken,
1141
+ });
1142
+
1143
+ /**
1144
+ * The GitHub App has "Expire user authorization tokens" on (a token answer
1145
+ * carried expires_in or a refresh_token). Sign-in still completes; Den
1146
+ * warns once, naming the fix, and Settings › Access (GET /api/web/owner)
1147
+ * says so as a boolean. The token itself is never logged or kept.
1148
+ */
1149
+ let GITHUB_TOKENS_EXPIRE = false;
1150
+ function noteExpiringToken(): void {
1151
+ if (GITHUB_TOKENS_EXPIRE) return;
1152
+ GITHUB_TOKENS_EXPIRE = true;
1153
+ // The in-app log, and stderr (web.log), as the edge's exposure warning.
1154
+ const line = ` ⚠ ${expiringTokenWarning()}`;
1155
+ pushLog("warn", line);
1156
+ if (!outputLost()) process.stderr.write(line + "\n");
1157
+ }
1158
+
1159
+ // ── SEC-2a: approve this computer with a code (pair-requests.ts) ──────────
1160
+ const PAIRS = new PairRequests();
1161
+
1162
+ /**
1163
+ * What every printed command starts with (cli-command.ts): `<cli>` when the
1164
+ * first one on this server's PATH is this distribution's, else its
1165
+ * absolute path. PATH doesn't change while the server runs: once.
1166
+ */
1167
+ let CLI_PREFIX: string | null = null;
1168
+ function printedCli(): string {
1169
+ CLI_PREFIX ??= cliPrefix(product().cli, getDistRoot(), getEngineRoot());
1170
+ return CLI_PREFIX;
1171
+ }
1172
+
1173
+ /**
1174
+ * The name people read for this machine: `web.machine_name` (user.yml,
1175
+ * read now, so a change in Settings shows at once), else the short
1176
+ * hostname. Never the browser's Host.
1177
+ */
1178
+ function machineName(): string {
1179
+ let set: string | undefined;
1180
+ try {
1181
+ set = readWebAuthConfig(getDistRoot(), product().configFile).machineName;
1182
+ } catch {
1183
+ set = WEB_AUTH.machineName;
1184
+ }
1185
+ return machineLabel(set, os.hostname());
1186
+ }
1187
+
1188
+ /** `web.machine_name` as user.yml holds it, or null (unset or unreadable). */
1189
+ function machineNameSetting(user: {
1190
+ web?: Record<string, unknown>;
1191
+ }): string | null {
1192
+ try {
1193
+ return parseMachineName(user.web?.machine_name);
1194
+ } catch {
1195
+ return null;
1196
+ }
1197
+ }
1198
+
1199
+ /**
1200
+ * A path as the request log writes it: a sign-in poll's id is its
1201
+ * credential (SEC-2a: the code request's, a GitHub flow's), so it shows
1202
+ * as `:id`.
1203
+ */
1204
+ function loggedPath(pathname: string): string {
1205
+ return pathname.replace(
1206
+ /^(\/api\/(?:signin\/code|github\/signin|web\/owner\/link))\/[^/]+$/,
1207
+ "$1/:id",
1208
+ );
1209
+ }
1210
+
1211
+ /** The ssh target for this request: the Host it used, minus the port. */
1212
+ function sshTargetOf(req: http.IncomingMessage): string {
1213
+ return sshTarget(req.headers.host, os.hostname());
1214
+ }
1215
+
1216
+ /**
1217
+ * A browser's live WebSockets (a session's terminal), by its session's
1218
+ * key: revoking a device closes them at once (SEC-2a).
1219
+ */
1220
+ const SESSION_SOCKETS = new Map<string, Set<net.Socket>>();
1221
+ function trackSessionSocket(key: string, socket: net.Socket): void {
1222
+ const set = SESSION_SOCKETS.get(key) ?? new Set<net.Socket>();
1223
+ set.add(socket);
1224
+ SESSION_SOCKETS.set(key, set);
1225
+ socket.once("close", () => {
1226
+ set.delete(socket);
1227
+ if (set.size === 0) SESSION_SOCKETS.delete(key);
1228
+ });
1229
+ }
1230
+ function closeSessionSockets(keys: Iterable<string>): void {
1231
+ for (const k of keys) {
1232
+ for (const sock of SESSION_SOCKETS.get(k) ?? []) sock.destroy();
1233
+ SESSION_SOCKETS.delete(k);
1234
+ }
1235
+ }
1236
+
1237
+ /** A new session for this request and its Set-Cookie line (GitHub or a link). */
1238
+ function startSession(
1239
+ req: http.IncomingMessage,
1240
+ via: "github" | "link" | "code",
1241
+ login?: string,
1242
+ ): string {
1243
+ const g = checkRequest(req, GUARD, product().cli, product().title);
1244
+ const named = g.ok ? g.via : null;
1245
+ const { id } = webSessions.create({
1246
+ userAgent: req.headers["user-agent"],
1247
+ host: req.headers.host,
1248
+ via,
1249
+ ...(g.ok && g.client ? { client: g.client } : {}),
1250
+ ...(login ? { login } : {}),
1251
+ });
1252
+ return sessionSetCookie(id, requestIsHttps(req, named));
1253
+ }
1254
+
1255
+ /** The edge hostnames that route here, and their URLs (Pair a device). */
1256
+ function denEdgeRoutes(): { host: string; url: string }[] {
1257
+ try {
1258
+ return webRouteHosts(readEdgeConfig(), new Set([PORT, readWebPortSafe()]));
1259
+ } catch {
1260
+ return [];
1261
+ }
1262
+ }
1263
+
1264
+ /** What the signed-out page says about Sign in with GitHub. */
1265
+ function githubSigninState(): "ready" | "no_client_id" | "no_owner" {
1266
+ if (!GITHUB_CLIENT_ID) return "no_client_id";
1267
+ return readWebOwner(getStateDir()) ? "ready" : "no_owner";
1268
+ }
1269
+
1098
1270
  /**
1099
1271
  * 0.4.3: why the edge is reachable beyond this machine (edge-exposure.ts),
1100
1272
  * once at start; null until known, or when Den can't tell.
@@ -1118,7 +1290,7 @@ function checkEdgeExposure(): void {
1118
1290
  EDGE_EXPOSED = why;
1119
1291
  // The in-app log, and stderr, which is web.log even when `web up`
1120
1292
  // starts this --silent (stdout is not written then).
1121
- const line = ` ⚠ ${EDGE_EXPOSED_MESSAGE} (${why})`;
1293
+ const line = ` ⚠ ${edgeExposedMessage(WEB_AUTH.edgeUnattested)} (${why})`;
1122
1294
  pushLog("warn", line);
1123
1295
  if (!outputLost()) process.stderr.write(line + "\n");
1124
1296
  })
@@ -1166,6 +1338,8 @@ function sendSignedOut(
1166
1338
  search: string;
1167
1339
  scope: "terminal" | "agents" | "den";
1168
1340
  expired?: boolean;
1341
+ /** SEC-2a: a waited-on link: ask for the code `web login` printed. */
1342
+ confirm?: boolean;
1169
1343
  requestId: string;
1170
1344
  message: string;
1171
1345
  },
@@ -1176,9 +1350,11 @@ function sendSignedOut(
1176
1350
  ? "edge"
1177
1351
  : "other";
1178
1352
  const origin = `${requestIsHttps(req, via === "edge" ? "edge" : null) ? "https" : "http"}://${host}`;
1179
- const cmd =
1180
- `${product().cli} web login` +
1181
- (toLocal || host === `localhost:${PORT}` ? "" : ` --origin ${origin}`);
1353
+ const bin = printedCli();
1354
+ const loginArgs =
1355
+ toLocal || host === `localhost:${PORT}` ? [] : ["--origin", origin];
1356
+ const cmd = cliCommand(bin, "web", "login", ...loginArgs);
1357
+ const ssh = sshTargetOf(req);
1182
1358
  res.setHeader("Cache-Control", "no-store");
1183
1359
  sendErrorPage(req, res, {
1184
1360
  status: 401,
@@ -1190,7 +1366,24 @@ function sendSignedOut(
1190
1366
  scope: opts.scope,
1191
1367
  name: product().title,
1192
1368
  cmd,
1369
+ // SEC-2a: which machine runs the commands, and how to reach it.
1370
+ machine: machineName(),
1371
+ ssh: {
1372
+ target: ssh,
1373
+ // Open Terminal (ssh://) only for a name, never an IP literal.
1374
+ name: !isIpLiteral(ssh),
1375
+ login: sshCommand(ssh, cmd),
1376
+ },
1377
+ // SEC-2a: Sign in with GitHub, and why it can't be offered yet.
1378
+ github: {
1379
+ state: githubSigninState(),
1380
+ owner: cliCommand(bin, "web", "owner", "link"),
1381
+ ...(GITHUB_CLIENT_ID
1382
+ ? {}
1383
+ : { message: noClientIdMessage(product().cli) }),
1384
+ },
1193
1385
  ...(opts.expired ? { expired: true } : {}),
1386
+ ...(opts.confirm ? { confirm: true } : {}),
1194
1387
  ...(toLocal && !opts.expired
1195
1388
  ? {
1196
1389
  localhost: `${localhostOrigin(PORT)}${opts.pathname}${opts.search}`,
@@ -1265,19 +1458,34 @@ function sendGuardRefusal(
1265
1458
  * 303 to `next` when it is a same-origin path, else `/`. Anything else gets
1266
1459
  * the signed-out page, which never echoes the code.
1267
1460
  */
1268
- function handleAuth(
1461
+ async function handleAuth(
1269
1462
  req: http.IncomingMessage,
1270
1463
  res: http.ServerResponse,
1271
1464
  url: URL,
1272
1465
  requestId: string,
1273
- via: "loopback" | "edge" | "lan" | null,
1274
- ): void {
1466
+ ): Promise<void> {
1275
1467
  const secret = webSecret.current();
1468
+ const c = url.searchParams.get("c") ?? "";
1469
+ const nonce = loginCodeNonce(c);
1470
+ // SEC-2a: a link `web login` waits on asks for its code first. Opened,
1471
+ // its code is checked as of the moment it was opened (then 5 minutes).
1472
+ const wait = nonce ? LOGIN_WAITS.forNonce(nonce) : null;
1276
1473
  const check = secret
1277
- ? checkLoginCode(url.searchParams.get("c") ?? "", secret)
1474
+ ? checkLoginCode(c, secret, wait?.openedAt ?? Date.now())
1278
1475
  : null;
1279
- if (!check?.ok || !webSessions.consumeCode(check.nonce, check.expiresAt)) {
1476
+ const post = req.method === "POST";
1477
+ const gone = () => {
1280
1478
  res.setHeader("Referrer-Policy", "no-referrer");
1479
+ if (post)
1480
+ return sendJson(
1481
+ res,
1482
+ {
1483
+ ok: false,
1484
+ code: "expired",
1485
+ error: `This link no longer works. On ${machineName()}, run web login again for a new one.`,
1486
+ },
1487
+ 410,
1488
+ );
1281
1489
  return sendSignedOut(req, res, {
1282
1490
  pathname: "/auth",
1283
1491
  search: "",
@@ -1286,20 +1494,90 @@ function handleAuth(
1286
1494
  requestId,
1287
1495
  message: `That sign-in link expired or was used. Run \`${product().cli} web login\` for a new one.`,
1288
1496
  });
1497
+ };
1498
+ if (!check?.ok) return gone();
1499
+ if (wait && nonce) {
1500
+ if (!post) {
1501
+ if (wait.state !== "waiting" && wait.state !== "opened") return gone();
1502
+ LOGIN_WAITS.open(nonce);
1503
+ res.setHeader("Referrer-Policy", "no-referrer");
1504
+ return sendSignedOut(req, res, {
1505
+ pathname: "/auth",
1506
+ search: "",
1507
+ scope: "den",
1508
+ confirm: true,
1509
+ requestId,
1510
+ message: "Enter the code `web login` printed.",
1511
+ });
1512
+ }
1513
+ let body: { code?: unknown } = {};
1514
+ try {
1515
+ body = (JSON.parse((await readBody(req)) || "{}") ?? {}) as typeof body;
1516
+ } catch {
1517
+ return sendJson(
1518
+ res,
1519
+ { ok: false, error: "the body must be JSON: { code }" },
1520
+ 400,
1521
+ );
1522
+ }
1523
+ const r = LOGIN_WAITS.confirm(nonce, String(body.code ?? ""));
1524
+ res.setHeader("Referrer-Policy", "no-referrer");
1525
+ res.setHeader("Cache-Control", "no-store");
1526
+ if (!r.ok) {
1527
+ if (r.error === "expired") return gone();
1528
+ return sendJson(
1529
+ res,
1530
+ r.error === "wrong"
1531
+ ? {
1532
+ ok: false,
1533
+ code: "wrong_code",
1534
+ left: r.left,
1535
+ error:
1536
+ "That code isn't right. Check what web login printed and try again.",
1537
+ }
1538
+ : r.error === "rate_limited"
1539
+ ? {
1540
+ ok: false,
1541
+ code: "rate_limited",
1542
+ error: "Too many wrong codes. Wait a minute and try again.",
1543
+ }
1544
+ : {
1545
+ ok: false,
1546
+ code: "refused",
1547
+ error: `Too many wrong codes: this link no longer works. On ${machineName()}, run web login again.`,
1548
+ },
1549
+ r.error === "wrong" ? 401 : r.error === "rate_limited" ? 429 : 410,
1550
+ );
1551
+ }
1552
+ if (!webSessions.consumeCode(check.nonce, check.expiresAt)) return gone();
1553
+ const g = checkRequest(req, GUARD, product().cli, product().title);
1554
+ LOGIN_WAITS.used(nonce, {
1555
+ device: deviceName(req.headers["user-agent"]),
1556
+ ...(g.ok && g.client ? { client: g.client } : {}),
1557
+ });
1558
+ res.setHeader("Set-Cookie", startSession(req, "link"));
1559
+ return sendJson(res, {
1560
+ ok: true,
1561
+ next: safeNext(url.searchParams.get("next")),
1562
+ });
1289
1563
  }
1290
- const { id } = webSessions.create({
1291
- userAgent: req.headers["user-agent"],
1292
- host: req.headers.host,
1293
- });
1564
+ // A link nobody waits on (`web login --no-wait`, or minted with no
1565
+ // server running): it signs in when opened, once, as before.
1566
+ if (post || !webSessions.consumeCode(check.nonce, check.expiresAt))
1567
+ return gone();
1294
1568
  res.writeHead(303, {
1295
1569
  Location: safeNext(url.searchParams.get("next")),
1296
- "Set-Cookie": sessionSetCookie(id, requestIsHttps(req, via)),
1570
+ "Set-Cookie": startSession(req, "link"),
1297
1571
  "Cache-Control": "no-store",
1298
1572
  "Referrer-Policy": "no-referrer",
1299
1573
  });
1300
1574
  res.end();
1301
1575
  }
1302
1576
 
1577
+ /** `web login`'s wait (login-waits.ts), by the CLI's id. */
1578
+ const LOGIN_WAIT_PATH = /^\/api\/web\/logins\/([A-Za-z0-9_-]{16,64})$/;
1579
+ const LOGIN_WAITS = new LoginWaits();
1580
+
1303
1581
  /**
1304
1582
  * A service's declared command `cmd` for `env`, own properties only
1305
1583
  * (SEC-1 §6b.2): `constructor` or `__proto__` is no command, never
@@ -1350,7 +1628,7 @@ async function handleRequest(
1350
1628
  res.on("finish", () => {
1351
1629
  const ms = dim(`${Date.now() - start}ms`);
1352
1630
  serverLog(
1353
- ` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}` +
1631
+ ` ${logTimestamp()} ${method} ${loggedPath(pathname)} → ${logStatus(res.statusCode)} ${ms}` +
1354
1632
  (res.statusCode >= 400 ? dim(` req ${requestId}`) : ""),
1355
1633
  "request",
1356
1634
  );
@@ -1379,8 +1657,51 @@ async function handleRequest(
1379
1657
 
1380
1658
  // ── Sign-in (SEC-1b, §2) ───────────────────────────────────────────────────
1381
1659
  // Before config validation: signing in never depends on the config.
1382
- if (pathname === "/auth" && (req.method === "GET" || req.method === "HEAD"))
1383
- return handleAuth(req, res, url, requestId, guard.ok ? guard.via : null);
1660
+ if (
1661
+ pathname === "/auth" &&
1662
+ (req.method === "GET" || req.method === "HEAD" || req.method === "POST")
1663
+ )
1664
+ return handleAuth(req, res, url, requestId);
1665
+ // SEC-2a: `web login` registers its link's wait, then polls it
1666
+ // (shell-grade: the CLI's bearer).
1667
+ if (pathname === "/api/web/logins" && req.method === "POST") {
1668
+ let body: { nonce?: unknown; code?: unknown; expiresAt?: unknown } = {};
1669
+ try {
1670
+ body = (JSON.parse((await readBody(req)) || "{}") ?? {}) as typeof body;
1671
+ } catch {
1672
+ return send400(res, "the body must be JSON: { nonce, code, expiresAt }");
1673
+ }
1674
+ const exp = Number(body.expiresAt);
1675
+ const id =
1676
+ Number.isFinite(exp) &&
1677
+ exp > Date.now() &&
1678
+ exp - Date.now() <= LOGIN_CODE_TTL_MS + 5_000
1679
+ ? LOGIN_WAITS.register(
1680
+ String(body.nonce ?? ""),
1681
+ String(body.code ?? ""),
1682
+ exp,
1683
+ )
1684
+ : null;
1685
+ if (!id) return send400(res, "not a login link's nonce, code and expiry");
1686
+ res.setHeader("Cache-Control", "no-store");
1687
+ return sendJson(res, { ok: true, id });
1688
+ }
1689
+ {
1690
+ const w = LOGIN_WAIT_PATH.exec(pathname);
1691
+ if (w && (req.method === "GET" || req.method === "HEAD")) {
1692
+ res.setHeader("Cache-Control", "no-store");
1693
+ const v = LOGIN_WAITS.get(w[1]);
1694
+ if (!v) return sendJson(res, { ok: false, state: "expired" }, 404);
1695
+ return sendJson(res, {
1696
+ ok: true,
1697
+ state: v.state,
1698
+ ...(v.device ? { device: v.device } : {}),
1699
+ ...(v.client ? { client: v.client } : {}),
1700
+ ...(v.usedAt ? { usedAt: new Date(v.usedAt).toISOString() } : {}),
1701
+ expiresAt: new Date(v.expiresAt).toISOString(),
1702
+ });
1703
+ }
1704
+ }
1384
1705
  if (
1385
1706
  pathname === "/signin" &&
1386
1707
  (req.method === "GET" || req.method === "HEAD")
@@ -1410,6 +1731,65 @@ async function handleRequest(
1410
1731
  // is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
1411
1732
  if (pathname.startsWith("/api/sessions/"))
1412
1733
  return sendSessionHttp(req, res, pathname);
1734
+ // SEC-2a: Sign in with GitHub, the owner's link, Pair a device. Before
1735
+ // config validation, like the sign-in above.
1736
+ if (
1737
+ isGithubRoute(pathname) &&
1738
+ (await handleGithubRoutes(req, res, pathname, {
1739
+ flows: DEVICE_FLOWS,
1740
+ clientId: () => GITHUB_CLIENT_ID,
1741
+ tokensExpire: () => GITHUB_TOKENS_EXPIRE,
1742
+ cli: product().cli,
1743
+ sendJson,
1744
+ readBody,
1745
+ signIn: startSession,
1746
+ edgeRoutes: denEdgeRoutes,
1747
+ mintLink: (origin) => {
1748
+ const secret = webSecret.current();
1749
+ if (!secret) return null;
1750
+ const now = Date.now();
1751
+ return {
1752
+ url: loginLink(origin, mintLoginCode(secret, now)),
1753
+ expiresAt: now + LOGIN_CODE_TTL_MS,
1754
+ };
1755
+ },
1756
+ log: (l) => serverLog(l, "lifecycle"),
1757
+ stateDir: getStateDir,
1758
+ }))
1759
+ )
1760
+ return;
1761
+
1762
+ // SEC-2a: approve this computer with a code. Before config validation.
1763
+ if (
1764
+ isPairRoute(pathname) &&
1765
+ (await handlePairRoutes(req, res, pathname, {
1766
+ pairs: PAIRS,
1767
+ sendJson,
1768
+ readBody,
1769
+ signIn: (r) => startSession(r, "code"),
1770
+ client: (r) => {
1771
+ const g = checkRequest(r, GUARD, product().cli, product().title);
1772
+ return g.ok ? g.client : undefined;
1773
+ },
1774
+ commands: (r, code) => {
1775
+ const approve = cliCommand(printedCli(), "web", "approve", code);
1776
+ return { approve, ssh: sshCommand(sshTargetOf(r), approve) };
1777
+ },
1778
+ audit: (r, verb, detail, refused) =>
1779
+ recordAction(
1780
+ auditActorOf(r),
1781
+ {
1782
+ verb,
1783
+ detail,
1784
+ ...(refused
1785
+ ? { outcome: "refused" as const, reason: refused }
1786
+ : {}),
1787
+ },
1788
+ principalOf(r),
1789
+ ),
1790
+ }))
1791
+ )
1792
+ return;
1413
1793
 
1414
1794
  // A mutating request may change what /api/services would list (exec and
1415
1795
  // sync stream over GET, for EventSource).
@@ -3385,22 +3765,41 @@ async function handleRequest(
3385
3765
  actor: g.principal.actor,
3386
3766
  signedIn: false,
3387
3767
  });
3388
- if (g?.session)
3768
+ // SEC-2a: who this browser is (the rail's identity chip), and, once
3769
+ // after a sign-in, that it just signed in (the shell's toast).
3770
+ if (g?.session) {
3771
+ const rec = webSessions.list().find((x) => x.key === g.session!.key);
3772
+ const github = rec?.via === "github";
3389
3773
  return sendJson(res, {
3390
3774
  csrf: g.session.csrf,
3391
3775
  actor: "person-browser",
3392
3776
  signedIn: true,
3777
+ identity: {
3778
+ kind: github ? "github" : "device",
3779
+ via: rec?.via ?? "link",
3780
+ ...(github && rec?.login ? { login: rec.login } : {}),
3781
+ },
3782
+ ...(webSessions.takeWelcome(g.session.key)
3783
+ ? {
3784
+ justSignedIn: true,
3785
+ product: product().title,
3786
+ machine: machineName(),
3787
+ }
3788
+ : {}),
3393
3789
  });
3790
+ }
3394
3791
  return sendJson(res, {
3395
3792
  csrf: g?.beyond ? null : GUARD.csrfToken,
3396
3793
  actor: null,
3397
3794
  signedIn: false,
3795
+ ...(g && !g.beyond ? { identity: { kind: "local" } } : {}),
3398
3796
  });
3399
3797
  }
3400
3798
  // Sign out: this browser's session ends and its cookie is cleared.
3401
3799
  if (pathname === "/api/session/logout" && req.method === "POST") {
3402
3800
  const g = guard.ok ? guard : null;
3403
3801
  const ended = g?.session ? webSessions.end(g.session.key) : false;
3802
+ if (g?.session) closeSessionSockets([g.session.key]);
3404
3803
  res.setHeader(
3405
3804
  "Set-Cookie",
3406
3805
  clearSessionCookie(requestIsHttps(req, g?.via ?? null)),
@@ -3417,6 +3816,11 @@ async function handleRequest(
3417
3816
  lastSeenAt: new Date(x.lastSeenAt).toISOString(),
3418
3817
  userAgent: x.userAgent,
3419
3818
  host: x.host,
3819
+ // SEC-2a: a linked device's name, how it signed in, its address.
3820
+ name: deviceName(x.userAgent),
3821
+ via: x.via ?? "link",
3822
+ ...(x.login ? { login: x.login } : {}),
3823
+ ...(x.client ? { client: x.client } : {}),
3420
3824
  ...(x.key === mine ? { current: true } : {}),
3421
3825
  })),
3422
3826
  });
@@ -3437,11 +3841,14 @@ async function handleRequest(
3437
3841
  if (body.id !== undefined && typeof body.id !== "string")
3438
3842
  return send400(res, "id must be a session id prefix");
3439
3843
  try {
3440
- const ended =
3844
+ const endedKeys =
3441
3845
  typeof body.id === "string"
3442
- ? [sessionIdPrefix(webSessions.endByPrefix(body.id).key)]
3443
- : webSessions.list().map((x) => sessionIdPrefix(x.key));
3846
+ ? [webSessions.endByPrefix(body.id).key]
3847
+ : webSessions.list().map((x) => x.key);
3444
3848
  if (typeof body.id !== "string") webSessions.endAll();
3849
+ // SEC-2a: a revoked device's open terminals close now, too.
3850
+ closeSessionSockets(endedKeys);
3851
+ const ended = endedKeys.map(sessionIdPrefix);
3445
3852
  const rotated = body.rotate === true;
3446
3853
  let agentTokens = 0;
3447
3854
  if (rotated) {
@@ -3895,7 +4302,7 @@ async function handleRequest(
3895
4302
  // GET /api/sudo-password/status → { set, passwordless,
3896
4303
  // saved: "vault" | "session" | null, vault: { configured, label } }
3897
4304
  // GET /api/sudo-password/vault-status → { backend, label, configured,
3898
- // backendAvailable, reason?, hasStored, opAvailable (deprecated) }
4305
+ // canSave, backendAvailable, reason?, hasStored, opAvailable (deprecated) }
3899
4306
  // POST /api/sudo-password/load-from-vault → { ok, reason?, message? }
3900
4307
  if (pathname === "/api/sudo-password") {
3901
4308
  if (req.method !== "POST") {
@@ -3925,16 +4332,24 @@ async function handleRequest(
3925
4332
  setSudoPassword(password);
3926
4333
  let savedToVault = false;
3927
4334
  let vaultError: string | undefined;
4335
+ let refCreated = false;
3928
4336
  if (saveToVault) {
4337
+ // LK-4: with no secrets.refs.sudo, this creates the item and writes
4338
+ // the ref to user.yml. The error never carries the password.
3929
4339
  try {
3930
- await saveSudoToVault(password);
4340
+ refCreated = (await saveSudoToVault(password)).ref === "created";
3931
4341
  savedToVault = true;
3932
4342
  sudoInVault = true;
3933
4343
  } catch (err) {
3934
4344
  vaultError = (err as Error).message;
3935
4345
  }
3936
4346
  }
3937
- return sendJson(res, { ok: true, savedToVault, vaultError });
4347
+ return sendJson(res, {
4348
+ ok: true,
4349
+ savedToVault,
4350
+ ...(refCreated ? { refCreated } : {}),
4351
+ vaultError,
4352
+ });
3938
4353
  } catch (e) {
3939
4354
  return send400(res, `Invalid request: ${(e as Error).message}`);
3940
4355
  }
@@ -4096,6 +4511,10 @@ async function handleRequest(
4096
4511
  // The UI's language (R19), and the ones it has catalogs for.
4097
4512
  language: uiLanguage(user.language),
4098
4513
  ui_languages: UI_LANGUAGES,
4514
+ // SEC-2a: the name the signed-out page gives this machine, and
4515
+ // what it is when unset (the short hostname).
4516
+ machine_name: machineNameSetting(user),
4517
+ machine_name_default: machineLabel(null, os.hostname()),
4099
4518
  });
4100
4519
  }
4101
4520
  if (req.method === "POST") {
@@ -4108,8 +4527,23 @@ async function handleRequest(
4108
4527
  appearance?: unknown;
4109
4528
  shell?: unknown;
4110
4529
  language?: unknown;
4530
+ machine_name?: unknown;
4111
4531
  };
4112
4532
  const user = readUserConfig();
4533
+ // SEC-2a: web.machine_name (user.yml); null or "" clears it.
4534
+ if (body.machine_name !== undefined) {
4535
+ let name: string | null;
4536
+ try {
4537
+ name = parseMachineName(body.machine_name);
4538
+ } catch (e) {
4539
+ return send400(res, (e as Error).message);
4540
+ }
4541
+ const web = { ...(user.web ?? {}) };
4542
+ if (name) web.machine_name = name;
4543
+ else delete web.machine_name;
4544
+ if (Object.keys(web).length) user.web = web;
4545
+ else delete user.web;
4546
+ }
4113
4547
  // null clears (0.3.0-dev.32, a consumer's nit): a block or a key
4114
4548
  // set to null is removed from user.yml, and a block left empty
4115
4549
  // goes too, so setting a pref and clearing it again leaves the
@@ -4204,6 +4638,7 @@ async function handleRequest(
4204
4638
  appearance: appearancePrefs(user.appearance),
4205
4639
  shell: shellPrefs(user.shell),
4206
4640
  language: uiLanguage(user.language),
4641
+ machine_name: machineNameSetting(user),
4207
4642
  });
4208
4643
  } catch (e) {
4209
4644
  return send400(res, `Invalid request: ${(e as Error).message}`);
@@ -4558,6 +4993,7 @@ server.on(
4558
4993
  setPrincipal(req, guard.principal);
4559
4994
  if (!isSessionIoPath(pathname))
4560
4995
  return refuse(404, "No WebSocket route here", "not_found");
4996
+ if (guard.session) trackSessionSocket(guard.session.key, socket);
4561
4997
  handleSessionUpgrade(req, socket, head, pathname, requestId).catch(
4562
4998
  (err: unknown) => {
4563
4999
  serverLog(