@openparachute/vault 0.2.4 → 0.3.0

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 (102) hide show
  1. package/.claude/settings.local.json +2 -25
  2. package/CHANGELOG.md +64 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +591 -19
  6. package/core/src/hooks.ts +111 -3
  7. package/core/src/indexed-fields.test.ts +285 -0
  8. package/core/src/indexed-fields.ts +238 -0
  9. package/core/src/mcp.ts +127 -6
  10. package/core/src/notes.ts +153 -11
  11. package/core/src/query-operators.ts +174 -0
  12. package/core/src/schema.ts +69 -2
  13. package/core/src/store.ts +95 -1
  14. package/core/src/tag-schemas.ts +5 -0
  15. package/core/src/types.ts +28 -1
  16. package/docs/HTTP_API.md +105 -1
  17. package/docs/auth-model.md +340 -0
  18. package/package/package.json +32 -0
  19. package/package.json +2 -2
  20. package/src/auth.test.ts +83 -114
  21. package/src/auth.ts +68 -6
  22. package/src/backup-launchd.ts +1 -1
  23. package/src/backup.test.ts +1 -1
  24. package/src/backup.ts +18 -17
  25. package/src/bind.test.ts +28 -0
  26. package/src/bind.ts +19 -0
  27. package/src/cli.ts +228 -133
  28. package/src/config-triggers.test.ts +49 -0
  29. package/src/config.test.ts +317 -2
  30. package/src/config.ts +420 -40
  31. package/src/context.test.ts +136 -0
  32. package/src/context.ts +115 -0
  33. package/src/daemon.ts +17 -16
  34. package/src/doctor.test.ts +9 -7
  35. package/src/launchd.test.ts +1 -1
  36. package/src/launchd.ts +6 -6
  37. package/src/mcp-http.ts +75 -21
  38. package/src/mcp-install.test.ts +125 -0
  39. package/src/mcp-install.ts +60 -0
  40. package/src/mcp-tools.ts +34 -96
  41. package/src/module-config.ts +109 -0
  42. package/src/oauth.test.ts +345 -57
  43. package/src/oauth.ts +155 -35
  44. package/src/published.test.ts +2 -2
  45. package/src/routes.ts +209 -33
  46. package/src/routing.test.ts +817 -300
  47. package/src/routing.ts +204 -202
  48. package/src/scopes.test.ts +294 -0
  49. package/src/scopes.ts +253 -0
  50. package/src/scribe-env.test.ts +49 -0
  51. package/src/scribe-env.ts +33 -0
  52. package/src/server.ts +73 -9
  53. package/src/services-manifest.test.ts +140 -0
  54. package/src/services-manifest.ts +99 -0
  55. package/src/systemd.ts +3 -3
  56. package/src/token-store.ts +42 -9
  57. package/src/transcription-worker.test.ts +864 -0
  58. package/src/transcription-worker.ts +501 -0
  59. package/src/triggers.test.ts +191 -1
  60. package/src/triggers.ts +17 -2
  61. package/src/vault.test.ts +693 -77
  62. package/src/version.test.ts +1 -1
  63. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  64. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  65. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  66. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  67. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  68. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  69. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  70. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  71. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  72. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  73. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  74. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  75. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  76. package/religions-abrahamic-filter.png +0 -0
  77. package/religions-buddhism-v2.png +0 -0
  78. package/religions-buddhism.png +0 -0
  79. package/religions-final.png +0 -0
  80. package/religions-v1.png +0 -0
  81. package/religions-v2.png +0 -0
  82. package/religions-zen.png +0 -0
  83. package/web/README.md +0 -73
  84. package/web/bun.lock +0 -827
  85. package/web/eslint.config.js +0 -23
  86. package/web/index.html +0 -15
  87. package/web/package.json +0 -36
  88. package/web/public/favicon.svg +0 -1
  89. package/web/public/icons.svg +0 -24
  90. package/web/src/App.tsx +0 -149
  91. package/web/src/Graph.tsx +0 -200
  92. package/web/src/NoteView.tsx +0 -155
  93. package/web/src/Sidebar.tsx +0 -186
  94. package/web/src/api.ts +0 -21
  95. package/web/src/index.css +0 -50
  96. package/web/src/main.tsx +0 -10
  97. package/web/src/types.ts +0 -37
  98. package/web/src/utils.ts +0 -107
  99. package/web/tsconfig.app.json +0 -25
  100. package/web/tsconfig.json +0 -7
  101. package/web/tsconfig.node.json +0 -24
  102. package/web/vite.config.ts +0 -16
package/src/auth.test.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Auth invariants — routing coherence between unscoped and scoped paths.
2
+ * Auth invariants — per-vault routing with strict isolation.
3
3
  *
