@openparachute/vault 0.4.5 → 0.4.6-rc.3
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 +29 -0
- package/package.json +1 -1
- package/src/auth.test.ts +235 -0
- package/src/auth.ts +78 -0
- package/src/routing.test.ts +85 -1
- package/src/server.ts +23 -4
- package/src/vault-name.test.ts +100 -4
- package/src/vault-name.ts +61 -3
package/README.md
CHANGED
|
@@ -810,8 +810,37 @@ cp .env.example .env # edit with your config
|
|
|
810
810
|
docker compose up -d
|
|
811
811
|
```
|
|
812
812
|
|
|
813
|
+
Optionally set `PARACHUTE_VAULT_NAME` to choose a name for your first vault (defaults to `default`). Lowercase alphanumeric + hyphens or underscores, 2–32 chars.
|
|
814
|
+
|
|
813
815
|
### Cloud platforms
|
|
814
816
|
|
|
817
|
+
#### Render — recommended (hub-managed, v0.6)
|
|
818
|
+
|
|
819
|
+
Most users deploy vault via parachute-hub's `/admin/modules` after the
|
|
820
|
+
hub itself is on Render. See <https://parachute.computer/deploy/render/>
|
|
821
|
+
for the primary v0.6 self-host story: one Render Blueprint provisions
|
|
822
|
+
hub on a persistent disk, then you install vault (and the other
|
|
823
|
+
Parachute modules) from the hub's admin UI. The hub container
|
|
824
|
+
supervises vault's process, the shared persistent disk holds vault
|
|
825
|
+
state, and module upgrades flow through the admin UI rather than
|
|
826
|
+
separate Render redeploys.
|
|
827
|
+
|
|
828
|
+
#### Render — standalone vault (advanced)
|
|
829
|
+
|
|
830
|
+
The `render.yaml` Blueprint at the repo root deploys vault as its own
|
|
831
|
+
Render web service, separate from hub. This is the **advanced path** —
|
|
832
|
+
useful when you want vault on its own container (separate scaling,
|
|
833
|
+
isolated logs, vault-only deploy without a hub) but not what the
|
|
834
|
+
typical v0.6 self-host wants. If you're not sure, use the hub-managed
|
|
835
|
+
path above. The standalone Blueprint stays in tree because some
|
|
836
|
+
operators specifically want this shape (vault#341).
|
|
837
|
+
|
|
838
|
+
Optionally set `PARACHUTE_VAULT_NAME` to choose a name for your first
|
|
839
|
+
vault (defaults to `default`). Lowercase alphanumeric + hyphens or
|
|
840
|
+
underscores, 2–32 chars.
|
|
841
|
+
|
|
842
|
+
#### Other platforms
|
|
843
|
+
|
|
815
844
|
**Railway** ($5/mo) — Deploy from GitHub, persistent volume, public URL.
|
|
816
845
|
**Fly.io** ($3-5/mo) — `fly launch --copy-config && fly volumes create vault_data --size 1 && fly deploy`
|
|
817
846
|
|
package/package.json
CHANGED
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
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { VaultConfig, StoredKey } from "./config.ts";
|
|
|
20
20
|
import { resolveToken } from "./token-store.ts";
|
|
21
21
|
import type { TokenPermission } from "./token-store.ts";
|
|
22
22
|
import type { Database } from "bun:sqlite";
|
|
23
|
+
import crypto from "node:crypto";
|
|
23
24
|
import { getVaultStore } from "./vault-store.ts";
|
|
24
25
|
import {
|
|
25
26
|
findBroadVaultScopes,
|
|
@@ -32,6 +33,66 @@ import {
|
|
|
32
33
|
} from "./scopes.ts";
|
|
33
34
|
import { HubJwtError, looksLikeJwt, validateHubJwt } from "./hub-jwt.ts";
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Server-wide operator bearer token, sourced from the `VAULT_AUTH_TOKEN`
|
|
38
|
+
* environment variable.
|
|
39
|
+
*
|
|
40
|
+
* Read dynamically per request (not cached at import time) so test seams
|
|
41
|
+
* that mutate `process.env.VAULT_AUTH_TOKEN` work without re-importing.
|
|
42
|
+
* In production the env var is set at container start and doesn't change.
|
|
43
|
+
*
|
|
44
|
+
* Empty / whitespace-only values are treated as unset — the operator
|
|
45
|
+
* either commits to bearer auth or doesn't, no degraded "empty token
|
|
46
|
+
* always matches" failure mode.
|
|
47
|
+
*/
|
|
48
|
+
function getServerWideAuthToken(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
49
|
+
const raw = env.VAULT_AUTH_TOKEN;
|
|
50
|
+
if (typeof raw !== "string") return null;
|
|
51
|
+
const trimmed = raw.trim();
|
|
52
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Constant-time string equality. Returns false when lengths differ
|
|
57
|
+
* (timingSafeEqual throws on length mismatch; we want a quiet false).
|
|
58
|
+
*/
|
|
59
|
+
function constantTimeEquals(a: string, b: string): boolean {
|
|
60
|
+
const aBuf = Buffer.from(a, "utf8");
|
|
61
|
+
const bBuf = Buffer.from(b, "utf8");
|
|
62
|
+
if (aBuf.length !== bBuf.length) return false;
|
|
63
|
+
return crypto.timingSafeEqual(aBuf, bBuf);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* If `VAULT_AUTH_TOKEN` is set and the provided bearer matches it
|
|
68
|
+
* (constant-time), return a full-admin AuthResult that's accepted by
|
|
69
|
+
* every vault on the server.
|
|
70
|
+
*
|
|
71
|
+
* The operator-channel auth shape for non-loopback deploys (Render,
|
|
72
|
+
* sibling-container setups, vault#339). Hub uses this to call vault
|
|
73
|
+
* across a container boundary; end-user OAuth tokens still take the
|
|
74
|
+
* per-vault hub-JWT / pvt_* paths below. See `docs/auth-model.md` §2.
|
|
75
|
+
*
|
|
76
|
+
* Scope set is broad (`vault:admin`) — the env-var bearer is an
|
|
77
|
+
* operator credential, not a user credential. Tag-scoping doesn't
|
|
78
|
+
* apply; we represent it as unscoped (`scoped_tags: null`).
|
|
79
|
+
*/
|
|
80
|
+
function tryServerWideAuth(
|
|
81
|
+
providedKey: string,
|
|
82
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
83
|
+
): AuthResult | null {
|
|
84
|
+
const configured = getServerWideAuthToken(env);
|
|
85
|
+
if (configured === null) return null;
|
|
86
|
+
if (!constantTimeEquals(providedKey, configured)) return null;
|
|
87
|
+
return {
|
|
88
|
+
permission: "full",
|
|
89
|
+
scopes: [SCOPE_ADMIN, SCOPE_WRITE, SCOPE_READ],
|
|
90
|
+
legacyDerived: false,
|
|
91
|
+
scoped_tags: null,
|
|
92
|
+
vault_name: null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
35
96
|
/** Result of a successful auth check. */
|
|
36
97
|
export interface AuthResult {
|
|
37
98
|
permission: TokenPermission;
|
|
@@ -168,6 +229,15 @@ export async function authenticateVaultRequest(
|
|
|
168
229
|
return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
|
|
169
230
|
}
|
|
170
231
|
|
|
232
|
+
// Server-wide operator token (vault#339). When VAULT_AUTH_TOKEN is set,
|
|
233
|
+
// a matching bearer authenticates as full/admin against any vault. This
|
|
234
|
+
// is the cross-container path for Render / sibling-service deployments
|
|
235
|
+
// where hub talks to vault over HTTP. Checked first so it short-circuits
|
|
236
|
+
// both JWT validation and per-vault DB lookups — the operator token is
|
|
237
|
+
// a credential the operator opts into, not one we'd ever fall through.
|
|
238
|
+
const serverWide = tryServerWideAuth(key);
|
|
239
|
+
if (serverWide !== null) return serverWide;
|
|
240
|
+
|
|
171
241
|
// JWT path: hub-issued tokens. Trust pinned to the hub origin via `iss`
|
|
172
242
|
// verification inside validateHubJwt; signature checked against hub's JWKS.
|
|
173
243
|
// Audience strict-checked against `vault.<name>` so a token stamped for
|
|
@@ -326,6 +396,14 @@ export async function authenticateGlobalRequest(
|
|
|
326
396
|
return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
|
|
327
397
|
}
|
|
328
398
|
|
|
399
|
+
// Server-wide operator token (vault#339). When VAULT_AUTH_TOKEN is set,
|
|
400
|
+
// a matching bearer authenticates as full/admin on cross-vault routes
|
|
401
|
+
// (/vaults metadata listing, /health detail). Checked first so a
|
|
402
|
+
// container-host operator-channel call doesn't depend on any per-vault
|
|
403
|
+
// DB lookup.
|
|
404
|
+
const serverWide = tryServerWideAuth(key);
|
|
405
|
+
if (serverWide !== null) return serverWide;
|
|
406
|
+
|
|
329
407
|
// Hub-issued JWTs are always vault-bound (aud=vault.<name>). The unified
|
|
330
408
|
// /vaults / /health surface spans every vault and has no single audience to
|
|
331
409
|
// strict-check against, so JWTs aren't accepted here. Cross-vault listing
|
package/src/routing.test.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* never touch ~/.parachute.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { describe, test, expect, beforeEach, afterAll } from "bun:test";
|
|
20
|
+
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
|
|
21
21
|
import { rmSync, existsSync, mkdirSync, writeFileSync } from "fs";
|
|
22
22
|
import { join } from "path";
|
|
23
23
|
import { tmpdir } from "os";
|
|
@@ -1641,3 +1641,87 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1641
1641
|
expect(writeRes.status).toBe(403);
|
|
1642
1642
|
});
|
|
1643
1643
|
});
|
|
1644
|
+
|
|
1645
|
+
// ---------------------------------------------------------------------------
|
|
1646
|
+
// /health — smoke tests for the unauthenticated liveness probe (vault#339).
|
|
1647
|
+
//
|
|
1648
|
+
// /health must ALWAYS return 200 regardless of VAULT_AUTH_TOKEN config so
|
|
1649
|
+
// Render's health probe + Docker HEALTHCHECK can poll the container even
|
|
1650
|
+
// before the operator has configured a bearer. The response shape changes
|
|
1651
|
+
// (vault names are leaked only to authed callers) but the status code is
|
|
1652
|
+
// invariant.
|
|
1653
|
+
// ---------------------------------------------------------------------------
|
|
1654
|
+
|
|
1655
|
+
describe("/health — always 200 (Render/Docker healthcheck contract)", () => {
|
|
1656
|
+
let prevToken: string | undefined;
|
|
1657
|
+
|
|
1658
|
+
beforeEach(() => {
|
|
1659
|
+
prevToken = process.env.VAULT_AUTH_TOKEN;
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
afterEach(() => {
|
|
1663
|
+
if (prevToken === undefined) delete process.env.VAULT_AUTH_TOKEN;
|
|
1664
|
+
else process.env.VAULT_AUTH_TOKEN = prevToken;
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
test("env unset + no bearer → 200 (anonymous probe)", async () => {
|
|
1668
|
+
delete process.env.VAULT_AUTH_TOKEN;
|
|
1669
|
+
createVault("journal");
|
|
1670
|
+
|
|
1671
|
+
const res = await route(new Request("http://localhost:1940/health"), "/health");
|
|
1672
|
+
expect(res.status).toBe(200);
|
|
1673
|
+
const body = await res.json();
|
|
1674
|
+
expect(body.status).toBe("ok");
|
|
1675
|
+
// No bearer → no vault names leaked.
|
|
1676
|
+
expect(body.vaults).toBeUndefined();
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
test("env set + no bearer → 200 (Render's health probe doesn't have the secret)", async () => {
|
|
1680
|
+
process.env.VAULT_AUTH_TOKEN = "operator-token-xyz";
|
|
1681
|
+
createVault("journal");
|
|
1682
|
+
|
|
1683
|
+
const res = await route(new Request("http://localhost:1940/health"), "/health");
|
|
1684
|
+
expect(res.status).toBe(200);
|
|
1685
|
+
const body = await res.json();
|
|
1686
|
+
expect(body.status).toBe("ok");
|
|
1687
|
+
expect(body.vaults).toBeUndefined();
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
test("env set + matching bearer → 200 + vault names leaked", async () => {
|
|
1691
|
+
process.env.VAULT_AUTH_TOKEN = "operator-token-xyz";
|
|
1692
|
+
createVault("journal");
|
|
1693
|
+
createVault("work");
|
|
1694
|
+
|
|
1695
|
+
const res = await route(
|
|
1696
|
+
new Request("http://localhost:1940/health", {
|
|
1697
|
+
headers: { Authorization: "Bearer operator-token-xyz" },
|
|
1698
|
+
}),
|
|
1699
|
+
"/health",
|
|
1700
|
+
);
|
|
1701
|
+
expect(res.status).toBe(200);
|
|
1702
|
+
const body = await res.json();
|
|
1703
|
+
expect(body.status).toBe("ok");
|
|
1704
|
+
expect(Array.isArray(body.vaults)).toBe(true);
|
|
1705
|
+
expect(body.vaults).toContain("journal");
|
|
1706
|
+
expect(body.vaults).toContain("work");
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
test("env set + wrong bearer → 200 (still healthy, just no vault detail)", async () => {
|
|
1710
|
+
// /health doesn't 401 on a wrong bearer — it just falls back to the
|
|
1711
|
+
// anonymous response. The operator's probe stays green even if the
|
|
1712
|
+
// bearer is mid-rotation.
|
|
1713
|
+
process.env.VAULT_AUTH_TOKEN = "operator-token-xyz";
|
|
1714
|
+
createVault("journal");
|
|
1715
|
+
|
|
1716
|
+
const res = await route(
|
|
1717
|
+
new Request("http://localhost:1940/health", {
|
|
1718
|
+
headers: { Authorization: "Bearer wrong-token" },
|
|
1719
|
+
}),
|
|
1720
|
+
"/health",
|
|
1721
|
+
);
|
|
1722
|
+
expect(res.status).toBe(200);
|
|
1723
|
+
const body = await res.json();
|
|
1724
|
+
expect(body.status).toBe("ok");
|
|
1725
|
+
expect(body.vaults).toBeUndefined();
|
|
1726
|
+
});
|
|
1727
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey, stopSignalPath } from "./config.ts";
|
|
19
19
|
import { existsSync, rmSync } from "fs";
|
|
20
20
|
import { migrateVaultKeys } from "./token-store.ts";
|
|
21
|
+
import { resolveFirstBootVaultName } from "./vault-name.ts";
|
|
21
22
|
import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
|
|
22
23
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
23
24
|
import { registerTriggers } from "./triggers.ts";
|
|
@@ -98,13 +99,31 @@ if (process.env.SCRIBE_URL) {
|
|
|
98
99
|
console.log("[transcribe] worker disabled (set SCRIBE_URL to enable)");
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
if (process.env.VAULT_AUTH_TOKEN?.trim()) {
|
|
103
|
+
console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Auto-init: create a default vault if none exist (first run in Docker).
|
|
107
|
+
// The vault name comes from PARACHUTE_VAULT_NAME when set + valid; otherwise
|
|
108
|
+
// falls back to "default". Hub's first-boot wizard (hub#267) passes through
|
|
109
|
+
// an operator-chosen name via this env var.
|
|
102
110
|
if (listVaults().length === 0) {
|
|
103
111
|
const globalConfig = readGlobalConfig();
|
|
104
112
|
if (!globalConfig.default_vault) {
|
|
113
|
+
const firstBoot = resolveFirstBootVaultName(process.env.PARACHUTE_VAULT_NAME);
|
|
114
|
+
if (firstBoot.source === "env") {
|
|
115
|
+
console.log(`[vault first-boot] using PARACHUTE_VAULT_NAME=${firstBoot.name}`);
|
|
116
|
+
} else if (firstBoot.source === "env-invalid") {
|
|
117
|
+
console.warn(
|
|
118
|
+
`[vault first-boot] PARACHUTE_VAULT_NAME=${JSON.stringify(firstBoot.rawValue)} is invalid (${firstBoot.reason}); falling back to "default"`,
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
console.log("[vault first-boot] using default name (no PARACHUTE_VAULT_NAME set)");
|
|
122
|
+
}
|
|
123
|
+
const vaultName = firstBoot.name;
|
|
105
124
|
const { fullKey, keyId } = generateApiKey();
|
|
106
125
|
writeVaultConfig({
|
|
107
|
-
name:
|
|
126
|
+
name: vaultName,
|
|
108
127
|
api_keys: [{
|
|
109
128
|
id: keyId,
|
|
110
129
|
label: "default",
|
|
@@ -114,7 +133,7 @@ if (listVaults().length === 0) {
|
|
|
114
133
|
}],
|
|
115
134
|
created_at: new Date().toISOString(),
|
|
116
135
|
});
|
|
117
|
-
globalConfig.default_vault =
|
|
136
|
+
globalConfig.default_vault = vaultName;
|
|
118
137
|
if (!globalConfig.api_keys?.length) {
|
|
119
138
|
globalConfig.api_keys = [{
|
|
120
139
|
id: keyId,
|
|
@@ -125,7 +144,7 @@ if (listVaults().length === 0) {
|
|
|
125
144
|
}];
|
|
126
145
|
}
|
|
127
146
|
writeGlobalConfig(globalConfig);
|
|
128
|
-
console.log(`Auto-created
|
|
147
|
+
console.log(`Auto-created vault "${vaultName}" (API key: ${fullKey})`);
|
|
129
148
|
}
|
|
130
149
|
}
|
|
131
150
|
|
package/src/vault-name.test.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unit tests for `validateVaultName` — the rule enforced by the `init`
|
|
3
|
-
* prompt
|
|
4
|
-
*
|
|
3
|
+
* prompt, the `--vault-name` flag, and the `PARACHUTE_VAULT_NAME` env var
|
|
4
|
+
* at server first-boot. Covers each rejection branch (empty, length,
|
|
5
|
+
* regex, reserved) plus the happy paths the prompt has to accept (default,
|
|
6
|
+
* hyphens, underscores, length boundaries).
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { describe, test, expect } from "bun:test";
|
|
8
|
-
import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
|
|
10
|
+
import { validateVaultName, decideInitVaultName, resolveFirstBootVaultName } from "./vault-name.ts";
|
|
9
11
|
|
|
10
12
|
describe("validateVaultName", () => {
|
|
11
13
|
describe("accepts", () => {
|
|
@@ -14,11 +16,12 @@ describe("validateVaultName", () => {
|
|
|
14
16
|
"aaron",
|
|
15
17
|
"personal",
|
|
16
18
|
"work",
|
|
17
|
-
"
|
|
19
|
+
"ab", // 2-char boundary (min length)
|
|
18
20
|
"vault-1",
|
|
19
21
|
"my_vault",
|
|
20
22
|
"a-b_c-1",
|
|
21
23
|
"abc123",
|
|
24
|
+
"a".repeat(32), // 32-char boundary (max length)
|
|
22
25
|
])("%s", (name) => {
|
|
23
26
|
const result = validateVaultName(name);
|
|
24
27
|
expect(result.ok).toBe(true);
|
|
@@ -71,6 +74,24 @@ describe("validateVaultName", () => {
|
|
|
71
74
|
expect(result.ok).toBe(false);
|
|
72
75
|
if (!result.ok) expect(result.error).toContain("reserved");
|
|
73
76
|
});
|
|
77
|
+
|
|
78
|
+
test("single character (below 2-char min)", () => {
|
|
79
|
+
const result = validateVaultName("a");
|
|
80
|
+
expect(result.ok).toBe(false);
|
|
81
|
+
if (!result.ok) expect(result.error).toContain("2");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("33 characters (above 32-char max)", () => {
|
|
85
|
+
const result = validateVaultName("a".repeat(33));
|
|
86
|
+
expect(result.ok).toBe(false);
|
|
87
|
+
if (!result.ok) expect(result.error).toContain("32");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("200 characters (well above max)", () => {
|
|
91
|
+
const result = validateVaultName("a".repeat(200));
|
|
92
|
+
expect(result.ok).toBe(false);
|
|
93
|
+
if (!result.ok) expect(result.error).toContain("32");
|
|
94
|
+
});
|
|
74
95
|
});
|
|
75
96
|
});
|
|
76
97
|
|
|
@@ -121,3 +142,78 @@ describe("decideInitVaultName", () => {
|
|
|
121
142
|
expect(d).toEqual({ kind: "name", name: "aaron" });
|
|
122
143
|
});
|
|
123
144
|
});
|
|
145
|
+
|
|
146
|
+
describe("resolveFirstBootVaultName", () => {
|
|
147
|
+
test("env var unset → fallback to default", () => {
|
|
148
|
+
const r = resolveFirstBootVaultName(undefined);
|
|
149
|
+
expect(r).toEqual({ source: "default", name: "default" });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("env var empty string → fallback to default", () => {
|
|
153
|
+
const r = resolveFirstBootVaultName("");
|
|
154
|
+
expect(r).toEqual({ source: "default", name: "default" });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("env var whitespace-only → fallback to default", () => {
|
|
158
|
+
const r = resolveFirstBootVaultName(" ");
|
|
159
|
+
expect(r).toEqual({ source: "default", name: "default" });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("env var set to a valid name → that name (positive path)", () => {
|
|
163
|
+
const r = resolveFirstBootVaultName("smoke-1939");
|
|
164
|
+
expect(r).toEqual({ source: "env", name: "smoke-1939" });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("env var set to a valid name with underscores → that name", () => {
|
|
168
|
+
const r = resolveFirstBootVaultName("my_vault");
|
|
169
|
+
expect(r).toEqual({ source: "env", name: "my_vault" });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("env var set with surrounding whitespace → trimmed + accepted", () => {
|
|
173
|
+
const r = resolveFirstBootVaultName(" aaron ");
|
|
174
|
+
expect(r).toEqual({ source: "env", name: "aaron" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("env var set to invalid (uppercase + spaces + special) → fallback to default + record raw + reason", () => {
|
|
178
|
+
const r = resolveFirstBootVaultName("Bad Name!!");
|
|
179
|
+
expect(r.source).toBe("env-invalid");
|
|
180
|
+
expect(r.name).toBe("default");
|
|
181
|
+
if (r.source === "env-invalid") {
|
|
182
|
+
expect(r.rawValue).toBe("Bad Name!!");
|
|
183
|
+
expect(r.reason).toContain("lowercase alphanumeric");
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("env var set to reserved name 'list' → fallback to default", () => {
|
|
188
|
+
const r = resolveFirstBootVaultName("list");
|
|
189
|
+
expect(r.source).toBe("env-invalid");
|
|
190
|
+
expect(r.name).toBe("default");
|
|
191
|
+
if (r.source === "env-invalid") {
|
|
192
|
+
expect(r.reason).toContain("reserved");
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("env var set to slash-containing name → fallback to default", () => {
|
|
197
|
+
const r = resolveFirstBootVaultName("team/work");
|
|
198
|
+
expect(r.source).toBe("env-invalid");
|
|
199
|
+
expect(r.name).toBe("default");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("env var set to a 200-char name → fallback to default (over max-length)", () => {
|
|
203
|
+
const r = resolveFirstBootVaultName("a".repeat(200));
|
|
204
|
+
expect(r.source).toBe("env-invalid");
|
|
205
|
+
expect(r.name).toBe("default");
|
|
206
|
+
if (r.source === "env-invalid") {
|
|
207
|
+
expect(r.reason).toContain("32");
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("env var set to a single character → fallback to default (under min-length)", () => {
|
|
212
|
+
const r = resolveFirstBootVaultName("a");
|
|
213
|
+
expect(r.source).toBe("env-invalid");
|
|
214
|
+
expect(r.name).toBe("default");
|
|
215
|
+
if (r.source === "env-invalid") {
|
|
216
|
+
expect(r.reason).toContain("2");
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
package/src/vault-name.ts
CHANGED
|
@@ -5,12 +5,17 @@
|
|
|
5
5
|
* the SQLite filename, and the OAuth consent page — anything that breaks
|
|
6
6
|
* URL routing or filesystem assumptions has to be rejected up front.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Rule: lowercase alphanumeric + hyphens or underscores, 2–32 chars, with
|
|
9
|
+
* `list` reserved. Used by the `init` prompt, the `--vault-name` flag, and
|
|
10
|
+
* the `PARACHUTE_VAULT_NAME` env var at server first-boot. `cmdCreate`
|
|
11
|
+
* keeps its own (slightly more permissive, legacy) regex for backward
|
|
12
|
+
* compat — tightening it would reject names existing users may already
|
|
13
|
+
* have minted.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
const VAULT_NAME_RE = /^[a-z0-9_-]+$/;
|
|
17
|
+
const VAULT_NAME_MIN_LEN = 2;
|
|
18
|
+
const VAULT_NAME_MAX_LEN = 32;
|
|
14
19
|
|
|
15
20
|
const RESERVED_NAMES = new Set([
|
|
16
21
|
// Collides with the `/vaults/list` discovery endpoint historically; the
|
|
@@ -23,11 +28,24 @@ export type VaultNameValidation =
|
|
|
23
28
|
| { ok: true; name: string }
|
|
24
29
|
| { ok: false; error: string };
|
|
25
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Validate a vault name. Accepts lowercase alphanumeric + hyphens or
|
|
33
|
+
* underscores, 2–32 chars. Trims surrounding whitespace before checking.
|
|
34
|
+
* `cmdCreate` keeps its own (legacy-permissive) regex; this validator is
|
|
35
|
+
* the strict gate used by the env var, the `--vault-name` flag, and
|
|
36
|
+
* hub's first-boot wizard.
|
|
37
|
+
*/
|
|
26
38
|
export function validateVaultName(raw: string): VaultNameValidation {
|
|
27
39
|
const name = raw.trim();
|
|
28
40
|
if (!name) {
|
|
29
41
|
return { ok: false, error: "vault name cannot be empty." };
|
|
30
42
|
}
|
|
43
|
+
if (name.length < VAULT_NAME_MIN_LEN || name.length > VAULT_NAME_MAX_LEN) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
error: `vault names must be ${VAULT_NAME_MIN_LEN}–${VAULT_NAME_MAX_LEN} characters long.`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
31
49
|
if (!VAULT_NAME_RE.test(name)) {
|
|
32
50
|
return {
|
|
33
51
|
ok: false,
|
|
@@ -78,3 +96,43 @@ export function decideInitVaultName(
|
|
|
78
96
|
}
|
|
79
97
|
return { kind: "prompt" };
|
|
80
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pick the first-boot vault name based on `PARACHUTE_VAULT_NAME`. Used by
|
|
102
|
+
* `server.ts` when the server starts with zero vaults on disk (Docker
|
|
103
|
+
* first-boot, hub-driven self-host install).
|
|
104
|
+
*
|
|
105
|
+
* - env var unset / empty / whitespace-only → `{ source: "default", name: "default" }`
|
|
106
|
+
* - env var present + valid → `{ source: "env", name: <validated> }`
|
|
107
|
+
* - env var present + invalid → `{ source: "env-invalid", name: "default",
|
|
108
|
+
* rawValue: <original>, reason: <validator message> }` (caller logs a
|
|
109
|
+
* warning and proceeds with the default name; we never abort first-boot
|
|
110
|
+
* over a misconfigured env var)
|
|
111
|
+
*
|
|
112
|
+
* Validation uses the same `validateVaultName` rule as the `--vault-name`
|
|
113
|
+
* flag — lowercase alphanumeric + hyphens or underscores, 2–32 chars, with
|
|
114
|
+
* the `list` reserved-name carveout — so hub's wizard, the CLI flag, and
|
|
115
|
+
* the env var all share one truth.
|
|
116
|
+
*/
|
|
117
|
+
export type FirstBootVaultName =
|
|
118
|
+
| { source: "default"; name: "default" }
|
|
119
|
+
| { source: "env"; name: string }
|
|
120
|
+
| { source: "env-invalid"; name: "default"; rawValue: string; reason: string };
|
|
121
|
+
|
|
122
|
+
export function resolveFirstBootVaultName(
|
|
123
|
+
rawEnvValue: string | undefined,
|
|
124
|
+
): FirstBootVaultName {
|
|
125
|
+
if (rawEnvValue === undefined || rawEnvValue.trim() === "") {
|
|
126
|
+
return { source: "default", name: "default" };
|
|
127
|
+
}
|
|
128
|
+
const v = validateVaultName(rawEnvValue);
|
|
129
|
+
if (v.ok) {
|
|
130
|
+
return { source: "env", name: v.name };
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
source: "env-invalid",
|
|
134
|
+
name: "default",
|
|
135
|
+
rawValue: rawEnvValue,
|
|
136
|
+
reason: v.error,
|
|
137
|
+
};
|
|
138
|
+
}
|