@openparachute/vault 0.7.5-rc.6 → 0.7.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.5-rc.6",
3
+ "version": "0.7.5",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -270,6 +270,72 @@ describe("attachment tickets — upload lifecycle", () => {
270
270
  expect((updatedNote!.metadata as any)?.transcribe_stub).toBe(true);
271
271
  });
272
272
 
273
+ // vault#643 — the fresh-install case. Auto-transcribe is ON by default and
274
+ // no provider is reachable (SCRIBE_URL unset, no scribe row), which is
275
+ // exactly what a new box looks like. The upload must not come back looking
276
+ // like an ordinary file: it carries a `failed` status and an actionable
277
+ // reason, so the state is visible in the API and the admin SPA.
278
+ test("audio + auto-transcribe on + NO provider → attachment records the failure, not silence", async () => {
279
+ const vaultName = freshVault("tickets-no-provider");
280
+ const store = getVaultStore(vaultName);
281
+ const note = await store.createNote("# Voice memo\n", { path: "memo-noprov" });
282
+
283
+ const mint = await callTool(vaultName, "request-attachment-upload", {
284
+ note: note.id,
285
+ filename: "memo.webm",
286
+ size_bytes: 4,
287
+ // NO `transcribe: true` — this is the AUTO path, the one that used to
288
+ // silently do nothing.
289
+ });
290
+ const res = await routeReq(
291
+ new Request(mint.url, {
292
+ method: "PUT",
293
+ headers: { "content-type": "audio/webm" },
294
+ body: new Uint8Array([1, 2, 3, 4]),
295
+ }),
296
+ );
297
+ expect(res.status).toBe(201);
298
+ const attachment = (await res.json()) as any;
299
+ expect(attachment.metadata.transcribe_status).toBe("failed");
300
+ expect(attachment.metadata.transcribe_error).toMatch(/no transcription provider configured/i);
301
+ // The reason has to be actionable — naming both routes out of it.
302
+ expect(attachment.metadata.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
303
+ expect(attachment.metadata.transcribe_error).toMatch(/SCRIBE_URL/);
304
+ expect(attachment.metadata.transcribe_origin).toBe("auto");
305
+ });
306
+
307
+ // The counter-case: turning auto-transcribe OFF must stay silent. The
308
+ // operator asked for nothing to happen, so nothing happening is correct and
309
+ // must not be dressed up as a failure.
310
+ test("audio + auto-transcribe explicitly OFF → no transcribe metadata at all", async () => {
311
+ const vaultName = freshVault("tickets-transcribe-off");
312
+ const store = getVaultStore(vaultName);
313
+ writeVaultConfig({
314
+ name: vaultName,
315
+ api_keys: [],
316
+ created_at: new Date().toISOString(),
317
+ auto_transcribe: { enabled: false },
318
+ } as never);
319
+ const note = await store.createNote("# Voice memo\n", { path: "memo-off" });
320
+
321
+ const mint = await callTool(vaultName, "request-attachment-upload", {
322
+ note: note.id,
323
+ filename: "memo.webm",
324
+ size_bytes: 4,
325
+ });
326
+ const res = await routeReq(
327
+ new Request(mint.url, {
328
+ method: "PUT",
329
+ headers: { "content-type": "audio/webm" },
330
+ body: new Uint8Array([1, 2, 3, 4]),
331
+ }),
332
+ );
333
+ expect(res.status).toBe(201);
334
+ const attachment = (await res.json()) as any;
335
+ expect(attachment.metadata.transcribe_status).toBeUndefined();
336
+ expect(attachment.metadata.transcribe_error).toBeUndefined();
337
+ });
338
+
273
339
  test("segment_index (voice W2): a valid integer >= 0 rides ticket mint through to the attachment row", async () => {
274
340
  const vaultName = freshVault("tickets-segment");
275
341
  const store = getVaultStore(vaultName);
@@ -22,7 +22,11 @@ import type { Store } from "../core/src/types.ts";
22
22
  import type { AttachmentTicket, AttachmentTicketProvider } from "../core/src/attachment/tickets.ts";
23
23
  import { sanitizeAttachmentExtension } from "../core/src/attachment/policy.ts";
24
24
  import { assetsDir, readVaultConfig } from "./config.ts";
25
- import { shouldAutoTranscribe } from "./auto-transcribe.ts";
25
+ import {
26
+ NO_PROVIDER_ERROR,
27
+ classifyAutoTranscribe,
28
+ warnNoTranscriptionProvider,
29
+ } from "./auto-transcribe.ts";
26
30
  import { invalidateUsageCache } from "./usage.ts";
27
31
 
28
32
  function json(data: unknown, status = 200): Response {
@@ -265,7 +269,13 @@ async function handleUploadSpend(
265
269
  // auto-transcribe toggle.
266
270
  const explicitOptIn = ticket.transcribe === true;
267
271
  const perVaultEnabled = readVaultConfig(vaultName)?.auto_transcribe?.enabled;
268
- const autoOptIn = !explicitOptIn && shouldAutoTranscribe(ticket.mimeType, { perVaultEnabled });
272
+ const autoDecision = explicitOptIn
273
+ ? ({ kind: "transcribe" } as const)
274
+ : classifyAutoTranscribe(ticket.mimeType, { perVaultEnabled });
275
+ const autoOptIn = !explicitOptIn && autoDecision.kind === "transcribe";
276
+ // vault#643 — same honesty as the REST path: enabled-but-unconfigured is a
277
+ // misconfiguration, not a silent no-op.
278
+ const transcribeUnavailable = !explicitOptIn && autoDecision.kind === "unavailable";
269
279
  const attMeta: Record<string, unknown> = {
270
280
  original_name: ticket.filename,
271
281
  size: buffer.length,
@@ -280,6 +290,15 @@ async function handleUploadSpend(
280
290
  if (ticket.segmentIndex !== undefined) {
281
291
  attMeta.segment_index = ticket.segmentIndex;
282
292
  }
293
+ } else if (transcribeUnavailable) {
294
+ attMeta.transcribe_status = "failed";
295
+ attMeta.transcribe_error = NO_PROVIDER_ERROR;
296
+ attMeta.transcribe_requested_at = new Date().toISOString();
297
+ attMeta.transcribe_origin = "auto";
298
+ if (ticket.segmentIndex !== undefined) {
299
+ attMeta.segment_index = ticket.segmentIndex;
300
+ }
301
+ warnNoTranscriptionProvider();
283
302
  }
284
303
 
285
304
  const attachment = await store.addAttachment(ticket.noteId, relativePath, ticket.mimeType, attMeta);
@@ -6,7 +6,12 @@
6
6
  */
7
7
 
8
8
  import { describe, test, expect } from "bun:test";
9
- import { shouldAutoTranscribe } from "./auto-transcribe.ts";
9
+ import {
10
+ _resetNoProviderWarnForTest,
11
+ classifyAutoTranscribe,
12
+ shouldAutoTranscribe,
13
+ warnNoTranscriptionProvider,
14
+ } from "./auto-transcribe.ts";
10
15
 
11
16
  function readGlobalConfig(enabled: boolean | undefined) {
12
17
  return () => ({
@@ -170,3 +175,109 @@ describe("shouldAutoTranscribe", () => {
170
175
  });
171
176
  });
172
177
  });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // vault#643 — "disabled" and "unavailable" are not the same answer.
181
+ //
182
+ // `shouldAutoTranscribe` collapsed both into `false`, so a box with
183
+ // auto-transcribe ON and no reachable provider accepted audio, transcribed
184
+ // nothing, wrote no marker, and logged nothing. The attachment was
185
+ // indistinguishable from a plain upload.
186
+ //
187
+ // That is the DEFAULT state of a fresh install: the provider resolves to
188
+ // `scribe-http`, nothing sets SCRIBE_URL, and voice memos silently never
189
+ // transcribe. The hosted door already marks a terminal state rather than
190
+ // skipping quietly (workers/vault/src/vault-do.ts — "voice not enabled for
191
+ // this plan" / "monthly voice limit reached"); this is the self-host twin.
192
+ // ---------------------------------------------------------------------------
193
+ describe("classifyAutoTranscribe (vault#643)", () => {
194
+ const audio = "audio/webm";
195
+ const withProvider = () => "http://127.0.0.1:1943";
196
+ const noProvider = () => undefined;
197
+
198
+ test("non-audio → not-audio, whatever else is true", () => {
199
+ expect(
200
+ classifyAutoTranscribe("image/png", { getCachedScribeUrlImpl: withProvider }).kind,
201
+ ).toBe("not-audio");
202
+ });
203
+
204
+ test("operator turned it off → disabled (silence is CORRECT here)", () => {
205
+ expect(
206
+ classifyAutoTranscribe(audio, {
207
+ perVaultEnabled: false,
208
+ getCachedScribeUrlImpl: withProvider,
209
+ }).kind,
210
+ ).toBe("disabled");
211
+ });
212
+
213
+ test("enabled + no provider → unavailable, NOT disabled — the whole point", () => {
214
+ expect(
215
+ classifyAutoTranscribe(audio, {
216
+ perVaultEnabled: true,
217
+ getCachedScribeUrlImpl: noProvider,
218
+ }).kind,
219
+ ).toBe("unavailable");
220
+ });
221
+
222
+ test("a blank provider URL is unavailable, not a provider", () => {
223
+ expect(
224
+ classifyAutoTranscribe(audio, {
225
+ perVaultEnabled: true,
226
+ getCachedScribeUrlImpl: () => " ",
227
+ }).kind,
228
+ ).toBe("unavailable");
229
+ });
230
+
231
+ test("the default (no toggle set) with no provider is unavailable — the fresh-install case", () => {
232
+ expect(
233
+ classifyAutoTranscribe(audio, {
234
+ readGlobalConfigImpl: (() => ({})) as never,
235
+ getCachedScribeUrlImpl: noProvider,
236
+ }).kind,
237
+ ).toBe("unavailable");
238
+ });
239
+
240
+ test("enabled + provider → transcribe", () => {
241
+ expect(
242
+ classifyAutoTranscribe(audio, {
243
+ perVaultEnabled: true,
244
+ getCachedScribeUrlImpl: withProvider,
245
+ }).kind,
246
+ ).toBe("transcribe");
247
+ });
248
+
249
+ test("shouldAutoTranscribe stays a faithful boolean view of the classifier", () => {
250
+ for (const opts of [
251
+ { perVaultEnabled: false, getCachedScribeUrlImpl: withProvider },
252
+ { perVaultEnabled: true, getCachedScribeUrlImpl: noProvider },
253
+ { perVaultEnabled: true, getCachedScribeUrlImpl: withProvider },
254
+ ]) {
255
+ expect(shouldAutoTranscribe(audio, opts)).toBe(
256
+ classifyAutoTranscribe(audio, opts).kind === "transcribe",
257
+ );
258
+ }
259
+ });
260
+ });
261
+
262
+ describe("warnNoTranscriptionProvider throttle (vault#643)", () => {
263
+ test("warns once per window — a bulk import gets one line, not a hundred", () => {
264
+ _resetNoProviderWarnForTest();
265
+ const seen: string[] = [];
266
+ const orig = console.warn;
267
+ console.warn = (...args: unknown[]) => seen.push(String(args[0]));
268
+ try {
269
+ let now = 1_000_000;
270
+ const clock = () => now;
271
+ for (let i = 0; i < 50; i++) warnNoTranscriptionProvider(clock);
272
+ expect(seen.length).toBe(1);
273
+ expect(seen[0]).toMatch(/no transcription provider configured/i);
274
+ // Past the window → one more.
275
+ now += 61_000;
276
+ warnNoTranscriptionProvider(clock);
277
+ expect(seen.length).toBe(2);
278
+ } finally {
279
+ console.warn = orig;
280
+ _resetNoProviderWarnForTest();
281
+ }
282
+ });
283
+ });
@@ -59,12 +59,90 @@ export function shouldAutoTranscribe(
59
59
  if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
60
60
  return false;
61
61
  }
62
+ return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
63
+ }
64
+
65
+ /**
66
+ * Why an audio attachment is (or isn't) being transcribed. The three outcomes
67
+ * are NOT interchangeable, which is the whole point of this type:
68
+ *
69
+ * - `transcribe` — enqueue it.
70
+ * - `disabled` — the operator turned auto-transcribe off. Silence is
71
+ * correct: they asked for nothing to happen.
72
+ * - `unavailable` — auto-transcribe is ON, but no provider is reachable.
73
+ * This is a MISCONFIGURATION, and silence is wrong.
74
+ *
75
+ * vault#643: `shouldAutoTranscribe` collapsed the last two into `false`, so a
76
+ * box with transcription enabled and no reachable provider accepted audio,
77
+ * transcribed nothing, wrote no marker, logged nothing, and left an attachment
78
+ * indistinguishable from a plain upload. Observed on a fresh install: the
79
+ * provider resolves to `scribe-http` by default, nothing sets `SCRIBE_URL`, and
80
+ * voice memos silently never transcribe.
81
+ *
82
+ * The hosted door already gets this right — `workers/vault/src/vault-do.ts`
83
+ * marks a terminal state ("voice not enabled for this plan", "monthly voice
84
+ * limit reached") rather than skipping quietly, precisely so the operator never
85
+ * faces an eternal spinner. This brings self-host to the same posture.
86
+ */
87
+ export type AutoTranscribeDecision =
88
+ | { kind: "transcribe" }
89
+ | { kind: "not-audio" }
90
+ | { kind: "disabled" }
91
+ | { kind: "unavailable" };
92
+
93
+ /** The full decision behind `shouldAutoTranscribe`. Same inputs, more answer. */
94
+ export function classifyAutoTranscribe(
95
+ mimeType: string,
96
+ opts: {
97
+ readGlobalConfigImpl?: typeof readGlobalConfig;
98
+ getCachedScribeUrlImpl?: () => string | undefined;
99
+ perVaultEnabled?: boolean;
100
+ enabledOverride?: boolean;
101
+ } = {},
102
+ ): AutoTranscribeDecision {
103
+ if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
104
+ return { kind: "not-audio" };
105
+ }
62
106
  const enabled = opts.enabledOverride
63
107
  ?? opts.perVaultEnabled
64
108
  ?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
65
109
  ?? true;
66
- if (!enabled) return false;
110
+ if (!enabled) return { kind: "disabled" };
67
111
  const url = (opts.getCachedScribeUrlImpl ?? getCachedScribeUrl)();
68
- if (!url || !url.trim()) return false;
69
- return true;
112
+ if (!url || !url.trim()) return { kind: "unavailable" };
113
+ return { kind: "transcribe" };
114
+ }
115
+
116
+ /**
117
+ * The `transcribe_error` written when auto-transcribe is on but no provider is
118
+ * reachable. Deliberately actionable — the operator needs to know which of the
119
+ * two things to do, not just that something went wrong.
120
+ */
121
+ export const NO_PROVIDER_ERROR =
122
+ "no transcription provider configured — set TRANSCRIPTION_PROVIDER to a local " +
123
+ "provider (see `parachute-vault transcription install`), or point SCRIBE_URL at " +
124
+ "a transcription service";
125
+
126
+ /** Throttle for {@link warnNoTranscriptionProvider}. */
127
+ const NO_PROVIDER_WARN_INTERVAL_MS = 60_000;
128
+ let lastNoProviderWarnAt = 0;
129
+
130
+ /**
131
+ * Warn (at most once a minute) that audio is arriving with nowhere to send it.
132
+ *
133
+ * Throttled because it fires per-upload: a bulk import of a hundred voice
134
+ * memos should produce one actionable line, not a hundred. Silence was the old
135
+ * behaviour and it is what made this invisible — a box can accept audio for
136
+ * months and never say that transcription isn't wired up.
137
+ */
138
+ export function warnNoTranscriptionProvider(now: () => number = Date.now): void {
139
+ const t = now();
140
+ if (t - lastNoProviderWarnAt < NO_PROVIDER_WARN_INTERVAL_MS) return;
141
+ lastNoProviderWarnAt = t;
142
+ console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}.`);
143
+ }
144
+
145
+ /** Test seam: forget the throttle window. */
146
+ export function _resetNoProviderWarnForTest(): void {
147
+ lastNoProviderWarnAt = 0;
70
148
  }
@@ -1655,7 +1655,7 @@ export async function applyCredentialsToMirror(
1655
1655
  // "credentials": null
1656
1656
  // | { "kind": "pat", "token": "ghp_..." }
1657
1657
  // | { "kind": "none" },
1658
- // "enable_sync": true // optional, DEFAULT TRUE
1658
+ // "enable_sync": false // optional, DEFAULT FALSE (vault#641)
1659
1659
  // }
1660
1660
  //
1661
1661
  // `credentials: null` means "use the stored mirror credentials." Passing
@@ -1663,11 +1663,18 @@ export async function applyCredentialsToMirror(
1663
1663
  // for the CLONE, but IS persisted when sync is enabled (it's the push
1664
1664
  // credential for the now-configured mirror).
1665
1665
  //
1666
- // `enable_sync` (vault#416) — DEFAULT TRUE when omitted. After a successful
1667
- // import, auto-enable mirror push-back to the SAME repo, reusing the import's
1668
- // credentials. Makes "import a repo" and "back up to that repo going forward"
1669
- // one fluid flow. The UI ships a checked-by-default checkbox the operator can
1670
- // uncheck. Edge cases (handled in `enableSyncToImportedRepo`, never fail the
1666
+ // `enable_sync` — DEFAULT **FALSE** when omitted (vault#641). When explicitly
1667
+ // enabled, a successful import also configures mirror push-back to the SAME
1668
+ // repo, reusing the import's credentials "import a repo" and "back up to that
1669
+ // repo going forward" in one flow. The UI's checkbox is **unchecked** by default
1670
+ // to match.
1671
+ //
1672
+ // This shipped default-ON in vault#416 and was inverted in vault#641: import is
1673
+ // a READ, and defaulting to ON silently made the repo you were reading from into
1674
+ // this vault's push target. The full rationale lives at the handler below; keep
1675
+ // these two in agreement.
1676
+ //
1677
+ // Edge cases (handled in `enableSyncToImportedRepo`, never fail the
1671
1678
  // whole import):
1672
1679
  // - `auth: none` (public repo, no push creds) → skip + warn (can't push
1673
1680
  // without a credential).
package/src/routes.ts CHANGED
@@ -145,7 +145,12 @@ import {
145
145
  import { join, extname, normalize } from "path";
146
146
  import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "fs";
147
147
  import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
148
- import { shouldAutoTranscribe } from "./auto-transcribe.ts";
148
+ import {
149
+ NO_PROVIDER_ERROR,
150
+ classifyAutoTranscribe,
151
+ shouldAutoTranscribe,
152
+ warnNoTranscriptionProvider,
153
+ } from "./auto-transcribe.ts";
149
154
  // usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
150
155
  // of invalidateUsageCache does NOT form a cycle — routes.ts → usage.ts only.
151
156
  import { invalidateUsageCache } from "./usage.ts";
@@ -2369,7 +2374,18 @@ async function handleNotesInner(
2369
2374
  const perVaultEnabled = vault
2370
2375
  ? readVaultConfig(vault)?.auto_transcribe?.enabled
2371
2376
  : undefined;
2372
- const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType, { perVaultEnabled });
2377
+ // vault#643: classify rather than collapse to a boolean. "Operator
2378
+ // turned it off" and "nothing is configured to do it" both used to read
2379
+ // as `false`, so a misconfigured box silently accepted audio and
2380
+ // transcribed nothing — no marker, no status, no log.
2381
+ const autoDecision = explicitOptIn
2382
+ ? ({ kind: "transcribe" } as const)
2383
+ : classifyAutoTranscribe(body.mimeType, { perVaultEnabled });
2384
+ const autoOptIn = !explicitOptIn && autoDecision.kind === "transcribe";
2385
+ // Enabled, audio, but no reachable provider. Record it on the attachment
2386
+ // so the state is visible in the API and the admin SPA instead of the
2387
+ // upload looking like an ordinary file.
2388
+ const transcribeUnavailable = !explicitOptIn && autoDecision.kind === "unavailable";
2373
2389
  // Per-segment slots (voice W2, cloud twin: workers/vault/src/rest/notes.ts
2374
2390
  // ~791-800): an optional `segment_index` (integer >= 0) lets one recording
2375
2391
  // split across several attachments on ONE note, each resolving into its
@@ -2385,7 +2401,16 @@ async function handleNotesInner(
2385
2401
  transcribe_origin: (explicitOptIn ? "legacy" : "auto") as "legacy" | "auto",
2386
2402
  ...(validSegment ? { segment_index: segIdx } : {}),
2387
2403
  }
2388
- : undefined;
2404
+ : transcribeUnavailable
2405
+ ? {
2406
+ transcribe_status: "failed" as const,
2407
+ transcribe_error: NO_PROVIDER_ERROR,
2408
+ transcribe_requested_at: new Date().toISOString(),
2409
+ transcribe_origin: "auto" as const,
2410
+ ...(validSegment ? { segment_index: segIdx } : {}),
2411
+ }
2412
+ : undefined;
2413
+ if (transcribeUnavailable) warnNoTranscriptionProvider();
2389
2414
 
2390
2415
  const attachment = await store.addAttachment(note.id, body.path, body.mimeType, attMeta);
2391
2416
 
package/src/routing.ts CHANGED
@@ -118,6 +118,10 @@ import {
118
118
  handleMirrorRunNow,
119
119
  } from "./mirror-routes.ts";
120
120
  import { handleEmbeddingsGet, handleEmbeddingsPut } from "./embeddings-routes.ts";
121
+ import {
122
+ handleTranscriptionGet,
123
+ handleTranscriptionPut,
124
+ } from "./transcription-routes.ts";
121
125
  import { getMirrorManager } from "./mirror-registry.ts";
122
126
  import { buildUsageReport } from "./usage.ts";
123
127
  import { handleTicketSpend } from "./attachment-tickets.ts";
@@ -716,6 +720,38 @@ export async function route(
716
720
  return Response.json({ error: "Method not allowed" }, { status: 405 });
717
721
  }
718
722
 
723
+ // /.parachute/transcription — Admin-gated read+write of the transcription
724
+ // setup. Mirrors the embeddings toggle's shape (persist a preference, report
725
+ // `restart_required`) but answers a harder question: transcription can be
726
+ // "configured" and still not work, because it needs a binary and a model on
727
+ // disk that no config file can promise are there.
728
+ //
729
+ // So GET reports readiness with the missing PIECE named, the paths searched,
730
+ // and the exact command that fixes it. That's the gap this closes: a box
731
+ // could accept audio for weeks and transcribe nothing, with the only evidence
732
+ // a boot log line the operator scrolled past (vault#643).
733
+ //
734
+ // PUT persists provider/model only — installing downloads hundreds of MB and
735
+ // shells brew/tar, which belongs in the CLI with a progress bar, not in a
736
+ // request a browser tab can abandon. See transcription-routes.ts.
737
+ if (subpath === "/.parachute/transcription") {
738
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
739
+ return Response.json(
740
+ {
741
+ error: "Forbidden",
742
+ error_type: "insufficient_scope",
743
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
744
+ required_scope: SCOPE_ADMIN,
745
+ granted_scopes: auth.scopes,
746
+ },
747
+ { status: 403 },
748
+ );
749
+ }
750
+ if (req.method === "GET") return handleTranscriptionGet();
751
+ if (req.method === "PUT") return handleTranscriptionPut(req);
752
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
753
+ }
754
+
719
755
  // The per-vault `/tokens` REST surface (pvt_* mint/list/revoke) was removed
720
756
  // at 0.5.0 (vault#282 Stage 2 — vault is a pure hub resource-server). Hub
721
757
  // JWTs are minted via hub's registry (`/api/auth/mint-token`); a `/tokens`
package/src/server.ts CHANGED
@@ -279,7 +279,20 @@ if (providerName === "whisper-cpp") {
279
279
  wireTranscriptionWorker(transcriptionWorker);
280
280
  console.log(`[transcribe] worker started → ${scribeUrl}`);
281
281
  } else {
282
- console.log("[transcribe] worker disabled (no scribe in services.json and SCRIBE_URL unset)");
282
+ // vault#643: was a console.log reading "worker disabled", which states a
283
+ // fact about the worker and says nothing about the CONSEQUENCE. On a fresh
284
+ // box the provider resolves to `scribe-http` by default and nothing sets
285
+ // SCRIBE_URL, so this is the DEFAULT state — and audio silently never
286
+ // transcribes. Name the consequence, and warn rather than log, because the
287
+ // box is misconfigured for a feature that is on by default.
288
+ console.warn(
289
+ "[transcribe] NO transcription provider is reachable — audio attachments " +
290
+ "will be accepted but never transcribed. " +
291
+ `Provider resolved to "${providerName}". Fix with either: ` +
292
+ "`parachute-vault transcription install` + TRANSCRIPTION_PROVIDER=<local provider>, " +
293
+ "or point SCRIBE_URL at a transcription service. " +
294
+ "Set `auto_transcribe.enabled: false` to silence this if you don't want transcription.",
295
+ );
283
296
  }
284
297
  }
285
298