@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.6

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/.parachute/module.json +0 -1
  2. package/README.md +44 -10
  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 +98 -2
  10. package/core/src/store.ts +11 -1
  11. package/core/src/types.ts +32 -0
  12. package/package.json +1 -1
  13. package/src/auth-status.ts +4 -0
  14. package/src/auto-transcribe.test.ts +116 -0
  15. package/src/auto-transcribe.ts +48 -0
  16. package/src/cli.ts +57 -48
  17. package/src/config.test.ts +26 -0
  18. package/src/config.ts +53 -1
  19. package/src/db.ts +15 -2
  20. package/src/mcp-install-interactive.test.ts +23 -2
  21. package/src/mcp-install-interactive.ts +21 -2
  22. package/src/mcp-install.test.ts +40 -0
  23. package/src/mcp-tools.ts +17 -1
  24. package/src/module-config.ts +70 -14
  25. package/src/module-manifest.test.ts +114 -0
  26. package/src/module-manifest.ts +104 -0
  27. package/src/routes.ts +268 -51
  28. package/src/routing.test.ts +4 -2
  29. package/src/routing.ts +4 -4
  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 +379 -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
package/src/cli.ts CHANGED
@@ -103,7 +103,7 @@ import type { TokenPermission } from "./token-store.ts";
103
103
  import { resolveCreateTokenFlags, VAULT_SCOPES } from "./scopes.ts";
104
104
  import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
105
105
  import { getVaultStore } from "./vault-store.ts";
