@openparachute/hub 0.6.5-rc.8 → 0.7.1

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 (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * `POST /vaults` — provision a new vault on the host.
3
+ * `DELETE /vaults/<name>` — destroy a vault WITH the identity cascade
4
+ * (B1, 2026-06-09 hub-module-boundary migration — see `handleDeleteVault`).
3
5
  *
4
6
  * The hub's first authenticated, mutating endpoint. Until now the hub has
5
7
  * been a pure issuer; Phase 1 of the vault-config-and-scopes design (D1)
@@ -57,30 +59,29 @@
57
59
  */
58
60
  import type { Database } from "bun:sqlite";
59
61
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
62
+ import { type ConnectionsDeps, teardownConnection } from "./admin-connections.ts";
60
63
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
64
+ import { readConnections } from "./connections-store.ts";
65
+ import { rewriteGrantsRemovingVault } from "./grants.ts";
66
+ import { revokeInvitesForVault } from "./invites.ts";
67
+ import { revokeTokensNamingVault, signAccessToken } from "./jwt-sign.ts";
61
68
  import { findService, type readManifest, readManifestLenient } from "./services-manifest.ts";
62
69
  import { enrichedPath } from "./spawn-path.ts";
63
- import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
70
+ import { removeVaultAssignments } from "./users.ts";
71
+ import { RESERVED_VAULT_NAMES, VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
64
72
  import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
65
73
 
66
74
  /** Scope required to call POST /vaults. */
67
75
  export const HOST_ADMIN_SCOPE = "parachute:host:admin";
68
76
 
69
- /**
70
- * Mirror parachute-vault's `cmdCreate` validation rules, plus hub-only
71
- * reservations for SPA-route shadowing. `list` matches the CLI; `new` and
72
- * `assets` would collide with `/vault/new` (the SPA's create-vault route)
73
- * and `/vault/assets/*` (the SPA's static asset bundle) respectively, so
74
- * the hub rejects them at the API edge before a vault under those names
75
- * can register and capture the proxy path.
76
- */
77
77
  // Lowercase-only (item I) — single source of truth in vault-name.ts. Vault's
78
78
  // init enforces `[a-z0-9_-]`; a hub-side `[a-zA-Z0-9_-]` superset let an
79
79
  // uppercased name through that vault would then lowercase or reject, drifting
80
- // the hub's idea of the vault from vault's. The hub-only reservations (`new`,
81
- // `assets`) shadow SPA routes and stay on top of vault's `list`.
80
+ // the hub's idea of the vault from vault's. The reserved set is the ONE
81
+ // consolidated `RESERVED_VAULT_NAMES` from vault-name.ts (B2h) this file
82
+ // used to carry its own `{list, new, assets}` copy that drifted from the
83
+ // `{list}`-only set gating the wizard + invite redemption.
82
84
  const VAULT_NAME_PATTERN = VAULT_NAME_CHARSET_RE;
83
- const RESERVED_VAULT_NAMES = new Set(["list", "new", "assets"]);
84
85
 
85
86
  export interface CreateVaultRequest {
86
87
  name: string;
@@ -504,3 +505,389 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
504
505
  headers: { "content-type": "application/json" },
505
506
  });
506
507
  }
