@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.
- package/.parachute/module.json +15 -0
- package/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- 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 +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- 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 +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- 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 +6 -5
- package/src/routes.ts +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -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 +727 -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 +1626 -183
- 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
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,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
|
+
}
|