@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -54
- package/core/src/core.test.ts +4 -1
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +66 -43
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +52 -0
- package/core/src/portable-md.ts +48 -0
- package/core/src/schema.ts +87 -14
- package/core/src/store.ts +117 -0
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +191 -11
- package/src/auth-status.ts +12 -5
- package/src/auth.test.ts +135 -219
- package/src/auth.ts +158 -107
- package/src/cli.ts +306 -224
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/init-summary.test.ts +4 -4
- package/src/init-summary.ts +36 -10
- package/src/mcp-config.test.ts +4 -2
- package/src/mcp-http.ts +24 -3
- package/src/mcp-install-interactive.test.ts +33 -71
- package/src/mcp-install-interactive.ts +23 -76
- package/src/mcp-install.test.ts +156 -55
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +249 -74
- package/src/mirror-config.test.ts +107 -0
- package/src/mirror-config.ts +275 -9
- package/src/mirror-credentials.test.ts +168 -17
- package/src/mirror-credentials.ts +155 -32
- package/src/mirror-deps.ts +25 -16
- package/src/mirror-import.test.ts +122 -16
- package/src/mirror-import.ts +50 -16
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +116 -22
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +81 -21
- package/src/mirror-routes.ts +90 -16
- package/src/routes.ts +39 -2
- package/src/routing.test.ts +203 -118
- package/src/routing.ts +46 -59
- package/src/scopes.test.ts +0 -86
- package/src/scopes.ts +9 -97
- package/src/server.ts +102 -34
- package/src/storage.test.ts +132 -7
- package/src/token-store.test.ts +88 -169
- package/src/token-store.ts +123 -249
- package/src/vault-create.test.ts +12 -4
- package/src/vault.test.ts +408 -103
- package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/tokens-routes.test.ts +0 -727
- package/src/tokens-routes.ts +0 -392
- package/web/ui/dist/assets/index-Degr8snN.js +0 -60
package/src/mcp-tools.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "../core/src/vault-projection.ts";
|
|
15
15
|
import { readVaultConfig, writeVaultConfig } from "./config.ts";
|
|
16
16
|
import { getVaultStore } from "./vault-store.ts";
|
|
17
|
-
import { hasScopeForVault, parseScopes, validateMintedScopes
|
|
17
|
+
import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts";
|
|
18
18
|
import type { AuthResult } from "./auth.ts";
|
|
19
19
|
import {
|
|
20
20
|
expandTokenTagScope,
|
|
@@ -23,12 +23,14 @@ import {
|
|
|
23
23
|
} from "./tag-scope.ts";
|
|
24
24
|
import {
|
|
25
25
|
findTokensReferencingTag,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
type TokenPermission,
|
|
26
|
+
recordMcpMintLedger,
|
|
27
|
+
listMcpMintedHubJwts,
|
|
28
|
+
findMcpMintLedgerEntry,
|
|
29
|
+
markMcpMintLedgerRevoked,
|
|
31
30
|
} from "./token-store.ts";
|
|
31
|
+
import { chooseHubOrigin, mintHubJwt, revokeHubJwt } from "./mcp-install.ts";
|
|
32
|
+
import { looksLikeJwt } from "./hub-jwt.ts";
|
|
33
|
+
import { readGlobalConfig, DEFAULT_PORT } from "./config.ts";
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* Filter a vault projection to entries an in-scope tag contributes to.
|
|
@@ -109,7 +111,11 @@ export async function getServerInstruction(
|
|
|
109
111
|
* When omitted (internal callers that only inspect the tool list — no execute
|
|
110
112
|
* path exercised), the description-update branch is disabled entirely.
|
|
111
113
|
*/
|
|
112
|
-
export function generateScopedMcpTools(
|
|
114
|
+
export function generateScopedMcpTools(
|
|
115
|
+
vaultName: string,
|
|
116
|
+
auth?: AuthResult,
|
|
117
|
+
callerBearer?: string | null,
|
|
118
|
+
): McpToolDef[] {
|
|
113
119
|
const store = getVaultStore(vaultName);
|
|
114
120
|
const tools = generateMcpTools(store);
|
|
115
121
|
|
|
@@ -120,8 +126,9 @@ export function generateScopedMcpTools(vaultName: string, auth?: AuthResult): Mc
|
|
|
120
126
|
// manage-token is server-only (needs token-store + auth context), so it
|
|
121
127
|
// lives here rather than in core. Always appended to the surface; the
|
|
122
128
|
// `requiredVerb: "admin"` filter in mcp-http.ts hides it from non-admin
|
|
123
|
-
// callers. See vault#376.
|
|
124
|
-
|
|
129
|
+
// callers. See vault#376. The raw caller bearer (vault#403, MGT) is
|
|
130
|
+
// forwarded to hub's mint-token attenuation proxy on mint.
|
|
131
|
+
tools.push(buildManageTokenTool(vaultName, auth, callerBearer ?? null));
|
|
125
132
|
|
|
126
133
|
return tools;
|
|
127
134
|
}
|
|
@@ -428,50 +435,77 @@ function overrideVaultInfo(
|
|
|
428
435
|
* for one-shot scripted work, then revokes immediately" surface. A long
|
|
429
436
|
* TTL would defeat the safety story — if revoke fails (network blip,
|
|
430
437
|
* model error), the cap is the backstop. Operators wanting long-lived
|
|
431
|
-
* tokens
|
|
438
|
+
* tokens mint a hub-issued JWT via the hub mint-token flow (the REST
|
|
439
|
+
* /vault/<name>/tokens endpoint was removed with the pvt_* drop, vault#282).
|
|
432
440
|
*/
|
|
433
441
|
const MANAGE_TOKEN_DEFAULT_TTL_SECONDS = 900; // 15 minutes
|
|
434
442
|
const MANAGE_TOKEN_MAX_TTL_SECONDS = 3600; // 1 hour
|
|
435
443
|
|
|
436
|
-
|
|
437
|
-
|
|
444
|
+
/**
|
|
445
|
+
* Resolve the bare hub origin for the mint/revoke proxy calls. Reuses
|
|
446
|
+
* `chooseHubOrigin` (PARACHUTE_HUB_ORIGIN → expose-state FQDN → loopback) so
|
|
447
|
+
* the manage-token proxy targets the same hub the rest of vault talks to.
|
|
448
|
+
* The port is read from global config (same source the server binds on).
|
|
449
|
+
*/
|
|
450
|
+
function resolveHubOrigin(): { url: string; source: string } {
|
|
451
|
+
let port = DEFAULT_PORT;
|
|
452
|
+
try {
|
|
453
|
+
port = readGlobalConfig().port || DEFAULT_PORT;
|
|
454
|
+
} catch {
|
|
455
|
+
// Config unreadable (fresh / test fixture) — fall back to the default
|
|
456
|
+
// port; chooseHubOrigin still honors PARACHUTE_HUB_ORIGIN / expose-state.
|
|
457
|
+
}
|
|
458
|
+
return chooseHubOrigin(port);
|
|
438
459
|
}
|
|
439
460
|
|
|
440
461
|
/**
|
|
441
462
|
* Build the manage-token MCP tool, wired to the calling session's auth.
|
|
442
463
|
*
|
|
464
|
+
* After the auth-unification arc (vault#403, MGT) the tool is a thin proxy to
|
|
465
|
+
* hub's mint-token attenuation endpoint: it mints short-TTL HUB JWTs. The
|
|
466
|
+
* `pvt_*` vault-DB mint infra it replaced was removed at 0.5.0 (vault#282
|
|
467
|
+
* Stage 2 — vault is a pure hub resource-server).
|
|
468
|
+
*
|
|
443
469
|
* Closure-captured context:
|
|
444
|
-
* - `vaultName`: every mint
|
|
445
|
-
* are rejected by `validateMintedScopes`
|
|
446
|
-
*
|
|
447
|
-
* - `auth.scopes`:
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
470
|
+
* - `vaultName`: every mint requests `vault:<vaultName>:<verb>`; cross-vault
|
|
471
|
+
* and over-scope requests are rejected locally by `validateMintedScopes`
|
|
472
|
+
* (fail-fast) AND by hub's attenuation guard (authoritative).
|
|
473
|
+
* - `auth.scopes`: the caller must hold `vault:<vaultName>:admin` to see the
|
|
474
|
+
* tool (mcp-http.ts visibleTools filter) and to mint; `validateMintedScopes`
|
|
475
|
+
* enforces the requested scope is a same-vault subset of what's held.
|
|
476
|
+
* - `auth.caller_jti`: the minting MCP session's id, recorded as the
|
|
477
|
+
* `parent_jti` in the local ledger so list/revoke stay session-scoped.
|
|
478
|
+
* When NULL (env-var operator / hub JWT without jti) there's no stable
|
|
479
|
+
* session id → list returns empty + revoke returns not_found.
|
|
480
|
+
* - `callerBearer`: the RAW credential the session presented. Only forwarded
|
|
481
|
+
* to hub when JWT-shaped (a hub JWT carrying `vault:<name>:admin`). A
|
|
482
|
+
* non-forwardable credential (the VAULT_AUTH_TOKEN env-var operator secret)
|
|
483
|
+
* yields a clear "mint requires a hub-JWT session" error rather than a
|
|
484
|
+
* fabricated bearer.
|
|
458
485
|
*
|
|
459
|
-
* The execute function is async (
|
|
486
|
+
* The execute function is async (mint/revoke do an HTTP round-trip to hub) and
|
|
460
487
|
* returns a discriminated-union response shape: `{action, …}` with `action`
|
|
461
488
|
* matching the requested action. The MCP HTTP layer serializes the result
|
|
462
489
|
* via `JSON.stringify`, so caller-side parsing keys off the action field.
|
|
463
490
|
*/
|
|
464
|
-
function buildManageTokenTool(
|
|
491
|
+
function buildManageTokenTool(
|
|
492
|
+
vaultName: string,
|
|
493
|
+
auth: AuthResult | undefined,
|
|
494
|
+
callerBearer: string | null,
|
|
495
|
+
): McpToolDef {
|
|
465
496
|
return {
|
|
466
497
|
name: "manage-token",
|
|
467
498
|
requiredVerb: "admin",
|
|
468
499
|
description:
|
|
469
|
-
"Mint, revoke, or list short-TTL
|
|
500
|
+
"Mint, revoke, or list short-TTL hub JWTs within this MCP session. " +
|
|
470
501
|
"Designed for one-shot AI-driven workflows: mint a narrow token, run a " +
|
|
471
|
-
"script with it, revoke immediately.
|
|
472
|
-
"(
|
|
473
|
-
"
|
|
474
|
-
"
|
|
502
|
+
"script with it, revoke immediately. Minted tokens are short-lived hub " +
|
|
503
|
+
"JWTs (revocable via the hub's token registry), not legacy vault-DB " +
|
|
504
|
+
"tokens. Lifetime defaults to 15 min (max 1 hour). Mints are pinned to " +
|
|
505
|
+
"this vault and attenuated to a subset of the caller's scope — you cannot " +
|
|
506
|
+
"escalate. Minting requires a hub-JWT session holding 'vault:" + vaultName +
|
|
507
|
+
":admin'. List + revoke are scoped to tokens this session minted; " +
|
|
508
|
+
"CLI/REST-minted tokens are not surfaced here.\n\n" +
|
|
475
509
|
"Actions (discriminator: `action`):\n" +
|
|
476
510
|
"- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at }\n" +
|
|
477
511
|
"- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean }\n" +
|
|
@@ -525,8 +559,8 @@ function buildManageTokenTool(vaultName: string, auth: AuthResult | undefined):
|
|
|
525
559
|
};
|
|
526
560
|
}
|
|
527
561
|
|
|
528
|
-
if (action === "mint") return await mintAction(params, vaultName, auth);
|
|
529
|
-
if (action === "revoke") return revokeAction(params, vaultName, auth);
|
|
562
|
+
if (action === "mint") return await mintAction(params, vaultName, auth, callerBearer);
|
|
563
|
+
if (action === "revoke") return await revokeAction(params, vaultName, auth, callerBearer);
|
|
530
564
|
if (action === "list") return listAction(vaultName, auth);
|
|
531
565
|
|
|
532
566
|
return {
|
|
@@ -537,10 +571,29 @@ function buildManageTokenTool(vaultName: string, auth: AuthResult | undefined):
|
|
|
537
571
|
};
|
|
538
572
|
}
|
|
539
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Normalize a requested scope to the resource-narrowed `vault:<name>:<verb>`
|
|
576
|
+
* shape hub expects. Callers may pass either the broad `vault:<verb>` form
|
|
577
|
+
* (the manage-token v1 surface accepted this) or the explicit
|
|
578
|
+
* `vault:<name>:<verb>` form. We rewrite the broad form to name THIS vault so
|
|
579
|
+
* hub's attenuation guard — which only knows resource-narrowed scopes — sees a
|
|
580
|
+
* `vault:<vaultName>:<verb>` request. A scope already naming a different vault
|
|
581
|
+
* is left untouched (validateMintedScopes rejects it before we get here).
|
|
582
|
+
*/
|
|
583
|
+
function narrowScopeForVault(scope: string, vaultName: string): string {
|
|
584
|
+
const parts = scope.split(":");
|
|
585
|
+
// `vault:<verb>` (2 parts) → `vault:<name>:<verb>`.
|
|
586
|
+
if (parts.length === 2 && parts[0] === "vault") {
|
|
587
|
+
return `vault:${vaultName}:${parts[1]}`;
|
|
588
|
+
}
|
|
589
|
+
return scope;
|
|
590
|
+
}
|
|
591
|
+
|
|
540
592
|
async function mintAction(
|
|
541
593
|
params: Record<string, unknown>,
|
|
542
594
|
vaultName: string,
|
|
543
595
|
auth: AuthResult,
|
|
596
|
+
callerBearer: string | null,
|
|
544
597
|
): Promise<Record<string, unknown>> {
|
|
545
598
|
// Scope parsing: accept string or string[]. Empty/missing is rejected
|
|
546
599
|
// explicitly (no implicit "full scope" default — manage-token always
|
|
@@ -568,6 +621,10 @@ async function mintAction(
|
|
|
568
621
|
};
|
|
569
622
|
}
|
|
570
623
|
|
|
624
|
+
// Fail-fast local guard (defense-in-depth — hub's attenuation is
|
|
625
|
+
// authoritative): cross-vault + over-scope requests are rejected here with a
|
|
626
|
+
// clear message before any HTTP round-trip. The caller cannot request a
|
|
627
|
+
// scope outside their own vault/authority.
|
|
571
628
|
const validation = validateMintedScopes(requested, vaultName, auth.scopes);
|
|
572
629
|
if (!validation.ok) {
|
|
573
630
|
return {
|
|
@@ -578,6 +635,31 @@ async function mintAction(
|
|
|
578
635
|
};
|
|
579
636
|
}
|
|
580
637
|
|
|
638
|
+
// Forwardability: minting is a proxy to hub's attenuation endpoint, so the
|
|
639
|
+
// caller must present a forwardable hub-JWT bearer carrying
|
|
640
|
+
// `vault:<name>:admin`. A non-JWT credential (the VAULT_AUTH_TOKEN env-var
|
|
641
|
+
// operator secret) can't be forwarded — and wouldn't carry mint authority at
|
|
642
|
+
// hub anyway — so fail with a clear, actionable error rather than
|
|
643
|
+
// fabricating a bearer.
|
|
644
|
+
//
|
|
645
|
+
// `looksLikeJwt` is a SYNTACTIC hint only (startsWith("eyJ") — the base64url
|
|
646
|
+
// of a JWS header `{"`). It does NOT verify the signature, issuer, scopes,
|
|
647
|
+
// or that the bearer actually grants mint authority. That's intentional:
|
|
648
|
+
// hub's mint-token attenuation guard is the authoritative gate (it validates
|
|
649
|
+
// the bearer and rejects anything it couldn't have minted). This check just
|
|
650
|
+
// avoids forwarding a credential we already know can't be a hub JWT.
|
|
651
|
+
if (!callerBearer || !looksLikeJwt(callerBearer)) {
|
|
652
|
+
return {
|
|
653
|
+
action: "mint",
|
|
654
|
+
error: "forbidden",
|
|
655
|
+
message:
|
|
656
|
+
`manage-token mint requires a hub-JWT session holding 'vault:${vaultName}:admin'. ` +
|
|
657
|
+
"This session authenticated with a non-forwardable credential (operator " +
|
|
658
|
+
"env-var token or legacy vault-DB token); mint a token via the hub admin " +
|
|
659
|
+
"UI / CLI instead, or reconnect MCP with a hub-issued JWT.",
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
581
663
|
// TTL bounds. Default 900 (15 min); explicit values must satisfy
|
|
582
664
|
// `0 < ttl <= MANAGE_TOKEN_MAX_TTL_SECONDS`. Zero, negative, NaN, and
|
|
583
665
|
// beyond-max all reject — the cap is the safety backstop if revoke fails,
|
|
@@ -600,49 +682,87 @@ async function mintAction(
|
|
|
600
682
|
}
|
|
601
683
|
ttl = params.ttl_seconds;
|
|
602
684
|
}
|
|
603
|
-
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
604
685
|
|
|
605
686
|
const description = typeof params.description === "string" && params.description.length > 0
|
|
606
687
|
? params.description
|
|
607
688
|
: null;
|
|
608
689
|
const label = description ?? `mcp-mint (parent=${auth.caller_jti ?? "unknown"})`;
|
|
609
690
|
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
691
|
+
// Resolve hub origin (PARACHUTE_HUB_ORIGIN → expose-state FQDN → loopback).
|
|
692
|
+
const hub = resolveHubOrigin();
|
|
693
|
+
|
|
694
|
+
// Build the mint-token request. Scopes are narrowed to the resource-named
|
|
695
|
+
// `vault:<name>:<verb>` form hub's attenuation guard requires. Tag-scoping
|
|
696
|
+
// (when the caller is tag-scoped) rides along as `permissions.scoped_tags`
|
|
697
|
+
// so the minted hub JWT carries the same restriction — vault enforces it on
|
|
698
|
+
// read via C0 (vault#403). Unscoped callers omit `permissions`.
|
|
699
|
+
const narrowedScopes = requested.map((s) => narrowScopeForVault(s, vaultName));
|
|
700
|
+
const permissions =
|
|
701
|
+
auth.scoped_tags && auth.scoped_tags.length > 0
|
|
702
|
+
? { scoped_tags: auth.scoped_tags }
|
|
703
|
+
: undefined;
|
|
704
|
+
|
|
705
|
+
const minted = await mintHubJwt({
|
|
706
|
+
hubOrigin: hub.url,
|
|
707
|
+
operatorToken: callerBearer,
|
|
708
|
+
scope: narrowedScopes.join(" "),
|
|
709
|
+
expiresInSeconds: ttl,
|
|
710
|
+
...(permissions !== undefined ? { permissions } : {}),
|
|
628
711
|
});
|
|
629
712
|
|
|
713
|
+
if ("kind" in minted) {
|
|
714
|
+
// Surface a clear, action-keyed error. Network → "hub unreachable";
|
|
715
|
+
// api-error → hub's own error_description (e.g. attenuation rejection).
|
|
716
|
+
if (minted.kind === "network") {
|
|
717
|
+
return {
|
|
718
|
+
action: "mint",
|
|
719
|
+
error: "hub_unreachable",
|
|
720
|
+
message: `manage-token mint: could not reach hub at ${minted.origin} (${minted.cause}). Check PARACHUTE_HUB_ORIGIN / that the hub is running.`,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
action: "mint",
|
|
725
|
+
error: "hub_rejected",
|
|
726
|
+
message: `manage-token mint: hub rejected the request (${minted.error}: ${minted.description}).`,
|
|
727
|
+
hub_status: minted.status,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Record in the session-pinned ledger so list/revoke can scope to this
|
|
732
|
+
// session's mints. The signed JWT is never stored — only its jti (the
|
|
733
|
+
// revocation handle) + display metadata. NULL caller_jti (env-var / no-jti
|
|
734
|
+
// sessions) can't pass the forwardability gate above, so by here caller_jti
|
|
735
|
+
// is effectively the JWT's jti; we still guard defensively.
|
|
736
|
+
const store = getVaultStore(vaultName);
|
|
737
|
+
if (auth.caller_jti) {
|
|
738
|
+
recordMcpMintLedger(store.db, {
|
|
739
|
+
jti: minted.jti,
|
|
740
|
+
parentJti: auth.caller_jti,
|
|
741
|
+
vaultName,
|
|
742
|
+
label,
|
|
743
|
+
scopes: narrowedScopes,
|
|
744
|
+
scopedTags: auth.scoped_tags,
|
|
745
|
+
expiresAt: minted.expires_at,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
630
749
|
return {
|
|
631
750
|
action: "mint",
|
|
632
|
-
token:
|
|
633
|
-
jti:
|
|
634
|
-
expires_at:
|
|
635
|
-
scopes:
|
|
751
|
+
token: minted.token,
|
|
752
|
+
jti: minted.jti,
|
|
753
|
+
expires_at: minted.expires_at,
|
|
754
|
+
scopes: narrowedScopes,
|
|
636
755
|
scoped_tags: auth.scoped_tags,
|
|
637
756
|
vault_name: vaultName,
|
|
638
757
|
};
|
|
639
758
|
}
|
|
640
759
|
|
|
641
|
-
function revokeAction(
|
|
760
|
+
async function revokeAction(
|
|
642
761
|
params: Record<string, unknown>,
|
|
643
762
|
vaultName: string,
|
|
644
763
|
auth: AuthResult,
|
|
645
|
-
|
|
764
|
+
callerBearer: string | null,
|
|
765
|
+
): Promise<Record<string, unknown>> {
|
|
646
766
|
if (typeof params.jti !== "string" || params.jti.length === 0) {
|
|
647
767
|
return {
|
|
648
768
|
action: "revoke",
|
|
@@ -651,30 +771,82 @@ function revokeAction(
|
|
|
651
771
|
message: "manage-token revoke: `jti` is required (string).",
|
|
652
772
|
};
|
|
653
773
|
}
|
|
654
|
-
|
|
774
|
+
const jti = params.jti;
|
|
775
|
+
|
|
776
|
+
// Session-pin: revoke is restricted to hub JWTs THIS MCP session minted.
|
|
655
777
|
// When auth.caller_jti is null (no stable session id — env-var operator,
|
|
656
|
-
// legacy YAML key, hub JWT without jti), there are no
|
|
657
|
-
//
|
|
778
|
+
// legacy YAML key, hub JWT without jti), there are no attributable mints,
|
|
779
|
+
// so revoke returns not_found.
|
|
658
780
|
if (!auth.caller_jti) {
|
|
659
781
|
return {
|
|
660
782
|
action: "revoke",
|
|
661
783
|
ok: false,
|
|
662
784
|
error: "not_found",
|
|
663
|
-
message: "manage-token revoke: this session has no stable id; revoke via the
|
|
785
|
+
message: "manage-token revoke: this session has no stable id; revoke via the hub admin UI / CLI.",
|
|
664
786
|
};
|
|
665
787
|
}
|
|
788
|
+
|
|
666
789
|
const store = getVaultStore(vaultName);
|
|
667
|
-
const
|
|
668
|
-
if (!
|
|
669
|
-
// Idempotency: not-
|
|
670
|
-
// revoke" loop doesn't surface a confusing failure
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
// about other sessions' jti space).
|
|
790
|
+
const entry = findMcpMintLedgerEntry(store.db, jti, auth.caller_jti, vaultName);
|
|
791
|
+
if (!entry) {
|
|
792
|
+
// Idempotency: not-in-this-session's-ledger returns ok=true so the AI's
|
|
793
|
+
// "mint → run → revoke" loop doesn't surface a confusing failure on a
|
|
794
|
+
// duplicate revoke or a network-blip retry. The "minted by another
|
|
795
|
+
// session" case also lands here; we don't differentiate (no information
|
|
796
|
+
// leak about other sessions' jti space).
|
|
675
797
|
return { action: "revoke", ok: true, note: "no matching token in this session" };
|
|
676
798
|
}
|
|
677
|
-
|
|
799
|
+
if (entry.revoked_at) {
|
|
800
|
+
// Already revoked locally — idempotent success, no second hub round-trip.
|
|
801
|
+
return { action: "revoke", ok: true, already_revoked: true };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Forward the revoke to hub's token registry (the authoritative revocation
|
|
805
|
+
// surface — vault is resource-server-only). The caller's `vault:<N>:admin`
|
|
806
|
+
// bearer is forwarded, same as on mint. As of hub#454 this is the
|
|
807
|
+
// expected-SUCCESS path: hub's revoke-token applies capability attenuation
|
|
808
|
+
// symmetric to mint, so a `vault:<N>:admin` bearer may revoke any jti whose
|
|
809
|
+
// scopes it could have minted (and these are exactly the tokens this session
|
|
810
|
+
// minted within that vault's authority). Hub's revoke-token is idempotent.
|
|
811
|
+
//
|
|
812
|
+
// The `"kind" in revoked` branch below is now the EXCEPTION, not the norm —
|
|
813
|
+
// it only fires on a genuine edge (network blip, or a hub-side rejection
|
|
814
|
+
// that shouldn't happen for an in-authority jti). When it does, we still
|
|
815
|
+
// flip the local ledger marker so list reflects the operator's intent, and
|
|
816
|
+
// surface the hub failure so the caller knows the registry-side revoke may
|
|
817
|
+
// not have landed (the short TTL is the backstop either way).
|
|
818
|
+
if (callerBearer && looksLikeJwt(callerBearer)) {
|
|
819
|
+
const hub = resolveHubOrigin();
|
|
820
|
+
const revoked = await revokeHubJwt({
|
|
821
|
+
hubOrigin: hub.url,
|
|
822
|
+
operatorToken: callerBearer,
|
|
823
|
+
jti,
|
|
824
|
+
});
|
|
825
|
+
if ("kind" in revoked) {
|
|
826
|
+
// Unexpected hub failure. Local ledger still flips (operator asked to
|
|
827
|
+
// revoke), but report the hub-side failure so a network blip / scope
|
|
828
|
+
// gap is visible.
|
|
829
|
+
markMcpMintLedgerRevoked(store.db, jti, auth.caller_jti, vaultName);
|
|
830
|
+
if (revoked.kind === "network") {
|
|
831
|
+
return {
|
|
832
|
+
action: "revoke",
|
|
833
|
+
ok: false,
|
|
834
|
+
error: "hub_unreachable",
|
|
835
|
+
message: `manage-token revoke: could not reach hub at ${revoked.origin} (${revoked.cause}); local ledger marked revoked but the hub registry may still list it. The token's short TTL is the backstop.`,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
action: "revoke",
|
|
840
|
+
ok: false,
|
|
841
|
+
error: "hub_rejected",
|
|
842
|
+
message: `manage-token revoke: hub rejected the request (${revoked.error}: ${revoked.description}); local ledger marked revoked. The token's short TTL is the backstop.`,
|
|
843
|
+
hub_status: revoked.status,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
markMcpMintLedgerRevoked(store.db, jti, auth.caller_jti, vaultName);
|
|
849
|
+
return { action: "revoke", ok: true, already_revoked: false };
|
|
678
850
|
}
|
|
679
851
|
|
|
680
852
|
function listAction(vaultName: string, auth: AuthResult): Record<string, unknown> {
|
|
@@ -685,6 +857,9 @@ function listAction(vaultName: string, auth: AuthResult): Record<string, unknown
|
|
|
685
857
|
return { action: "list", tokens: [] };
|
|
686
858
|
}
|
|
687
859
|
const store = getVaultStore(vaultName);
|
|
688
|
-
|
|
860
|
+
// Read from the hub-JWT mint ledger (vault#403, MGT) — mints now live in
|
|
861
|
+
// hub's registry, not the pvt_* tokens table; the ledger is the local
|
|
862
|
+
// session-attribution index.
|
|
863
|
+
const tokens = listMcpMintedHubJwts(store.db, auth.caller_jti, vaultName);
|
|
689
864
|
return { action: "list", tokens };
|
|
690
865
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
DEFAULT_SAFETY_NET_SECONDS,
|
|
16
16
|
MAX_SAFETY_NET_SECONDS,
|
|
17
17
|
MIN_SAFETY_NET_SECONDS,
|
|
18
|
+
commentOutMirrorBlock,
|
|
18
19
|
defaultMirrorConfig,
|
|
19
20
|
parseMirrorConfig,
|
|
20
21
|
resolveMirrorPath,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
validateExternalPath,
|
|
23
24
|
validateMirrorConfigShape,
|
|
24
25
|
} from "./mirror-config.ts";
|
|
26
|
+
import { GitNotInstalledError } from "./git-preflight.ts";
|
|
25
27
|
|
|
26
28
|
function tmp(prefix: string): string {
|
|
27
29
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
@@ -495,4 +497,109 @@ describe("validateExternalPath", () => {
|
|
|
495
497
|
expect(r.ok).toBe(true);
|
|
496
498
|
if (r.ok) expect(r.resolved_path).toBe(dir);
|
|
497
499
|
});
|
|
500
|
+
|
|
501
|
+
test("git not installed → throws GitNotInstalledError (route maps to 503)", async () => {
|
|
502
|
+
// vault#415 nit — the isGitRepo() check shells `git`. On a git-less
|
|
503
|
+
// server, throw the friendly error (handleMirrorPut maps it to 503
|
|
504
|
+
// git_not_installed) instead of a raw "Executable not found" crash.
|
|
505
|
+
// Force the preflight via the `which` seam; a real, valid git repo is
|
|
506
|
+
// used so the ONLY failure source is the preflight.
|
|
507
|
+
dir = tmp("mirror-validate-nogit-installed-");
|
|
508
|
+
initRepo(dir);
|
|
509
|
+
await expect(validateExternalPath(dir, () => null)).rejects.toBeInstanceOf(
|
|
510
|
+
GitNotInstalledError,
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// commentOutMirrorBlock — vault#400 migration YAML rewrite (extracted from
|
|
517
|
+
// server.ts per vault#408 review N3). Runs against the operator's real
|
|
518
|
+
// config.yaml, so it gets direct coverage here.
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
describe("commentOutMirrorBlock", () => {
|
|
522
|
+
test("comments out a real serializer-shaped mirror block; leaves other keys intact", () => {
|
|
523
|
+
// Build the block exactly as serializeMirrorConfig emits it — pins the
|
|
524
|
+
// real production shape rather than a hand-written approximation.
|
|
525
|
+
const block = serializeMirrorConfig({
|
|
526
|
+
...defaultMirrorConfig(),
|
|
527
|
+
enabled: true,
|
|
528
|
+
location: "external",
|
|
529
|
+
external_path: "/home/aaron/mirrors/brain",
|
|
530
|
+
auto_push: true,
|
|
531
|
+
}).join("\n");
|
|
532
|
+
const yaml = `port: 1940
|
|
533
|
+
default_vault: brain
|
|
534
|
+
${block}
|
|
535
|
+
auto_transcribe:
|
|
536
|
+
enabled: true
|
|
537
|
+
`;
|
|
538
|
+
const out = commentOutMirrorBlock(yaml);
|
|
539
|
+
|
|
540
|
+
// No LIVE mirror block survives (the parser anchor won't match).
|
|
541
|
+
expect(parseMirrorConfig(out)).toBeUndefined();
|
|
542
|
+
// Every mirror line is commented.
|
|
543
|
+
expect(out).toContain("# mirror:");
|
|
544
|
+
expect(out).toContain("# enabled: true");
|
|
545
|
+
expect(out).toContain("# external_path: /home/aaron/mirrors/brain");
|
|
546
|
+
expect(out).toContain("# auto_push: true");
|
|
547
|
+
// Provenance marker added.
|
|
548
|
+
expect(out).toContain("# [vault#400] migrated to per-vault");
|
|
549
|
+
// Non-mirror top-level keys untouched (byte-for-byte).
|
|
550
|
+
expect(out).toContain("port: 1940");
|
|
551
|
+
expect(out).toContain("default_vault: brain");
|
|
552
|
+
expect(out).toContain("auto_transcribe:");
|
|
553
|
+
expect(out).toContain(" enabled: true");
|
|
554
|
+
// The mirror block must NOT have swallowed the auto_transcribe block —
|
|
555
|
+
// its child line stays a live (uncommented) 2-space-indent field.
|
|
556
|
+
expect(out).not.toContain("# enabled: true\n# auto_transcribe");
|
|
557
|
+
const at = out.indexOf("auto_transcribe:");
|
|
558
|
+
expect(out.slice(at)).toContain("\n enabled: true");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("idempotent — running on already-commented output is a no-op", () => {
|
|
562
|
+
const block = serializeMirrorConfig({
|
|
563
|
+
...defaultMirrorConfig(),
|
|
564
|
+
enabled: true,
|
|
565
|
+
}).join("\n");
|
|
566
|
+
const yaml = `port: 1940\n${block}\ndiscovery: enabled\n`;
|
|
567
|
+
const once = commentOutMirrorBlock(yaml);
|
|
568
|
+
const twice = commentOutMirrorBlock(once);
|
|
569
|
+
expect(twice).toBe(once); // second pass changes nothing
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("no mirror block → returns input unchanged", () => {
|
|
573
|
+
const yaml = `port: 1940
|
|
574
|
+
default_vault: brain
|
|
575
|
+
discovery: enabled
|
|
576
|
+
`;
|
|
577
|
+
expect(commentOutMirrorBlock(yaml)).toBe(yaml);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("mirror block at EOF (no trailing key) is fully commented", () => {
|
|
581
|
+
const block = serializeMirrorConfig({
|
|
582
|
+
...defaultMirrorConfig(),
|
|
583
|
+
enabled: true,
|
|
584
|
+
auto_commit: false,
|
|
585
|
+
}).join("\n");
|
|
586
|
+
const yaml = `port: 1940\n${block}\n`;
|
|
587
|
+
const out = commentOutMirrorBlock(yaml);
|
|
588
|
+
expect(parseMirrorConfig(out)).toBeUndefined();
|
|
589
|
+
expect(out).toContain("# auto_commit: false");
|
|
590
|
+
expect(out).toContain("port: 1940"); // live, untouched
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("preserves a blank line between the mirror block and the next key", () => {
|
|
594
|
+
const block = serializeMirrorConfig({
|
|
595
|
+
...defaultMirrorConfig(),
|
|
596
|
+
enabled: true,
|
|
597
|
+
}).join("\n");
|
|
598
|
+
// Blank line separates the block from `discovery:` — must stay blank
|
|
599
|
+
// (not commented) and `discovery:` must stay live.
|
|
600
|
+
const yaml = `port: 1940\n${block}\n\ndiscovery: enabled\n`;
|
|
601
|
+
const out = commentOutMirrorBlock(yaml);
|
|
602
|
+
expect(out).toContain("\n\ndiscovery: enabled"); // blank line preserved, key live
|
|
603
|
+
expect(parseMirrorConfig(out)).toBeUndefined();
|
|
604
|
+
});
|
|
498
605
|
});
|