@openparachute/vault 0.4.7-rc.1 → 0.4.8-rc.4

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 (42) hide show
  1. package/README.md +44 -10
  2. package/core/src/connection-pragmas.test.ts +232 -0
  3. package/core/src/core.test.ts +257 -0
  4. package/core/src/cursor.test.ts +160 -0
  5. package/core/src/cursor.ts +272 -0
  6. package/core/src/mcp.ts +51 -7
  7. package/core/src/notes.ts +164 -2
  8. package/core/src/portable-md.test.ts +247 -0
  9. package/core/src/portable-md.ts +118 -1
  10. package/core/src/schema.ts +98 -2
  11. package/core/src/store.ts +11 -1
  12. package/core/src/types.ts +32 -0
  13. package/package.json +1 -1
  14. package/src/auth-status.ts +4 -0
  15. package/src/auto-transcribe.test.ts +116 -0
  16. package/src/auto-transcribe.ts +48 -0
  17. package/src/cli.ts +151 -50
  18. package/src/config.test.ts +26 -0
  19. package/src/config.ts +53 -1
  20. package/src/db.ts +15 -2
  21. package/src/export-watch.test.ts +99 -0
  22. package/src/mcp-install-interactive.test.ts +23 -2
  23. package/src/mcp-install-interactive.ts +21 -2
  24. package/src/mcp-install.test.ts +40 -0
  25. package/src/mcp-tools.ts +17 -1
  26. package/src/module-config.ts +70 -14
  27. package/src/module-manifest.test.ts +93 -0
  28. package/src/module-manifest.ts +94 -0
  29. package/src/routes.ts +267 -50
  30. package/src/scribe-discovery.test.ts +77 -0
  31. package/src/scribe-discovery.ts +91 -0
  32. package/src/scribe-env.test.ts +66 -1
  33. package/src/scribe-env.ts +42 -1
  34. package/src/self-register.test.ts +380 -0
  35. package/src/self-register.ts +234 -0
  36. package/src/server.ts +46 -11
  37. package/src/transcript-note.test.ts +171 -0
  38. package/src/transcript-note.ts +189 -0
  39. package/src/transcription-registry.ts +22 -0
  40. package/src/transcription-worker.test.ts +250 -0
  41. package/src/transcription-worker.ts +186 -27
  42. package/src/vault.test.ts +347 -0
@@ -522,6 +522,46 @@ describe("mcp-install flag parsing", () => {
522
522
  expect(res.exitCode).toBe(1);
523
523
  expect(res.stderr).toMatch(/No hub origin configured/);
524
524
  });
525
+
526
+ test("rejects --mint --scope vault:admin pre-flight (hub policy: per-vault admin is non-requestable)", () => {
527
+ // Regression for the symptom Aaron hit on hub 0.5.12-rc.2 / vault
528
+ // 0.4.7-rc.1: `parachute vault mcp-install` with the "admin" mint
529
+ // option sent `vault:default:admin` to `POST /api/auth/mint-token`,
530
+ // and hub responded:
531
+ //
532
+ // Hub mint-token rejected (HTTP 400, invalid_scope):
533
+ // scope vault:default:admin is not requestable via mint-token;
534
+ // use OAuth flow or operator rotation
535
+ //
536
+ // The combination is invalid by hub policy (see
537
+ // `parachute-hub/src/scope-explanations.ts:VAULT_ADMIN_RE` and
538
+ // `api-mint-token.ts`'s non-requestable guard) — per-vault admin
539
+ // is operator-only, mintable only through the session-cookie-gated
540
+ // `/admin/vault-admin-token/:name` SPA path.
541
+ //
542
+ // The fix rejects the combination pre-flight in vault's mcp-install
543
+ // with a clear remediation pointing at `--legacy-pat --scope vault:admin`
544
+ // (which mints a vault-DB pvt_* with admin scope — the right shape
545
+ // for a local MCP entry needing schema management).
546
+ setupBareVault(tmp, "default");
547
+ fs.writeFileSync(path.join(tmp, "operator.token"), "operator-bearer-stub");
548
+ const res = runCli(
549
+ ["mcp-install", "--mint", "--scope", "vault:admin"],
550
+ tmp,
551
+ { PARACHUTE_HUB_ORIGIN: "https://hub.example.org" },
552
+ );
553
+ expect(res.exitCode).toBe(1);
554
+ // Surface the policy reason so the operator knows why this combo is
555
+ // rejected (not a transient bug).
556
+ expect(res.stderr).toMatch(/not requestable via mint-token/);
557
+ // Point at the working remediation.
558
+ expect(res.stderr).toMatch(/--legacy-pat --scope vault:admin/);
559
+ // Pre-flight must fire BEFORE the operator-token / hub-origin checks
560
+ // pass the request to the network — no "Hub unreachable" / "No hub
561
+ // origin configured" leak.
562
+ expect(res.stderr).not.toMatch(/No hub origin configured/);
563
+ expect(res.stderr).not.toMatch(/Hub unreachable/);
564
+ });
525
565
  });
526
566
 
527
567
  // ---------------------------------------------------------------------------
