@openparachute/vault 0.5.3-rc.3 → 0.6.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.
- package/.parachute/module.json +14 -3
- package/core/src/mcp.ts +20 -0
- package/core/src/schema.ts +45 -1
- package/core/src/store.ts +66 -19
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +27 -1
- package/package.json +1 -1
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/cli.ts +45 -18
- package/src/config.test.ts +27 -0
- package/src/config.ts +87 -0
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/routes.ts +192 -78
- package/src/routing.test.ts +64 -0
- package/src/routing.ts +48 -1
- package/src/server.ts +49 -3
- 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/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/vault-create.test.ts +35 -1
- 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 +194 -0
- package/web/ui/dist/assets/index-CGL256oe.js +60 -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-DJL6Az--.js +0 -60
package/src/routing.test.ts
CHANGED
|
@@ -431,6 +431,70 @@ describe("/vault/<name>/admin/* SPA mount", () => {
|
|
|
431
431
|
expect(res.status).toBe(401);
|
|
432
432
|
});
|
|
433
433
|
|
|
434
|
+
// ---------------------------------------------------------------------
|
|
435
|
+
// /vault/admin[/*] — DAEMON-LEVEL multi-vault SPA mount (B3, 2026-06-09
|
|
436
|
+
// hub-module-boundary migration). `admin` is a reserved vault name (B2),
|
|
437
|
+
// and this branch dispatches BEFORE both the per-vault SPA mount and the
|
|
438
|
+
// per-vault dispatcher. Detailed serving behavior (the bare-mount 301,
|
|
439
|
+
// asset strip) is pinned in admin-spa.test.ts with a tmp dist dir.
|
|
440
|
+
// ---------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
test("/vault/admin/ fires the SPA layer, never the per-vault 'Vault not found' JSON", async () => {
|
|
443
|
+
// No vault named "admin" exists (it can't — reserved). Without the
|
|
444
|
+
// daemon-level branch this path would fall to the per-vault dispatcher
|
|
445
|
+
// and 404 as JSON.
|
|
446
|
+
const req = new Request("http://localhost:1940/vault/admin/");
|
|
447
|
+
const res = await route(req, "/vault/admin/");
|
|
448
|
+
expect(res.status === 200 || res.status === 503).toBe(true);
|
|
449
|
+
expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("/vault/admin/admin does NOT boot per-vault mode for a vault named 'admin'", async () => {
|
|
453
|
+
// The per-vault regex would capture name="admin" here. The daemon-level
|
|
454
|
+
// branch must win — the regexes are deliberately not merged.
|
|
455
|
+
const req = new Request("http://localhost:1940/vault/admin/admin");
|
|
456
|
+
const res = await route(req, "/vault/admin/admin");
|
|
457
|
+
expect(res.status === 200 || res.status === 503).toBe(true);
|
|
458
|
+
expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("a squatted vault named 'admin' is shadowed — its data plane serves the SPA layer", async () => {
|
|
462
|
+
// A vault created before the reservation landed. The daemon mount wins
|
|
463
|
+
// over its entire /vault/admin/* surface (server boot warns with the
|
|
464
|
+
// recovery procedure — see vault-name.ts:reservedNameSquatWarnings).
|
|
465
|
+
createVault("admin");
|
|
466
|
+
const req = new Request("http://localhost:1940/vault/admin/api/notes");
|
|
467
|
+
const res = await route(req, "/vault/admin/api/notes");
|
|
468
|
+
// The per-vault API would 401 (auth wall); the SPA layer serves the
|
|
469
|
+
// static shell (200) or the unbuilt-dist 503 — never the API's JSON.
|
|
470
|
+
expect(res.status === 200 || res.status === 503).toBe(true);
|
|
471
|
+
expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("POST /vault/admin/ returns 405 (daemon mount is GET-only)", async () => {
|
|
475
|
+
const req = new Request("http://localhost:1940/vault/admin/", { method: "POST" });
|
|
476
|
+
const res = await route(req, "/vault/admin/");
|
|
477
|
+
expect(res.status).toBe(405);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("/vault/adminx/* does NOT match the daemon mount — routes per-vault", async () => {
|
|
481
|
+
// Exact-segment match only: a real vault whose name merely starts with
|
|
482
|
+
// "admin" keeps its normal per-vault surface (the API auth wall 401s,
|
|
483
|
+
// proving the per-vault dispatcher handled it).
|
|
484
|
+
createVault("adminx");
|
|
485
|
+
const req = new Request("http://localhost:1940/vault/adminx/api/notes");
|
|
486
|
+
const res = await route(req, "/vault/adminx/api/notes");
|
|
487
|
+
expect(res.status).toBe(401);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("per-vault /vault/<real>/admin/ is unaffected by the daemon mount", async () => {
|
|
491
|
+
createVault("work");
|
|
492
|
+
const req = new Request("http://localhost:1940/vault/work/admin/");
|
|
493
|
+
const res = await route(req, "/vault/work/admin/");
|
|
494
|
+
expect(res.status === 200 || res.status === 503).toBe(true);
|
|
495
|
+
expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
|
|
496
|
+
});
|
|
497
|
+
|
|
434
498
|
test("origin-rooted /admin (legacy mount retired) returns 404", async () => {
|
|
435
499
|
// Pre-vault#252 the SPA was at /admin/*. Routing now lets that fall
|
|
436
500
|
// through to the catch-all — hub's directory page should link to
|
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)
|
|
@@ -52,7 +55,13 @@ import {
|
|
|
52
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,
|
|
@@ -287,6 +298,26 @@ export async function route(
|
|
|
287
298
|
});
|
|
288
299
|
}
|
|
289
300
|
|
|
301
|
+
// Daemon-level multi-vault admin SPA — `/vault/admin[/*]` (B3 of the
|
|
302
|
+
// 2026-06-09 hub-module-boundary migration). The vault MODULE's own home
|
|
303
|
+
// surface: list / create / delete vaults, deep-link into each instance's
|
|
304
|
+
// per-vault admin. Same static bundle as the per-vault mount below —
|
|
305
|
+
// `web/ui/src/lib/mount.ts` detects the basename at runtime.
|
|
306
|
+
//
|
|
307
|
+
// MUST dispatch BEFORE `isAdminSpaPath`: the per-vault regex also matches
|
|
308
|
+
// `/vault/admin/admin` (capturing name="admin"), which must NOT boot
|
|
309
|
+
// per-vault mode — `admin` is a reserved vault name (B2) and this mount is
|
|
310
|
+
// daemon-owned. Hub#637's `/vault/admin` route forwards the FULL path here
|
|
311
|
+
// (no stripPrefix on vault's services row). A pre-reservation squatter
|
|
312
|
+
// vault named "admin" is fully shadowed by this branch — server boot
|
|
313
|
+
// warns with the recovery procedure (see vault-name.ts).
|
|
314
|
+
if (isDaemonAdminSpaPath(path)) {
|
|
315
|
+
if (req.method !== "GET") {
|
|
316
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
317
|
+
}
|
|
318
|
+
return serveDaemonAdminSpa(defaultAdminSpaDistDir(), path);
|
|
319
|
+
}
|
|
320
|
+
|
|
290
321
|
// Admin SPA — per-vault at `/vault/<name>/admin/*` (vault#252). Static-
|
|
291
322
|
// file serving only (index.html + Vite asset bundle); no auth at this
|
|
292
323
|
// seam since the bundle reveals nothing privileged. The SPA's data
|
|
@@ -728,6 +759,18 @@ export async function route(
|
|
|
728
759
|
return Response.json({ error: "Not found" }, { status: 404 });
|
|
729
760
|
}
|
|
730
761
|
|
|
762
|
+
// /api/triggers — runtime trigger-registration API. Dispatched BEFORE the
|
|
763
|
+
// generic method→verb scope gate below because it's ADMIN-scoped on EVERY
|
|
764
|
+
// method (a webhook trigger exfiltrates note data; even GET is admin). The
|
|
765
|
+
// gate lives inside `handleTriggers`. Subpath is everything after
|
|
766
|
+
// `/api/triggers`. See src/triggers-api.ts.
|
|
767
|
+
{
|
|
768
|
+
const triggersPath = apiMatch[1] ?? "";
|
|
769
|
+
if (triggersPath === "/triggers" || triggersPath.startsWith("/triggers/")) {
|
|
770
|
+
return handleTriggers(req, store, triggersPath.slice("/triggers".length), vaultName, auth);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
731
774
|
// REST API — scope gate. GET/HEAD/OPTIONS → vault:read,
|
|
732
775
|
// POST/PATCH/PUT/DELETE → vault:write. Inheritance (admin ⊇ write ⊇ read)
|
|
733
776
|
// and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
|
|
@@ -762,6 +805,10 @@ export async function route(
|
|
|
762
805
|
};
|
|
763
806
|
|
|
764
807
|
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
|
|
808
|
+
// Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live
|
|
809
|
+
// upsert/remove events over text/event-stream. Auth + tag-scope already
|
|
810
|
+
// resolved above and threaded through, mirroring the /notes branch.
|
|
811
|
+
if (apiPath === "/subscribe") return handleSubscribe(req, store, vaultName, tagScope);
|
|
765
812
|
if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
|
|
766
813
|
if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
|
|
767
814
|
if (apiPath === "/vault") {
|
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";
|
|
@@ -155,9 +156,20 @@ warnLegacyGlobalApiKeys(readGlobalConfig().api_keys);
|
|
|
155
156
|
// The vault name comes from PARACHUTE_VAULT_NAME when set + valid; otherwise
|
|
156
157
|
// falls back to "default". Hub's first-boot wizard (hub#267) passes through
|
|
157
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.
|
|
158
166
|
if (listVaults().length === 0) {
|
|
159
167
|
const globalConfig = readGlobalConfig();
|
|
160
|
-
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) {
|
|
161
173
|
const firstBoot = resolveFirstBootVaultName(process.env.PARACHUTE_VAULT_NAME);
|
|
162
174
|
if (firstBoot.source === "env") {
|
|
163
175
|
console.log(`[vault first-boot] using PARACHUTE_VAULT_NAME=${firstBoot.name}`);
|
|
@@ -204,6 +216,15 @@ if (listVaults().length === 0) {
|
|
|
204
216
|
// survive — see self-register.ts header for the v0.6 vs v0.7 design note.
|
|
205
217
|
selfRegister({ version: pkg.version });
|
|
206
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
|
+
|
|
207
228
|
// Migrate tag schemas from vault.yaml → DB for each vault.
|
|
208
229
|
// Only inserts schemas that don't already exist in the DB (safe across restarts).
|
|
209
230
|
for (const vaultName of listVaults()) {
|
|
@@ -244,6 +265,31 @@ for (const vaultName of listVaults()) {
|
|
|
244
265
|
}
|
|
245
266
|
}
|
|
246
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
|
+
|
|
247
293
|
const globalConfig = readGlobalConfig();
|
|
248
294
|
const port = parseInt(process.env.PORT ?? "") || globalConfig.port || DEFAULT_PORT;
|
|
249
295
|
const hostname = resolveBindHostname();
|