@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.
- package/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- 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
|
+
}
|