@openparachute/vault 0.3.3 → 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.
Files changed (79) hide show
  1. package/.parachute/module.json +15 -0
  2. package/core/src/core.test.ts +2252 -7
  3. package/core/src/links.ts +1 -1
  4. package/core/src/mcp.ts +801 -67
  5. package/core/src/note-schemas.ts +232 -0
  6. package/core/src/notes.ts +313 -35
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +287 -0
  11. package/core/src/schema.ts +393 -9
  12. package/core/src/store.ts +248 -6
  13. package/core/src/tag-hierarchy.ts +137 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +100 -6
  16. package/core/src/wikilinks.ts +3 -3
  17. package/package.json +13 -3
  18. package/src/admin-spa.test.ts +161 -0
  19. package/src/admin-spa.ts +161 -0
  20. package/src/auth-hub-jwt.test.ts +231 -0
  21. package/src/auth-status.ts +84 -0
  22. package/src/auth.test.ts +135 -23
  23. package/src/auth.ts +144 -15
  24. package/src/backup.ts +4 -7
  25. package/src/cli.ts +322 -57
  26. package/src/config.test.ts +44 -0
  27. package/src/config.ts +68 -40
  28. package/src/hub-jwt.test.ts +296 -0
  29. package/src/hub-jwt.ts +79 -0
  30. package/src/init.test.ts +216 -0
  31. package/src/mcp-http.ts +30 -28
  32. package/src/mcp-install.ts +1 -1
  33. package/src/mcp-tools.ts +294 -6
  34. package/src/module-config.ts +1 -1
  35. package/src/oauth.test.ts +345 -0
  36. package/src/oauth.ts +85 -14
  37. package/src/owner-auth.ts +57 -1
  38. package/src/prompt.ts +6 -5
  39. package/src/routes.ts +686 -58
  40. package/src/routing.test.ts +466 -1
  41. package/src/routing.ts +108 -24
  42. package/src/scopes.test.ts +66 -8
  43. package/src/scopes.ts +163 -37
  44. package/src/server.ts +24 -2
  45. package/src/services-manifest.test.ts +20 -0
  46. package/src/services-manifest.ts +9 -2
  47. package/src/stop-signal.test.ts +85 -0
  48. package/src/storage.test.ts +92 -0
  49. package/src/tag-scope.ts +118 -0
  50. package/src/token-store.test.ts +47 -0
  51. package/src/token-store.ts +128 -13
  52. package/src/tokens-routes.test.ts +720 -0
  53. package/src/tokens-routes.ts +392 -0
  54. package/src/transcription-worker.test.ts +5 -0
  55. package/src/triggers.ts +1 -1
  56. package/src/two-factor.ts +2 -2
  57. package/src/vault-create.test.ts +193 -0
  58. package/src/vault-name.test.ts +123 -0
  59. package/src/vault-name.ts +80 -0
  60. package/src/vault.test.ts +868 -3
  61. package/tsconfig.json +8 -1
  62. package/.claude/settings.local.json +0 -8
  63. package/.dockerignore +0 -8
  64. package/.env.example +0 -9
  65. package/CHANGELOG.md +0 -175
  66. package/CLAUDE.md +0 -125
  67. package/Caddyfile +0 -3
  68. package/Dockerfile +0 -22
  69. package/bun.lock +0 -219
  70. package/bunfig.toml +0 -2
  71. package/deploy/parachute-vault.service +0 -20
  72. package/docker-compose.yml +0 -50
  73. package/docs/HTTP_API.md +0 -434
  74. package/docs/auth-model.md +0 -340
  75. package/fly.toml +0 -24
  76. package/package/package.json +0 -32
  77. package/railway.json +0 -14
  78. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  79. 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
+ });
@@ -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
+ }