@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.
Files changed (54) hide show
  1. package/.parachute/module.json +1 -1
  2. package/README.md +78 -41
  3. package/core/src/connection-pragmas.test.ts +232 -0
  4. package/core/src/core.test.ts +257 -0
  5. package/core/src/cursor.test.ts +160 -0
  6. package/core/src/cursor.ts +272 -0
  7. package/core/src/mcp.ts +51 -7
  8. package/core/src/notes.ts +164 -2
  9. package/core/src/schema.ts +106 -5
  10. package/core/src/store.ts +11 -1
  11. package/core/src/types.ts +32 -0
  12. package/package.json +7 -3
  13. package/src/auth-status.ts +4 -0
  14. package/src/auth.test.ts +5 -112
  15. package/src/auto-transcribe.test.ts +116 -0
  16. package/src/auto-transcribe.ts +48 -0
  17. package/src/backup.ts +17 -3
  18. package/src/cli.ts +95 -66
  19. package/src/config.test.ts +26 -0
  20. package/src/config.ts +53 -1
  21. package/src/db.ts +15 -2
  22. package/src/export-watch.test.ts +21 -0
  23. package/src/mcp-install-interactive.test.ts +23 -2
  24. package/src/mcp-install-interactive.ts +21 -2
  25. package/src/mcp-install.test.ts +40 -0
  26. package/src/mcp-tools.ts +17 -1
  27. package/src/module-config.ts +70 -14
  28. package/src/module-manifest.test.ts +114 -0
  29. package/src/module-manifest.ts +104 -0
  30. package/src/oauth-discovery.ts +95 -0
  31. package/src/owner-auth.ts +22 -149
  32. package/src/routes.ts +268 -51
  33. package/src/routing.test.ts +102 -99
  34. package/src/routing.ts +33 -47
  35. package/src/scribe-discovery.test.ts +77 -0
  36. package/src/scribe-discovery.ts +91 -0
  37. package/src/scribe-env.test.ts +66 -1
  38. package/src/scribe-env.ts +42 -1
  39. package/src/self-register.test.ts +412 -0
  40. package/src/self-register.ts +247 -0
  41. package/src/server.ts +47 -23
  42. package/src/transcript-note.test.ts +171 -0
  43. package/src/transcript-note.ts +189 -0
  44. package/src/transcription-registry.ts +22 -0
  45. package/src/transcription-worker.test.ts +250 -0
  46. package/src/transcription-worker.ts +186 -27
  47. package/src/vault-name.ts +3 -2
  48. package/src/vault.test.ts +347 -0
  49. package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
  50. package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
  51. package/web/ui/dist/index.html +14 -0
  52. package/web/ui/tsconfig.json +21 -0
  53. package/src/oauth.test.ts +0 -2156
  54. package/src/oauth.ts +0 -973
@@ -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: per-vault enum, backed by VaultConfig.audio_retention.
19
- * - scribe_url: env var SCRIBE_URL (read-only for now — there is no
20
- * yaml slot yet, so PUT won't come online until Phase 3).
21
- * - scribe_token: env var SCRIBE_TOKEN, writeOnly (never returned).
22
- * - port: GlobalConfig.port, exposed read-only so the hub can
23
- * display it without round-tripping through /health.
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
- "URL of the Scribe service for transcription. Empty disables the background worker. Currently sourced from the SCRIBE_URL env var; a PUT slot lands in Phase 3.",
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
- "Optional bearer token for Scribe. Stored in the SCRIBE_TOKEN env var today. Write-only — never returned by GET.",
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. `scribe_token` is
81
- * declared `writeOnly` and is never returned here, even when SCRIBE_TOKEN is
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
- scribe_url: env.SCRIBE_URL ?? "",
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 authentication for the OAuth consent page.
2
+ * Owner-password storage + verification.
3
3
  *
4
- * The "owner" is the person who set up this vault — identified by a password
5
- * stored globally in config.yaml (owner_password_hash). The password is used
6
- * to prove ownership when authorizing third-party OAuth clients.
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
- * Password hashing uses Bun.password (bcrypt, cost 12 by default) no deps.
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
- * Rate limiting is per-IP, in-memory. Acceptable for v1: resets on restart,
11
- * doesn't handle multi-process deployments. Tighten later if needed.
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
- }