@openparachute/vault 0.4.5 → 0.4.6

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 CHANGED
@@ -197,6 +197,9 @@ parachute-vault mcp-install --install-scope user # write ~/.claude.json top
197
197
  parachute-vault mcp-install --install-scope local # write ~/.claude.json projects[<cwd>] (this directory only — default)
198
198
  parachute-vault mcp-install --install-scope project # write ./.mcp.json (checked into the repo)
199
199
  parachute-vault mcp-install --vault work # target the "work" vault (keyed as parachute-vault-work)
200
+ parachute-vault mcp-install --dry-run # describe the write without touching disk or the hub
201
+ parachute-vault mcp-config gitcoin # emit JSON for `claude -p --mcp-config "$(...)"`
202
+ parachute-vault mcp-config gitcoin --env-vars # template form with ${PARACHUTE_HUB_URL}/${PARACHUTE_VAULT_TOKEN}
200
203
 
201
204
  # OAuth — owner password + 2FA
202
205
  parachute-vault set-password # set/change the owner password (OAuth consent page)
@@ -619,8 +622,37 @@ parachute-vault mcp-install --legacy-pat
619
622
 
620
623
  **Multi-vault.** `--vault <name>` targets a specific vault and writes the entry under `parachute-vault-<name>` so multiple vaults coexist. Without `--vault`, the singular `parachute-vault` slot is used and one install clobbers another — that's intentional for the common single-vault case.
621
624
 
625
+ **Dry-run.** `parachute-vault mcp-install --dry-run` prints the write that would happen — target file, install scope, entry key, URL, auth mode — without touching disk or hitting the hub. Useful for probing the command's effect; in particular, `--help` no longer creates an empty `projects[<cwd>]` entry as a side effect, but `--dry-run` is the deliberate "tell me what you'd do" path.
626
+
622
627
  **Doctor.** `parachute-vault doctor` checks `~/.claude.json` (both top-level and `projects[<cwd>]`) and `./.mcp.json`, and reports which one holds the entry, plus port-match and reachability of the MCP URL.
623
628
 
629
+ #### Headless flows — `claude -p` runners (`mcp-config`)
630
+
631
+ `mcp-install` writes a persistent entry into a Claude Code config file. That's the right shape for interactive sessions, but it has two sharp edges for headless runners that spawn `claude -p` against a vault:
632
+
633
+ 1. **Local-scope MCP entries don't propagate to `claude -p` subprocesses.** A project-scoped install under `projects[<cwd>].mcpServers` is visible to an *interactive* `claude` launched from that directory, but a `claude -p` invocation spawned by a Python script — even from the same directory, even with `--setting-sources user,project,local` — doesn't pick it up. Use `--install-scope user` for scripts, cron jobs, and runners; the local default is fine for interactive sessions.
634
+ 2. **`--mcp-config` JSON is per-runner boilerplate.** Some runners prefer to inline the MCP config rather than mutate the user's Claude Code state. `parachute-vault mcp-config <vault-name>` emits exactly the JSON shape `--mcp-config` consumes:
635
+
636
+ ```bash
637
+ # Mint or fetch a vault-scoped token first, then:
638
+ export PARACHUTE_VAULT_TOKEN=pvt_...
639
+ claude -p --mcp-config "$(parachute-vault mcp-config gitcoin)" \
640
+ --strict-mcp-config \
641
+ "Summarize the latest notes under projects/gitcoin"
642
+
643
+ # Or commit a template and expand at use-time:
644
+ parachute-vault mcp-config gitcoin --env-vars > .claude/mcp-gitcoin.json
645
+ # ...then later, when invoking claude:
646
+ export PARACHUTE_HUB_URL=http://127.0.0.1:1940
647
+ export PARACHUTE_VAULT_TOKEN=pvt_...
648
+ claude -p --mcp-config "$(envsubst < .claude/mcp-gitcoin.json)" \
649
+ --strict-mcp-config ...
650
+ ```
651
+
652
+ **Note on `--env-vars` expansion.** The file-save pattern requires the caller to expand `${...}` placeholders at use-time (e.g. via `envsubst`, available on most Linux distros and via `brew install gettext` on macOS). Bare `$(cat .claude/mcp-gitcoin.json)` feeds literal `${PARACHUTE_HUB_URL}` strings into `claude -p` — the shell only expands placeholders inside the command-substitution *source*, not inside the *captured output*. `claude -p` itself doesn't expand `${...}` either, so the substitution has to happen between the file and the command.
653
+
654
+ Flags: `--token <bearer>` (alternative to `PARACHUTE_VAULT_TOKEN`); `--base-url <url>` (override the auto-detected origin, useful for tailnet-exposed hubs: `--base-url https://hub.tail.ts.net`); `--env-vars` (emit the template form with `${PARACHUTE_HUB_URL}` and `${PARACHUTE_VAULT_TOKEN}` placeholders, safe to commit). With no token and no `--env-vars`, the command exits 1 with a clear error — runners get a fail-fast.
655
+
624
656
  ## Data model
