@openparachute/vault 0.4.8 → 0.4.9-rc.11
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/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/src/mcp-tools.ts
CHANGED
|
@@ -14,14 +14,23 @@ 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 } from "./scopes.ts";
|
|
17
|
+
import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts";
|
|
18
18
|
import type { AuthResult } from "./auth.ts";
|
|
19
19
|
import {
|
|
20
20
|
expandTokenTagScope,
|
|
21
21
|
noteWithinTagScope,
|
|
22
22
|
tagsWithinScope,
|
|
23
23
|
} from "./tag-scope.ts";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
findTokensReferencingTag,
|
|
26
|
+
recordMcpMintLedger,
|
|
27
|
+
listMcpMintedHubJwts,
|
|
28
|
+
findMcpMintLedgerEntry,
|
|
29
|
+
markMcpMintLedgerRevoked,
|
|
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";
|
|
25
34
|
|
|
26
35
|
/**
|
|
27
36
|
* Filter a vault projection to entries an in-scope tag contributes to.
|
|
@@ -102,7 +111,11 @@ export async function getServerInstruction(
|
|
|
102
111
|
* When omitted (internal callers that only inspect the tool list — no execute
|
|
103
112
|
* path exercised), the description-update branch is disabled entirely.
|
|
104
113
|
*/
|
|
105
|
-
export function generateScopedMcpTools(
|
|
114
|
+
export function generateScopedMcpTools(
|
|
115
|
+
vaultName: string,
|
|
116
|
+
auth?: AuthResult,
|
|
117
|
+
callerBearer?: string | null,
|
|
118
|
+
): McpToolDef[] {
|
|
106
119
|
const store = getVaultStore(vaultName);
|
|
107
120
|
const tools = generateMcpTools(store);
|
|
108
121
|
|
|
@@ -110,6 +123,13 @@ export function generateScopedMcpTools(vaultName: string, auth?: AuthResult): Mc
|
|
|
110
123
|
applyTagDependencyGuards(tools, vaultName);
|
|
111
124
|
applyTagScopeWrappers(tools, vaultName, auth);
|
|
112
125
|
|
|
126
|
+
// manage-token is server-only (needs token-store + auth context), so it
|
|
127
|
+
// lives here rather than in core. Always appended to the surface; the
|
|
128
|
+
// `requiredVerb: "admin"` filter in mcp-http.ts hides it from non-admin
|
|
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));
|
|
132
|
+
|
|
113
133
|
return tools;
|
|
114
134
|
}
|
|
115
135
|
|
|
@@ -404,3 +424,440 @@ function overrideVaultInfo(
|
|
|
404
424
|
return result;
|
|
405
425
|
};
|
|
406
426
|
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// manage-token (vault#376) — single MCP tool with mint/revoke/list actions
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* TTL bounds for `manage-token` action=mint, in seconds. Short by design:
|
|
434
|
+
* the design doc (vault#376) calls the tool out as the "AI mints a token
|
|
435
|
+
* for one-shot scripted work, then revokes immediately" surface. A long
|
|
436
|
+
* TTL would defeat the safety story — if revoke fails (network blip,
|
|
437
|
+
* model error), the cap is the backstop. Operators wanting long-lived
|
|
438
|
+
* tokens still use the REST /vault/<name>/tokens endpoint.
|
|
439
|
+
*/
|
|
440
|
+
const MANAGE_TOKEN_DEFAULT_TTL_SECONDS = 900; // 15 minutes
|
|
441
|
+
const MANAGE_TOKEN_MAX_TTL_SECONDS = 3600; // 1 hour
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Resolve the bare hub origin for the mint/revoke proxy calls. Reuses
|
|
445
|
+
* `chooseHubOrigin` (PARACHUTE_HUB_ORIGIN → expose-state FQDN → loopback) so
|
|
446
|
+
* the manage-token proxy targets the same hub the rest of vault talks to.
|
|
447
|
+
* The port is read from global config (same source the server binds on).
|
|
448
|
+
*/
|
|
449
|
+
function resolveHubOrigin(): { url: string; source: string } {
|
|
450
|
+
let port = DEFAULT_PORT;
|
|
451
|
+
try {
|
|
452
|
+
port = readGlobalConfig().port || DEFAULT_PORT;
|
|
453
|
+
} catch {
|
|
454
|
+
// Config unreadable (fresh / test fixture) — fall back to the default
|
|
455
|
+
// port; chooseHubOrigin still honors PARACHUTE_HUB_ORIGIN / expose-state.
|
|
456
|
+
}
|
|
457
|
+
return chooseHubOrigin(port);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Build the manage-token MCP tool, wired to the calling session's auth.
|
|
462
|
+
*
|
|
463
|
+
* After the auth-unification arc (vault#403, MGT) the tool is a thin proxy to
|
|
464
|
+
* hub's mint-token attenuation endpoint: it mints short-TTL HUB JWTs, not
|
|
465
|
+
* deprecated `pvt_*` vault-DB tokens. The DROP step removes the pvt_* mint
|
|
466
|
+
* infra entirely once every consumer has migrated.
|
|
467
|
+
*
|
|
468
|
+
* Closure-captured context:
|
|
469
|
+
* - `vaultName`: every mint requests `vault:<vaultName>:<verb>`; cross-vault
|
|
470
|
+
* and over-scope requests are rejected locally by `validateMintedScopes`
|
|
471
|
+
* (fail-fast) AND by hub's attenuation guard (authoritative).
|
|
472
|
+
* - `auth.scopes`: the caller must hold `vault:<vaultName>:admin` to see the
|
|
473
|
+
* tool (mcp-http.ts visibleTools filter) and to mint; `validateMintedScopes`
|
|
474
|
+
* enforces the requested scope is a same-vault subset of what's held.
|
|
475
|
+
* - `auth.caller_jti`: the minting MCP session's id, recorded as the
|
|
476
|
+
* `parent_jti` in the local ledger so list/revoke stay session-scoped.
|
|
477
|
+
* When NULL (env-var operator / hub JWT without jti) there's no stable
|
|
478
|
+
* session id → list returns empty + revoke returns not_found.
|
|
479
|
+
* - `callerBearer`: the RAW credential the session presented. Only forwarded
|
|
480
|
+
* to hub when JWT-shaped (a hub JWT carrying `vault:<name>:admin`). A
|
|
481
|
+
* non-forwardable credential (env-var secret, legacy pvt_*) yields a clear
|
|
482
|
+
* "mint requires a hub-JWT session" error rather than a fabricated bearer.
|
|
483
|
+
*
|
|
484
|
+
* The execute function is async (mint/revoke do an HTTP round-trip to hub) and
|
|
485
|
+
* returns a discriminated-union response shape: `{action, …}` with `action`
|
|
486
|
+
* matching the requested action. The MCP HTTP layer serializes the result
|
|
487
|
+
* via `JSON.stringify`, so caller-side parsing keys off the action field.
|
|
488
|
+
*/
|
|
489
|
+
function buildManageTokenTool(
|
|
490
|
+
vaultName: string,
|
|
491
|
+
auth: AuthResult | undefined,
|
|
492
|
+
callerBearer: string | null,
|
|
493
|
+
): McpToolDef {
|
|
494
|
+
return {
|
|
495
|
+
name: "manage-token",
|
|
496
|
+
requiredVerb: "admin",
|
|
497
|
+
description:
|
|
498
|
+
"Mint, revoke, or list short-TTL hub JWTs within this MCP session. " +
|
|
499
|
+
"Designed for one-shot AI-driven workflows: mint a narrow token, run a " +
|
|
500
|
+
"script with it, revoke immediately. Minted tokens are short-lived hub " +
|
|
501
|
+
"JWTs (revocable via the hub's token registry), not legacy vault-DB " +
|
|
502
|
+
"tokens. Lifetime defaults to 15 min (max 1 hour). Mints are pinned to " +
|
|
503
|
+
"this vault and attenuated to a subset of the caller's scope — you cannot " +
|
|
504
|
+
"escalate. Minting requires a hub-JWT session holding 'vault:" + vaultName +
|
|
505
|
+
":admin'. List + revoke are scoped to tokens this session minted; " +
|
|
506
|
+
"CLI/REST-minted tokens are not surfaced here.\n\n" +
|
|
507
|
+
"Actions (discriminator: `action`):\n" +
|
|
508
|
+
"- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at }\n" +
|
|
509
|
+
"- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean }\n" +
|
|
510
|
+
"- `list` — (no inputs) → { action: \"list\", tokens: [...] }",
|
|
511
|
+
inputSchema: {
|
|
512
|
+
type: "object",
|
|
513
|
+
properties: {
|
|
514
|
+
action: {
|
|
515
|
+
type: "string",
|
|
516
|
+
enum: ["mint", "revoke", "list"],
|
|
517
|
+
description: "Which action to perform. Required.",
|
|
518
|
+
},
|
|
519
|
+
scope: {
|
|
520
|
+
oneOf: [
|
|
521
|
+
{ type: "string" },
|
|
522
|
+
{ type: "array", items: { type: "string" } },
|
|
523
|
+
],
|
|
524
|
+
description:
|
|
525
|
+
"(action=mint) Scope to grant. String like \"vault:write\" or array. Must be a subset of the caller's scope; cross-vault scopes are rejected.",
|
|
526
|
+
},
|
|
527
|
+
ttl_seconds: {
|
|
528
|
+
type: "number",
|
|
529
|
+
description: `(action=mint) Token lifetime in seconds. Default ${MANAGE_TOKEN_DEFAULT_TTL_SECONDS} (15 min), max ${MANAGE_TOKEN_MAX_TTL_SECONDS} (1 hour). Values outside (0, ${MANAGE_TOKEN_MAX_TTL_SECONDS}] are rejected.`,
|
|
530
|
+
},
|
|
531
|
+
description: {
|
|
532
|
+
type: "string",
|
|
533
|
+
description: "(action=mint, optional) Free-text label surfaced in the token list + audit trail.",
|
|
534
|
+
},
|
|
535
|
+
jti: {
|
|
536
|
+
type: "string",
|
|
537
|
+
description: "(action=revoke) The jti (e.g. `t_abc123…`) returned by a prior mint. Revoke is idempotent — second revoke also returns ok=true.",
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
required: ["action"],
|
|
541
|
+
},
|
|
542
|
+
execute: async (params) => {
|
|
543
|
+
const action = params.action;
|
|
544
|
+
|
|
545
|
+
// Defense-in-depth: the outer filter (mcp-http.ts visibleTools)
|
|
546
|
+
// already requires vault:admin for this vault to see manage-token,
|
|
547
|
+
// so reaching execute means the gate passed. A hand-crafted
|
|
548
|
+
// tools/call bypassing list would still hit the dispatch verb-check
|
|
549
|
+
// in handleScopedMcp. The block below is a third belt-and-suspenders
|
|
550
|
+
// check so a refactor of either layer can't lose the invariant
|
|
551
|
+
// silently.
|
|
552
|
+
if (!auth || !hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
553
|
+
return {
|
|
554
|
+
action,
|
|
555
|
+
error: "Forbidden",
|
|
556
|
+
message: `manage-token requires the 'vault:admin' scope (or 'vault:${vaultName}:admin'). Granted: ${auth?.scopes.join(" ") || "(none)"}.`,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (action === "mint") return await mintAction(params, vaultName, auth, callerBearer);
|
|
561
|
+
if (action === "revoke") return await revokeAction(params, vaultName, auth, callerBearer);
|
|
562
|
+
if (action === "list") return listAction(vaultName, auth);
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
error: "invalid_request",
|
|
566
|
+
message: `manage-token: unknown action "${String(action)}" — expected "mint" | "revoke" | "list".`,
|
|
567
|
+
};
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Normalize a requested scope to the resource-narrowed `vault:<name>:<verb>`
|
|
574
|
+
* shape hub expects. Callers may pass either the broad `vault:<verb>` form
|
|
575
|
+
* (the manage-token v1 surface accepted this) or the explicit
|
|
576
|
+
* `vault:<name>:<verb>` form. We rewrite the broad form to name THIS vault so
|
|
577
|
+
* hub's attenuation guard — which only knows resource-narrowed scopes — sees a
|
|
578
|
+
* `vault:<vaultName>:<verb>` request. A scope already naming a different vault
|
|
579
|
+
* is left untouched (validateMintedScopes rejects it before we get here).
|
|
580
|
+
*/
|
|
581
|
+
function narrowScopeForVault(scope: string, vaultName: string): string {
|
|
582
|
+
const parts = scope.split(":");
|
|
583
|
+
// `vault:<verb>` (2 parts) → `vault:<name>:<verb>`.
|
|
584
|
+
if (parts.length === 2 && parts[0] === "vault") {
|
|
585
|
+
return `vault:${vaultName}:${parts[1]}`;
|
|
586
|
+
}
|
|
587
|
+
return scope;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function mintAction(
|
|
591
|
+
params: Record<string, unknown>,
|
|
592
|
+
vaultName: string,
|
|
593
|
+
auth: AuthResult,
|
|
594
|
+
callerBearer: string | null,
|
|
595
|
+
): Promise<Record<string, unknown>> {
|
|
596
|
+
// Scope parsing: accept string or string[]. Empty/missing is rejected
|
|
597
|
+
// explicitly (no implicit "full scope" default — manage-token always
|
|
598
|
+
// narrows). The validateMintedScopes call then enforces:
|
|
599
|
+
// - shape (recognized vault scope)
|
|
600
|
+
// - vault-pin (cross-vault rejected)
|
|
601
|
+
// - subset of caller's scope on this vault.
|
|
602
|
+
let requested: string[];
|
|
603
|
+
if (typeof params.scope === "string") {
|
|
604
|
+
requested = parseScopes(params.scope);
|
|
605
|
+
} else if (Array.isArray(params.scope)) {
|
|
606
|
+
requested = params.scope.filter((s): s is string => typeof s === "string" && s.length > 0);
|
|
607
|
+
} else {
|
|
608
|
+
return {
|
|
609
|
+
action: "mint",
|
|
610
|
+
error: "invalid_request",
|
|
611
|
+
message: "manage-token mint: `scope` is required (string or string[]).",
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
if (requested.length === 0) {
|
|
615
|
+
return {
|
|
616
|
+
action: "mint",
|
|
617
|
+
error: "invalid_request",
|
|
618
|
+
message: "manage-token mint: at least one scope required.",
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Fail-fast local guard (defense-in-depth — hub's attenuation is
|
|
623
|
+
// authoritative): cross-vault + over-scope requests are rejected here with a
|
|
624
|
+
// clear message before any HTTP round-trip. The caller cannot request a
|
|
625
|
+
// scope outside their own vault/authority.
|
|
626
|
+
const validation = validateMintedScopes(requested, vaultName, auth.scopes);
|
|
627
|
+
if (!validation.ok) {
|
|
628
|
+
return {
|
|
629
|
+
action: "mint",
|
|
630
|
+
error: "forbidden",
|
|
631
|
+
message: "manage-token mint: scope rejected (must be a subset of the caller's scope on this vault).",
|
|
632
|
+
rejected: validation.rejected,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Forwardability: minting is a proxy to hub's attenuation endpoint, so the
|
|
637
|
+
// caller must present a forwardable hub-JWT bearer carrying
|
|
638
|
+
// `vault:<name>:admin`. A non-JWT credential (env-var operator secret,
|
|
639
|
+
// legacy pvt_*) can't be forwarded — and wouldn't carry mint authority at
|
|
640
|
+
// hub anyway — so fail with a clear, actionable error rather than
|
|
641
|
+
// fabricating a bearer.
|
|
642
|
+
//
|
|
643
|
+
// `looksLikeJwt` is a SYNTACTIC hint only (startsWith("eyJ") — the base64url
|
|
644
|
+
// of a JWS header `{"`). It does NOT verify the signature, issuer, scopes,
|
|
645
|
+
// or that the bearer actually grants mint authority. That's intentional:
|
|
646
|
+
// hub's mint-token attenuation guard is the authoritative gate (it validates
|
|
647
|
+
// the bearer and rejects anything it couldn't have minted). This check just
|
|
648
|
+
// avoids forwarding a credential we already know can't be a hub JWT.
|
|
649
|
+
if (!callerBearer || !looksLikeJwt(callerBearer)) {
|
|
650
|
+
return {
|
|
651
|
+
action: "mint",
|
|
652
|
+
error: "forbidden",
|
|
653
|
+
message:
|
|
654
|
+
`manage-token mint requires a hub-JWT session holding 'vault:${vaultName}:admin'. ` +
|
|
655
|
+
"This session authenticated with a non-forwardable credential (operator " +
|
|
656
|
+
"env-var token or legacy vault-DB token); mint a token via the hub admin " +
|
|
657
|
+
"UI / CLI instead, or reconnect MCP with a hub-issued JWT.",
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// TTL bounds. Default 900 (15 min); explicit values must satisfy
|
|
662
|
+
// `0 < ttl <= MANAGE_TOKEN_MAX_TTL_SECONDS`. Zero, negative, NaN, and
|
|
663
|
+
// beyond-max all reject — the cap is the safety backstop if revoke fails,
|
|
664
|
+
// so it must be strict.
|
|
665
|
+
let ttl = MANAGE_TOKEN_DEFAULT_TTL_SECONDS;
|
|
666
|
+
if (params.ttl_seconds !== undefined && params.ttl_seconds !== null) {
|
|
667
|
+
if (typeof params.ttl_seconds !== "number" || !Number.isFinite(params.ttl_seconds)) {
|
|
668
|
+
return {
|
|
669
|
+
action: "mint",
|
|
670
|
+
error: "invalid_request",
|
|
671
|
+
message: "manage-token mint: ttl_seconds must be a finite number.",
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
if (params.ttl_seconds <= 0 || params.ttl_seconds > MANAGE_TOKEN_MAX_TTL_SECONDS) {
|
|
675
|
+
return {
|
|
676
|
+
action: "mint",
|
|
677
|
+
error: "invalid_request",
|
|
678
|
+
message: `manage-token mint: ttl_seconds must be in (0, ${MANAGE_TOKEN_MAX_TTL_SECONDS}]; got ${params.ttl_seconds}.`,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
ttl = params.ttl_seconds;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const description = typeof params.description === "string" && params.description.length > 0
|
|
685
|
+
? params.description
|
|
686
|
+
: null;
|
|
687
|
+
const label = description ?? `mcp-mint (parent=${auth.caller_jti ?? "unknown"})`;
|
|
688
|
+
|
|
689
|
+
// Resolve hub origin (PARACHUTE_HUB_ORIGIN → expose-state FQDN → loopback).
|
|
690
|
+
const hub = resolveHubOrigin();
|
|
691
|
+
|
|
692
|
+
// Build the mint-token request. Scopes are narrowed to the resource-named
|
|
693
|
+
// `vault:<name>:<verb>` form hub's attenuation guard requires. Tag-scoping
|
|
694
|
+
// (when the caller is tag-scoped) rides along as `permissions.scoped_tags`
|
|
695
|
+
// so the minted hub JWT carries the same restriction — vault enforces it on
|
|
696
|
+
// read via C0 (vault#403). Unscoped callers omit `permissions`.
|
|
697
|
+
const narrowedScopes = requested.map((s) => narrowScopeForVault(s, vaultName));
|
|
698
|
+
const permissions =
|
|
699
|
+
auth.scoped_tags && auth.scoped_tags.length > 0
|
|
700
|
+
? { scoped_tags: auth.scoped_tags }
|
|
701
|
+
: undefined;
|
|
702
|
+
|
|
703
|
+
const minted = await mintHubJwt({
|
|
704
|
+
hubOrigin: hub.url,
|
|
705
|
+
operatorToken: callerBearer,
|
|
706
|
+
scope: narrowedScopes.join(" "),
|
|
707
|
+
expiresInSeconds: ttl,
|
|
708
|
+
...(permissions !== undefined ? { permissions } : {}),
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
if ("kind" in minted) {
|
|
712
|
+
// Surface a clear, action-keyed error. Network → "hub unreachable";
|
|
713
|
+
// api-error → hub's own error_description (e.g. attenuation rejection).
|
|
714
|
+
if (minted.kind === "network") {
|
|
715
|
+
return {
|
|
716
|
+
action: "mint",
|
|
717
|
+
error: "hub_unreachable",
|
|
718
|
+
message: `manage-token mint: could not reach hub at ${minted.origin} (${minted.cause}). Check PARACHUTE_HUB_ORIGIN / that the hub is running.`,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
action: "mint",
|
|
723
|
+
error: "hub_rejected",
|
|
724
|
+
message: `manage-token mint: hub rejected the request (${minted.error}: ${minted.description}).`,
|
|
725
|
+
hub_status: minted.status,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Record in the session-pinned ledger so list/revoke can scope to this
|
|
730
|
+
// session's mints. The signed JWT is never stored — only its jti (the
|
|
731
|
+
// revocation handle) + display metadata. NULL caller_jti (env-var / no-jti
|
|
732
|
+
// sessions) can't pass the forwardability gate above, so by here caller_jti
|
|
733
|
+
// is effectively the JWT's jti; we still guard defensively.
|
|
734
|
+
const store = getVaultStore(vaultName);
|
|
735
|
+
if (auth.caller_jti) {
|
|
736
|
+
recordMcpMintLedger(store.db, {
|
|
737
|
+
jti: minted.jti,
|
|
738
|
+
parentJti: auth.caller_jti,
|
|
739
|
+
vaultName,
|
|
740
|
+
label,
|
|
741
|
+
scopes: narrowedScopes,
|
|
742
|
+
scopedTags: auth.scoped_tags,
|
|
743
|
+
expiresAt: minted.expires_at,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
action: "mint",
|
|
749
|
+
token: minted.token,
|
|
750
|
+
jti: minted.jti,
|
|
751
|
+
expires_at: minted.expires_at,
|
|
752
|
+
scopes: narrowedScopes,
|
|
753
|
+
scoped_tags: auth.scoped_tags,
|
|
754
|
+
vault_name: vaultName,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function revokeAction(
|
|
759
|
+
params: Record<string, unknown>,
|
|
760
|
+
vaultName: string,
|
|
761
|
+
auth: AuthResult,
|
|
762
|
+
callerBearer: string | null,
|
|
763
|
+
): Promise<Record<string, unknown>> {
|
|
764
|
+
if (typeof params.jti !== "string" || params.jti.length === 0) {
|
|
765
|
+
return {
|
|
766
|
+
action: "revoke",
|
|
767
|
+
ok: false,
|
|
768
|
+
error: "invalid_request",
|
|
769
|
+
message: "manage-token revoke: `jti` is required (string).",
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
const jti = params.jti;
|
|
773
|
+
|
|
774
|
+
// Session-pin: revoke is restricted to hub JWTs THIS MCP session minted.
|
|
775
|
+
// When auth.caller_jti is null (no stable session id — env-var operator,
|
|
776
|
+
// legacy YAML key, hub JWT without jti), there are no attributable mints,
|
|
777
|
+
// so revoke returns not_found.
|
|
778
|
+
if (!auth.caller_jti) {
|
|
779
|
+
return {
|
|
780
|
+
action: "revoke",
|
|
781
|
+
ok: false,
|
|
782
|
+
error: "not_found",
|
|
783
|
+
message: "manage-token revoke: this session has no stable id; revoke via the hub admin UI / CLI.",
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const store = getVaultStore(vaultName);
|
|
788
|
+
const entry = findMcpMintLedgerEntry(store.db, jti, auth.caller_jti, vaultName);
|
|
789
|
+
if (!entry) {
|
|
790
|
+
// Idempotency: not-in-this-session's-ledger returns ok=true so the AI's
|
|
791
|
+
// "mint → run → revoke" loop doesn't surface a confusing failure on a
|
|
792
|
+
// duplicate revoke or a network-blip retry. The "minted by another
|
|
793
|
+
// session" case also lands here; we don't differentiate (no information
|
|
794
|
+
// leak about other sessions' jti space).
|
|
795
|
+
return { action: "revoke", ok: true, note: "no matching token in this session" };
|
|
796
|
+
}
|
|
797
|
+
if (entry.revoked_at) {
|
|
798
|
+
// Already revoked locally — idempotent success, no second hub round-trip.
|
|
799
|
+
return { action: "revoke", ok: true, already_revoked: true };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Forward the revoke to hub's token registry (the authoritative revocation
|
|
803
|
+
// surface — vault is resource-server-only). The caller's `vault:<N>:admin`
|
|
804
|
+
// bearer is forwarded, same as on mint. As of hub#454 this is the
|
|
805
|
+
// expected-SUCCESS path: hub's revoke-token applies capability attenuation
|
|
806
|
+
// symmetric to mint, so a `vault:<N>:admin` bearer may revoke any jti whose
|
|
807
|
+
// scopes it could have minted (and these are exactly the tokens this session
|
|
808
|
+
// minted within that vault's authority). Hub's revoke-token is idempotent.
|
|
809
|
+
//
|
|
810
|
+
// The `"kind" in revoked` branch below is now the EXCEPTION, not the norm —
|
|
811
|
+
// it only fires on a genuine edge (network blip, or a hub-side rejection
|
|
812
|
+
// that shouldn't happen for an in-authority jti). When it does, we still
|
|
813
|
+
// flip the local ledger marker so list reflects the operator's intent, and
|
|
814
|
+
// surface the hub failure so the caller knows the registry-side revoke may
|
|
815
|
+
// not have landed (the short TTL is the backstop either way).
|
|
816
|
+
if (callerBearer && looksLikeJwt(callerBearer)) {
|
|
817
|
+
const hub = resolveHubOrigin();
|
|
818
|
+
const revoked = await revokeHubJwt({
|
|
819
|
+
hubOrigin: hub.url,
|
|
820
|
+
operatorToken: callerBearer,
|
|
821
|
+
jti,
|
|
822
|
+
});
|
|
823
|
+
if ("kind" in revoked) {
|
|
824
|
+
// Unexpected hub failure. Local ledger still flips (operator asked to
|
|
825
|
+
// revoke), but report the hub-side failure so a network blip / scope
|
|
826
|
+
// gap is visible.
|
|
827
|
+
markMcpMintLedgerRevoked(store.db, jti, auth.caller_jti, vaultName);
|
|
828
|
+
if (revoked.kind === "network") {
|
|
829
|
+
return {
|
|
830
|
+
action: "revoke",
|
|
831
|
+
ok: false,
|
|
832
|
+
error: "hub_unreachable",
|
|
833
|
+
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.`,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
action: "revoke",
|
|
838
|
+
ok: false,
|
|
839
|
+
error: "hub_rejected",
|
|
840
|
+
message: `manage-token revoke: hub rejected the request (${revoked.error}: ${revoked.description}); local ledger marked revoked. The token's short TTL is the backstop.`,
|
|
841
|
+
hub_status: revoked.status,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
markMcpMintLedgerRevoked(store.db, jti, auth.caller_jti, vaultName);
|
|
847
|
+
return { action: "revoke", ok: true, already_revoked: false };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function listAction(vaultName: string, auth: AuthResult): Record<string, unknown> {
|
|
851
|
+
if (!auth.caller_jti) {
|
|
852
|
+
// No session id → no attributable mints. Return empty list rather
|
|
853
|
+
// than erroring, so callers can branch on tokens.length without
|
|
854
|
+
// exception handling.
|
|
855
|
+
return { action: "list", tokens: [] };
|
|
856
|
+
}
|
|
857
|
+
const store = getVaultStore(vaultName);
|
|
858
|
+
// Read from the hub-JWT mint ledger (vault#403, MGT) — mints now live in
|
|
859
|
+
// hub's registry, not the pvt_* tokens table; the ledger is the local
|
|
860
|
+
// session-attribution index.
|
|
861
|
+
const tokens = listMcpMintedHubJwts(store.db, auth.caller_jti, vaultName);
|
|
862
|
+
return { action: "list", tokens };
|
|
863
|
+
}
|