@openparachute/vault 0.3.3 → 0.4.3

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 (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  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 +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -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,360 @@
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
+ * - revoked jti rejected (revocation list integration; client-facing
14
+ * message is sanitized so the jti doesn't leak)
15
+ * - revocation list unavailable on cold start → fail-closed 401
16
+ *
17
+ * Each test owns a fresh `PARACHUTE_HOME` and a fake hub fixture that serves
18
+ * BOTH `/.well-known/jwks.json` and `/.well-known/parachute-revocation.json`.
19
+ * scope-guard's own unit suite covers the cache mechanics (TTL refresh,
20
+ * fail-open with last-good, single-flight); this file pins the vault-side
21
+ * wiring and the response-shape contract.
22
+ */
23
+
24
+ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
25
+ import { mkdirSync, rmSync, existsSync } from "fs";
26
+ import { join } from "path";
27
+ import { tmpdir } from "os";
28
+ import { generateKeyPair, exportJWK, SignJWT } from "jose";
29
+ import { writeVaultConfig, readVaultConfig } from "./config.ts";
30
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
31
+ import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
32
+ import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
33
+
34
+ interface Keypair {
35
+ privateKey: CryptoKey;
36
+ publicJwk: { kty: string; n: string; e: string; kid: string; alg: string; use: string };
37
+ kid: string;
38
+ }
39
+
40
+ async function makeKeypair(kid: string): Promise<Keypair> {
41
+ const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
42
+ const jwk = await exportJWK(publicKey);
43
+ return {
44
+ privateKey,
45
+ publicJwk: { kty: "RSA", n: jwk.n!, e: jwk.e!, kid, alg: "RS256", use: "sig" },
46
+ kid,
47
+ };
48
+ }
49
+
50
+ interface HubFixture {
51
+ origin: string;
52
+ /** Drive the revocation list contents; cleared by default. */
53
+ setRevoked(jtis: string[]): void;
54
+ /** When true, the revocation endpoint returns 503 — exercises fail-closed. */
55
+ setRevocationFails(fails: boolean): void;
56
+ stop: () => void;
57
+ }
58
+
59
+ function startHubFixture(keys: Keypair[]): HubFixture {
60
+ let revokedJtis: string[] = [];
61
+ let revocationFails = false;
62
+ const server = Bun.serve({
63
+ port: 0,
64
+ fetch(req) {
65
+ const url = new URL(req.url);
66
+ if (url.pathname === "/.well-known/jwks.json") {
67
+ return Response.json({ keys: keys.map((k) => k.publicJwk) });
68
+ }
69
+ if (url.pathname === "/.well-known/parachute-revocation.json") {
70
+ if (revocationFails) {
71
+ return new Response("hub down", { status: 503 });
72
+ }
73
+ return Response.json({
74
+ generated_at: new Date().toISOString(),
75
+ jtis: revokedJtis,
76
+ });
77
+ }
78
+ return new Response("not found", { status: 404 });
79
+ },
80
+ });
81
+ return {
82
+ origin: `http://127.0.0.1:${server.port}`,
83
+ setRevoked: (jtis) => {
84
+ revokedJtis = jtis;
85
+ },
86
+ setRevocationFails: (fails) => {
87
+ revocationFails = fails;
88
+ },
89
+ stop: () => server.stop(true),
90
+ };
91
+ }
92
+
93
+ interface SignOpts {
94
+ iss: string;
95
+ aud: string;
96
+ scope: string;
97
+ sub?: string;
98
+ ttlSeconds?: number;
99
+ /** Override the random jti — needed when a test wants to revoke this exact token. */
100
+ jti?: string;
101
+ }
102
+
103
+ async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
104
+ const iat = Math.floor(Date.now() / 1000);
105
+ const exp = iat + (opts.ttlSeconds ?? 60);
106
+ return await new SignJWT({ scope: opts.scope, client_id: "test-client" })
107
+ .setProtectedHeader({ alg: "RS256", kid: kp.kid })
108
+ .setIssuer(opts.iss)
109
+ .setSubject(opts.sub ?? "user-1")
110
+ .setAudience(opts.aud)
111
+ .setIssuedAt(iat)
112
+ .setExpirationTime(exp)
113
+ .setJti(opts.jti ?? `jti-${Math.random().toString(36).slice(2)}`)
114
+ .sign(kp.privateKey);
115
+ }
116
+
117
+ function bearer(token: string): Request {
118
+ return new Request("https://vault.test/x", {
119
+ headers: { Authorization: `Bearer ${token}` },
120
+ });
121
+ }
122
+
123
+ let tmpHome: string;
124
+ let prevHome: string | undefined;
125
+ let prevHubOrigin: string | undefined;
126
+ let fixture: HubFixture;
127
+ let kp: Keypair;
128
+
129
+ beforeEach(async () => {
130
+ tmpHome = join(
131
+ tmpdir(),
132
+ `vault-auth-jwt-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
133
+ );
134
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
135
+ prevHome = process.env.PARACHUTE_HOME;
136
+ process.env.PARACHUTE_HOME = tmpHome;
137
+ clearVaultStoreCache();
138
+
139
+ kp = await makeKeypair("k1");
140
+ fixture = startHubFixture([kp]);
141
+ prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
142
+ process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
143
+ resetJwksCache();
144
+ resetRevocationCache();
145
+ });
146
+
147
+ afterEach(() => {
148
+ fixture.stop();
149
+ clearVaultStoreCache();
150
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
151
+ else process.env.PARACHUTE_HOME = prevHome;
152
+ if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
153
+ else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
154
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
155
+ });
156
+
157
+ function seedVault(name: string): void {
158
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
159
+ // Touch the store so the DB file exists (matches the routing path's expectation).
160
+ getVaultStore(name);
161
+ }
162
+
163
+ describe("authenticateVaultRequest — hub JWT integration", () => {
164
+ test("narrowed scope + matching aud → AuthResult with permission derived from verb", async () => {
165
+ seedVault("journal");
166
+ const token = await signJwt(kp, {
167
+ iss: fixture.origin,
168
+ aud: "vault.journal",
169
+ scope: "vault:journal:write",
170
+ });
171
+ const config = readVaultConfig("journal")!;
172
+ const store = getVaultStore("journal");
173
+
174
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
175
+ expect("error" in result).toBe(false);
176
+ if (!("error" in result)) {
177
+ expect(result.permission).toBe("full");
178
+ expect(result.scopes).toEqual(["vault:journal:write"]);
179
+ expect(result.legacyDerived).toBe(false);
180
+ }
181
+ });
182
+
183
+ test("narrowed read scope → permission='read'", async () => {
184
+ seedVault("journal");
185
+ const token = await signJwt(kp, {
186
+ iss: fixture.origin,
187
+ aud: "vault.journal",
188
+ scope: "vault:journal:read",
189
+ });
190
+ const config = readVaultConfig("journal")!;
191
+ const store = getVaultStore("journal");
192
+
193
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
194
+ expect("error" in result).toBe(false);
195
+ if (!("error" in result)) expect(result.permission).toBe("read");
196
+ });
197
+
198
+ test("broad vault:write scope from a JWT → 401 with explanatory message", async () => {
199
+ seedVault("journal");
200
+ const token = await signJwt(kp, {
201
+ iss: fixture.origin,
202
+ aud: "vault.journal",
203
+ scope: "vault:write",
204
+ });
205
+ const config = readVaultConfig("journal")!;
206
+ const store = getVaultStore("journal");
207
+
208
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
209
+ expect("error" in result).toBe(true);
210
+ if ("error" in result) {
211
+ expect(result.error.status).toBe(401);
212
+ const body = (await result.error.json()) as { error: string; message: string };
213
+ expect(body.error).toBe("Unauthorized");
214
+ expect(body.message).toContain("broad vault scope");
215
+ expect(body.message).toContain("vault:write");
216
+ }
217
+ });
218
+
219
+ test("aud=vault.work cannot reach /vault/journal/* → 401 audience mismatch", async () => {
220
+ seedVault("journal");
221
+ seedVault("work");
222
+ // Token is correctly stamped for work, but presented at journal's endpoint.
223
+ const token = await signJwt(kp, {
224
+ iss: fixture.origin,
225
+ aud: "vault.work",
226
+ scope: "vault:work:write",
227
+ });
228
+ const journalConfig = readVaultConfig("journal")!;
229
+ const journalStore = getVaultStore("journal");
230
+
231
+ const result = await authenticateVaultRequest(
232
+ bearer(token),
233
+ journalConfig,
234
+ journalStore.db,
235
+ );
236
+ expect("error" in result).toBe(true);
237
+ if ("error" in result) {
238
+ expect(result.error.status).toBe(401);
239
+ const body = (await result.error.json()) as { error: string; message: string };
240
+ expect(body.message).toMatch(/audience mismatch.*vault\.journal.*vault\.work/);
241
+ }
242
+ });
243
+
244
+ test("hub JWT at the global (cross-vault) entrypoint → 401 with vault-bound hint", async () => {
245
+ seedVault("journal");
246
+ const token = await signJwt(kp, {
247
+ iss: fixture.origin,
248
+ aud: "vault.journal",
249
+ scope: "vault:journal:read",
250
+ });
251
+ const result = await authenticateGlobalRequest(bearer(token));
252
+ expect("error" in result).toBe(true);
253
+ if ("error" in result) {
254
+ expect(result.error.status).toBe(401);
255
+ const body = (await result.error.json()) as { error: string; message: string };
256
+ expect(body.message).toContain("vault-bound");
257
+ expect(body.message).toContain("/vault/<name>");
258
+ }
259
+ });
260
+
261
+ test("revoked jti → 401 sanitized; full diagnostic (with jti) routed to console.warn audit log", async () => {
262
+ seedVault("journal");
263
+ const revokedJti = "jti-revoked-by-operator";
264
+ fixture.setRevoked([revokedJti]);
265
+ const token = await signJwt(kp, {
266
+ iss: fixture.origin,
267
+ aud: "vault.journal",
268
+ scope: "vault:journal:read",
269
+ jti: revokedJti,
270
+ });
271
+ const config = readVaultConfig("journal")!;
272
+ const store = getVaultStore("journal");
273
+
274
+ // Spy + suppress so the assertion is the audit-trail invariant for
275
+ // this scenario, not a stderr inspection. Pattern carries to scribe/agent.
276
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
277
+ try {
278
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
279
+ expect("error" in result).toBe(true);
280
+ if ("error" in result) {
281
+ expect(result.error.status).toBe(401);
282
+ const body = (await result.error.json()) as { error: string; message: string };
283
+ expect(body.error).toBe("Unauthorized");
284
+ // Client-facing message must NOT carry the jti — that's a server-side
285
+ // audit-log concern only. See the `code === "revoked"` branch in
286
+ // authenticateHubJwt for the sanitization.
287
+ expect(body.message).toBe("token has been revoked");
288
+ expect(body.message).not.toContain(revokedJti);
289
+ }
290
+ // Audit-log invariant: console.warn fires exactly once with a message
291
+ // that carries the jti, so an operator chasing a 401 in production logs
292
+ // can correlate to which token was retired.
293
+ expect(warnSpy).toHaveBeenCalledTimes(1);
294
+ const warnArg = warnSpy.mock.calls[0]![0] as string;
295
+ expect(warnArg).toContain(revokedJti);
296
+ expect(warnArg).toContain("revoked");
297
+ } finally {
298
+ warnSpy.mockRestore();
299
+ }
300
+ });
301
+
302
+ test("non-revoked jti against populated list → still honored (happy path with active revocations)", async () => {
303
+ seedVault("journal");
304
+ fixture.setRevoked(["some-other-revoked-jti"]);
305
+ const token = await signJwt(kp, {
306
+ iss: fixture.origin,
307
+ aud: "vault.journal",
308
+ scope: "vault:journal:write",
309
+ jti: "jti-still-good",
310
+ });
311
+ const config = readVaultConfig("journal")!;
312
+ const store = getVaultStore("journal");
313
+
314
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
315
+ expect("error" in result).toBe(false);
316
+ if (!("error" in result)) {
317
+ expect(result.permission).toBe("full");
318
+ expect(result.scopes).toEqual(["vault:journal:write"]);
319
+ }
320
+ });
321
+
322
+ test("revocation list unreachable on cold start → fail-closed 401 sanitized; full diagnostic routed to console.warn", async () => {
323
+ seedVault("journal");
324
+ // Hub is reachable for JWKS but the revocation endpoint 503s. Cold cache
325
+ // + first-fetch-fail = "unknown" outcome, surfaced as
326
+ // HubJwtError(code: "revocation_unavailable"). Client gets a code-shaped
327
+ // sentence; the implementation-detail phrasing ("no last-good cache")
328
+ // stays in the server-side audit log.
329
+ fixture.setRevocationFails(true);
330
+ const token = await signJwt(kp, {
331
+ iss: fixture.origin,
332
+ aud: "vault.journal",
333
+ scope: "vault:journal:read",
334
+ });
335
+ const config = readVaultConfig("journal")!;
336
+ const store = getVaultStore("journal");
337
+
338
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
339
+ try {
340
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
341
+ expect("error" in result).toBe(true);
342
+ if ("error" in result) {
343
+ expect(result.error.status).toBe(401);
344
+ const body = (await result.error.json()) as { error: string; message: string };
345
+ // Client message: code-shaped, no internals.
346
+ expect(body.message).toBe("token cannot be validated: revocation list unavailable");
347
+ // The internal phrase "no last-good cache" is a scope-guard
348
+ // implementation detail and must not leak into the public response.
349
+ expect(body.message).not.toContain("last-good cache");
350
+ }
351
+ // Audit-log invariant: full diagnostic routed to console.warn so
352
+ // operators can distinguish cold-start from sustained outage.
353
+ expect(warnSpy).toHaveBeenCalledTimes(1);
354
+ const warnArg = warnSpy.mock.calls[0]![0] as string;
355
+ expect(warnArg).toContain("no last-good cache");
356
+ } finally {
357
+ warnSpy.mockRestore();
358
+ }
359
+ });
360
+ });
@@ -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
+ }