@openparachute/vault 0.2.3 → 0.3.0-rc.1
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/.claude/settings.local.json +8 -0
- package/CHANGELOG.md +70 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +603 -19
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +157 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +92 -0
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +29 -1
- package/docs/HTTP_API.md +105 -1
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/cli.ts +179 -121
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +136 -0
- package/src/scopes.ts +105 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +57 -5
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +583 -0
- package/src/transcription-worker.ts +346 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
package/src/auth.test.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auth invariants — routing
|
|
2
|
+
* Auth invariants — per-vault routing with strict isolation.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* authenticate
|
|
7
|
-
*
|
|
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, "
|
|
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 —
|
|
87
|
-
test("token minted in
|
|
88
|
-
seedVault("
|
|
89
|
-
const token = mintTokenInVault("
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
// authenticateVaultRequest with
|
|
95
|
-
const
|
|
96
|
-
expect("error" in
|
|
97
|
-
if (!("error" in
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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
|
|
111
|
-
//
|
|
112
|
-
//
|
|
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
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
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
|
|
140
|
-
seedVault("
|
|
141
|
-
const token = mintTokenInVault("
|
|
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
|
-
|
|
152
|
-
expect(
|
|
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
|
|
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
|
|
158
|
-
//
|
|
159
|
-
seedVault("
|
|
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("/
|
|
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 —
|
|
151
|
+
describe("auth — cross-vault isolation", () => {
|
|
169
152
|
test("token minted in a non-default vault authenticates via scoped and global paths", () => {
|
|
170
|
-
seedVault("
|
|
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
|
-
//
|
|
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
|
|
186
|
-
|
|
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
|
|
194
|
-
const
|
|
171
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
172
|
+
const journalStore = getVaultStore("journal");
|
|
195
173
|
|
|
196
|
-
const res = authenticateVaultRequest(bearer(workToken),
|
|
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
|
|
180
|
+
// End-to-end: OAuth flow → resulting token authenticates against its vault
|
|
203
181
|
// ---------------------------------------------------------------------------
|
|
204
182
|
|
|
205
|
-
describe("OAuth-minted tokens —
|
|
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
|
|
208
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
276
|
-
seedVault("
|
|
277
|
-
const token = await runOAuthFlow("
|
|
278
|
-
const cfg = readVaultConfig("
|
|
279
|
-
const store = getVaultStore("
|
|
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
|
-
//
|
|
286
|
-
const
|
|
287
|
-
expect("error" in
|
|
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
|
-
//
|
|
290
|
-
const
|
|
291
|
-
expect("error" in
|
|
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
|
|
299
|
-
seedVault("
|
|
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
|
-
//
|
|
306
|
-
const
|
|
307
|
-
expect("error" in
|
|
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
|
-
//
|
|
310
|
-
const
|
|
311
|
-
expect("error" in
|
|
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
|
-
//
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
const crossCheck = authenticateVaultRequest(bearer(token),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/backup-launchd.ts
CHANGED
|
@@ -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
|
|
38
|
+
* `parachute-vault backup --schedule <f>` run.
|
|
39
39
|
*/
|
|
40
40
|
export function resolveCliPath(): string {
|
|
41
41
|
const serverPath = resolveServerPath(); // <repo>/src/server.ts
|
package/src/backup.test.ts
CHANGED
|
@@ -604,7 +604,7 @@ describe("backup — checkDestinationWritable", () => {
|
|
|
604
604
|
});
|
|
605
605
|
|
|
606
606
|
// ---------------------------------------------------------------------------
|
|
607
|
-
// CLI integration — `parachute
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
109
|
-
* `
|
|
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
|
|
112
|
-
* (e.g. `daily.db`) and any user-placed sidecar DBs. Hits
|
|
113
|
-
* copies, which we intentionally skip since they're
|
|
114
|
-
* snapshots that `cp` would duplicate faster than
|
|
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
|
|
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 ??
|
|
128
|
-
const vaultsDir = opts?.vaultsDir ??
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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.
|