508
+
509
+ // ===========================================================================
510
+ // DELETE /vaults/<name> — the identity cascade (B1, 2026-06-09
511
+ // hub-module-boundary migration: lifecycle symmetry)
512
+ // ===========================================================================
513
+ //
514
+ // "Every provision flow must have a deprovision flow that cascades the
515
+ // identity artifacts it created." Mechanics deletion without identity
516
+ // cascade is a security hole: before B1, vault deletion was CLI-only
517
+ // (`parachute-vault remove`) and left every hub-side identity artifact
518
+ // naming the vault alive — scoped tokens, grants, user assignments, pinned
519
+ // invites, connections. This handler enumerates the full artifact list and
520
+ // handles every entry.
521
+ //
522
+ // Documented bound: short-lived (≤10-min) unregistered interactive mints
523
+ // ride to expiry by design — the cascade revokes persisted registry rows
524
+ // and publishes the revocation list; it does not claim instant revocation
525
+ // of in-flight interactive JWTs.
526
+
527
+ /** Client id stamped on the short-lived channel-scan mint. */
528
+ const DELETE_VAULT_CLIENT_ID = "parachute-hub-spa";
529
+ /** TTL for the cascade's interactive provisioning mints (channel scan). */
530
+ const DELETE_VAULT_PROVISION_TTL_SECONDS = 60;
531
+
532
+ export interface DeleteVaultDeps {
533
+ db: Database;
534
+ /** Hub origin — JWT `iss` validation + cascade mint issuer. */
535
+ issuer: string;
536
+ /** Override the services.json path. Defaults to `~/.parachute/services.json`. */
537
+ manifestPath?: string;
538
+ /** Absolute path to `connections.json` in the hub state dir. */
539
+ connectionsStorePath: string;
540
+ /** Loopback origin for the channel daemon, or `null` when not installed. */
541
+ channelOrigin: string | null;
542
+ /** Resolve a vault's loopback origin from services.json (trigger teardown). */
543
+ resolveVaultOrigin: (vaultName: string) => string | null;
544
+ /**
545
+ * Resolve a module's loopback origin by short name (H4 — best-effort
546
+ * credential-removal notification during connection teardown). Optional:
547
+ * absent → the notification step records a warning, revocation still runs.
548
+ */
549
+ resolveModuleOrigin?: (short: string) => string | null;
550
+ /** Test seam: run `parachute-vault remove` — same Runner seam as create. */
551
+ runCommand?: CreateVaultDeps["runCommand"];
552
+ /**
553
+ * Supervisor-restart the vault module — the daemon-eviction cascade step.
554
+ * The running daemon caches open store handles, so rmSync alone leaves the
555
+ * deleted vault SERVING from the open fd; and vault's boot `selfRegister`
556
+ * rebuilds services.json paths from `listVaults()`, dropping the deleted
557
+ * path. Wired to the same supervisor machinery the lifecycle verbs use.
558
+ * Absent (no supervisor — CLI-mode hub, tests) → recorded as a warning in
559
+ * the response, not silently skipped. (The boundary-conformant per-daemon
560
+ * store-eviction endpoint is tracked as E9.)
561
+ */
562
+ restartVaultModule?: () => Promise<void>;
563
+ /** Test seam — `globalThis.fetch` in production. */
564
+ fetchImpl?: typeof fetch;
565
+ /** Test seam for the clock. */
566
+ now?: () => Date;
567
+ }
568
+
569
+ interface DeleteVaultBody {
570
+ confirm?: unknown;
571
+ }
572
+
573
+ /** Wire shape of the cascade summary in the 200/500 response. */
574
+ interface CascadeSummary {
575
+ tokens_revoked: number;
576
+ grants_rewritten: number;
577
+ grants_dropped: number;
578
+ user_vaults_removed: number;
579
+ invites_invalidated: number;
580
+ connections_torn_down: number;
581
+ /**
582
+ * Vault-backed channel-daemon entries still referencing the deleted vault
583
+ * AFTER connection teardown (legacy pre-Connections wiring). Surfaced for
584
+ * the operator — the hub does NOT silently delete channel's config; remove
585
+ * them from channel's own UI.
586
+ */
587
+ orphaned_channels: string[];
588
+ vault_removed: boolean;
589
+ module_restarted: boolean;
590
+ }
591
+
592
+ function emptyCascadeSummary(): CascadeSummary {
593
+ return {
594
+ tokens_revoked: 0,
595
+ grants_rewritten: 0,
596
+ grants_dropped: 0,
597
+ user_vaults_removed: 0,
598
+ invites_invalidated: 0,
599
+ connections_torn_down: 0,
600
+ orphaned_channels: [],
601
+ vault_removed: false,
602
+ module_restarted: false,
603
+ };
604
+ }
605
+
606
+ /** Every vault instance name currently registered in services.json. */
607
+ function listVaultInstanceNames(manifestPath: string): Set<string> {
608
+ const names = new Set<string>();
609
+ let manifest: ReturnType<typeof readManifest>;
610
+ try {
611
+ manifest = readManifestLenient(manifestPath);
612
+ } catch {
613
+ return names;
614
+ }
615
+ for (const svc of manifest.services) {
616
+ if (!isVaultEntry(svc)) continue;
617
+ if (svc.paths.length === 0) {
618
+ names.add(vaultInstanceNameFor(svc.name, undefined));
619
+ continue;
620
+ }
621
+ for (const path of svc.paths) names.add(vaultInstanceNameFor(svc.name, path));
622
+ }
623
+ return names;
624
+ }
625
+
626
+ /**
627
+ * `DELETE /vaults/<name>` — destroy a vault with the full identity cascade.
628
+ *
629
+ * Gate: Bearer `parachute:host:admin` (same gate as create). Body MUST carry
630
+ * `{"confirm": "<name>"}` — a deliberate retype-the-name guard against
631
+ * fat-finger disasters (mismatch → 400).
632
+ *
633
+ * Refusals:
634
+ * - unknown vault → 404;
635
+ * - LAST remaining vault → 409. Vault's boot auto-creates `default` at
636
+ * zero vaults, so deleting the last one would silently resurrect a fresh
637
+ * `default` (with a fresh global API key) — refusing sidesteps the
638
+ * resurrection class entirely. The CLI (`parachute-vault remove`) is the
639
+ * escape hatch for an operator who really means it.
640
+ * - RESERVED names are deliberately ALLOWED (no reserved-name gate): a
641
+ * squatted `admin`/`new`/`assets` vault created before the B2h
642
+ * reservation must be removable through this endpoint.
643
+ *
644
+ * Cascade, in order (identity first, mechanics last — revocation is the safe
645
+ * direction if a later step fails):
646
+ * 1. tokens-registry sweep (exact scope-segment match — never SQL LIKE);
647
+ * 2. grants rewrite (drop `vault:<name>:*` entries; drop the row only when
648
+ * it empties — a (user,client) grant can span multiple vaults);
649
+ * 3. `user_vaults` rows;
650
+ * 4. unredeemed invites pinned to the vault (redemption would resurrect
651
+ * the name);
652
+ * 5. connections whose source/provisioned vault is the deleted vault
653
+ * (via `teardownConnection`, which post-B0 also revokes the registered
654
+ * long-lived mints) + a report-only scan of channel's `/api/channels`
655
+ * for legacy vault-backed entries (`orphaned_channels`);
656
+ * 6. mechanics: shell to `parachute-vault remove <name> --yes` (the module
657
+ * CLI stays the single source of truth for vault destruction,
658
+ * mirroring create);
659
+ * 7. daemon eviction: supervisor-restart the vault module (open
660
+ * store-handle eviction + `selfRegister` services.json path rebuild).
661
+ *
662
+ * Response: 200 with a structured per-step summary (counts +
663
+ * `orphaned_channels` + warnings). A mechanics failure responds 500 with
664
+ * the partial summary — the identity artifacts already revoked stay revoked.
665
+ */
666
+ export async function handleDeleteVault(
667
+ req: Request,
668
+ rawName: string,
669
+ deps: DeleteVaultDeps,
670
+ ): Promise<Response> {
671
+ if (req.method !== "DELETE") {
672
+ return new Response("method not allowed", { status: 405 });
673
+ }
674
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
675
+ const runCommand = deps.runCommand ?? defaultRunCommand;
676
+ const now = deps.now?.() ?? new Date();
677
+
678
+ // Auth gate: parachute:host:admin — the same gate as POST /vaults.
679
+ let adminSub: string;
680
+ try {
681
+ const auth = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
682
+ adminSub = auth.sub;
683
+ } catch (err) {
684
+ return adminAuthErrorResponse(err as AdminAuthError);
685
+ }
686
+
687
+ // Name shape guard. NOTE: no reserved-name check — see docstring.
688
+ const name = rawName.trim();
689
+ if (!VAULT_NAME_PATTERN.test(name)) {
690
+ return jsonError(
691
+ 400,
692
+ "invalid_request",
693
+ "vault name must contain only lowercase letters, numbers, hyphens, and underscores",
694
+ );
695
+ }
696
+
697
+ // Confirm body — the retype-the-name guard.
698
+ let body: DeleteVaultBody;
699
+ try {
700
+ body = (await req.json()) as DeleteVaultBody;
701
+ } catch {
702
+ return jsonError(400, "confirm_mismatch", `body must be JSON: {"confirm": "${name}"}`);
703
+ }
704
+ if (!body || typeof body !== "object" || body.confirm !== name) {
705
+ return jsonError(
706
+ 400,
707
+ "confirm_mismatch",
708
+ `deleting a vault requires the body {"confirm": "${name}"} (retype the vault name)`,
709
+ );
710
+ }
711
+
712
+ // Existence.
713
+ const existing = findExistingVault(manifestPath, name);
714
+ if (!existing) {
715
+ return jsonError(404, "not_found", `no vault named "${name}" on this hub`);
716
+ }
717
+
718
+ // Last-vault refusal (resurrection guard).
719
+ const instanceNames = listVaultInstanceNames(manifestPath);
720
+ instanceNames.delete(name);
721
+ if (instanceNames.size === 0) {
722
+ return jsonError(
723
+ 409,
724
+ "last_vault",
725
+ `"${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.`,
726
+ );
727
+ }
728
+
729
+ const summary = emptyCascadeSummary();
730
+ const warnings: { step: string; detail: string }[] = [];
731
+
732
+ // --- 1. Registry sweep: revoke every tokens row naming the vault. --------
733
+ summary.tokens_revoked = revokeTokensNamingVault(deps.db, name, now);
734
+
735
+ // NOTE on `auth_codes.scopes` — the one identity-artifact column from the
736
+ // charter's enumeration deliberately NOT swept here. Authorization codes
737
+ // are transient by construction: AUTH_CODE_TTL_SECONDS = 60 (auth-codes.ts)
738
+ // and single-use. A code naming the deleted vault either (a) expires
739
+ // unredeemed within the minute, or (b) redeems into tokens whose registry
740
+ // rows the sweep above already governs — and whose requests the evicted
741
+ // daemon no longer serves. Sweeping a 60-second-lived table adds a step
742
+ // with no security delta; same class as the documented ≤10-min
743
+ // unregistered interactive-mint bound.
744
+
745
+ // --- 2. Grants rewrite (drop rows only when emptied). ---------------------
746
+ const grants = rewriteGrantsRemovingVault(deps.db, name);
747
+ summary.grants_rewritten = grants.rewritten;
748
+ summary.grants_dropped = grants.dropped;
749
+
750
+ // --- 3. user_vaults assignments. ------------------------------------------
751
+ summary.user_vaults_removed = removeVaultAssignments(deps.db, name);
752
+
753
+ // --- 4. Unredeemed invites pinned to the vault. ----------------------------
754
+ summary.invites_invalidated = revokeInvitesForVault(deps.db, name, now);
755
+
756
+ // --- 5. Connections teardown (+ legacy channel scan, report-only). --------
757
+ // Runs BEFORE the CLI remove so the vault daemon is still alive to accept
758
+ // the trigger-deregister calls.
759
+ const connectionsDeps: ConnectionsDeps = {
760
+ db: deps.db,
761
+ hubOrigin: deps.issuer,
762
+ modules: [], // teardown never consults the catalog
763
+ resolveVaultOrigin: deps.resolveVaultOrigin,
764
+ ...(deps.resolveModuleOrigin !== undefined
765
+ ? { resolveModuleOrigin: deps.resolveModuleOrigin }
766
+ : {}),
767
+ channelOrigin: deps.channelOrigin,
768
+ storePath: deps.connectionsStorePath,
769
+ ...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
770
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
771
+ };
772
+ const records = readConnections(deps.connectionsStorePath).filter(
773
+ (r) => r.source.vault === name || r.provisioned?.vault === name,
774
+ );
775
+ for (const record of records) {
776
+ try {
777
+ const res = await teardownConnection(record.id, adminSub, connectionsDeps);
778
+ if (res.status === 200) {
779
+ summary.connections_torn_down++;
780
+ } else {
781
+ // 207 partial — the record is removed + mints revoked; remote steps
782
+ // failed. Surface as warnings, count as torn down (the identity side
783
+ // is done; the operator can clean the remote residue).
784
+ summary.connections_torn_down++;
785
+ const out = (await res.json()) as { errors?: { step: string; detail: string }[] };
786
+ for (const e of out.errors ?? []) {
787
+ warnings.push({ step: `connection_${record.id}_${e.step}`, detail: e.detail });
788
+ }
789
+ }
790
+ } catch (err) {
791
+ warnings.push({
792
+ step: `connection_${record.id}`,
793
+ detail: err instanceof Error ? err.message : String(err),
794
+ });
795
+ }
796
+ }
797
+
798
+ // Legacy channel scan — vault-backed channel entries still referencing the
799
+ // vault after connection teardown (pre-Connections wiring). REPORT ONLY:
800
+ // channel's config is the channel module's domain; the operator removes
801
+ // them from channel's own UI.
802
+ if (deps.channelOrigin !== null) {
803
+ try {
804
+ const fetchImpl = deps.fetchImpl ?? fetch;
805
+ // Short-lived (60s) — stays below the registered-mint threshold by
806
+ // design (the documented ≤10-min interactive bound; see B0's
807
+ // REGISTERED_MINT_TTL_THRESHOLD_SECONDS in admin-connections.ts).
808
+ const scanToken = (
809
+ await signAccessToken(deps.db, {
810
+ sub: adminSub,
811
+ scopes: ["channel:admin"],
812
+ audience: "channel",
813
+ clientId: DELETE_VAULT_CLIENT_ID,
814
+ issuer: deps.issuer,
815
+ ttlSeconds: DELETE_VAULT_PROVISION_TTL_SECONDS,
816
+ vaultScope: [],
817
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
818
+ })
819
+ ).token;
820
+ const res = await fetchImpl(`${deps.channelOrigin}/api/channels`, {
821
+ headers: { authorization: `Bearer ${scanToken}`, accept: "application/json" },
822
+ });
823
+ if (res.ok) {
824
+ const listed = (await res.json()) as { channels?: unknown };
825
+ const rawList = Array.isArray(listed?.channels) ? listed.channels : [];
826
+ for (const c of rawList) {
827
+ const row = (c ?? {}) as Record<string, unknown>;
828
+ if (row.transport === "vault" && row.vault === name && typeof row.name === "string") {
829
+ summary.orphaned_channels.push(row.name);
830
+ }
831
+ }
832
+ } else {
833
+ warnings.push({ step: "channel_scan", detail: `channel list returned ${res.status}` });
834
+ }
835
+ } catch (err) {
836
+ warnings.push({
837
+ step: "channel_scan",
838
+ detail: err instanceof Error ? err.message : String(err),
839
+ });
840
+ }
841
+ }
842
+
843
+ // --- 6. Mechanics: the module CLI is the source of truth for destruction. -
844
+ try {
845
+ const result = await runCommand(["parachute-vault", "remove", name, "--yes"]);
846
+ if (result.exitCode !== 0) {
847
+ const stderrTail = result.stderr.trim();
848
+ const tailSuffix = stderrTail ? `: ${stderrTail.slice(-500)}` : "";
849
+ return jsonError(
850
+ 500,
851
+ "server_error",
852
+ `parachute-vault remove ${name} --yes exited with code ${result.exitCode}${tailSuffix} — identity artifacts already revoked (summary: ${JSON.stringify(summary)})`,
853
+ );
854
+ }
855
+ summary.vault_removed = true;
856
+ } catch (err) {
857
+ const msg = err instanceof Error ? err.message : String(err);
858
+ return jsonError(
859
+ 500,
860
+ "server_error",
861
+ `orchestration failed: ${msg} — identity artifacts already revoked (summary: ${JSON.stringify(summary)})`,
862
+ );
863
+ }
864
+
865
+ // --- 7. Daemon eviction: supervisor-restart the vault module. -------------
866
+ if (deps.restartVaultModule) {
867
+ try {
868
+ await deps.restartVaultModule();
869
+ summary.module_restarted = true;
870
+ } catch (err) {
871
+ warnings.push({
872
+ step: "module_restart",
873
+ detail: `vault module restart failed: ${err instanceof Error ? err.message : String(err)} — the running daemon may still serve the deleted vault from its open store handle until restarted (parachute restart vault)`,
874
+ });
875
+ }
876
+ } else {
877
+ warnings.push({
878
+ step: "module_restart",
879
+ detail:
880
+ "no supervisor available — restart the vault module (parachute restart vault) to evict the deleted vault's open store handle and refresh services.json",
881
+ });
882
+ }
883
+
884
+ return new Response(
885
+ JSON.stringify({
886
+ ok: true,
887
+ name,
888
+ cascade: summary,
889
+ ...(warnings.length > 0 ? { warnings } : {}),
890
+ }),
891
+ { status: 200, headers: { "content-type": "application/json" } },
892
+ );
893
+ }
@@ -4,9 +4,10 @@
4
4
  *
