@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/admin-spa.test.ts
CHANGED
|
@@ -12,7 +12,12 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
|
12
12
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
13
13
|
import { join } from "path";
|
|
14
14
|
import { tmpdir } from "os";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
isAdminSpaPath,
|
|
17
|
+
isDaemonAdminSpaPath,
|
|
18
|
+
serveAdminSpa,
|
|
19
|
+
serveDaemonAdminSpa,
|
|
20
|
+
} from "./admin-spa.ts";
|
|
16
21
|
|
|
17
22
|
const fixtureDir = join(tmpdir(), `vault-admin-spa-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
18
23
|
|
|
@@ -130,15 +135,76 @@ describe("serveAdminSpa", () => {
|
|
|
130
135
|
});
|
|
131
136
|
});
|
|
132
137
|
|
|
138
|
+
describe("isDaemonAdminSpaPath", () => {
|
|
139
|
+
test("matches /vault/admin and true subpaths", () => {
|
|
140
|
+
expect(isDaemonAdminSpaPath("/vault/admin")).toBe(true);
|
|
141
|
+
expect(isDaemonAdminSpaPath("/vault/admin/")).toBe(true);
|
|
142
|
+
expect(isDaemonAdminSpaPath("/vault/admin/assets/index.js")).toBe(true);
|
|
143
|
+
// The doubled path the per-vault regex would mis-read as vault "admin".
|
|
144
|
+
expect(isDaemonAdminSpaPath("/vault/admin/admin")).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("does not match vaults whose name merely starts with 'admin'", () => {
|
|
148
|
+
expect(isDaemonAdminSpaPath("/vault/adminx")).toBe(false);
|
|
149
|
+
expect(isDaemonAdminSpaPath("/vault/admin2/admin")).toBe(false);
|
|
150
|
+
expect(isDaemonAdminSpaPath("/vault/admin-foo")).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("does not match per-vault mounts or unrelated paths", () => {
|
|
154
|
+
expect(isDaemonAdminSpaPath("/vault/work/admin")).toBe(false);
|
|
155
|
+
expect(isDaemonAdminSpaPath("/admin")).toBe(false);
|
|
156
|
+
expect(isDaemonAdminSpaPath("/vaults")).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("serveDaemonAdminSpa (the /vault/admin multi-vault mount)", () => {
|
|
161
|
+
test("bare /vault/admin redirects to trailing-slash form (301)", async () => {
|
|
162
|
+
// Same load-bearing canonicalization as the per-vault mount: Vite's
|
|
163
|
+
// relative asset URLs (./assets/...) resolve against the document's
|
|
164
|
+
// DIRECTORY, so /vault/admin (bare) would resolve assets to
|
|
165
|
+
// /vault/assets/... and 404 them.
|
|
166
|
+
const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin");
|
|
167
|
+
expect(res.status).toBe(301);
|
|
168
|
+
expect(res.headers.get("Location")).toBe("/vault/admin/");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("/vault/admin/ returns the SPA index", async () => {
|
|
172
|
+
const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/");
|
|
173
|
+
expect(res.status).toBe(200);
|
|
174
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
175
|
+
expect(await res.text()).toContain("shell");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("daemon-mount asset path strips cleanly", async () => {
|
|
179
|
+
const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/assets/index-abc.js");
|
|
180
|
+
expect(res.status).toBe(200);
|
|
181
|
+
expect(res.headers.get("content-type")).toContain("application/javascript");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("/vault/admin/admin serves the shell (client route, not a per-vault boot)", async () => {
|
|
185
|
+
const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/admin");
|
|
186
|
+
expect(res.status).toBe(200);
|
|
187
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
188
|
+
expect(await res.text()).toContain("shell");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("path traversal (..) cannot escape dist dir on the daemon mount", async () => {
|
|
192
|
+
const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/../../etc/passwd");
|
|
193
|
+
expect(res.status).toBe(200);
|
|
194
|
+
expect(await res.text()).toContain("shell");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
133
198
|
describe("hub <-> vault managementUrl contract", () => {
|
|
134
199
|
// Browsers drop the URL fragment when following a 301 (RFC 7231 SHOULD
|
|
135
200
|
// preserve, but Chrome/Firefox/Safari are inconsistent in practice). The
|
|
136
201
|
// hub-issued JWT travels in `#token=...`, so a redirected click loses the
|
|
137
|
-
// token and the SPA boots unauthenticated.
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// (no redirect, fragment
|
|
141
|
-
// `/vault/<name>/admin`,
|
|
202
|
+
// token and the SPA boots unauthenticated. Under the B4 URL-resolution
|
|
203
|
+
// semantics (hub#637) a RELATIVE managementUrl is mount-joined per
|
|
204
|
+
// instance (`/vault/<name>` + "/" + "admin/") — if it ends with "/" the
|
|
205
|
+
// canonical click target is `/vault/<name>/admin/` (no redirect, fragment
|
|
206
|
+
// preserved). Without the trailing slash hub emits `/vault/<name>/admin`,
|
|
207
|
+
// the server 301s, and the fragment is gone.
|
|
142
208
|
test("module.json managementUrl ends with '/' so hub emits the no-redirect form", () => {
|
|
143
209
|
const moduleJson = JSON.parse(
|
|
144
210
|
readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
|
|
@@ -146,16 +212,40 @@ describe("hub <-> vault managementUrl contract", () => {
|
|
|
146
212
|
expect(moduleJson.managementUrl).toMatch(/\/$/);
|
|
147
213
|
});
|
|
148
214
|
|
|
149
|
-
test("
|
|
150
|
-
//
|
|
151
|
-
//
|
|
215
|
+
test("managementUrl + uiUrl are RELATIVE (per-instance); configUiUrl is origin-absolute (daemon-level)", () => {
|
|
216
|
+
// B4 semantics (2026-06-09 hub-module-boundary): relative = mount-joined
|
|
217
|
+
// per instance; leading "/" = origin-absolute verbatim. The per-instance
|
|
218
|
+
// surfaces (manage tile, instance UI) stay per-vault; the module-level
|
|
219
|
+
// config UI points at the daemon-level multi-vault home. A leading "/"
|
|
220
|
+
// on managementUrl/uiUrl here would flip every instance tile to the
|
|
221
|
+
// module home; a relative configUiUrl would wrongly mount-join.
|
|
222
|
+
const moduleJson = JSON.parse(
|
|
223
|
+
readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
|
|
224
|
+
);
|
|
225
|
+
expect(moduleJson.managementUrl).toBe("admin/");
|
|
226
|
+
expect(moduleJson.uiUrl).toBe("admin/");
|
|
227
|
+
expect(moduleJson.configUiUrl).toBe("/vault/admin/");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("the canonical hub-emitted per-instance URL serves the SPA shell directly (no 301)", async () => {
|
|
231
|
+
// Mirror hub's per-instance join under B4: mount + "/" + relative
|
|
232
|
+
// managementUrl. With managementUrl="admin/" the result is
|
|
152
233
|
// /vault/<name>/admin/ — which serveAdminSpa returns as 200, not 301.
|
|
153
234
|
const moduleJson = JSON.parse(
|
|
154
235
|
readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
|
|
155
236
|
);
|
|
156
|
-
const canonical = `/vault/work
|
|
237
|
+
const canonical = `/vault/work/${moduleJson.managementUrl}`;
|
|
157
238
|
const res = await serveAdminSpa(fixtureDir, canonical);
|
|
158
239
|
expect(res.status).toBe(200);
|
|
159
240
|
expect(res.headers.get("Location")).toBeNull();
|
|
160
241
|
});
|
|
242
|
+
|
|
243
|
+
test("the canonical configUiUrl serves the daemon-level shell directly (no 301)", async () => {
|
|
244
|
+
const moduleJson = JSON.parse(
|
|
245
|
+
readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
|
|
246
|
+
);
|
|
247
|
+
const res = await serveDaemonAdminSpa(fixtureDir, moduleJson.configUiUrl);
|
|
248
|
+
expect(res.status).toBe(200);
|
|
249
|
+
expect(res.headers.get("Location")).toBeNull();
|
|
250
|
+
});
|
|
161
251
|
});
|
package/src/admin-spa.ts
CHANGED
|
@@ -28,6 +28,18 @@ import { fileURLToPath } from "node:url";
|
|
|
28
28
|
*/
|
|
29
29
|
const ADMIN_SPA_MOUNT_RE = /^\/vault\/([^/]+)\/admin(?=\/|$)/;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Regex anchoring the DAEMON-LEVEL multi-vault SPA mount at `/vault/admin`
|
|
33
|
+
* (B3 of the 2026-06-09 hub-module-boundary migration). Deliberately a
|
|
34
|
+
* SEPARATE regex from the per-vault one — merging them would let
|
|
35
|
+
* `/vault/admin/admin` boot per-vault mode with name="admin". `admin` is a
|
|
36
|
+
* reserved vault name (see `vault-name.ts:RESERVED_VAULT_NAMES`), so this
|
|
37
|
+
* mount can never collide with a real instance; routing dispatches it
|
|
38
|
+
* BEFORE the per-vault branch so a pre-reservation squatter is shadowed
|
|
39
|
+
* (and warned about at boot) rather than capturing the mount.
|
|
40
|
+
*/
|
|
41
|
+
const DAEMON_ADMIN_SPA_MOUNT_RE = /^\/vault\/admin(?=\/|$)/;
|
|
42
|
+
|
|
31
43
|
/**
|
|
32
44
|
* Resolve the default SPA bundle dir. Anchored to this file's location so
|
|
33
45
|
* a `bun src/server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
|
|
@@ -92,18 +104,24 @@ function spaContentType(pathname: string): string {
|
|
|
92
104
|
* even before a token has been minted (so the operator can actually see
|
|
93
105
|
* the empty / auth-required state we render in `VaultDetail.tsx`).
|
|
94
106
|
*/
|
|
95
|
-
export async function serveAdminSpa(
|
|
107
|
+
export async function serveAdminSpa(
|
|
108
|
+
spaDistDir: string,
|
|
109
|
+
pathname: string,
|
|
110
|
+
mountRe: RegExp = ADMIN_SPA_MOUNT_RE,
|
|
111
|
+
): Promise<Response> {
|
|
96
112
|
if (!existsSync(spaDistDir)) {
|
|
97
113
|
return new Response(
|
|
98
114
|
"vault admin SPA bundle not found — run `bun run build` in web/ui/ to produce dist/",
|
|
99
115
|
{ status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
|
|
100
116
|
);
|
|
101
117
|
}
|
|
102
|
-
// Strip the mount prefix
|
|
118
|
+
// Strip the mount prefix (per-vault by default; the daemon-level mount
|
|
119
|
+
// passes its own regex via `serveDaemonAdminSpa`):
|
|
103
120
|
// /vault/foo/admin → ""
|
|
104
121
|
// /vault/foo/admin/ → "/"
|
|
105
122
|
// /vault/foo/admin/x.js → "/x.js"
|
|
106
|
-
|
|
123
|
+
// /vault/admin/x.js → "/x.js" (daemon mount)
|
|
124
|
+
const sub = pathname.replace(mountRe, "");
|
|
107
125
|
|
|
108
126
|
// Canonicalize the bare mount → trailing-slash form. Vite emits
|
|
109
127
|
// *relative* asset URLs (`./assets/index-abc.js`) since `<name>` isn't
|
|
@@ -155,7 +173,34 @@ export async function serveAdminSpa(spaDistDir: string, pathname: string): Promi
|
|
|
155
173
|
* Match `/vault/<name>/admin` or `/vault/<name>/admin/...`. Bare
|
|
156
174
|
* `/vault/<name>/admin-foo` and `/vault/<name>` (the metadata endpoint)
|
|
157
175
|
* must NOT trigger this — only the SPA mount root and its true subpaths.
|
|
176
|
+
*
|
|
177
|
+
* NOTE: `/vault/admin/admin` also matches this regex (name="admin") — the
|
|
178
|
+
* router dispatches `isDaemonAdminSpaPath` FIRST so that path never
|
|
179
|
+
* reaches per-vault mode. Keep that dispatch order; it's pinned in
|
|
180
|
+
* routing.test.ts.
|
|
158
181
|
*/
|
|
159
182
|
export function isAdminSpaPath(pathname: string): boolean {
|
|
160
183
|
return ADMIN_SPA_MOUNT_RE.test(pathname);
|
|
161
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Match the daemon-level multi-vault mount: `/vault/admin` or
|
|
188
|
+
* `/vault/admin/...`. `/vault/adminx` (a real vault that begins with
|
|
189
|
+
* "admin") must NOT trigger this — only the exact segment.
|
|
190
|
+
*/
|
|
191
|
+
export function isDaemonAdminSpaPath(pathname: string): boolean {
|
|
192
|
+
return DAEMON_ADMIN_SPA_MOUNT_RE.test(pathname);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Serve the SPA bundle under the daemon-level `/vault/admin` mount. Same
|
|
197
|
+
* bundle as the per-vault mount — `web/ui/src/lib/mount.ts` detects which
|
|
198
|
+
* basename it booted under at runtime — with the daemon mount's own
|
|
199
|
+
* prefix-strip. The bare-mount 301 inside `serveAdminSpa` fires for
|
|
200
|
+
* `/vault/admin` too: Vite's relative asset URLs resolve against the
|
|
201
|
+
* document's DIRECTORY, so without the trailing-slash canonicalization
|
|
202
|
+
* assets would resolve to `/vault/assets/...` and 404.
|
|
203
|
+
*/
|
|
204
|
+
export function serveDaemonAdminSpa(spaDistDir: string, pathname: string): Promise<Response> {
|
|
205
|
+
return serveAdminSpa(spaDistDir, pathname, DAEMON_ADMIN_SPA_MOUNT_RE);
|
|
206
|
+
}
|
package/src/auth-hub-jwt.test.ts
CHANGED
|
@@ -143,6 +143,7 @@ function bearer(token: string): Request {
|
|
|
143
143
|
let tmpHome: string;
|
|
144
144
|
let prevHome: string | undefined;
|
|
145
145
|
let prevHubOrigin: string | undefined;
|
|
146
|
+
let prevJwksOrigin: string | undefined;
|
|
146
147
|
let fixture: HubFixture;
|
|
147
148
|
let kp: Keypair;
|
|
148
149
|
|
|
@@ -159,7 +160,11 @@ beforeEach(async () => {
|
|
|
159
160
|
kp = await makeKeypair("k1");
|
|
160
161
|
fixture = startHubFixture([kp]);
|
|
161
162
|
prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
163
|
+
prevJwksOrigin = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
162
164
|
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
165
|
+
// Post-vault#464 the JWKS fetch origin resolves separately (loopback by
|
|
166
|
+
// default); point it at the fixture so keys are reachable in-test.
|
|
167
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = fixture.origin;
|
|
163
168
|
resetJwksCache();
|
|
164
169
|
resetRevocationCache();
|
|
165
170
|
});
|
|
@@ -171,6 +176,8 @@ afterEach(() => {
|
|
|
171
176
|
else process.env.PARACHUTE_HOME = prevHome;
|
|
172
177
|
if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
173
178
|
else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
|
|
179
|
+
if (prevJwksOrigin === undefined) delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
180
|
+
else process.env.PARACHUTE_HUB_JWKS_ORIGIN = prevJwksOrigin;
|
|
174
181
|
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
|
|
175
182
|
});
|
|
176
183
|
|
|
@@ -668,7 +675,7 @@ describe("authenticateVaultRequest — hub JWT tag-scoping (auth-unification C0)
|
|
|
668
675
|
|
|
669
676
|
// ---------------------------------------------------------------------------
|
|
670
677
|
// pvt_* DROP (vault#282 Stage 2 — BREAKING). pvt_* tokens were the only
|
|
671
|
-
// non-JWT, non-YAML credential vault used to mint + validate. At 0.
|
|
678
|
+
// non-JWT, non-YAML credential vault used to mint + validate. At 0.5.0 the
|
|
672
679
|
// mint + validation were removed entirely: a pvt_*-prefixed bearer is no
|
|
673
680
|
// longer JWT-shaped (skips authenticateHubJwt) and matches no surviving
|
|
674
681
|
// credential, so it 401s. The hub JWT — the migration target — keeps working.
|
package/src/auth-status.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* What gets exposed:
|
|
10
10
|
* - `initialized` — at least one vault exists
|
|
11
|
-
* - `auth_modes` — accepted bearer formats. As of 0.
|
|
11
|
+
* - `auth_modes` — accepted bearer formats. As of 0.5.0 (vault#282 Stage 2)
|
|
12
12
|
* vault is a pure hub resource-server: the only first-class user
|
|
13
13
|
* credential is a hub-issued JWT, so this is `["hub_jwt"]`. (The
|
|
14
14
|
* server-wide VAULT_AUTH_TOKEN operator bearer + legacy YAML api_keys
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* - `vaults` — list of `{ name, url }` for client-side dispatch
|
|
18
18
|
* - `hasOwnerPassword`, `hasTotp` — OAuth consent prerequisites
|
|
19
19
|
* - `hasTokens` — boolean | null. Probes the vestigial `tokens` table for
|
|
20
|
-
* any leftover pre-0.
|
|
20
|
+
* any leftover pre-0.5.0 rows (the table is kept inert as the YAML-import
|
|
21
21
|
* landing zone + a future-cosmetic-drop target). `null` ≈ "we couldn't
|
|
22
22
|
* read all DBs, don't trust this answer"; `true`/`false` are honest yes/no.
|
|
23
23
|
*
|
package/src/auth.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Auth invariants — vault as a pure hub resource-server (vault#282 Stage 2).
|
|
3
3
|
*
|
|
4
|
-
* The `pvt_*` opaque vault-DB token was dropped at 0.
|
|
4
|
+
* The `pvt_*` opaque vault-DB token was dropped at 0.5.0: vault no longer
|
|
5
5
|
* mints or validates it. The surviving auth surfaces tested here are:
|
|
6
6
|
* - VAULT_AUTH_TOKEN — the server-wide operator bearer.
|
|
7
7
|
* - Legacy YAML api_keys (vault.yaml / config.yaml) — hashed keys.
|
|
@@ -26,7 +26,8 @@ import {
|
|
|
26
26
|
hashKey,
|
|
27
27
|
} from "./config.ts";
|
|
28
28
|
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
29
|
-
import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
|
|
29
|
+
import { authenticateVaultRequest, authenticateGlobalRequest, warnLegacyGlobalApiKeys } from "./auth.ts";
|
|
30
|
+
import type { StoredKey } from "./config.ts";
|
|
30
31
|
|
|
31
32
|
let tmpHome: string;
|
|
32
33
|
let prevHome: string | undefined;
|
|
@@ -91,7 +92,7 @@ describe("auth — pvt_* tokens are unvalidatable (fail closed)", () => {
|
|
|
91
92
|
// API key" a non-pvt_ bad token gets) — the prefix is the user-meaningful
|
|
92
93
|
// signal that the mechanism was dropped, not that the key was mistyped.
|
|
93
94
|
const PVT_MESSAGE =
|
|
94
|
-
"pvt_* tokens are no longer supported (vault 0.
|
|
95
|
+
"pvt_* tokens are no longer supported (vault 0.5.0). Re-add this vault via your hub to get an access token.";
|
|
95
96
|
|
|
96
97
|
test("a pvt_* bearer is 401-rejected with the dropped-token message on the per-vault surface", async () => {
|
|
97
98
|
seedVault("journal");
|
|
@@ -442,3 +443,38 @@ describe("auth — VAULT_AUTH_TOKEN server-wide operator bearer", () => {
|
|
|
442
443
|
expect("error" in result).toBe(true);
|
|
443
444
|
});
|
|
444
445
|
});
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Legacy GLOBAL api_keys boot warning (security review — multi-user
|
|
449
|
+
// hardening). Cross-vault credentials in config.yaml must be surfaced loudly
|
|
450
|
+
// at boot, but never altered. Pure-function unit tests (no server boot).
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
describe("warnLegacyGlobalApiKeys (legacy cross-vault key boot warning)", () => {
|
|
453
|
+
function key(id: string): StoredKey {
|
|
454
|
+
return {
|
|
455
|
+
id,
|
|
456
|
+
label: id,
|
|
457
|
+
key_hash: `sha256:${id}`,
|
|
458
|
+
scope: "full",
|
|
459
|
+
created_at: new Date().toISOString(),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
test("warns when global api_keys are present", () => {
|
|
464
|
+
const msgs: string[] = [];
|
|
465
|
+
const count = warnLegacyGlobalApiKeys([key("a"), key("b")], (m) => msgs.push(m));
|
|
466
|
+
expect(count).toBe(2);
|
|
467
|
+
expect(msgs).toHaveLength(1);
|
|
468
|
+
expect(msgs[0]).toContain("legacy GLOBAL api_key");
|
|
469
|
+
expect(msgs[0]).toContain("CROSS-VAULT");
|
|
470
|
+
// Heads-up only — must signal it does NOT alter the keys.
|
|
471
|
+
expect(msgs[0]).toContain("remain active");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("silent when there are no global api_keys", () => {
|
|
475
|
+
const msgs: string[] = [];
|
|
476
|
+
expect(warnLegacyGlobalApiKeys([], (m) => msgs.push(m))).toBe(0);
|
|
477
|
+
expect(warnLegacyGlobalApiKeys(undefined, (m) => msgs.push(m))).toBe(0);
|
|
478
|
+
expect(msgs).toHaveLength(0);
|
|
479
|
+
});
|
|
480
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Authentication and authorization for the vault server.
|
|
3
3
|
*
|
|
4
|
-
* As of 0.
|
|
4
|
+
* As of 0.5.0 vault is a PURE HUB RESOURCE-SERVER (vault#282 Stage 2). The
|
|
5
5
|
* opaque `pvt_*` vault-DB token was dropped — vault no longer mints or
|
|
6
6
|
* validates it. Three auth paths survive:
|
|
7
7
|
*
|
|
@@ -171,6 +171,35 @@ export function warnLegacyOnce(cacheKey: string, context: string): void {
|
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Boot-time warning for legacy GLOBAL `api_keys` in `config.yaml` (security
|
|
176
|
+
* review — multi-user hardening). Those keys are CROSS-VAULT credentials: a
|
|
177
|
+
* single key authenticates against EVERY vault on this server (see the global
|
|
178
|
+
* `api_keys` branch in `authenticate` ~L283). That predates per-vault keys +
|
|
179
|
+
* tag-scoped hub JWTs and is a confidentiality hazard once a server hosts
|
|
180
|
+
* multiple users' vaults — one user's global key reads another's vault.
|
|
181
|
+
*
|
|
182
|
+
* WARNING ONLY — never touches the keys (the operator owns them). The
|
|
183
|
+
* verification flagged 6 such keys on the live box; this surfaces them at
|
|
184
|
+
* boot so they're rotated/removed before multi-user sharing. Returns the
|
|
185
|
+
* count it warned about (0 = silent) so callers / tests can assert.
|
|
186
|
+
*/
|
|
187
|
+
export function warnLegacyGlobalApiKeys(
|
|
188
|
+
globalApiKeys: StoredKey[] | undefined,
|
|
189
|
+
warn: (msg: string) => void = console.warn,
|
|
190
|
+
): number {
|
|
191
|
+
const count = globalApiKeys?.length ?? 0;
|
|
192
|
+
if (count === 0) return 0;
|
|
193
|
+
warn(
|
|
194
|
+
`[auth] WARNING: ${count} legacy GLOBAL api_key(s) found in config.yaml. ` +
|
|
195
|
+
"These are CROSS-VAULT credentials (each grants access to every vault on this server) " +
|
|
196
|
+
"and predate per-vault keys + tag-scoped hub JWTs. Before multi-user sharing, ROTATE or " +
|
|
197
|
+
"REMOVE them — a global key leaks one user's vault to another. They remain active (the " +
|
|
198
|
+
"operator owns them); this is a heads-up, not an automatic change.",
|
|
199
|
+
);
|
|
200
|
+
return count;
|
|
201
|
+
}
|
|
202
|
+
|
|
174
203
|
/** Read-only tools (the only tools allowed for "read" permission). */
|
|
175
204
|
const READ_TOOLS = new Set([
|
|
176
205
|
"query-notes",
|
|
@@ -310,7 +339,7 @@ function droppedPvtTokenResponse(): Response {
|
|
|
310
339
|
{
|
|
311
340
|
error: "Unauthorized",
|
|
312
341
|
message:
|
|
313
|
-
"pvt_* tokens are no longer supported (vault 0.
|
|
342
|
+
"pvt_* tokens are no longer supported (vault 0.5.0). Re-add this vault via your hub to get an access token.",
|
|
314
343
|
},
|
|
315
344
|
{ status: 401 },
|
|
316
345
|
);
|
|
@@ -118,4 +118,55 @@ describe("shouldAutoTranscribe", () => {
|
|
|
118
118
|
enabledOverride: false,
|
|
119
119
|
})).toBe(false);
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
describe("per-vault precedence (per-vault → global → true)", () => {
|
|
123
|
+
test("per-vault true wins even when global is false", () => {
|
|
124
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
125
|
+
readGlobalConfigImpl: readGlobalConfig(false),
|
|
126
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
127
|
+
perVaultEnabled: true,
|
|
128
|
+
})).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("per-vault false wins even when global is true", () => {
|
|
132
|
+
// The whole point: linking scribe to vault X (perVault true) elsewhere
|
|
133
|
+
// must not force-on a vault that set its own false.
|
|
134
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
135
|
+
readGlobalConfigImpl: readGlobalConfig(true),
|
|
136
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
137
|
+
perVaultEnabled: false,
|
|
138
|
+
})).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("per-vault unset falls back to global", () => {
|
|
142
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
143
|
+
readGlobalConfigImpl: readGlobalConfig(true),
|
|
144
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
145
|
+
perVaultEnabled: undefined,
|
|
146
|
+
})).toBe(true);
|
|
147
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
148
|
+
readGlobalConfigImpl: readGlobalConfig(false),
|
|
149
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
150
|
+
perVaultEnabled: undefined,
|
|
151
|
+
})).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("both per-vault and global unset falls back to true (no regression)", () => {
|
|
155
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
156
|
+
readGlobalConfigImpl: readGlobalConfig(undefined),
|
|
157
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
158
|
+
perVaultEnabled: undefined,
|
|
159
|
+
})).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("enabledOverride still hard-overrides the per-vault value", () => {
|
|
163
|
+
// The explicit caller-opt-in path beats everything.
|
|
164
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
165
|
+
readGlobalConfigImpl: readGlobalConfig(true),
|
|
166
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
167
|
+
perVaultEnabled: false,
|
|
168
|
+
enabledOverride: true,
|
|
169
|
+
})).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
121
172
|
});
|
package/src/auto-transcribe.ts
CHANGED
|
@@ -19,11 +19,18 @@ import { getCachedScribeUrl } from "./scribe-discovery.ts";
|
|
|
19
19
|
*
|
|
20
20
|
* Returns `true` only when ALL three conditions hold:
|
|
21
21
|
* 1. mime-type starts with `audio/` (case-insensitive).
|
|
22
|
-
* 2.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* 2. The resolved auto-transcribe toggle is not `false`. Resolution is
|
|
23
|
+
* **per-vault → global → true**:
|
|
24
|
+
* - `perVaultEnabled` (the owning vault's own `auto_transcribe.enabled`)
|
|
25
|
+
* wins when set — this is what makes scribe's "link to vault X" affect
|
|
26
|
+
* only X, not the whole server.
|
|
27
|
+
* - else the server-wide `globalConfig.auto_transcribe?.enabled`.
|
|
28
|
+
* - else `true` (default ON — once scribe is reachable, audio
|
|
29
|
+
* transcribes without a separate config step). Operators who want it
|
|
30
|
+
* OFF set `auto_transcribe.enabled: false` explicitly (per-vault or
|
|
31
|
+
* globally).
|
|
32
|
+
* `enabledOverride`, when present, hard-overrides the whole chain (used
|
|
33
|
+
* by the explicit caller-opt-in path).
|
|
27
34
|
* 3. Scribe is discoverable (services.json entry OR SCRIBE_URL env).
|
|
28
35
|
*
|
|
29
36
|
* The three conditions are independent guards: a single `false` is sufficient
|
|
@@ -35,7 +42,17 @@ export function shouldAutoTranscribe(
|
|
|
35
42
|
/** Injection seam for tests — defaults to live globals. */
|
|
36
43
|
readGlobalConfigImpl?: typeof readGlobalConfig;
|
|
37
44
|
getCachedScribeUrlImpl?: () => string | undefined;
|
|
38
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* The owning vault's per-vault `auto_transcribe.enabled` (vault.yaml).
|
|
47
|
+
* Takes precedence over the global toggle when set, so enabling/disabling
|
|
48
|
+
* one vault doesn't move the rest. `undefined` (the vault left it unset)
|
|
49
|
+
* falls through to the global toggle.
|
|
50
|
+
*/
|
|
51
|
+
perVaultEnabled?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Hard override of the entire per-vault→global→true chain. Used by the
|
|
54
|
+
* explicit caller-opt-in path; not part of the normal precedence ladder.
|
|
55
|
+
*/
|
|
39
56
|
enabledOverride?: boolean;
|
|
40
57
|
} = {},
|
|
41
58
|
): boolean {
|
|
@@ -43,6 +60,7 @@ export function shouldAutoTranscribe(
|
|
|
43
60
|
return false;
|
|
44
61
|
}
|
|
45
62
|
const enabled = opts.enabledOverride
|
|
63
|
+
?? opts.perVaultEnabled
|
|
46
64
|
?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
|
|
47
65
|
?? true;
|
|
48
66
|
if (!enabled) return false;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { decideAutostart } from "./autostart.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure matrix for the autostart decision (ParachuteComputer/parachute-hub#580
|
|
6
|
+
* item 2). No launchd/systemd is touched — `decideAutostart` is side-effect
|
|
7
|
+
* free; the CLI consumes its result to register or skip.
|
|
8
|
+
*/
|
|
9
|
+
describe("decideAutostart", () => {
|
|
10
|
+
test("hub present, no flag, no persisted → default OFF (#580)", () => {
|
|
11
|
+
const d = decideAutostart({ flagOn: false, flagOff: false, persisted: undefined, hubPresent: true });
|
|
12
|
+
expect(d.enabled).toBe(false);
|
|
13
|
+
expect(d.reason).toBe("hub-default-off");
|
|
14
|
+
// Per-run inference — not persisted, so a later standalone re-run registers.
|
|
15
|
+
expect(d.persist).toBe(false);
|
|
16
|
+
expect(d.overrodeHub).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("hub absent, no flag, no persisted → default ON (standalone)", () => {
|
|
20
|
+
const d = decideAutostart({ flagOn: false, flagOff: false, persisted: undefined, hubPresent: false });
|
|
21
|
+
expect(d.enabled).toBe(true);
|
|
22
|
+
expect(d.reason).toBe("default-on");
|
|
23
|
+
expect(d.persist).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("explicit --autostart forces ON even under a hub (operator override + warn flag)", () => {
|
|
27
|
+
const d = decideAutostart({ flagOn: true, flagOff: false, persisted: undefined, hubPresent: true });
|
|
28
|
+
expect(d.enabled).toBe(true);
|
|
29
|
+
expect(d.reason).toBe("flag-on");
|
|
30
|
+
expect(d.persist).toBe(true);
|
|
31
|
+
expect(d.overrodeHub).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("explicit --autostart with no hub does not set overrodeHub", () => {
|
|
35
|
+
const d = decideAutostart({ flagOn: true, flagOff: false, persisted: undefined, hubPresent: false });
|
|
36
|
+
expect(d.enabled).toBe(true);
|
|
37
|
+
expect(d.overrodeHub).toBe(false);
|
|
38
|
+
expect(d.persist).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("explicit --no-autostart forces OFF and persists (even under a hub)", () => {
|
|
42
|
+
const d = decideAutostart({ flagOn: false, flagOff: true, persisted: undefined, hubPresent: true });
|
|
43
|
+
expect(d.enabled).toBe(false);
|
|
44
|
+
expect(d.reason).toBe("flag-off");
|
|
45
|
+
expect(d.persist).toBe(true);
|
|
46
|
+
expect(d.overrodeHub).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("--no-autostart wins over --autostart on the same line (safer default)", () => {
|
|
50
|
+
const d = decideAutostart({ flagOn: true, flagOff: true, persisted: undefined, hubPresent: false });
|
|
51
|
+
expect(d.enabled).toBe(false);
|
|
52
|
+
expect(d.reason).toBe("flag-off");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("persisted=false honored over hub-present default", () => {
|
|
56
|
+
const d = decideAutostart({ flagOn: false, flagOff: false, persisted: false, hubPresent: true });
|
|
57
|
+
expect(d.enabled).toBe(false);
|
|
58
|
+
expect(d.reason).toBe("persisted");
|
|
59
|
+
expect(d.persist).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("persisted=true honored even when a hub is present (prior explicit choice)", () => {
|
|
63
|
+
const d = decideAutostart({ flagOn: false, flagOff: false, persisted: true, hubPresent: true });
|
|
64
|
+
expect(d.enabled).toBe(true);
|
|
65
|
+
expect(d.reason).toBe("persisted");
|
|
66
|
+
expect(d.persist).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("flag beats persisted: --no-autostart over persisted=true", () => {
|
|
70
|
+
const d = decideAutostart({ flagOn: false, flagOff: true, persisted: true, hubPresent: false });
|
|
71
|
+
expect(d.enabled).toBe(false);
|
|
72
|
+
expect(d.reason).toBe("flag-off");
|
|
73
|
+
expect(d.persist).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|