106
- import { upsertService, ServicesManifestError } from "./services-manifest.ts";
106
+ import { selfRegister } from "./self-register.ts";
107
107
  import {
108
108
  hasOwnerPassword,
109
109
  setOwnerPassword,
@@ -245,27 +245,6 @@ switch (command) {
245
245
  // Command implementations
246
246
  // ---------------------------------------------------------------------------
247
247
 
248
- /**
249
- * Compute the `paths` array for the parachute-vault entry in services.json.
250
- * One entry advertises every vault on this server; `paths[0]` is the
251
- * canonical mount the hub stamps into `.well-known/parachute.json`, so the
252
- * default vault sorts first when one is set. With no vaults yet, fall back
253
- * to "/" so an early-init registration is still well-formed.
254
- */
255
- function buildVaultServicePaths(
256
- defaultVault: string | undefined,
257
- vaults: string[],
258
- ): string[] {
259
- if (vaults.length === 0) return ["/"];
260
- if (defaultVault && vaults.includes(defaultVault)) {
261
- return [
262
- `/vault/${defaultVault}`,
263
- ...vaults.filter((v) => v !== defaultVault).map((v) => `/vault/${v}`),
264
- ];
265
- }
266
- return vaults.map((v) => `/vault/${v}`);
267
- }
268
-
269
248
  async function cmdInit(args: string[] = []) {
270
249
  ensureConfigDirSync();
271
250
 
@@ -363,24 +342,24 @@ async function cmdInit(args: string[] = []) {
363
342
  // by name, preserving entries for other services. Non-fatal on failure —
364
343
  // init can complete without the manifest, just with a warning.
365
344
  //
345
+ // `selfRegister` stamps the full manifest-sourced row (displayName, tagline,
346
+ // stripPrefix, installDir) from `.parachute/module.json` — the same shape
347
+ // server boot writes via the self-registration pass (vault#266). Keeping
348
+ // the two write paths in lockstep means `parachute-vault init` and the
349
+ // first server boot agree on the row contents; without that, a re-init
350
+ // would silently lose the manifest fields the boot pass had added.
351
+ //
366
352
  // `paths[0]` is the canonical mount point — the hub uses it for the
367
353
  // `.well-known/parachute.json` URL and for `parachute expose`, so the
368
354
  // default vault always sorts first. Remaining vaults follow so the hub
369
355
  // well-known and paraclaw's attach picker see every vault on this server.
370
356
  // Re-running init re-registers the full set; that doubles as the
371
357
  // recovery path for installs whose services.json is stale (#208).
372
- try {
373
- upsertService({
374
- name: "parachute-vault",
375
- port: globalConfig.port || DEFAULT_PORT,
376
- paths: buildVaultServicePaths(globalConfig.default_vault, allVaults),
377
- health: "/health",
378
- version: pkg.version,
379
- });
380
- } catch (err) {
381
- const msg = err instanceof ServicesManifestError ? err.message : String(err);
382
- console.error(` Warning: could not update ~/.parachute/services.json: ${msg}`);
383
- }
358
+ selfRegister({
359
+ version: pkg.version,
360
+ warn: (msg) => console.error(` Warning: ${msg}`),
361
+ log: () => {}, // CLI init has its own status lines; suppress duplicate noise.
362
+ });
384
363
 
385
364
  // 2b. Migrate existing legacy keys into per-vault token tables
386
365
  for (const v of listVaults()) {
@@ -862,19 +841,16 @@ function cmdCreate(args: string[]) {
862
841
  // attach picker see this vault. cmdInit registers on first run; cmdCreate
863
842
  // adds the new path on every subsequent vault. Without this, vaults
864
843
  // created after init were invisible to the hub (#208).
865
- // Warnings go to stderr to keep --json stdout clean for the orchestrator.
866
- try {
867
- upsertService({
868
- name: "parachute-vault",
869
- port: globalConfig.port || DEFAULT_PORT,
870
- paths: buildVaultServicePaths(globalConfig.default_vault, listVaults()),
871
- health: "/health",
872
- version: pkg.version,
873
- });
874
- } catch (err) {
875
- const msg = err instanceof ServicesManifestError ? err.message : String(err);
876
- console.error(`Warning: could not update ~/.parachute/services.json: ${msg}`);
877
- }
844
+ //
845
+ // Routed through `selfRegister` (vault#266) so the row carries the full
846
+ // manifest-sourced metadata (displayName, tagline, stripPrefix, installDir)
847
+ // — same shape server boot writes. Warnings go to stderr to keep --json
848
+ // stdout clean for the orchestrator.
849
+ selfRegister({
850
+ version: pkg.version,
851
+ warn: (msg) => console.error(`Warning: ${msg}`),
852
+ log: () => {}, // CLI create has its own status lines.
853
+ });
878
854
 
879
855
  if (jsonMode) {
880
856
  const payload = {
@@ -943,6 +919,11 @@ function takeArgValue(args: string[], name: string): { value?: string; missingVa
943
919
  * Targeting:
944
920
  * --scope <verb> vault:read | vault:write | vault:admin (default: vault:read).
945
921
  * For --mint, expands to vault:<vault-name>:<verb>.
922
+ * vault:admin requires --legacy-pat — hub policy makes
923
+ * per-vault admin non-requestable via mint-token (it's
924
+ * operator-only, minted only through the session-
925
+ * cookie-gated admin SPA endpoint), so --mint + admin
926
+ * is rejected pre-flight.
946
927
  * --install-scope <s> local (default) | user | project. local writes to
947
928
  * ~/.claude.json under projects[<cwd>].mcpServers
948
929
  * (private, this directory only — matches Claude
@@ -1331,6 +1312,28 @@ async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
1331
1312
  bearer = fullToken;
1332
1313
  } else {
1333
1314
  // mode === "mint"
1315
+ // Pre-flight: hub policy rejects `vault:<name>:admin` via the public
1316
+ // mint-token endpoint. Per-vault admin is operator-only, mintable
1317
+ // only through the session-cookie-gated `/admin/vault-admin-token/:name`
1318
+ // SPA path. Calling hub with admin would surface a 400:
1319
+ // "Hub mint-token rejected (HTTP 400, invalid_scope):
1320
+ // scope vault:default:admin is not requestable via mint-token;
1321
+ // use OAuth flow or operator rotation"
1322
+ // Fail early with the actionable remediation rather than letting
1323
+ // the operator chase the hub's wire-level error. See
1324
+ // `parachute-hub/src/scope-explanations.ts` (VAULT_ADMIN_RE) and
1325
+ // `parachute-hub/src/api-mint-token.ts` (non-requestable guard).
1326
+ if (verb === "admin") {
1327
+ console.error(
1328
+ "Hub policy: vault:<name>:admin is not requestable via mint-token " +
1329
+ "(per-vault admin is operator-only, minted only by the session-cookie-gated " +
1330
+ "admin SPA at <hub>/admin/vaults/" + vaultName + ").\n" +
1331
+ " Fix: use `--legacy-pat --scope vault:admin` to mint a vault-DB pvt_* with admin scope " +
1332
+ "(the right shape for an MCP entry needing schema management).\n" +
1333
+ " Or: drop --scope to default to vault:read (least privilege), or use --scope vault:write.",
1334
+ );
1335
+ process.exit(1);
1336
+ }
1334
1337
  const operatorToken = readOperatorToken();
1335
1338
  if (!operatorToken) {
1336
1339
  console.error(
@@ -3392,7 +3395,13 @@ Vaults:
3392
3395
  (any shape) instead of minting.
3393
3396
  --legacy-pat: mint a vault-DB pvt_*
3394
3397
  token (deprecated; for self-hosted-
3395
- without-hub setups).
3398
+ without-hub setups). Also the only
3399
+ path for --scope vault:admin — hub
3400
+ policy reserves per-vault admin for
3401
+ operator-only minting (the admin SPA
3402
+ session-cookie path), so --mint
3403
+ --scope vault:admin is rejected
3404
+ pre-flight.
3396
3405
  --install-scope local (default) writes
3397
3406
  ~/.claude.json under
3398
3407
  projects[<cwd>].mcpServers (this
@@ -1,9 +1,12 @@
1
1
  import { describe, test, expect } from "bun:test";
2
+ import { statSync } from "fs";
3
+ import { join } from "path";
2
4
  import {
3
5
  writeVaultConfig,
4
6
  readVaultConfig,
5
7
  writeGlobalConfig,
6
8
  readGlobalConfig,
9
+ writeEnvFile,
7
10
  generateApiKey,
8
11
  hashKey,
9
12
  verifyKey,
@@ -210,6 +213,29 @@ describe("config", () => {
210
213
  expect(reloaded.api_keys?.find((k) => k.id === "k_legacy")?.scope).toBe("write");
211
214
  });
212
215
 
216
+ test("writeEnvFile writes .env at 0600 (SCRIBE_AUTH_TOKEN secrecy)", () => {
217
+ // Regression for vault#354 reviewer finding: the .env holds
218
+ // SCRIBE_AUTH_TOKEN (the vault↔scribe loopback bearer). On a
219
+ // shared-user machine or a Docker image with a loose umask, a
220
+ // world-readable .env would leak the bearer to any local process.
221
+ //
222
+ // Resolve the path dynamically from PARACHUTE_HOME (not the
223
+ // module-load-time `ENV_PATH` constant) so this test is robust to
224
+ // earlier tests in the suite that mutate PARACHUTE_HOME — writeEnvFile
225
+ // itself resolves the path dynamically via `envFilePath()`.
226
+ const envPath = join(process.env.PARACHUTE_HOME!, "vault", ".env");
227
+ writeEnvFile({ SCRIBE_AUTH_TOKEN: "test-bearer-do-not-leak", PORT: "1940" });
228
+ const mode = statSync(envPath).mode & 0o777;
229
+ expect(mode).toBe(0o600);
230
+
231
+ // Existing-file branch: writeFileSync's `mode` only applies on
232
+ // create, so the defensive chmodSync must downgrade an existing
233
+ // (e.g. 0644-from-an-older-version) .env to 0600 on the next write.
234
+ writeEnvFile({ SCRIBE_AUTH_TOKEN: "rotated", PORT: "1940" });
235
+ const mode2 = statSync(envPath).mode & 0o777;
236
+ expect(mode2).toBe(0o600);
237
+ });
238
+
213
239
  test("round-trips autostart: true|false (#113)", () => {
214
240
  // Default: absent means autostart-on (init registers the daemon).
215
241
  writeGlobalConfig({ port: 1940 });
package/src/config.ts CHANGED
@@ -277,6 +277,24 @@ export interface GlobalConfig {
277
277
  * resolved path. See `./mirror-config.ts`.
278
278
  */
279
279
  mirror?: MirrorConfigType;
280
+ /**
281
+ * Auto-transcribe configuration for the vault↔scribe handoff (vault#353,
282
+ * design 2026-05-21 Part 2). When `enabled: true` AND scribe is discoverable
283
+ * (`services.json` or `SCRIBE_URL` env), audio attachments uploaded to any
284
+ * vault are automatically sent to scribe and the resulting transcript lands
285
+ * as a sibling `<attachment-path>.transcript.md` note.
286
+ *
287
+ * URL + bearer are not stored here — URL is resolved per-process from
288
+ * `services.json` via `scribe-discovery.ts`, and bearer comes from the
289
+ * `SCRIBE_AUTH_TOKEN` env var (persisted in `~/.parachute/vault/.env`).
290
+ * The schema's `autoTranscribe.scribeUrl` / `autoTranscribe.scribeBearer`
291
+ * fields are projection of those resolved values (readOnly / writeOnly),
292
+ * not separate storage.
293
+ */
294
+ auto_transcribe?: {
295
+ /** Master toggle. Default false; the worker is a no-op when unset. */
296
+ enabled?: boolean;
297
+ };
280
298
  }
281
299
 
282
300
  // ---------------------------------------------------------------------------
@@ -1141,6 +1159,22 @@ export function readGlobalConfig(): GlobalConfig {
1141
1159
  const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
1142
1160
  const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
1143
1161
  const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
1162
+ // auto_transcribe block — currently single boolean `enabled` (vault#353).
1163
+ // Parsed as a nested 2-space-indent block so future fields can grow under
1164
+ // it without breaking the regex; only `enabled` is read for v0.6.
1165
+ const autoTranscribeStart = yaml.match(/^auto_transcribe:\s*$/m);
1166
+ let autoTranscribeEnabled: boolean | undefined;
1167
+ if (autoTranscribeStart) {
1168
+ const after = yaml.slice((autoTranscribeStart.index ?? 0) + autoTranscribeStart[0].length);
1169
+ for (const line of after.split("\n")) {
1170
+ if (line.match(/^\S/) && line.trim().length > 0) break; // next top-level key
1171
+ const m = line.match(/^\s+enabled:\s*(true|false)/);
1172
+ if (m) {
1173
+ autoTranscribeEnabled = m[1]! === "true";
1174
+ break;
1175
+ }
1176
+ }
1177
+ }
1144
1178
  const config: GlobalConfig = {
1145
1179
  port: portMatch ? parseInt(portMatch[1]!, 10) : DEFAULT_PORT,
1146
1180
  default_vault: defaultVaultMatch?.[1],
@@ -1153,6 +1187,9 @@ export function readGlobalConfig(): GlobalConfig {
1153
1187
  if (autostartMatch) {
1154
1188
  config.autostart = autostartMatch[1]! === "true";
1155
1189
  }
1190
+ if (autoTranscribeEnabled !== undefined) {
1191
+ config.auto_transcribe = { enabled: autoTranscribeEnabled };
1192
+ }
1156
1193
 
1157
1194
  // Parse backup_codes: a YAML list of quoted bcrypt hashes under
1158
1195
  // backup_codes:
@@ -1297,6 +1334,13 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1297
1334
  lines.push(...serializeMirrorSection(config.mirror));
1298
1335
  }
1299
1336
 
1337
+ if (config.auto_transcribe) {
1338
+ lines.push("auto_transcribe:");
1339
+ if (config.auto_transcribe.enabled !== undefined) {
1340
+ lines.push(` enabled: ${config.auto_transcribe.enabled}`);
1341
+ }
1342
+ }
1343
+
1300
1344
  // 0600 — owner read/write only. This file may contain the bcrypt password
1301
1345
  // hash and plaintext TOTP secret; it must not be world- or group-readable.
1302
1346
  writeFileSync(globalConfigPath(), lines.join("\n") + "\n", { mode: 0o600 });
@@ -1395,7 +1439,15 @@ export function writeEnvFile(env: Record<string, string>): void {
1395
1439
  lines.push(`${key}=${val}`);
1396
1440
  }
1397
1441
  }
1398
- writeFileSync(envFilePath(), lines.join("\n") + "\n");
1442
+ // 0600 — owner read/write only. This file holds SCRIBE_AUTH_TOKEN (the
1443
+ // vault↔scribe loopback bearer) and any other secrets the operator drops
1444
+ // in via `parachute-vault config set`. It must not be world- or
1445
+ // group-readable on shared-user machines or Docker images with a loose
1446
+ // umask. Mirrors the writeGlobalConfig pattern above.
1447
+ writeFileSync(envFilePath(), lines.join("\n") + "\n", { mode: 0o600 });
1448
+ // writeFileSync's `mode` only applies on file creation, so chmod an existing
1449
+ // file explicitly in case it was written by an older version at 0644.
1450
+ try { chmodSync(envFilePath(), 0o600); } catch {}
1399
1451
  }
1400
1452
 
1401
1453
  /**
package/src/db.ts CHANGED
@@ -4,11 +4,24 @@
4
4
 
5
5
  import { Database } from "bun:sqlite";
6
6
  import { vaultDbPath, vaultDir } from "./config.ts";
7
+ import { applyConnectionPragmas } from "../core/src/schema.ts";
7
8
  import { mkdirSync } from "fs";
8
9
 
9
- /** Open (or create) a vault's SQLite database. */
10
+ /**
11
+ * Open (or create) a vault's SQLite database with vault's standard
12
+ * connection pragmas (WAL + synchronous=NORMAL + foreign_keys=ON). Safe
13
+ * for both the in-process store and out-of-process consumers (CLI,
14
+ * parachute-runner, mirror tools) — pragmas are idempotent.
15
+ *
16
+ * The full schema migration runs separately when a `BunSqliteStore` is
17
+ * instantiated around the returned handle; callers that just want raw
18
+ * read access (auth probes, backup tooling) skip that and pay only the
19
+ * pragma cost.
20
+ */
10
21
  export function openVaultDb(name: string): Database {
11
22
  const dir = vaultDir(name);
12
23
  mkdirSync(dir, { recursive: true });
13
- return new Database(vaultDbPath(name));
24
+ const db = new Database(vaultDbPath(name));
25
+ applyConnectionPragmas(db);
26
+ return db;
14
27
  }
@@ -287,8 +287,23 @@ describe("runInteractiveInstall — decision tree", () => {
287
287
  expect(result.scope).toBe("vault:write");
288
288
  });
289
289
 
290
- test("scope widening: typing 'admin' produces vault:admin mint", async () => {
291
- const { io } = mockIO([
290
+ test("typing 'admin' auto-routes to legacy-pat with vault:admin scope (hub mint-token rejects admin)", async () => {
291
+ // Regression for the symptom Aaron hit on hub 0.5.12-rc.2 / vault
292
+ // 0.4.7-rc.1: picking "admin" in the mint prompt sent
293
+ // `vault:default:admin` to `POST /api/auth/mint-token`, which hub
294
+ // rejects by policy (per-vault admin is non-requestable; see
295
+ // `parachute-hub/src/scope-explanations.ts:VAULT_ADMIN_RE` and
296
+ // `api-mint-token.ts`'s non-requestable guard):
297
+ //
298
+ // Hub mint-token rejected (HTTP 400, invalid_scope):
299
+ // scope vault:default:admin is not requestable via mint-token;
300
+ // use OAuth flow or operator rotation
301
+ //
302
+ // Fix: auto-route "admin" in the interactive prompt to legacy-pat
303
+ // mode (which mints a vault-DB pvt_* — the right shape for an MCP
304
+ // entry needing admin permissions), with a printed explanation so
305
+ // the switch isn't silent.
306
+ const { io, state } = mockIO([
292
307
  null, // accept install-scope default
293
308
  "admin",
294
309
  true,
@@ -296,7 +311,13 @@ describe("runInteractiveInstall — decision tree", () => {
296
311
  const result = await runInteractiveInstall(baseCtx(), io);
297
312
  expect(result).not.toBe("abort");
298
313
  if (result === "abort") return;
314
+ expect(result.mode).toBe("legacy-pat");
299
315
  expect(result.scope).toBe("vault:admin");
316
+ // The auto-route must surface the reason — silent re-routing would
317
+ // mislead operators who specifically want a hub JWT.
318
+ const logged = state.logs.join("\n");
319
+ expect(logged).toMatch(/admin requires a vault-DB pvt_\*/);
320
+ expect(logged).toMatch(/hub policy/);
300
321
  });
301
322
 
302
323
  test("typing 'paste' at the auth prompt switches to token mode + asks for token", async () => {
@@ -190,7 +190,9 @@ export async function runInteractiveInstall(
190
190
  "Choices:",
191
191
  " Enter → mint a hub JWT with vault:read scope (recommended).",
192
192
  " write → mint with vault:write (mutations).",
193
- " admin → mint with vault:admin (schema management).",
193
+ " admin → mint a vault-DB pvt_* with vault:admin (schema management;",
194
+ " hub policy reserves per-vault admin for operator-only paths,",
195
+ " so this auto-routes to legacy-pat).",
194
196
  " paste → use an existing token instead of minting.",
195
197
  " legacy → mint a vault-DB pvt_* (self-hosted-without-hub).",
196
198
  ].join("\n"),
@@ -209,10 +211,27 @@ export async function runInteractiveInstall(
209
211
  // operator gets the same control they get when widening a hub
210
212
  // JWT's scope. (vault#292 review F2.)
211
213
  scope = await askScope(io);
214
+ } else if (answer === "admin") {
215
+ // `vault:<name>:admin` is non-requestable via hub mint-token by
216
+ // policy — only the session-cookie-gated `/admin/vault-admin-token/:name`
217
+ // endpoint can mint per-vault admin scopes (see
218
+ // `parachute-hub/src/scope-explanations.ts:VAULT_ADMIN_RE` and
219
+ // `api-mint-token.ts`'s non-requestable guard). Hub returns
220
+ // HTTP 400 invalid_scope: "scope vault:<name>:admin is not
221
+ // requestable via mint-token; use OAuth flow or operator rotation".
222
+ //
223
+ // Auto-route to legacy-pat which mints a vault-DB pvt_* with full
224
+ // permissions — that's the right shape for an operator who wants
225
+ // admin scope on a local MCP entry. Print a one-line explanation
226
+ // so the switch isn't silent.
227
+ io.log(" → admin requires a vault-DB pvt_* (hub policy: per-vault admin");
228
+ io.log(" is operator-only, not mintable via the public mint-token API).");
229
+ io.log(" Switching to legacy-pat mode with vault:admin scope.");
230
+ mode = "legacy-pat";
231
+ scope = "vault:admin";
212
232
  } else {
213
233
  mode = "mint";
214
234
  if (answer === "write") scope = "vault:write";
215
- else if (answer === "admin") scope = "vault:admin";
216
235
  }
217
236
  } else {
218
237
  // No hub-mint path available — explain why and offer the alternatives.
@@ -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
  }