5
5
  * ── WHY A DEDICATED ENDPOINT (not /api/modules/hub/*) ──────────────────────
6
6
  *
7
- * The hub is NOT a supervised module — `CURATED_MODULES` rejects `hub`, so
8
- * `parseModulesPath("/api/modules/hub/upgrade")` returns undefined and the
9
- * module-ops switch never reaches a hub case. The hub needs its OWN endpoint
7
+ * The hub is NOT a supervised module — `parseModulesPath` rejects `hub`
8
+ * (`isKnownModuleShort("hub")` is false: hub isn't in KNOWN_MODULES /
9
+ * FIRST_PARTY_FALLBACKS), so `parseModulesPath("/api/modules/hub/upgrade")`
10
+ * returns undefined and the module-ops switch never reaches a hub case. The hub needs its OWN endpoint
10
11
  * because the constraint is unique: the hub can't restart itself synchronously
11
12
  * (the request dies with the old process before it can report success). So:
12
13
  *
@@ -19,6 +19,7 @@
19
19
  import type { Database } from "bun:sqlite";
20
20
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
21
21
  import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
22
+ import { SERVICES_MANIFEST_PATH } from "./config.ts";
22
23
  import {
23
24
  DEFAULT_INVITE_TTL_SECONDS,
24
25
  type Invite,
@@ -28,8 +29,11 @@ import {
28
29
  issueInvite,
29
30
  listInvites,
30
31
  revokeInvite,
32
+ usernameReservedByPendingInvite,
31
33
  } from "./invites.ts";
34
+ import { getUserByUsernameCI, validateUsername } from "./users.ts";
32
35
  import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
36
+ import { listVaultNamesFromPath } from "./vault-names.ts";
33
37
 
34
38
  export interface ApiInvitesDeps {
35
39
  db: Database;
@@ -49,6 +53,7 @@ interface InviteWireShape {
49
53
  id: string;
50
54
  status: InviteStatus;
51
55
  vault_name: string | null;
56
+ username: string | null;
52
57
  role: string;
53
58
  provision_vault: boolean;
54
59
  default_mirror: string | null;
@@ -64,6 +69,7 @@ function toWire(invite: Invite, status: InviteStatus): InviteWireShape {
64
69
  id: invite.tokenHash,
65
70
  status,
66
71
  vault_name: invite.vaultName,
72
+ username: invite.username,
67
73
  role: invite.role,
68
74
  provision_vault: invite.provisionVault,
69
75
  default_mirror: invite.defaultMirror,
@@ -89,6 +95,7 @@ function redeemUrl(issuer: string, rawToken: string): string {
89
95
 
90
96
  interface CreateInviteBody {
91
97
  vaultName: string | null;
98
+ username: string | null;
92
99
  role: string;
93
100
  provisionVault: boolean;
94
101
  defaultMirror: string | null;
@@ -162,6 +169,37 @@ async function parseCreateBody(
162
169
  vaultName = rawVault;
163
170
  }
164
171
 
172
+ // username — optional. null/omitted = redeemer picks their own. Validated
173
+ // with the SAME vocabulary as /api/users (charset + length + reserved).
174
+ let username: string | null = null;
175
+ const rawUsername =
176
+ Object.hasOwn(obj, "username") && obj.username !== undefined ? obj.username : undefined;
177
+ if (rawUsername !== undefined && rawUsername !== null) {
178
+ if (typeof rawUsername !== "string" || rawUsername.length === 0) {
179
+ return {
180
+ ok: false,
181
+ status: 400,
182
+ error: "invalid_request",
183
+ description: '"username" must be a non-empty string or null',
184
+ };
185
+ }
186
+ const u = validateUsername(rawUsername);
187
+ if (!u.valid) {
188
+ return {
189
+ ok: false,
190
+ status: 400,
191
+ error: "invalid_username",
192
+ description:
193
+ u.reason === "length"
194
+ ? "username must be 2-32 characters long"
195
+ : u.reason === "reserved"
196
+ ? "username is reserved (admin, root, system, setup, parachute, hub)"
197
+ : "username must contain only lowercase letters, digits, hyphens, and underscores ([a-z0-9_-])",
198
+ };
199
+ }
200
+ username = u.name;
201
+ }
202
+
165
203
  // role — default 'write' (owner).
166
204
  let role = "write";
167
205
  const rawRole = obj.role;
@@ -245,7 +283,10 @@ async function parseCreateBody(
245
283
  expiresInSeconds = Math.floor(rawExpiry);
246
284
  }
247
285
 
248
- return { ok: true, body: { vaultName, role, provisionVault, defaultMirror, expiresInSeconds } };
286
+ return {
287
+ ok: true,
288
+ body: { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds },
289
+ };
249
290
  }
250
291
 
251
292
  /** POST /api/invites — create an invite, return the single-emit URL + token. */
@@ -262,33 +303,72 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
262
303
  }
