@wrongstack/cli 0.299.0 → 0.301.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/{acp-S4LZQB66.js → acp-DATHHPNT.js} +36 -17
  2. package/dist/acp-server-agent.d.ts +1 -1
  3. package/dist/{audit-2ZSFGNIF.js → audit-QRCLEHHW.js} +5 -1
  4. package/dist/{auth-G5YKKSRH.js → auth-JASC4K3F.js} +151 -38
  5. package/dist/auth-menu/loopback-server.d.ts +41 -55
  6. package/dist/auth-menu/openai-codex-oauth.d.ts +36 -123
  7. package/dist/auto-discover-providers-DRW4OZHG.js +8 -0
  8. package/dist/boot/dispatch-webui.d.ts +3 -0
  9. package/dist/boot/launch-menu.d.ts +2 -4
  10. package/dist/boot/tool-registry.d.ts +3 -0
  11. package/dist/chimera-reviewer-policy.d.ts +10 -3
  12. package/dist/chimera-work-registry.d.ts +19 -0
  13. package/dist/{chronicle-QQEQFSSE.js → chronicle-63MFCZN6.js} +26 -5
  14. package/dist/{chunk-YWUPHRGB.js → chunk-263G3FMB.js} +33 -10
  15. package/dist/{chunk-2SLSUDYS.js → chunk-2KDOFTTM.js} +312 -211
  16. package/dist/{chunk-TGDHN4LM.js → chunk-4GVH7V3S.js} +4 -24
  17. package/dist/chunk-CSAPOCBP.js +24 -0
  18. package/dist/{chunk-66T43Q4Y.js → chunk-ETOEGL3V.js} +6 -9
  19. package/dist/{chunk-PEKUKUCH.js → chunk-GUHQQG63.js} +32 -14
  20. package/dist/chunk-KE7E7DPX.js +38 -0
  21. package/dist/{chunk-ZNZJ34RF.js → chunk-MHF2IJDE.js} +21 -4
  22. package/dist/{chunk-4HH3YGG4.js → chunk-MRLYDHQ7.js} +8 -5
  23. package/dist/{chunk-L5CE52XW.js → chunk-O7XNGCRT.js} +317 -309
  24. package/dist/chunk-PYTFS4MC.js +238 -0
  25. package/dist/{chunk-DPJZQCVP.js → chunk-YMXXOOFN.js} +1 -1
  26. package/dist/{chunk-4U3DMA2Y.js → chunk-ZECLGUTF.js} +28 -21
  27. package/dist/{cli-main-3MQDFSYK.js → cli-main-XWC57MGE.js} +849 -242
  28. package/dist/{execution-ANOYMWPO.js → execution-LHMSMDHB.js} +174 -55
  29. package/dist/execution-chimera-cascade.d.ts +3 -4
  30. package/dist/execution-chimera-review.d.ts +2 -3
  31. package/dist/execution-cleanup.d.ts +3 -2
  32. package/dist/execution.d.ts +1 -1
  33. package/dist/{export-TPORYVRZ.js → export-DOBGPY23.js} +12 -1
  34. package/dist/fleet/budget-source.d.ts +18 -0
  35. package/dist/fleet/host-context.d.ts +1 -1
  36. package/dist/fleet/host-helpers.d.ts +14 -0
  37. package/dist/fleet/host-status.d.ts +31 -0
  38. package/dist/fleet/host-types.d.ts +8 -0
  39. package/dist/fleet/host.d.ts +5 -0
  40. package/dist/fleet/status-broadcast.d.ts +1 -1
  41. package/dist/{hq-BDO56CX6.js → hq-LN5CJHCM.js} +22 -3
  42. package/dist/hq-server/auth-state.d.ts +29 -1
  43. package/dist/hq-server/auth.d.ts +16 -0
  44. package/dist/hq-server/client-address.d.ts +56 -0
  45. package/dist/hq-server/login-attempt-store.d.ts +30 -10
  46. package/dist/hq-server/routes/auth-handlers.d.ts +66 -26
  47. package/dist/hq-server/routes.d.ts +23 -1
  48. package/dist/hq-server/types.d.ts +10 -0
  49. package/dist/hq-server/ws.d.ts +17 -6
  50. package/dist/{hq-server-RJLJJZU7.js → hq-server-S7HZG3PA.js} +3 -2
  51. package/dist/hq-server.d.ts +19 -0
  52. package/dist/index.js +69 -104
  53. package/dist/live-settings-input.d.ts +5 -0
  54. package/dist/{mcp-CYLPUEHC.js → mcp-OHPJH73U.js} +6 -2
  55. package/dist/{modeldiag-HENYJ4N7.js → modeldiag-3DOK4BWW.js} +3 -3
  56. package/dist/{plugin-usage-VTL7ZTVC.js → plugin-usage-YEVLOR33.js} +7 -3
  57. package/dist/{plugins-ETY6W3DC.js → plugins-K3BAUP4D.js} +3 -3
  58. package/dist/profile-config-path.d.ts +0 -2
  59. package/dist/provider-helpers.d.ts +0 -5
  60. package/dist/{providers-models-GDELBHZK.js → providers-models-WEZ4EGLL.js} +38 -12
  61. package/dist/{replay-KCTXNMZQ.js → replay-FCBNHW3K.js} +5 -1
  62. package/dist/{rewind-EJMVFVLS.js → rewind-AOD3JIYX.js} +5 -1
  63. package/dist/{sessions-config-NLCMK3BY.js → sessions-config-XXN7267N.js} +8 -3
  64. package/dist/slash-commands/command-context.d.ts +19 -0
  65. package/dist/subcommands/flags.d.ts +23 -0
  66. package/dist/terminal-format.d.ts +15 -0
  67. package/dist/webui-server/setup-events.d.ts +17 -0
  68. package/dist/webui-server/terminal-log-view.d.ts +29 -12
  69. package/dist/{webui-server-PCLSXX6L.js → webui-server-DD35QFNO.js} +113 -50
  70. package/dist/webui-server-options.d.ts +16 -0
  71. package/dist/wiring/brain-and-orchestration.d.ts +4 -1
  72. package/dist/wiring/director-setup.d.ts +4 -0
  73. package/dist/wiring/fallback-gate.d.ts +31 -0
  74. package/dist/wiring/fleet-command-handlers.d.ts +1 -1
  75. package/dist/wiring/mailbox-bridge-bootstrap.d.ts +3 -1
  76. package/dist/wiring/management-tools.d.ts +9 -1
  77. package/dist/wiring/pipeline.d.ts +5 -5
  78. package/dist/wiring/plugins.d.ts +3 -10
  79. package/dist/wiring/provider-utility-tools.d.ts +27 -1
  80. package/dist/wiring/tools.d.ts +4 -5
  81. package/package.json +23 -23
  82. package/dist/chunk-GO3TJICK.js +0 -489
@@ -1,3 +1,7 @@
1
+ import {
2
+ terminalLink,
3
+ terminalText
4
+ } from "./chunk-KE7E7DPX.js";
1
5
  import {
2
6
  resolveAuditActor
3
7
  } from "./chunk-Q5GTM25S.js";
@@ -21,7 +25,7 @@ import {
21
25
  HqAlertEngine,
22
26
  HqBootstrapCodeStore,
23
27
  HqCommandAuditLog,
24
- hqTokenKey as hqTokenKey3,
28
+ hqTokenKey as hqTokenKey2,
25
29
  hqTokenVerifier as hqTokenVerifier2,
26
30
  mintHqCookieSecret as mintHqCookieSecret2,
27
31
  mutateHqAuthFile as mutateHqAuthFile2,
@@ -174,6 +178,10 @@ function parseCookieHeader(cookieHeader) {
174
178
  }
175
179
  return out;
176
180
  }
