@openparachute/vault 0.6.0-rc.1 → 0.6.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/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/content-range-routes.test.ts +178 -0
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/src/routing.ts
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* /vaults/list — public vault-name discovery (can be
|
|
16
16
|
* disabled globally via config)
|
|
17
17
|
* /vaults — authenticated vault metadata list
|
|
18
|
+
* /vault/admin[/*] — daemon-level multi-vault admin SPA
|
|
19
|
+
* (B3; `admin` is a reserved vault
|
|
20
|
+
* name, dispatched BEFORE per-vault)
|
|
18
21
|
* /vault/<name>/.well-known/oauth-* — discovery forwarder; metadata names
|
|
19
22
|
* the hub as the authorization server
|
|
20
23
|
* (vault is resource-server only)
|
|
@@ -49,10 +52,16 @@ import {
|
|
|
49
52
|
authenticateGlobalRequest,
|
|
50
53
|
extractApiKey,
|
|
51
54
|
} from "./auth.ts";
|
|
52
|
-
import { hasScopeForVault, SCOPE_ADMIN, scopeForMethod, verbForMethod } from "./scopes.ts";
|
|
55
|
+
import { hasScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
|
|
53
56
|
import { getVaultStore } from "./vault-store.ts";
|
|
54
57
|
import { handleScopedMcp } from "./mcp-http.ts";
|
|
55
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
defaultAdminSpaDistDir,
|
|
60
|
+
isAdminSpaPath,
|
|
61
|
+
isDaemonAdminSpaPath,
|
|
62
|
+
serveAdminSpa,
|
|
63
|
+
serveDaemonAdminSpa,
|
|
64
|
+
} from "./admin-spa.ts";
|
|
56
65
|
import {
|
|
57
66
|
handleNotes,
|
|
58
67
|
handleTags,
|
|
@@ -63,6 +72,8 @@ import {
|
|
|
63
72
|
handleViewNote,
|
|
64
73
|
type TagScopeCtx,
|
|
65
74
|
} from "./routes.ts";
|
|
75
|
+
import { handleSubscribe } from "./subscribe.ts";
|
|
76
|
+
import { handleTriggers } from "./triggers-api.ts";
|
|
66
77
|
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
67
78
|
import {
|
|
68
79
|
handleProtectedResource,
|
|
@@ -76,6 +87,7 @@ import {
|
|
|
76
87
|
handleAuthGet,
|
|
77
88
|
handleAuthGithubCreateRepo,
|
|
78
89
|
handleAuthGithubDeviceCode,
|
|
90
|
+
handleAuthGithubInstallations,
|
|
79
91
|
handleAuthGithubPoll,
|
|
80
92
|
handleAuthGithubRepos,
|
|
81
93
|
handleAuthGithubSelectRepo,
|
|
@@ -87,6 +99,7 @@ import {
|
|
|
87
99
|
handleMirrorRunNow,
|
|
88
100
|
} from "./mirror-routes.ts";
|
|
89
101
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
102
|
+
import { buildUsageReport } from "./usage.ts";
|
|
90
103
|
|
|
91
104
|
/**
|
|
92
105
|
* Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
|
|
@@ -179,6 +192,23 @@ function handleParachuteIcon(): Response {
|
|
|
179
192
|
});
|
|
180
193
|
}
|
|
181
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Filter a server-wide vault-name list for a caller's per-vault binding
|
|
197
|
+
* (vault#259). `vaultName === null` is the operator / admin-channel caller
|
|
198
|
+
* (server-wide VAULT_AUTH_TOKEN or legacy cross-vault config.yaml key) — they
|
|
199
|
+
* keep the FULL list. A non-null binding reduces the list to just that vault
|
|
200
|
+
* (empty if the bound vault isn't in the listing). Pure + exported so the
|
|
201
|
+
* policy is unit-testable independent of the auth path. See the `/vaults`
|
|
202
|
+
* handler for the full rationale.
|
|
203
|
+
*/
|
|
204
|
+
export function filterVaultListForBinding(
|
|
205
|
+
names: string[],
|
|
206
|
+
vaultName: string | null,
|
|
207
|
+
): string[] {
|
|
208
|
+
if (vaultName === null) return names;
|
|
209
|
+
return names.filter((name) => name === vaultName);
|
|
210
|
+
}
|
|
211
|
+
|
|
182
212
|
export async function route(
|
|
183
213
|
req: Request,
|
|
184
214
|
path: string,
|
|
@@ -269,6 +299,26 @@ export async function route(
|
|
|
269
299
|
});
|
|
270
300
|
}
|
|
271
301
|
|
|
302
|
+
// Daemon-level multi-vault admin SPA — `/vault/admin[/*]` (B3 of the
|
|
303
|
+
// 2026-06-09 hub-module-boundary migration). The vault MODULE's own home
|
|
304
|
+
// surface: list / create / delete vaults, deep-link into each instance's
|
|
305
|
+
// per-vault admin. Same static bundle as the per-vault mount below —
|
|
306
|
+
// `web/ui/src/lib/mount.ts` detects the basename at runtime.
|
|
307
|
+
//
|
|
308
|
+
// MUST dispatch BEFORE `isAdminSpaPath`: the per-vault regex also matches
|
|
309
|
+
// `/vault/admin/admin` (capturing name="admin"), which must NOT boot
|
|
310
|
+
// per-vault mode — `admin` is a reserved vault name (B2) and this mount is
|
|
311
|
+
// daemon-owned. Hub#637's `/vault/admin` route forwards the FULL path here
|
|
312
|
+
// (no stripPrefix on vault's services row). A pre-reservation squatter
|
|
313
|
+
// vault named "admin" is fully shadowed by this branch — server boot
|
|
314
|
+
// warns with the recovery procedure (see vault-name.ts).
|
|
315
|
+
if (isDaemonAdminSpaPath(path)) {
|
|
316
|
+
if (req.method !== "GET") {
|
|
317
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
318
|
+
}
|
|
319
|
+
return serveDaemonAdminSpa(defaultAdminSpaDistDir(), path);
|
|
320
|
+
}
|
|
321
|
+
|
|
272
322
|
// Admin SPA — per-vault at `/vault/<name>/admin/*` (vault#252). Static-
|
|
273
323
|
// file serving only (index.html + Vite asset bundle); no auth at this
|
|
274
324
|
// seam since the bundle reveals nothing privileged. The SPA's data
|
|
@@ -285,10 +335,24 @@ export async function route(
|
|
|
285
335
|
}
|
|
286
336
|
|
|
287
337
|
// Authenticated vault metadata list.
|
|
338
|
+
//
|
|
339
|
+
// Vault-binding filter (vault#259, info-leak follow-up): `/vaults` is a
|
|
340
|
+
// cross-vault DISCOVERY endpoint, not a single-vault operational one (unlike
|
|
341
|
+
// /mcp, which legitimately routes a vault-bound token back into its own
|
|
342
|
+
// vault). A caller bound to one vault shouldn't learn that OTHER vaults exist
|
|
343
|
+
// on this server. So when the authenticated caller carries a per-vault
|
|
344
|
+
// binding (`auth.vault_name !== null`), the listing is reduced to just that
|
|
345
|
+
// vault. Operator / admin-channel callers — the server-wide VAULT_AUTH_TOKEN
|
|
346
|
+
// and legacy cross-vault config.yaml keys — have `vault_name === null` and
|
|
347
|
+
// keep the full listing (they're explicitly the cross-vault management
|
|
348
|
+
// channel). `authenticateGlobalRequest` already 401s hub JWTs here, so the
|
|
349
|
+
// only callers that reach this point today are operator-channel
|
|
350
|
+
// (`vault_name === null`); this filter is the security-correct shape for any
|
|
351
|
+
// future vault-bound credential that becomes accepted on this surface.
|
|
288
352
|
if (path === "/vaults" && req.method === "GET") {
|
|
289
353
|
const auth = await authenticateGlobalRequest(req);
|
|
290
354
|
if ("error" in auth) return auth.error;
|
|
291
|
-
const names = listVaults();
|
|
355
|
+
const names = filterVaultListForBinding(listVaults(), auth.vault_name);
|
|
292
356
|
const vaults = names.map((name) => {
|
|
293
357
|
const config = readVaultConfig(name);
|
|
294
358
|
return {
|
|
@@ -462,8 +526,37 @@ export async function route(
|
|
|
462
526
|
});
|
|
463
527
|
}
|
|
464
528
|
|
|
529
|
+
// /.parachute/usage — per-vault data-footprint report ("how big is this
|
|
530
|
+
// vault"). READ-scoped, deliberately: a vault's own user must be able to
|
|
531
|
+
// see their own vault's size, and the operator (who holds broad/admin)
|
|
532
|
+
// inherits read. This mirrors the bare-root stats precedent above (also
|
|
533
|
+
// read-gated by the method) — same data-disclosure class (counts + sizes,
|
|
534
|
+
// no note content). The expensive part (two dir-walks) is TTL-cached inside
|
|
535
|
+
// usage.ts; `?fresh=1` bypasses the cache. Hub-side aggregation/UI is a
|
|
536
|
+
// separate follow-up; this endpoint is the load-bearing primitive.
|
|
537
|
+
if (subpath === "/.parachute/usage") {
|
|
538
|
+
if (req.method !== "GET") {
|
|
539
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
540
|
+
}
|
|
541
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "read")) {
|
|
542
|
+
return Response.json(
|
|
543
|
+
{
|
|
544
|
+
error: "Forbidden",
|
|
545
|
+
error_type: "insufficient_scope",
|
|
546
|
+
message: `This endpoint requires the '${SCOPE_READ}' scope (or '${SCOPE_READ.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
547
|
+
required_scope: SCOPE_READ,
|
|
548
|
+
granted_scopes: auth.scopes,
|
|
549
|
+
},
|
|
550
|
+
{ status: 403 },
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
const fresh = new URL(req.url).searchParams.get("fresh") === "1";
|
|
554
|
+
const stats = await store.getVaultStats();
|
|
555
|
+
return Response.json(buildUsageReport(vaultName, stats, { fresh }));
|
|
556
|
+
}
|
|
557
|
+
|
|
465
558
|
// The per-vault `/tokens` REST surface (pvt_* mint/list/revoke) was removed
|
|
466
|
-
// at 0.
|
|
559
|
+
// at 0.5.0 (vault#282 Stage 2 — vault is a pure hub resource-server). Hub
|
|
467
560
|
// JWTs are minted via hub's registry (`/api/auth/mint-token`); a `/tokens`
|
|
468
561
|
// request now falls through to the catch-all 404 below.
|
|
469
562
|
|
|
@@ -643,6 +736,13 @@ export async function route(
|
|
|
643
736
|
if (req.method === "POST") return handleAuthGithubPoll(req, manager);
|
|
644
737
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
645
738
|
}
|
|
739
|
+
if (subpath === "/.parachute/mirror/auth/github/installations") {
|
|
740
|
+
// Install state (vault#480) — which app, installed-anywhere?, install
|
|
741
|
+
// link, per-account installations. Explicitly-network (probes GitHub);
|
|
742
|
+
// the offline status read stays on GET /.parachute/mirror/auth.
|
|
743
|
+
if (req.method === "GET") return handleAuthGithubInstallations(manager);
|
|
744
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
745
|
+
}
|
|
646
746
|
if (subpath === "/.parachute/mirror/auth/github/repos") {
|
|
647
747
|
if (req.method === "GET") return handleAuthGithubRepos(manager);
|
|
648
748
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
@@ -667,6 +767,18 @@ export async function route(
|
|
|
667
767
|
return Response.json({ error: "Not found" }, { status: 404 });
|
|
668
768
|
}
|
|
669
769
|
|
|
770
|
+
// /api/triggers — runtime trigger-registration API. Dispatched BEFORE the
|
|
771
|
+
// generic method→verb scope gate below because it's ADMIN-scoped on EVERY
|
|
772
|
+
// method (a webhook trigger exfiltrates note data; even GET is admin). The
|
|
773
|
+
// gate lives inside `handleTriggers`. Subpath is everything after
|
|
774
|
+
// `/api/triggers`. See src/triggers-api.ts.
|
|
775
|
+
{
|
|
776
|
+
const triggersPath = apiMatch[1] ?? "";
|
|
777
|
+
if (triggersPath === "/triggers" || triggersPath.startsWith("/triggers/")) {
|
|
778
|
+
return handleTriggers(req, store, triggersPath.slice("/triggers".length), vaultName, auth);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
670
782
|
// REST API — scope gate. GET/HEAD/OPTIONS → vault:read,
|
|
671
783
|
// POST/PATCH/PUT/DELETE → vault:write. Inheritance (admin ⊇ write ⊇ read)
|
|
672
784
|
// and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
|
|
@@ -701,6 +813,10 @@ export async function route(
|
|
|
701
813
|
};
|
|
702
814
|
|
|
703
815
|
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
|
|
816
|
+
// Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live
|
|
817
|
+
// upsert/remove events over text/event-stream. Auth + tag-scope already
|
|
818
|
+
// resolved above and threaded through, mirroring the /notes branch.
|
|
819
|
+
if (apiPath === "/subscribe") return handleSubscribe(req, store, vaultName, tagScope);
|
|
704
820
|
if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
|
|
705
821
|
if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
|
|
706
822
|
if (apiPath === "/vault") {
|
|
@@ -708,7 +824,7 @@ export async function route(
|
|
|
708
824
|
writeVaultConfig(vaultConfig);
|
|
709
825
|
});
|
|
710
826
|
}
|
|
711
|
-
if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
|
|
827
|
+
if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store, tagScope);
|
|
712
828
|
if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName, store, tagScope);
|
|
713
829
|
if (apiPath === "/health") return Response.json({ status: "ok", vault: vaultName });
|
|
714
830
|
|
package/src/scopes.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* VAULT_AUTH_TOKEN operator bearer, which are vault-pinned by context
|
|
8
8
|
* (the YAML key lives under a specific vault; the operator bearer is
|
|
9
9
|
* server-wide full-admin). (The `pvt_*` vault-DB token that also used
|
|
10
|
-
* this shape was dropped at 0.
|
|
10
|
+
* this shape was dropped at 0.5.0 — vault#282 Stage 2.)
|
|
11
11
|
* - **Narrowed** `vault:<name>:<verb>` — used by hub-issued JWTs, which are
|
|
12
12
|
* not pinned by storage and so MUST name the resource they grant access
|
|
13
13
|
* to. Hub JWTs carrying broad `vault:<verb>` are rejected at validation
|
package/src/server.ts
CHANGED
|
@@ -15,13 +15,14 @@
|
|
|
15
15
|
* The request pipeline lives in ./routing.ts (exported for unit testing).
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey, stopSignalPath } from "./config.ts";
|
|
18
|
+
import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey, stopSignalPath, bootAutoCreateAllowed } 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
|
+
import { resolveFirstBootVaultName, reservedNameSquatWarnings } from "./vault-name.ts";
|
|
22
22
|
import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
|
|
23
23
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
24
24
|
import { registerTriggers } from "./triggers.ts";
|
|
25
|
+
import { loadVaultTriggers } from "./triggers-api.ts";
|
|
25
26
|
import { route } from "./routing.ts";
|
|
26
27
|
import { startTranscriptionWorker, registerTranscriptionHook, type TranscriptionWorker } from "./transcription-worker.ts";
|
|
27
28
|
import { setTranscriptionWorker } from "./transcription-registry.ts";
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
} from "./mirror-config.ts";
|
|
46
47
|
import { GLOBAL_CONFIG_PATH } from "./config.ts";
|
|
47
48
|
import { selfRegister } from "./self-register.ts";
|
|
49
|
+
import { warnLegacyGlobalApiKeys } from "./auth.ts";
|
|
48
50
|
import pkg from "../package.json" with { type: "json" };
|
|
49
51
|
|
|
50
52
|
// Register webhook triggers from global config. Replaces the old hardcoded
|
|
@@ -144,13 +146,30 @@ if (process.env.VAULT_AUTH_TOKEN?.trim()) {
|
|
|
144
146
|
console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
// Legacy GLOBAL api_keys warning (security review — multi-user hardening).
|
|
150
|
+
// Surfaced at boot so the operator rotates/removes cross-vault credentials
|
|
151
|
+
// before multi-user sharing. Warning only — never touches the keys. The
|
|
152
|
+
// logic lives in auth.ts (import-safe + unit-tested); see warnLegacyGlobalApiKeys.
|
|
153
|
+
warnLegacyGlobalApiKeys(readGlobalConfig().api_keys);
|
|
154
|
+
|
|
147
155
|
// Auto-init: create a default vault if none exist (first run in Docker).
|
|
148
156
|
// The vault name comes from PARACHUTE_VAULT_NAME when set + valid; otherwise
|
|
149
157
|
// falls back to "default". Hub's first-boot wizard (hub#267) passes through
|
|
150
158
|
// an operator-chosen name via this env var.
|
|
159
|
+
//
|
|
160
|
+
// Gated on the `auto_create: false` marker (2026-06-09 hub-module-boundary
|
|
161
|
+
// migration): `parachute-vault remove` writes it when the operator deletes
|
|
162
|
+
// their LAST vault, so an explicit empty-the-server action isn't silently
|
|
163
|
+
// undone by a resurrection with fresh credentials on the next boot. Fresh
|
|
164
|
+
// installs have no config.yaml — no marker — so Docker first-run still
|
|
165
|
+
// auto-creates.
|
|
151
166
|
if (listVaults().length === 0) {
|
|
152
167
|
const globalConfig = readGlobalConfig();
|
|
153
|
-
if (!globalConfig
|
|
168
|
+
if (!bootAutoCreateAllowed(globalConfig)) {
|
|
169
|
+
console.log(
|
|
170
|
+
'[vault first-boot] auto-create disabled (auto_create: false in config.yaml — the last vault was removed deliberately). Create one with: parachute-vault create <name>',
|
|
171
|
+
);
|
|
172
|
+
} else if (!globalConfig.default_vault) {
|
|
154
173
|
const firstBoot = resolveFirstBootVaultName(process.env.PARACHUTE_VAULT_NAME);
|
|
155
174
|
if (firstBoot.source === "env") {
|
|
156
175
|
console.log(`[vault first-boot] using PARACHUTE_VAULT_NAME=${firstBoot.name}`);
|
|
@@ -197,6 +216,15 @@ if (listVaults().length === 0) {
|
|
|
197
216
|
// survive — see self-register.ts header for the v0.6 vs v0.7 design note.
|
|
198
217
|
selfRegister({ version: pkg.version });
|
|
199
218
|
|
|
219
|
+
// Loud boot warning for vaults squatting a shadowed reserved name (B2 of the
|
|
220
|
+
// 2026-06-09 hub-module-boundary migration). A vault named `admin`/`new`/
|
|
221
|
+
// `assets` (created before the names were reserved) has its entire
|
|
222
|
+
// `/vault/<name>/*` data plane shadowed by reserved hub/daemon routes — warn
|
|
223
|
+
// with the recovery procedure rather than silently serving nothing.
|
|
224
|
+
for (const warning of reservedNameSquatWarnings(listVaults())) {
|
|
225
|
+
console.warn(warning);
|
|
226
|
+
}
|
|
227
|
+
|
|
200
228
|
// Migrate tag schemas from vault.yaml → DB for each vault.
|
|
201
229
|
// Only inserts schemas that don't already exist in the DB (safe across restarts).
|
|
202
230
|
for (const vaultName of listVaults()) {
|
|
@@ -237,6 +265,31 @@ for (const vaultName of listVaults()) {
|
|
|
237
265
|
}
|
|
238
266
|
}
|
|
239
267
|
|
|
268
|
+
// Load persisted runtime triggers for each vault and register them
|
|
269
|
+
// vault-scoped on the shared hook registry (frictionless-channel-setup PR 1).
|
|
270
|
+
// Runs alongside the config.yaml trigger registration above; config.yaml
|
|
271
|
+
// triggers stay global, these fire only for their own vault. Survives
|
|
272
|
+
// restart — the `triggers` table is the source of truth. Per-vault failure
|
|
273
|
+
// is isolated so one bad vault can't block the others.
|
|
274
|
+
{
|
|
275
|
+
let totalTriggers = 0;
|
|
276
|
+
for (const vaultName of listVaults()) {
|
|
277
|
+
try {
|
|
278
|
+
const store = getVaultStore(vaultName);
|
|
279
|
+
const n = loadVaultTriggers(vaultName, store);
|
|
280
|
+
totalTriggers += n;
|
|
281
|
+
if (n > 0) {
|
|
282
|
+
console.log(`[triggers] loaded ${n} runtime trigger(s) for vault "${vaultName}"`);
|
|
283
|
+
}
|
|
284
|
+
} catch (err) {
|
|
285
|
+
console.error(`[triggers] failed to load runtime triggers for vault "${vaultName}":`, err);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (totalTriggers === 0) {
|
|
289
|
+
console.log("[triggers] no persisted runtime triggers");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
240
293
|
const globalConfig = readGlobalConfig();
|
|
241
294
|
const port = parseInt(process.env.PORT ?? "") || globalConfig.port || DEFAULT_PORT;
|
|
242
295
|
const hostname = resolveBindHostname();
|
|
@@ -345,8 +398,17 @@ const server = Bun.serve({
|
|
|
345
398
|
|
|
346
399
|
const corsHeaders = {
|
|
347
400
|
"Access-Control-Allow-Origin": "*",
|
|
348
|
-
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
401
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
349
402
|
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key, Mcp-Session-Id",
|
|
403
|
+
// Expose response headers a BROWSER-based MCP client (e.g. claude.ai) must
|
|
404
|
+
// read cross-origin: `WWW-Authenticate` carries the RFC 9728 auth challenge
|
|
405
|
+
// (the `resource_metadata` PRM URL) the client follows to discover the auth
|
|
406
|
+
// server — without exposing it, the browser's fetch() can't see it and the
|
|
407
|
+
// OAuth flow never starts ("Couldn't register with the sign-in service").
|
|
408
|
+
// `Mcp-Session-Id` is the streamable-HTTP MCP session the client echoes
|
|
409
|
+
// back. (Claude Code is a CLI → no CORS → unaffected. The hub already
|
|
410
|
+
// exposes WWW-Authenticate; this matches it on the resource server.)
|
|
411
|
+
"Access-Control-Expose-Headers": "WWW-Authenticate, Mcp-Session-Id",
|
|
350
412
|
};
|
|
351
413
|
|
|
352
414
|
if (req.method === "OPTIONS") {
|
package/src/storage.test.ts
CHANGED
|
@@ -215,3 +215,165 @@ describe("storage GET tag-scope enforcement", () => {
|
|
|
215
215
|
expect(res.status).toBe(403);
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// GET percent-encoded-slash handling (feedback finding D).
|
|
221
|
+
//
|
|
222
|
+
// `path` reaches handleStorage from `url.pathname`, which keeps an encoded
|
|
223
|
+
// `%2F` slash LITERAL (WHATWG). The old `path.match(/^\/([^/]+)\/(.+)$/)`
|
|
224
|
+
// required a literal slash, so `/api/storage/<date>%2F<file>` fell to the
|
|
225
|
+
// unconditional 404 — a trap-grade asymmetry with the single-note routes,
|
|
226
|
+
// which decode their first segment and therefore REQUIRE `%2F`. The fix
|
|
227
|
+
// decodes `path` before matching, accepting BOTH forms; the decoded path is
|
|
228
|
+
// also what the DB stores (`${date}/${filename}`), so tag-scope lookup and
|
|
229
|
+
// the traversal guard keep working. These tests pin both forms + the
|
|
230
|
+
// guard-safety of the decode.
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
describe("storage GET percent-encoded slash (finding D)", () => {
|
|
234
|
+
const VAULT = "encode-vault";
|
|
235
|
+
|
|
236
|
+
async function setup(): Promise<{
|
|
237
|
+
store: SqliteStore;
|
|
238
|
+
assets: string;
|
|
239
|
+
inScopePath: string;
|
|
240
|
+
outScopePath: string;
|
|
241
|
+
}> {
|
|
242
|
+
const store = freshStore();
|
|
243
|
+
const assets = join(testDir, "assets", VAULT, "data");
|
|
244
|
+
mkdirSync(join(assets, "2026-05-28"), { recursive: true });
|
|
245
|
+
process.env.ASSETS_DIR = assets;
|
|
246
|
+
|
|
247
|
+
const workNote = await store.createNote("work note", { tags: ["work"] });
|
|
248
|
+
const healthNote = await store.createNote("health note", { tags: ["health"] });
|
|
249
|
+
|
|
250
|
+
const inScopePath = "2026-05-28/work-asset.pdf";
|
|
251
|
+
const outScopePath = "2026-05-28/health-asset.pdf";
|
|
252
|
+
writeFileSync(join(assets, inScopePath), Buffer.from([0x25, 0x50, 0x44, 0x46])); // %PDF
|
|
253
|
+
writeFileSync(join(assets, outScopePath), Buffer.from([0x25, 0x50, 0x44, 0x46]));
|
|
254
|
+
|
|
255
|
+
await store.addAttachment(workNote.id, inScopePath, "application/pdf");
|
|
256
|
+
await store.addAttachment(healthNote.id, outScopePath, "application/pdf");
|
|
257
|
+
|
|
258
|
+
return { store, assets, inScopePath, outScopePath };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The request URL carries the encoded form; the `path` arg mirrors what the
|
|
262
|
+
// dispatcher hands the handler (derived from url.pathname, %2F kept literal).
|
|
263
|
+
function getReqEncoded(reqPath: string): { req: Request; path: string } {
|
|
264
|
+
const encoded = reqPath.replace(/\//g, "%2F");
|
|
265
|
+
return {
|
|
266
|
+
req: new Request(`http://localhost:1940/storage/${encoded}`, { method: "GET" }),
|
|
267
|
+
path: `/${encoded}`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
test("encoded %2F path serves the same bytes as the literal form (200)", async () => {
|
|
272
|
+
const { store, inScopePath } = await setup();
|
|
273
|
+
const ctx = await tagScopeCtx(store, ["work"]);
|
|
274
|
+
const { req, path } = getReqEncoded(inScopePath);
|
|
275
|
+
const res = await handleStorage(req, path, VAULT, store, ctx);
|
|
276
|
+
expect(res.status).toBe(200);
|
|
277
|
+
expect(res.headers.get("Content-Type")).toBe("application/pdf");
|
|
278
|
+
expect((await res.arrayBuffer()).byteLength).toBe(4);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("literal-slash path still serves (regression)", async () => {
|
|
282
|
+
const { store, inScopePath } = await setup();
|
|
283
|
+
const ctx = await tagScopeCtx(store, ["work"]);
|
|
284
|
+
const res = await handleStorage(
|
|
285
|
+
new Request(`http://localhost:1940/storage/${inScopePath}`, { method: "GET" }),
|
|
286
|
+
`/${inScopePath}`,
|
|
287
|
+
VAULT,
|
|
288
|
+
store,
|
|
289
|
+
ctx,
|
|
290
|
+
);
|
|
291
|
+
expect(res.status).toBe(200);
|
|
292
|
+
expect((await res.arrayBuffer()).byteLength).toBe(4);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("encoded traversal %2E%2E%2F… → 403 (decoded `..` hits the traversal guard)", async () => {
|
|
296
|
+
const { store } = await setup();
|
|
297
|
+
const ctx = await tagScopeCtx(store, ["work"]);
|
|
298
|
+
// Fully percent-encoded `/a/../../../../../../etc/passwd`. Decode yields
|
|
299
|
+
// the literal traversal, which resolves outside assetsDir → 403.
|
|
300
|
+
const evilDecoded = "/a/../../../../../../etc/passwd";
|
|
301
|
+
const evilEncoded = "/a%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2Fetc%2Fpasswd";
|
|
302
|
+
expect(decodeURIComponent(evilEncoded)).toBe(evilDecoded);
|
|
303
|
+
const res = await handleStorage(
|
|
304
|
+
new Request(`http://localhost:1940/storage${evilEncoded}`, { method: "GET" }),
|
|
305
|
+
evilEncoded,
|
|
306
|
+
VAULT,
|
|
307
|
+
store,
|
|
308
|
+
ctx,
|
|
309
|
+
);
|
|
310
|
+
expect(res.status).toBe(403);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("tag-scoped token + out-of-scope owning note → still 404 with an encoded path", async () => {
|
|
314
|
+
const { store, outScopePath } = await setup();
|
|
315
|
+
const ctx = await tagScopeCtx(store, ["work"]);
|
|
316
|
+
const { req, path } = getReqEncoded(outScopePath);
|
|
317
|
+
const res = await handleStorage(req, path, VAULT, store, ctx);
|
|
318
|
+
expect(res.status).toBe(404);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("malformed `%` (e.g. /api/storage/2026%2) → 404, not 500", async () => {
|
|
322
|
+
const { store } = await setup();
|
|
323
|
+
const ctx = await tagScopeCtx(store, ["work"]);
|
|
324
|
+
// `%2` is not a valid percent-escape → decodeURIComponent throws → 404.
|
|
325
|
+
const bad = "/2026%2";
|
|
326
|
+
expect(() => decodeURIComponent(bad)).toThrow();
|
|
327
|
+
const res = await handleStorage(
|
|
328
|
+
new Request(`http://localhost:1940/storage${bad}`, { method: "GET" }),
|
|
329
|
+
bad,
|
|
330
|
+
VAULT,
|
|
331
|
+
store,
|
|
332
|
+
ctx,
|
|
333
|
+
);
|
|
334
|
+
expect(res.status).toBe(404);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("double-encoded %252F → 404 (decodes ONCE to literal %2F, no slash, no second decode)", async () => {
|
|
338
|
+
const { store } = await setup();
|
|
339
|
+
const ctx = await tagScopeCtx(store, ["work"]);
|
|
340
|
+
// `%252F` → decodeURIComponent once → `%2F` (a literal `%2F`, NOT a slash).
|
|
341
|
+
// The single decode is deliberate: a second decode would turn this into a
|
|
342
|
+
// real slash and risk serving / re-looping. With one decode the path has
|
|
343
|
+
// no `/` separator, so the date/file match fails → 404.
|
|
344
|
+
const doubleEncoded = "/2026-05-28%252Ffile.bin";
|
|
345
|
+
expect(decodeURIComponent(doubleEncoded)).toBe("/2026-05-28%2Ffile.bin");
|
|
346
|
+
const res = await handleStorage(
|
|
347
|
+
new Request(`http://localhost:1940/storage${doubleEncoded}`, { method: "GET" }),
|
|
348
|
+
doubleEncoded,
|
|
349
|
+
VAULT,
|
|
350
|
+
store,
|
|
351
|
+
ctx,
|
|
352
|
+
);
|
|
353
|
+
expect(res.status).toBe(404);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// POST parity (finding D — decode block must not change upload behavior).
|
|
359
|
+
//
|
|
360
|
+
// The `POST /upload` branch returns BEFORE the decode block, so a real upload
|
|
361
|
+
// is untouched. A POST to a non-`/upload` storage path with a malformed `%`
|
|
362
|
+
// falls past the upload branch into the decode (the `try/catch` is not method-
|
|
363
|
+
// gated), where the throw → 404 — the same status the pre-fix unconditional
|
|
364
|
+
// final 404 produced for this request. Pins that the decode doesn't turn a
|
|
365
|
+
// malformed-`%` POST into a 500 and keeps POST behavior at parity.
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
describe("storage POST parity (finding D)", () => {
|
|
369
|
+
test("POST to a malformed-`%` storage path → 404, unchanged by the GET-side decode", async () => {
|
|
370
|
+
const bad = "/2026%2";
|
|
371
|
+
const res = await handleStorage(
|
|
372
|
+
new Request(`http://localhost:1940/storage${bad}`, { method: "POST" }),
|
|
373
|
+
bad,
|
|
374
|
+
"default",
|
|
375
|
+
uploadStore,
|
|
376
|
+
);
|
|
377
|
+
expect(res.status).toBe(404);
|
|
378
|
+
});
|
|
379
|
+
});
|