625
657
 
626
658
  ```
@@ -729,6 +761,15 @@ The checks, in the order they're emitted:
729
761
  - **Claude Code shows no vault tools.** Check in order: (1) is the daemon up (`parachute-vault status`)? (2) does `~/.claude.json` have a `parachute-vault` entry with both `url` and a valid `Authorization` header? (3) does the URL's vault name match an existing vault? `parachute-vault doctor` catches the first two. A missing or stale `Authorization` header after a bare `vault mcp-install` is the usual culprit for #2 — see the Claude Code section of [Connecting a client](#connecting-a-client) for how to rewrite it.
730
762
  - **Claude Desktop / Daily won't connect via OAuth.** If the owner-password prompt was skipped at `vault init`, the consent page falls back to requiring a vault token in place of the password (functional but clunky). Set one now with `parachute-vault set-password`. If 2FA is enrolled, have your authenticator app ready before starting the flow; lost TOTP access recovers via the backup codes printed at enrollment.
731
763
  - **Scheduled backups aren't running.** On macOS: `doctor` flags `backup agent: not loaded` when `schedule` isn't `manual` but the launchd agent is missing — rerun `parachute-vault backup --schedule <freq>` to reinstall it. On Linux: systemd-timer support for backup isn't shipped yet, so `--schedule daily` silently skips the scheduler. Run `parachute-vault backup` from cron (or similar) until that lands.
764
+ - **Manual `curl` against `/vault/<name>/mcp` returns `406 Not Acceptable`.** The MCP HTTP transport requires both `application/json` and `text/event-stream` in the `Accept` header (it negotiates between the JSON response and the SSE streaming variant). Claude Code's `--mcp-config` http transport sets this automatically — the symptom only shows up when you probe the endpoint by hand. The fix:
765
+
766
+ ```bash
767
+ curl -H 'Accept: application/json, text/event-stream' \
768
+ -H "Authorization: Bearer $VAULT_TOKEN" \
769
+ http://127.0.0.1:1940/vault/default/mcp
770
+ ```
771
+
772
+ Both media types are needed; omitting either is what triggers the 406.
732
773
 
733
774
  ### Getting help
734
775
 
@@ -810,8 +851,37 @@ cp .env.example .env # edit with your config
810
851
  docker compose up -d
811
852
  ```
812
853
 
854
+ Optionally set `PARACHUTE_VAULT_NAME` to choose a name for your first vault (defaults to `default`). Lowercase alphanumeric + hyphens or underscores, 2–32 chars.
855
+
813
856
  ### Cloud platforms
814
857
 