263
304
  const parsed = await parseCreateBody(req);
264
305
  if (!parsed.ok) return jsonError(parsed.status, parsed.error, parsed.description);
265
- const { vaultName, role, provisionVault, defaultMirror, expiresInSeconds } = parsed.body;
306
+ const { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds } =
307
+ parsed.body;
308
+ const now = (deps.now ?? (() => new Date()))();
266
309
 
267
- // SECURITY: a pinned vault_name with provision_vault=false would assign the
268
- // redeeming user to a PRE-EXISTING vault as owner-admin — a cross-tenant
269
- // breach, since the owner-vs-shared role split isn't built. Shared-vault
270
- // invites aren't supported yet, so reject this combination outright (defense
271
- // in depth the redeem path rejects it too). The supported shapes are:
272
- // provision_vault=true (+ optional pinned name → provisions THAT name), or
273
- // provision_vault=false with NO name (account-only, assignedVaults=[]).
274
- if (vaultName !== null && !provisionVault) {
310
+ // Shape gates over the three supported invite shapes:
311
+ //
312
+ // 1. provision_vault=true (+ optional pinned NEW name) redemption
313
+ // provisions a fresh vault for the redeemer. Role must be 'write':
314
+ // the redeemer is the vault's ONLY user, so a read-only sole user
315
+ // would leave the new vault permanently un-writable.
316
+ // 2. provision_vault=false + vault_name SHARED-VAULT invite: redemption
317
+ // assigns the redeemer to the admin's EXISTING vault at `role` ('read'
318
+ // or 'write'). The vault must exist NOW (services.json) — pinning a
319
+ // nonexistent name is a typo, not a future reservation. Issuing is
320
+ // host:admin-gated, the same authority that can already assign any
321
+ // user to any vault via POST /api/users — the invite only packages
322
+ // that assignment as a deliverable link. The vault-delete cascade
323
+ // (`revokeInvitesForVault`) revokes pending shared invites when the
324
+ // pinned vault is deleted, and the redeem path re-checks existence.
325
+ // 3. provision_vault=false with NO name — account-only (assignedVaults=[]).
326
+ if (provisionVault && role !== "write") {
275
327
  return jsonError(
276
328
  400,
277
329
  "invalid_request",
278
- "shared-vault invites (provision_vault=false with a vault_name) aren't supported yet omit vault_name for an account-only invite, or set provision_vault=true to provision a new vault",
330
+ 'a provisioned vault\'s sole user must hold write use role "write", or share an existing vault (provision_vault=false + vault_name) for read-only access',
279
331
  );
280
332
  }
333
+ if (vaultName !== null && !provisionVault) {
334
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
335
+ const known = new Set(listVaultNamesFromPath(manifestPath));
336
+ if (!known.has(vaultName)) {
337
+ return jsonError(
338
+ 400,
339
+ "vault_not_found",
340
+ `vault "${vaultName}" is not registered on this hub — shared-vault invites must name an existing vault`,
341
+ );
342
+ }
343
+ }
344
+
345
+ // Pre-named username: catch collisions at MINT time (the redeem-time check
346
+ // stays authoritative, but an enforced name that's already taken makes the
347
+ // link dead-on-arrival — fail fast for the admin instead).
348
+ if (username !== null) {
349
+ if (getUserByUsernameCI(deps.db, username) !== null) {
350
+ return jsonError(409, "username_taken", `username "${username}" is already in use`);
351
+ }
352
+ if (usernameReservedByPendingInvite(deps.db, username, now)) {
353
+ return jsonError(
354
+ 409,
355
+ "username_reserved",
356
+ `username "${username}" is already reserved by another pending invite — revoke that invite first or pick a different name`,
357
+ );
358
+ }
359
+ }
281
360
 
282
361
  const issued = issueInvite(deps.db, {
283
362
  createdBy: authUserId,
284
363
  vaultName,
364
+ username,
285
365
  role,
286
366
  provisionVault,
287
367
  defaultMirror,
288
368
  expiresInSeconds,
289
369
  ...(deps.now !== undefined ? { now: deps.now } : {}),
290
370
  });
291
- const status = inviteStatus(issued.invite, (deps.now ?? (() => new Date()))());
371
+ const status = inviteStatus(issued.invite, now);
292
372
  return new Response(
293
373
  JSON.stringify({
294
374
  invite: toWire(issued.invite, status),