@openparachute/hub 0.7.0 → 0.7.2-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -0,0 +1,170 @@
1
+ /**
2
+ * WebSocket connection caps for the upgrade bridge (hub#649) — the gate
3
+ * before any backed surface goes public-facing.
4
+ *
5
+ * The H1 bridge (`ws-bridge.ts`) holds one client socket + one upstream
6
+ * socket per connection, and the only per-connection bound before this
7
+ * module was the 8 MiB buffered-byte backpressure cap — a public client
8
+ * could hold upgrade slots open indefinitely at near-zero send rate and
9
+ * exhaust daemon memory at ~KB/connection. With the `surface` audience tier
10
+ * (H3, hub#651) anonymous WS is REACHABLE BY DESIGN on surface-audience
11
+ * mounts, so admission control can't lean on auth: the hub needs a blunt
12
+ * concurrent-connection bound of its own.
13
+ *
14
+ * Enforcement lives in `maybeUpgradeWebSocket` (hub-server.ts), which calls
15
+ * {@link WsConnectionTracker.tryAcquire} synchronously right before
16
+ * `server.upgrade()` — over-cap upgrades are refused with a clean HTTP 429
17
+ * (the upgrade never happens, so a normal Response is the correct refusal
18
+ * shape in Bun) BEFORE the proxy commits any socket or upstream-dial
19
+ * resources. The refusal body is generic: it never reveals counts, caps, or
20
+ * which cap tripped (the hub log carries the specifics for the operator).
21
+ *
22
+ * Release rides the bridge's socket lifecycle: the acquire site threads a
23
+ * self-disarming release closure into `ws.data` ({@link
24
+ * WsBridgeData.releaseCap}), and the bridge's Bun-level `close` handler —
25
+ * the single funnel every accepted socket passes through, whatever the
26
+ * teardown reason (client close, upstream close, backpressure, connect
27
+ * failure) — invokes it first thing. A failed `server.upgrade()` releases
28
+ * inline (no socket ⇒ no close callback). The closure latches, so a stray
29
+ * double-close can't corrupt the counters.
30
+ *
31
+ * Defaults (overridable via env, the hub's config precedent —
32
+ * `PARACHUTE_HUB_ORIGIN` et al.):
33
+ *
34
+ * - {@link DEFAULT_WS_MAX_PER_IP} = 32 per client IP
35
+ * (`PARACHUTE_WS_MAX_PER_IP`). An owner-operated box realistically
36
+ * serves a handful of humans; a collab surface opens a socket or two
37
+ * per tab, so 32 covers a small team behind one NAT egress IP with
38
+ * headroom, while turning a single-source flood into a rotate-IPs
39
+ * problem.
40
+ * - {@link DEFAULT_WS_MAX_TOTAL} = 512 total (`PARACHUTE_WS_MAX_TOTAL`).
41
+ * Bounds worst-case hub memory under a distributed flood (512 bridged
42
+ * pairs ≈ low MBs idle) at a ceiling far above any realistic legitimate
43
+ * concurrent load for a single-operator hub.
44
+ *
45
+ * Keying: callers derive the bucket key with `wsCapBucketKey`
46
+ * (hub-server.ts), which follows the hub's substrate trust model — forwarded
47
+ * IP headers are only believed when the peer is an on-box (loopback)
48
+ * forwarder; direct network peers key by their socket address no matter
49
+ * what headers they inject; an underivable IP lands in one shared bucket
50
+ * (fail closed, same posture as rate-limit.ts's UNKNOWN_IP_SENTINEL).
51
+ */
52
+
53
+ /** Default cap on concurrent bridged WS connections per client-IP bucket. */
54
+ export const DEFAULT_WS_MAX_PER_IP = 32;
55
+ /** Default cap on concurrent bridged WS connections across all clients. */
56
+ export const DEFAULT_WS_MAX_TOTAL = 512;
57
+
58
+ /** Env var overriding {@link DEFAULT_WS_MAX_PER_IP}. */
59
+ export const WS_MAX_PER_IP_ENV = "PARACHUTE_WS_MAX_PER_IP";
60
+ /** Env var overriding {@link DEFAULT_WS_MAX_TOTAL}. */
61
+ export const WS_MAX_TOTAL_ENV = "PARACHUTE_WS_MAX_TOTAL";
62
+
63
+ export interface WsCaps {
64
+ maxPerIp: number;
65
+ maxTotal: number;
66
+ }
67
+
68
+ /**
69
+ * Parse the cap overrides from an env bag. Only positive integers are
70
+ * honored; absent / malformed / non-positive values fall back to the
71
+ * defaults (an operator typo must not silently disable the gate — fail to
72
+ * the safe defaults, never to "unlimited").
73
+ */
74
+ export function wsCapsFromEnv(env: NodeJS.ProcessEnv = process.env): WsCaps {
75
+ return {
76
+ maxPerIp: parseCap(env[WS_MAX_PER_IP_ENV], DEFAULT_WS_MAX_PER_IP),
77
+ maxTotal: parseCap(env[WS_MAX_TOTAL_ENV], DEFAULT_WS_MAX_TOTAL),
78
+ };
79
+ }
80
+
81
+ function parseCap(raw: string | undefined, fallback: number): number {
82
+ if (raw === undefined) return fallback;
83
+ const n = Number.parseInt(raw.trim(), 10);
84
+ if (!Number.isSafeInteger(n) || n <= 0 || String(n) !== raw.trim()) return fallback;
85
+ return n;
86
+ }
87
+
88
+ /** The verdict of {@link WsConnectionTracker.tryAcquire}. */
89
+ export type WsAcquireResult =
90
+ | { ok: true; release: () => void }
91
+ | { ok: false; reason: "per_ip_cap" | "total_cap" };
92
+
93
+ /**
94
+ * Concurrent-connection accounting: a per-key map + a global counter.
95
+ *
96
+ * Acquire and release are synchronous and O(1); `tryAcquire` is called in
97
+ * the same synchronous block as `server.upgrade()` (no await between check
98
+ * and commit), so the counters can't race the admission decision.
99
+ *
100
+ * The returned `release` closure is latched — calling it twice decrements
101
+ * once. That makes leaks structurally hard: the caller doesn't need to know
102
+ * which teardown paths can double-fire; any number of invocations after the
103
+ * first are no-ops, and a key's bucket entry is deleted at zero so an
104
+ * attacker cycling keys can't grow the map without also holding sockets.
105
+ */
106
+ export class WsConnectionTracker {
107
+ private readonly perKey = new Map<string, number>();
108
+ private total = 0;
109
+
110
+ constructor(
111
+ private readonly maxPerKey: number = DEFAULT_WS_MAX_PER_IP,
112
+ private readonly maxTotal: number = DEFAULT_WS_MAX_TOTAL,
113
+ ) {}
114
+
115
+ /**
116
+ * Admit-or-refuse a would-be connection for `key`. On admission the slot
117
+ * is counted immediately and the caller MUST either hand the returned
118
+ * `release` to the socket's close path or invoke it inline when the
119
+ * upgrade fails to complete.
120
+ */
121
+ tryAcquire(key: string): WsAcquireResult {
122
+ if (this.total >= this.maxTotal) return { ok: false, reason: "total_cap" };
123
+ const current = this.perKey.get(key) ?? 0;
124
+ if (current >= this.maxPerKey) return { ok: false, reason: "per_ip_cap" };
125
+ this.perKey.set(key, current + 1);
126
+ this.total += 1;
127
+
128
+ let released = false;
129
+ return {
130
+ ok: true,
131
+ release: () => {
132
+ if (released) return;
133
+ released = true;
134
+ const n = this.perKey.get(key) ?? 0;
135
+ if (n <= 1) this.perKey.delete(key);
136
+ else this.perKey.set(key, n - 1);
137
+ if (this.total > 0) this.total -= 1;
138
+ },
139
+ };
140
+ }
141
+
142
+ /** Current global connection count (observability + tests). */
143
+ get totalCount(): number {
144
+ return this.total;
145
+ }
146
+
147
+ /** Current count for one key (observability + tests). 0 when absent. */
148
+ countFor(key: string): number {
149
+ return this.perKey.get(key) ?? 0;
150
+ }
151
+
152
+ /** Number of distinct keys currently holding connections (leak probe). */
153
+ get keyCount(): number {
154
+ return this.perKey.size;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * The production tracker — one per hub process, shared by every upgrade.
160
+ * Caps come from the env at module load (the hub reads its config once at
161
+ * boot; changing the env requires a restart, same as every other
162
+ * `PARACHUTE_*` knob). Tests construct their own trackers and inject them
163
+ * via `HubFetchDeps.wsConnectionTracker` so they never consume (or depend
164
+ * on) the shared process-level counters.
165
+ */
166
+ const bootCaps = wsCapsFromEnv();
167
+ export const defaultWsConnectionTracker = new WsConnectionTracker(
168
+ bootCaps.maxPerIp,
169
+ bootCaps.maxTotal,
170
+ );