@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.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +310 -6
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +585 -21
- package/src/__tests__/invites.test.ts +91 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +1882 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +399 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +191 -0
- package/src/grants.ts +50 -0
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +849 -70
- package/src/invites.ts +91 -2
- package/src/jwt-sign.ts +47 -1
- package/src/module-manifest.ts +536 -23
- package/src/origin-check.ts +109 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/admin-vaults.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
81
|
-
// `
|
|
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
|
+
}
|
package/src/api-hub-upgrade.ts
CHANGED
|
@@ -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 — `
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
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
|
*
|
package/src/api-invites.ts
CHANGED
|
@@ -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 {
|
|
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 } =
|
|
306
|
+
const { vaultName, username, role, provisionVault, defaultMirror, expiresInSeconds } =
|
|
307
|
+
parsed.body;
|
|
308
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
266
309
|
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
// provision_vault=false
|
|
274
|
-
|
|
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
|
-
|
|
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,
|
|
371
|
+
const status = inviteStatus(issued.invite, now);
|
|
292
372
|
return new Response(
|
|
293
373
|
JSON.stringify({
|
|
294
374
|
invite: toWire(issued.invite, status),
|