@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.10
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 +1 -1
- package/README.md +78 -41
- package/core/src/connection-pragmas.test.ts +232 -0
- package/core/src/core.test.ts +257 -0
- package/core/src/cursor.test.ts +160 -0
- package/core/src/cursor.ts +272 -0
- package/core/src/mcp.ts +51 -7
- package/core/src/notes.ts +164 -2
- package/core/src/schema.ts +106 -5
- package/core/src/store.ts +11 -1
- package/core/src/types.ts +32 -0
- package/package.json +7 -3
- package/src/auth-status.ts +4 -0
- package/src/auth.test.ts +5 -112
- package/src/auto-transcribe.test.ts +116 -0
- package/src/auto-transcribe.ts +48 -0
- package/src/backup.ts +17 -3
- package/src/cli.ts +95 -66
- package/src/config.test.ts +26 -0
- package/src/config.ts +53 -1
- package/src/db.ts +15 -2
- package/src/export-watch.test.ts +21 -0
- package/src/mcp-install-interactive.test.ts +23 -2
- package/src/mcp-install-interactive.ts +21 -2
- package/src/mcp-install.test.ts +40 -0
- package/src/mcp-tools.ts +17 -1
- package/src/module-config.ts +70 -14
- package/src/module-manifest.test.ts +114 -0
- package/src/module-manifest.ts +104 -0
- package/src/oauth-discovery.ts +95 -0
- package/src/owner-auth.ts +22 -149
- package/src/routes.ts +268 -51
- package/src/routing.test.ts +102 -99
- package/src/routing.ts +33 -47
- package/src/scribe-discovery.test.ts +77 -0
- package/src/scribe-discovery.ts +91 -0
- package/src/scribe-env.test.ts +66 -1
- package/src/scribe-env.ts +42 -1
- package/src/self-register.test.ts +412 -0
- package/src/self-register.ts +247 -0
- package/src/server.ts +47 -23
- package/src/transcript-note.test.ts +171 -0
- package/src/transcript-note.ts +189 -0
- package/src/transcription-registry.ts +22 -0
- package/src/transcription-worker.test.ts +250 -0
- package/src/transcription-worker.ts +186 -27
- package/src/vault-name.ts +3 -2
- package/src/vault.test.ts +347 -0
- package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
- package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
- package/web/ui/dist/index.html +14 -0
- package/web/ui/tsconfig.json +21 -0
- package/src/oauth.test.ts +0 -2156
- package/src/oauth.ts +0 -973
package/src/module-config.ts
CHANGED
|
@@ -15,15 +15,27 @@
|
|
|
15
15
|
* PUT /.parachute/config is Phase 3 — not implemented here.
|
|
16
16
|
*
|
|
17
17
|
* Fields currently described:
|
|
18
|
-
* - audio_retention:
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* - audio_retention: per-vault enum, backed by VaultConfig.audio_retention.
|
|
19
|
+
* - port: GlobalConfig.port, exposed read-only.
|
|
20
|
+
* - autoTranscribe.*: vault↔scribe handoff (vault#353, design 2026-05-21
|
|
21
|
+
* Part 2). Three nested fields per design Q4:
|
|
22
|
+
* - enabled: boolean toggle, default false (persisted in
|
|
23
|
+
* GlobalConfig.auto_transcribe.enabled).
|
|
24
|
+
* - scribeUrl: readOnly — resolved per-process from
|
|
25
|
+
* `~/.parachute/services.json` via
|
|
26
|
+
* `scribe-discovery.ts`. Operators can't point at an
|
|
27
|
+
* arbitrary scribe; the discovery layer is the gate.
|
|
28
|
+
* - scribeBearer: writeOnly — sourced from SCRIBE_AUTH_TOKEN env var.
|
|
29
|
+
* Hub install generates one at first boot
|
|
30
|
+
* (see scribe-env.ts:ensureScribeBearer); manual
|
|
31
|
+
* rotation is via `parachute-vault config set`.
|
|
32
|
+
* - scribe_url / scribe_token (deprecated): kept under their legacy names
|
|
33
|
+
* through one release for the hub admin SPA's prior
|
|
34
|
+
* render path; new code should read autoTranscribe.*.
|
|
24
35
|
*/
|
|
25
36
|
|
|
26
37
|
import type { VaultConfig, GlobalConfig } from "./config.ts";
|
|
38
|
+
import { resolveScribeUrl } from "./scribe-discovery.ts";
|
|
27
39
|
|
|
28
40
|
export interface ModuleConfigSchema {
|
|
29
41
|
$schema: string;
|
|
@@ -49,20 +61,54 @@ export function buildConfigSchema(): ModuleConfigSchema {
|
|
|
49
61
|
description:
|
|
50
62
|
"What to do with audio attachments after transcription. `keep` leaves the file on disk; `until_transcribed` unlinks on successful transcribe (keeps on failure for retry); `never` unlinks on any terminal state (including failure — no retries).",
|
|
51
63
|
},
|
|
64
|
+
autoTranscribe: {
|
|
65
|
+
type: "object",
|
|
66
|
+
title: "Auto-transcribe voice uploads",
|
|
67
|
+
description:
|
|
68
|
+
"When enabled, audio attachments (mime-type prefix `audio/`) are automatically sent to scribe and the resulting transcript lands as a sibling `<attachment-path>.transcript.md` note. Scribe must be reachable for transcription to succeed; failures are recorded as a transcript note with `transcript_status: failed`.",
|
|
69
|
+
properties: {
|
|
70
|
+
enabled: {
|
|
71
|
+
type: "boolean",
|
|
72
|
+
default: false,
|
|
73
|
+
title: "Enable auto-transcription",
|
|
74
|
+
description:
|
|
75
|
+
"Master toggle. When false, audio uploads land normally without any scribe interaction. Global — persisted in `GlobalConfig.auto_transcribe.enabled` and applies to every vault on this server. Per-vault control is a future enhancement when multi-vault deployments need it.",
|
|
76
|
+
},
|
|
77
|
+
scribeUrl: {
|
|
78
|
+
type: "string",
|
|
79
|
+
format: "uri",
|
|
80
|
+
readOnly: true,
|
|
81
|
+
title: "Scribe URL",
|
|
82
|
+
description:
|
|
83
|
+
"URL of the scribe service. Auto-populated from `~/.parachute/services.json` at vault startup (or from the SCRIBE_URL env var when set). Read-only — operators can't point at an arbitrary scribe.",
|
|
84
|
+
},
|
|
85
|
+
scribeBearer: {
|
|
86
|
+
type: "string",
|
|
87
|
+
writeOnly: true,
|
|
88
|
+
title: "Scribe auth bearer",
|
|
89
|
+
description:
|
|
90
|
+
"Shared bearer for the vault→scribe loopback contract. Hub install generates one at first boot. Write-only — never returned by GET.",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
// Legacy aliases kept for back-compat with callers that read the
|
|
95
|
+
// pre-vault#353 shape. New consumers should read `autoTranscribe.*`.
|
|
52
96
|
scribe_url: {
|
|
53
97
|
type: "string",
|
|
54
98
|
format: "uri",
|
|
55
|
-
title: "Scribe URL",
|
|
99
|
+
title: "Scribe URL (deprecated alias)",
|
|
56
100
|
description:
|
|
57
|
-
"
|
|
101
|
+
"Legacy alias for `autoTranscribe.scribeUrl`. Will be removed in a future release.",
|
|
58
102
|
readOnly: true,
|
|
103
|
+
deprecated: true,
|
|
59
104
|
},
|
|
60
105
|
scribe_token: {
|
|
61
106
|
type: "string",
|
|
62
|
-
title: "Scribe auth token",
|
|
107
|
+
title: "Scribe auth token (deprecated alias)",
|
|
63
108
|
description:
|
|
64
|
-
"
|
|
109
|
+
"Legacy alias for `autoTranscribe.scribeBearer`. Will be removed in a future release.",
|
|
65
110
|
writeOnly: true,
|
|
111
|
+
deprecated: true,
|
|
66
112
|
},
|
|
67
113
|
port: {
|
|
68
114
|
type: "integer",
|
|
@@ -77,18 +123,28 @@ export function buildConfigSchema(): ModuleConfigSchema {
|
|
|
77
123
|
}
|
|
78
124
|
|
|
79
125
|
/**
|
|
80
|
-
* Effective config values, with `writeOnly` fields stripped. `
|
|
81
|
-
* declared `writeOnly` and
|
|
82
|
-
* set in the environment.
|
|
126
|
+
* Effective config values, with `writeOnly` fields stripped. `scribeBearer`
|
|
127
|
+
* (and its legacy alias `scribe_token`) are declared `writeOnly` and never
|
|
128
|
+
* returned, even when set in the environment.
|
|
83
129
|
*/
|
|
84
130
|
export function buildConfigValues(
|
|
85
131
|
vaultConfig: VaultConfig,
|
|
86
132
|
globalConfig: GlobalConfig,
|
|
87
133
|
env: { SCRIBE_URL?: string | undefined } = process.env as { SCRIBE_URL?: string },
|
|
88
134
|
): Record<string, unknown> {
|
|
135
|
+
// Resolve scribe URL through the discovery layer so the GET shape reflects
|
|
136
|
+
// what the worker will actually use (services.json > SCRIBE_URL > unset).
|
|
137
|
+
// Pass env through so the test harness's override is honored.
|
|
138
|
+
const scribeUrl = resolveScribeUrl(env as NodeJS.ProcessEnv) ?? "";
|
|
89
139
|
return {
|
|
90
140
|
audio_retention: vaultConfig.audio_retention ?? "keep",
|
|
91
|
-
|
|
141
|
+
autoTranscribe: {
|
|
142
|
+
enabled: globalConfig.auto_transcribe?.enabled ?? false,
|
|
143
|
+
scribeUrl,
|
|
144
|
+
},
|
|
145
|
+
// Legacy alias mirrors `autoTranscribe.scribeUrl` so hubs reading the
|
|
146
|
+
// pre-vault#353 shape don't regress.
|
|
147
|
+
scribe_url: scribeUrl,
|
|
92
148
|
port: globalConfig.port,
|
|
93
149
|
};
|
|
94
150
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readSelfManifest, resolvePackageRoot } from "./module-manifest.ts";
|
|
6
|
+
|
|
7
|
+
function withTempPackageRoot(
|
|
8
|
+
manifest: unknown | undefined,
|
|
9
|
+
fn: (root: string) => void,
|
|
10
|
+
): void {
|
|
11
|
+
const root = mkdtempSync(join(tmpdir(), "pvault-manifest-"));
|
|
12
|
+
try {
|
|
13
|
+
if (manifest !== undefined) {
|
|
14
|
+
mkdirSync(join(root, ".parachute"), { recursive: true });
|
|
15
|
+
writeFileSync(
|
|
16
|
+
join(root, ".parachute", "module.json"),
|
|
17
|
+
typeof manifest === "string" ? manifest : JSON.stringify(manifest),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
fn(root);
|
|
21
|
+
} finally {
|
|
22
|
+
rmSync(root, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("module-manifest", () => {
|
|
27
|
+
test("resolvePackageRoot returns the directory containing package.json", () => {
|
|
28
|
+
// In the test env, this module lives at <repo>/src/module-manifest.test.ts —
|
|
29
|
+
// so the resolved root is the repo root. We don't pin the exact path
|
|
30
|
+
// (tests run from various cwds); we just sanity-check it's an absolute
|
|
31
|
+
// directory ending in the vault repo's name.
|
|
32
|
+
const root = resolvePackageRoot();
|
|
33
|
+
expect(root.startsWith("/")).toBe(true);
|
|
34
|
+
expect(root.endsWith("/src")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("readSelfManifest returns null when .parachute/module.json is missing", () => {
|
|
38
|
+
withTempPackageRoot(undefined, (root) => {
|
|
39
|
+
expect(readSelfManifest(root)).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("readSelfManifest parses a valid manifest (no kind — hub#301 Phase B)", () => {
|
|
44
|
+
withTempPackageRoot(
|
|
45
|
+
{
|
|
46
|
+
name: "vault",
|
|
47
|
+
manifestName: "parachute-vault",
|
|
48
|
+
displayName: "Vault",
|
|
49
|
+
tagline: "Test tagline",
|
|
50
|
+
port: 1940,
|
|
51
|
+
paths: ["/vault/default"],
|
|
52
|
+
health: "/vault/default/health",
|
|
53
|
+
},
|
|
54
|
+
(root) => {
|
|
55
|
+
const m = readSelfManifest(root);
|
|
56
|
+
expect(m).not.toBeNull();
|
|
57
|
+
expect(m?.name).toBe("vault");
|
|
58
|
+
expect(m?.manifestName).toBe("parachute-vault");
|
|
59
|
+
expect(m?.displayName).toBe("Vault");
|
|
60
|
+
expect(m?.kind).toBeUndefined();
|
|
61
|
+
expect(m?.port).toBe(1940);
|
|
62
|
+
expect(m?.paths).toEqual(["/vault/default"]);
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("readSelfManifest tolerates a legacy manifest that still includes kind", () => {
|
|
68
|
+
// hub#301 Phase B retired the `kind` field, but legacy manifests on
|
|
69
|
+
// pinned installs may still include it. The reader accepts it without
|
|
70
|
+
// erroring; the field is never branched on.
|
|
71
|
+
withTempPackageRoot(
|
|
72
|
+
{
|
|
73
|
+
name: "vault",
|
|
74
|
+
manifestName: "parachute-vault",
|
|
75
|
+
kind: "api",
|
|
76
|
+
port: 1940,
|
|
77
|
+
paths: ["/vault/default"],
|
|
78
|
+
health: "/vault/default/health",
|
|
79
|
+
},
|
|
80
|
+
(root) => {
|
|
81
|
+
const m = readSelfManifest(root);
|
|
82
|
+
expect(m).not.toBeNull();
|
|
83
|
+
expect(m?.kind).toBe("api");
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("readSelfManifest throws on malformed JSON", () => {
|
|
89
|
+
withTempPackageRoot("{ not valid json", (root) => {
|
|
90
|
+
expect(() => readSelfManifest(root)).toThrow();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("readSelfManifest throws when required field missing", () => {
|
|
95
|
+
withTempPackageRoot(
|
|
96
|
+
{ name: "vault" /* missing manifestName / port / paths / health */ },
|
|
97
|
+
(root) => {
|
|
98
|
+
expect(() => readSelfManifest(root)).toThrow(/missing required/);
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("readSelfManifest reads the actual shipped manifest in the repo", () => {
|
|
104
|
+
// Smoke test the real shipped file — guards against ever shipping a
|
|
105
|
+
// malformed manifest. Uses the real resolvePackageRoot (which finds
|
|
106
|
+
// the repo root in tests). Post hub#301 Phase B, the shipped manifest
|
|
107
|
+
// no longer includes `kind`.
|
|
108
|
+
const m = readSelfManifest();
|
|
109
|
+
expect(m).not.toBeNull();
|
|
110
|
+
expect(m?.manifestName).toBe("parachute-vault");
|
|
111
|
+
expect(m?.kind).toBeUndefined();
|
|
112
|
+
expect(m?.port).toBe(1940);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader for the package's own `.parachute/module.json`.
|
|
3
|
+
*
|
|
4
|
+
* Vault ships `module.json` alongside `package.json` at the package root.
|
|
5
|
+
* This module locates the file via `import.meta.url` (which works for both
|
|
6
|
+
* `bun src/cli.ts …` dev runs and the published-package `parachute-vault`
|
|
7
|
+
* binary — the file ships in `package.json` `files` next to `src/`).
|
|
8
|
+
*
|
|
9
|
+
* Used by `self-register.ts` on server boot: vault reads its own manifest
|
|
10
|
+
* + computes the package's `installDir` so the services.json row carries
|
|
11
|
+
* the same metadata that hub's `FIRST_PARTY_FALLBACKS[vault]` provides
|
|
12
|
+
* today. The endgame is that hub's vendored fallback retires once every
|
|
13
|
+
* first-party module self-registers reliably — this is the POC for the
|
|
14
|
+
* pattern.
|
|
15
|
+
*
|
|
16
|
+
* Shape mirrors `parachute-hub/src/module-manifest.ts`. Kept narrow: we
|
|
17
|
+
* only consume the fields vault stamps onto services.json
|
|
18
|
+
* (displayName, tagline, stripPrefix). The full manifest validator lives
|
|
19
|
+
* on the hub side; vault treats its own manifest as authored-by-us +
|
|
20
|
+
* trusts the shape.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
export type ModuleKind = "api" | "frontend" | "tool";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Subset of the full manifest schema (see `parachute-hub/src/module-manifest.ts`)
|
|
31
|
+
* — only the fields vault's self-registration consumes today. Adding more is
|
|
32
|
+
* a one-line edit when the surface widens.
|
|
33
|
+
*/
|
|
34
|
+
export interface VaultModuleManifest {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly manifestName: string;
|
|
37
|
+
readonly displayName?: string;
|
|
38
|
+
readonly tagline?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Deprecated as of hub#301 Phase B (kind retirement, 2026-05-23). Hub's
|
|
41
|
+
* validator dropped `kind` from required-fields in hub#327; vault no
|
|
42
|
+
* longer ships the field in `.parachute/module.json`. Kept here as
|
|
43
|
+
* optional only so an older shipped manifest (pinned legacy install)
|
|
44
|
+
* still parses without throwing — the field is never branched on.
|
|
45
|
+
*/
|
|
46
|
+
readonly kind?: ModuleKind;
|
|
47
|
+
readonly port: number;
|
|
48
|
+
readonly paths: readonly string[];
|
|
49
|
+
readonly health: string;
|
|
50
|
+
readonly stripPrefix?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the path to the package root — the directory containing both
|
|
55
|
+
* `package.json` and `.parachute/module.json`. Walks up from
|
|
56
|
+
* `import.meta.url` so the answer is correct under both:
|
|
57
|
+
*
|
|
58
|
+
* - dev: `bun src/cli.ts serve` → `src/module-manifest.ts` → parent = repo root
|
|
59
|
+
* - prod: published package → `src/module-manifest.ts` → parent = installed
|
|
60
|
+
* package dir (e.g. `~/.bun/install/global/node_modules/@openparachute/vault`)
|
|
61
|
+
*
|
|
62
|
+
* Exported for tests + the self-register flow that needs to stamp this as
|
|
63
|
+
* `installDir` on the services.json row.
|
|
64
|
+
*/
|
|
65
|
+
export function resolvePackageRoot(): string {
|
|
66
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
67
|
+
// `src/module-manifest.ts` lives one level under the package root.
|
|
68
|
+
return resolve(here, "..");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read `<packageRoot>/.parachute/module.json` if present. Returns null when
|
|
73
|
+
* the file is missing (e.g. during local dev before the file was committed)
|
|
74
|
+
* — callers treat that as "self-registration unavailable, log + continue."
|
|
75
|
+
*
|
|
76
|
+
* Throws on malformed JSON: a corrupt manifest is a deploy bug we want to
|
|
77
|
+
* surface, not silently swallow. The self-register caller catches + logs
|
|
78
|
+
* so a bad manifest doesn't crash server boot.
|
|
79
|
+
*/
|
|
80
|
+
export function readSelfManifest(
|
|
81
|
+
packageRoot: string = resolvePackageRoot(),
|
|
82
|
+
): VaultModuleManifest | null {
|
|
83
|
+
const path = join(packageRoot, ".parachute", "module.json");
|
|
84
|
+
if (!existsSync(path)) return null;
|
|
85
|
+
const raw = readFileSync(path, "utf8");
|
|
86
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
87
|
+
// Minimal shape validation. Only the fields we actually consume — anything
|
|
88
|
+
// else passes through untouched. Strict full-shape validation is the hub's
|
|
89
|
+
// job (it'll fail an install on a malformed manifest); vault treats its
|
|
90
|
+
// own shipped file as authored-by-us.
|
|
91
|
+
if (typeof parsed.name !== "string" || typeof parsed.manifestName !== "string") {
|
|
92
|
+
throw new Error(`${path}: manifest missing required "name" / "manifestName"`);
|
|
93
|
+
}
|
|
94
|
+
if (typeof parsed.port !== "number" || !Array.isArray(parsed.paths)) {
|
|
95
|
+
throw new Error(`${path}: manifest missing required "port" / "paths"`);
|
|
96
|
+
}
|
|
97
|
+
if (typeof parsed.health !== "string") {
|
|
98
|
+
throw new Error(`${path}: manifest missing required "health"`);
|
|
99
|
+
}
|
|
100
|
+
// `kind` is retired as of hub#301 Phase B — hub#327 made it optional in
|
|
101
|
+
// the hub-side validator, and vault no longer ships it. If a legacy
|
|
102
|
+
// manifest still includes the field, accept it; just don't require it.
|
|
103
|
+
return parsed as unknown as VaultModuleManifest;
|
|
104
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth discovery endpoints — the *resource server* side of the
|
|
3
|
+
* authorization story.
|
|
4
|
+
*
|
|
5
|
+
* Vault is a resource server, not an authorization server. The hub is the
|
|
6
|
+
* OAuth issuer (see [`design/2026-04-20-hub-as-portal-oauth-and-service-catalog.md`](
|
|
7
|
+
* ../../parachute.computer/design/2026-04-20-hub-as-portal-oauth-and-service-catalog.md)
|
|
8
|
+
* and `docs/auth-model.md`). The endpoints below advertise that contract to
|
|
9
|
+
* clients per RFC 8414 + RFC 9728:
|
|
10
|
+
*
|
|
11
|
+
* - `handleProtectedResource` — RFC 9728: "this is the protected resource
|
|
12
|
+
* at `<vault>/mcp`; the authorization server lives at <hub>"
|
|
13
|
+
* - `handleAuthorizationServer` — RFC 8414: "go to <hub>/oauth/* for the
|
|
14
|
+
* authorization endpoints" (forwarded shape — issuer + endpoints all
|
|
15
|
+
* name the hub)
|
|
16
|
+
*
|
|
17
|
+
* The standalone OAuth issuer that previously lived in `src/oauth.ts` was
|
|
18
|
+
* retired in vault#366 (workstream E of the UX audit). Hub is now a hard
|
|
19
|
+
* requirement; vault never mints OAuth tokens itself, never renders a
|
|
20
|
+
* consent UI, never accepts `/oauth/authorize|token|register` requests.
|
|
21
|
+
* Operators who need OAuth install the hub and front vault with it.
|
|
22
|
+
*
|
|
23
|
+
* `PARACHUTE_HUB_ORIGIN` is required for these endpoints to advertise the
|
|
24
|
+
* right issuer URL. Without it we fall back to the canonical loopback
|
|
25
|
+
* (`http://127.0.0.1:1939`) since the hub binds that port by default — that
|
|
26
|
+
* keeps single-host installs working without explicit configuration.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { getHubOrigin } from "./hub-jwt.ts";
|
|
30
|
+
|
|
31
|
+
/** OAuth scopes vault publishes through discovery; see scopes.ts for enforcement. */
|
|
32
|
+
const SCOPES_SUPPORTED = ["vault:read", "vault:write", "vault:admin"];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Public-facing base URL of the server. Honors `x-forwarded-*` headers so a
|
|
36
|
+
* Cloudflare Tunnel / Tailscale Funnel / reverse-proxied deployment advertises
|
|
37
|
+
* the right external origin in `resource` URLs.
|
|
38
|
+
*
|
|
39
|
+
* Exported so the router can build `WWW-Authenticate` challenge headers that
|
|
40
|
+
* point at the same origin as the `/.well-known/*` metadata documents.
|
|
41
|
+
*/
|
|
42
|
+
export function getBaseUrl(req: Request): string {
|
|
43
|
+
const forwardedHost = req.headers.get("x-forwarded-host");
|
|
44
|
+
const forwardedProto = req.headers.get("x-forwarded-proto");
|
|
45
|
+
if (forwardedHost) {
|
|
46
|
+
return `${forwardedProto || "https"}://${forwardedHost}`;
|
|
47
|
+
}
|
|
48
|
+
const url = new URL(req.url);
|
|
49
|
+
return url.origin;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* OAuth 2.0 Protected Resource Metadata (RFC 9728).
|
|
54
|
+
*
|
|
55
|
+
* Advertises the MCP endpoint as the protected resource and names the hub as
|
|
56
|
+
* the authorization server. Clients following the spec fetch this, then fetch
|
|
57
|
+
* the AS metadata at `<hub>/.well-known/oauth-authorization-server` to drive
|
|
58
|
+
* the full flow.
|
|
59
|
+
*/
|
|
60
|
+
export function handleProtectedResource(req: Request, vaultName: string): Response {
|
|
61
|
+
const base = getBaseUrl(req);
|
|
62
|
+
const prefix = `/vault/${vaultName}`;
|
|
63
|
+
return Response.json({
|
|
64
|
+
resource: `${base}${prefix}/mcp`,
|
|
65
|
+
authorization_servers: [getHubOrigin()],
|
|
66
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
67
|
+
bearer_methods_supported: ["header"],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* OAuth 2.0 Authorization Server Metadata (RFC 8414).
|
|
73
|
+
*
|
|
74
|
+
* Vault is a resource server, not an authorization server — but we serve this
|
|
75
|
+
* document at `/vault/<name>/.well-known/oauth-authorization-server` (and the
|
|
76
|
+
* RFC 8414 §3.1 path-insertion shape) as a *forwarding* metadata document:
|
|
77
|
+
* issuer + every endpoint name the hub. Clients that follow the PRM pointer
|
|
78
|
+
* land here and discover the hub's actual endpoints; conformant clients that
|
|
79
|
+
* probe AS metadata directly at the vault path get the same answer.
|
|
80
|
+
*/
|
|
81
|
+
export function handleAuthorizationServer(_req: Request, _vaultName: string): Response {
|
|
82
|
+
const hub = getHubOrigin();
|
|
83
|
+
return Response.json({
|
|
84
|
+
issuer: hub,
|
|
85
|
+
authorization_endpoint: `${hub}/oauth/authorize`,
|
|
86
|
+
token_endpoint: `${hub}/oauth/token`,
|
|
87
|
+
registration_endpoint: `${hub}/oauth/register`,
|
|
88
|
+
jwks_uri: `${hub}/.well-known/jwks.json`,
|
|
89
|
+
response_types_supported: ["code"],
|
|
90
|
+
code_challenge_methods_supported: ["S256"],
|
|
91
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
92
|
+
token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
|
|
93
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
94
|
+
});
|
|
95
|
+
}
|
package/src/owner-auth.ts
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Owner
|
|
2
|
+
* Owner-password storage + verification.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* **Vestigial after vault#366 (workstream E of the UX audit, 2026-05-25).**
|
|
5
|
+
* The owner password used to authenticate the vault's standalone OAuth
|
|
6
|
+
* consent page (the one rendered by the now-deleted `src/oauth.ts`). With
|
|
7
|
+
* hub required and consent moved to the hub, the password no longer
|
|
8
|
+
* protects anything inside vault. The module is kept because:
|
|
7
9
|
*
|
|
8
|
-
*
|
|
10
|
+
* 1. Hub's `expose public` preflight reads `owner_password_hash` /
|
|
11
|
+
* `totp_secret` from vault's `config.yaml` to score auth posture
|
|
12
|
+
* (`parachute-hub/src/vault/auth-status.ts`). Removing the YAML
|
|
13
|
+
* surface in lockstep would turn every install's preflight
|
|
14
|
+
* score into "wide-open" until hub ships its own posture check.
|
|
15
|
+
* 2. The CLI `set-password` / `2fa enroll` commands keep working for
|
|
16
|
+
* operators on the legacy posture mid-migration.
|
|
9
17
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
18
|
+
* Retirement is tracked as a follow-up; this file should go away once
|
|
19
|
+
* the hub-side preflight is updated to score hub credentials instead of
|
|
20
|
+
* vault credentials.
|
|
21
|
+
*
|
|
22
|
+
* The per-IP `RateLimiter` (formerly in this file) was deleted alongside
|
|
23
|
+
* the consent page — there's no traffic to limit on a route that no
|
|
24
|
+
* longer exists.
|
|
25
|
+
*
|
|
26
|
+
* Password hashing uses Bun.password (bcrypt, cost 12 by default).
|
|
12
27
|
*/
|
|
13
28
|
|
|
14
29
|
import { readGlobalConfig, writeGlobalConfig } from "./config.ts";
|
|
@@ -71,145 +86,3 @@ export async function verifyOwnerPassword(password: string, hash: string): Promi
|
|
|
71
86
|
}
|
|
72
87
|
}
|
|
73
88
|
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
// Rate limiting
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
interface RateLimitEntry {
|
|
79
|
-
failures: number;
|
|
80
|
-
firstFailureAt: number;
|
|
81
|
-
lockedUntil: number | null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Per-IP rate limiter for consent-page attempts.
|
|
86
|
-
*
|
|
87
|
-
* Policy:
|
|
88
|
-
* - Up to MAX_FAILURES failed attempts within WINDOW_MS → lockout
|
|
89
|
-
* - Lockout lasts LOCKOUT_MS
|
|
90
|
-
* - A successful attempt clears the IP's counter
|
|
91
|
-
* - Hard cap on entry count — when full, the oldest insertion is evicted
|
|
92
|
-
* before a new one is recorded. Prevents memory exhaustion via IP /
|
|
93
|
-
* client_id enumeration (#93).
|
|
94
|
-
*/
|
|
95
|
-
export class RateLimiter {
|
|
96
|
-
private entries = new Map<string, RateLimitEntry>();
|
|
97
|
-
|
|
98
|
-
constructor(
|
|
99
|
-
private readonly maxFailures = 10,
|
|
100
|
-
private readonly windowMs = 60_000,
|
|
101
|
-
private readonly lockoutMs = 15 * 60_000,
|
|
102
|
-
private readonly maxEntries = 10_000,
|
|
103
|
-
) {}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Check whether an IP is currently allowed to attempt auth.
|
|
107
|
-
* Returns `{ allowed: false, retryAfterSec }` if locked out.
|
|
108
|
-
*/
|
|
109
|
-
check(ip: string): { allowed: true } | { allowed: false; retryAfterSec: number } {
|
|
110
|
-
const entry = this.entries.get(ip);
|
|
111
|
-
if (!entry) return { allowed: true };
|
|
112
|
-
|
|
113
|
-
const now = Date.now();
|
|
114
|
-
if (entry.lockedUntil && entry.lockedUntil > now) {
|
|
115
|
-
return { allowed: false, retryAfterSec: Math.ceil((entry.lockedUntil - now) / 1000) };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Expired lockout or old window — reset and allow
|
|
119
|
-
if (entry.lockedUntil && entry.lockedUntil <= now) {
|
|
120
|
-
this.entries.delete(ip);
|
|
121
|
-
return { allowed: true };
|
|
122
|
-
}
|
|
123
|
-
if (now - entry.firstFailureAt > this.windowMs) {
|
|
124
|
-
this.entries.delete(ip);
|
|
125
|
-
return { allowed: true };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return { allowed: true };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Record a failed attempt. Triggers lockout if threshold reached. */
|
|
132
|
-
recordFailure(ip: string): void {
|
|
133
|
-
const now = Date.now();
|
|
134
|
-
const entry = this.entries.get(ip);
|
|
135
|
-
|
|
136
|
-
if (!entry || now - entry.firstFailureAt > this.windowMs) {
|
|
137
|
-
this.evictIfFull();
|
|
138
|
-
this.entries.set(ip, {
|
|
139
|
-
failures: 1,
|
|
140
|
-
firstFailureAt: now,
|
|
141
|
-
lockedUntil: null,
|
|
142
|
-
});
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
entry.failures += 1;
|
|
147
|
-
if (entry.failures >= this.maxFailures) {
|
|
148
|
-
entry.lockedUntil = now + this.lockoutMs;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Record a successful attempt. Clears the IP's counter. */
|
|
153
|
-
recordSuccess(ip: string): void {
|
|
154
|
-
this.entries.delete(ip);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** For tests: drop all state. */
|
|
158
|
-
reset(): void {
|
|
159
|
-
this.entries.clear();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Current entry count — exposed for tests + observability. */
|
|
163
|
-
size(): number {
|
|
164
|
-
return this.entries.size;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Evict the oldest insertion(s) until size < maxEntries. Map preserves
|
|
169
|
-
* insertion order, so `keys().next().value` is the oldest. We re-insert
|
|
170
|
-
* on window-rollover (delete + new set), so insertion order tracks
|
|
171
|
-
* recency-of-failure closely enough for FIFO eviction.
|
|
172
|
-
*/
|
|
173
|
-
private evictIfFull(): void {
|
|
174
|
-
while (this.entries.size >= this.maxEntries) {
|
|
175
|
-
const oldest = this.entries.keys().next().value;
|
|
176
|
-
if (oldest === undefined) break;
|
|
177
|
-
this.entries.delete(oldest);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Singleton rate limiter — kept for back-compat with callers that don't pass
|
|
184
|
-
* through per-vault routing. Fresh callers should prefer
|
|
185
|
-
* `getAuthorizeRateLimiter(vaultName)` so traffic on one vault's consent flow
|
|
186
|
-
* doesn't lock out IPs on another vault's consent flow (#93).
|
|
187
|
-
*
|
|
188
|
-
* @deprecated Use `getAuthorizeRateLimiter(vaultName)` instead. The singleton
|
|
189
|
-
* cross-pollutes per-vault consent traffic — one vault under brute-force can
|
|
190
|
-
* lock out IPs on every other vault's consent page.
|
|
191
|
-
*/
|
|
192
|
-
export const authorizeRateLimit = new RateLimiter();
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Per-vault rate limiter registry. The vault count is admin-bounded (vaults
|
|
196
|
-
* are created via CLI, not by clients) so this Map can grow only with operator
|
|
197
|
-
* action — no attacker-driven growth path. Each instance carries the
|
|
198
|
-
* default 10,000-entry IP cap, scoped to its vault (#93).
|
|
199
|
-
*/
|
|
200
|
-
const vaultAuthorizeRateLimiters = new Map<string, RateLimiter>();
|
|
201
|
-
|
|
202
|
-
/** Lazily get-or-create the rate limiter for a given vault. */
|
|
203
|
-
export function getAuthorizeRateLimiter(vaultName: string): RateLimiter {
|
|
204
|
-
let limiter = vaultAuthorizeRateLimiters.get(vaultName);
|
|
205
|
-
if (!limiter) {
|
|
206
|
-
limiter = new RateLimiter();
|
|
207
|
-
vaultAuthorizeRateLimiters.set(vaultName, limiter);
|
|
208
|
-
}
|
|
209
|
-
return limiter;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** For tests: drop all per-vault limiters. */
|
|
213
|
-
export function resetVaultAuthorizeRateLimiters(): void {
|
|
214
|
-
vaultAuthorizeRateLimiters.clear();
|
|
215
|
-
}
|