@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.
Files changed (62) hide show
  1. package/README.md +51 -54
  2. package/core/src/core.test.ts +4 -1
  3. package/core/src/indexed-fields.test.ts +151 -0
  4. package/core/src/indexed-fields.ts +98 -0
  5. package/core/src/mcp.ts +66 -43
  6. package/core/src/notes.ts +26 -2
  7. package/core/src/portable-md.test.ts +52 -0
  8. package/core/src/portable-md.ts +48 -0
  9. package/core/src/schema.ts +87 -14
  10. package/core/src/store.ts +117 -0
  11. package/core/src/types.ts +28 -0
  12. package/package.json +2 -2
  13. package/src/auth-hub-jwt.test.ts +191 -11
  14. package/src/auth-status.ts +12 -5
  15. package/src/auth.test.ts +135 -219
  16. package/src/auth.ts +158 -107
  17. package/src/cli.ts +306 -224
  18. package/src/config.ts +12 -4
  19. package/src/export-watch.test.ts +23 -0
  20. package/src/export-watch.ts +14 -0
  21. package/src/git-preflight.test.ts +70 -0
  22. package/src/git-preflight.ts +68 -0
  23. package/src/hub-jwt.test.ts +27 -2
  24. package/src/hub-jwt.ts +10 -0
  25. package/src/init-summary.test.ts +4 -4
  26. package/src/init-summary.ts +36 -10
  27. package/src/mcp-config.test.ts +4 -2
  28. package/src/mcp-http.ts +24 -3
  29. package/src/mcp-install-interactive.test.ts +33 -71
  30. package/src/mcp-install-interactive.ts +23 -76
  31. package/src/mcp-install.test.ts +156 -55
  32. package/src/mcp-install.ts +109 -3
  33. package/src/mcp-tools.ts +249 -74
  34. package/src/mirror-config.test.ts +107 -0
  35. package/src/mirror-config.ts +275 -9
  36. package/src/mirror-credentials.test.ts +168 -17
  37. package/src/mirror-credentials.ts +155 -32
  38. package/src/mirror-deps.ts +25 -16
  39. package/src/mirror-import.test.ts +122 -16
  40. package/src/mirror-import.ts +50 -16
  41. package/src/mirror-manager.test.ts +51 -0
  42. package/src/mirror-manager.ts +116 -22
  43. package/src/mirror-per-vault.test.ts +519 -0
  44. package/src/mirror-registry.ts +91 -14
  45. package/src/mirror-routes.test.ts +81 -21
  46. package/src/mirror-routes.ts +90 -16
  47. package/src/routes.ts +39 -2
  48. package/src/routing.test.ts +203 -118
  49. package/src/routing.ts +46 -59
  50. package/src/scopes.test.ts +0 -86
  51. package/src/scopes.ts +9 -97
  52. package/src/server.ts +102 -34
  53. package/src/storage.test.ts +132 -7
  54. package/src/token-store.test.ts +88 -169
  55. package/src/token-store.ts +123 -249
  56. package/src/vault-create.test.ts +12 -4
  57. package/src/vault.test.ts +408 -103
  58. package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
  59. package/web/ui/dist/index.html +1 -1
  60. package/src/tokens-routes.test.ts +0 -727
  61. package/src/tokens-routes.ts +0 -392
  62. package/web/ui/dist/assets/index-Degr8snN.js +0 -60
package/core/src/types.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
2
+ import type { PrunedField } from "./indexed-fields.js";
2
3
 
3
4
  // ---- Re-exports ----
4
5
 
5
6
  export type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
7
+ export type { PrunedField } from "./indexed-fields.js";
6
8
 
7
9
  // ---- Note ----
8
10
 
