@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,4 +1,27 @@
1
1
  import type { TaskResult } from '@wrongstack/core/types';
2
+ /** Read-only fleet concurrency + lifetime spawn budget snapshot for operators. */
3
+ export interface FleetBudgetView {
4
+ maxConcurrent: number;
5
+ activeAgents: number;
6
+ maxSpawns: number;
7
+ usedSpawns: number;
8
+ remainingSpawns: number;
9
+ maxTokens?: number | undefined;
10
+ usedTokens?: number | undefined;
11
+ remainingTokens?: number | undefined;
12
+ maxCostUsd?: number | undefined;
13
+ usedCostUsd?: number | undefined;
14
+ remainingCostUsd?: number | undefined;
15
+ /** Historical ceiling from checkpoint metadata when it differed from live. */
16
+ checkpointMaxSpawns?: number | undefined;
17
+ ceilingMismatch?: boolean | undefined;
18
+ /** Winning source for maxConcurrent: cli-flag | env | profile | default. */
19
+ maxConcurrentSource?: string | undefined;
20
+ /** Winning source for maxSpawns: cli-flag | env | profile | default. */
21
+ maxSpawnsSource?: string | undefined;
22
+ /** Compact effective-source label for status lines. */
23
+ effectiveSource?: string | undefined;
24
+ }
2
25
  export interface FleetHostStatus {
3
26
  pending: {
4
27
  taskId: string;
@@ -12,6 +35,7 @@ export interface FleetHostStatus {
12
35
  task?: string | undefined;
13
36
  }[];
14
37
  summary: string;
38
+ budget?: FleetBudgetView | undefined;
15
39
  }
16
40
  export interface FleetHostUsage {
17
41
  rows: Array<{
@@ -42,7 +66,14 @@ export interface FleetHostStatusInputs {
42
66
  } | null | undefined;
43
67
  completedResults?: readonly TaskResult[] | null | undefined;
44
68
  shadowTaskIds: ReadonlySet<string>;
69
+ budget?: FleetBudgetView | undefined;
45
70
  }
46
71
  export declare function buildFleetHostStatus(input: FleetHostStatusInputs): FleetHostStatus;
72
+ /** Format a compact multi-line budget block for `/fleet status` and resume logs. */
73
+ export declare function formatFleetBudgetLines(budget: FleetBudgetView, colorize?: {
74
+ bold: (s: string) => string;
75
+ dim: (s: string) => string;
76
+ amber: (s: string) => string;
77
+ }): string[];
47
78
  export declare function aggregateFleetUsage(completed: readonly TaskResult[]): FleetHostUsage;
48
79
  //# sourceMappingURL=host-status.d.ts.map
@@ -58,6 +58,14 @@ export interface MultiAgentHostOptions {
58
58
  };
59
59
  /** Lifetime spawn cap for this Director. CLI default: 64. */
60
60
  maxSpawns?: number | undefined;
61
+ /**
62
+ * Winning configuration sources for concurrency / lifetime spawn ceilings
63
+ * (issue #323). Surfaced in `/fleet status` and WebUI budget views.
64
+ */
65
+ budgetSources?: {
66
+ maxConcurrent?: import('./budget-source.js').FleetBudgetSource | undefined;
67
+ maxSpawns?: import('./budget-source.js').FleetBudgetSource | undefined;
68
+ } | undefined;
61
69
  /** Maximum auto-extensions per subagent per budget kind. */
62
70
  maxBudgetExtensions?: number | undefined;
63
71
  /** Optional global Brain arbiter for director-level policy decisions. */
@@ -203,6 +203,11 @@ export declare class MultiAgentHost {
203
203
  private recordLearningRole;
204
204
  private captureCompletedTaskLearning;
205
205
  status(): FleetHostStatus;
206
+ /**
207
+ * Read-only concurrency + lifetime spawn budget for `/fleet status` and
208
+ * resume diagnostics. Safe when no director/fleet is active yet.
209
+ */
210
+ budgetView(): import('./host-status.js').FleetBudgetView;
206
211
  usage(): FleetHostUsage;
207
212
  manifest(): Promise<string | null>;
208
213
  promoteToDirector(): Promise<Director | null>;
@@ -22,8 +22,8 @@
22
22
  *
23
23
  * @module fleet/status-broadcast
24
24
  */
25
- import type { EventBus } from '@wrongstack/core/kernel';
26
25
  import type { Mailbox } from '@wrongstack/core/coordination';
26
+ import type { EventBus } from '@wrongstack/core/kernel';
27
27
  import type { FleetConfig } from '@wrongstack/core/types';
28
28
  export interface FleetStatusBroadcasterOptions {
29
29
  /** Host EventBus carrying the re-emitted `subagent.*` lifecycle events. */
@@ -36,7 +36,7 @@ var hqCmd = async (args, deps) => {
36
36
  return 1;
37
37
  };
38
38
  async function startServer(deps) {
39
- const { startHqServer } = await import("./hq-server-RJLJJZU7.js");
39
+ const { startHqServer } = await import("./hq-server-S7HZG3PA.js");
40
40
  const dataDir = resolveDataDir(deps);
41
41
  const flags = deps.flags ?? {};
42
42
  const host = typeof flags["host"] === "string" ? flags["host"] : HQ_CLI_DEFAULT_HOST;
@@ -56,6 +56,18 @@ async function startServer(deps) {
56
56
  }
57
57
  tokenTtlMs = parsed.value;
58
58
  }
59
+ const rawHops = typeof flags["hq-trusted-proxy-hops"] === "string" ? flags["hq-trusted-proxy-hops"] : void 0;
60
+ let trustedProxyHops;
61
+ if (rawHops !== void 0 && rawHops.length > 0) {
62
+ const parsed = Number.parseInt(rawHops, 10);
63
+ if (!Number.isFinite(parsed) || parsed < 0 || String(parsed) !== rawHops.trim()) {
64
+ deps.renderer.writeError(
65
+ "--hq-trusted-proxy-hops must be a non-negative integer (0 = ignore X-Forwarded-For).\n"
66
+ );
67
+ return 1;
68
+ }
69
+ trustedProxyHops = parsed;
70
+ }
59
71
  if (password !== void 0 && password.length < 8) {
60
72
  deps.renderer.writeError("HQ password must be at least 8 characters.\n");
61
73
  return 1;
@@ -69,7 +81,8 @@ async function startServer(deps) {
69
81
  dataDir,
70
82
  allowInsecureOpen,
71
83
  ...password !== void 0 ? { password } : {},
72
- ...tokenTtlMs !== void 0 ? { tokenTtlMs } : {}
84
+ ...tokenTtlMs !== void 0 ? { tokenTtlMs } : {},
85
+ ...trustedProxyHops !== void 0 ? { trustedProxyHops } : {}
73
86
  });
74
87
  } catch (err) {
75
88
  if (err instanceof HqInsecureExposureError) {
@@ -528,6 +541,12 @@ function printHelp(deps) {
528
541
  `);
529
542
  deps.renderer.write(` --hq-token-ttl <dur> Stamp an expiresAt on first-run tokens (e.g. 1h, 7d, 3600s).
530
543
  `);
544
+ deps.renderer.write(
545
+ ` --hq-trusted-proxy-hops <n> Trust the rightmost n X-Forwarded-For entries when rate-limiting logins.
546
+ Default 0 (ignore the header). Set to the real hop count behind a tunnel,
547
+ otherwise every user shares one backoff bucket. Never guess high.
548
+ `
549
+ );
531
550
  deps.renderer.write(` --open Open the dashboard in the default browser.
532
551
  `);
533
552
  deps.renderer.write(` --client, -c Operate on client tokens instead of browser tokens.
@@ -544,4 +563,4 @@ export {
544
563
  hqCmd,
545
564
  resolveAuditActor
546
565
  };
547
- //# sourceMappingURL=hq-BDO56CX6.js.map
566
+ //# sourceMappingURL=hq-LN5CJHCM.js.map
@@ -5,6 +5,34 @@ export interface HqAuthState {
5
5
  apply(next: HqAuthFile): void;
6
6
  tokenStats(): NonNullable<HqSnapshot['totals']['tokenStats']>;
7
7
  }
8
+ export interface HqAuthStateOptions {
9
+ /**
10
+ * Invoked after every {@link HqAuthState.apply}, with the freshly projected
11
+ * `mutableAuth`. The server wires this to the WS-010 exposure re-assessment
12
+ * so `requireAuthFloor` is re-latched by EVERY path that changes the live
13
+ * credential set — not just the `fs.watch` reload.
14
+ *
15
+ * WS-101: the in-process mutation routes (`DELETE /api/auth/password`, the
16
+ * TOTP endpoints, recovery-code consumption) used to run their own copy of
17
+ * this projection and never touched the floor, so removing the last
18
+ * credential on a non-loopback bind dropped HQ into open mode until the
19
+ * watcher happened to fire. Routing every mutation through `apply` is what
20
+ * makes the latch unconditional.
21
+ */
22
+ onApplied?: ((mutableAuth: HqRouterMutableAuth) => void) | undefined;
23
+ }
24
+ /**
25
+ * Project an `auth.json` document onto the live `mutableAuth` used by every
26
+ * HQ gate. THE single projection — `HqAuthState.apply` is its only caller and
27
+ * every mutation path goes through that, so there is exactly one place where
28
+ * "which tokens are live" is decided.
29
+ *
30
+ * Expired tokens are dropped from BOTH scopes here. The browser scope is also
31
+ * re-checked at the request boundary (`authenticateBrowserRequest`), but the
32
+ * `/ws/client` upgrade gate is a bare set-membership test, so for client
33
+ * tokens this filter is the only expiry enforcement there is.
34
+ */
35
+ export declare function projectAuthFile(mutableAuth: HqRouterMutableAuth, next: HqAuthFile): void;
8
36
  /** Owns the live auth projection, expiry statistics, and reload audit diff. */
9
- export declare function createHqAuthState(authFile: HqAuthFile, dataDir: string): HqAuthState;
37
+ export declare function createHqAuthState(authFile: HqAuthFile, dataDir: string, opts?: HqAuthStateOptions): HqAuthState;
10
38
  //# sourceMappingURL=auth-state.d.ts.map
@@ -42,6 +42,22 @@ export declare function hasTrustedBrowserOrigin(req: http.IncomingMessage, bound
42
42
  export declare function serializeHqSessionCookie(sessionId: string, secret: string): string;
43
43
  export declare function parseHqSessionCookie(value: string, secret: string): string | undefined;
44
44
  export declare function parseCookieHeader(cookieHeader: string | undefined): Record<string, string>;
45
+ /**
46
+ * Read the HQ session cookie, preferring the `__Host-`-prefixed name.
47
+ *
48
+ * WS-103: every reader open-coded `cookies[HQ_SESSION_COOKIE] ??
49
+ * cookies['__Host-hq.session']`, which prefers the UNPREFIXED name. The whole
50
+ * point of the `__Host-` prefix is that a browser refuses to accept such a
51
+ * cookie unless it is Secure, Path=/, and Domain-less — guarantees a plain
52
+ * `hq.session` set by a sibling subdomain does not carry. Reading the weak name
53
+ * first hands that injected value precedence over the hardened one on exactly
54
+ * the deployments (`secure`) where the prefix was doing work.
55
+ *
56
+ * Signature verification still stands behind this, so a forged cookie needs
57
+ * `cookieSecret` — but preference order is the layer whose only job is to stop
58
+ * the attacker-controlled name from winning, so it should not be inverted.
59
+ */
60
+ export declare function readHqSessionCookie(cookieHeader: string | undefined): string | undefined;
45
61
  export declare function setHqSessionCookie(res: http.ServerResponse, value: string, secure?: boolean): void;
46
62
  export declare function clearHqSessionCookie(res: http.ServerResponse, secure?: boolean): void;
47
63
  export declare function isTokenAuth(auth: HqBrowserAuthResult): auth is HqBrowserAuthContext;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Client-address resolution for rate limiting behind a reverse proxy.
3
+ *
4
+ * WS-106: HQ's login backoff keys on `req.socket.remoteAddress`. On a direct
5
+ * bind that is exactly right. Behind the documented public-relay / tunnel
6
+ * deployment (`--hq-public-url`, `requireBrowserAuth`) it is exactly wrong:
7
+ * every request arrives from the tunnel's own address, so all users share one
8
+ * bucket and a single attacker's backoff locks out everyone — while a
9
+ * distributed attacker gets no per-source limiting at all.
10
+ *
11
+ * The fix is NOT to read `X-Forwarded-For` whenever it is present. That header
12
+ * is client-supplied; trusting it unconditionally would let an attacker mint a
13
+ * fresh rate-limit identity per request and remove the limiter entirely, which
14
+ * is strictly worse than the shared bucket. It is only meaningful when the
15
+ * operator states how many proxies actually sit in front of HQ.
16
+ *
17
+ * So resolution is opt-in and counted, following the well-understood
18
+ * `trust proxy: <n>` model:
19
+ *
20
+ * chain = [socket.remoteAddress, ...reverse(X-Forwarded-For)]
21
+ * client = chain[hops]
22
+ *
23
+ * `hops` is how many trusted proxies stand between the client and this server.
24
+ * The default, 0, ignores the header completely and returns the socket peer —
25
+ * byte-for-byte the previous behaviour.
26
+ *
27
+ * Only the rightmost `hops` entries are ever consulted, because those are the
28
+ * ones trusted infrastructure appended. Everything further left is whatever
29
+ * the client claimed and is never read. If the chain is shorter than `hops`
30
+ * (a client that stripped or under-filled the header), resolution falls back
31
+ * to the socket address rather than reaching into attacker-controlled entries:
32
+ * such requests then share the proxy's bucket, which throttles them rather
33
+ * than freeing them.
34
+ *
35
+ * @module hq-server/client-address
36
+ */
37
+ import type * as http from 'node:http';
38
+ /**
39
+ * Parse one `X-Forwarded-For` element into a bare address, or `undefined` when
40
+ * it is not one. Handles `1.2.3.4`, `1.2.3.4:5678`, `[::1]`, and `[::1]:5678`.
41
+ * Anything that does not parse as an IP is dropped — a forwarded chain with a
42
+ * junk entry must not silently become a rate-limit identity of its own.
43
+ */
44
+ export declare function parseForwardedEntry(entry: string): string | undefined;
45
+ /**
46
+ * Resolve the address a rate limiter should key on.
47
+ *
48
+ * @param req - the incoming request.
49
+ * @param trustedProxyHops - how many trusted reverse proxies sit in front of
50
+ * this server. `0` (the default) ignores `X-Forwarded-For` entirely.
51
+ * @returns a bare IP address, or `'unknown'` when the socket has none (a
52
+ * destroyed socket). `'unknown'` is a single shared bucket by design — it
53
+ * should throttle, not exempt.
54
+ */
55
+ export declare function resolveClientAddress(req: http.IncomingMessage, trustedProxyHops?: number): string;
56
+ //# sourceMappingURL=client-address.d.ts.map
@@ -38,27 +38,47 @@ export declare class LoginAttemptStore {
38
38
  set(key: string, entry: LoginAttemptEntry): void;
39
39
  delete(key: string): void;
40
40
  /**
41
- * Compound rate-limit key for public-relay mode: IP + SHA-256(password).
42
- * Limits how often the SAME password can be tried from different IPs,
43
- * so a rotating-IP attacker can't bypass per-IP backoff.
41
+ * Rate-limit key for a *candidate password*, independent of source IP.
42
+ *
43
+ * WS-104: this used to be `cred:${ip}:${hash}`. Embedding the IP made the
44
+ * documented purpose — "limit how often the SAME password can be tried from
45
+ * different IPs" — structurally impossible, because a rotating-IP attacker
46
+ * got a fresh counter with every hop, exactly the case the key exists for.
47
+ * The IP-scoped counter is already kept separately under the bare `ip` key.
48
+ *
49
+ * Backoff caps at 16s (see {@link recordFailure}), so a global per-password
50
+ * counter throttles guessing without becoming a lockout an attacker could
51
+ * aim at the operator.
44
52
  */
45
- static credentialKey(ip: string, password: string): string;
53
+ static credentialKey(password: string): string;
46
54
  /**
47
- * Check both IP and credential keys; return the stricter (later blockedUntil).
55
+ * Check the IP key and when a candidate password is supplied — the
56
+ * credential key too, returning the stricter (later `blockedUntil`).
57
+ *
58
+ * WS-104: `handleApiLogin` called this with `''` before parsing the request
59
+ * body ("we don't have the password yet") and never called it again, so the
60
+ * credential entries {@link recordFailure} wrote were only ever *written*.
61
+ * Callers now re-check once the password is known; passing no password (or
62
+ * an empty one) means "IP scope only".
48
63
  */
49
- checkBlocked(ip: string, password: string): {
64
+ checkBlocked(ip: string, password?: string): {
50
65
  blocked: boolean;
51
66
  retryAfter: number;
52
67
  };
53
68
  /**
54
- * Record a failed attempt on both IP and credential keys.
69
+ * Record a failed attempt on the IP key, and on the credential key when a
70
+ * candidate password is supplied. Omit the password for flows that have no
71
+ * one (2FA verification, TOTP disable) so they do not accumulate a
72
+ * meaningless `cred:sha256("")` counter shared by every such flow.
73
+ *
55
74
  * Returns the updated IP entry (for the response Retry-After header).
56
75
  */
57
- recordFailure(ip: string, password: string, maxBackoffMs?: number): LoginAttemptEntry;
76
+ recordFailure(ip: string, password?: string, maxBackoffMs?: number): LoginAttemptEntry;
58
77
  /**
59
- * Clear both IP and credential entries on successful login.
78
+ * Clear the IP entry — and the credential entry when a password is given —
79
+ * on successful login.
60
80
  */
61
- clearOnSuccess(ip: string, password: string): void;
81
+ clearOnSuccess(ip: string, password?: string): void;
62
82
  /** Number of tracked entries (for diagnostics). */
63
83
  get size(): number;
64
84
  /**
@@ -1,27 +1,59 @@
1
1
  import type * as http from 'node:http';
2
- import { type HqAlertRuleConfig, type HqRedactionPolicy, type HqToken } from '@wrongstack/core/hq';
2
+ import { type HqAuthFile } from '@wrongstack/core/hq';
3
+ import { type HqBrowserAuthResult } from '../auth.js';
3
4
  import type { LoginAttemptStore } from '../login-attempt-store.js';
4
5
  import type { HqRouterMutableAuth, HqSessionEntry } from '../types.js';
6
+ /**
7
+ * Capability required to operate the account-security surface: enrolling or
8
+ * removing 2FA, changing the password without knowing it, listing and revoking
9
+ * browser sessions, and reading the auth audit log.
10
+ *
11
+ * WS-102: these routes used to gate on `if (!auth)` alone, so ANY authenticated
12
+ * principal could reach them — including the least-privileged token HQ mints on
13
+ * first run (`capabilities: ['control.enqueue']`). That token could
14
+ * `POST /api/auth/totp/setup` → `/enable`, receive the recovery codes, and take
15
+ * the `sessions.clear()` that enrollment performs. The operator was then held
16
+ * at `totpRequired: true` on every password login with no authenticator and no
17
+ * recovery codes, and `/totp/disable` demands auth they could no longer obtain:
18
+ * a one-request, unrecoverable lockout from a token scoped to enqueueing
19
+ * commands. Editing `auth.json` by hand was the only way back.
20
+ */
21
+ export declare const HQ_AUTH_ADMIN_CAPABILITY = "auth.admin";
22
+ /**
23
+ * True when `auth` may operate the account-security surface.
24
+ *
25
+ * - Password-origin cookie sessions (`kind: 'cookie'` with no `tokenId`) are
26
+ * the operator themselves — always allowed.
27
+ * - A token with no `capabilities` field is unrestricted by the documented
28
+ * contract in `HqToken.capabilities`, so it is allowed.
29
+ * - A capability-scoped token must list {@link HQ_AUTH_ADMIN_CAPABILITY}.
30
+ *
31
+ * Token-origin cookie sessions resolve their capabilities from the LIVE token
32
+ * record on every request (see `authenticateBrowserRequest`), so upgrading a
33
+ * token to a cookie never widens what it can do here.
34
+ */
35
+ export declare function callerCanAdministerAuth(auth: HqBrowserAuthResult): boolean;
5
36
  export declare function handleApiAuthStatus(req: http.IncomingMessage, res: http.ServerResponse, url: URL, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, requireBrowserAuth: boolean | undefined, trustedPublicOrigins: Set<string>, secureCookies: boolean | undefined): Promise<void>;
6
- export declare function handleApiLogin(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, secureCookies: boolean | undefined): Promise<void>;
37
+ export declare function handleApiLogin(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, secureCookies: boolean | undefined, trustedProxyHops: number): Promise<void>;
7
38
  export declare function handleApiLogout(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, secureCookies: boolean | undefined): Promise<void>;
8
- export declare function handleApiPassword(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string, secureCookies: boolean | undefined, requireBrowserAuth: boolean | undefined): Promise<void>;
9
- export declare function applyAuthFile(mutableAuth: HqRouterMutableAuth, next: {
10
- browserTokens?: Array<{
11
- token: string;
12
- id: string;
13
- capabilities?: string[];
14
- expiresAt?: string;
15
- }>;
16
- clientTokens?: Array<HqToken>;
17
- redactionPolicy?: Partial<HqRedactionPolicy>;
18
- passwordHash?: string;
19
- cookieSecret?: string;
20
- alertRules?: HqAlertRuleConfig;
21
- totpSecret?: string;
22
- totpPendingSecret?: string;
23
- totpRecoveryCodes?: string[];
24
- }): void;
39
+ export declare function handleApiPassword(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string, secureCookies: boolean | undefined, requireBrowserAuth: boolean | undefined, applyAuthFile: ApplyHqAuthFile): Promise<void>;
40
+ /**
41
+ * Applies a freshly-persisted `auth.json` to the live server state.
42
+ *
43
+ * WS-101: this used to be a second, hand-rolled copy of the projection in
44
+ * `hq-server/auth-state.ts`. The two drifted in exactly the ways duplicated
45
+ * security code drifts: this copy skipped the expired-token filter on the
46
+ * CLIENT scope (re-admitting expired `/ws/client` tokens, where the upgrade
47
+ * gate does no expiry check of its own), never refreshed the raw token lists
48
+ * behind `tokenStats()`, and — worst — never re-ran the WS-010 exposure
49
+ * assessment, so `DELETE /api/auth/password` on a non-loopback bind dropped
50
+ * every gate into open mode until the `fs.watch` debounce happened to fire.
51
+ *
52
+ * It is now a function type supplied by the server, bound to
53
+ * `HqAuthState.apply`, so the mutation routes and the reload watcher share one
54
+ * projection and one floor evaluation.
55
+ */
56
+ export type ApplyHqAuthFile = (next: HqAuthFile) => void;
25
57
  export declare function handleApiBootstrap(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, secureCookies: boolean | undefined, bootstrapStore: import('@wrongstack/core/hq').HqBootstrapCodeStore): Promise<void>;
26
58
  /**
27
59
  * WS-065 — exchange a live browser token for an HttpOnly session cookie.
@@ -50,7 +82,7 @@ export declare function handleApiTokenUpgrade(req: http.IncomingMessage, res: ht
50
82
  * session is upgraded to a full session (pending2fa cleared). Recovery codes
51
83
  * are single-use: the matched hash is removed from `auth.json` on success.
52
84
  */
53
- export declare function handleApiLoginVerify(req: http.IncomingMessage, res: http.ServerResponse, _url: URL, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, dataDir: string, secureCookies: boolean | undefined): Promise<void>;
85
+ export declare function handleApiLoginVerify(req: http.IncomingMessage, res: http.ServerResponse, _url: URL, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, dataDir: string, secureCookies: boolean | undefined, applyAuthFile: ApplyHqAuthFile, trustedProxyHops: number): Promise<void>;
54
86
  /**
55
87
  * POST `/api/auth/totp/setup` — generate a new TOTP secret (not yet active).
56
88
  * Returns the base32 secret + otpauth URI for QR display. The secret is
@@ -60,24 +92,32 @@ export declare function handleApiLoginVerify(req: http.IncomingMessage, res: htt
60
92
  *
61
93
  * Requires existing auth (cookie or token) — only the operator can set up 2FA.
62
94
  */
63
- export declare function handleApiTotpSetup(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string): Promise<void>;
95
+ export declare function handleApiTotpSetup(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string, applyAuthFile: ApplyHqAuthFile): Promise<void>;
64
96
  /**
65
97
  * POST `/api/auth/totp/enable` — confirm 2FA enrollment by providing a valid
66
98
  * TOTP code. Promotes `totpPendingSecret` → `totpSecret` (active), generates
67
99
  * and returns recovery codes (shown once). After this, 2FA is required for
68
100
  * password login.
69
101
  */
70
- export declare function handleApiTotpEnable(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string): Promise<void>;
102
+ export declare function handleApiTotpEnable(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string, applyAuthFile: ApplyHqAuthFile): Promise<void>;
71
103
  /**
72
104
  * POST `/api/auth/totp/disable` — remove TOTP 2FA entirely. Requires the
73
105
  * current password (or a valid TOTP code) as confirmation.
74
106
  */
75
- export declare function handleApiTotpDisable(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, dataDir: string): Promise<void>;
107
+ export declare function handleApiTotpDisable(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, dataDir: string, applyAuthFile: ApplyHqAuthFile, trustedProxyHops: number): Promise<void>;
76
108
  /**
77
109
  * GET `/api/auth/sessions` — list all active browser sessions for the
78
- * session-management UI. Returns each session's kind, creation time,
79
- * last-seen time, and a truncated ID (never the full session ID — that
80
- * would allow cookie forgery since the session ID IS the cookie value).
110
+ * session-management UI.
111
+ *
112
+ * The full session ID is returned because the revoke endpoint below is keyed
113
+ * on it. That is safe on its own — the cookie value is `<id>.<HMAC>` and the
114
+ * HMAC needs `cookieSecret` — but it does hand every reader a working revoke
115
+ * handle for every other session, so the route is gated on `auth.admin`
116
+ * (WS-102) rather than on "is authenticated" as it was.
117
+ *
118
+ * The previous docstring claimed the full ID was never returned while the code
119
+ * returned it; the code was right about what the UI needs, the docstring was
120
+ * right about it deserving a gate.
81
121
  */
82
122
  export declare function handleApiAuthSessions(_req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>): void;
83
123
  /**
@@ -16,6 +16,7 @@ import type { createMailboxHttpRouter } from '@wrongstack/core/coordination';
16
16
  import { type Mailbox, type MailboxHttpAccessDecision, type MailboxHttpRateLimiter } from '@wrongstack/core/coordination';
17
17
  import * as HqServerAuth from './auth.js';
18
18
  import * as HqServerUtils from './utils.js';
19
+ import { type ApplyHqAuthFile } from './routes/auth-handlers.js';
19
20
  export declare const setHqSecurityHeaders: typeof HqServerAuth.setHqSecurityHeaders;
20
21
  export declare const hasTrustedBrowserOrigin: typeof HqServerAuth.hasTrustedBrowserOrigin;
21
22
  export declare const authenticateBrowserRequest: typeof HqServerAuth.authenticateBrowserRequest;
@@ -50,7 +51,16 @@ export interface HqRouterMailboxGateway {
50
51
  export interface HqRouterDeps {
51
52
  trustBoundary: TrustBoundary;
52
53
  host: string;
53
- listeningPort: number;
54
+ /**
55
+ * Getter, not a value. The router is constructed BEFORE `listen()`
56
+ * resolves, so a copied number froze the REQUESTED port. When the default
57
+ * is busy and `strictPort` is off, the scan binds `port+1` and the banner
58
+ * prints it — but the origin guard kept comparing against the old number and
59
+ * answered `403 forbidden: untrusted request origin` to the dashboard and
60
+ * every `/api/*` call. (The WS upgrade handler read the live local, so the
61
+ * failure looked like "the socket connects but the page won't load".)
62
+ */
63
+ listeningPort: () => number;
54
64
  trustedPublicOrigins: Set<string>;
55
65
  /** Trust `Origin: file://`. Off by default — see StartHqServerOptions (WS-081). */
56
66
  allowFileOrigin?: boolean | undefined;
@@ -77,6 +87,18 @@ export interface HqRouterDeps {
77
87
  getTokenStats?: (() => HqSnapshot['totals']['tokenStats'] | undefined) | undefined;
78
88
  /** One-time bootstrap code store for token-to-cookie exchange. */
79
89
  bootstrapStore?: import('@wrongstack/core/hq').HqBootstrapCodeStore | undefined;
90
+ /**
91
+ * Applies a freshly-persisted `auth.json` to live server state. Bound to
92
+ * `HqAuthState.apply` so the in-process mutation routes share one projection
93
+ * — and one WS-010 exposure re-assessment — with the reload watcher.
94
+ */
95
+ applyAuthFile: ApplyHqAuthFile;
96
+ /**
97
+ * How many trusted reverse proxies sit in front of this server. Only used to
98
+ * resolve the address the login backoff keys on — see `client-address.ts`.
99
+ * Defaults to 0, which ignores `X-Forwarded-For` entirely.
100
+ */
101
+ trustedProxyHops?: number | undefined;
80
102
  }
81
103
  /**
82
104
  * Create the HTTP request listener that handles all `/api/*` routes as well
@@ -83,6 +83,16 @@ export interface HqRouterMutableAuth {
83
83
  totpPendingSecret?: string | undefined;
84
84
  /** SHA-256 hashes of single-use recovery codes (live-reloaded from auth.json). */
85
85
  totpRecoveryCodes?: string[] | undefined;
86
+ /**
87
+ * Highest TOTP time-step counter already spent on a successful login
88
+ * (RFC 6238 §5.2 single-use). The ±1-step validation window keeps a code
89
+ * arithmetically valid for ~90s, so without this a code captured in transit
90
+ * — shoulder-surfed, read off a notification, replayed from a proxy log —
91
+ * authenticates a second time. Deliberately process-local and NOT persisted
92
+ * to auth.json: a code cannot outlive its own window, so a restart losing
93
+ * the marker costs at most the tail of one step.
94
+ */
95
+ totpLastUsedCounter?: number | undefined;
86
96
  alertRules: HqAlertRuleConfig | undefined;
87
97
  /**
88
98
  * WS-010: set when the live bind would become unauthenticated and
@@ -32,11 +32,22 @@ export declare function detectLeaderLoss(lostClient: ConnectedClient, clients: M
32
32
  /**
33
33
  * Server-side fanout of a `hq.kanban_snapshot` envelope after a merge.
34
34
  *
35
- * C2 — broadcasts the post-merge `delta` payload to:
36
- * - every client whose `projectId` matches `projectId`, and
37
- * - every browser in `browsers` (browsers are not project-scoped at the
38
- * WS level; the browser-side projection filters by project when it
39
- * applies the snapshot).
35
+ * C2 — broadcasts the post-merge `delta` payload to every client whose
36
+ * `projectId` matches `projectId`.
37
+ *
38
+ * BROWSERS ARE DELIBERATELY NOT NOTIFIED. They used to be — unscoped, so
39
+ * every browser received every project's deltas — on the strength of a
40
+ * comment claiming "the browser-side projection filters by project when it
41
+ * applies the snapshot". No such projection exists: `hq.kanban_snapshot` is
42
+ * absent from `HqBrowserMessage` and from the SPA's dispatch chain, so
43
+ * `HqWsClient` parsed each frame and dropped it. The board view learns about
44
+ * changes from the `kanban.snapshot` HQ *event* and refetches over HTTP
45
+ * (`views/kanban.tsx`), which is why the drift was invisible.
46
+ *
47
+ * `browsers` stays in the signature: the fan-out is the natural place to wire
48
+ * a real browser consumer, and the parity test in
49
+ * `tests/hq-browser-protocol-parity.test.ts` now fails the moment a
50
+ * browser-bound frame has no handler.
40
51
  *
41
52
  * The `delta` payload is the same touched-only payload the merge handler
42
53
  * builds (full-set broadcasts drive V8 heap exhaustion on long-lived
@@ -44,7 +55,7 @@ export declare function detectLeaderLoss(lostClient: ConnectedClient, clients: M
44
55
  *
45
56
  * Exported for direct unit testing in `tests/hq-kanban-fanout.test.ts`.
46
57
  */
47
- export declare function fanoutKanbanDelta(message: string, clients: Map<WebSocket, ConnectedClient>, browsers: Set<WebSocket>, projectId: string): {
58
+ export declare function fanoutKanbanDelta(message: string, clients: Map<WebSocket, ConnectedClient>, _browsers: Set<WebSocket>, projectId: string): {
48
59
  clientsNotified: number;
49
60
  browsersNotified: number;
50
61
  };
@@ -15,7 +15,8 @@ import {
15
15
  readLocalSubagentTranscript,
16
16
  sanitizeApiError,
17
17
  startHqServer
18
- } from "./chunk-2SLSUDYS.js";
18
+ } from "./chunk-2KDOFTTM.js";
19
+ import "./chunk-KE7E7DPX.js";
19
20
  import "./chunk-Q5GTM25S.js";
20
21
  import "./chunk-7OCVIDC7.js";
21
22
  export {
@@ -36,4 +37,4 @@ export {
36
37
  sanitizeApiError,
37
38
  startHqServer
38
39
  };
39
- //# sourceMappingURL=hq-server-RJLJJZU7.js.map
40
+ //# sourceMappingURL=hq-server-S7HZG3PA.js.map
@@ -60,6 +60,25 @@ export interface HqServerOptions {
60
60
  * `--hq-token-ttl-ms` CLI flag.
61
61
  */
62
62
  tokenTtlMs?: number;
63
+ /**
64
+ * How many trusted reverse proxies sit between a browser and this server.
65
+ * Controls which address the login backoff keys on (WS-106).
66
+ *
67
+ * Default `0`: `X-Forwarded-For` is ignored entirely and the socket peer is
68
+ * used — correct for a direct bind, and the only safe default, because the
69
+ * header is client-supplied and honouring it unasked would let an attacker
70
+ * mint a fresh rate-limit identity per request.
71
+ *
72
+ * Set it to the real hop count when HQ runs behind a tunnel or relay
73
+ * (`--hq-public-url`, `--hq-require-browser-auth`). Without it every request
74
+ * arrives from the tunnel's own address, so all users share one backoff
75
+ * bucket: one attacker locks out everyone, and a distributed attacker is not
76
+ * limited per-source at all. `1` covers the common single-tunnel case.
77
+ *
78
+ * Only the rightmost `n` forwarded entries are ever read — see
79
+ * `hq-server/client-address.ts`. Set via `--hq-trusted-proxy-hops`.
80
+ */
81
+ trustedProxyHops?: number;
63
82
  }
64
83
  export interface HqStartupConnectionInfo {
65
84
  dataDir: string;