@openparachute/vault 0.3.0-rc.1 → 0.3.1

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/src/cli.ts CHANGED
@@ -77,8 +77,10 @@ import {
77
77
  resolveServerPath,
78
78
  } from "./daemon.ts";
79
79
  import { confirm, ask, askPassword, choose } from "./prompt.ts";
80
+ import { resolveBindHostname } from "./bind.ts";
80
81
  import { generateToken, createToken, listTokens, revokeToken, migrateVaultKeys } from "./token-store.ts";
81
82
  import type { TokenPermission } from "./token-store.ts";
83
+ import { resolveCreateTokenFlags, VAULT_SCOPES } from "./scopes.ts";
82
84
  import { getVaultStore } from "./vault-store.ts";
83
85
  import { upsertService, ServicesManifestError } from "./services-manifest.ts";
84
86
  import {
@@ -135,7 +137,7 @@ if (!SKIP_MIGRATION.has(command)) {
135
137
 
136
138
  switch (command) {
137
139
  case "init":
138
- await cmdInit();
140
+ await cmdInit(cmdArgs);
139
141
  break;
140
142
  case "create":
141
143
  cmdCreate(cmdArgs);
@@ -216,9 +218,16 @@ switch (command) {
216
218
  // Command implementations
217
219
  // ---------------------------------------------------------------------------
218
220
 
219
- async function cmdInit() {
221
+ async function cmdInit(args: string[] = []) {
220
222
  ensureConfigDirSync();
221
223
 
224
+ // Flags: --mcp installs MCP in ~/.claude.json without prompting;
225
+ // --no-mcp skips it without prompting. If both passed, --no-mcp wins
226
+ // (safer default). Neither → prompt in a TTY, default-yes in a
227
+ // non-TTY for back-compat with existing piped install scripts.
228
+ const flagMcpOn = args.includes("--mcp");
229
+ const flagMcpOff = args.includes("--no-mcp");
230
+
222
231
  const isMac = process.platform === "darwin";
223
232
  const isLinux = process.platform === "linux";
224
233
  const isFirstRun = !existsSync(ENV_PATH);
@@ -339,11 +348,31 @@ async function cmdInit() {
339
348
  console.log(` Server path: ${serverPath}`);
340
349
  console.log(` Wrapper: ~/.parachute/vault/start.sh`);
341
350
  }
342
- console.log(` Listening on http://0.0.0.0:${globalConfig.port || DEFAULT_PORT}`);
351
+ const bindHost = resolveBindHostname(process.env);
352
+ console.log(` Listening on http://${bindHost}:${globalConfig.port || DEFAULT_PORT}`);
353
+
354
+ // 7. Install MCP for Claude Code (with token for auth) — user confirms
355
+ // unless --mcp / --no-mcp explicitly passed. Writing to ~/.claude.json
356
+ // is a side effect some users don't want; default-yes in a TTY since
357
+ // most users installing vault want Claude Code to see it, but ask.
358
+ let addMcp: boolean;
359
+ if (flagMcpOff) {
360
+ addMcp = false;
361
+ } else if (flagMcpOn) {
362
+ addMcp = true;
363
+ } else if (process.stdin.isTTY) {
364
+ addMcp = await confirm("Add Vault MCP to Claude Code (~/.claude.json)?", true);
365
+ } else {
366
+ addMcp = true; // non-interactive: preserve the installable-via-pipe default
367
+ }
343
368
 
344
- // 7. Install MCP for Claude Code (with token for auth)
345
- installMcpConfig(apiKey);
346
- console.log(` MCP server added to ~/.claude.json`);
369
+ if (addMcp) {
370
+ installMcpConfig(apiKey);
371
+ console.log(` MCP server added to ~/.claude.json`);
372
+ } else {
373
+ console.log(" Skipped adding MCP to ~/.claude.json.");
374
+ console.log(" Run `parachute-vault mcp-install` later if you want it.");
375
+ }
347
376
 
348
377
  // 8. Summary
349
378
  console.log("\n---");
@@ -357,7 +386,7 @@ async function cmdInit() {
357
386
  }
358
387
 
359
388
  console.log(`\nConfig: ${CONFIG_DIR}`);
360
- console.log(`Server: http://0.0.0.0:${port}`);
389
+ console.log(`Server: http://${bindHost}:${port}`);
361
390
 
362
391
  console.log(`\nUsage examples:`);
363
392
  console.log(` curl http://localhost:${port}/health`);
@@ -818,7 +847,8 @@ function cmdTokens(args: string[]) {
818
847
  return;
819
848
  }
820
849
 
821
- // parachute-vault tokens create --vault <name> [--permission full|read]
850
+ // parachute-vault tokens create --vault <name>
851
+ // [--scope vault:read,vault:write | --read | --permission full|read]
822
852
  // [--expires <duration>] [--label <label>]
823
853
  if (subcmd === "create") {
824
854
  const vaultFlag = args.indexOf("--vault");
@@ -830,15 +860,17 @@ function cmdTokens(args: string[]) {
830
860
  process.exit(1);
831
861
  }
832
862
 
833
- // --read shorthand or --permission full|read
834
- const isReadShorthand = args.includes("--read");
835
- const permFlag = args.indexOf("--permission");
836
- const rawPerm = isReadShorthand ? "read" : (permFlag !== -1 ? args[permFlag + 1] : "full");
837
- const permission: TokenPermission = rawPerm === "read" ? "read" : "full";
838
- if (!["full", "read", "admin", "write"].includes(rawPerm)) {
839
- console.error(`Invalid permission: ${rawPerm}. Must be full or read.`);
863
+ // Combining --scope / --read / --permission is always an error: a
864
+ // user minting a token expects exactly one narrowing signal, and
865
+ // silently picking one would mint the opposite of what the other
866
+ // reading intended. See resolveCreateTokenFlags.
867
+ const resolved = resolveCreateTokenFlags(args);
868
+ if (resolved.error) {
869
+ console.error(resolved.error);
840
870
  process.exit(1);
841
871
  }
872
+ const scopes = resolved.scopes;
873
+ const permission: TokenPermission = resolved.permission;
842
874
 
843
875
  const expiresFlag = args.indexOf("--expires");
844
876
  let expiresAt: string | null = null;
@@ -859,12 +891,15 @@ function cmdTokens(args: string[]) {
859
891
  createToken(store.db, fullToken, {
860
892
  label,
861
893
  permission,
894
+ scopes,
862
895
  expires_at: expiresAt,
863
896
  });
864
897
 
898
+ const displayScopes = scopes ?? [...VAULT_SCOPES];
865
899
  console.log(`Created token for vault "${vaultName}":`);
866
900
  console.log(` Token: ${fullToken}`);
867
901
  console.log(` Permission: ${permission}`);
902
+ console.log(` Scopes: ${displayScopes.join(" ")}`);
868
903
  if (expiresAt) console.log(` Expires: ${expiresAt}`);
869
904
  console.log(` Label: ${label}`);
870
905
  console.log();
@@ -2031,7 +2066,7 @@ data, and debugging.
2031
2066
  ── Standard use ───────────────────────────────────────────────────────
2032
2067
 
2033
2068
  Setup:
2034
- parachute-vault init Set up everything (one command, idempotent)
2069
+ parachute-vault init [--mcp | --no-mcp] Set up everything (one command, idempotent)
2035
2070
  parachute-vault doctor Diagnose install/config issues
2036
2071
  parachute-vault uninstall [--wipe] [--yes]
2037
2072
  Remove daemon + MCP entry; --wipe also removes vaults, .env,
@@ -2050,7 +2085,11 @@ Tokens:
2050
2085
  parachute-vault tokens List all tokens
2051
2086
  parachute-vault tokens create Create a full-access token in the default vault
2052
2087
  parachute-vault tokens create --vault <name> Create a token in a specific vault
2053
- parachute-vault tokens create --read Read-only token
2088
+ parachute-vault tokens create --read Read-only token (shorthand for --scope vault:read)
2089
+ parachute-vault tokens create --scope vault:write
2090
+ Narrow the token's scopes. Accepts a comma-separated
2091
+ list or repeated --scope flags. Valid scopes:
2092
+ vault:read, vault:write, vault:admin.
2054
2093
  parachute-vault tokens create --label x Set a label
2055
2094
  parachute-vault tokens create --expires 30d Expiring token
2056
2095
  parachute-vault tokens revoke <token-id> Revoke a token (default vault)
@@ -10,6 +10,8 @@ import {
10
10
  SCOPE_WRITE,
11
11
  SCOPE_ADMIN,
12
12
  parseScopes,
13
+ parseScopeFlags,
14
+ resolveCreateTokenFlags,
13
15
  hasScope,
14
16
  scopeForMethod,
15
17
  legacyPermissionToScopes,
@@ -123,6 +125,162 @@ describe("legacyPermissionToScopes", () => {
123
125
  });
124
126
  });
125
127
 
128
+ describe("parseScopeFlags", () => {
129
+ test("returns null scopes when --scope is absent", () => {
130
+ expect(parseScopeFlags([])).toEqual({ scopes: null, error: null });
131
+ expect(parseScopeFlags(["--vault", "default", "--label", "x"]))
132
+ .toEqual({ scopes: null, error: null });
133
+ });
134
+
135
+ test("single --scope with one value", () => {
136
+ expect(parseScopeFlags(["--scope", "vault:read"]))
137
+ .toEqual({ scopes: [SCOPE_READ], error: null });
138
+ });
139
+
140
+ test("single --scope with comma-separated values", () => {
141
+ expect(parseScopeFlags(["--scope", "vault:read,vault:write"]))
142
+ .toEqual({ scopes: [SCOPE_READ, SCOPE_WRITE], error: null });
143
+ });
144
+
145
+ test("repeated --scope flags", () => {
146
+ expect(parseScopeFlags(["--scope", "vault:read", "--scope", "vault:write"]))
147
+ .toEqual({ scopes: [SCOPE_READ, SCOPE_WRITE], error: null });
148
+ });
149
+
150
+ test("dedupes while preserving first-occurrence order", () => {
151
+ expect(parseScopeFlags(["--scope", "vault:write,vault:read,vault:write"]))
152
+ .toEqual({ scopes: [SCOPE_WRITE, SCOPE_READ], error: null });
153
+ });
154
+
155
+ test("trims whitespace around each scope", () => {
156
+ expect(parseScopeFlags(["--scope", " vault:read , vault:write "]))
157
+ .toEqual({ scopes: [SCOPE_READ, SCOPE_WRITE], error: null });
158
+ });
159
+
160
+ test("rejects unknown scopes with a helpful error", () => {
161
+ const result = parseScopeFlags(["--scope", "vault:frob"]);
162
+ expect(result.scopes).toBeNull();
163
+ expect(result.error).toContain("Unknown scope");
164
+ expect(result.error).toContain("vault:frob");
165
+ expect(result.error).toContain("vault:read, vault:write, vault:admin");
166
+ });
167
+
168
+ test("rejects a mixed list with one invalid scope", () => {
169
+ const result = parseScopeFlags(["--scope", "vault:read,admin"]);
170
+ expect(result.scopes).toBeNull();
171
+ expect(result.error).toContain("admin");
172
+ });
173
+
174
+ test("rejects --scope with no value (end of argv)", () => {
175
+ const result = parseScopeFlags(["--scope"]);
176
+ expect(result.scopes).toBeNull();
177
+ expect(result.error).toContain("--scope requires a value");
178
+ });
179
+
180
+ test("rejects --scope followed by another flag", () => {
181
+ const result = parseScopeFlags(["--scope", "--label", "x"]);
182
+ expect(result.scopes).toBeNull();
183
+ expect(result.error).toContain("--scope requires a value");
184
+ });
185
+
186
+ test("rejects --scope with an empty value", () => {
187
+ const result = parseScopeFlags(["--scope", ""]);
188
+ expect(result.scopes).toBeNull();
189
+ expect(result.error).toContain("empty");
190
+ });
191
+
192
+ test("rejects --scope with only commas", () => {
193
+ const result = parseScopeFlags(["--scope", ",,"]);
194
+ expect(result.scopes).toBeNull();
195
+ expect(result.error).toContain("empty");
196
+ });
197
+ });
198
+
199
+ describe("resolveCreateTokenFlags", () => {
200
+ test("no flags → full scope, full permission (historical default)", () => {
201
+ expect(resolveCreateTokenFlags([]))
202
+ .toEqual({ scopes: undefined, permission: "full", error: null });
203
+ });
204
+
205
+ test("--read alone → [vault:read], read permission", () => {
206
+ expect(resolveCreateTokenFlags(["--read"]))
207
+ .toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
208
+ });
209
+
210
+ test("--scope vault:read alone → read permission", () => {
211
+ expect(resolveCreateTokenFlags(["--scope", "vault:read"]))
212
+ .toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
213
+ });
214
+
215
+ test("--scope vault:write,vault:read → full permission (any write surface → full)", () => {
216
+ expect(resolveCreateTokenFlags(["--scope", "vault:write,vault:read"]))
217
+ .toEqual({ scopes: [SCOPE_WRITE, SCOPE_READ], permission: "full", error: null });
218
+ });
219
+
220
+ test("--scope vault:admin alone → full permission", () => {
221
+ expect(resolveCreateTokenFlags(["--scope", "vault:admin"]))
222
+ .toEqual({ scopes: [SCOPE_ADMIN], permission: "full", error: null });
223
+ });
224
+
225
+ test("--permission read → no scopes (token-store default), read permission", () => {
226
+ expect(resolveCreateTokenFlags(["--permission", "read"]))
227
+ .toEqual({ scopes: undefined, permission: "read", error: null });
228
+ });
229
+
230
+ test("--permission full → no scopes, full permission", () => {
231
+ expect(resolveCreateTokenFlags(["--permission", "full"]))
232
+ .toEqual({ scopes: undefined, permission: "full", error: null });
233
+ });
234
+
235
+ test("--scope + --read errors and mentions both flags", () => {
236
+ const result = resolveCreateTokenFlags(["--scope", "vault:write", "--read"]);
237
+ expect(result.scopes).toBeUndefined();
238
+ expect(result.error).toContain("--scope");
239
+ expect(result.error).toContain("--read");
240
+ expect(result.error).toContain("cannot be combined");
241
+ });
242
+
243
+ test("--scope + --permission errors", () => {
244
+ const result = resolveCreateTokenFlags(["--scope", "vault:read", "--permission", "full"]);
245
+ expect(result.scopes).toBeUndefined();
246
+ expect(result.error).toContain("--scope");
247
+ expect(result.error).toContain("--permission");
248
+ expect(result.error).toContain("cannot be combined");
249
+ });
250
+
251
+ test("--read + --permission errors", () => {
252
+ const result = resolveCreateTokenFlags(["--read", "--permission", "full"]);
253
+ expect(result.scopes).toBeUndefined();
254
+ expect(result.error).toContain("--read");
255
+ expect(result.error).toContain("--permission");
256
+ expect(result.error).toContain("cannot be combined");
257
+ });
258
+
259
+ test("invalid --permission value errors with prefer-scope hint", () => {
260
+ const result = resolveCreateTokenFlags(["--permission", "admin"]);
261
+ expect(result.scopes).toBeUndefined();
262
+ expect(result.error).toContain("admin");
263
+ expect(result.error).toContain("full");
264
+ expect(result.error).toContain("read");
265
+ expect(result.error).toContain("--scope");
266
+ });
267
+
268
+ test("--permission with no value errors", () => {
269
+ expect(resolveCreateTokenFlags(["--permission"]).error).toContain("requires a value");
270
+ expect(resolveCreateTokenFlags(["--permission", "--label", "x"]).error).toContain("requires a value");
271
+ });
272
+
273
+ test("surfaces parseScopeFlags errors unchanged", () => {
274
+ const result = resolveCreateTokenFlags(["--scope", "vault:frob"]);
275
+ expect(result.error).toContain("Unknown scope");
276
+ });
277
+
278
+ test("ignores unrelated flags", () => {
279
+ const result = resolveCreateTokenFlags(["--vault", "journal", "--label", "r", "--read"]);
280
+ expect(result).toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
281
+ });
282
+ });
283
+
126
284
  describe("serializeScopes — round-trips with parseScopes", () => {
127
285
  test("joins with spaces", () => {
128
286
  expect(serializeScopes([SCOPE_READ, SCOPE_WRITE])).toBe("vault:read vault:write");
package/src/scopes.ts CHANGED
@@ -103,3 +103,151 @@ export function legacyPermissionToScopes(permission: string): string[] {
103
103
  export function serializeScopes(scopes: string[]): string {
104
104
  return scopes.join(" ");
105
105
  }
106
+
107
+ /**
108
+ * Parse `--scope` flag values from an argv list into a validated scope list.
109
+ *
110
+ * Accepts repeatable `--scope vault:read --scope vault:write` and
111
+ * comma-separated `--scope vault:read,vault:write` (and a mix of the two).
112
+ * Scopes are validated against `VAULT_SCOPES` — we refuse to mint a token
113
+ * with a scope the server has no way to enforce.
114
+ *
115
+ * Return shape: `{scopes}` is `null` when no `--scope` appears anywhere, so
116
+ * the caller can distinguish "flag not set" from "flag set to empty." On
117
+ * validation failure, `error` is a human-readable message suitable for
118
+ * `console.error` + `process.exit(1)`.
119
+ */
120
+ export function parseScopeFlags(
121
+ args: string[],
122
+ ): { scopes: string[] | null; error: string | null } {
123
+ const validList = VAULT_SCOPES.join(", ");
124
+ const raw: string[] = [];
125
+ for (let i = 0; i < args.length; i++) {
126
+ if (args[i] !== "--scope") continue;
127
+ const val = args[i + 1];
128
+ if (val === undefined || val.startsWith("--")) {
129
+ return { scopes: null, error: `--scope requires a value. Valid scopes: ${validList}` };
130
+ }
131
+ raw.push(val);
132
+ i++;
133
+ }
134
+ if (raw.length === 0) return { scopes: null, error: null };
135
+
136
+ const expanded = raw
137
+ .flatMap((v) => v.split(","))
138
+ .map((s) => s.trim())
139
+ .filter((s) => s.length > 0);
140
+ if (expanded.length === 0) {
141
+ return { scopes: null, error: `--scope value was empty. Valid scopes: ${validList}` };
142
+ }
143
+
144
+ const validSet = new Set<string>(VAULT_SCOPES);
145
+ const invalid = expanded.filter((s) => !validSet.has(s));
146
+ if (invalid.length > 0) {
147
+ return {
148
+ scopes: null,
149
+ error: `Unknown scope(s): ${invalid.join(", ")}. Valid scopes: ${validList}`,
150
+ };
151
+ }
152
+
153
+ const seen = new Set<string>();
154
+ const deduped: string[] = [];
155
+ for (const s of expanded) {
156
+ if (!seen.has(s)) {
157
+ seen.add(s);
158
+ deduped.push(s);
159
+ }
160
+ }
161
+ return { scopes: deduped, error: null };
162
+ }
163
+
164
+ /**
165
+ * Resolve `parachute vault tokens create` argv into a concrete scope set +
166
+ * legacy `permission` column value, or an actionable error.
167
+ *
168
+ * Precedence is **exclusive**: `--scope`, `--read`, and `--permission` all
169
+ * narrow the token, but combining them is always an error — a user who
170
+ * writes `--scope vault:write --read` almost certainly expects one of the
171
+ * two to win, and silently picking would mint the opposite of what at
172
+ * least one reading intended. Fail loud for anything token-minting.
173
+ *
174
+ * With no narrowing flag, falls back to a full-scope token for back-compat.
175
+ */
176
+ export function resolveCreateTokenFlags(args: string[]): {
177
+ scopes: string[] | undefined;
178
+ permission: "full" | "read";
179
+ error: string | null;
180
+ } {
181
+ const scopeResult = parseScopeFlags(args);
182
+ if (scopeResult.error) {
183
+ return { scopes: undefined, permission: "full", error: scopeResult.error };
184
+ }
185
+ const hasScopeFlag = scopeResult.scopes !== null;
186
+ const hasReadFlag = args.includes("--read");
187
+ const permIdx = args.indexOf("--permission");
188
+ const hasPermFlag = permIdx !== -1;
189
+
190
+ if (hasScopeFlag && hasReadFlag) {
191
+ return {
192
+ scopes: undefined,
193
+ permission: "full",
194
+ error:
195
+ "--scope and --read cannot be combined. Pick one:\n" +
196
+ " --read # shorthand for --scope vault:read\n" +
197
+ " --scope vault:read # equivalent, explicit\n" +
198
+ " --scope vault:write # write scope",
199
+ };
200
+ }
201
+ if (hasScopeFlag && hasPermFlag) {
202
+ return {
203
+ scopes: undefined,
204
+ permission: "full",
205
+ error:
206
+ "--scope and --permission cannot be combined. --scope is the canonical way to narrow a token; --permission is legacy.",
207
+ };
208
+ }
209
+ if (hasReadFlag && hasPermFlag) {
210
+ return {
211
+ scopes: undefined,
212
+ permission: "full",
213
+ error: "--read and --permission cannot be combined. --read is a shorthand for --permission read.",
214
+ };
215
+ }
216
+
217
+ if (hasPermFlag) {
218
+ const rawPerm = args[permIdx + 1];
219
+ if (!rawPerm || rawPerm.startsWith("--")) {
220
+ return {
221
+ scopes: undefined,
222
+ permission: "full",
223
+ error: `--permission requires a value ("full" or "read"). Prefer --scope for new scripts.`,
224
+ };
225
+ }
226
+ if (!["full", "read"].includes(rawPerm)) {
227
+ return {
228
+ scopes: undefined,
229
+ permission: "full",
230
+ error: `Invalid --permission: ${rawPerm}. Must be "full" or "read". Prefer --scope for new scripts.`,
231
+ };
232
+ }
233
+ }
234
+
235
+ if (scopeResult.scopes) {
236
+ const scopes = scopeResult.scopes;
237
+ const permission: "full" | "read" =
238
+ scopes.includes(SCOPE_WRITE) || scopes.includes(SCOPE_ADMIN) ? "full" : "read";
239
+ return { scopes, permission, error: null };
240
+ }
241
+ if (hasReadFlag) {
242
+ return { scopes: [SCOPE_READ], permission: "read", error: null };
243
+ }
244
+ if (hasPermFlag) {
245
+ const rawPerm = args[permIdx + 1];
246
+ return {
247
+ scopes: undefined,
248
+ permission: rawPerm === "read" ? "read" : "full",
249
+ error: null,
250
+ };
251
+ }
252
+ return { scopes: undefined, permission: "full", error: null };
253
+ }
package/src/server.ts CHANGED
@@ -17,13 +17,14 @@
17
17
 
18
18
  import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey } from "./config.ts";
19
19
  import { migrateVaultKeys } from "./token-store.ts";
20
- import { getVaultStore } from "./vault-store.ts";
20
+ import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
21
21
  import { defaultHookRegistry } from "../core/src/hooks.ts";
22
22
  import { registerTriggers } from "./triggers.ts";
23
23
  import { route } from "./routing.ts";
24
- import { startTranscriptionWorker, type TranscriptionWorker } from "./transcription-worker.ts";
24
+ import { startTranscriptionWorker, registerTranscriptionHook, type TranscriptionWorker } from "./transcription-worker.ts";
25
25
  import { assetsDir } from "./routes.ts";
26
26
  import { resolveScribeAuthToken } from "./scribe-env.ts";
27
+ import { resolveBindHostname } from "./bind.ts";
27
28
 
28
29
  // Register webhook triggers from global config. Replaces the old hardcoded
29
30
  // tts-hook and transcription-hook with config-driven webhooks.
@@ -59,6 +60,12 @@ function safeHost(url: string): string | null {
59
60
  try { return new URL(url).host; } catch { return null; }
60
61
  }
61
62
 
63
+ // Load .env before anything reads process.env — otherwise SCRIBE_URL and
64
+ // friends configured in ~/.parachute/vault/.env are invisible to the
65
+ // transcription-worker check and the trigger double-fire warning below.
66
+ ensureConfigDirSync();
67
+ loadEnvFile();
68
+
62
69
  registerConfiguredTriggers();
63
70
 
64
71
  /**
@@ -78,14 +85,18 @@ if (process.env.SCRIBE_URL) {
78
85
  getAudioRetention: (vault) => readVaultConfig(vault)?.audio_retention ?? "keep",
79
86
  getContextPredicates: (vault) => readVaultConfig(vault)?.transcription?.context,
80
87
  });
88
+ // Event-driven hot path — the `attachment:created` hook fires the worker
89
+ // in a microtask instead of waiting for the 30s sweep.
90
+ registerTranscriptionHook(
91
+ defaultHookRegistry,
92
+ transcriptionWorker,
93
+ (store) => getVaultNameForStore(store as never),
94
+ );
81
95
  console.log(`[transcribe] worker started → ${process.env.SCRIBE_URL}`);
82
96
  } else {
83
97
  console.log("[transcribe] worker disabled (set SCRIBE_URL to enable)");
84
98
  }
85
99
 
86
- ensureConfigDirSync();
87
- loadEnvFile();
88
-
89
100
  // Auto-init: create a default vault if none exist (first run in Docker)
90
101
  if (listVaults().length === 0) {
91
102
  const globalConfig = readGlobalConfig();
@@ -159,10 +170,11 @@ for (const vaultName of listVaults()) {
159
170
 
160
171
  const globalConfig = readGlobalConfig();
161
172
  const port = parseInt(process.env.PORT ?? "") || globalConfig.port || DEFAULT_PORT;
173
+ const hostname = resolveBindHostname();
162
174
 
163
175
  const server = Bun.serve({
164
176
  port,
165
- hostname: "0.0.0.0",
177
+ hostname,
166
178
  idleTimeout: 120, // seconds — webhook triggers can take a while
167
179
  async fetch(req, server) {
168
180
  const url = new URL(req.url);
@@ -208,7 +220,7 @@ const server = Bun.serve({
208
220
  },
209
221
  });
210
222
 
211
- console.log(`Parachute Vault server listening on http://0.0.0.0:${server.port}`);
223
+ console.log(`Parachute Vault server listening on http://${hostname}:${server.port}`);
212
224
 
213
225
  // Graceful shutdown — best-effort drain of in-flight note-mutation hooks.
214
226
  async function shutdown(signal: string): Promise<void> {