@@ -278,6 +280,24 @@ export interface Store {
278
280
  deleteTagSchema(tag: string): Promise<boolean>;
279
281
  getTagSchemaMap(): Promise<Record<string, { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> }>>;
280
282
 
283
+ // Indexed-field lifecycle — generated columns + indexes on `notes` derived
284
+ // from tag-declared `indexed: true` fields. See core/src/indexed-fields.ts.
285
+
286
+ /**
287
+ * Prune orphaned `indexed_fields` declarers — declarer tags with no `tags`
288
+ * row. Fields left with no live declarer are dropped wholesale; co-declared
289
+ * fields keep their column and lose only the dead declarers. `dryRun`
290
+ * (default true) returns the plan without mutating.
291
+ */
292
+ pruneIndexedFields(opts?: { dryRun?: boolean }): Promise<PrunedField[]>;
293
+ /**
294
+ * Replay `declareField` for every `indexed: true` field across all current
295
+ * tag records, materializing the backing columns + indexes. Idempotent —
296
+ * used by the import path so a fresh import has the same columns a live
297
+ * vault would. Returns the count of (tag, field) declarations replayed.
298
+ */
299
+ reconcileDeclaredIndexes(): Promise<number>;
300
+
281
301
  // Tag records — full v14 identity row (description + fields + typed
282
302
  // relationships + parent_names + timestamps). See
283
303
  // parachute-patterns/patterns/tag-data-model.md.
@@ -322,6 +342,14 @@ export interface Store {
322
342
  addAttachment(noteId: string, path: string, mimeType: string, metadata?: Record<string, unknown>): Promise<Attachment>;
323
343
  getAttachments(noteId: string): Promise<Attachment[]>;
324
344
  getAttachment(attachmentId: string): Promise<Attachment | null>;
345
+ /**
346
+ * Reverse-lookup attachment rows by their vault-internal relative `path`
347
+ * (`<date>/<filename>`). Returns every row sharing that path (a single
348
+ * on-disk asset can be referenced by >1 row). Used by the raw
349
+ * `/api/storage/<date>/<file>` serve path to map a requested file back to
350
+ * its owning note(s) for tag-scope enforcement.
351
+ */
352
+ getAttachmentsByPath(path: string): Promise<Attachment[]>;
325
353
  setAttachmentMetadata(attachmentId: string, metadata: Record<string, unknown>): Promise<void>;
326
354
  deleteAttachment(noteId: string, attachmentId: string): Promise<{ deleted: boolean; path: string | null; orphaned: boolean }>;
327
355
  listAttachmentsByTranscribeStatus(status: "pending" | "failed" | "done", limit?: number): Promise<Attachment[]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.9-rc.9",
3
+ "version": "0.5.0-rc.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@modelcontextprotocol/sdk": "^1.12.1",
30
- "@openparachute/scope-guard": "^0.3.0",
30
+ "@openparachute/scope-guard": "^0.4.0-rc.2",
31
31
  "jose": "^6.2.2",
32
32
  "otpauth": "^9.5.0",
33
33
  "qrcode-terminal": "^0.12.0"
@@ -105,6 +105,13 @@ interface SignOpts {
105
105
  * a hub-minted admin token; provide `["<name>"]` for a non-admin user.
106
106
  */
107
107
  vaultScope?: string[];
108
+ /**
109
+ * `permissions` claim (auth-unification arc, C0). Undefined → omit
110
+ * entirely (today's hub-JWT shape → unscoped). Provide
111
+ * `{ scoped_tags: [...] }` for a tag-scoped token, or a deliberately
112
+ * malformed value to exercise the fail-closed path.
113
+ */
114
+ permissions?: unknown;
108
115
  }
109
116
 
110
117
  async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
@@ -115,6 +122,7 @@ async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
115
122
  client_id: "test-client",
116
123
  };
117
124
  if (opts.vaultScope !== undefined) payload.vault_scope = opts.vaultScope;
125
+ if (opts.permissions !== undefined) payload.permissions = opts.permissions;
118
126
  return await new SignJWT(payload)
119
127
  .setProtectedHeader({ alg: "RS256", kid: kp.kid })
120
128
  .setIssuer(opts.iss)
@@ -183,7 +191,7 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
183
191
  const config = readVaultConfig("journal")!;
184
192
  const store = getVaultStore("journal");
185
193
 
186
- const result = await authenticateVaultRequest(bearer(token), config, store.db);
194
+ const result = await authenticateVaultRequest(bearer(token), config);
187
195
  expect("error" in result).toBe(false);
188
196
  if (!("error" in result)) {
189
197
  expect(result.permission).toBe("full");
@@ -202,7 +210,7 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
202
210
  const config = readVaultConfig("journal")!;
203
211
  const store = getVaultStore("journal");
204
212
 
205
- const result = await authenticateVaultRequest(bearer(token), config, store.db);
213
+ const result = await authenticateVaultRequest(bearer(token), config);
206
214
  expect("error" in result).toBe(false);
207
215
  if (!("error" in result)) expect(result.permission).toBe("read");
208
216
  });