858
+ #### Render — recommended (hub-managed, v0.6)
859
+
860
+ Most users deploy vault via parachute-hub's `/admin/modules` after the
861
+ hub itself is on Render. See <https://parachute.computer/deploy/render/>
862
+ for the primary v0.6 self-host story: one Render Blueprint provisions
863
+ hub on a persistent disk, then you install vault (and the other
864
+ Parachute modules) from the hub's admin UI. The hub container
865
+ supervises vault's process, the shared persistent disk holds vault
866
+ state, and module upgrades flow through the admin UI rather than
867
+ separate Render redeploys.
868
+
869
+ #### Render — standalone vault (advanced)
870
+
871
+ The `render.yaml` Blueprint at the repo root deploys vault as its own
872
+ Render web service, separate from hub. This is the **advanced path** —
873
+ useful when you want vault on its own container (separate scaling,
874
+ isolated logs, vault-only deploy without a hub) but not what the
875
+ typical v0.6 self-host wants. If you're not sure, use the hub-managed
876
+ path above. The standalone Blueprint stays in tree because some
877
+ operators specifically want this shape (vault#341).
878
+
879
+ Optionally set `PARACHUTE_VAULT_NAME` to choose a name for your first
880
+ vault (defaults to `default`). Lowercase alphanumeric + hyphens or
881
+ underscores, 2–32 chars.
882
+
883
+ #### Other platforms
884
+
815
885
  **Railway** ($5/mo) — Deploy from GitHub, persistent volume, public URL.
816
886
  **Fly.io** ($3-5/mo) — `fly launch --copy-config && fly volumes create vault_data --size 1 && fly deploy`
817
887
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@modelcontextprotocol/sdk": "^1.12.1",
26
- "@openparachute/scope-guard": "^0.2.0",
26
+ "@openparachute/scope-guard": "^0.3.0",
27
27
  "jose": "^6.2.2",
28
28
  "otpauth": "^9.5.0",
29
29
  "qrcode-terminal": "^0.12.0"
@@ -98,12 +98,24 @@ interface SignOpts {
98
98
  ttlSeconds?: number;
99
99
  /** Override the random jti — needed when a test wants to revoke this exact token. */
100
100
  jti?: string;
101
+ /**
102
+ * `vault_scope` claim (multi-user Phase 1 PR 4 / scope-guard 0.3+).
103
+ * Undefined → omit the claim entirely (pre-PR-4 token shape; surfaces
104
+ * at scope-guard as `[]` = unrestricted). Provide `[]` explicitly for
105
+ * a hub-minted admin token; provide `["<name>"]` for a non-admin user.
106
+ */
107
+ vaultScope?: string[];
101
108
  }
102
109
 
103
110
  async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
104
111
  const iat = Math.floor(Date.now() / 1000);
105
112
  const exp = iat + (opts.ttlSeconds ?? 60);
106
- return await new SignJWT({ scope: opts.scope, client_id: "test-client" })
113
+ const payload: Record<string, unknown> = {
114
+ scope: opts.scope,
115
+ client_id: "test-client",
116
+ };
117
+ if (opts.vaultScope !== undefined) payload.vault_scope = opts.vaultScope;
118
+ return await new SignJWT(payload)
107
119
  .setProtectedHeader({ alg: "RS256", kid: kp.kid })
108
120
  .setIssuer(opts.iss)
109
121
  .setSubject(opts.sub ?? "user-1")
@@ -319,6 +331,154 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
319
331
  }
320
332
  });
321
333
 
