@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 +70 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +161 -1
- package/src/auth.test.ts +235 -0
- package/src/auth.ts +135 -3
- package/src/cli.ts +420 -22
- package/src/export-watch.test.ts +811 -0
- package/src/export-watch.ts +255 -0
- package/src/mcp-config.test.ts +260 -0
- package/src/mcp-install.test.ts +60 -0
- package/src/mcp-install.ts +61 -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/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
|
+
}
|