4
- * See Fix 2 in the OAuth-to-Daily launch work: a vault token minted by one
5
- * path (unscoped `/oauth/token` or scoped `/vaults/X/oauth/token`) must
6
- * authenticate identically at every endpoint that addresses the same vault,
7
- * regardless of whether the URL uses `/api/*` (default-vault shortcut) or
8
- * `/vaults/X/api/*` (explicit). Same for `/mcp` vs `/vaults/X/mcp`.
4
+ * Every HTTP path that touches a vault lives under `/vault/<name>/...`, so
5
+ * a token minted for vault A must authenticate at vault A endpoints and
6
+ * must not authenticate at vault B endpoints. The global auth path still
7
+ * exists for cross-vault listings (`/vaults`) and scans every vault's DB.
9
8
  *
10
9
  * These tests isolate `PARACHUTE_HOME` so they don't touch the user's real
11
10
  * config. Each test builds 1-2 vaults from scratch.
@@ -34,7 +33,7 @@ let prevHome: string | undefined;
34
33
 
35
34
  beforeEach(() => {
36
35
  tmpHome = join(tmpdir(), `vault-auth-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
37
- mkdirSync(join(tmpHome, "vaults"), { recursive: true });
36
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
38
37
  prevHome = process.env.PARACHUTE_HOME;
39
38
  process.env.PARACHUTE_HOME = tmpHome;
40
39
  clearVaultStoreCache();
@@ -83,51 +82,35 @@ function bearer(token: string): Request {
83
82
  });
84
83
  }
85
84
 
86
- describe("auth — default-vault routing coherence", () => {
87
- test("token minted in default vault authenticates at both unscoped and scoped paths", () => {
88
- seedVault("default", { isDefault: true });
89
- const token = mintTokenInVault("default");
90
- const defaultConfig = readVaultConfig("default")!;
91
- const defaultStore = getVaultStore("default");
92
-
93
- // Unscoped `/api/*` flow: server resolves default vault, calls
94
- // authenticateVaultRequest with default's config + DB. Token must resolve.
95
- const unscoped = authenticateVaultRequest(bearer(token), defaultConfig, defaultStore.db);
96
- expect("error" in unscoped).toBe(false);
97
- if (!("error" in unscoped)) expect(unscoped.permission).toBe("full");
98
-
99
- // Scoped `/vaults/default/api/*` flow: same defaultConfig + DB. Must also
100
- // resolve this is the invariant Aaron's complaint hinges on.
101
- const scoped = authenticateVaultRequest(bearer(token), defaultConfig, defaultStore.db);
102
- expect("error" in scoped).toBe(false);
103
-
104
- // Unified `/mcp` flow: authenticateGlobalRequest scans every vault's DB.
105
- // Since the token is in default's DB, this must also resolve.
85
+ describe("auth — per-vault routing", () => {
86
+ test("token minted in a vault authenticates at its own /vault/<name>/* endpoints", () => {
87
+ seedVault("journal");
88
+ const token = mintTokenInVault("journal");
89
+ const journalConfig = readVaultConfig("journal")!;
90
+ const journalStore = getVaultStore("journal");
91
+
92
+ // /vault/journal/api/* and /vault/journal/mcp both funnel into
93
+ // authenticateVaultRequest with journal's config + DB.
94
+ const vaultAuth = authenticateVaultRequest(bearer(token), journalConfig, journalStore.db);
95
+ expect("error" in vaultAuth).toBe(false);
96
+ if (!("error" in vaultAuth)) expect(vaultAuth.permission).toBe("full");
97
+
98
+ // /vaults (global metadata listing) uses authenticateGlobalRequest which
99
+ // scans every vault's DB. Since the token is in journal's DB, it must resolve.
106
100
  const global = authenticateGlobalRequest(bearer(token));
107
101
  expect("error" in global).toBe(false);
108
102
  });
109
103
 
110
- // HTTP-level routing stand-in. Mirrors server.ts's vault-resolution step:
111
- // unscoped `/api/*` resolves to the default vault; scoped `/vaults/X/api/*`
112
- // extracts the name from the URL. After resolution both paths funnel into
113
- // `authenticateVaultRequest` with that vault's config + DB. The earlier
114
- // version of this test called `authenticateVaultRequest` twice with the same
115
- // args and labelled the calls "scoped"/"unscoped" — tautological, because
116
- // routing was never exercised. This variant drives the resolver from the
117
- // URL, so the routing step is the thing under test.
104
+ // HTTP-level routing stand-in. Mirrors routing.ts: every vault-scoped path
105
+ // matches `/vault/<name>/...`, we look the vault up, then authenticate the
106
+ // request against that vault's DB.
118
107
  function dispatchAuthFromPath(path: string, req: Request): {
119
108
  status: number;
120
109
  permission?: string;
121
110
  } {
122
- let vaultName: string;
123
- if (path.startsWith("/vaults/")) {
124
- vaultName = path.split("/")[2];
125
- } else if (path.startsWith("/api/")) {
126
- const gc = readGlobalConfig();
127
- vaultName = gc.default_vault ?? "default";
128
- } else {
129
- return { status: 404 };
130
- }
111
+ const match = path.match(/^\/vault\/([^/]+)(\/.*)?$/);
112
+ if (!match) return { status: 404 };
113
+ const vaultName = match[1];
131
114
  const vaultConfig = readVaultConfig(vaultName);
132
115
  if (!vaultConfig) return { status: 404 };
133
116
  const store = getVaultStore(vaultName);
@@ -136,77 +119,71 @@ describe("auth — default-vault routing coherence", () => {
136
119
  return { status: 200, permission: res.permission };
137
120
  }
138
121
 
139
- test("routing coherence: unscoped and scoped /api/health accept a default-vault token identically", () => {
140
- seedVault("default", { isDefault: true });
141
- const token = mintTokenInVault("default");
142
-
143
- // (a) unscoped /api/health with default-vault token
144
- const unscoped = dispatchAuthFromPath("/api/health", bearer(token));
145
- expect(unscoped.status).toBe(200);
146
-
147
- // (b) scoped /vaults/default/api/health with the same token
148
- const scoped = dispatchAuthFromPath("/vaults/default/api/health", bearer(token));
149
- expect(scoped.status).toBe(200);
122
+ test("routing: /vault/<name>/api/health accepts a token minted in that vault", () => {
123
+ seedVault("journal");
124
+ const token = mintTokenInVault("journal");
150
125
 
151
- // Both paths resolve the same vault → same permission level comes back.
152
- expect(unscoped.permission).toBe(scoped.permission);
126
+ const result = dispatchAuthFromPath("/vault/journal/api/health", bearer(token));
127
+ expect(result.status).toBe(200);
128
+ expect(result.permission).toBe("full");
153
129
  });
154
130
 
155
- test("routing coherence: scoped /vaults/X/api/health rejects a token issued for vault Y", () => {
131
+ test("routing: /vault/A/api/* rejects a token issued for vault B", () => {
156
132
  // The privilege-escalation barrier: a valid token for vault A must not
157
- // authenticate at vault B's scoped endpoint, even though the URL is
158
- // well-formed and the token itself is valid for *some* vault.
159
- seedVault("default", { isDefault: true });
133
+ // authenticate at vault B's endpoint, even though the token is valid
134
+ // for some vault. This is the point of per-vault DBs.
135
+ seedVault("journal");
160
136
  seedVault("work");
161
137
  const workToken = mintTokenInVault("work");
162
138
 
163
- const crossVault = dispatchAuthFromPath("/vaults/default/api/health", bearer(workToken));
139
+ const crossVault = dispatchAuthFromPath("/vault/journal/api/health", bearer(workToken));
164
140
  expect(crossVault.status).toBe(401);
165
141
  });
142
+
143
+ test("routing: /vault/<unknown> returns 404 (not 401)", () => {
144
+ seedVault("journal");
145
+ const token = mintTokenInVault("journal");
146
+ const result = dispatchAuthFromPath("/vault/nonexistent/api/health", bearer(token));
147
+ expect(result.status).toBe(404);
148
+ });
166
149
  });
167
150
 
168
- describe("auth — named-vault routing coherence", () => {
151
+ describe("auth — cross-vault isolation", () => {
169
152
  test("token minted in a non-default vault authenticates via scoped and global paths", () => {
170
- seedVault("default", { isDefault: true });
153
+ seedVault("journal", { isDefault: true });
171
154
  seedVault("work");
172
155
  const workToken = mintTokenInVault("work");
173
156
  const workConfig = readVaultConfig("work")!;
174
157
  const workStore = getVaultStore("work");
175
158
 
176
- // Scoped `/vaults/work/api/*` — must resolve against work's DB.
177
159
  const scoped = authenticateVaultRequest(bearer(workToken), workConfig, workStore.db);
178
160
  expect("error" in scoped).toBe(false);
179
161
 
180
- // Unified `/mcp` — global auth scans all vaults, must find it.
162
+ // Global auth scans every vault, must find the token in work's DB.
181
163
  const global = authenticateGlobalRequest(bearer(workToken));
182
164
  expect("error" in global).toBe(false);
183
165
  });
184
166
 
185
- test("a work-vault token does NOT authenticate against the default vault's /api/*", () => {
186
- // This is the correct isolation behavior: a token scoped to vault X has no
187
- // business being accepted at endpoints that address vault Y. If this ever
188
- // regressed, we'd have a privilege-escalation bug (read a different vault
189
- // by just sending a valid token at the wrong URL).
190
- seedVault("default", { isDefault: true });
167
+ test("a work-vault token does NOT authenticate against the journal vault", () => {
168
+ seedVault("journal", { isDefault: true });
191
169
  seedVault("work");
192
170
  const workToken = mintTokenInVault("work");
193
- const defaultConfig = readVaultConfig("default")!;
194
- const defaultStore = getVaultStore("default");
171
+ const journalConfig = readVaultConfig("journal")!;
172
+ const journalStore = getVaultStore("journal");
195
173
 
196
- const res = authenticateVaultRequest(bearer(workToken), defaultConfig, defaultStore.db);
174
+ const res = authenticateVaultRequest(bearer(workToken), journalConfig, journalStore.db);
197
175
  expect("error" in res).toBe(true);
198
176
  });
199
177
  });
200
178
 
201
179
  // ---------------------------------------------------------------------------
202
- // End-to-end: OAuth flow → resulting token authenticates at expected paths
180
+ // End-to-end: OAuth flow → resulting token authenticates against its vault
203
181
  // ---------------------------------------------------------------------------
204
182
 
205
- describe("OAuth-minted tokens — cross-endpoint coherence", () => {
183
+ describe("OAuth-minted tokens — per-vault coherence", () => {
206
184
  // These tests drive the OAuth handlers directly (no HTTP), then take the
207
- // resulting access_token and verify it resolves at every endpoint that
208
- // addresses its issuing vault. This is the key coherence invariant for
209
- // Aaron's launch complaint.
185
+ // resulting access_token and verify it resolves at endpoints addressing
186
+ // its issuing vault and only its issuing vault.
210
187
 
211
188
  async function runOAuthFlow(vaultName: string): Promise<string> {
212
189
  const store = getVaultStore(vaultName);
@@ -218,7 +195,7 @@ describe("OAuth-minted tokens — cross-endpoint coherence", () => {
218
195
 
219
196
  // 1. Register client
220
197
  const regRes = await handleRegister(
221
- new Request("https://vault.test/oauth/register", {
198
+ new Request(`https://vault.test/vault/${vaultName}/oauth/register`, {
222
199
  method: "POST",
223
200
  headers: { "Content-Type": "application/json" },
224
201
  body: JSON.stringify({
@@ -234,7 +211,7 @@ describe("OAuth-minted tokens — cross-endpoint coherence", () => {
234
211
  const codeVerifier = crypto.randomBytes(32).toString("base64url");
235
212
  const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
236
213
  const authRes = await handleAuthorizePost(
237
- new Request("https://vault.test/oauth/authorize", {
214
+ new Request(`https://vault.test/vault/${vaultName}/oauth/authorize`, {
238
215
  method: "POST",
239
216
  body: new URLSearchParams({
240
217
  action: "authorize",
@@ -253,7 +230,7 @@ describe("OAuth-minted tokens — cross-endpoint coherence", () => {
253
230
 
254
231
  // 3. Token exchange
255
232
  const tokRes = await handleToken(
256
- new Request("https://vault.test/oauth/token", {
233
+ new Request(`https://vault.test/vault/${vaultName}/oauth/token`, {
257
234
  method: "POST",
258
235
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
259
236
  body: new URLSearchParams({
@@ -272,48 +249,40 @@ describe("OAuth-minted tokens — cross-endpoint coherence", () => {
272
249
  return tokBody.access_token;
273
250
  }
274
251
 
275
- test("default-vault OAuth: token works at /api/*, /mcp, /vaults/default/api/*, /vaults/default/mcp", async () => {
276
- seedVault("default", { isDefault: true });
277
- const token = await runOAuthFlow("default");
278
- const cfg = readVaultConfig("default")!;
279
- const store = getVaultStore("default");
280
-
281
- // `/api/*` — unscoped path resolves default vault, calls authenticateVaultRequest.
282
- const apiUnscoped = authenticateVaultRequest(bearer(token), cfg, store.db);
283
- expect("error" in apiUnscoped).toBe(false);
252
+ test("OAuth-minted token works at /vault/<name>/api/* and /vault/<name>/mcp", async () => {
253
+ seedVault("journal", { isDefault: true });
254
+ const token = await runOAuthFlow("journal");
255
+ const cfg = readVaultConfig("journal")!;
256
+ const store = getVaultStore("journal");
284
257
 
285
- // `/vaults/default/api/*` scoped path resolves same default, same DB, same call.
286
- const apiScoped = authenticateVaultRequest(bearer(token), cfg, store.db);
287
- expect("error" in apiScoped).toBe(false);
258
+ // /vault/journal/api/* and /vault/journal/mcp both reach this auth call.
259
+ const vaultAuth = authenticateVaultRequest(bearer(token), cfg, store.db);
260
+ expect("error" in vaultAuth).toBe(false);
288
261
 
289
- // `/mcp` unified endpoint uses authenticateGlobalRequest which scans all DBs.
290
- const mcpUnscoped = authenticateGlobalRequest(bearer(token));
291
- expect("error" in mcpUnscoped).toBe(false);
292
-
293
- // `/vaults/default/mcp` — scoped MCP uses authenticateVaultRequest (same as api).
294
- const mcpScoped = authenticateVaultRequest(bearer(token), cfg, store.db);
295
- expect("error" in mcpScoped).toBe(false);
262
+ // /vaults (authenticated listing) uses authenticateGlobalRequest.
263
+ const global = authenticateGlobalRequest(bearer(token));
264
+ expect("error" in global).toBe(false);
296
265
  });
297
266
 
298
- test("named-vault OAuth: token works at /vaults/X/api/*, /vaults/X/mcp, /mcp", async () => {
299
- seedVault("default", { isDefault: true });
267
+ test("named-vault OAuth: token works for its vault, rejected by others", async () => {
268
+ seedVault("journal", { isDefault: true });
300
269
  seedVault("work");
301
270
  const token = await runOAuthFlow("work");
302
271
  const workCfg = readVaultConfig("work")!;
303
272
  const workStore = getVaultStore("work");
304
273
 
305
- // Scoped endpoints addressing vault work must resolve.
306
- const apiScoped = authenticateVaultRequest(bearer(token), workCfg, workStore.db);
307
- expect("error" in apiScoped).toBe(false);
274
+ // Valid at work's own endpoints.
275
+ const scoped = authenticateVaultRequest(bearer(token), workCfg, workStore.db);
276
+ expect("error" in scoped).toBe(false);
308
277
 
309
- // Unified /mcp scans all vaults, must find the token in work's DB.
310
- const mcpUnified = authenticateGlobalRequest(bearer(token));
311
- expect("error" in mcpUnified).toBe(false);
278
+ // Global auth finds the token in work's DB.
279
+ const global = authenticateGlobalRequest(bearer(token));
280
+ expect("error" in global).toBe(false);
312
281
 
313
- // Defensive: the same token is NOT usable against the default vault's /api/*.
314
- const defaultCfg = readVaultConfig("default")!;
315
- const defaultStore = getVaultStore("default");
316
- const crossCheck = authenticateVaultRequest(bearer(token), defaultCfg, defaultStore.db);
282
+ // Isolation: the token is NOT usable against the journal vault.
283
+ const journalCfg = readVaultConfig("journal")!;
284
+ const journalStore = getVaultStore("journal");
285
+ const crossCheck = authenticateVaultRequest(bearer(token), journalCfg, journalStore.db);
317
286
  expect("error" in crossCheck).toBe(true);
318
287
  });
319
288
  });
package/src/auth.ts CHANGED
@@ -21,10 +21,56 @@ import { resolveToken } from "./token-store.ts";
21
21
  import type { TokenPermission } from "./token-store.ts";
22
22
  import type { Database } from "bun:sqlite";
23
23
  import { getVaultStore } from "./vault-store.ts";
24
+ import { hasScope, legacyPermissionToScopes, SCOPE_ADMIN, SCOPE_READ, SCOPE_WRITE } from "./scopes.ts";
24
25
 
25
26
  /** Result of a successful auth check. */
26
27
  export interface AuthResult {
27
28
  permission: TokenPermission;
29
+ /** OAuth-standard scopes granted to this token. */
30
+ scopes: string[];
31
+ /**
32
+ * True iff scopes were derived from a legacy permission value (no `vault:*`
33
+ * scopes stored on the token row or legacy YAML key). Callers should log a
34
+ * one-time deprecation warning when they encounter `legacyDerived: true`.
35
+ */
36
+ legacyDerived: boolean;
37
+ }
38
+
39
+ /**
40
+ * Convert a legacy "read" | "full" permission into scopes + the legacyDerived
41
+ * flag. Used for legacy YAML key authentication paths and for tokens whose
42
+ * `scopes` column is still NULL.
43
+ */
44
+ function legacyAuthResult(permission: TokenPermission): AuthResult {
45
+ return {
46
+ permission,
47
+ scopes: legacyPermissionToScopes(permission),
48
+ legacyDerived: true,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Guard: does the authenticated request carry the required scope?
54
+ * Uses `hasScope` inheritance: admin ⊇ write ⊇ read.
55
+ */
56
+ export function requireScope(auth: AuthResult, required: string): boolean {
57
+ return hasScope(auth.scopes, required);
58
+ }
59
+
60
+ // One-shot deprecation warning tracker, keyed by token hash / legacy label so
61
+ // we don't spam the log on every request.
62
+ const warnedLegacyTokens = new Set<string>();
63
+
64
+ /**
65
+ * Log a one-time deprecation warning for legacy-derived auth results.
66
+ * Safe to call on every request — dedupes internally by cache key.
67
+ */
68
+ export function warnLegacyOnce(cacheKey: string, context: string): void {
69
+ if (warnedLegacyTokens.has(cacheKey)) return;
70
+ warnedLegacyTokens.add(cacheKey);
71
+ console.warn(
72
+ `[scopes] legacy permission-based auth used (${context}); migrate to vault:read / vault:write / vault:admin scopes. This compat shim will be removed after the next release.`,
73
+ );
28
74
  }
29
75
 
30
76
  /** Read-only tools (the only tools allowed for "read" permission). */
@@ -33,7 +79,6 @@ const READ_TOOLS = new Set([
33
79
  "list-tags",
34
80
  "find-path",
35
81
  "vault-info",
36
- "list-vaults",
37
82
  ]);
38
83
 
39
84
  /** Check if a tool call is allowed for a given permission level. */
@@ -100,7 +145,14 @@ export function authenticateVaultRequest(
100
145
  try {
101
146
  const resolved = resolveToken(vaultDb, key);
102
147
  if (resolved) {
103
- return { permission: resolved.permission };
148
+ if (resolved.legacyDerived) {
149
+ warnLegacyOnce(`vault-token:${vaultConfig.name ?? ""}`, "vault token without scopes column");
150
+ }
151
+ return {
152
+ permission: resolved.permission,
153
+ scopes: resolved.scopes,
154
+ legacyDerived: resolved.legacyDerived,
155
+ };
104
156
  }
105
157
  } catch {
106
158
  // Token table might not exist yet — fall through to legacy auth
@@ -111,7 +163,8 @@ export function authenticateVaultRequest(
111
163
  const vaultKey = validateKey(vaultConfig.api_keys, key);
112
164
  if (vaultKey) {
113
165
  try { writeVaultConfig(vaultConfig); } catch {}
114
- return { permission: vaultKey.scope === "read" ? "read" : "full" };
166
+ warnLegacyOnce(`yaml-vault:${vaultKey.key_hash}`, "vault.yaml api_keys");
167
+ return legacyAuthResult(vaultKey.scope === "read" ? "read" : "full");
115
168
  }
116
169
 
117
170
  // Legacy: check global keys from config.yaml
@@ -120,7 +173,8 @@ export function authenticateVaultRequest(
120
173
  const globalKey = validateKey(globalConfig.api_keys, key);
121
174
  if (globalKey) {
122
175
  try { writeGlobalConfig(globalConfig); } catch {}
123
- return { permission: globalKey.scope === "read" ? "read" : "full" };
176
+ warnLegacyOnce(`yaml-global:${globalKey.key_hash}`, "config.yaml api_keys");
177
+ return legacyAuthResult(globalKey.scope === "read" ? "read" : "full");
124
178
  }
125
179
  }
126
180
 
@@ -147,7 +201,8 @@ export function authenticateGlobalRequest(
147
201
  const matched = validateKey(globalConfig.api_keys, key);
148
202
  if (matched) {
149
203
  try { writeGlobalConfig(globalConfig); } catch {}
150
- return { permission: matched.scope === "read" ? "read" : "full" };
204
+ warnLegacyOnce(`yaml-global:${matched.key_hash}`, "config.yaml api_keys");
205
+ return legacyAuthResult(matched.scope === "read" ? "read" : "full");
151
206
  }
152
207
  }
153
208
 
@@ -159,7 +214,14 @@ export function authenticateGlobalRequest(
159
214
  const store = getVaultStore(vaultName);
160
215
  const resolved = resolveToken(store.db, key);
161
216
  if (resolved) {
162
- return { permission: resolved.permission };
217
+ if (resolved.legacyDerived) {
218
+ warnLegacyOnce(`vault-token:${vaultName}`, "vault token without scopes column");
219
+ }
220
+ return {
221
+ permission: resolved.permission,
222
+ scopes: resolved.scopes,
223
+ legacyDerived: resolved.legacyDerived,
224
+ };
163
225
  }
164
226
  } catch {
165
227
  // Skip vaults that can't be opened
@@ -35,7 +35,7 @@ export const BACKUP_PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${B
35
35
  * Resolve the CLI path the backup job should invoke. Sibling-to-server.ts
36
36
  * — we reuse `resolveServerPath()`'s dirname-of-module approach so a move
37
37
  * of the repo updates both the daemon and the backup agent on the next
38
- * `parachute vault backup --schedule <f>` run.
38
+ * `parachute-vault backup --schedule <f>` run.
39
39
  */
40
40
  export function resolveCliPath(): string {
41
41
  const serverPath = resolveServerPath(); // <repo>/src/server.ts
@@ -604,7 +604,7 @@ describe("backup — checkDestinationWritable", () => {
604
604
  });
605
605
 
606
606
  // ---------------------------------------------------------------------------
607
- // CLI integration — `parachute vault backup` + `backup status`
607
+ // CLI integration — `parachute-vault backup` + `backup status`
608
608
  // ---------------------------------------------------------------------------
609
609
 
610
610
  describe("CLI — vault backup", () => {
package/src/backup.ts CHANGED
@@ -24,8 +24,8 @@ import { tmpdir } from "os";
24
24
  import { Database } from "bun:sqlite";
25
25
  import { $ } from "bun";
26
26
  import {
27
- CONFIG_DIR,
28
- VAULTS_DIR,
27
+ VAULT_HOME,
28
+ DATA_DIR,
29
29
  GLOBAL_CONFIG_PATH,
30
30
  listVaults,
31
31
  vaultDir,
@@ -105,33 +105,34 @@ export function parseBackupFilename(name: string): { timestamp: string } | null
105
105
  // ---------------------------------------------------------------------------
106
106
 
107
107
  /**
108
- * Take a `VACUUM INTO` snapshot of every `.db` file we can find in
109
- * `CONFIG_DIR`:
108
+ * Take a `VACUUM INTO` snapshot of every `.db` file we can find under
109
+ * `VAULT_HOME`:
110
110
  *
111
- * 1. Top-level `~/.parachute/*.db` — covers legacy pre-multi-vault installs
112
- * (e.g. `daily.db`) and any user-placed sidecar DBs. Hits include `.bak`
113
- * copies, which we intentionally skip since they're already static
114
- * snapshots that `cp` would duplicate faster than VACUUM.
111
+ * 1. Top-level `~/.parachute/vault/*.db` — covers legacy pre-multi-vault
112
+ * installs (e.g. `daily.db`) and any user-placed sidecar DBs. Hits
113
+ * include `.bak` copies, which we intentionally skip since they're
114
+ * already static snapshots that `cp` would duplicate faster than
115
+ * VACUUM.
115
116
  * 2. Per-vault `vaults/<name>/vault.db` via `listVaults()`.
116
117
  *
117
118
  * Returns the staging directory so the next stage can tar it up.
118
119
  */
119
120
  export async function stageSnapshot(opts?: {
120
- /** Override config dir. Tests point this at a tempdir. */
121
+ /** Override vault-home dir. Tests point this at a tempdir. */
121
122
  configDir?: string;
122
123
  /** Override vaults dir. Tests point this at a tempdir's vaults/. */
123
124
  vaultsDir?: string;
124
125
  /** Override staging dir. Tests pass a tempdir to inspect contents. */
125
126
  stagingDir?: string;
126
127
  }): Promise<{ stagingDir: string; contents: TarballContents }> {
127
- const configDir = opts?.configDir ?? CONFIG_DIR;
128
- const vaultsDir = opts?.vaultsDir ?? VAULTS_DIR;
128
+ const configDir = opts?.configDir ?? VAULT_HOME;
129
+ const vaultsDir = opts?.vaultsDir ?? DATA_DIR;
129
130
  const stagingDir = opts?.stagingDir ?? mkdtempSync(join(tmpdir(), "parachute-backup-"));
130
131
 
131
132
  const dbSnapshots: string[] = [];
132
133
  const configFiles: string[] = [];
133
134
 
134
- // 1. Top-level *.db files in CONFIG_DIR. Skip .bak and other non-live files.
135
+ // 1. Top-level *.db files in VAULT_HOME. Skip .bak and other non-live files.
135
136
  if (existsSync(configDir)) {
136
137
  for (const entry of readdirSync(configDir)) {
137
138
  if (!entry.endsWith(".db")) continue;
@@ -210,7 +211,7 @@ function vacuumInto(srcDbPath: string, destPath: string): void {
210
211
 
211
212
  /**
212
213
  * Small internal helper so tests can point us at a vaults dir that isn't
213
- * the global VAULTS_DIR without plumbing the override through `listVaults()`.
214
+ * the global DATA_DIR without plumbing the override through `listVaults()`.
214
215
  */
215
216
  function listVaultsIn(dir: string): string[] {
216
217
  if (!existsSync(dir)) return [];
@@ -552,7 +553,7 @@ export function pruneRetention(dir: string, policy: RetentionPolicy): number {
552
553
 
553
554
  /**
554
555
  * Run a single backup end-to-end: stage, tar, ship to destinations. This is
555
- * what `parachute vault backup` and the launchd-scheduled job both invoke.
556
+ * what `parachute-vault backup` and the launchd-scheduled job both invoke.
556
557
  *
557
558
  * The staging dir is cleaned up on exit; the tarball itself is kept (copied
558
559
  * to each destination) and also left behind in the staging dir's parent —
@@ -583,7 +584,7 @@ export async function runBackup(opts?: {
583
584
  const results = await writeToDestinations(tarballPath, backup.destinations, backup.retention);
584
585
 
585
586
  // Record last-backup metadata for `status`. Stored in a small JSON file
586
- // inside CONFIG_DIR so it survives across daemons and doesn't require
587
+ // inside VAULT_HOME so it survives across daemons and doesn't require
587
588
  // plumbing through config.yaml (which is hand-edited by users).
588
589
  recordLastBackup({
589
590
  timestamp,
@@ -613,12 +614,12 @@ export interface LastBackupMeta {
613
614
  }
614
615
 
615
616
  export function lastBackupPath(configDir?: string): string {
616
- return join(configDir ?? CONFIG_DIR, "backup-last.json");
617
+ return join(configDir ?? VAULT_HOME, "backup-last.json");
617
618
  }
618
619
 
619
620
  function recordLastBackup(meta: LastBackupMeta, configDir?: string): void {
620
621
  try {
621
- mkdirSync(configDir ?? CONFIG_DIR, { recursive: true });
622
+ mkdirSync(configDir ?? VAULT_HOME, { recursive: true });
622
623
  Bun.write(lastBackupPath(configDir), JSON.stringify(meta, null, 2) + "\n");
623
624
  } catch {
624
625
  // Non-fatal — losing last-run metadata is a UX regression, not a data loss.
@@ -0,0 +1,28 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { resolveBindHostname } from "./bind.ts";
3
+
4
+ describe("resolveBindHostname", () => {
5
+ test("defaults to 127.0.0.1 when VAULT_BIND is unset", () => {
6
+ expect(resolveBindHostname({})).toBe("127.0.0.1");
7
+ });
8
+
9
+ test("honors VAULT_BIND=0.0.0.0 for Docker / LAN", () => {
10
+ expect(resolveBindHostname({ VAULT_BIND: "0.0.0.0" })).toBe("0.0.0.0");
11
+ });
12
+
13
+ test("honors a specific interface IP in VAULT_BIND", () => {
14
+ expect(resolveBindHostname({ VAULT_BIND: "10.0.0.5" })).toBe("10.0.0.5");
15
+ });
16
+
17
+ test("treats empty VAULT_BIND as unset", () => {
18
+ expect(resolveBindHostname({ VAULT_BIND: "" })).toBe("127.0.0.1");
19
+ });
20
+
21
+ test("treats whitespace-only VAULT_BIND as unset", () => {
22
+ expect(resolveBindHostname({ VAULT_BIND: " " })).toBe("127.0.0.1");
23
+ });
24
+
25
+ test("trims surrounding whitespace", () => {
26
+ expect(resolveBindHostname({ VAULT_BIND: " 127.0.0.1 " })).toBe("127.0.0.1");
27
+ });
28
+ });
package/src/bind.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resolve the hostname the HTTP server binds to.
3
+ *
4
+ * Default is `127.0.0.1` — loopback-only at the socket level. The auth gate
5
+ * protects vault data regardless, but listening only on loopback is a
6
+ * defense-in-depth default that matches the threat model in
7
+ * `docs/auth-model.md` §4. Supported remote-access paths (Tailscale Serve,
8
+ * Cloudflare Tunnel) proxy from loopback, so the default does not break any
9
+ * documented exposure path.
10
+ *
11
+ * Escape hatch: `VAULT_BIND`. Set to `0.0.0.0` for Docker bridge networking
12
+ * or an intentional LAN setup; set to a specific interface IP for
13
+ * multi-homed hosts. Empty/whitespace values are treated as unset.
14
+ */
15
+ export function resolveBindHostname(env: NodeJS.ProcessEnv = process.env): string {
16
+ const override = env.VAULT_BIND?.trim();
17
+ if (override) return override;
18
+ return "127.0.0.1";
19
+ }