@openparachute/hub 0.6.4 → 0.6.5-rc.2
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/package.json +1 -1
- package/src/__tests__/cloudflare-tunnel.test.ts +78 -0
- package/src/__tests__/expose-cloudflare.test.ts +253 -0
- package/src/__tests__/hub-db-liveness.test.ts +139 -0
- package/src/__tests__/hub-server.test.ts +145 -6
- package/src/__tests__/hub-unit.test.ts +110 -1
- package/src/__tests__/oauth-handlers.test.ts +457 -0
- package/src/__tests__/oauth-ui.test.ts +27 -0
- package/src/cloudflare/tunnel.ts +70 -0
- package/src/commands/expose-cloudflare.ts +157 -2
- package/src/commands/serve.ts +14 -4
- package/src/hub-db-liveness.ts +211 -0
- package/src/hub-server.ts +1175 -1104
- package/src/hub-unit.ts +74 -27
- package/src/oauth-handlers.ts +69 -25
- package/src/oauth-ui.ts +28 -2
package/src/hub-unit.ts
CHANGED
|
@@ -85,7 +85,9 @@ export interface HubUnitDeps extends ManagedUnitDeps {
|
|
|
85
85
|
* `null` when the hub doesn't answer at all (connection-refused / timeout).
|
|
86
86
|
* Production uses a bounded `fetch`; tests inject a deterministic stub.
|
|
87
87
|
*/
|
|
88
|
-
probeHealthVersion: (
|
|
88
|
+
probeHealthVersion: (
|
|
89
|
+
port: number,
|
|
90
|
+
) => Promise<{ ok: boolean; version?: string; db?: string } | null>;
|
|
89
91
|
/** TCP connect-probe for readiness polling (reuses `defaultPortListening`). */
|
|
90
92
|
portListening: PortListeningFn;
|
|
91
93
|
/** Sleep between readiness polls (tests pin to 0). */
|
|
@@ -118,27 +120,48 @@ async function defaultProbeHealth(port: number): Promise<boolean> {
|
|
|
118
120
|
*/
|
|
119
121
|
async function defaultProbeHealthVersion(
|
|
120
122
|
port: number,
|
|
121
|
-
): Promise<{ ok: boolean; version?: string } | null> {
|
|
123
|
+
): Promise<{ ok: boolean; version?: string; db?: string } | null> {
|
|
122
124
|
try {
|
|
123
125
|
const res = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
124
126
|
signal: AbortSignal.timeout(1500),
|
|
125
127
|
});
|
|
126
128
|
let version: string | undefined;
|
|
129
|
+
let db: string | undefined;
|
|
127
130
|
try {
|
|
128
131
|
const body = (await res.json()) as unknown;
|
|
129
|
-
if (body && typeof body === "object"
|
|
132
|
+
if (body && typeof body === "object") {
|
|
130
133
|
const v = (body as { version?: unknown }).version;
|
|
131
134
|
if (typeof v === "string" && v.length > 0) version = v;
|
|
135
|
+
// `db` liveness verdict (#594): "ok" / "error: <class>" / "unconfigured".
|
|
136
|
+
// Threaded through so the adoption probe can treat a db-error hub as
|
|
137
|
+
// needing a restart even when its version matches.
|
|
138
|
+
const d = (body as { db?: unknown }).db;
|
|
139
|
+
if (typeof d === "string" && d.length > 0) db = d;
|
|
132
140
|
}
|
|
133
141
|
} catch {
|
|
134
|
-
// Non-JSON body → no version. Leave
|
|
142
|
+
// Non-JSON body → no version/db. Leave undefined (→ mismatch / unknown db).
|
|
135
143
|
}
|
|
136
|
-
|
|
144
|
+
const out: { ok: boolean; version?: string; db?: string } = { ok: res.ok };
|
|
145
|
+
if (version !== undefined) out.version = version;
|
|
146
|
+
if (db !== undefined) out.db = db;
|
|
147
|
+
return out;
|
|
137
148
|
} catch {
|
|
138
149
|
return null;
|
|
139
150
|
}
|
|
140
151
|
}
|
|
141
152
|
|
|
153
|
+
/**
|
|
154
|
+
* True when a `/health` `db` field reports a non-recoverable liveness fault
|
|
155
|
+
* (#594) — anything starting with "error:" (e.g. "error: fatal" from the
|
|
156
|
+
* dead-handle field repro). "ok" and "unconfigured" are not faults: a
|
|
157
|
+
* pre-wizard hub with no DB rows still reports a working handle. A missing
|
|
158
|
+
* `db` field (an older hub that predates #594) reads as "unknown → don't
|
|
159
|
+
* treat as a fault" so we never restart a hub merely for lacking the field.
|
|
160
|
+
*/
|
|
161
|
+
function healthReportsDbFault(db: string | undefined): boolean {
|
|
162
|
+
return typeof db === "string" && db.startsWith("error:");
|
|
163
|
+
}
|
|
164
|
+
|
|
142
165
|
export const defaultHubUnitDeps: HubUnitDeps = {
|
|
143
166
|
...defaultManagedUnitDeps,
|
|
144
167
|
probeHealth: defaultProbeHealth,
|
|
@@ -510,13 +533,22 @@ export async function ensureHubVersionMatches(
|
|
|
510
533
|
}
|
|
511
534
|
|
|
512
535
|
const runningVersion = probe.version;
|
|
513
|
-
|
|
514
|
-
|
|
536
|
+
const dbFault = healthReportsDbFault(probe.db);
|
|
537
|
+
if (runningVersion === installedVersion && !dbFault) {
|
|
538
|
+
// Versions agree AND the DB handle is live — today's behavior, no restart.
|
|
515
539
|
return { outcome: "match", runningVersion, installedVersion, messages: [] };
|
|
516
540
|
}
|
|
517
541
|
|
|
518
|
-
//
|
|
519
|
-
|
|
542
|
+
// From here we know the running hub needs a restart: EITHER its version is
|
|
543
|
+
// stale (the #590 zombie-adoption case) OR it's reporting a dead DB handle
|
|
544
|
+
// (#594 — a hub that adopted-as-version-match but whose state dir was deleted
|
|
545
|
+
// under it; /health stays 200 while every DB route 500s). Both run through
|
|
546
|
+
// the same restart-once machinery. `runningLabel` describes whichever fault
|
|
547
|
+
// we're acting on so the operator sees an accurate reason.
|
|
548
|
+
const versionMismatch = runningVersion !== installedVersion;
|
|
549
|
+
const runningLabel = versionMismatch
|
|
550
|
+
? (runningVersion ?? "an older version (no version field)")
|
|
551
|
+
: `${runningVersion} with a dead database handle (${probe.db})`;
|
|
520
552
|
|
|
521
553
|
// Is this hub one we can restart through the manager? If there's no manager,
|
|
522
554
|
// or no unit installed, the running hub is a legacy detached pid / a dev
|
|
@@ -556,45 +588,60 @@ export async function ensureHubVersionMatches(
|
|
|
556
588
|
outcome: "restarted",
|
|
557
589
|
runningVersion: v,
|
|
558
590
|
installedVersion,
|
|
559
|
-
messages: [`✓ hub unit restarted; now running ${installedVersion}.`],
|
|
591
|
+
messages: [`✓ hub unit restarted; now running ${installedVersion} with a live database.`],
|
|
560
592
|
});
|
|
561
|
-
const stillMismatchedResult = (
|
|
562
|
-
|
|
593
|
+
const stillMismatchedResult = (
|
|
594
|
+
last: { version?: string; db?: string } | undefined,
|
|
595
|
+
): EnsureHubVersionMatchesResult => {
|
|
596
|
+
const lastVersion = last?.version;
|
|
597
|
+
const reports = lastVersion ? ` (reports ${lastVersion})` : "";
|
|
598
|
+
const dbStillBad = healthReportsDbFault(last?.db);
|
|
563
599
|
return {
|
|
564
600
|
outcome: "still-mismatched",
|
|
565
|
-
...(
|
|
601
|
+
...(lastVersion !== undefined ? { runningVersion: lastVersion } : {}),
|
|
566
602
|
installedVersion,
|
|
567
|
-
messages:
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
603
|
+
messages: dbStillBad
|
|
604
|
+
? [
|
|
605
|
+
`⚠ restarted the hub unit, but its database still reports a fault (${last?.db}).`,
|
|
606
|
+
" The state directory may still be missing or the database file corrupted.",
|
|
607
|
+
` Check it with \`curl http://127.0.0.1:${port}/health\` and ensure ~/.parachute exists.`,
|
|
608
|
+
]
|
|
609
|
+
: [
|
|
610
|
+
`⚠ restarted the hub unit, but it is still not reporting ${installedVersion}${reports}.`,
|
|
611
|
+
" This can happen with a bun-linked checkout on a feature branch whose package.json version trails the running code.",
|
|
612
|
+
` Continuing — verify with \`parachute status\` / \`curl http://127.0.0.1:${port}/health\` if the hub should be on a specific version.`,
|
|
613
|
+
],
|
|
572
614
|
};
|
|
573
615
|
};
|
|
574
616
|
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
|
|
617
|
+
// A re-probe counts as "healed" only when the version matches AND the DB
|
|
618
|
+
// handle is live — a restart that came back on the right version but with a
|
|
619
|
+
// still-dead handle hasn't actually fixed the #594 fault.
|
|
620
|
+
const probeHealed = (p: { version?: string; db?: string } | null): boolean =>
|
|
621
|
+
p !== null && p.version === installedVersion && !healthReportsDbFault(p.db);
|
|
622
|
+
|
|
623
|
+
// Re-probe `/health` until the hub is healed or the readiness budget elapses.
|
|
624
|
+
// Restart-loop guard: we restart AT MOST once — if it still mismatches /
|
|
625
|
+
// db-faults after this single restart (e.g. a bun-linked checkout on a
|
|
626
|
+
// branch, or a still-missing state dir), we warn + continue rather than loop.
|
|
579
627
|
const deadline = Date.now() + readyTimeoutMs;
|
|
580
628
|
for (;;) {
|
|
581
629
|
const after = await deps.probeHealthVersion(port);
|
|
582
|
-
if (after
|
|
630
|
+
if (probeHealed(after)) {
|
|
583
631
|
return restartedResult(installedVersion);
|
|
584
632
|
}
|
|
585
633
|
if (Date.now() >= deadline) {
|
|
586
|
-
|
|
587
|
-
return stillMismatchedResult(after?.version ?? runningVersion);
|
|
634
|
+
return stillMismatchedResult(after ?? { version: runningVersion });
|
|
588
635
|
}
|
|
589
636
|
if (readyPollMs > 0) await deps.sleep(readyPollMs);
|
|
590
637
|
else break;
|
|
591
638
|
}
|
|
592
639
|
// readyPollMs === 0 fast-path: one more probe, then settle.
|
|
593
640
|
const finalProbe = await deps.probeHealthVersion(port);
|
|
594
|
-
if (finalProbe
|
|
641
|
+
if (probeHealed(finalProbe)) {
|
|
595
642
|
return restartedResult(installedVersion);
|
|
596
643
|
}
|
|
597
|
-
return stillMismatchedResult(finalProbe
|
|
644
|
+
return stillMismatchedResult(finalProbe ?? { version: runningVersion });
|
|
598
645
|
}
|
|
599
646
|
|
|
600
647
|
/**
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -861,22 +861,14 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
861
861
|
if ("error" in parsed) {
|
|
862
862
|
return htmlError("Invalid authorization request", parsed.error, 400);
|
|
863
863
|
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
if (parsed.codeChallengeMethod !== "S256") {
|
|
873
|
-
return oauthErrorRedirect(
|
|
874
|
-
parsed.redirectUri,
|
|
875
|
-
"invalid_request",
|
|
876
|
-
"PKCE S256 is required",
|
|
877
|
-
parsed.state,
|
|
878
|
-
);
|
|
879
|
-
}
|
|
864
|
+
// NOTE: response_type / code_challenge_method validation is DELIBERATELY
|
|
865
|
+
// deferred until after the (client_id, redirect_uri) pair is confirmed
|
|
866
|
+
// registered (below, just past `requireRegisteredRedirectUri`). RFC 6749
|
|
867
|
+
// §4.1.2.1: when the redirect_uri can't be validated against the client,
|
|
868
|
+
// errors MUST be shown to the user — NOT redirected to the supplied URI.
|
|
869
|
+
// Redirecting here (pre-validation) on a crafted redirect_uri is an open
|
|
870
|
+
// redirect (hub#570). Once the pair is validated, redirecting these errors
|
|
871
|
+
// back to the now-trusted URI is spec-correct.
|
|
880
872
|
let client = getClient(db, parsed.clientId);
|
|
881
873
|
if (!client) {
|
|
882
874
|
// Can't safely redirect — we don't trust the redirect_uri until we've
|
|
@@ -928,6 +920,49 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
928
920
|
url.searchParams.set("scope", parsed.scope);
|
|
929
921
|
}
|
|
930
922
|
|
|
923
|
+
// Validate the FULL request BEFORE any state mutation (the pending-client
|
|
924
|
+
// auto-approve below calls `approveClient`). Two reasons, both #570:
|
|
925
|
+
//
|
|
926
|
+
// 1. RFC 6749 §4.1.2.1 — an unvalidated redirect_uri error is shown to
|
|
927
|
+
// the user, never redirected. `requireRegisteredRedirectUri` first
|
|
928
|
+
// confirms the (client_id, redirect_uri) pair is registered; only then
|
|
929
|
+
// may we redirect the protocol errors (response_type / PKCE) to it.
|
|
930
|
+
// 2. We must not permanently promote a pending client to `approved` for a
|
|
931
|
+
// request we're about to reject. Pre-fix the redirect-uri + protocol
|
|
932
|
+
// checks sat BELOW the auto-approve block, so a malformed request
|
|
933
|
+
// (bad response_type / PKCE) against a registered-but-pending client
|
|
934
|
+
// mutated the DB before validation ever ran (#570 reviewer fold).
|
|
935
|
+
//
|
|
936
|
+
// So: redirect_uri registration → response_type → PKCE, all ahead of the
|
|
937
|
+
// pending-client auto-approve.
|
|
938
|
+
try {
|
|
939
|
+
requireRegisteredRedirectUri(client, parsed.redirectUri);
|
|
940
|
+
} catch {
|
|
941
|
+
return htmlError(
|
|
942
|
+
"Redirect mismatch",
|
|
943
|
+
"The redirect_uri does not match any URI registered for this app.",
|
|
944
|
+
400,
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
// The pair is confirmed registered, so redirecting these protocol errors to
|
|
948
|
+
// it is spec-correct (RFC 6749 §4.1.2.1).
|
|
949
|
+
if (parsed.responseType !== "code") {
|
|
950
|
+
return oauthErrorRedirect(
|
|
951
|
+
parsed.redirectUri,
|
|
952
|
+
"unsupported_response_type",
|
|
953
|
+
"only response_type=code is supported",
|
|
954
|
+
parsed.state,
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
if (parsed.codeChallengeMethod !== "S256") {
|
|
958
|
+
return oauthErrorRedirect(
|
|
959
|
+
parsed.redirectUri,
|
|
960
|
+
"invalid_request",
|
|
961
|
+
"PKCE S256 is required",
|
|
962
|
+
parsed.state,
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
931
966
|
if (client.status !== "approved") {
|
|
932
967
|
// Single-consent change (2026-05-29): the separate operator "approve this
|
|
933
968
|
// client" gate is retired — the user's OAuth consent IS the authorization.
|
|
@@ -995,15 +1030,6 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
995
1030
|
}
|
|
996
1031
|
client = refreshed;
|
|
997
1032
|
}
|
|
998
|
-
try {
|
|
999
|
-
requireRegisteredRedirectUri(client, parsed.redirectUri);
|
|
1000
|
-
} catch {
|
|
1001
|
-
return htmlError(
|
|
1002
|
-
"Redirect mismatch",
|
|
1003
|
-
"The redirect_uri does not match any URI registered for this app.",
|
|
1004
|
-
400,
|
|
1005
|
-
);
|
|
1006
|
-
}
|
|
1007
1033
|
|
|
1008
1034
|
// Operator-only scope gate (#96). Reject any request that names a scope
|
|
1009
1035
|
// we'll never mint via this flow — `parachute:host:admin` and friends.
|
|
@@ -2571,6 +2597,23 @@ function consentProps(
|
|
|
2571
2597
|
) {
|
|
2572
2598
|
const scopes = params.scope.split(" ").filter((s) => s.length > 0);
|
|
2573
2599
|
const unnamedVerbs = unnamedVaultVerbs(scopes);
|
|
2600
|
+
// Zero-vault non-admin can't authorize a vault-scoped request (hub#431).
|
|
2601
|
+
// The POST handler already 400s this case ("No vaults assigned"); this
|
|
2602
|
+
// flag lets the consent screen render Approve disabled + explain why,
|
|
2603
|
+
// instead of showing an enabled button that lands the user on an error.
|
|
2604
|
+
// Mirrors the vault-scope detection in `handleConsentSubmit`'s zero-vault
|
|
2605
|
+
// gate. Non-vault scopes (`scribe:transcribe`, etc.) stay authorizable.
|
|
2606
|
+
const requestsVaultScope = scopes.some((s) => {
|
|
2607
|
+
if (s === "vault:read" || s === "vault:write" || s === "vault:admin") return true;
|
|
2608
|
+
const parts = s.split(":");
|
|
2609
|
+
return (
|
|
2610
|
+
parts.length === 3 &&
|
|
2611
|
+
parts[0] === "vault" &&
|
|
2612
|
+
parts[2] !== undefined &&
|
|
2613
|
+
VAULT_VERBS.has(parts[2])
|
|
2614
|
+
);
|
|
2615
|
+
});
|
|
2616
|
+
const userCanAuthorizeRequest = userIsAdmin || assignedVaults.length > 0 || !requestsVaultScope;
|
|
2574
2617
|
// Multi-user Phase 2 PR 2 stale-assignment branch (hub#284 generalized
|
|
2575
2618
|
// from one vault to N). A non-admin user whose entire vault list has
|
|
2576
2619
|
// been removed from services.json — admin removed / renamed the vaults
|
|
@@ -2696,6 +2739,7 @@ function consentProps(
|
|
|
2696
2739
|
// verb against the stale assignment).
|
|
2697
2740
|
blockApproveForStaleAssignment:
|
|
2698
2741
|
staleAssignedVault !== undefined && (unnamedVerbs.length > 0 || hasNamedStaleVaultScope),
|
|
2742
|
+
userCanAuthorizeRequest,
|
|
2699
2743
|
};
|
|
2700
2744
|
}
|
|
2701
2745
|
|
package/src/oauth-ui.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* module scopes that the hub doesn't know about) render verbatim.
|
|
21
21
|
* - **No JavaScript.** Entirely form-based. Submit is the only interaction.
|
|
22
22
|
*/
|
|
23
|
-
import {
|
|
23
|
+
import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
|
|
24
24
|
import { renderCsrfHiddenInput } from "./csrf.ts";
|
|
25
25
|
import { type ScopeExplanation, explainScope } from "./scope-explanations.ts";
|
|
26
26
|
|
|
@@ -138,6 +138,15 @@ export interface ConsentViewProps {
|
|
|
138
138
|
* the user can still proceed.
|
|
139
139
|
*/
|
|
140
140
|
blockApproveForStaleAssignment?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Multi-user (hub#431): false when the signed-in user can't authorize this
|
|
143
|
+
* request at all — a non-admin with zero assigned vaults requesting a vault
|
|
144
|
+
* scope (named or unnamed). The POST handler already 400s this case ("No
|
|
145
|
+
* vaults assigned"); this flag lets the consent screen render Approve
|
|
146
|
+
* disabled + show explanatory copy instead of an enabled button that lands
|
|
147
|
+
* the user on an error page. Defaults to authorizable when omitted.
|
|
148
|
+
*/
|
|
149
|
+
userCanAuthorizeRequest?: boolean;
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
export interface VaultPicker {
|
|
@@ -318,6 +327,7 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
318
327
|
displayVault,
|
|
319
328
|
staleAssignedVault,
|
|
320
329
|
blockApproveForStaleAssignment,
|
|
330
|
+
userCanAuthorizeRequest,
|
|
321
331
|
} = props;
|
|
322
332
|
// Substitute unnamed `vault:<verb>` rows with the resolved named form so
|
|
323
333
|
// the operator sees the scope shape that will appear in the token. Raw
|
|
@@ -345,11 +355,16 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
345
355
|
// requested scope actually depends on a vault — non-vault flows (e.g.
|
|
346
356
|
// `scribe:transcribe` only) keep Approve enabled so the user can still
|
|
347
357
|
// proceed despite the informational banner.
|
|
358
|
+
// Zero-vault non-admin requesting a vault scope (hub#431): the POST handler
|
|
359
|
+
// refuses with 400 "No vaults assigned", so render Approve disabled rather
|
|
360
|
+
// than leading the user to click into an error page.
|
|
361
|
+
const cannotAuthorize = userCanAuthorizeRequest === false;
|
|
348
362
|
const approveDisabled =
|
|
349
363
|
(vaultPicker &&
|
|
350
364
|
vaultPicker.lockedVault === undefined &&
|
|
351
365
|
vaultPicker.availableVaults.length === 0) ||
|
|
352
|
-
blockApproveForStaleAssignment === true
|
|
366
|
+
blockApproveForStaleAssignment === true ||
|
|
367
|
+
cannotAuthorize
|
|
353
368
|
? " disabled"
|
|
354
369
|
: "";
|
|
355
370
|
// Banner copy (hub#284). Worded to the *user* — "ask the admin" framing
|
|
@@ -366,6 +381,16 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
366
381
|
again.
|
|
367
382
|
</p>`
|
|
368
383
|
: "";
|
|
384
|
+
// No-vaults-assigned banner (hub#431). Shown when a non-admin with zero
|
|
385
|
+
// assigned vaults requests a vault scope — Approve is disabled above, this
|
|
386
|
+
// explains why and points at admin remediation.
|
|
387
|
+
const noVaultsBanner = cannotAuthorize
|
|
388
|
+
? `<p class="stale-assignment-banner" role="alert">
|
|
389
|
+
<strong>You have no assigned vaults.</strong>
|
|
390
|
+
Ask the hub admin to assign you a vault via <code>/admin/users</code>
|
|
391
|
+
before authorizing vault access.
|
|
392
|
+
</p>`
|
|
393
|
+
: "";
|
|
369
394
|
const body = `
|
|
370
395
|
<div class="card">
|
|
371
396
|
<div class="card-header">
|
|
@@ -383,6 +408,7 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
383
408
|
</p>
|
|
384
409
|
</div>
|
|
385
410
|
${staleBanner}
|
|
411
|
+
${noVaultsBanner}
|
|
386
412
|
<section class="scopes">
|
|
387
413
|
<h2 class="scopes-title">Permissions requested</h2>
|
|
388
414
|
<ul class="scope-list">${scopeRows}</ul>
|