@@ -217,7 +225,7 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
217
225
  const config = readVaultConfig("journal")!;
218
226
  const store = getVaultStore("journal");
219
227
 
220
- const result = await authenticateVaultRequest(bearer(token), config, store.db);
228
+ const result = await authenticateVaultRequest(bearer(token), config);
221
229
  expect("error" in result).toBe(true);
222
230
  if ("error" in result) {
223
231
  expect(result.error.status).toBe(401);
@@ -287,7 +295,7 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
287
295
  // this scenario, not a stderr inspection. Pattern carries to scribe/agent.
288
296
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
289
297
  try {
290
- const result = await authenticateVaultRequest(bearer(token), config, store.db);
298
+ const result = await authenticateVaultRequest(bearer(token), config);
291
299
  expect("error" in result).toBe(true);
292
300
  if ("error" in result) {
293
301
  expect(result.error.status).toBe(401);
@@ -323,7 +331,7 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
323
331
  const config = readVaultConfig("journal")!;
324
332
  const store = getVaultStore("journal");
325
333
 
326
- const result = await authenticateVaultRequest(bearer(token), config, store.db);
334
+ const result = await authenticateVaultRequest(bearer(token), config);
327
335
  expect("error" in result).toBe(false);
328
336
  if (!("error" in result)) {
329
337
  expect(result.permission).toBe("full");
@@ -345,7 +353,7 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
345
353
  const config = readVaultConfig("aaron")!;
346
354
  const store = getVaultStore("aaron");
347
355
 
348
- const result = await authenticateVaultRequest(bearer(token), config, store.db);
356
+ const result = await authenticateVaultRequest(bearer(token), config);
349
357
  expect("error" in result).toBe(false);
350
358
  if (!("error" in result)) {
351
359
  expect(result.permission).toBe("full");
@@ -374,7 +382,7 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
374
382
  const bobConfig = readVaultConfig("bob")!;
375
383
  const bobStore = getVaultStore("bob");
376
384
 
377
- const result = await authenticateVaultRequest(bearer(token), bobConfig, bobStore.db);
385
+ const result = await authenticateVaultRequest(bearer(token), bobConfig);
378
386
  expect("error" in result).toBe(true);
379
387
  if ("error" in result) {
380
388
  expect(result.error.status).toBe(403);
@@ -418,7 +426,7 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
418
426
  const config = readVaultConfig("work")!;
419
427
  const store = getVaultStore("work");
420
428
 
421
- const result = await authenticateVaultRequest(bearer(token), config, store.db);
429
+ const result = await authenticateVaultRequest(bearer(token), config);
422
430
  expect("error" in result).toBe(false);
423
431
  if (!("error" in result)) {
424
432
  expect(result.permission).toBe("full");
@@ -444,7 +452,7 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
444
452
  const config = readVaultConfig("legacy")!;
445
453
  const store = getVaultStore("legacy");
446
454
 
447
- const result = await authenticateVaultRequest(bearer(token), config, store.db);
455
+ const result = await authenticateVaultRequest(bearer(token), config);
448
456
  expect("error" in result).toBe(false);
449
457
  if (!("error" in result)) {
450
458
  expect(result.permission).toBe("full");
@@ -469,7 +477,7 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
469
477
  const config = readVaultConfig("aaron")!;
470
478
  const store = getVaultStore("aaron");
471
479
 
472
- const result = await authenticateVaultRequest(bearer(token), config, store.db);
480
+ const result = await authenticateVaultRequest(bearer(token), config);
473
481
  expect("error" in result).toBe(true);
474
482
  if ("error" in result) {
475
483
  // 401, not 403 — broad-scope rejection takes precedence.
@@ -497,7 +505,7 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
497
505
 
498
506
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
499
507
  try {
500
- const result = await authenticateVaultRequest(bearer(token), config, store.db);
508
+ const result = await authenticateVaultRequest(bearer(token), config);
501
509
  expect("error" in result).toBe(true);
502
510
  if ("error" in result) {
503
511
  expect(result.error.status).toBe(401);
@@ -518,3 +526,175 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
518
526
  }
519
527
  });
520
528
  });
529
+
530
+ describe("authenticateVaultRequest — hub JWT tag-scoping (auth-unification C0)", () => {
531
+ // Helper: pull a successful AuthResult out of authenticateVaultRequest,
532
+ // failing the test loudly if auth returned an error Response.
533
+ async function authOk(
534
+ token: string,
535
+ vaultName: string,
536
+ ): Promise<import("./auth.ts").AuthResult> {
537
+ const config = readVaultConfig(vaultName)!;
538
+ const store = getVaultStore(vaultName);
539
+ const result = await authenticateVaultRequest(bearer(token), config);
540
+ if ("error" in result) {
541
+ const body = await result.error.json();
542
+ throw new Error(`expected AuthResult, got ${result.error.status}: ${JSON.stringify(body)}`);
543
+ }
544
+ return result;
545
+ }
546
+
547
+ test("permissions.scoped_tags:[health] → AuthResult.scoped_tags=[health]; query-notes enforces (health visible, work hidden)", async () => {
548
+ seedVault("journal");
549
+ const store = getVaultStore("journal");
550
+ // Seed two notes: one tagged health, one tagged work.
551
+ await store.createNote("blood pressure log", { path: "h1", tags: ["health"] });
552
+ await store.createNote("quarterly OKRs", { path: "w1", tags: ["work"] });
553
+
554
+ const token = await signJwt(kp, {
555
+ iss: fixture.origin,
556
+ aud: "vault.journal",
557
+ scope: "vault:journal:read",
558
+ permissions: { scoped_tags: ["health"] },
559
+ });
560
+
561
+ const auth = await authOk(token, "journal");
562
+ // The READ side: the allowlist is lifted off the validated token.
563
+ expect(auth.scoped_tags).toEqual(["health"]);
564
+
565
+ // End-to-end enforcement: the tag-scope-wrapped query-notes tool must
566
+ // hide the `work` note and surface the `health` note.
567
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
568
+ const tools = generateScopedMcpTools("journal", auth);
569
+ const query = tools.find((t) => t.name === "query-notes")!;
570
+ const result = (await query.execute({})) as any;
571
+ const notes = Array.isArray(result) ? result : result.notes;
572
+ const paths = notes.map((n: any) => n.path).sort();
573
+ expect(paths).toEqual(["h1"]);
574
+ expect(paths).not.toContain("w1");
575
+ });
576
+
577
+ test("no permissions claim → scoped_tags=null (unscoped, full vault — regression: unchanged)", async () => {
578
+ seedVault("journal");
579
+ const store = getVaultStore("journal");
580
+ await store.createNote("a", { path: "h1", tags: ["health"] });
581
+ await store.createNote("b", { path: "w1", tags: ["work"] });
582
+
583
+ const token = await signJwt(kp, {
584
+ iss: fixture.origin,
585
+ aud: "vault.journal",
586
+ scope: "vault:journal:read",
587
+ // permissions intentionally omitted — today's hub-JWT shape
588
+ });
589
+
590
+ const auth = await authOk(token, "journal");
591
+ expect(auth.scoped_tags).toBeNull();
592
+
593
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
594
+ const tools = generateScopedMcpTools("journal", auth);
595
+ const query = tools.find((t) => t.name === "query-notes")!;
596
+ const result = (await query.execute({})) as any;
597
+ const notes = Array.isArray(result) ? result : result.notes;
598
+ const paths = notes.map((n: any) => n.path).sort();
599
+ // Unscoped: BOTH notes visible.
600
+ expect(paths).toEqual(["h1", "w1"]);
601
+ });
602
+
603
+ test("permissions present but scoped_tags absent → scoped_tags=null (unscoped)", async () => {
604
+ seedVault("journal");
605
+ const token = await signJwt(kp, {
606
+ iss: fixture.origin,
607
+ aud: "vault.journal",
608
+ scope: "vault:journal:read",
609
+ permissions: { some_other_perm: true },
610
+ });
611
+ const auth = await authOk(token, "journal");
612
+ expect(auth.scoped_tags).toBeNull();
613
+ });
614
+
615
+ test("permissions.scoped_tags:null → scoped_tags=null (explicit unscoped)", async () => {
616
+ seedVault("journal");
617
+ const token = await signJwt(kp, {
618
+ iss: fixture.origin,
619
+ aud: "vault.journal",
620
+ scope: "vault:journal:read",
621
+ permissions: { scoped_tags: null },
622
+ });
623
+ const auth = await authOk(token, "journal");
624
+ expect(auth.scoped_tags).toBeNull();
625
+ });
626
+
627
+ // ---- Fail-closed cases: present-but-malformed scoped_tags MUST 401, ----
628
+ // ---- never silently widen to full-vault. ----
629
+ for (const [label, badValue] of [
630
+ ["a string", "health"],
631
+ ["a number", 42],
632
+ ["an object", { health: true }],
633
+ ["an array with a non-string", ["health", 5]],
634
+ ["an array with an empty string", ["health", ""]],
635
+ ["an empty array", []],
636
+ ] as Array<[string, unknown]>) {
637
+ test(`malformed scoped_tags (${label}) → 401 fail-closed (does NOT widen to full vault)`, async () => {
638
+ seedVault("journal");
639
+ const token = await signJwt(kp, {
640
+ iss: fixture.origin,
641
+ aud: "vault.journal",
642
+ scope: "vault:journal:read",
643
+ permissions: { scoped_tags: badValue },
644
+ });
645
+ const config = readVaultConfig("journal")!;
646
+ const store = getVaultStore("journal");
647
+
648
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
649
+ try {
650
+ const result = await authenticateVaultRequest(bearer(token), config);
651
+ // The whole request is rejected — NOT served with scoped_tags=null
652
+ // (full vault) or scoped_tags=[] (also full vault on the MCP path).
653
+ expect("error" in result).toBe(true);
654
+ if ("error" in result) {
655
+ expect(result.error.status).toBe(401);
656
+ const body = (await result.error.json()) as { error: string; message: string };
657
+ expect(body.error).toBe("Unauthorized");
658
+ expect(body.message).toContain("malformed tag-scope");
659
+ }
660
+ // Audit log carries the diagnostic.
661
+ expect(warnSpy).toHaveBeenCalled();
662
+ } finally {
663
+ warnSpy.mockRestore();
664
+ }
665
+ });
666
+ }
667
+ });
668
+
669
+ // ---------------------------------------------------------------------------
670
+ // pvt_* DROP (vault#282 Stage 2 — BREAKING). pvt_* tokens were the only
671
+ // non-JWT, non-YAML credential vault used to mint + validate. At 0.5.0 the
672
+ // mint + validation were removed entirely: a pvt_*-prefixed bearer is no
673
+ // longer JWT-shaped (skips authenticateHubJwt) and matches no surviving
674
+ // credential, so it 401s. The hub JWT — the migration target — keeps working.
675
+ // ---------------------------------------------------------------------------
676
+
677
+ describe("pvt_* DROP (vault#282 Stage 2 — unvalidatable)", () => {
678
+ test("a pvt_* bearer is 401-rejected on the per-vault hub-JWT surface", async () => {
679
+ seedVault("journal");
680
+ const config = readVaultConfig("journal")!;
681
+ const pvt = "pvt_deadbeefdeadbeefdeadbeefdeadbeef";
682
+
683
+ const result = await authenticateVaultRequest(bearer(pvt), config);
684
+ expect("error" in result).toBe(true);
685
+ if ("error" in result) expect(result.error.status).toBe(401);
686
+ });
687
+
688
+ test("a real hub JWT still authenticates (migration target works)", async () => {
689
+ seedVault("journal");
690
+ const token = await signJwt(kp, {
691
+ iss: fixture.origin,
692
+ aud: "vault.journal",
693
+ scope: "vault:journal:write",
694
+ });
695
+ const config = readVaultConfig("journal")!;
696
+
697
+ const result = await authenticateVaultRequest(bearer(token), config);
698
+ expect("error" in result).toBe(false);
699
+ });
700
+ });
@@ -8,11 +8,18 @@
8
8
  *
9
9
  * What gets exposed:
10
10
  * - `initialized` — at least one vault exists
11
- * - `auth_modes` — accepted bearer formats (pvt_*, hub-issued JWT)
11
+ * - `auth_modes` — accepted bearer formats. As of 0.5.0 (vault#282 Stage 2)
12
+ * vault is a pure hub resource-server: the only first-class user
13
+ * credential is a hub-issued JWT, so this is `["hub_jwt"]`. (The
14
+ * server-wide VAULT_AUTH_TOKEN operator bearer + legacy YAML api_keys
15
+ * still authenticate, but they're operator/legacy channels, not the
16
+ * advertised first-contact mode.)
12
17
  * - `vaults` — list of `{ name, url }` for client-side dispatch
13
18
  * - `hasOwnerPassword`, `hasTotp` — OAuth consent prerequisites
14
- * - `hasTokens` — boolean | null. `null` "we couldn't read all DBs,
15
- * don't trust this answer"; `true`/`false` are honest yes/no signals.
19
+ * - `hasTokens` — boolean | null. Probes the vestigial `tokens` table for
20
+ * any leftover pre-0.5.0 rows (the table is kept inert as the YAML-import
21
+ * landing zone + a future-cosmetic-drop target). `null` ≈ "we couldn't
22
+ * read all DBs, don't trust this answer"; `true`/`false` are honest yes/no.
16
23
  *
17
24
  * What is deliberately NOT exposed: token counts, hashes, descriptions,
18
25
  * timestamps, owner-password hash, totp secret, backup codes. The endpoint
@@ -26,7 +33,7 @@ import { listVaults, readGlobalConfig, vaultDbPath } from "./config.ts";
26
33
 
27
34
  export interface AuthStatusResponse {
28
35
  initialized: boolean;
29
- auth_modes: ("pvt_token" | "hub_jwt")[];
36
+ auth_modes: "hub_jwt"[];
30
37
  vaults: { name: string; url: string }[];
31
38
  hasOwnerPassword: boolean;
32
39
  hasTotp: boolean;
@@ -77,7 +84,7 @@ export function buildAuthStatus(): AuthStatusResponse {
77
84
  const vaultNames = listVaults();
78
85
  return {
79
86
  initialized: vaultNames.length > 0,
80
- auth_modes: ["pvt_token", "hub_jwt"],
87
+ auth_modes: ["hub_jwt"],
81
88
  vaults: vaultNames.map((name) => ({ name, url: `/vault/${name}` })),
82
89
  hasOwnerPassword: typeof globalConfig.owner_password_hash === "string"
83
90
  && globalConfig.owner_password_hash.length > 0,