package/src/mcp-tools.ts CHANGED
@@ -178,10 +178,26 @@ function applyTagScopeWrappers(
178
178
  const allowed = await getAllowed();
179
179
  const result = await orig(params);
180
180
  if (!allowed) return result;
181
- // Single-note shape (`{...note}` with `id`) vs list shape (array).
181
+ // Three possible response shapes:
182
+ // - Array (legacy list, no cursor)
183
+ // - `{notes, next_cursor}` (cursor mode, vault#313)
184
+ // - `{...note}` with `id`+`tags` (single-note by id)
182
185
  if (Array.isArray(result)) {
183
186
  return result.filter((n: any) => noteWithinTagScope(n, allowed, rawTags));
184
187
  }
188
+ if (
189
+ result &&
190
+ typeof result === "object" &&
191
+ "notes" in result &&
192
+ Array.isArray((result as any).notes) &&
193
+ "next_cursor" in result
194
+ ) {
195
+ const r = result as { notes: any[]; next_cursor: string | null };
196
+ return {
197
+ notes: r.notes.filter((n: any) => noteWithinTagScope(n, allowed, rawTags)),
198
+ next_cursor: r.next_cursor,
199
+ };
200
+ }
185
201
  if (result && typeof result === "object" && "id" in result && "tags" in result) {
186
202
  return noteWithinTagScope(result as any, allowed, rawTags)
187
203
  ? result
@@ -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,93 @@
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", () => {
44
+ withTempPackageRoot(
45
+ {
46
+ name: "vault",
47
+ manifestName: "parachute-vault",
48
+ displayName: "Vault",
49
+ tagline: "Test tagline",
50
+ kind: "api",
51
+ port: 1940,
52
+ paths: ["/vault/default"],
53
+ health: "/vault/default/health",
54
+ },
55
+ (root) => {
56
+ const m = readSelfManifest(root);
57
+ expect(m).not.toBeNull();
58
+ expect(m?.name).toBe("vault");
59
+ expect(m?.manifestName).toBe("parachute-vault");
60
+ expect(m?.displayName).toBe("Vault");
61
+ expect(m?.kind).toBe("api");
62
+ expect(m?.port).toBe(1940);
63
+ expect(m?.paths).toEqual(["/vault/default"]);
64
+ },
65
+ );
66
+ });
67
+
68
+ test("readSelfManifest throws on malformed JSON", () => {
69
+ withTempPackageRoot("{ not valid json", (root) => {
70
+ expect(() => readSelfManifest(root)).toThrow();
71
+ });
72
+ });
73
+
74
+ test("readSelfManifest throws when required field missing", () => {
75
+ withTempPackageRoot(
76
+ { name: "vault" /* missing manifestName / port / paths / health / kind */ },
77
+ (root) => {
78
+ expect(() => readSelfManifest(root)).toThrow(/missing required/);
79
+ },
80
+ );
81
+ });
82
+
83
+ test("readSelfManifest reads the actual shipped manifest in the repo", () => {
84
+ // Smoke test the real shipped file — guards against ever shipping a
85
+ // malformed manifest. Uses the real resolvePackageRoot (which finds
86
+ // the repo root in tests).
87
+ const m = readSelfManifest();
88
+ expect(m).not.toBeNull();
89
+ expect(m?.manifestName).toBe("parachute-vault");
90
+ expect(m?.kind).toBe("api");
91
+ expect(m?.port).toBe(1940);
92
+ });
93
+ });
@@ -0,0 +1,94 @@
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
+ readonly kind: ModuleKind;
40
+ readonly port: number;
41
+ readonly paths: readonly string[];
42
+ readonly health: string;
43
+ readonly stripPrefix?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Resolve the path to the package root — the directory containing both
48
+ * `package.json` and `.parachute/module.json`. Walks up from
49
+ * `import.meta.url` so the answer is correct under both:
50
+ *
51
+ * - dev: `bun src/cli.ts serve` → `src/module-manifest.ts` → parent = repo root
52
+ * - prod: published package → `src/module-manifest.ts` → parent = installed
53
+ * package dir (e.g. `~/.bun/install/global/node_modules/@openparachute/vault`)
54
+ *
55
+ * Exported for tests + the self-register flow that needs to stamp this as
56
+ * `installDir` on the services.json row.
57
+ */
58
+ export function resolvePackageRoot(): string {
59
+ const here = dirname(fileURLToPath(import.meta.url));
60
+ // `src/module-manifest.ts` lives one level under the package root.
61
+ return resolve(here, "..");
62
+ }
63
+
64
+ /**
65
+ * Read `<packageRoot>/.parachute/module.json` if present. Returns null when
66
+ * the file is missing (e.g. during local dev before the file was committed)
67
+ * — callers treat that as "self-registration unavailable, log + continue."
68
+ *
69
+ * Throws on malformed JSON: a corrupt manifest is a deploy bug we want to
70
+ * surface, not silently swallow. The self-register caller catches + logs
71
+ * so a bad manifest doesn't crash server boot.
72
+ */
73
+ export function readSelfManifest(
74
+ packageRoot: string = resolvePackageRoot(),
75
+ ): VaultModuleManifest | null {
76
+ const path = join(packageRoot, ".parachute", "module.json");
77
+ if (!existsSync(path)) return null;
78
+ const raw = readFileSync(path, "utf8");
79
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
80
+ // Minimal shape validation. Only the fields we actually consume — anything
81
+ // else passes through untouched. Strict full-shape validation is the hub's
82
+ // job (it'll fail an install on a malformed manifest); vault treats its
83
+ // own shipped file as authored-by-us.
84
+ if (typeof parsed.name !== "string" || typeof parsed.manifestName !== "string") {
85
+ throw new Error(`${path}: manifest missing required "name" / "manifestName"`);
86
+ }
87
+ if (typeof parsed.port !== "number" || !Array.isArray(parsed.paths)) {
88
+ throw new Error(`${path}: manifest missing required "port" / "paths"`);
89
+ }
90
+ if (typeof parsed.health !== "string" || typeof parsed.kind !== "string") {
91
+ throw new Error(`${path}: manifest missing required "health" / "kind"`);
92
+ }
93
+ return parsed as unknown as VaultModuleManifest;
94
+ }