@openparachute/vault 0.5.2-rc.3 → 0.5.2-rc.5

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.
@@ -216,6 +216,112 @@ export function readOperatorToken(env: NodeJS.ProcessEnv = process.env): string
216
216
  }
217
217
  }
218
218
 
219
+ // ---------------------------------------------------------------------------
220
+ // Hub-presence probe
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Default loopback port the hub binds. Mirrors `hub-jwt.ts`'s
225
+ * `DEFAULT_HUB_LOOPBACK` (`http://127.0.0.1:1939`). When no hub origin is
226
+ * configured (the common fresh-box case), this is where a co-located hub
227
+ * answers.
228
+ */
229
+ export const DEFAULT_HUB_LOOPBACK_PORT = 1939;
230
+
231
+ /**
232
+ * Best-effort: is a hub actually present on this host *right now*?
233
+ *
234
+ * This is distinct from {@link InstallContext.hubReachable}, which only asks
235
+ * "is a non-loopback hub *origin* configured?" (env / expose-state). On a fresh
236
+ * box the hub is installed and running on loopback, but no origin is configured
237
+ * and the operator token isn't minted yet (hub mints it only when the first
238
+ * admin user is created in the web wizard). The stale standalone-era copy
239
+ * ("install the hub …") fires off `operatorTokenPresent === false` and so
240
+ * misreads that fresh-box state as "no hub". This probe lets the copy branch on
241
+ * whether a hub is genuinely absent vs. merely not-yet-bootstrapped.
242
+ *
243
+ * Signals, cheapest-first:
244
+ * 1. A configured non-loopback hub origin (`PARACHUTE_HUB_ORIGIN` /
245
+ * expose-state) → a hub origin exists, treat as present without a probe.
246
+ * 2. A live `GET http://127.0.0.1:<hubPort>/health` returning a 2xx. The
247
+ * hub binds its own fixed loopback port (1939 by default), independent of
248
+ * the vault's listen port — so the probe always targets the hub port, not
249
+ * `chooseHubOrigin`'s vault-loopback fallback. Short timeout; any error →
250
+ * not present.
251
+ *
252
+ * `port` is the hub's loopback port (defaults to `$PARACHUTE_HUB_PORT`, else
253
+ * 1939). `fetchImpl` is an injectable test seam; `timeoutMs` keeps a dead port
254
+ * from stalling init. Never throws — returns `false` on any failure.
255
+ */
256
+ export async function detectHubPresence(opts: {
257
+ port?: number;
258
+ env?: { PARACHUTE_HUB_ORIGIN?: string | undefined; PARACHUTE_HUB_PORT?: string | undefined };
259
+ fetchImpl?: typeof fetch;
260
+ timeoutMs?: number;
261
+ } = {}): Promise<boolean> {
262
+ const env =
263
+ opts.env ?? (process.env as { PARACHUTE_HUB_ORIGIN?: string; PARACHUTE_HUB_PORT?: string });
264
+ // Port precedence: explicit arg → `$PARACHUTE_HUB_PORT` → 1939. The env
265
+ // override keeps the probe deterministic for tests + non-default-port hubs,
266
+ // so it never accidentally hits an unrelated hub on the host's 1939.
267
+ const envPort = env.PARACHUTE_HUB_PORT ? Number(env.PARACHUTE_HUB_PORT) : undefined;
268
+ const hubPort =
269
+ opts.port ?? (envPort !== undefined && Number.isFinite(envPort) ? envPort : DEFAULT_HUB_LOOPBACK_PORT);
270
+ // 1. A configured hub origin (env / expose-state) is itself a present-hub
271
+ // signal — no need to probe. We pass `hubPort` purely as the loopback
272
+ // fallback arg; its only role here is the source discriminator.
273
+ const configured = chooseHubOrigin(hubPort, env);
274
+ // A stale expose-state (or a leftover PARACHUTE_HUB_ORIGIN) can
275
+ // false-positive here. Originally this only selected guidance copy, but as
276
+ // of hub#580 it ALSO gates `vault init`'s daemon registration default
277
+ // (hub present → skip autostart). The false-positive failure mode is
278
+ // therefore: a genuinely hubless box with stale hub-origin state runs init
279
+ // without a flag and silently skips registering a daemon. Narrow + accepted
280
+ // — the operator can re-run with `--autostart`, and any explicit flag or a
281
+ // persisted `config.autostart` short-circuits the probe entirely. See the
282
+ // call site in cli.ts for the persisted-value guard.
283
+ if (configured.source !== "loopback") return true;
284
+
285
+ // 2. Live health probe against the hub's fixed loopback port.
286
+ const origin = `http://127.0.0.1:${hubPort}`;
287
+ const fetchImpl = opts.fetchImpl ?? fetch;
288
+ const timeoutMs = opts.timeoutMs ?? 800;
289
+ const controller = new AbortController();
290
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
291
+ try {
292
+ const res = await fetchImpl(`${origin}/health`, { signal: controller.signal });
293
+ return res.ok;
294
+ } catch {
295
+ return false;
296
+ } finally {
297
+ clearTimeout(timer);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Pick the operator-facing guidance for the "no operator.token present" case,
303
+ * branched on whether a hub is genuinely absent vs. merely not-yet-bootstrapped
304
+ * (#445). Extracted as a pure function so the copy is unit-testable without
305
+ * importing cli.ts (which dispatches on import).
306
+ *
307
+ * hubPresent === true → a hub is running; the operator token just hasn't
308
+ * been minted yet (it's minted when the first admin
309
+ * user is created in the hub's web wizard). The old
310
+ * "install the hub …" advice is circular here — this
311
+ * flow was spawned *by* the hub. Tell them there's
312
+ * nothing to do and to finish in the wizard.
313
+ * hubPresent === false → genuinely standalone. Keep the original advice.
314
+ */
315
+ export function noOperatorTokenGuidance(hubPresent: boolean): string {
316
+ return hubPresent
317
+ ? "No token yet — the hub's admin wizard mints the operator token when you " +
318
+ "create the first admin user. Nothing to do here; finish setup in the wizard, " +
319
+ "then run `parachute-vault mcp-install` if you want a header-auth token for scripts."
320
+ : "No token issued — no hub operator token at ~/.parachute/operator.token. " +
321
+ "Install the hub (`bun add -g @openparachute/hub` + `parachute init`) and re-run, " +
322
+ "or set VAULT_AUTH_TOKEN for an operator-channel bearer.";
323
+ }
324
+
219
325
  // ---------------------------------------------------------------------------
220
326
  // Hub mint-token client
221
327
  // ---------------------------------------------------------------------------
@@ -236,16 +236,23 @@ describe("vault create — OAuth-first auth (vault#442)", () => {
236
236
  test("--mint (no hub reachable) opts in but mints scope-narrow read, never admin", () => {
237
237
  // In this sandbox there's no hub/operator.token, so the mint can't complete
238
238
  // — but the request is scope-narrow read by default and must NEVER ask for
239
- // admin. We assert the create still succeeds and the guidance points at the
240
- // mint-token recovery (the scope requested is read, per mintBootstrapCredential).
239
+ // an admin grant. We assert the create still succeeds and the guidance is
240
+ // the standalone path (the scope requested is read, per
241
+ // mintBootstrapCredential).
242
+ //
243
+ // Point the hub-presence probe at a guaranteed-closed port so the test is
244
+ // deterministic regardless of whether a real hub happens to be running on
245
+ // the dev box's 1939 (#445 added a live `/health` probe to branch the
246
+ // no-operator-token copy).
241
247
  const { exitCode, stdout } = runCli(
242
248
  ["create", "wantmint", "--mint", "--json"],
243
- { PARACHUTE_HOME: home },
249
+ { PARACHUTE_HOME: home, PARACHUTE_HUB_PORT: "59399" },
244
250
  );
245
251
  expect(exitCode).toBe(0);
246
252
  const payload = JSON.parse(stdout.trim());
247
- // No hub here → no token, but the guidance is the standalone mint path, not
248
- // an admin grant.
253
+ // No hub here → no token, and the standalone guidance asks for NO admin
254
+ // grant (the #445 hub-present "admin wizard" copy is gated out by the dead
255
+ // probe port above).
249
256
  expect(payload.token).toBe("");
250
257
  expect(payload.token_guidance).not.toContain("admin");
251
258
  });