181
+ function readHqSessionCookie(cookieHeader) {
182
+ const cookies = parseCookieHeader(cookieHeader);
183
+ return cookies[HQ_SESSION_COOKIE_SECURE] ?? cookies[HQ_SESSION_COOKIE];
184
+ }
177
185
  function setHqSessionCookie(res, value, secure) {
178
186
  const name = cookieName(secure);
179
187
  const parts = [
@@ -265,8 +273,7 @@ function authenticateBrowserRequest(req, url, mutableAuth, sessions) {
265
273
  }
266
274
  }
267
275
  if (mutableAuth.cookieSecret) {
268
- const cookies = parseCookieHeader(req.headers.cookie);
269
- const raw = cookies[HQ_SESSION_COOKIE] ?? cookies["__Host-hq.session"];
276
+ const raw = readHqSessionCookie(req.headers.cookie);
270
277
  if (raw) {
271
278
  const sessionId = parseHqSessionCookie(raw, mutableAuth.cookieSecret);
272
279
  if (sessionId) {
@@ -312,25 +319,38 @@ var TOKEN_EXPIRY_WARNING_MS = 24 * 60 * 60 * 1e3;
312
319
  function liveTokens(list) {
313
320
  return (list ?? []).filter((token) => !isTokenExpired2(token));
314
321
  }
315
- function createHqAuthState(authFile, dataDir) {
322
+ function projectAuthFile(mutableAuth, next) {
323
+ mutableAuth.operatorPolicy = {
324
+ ...DEFAULT_HQ_REDACTION_POLICY,
325
+ ...next.redactionPolicy ?? {}
326
+ };
327
+ mutableAuth.operatorPolicyOverride = next.redactionPolicy;
328
+ mutableAuth.browserTokens = new Set(liveTokens(next.browserTokens).map(hqTokenKey));
329
+ mutableAuth.clientTokens = new Set(liveTokens(next.clientTokens).map(hqTokenKey));
330
+ mutableAuth.browserTokenObjs = browserTokenMap(next.browserTokens);
331
+ mutableAuth.clientTokenObjs = new Map(
332
+ liveTokens(next.clientTokens).map((token) => [hqTokenKey(token), token])
333
+ );
334
+ mutableAuth.passwordHash = next.passwordHash;
335
+ mutableAuth.cookieSecret = next.cookieSecret;
336
+ mutableAuth.totpSecret = next.totpSecret;
337
+ mutableAuth.totpPendingSecret = next.totpPendingSecret;
338
+ mutableAuth.totpRecoveryCodes = next.totpRecoveryCodes;
339
+ mutableAuth.alertRules = next.alertRules;
340
+ }
341
+ function createHqAuthState(authFile, dataDir, opts = {}) {
316
342
  let rawBrowserTokens = authFile.browserTokens ?? [];
317
343
  let rawClientTokens = authFile.clientTokens ?? [];
318
344
  const mutableAuth = {
319
- operatorPolicy: { ...DEFAULT_HQ_REDACTION_POLICY, ...authFile.redactionPolicy ?? {} },
320
- operatorPolicyOverride: authFile.redactionPolicy,
321
- browserTokens: new Set(liveTokens(authFile.browserTokens).map(hqTokenKey)),
322
- clientTokens: new Set(liveTokens(authFile.clientTokens).map(hqTokenKey)),
323
- browserTokenObjs: browserTokenMap(authFile.browserTokens),
324
- clientTokenObjs: new Map(
325
- liveTokens(authFile.clientTokens).map((token) => [hqTokenKey(token), token])
326
- ),
327
- passwordHash: authFile.passwordHash,
328
- cookieSecret: authFile.cookieSecret,
329
- totpSecret: authFile.totpSecret,
330
- totpPendingSecret: authFile.totpPendingSecret,
331
- totpRecoveryCodes: authFile.totpRecoveryCodes,
332
- alertRules: authFile.alertRules
345
+ operatorPolicy: { ...DEFAULT_HQ_REDACTION_POLICY },
346
+ operatorPolicyOverride: void 0,
347
+ browserTokens: /* @__PURE__ */ new Set(),
348
+ clientTokens: /* @__PURE__ */ new Set(),
349
+ browserTokenObjs: /* @__PURE__ */ new Map(),
350
+ clientTokenObjs: /* @__PURE__ */ new Map(),
351
+ alertRules: void 0
333
352
  };
353
+ projectAuthFile(mutableAuth, authFile);
334
354
  return {
335
355
  mutableAuth,
336
356
  tokenStats: () => {
@@ -355,23 +375,8 @@ function createHqAuthState(authFile, dataDir) {
355
375
  auditPrunedTokens("client", rawClientTokens, newClient, next, dataDir);
356
376
  rawBrowserTokens = newBrowser;
357
377
  rawClientTokens = newClient;
358
- mutableAuth.operatorPolicy = {
359
- ...DEFAULT_HQ_REDACTION_POLICY,
360
- ...next.redactionPolicy ?? {}
361
- };
362
- mutableAuth.operatorPolicyOverride = next.redactionPolicy;
363
- mutableAuth.browserTokens = new Set(liveTokens(next.browserTokens).map(hqTokenKey));
364
- mutableAuth.clientTokens = new Set(liveTokens(next.clientTokens).map(hqTokenKey));
365
- mutableAuth.browserTokenObjs = browserTokenMap(next.browserTokens);
366
- mutableAuth.clientTokenObjs = new Map(
367
- liveTokens(next.clientTokens).map((token) => [hqTokenKey(token), token])
368
- );
369
- mutableAuth.passwordHash = next.passwordHash;
370
- mutableAuth.cookieSecret = next.cookieSecret;
371
- mutableAuth.totpSecret = next.totpSecret;
372
- mutableAuth.totpPendingSecret = next.totpPendingSecret;
373
- mutableAuth.totpRecoveryCodes = next.totpRecoveryCodes;
374
- mutableAuth.alertRules = next.alertRules;
378
+ projectAuthFile(mutableAuth, next);
379
+ opts.onApplied?.(mutableAuth);
375
380
  }
376
381
  };
377
382
  }
@@ -453,24 +458,41 @@ var LoginAttemptStore = class _LoginAttemptStore {
453
458
  }
454
459
  }
455
460
  /**
456
- * Compound rate-limit key for public-relay mode: IP + SHA-256(password).
457
- * Limits how often the SAME password can be tried from different IPs,
458
- * so a rotating-IP attacker can't bypass per-IP backoff.
461
+ * Rate-limit key for a *candidate password*, independent of source IP.
462
+ *
463
+ * WS-104: this used to be `cred:${ip}:${hash}`. Embedding the IP made the
464
+ * documented purpose — "limit how often the SAME password can be tried from
465
+ * different IPs" — structurally impossible, because a rotating-IP attacker
466
+ * got a fresh counter with every hop, exactly the case the key exists for.
467
+ * The IP-scoped counter is already kept separately under the bare `ip` key.
468
+ *
469
+ * Backoff caps at 16s (see {@link recordFailure}), so a global per-password
470
+ * counter throttles guessing without becoming a lockout an attacker could
471
+ * aim at the operator.
459
472
  */
460
- static credentialKey(ip, password) {
473
+ static credentialKey(password) {
461
474
  const hash = createHash("sha256").update(password, "utf8").digest("hex").slice(0, 16);
462
- return `cred:${ip}:${hash}`;
475
+ return `cred:${hash}`;
463
476
  }
464
477
  /**
465
- * Check both IP and credential keys; return the stricter (later blockedUntil).
478
+ * Check the IP key and when a candidate password is supplied — the
479
+ * credential key too, returning the stricter (later `blockedUntil`).
480
+ *
481
+ * WS-104: `handleApiLogin` called this with `''` before parsing the request
482
+ * body ("we don't have the password yet") and never called it again, so the
483
+ * credential entries {@link recordFailure} wrote were only ever *written*.
484
+ * Callers now re-check once the password is known; passing no password (or
485
+ * an empty one) means "IP scope only".
466
486
  */
467
487
  checkBlocked(ip, password) {
468
- const ipEntry = this.store.get(ip);
469
- const credKey = _LoginAttemptStore.credentialKey(ip, password);
470
- const credEntry = this.store.get(credKey);
471
488
  const now = Date.now();
489
+ const ipEntry = this.store.get(ip);
472
490
  const ipBlocked = ipEntry && ipEntry.blockedUntil > now ? ipEntry.blockedUntil : 0;
473
- const credBlocked = credEntry && credEntry.blockedUntil > now ? credEntry.blockedUntil : 0;
491
+ let credBlocked = 0;
492
+ if (password !== void 0 && password.length > 0) {
493
+ const credEntry = this.store.get(_LoginAttemptStore.credentialKey(password));
494
+ credBlocked = credEntry && credEntry.blockedUntil > now ? credEntry.blockedUntil : 0;
495
+ }
474
496
  const blockedUntil = Math.max(ipBlocked, credBlocked);
475
497
  if (blockedUntil > now) {
476
498
  return { blocked: true, retryAfter: Math.ceil((blockedUntil - now) / 1e3) };
@@ -478,7 +500,11 @@ var LoginAttemptStore = class _LoginAttemptStore {
478
500
  return { blocked: false, retryAfter: 0 };
479
501
  }
480
502
  /**
481
- * Record a failed attempt on both IP and credential keys.
503
+ * Record a failed attempt on the IP key, and on the credential key when a
504
+ * candidate password is supplied. Omit the password for flows that have no
505
+ * one (2FA verification, TOTP disable) so they do not accumulate a
506
+ * meaningless `cred:sha256("")` counter shared by every such flow.
507
+ *
482
508
  * Returns the updated IP entry (for the response Retry-After header).
483
509
  */
484
510
  recordFailure(ip, password, maxBackoffMs = 16e3) {
@@ -491,16 +517,21 @@ var LoginAttemptStore = class _LoginAttemptStore {
491
517
  };
492
518
  };
493
519
  this.set(ip, backoff(this.store.get(ip)));
494
- const credKey = _LoginAttemptStore.credentialKey(ip, password);
495
- this.set(credKey, backoff(this.store.get(credKey)));
520
+ if (password !== void 0 && password.length > 0) {
521
+ const credKey = _LoginAttemptStore.credentialKey(password);
522
+ this.set(credKey, backoff(this.store.get(credKey)));
523
+ }
496
524
  return this.store.get(ip);
497
525
  }
498
526
  /**
499
- * Clear both IP and credential entries on successful login.
527
+ * Clear the IP entry — and the credential entry when a password is given —
528
+ * on successful login.
500
529
  */
501
530
  clearOnSuccess(ip, password) {
502
531
  this.delete(ip);
503
- this.delete(_LoginAttemptStore.credentialKey(ip, password));
532
+ if (password !== void 0 && password.length > 0) {
533
+ this.delete(_LoginAttemptStore.credentialKey(password));
534
+ }
504
535
  }
505
536
  /** Number of tracked entries (for diagnostics). */
506
537
  get size() {
@@ -1180,7 +1211,14 @@ function buildSnapshot(clients, options) {
1180
1211
  completedTasks: f.payload.completedTasks,
1181
1212
  failedTasks: f.payload.failedTasks,
1182
1213
  ...f.payload.totalCostUsd !== void 0 ? { totalCostUsd: f.payload.totalCostUsd } : {},
1183
- lastActivityAt: f.lastActivityAt
1214
+ lastActivityAt: f.lastActivityAt,
1215
+ ...f.payload.maxConcurrent !== void 0 ? { maxConcurrent: f.payload.maxConcurrent } : {},
1216
+ ...f.payload.maxSpawns !== void 0 ? { maxSpawns: f.payload.maxSpawns } : {},
1217
+ ...f.payload.usedSpawns !== void 0 ? { usedSpawns: f.payload.usedSpawns } : {},
1218
+ ...f.payload.remainingSpawns !== void 0 ? { remainingSpawns: f.payload.remainingSpawns } : {},
1219
+ ...f.payload.effectiveSource !== void 0 ? { effectiveSource: f.payload.effectiveSource } : {},
1220
+ ...f.payload.checkpointMaxSpawns !== void 0 ? { checkpointMaxSpawns: f.payload.checkpointMaxSpawns } : {},
1221
+ ...f.payload.ceilingMismatch ? { ceilingMismatch: true } : {}
1184
1222
  }));
1185
1223
  const mcpServers = [];
1186
1224
  const seenMcp = /* @__PURE__ */ new Set();
@@ -1398,11 +1436,8 @@ async function authorizeHqCommand(input) {
1398
1436
  // src/hq-server/routes/auth-handlers.ts
1399
1437
  import { randomUUID } from "node:crypto";
1400
1438
  import {
1401
- DEFAULT_HQ_REDACTION_POLICY as DEFAULT_HQ_REDACTION_POLICY2,
1402
1439
  hashHqPassword,
1403
- hqTokenKey as hqTokenKey2,
1404
1440
  isLoopbackHost as isLoopbackHost2,
1405
- isTokenExpired as isTokenExpired4,
1406
1441
  mintHqCookieSecret,
1407
1442
  mutateHqAuthFile,
1408
1443
  verifyHqPassword
@@ -1413,13 +1448,92 @@ import {
1413
1448
  generateTotpSecret,
1414
1449
  hashRecoveryCode,
1415
1450
  verifyRecoveryCode,
1416
- verifyTotp
1451
+ verifyTotp,
1452
+ verifyTotpCounter
1417
1453
  } from "@wrongstack/core/security";
1454
+
1455
+ // src/hq-server/client-address.ts
1456
+ import { isIP as isIP2 } from "node:net";
1457
+ function normalizeAddress(raw) {
1458
+ const trimmed = raw.trim().replace(/^\[(.*)\]$/, "$1");
1459
+ return trimmed.replace(/^::ffff:/i, "");
1460
+ }
1461
+ function parseForwardedEntry(entry) {
1462
+ const value = entry.trim();
1463
+ if (!value) return void 0;
1464
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(value);
1465
+ if (bracketed?.[1] !== void 0) {
1466
+ const inner = normalizeAddress(bracketed[1]);
1467
+ return isIP2(inner) !== 0 ? inner : void 0;
1468
+ }
1469
+ const normalized = normalizeAddress(value);
1470
+ if (isIP2(normalized) !== 0) return normalized;
1471
+ const lastColon = normalized.lastIndexOf(":");
1472
+ if (lastColon > 0 && normalized.indexOf(":") === lastColon) {
1473
+ const host = normalized.slice(0, lastColon);
1474
+ if (isIP2(host) === 4) return host;
1475
+ }
1476
+ return void 0;
1477
+ }
1478
+ function forwardedChain(req) {
1479
+ const header = req.headers["x-forwarded-for"];
1480
+ if (header === void 0) return [];
1481
+ const raw = Array.isArray(header) ? header.join(",") : header;
1482
+ return raw.split(",").map((entry) => parseForwardedEntry(entry)).filter((entry) => entry !== void 0);
1483
+ }
1484
+ function resolveClientAddress(req, trustedProxyHops = 0) {
1485
+ const socketAddress = req.socket.remoteAddress ? normalizeAddress(req.socket.remoteAddress) : void 0;
1486
+ const hops = Number.isFinite(trustedProxyHops) ? Math.max(0, Math.trunc(trustedProxyHops)) : 0;
1487
+ if (hops === 0) return socketAddress ?? "unknown";
1488
+ const chain = [...socketAddress !== void 0 ? [socketAddress] : [], ...forwardedChain(req).reverse()];
1489
+ if (hops >= chain.length) return socketAddress ?? "unknown";
1490
+ return chain[hops] ?? socketAddress ?? "unknown";
1491
+ }
1492
+
1493
+ // src/hq-server/routes/auth-handlers.ts
1418
1494
  import { readHqAuthAuditTail } from "@wrongstack/core/hq";
1419
1495
  function isLoopbackRequest(req) {
1420
1496
  const address = req.socket.remoteAddress?.replace(/^::ffff:/, "");
1421
1497
  return address !== void 0 && isLoopbackHost2(address);
1422
1498
  }
1499
+ var HQ_AUTH_ADMIN_CAPABILITY = "auth.admin";
1500
+ function callerCanAdministerAuth(auth) {
1501
+ if (auth === void 0) return false;
1502
+ if (auth.kind === "cookie" && auth.tokenId === void 0) return true;
1503
+ if (auth.capabilities === void 0) return true;
1504
+ return auth.capabilities.includes(HQ_AUTH_ADMIN_CAPABILITY);
1505
+ }
1506
+ function writeAuthAdminRequired(res) {
1507
+ res.writeHead(403, { "Content-Type": "application/json" });
1508
+ res.end(
1509
+ JSON.stringify({
1510
+ error: {
1511
+ code: "AUTH_ADMIN_REQUIRED",
1512
+ message: `This endpoint requires the '${HQ_AUTH_ADMIN_CAPABILITY}' capability.`
1513
+ }
1514
+ })
1515
+ );
1516
+ }
1517
+ function authorizeAuthAdmin(req, res, mutableAuth, sessions) {
1518
+ const auth = authenticateBrowserRequest(
1519
+ req,
1520
+ new URL(req.url ?? "/", "http://localhost"),
1521
+ mutableAuth,
1522
+ sessions
1523
+ );
1524
+ if (!auth) {
1525
+ res.writeHead(401, { "Content-Type": "application/json" });
1526
+ res.end(
1527
+ JSON.stringify({ error: { code: "UNAUTHORIZED", message: "Authentication required." } })
1528
+ );
1529
+ return void 0;
1530
+ }
1531
+ if (!callerCanAdministerAuth(auth)) {
1532
+ writeAuthAdminRequired(res);
1533
+ return void 0;
1534
+ }
1535
+ return auth;
1536
+ }
1423
1537
  async function handleApiAuthStatus(req, res, url, mutableAuth, sessions, requireBrowserAuth, trustedPublicOrigins, secureCookies) {
1424
1538
  const auth = authenticateBrowserRequest(req, url, mutableAuth, sessions);
1425
1539
  const openMode = mutableAuth.browserTokens.size === 0 && mutableAuth.passwordHash === void 0 && mutableAuth.requireAuthFloor !== true;
@@ -1440,7 +1554,7 @@ async function handleApiAuthStatus(req, res, url, mutableAuth, sessions, require
1440
1554
  })
1441
1555
  );
1442
1556
  }
1443
- async function handleApiLogin(req, res, mutableAuth, sessions, loginAttempts, secureCookies) {
1557
+ async function handleApiLogin(req, res, mutableAuth, sessions, loginAttempts, secureCookies, trustedProxyHops) {
1444
1558
  if (!mutableAuth.passwordHash) {
1445
1559
  res.writeHead(403, { "Content-Type": "application/json" });
1446
1560
  res.end(
@@ -1453,9 +1567,8 @@ async function handleApiLogin(req, res, mutableAuth, sessions, loginAttempts, se
1453
1567
  );
1454
1568
  return;
1455
1569
  }
1456
- const clientIp = req.socket.remoteAddress ?? "unknown";
1457
- const { blocked, retryAfter } = loginAttempts.checkBlocked(clientIp, "");
1458
- if (blocked) {
1570
+ const clientIp = resolveClientAddress(req, trustedProxyHops);
1571
+ const writeRateLimited = (retryAfter) => {
1459
1572
  res.writeHead(429, {
1460
1573
  "Content-Type": "application/json",
1461
1574
  "Retry-After": String(retryAfter)
@@ -1468,6 +1581,10 @@ async function handleApiLogin(req, res, mutableAuth, sessions, loginAttempts, se
1468
1581
  }
1469
1582
  })
1470
1583
  );
1584
+ };
1585
+ const ipCheck = loginAttempts.checkBlocked(clientIp);
1586
+ if (ipCheck.blocked) {
1587
+ writeRateLimited(ipCheck.retryAfter);
1471
1588
  return;
1472
1589
  }
1473
1590
  let body;
@@ -1482,6 +1599,11 @@ async function handleApiLogin(req, res, mutableAuth, sessions, loginAttempts, se
1482
1599
  res.end(JSON.stringify({ error: { code: "BAD_REQUEST", message: "password is required" } }));
1483
1600
  return;
1484
1601
  }
1602
+ const credCheck = loginAttempts.checkBlocked(clientIp, body.password);
1603
+ if (credCheck.blocked) {
1604
+ writeRateLimited(credCheck.retryAfter);
1605
+ return;
1606
+ }
1485
1607
  const ok = await verifyHqPassword(body.password, mutableAuth.passwordHash);
1486
1608
  if (!ok || !mutableAuth.cookieSecret) {
1487
1609
  loginAttempts.recordFailure(clientIp, body.password);
@@ -1525,8 +1647,7 @@ async function handleApiLogout(req, res, mutableAuth, sessions, secureCookies) {
1525
1647
  sessions
1526
1648
  );
1527
1649
  if (isCookieAuth(auth)) {
1528
- const cookies = parseCookieHeader(req.headers.cookie);
1529
- const raw = cookies[HQ_SESSION_COOKIE] ?? cookies["__Host-hq.session"];
1650
+ const raw = readHqSessionCookie(req.headers.cookie);
1530
1651
  if (raw) {
1531
1652
  const sessionId = parseHqSessionCookie(raw, mutableAuth.cookieSecret ?? "");
1532
1653
  if (sessionId) sessions.delete(sessionId);
@@ -1536,7 +1657,7 @@ async function handleApiLogout(req, res, mutableAuth, sessions, secureCookies) {
1536
1657
  res.writeHead(200, { "Content-Type": "application/json" });
1537
1658
  res.end(JSON.stringify({ loggedIn: false }));
1538
1659
  }
1539
- async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secureCookies, requireBrowserAuth) {
1660
+ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secureCookies, requireBrowserAuth, applyAuthFile) {
1540
1661
  const auth = authenticateBrowserRequest(
1541
1662
  req,
1542
1663
  new URL(req.url ?? "/", "http://localhost"),
@@ -1611,7 +1732,7 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
1611
1732
  delete updated.totpRecoveryCodes;
1612
1733
  return updated;
1613
1734
  });
1614
- applyAuthFile(mutableAuth, next2);
1735
+ applyAuthFile(next2);
1615
1736
  sessions.clear();
1616
1737
  clearHqSessionCookie(res, secureCookies);
1617
1738
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -1644,7 +1765,7 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
1644
1765
  passwordHash,
1645
1766
  cookieSecret
1646
1767
  }));
1647
- applyAuthFile(mutableAuth, next);
1768
+ applyAuthFile(next);
1648
1769
  sessions.clear();
1649
1770
  if (isCookieAuth(auth) || localOpenBootstrap) {
1650
1771
  const sessionId = randomUUID();
@@ -1660,35 +1781,6 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
1660
1781
  })
1661
1782
  );
1662
1783
  }
1663
- function applyAuthFile(mutableAuth, next) {
1664
- mutableAuth.operatorPolicy = {
1665
- ...DEFAULT_HQ_REDACTION_POLICY2,
1666
- ...next.redactionPolicy ?? {}
1667
- };
1668
- mutableAuth.operatorPolicyOverride = next.redactionPolicy;
1669
- const liveBrowserTokens = (next.browserTokens ?? []).filter((t) => !isTokenExpired4(t));
1670
- mutableAuth.browserTokens = new Set(liveBrowserTokens.map(hqTokenKey2));
1671
- mutableAuth.clientTokens = new Set((next.clientTokens ?? []).map(hqTokenKey2));
1672
- mutableAuth.browserTokenObjs = new Map(
1673
- liveBrowserTokens.map((t) => [
1674
- hqTokenKey2(t),
1675
- {
1676
- id: t.id,
1677
- ...t.capabilities !== void 0 ? { capabilities: t.capabilities } : {},
1678
- ...t.expiresAt !== void 0 ? { expiresAt: t.expiresAt } : {}
1679
- }
1680
- ])
1681
- );
1682
- mutableAuth.clientTokenObjs = new Map(
1683
- (next.clientTokens ?? []).map((token) => [hqTokenKey2(token), token])
1684
- );
1685
- mutableAuth.passwordHash = next.passwordHash;
1686
- mutableAuth.cookieSecret = next.cookieSecret;
1687
- mutableAuth.alertRules = next.alertRules;
1688
- mutableAuth.totpSecret = next.totpSecret;
1689
- mutableAuth.totpPendingSecret = next.totpPendingSecret;
1690
- mutableAuth.totpRecoveryCodes = next.totpRecoveryCodes;
1691
- }
1692
1784
  async function handleApiBootstrap(req, res, mutableAuth, sessions, secureCookies, bootstrapStore) {
1693
1785
  if (!mutableAuth.cookieSecret) {
1694
1786
  res.writeHead(503, { "Content-Type": "application/json" });
@@ -1821,24 +1913,23 @@ var MAX_2FA_VERIFY_FAILURES = 5;
1821
1913
  function recordVerifyFailure(loginAttempts, clientIp) {
1822
1914
  const prev = loginAttempts.get(clientIp);
1823
1915
  const count = (prev?.count ?? 0) + 1;
1824
- loginAttempts.recordFailure(clientIp, "");
1916
+ loginAttempts.recordFailure(clientIp);
1825
1917
  return count;
1826
1918
  }
1827
- async function handleApiLoginVerify(req, res, _url, mutableAuth, sessions, loginAttempts, dataDir, secureCookies) {
1919
+ async function handleApiLoginVerify(req, res, _url, mutableAuth, sessions, loginAttempts, dataDir, secureCookies, applyAuthFile, trustedProxyHops) {
1828
1920
  if (!mutableAuth.cookieSecret) {
1829
1921
  res.writeHead(503, { "Content-Type": "application/json" });
1830
1922
  res.end(JSON.stringify({ error: { code: "NO_COOKIE_SECRET" } }));
1831
1923
  return;
1832
1924
  }
1833
- const clientIp = req.socket.remoteAddress ?? "unknown";
1834
- const { blocked, retryAfter } = loginAttempts.checkBlocked(clientIp, "");
1925
+ const clientIp = resolveClientAddress(req, trustedProxyHops);
1926
+ const { blocked, retryAfter } = loginAttempts.checkBlocked(clientIp);
1835
1927
  if (blocked) {
1836
1928
  res.writeHead(429, { "Content-Type": "application/json", "Retry-After": String(retryAfter) });
1837
1929
  res.end(JSON.stringify({ error: { code: "RATE_LIMITED", message: "Too many attempts. Try again later." } }));
1838
1930
  return;
1839
1931
  }
1840
- const cookies = parseCookieHeader(req.headers.cookie);
1841
- const raw = cookies[HQ_SESSION_COOKIE] ?? cookies["__Host-hq.session"];
1932
+ const raw = readHqSessionCookie(req.headers.cookie);
1842
1933
  const sessionId = raw ? parseHqSessionCookie(raw, mutableAuth.cookieSecret) : void 0;
1843
1934
  if (!sessionId) {
1844
1935
  res.writeHead(401, { "Content-Type": "application/json" });
@@ -1871,15 +1962,25 @@ async function handleApiLoginVerify(req, res, _url, mutableAuth, sessions, login
1871
1962
  res.end(JSON.stringify({ error: { code: "TOTP_NOT_ENABLED", message: "2FA is not configured." } }));
1872
1963
  return;
1873
1964
  }
1874
- if (!verifyTotp(body.code, mutableAuth.totpSecret)) {
1965
+ const matchedCounter = verifyTotpCounter(body.code, mutableAuth.totpSecret);
1966
+ const replayed = matchedCounter !== void 0 && mutableAuth.totpLastUsedCounter !== void 0 && matchedCounter <= mutableAuth.totpLastUsedCounter;
1967
+ if (matchedCounter === void 0 || replayed) {
1875
1968
  if (recordVerifyFailure(loginAttempts, clientIp) >= MAX_2FA_VERIFY_FAILURES) {
1876
1969
  sessions.delete(sessionId);
1877
1970
  }
1878
1971
  res.writeHead(401, { "Content-Type": "application/json" });
1879
- res.end(JSON.stringify({ error: { code: "INVALID_TOTP", message: "Invalid authenticator code." } }));
1972
+ res.end(
1973
+ JSON.stringify({
1974
+ error: {
1975
+ code: replayed ? "TOTP_ALREADY_USED" : "INVALID_TOTP",
1976
+ message: replayed ? "That authenticator code has already been used. Wait for the next one." : "Invalid authenticator code."
1977
+ }
1978
+ })
1979
+ );
1880
1980
  return;
1881
1981
  }
1882
- loginAttempts.clearOnSuccess(clientIp, "");
1982
+ mutableAuth.totpLastUsedCounter = matchedCounter;
1983
+ loginAttempts.clearOnSuccess(clientIp);
1883
1984
  sessions.delete(sessionId);
1884
1985
  const fullSessionId = randomUUID();
1885
1986
  sessions.set(fullSessionId, { createdAt: Date.now(), kind: "password", lastSeenAt: Date.now() });
@@ -1935,11 +2036,11 @@ async function handleApiLoginVerify(req, res, _url, mutableAuth, sessions, login
1935
2036
  res.end(JSON.stringify({ error: { code: "RECOVERY_CODE_ALREADY_USED", message: "This recovery code has already been used." } }));
1936
2037
  return;
1937
2038
  }
1938
- applyAuthFile(mutableAuth, next);
2039
+ applyAuthFile(next);
1939
2040
  } finally {
1940
2041
  releaseLock();
1941
2042
  }
1942
- loginAttempts.clearOnSuccess(clientIp, "");
2043
+ loginAttempts.clearOnSuccess(clientIp);
1943
2044
  sessions.delete(sessionId);
1944
2045
  const fullSessionId = randomUUID();
1945
2046
  sessions.set(fullSessionId, { createdAt: Date.now(), kind: "password", lastSeenAt: Date.now() });
@@ -1957,18 +2058,8 @@ async function handleApiLoginVerify(req, res, _url, mutableAuth, sessions, login
1957
2058
  JSON.stringify({ error: { code: "BAD_REQUEST", message: "Provide a `code` or `recoveryCode`." } })
1958
2059
  );
1959
2060
  }
1960
- async function handleApiTotpSetup(req, res, mutableAuth, sessions, dataDir) {
1961
- const auth = authenticateBrowserRequest(
1962
- req,
1963
- new URL(req.url ?? "/", "http://localhost"),
1964
- mutableAuth,
1965
- sessions
1966
- );
1967
- if (!auth) {
1968
- res.writeHead(401, { "Content-Type": "application/json" });
1969
- res.end(JSON.stringify({ error: { code: "UNAUTHORIZED", message: "Authentication required." } }));
1970
- return;
1971
- }
2061
+ async function handleApiTotpSetup(req, res, mutableAuth, sessions, dataDir, applyAuthFile) {
2062
+ if (!authorizeAuthAdmin(req, res, mutableAuth, sessions)) return;
1972
2063
  if (mutableAuth.totpSecret) {
1973
2064
  res.writeHead(409, { "Content-Type": "application/json" });
1974
2065
  res.end(
@@ -1982,22 +2073,12 @@ async function handleApiTotpSetup(req, res, mutableAuth, sessions, dataDir) {
1982
2073
  ...current,
1983
2074
  totpPendingSecret: secret
1984
2075
  }));
1985
- applyAuthFile(mutableAuth, next);
2076
+ applyAuthFile(next);
1986
2077
  res.writeHead(200, { "Content-Type": "application/json" });
1987
2078
  res.end(JSON.stringify({ secret, uri, enabled: false }));
1988
2079
  }
1989
- async function handleApiTotpEnable(req, res, mutableAuth, sessions, dataDir) {
1990
- const auth = authenticateBrowserRequest(
1991
- req,
1992
- new URL(req.url ?? "/", "http://localhost"),
1993
- mutableAuth,
1994
- sessions
1995
- );
1996
- if (!auth) {
1997
- res.writeHead(401, { "Content-Type": "application/json" });
1998
- res.end(JSON.stringify({ error: { code: "UNAUTHORIZED", message: "Authentication required." } }));
1999
- return;
2000
- }
2080
+ async function handleApiTotpEnable(req, res, mutableAuth, sessions, dataDir, applyAuthFile) {
2081
+ if (!authorizeAuthAdmin(req, res, mutableAuth, sessions)) return;
2001
2082
  if (mutableAuth.totpSecret) {
2002
2083
  res.writeHead(409, { "Content-Type": "application/json" });
2003
2084
  res.end(
@@ -2040,25 +2121,15 @@ async function handleApiTotpEnable(req, res, mutableAuth, sessions, dataDir) {
2040
2121
  updated.totpRecoveryCodes = recoveryHashes;
2041
2122
  return updated;
2042
2123
  });
2043
- applyAuthFile(mutableAuth, next);
2124
+ applyAuthFile(next);
2044
2125
  sessions.clear();
2045
2126
  res.writeHead(200, { "Content-Type": "application/json" });
2046
2127
  res.end(JSON.stringify({ enabled: true, recoveryCodes }));
2047
2128
  }
2048
- async function handleApiTotpDisable(req, res, mutableAuth, sessions, loginAttempts, dataDir) {
2049
- const auth = authenticateBrowserRequest(
2050
- req,
2051
- new URL(req.url ?? "/", "http://localhost"),
2052
- mutableAuth,
2053
- sessions
2054
- );
2055
- if (!auth) {
2056
- res.writeHead(401, { "Content-Type": "application/json" });
2057
- res.end(JSON.stringify({ error: { code: "UNAUTHORIZED", message: "Authentication required." } }));
2058
- return;
2059
- }
2060
- const clientIp = req.socket.remoteAddress ?? "unknown";
2061
- const { blocked, retryAfter } = loginAttempts.checkBlocked(clientIp, "");
2129
+ async function handleApiTotpDisable(req, res, mutableAuth, sessions, loginAttempts, dataDir, applyAuthFile, trustedProxyHops) {
2130
+ if (!authorizeAuthAdmin(req, res, mutableAuth, sessions)) return;
2131
+ const clientIp = resolveClientAddress(req, trustedProxyHops);
2132
+ const { blocked, retryAfter } = loginAttempts.checkBlocked(clientIp);
2062
2133
  if (blocked) {
2063
2134
  res.writeHead(429, { "Content-Type": "application/json", "Retry-After": String(retryAfter) });
2064
2135
  res.end(JSON.stringify({ error: { code: "RATE_LIMITED", message: "Too many attempts. Try again later." } }));
@@ -2095,7 +2166,7 @@ async function handleApiTotpDisable(req, res, mutableAuth, sessions, loginAttemp
2095
2166
  delete updated.totpRecoveryCodes;
2096
2167
  return updated;
2097
2168
  });
2098
- applyAuthFile(mutableAuth, next);
2169
+ applyAuthFile(next);
2099
2170
  res.writeHead(200, { "Content-Type": "application/json" });
2100
2171
  res.end(JSON.stringify({ enabled: false }));
2101
2172
  }
@@ -2105,7 +2176,7 @@ function handleApiAuthSessions(_req, res, mutableAuth, sessions) {
2105
2176
  for (const [id, session] of sessions) {
2106
2177
  if (session.pending2fa) continue;
2107
2178
  list.push({
2108
- // Truncated for display only — the client sends the full ID to revoke.
2179
+ // The client sends the full ID back to revoke; `shortId` is for display.
2109
2180
  id,
2110
2181
  shortId: id.slice(0, 8),
2111
2182
  kind: session.kind,
@@ -2431,8 +2502,6 @@ var isTokenAuth2 = isTokenAuth;
2431
2502
  var isCookieAuth2 = isCookieAuth;
2432
2503
  var hqAuthRequired2 = hqAuthRequired;
2433
2504
  var parseHqSessionCookie2 = parseHqSessionCookie;
2434
- var parseCookieHeader2 = parseCookieHeader;
2435
- var HQ_SESSION_COOKIE2 = HQ_SESSION_COOKIE;
2436
2505
  var decodePathSegment2 = decodePathSegment;
2437
2506
  var readRequestBody2 = readRequestBody;
2438
2507
  var writeInvalidBody2 = writeInvalidBody;
@@ -2468,16 +2537,18 @@ function createHqRouter(deps) {
2468
2537
  getMailboxGateway,
2469
2538
  getTokenStats,
2470
2539
  trustBoundary,
2471
- bootstrapStore
2540
+ bootstrapStore,
2541
+ applyAuthFile,
2542
+ trustedProxyHops = 0
2472
2543
  } = deps;
2473
2544
  return async (req, res) => {
2474
2545
  try {
2475
- const url = new URL(req.url ?? "/", `http://${host}:${listeningPort}`);
2546
+ const url = new URL(req.url ?? "/", `http://${host}:${listeningPort()}`);
2476
2547
  setHqSecurityHeaders2(res);
2477
2548
  if (!hasTrustedBrowserOrigin2(
2478
2549
  req,
2479
2550
  host,
2480
- listeningPort,
2551
+ listeningPort(),
2481
2552
  trustedPublicOrigins,
2482
2553
  allowFileOrigin
2483
2554
  )) {
@@ -2516,6 +2587,21 @@ function createHqRouter(deps) {
2516
2587
  await handleApiAuthStatus(req, res, url, mutableAuth, sessions, requireBrowserAuth, trustedPublicOrigins, secureCookies);
2517
2588
  return;
2518
2589
  }
2590
+ if (url.pathname === "/api/auth/audit" && req.method === "GET" || url.pathname.startsWith("/api/auth/sessions") && (req.method === "GET" || req.method === "DELETE") || url.pathname.startsWith("/api/auth/totp/") && req.method === "POST") {
2591
+ const auth = authenticateBrowserRequest2(req, url, mutableAuth, sessions);
2592
+ if (auth !== void 0 && !callerCanAdministerAuth(auth)) {
2593
+ res.writeHead(403, { "Content-Type": "application/json" });
2594
+ res.end(
2595
+ JSON.stringify({
2596
+ error: {
2597
+ code: "AUTH_ADMIN_REQUIRED",
2598
+ message: "This endpoint requires the 'auth.admin' capability."
2599
+ }
2600
+ })
2601
+ );
2602
+ return;
2603
+ }
2604
+ }
2519
2605
  if (url.pathname === "/api/auth/audit" && req.method === "GET") {
2520
2606
  handleApiAuthAudit(req, res, dataDir);
2521
2607
  return;
@@ -2529,7 +2615,7 @@ function createHqRouter(deps) {
2529
2615
  return;
2530
2616
  }
2531
2617
  if (url.pathname === "/api/login" && req.method === "POST") {
2532
- await handleApiLogin(req, res, mutableAuth, sessions, loginAttempts, secureCookies);
2618
+ await handleApiLogin(req, res, mutableAuth, sessions, loginAttempts, secureCookies, trustedProxyHops);
2533
2619
  return;
2534
2620
  }
2535
2621
  if (url.pathname === "/api/auth/bootstrap" && req.method === "POST") {
@@ -2557,23 +2643,23 @@ function createHqRouter(deps) {
2557
2643
  return;
2558
2644
  }
2559
2645
  if (url.pathname === "/api/login/verify" && req.method === "POST") {
2560
- await handleApiLoginVerify(req, res, url, mutableAuth, sessions, loginAttempts, dataDir, secureCookies);
2646
+ await handleApiLoginVerify(req, res, url, mutableAuth, sessions, loginAttempts, dataDir, secureCookies, applyAuthFile, trustedProxyHops);
2561
2647
  return;
2562
2648
  }
2563
2649
  if (url.pathname === "/api/auth/totp/setup" && req.method === "POST") {
2564
- await handleApiTotpSetup(req, res, mutableAuth, sessions, dataDir);
2650
+ await handleApiTotpSetup(req, res, mutableAuth, sessions, dataDir, applyAuthFile);
2565
2651
  return;
2566
2652
  }
2567
2653
  if (url.pathname === "/api/auth/totp/enable" && req.method === "POST") {
2568
- await handleApiTotpEnable(req, res, mutableAuth, sessions, dataDir);
2654
+ await handleApiTotpEnable(req, res, mutableAuth, sessions, dataDir, applyAuthFile);
2569
2655
  return;
2570
2656
  }
2571
2657
  if (url.pathname === "/api/auth/totp/disable" && req.method === "POST") {
2572
- await handleApiTotpDisable(req, res, mutableAuth, sessions, loginAttempts, dataDir);
2658
+ await handleApiTotpDisable(req, res, mutableAuth, sessions, loginAttempts, dataDir, applyAuthFile, trustedProxyHops);
2573
2659
  return;
2574
2660
  }
2575
2661
  if (url.pathname === "/api/auth/password" && (req.method === "POST" || req.method === "DELETE")) {
2576
- await handleApiPassword(req, res, mutableAuth, sessions, dataDir, secureCookies, requireBrowserAuth);
2662
+ await handleApiPassword(req, res, mutableAuth, sessions, dataDir, secureCookies, requireBrowserAuth, applyAuthFile);
2577
2663
  return;
2578
2664
  }
2579
2665
  if (url.pathname === "/api/snapshot" && req.method === "GET") {
@@ -3081,17 +3167,11 @@ async function handleMailboxAction(_req, res, match, mutableAuth, sessions, data
3081
3167
  // src/hq-server/startup.ts
3082
3168
  import * as fs4 from "node:fs/promises";
3083
3169
  import * as path6 from "node:path";
3170
+ import { writeHqRuntimeFile } from "@wrongstack/core/hq";
3084
3171
  var hqRuntimeMarkerPath2 = hqRuntimeMarkerPath;
3085
3172
  async function writeHqRuntimeMarker(dataDir, url) {
3086
- const file = hqRuntimeMarkerPath2(dataDir);
3087
- const payload = JSON.stringify(
3088
- { url, pid: process.pid, updatedAt: (/* @__PURE__ */ new Date()).toISOString() },
3089
- null,
3090
- 2
3091
- );
3092
- await fs4.mkdir(path6.dirname(file), { recursive: true });
3093
- await fs4.writeFile(file, `${payload}
3094
- `, { encoding: "utf8", mode: 384 });
3173
+ await fs4.mkdir(dataDir, { recursive: true });
3174
+ await writeHqRuntimeFile(dataDir, { url, pid: process.pid });
3095
3175
  }
3096
3176
  async function clearHqRuntimeMarker(dataDir, url) {
3097
3177
  const file = hqRuntimeMarkerPath2(dataDir);
@@ -3106,19 +3186,23 @@ async function clearHqRuntimeMarker(dataDir, url) {
3106
3186
  }
3107
3187
  function writeHqStartupInfo(write, handle) {
3108
3188
  const startup = handle.firstRunSetup;
3109
- write(`WrongStack HQ listening on http://${handle.host}:${handle.port}
3110
- `);
3189
+ const browserUrl = buildHttpUrl(handle.host, handle.port);
3190
+ const clientUrl = buildClientWsUrl(handle.host, handle.port);
3191
+ write(
3192
+ `${terminalText("WrongStack HQ", "magenta")} ${terminalText("listening on", "green")} ${terminalLink(browserUrl)}
3193
+ `
3194
+ );
3111
3195
  if (!startup) {
3112
- write(`Browser endpoint: ${buildHttpUrl(handle.host, handle.port)}
3196
+ write(`${terminalText("Browser endpoint:", "blue")} ${terminalLink(browserUrl)}
3113
3197
  `);
3114
- write(`Client endpoint: ${buildClientWsUrl(handle.host, handle.port)}
3198
+ write(`${terminalText("Client endpoint:", "blue")} ${terminalLink(clientUrl)}
3115
3199
  `);
3116
3200
  writeHqLanEndpoints(write, handle, void 0);
3117
3201
  return;
3118
3202
  }
3119
- write(`Browser endpoint: ${startup.browserUrl}
3203
+ write(`${terminalText("Browser endpoint:", "blue")} ${terminalLink(startup.browserUrl)}
3120
3204
  `);
3121
- write(`Client endpoint: ${startup.clientUrl}
3205
+ write(`${terminalText("Client endpoint:", "blue")} ${terminalLink(startup.clientUrl)}
3122
3206
  `);
3123
3207
  if (startup.createdAuth) {
3124
3208
  write(`
@@ -3144,13 +3228,23 @@ function writeHqLanEndpoints(write, handle, browserToken) {
3144
3228
  if (handle.host !== "0.0.0.0" && handle.host !== "::") return;
3145
3229
  const ips = lanIPv4Addresses();
3146
3230
  if (ips.length === 0) return;
3147
- write("\nReachable from other machines on your network:\n");
3231
+ write(
3232
+ `
3233
+ ${terminalText("Reachable from other machines on your network:", "blue", { bold: true })}
3234
+ `
3235
+ );
3148
3236
  for (const ip of ips) {
3149
- write(` ${buildHttpUrl(ip, handle.port, browserToken)}
3237
+ const url = buildHttpUrl(ip, handle.port, browserToken);
3238
+ write(` ${terminalLink(url)}
3150
3239
  `);
3151
3240
  }
3152
- write(` On another machine, set WRONGSTACK_HQ_URL=http://${ips[0]}:${handle.port}
3153
- `);
3241
+ const firstIp = ips[0];
3242
+ if (firstIp === void 0) return;
3243
+ const clientUrl = buildHttpUrl(firstIp, handle.port);
3244
+ write(
3245
+ ` ${terminalText("On another machine, set", "muted")} WRONGSTACK_HQ_URL=${terminalLink(clientUrl)}
3246
+ `
3247
+ );
3154
3248
  }
3155
3249
 
3156
3250
  // src/hq-server/ws.ts
@@ -3281,7 +3375,7 @@ function nextPeerEventSeq(eventLog) {
3281
3375
  peerEventSeq += 1;
3282
3376
  return peerEventSeq;
3283
3377
  }
3284
- function fanoutKanbanDelta(message, clients, browsers, projectId) {
3378
+ function fanoutKanbanDelta(message, clients, _browsers, projectId) {
3285
3379
  let clientsNotified = 0;
3286
3380
  for (const peer of clients.values()) {
3287
3381
  if (peer.projectId === projectId) {
@@ -3289,12 +3383,7 @@ function fanoutKanbanDelta(message, clients, browsers, projectId) {
3289
3383
  clientsNotified++;
3290
3384
  }
3291
3385
  }
3292
- let browsersNotified = 0;
3293
- for (const browserWs of browsers) {
3294
- sendGuarded(browserWs, message);
3295
- browsersNotified++;
3296
- }
3297
- return { clientsNotified, browsersNotified };
3386
+ return { clientsNotified, browsersNotified: 0 };
3298
3387
  }
3299
3388
  function handleClientResume(ws, clients, eventLog, frame, snapshotBroadcaster) {
3300
3389
  const client = clients.get(ws);
@@ -3896,7 +3985,30 @@ async function startHqServer(options = {}) {
3896
3985
  async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
3897
3986
  const trustBoundary = options.trustBoundary ?? createCompatibilityTrustBoundary({ policyId: "hq-trusted-host-compat-v1" });
3898
3987
  const authFile = firstRunAuth.authFile;
3899
- const authState = createHqAuthState(authFile, dataDir);
3988
+ const reassessExposureFloor = (live) => {
3989
+ const exposure = assessHqExposure2({
3990
+ host,
3991
+ hasBrowserTokens: live.browserTokens.size > 0,
3992
+ hasPassword: live.passwordHash !== void 0,
3993
+ allowInsecure: options.allowInsecureOpen
3994
+ });
3995
+ const previousFloor = live.requireAuthFloor === true;
3996
+ live.requireAuthFloor = exposure.kind === "refuse";
3997
+ if (live.requireAuthFloor && !previousFloor) {
3998
+ console.error(
3999
+ JSON.stringify({
4000
+ level: "error",
4001
+ event: "hq.auth.open_mode_refused",
4002
+ message: "Last HQ credential removed while bound to a non-loopback address. Refusing to serve unauthenticated: requests will return 401 until a token or password is configured, or HQ is restarted with --host 127.0.0.1.",
4003
+ host,
4004
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4005
+ })
4006
+ );
4007
+ }
4008
+ };
4009
+ const authState = createHqAuthState(authFile, dataDir, {
4010
+ onApplied: (live) => reassessExposureFloor(live)
4011
+ });
3900
4012
  const { mutableAuth } = authState;
3901
4013
  if (!mutableAuth.cookieSecret && mutableAuth.browserTokens.size > 0) {
3902
4014
  const secret = mintHqCookieSecret2();
@@ -3904,7 +4016,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
3904
4016
  ...current,
3905
4017
  cookieSecret: secret
3906
4018
  }));
3907
- mutableAuth.cookieSecret = next.cookieSecret;
4019
+ authState.apply(next);
3908
4020
  console.warn(
3909
4021
  JSON.stringify({
3910
4022
  level: "info",
@@ -4170,7 +4282,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4170
4282
  const routerDeps = {
4171
4283
  trustBoundary,
4172
4284
  host,
4173
- listeningPort,
4285
+ listeningPort: () => listeningPort,
4174
4286
  trustedPublicOrigins,
4175
4287
  allowFileOrigin: options.allowFileOrigin,
4176
4288
  mutableAuth,
@@ -4193,6 +4305,10 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4193
4305
  authorizeMailboxGateway,
4194
4306
  getMailboxGateway,
4195
4307
  getTokenStats: () => authState.tokenStats(),
4308
+ // Single projection + single floor evaluation, shared with the reload
4309
+ // watcher below. See `createHqAuthState`'s `onApplied` hook.
4310
+ applyAuthFile: (next) => authState.apply(next),
4311
+ trustedProxyHops: options.trustedProxyHops ?? 0,
4196
4312
  bootstrapStore
4197
4313
  };
4198
4314
  const handleRequest = createHqRouter(routerDeps);
@@ -4260,8 +4376,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4260
4376
  }
4261
4377
  const tokenValid = timingSafeTokenMatch(tokenSet, supplied) !== void 0;
4262
4378
  const cookieValid = pathname === "/ws/browser" && mutableAuth.cookieSecret !== void 0 && (() => {
4263
- const cookies = parseCookieHeader2(req.headers.cookie);
4264
- const raw = cookies[HQ_SESSION_COOKIE2] ?? cookies["__Host-hq.session"];
4379
+ const raw = readHqSessionCookie(req.headers.cookie);
4265
4380
  if (!raw) return false;
4266
4381
  const sessionId = parseHqSessionCookie2(raw, mutableAuth.cookieSecret);
4267
4382
  if (sessionId === void 0) return false;
@@ -4327,25 +4442,6 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4327
4442
  dataDir,
4328
4443
  (next) => {
4329
4444
  authState.apply(next);
4330
- const exposure = assessHqExposure2({
4331
- host,
4332
- hasBrowserTokens: mutableAuth.browserTokens.size > 0,
4333
- hasPassword: mutableAuth.passwordHash !== void 0,
4334
- allowInsecure: options.allowInsecureOpen
4335
- });
4336
- const previousFloor = mutableAuth.requireAuthFloor === true;
4337
- mutableAuth.requireAuthFloor = exposure.kind === "refuse";
4338
- if (mutableAuth.requireAuthFloor && !previousFloor) {
4339
- console.error(
4340
- JSON.stringify({
4341
- level: "error",
4342
- event: "hq.auth.open_mode_refused",
4343
- message: "Last HQ credential removed while bound to a non-loopback address. Refusing to serve unauthenticated: requests will return 401 until a token or password is configured, or HQ is restarted with --host 127.0.0.1.",
4344
- host,
4345
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
4346
- })
4347
- );
4348
- }
4349
4445
  if ((options.requireBrowserAuth || mutableAuth.requireAuthFloor) && mutableAuth.browserTokens.size === 0 && mutableAuth.passwordHash === void 0) {
4350
4446
  sessions.clear();
4351
4447
  for (const browser of browsers) browser.close(1008, "Browser authentication removed");
@@ -4401,7 +4497,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4401
4497
  const hqUrl = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}`;
4402
4498
  await writeHqRuntimeMarker(dataDir, hqUrl).catch(() => {
4403
4499
  });
4404
- const browserTokenObj = (firstRunAuth.browserToken !== void 0 ? mutableAuth.browserTokenObjs.get(hqTokenKey3(firstRunAuth.browserToken)) : void 0) ?? mutableAuth.browserTokenObjs.values().next().value;
4500
+ const browserTokenObj = (firstRunAuth.browserToken !== void 0 ? mutableAuth.browserTokenObjs.get(hqTokenKey2(firstRunAuth.browserToken)) : void 0) ?? mutableAuth.browserTokenObjs.values().next().value;
4405
4501
  let browserUrl;
4406
4502
  if (browserTokenObj) {
4407
4503
  const code = bootstrapStore.issue({
@@ -4456,8 +4552,13 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4456
4552
  snapshotBroadcaster.close();
4457
4553
  bootstrapStore.clear();
4458
4554
  persistence.timeseries.flush();
4459
- for (const ws of browsers) ws.close(1001, "HQ shutting down");
4460
- for (const ws of clients.keys()) ws.close(1001, "HQ shutting down");
4555
+ for (const ws of [...browsers, ...clients.keys()]) {
4556
+ try {
4557
+ ws.close(1001, "HQ shutting down");
4558
+ ws.terminate();
4559
+ } catch {
4560
+ }
4561
+ }
4461
4562
  wss.close();
4462
4563
  for (const { router } of mailboxGateways.values()) router.close();
4463
4564
  httpServer.close(() => {
@@ -4505,4 +4606,4 @@ export {
4505
4606
  startHqServer,
4506
4607
  HqInsecureExposureError2 as HqInsecureExposureError
4507
4608
  };
4508
- //# sourceMappingURL=chunk-2SLSUDYS.js.map
4609
+ //# sourceMappingURL=chunk-2KDOFTTM.js.map