334
+ test("vault_scope=[aaron] reaching /vault/aaron → success (matching pin)", async () => {
335
+ // Non-admin user assigned to vault "aaron" presenting their token
336
+ // at their own vault. The vault_scope claim names "aaron"; the
337
+ // request targets "aaron"; the defense-in-depth check passes.
338
+ seedVault("aaron");
339
+ const token = await signJwt(kp, {
340
+ iss: fixture.origin,
341
+ aud: "vault.aaron",
342
+ scope: "vault:aaron:write",
343
+ vaultScope: ["aaron"],
344
+ });
345
+ const config = readVaultConfig("aaron")!;
346
+ const store = getVaultStore("aaron");
347
+
348
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
349
+ expect("error" in result).toBe(false);
350
+ if (!("error" in result)) {
351
+ expect(result.permission).toBe("full");
352
+ expect(result.scopes).toEqual(["vault:aaron:write"]);
353
+ }
354
+ });
355
+
356
+ test("vault_scope=[aaron] reaching /vault/bob (cross-vault) → 403 vault_scope_mismatch", async () => {
357
+ // The exact threat model multi-user Phase 1 PR 5 protects against.
358
+ // A non-admin user pinned to "aaron" somehow gets a token naming
359
+ // "bob" in its scope string (mint bug, edited token, third-party RS
360
+ // bug, replay attack). The audience strict-check inside
361
+ // validateHubJwt would catch the obvious shape (aud=vault.bob
362
+ // reaching journal endpoint), but the case below pre-fabricates a
363
+ // token whose aud + scope DO name bob — only the vault_scope claim
364
+ // pins the user to aaron. Without the new enforcement, the request
365
+ // would coast through.
366
+ seedVault("aaron");
367
+ seedVault("bob");
368
+ const token = await signJwt(kp, {
369
+ iss: fixture.origin,
370
+ aud: "vault.bob", // audience matches bob (so the aud check passes)
371
+ scope: "vault:bob:write", // scope strings name bob too
372
+ vaultScope: ["aaron"], // but the user is pinned to aaron
373
+ });
374
+ const bobConfig = readVaultConfig("bob")!;
375
+ const bobStore = getVaultStore("bob");
376
+
377
+ const result = await authenticateVaultRequest(bearer(token), bobConfig, bobStore.db);
378
+ expect("error" in result).toBe(true);
379
+ if ("error" in result) {
380
+ expect(result.error.status).toBe(403);
381
+ const body = (await result.error.json()) as {
382
+ error: string;
383
+ error_type: string;
384
+ message: string;
385
+ required_vault: string;
386
+ granted_vault_scope?: unknown;
387
+ };
388
+ expect(body.error).toBe("Forbidden");
389
+ expect(body.error_type).toBe("vault_scope_mismatch");
390
+ // Message names both the pinned vault (aaron) and the requested
391
+ // vault (bob) so operators reading 403 logs can correlate to
392
+ // user assignment without needing to decode the token.
393
+ expect(body.message).toContain("aaron");
394
+ expect(body.message).toContain("bob");
395
+ expect(body.required_vault).toBe("bob");
396
+ // Surface hygiene: the pinned vault is intentionally NOT echoed
397
+ // back in a dedicated body field. The message carries enough
398
+ // diagnostic for an operator reading logs; a dedicated field
399
+ // would only leak the pin to a passive observer (the attacker
400
+ // already has it via local decode, but pattern hygiene says
401
+ // don't volunteer it).
402
+ expect(body.granted_vault_scope).toBeUndefined();
403
+ }
404
+ });
405
+
406
+ test("vault_scope=[] (admin token) passes any-vault check", async () => {
407
+ // Admin tokens carry vault_scope: [] — the explicit "no per-user pin"
408
+ // sentinel. The defense-in-depth check skips, and the request is
409
+ // gated purely by audience + scope strings. Both name "work" here,
410
+ // so the request succeeds.
411
+ seedVault("work");
412
+ const token = await signJwt(kp, {
413
+ iss: fixture.origin,
414
+ aud: "vault.work",
415
+ scope: "vault:work:admin",
416
+ vaultScope: [],
417
+ });
418
+ const config = readVaultConfig("work")!;
419
+ const store = getVaultStore("work");
420
+
421
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
422
+ expect("error" in result).toBe(false);
423
+ if (!("error" in result)) {
424
+ expect(result.permission).toBe("full");
425
+ expect(result.scopes).toEqual(["vault:work:admin"]);
426
+ }
427
+ });
428
+
429
+ test("pre-PR-4 token (vault_scope claim absent) → treated as admin (back-compat)", async () => {
430
+ // Every operator token + CLI-mint that existed before hub PR 4
431
+ // merged lacks the vault_scope claim entirely. scope-guard surfaces
432
+ // the absent claim as `[]`, which the helper treats as
433
+ // "unrestricted" — so the request goes through as long as audience
434
+ // + scope strings are correct. Without this back-compat, the entire
435
+ // pre-PR-4 fleet would 403 the moment this code shipped, which is
436
+ // the wrong tradeoff for a defense-in-depth check.
437
+ seedVault("legacy");
438
+ const token = await signJwt(kp, {
439
+ iss: fixture.origin,
440
+ aud: "vault.legacy",
441
+ scope: "vault:legacy:write",
442
+ // vaultScope intentionally omitted — pre-PR-4 token shape
443
+ });
444
+ const config = readVaultConfig("legacy")!;
445
+ const store = getVaultStore("legacy");
446
+
447
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
448
+ expect("error" in result).toBe(false);
449
+ if (!("error" in result)) {
450
+ expect(result.permission).toBe("full");
451
+ expect(result.scopes).toEqual(["vault:legacy:write"]);
452
+ }
453
+ });
454
+
455
+ test("vault_scope check runs AFTER broad-scope check — broad scope still wins the failure mode", async () => {
456
+ // A token with a broad vault scope AND a vault_scope pin gets
457
+ // rejected for the broad scope (more diagnostic). Pinning the
458
+ // ordering matters because the 401 message tells the operator
459
+ // "your token shape is wrong" whereas 403 vault_scope_mismatch
460
+ // tells them "your user can't reach this vault" — those have
461
+ // different remediation paths.
462
+ seedVault("aaron");
463
+ const token = await signJwt(kp, {
464
+ iss: fixture.origin,
465
+ aud: "vault.aaron",
466
+ scope: "vault:write", // broad — should trigger 401 first
467
+ vaultScope: ["other-vault"], // would also fail vault_scope check
468
+ });
469
+ const config = readVaultConfig("aaron")!;
470
+ const store = getVaultStore("aaron");
471
+
472
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
473
+ expect("error" in result).toBe(true);
474
+ if ("error" in result) {
475
+ // 401, not 403 — broad-scope rejection takes precedence.
476
+ expect(result.error.status).toBe(401);
477
+ const body = (await result.error.json()) as { error: string; message: string };
478
+ expect(body.message).toContain("broad vault scope");
479
+ }
480
+ });
481
+
322
482
  test("revocation list unreachable on cold start → fail-closed 401 sanitized; full diagnostic routed to console.warn", async () => {
323
483
  seedVault("journal");
324
484
  // Hub is reachable for JWKS but the revocation endpoint 503s. Cold cache
package/src/auth.test.ts CHANGED
@@ -398,3 +398,238 @@ describe("auth — legacy global YAML keys honor declared scope", () => {
398
398
  }
399
399
  });
400
400
  });
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // VAULT_AUTH_TOKEN — server-wide operator bearer (vault#339)
404
+ //
405
+ // The container-shape auth gate. When the env var is set, a request whose
406
+ // `Authorization: Bearer <value>` matches authenticates as full/admin
407
+ // against any vault on the server — the operator-channel path for sibling
408
+ // services on Render where vault and hub run as separate containers and
409
+ // hub needs a stable shared bearer to call vault.
410
+ //
411
+ // Semantic confirmed for the loopback/non-loopback split (auth gate is
412
+ // orthogonal to socket-level loopback): when VAULT_AUTH_TOKEN is unset,
413
+ // vault's existing token surface (per-vault DB tokens + hub JWTs + legacy
414
+ // YAML keys) is the ONLY auth surface. The bind socket defaults to
415
+ // 127.0.0.1 (`VAULT_BIND` in bind.ts), but no implicit loopback trust
416
+ // exists at the auth layer — a request from 127.0.0.1 still has to
417
+ // present a valid bearer. This matches docs/auth-model.md §1.
418
+ // ---------------------------------------------------------------------------
419
+
420
+ describe("auth — VAULT_AUTH_TOKEN server-wide operator bearer", () => {
421
+ const TOKEN = "test-operator-token-deadbeef0123456789abcdef";
422
+ let prevToken: string | undefined;
423
+
424
+ beforeEach(() => {
425
+ prevToken = process.env.VAULT_AUTH_TOKEN;
426
+ });
427
+
428
+ afterEach(() => {
429
+ if (prevToken === undefined) delete process.env.VAULT_AUTH_TOKEN;
430
+ else process.env.VAULT_AUTH_TOKEN = prevToken;
431
+ });
432
+
433
+ test("env set + matching bearer → 200 on vault auth, full permission, admin scopes", async () => {
434
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
435
+ seedVault("journal");
436
+ const journalConfig = readVaultConfig("journal")!;
437
+ const journalStore = getVaultStore("journal");
438
+
439
+ const result = await authenticateVaultRequest(
440
+ bearer(TOKEN),
441
+ journalConfig,
442
+ journalStore.db,
443
+ );
444
+
445
+ expect("error" in result).toBe(false);
446
+ if (!("error" in result)) {
447
+ expect(result.permission).toBe("full");
448
+ expect(result.scopes).toContain("vault:admin");
449
+ expect(result.legacyDerived).toBe(false);
450
+ expect(result.scoped_tags).toBeNull();
451
+ }
452
+ });
453
+
454
+ test("env set + matching bearer authenticates against ANY vault on the server", async () => {
455
+ // Server-wide → not tied to any one vault's DB. Same bearer works
456
+ // for journal and work without minting a per-vault token in either.
457
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
458
+ seedVault("journal");
459
+ seedVault("work");
460
+ const journalConfig = readVaultConfig("journal")!;
461
+ const journalStore = getVaultStore("journal");
462
+ const workConfig = readVaultConfig("work")!;
463
+ const workStore = getVaultStore("work");
464
+
465
+ const j = await authenticateVaultRequest(bearer(TOKEN), journalConfig, journalStore.db);
466
+ const w = await authenticateVaultRequest(bearer(TOKEN), workConfig, workStore.db);
467
+
468
+ expect("error" in j).toBe(false);
469
+ expect("error" in w).toBe(false);
470
+ });
471
+
472
+ test("env set + missing bearer → 401 (no implicit auth)", async () => {
473
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
474
+ seedVault("journal");
475
+ const journalConfig = readVaultConfig("journal")!;
476
+ const journalStore = getVaultStore("journal");
477
+
478
+ // No Authorization header at all.
479
+ const noBearer = new Request("https://vault.test/x");
480
+ const result = await authenticateVaultRequest(noBearer, journalConfig, journalStore.db);
481
+
482
+ expect("error" in result).toBe(true);
483
+ if ("error" in result) {
484
+ expect(result.error.status).toBe(401);
485
+ }
486
+ });
487
+
488
+ test("env set + wrong bearer → 401", async () => {
489
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
490
+ seedVault("journal");
491
+ const journalConfig = readVaultConfig("journal")!;
492
+ const journalStore = getVaultStore("journal");
493
+
494
+ const result = await authenticateVaultRequest(
495
+ bearer("wrong-token-doesnotmatch"),
496
+ journalConfig,
497
+ journalStore.db,
498
+ );
499
+
500
+ expect("error" in result).toBe(true);
501
+ if ("error" in result) {
502
+ expect(result.error.status).toBe(401);
503
+ }
504
+ });
505
+
506
+ test("env set + bearer that matches a vault token still resolves (server-wide first, but per-vault unchanged)", async () => {
507
+ // Per-vault tokens keep working even when the server-wide bearer is
508
+ // set. The server-wide check is a fast-path lookup before token DB
509
+ // resolution — a per-vault token doesn't match the env var so it
510
+ // falls through to the existing path.
511
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
512
+ seedVault("journal");
513
+ const perVaultToken = mintTokenInVault("journal");
514
+ const journalConfig = readVaultConfig("journal")!;
515
+ const journalStore = getVaultStore("journal");
516
+
517
+ const result = await authenticateVaultRequest(
518
+ bearer(perVaultToken),
519
+ journalConfig,
520
+ journalStore.db,
521
+ );
522
+
523
+ expect("error" in result).toBe(false);
524
+ });
525
+
526
+ test("env unset + valid per-vault bearer → 200 (existing behavior preserved)", async () => {
527
+ delete process.env.VAULT_AUTH_TOKEN;
528
+ seedVault("journal");
529
+ const token = mintTokenInVault("journal");
530
+ const journalConfig = readVaultConfig("journal")!;
531
+ const journalStore = getVaultStore("journal");
532
+
533
+ const result = await authenticateVaultRequest(bearer(token), journalConfig, journalStore.db);
534
+ expect("error" in result).toBe(false);
535
+ });
536
+
537
+ test("env unset + missing bearer → 401 (existing behavior preserved)", async () => {
538
+ delete process.env.VAULT_AUTH_TOKEN;
539
+ seedVault("journal");
540
+ const journalConfig = readVaultConfig("journal")!;
541
+ const journalStore = getVaultStore("journal");
542
+
543
+ const noBearer = new Request("https://vault.test/x");
544
+ const result = await authenticateVaultRequest(noBearer, journalConfig, journalStore.db);
545
+ expect("error" in result).toBe(true);
546
+ if ("error" in result) {
547
+ expect(result.error.status).toBe(401);
548
+ }
549
+ });
550
+
551
+ test("env unset + non-loopback simulated via X-Forwarded-For → still 401 without bearer", async () => {
552
+ // Doc note: vault has NO implicit loopback trust at the auth layer.
553
+ // The X-Forwarded-For shape (set by hub / Cloudflare Tunnel / etc.)
554
+ // doesn't affect the auth gate; tokens are required regardless of
555
+ // socket origin. The `bind.ts` 127.0.0.1 default is a socket-level
556
+ // listen-restriction, not a trust-asymmetric auth bypass.
557
+ delete process.env.VAULT_AUTH_TOKEN;
558
+ seedVault("journal");
559
+ const journalConfig = readVaultConfig("journal")!;
560
+ const journalStore = getVaultStore("journal");
561
+
562
+ const remote = new Request("https://vault.test/x", {
563
+ headers: { "X-Forwarded-For": "203.0.113.7" },
564
+ });
565
+ const result = await authenticateVaultRequest(remote, journalConfig, journalStore.db);
566
+ expect("error" in result).toBe(true);
567
+ if ("error" in result) {
568
+ expect(result.error.status).toBe(401);
569
+ }
570
+ });
571
+
572
+ test("env set with whitespace-only value → treated as unset", async () => {
573
+ process.env.VAULT_AUTH_TOKEN = " ";
574
+ seedVault("journal");
575
+ const journalConfig = readVaultConfig("journal")!;
576
+ const journalStore = getVaultStore("journal");
577
+
578
+ // An empty/whitespace VAULT_AUTH_TOKEN must NOT allow any bearer to
579
+ // pass — the operator either commits to bearer auth or doesn't.
580
+ const result = await authenticateVaultRequest(
581
+ bearer(""),
582
+ journalConfig,
583
+ journalStore.db,
584
+ );
585
+ expect("error" in result).toBe(true);
586
+ });
587
+
588
+ test("env set + matching bearer also works on the global auth surface", async () => {
589
+ // /vaults metadata listing + /health vault names go through
590
+ // authenticateGlobalRequest. The server-wide bearer must work there
591
+ // too — otherwise hub couldn't enumerate vaults using the operator
592
+ // channel.
593
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
594
+ seedVault("journal");
595
+
596
+ const result = await authenticateGlobalRequest(bearer(TOKEN));
597
+ expect("error" in result).toBe(false);
598
+ if (!("error" in result)) {
599
+ expect(result.permission).toBe("full");
600
+ }
601
+ });
602
+
603
+ test("env set + wrong bearer on global auth surface → 401", async () => {
604
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
605
+ seedVault("journal");
606
+
607
+ const result = await authenticateGlobalRequest(bearer("wrong-token"));
608
+ expect("error" in result).toBe(true);
609
+ if ("error" in result) {
610
+ expect(result.error.status).toBe(401);
611
+ }
612
+ });
613
+
614
+ test("near-miss bearer (one-char difference, same length) → 401 (constant-time compare)", async () => {
615
+ // Defensive: the server-wide compare uses crypto.timingSafeEqual so
616
+ // a one-char-off bearer that matches length-wise still rejects.
617
+ // We can't measure timing in a unit test, but we can pin the
618
+ // correctness side: a same-length near-miss must still reject.
619
+ process.env.VAULT_AUTH_TOKEN = TOKEN;
620
+ seedVault("journal");
621
+ const journalConfig = readVaultConfig("journal")!;
622
+ const journalStore = getVaultStore("journal");
623
+
624
+ const nearMiss = TOKEN.slice(0, -1) + "x";
625
+ expect(nearMiss).not.toBe(TOKEN);
626
+ expect(nearMiss.length).toBe(TOKEN.length);
627
+
628
+ const result = await authenticateVaultRequest(
629
+ bearer(nearMiss),
630
+ journalConfig,
631
+ journalStore.db,
632
+ );
633
+ expect("error" in result).toBe(true);
634
+ });
635
+ });