@openparachute/hub 0.7.4-rc.12 → 0.7.4-rc.14

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.12",
3
+ "version": "0.7.4-rc.14",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -527,7 +527,13 @@ describe("admin-lock management API", () => {
527
527
  const r2 = await handleAdminLock(lockReq("/heartbeat", cookie, {}), "/heartbeat", {
528
528
  db: harness.db,
529
529
  });
530
- expect(((await r2.json()) as { locked: boolean }).locked).toBe(false);
530
+ const body2 = (await r2.json()) as { locked: boolean; idle_seconds?: number };
531
+ expect(body2.locked).toBe(false);
532
+ // The heartbeat MUST carry idle_seconds — the client re-anchors its local
533
+ // idle timer from it on every heartbeat. Omitting it poisoned the timer
534
+ // (undefined → NaN → instant re-lock). Regression guard for the PIN
535
+ // re-prompt loop.
536
+ expect(body2.idle_seconds).toBe(getIdleSeconds(harness.db));
531
537
  });
532
538
 
533
539
  test("unlock brute-force limiter: 6th attempt is 429", async () => {
@@ -940,27 +940,70 @@ describe("DELETE /vaults/<name> — gates", () => {
940
940
  }
941
941
  });
942
942
 
943
- test("409 last_vault on the only remaining vault (CLI never runs)", async () => {
943
+ test("#678: deleting the LAST vault cascades + deletes (no 409), with a last_vault warning", async () => {
944
944
  const h = makeHarness();
945
945
  try {
946
946
  const db = openHubDb(hubDbPath(h.dir));
947
947
  try {
948
948
  rotateSigningKey(db);
949
- writeVaults(h.manifestPath, ["default"]);
949
+ // Only one vault on the hub — the previously-refused last-vault case.
950
+ writeVaults(h.manifestPath, ["solo"]);
951
+
952
+ // Seed identity artifacts naming the soon-to-be-deleted last vault.
953
+ registryRow(db, "jti-solo", ["vault:solo:write"]);
954
+ const carol = await createUser(db, "carol", "carol-passphrase-123");
955
+ const client = registerClient(db, { redirectUris: ["https://d.example/cb"] }).client
956
+ .clientId;
957
+ recordGrant(db, carol.id, client, ["vault:solo:admin"]);
958
+ setUserVaults(db, carol.id, ["solo"]);
959
+
950
960
  const runner = stubRun();
951
961
  const res = await callDelete({
952
- name: "default",
962
+ name: "solo",
953
963
  db,
954
964
  manifestPath: h.manifestPath,
955
965
  connectionsStorePath: join(h.dir, "connections.json"),
956
966
  runCommand: runner.run,
957
967
  });
958
- expect(res.status).toBe(409);
959
- const out = (await res.json()) as { error: string; error_description: string };
960
- expect(out.error).toBe("last_vault");
961
- // Guidance names the CLI escape hatch + the resurrection reason.
962
- expect(out.error_description).toContain("parachute-vault remove");
963
- expect(runner.calls.length).toBe(0);
968
+
969
+ // No 409 the delete completes.
970
+ expect(res.status).toBe(200);
971
+ const out = (await res.json()) as {
972
+ ok: boolean;
973
+ cascade: {
974
+ tokens_revoked: number;
975
+ grants_dropped: number;
976
+ user_vaults_removed: number;
977
+ vault_removed: boolean;
978
+ };
979
+ warnings?: { step: string; detail: string }[];
980
+ };
981
+ expect(out.ok).toBe(true);
982
+
983
+ // The cascade ran for the last vault: tokens/grants/assignments gone.
984
+ expect(out.cascade.tokens_revoked).toBe(1);
985
+ expect(findTokenRowByJti(db, "jti-solo")?.revokedAt).not.toBeNull();
986
+ expect(out.cascade.grants_dropped).toBe(1);
987
+ expect(findGrant(db, carol.id, client)).toBeNull();
988
+ expect(out.cascade.user_vaults_removed).toBe(1);
989
+ expect(
990
+ db
991
+ .query<{ vault_name: string }, [string]>(
992
+ "SELECT vault_name FROM user_vaults WHERE user_id = ?",
993
+ )
994
+ .all(carol.id),
995
+ ).toEqual([]);
996
+
997
+ // The underlying vault remove ran (the cascade no longer skips it).
998
+ expect(runner.calls).toContainEqual(["parachute-vault", "remove", "solo", "--yes"]);
999
+ expect(out.cascade.vault_removed).toBe(true);
1000
+
1001
+ // A last_vault heads-up warning is surfaced (name-agnostic — does not
1002
+ // assume "default"); the auto_create:false marker prevents resurrection.
1003
+ const lastVaultWarning = out.warnings?.find((w) => w.step === "last_vault");
1004
+ expect(lastVaultWarning).toBeDefined();
1005
+ expect(lastVaultWarning?.detail).toContain("auto_create: false");
1006
+ expect(lastVaultWarning?.detail).not.toContain('"default"');
964
1007
  } finally {
965
1008
  db.close();
966
1009
  }
@@ -83,6 +83,7 @@ const SUCCESS_BODY = {
83
83
  grants_dropped: 2,
84
84
  user_vaults_removed: 4,
85
85
  invites_invalidated: 1,
86
+ vault_cap_removed: true,
86
87
  connections_torn_down: 1,
87
88
  orphaned_channels: [],
88
89
  vault_removed: true,
@@ -156,6 +157,7 @@ describe("vaultRemove — 200 success", () => {
156
157
  expect(text).toContain("3");
157
158
  expect(text).toContain("user_vaults removed:");
158
159
  expect(text).toContain("4");
160
+ expect(text).toContain("storage cap removed:");
159
161
  expect(text).toContain("vault removed:");
160
162
  });
161
163
 
@@ -194,19 +196,35 @@ describe("vaultRemove — 200 success", () => {
194
196
  });
195
197
  });
196
198
 
197
- describe("vaultRemove — 409 last_vault GUARDRAIL", () => {
198
- test("returns NON-ZERO and NEVER spawns parachute-vault", async () => {
199
+ describe("vaultRemove — last vault (#678: cascade-then-delete, no 409)", () => {
200
+ test("the last vault deletes via the cascade (200) and NEVER spawns parachute-vault directly", async () => {
199
201
  await withSpawnSpy(async (spawned) => {
200
- const { fetch, calls } = fakeFetch([
201
- {
202
- status: 409,
203
- body: {
204
- error: "last_vault",
205
- error_description:
206
- '"scratch" is the last vault on this hub. Create another vault first, or use the CLI.',
207
- },
202
+ // The endpoint no longer refuses the last vault — it returns 200 with the
203
+ // cascade summary, identical to any other delete. The CLI just renders it.
204
+ const lastVaultBody = {
205
+ ok: true,
206
+ name: "scratch",
207
+ cascade: {
208
+ tokens_revoked: 2,
209
+ grants_rewritten: 0,
210
+ grants_dropped: 1,
211
+ user_vaults_removed: 1,
212
+ invites_invalidated: 0,
213
+ vault_cap_removed: true,
214
+ connections_torn_down: 0,
215
+ orphaned_channels: [],
216
+ vault_removed: true,
217
+ module_restarted: true,
208
218
  },
209
- ]);
219
+ warnings: [
220
+ {
221
+ step: "last_vault",
222
+ detail:
223
+ "the deleted vault was the last one on this hub — no vaults remain. The vault CLI wrote auto_create: false, so boot won't recreate a default vault. Create one with: parachute-vault create <name>",
224
+ },
225
+ ],
226
+ };
227
+ const { fetch, calls } = fakeFetch([{ status: 200, body: lastVaultBody }]);
210
228
  const sinks = makeSinks();
211
229
  const code = await vaultRemove(["scratch"], {
212
230
  resolveBearer: async () => BEARER,
@@ -214,17 +232,20 @@ describe("vaultRemove — 409 last_vault GUARDRAIL", () => {
214
232
  log: sinks.log,
215
233
  logError: sinks.logError,
216
234
  });
217
- // Non-zero exit.
218
- expect(code).not.toBe(0);
219
- // Exactly ONE fetch (the DELETE) — no fall-through retry path.
235
+ // 200 → success exit; the cascade did its work.
236
+ expect(code).toBe(0);
237
+ // Exactly ONE fetch (the DELETE) — the cascade runs server-side over loopback.
220
238
  expect(calls).toHaveLength(1);
221
239
  expect(calls[0]?.method).toBe("DELETE");
222
- // The load-bearing invariant: no `parachute-vault` spawn.
240
+ // The load-bearing invariant still holds: the CLI never spawns
241
+ // `parachute-vault` itself — destruction goes through the hub endpoint.
223
242
  expect(spawned.count).toBe(0);
224
- // Surfaces the endpoint message + the cascade-skip warning on the escape hatch.
225
- const errText = sinks.errText();
226
- expect(errText).toContain("last vault");
227
- expect(errText).toContain("SKIPS the identity cascade");
243
+ // The cascade summary renders, including the last_vault heads-up warning.
244
+ const text = sinks.text();
245
+ expect(text).toContain("tokens revoked:");
246
+ expect(text).toContain("vault removed:");
247
+ expect(text).toContain("last_vault");
248
+ expect(text).toContain("auto_create: false");
228
249
  });
229
250
  });
230
251
  });
@@ -630,15 +630,22 @@ export function listVaultInstanceNames(manifestPath: string): Set<string> {
630
630
  *
631
631
  * Refusals:
632
632
  * - unknown vault → 404;
633
- * - LAST remaining vault → 409. Vault's boot auto-creates `default` at
634
- * zero vaults, so deleting the last one would silently resurrect a fresh
635
- * `default` (with a fresh global API key) — refusing sidesteps the
636
- * resurrection class entirely. The CLI (`parachute-vault remove`) is the
637
- * escape hatch for an operator who really means it.
638
633
  * - RESERVED names are deliberately ALLOWED (no reserved-name gate): a
639
634
  * squatted `admin`/`new`/`assets` vault created before the B2h
640
635
  * reservation must be removable through this endpoint.
641
636
  *
637
+ * Last-vault handling (#678): deleting the LAST remaining vault runs the SAME
638
+ * cascade-then-delete as any other vault — it is NOT refused. The old 409 that
639
+ * steered the operator to the raw `parachute-vault remove` CLI was a
640
+ * correctness defect: that escape hatch SKIPS this cascade, orphaning tokens +
641
+ * grants that named the last vault. The resurrection risk the refusal once
642
+ * guarded (vault boot auto-creating a fresh-credentialed first vault at zero
643
+ * vaults) is handled downstream instead: the vault CLI writes an
644
+ * `auto_create: false` marker on last-vault removal and the vault boot gate
645
+ * honors it, so the server won't silently resurrect. Detection stays
646
+ * count-based + name-agnostic (no `name === "default"` special case); the last
647
+ * vault just adds a `last_vault` warning to the 200 response.
648
+ *
642
649
  * Cascade, in order (identity first, mechanics last — revocation is the safe
643
650
  * direction if a later step fails):
644
651
  * 1. tokens-registry sweep (exact scope-segment match — never SQL LIKE);
@@ -661,6 +668,15 @@ export function listVaultInstanceNames(manifestPath: string): Set<string> {
661
668
  * Response: 200 with a structured per-step summary (counts +
662
669
  * `orphaned_channels` + warnings). A mechanics failure responds 500 with
663
670
  * the partial summary — the identity artifacts already revoked stay revoked.
671
+ *
672
+ * Bounded residual: the cascade revokes every *registered* token row naming
673
+ * the vault, but an UNREGISTERED interactive-mint (a host-admin browser
674
+ * session minting a short-lived vault token at ≤10-min TTL — see
675
+ * REGISTERED_MINT_TTL_THRESHOLD_SECONDS in admin-connections.ts) that was
676
+ * issued just before the delete leaves no registry row to sweep. Such a token
677
+ * stays valid for at most its remaining ≤10-min TTL, against a vault whose
678
+ * daemon is evicted in step 7 anyway. Same bound the auth-codes note below
679
+ * relies on; not eliminated, just bounded.
664
680
  */
665
681
  export async function handleDeleteVault(
666
682
  req: Request,
@@ -714,16 +730,20 @@ export async function handleDeleteVault(
714
730
  return jsonError(404, "not_found", `no vault named "${name}" on this hub`);
715
731
  }
716
732
 
717
- // Last-vault refusal (resurrection guard).
733
+ // Last-vault detection (count-based, name-agnostic). Deleting the LAST
734
+ // vault used to refuse with 409 and steer the operator to the raw
735
+ // `parachute-vault remove` CLI — but that path SKIPS this whole identity
736
+ // cascade, orphaning tokens + grants that named the vault. The resurrection
737
+ // risk the refusal guarded against is already handled downstream: the vault
738
+ // CLI writes an `auto_create: false` marker when it removes the last vault
739
+ // (vault `cli.ts` cmdRemove) and the vault boot gate honors it
740
+ // (`bootAutoCreateAllowed` in vault `config.ts`), so the server won't
741
+ // silently resurrect a fresh-credentialed first vault. We therefore run the
742
+ // cascade-then-delete for the last vault exactly as for any other — the
743
+ // count is informational only (no refusal).
718
744
  const instanceNames = listVaultInstanceNames(manifestPath);
719
745
  instanceNames.delete(name);
720
- if (instanceNames.size === 0) {
721
- return jsonError(
722
- 409,
723
- "last_vault",
724
- `"${name}" is the last vault on this hub. Vault's boot auto-creates "default" at zero vaults, so deleting the last one would silently resurrect it with fresh credentials. Create another vault first, or use the CLI (parachute-vault remove ${name} --yes) if you really mean to empty the hub.`,
725
- );
726
- }
746
+ const isLastVault = instanceNames.size === 0;
727
747
 
728
748
  const summary = emptyCascadeSummary();
729
749
  const warnings: { step: string; detail: string }[] = [];
@@ -865,6 +885,18 @@ export async function handleDeleteVault(
865
885
  );
866
886
  }
867
887
 
888
+ // Last-vault heads-up. The vault CLI's remove wrote `auto_create: false`, so
889
+ // the next vault boot won't resurrect a fresh-credentialed first vault — the
890
+ // hub is now deliberately empty. Surface that so the operator knows to create
891
+ // one when they want the hub serving again.
892
+ if (isLastVault) {
893
+ warnings.push({
894
+ step: "last_vault",
895
+ detail:
896
+ "the deleted vault was the last one on this hub — no vaults remain. The vault CLI wrote auto_create: false, so boot won't recreate a default vault. Create one with: parachute-vault create <name>",
897
+ });
898
+ }
899
+
868
900
  // --- 7. Daemon eviction: supervisor-restart the vault module. -------------
869
901
  if (deps.restartVaultModule) {
870
902
  try {
@@ -159,8 +159,15 @@ export async function handleAdminLock(
159
159
  case "/heartbeat":
160
160
  // Slide the idle window forward if (and only if) currently unlocked.
161
161
  refreshActivity(gate.sessionId, getIdleSeconds(db), now().getTime());
162
+ // `idle_seconds` is part of the response so the heartbeat fulfills the
163
+ // same `AdminLockStatus` shape as GET status — the client re-anchors its
164
+ // local idle timer from it on every heartbeat, so it MUST be present.
165
+ // Omitting it poisoned the client timer with `undefined` (→ NaN → instant
166
+ // re-lock), the bug this fixes. It also lets a live session pick up an
167
+ // idle-window change the operator made in Settings mid-session.
162
168
  return json(200, {
163
169
  locked: isLockConfigured(db) && !isSessionUnlocked(gate.sessionId, now().getTime()),
170
+ idle_seconds: getIdleSeconds(db),
164
171
  unlock_seconds_remaining: unlockSecondsRemaining(gate.sessionId, now().getTime()),
165
172
  });
166
173
  default:
@@ -31,14 +31,18 @@
31
31
  * `parachute:host:admin` — exactly the scope the endpoint gates on. This is the
32
32
  * same read-never-mint credential path `parachute start/stop/restart <svc>` use.
33
33
  *
34
- * ## The 409 guardrail (load-bearing)
34
+ * ## Last-vault handling (#678)
35
35
  *
36
- * On a `409 last_vault` the endpoint refuses (deleting the last vault would let
37
- * vault's boot silently resurrect a fresh `default`). We print the endpoint's
38
- * message + note the raw escape hatch (`parachute-vault remove <name> --yes`)
39
- * which SKIPS the cascade, then return NON-ZERO. We MUST NOT fall through to
40
- * spawning `parachute-vault` ourselves: a "helpful" fall-through would re-open
41
- * the exact orphaning bug B3 closes. The test locks this invariant.
36
+ * The last/only vault is deleted IDENTICALLY to any other vault: the endpoint
37
+ * runs the full cascade-then-delete and returns 200. There is no special-case
38
+ * here. (Older builds refused the last vault with a `409 last_vault` and steered
39
+ * the operator to the raw `parachute-vault remove --yes` but that escape hatch
40
+ * SKIPS the cascade, orphaning the very identity artifacts B3 set out to clean
41
+ * up. hub#678 removed that refusal: vault's boot can no longer silently
42
+ * resurrect a fresh first vault because vault's CLI writes an
43
+ * `auto_create: false` marker on last-vault removal and the boot gate honors
44
+ * it.) This command therefore needs no 409 branch — the 200 path renders the
45
+ * cascade summary for the last vault just like every other delete.
42
46
  */
43
47
 
44
48
  import { CONFIG_DIR } from "../config.ts";
@@ -56,8 +60,9 @@ import {
56
60
 
57
61
  /**
58
62
  * Injectable seams. Production wires the real operator-token bearer resolver +
59
- * the global `fetch`; tests inject fakes to assert the request shape + lock the
60
- * 409 guardrail without a live hub or a real socket.
63
+ * the global `fetch`; tests inject fakes to assert the request shape + that
64
+ * destruction always goes through the hub endpoint (never a direct
65
+ * `parachute-vault` spawn) without a live hub or a real socket.
61
66
  */
62
67
  export interface VaultRemoveDeps {
63
68
  /**
@@ -84,6 +89,7 @@ interface CascadeSummaryWire {
84
89
  grants_dropped?: number;
85
90
  user_vaults_removed?: number;
86
91
  invites_invalidated?: number;
92
+ vault_cap_removed?: boolean;
87
93
  connections_torn_down?: number;
88
94
  orphaned_channels?: unknown;
89
95
  vault_removed?: boolean;
@@ -151,6 +157,7 @@ function renderCascadeSummary(
151
157
  log(` grants dropped: ${n(c.grants_dropped)}`);
152
158
  log(` user_vaults removed: ${n(c.user_vaults_removed)}`);
153
159
  log(` invites invalidated: ${n(c.invites_invalidated)}`);
160
+ log(` storage cap removed: ${c.vault_cap_removed === true ? "yes" : "no"}`);
154
161
  log(` connections torn down: ${n(c.connections_torn_down)}`);
155
162
  log(` vault removed: ${c.vault_removed === true ? "yes" : "no"}`);
156
163
  log(` vault module restarted:${c.module_restarted === true ? " yes" : " no"}`);
@@ -302,21 +309,6 @@ export async function vaultRemove(args: string[], deps: VaultRemoveDeps = {}): P
302
309
  return 0;
303
310
  }
304
311
 
305
- if (res.status === 409 && error === "last_vault") {
306
- // CRITICAL GUARDRAIL: print + exit non-zero. Do NOT fall through to spawning
307
- // `parachute-vault` — that would re-open the orphaned-identity bug B3 closes.
308
- logError(`parachute vault remove: ${error_description}`);
309
- logError("");
310
- logError(
311
- `The raw mechanics-only path \`parachute-vault remove ${name} --yes\` can delete the last vault,`,
312
- );
313
- logError(
314
- "but it SKIPS the identity cascade — live tokens, grants, and user_vaults rows for that",
315
- );
316
- logError("vault would be left orphaned. Create another vault first if you can.");
317
- return 1;
318
- }
319
-
320
312
  if (res.status === 400 && error === "confirm_mismatch") {
321
313
  // Pass the hub's confirm message through.
322
314
  logError(`parachute vault remove: ${error_description}`);