@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/routing.ts
CHANGED
|
@@ -64,7 +64,6 @@ import {
|
|
|
64
64
|
type TagScopeCtx,
|
|
65
65
|
} from "./routes.ts";
|
|
66
66
|
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
67
|
-
import { handleTokens } from "./tokens-routes.ts";
|
|
68
67
|
import {
|
|
69
68
|
handleProtectedResource,
|
|
70
69
|
handleAuthorizationServer,
|
|
@@ -127,12 +126,11 @@ async function withMcpChallenge(
|
|
|
127
126
|
async function isViewAuthenticated(
|
|
128
127
|
req: Request,
|
|
129
128
|
vaultConfig: VaultConfig | null,
|
|
130
|
-
vaultDb?: import("bun:sqlite").Database,
|
|
131
129
|
): Promise<boolean> {
|
|
132
130
|
if (!vaultConfig) return false;
|
|
133
131
|
const key = extractApiKey(req);
|
|
134
132
|
if (!key) return false;
|
|
135
|
-
const auth = await authenticateVaultRequest(req, vaultConfig
|
|
133
|
+
const auth = await authenticateVaultRequest(req, vaultConfig);
|
|
136
134
|
return !("error" in auth);
|
|
137
135
|
}
|
|
138
136
|
|
|
@@ -334,7 +332,7 @@ export async function route(
|
|
|
334
332
|
const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
|
|
335
333
|
if (vaultViewMatch && req.method === "GET") {
|
|
336
334
|
const store = getVaultStore(vaultName);
|
|
337
|
-
const authenticated = await isViewAuthenticated(req, vaultConfig
|
|
335
|
+
const authenticated = await isViewAuthenticated(req, vaultConfig);
|
|
338
336
|
return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]!), {
|
|
339
337
|
authenticated,
|
|
340
338
|
publishedTag: vaultConfig.published_tag,
|
|
@@ -395,7 +393,7 @@ export async function route(
|
|
|
395
393
|
// (worker intervals, TTLs, retention policy) but are still configuration
|
|
396
394
|
// an attacker could use to map the deployment. `vault:admin` keeps the
|
|
397
395
|
// hub's loopback workflow intact while locking out read-only tokens.
|
|
398
|
-
const configAuth = await authenticateVaultRequest(req, vaultConfig
|
|
396
|
+
const configAuth = await authenticateVaultRequest(req, vaultConfig);
|
|
399
397
|
if ("error" in configAuth) return configAuth.error;
|
|
400
398
|
if (!hasScopeForVault(configAuth.scopes, vaultName, "admin")) {
|
|
401
399
|
return Response.json(
|
|
@@ -430,7 +428,7 @@ export async function route(
|
|
|
430
428
|
// ---------------------------------------------------------------------
|
|
431
429
|
|
|
432
430
|
const store = getVaultStore(vaultName);
|
|
433
|
-
const auth = await authenticateVaultRequest(req, vaultConfig
|
|
431
|
+
const auth = await authenticateVaultRequest(req, vaultConfig);
|
|
434
432
|
const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
|
|
435
433
|
if ("error" in auth) {
|
|
436
434
|
return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
|
|
@@ -438,7 +436,15 @@ export async function route(
|
|
|
438
436
|
|
|
439
437
|
// MCP (per-vault, single-vault session).
|
|
440
438
|
if (isScopedMcp) {
|
|
441
|
-
|
|
439
|
+
// Thread the RAW caller bearer (the exact credential the session
|
|
440
|
+
// presented) into the MCP layer so the manage-token tool can forward it
|
|
441
|
+
// to hub's mint-token attenuation proxy (vault#403, MGT). Only the raw
|
|
442
|
+
// validated bearer — never a fabricated one. extractApiKey returns the
|
|
443
|
+
// same value `authenticateVaultRequest` validated above; non-forwardable
|
|
444
|
+
// credentials (env-var secret, legacy pvt_*) are handled by manage-token
|
|
445
|
+
// itself (it only forwards JWT-shaped bearers).
|
|
446
|
+
const callerBearer = extractApiKey(req);
|
|
447
|
+
return handleScopedMcp(req, vaultName, auth, callerBearer);
|
|
442
448
|
}
|
|
443
449
|
|
|
444
450
|
// Bare `/vault/<name>` — single-vault root. Returns name, description,
|
|
@@ -456,40 +462,18 @@ export async function route(
|
|
|
456
462
|
});
|
|
457
463
|
}
|
|
458
464
|
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
{
|
|
472
|
-
error: "Forbidden",
|
|
473
|
-
error_type: "insufficient_scope",
|
|
474
|
-
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
475
|
-
required_scope: SCOPE_ADMIN,
|
|
476
|
-
granted_scopes: auth.scopes,
|
|
477
|
-
},
|
|
478
|
-
{ status: 403 },
|
|
479
|
-
);
|
|
480
|
-
}
|
|
481
|
-
return handleTokens(req, store, vaultName, auth.scopes, auth.scoped_tags, tokensMatch[1] ?? "");
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// /.parachute/mirror — vault-sync Phase A1. Admin-gated read+write of
|
|
485
|
-
// the persistent mirror config + runtime status. Lives under
|
|
486
|
-
// `.parachute/` (alongside info/icon/config) rather than `admin/`
|
|
487
|
-
// because `/vault/<name>/admin/*` is reserved for the admin SPA's
|
|
488
|
-
// static-file mount; the API surface goes under `.parachute/` by the
|
|
489
|
-
// module-protocol convention. Per the design doc, the hub admin SPA
|
|
490
|
-
// (Phase A2 — future PR) is the eventual primary consumer; for Phase
|
|
491
|
-
// A1 these endpoints unblock direct API callers and the by-hand
|
|
492
|
-
// config workflow.
|
|
465
|
+
// The per-vault `/tokens` REST surface (pvt_* mint/list/revoke) was removed
|
|
466
|
+
// at 0.5.0 (vault#282 Stage 2 — vault is a pure hub resource-server). Hub
|
|
467
|
+
// JWTs are minted via hub's registry (`/api/auth/mint-token`); a `/tokens`
|
|
468
|
+
// request now falls through to the catch-all 404 below.
|
|
469
|
+
|
|
470
|
+
// /.parachute/mirror — Admin-gated read+write of THIS vault's persistent
|
|
471
|
+
// mirror config + runtime status. Per-vault (vault#400): the manager is
|
|
472
|
+
// resolved by the URL's vault name, so each vault's mirror page reflects
|
|
473
|
+
// its own config + git remote. Lives under `.parachute/` (alongside
|
|
474
|
+
// info/icon/config) rather than `admin/` because `/vault/<name>/admin/*`
|
|
475
|
+
// is reserved for the admin SPA's static-file mount; the API surface goes
|
|
476
|
+
// under `.parachute/` by the module-protocol convention.
|
|
493
477
|
if (subpath === "/.parachute/mirror") {
|
|
494
478
|
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
495
479
|
return Response.json(
|
|
@@ -503,19 +487,22 @@ export async function route(
|
|
|
503
487
|
{ status: 403 },
|
|
504
488
|
);
|
|
505
489
|
}
|
|
506
|
-
|
|
490
|
+
// vault#400: resolve the manager for THIS vault (from the URL). The
|
|
491
|
+
// registry lazily builds one (via the boot-installed factory) for any
|
|
492
|
+
// existing vault — including a non-default vault or one configured at
|
|
493
|
+
// runtime — so the handler operates on the right vault's config + status
|
|
494
|
+
// + git remote, never the default vault's.
|
|
495
|
+
const manager = getMirrorManager(vaultName);
|
|
507
496
|
if (!manager) {
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
// SPA know it's a service-state issue, not a misconfig on their
|
|
513
|
-
// end.
|
|
497
|
+
// Null only when boot hasn't installed the factory yet (startup race
|
|
498
|
+
// or boot failure). Surface a clear 503 rather than a JSON null so the
|
|
499
|
+
// operator + the hub SPA know it's a service-state issue, not a
|
|
500
|
+
// misconfig on their end.
|
|
514
501
|
return Response.json(
|
|
515
502
|
{
|
|
516
503
|
error: "Mirror manager not initialized",
|
|
517
504
|
message:
|
|
518
|
-
"The vault server hasn't wired
|
|
505
|
+
"The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
|
|
519
506
|
},
|
|
520
507
|
{ status: 503 },
|
|
521
508
|
);
|
|
@@ -543,13 +530,13 @@ export async function route(
|
|
|
543
530
|
{ status: 403 },
|
|
544
531
|
);
|
|
545
532
|
}
|
|
546
|
-
const manager = getMirrorManager();
|
|
533
|
+
const manager = getMirrorManager(vaultName);
|
|
547
534
|
if (!manager) {
|
|
548
535
|
return Response.json(
|
|
549
536
|
{
|
|
550
537
|
error: "Mirror manager not initialized",
|
|
551
538
|
message:
|
|
552
|
-
"The vault server hasn't wired
|
|
539
|
+
"The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
|
|
553
540
|
},
|
|
554
541
|
{ status: 503 },
|
|
555
542
|
);
|
|
@@ -575,13 +562,13 @@ export async function route(
|
|
|
575
562
|
{ status: 403 },
|
|
576
563
|
);
|
|
577
564
|
}
|
|
578
|
-
const manager = getMirrorManager();
|
|
565
|
+
const manager = getMirrorManager(vaultName);
|
|
579
566
|
if (!manager) {
|
|
580
567
|
return Response.json(
|
|
581
568
|
{
|
|
582
569
|
error: "Mirror manager not initialized",
|
|
583
570
|
message:
|
|
584
|
-
"The vault server hasn't wired
|
|
571
|
+
"The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
|
|
585
572
|
},
|
|
586
573
|
{ status: 503 },
|
|
587
574
|
);
|
|
@@ -631,20 +618,20 @@ export async function route(
|
|
|
631
618
|
{ status: 403 },
|
|
632
619
|
);
|
|
633
620
|
}
|
|
634
|
-
const manager = getMirrorManager();
|
|
621
|
+
const manager = getMirrorManager(vaultName);
|
|
635
622
|
if (!manager) {
|
|
636
623
|
return Response.json(
|
|
637
624
|
{
|
|
638
625
|
error: "Mirror manager not initialized",
|
|
639
626
|
message:
|
|
640
|
-
"The vault server hasn't wired
|
|
627
|
+
"The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
|
|
641
628
|
},
|
|
642
629
|
{ status: 503 },
|
|
643
630
|
);
|
|
644
631
|
}
|
|
645
632
|
|
|
646
633
|
if (subpath === "/.parachute/mirror/auth") {
|
|
647
|
-
if (req.method === "GET") return handleAuthGet();
|
|
634
|
+
if (req.method === "GET") return handleAuthGet(manager);
|
|
648
635
|
if (req.method === "DELETE") return handleAuthDelete(manager);
|
|
649
636
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
650
637
|
}
|
|
@@ -657,11 +644,11 @@ export async function route(
|
|
|
657
644
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
658
645
|
}
|
|
659
646
|
if (subpath === "/.parachute/mirror/auth/github/repos") {
|
|
660
|
-
if (req.method === "GET") return handleAuthGithubRepos();
|
|
647
|
+
if (req.method === "GET") return handleAuthGithubRepos(manager);
|
|
661
648
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
662
649
|
}
|
|
663
650
|
if (subpath === "/.parachute/mirror/auth/github/create-repo") {
|
|
664
|
-
if (req.method === "POST") return handleAuthGithubCreateRepo(req);
|
|
651
|
+
if (req.method === "POST") return handleAuthGithubCreateRepo(req, manager);
|
|
665
652
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
666
653
|
}
|
|
667
654
|
if (subpath === "/.parachute/mirror/auth/github/select-repo") {
|
|
@@ -722,7 +709,7 @@ export async function route(
|
|
|
722
709
|
});
|
|
723
710
|
}
|
|
724
711
|
if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
|
|
725
|
-
if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName);
|
|
712
|
+
if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName, store, tagScope);
|
|
726
713
|
if (apiPath === "/health") return Response.json({ status: "ok", vault: vaultName });
|
|
727
714
|
|
|
728
715
|
return Response.json({ error: "Not found" }, { status: 404 });
|
package/src/scopes.test.ts
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
SCOPE_ADMIN,
|
|
12
12
|
parseScopes,
|
|
13
13
|
parseScopeFlags,
|
|
14
|
-
resolveCreateTokenFlags,
|
|
15
14
|
hasScope,
|
|
16
15
|
hasScopeForVault,
|
|
17
16
|
findBroadVaultScopes,
|
|
@@ -254,91 +253,6 @@ describe("parseScopeFlags", () => {
|
|
|
254
253
|
});
|
|
255
254
|
});
|
|
256
255
|
|
|
257
|
-
describe("resolveCreateTokenFlags", () => {
|
|
258
|
-
test("no flags → full scope, full permission (historical default)", () => {
|
|
259
|
-
expect(resolveCreateTokenFlags([]))
|
|
260
|
-
.toEqual({ scopes: undefined, permission: "full", error: null });
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
test("--read alone → [vault:read], read permission", () => {
|
|
264
|
-
expect(resolveCreateTokenFlags(["--read"]))
|
|
265
|
-
.toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
test("--scope vault:read alone → read permission", () => {
|
|
269
|
-
expect(resolveCreateTokenFlags(["--scope", "vault:read"]))
|
|
270
|
-
.toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
test("--scope vault:write,vault:read → full permission (any write surface → full)", () => {
|
|
274
|
-
expect(resolveCreateTokenFlags(["--scope", "vault:write,vault:read"]))
|
|
275
|
-
.toEqual({ scopes: [SCOPE_WRITE, SCOPE_READ], permission: "full", error: null });
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
test("--scope vault:admin alone → full permission", () => {
|
|
279
|
-
expect(resolveCreateTokenFlags(["--scope", "vault:admin"]))
|
|
280
|
-
.toEqual({ scopes: [SCOPE_ADMIN], permission: "full", error: null });
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
test("--permission read → no scopes (token-store default), read permission", () => {
|
|
284
|
-
expect(resolveCreateTokenFlags(["--permission", "read"]))
|
|
285
|
-
.toEqual({ scopes: undefined, permission: "read", error: null });
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
test("--permission full → no scopes, full permission", () => {
|
|
289
|
-
expect(resolveCreateTokenFlags(["--permission", "full"]))
|
|
290
|
-
.toEqual({ scopes: undefined, permission: "full", error: null });
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
test("--scope + --read errors and mentions both flags", () => {
|
|
294
|
-
const result = resolveCreateTokenFlags(["--scope", "vault:write", "--read"]);
|
|
295
|
-
expect(result.scopes).toBeUndefined();
|
|
296
|
-
expect(result.error).toContain("--scope");
|
|
297
|
-
expect(result.error).toContain("--read");
|
|
298
|
-
expect(result.error).toContain("cannot be combined");
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
test("--scope + --permission errors", () => {
|
|
302
|
-
const result = resolveCreateTokenFlags(["--scope", "vault:read", "--permission", "full"]);
|
|
303
|
-
expect(result.scopes).toBeUndefined();
|
|
304
|
-
expect(result.error).toContain("--scope");
|
|
305
|
-
expect(result.error).toContain("--permission");
|
|
306
|
-
expect(result.error).toContain("cannot be combined");
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
test("--read + --permission errors", () => {
|
|
310
|
-
const result = resolveCreateTokenFlags(["--read", "--permission", "full"]);
|
|
311
|
-
expect(result.scopes).toBeUndefined();
|
|
312
|
-
expect(result.error).toContain("--read");
|
|
313
|
-
expect(result.error).toContain("--permission");
|
|
314
|
-
expect(result.error).toContain("cannot be combined");
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test("invalid --permission value errors with prefer-scope hint", () => {
|
|
318
|
-
const result = resolveCreateTokenFlags(["--permission", "admin"]);
|
|
319
|
-
expect(result.scopes).toBeUndefined();
|
|
320
|
-
expect(result.error).toContain("admin");
|
|
321
|
-
expect(result.error).toContain("full");
|
|
322
|
-
expect(result.error).toContain("read");
|
|
323
|
-
expect(result.error).toContain("--scope");
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
test("--permission with no value errors", () => {
|
|
327
|
-
expect(resolveCreateTokenFlags(["--permission"]).error).toContain("requires a value");
|
|
328
|
-
expect(resolveCreateTokenFlags(["--permission", "--label", "x"]).error).toContain("requires a value");
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
test("surfaces parseScopeFlags errors unchanged", () => {
|
|
332
|
-
const result = resolveCreateTokenFlags(["--scope", "vault:frob"]);
|
|
333
|
-
expect(result.error).toContain("Unknown scope");
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test("ignores unrelated flags", () => {
|
|
337
|
-
const result = resolveCreateTokenFlags(["--vault", "journal", "--label", "r", "--read"]);
|
|
338
|
-
expect(result).toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
|
|
339
|
-
});
|
|
340
|
-
});
|
|
341
|
-
|
|
342
256
|
describe("serializeScopes — round-trips with parseScopes", () => {
|
|
343
257
|
test("joins with spaces", () => {
|
|
344
258
|
expect(serializeScopes([SCOPE_READ, SCOPE_WRITE])).toBe("vault:read vault:write");
|
package/src/scopes.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tokens carry OAuth-standard whitespace-separated scopes. Two shapes coexist:
|
|
5
5
|
*
|
|
6
|
-
* - **Broad** `vault:<verb>` — used by
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* - **Broad** `vault:<verb>` — used by legacy YAML api_keys and the
|
|
7
|
+
* VAULT_AUTH_TOKEN operator bearer, which are vault-pinned by context
|
|
8
|
+
* (the YAML key lives under a specific vault; the operator bearer is
|
|
9
|
+
* server-wide full-admin). (The `pvt_*` vault-DB token that also used
|
|
10
|
+
* this shape was dropped at 0.5.0 — vault#282 Stage 2.)
|
|
9
11
|
* - **Narrowed** `vault:<name>:<verb>` — used by hub-issued JWTs, which are
|
|
10
12
|
* not pinned by storage and so MUST name the resource they grant access
|
|
11
13
|
* to. Hub JWTs carrying broad `vault:<verb>` are rejected at validation
|
|
@@ -113,9 +115,10 @@ export function hasScope(granted: string[], required: string): boolean {
|
|
|
113
115
|
*
|
|
114
116
|
* Match rules:
|
|
115
117
|
* - Broad `vault:<verb>` in granted satisfies any vault (the broad scope
|
|
116
|
-
* has no resource constraint; the caller pins the vault upstream —
|
|
117
|
-
*
|
|
118
|
-
* scopes at
|
|
118
|
+
* has no resource constraint; the caller pins the vault upstream — a
|
|
119
|
+
* legacy YAML key lives under a specific vault, the VAULT_AUTH_TOKEN
|
|
120
|
+
* bearer is server-wide full-admin, and hub JWTs reject broad scopes at
|
|
121
|
+
* validation).
|
|
119
122
|
* - Narrowed `vault:<name>:<verb>` satisfies only the matching `vaultName`.
|
|
120
123
|
* - Verb inheritance `admin ⊇ write ⊇ read` applies in both forms.
|
|
121
124
|
*/
|
|
@@ -286,94 +289,3 @@ export function parseScopeFlags(
|
|
|
286
289
|
}
|
|
287
290
|
return { scopes: deduped, error: null };
|
|
288
291
|
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Resolve `parachute vault tokens create` argv into a concrete scope set +
|
|
292
|
-
* legacy `permission` column value, or an actionable error.
|
|
293
|
-
*
|
|
294
|
-
* Precedence is **exclusive**: `--scope`, `--read`, and `--permission` all
|
|
295
|
-
* narrow the token, but combining them is always an error — a user who
|
|
296
|
-
* writes `--scope vault:write --read` almost certainly expects one of the
|
|
297
|
-
* two to win, and silently picking would mint the opposite of what at
|
|
298
|
-
* least one reading intended. Fail loud for anything token-minting.
|
|
299
|
-
*
|
|
300
|
-
* With no narrowing flag, falls back to a full-scope token for back-compat.
|
|
301
|
-
*/
|
|
302
|
-
export function resolveCreateTokenFlags(args: string[]): {
|
|
303
|
-
scopes: string[] | undefined;
|
|
304
|
-
permission: "full" | "read";
|
|
305
|
-
error: string | null;
|
|
306
|
-
} {
|
|
307
|
-
const scopeResult = parseScopeFlags(args);
|
|
308
|
-
if (scopeResult.error) {
|
|
309
|
-
return { scopes: undefined, permission: "full", error: scopeResult.error };
|
|
310
|
-
}
|
|
311
|
-
const hasScopeFlag = scopeResult.scopes !== null;
|
|
312
|
-
const hasReadFlag = args.includes("--read");
|
|
313
|
-
const permIdx = args.indexOf("--permission");
|
|
314
|
-
const hasPermFlag = permIdx !== -1;
|
|
315
|
-
|
|
316
|
-
if (hasScopeFlag && hasReadFlag) {
|
|
317
|
-
return {
|
|
318
|
-
scopes: undefined,
|
|
319
|
-
permission: "full",
|
|
320
|
-
error:
|
|
321
|
-
"--scope and --read cannot be combined. Pick one:\n" +
|
|
322
|
-
" --read # shorthand for --scope vault:read\n" +
|
|
323
|
-
" --scope vault:read # equivalent, explicit\n" +
|
|
324
|
-
" --scope vault:write # write scope",
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
if (hasScopeFlag && hasPermFlag) {
|
|
328
|
-
return {
|
|
329
|
-
scopes: undefined,
|
|
330
|
-
permission: "full",
|
|
331
|
-
error:
|
|
332
|
-
"--scope and --permission cannot be combined. --scope is the canonical way to narrow a token; --permission is legacy.",
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
if (hasReadFlag && hasPermFlag) {
|
|
336
|
-
return {
|
|
337
|
-
scopes: undefined,
|
|
338
|
-
permission: "full",
|
|
339
|
-
error: "--read and --permission cannot be combined. --read is a shorthand for --permission read.",
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (hasPermFlag) {
|
|
344
|
-
const rawPerm = args[permIdx + 1];
|
|
345
|
-
if (!rawPerm || rawPerm.startsWith("--")) {
|
|
346
|
-
return {
|
|
347
|
-
scopes: undefined,
|
|
348
|
-
permission: "full",
|
|
349
|
-
error: `--permission requires a value ("full" or "read"). Prefer --scope for new scripts.`,
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
if (!["full", "read"].includes(rawPerm)) {
|
|
353
|
-
return {
|
|
354
|
-
scopes: undefined,
|
|
355
|
-
permission: "full",
|
|
356
|
-
error: `Invalid --permission: ${rawPerm}. Must be "full" or "read". Prefer --scope for new scripts.`,
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (scopeResult.scopes) {
|
|
362
|
-
const scopes = scopeResult.scopes;
|
|
363
|
-
const permission: "full" | "read" =
|
|
364
|
-
scopes.includes(SCOPE_WRITE) || scopes.includes(SCOPE_ADMIN) ? "full" : "read";
|
|
365
|
-
return { scopes, permission, error: null };
|
|
366
|
-
}
|
|
367
|
-
if (hasReadFlag) {
|
|
368
|
-
return { scopes: [SCOPE_READ], permission: "read", error: null };
|
|
369
|
-
}
|
|
370
|
-
if (hasPermFlag) {
|
|
371
|
-
const rawPerm = args[permIdx + 1];
|
|
372
|
-
return {
|
|
373
|
-
scopes: undefined,
|
|
374
|
-
permission: rawPerm === "read" ? "read" : "full",
|
|
375
|
-
error: null,
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
return { scopes: undefined, permission: "full", error: null };
|
|
379
|
-
}
|
package/src/server.ts
CHANGED
|
@@ -31,8 +31,19 @@ import { getCachedScribeUrl } from "./scribe-discovery.ts";
|
|
|
31
31
|
import { readEnvFile, setEnvVar } from "./config.ts";
|
|
32
32
|
import { resolveBindHostname } from "./bind.ts";
|
|
33
33
|
import { MirrorManager } from "./mirror-manager.ts";
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
setMirrorManager,
|
|
36
|
+
setMirrorManagerFactory,
|
|
37
|
+
listMirrorManagers,
|
|
38
|
+
} from "./mirror-registry.ts";
|
|
35
39
|
import { buildMirrorDeps, resolveMirrorVaultName } from "./mirror-deps.ts";
|
|
40
|
+
import { migrateLegacyServerWideCredentials } from "./mirror-credentials.ts";
|
|
41
|
+
import {
|
|
42
|
+
commentOutLegacyMirrorBlockFile,
|
|
43
|
+
migrateLegacyServerWideConfig,
|
|
44
|
+
readMirrorConfigForVault,
|
|
45
|
+
} from "./mirror-config.ts";
|
|
46
|
+
import { GLOBAL_CONFIG_PATH } from "./config.ts";
|
|
36
47
|
import { selfRegister } from "./self-register.ts";
|
|
37
48
|
import pkg from "../package.json" with { type: "json" };
|
|
38
49
|
|
|
@@ -231,47 +242,96 @@ const port = parseInt(process.env.PORT ?? "") || globalConfig.port || DEFAULT_PO
|
|
|
231
242
|
const hostname = resolveBindHostname();
|
|
232
243
|
|
|
233
244
|
// ---------------------------------------------------------------------------
|
|
234
|
-
// Mirror lifecycle (vault
|
|
245
|
+
// Mirror lifecycle — per-vault (vault#400).
|
|
235
246
|
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
247
|
+
// Before vault#400 the server stood up a SINGLE mirror manager bound to the
|
|
248
|
+
// mirror-owning vault (default/first), and every mirror route resolved to it
|
|
249
|
+
// — so every vault's mirror page showed the default vault's config + git
|
|
250
|
+
// remote. Now each vault gets its OWN config (`data/<vault>/mirror-config.yaml`)
|
|
251
|
+
// + its OWN manager. Boot:
|
|
241
252
|
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
253
|
+
// 1. Migrate the legacy SERVER-WIDE credentials file (vault#399) + the
|
|
254
|
+
// legacy server-wide `mirror:` config block (vault#400) to the
|
|
255
|
+
// mirror-owning vault (default/first). Other vaults start unconfigured.
|
|
256
|
+
// 2. Install the lazy-build factory so routes can stand up a manager for
|
|
257
|
+
// any vault on first request (handles a vault configured at runtime
|
|
258
|
+
// without a restart).
|
|
259
|
+
// 3. Iterate ALL vaults; for each whose per-vault config has
|
|
260
|
+
// `enabled: true`, build + register + start its manager. Per-vault
|
|
261
|
+
// failure is logged + isolated — one vault's mirror error never breaks
|
|
262
|
+
// another vault's mirror or the vault server's serving path.
|
|
244
263
|
// ---------------------------------------------------------------------------
|
|
245
|
-
let mirrorManager: MirrorManager | null = null;
|
|
246
264
|
{
|
|
247
|
-
// Canonicalized in `mirror-deps.ts:resolveMirrorVaultName`
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// question 2) only has to touch one site.
|
|
265
|
+
// Canonicalized in `mirror-deps.ts:resolveMirrorVaultName` — the binding
|
|
266
|
+
// rule (default_vault → first listed vault → null) lives in exactly one
|
|
267
|
+
// place. Post-vault#400 it's only used for migration attribution.
|
|
251
268
|
const mirrorVaultName = resolveMirrorVaultName(listVaults);
|
|
252
|
-
|
|
269
|
+
|
|
270
|
+
// vault#399: migrate the legacy SERVER-WIDE credentials file to the
|
|
271
|
+
// per-vault layout, attributed to the mirror-owning vault. Idempotent +
|
|
272
|
+
// safe (no-op when no legacy file / already migrated; legacy file renamed
|
|
273
|
+
// `.bak`, never deleted). Runs even with no vaults (no-op).
|
|
274
|
+
try {
|
|
275
|
+
migrateLegacyServerWideCredentials(mirrorVaultName);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.warn(
|
|
278
|
+
`[mirror] legacy credential migration failed (non-fatal): ${(err as Error).message ?? err}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// vault#400: migrate the legacy server-wide `mirror:` CONFIG block from
|
|
283
|
+
// config.yaml to the mirror-owning vault's per-vault config file. The
|
|
284
|
+
// legacy block is commented out in place (preserved for reference), never
|
|
285
|
+
// silently dropped. Idempotent: no-op when no block / target already
|
|
286
|
+
// configured. (#399 already moved credentials — we do NOT re-migrate them.)
|
|
287
|
+
try {
|
|
288
|
+
migrateLegacyServerWideConfig(
|
|
289
|
+
readGlobalConfig().mirror,
|
|
290
|
+
mirrorVaultName,
|
|
291
|
+
() => commentOutLegacyMirrorBlockFile(GLOBAL_CONFIG_PATH),
|
|
292
|
+
);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.warn(
|
|
295
|
+
`[mirror] legacy config migration failed (non-fatal): ${(err as Error).message ?? err}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Install the lazy-build factory so routes can stand up a per-vault manager
|
|
300
|
+
// on demand (a vault configured at runtime works without a restart).
|
|
301
|
+
setMirrorManagerFactory((name) => new MirrorManager(buildMirrorDeps(name)));
|
|
302
|
+
|
|
303
|
+
// Stand up every vault whose per-vault config is enabled. Don't block
|
|
304
|
+
// server startup on slow initial exports — kick each off in the background.
|
|
305
|
+
const vaults = listVaults();
|
|
306
|
+
if (vaults.length === 0) {
|
|
307
|
+
console.log("[mirror] no vaults yet — managers stand up on next restart after a vault exists");
|
|
308
|
+
}
|
|
309
|
+
for (const name of vaults) {
|
|
310
|
+
let cfg;
|
|
253
311
|
try {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
312
|
+
cfg = readMirrorConfigForVault(name);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
console.warn(`[mirror] could not read config for vault "${name}" (skipping): ${(err as Error).message ?? err}`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!cfg?.enabled) continue; // unconfigured / disabled → no manager work
|
|
318
|
+
try {
|
|
319
|
+
const mgr = new MirrorManager(buildMirrorDeps(name));
|
|
320
|
+
setMirrorManager(name, mgr);
|
|
321
|
+
void mgr
|
|
261
322
|
.start()
|
|
262
323
|
.then((status) => {
|
|
263
324
|
if (!status.enabled && status.last_error) {
|
|
264
|
-
console.warn(`[mirror] startup error: ${status.last_error}`);
|
|
325
|
+
console.warn(`[mirror] vault "${name}" startup error: ${status.last_error}`);
|
|
265
326
|
}
|
|
266
327
|
})
|
|
267
328
|
.catch((err) => {
|
|
268
|
-
console.warn(`[mirror] startup threw: ${(err as Error).message ?? err}`);
|
|
329
|
+
console.warn(`[mirror] vault "${name}" startup threw: ${(err as Error).message ?? err}`);
|
|
269
330
|
});
|
|
270
331
|
} catch (err) {
|
|
271
|
-
|
|
332
|
+
// Isolated per-vault: one vault's failure never touches others.
|
|
333
|
+
console.warn(`[mirror] vault "${name}" manager construction failed: ${(err as Error).message ?? err}`);
|
|
272
334
|
}
|
|
273
|
-
} else {
|
|
274
|
-
console.log("[mirror] no vaults yet — manager will be initialized on next restart after a vault exists");
|
|
275
335
|
}
|
|
276
336
|
}
|
|
277
337
|
|
|
@@ -317,10 +377,11 @@ console.log(`Parachute Vault server listening on http://${hostname}:${server.por
|
|
|
317
377
|
// Graceful shutdown — best-effort drain of in-flight note-mutation hooks.
|
|
318
378
|
//
|
|
319
379
|
// Order matters under the event-driven mirror shape (vault#382):
|
|
320
|
-
// 1. MirrorManager.stop() runs FIRST
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
380
|
+
// 1. Every MirrorManager.stop() runs FIRST (vault#400: drain ALL per-vault
|
|
381
|
+
// managers, not just one) so they unsubscribe from hooks cleanly +
|
|
382
|
+
// cancel their debounce timers. Otherwise the registry drain below
|
|
383
|
+
// would wait on a manager's hook handler that just queued itself after
|
|
384
|
+
// a final mutation.
|
|
324
385
|
// 2. Then drain hooks + stop the transcription worker in parallel —
|
|
325
386
|
// neither depends on the other.
|
|
326
387
|
//
|
|
@@ -331,9 +392,16 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
331
392
|
try {
|
|
332
393
|
await Promise.race([
|
|
333
394
|
(async () => {
|
|
334
|
-
// Mirror stop first — unsubscribes + cancels
|
|
335
|
-
// internal soft-settle timeout (250ms) bounds the wait
|
|
336
|
-
|
|
395
|
+
// Mirror stop first — unsubscribes + cancels each debounce. Each
|
|
396
|
+
// manager's internal soft-settle timeout (250ms) bounds the wait;
|
|
397
|
+
// drain them in parallel so total shutdown stays bounded.
|
|
398
|
+
await Promise.all(
|
|
399
|
+
listMirrorManagers().map((m) =>
|
|
400
|
+
m.stop().catch((err) =>
|
|
401
|
+
console.warn(`[mirror] stop threw during shutdown (non-fatal): ${(err as Error).message ?? err}`),
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
);
|
|
337
405
|
// Then drain hooks + stop the transcription worker in parallel.
|
|
338
406
|
await Promise.all([
|
|
339
407
|
defaultHookRegistry.drain(),
|