@openparachute/vault 0.3.1 → 0.4.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 +15 -0
- package/README.md +9 -5
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +384 -78
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init-summary.test.ts +133 -0
- package/src/init-summary.ts +90 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +31 -14
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the admin SPA static-file mount (`src/admin-spa.ts`).
|
|
3
|
+
*
|
|
4
|
+
* The routing layer's responsibility is just "dispatch /admin/* to the SPA";
|
|
5
|
+
* this file tests the SPA-serving behavior itself with a tmp dist dir so
|
|
6
|
+
* the assertions don't depend on `bun run build` having been run in
|
|
7
|
+
* `web/ui/`. The integration check (admin path → SPA dispatch) lives in
|
|
8
|
+
* `routing.test.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
12
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { isAdminSpaPath, serveAdminSpa } from "./admin-spa.ts";
|
|
16
|
+
|
|
17
|
+
const fixtureDir = join(tmpdir(), `vault-admin-spa-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
mkdirSync(join(fixtureDir, "assets"), { recursive: true });
|
|
21
|
+
writeFileSync(join(fixtureDir, "index.html"), "<!doctype html><html><body>shell</body></html>");
|
|
22
|
+
writeFileSync(join(fixtureDir, "assets", "index-abc.js"), "console.log('bundle');");
|
|
23
|
+
writeFileSync(join(fixtureDir, "assets", "index-abc.css"), "body { color: red; }");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(() => {
|
|
27
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("isAdminSpaPath", () => {
|
|
31
|
+
test("matches /vault/<name>/admin and /vault/<name>/admin/...", () => {
|
|
32
|
+
expect(isAdminSpaPath("/vault/work/admin")).toBe(true);
|
|
33
|
+
expect(isAdminSpaPath("/vault/work/admin/")).toBe(true);
|
|
34
|
+
expect(isAdminSpaPath("/vault/work/admin/tokens")).toBe(true);
|
|
35
|
+
expect(isAdminSpaPath("/vault/boulder/admin/assets/index.js")).toBe(true);
|
|
36
|
+
// Vault names with URL-safe punctuation should still match — the regex
|
|
37
|
+
// captures up to the next "/" so dashes / dots / digits all pass.
|
|
38
|
+
expect(isAdminSpaPath("/vault/my-vault.2/admin")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("does not match adjacent paths under the same vault", () => {
|
|
42
|
+
expect(isAdminSpaPath("/vault/work")).toBe(false);
|
|
43
|
+
expect(isAdminSpaPath("/vault/work/")).toBe(false);
|
|
44
|
+
expect(isAdminSpaPath("/vault/work/api/notes")).toBe(false);
|
|
45
|
+
expect(isAdminSpaPath("/vault/work/tokens")).toBe(false);
|
|
46
|
+
// Bare `admin-foo` suffix must not trigger — only the SPA mount itself.
|
|
47
|
+
expect(isAdminSpaPath("/vault/work/admin-foo")).toBe(false);
|
|
48
|
+
expect(isAdminSpaPath("/vault/work/administrative")).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("does not match origin-rooted /admin (legacy mount retired)", () => {
|
|
52
|
+
expect(isAdminSpaPath("/admin")).toBe(false);
|
|
53
|
+
expect(isAdminSpaPath("/admin/")).toBe(false);
|
|
54
|
+
expect(isAdminSpaPath("/admin/tokens")).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("does not match unrelated paths", () => {
|
|
58
|
+
expect(isAdminSpaPath("/")).toBe(false);
|
|
59
|
+
expect(isAdminSpaPath("/vaults")).toBe(false);
|
|
60
|
+
expect(isAdminSpaPath("/auth/status")).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("serveAdminSpa", () => {
|
|
65
|
+
test("503 when the dist dir is absent (unbuilt)", async () => {
|
|
66
|
+
const res = await serveAdminSpa("/nonexistent/dist/dir", "/vault/work/admin/");
|
|
67
|
+
expect(res.status).toBe(503);
|
|
68
|
+
const body = await res.text();
|
|
69
|
+
expect(body).toContain("not found");
|
|
70
|
+
expect(body).toContain("bun run build");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("bare /vault/<name>/admin redirects to trailing-slash form (301)", async () => {
|
|
74
|
+
// Vite's relative asset URLs (./assets/...) resolve against the
|
|
75
|
+
// *directory* of the current document — without a trailing slash,
|
|
76
|
+
// /vault/foo/admin's directory is /vault/foo/ and assets 404 against
|
|
77
|
+
// the per-vault auth wall. Hub's resolveManagementUrl generates the
|
|
78
|
+
// bare form, so this redirect is the load-bearing canonicalization.
|
|
79
|
+
const res = await serveAdminSpa(fixtureDir, "/vault/work/admin");
|
|
80
|
+
expect(res.status).toBe(301);
|
|
81
|
+
expect(res.headers.get("Location")).toBe("/vault/work/admin/");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("/vault/<name>/admin/ returns the SPA index", async () => {
|
|
85
|
+
const res = await serveAdminSpa(fixtureDir, "/vault/work/admin/");
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
88
|
+
expect(await res.text()).toContain("shell");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("client-routed path (no extension) falls through to index.html", async () => {
|
|
92
|
+
const res = await serveAdminSpa(fixtureDir, "/vault/work/admin/tokens");
|
|
93
|
+
expect(res.status).toBe(200);
|
|
94
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
95
|
+
expect(await res.text()).toContain("shell");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("real asset path returns the asset with the right content-type", async () => {
|
|
99
|
+
const jsRes = await serveAdminSpa(fixtureDir, "/vault/work/admin/assets/index-abc.js");
|
|
100
|
+
expect(jsRes.status).toBe(200);
|
|
101
|
+
expect(jsRes.headers.get("content-type")).toContain("application/javascript");
|
|
102
|
+
expect(await jsRes.text()).toContain("console.log");
|
|
103
|
+
|
|
104
|
+
const cssRes = await serveAdminSpa(fixtureDir, "/vault/work/admin/assets/index-abc.css");
|
|
105
|
+
expect(cssRes.status).toBe(200);
|
|
106
|
+
expect(cssRes.headers.get("content-type")).toContain("text/css");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("vault names with URL-safe punctuation strip cleanly", async () => {
|
|
110
|
+
const res = await serveAdminSpa(fixtureDir, "/vault/my-vault.2/admin/assets/index-abc.js");
|
|
111
|
+
expect(res.status).toBe(200);
|
|
112
|
+
expect(res.headers.get("content-type")).toContain("application/javascript");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("typo'd asset path falls through to index.html (not a 404)", async () => {
|
|
116
|
+
const res = await serveAdminSpa(fixtureDir, "/vault/work/admin/assets/missing-xyz.js");
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("path traversal (..) cannot escape dist dir", async () => {
|
|
122
|
+
// Triggers the asset-shape filter (.. is rejected) so this falls through
|
|
123
|
+
// to the SPA shell rather than reading something outside dist/.
|
|
124
|
+
const res = await serveAdminSpa(fixtureDir, "/vault/work/admin/../../etc/passwd");
|
|
125
|
+
expect(res.status).toBe(200);
|
|
126
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
127
|
+
const body = await res.text();
|
|
128
|
+
expect(body).toContain("shell");
|
|
129
|
+
expect(body).not.toContain("root:");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("hub <-> vault managementUrl contract", () => {
|
|
134
|
+
// Browsers drop the URL fragment when following a 301 (RFC 7231 SHOULD
|
|
135
|
+
// preserve, but Chrome/Firefox/Safari are inconsistent in practice). The
|
|
136
|
+
// hub-issued JWT travels in `#token=...`, so a redirected click loses the
|
|
137
|
+
// token and the SPA boots unauthenticated. Hub's resolveManagementUrl joins
|
|
138
|
+
// the per-vault module URL with module.json's `managementUrl` verbatim — if
|
|
139
|
+
// it ends with "/" the canonical click target is `/vault/<name>/admin/`
|
|
140
|
+
// (no redirect, fragment preserved). Without the trailing slash hub emits
|
|
141
|
+
// `/vault/<name>/admin`, the server 301s, and the fragment is gone.
|
|
142
|
+
test("module.json managementUrl ends with '/' so hub emits the no-redirect form", () => {
|
|
143
|
+
const moduleJson = JSON.parse(
|
|
144
|
+
readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
|
|
145
|
+
);
|
|
146
|
+
expect(moduleJson.managementUrl).toMatch(/\/$/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("the canonical hub-emitted URL serves the SPA shell directly (no 301)", async () => {
|
|
150
|
+
// Mirror hub's resolveManagementUrl shape: per-vault module URL +
|
|
151
|
+
// managementUrl. With managementUrl="/admin/" the result is
|
|
152
|
+
// /vault/<name>/admin/ — which serveAdminSpa returns as 200, not 301.
|
|
153
|
+
const moduleJson = JSON.parse(
|
|
154
|
+
readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
|
|
155
|
+
);
|
|
156
|
+
const canonical = `/vault/work${moduleJson.managementUrl}`;
|
|
157
|
+
const res = await serveAdminSpa(fixtureDir, canonical);
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
expect(res.headers.get("Location")).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
});
|
package/src/admin-spa.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin SPA mount. Serves `web/ui/dist/` under `/vault/<name>/admin/*`.
|
|
3
|
+
*
|
|
4
|
+
* The vault HTTP server hosts an admin SPA (vault#216) co-located in the
|
|
5
|
+
* source tree at `web/ui/`. Vite produces the bundle in `web/ui/dist/`
|
|
6
|
+
* (gitignored — built locally before publish, or by a release pipeline).
|
|
7
|
+
* This module turns the bundle into a static-file response.
|
|
8
|
+
*
|
|
9
|
+
* Per-vault mount (vault#252): the SPA lives under `/vault/<name>/admin/*`
|
|
10
|
+
* rather than the origin-rooted `/admin/*` it shipped with. The hub only
|
|
11
|
+
* proxies `/vault/<name>/*` paths (per parachute-patterns/module-protocol),
|
|
12
|
+
* so an origin-rooted SPA is unreachable through the hub. Asset URLs are
|
|
13
|
+
* relative (Vite `base: "./"`), so the same bundle works at any mount
|
|
14
|
+
* point — no rebuild per vault.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors `parachute-hub/src/hub-server.ts:serveSpa` — the conventions
|
|
17
|
+
* (strip mount, asset-shape filter, `.html` fallthrough for client routes)
|
|
18
|
+
* are identical so an operator who knows one knows the other.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { dirname, join, resolve } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Regex anchoring the per-vault SPA mount. Matches `/vault/<name>/admin`
|
|
26
|
+
* exactly and any subpath under it. The `<name>` capture is reused by the
|
|
27
|
+
* prefix-strip below — keep the two in sync if this regex moves.
|
|
28
|
+
*/
|
|
29
|
+
const ADMIN_SPA_MOUNT_RE = /^\/vault\/([^/]+)\/admin(?=\/|$)/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the default SPA bundle dir. Anchored to this file's location so
|
|
33
|
+
* a `bun src/server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
|
|
34
|
+
* Tests / production override via the `spaDistDir` argument to
|
|
35
|
+
* `serveAdminSpa`.
|
|
36
|
+
*/
|
|
37
|
+
export function defaultAdminSpaDistDir(): string {
|
|
38
|
+
// import.meta.dir is the dir holding *this* file (`src/`); the SPA bundle
|
|
39
|
+
// sits at `<repo>/web/ui/dist/`.
|
|
40
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
return resolve(here, "..", "web", "ui", "dist");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pick a content type for static assets the SPA build produces. Vite's
|
|
46
|
+
* fingerprinted output is the realistic surface — js / css / svg / png /
|
|
47
|
+
* woff2 / ico. Mismatches show up loud (a `.js` served as `text/html` is
|
|
48
|
+
* unmistakable) and the list is trivially extensible if a future feature
|
|
49
|
+
* adds an asset type.
|
|
50
|
+
*/
|
|
51
|
+
function spaContentType(pathname: string): string {
|
|
52
|
+
const ext = pathname.slice(pathname.lastIndexOf(".") + 1).toLowerCase();
|
|
53
|
+
switch (ext) {
|
|
54
|
+
case "html":
|
|
55
|
+
return "text/html; charset=utf-8";
|
|
56
|
+
case "js":
|
|
57
|
+
case "mjs":
|
|
58
|
+
return "application/javascript; charset=utf-8";
|
|
59
|
+
case "css":
|
|
60
|
+
return "text/css; charset=utf-8";
|
|
61
|
+
case "svg":
|
|
62
|
+
return "image/svg+xml";
|
|
63
|
+
case "png":
|
|
64
|
+
return "image/png";
|
|
65
|
+
case "ico":
|
|
66
|
+
return "image/x-icon";
|
|
67
|
+
case "woff2":
|
|
68
|
+
return "font/woff2";
|
|
69
|
+
case "woff":
|
|
70
|
+
return "font/woff";
|
|
71
|
+
case "json":
|
|
72
|
+
case "map":
|
|
73
|
+
return "application/json";
|
|
74
|
+
case "txt":
|
|
75
|
+
return "text/plain; charset=utf-8";
|
|
76
|
+
default:
|
|
77
|
+
return "application/octet-stream";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Serve a single file under the SPA mount, falling back to `index.html`
|
|
83
|
+
* for client-side-routed paths (anything that doesn't resolve to a real
|
|
84
|
+
* file under `dist/`). Path-traversal is blocked twice: the asset-shape
|
|
85
|
+
* filter rejects sub-paths containing "..", and the resolved absolute
|
|
86
|
+
* path is checked to start with `dist/` before any read.
|
|
87
|
+
*
|
|
88
|
+
* No auth is enforced at this seam — the SPA's `index.html` and bundle are
|
|
89
|
+
* static assets and reveal nothing privileged. The data fetches the SPA
|
|
90
|
+
* issues land on existing per-vault routes that already enforce
|
|
91
|
+
* `vault:<name>:read` / `vault:<name>:admin`. This keeps the SPA loadable
|
|
92
|
+
* even before a token has been minted (so the operator can actually see
|
|
93
|
+
* the empty / auth-required state we render in `VaultDetail.tsx`).
|
|
94
|
+
*/
|
|
95
|
+
export async function serveAdminSpa(spaDistDir: string, pathname: string): Promise<Response> {
|
|
96
|
+
if (!existsSync(spaDistDir)) {
|
|
97
|
+
return new Response(
|
|
98
|
+
"vault admin SPA bundle not found — run `bun run build` in web/ui/ to produce dist/",
|
|
99
|
+
{ status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
// Strip the mount prefix:
|
|
103
|
+
// /vault/foo/admin → ""
|
|
104
|
+
// /vault/foo/admin/ → "/"
|
|
105
|
+
// /vault/foo/admin/x.js → "/x.js"
|
|
106
|
+
const sub = pathname.replace(ADMIN_SPA_MOUNT_RE, "");
|
|
107
|
+
|
|
108
|
+
// Canonicalize the bare mount → trailing-slash form. Vite emits
|
|
109
|
+
// *relative* asset URLs (`./assets/index-abc.js`) since `<name>` isn't
|
|
110
|
+
// known at build time, and browsers resolve relative URLs against the
|
|
111
|
+
// **directory** of the current document — not the document itself. So:
|
|
112
|
+
// /vault/foo/admin/ → asset resolves to /vault/foo/admin/assets/... ✓
|
|
113
|
+
// /vault/foo/admin → asset resolves to /vault/foo/assets/... ✗
|
|
114
|
+
// Without this redirect, the bare-form URL hub#162 generates (per
|
|
115
|
+
// `resolveManagementUrl` — strip trailing slash, append `/admin`)
|
|
116
|
+
// would 404 every asset against the per-vault auth wall, and the SPA
|
|
117
|
+
// would never boot. Same canonicalization the notes-server `--mount`
|
|
118
|
+
// path does for the same reason.
|
|
119
|
+
if (sub === "") {
|
|
120
|
+
return Response.redirect(`${pathname}/`, 301);
|
|
121
|
+
}
|
|
122
|
+
const indexPath = join(spaDistDir, "index.html");
|
|
123
|
+
|
|
124
|
+
// Empty / mount-root / any non-asset request → SPA shell. The router
|
|
125
|
+
// takes it from there. First defense against traversal: bare paths and
|
|
126
|
+
// anything containing ".." never enter the asset branch — they fall
|
|
127
|
+
// through to the shell below.
|
|
128
|
+
const looksLikeAsset = sub.length > 0 && /\.[a-z0-9]+$/i.test(sub) && !sub.includes("..");
|
|
129
|
+
if (!looksLikeAsset) {
|
|
130
|
+
return new Response(Bun.file(indexPath), {
|
|
131
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const filePath = resolve(spaDistDir, `.${sub}`);
|
|
136
|
+
// Second defense: even if a future tweak loosens looksLikeAsset, refuse
|
|
137
|
+
// any resolved path that escapes dist/. Belt-and-braces.
|
|
138
|
+
if (!filePath.startsWith(`${spaDistDir}/`)) {
|
|
139
|
+
return new Response("not found", { status: 404 });
|
|
140
|
+
}
|
|
141
|
+
if (!existsSync(filePath)) {
|
|
142
|
+
// Asset request that doesn't resolve to a real file → SPA shell.
|
|
143
|
+
// (e.g. `/admin/vault/foo` with a typo'd extension shouldn't 404 the
|
|
144
|
+
// page.)
|
|
145
|
+
return new Response(Bun.file(indexPath), {
|
|
146
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return new Response(Bun.file(filePath), {
|
|
150
|
+
headers: { "content-type": spaContentType(filePath) },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Match `/vault/<name>/admin` or `/vault/<name>/admin/...`. Bare
|
|
156
|
+
* `/vault/<name>/admin-foo` and `/vault/<name>` (the metadata endpoint)
|
|
157
|
+
* must NOT trigger this — only the SPA mount root and its true subpaths.
|
|
158
|
+
*/
|
|
159
|
+
export function isAdminSpaPath(pathname: string): boolean {
|
|
160
|
+
return ADMIN_SPA_MOUNT_RE.test(pathname);
|
|
161
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end auth tests for the hub-JWT path.
|
|
3
|
+
*
|
|
4
|
+
* `hub-jwt.test.ts` covers `validateHubJwt` in isolation. This file exercises
|
|
5
|
+
* the full request path: a JWT bearer arrives at `authenticateVaultRequest`,
|
|
6
|
+
* goes through `authenticateHubJwt`, and the result either resolves into an
|
|
7
|
+
* `AuthResult` or surfaces as a 401 Response. The cases that matter most:
|
|
8
|
+
*
|
|
9
|
+
* - happy path with narrowed scopes
|
|
10
|
+
* - broad `vault:<verb>` scope rejected (forced narrowing per #180)
|
|
11
|
+
* - `aud=vault.<other>` rejected (audience mismatch)
|
|
12
|
+
* - JWT path rejected at the global (cross-vault) entrypoint
|
|
13
|
+
*
|
|
14
|
+
* Each test owns a fresh `PARACHUTE_HOME` and JWKS fixture, like the auth.test
|
|
15
|
+
* peer file. The JWKS fixture mirrors the one in hub-jwt.test.ts; duplicating
|
|
16
|
+
* ~30 lines is cheaper than introducing a shared test-helper module.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
20
|
+
import { mkdirSync, rmSync, existsSync } from "fs";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
import { tmpdir } from "os";
|
|
23
|
+
import { generateKeyPair, exportJWK, SignJWT } from "jose";
|
|
24
|
+
import { writeVaultConfig, readVaultConfig } from "./config.ts";
|
|
25
|
+
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
26
|
+
import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
|
|
27
|
+
import { resetJwksCache } from "./hub-jwt.ts";
|
|
28
|
+
|
|
29
|
+
interface Keypair {
|
|
30
|
+
privateKey: CryptoKey;
|
|
31
|
+
publicJwk: { kty: string; n: string; e: string; kid: string; alg: string; use: string };
|
|
32
|
+
kid: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function makeKeypair(kid: string): Promise<Keypair> {
|
|
36
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
37
|
+
const jwk = await exportJWK(publicKey);
|
|
38
|
+
return {
|
|
39
|
+
privateKey,
|
|
40
|
+
publicJwk: { kty: "RSA", n: jwk.n!, e: jwk.e!, kid, alg: "RS256", use: "sig" },
|
|
41
|
+
kid,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface JwksFixture {
|
|
46
|
+
origin: string;
|
|
47
|
+
stop: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function startJwksFixture(keys: Keypair[]): JwksFixture {
|
|
51
|
+
const server = Bun.serve({
|
|
52
|
+
port: 0,
|
|
53
|
+
fetch(req) {
|
|
54
|
+
const url = new URL(req.url);
|
|
55
|
+
if (url.pathname !== "/.well-known/jwks.json") {
|
|
56
|
+
return new Response("not found", { status: 404 });
|
|
57
|
+
}
|
|
58
|
+
return Response.json({ keys: keys.map((k) => k.publicJwk) });
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
origin: `http://127.0.0.1:${server.port}`,
|
|
63
|
+
stop: () => server.stop(true),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface SignOpts {
|
|
68
|
+
iss: string;
|
|
69
|
+
aud: string;
|
|
70
|
+
scope: string;
|
|
71
|
+
sub?: string;
|
|
72
|
+
ttlSeconds?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
|
|
76
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
77
|
+
const exp = iat + (opts.ttlSeconds ?? 60);
|
|
78
|
+
return await new SignJWT({ scope: opts.scope, client_id: "test-client" })
|
|
79
|
+
.setProtectedHeader({ alg: "RS256", kid: kp.kid })
|
|
80
|
+
.setIssuer(opts.iss)
|
|
81
|
+
.setSubject(opts.sub ?? "user-1")
|
|
82
|
+
.setAudience(opts.aud)
|
|
83
|
+
.setIssuedAt(iat)
|
|
84
|
+
.setExpirationTime(exp)
|
|
85
|
+
.setJti(`jti-${Math.random().toString(36).slice(2)}`)
|
|
86
|
+
.sign(kp.privateKey);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function bearer(token: string): Request {
|
|
90
|
+
return new Request("https://vault.test/x", {
|
|
91
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let tmpHome: string;
|
|
96
|
+
let prevHome: string | undefined;
|
|
97
|
+
let prevHubOrigin: string | undefined;
|
|
98
|
+
let fixture: JwksFixture;
|
|
99
|
+
let kp: Keypair;
|
|
100
|
+
|
|
101
|
+
beforeEach(async () => {
|
|
102
|
+
tmpHome = join(
|
|
103
|
+
tmpdir(),
|
|
104
|
+
`vault-auth-jwt-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
105
|
+
);
|
|
106
|
+
mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
|
|
107
|
+
prevHome = process.env.PARACHUTE_HOME;
|
|
108
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
109
|
+
clearVaultStoreCache();
|
|
110
|
+
|
|
111
|
+
kp = await makeKeypair("k1");
|
|
112
|
+
fixture = startJwksFixture([kp]);
|
|
113
|
+
prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
114
|
+
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
115
|
+
resetJwksCache();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
fixture.stop();
|
|
120
|
+
clearVaultStoreCache();
|
|
121
|
+
if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
122
|
+
else process.env.PARACHUTE_HOME = prevHome;
|
|
123
|
+
if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
124
|
+
else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
|
|
125
|
+
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
function seedVault(name: string): void {
|
|
129
|
+
writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
|
|
130
|
+
// Touch the store so the DB file exists (matches the routing path's expectation).
|
|
131
|
+
getVaultStore(name);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe("authenticateVaultRequest — hub JWT integration", () => {
|
|
135
|
+
test("narrowed scope + matching aud → AuthResult with permission derived from verb", async () => {
|
|
136
|
+
seedVault("journal");
|
|
137
|
+
const token = await signJwt(kp, {
|
|
138
|
+
iss: fixture.origin,
|
|
139
|
+
aud: "vault.journal",
|
|
140
|
+
scope: "vault:journal:write",
|
|
141
|
+
});
|
|
142
|
+
const config = readVaultConfig("journal")!;
|
|
143
|
+
const store = getVaultStore("journal");
|
|
144
|
+
|
|
145
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
146
|
+
expect("error" in result).toBe(false);
|
|
147
|
+
if (!("error" in result)) {
|
|
148
|
+
expect(result.permission).toBe("full");
|
|
149
|
+
expect(result.scopes).toEqual(["vault:journal:write"]);
|
|
150
|
+
expect(result.legacyDerived).toBe(false);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("narrowed read scope → permission='read'", async () => {
|
|
155
|
+
seedVault("journal");
|
|
156
|
+
const token = await signJwt(kp, {
|
|
157
|
+
iss: fixture.origin,
|
|
158
|
+
aud: "vault.journal",
|
|
159
|
+
scope: "vault:journal:read",
|
|
160
|
+
});
|
|
161
|
+
const config = readVaultConfig("journal")!;
|
|
162
|
+
const store = getVaultStore("journal");
|
|
163
|
+
|
|
164
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
165
|
+
expect("error" in result).toBe(false);
|
|
166
|
+
if (!("error" in result)) expect(result.permission).toBe("read");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("broad vault:write scope from a JWT → 401 with explanatory message", async () => {
|
|
170
|
+
seedVault("journal");
|
|
171
|
+
const token = await signJwt(kp, {
|
|
172
|
+
iss: fixture.origin,
|
|
173
|
+
aud: "vault.journal",
|
|
174
|
+
scope: "vault:write",
|
|
175
|
+
});
|
|
176
|
+
const config = readVaultConfig("journal")!;
|
|
177
|
+
const store = getVaultStore("journal");
|
|
178
|
+
|
|
179
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
180
|
+
expect("error" in result).toBe(true);
|
|
181
|
+
if ("error" in result) {
|
|
182
|
+
expect(result.error.status).toBe(401);
|
|
183
|
+
const body = (await result.error.json()) as { error: string; message: string };
|
|
184
|
+
expect(body.error).toBe("Unauthorized");
|
|
185
|
+
expect(body.message).toContain("broad vault scope");
|
|
186
|
+
expect(body.message).toContain("vault:write");
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("aud=vault.work cannot reach /vault/journal/* → 401 audience mismatch", async () => {
|
|
191
|
+
seedVault("journal");
|
|
192
|
+
seedVault("work");
|
|
193
|
+
// Token is correctly stamped for work, but presented at journal's endpoint.
|
|
194
|
+
const token = await signJwt(kp, {
|
|
195
|
+
iss: fixture.origin,
|
|
196
|
+
aud: "vault.work",
|
|
197
|
+
scope: "vault:work:write",
|
|
198
|
+
});
|
|
199
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
200
|
+
const journalStore = getVaultStore("journal");
|
|
201
|
+
|
|
202
|
+
const result = await authenticateVaultRequest(
|
|
203
|
+
bearer(token),
|
|
204
|
+
journalConfig,
|
|
205
|
+
journalStore.db,
|
|
206
|
+
);
|
|
207
|
+
expect("error" in result).toBe(true);
|
|
208
|
+
if ("error" in result) {
|
|
209
|
+
expect(result.error.status).toBe(401);
|
|
210
|
+
const body = (await result.error.json()) as { error: string; message: string };
|
|
211
|
+
expect(body.message).toMatch(/audience mismatch.*vault\.journal.*vault\.work/);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("hub JWT at the global (cross-vault) entrypoint → 401 with vault-bound hint", async () => {
|
|
216
|
+
seedVault("journal");
|
|
217
|
+
const token = await signJwt(kp, {
|
|
218
|
+
iss: fixture.origin,
|
|
219
|
+
aud: "vault.journal",
|
|
220
|
+
scope: "vault:journal:read",
|
|
221
|
+
});
|
|
222
|
+
const result = await authenticateGlobalRequest(bearer(token));
|
|
223
|
+
expect("error" in result).toBe(true);
|
|
224
|
+
if ("error" in result) {
|
|
225
|
+
expect(result.error.status).toBe(401);
|
|
226
|
+
const body = (await result.error.json()) as { error: string; message: string };
|
|
227
|
+
expect(body.message).toContain("vault-bound");
|
|
228
|
+
expect(body.message).toContain("/vault/<name>");
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public auth-state probe — read-only summary that lets a first-contact
|
|
3
|
+
* client decide which token to mint before doing anything authenticated.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the consumer shape that `parachute-hub/src/vault/auth-status.ts`
|
|
6
|
+
* already computes by snooping vault's filesystem; this endpoint replaces
|
|
7
|
+
* that out-of-process coupling with an in-process read.
|
|
8
|
+
*
|
|
9
|
+
* What gets exposed:
|
|
10
|
+
* - `initialized` — at least one vault exists
|
|
11
|
+
* - `auth_modes` — accepted bearer formats (pvt_*, hub-issued JWT)
|
|
12
|
+
* - `vaults` — list of `{ name, url }` for client-side dispatch
|
|
13
|
+
* - `hasOwnerPassword`, `hasTotp` — OAuth consent prerequisites
|
|
14
|
+
* - `hasTokens` — boolean | null. `null` ≈ "we couldn't read all DBs,
|
|
15
|
+
* don't trust this answer"; `true`/`false` are honest yes/no signals.
|
|
16
|
+
*
|
|
17
|
+
* What is deliberately NOT exposed: token counts, hashes, descriptions,
|
|
18
|
+
* timestamps, owner-password hash, totp secret, backup codes. The endpoint
|
|
19
|
+
* is unauthenticated — anything sensitive belongs behind /vaults or
|
|
20
|
+
* /vault/<name>/.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Database } from "bun:sqlite";
|
|
24
|
+
import { existsSync } from "fs";
|
|
25
|
+
import { listVaults, readGlobalConfig, vaultDbPath } from "./config.ts";
|
|
26
|
+
|
|
27
|
+
export interface AuthStatusResponse {
|
|
28
|
+
initialized: boolean;
|
|
29
|
+
auth_modes: ("pvt_token" | "hub_jwt")[];
|
|
30
|
+
vaults: { name: string; url: string }[];
|
|
31
|
+
hasOwnerPassword: boolean;
|
|
32
|
+
hasTotp: boolean;
|
|
33
|
+
hasTokens: boolean | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Probe a single vault's `tokens` table for *existence* (not count). We open
|
|
38
|
+
* the DB read-only and `LIMIT 1` so we never block a writer or fight for a
|
|
39
|
+
* lock. Any failure (missing DB, schema drift, lock contention) is the
|
|
40
|
+
* caller's signal to degrade `hasTokens` to `null`.
|
|
41
|
+
*/
|
|
42
|
+
function vaultHasTokens(dbPath: string): boolean {
|
|
43
|
+
const db = new Database(dbPath, { readonly: true });
|
|
44
|
+
try {
|
|
45
|
+
const row = db.prepare("SELECT 1 FROM tokens LIMIT 1").get();
|
|
46
|
+
return row !== null && row !== undefined;
|
|
47
|
+
} finally {
|
|
48
|
+
db.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readTokenPresence(vaultNames: string[]): boolean | null {
|
|
53
|
+
if (vaultNames.length === 0) return false;
|
|
54
|
+
let any = false;
|
|
55
|
+
for (const name of vaultNames) {
|
|
56
|
+
const dbPath = vaultDbPath(name);
|
|
57
|
+
if (!existsSync(dbPath)) continue;
|
|
58
|
+
try {
|
|
59
|
+
if (vaultHasTokens(dbPath)) {
|
|
60
|
+
any = true;
|
|
61
|
+
// Don't early-return — keep probing so a later locked DB still
|
|
62
|
+
// surfaces as `null` rather than a misleading `true`.
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return any;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildAuthStatus(): AuthStatusResponse {
|
|
72
|
+
const globalConfig = readGlobalConfig();
|
|
73
|
+
const vaultNames = listVaults();
|
|
74
|
+
return {
|
|
75
|
+
initialized: vaultNames.length > 0,
|
|
76
|
+
auth_modes: ["pvt_token", "hub_jwt"],
|
|
77
|
+
vaults: vaultNames.map((name) => ({ name, url: `/vault/${name}` })),
|
|
78
|
+
hasOwnerPassword: typeof globalConfig.owner_password_hash === "string"
|
|
79
|
+
&& globalConfig.owner_password_hash.length > 0,
|
|
80
|
+
hasTotp: typeof globalConfig.totp_secret === "string"
|
|
81
|
+
&& globalConfig.totp_secret.length > 0,
|
|
82
|
+
hasTokens: readTokenPresence(vaultNames),
|
|
83
|
+
};
|
|
84
|
+
}
|