@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -0,0 +1,506 @@
1
+ /**
2
+ * HTML + JSON renderers for upstream-unreachable responses (hub#443).
3
+ *
4
+ * Two states, two presentations, two content types — four total responses.
5
+ *
6
+ * transient + HTML → 503, branded "Just a moment" page with
7
+ * meta-refresh + JS poll of `/api/ready` (max 5
8
+ * attempts on a 2s cadence ≈ 10s ceiling). After
9
+ * the budget, fall back to a manual Refresh button.
10
+ *
11
+ * transient + JSON → 503, `{ error: "upstream_starting", error_type,
12
+ * retry_after_ms: 2000, max_attempts: <N> }`
13
+ * so an API consumer can drive its own backoff.
14
+ *
15
+ * persistent + HTML → 502, branded "Module unreachable" page with NO
16
+ * auto-retry (it won't help) and a prominent link
17
+ * to /admin/modules so the operator can inspect
18
+ * logs / restart / etc.
19
+ *
20
+ * persistent + JSON → 502, `{ error: "upstream_unreachable", error_type,
21
+ * admin_url: "/admin/modules" }`.
22
+ *
23
+ * Status codes follow RFC 9110:
24
+ * - 503 Service Unavailable = "temporary, try again" — pairs with a
25
+ * `Retry-After` header on the response.
26
+ * - 502 Bad Gateway = "upstream broken, retry probably won't
27
+ * help" — no Retry-After.
28
+ *
29
+ * Design tokens (`--accent`, `--bg`, `--fg`, etc.) match the OAuth UI +
30
+ * SPA so a wizard interrupt blends visually with the surfaces operators
31
+ * just walked through. See `web/ui/src/styles.css` for the canonical
32
+ * source.
33
+ */
34
+
35
+ import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
36
+ import { escapeHtml } from "./oauth-ui.ts";
37
+ import type { UpstreamState } from "./proxy-state.ts";
38
+
39
+ /** Retry cadence for transient state, in ms. Mirrors meta-refresh content. */
40
+ export const TRANSIENT_RETRY_MS = 2_000;
41
+
42
+ /** Max polls a transient HTML page will run before showing a manual button. */
43
+ export const TRANSIENT_MAX_ATTEMPTS = 5;
44
+
45
+ /** Admin link surfaced on persistent responses. /admin/modules opens the
46
+ * modules pane with per-module status + restart controls. */
47
+ export const ADMIN_MODULES_URL = "/admin/modules";
48
+
49
+ /** JSON error vocabulary. Matches the snake_case shape used elsewhere
50
+ * in the hub's API (`api-modules-config.ts` etc.) for consistency. */
51
+ export const ERROR_TYPE_TRANSIENT = "upstream_starting";
52
+ export const ERROR_TYPE_PERSISTENT = "upstream_unreachable";
53
+
54
+ /** Per-route HTTP status for each upstream state. */
55
+ export function statusForState(state: UpstreamState): 502 | 503 {
56
+ return state === "transient" ? 503 : 502;
57
+ }
58
+
59
+ export interface BuildProxyErrorOpts {
60
+ /** Canonical short name (vault/scribe/notes/…). Folded into the response
61
+ * for operator clarity. */
62
+ short: string;
63
+ /** services.json `name` field — what shipped on the entry. Falls back to
64
+ * `short` when they coincide. Used in JSON error description. */
65
+ serviceLabel: string;
66
+ /** The classified failure mode. */
67
+ state: UpstreamState;
68
+ /** Raw error message from the failed `fetch()`. Surfaced in JSON so a
69
+ * consumer can log it; not surfaced in HTML (operators don't read
70
+ * ECONNREFUSED, the visual cue is enough). */
71
+ upstreamError: string;
72
+ }
73
+
74
+ export interface ProxyErrorResponse {
75
+ body: string;
76
+ status: 502 | 503;
77
+ contentType: string;
78
+ /** Optional Retry-After header value (seconds, per RFC 9110 §10.2.3). */
79
+ retryAfter?: string;
80
+ }
81
+
82
+ /**
83
+ * True iff the caller wants HTML. Mirrors the existing one-line check at
84
+ * `hub-server.ts:2079` rather than introducing a separate negotiation
85
+ * module — the hub's accept handling is uniformly "look for `text/html`"
86
+ * across surfaces.
87
+ */
88
+ export function wantsHtml(req: Request): boolean {
89
+ const accept = req.headers.get("accept") ?? "";
90
+ if (accept === "") return true; // No Accept header → assume browser-ish.
91
+ if (accept.includes("application/json") && !accept.includes("text/html")) {
92
+ return false;
93
+ }
94
+ return accept.includes("text/html") || accept.includes("*/*");
95
+ }
96
+
97
+ /**
98
+ * Build the JSON response body for a proxy-error response.
99
+ *
100
+ * - Transient: includes `retry_after_ms` + `max_attempts` so an API
101
+ * consumer (CLI, MCP client) can implement its own bounded retry.
102
+ * `max_attempts` is the ceiling, not a per-request remaining count —
103
+ * hub doesn't track per-consumer state, so it can't honestly emit a
104
+ * decrementing counter. The HTML page tracks its own counter
105
+ * client-side (5 polls then manual fallback); JSON consumers do
106
+ * the same with this ceiling as the budget.
107
+ * - Persistent: includes `admin_url` so a developer/operator tool can
108
+ * surface a "go check the supervisor" affordance.
109
+ */
110
+ export function renderProxyErrorJson(opts: BuildProxyErrorOpts): ProxyErrorResponse {
111
+ const status = statusForState(opts.state);
112
+ if (opts.state === "transient") {
113
+ const body = JSON.stringify({
114
+ error: ERROR_TYPE_TRANSIENT,
115
+ error_type: ERROR_TYPE_TRANSIENT,
116
+ error_description: `${opts.serviceLabel} is still starting; retry shortly.`,
117
+ service: opts.short,
118
+ retry_after_ms: TRANSIENT_RETRY_MS,
119
+ max_attempts: TRANSIENT_MAX_ATTEMPTS,
120
+ });
121
+ return {
122
+ body,
123
+ status,
124
+ contentType: "application/json",
125
+ retryAfter: String(Math.ceil(TRANSIENT_RETRY_MS / 1000)),
126
+ };
127
+ }
128
+ const body = JSON.stringify({
129
+ error: ERROR_TYPE_PERSISTENT,
130
+ error_type: ERROR_TYPE_PERSISTENT,
131
+ error_description: `${opts.serviceLabel} upstream unreachable: ${opts.upstreamError}`,
132
+ service: opts.short,
133
+ admin_url: ADMIN_MODULES_URL,
134
+ });
135
+ return {
136
+ body,
137
+ status,
138
+ contentType: "application/json",
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Build the HTML response body for a proxy-error response. Two distinct
144
+ * layouts:
145
+ *
146
+ * - **Transient**: title "Just a moment", subhead "Loading <module>",
147
+ * a quiet spinner, a meta-refresh + JS poll that hits `/api/ready`
148
+ * up to `TRANSIENT_MAX_ATTEMPTS` times at `TRANSIENT_RETRY_MS`
149
+ * cadence, then a manual "Refresh" button. No admin link.
150
+ *
151
+ * - **Persistent**: title "Module unreachable", subhead naming the
152
+ * module, no auto-retry, prominent "View module status" link to
153
+ * /admin/modules + a manual "Refresh" button. The visual treatment
154
+ * leans on `--error` / `--error-soft` design tokens.
155
+ */
156
+ export function renderProxyErrorHtml(opts: BuildProxyErrorOpts): ProxyErrorResponse {
157
+ const status = statusForState(opts.state);
158
+ const safeShort = escapeHtml(opts.short);
159
+ const safeLabel = escapeHtml(opts.serviceLabel);
160
+ const isTransient = opts.state === "transient";
161
+ const title = isTransient ? "Just a moment" : "Module unreachable";
162
+
163
+ const headerVariant = isTransient ? "transient" : "persistent";
164
+ const subheadCopy = isTransient
165
+ ? `<span class="muted">Loading</span> <code>${safeShort}</code><span class="muted">…</span>`
166
+ : `<code>${safeShort}</code> <span class="muted">is not responding right now.</span>`;
167
+
168
+ const explanation = isTransient
169
+ ? `<p class="explanation">The hub is still bringing up the <code>${safeLabel}</code> module. This usually resolves within a few seconds. We'll refresh automatically.</p>`
170
+ : `<p class="explanation">The hub couldn't reach the <code>${safeLabel}</code> module. It may have crashed, failed to start, or been stopped. Check the module's status for logs and a restart option.</p>`;
171
+
172
+ const metaRefresh = isTransient
173
+ ? `<meta http-equiv="refresh" content="${Math.ceil(TRANSIENT_RETRY_MS / 1000)}">`
174
+ : "";
175
+
176
+ const spinnerOrIcon = isTransient
177
+ ? `<div class="status-indicator status-transient" aria-hidden="true"><span class="spinner"></span></div>`
178
+ : `<div class="status-indicator status-persistent" aria-hidden="true"><span class="error-icon">!</span></div>`;
179
+
180
+ const actionsHtml = isTransient
181
+ ? `
182
+ <div class="actions" id="actions-transient">
183
+ <p class="poll-status" id="poll-status">Checking again in <span id="poll-countdown">${Math.ceil(
184
+ TRANSIENT_RETRY_MS / 1000,
185
+ )}</span>s… <span class="muted">(attempt <span id="poll-attempt">1</span> of ${TRANSIENT_MAX_ATTEMPTS})</span></p>
186
+ </div>
187
+ <div class="actions" id="actions-give-up" hidden>
188
+ <p class="give-up-copy">Still loading. <button type="button" class="btn btn-primary" id="manual-refresh">Refresh now</button></p>
189
+ </div>`
190
+ : `
191
+ <div class="actions">
192
+ <a href="${ADMIN_MODULES_URL}" class="btn btn-primary">View module status</a>
193
+ <button type="button" class="btn btn-secondary" id="manual-refresh">Refresh</button>
194
+ </div>`;
195
+
196
+ // The poll script only emits for transient state. It:
197
+ // 1. Polls /api/ready every TRANSIENT_RETRY_MS.
198
+ // 2. On `ready: true` for our module, reloads the page (which now
199
+ // proxies through successfully).
200
+ // 3. After TRANSIENT_MAX_ATTEMPTS, swaps the "Checking…" UI for the
201
+ // manual "Refresh now" button.
202
+ // Defensive: any fetch error counts as a missed attempt — we don't want
203
+ // a `/api/ready` outage to lock the page in a permanent "still loading"
204
+ // state.
205
+ const pollScript = isTransient
206
+ ? `
207
+ <script>
208
+ (function () {
209
+ var maxAttempts = ${TRANSIENT_MAX_ATTEMPTS};
210
+ var intervalMs = ${TRANSIENT_RETRY_MS};
211
+ var short = ${JSON.stringify(opts.short)};
212
+ var attempt = 1;
213
+ var elCountdown = document.getElementById('poll-countdown');
214
+ var elAttempt = document.getElementById('poll-attempt');
215
+ var elActive = document.getElementById('actions-transient');
216
+ var elGiveUp = document.getElementById('actions-give-up');
217
+ var elManualBtn = document.getElementById('manual-refresh');
218
+ var countdown = Math.ceil(intervalMs / 1000);
219
+ var countdownTimer = setInterval(function () {
220
+ countdown -= 1;
221
+ if (countdown <= 0) countdown = Math.ceil(intervalMs / 1000);
222
+ if (elCountdown) elCountdown.textContent = String(countdown);
223
+ }, 1000);
224
+ function giveUp() {
225
+ clearInterval(countdownTimer);
226
+ if (elActive) elActive.hidden = true;
227
+ if (elGiveUp) elGiveUp.hidden = false;
228
+ }
229
+ function poll() {
230
+ if (attempt > maxAttempts) {
231
+ giveUp();
232
+ return;
233
+ }
234
+ fetch('/api/ready', { headers: { accept: 'application/json' }, cache: 'no-store' })
235
+ .then(function (r) { return r.ok ? r.json() : null; })
236
+ .then(function (data) {
237
+ if (data && data.ready) {
238
+ window.location.reload();
239
+ return;
240
+ }
241
+ // Module-specific check: if /api/ready lists our short in
242
+ // ready_modules we can reload too.
243
+ if (data && Array.isArray(data.ready_modules) && data.ready_modules.indexOf(short) !== -1) {
244
+ window.location.reload();
245
+ return;
246
+ }
247
+ attempt += 1;
248
+ if (elAttempt) elAttempt.textContent = String(attempt);
249
+ if (attempt > maxAttempts) giveUp();
250
+ })
251
+ .catch(function () {
252
+ attempt += 1;
253
+ if (elAttempt) elAttempt.textContent = String(attempt);
254
+ if (attempt > maxAttempts) giveUp();
255
+ });
256
+ }
257
+ // Single timer source: setInterval fires every intervalMs and
258
+ // self-stops at maxAttempts. The previous shape armed BOTH a
259
+ // setTimeout AND a setInterval at the same cadence — they
260
+ // double-fired at T+intervalMs, racing the attempt counter and
261
+ // reaching the giveUp ceiling in ~6s instead of the designed 10s.
262
+ // Reviewer-flagged on #443.
263
+ var pollTimer = setInterval(function () {
264
+ if (attempt > maxAttempts) {
265
+ clearInterval(pollTimer);
266
+ return;
267
+ }
268
+ poll();
269
+ }, intervalMs);
270
+ if (elManualBtn) {
271
+ elManualBtn.addEventListener('click', function () { window.location.reload(); });
272
+ }
273
+ }());
274
+ </script>`
275
+ : `
276
+ <script>
277
+ (function () {
278
+ var btn = document.getElementById('manual-refresh');
279
+ if (btn) btn.addEventListener('click', function () { window.location.reload(); });
280
+ }());
281
+ </script>`;
282
+
283
+ const body = `<!doctype html>
284
+ <html lang="en">
285
+ <head>
286
+ <meta charset="utf-8" />
287
+ <title>${escapeHtml(title)}</title>
288
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
289
+ <meta name="referrer" content="no-referrer" />
290
+ ${metaRefresh}
291
+ <style>${PROXY_ERROR_STYLES}</style>
292
+ </head>
293
+ <body>
294
+ <main>
295
+ <div class="card card-${headerVariant}">
296
+ <div class="card-header">
297
+ <div class="brand">
298
+ <span class="brand-mark" aria-hidden="true">${brandMarkSvg(20, `proxy-${headerVariant}`)}</span>
299
+ <span class="brand-name">${WORDMARK_TEXT}</span>
300
+ </div>
301
+ ${spinnerOrIcon}
302
+ <h1>${escapeHtml(title)}</h1>
303
+ <p class="subtitle">${subheadCopy}</p>
304
+ </div>
305
+ ${explanation}
306
+ ${actionsHtml}
307
+ </div>
308
+ </main>
309
+ ${pollScript}
310
+ </body>
311
+ </html>`;
312
+
313
+ const headers: ProxyErrorResponse = {
314
+ body,
315
+ status,
316
+ contentType: "text/html; charset=utf-8",
317
+ };
318
+ if (isTransient) {
319
+ headers.retryAfter = String(Math.ceil(TRANSIENT_RETRY_MS / 1000));
320
+ }
321
+ return headers;
322
+ }
323
+
324
+ /**
325
+ * Dispatch between HTML + JSON renderers based on the request's Accept
326
+ * header. Single entry point used by `proxyRequest`.
327
+ */
328
+ export function renderProxyError(req: Request, opts: BuildProxyErrorOpts): ProxyErrorResponse {
329
+ return wantsHtml(req) ? renderProxyErrorHtml(opts) : renderProxyErrorJson(opts);
330
+ }
331
+
332
+ /**
333
+ * Construct the actual Response from a ProxyErrorResponse. Pulled out so
334
+ * tests can assert on the body shape without re-deriving the headers.
335
+ */
336
+ export function toResponse(out: ProxyErrorResponse): Response {
337
+ const headers: Record<string, string> = {
338
+ "content-type": out.contentType,
339
+ "cache-control": "no-store",
340
+ };
341
+ if (out.retryAfter) headers["retry-after"] = out.retryAfter;
342
+ return new Response(out.body, { status: out.status, headers });
343
+ }
344
+
345
+ const PROXY_ERROR_STYLES = `
346
+ :root {
347
+ --bg: #faf8f4;
348
+ --bg-soft: #f3f0ea;
349
+ --fg: #2c2a26;
350
+ --fg-muted: #6b6860;
351
+ --fg-dim: #9a9690;
352
+ --accent: #4a7c59;
353
+ --accent-soft: rgba(74, 124, 89, 0.08);
354
+ --accent-hover: #3d6849;
355
+ --border: #e4e0d8;
356
+ --card-bg: #ffffff;
357
+ --error: #a3392b;
358
+ --error-soft: rgba(163, 57, 43, 0.08);
359
+ --warn: #b08023;
360
+ --warn-soft: rgba(176, 128, 35, 0.08);
361
+ }
362
+ *, *::before, *::after { box-sizing: border-box; }
363
+ html, body { margin: 0; padding: 0; }
364
+ body {
365
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
366
+ background: var(--bg);
367
+ color: var(--fg);
368
+ line-height: 1.55;
369
+ min-height: 100vh;
370
+ -webkit-font-smoothing: antialiased;
371
+ }
372
+ main {
373
+ display: flex;
374
+ align-items: center;
375
+ justify-content: center;
376
+ min-height: 100vh;
377
+ padding: 1.5rem;
378
+ }
379
+ .card {
380
+ width: 100%;
381
+ max-width: 30rem;
382
+ background: var(--card-bg);
383
+ border: 1px solid var(--border);
384
+ border-radius: 12px;
385
+ padding: 2rem 1.75rem;
386
+ box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
387
+ text-align: center;
388
+ }
389
+ .card-header { margin-bottom: 1.25rem; }
390
+ .brand {
391
+ display: flex;
392
+ align-items: center;
393
+ justify-content: center;
394
+ gap: 0.5rem;
395
+ color: var(--accent);
396
+ font-weight: 500;
397
+ font-size: 0.95rem;
398
+ margin-bottom: 1.5rem;
399
+ }
400
+ .brand-mark { display: inline-flex; line-height: 0; }
401
+ .brand-mark svg { width: 20px; height: 20px; }
402
+ .status-indicator {
403
+ width: 56px;
404
+ height: 56px;
405
+ margin: 0 auto 1.25rem;
406
+ display: flex;
407
+ align-items: center;
408
+ justify-content: center;
409
+ border-radius: 50%;
410
+ }
411
+ .status-transient {
412
+ background: var(--accent-soft);
413
+ }
414
+ .status-persistent {
415
+ background: var(--error-soft);
416
+ color: var(--error);
417
+ font-family: Georgia, "Times New Roman", serif;
418
+ font-size: 1.75rem;
419
+ font-weight: 600;
420
+ }
421
+ .spinner {
422
+ width: 24px;
423
+ height: 24px;
424
+ border: 2.5px solid var(--accent-soft);
425
+ border-top-color: var(--accent);
426
+ border-radius: 50%;
427
+ animation: spin 0.9s linear infinite;
428
+ }
429
+ @keyframes spin {
430
+ to { transform: rotate(360deg); }
431
+ }
432
+ h1 {
433
+ font-family: Georgia, "Times New Roman", serif;
434
+ font-weight: 400;
435
+ font-size: 1.6rem;
436
+ line-height: 1.2;
437
+ margin: 0 0 0.5rem;
438
+ color: var(--fg);
439
+ }
440
+ .subtitle {
441
+ margin: 0;
442
+ color: var(--fg-muted);
443
+ font-size: 0.95rem;
444
+ }
445
+ .subtitle code, .explanation code {
446
+ font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
447
+ background: var(--bg-soft);
448
+ padding: 0.1rem 0.4rem;
449
+ border-radius: 4px;
450
+ font-size: 0.85em;
451
+ color: var(--fg);
452
+ }
453
+ .muted { color: var(--fg-dim); }
454
+ .explanation {
455
+ color: var(--fg-muted);
456
+ font-size: 0.92rem;
457
+ margin: 0 0 1.5rem;
458
+ text-align: left;
459
+ }
460
+ .actions {
461
+ display: flex;
462
+ flex-direction: column;
463
+ gap: 0.65rem;
464
+ align-items: stretch;
465
+ }
466
+ .poll-status {
467
+ margin: 0;
468
+ color: var(--fg-muted);
469
+ font-size: 0.88rem;
470
+ text-align: center;
471
+ }
472
+ .give-up-copy {
473
+ color: var(--fg-muted);
474
+ font-size: 0.92rem;
475
+ margin: 0;
476
+ text-align: center;
477
+ }
478
+ .btn {
479
+ display: inline-block;
480
+ font: inherit;
481
+ border-radius: 6px;
482
+ padding: 0.55rem 1.1rem;
483
+ cursor: pointer;
484
+ text-decoration: none;
485
+ text-align: center;
486
+ border: 1px solid transparent;
487
+ transition: background 0.15s ease, border-color 0.15s ease;
488
+ }
489
+ .btn-primary {
490
+ background: var(--accent);
491
+ color: white;
492
+ border-color: var(--accent);
493
+ }
494
+ .btn-primary:hover {
495
+ background: var(--accent-hover);
496
+ border-color: var(--accent-hover);
497
+ }
498
+ .btn-secondary {
499
+ background: white;
500
+ color: var(--fg);
501
+ border-color: var(--border);
502
+ }
503
+ .btn-secondary:hover {
504
+ background: var(--bg-soft);
505
+ }
506
+ `;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Boot-readiness classifier for upstream proxy failures (hub#443).
3
+ *
4
+ * When `proxyRequest` in `hub-server.ts` gets a fetch error talking to a
5
+ * module's loopback port, we want to differentiate two operator
6
+ * experiences:
7
+ *
8
+ * - **transient** — the module is *currently* booting and the loopback
9
+ * socket isn't bound yet (or is mid-restart). The right response is
10
+ * "wait a moment, refresh." A `parachute start hub` triggers this
11
+ * for the 5–15s window while modules are still coming up; without
12
+ * classification the wizard renders a hard `Bad gateway` JSON 502
13
+ * and operators panic.
14
+ *
15
+ * - **persistent** — the module has been spawned long enough that it
16
+ * should be reachable, OR the supervisor declared it crashed, OR
17
+ * there's no live process at all. The right response is "something
18
+ * went wrong, check logs." Auto-retry would just spin the SPA.
19
+ *
20
+ * The classification consults the cheapest signals we already have:
21
+ *
22
+ * 1. Container-mode (`parachute serve`): the `Supervisor` knows
23
+ * lifecycle (`starting | running | stopped | crashed | restarting`)
24
+ * and `startedAt`. Walks the four-state vocabulary; restarting +
25
+ * starting are always transient, crashed/stopped are persistent,
26
+ * running is transient inside the boot window and persistent after.
27
+ *
28
+ * 2. On-box CLI mode (`parachute start <svc>`): no supervisor. We
29
+ * fall back to the pidfile state via `processState`. A live PID
30
+ * whose pidfile mtime is recent → transient. Live PID with an old
31
+ * pidfile → persistent (process is up but the listener went away).
32
+ * No pidfile / stale pidfile → persistent.
33
+ *
34
+ * The default boot window is 30s — long enough that vault's SQLite
35
+ * pragma warmup + scribe's whisper-model load both finish inside it,
36
+ * short enough that an operator who's been staring at a `Loading…`
37
+ * spinner for 30+ seconds deserves the "something went wrong" page
38
+ * rather than another "still booting" tease.
39
+ */
40
+
41
+ import { CONFIG_DIR } from "./config.ts";
42
+ import { type ProcessState, processState } from "./process-state.ts";
43
+ import type { Supervisor } from "./supervisor.ts";
44
+
45
+ /** Classification result. */
46
+ export type UpstreamState = "transient" | "persistent";
47
+
48
+ /** Default boot window: a fetch failure within this many ms of the most
49
+ * recent spawn timestamp counts as transient (still warming up). 30s
50
+ * covers vault's SQLite pragma init + scribe's whisper model load with
51
+ * margin. */
52
+ export const DEFAULT_BOOT_WINDOW_MS = 30_000;
53
+
54
+ export interface ClassifyOpts {
55
+ /** Container-mode supervisor handle. Absent under on-box CLI mode. */
56
+ supervisor?: Supervisor;
57
+ /** Test seam over `Date.now()`. */
58
+ now?: () => number;
59
+ /** Test seam over `processState` (pidfile reader). */
60
+ readProcessState?: (svc: string, configDir?: string) => ProcessState;
61
+ /** Override config dir (test seam). Defaults to CONFIG_DIR. */
62
+ configDir?: string;
63
+ /** Override the boot window (test seam). Defaults to 30_000 ms. */
64
+ bootWindowMs?: number;
65
+ }
66
+
67
+ /**
68
+ * Classify why a loopback fetch to module `short` failed.
69
+ *
70
+ * `short` is the canonical short name (vault / scribe / notes / …) used as
71
+ * the supervisor map key AND the per-service `~/.parachute/<short>/` config
72
+ * directory key. Callers in `hub-server.ts` derive it from
73
+ * `shortNameForManifest(entry.name)` and fall back to the entry's raw name
74
+ * for unknown modules (third-party services with no canonical short — they
75
+ * land in "persistent" by default since we have no boot-window signal).
76
+ *
77
+ * Returns "persistent" by default — when in doubt, don't auto-retry. The
78
+ * worst outcome of a wrong "transient" classification is a JS poll that
79
+ * never sees the module come up; the worst outcome of a wrong "persistent"
80
+ * is an operator who has to refresh once. Persistent is the safer default.
81
+ */
82
+ export function classifyUpstream(short: string, opts: ClassifyOpts = {}): UpstreamState {
83
+ const now = (opts.now ?? Date.now)();
84
+ const bootWindow = opts.bootWindowMs ?? DEFAULT_BOOT_WINDOW_MS;
85
+
86
+ // 1. Supervisor (container mode) — authoritative when present.
87
+ if (opts.supervisor) {
88
+ const state = opts.supervisor.get(short);
89
+ if (state) {
90
+ switch (state.status) {
91
+ case "starting":
92
+ case "restarting":
93
+ return "transient";
94
+ case "crashed":
95
+ case "stopped":
96
+ return "persistent";
97
+ case "running": {
98
+ // Running but socket isn't answering. Inside the boot window
99
+ // we assume the process hasn't bound its listener yet; after,
100
+ // we assume the listener died.
101
+ if (state.startedAt === undefined) return "persistent";
102
+ const startedAt = Date.parse(state.startedAt);
103
+ if (!Number.isFinite(startedAt)) return "persistent";
104
+ return now - startedAt < bootWindow ? "transient" : "persistent";
105
+ }
106
+ }
107
+ }
108
+ // Module not tracked by supervisor — could be a third-party row or a
109
+ // services.json entry that wasn't spawned at boot. Fall through to the
110
+ // pidfile check (still useful if the operator launched it via `parachute
111
+ // start` before this hub came up).
112
+ }
113
+
114
+ // 2. Pidfile (on-box CLI mode) — `~/.parachute/<short>/run/<short>.pid`.
115
+ const readState = opts.readProcessState ?? processState;
116
+ const configDir = opts.configDir ?? CONFIG_DIR;
117
+ let ps: ProcessState;
118
+ try {
119
+ ps = readState(short, configDir);
120
+ } catch {
121
+ // pidfile read can race with cleanup; treat read errors as no signal.
122
+ return "persistent";
123
+ }
124
+ if (ps.status === "running" && ps.startedAt) {
125
+ const ageMs = now - ps.startedAt.getTime();
126
+ return ageMs < bootWindow ? "transient" : "persistent";
127
+ }
128
+ // Stopped (stale pidfile), unknown (no pidfile) → persistent. No claim
129
+ // of "currently booting" can be made without a fresh-mtime pidfile.
130
+ return "persistent";
131
+ }