@openparachute/vault 0.7.2 → 0.7.3-rc.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.
@@ -3910,6 +3910,78 @@ describe("MCP tools", async () => {
3910
3910
  expect(result.updatedAt).toBe(note.updatedAt);
3911
3911
  });
3912
3912
 
3913
+ describe("metadata is always present on the wire (V1.1)", () => {
3914
+ it("a note created with no metadata reads back with an empty-object `metadata` key, not an absent one", async () => {
3915
+ const note = await store.createNote("No metadata here");
3916
+ expect("metadata" in note).toBe(true);
3917
+ expect(note.metadata).toEqual({});
3918
+ // The real acid test: does the key survive a JSON round-trip, or does
3919
+ // `JSON.stringify` drop it because it's `undefined`?
3920
+ expect(JSON.parse(JSON.stringify(note))).toHaveProperty("metadata", {});
3921
+
3922
+ const fetched = await store.getNote(note.id);
3923
+ expect(fetched).toHaveProperty("metadata", {});
3924
+ expect(JSON.parse(JSON.stringify(fetched))).toHaveProperty("metadata", {});
3925
+
3926
+ // `tags` has always been unconditionally present ([]) — confirm that
3927
+ // invariant still holds alongside the metadata fix.
3928
+ expect(note.tags).toEqual([]);
3929
+ });
3930
+
3931
+ it("a note whose metadata was explicitly written as {} also reads back with metadata: {}", async () => {
3932
+ const note = await store.createNote("Explicit empty metadata", { metadata: {} });
3933
+ expect(note.metadata).toEqual({});
3934
+ const fetched = await store.getNote(note.id);
3935
+ expect(fetched!.metadata).toEqual({});
3936
+ });
3937
+
3938
+ it("a note WITH real metadata is unchanged by the fix", async () => {
3939
+ const note = await store.createNote("Has metadata", { metadata: { source: "import", version: 2 } });
3940
+ expect(note.metadata).toEqual({ source: "import", version: 2 });
3941
+ const fetched = await store.getNote(note.id);
3942
+ expect(fetched!.metadata).toEqual({ source: "import", version: 2 });
3943
+ });
3944
+
3945
+ it("queryNotes list results carry metadata: {} on metadata-less notes", async () => {
3946
+ await store.createNote("List entry, no metadata");
3947
+ const results = await store.queryNotes({});
3948
+ expect(results.length).toBeGreaterThan(0);
3949
+ for (const r of results) {
3950
+ expect(r).toHaveProperty("metadata", {});
3951
+ }
3952
+ });
3953
+
3954
+ it("searchNotes results carry metadata: {} on metadata-less notes", async () => {
3955
+ await store.createNote("Findable via search, no metadata");
3956
+ const results = await store.searchNotes("Findable");
3957
+ expect(results.length).toBeGreaterThan(0);
3958
+ for (const r of results) {
3959
+ expect(r).toHaveProperty("metadata", {});
3960
+ }
3961
+ });
3962
+
3963
+ it("MCP query-notes (single by id) carries metadata: {} on a metadata-less note", async () => {
3964
+ const note = await store.createNote("MCP single, no metadata");
3965
+ const tools = generateMcpTools(store);
3966
+ const query = tools.find((t) => t.name === "query-notes")!;
3967
+ const result = await query.execute({ id: note.id }) as any;
3968
+ expect(result).toHaveProperty("metadata", {});
3969
+ expect(JSON.parse(JSON.stringify(result))).toHaveProperty("metadata", {});
3970
+ });
3971
+
3972
+ it("MCP query-notes (list) carries metadata: {} on metadata-less notes", async () => {
3973
+ await store.createNote("MCP list, no metadata");
3974
+ const tools = generateMcpTools(store);
3975
+ const query = tools.find((t) => t.name === "query-notes")!;
3976
+ const result = await query.execute({}) as any;
3977
+ const list = Array.isArray(result) ? result : result.notes;
3978
+ expect(list.length).toBeGreaterThan(0);
3979
+ for (const r of list) {
3980
+ expect(r).toHaveProperty("metadata", {});
3981
+ }
3982
+ });
3983
+ });
3984
+
3913
3985
  it("query-notes single note by path", async () => {
3914
3986
  await store.createNote("By Path", { path: "Projects/README" });
3915
3987
  const tools = generateMcpTools(store);
@@ -4173,14 +4245,21 @@ describe("MCP tools", async () => {
4173
4245
  expect(projects).toHaveLength(3);
4174
4246
  expect(projects.every((n) => n.path!.startsWith("Projects"))).toBe(true);
4175
4247
 
4176
- // limit + offset
4177
- const page = await query.execute({
4248
+ // limit + offset. Returning exactly `limit` rows with no cursor also
4249
+ // trips the V1.3 truncation-honesty warning (results.length === limit,
4250
+ // an inherent heuristic — it can't distinguish "exactly this many rows
4251
+ // exist" from "capped, more may follow" without a lookahead), which
4252
+ // wraps the response in { notes, warnings } instead of a bare array —
4253
+ // same envelope shape the tool already uses for other warnings.
4254
+ const pageResult = await query.execute({
4178
4255
  path_prefix: "Projects",
4179
4256
  sort: "asc",
4180
4257
  limit: 2,
4181
4258
  offset: 1,
4182
- }) as any[];
4259
+ }) as any;
4260
+ const page = Array.isArray(pageResult) ? pageResult : pageResult.notes;
4183
4261
  expect(page).toHaveLength(2);
4262
+ expect(pageResult.warnings?.some((w: any) => w.code === "truncated" && w.limit === 2)).toBe(true);
4184
4263
  });
4185
4264
 
4186
4265
  it("query-notes full-text search works", async () => {
@@ -6614,10 +6693,10 @@ describe("create-note if_exists (vault#555)", async () => {
6614
6693
  if_exists: "replace",
6615
6694
  }) as any;
6616
6695
  expect(second.content).toBe("");
6617
- // An empty `{}` metadata collapses to `undefined` on read — the same
6618
- // convention every note with no metadata follows (rowToNote), not
6619
- // something special about `replace`.
6620
- expect(second.metadata).toBeUndefined();
6696
+ // An empty `{}` metadata reads back as `{}`, not an absent key — the
6697
+ // same convention every note with no metadata follows (rowToNote,
6698
+ // vault V1.1), not something special about `replace`.
6699
+ expect(second.metadata).toEqual({});
6621
6700
  });
6622
6701
 
6623
6702
  it("if_exists is a no-op without a path — a pathless create can never conflict", async () => {
package/core/src/mcp.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  collectUnknownTagWarnings,
11
11
  emptySearchWarning,
12
12
  ignoredParamWarning,
13
+ truncatedResultsWarning,
13
14
  computeSearchDidYouMean,
14
15
  searchDidYouMeanWarning,
15
16
  type QueryWarning,
@@ -771,6 +772,22 @@ Response shape (vault#550 — three variants, pick by what you passed):
771
772
  // never leaks via `did_you_mean`.
772
773
  let queryWarnings: QueryWarning[] = [];
773
774
  if (params.search) {
775
+ // `offset` under full-text search (vault contracts-brief V1.2):
776
+ // `searchNotes` has no offset parameter at all — FTS5 ranks by
777
+ // relevance, not a stable row order, so paging by offset over it
778
+ // is unsound. Rather than silently return page 1 with no signal,
779
+ // flag it. Presence check (`!== undefined`) so `offset: 0` — a
780
+ // caller being explicit about "start" — still warns. The
781
+ // structured-query branch (below) has real offset support and is
782
+ // unaffected.
783
+ if (params.offset !== undefined) {
784
+ queryWarnings.push(
785
+ ignoredParamWarning(
786
+ "offset",
787
+ "full-text search has no stable row order to offset into — results are always page 1, ranked by relevance",
788
+ ),
789
+ );
790
+ }
774
791
  // Normalize tag param
775
792
  const tags = normalizeTags(params.tag);
776
793
  const mode: SearchMode = searchMode ?? "literal";
@@ -887,6 +904,13 @@ Response shape (vault#550 — three variants, pick by what you passed):
887
904
  nextCursor = page.next_cursor;
888
905
  } else {
889
906
  results = await store.queryNotes(queryOpts);
907
+ // Truncation-honesty (vault contracts-brief V1.3): no cursor was
908
+ // requested, so `next_cursor` never surfaces as an honest
909
+ // "more may follow" signal — this is that signal. Mirrors the
910
+ // REST structured-query path in src/routes.ts.
911
+ if (results.length === queryOpts.limit) {
912
+ queryWarnings.push(truncatedResultsWarning(queryOpts.limit));
913
+ }
890
914
  }
891
915
  }
892
916
 
package/core/src/notes.ts CHANGED
@@ -2980,7 +2980,12 @@ interface NoteRow {
2980
2980
  }
2981
2981
 
2982
2982
  function rowToNote(row: NoteRow): Note {
2983
- let metadata: Record<string, unknown> | undefined;
2983
+ // `metadata` is always present on the wire — NULL/"{}"/unparseable all
2984
+ // collapse to `{}` rather than an absent key. `tags` is already always
2985
+ // `[]` (see getNote/queryNotes/searchNotes hydration below); this keeps
2986
+ // the note shape internally consistent and fixes a real third-party
2987
+ // crash on clients that assumed `note.metadata` always exists.
2988
+ let metadata: Record<string, unknown> = {};
2984
2989
  if (row.metadata && row.metadata !== "{}") {
2985
2990
  try { metadata = JSON.parse(row.metadata); } catch {}
2986
2991
  }
@@ -219,6 +219,26 @@ export function ignoredParamWarning(param: string, reason: string): QueryWarning
219
219
  };
220
220
  }
221
221
 
222
+ /**
223
+ * `truncated` warning (vault contracts-brief V1.3) — a structured, non-
224
+ * cursor list query returned exactly `limit` rows with no watermark to page
225
+ * from. That's ambiguous on its own: it might be the whole result set
226
+ * (coincidence), or there might be more rows silently cut off. Combined
227
+ * with the engine's default order (`created_at ASC` — oldest first,
228
+ * core/src/notes.ts) this is the concrete failure mode: a bare `GET
229
+ * /api/notes` on a >limit-note vault returns the OLDEST rows with zero
230
+ * signal that the NEWEST ones didn't make it. `?cursor=` (bootstrap or
231
+ * watermark) sidesteps this entirely — the warning only fires in its
232
+ * absence.
233
+ */
234
+ export function truncatedResultsWarning(limit: number): QueryWarning {
235
+ return {
236
+ code: "truncated",
237
+ message: `${limit} = limit rows returned; there may be more. Pass ?cursor= to page, or sort=desc for newest-first.`,
238
+ limit,
239
+ };
240
+ }
241
+
222
242
  /**
223
243
  * Cheap zero-result search suggestion (vault#551 WS2B — mirrors the tag
224
244
  * `did_you_mean` above via the SAME `suggestSimilarTag` scorer, just over a
package/core/src/types.ts CHANGED
@@ -31,6 +31,13 @@ export interface Note {
31
31
  * `core/src/portable-md.ts:supportsInlineFrontmatter`.
32
32
  */
33
33
  extension?: string;
34
+ /**
35
+ * Always present on notes read back from the store — NULL/`"{}"`/
36
+ * unparseable metadata all collapse to `{}` rather than an absent key
37
+ * (mirrors `tags`, which is likewise always `[]`). Optional here only so
38
+ * write-input shapes (`createNote`/`updateNote` options) can share this
39
+ * type without forcing every caller to pass an empty object.
40
+ */
34
41
  metadata?: Record<string, unknown>;
35
42
  createdAt: string; // ISO-8601
36
43
  updatedAt?: string;
@@ -352,6 +359,7 @@ export interface NoteIndex {
352
359
  lastUpdatedBy?: string | null;
353
360
  lastUpdatedVia?: string | null;
354
361
  tags?: string[];
362
+ /** Always present (`{}` when empty) — see `Note.metadata`. */
355
363
  metadata?: Record<string, unknown>;
356
364
  byteSize: number;
357
365
  preview: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.2",
3
+ "version": "0.7.3-rc.1",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/auth.ts CHANGED
@@ -280,14 +280,20 @@ export function isMethodAllowed(method: string, permission: TokenPermission): bo
280
280
  return READ_METHODS.has(method);
281
281
  }
282
282
 
283
+ // RFC 7235: the auth-scheme token is case-insensitive (`Bearer`/`bearer`/
284
+ // `BEARER` are all the same scheme) — only the token68/credentials that
285
+ // follow are case-sensitive. `\s+` (not a single literal space) tolerates
286
+ // the rare client that sends more than one space after the scheme.
287
+ const BEARER_PREFIX = /^Bearer\s+/i;
288
+
283
289
  /**
284
290
  * Extract API key/token from request.
285
291
  * Priority: Authorization header → X-API-Key header → ?key= query param.
286
292
  */
287
293
  export function extractApiKey(req: Request): string | null {
288
294
  const authHeader = req.headers.get("authorization");
289
- if (authHeader?.startsWith("Bearer ")) {
290
- return authHeader.slice(7);
295
+ if (authHeader && BEARER_PREFIX.test(authHeader)) {
296
+ return authHeader.replace(BEARER_PREFIX, "");
291
297
  }
292
298
  const xApiKey = req.headers.get("x-api-key");
293
299
  if (xApiKey) return xApiKey;
@@ -248,6 +248,48 @@ describe("contract: honest queries — warnings channel (#550)", () => {
248
248
  });
249
249
  });
250
250
 
251
+ describe("contract: truncation-honesty warning (V1.3)", () => {
252
+ it("a structured non-cursor list that hits its limit carries a `truncated` warning", async () => {
253
+ for (let i = 0; i < 3; i++) await store.createNote(`note ${i}`);
254
+ const res = await getNotes("limit=2");
255
+ expect(res.status).toBe(200);
256
+ const body: any[] = await res.json();
257
+ expect(body.length).toBe(2); // the limit itself is unaffected — warning only
258
+ const warnings = decodeWarningsHeader(res);
259
+ expect(warnings).not.toBeNull();
260
+ const truncated = warnings!.find((w) => w.code === "truncated");
261
+ expect(truncated).toBeDefined();
262
+ expect(truncated.limit).toBe(2);
263
+ });
264
+
265
+ it("an under-limit list carries no `truncated` warning", async () => {
266
+ await store.createNote("only one");
267
+ const res = await getNotes("limit=50");
268
+ expect(res.status).toBe(200);
269
+ const warnings = decodeWarningsHeader(res);
270
+ expect(warnings?.some((w) => w.code === "truncated") ?? false).toBe(false);
271
+ });
272
+
273
+ it("cursor mode never carries a `truncated` warning — `next_cursor` is already the honest signal", async () => {
274
+ for (let i = 0; i < 3; i++) await store.createNote(`note ${i}`);
275
+ const res = await getNotes("limit=2&cursor=");
276
+ expect(res.status).toBe(200);
277
+ const body: any = await res.json();
278
+ expect(body.notes.length).toBe(2);
279
+ expect(typeof body.next_cursor).toBe("string");
280
+ expect((body.warnings ?? []).some((w: any) => w.code === "truncated")).toBe(false);
281
+ });
282
+
283
+ it("MCP query-notes mirrors the same truncated warning on a limit-hit non-cursor list", async () => {
284
+ for (let i = 0; i < 3; i++) await store.createNote(`mcp note ${i}`);
285
+ const tools = generateMcpTools(store);
286
+ const query = tools.find((t) => t.name === "query-notes")!;
287
+ const result = (await query.execute({ limit: 2 })) as any;
288
+ expect(result.warnings).toBeDefined();
289
+ expect(result.warnings.some((w: any) => w.code === "truncated" && w.limit === 2)).toBe(true);
290
+ });
291
+ });
292
+
251
293
  describe("contract: honest queries — cursor bootstrap (#550, the P1)", () => {
252
294
  it("an empty cursor (`?cursor=`) engages the {notes, next_cursor} envelope on the VERY FIRST call, and the returned next_cursor sees only notes written after it", async () => {
253
295
  const n1 = await store.createNote("existing note");
@@ -410,3 +452,49 @@ describe("contract: honest queries — find-path hydration (#550, additive)", ()
410
452
  ]);
411
453
  });
412
454
  });
455
+
456
+ describe("contract: metadata always present on the wire (V1.1)", () => {
457
+ it("GET /api/notes?id= on a metadata-less note carries `metadata: {}`, not an absent key", async () => {
458
+ const note = await store.createNote("No metadata here");
459
+ const res = await getNotes(`id=${note.id}`);
460
+ expect(res.status).toBe(200);
461
+ const body: any = await res.json();
462
+ expect(body).toHaveProperty("metadata");
463
+ expect(body.metadata).toEqual({});
464
+ // `tags` has always been unconditionally present — confirm it still is.
465
+ expect(body.tags).toEqual([]);
466
+ });
467
+
468
+ it("GET /api/notes (list) carries `metadata: {}` on metadata-less entries", async () => {
469
+ await store.createNote("List entry, no metadata");
470
+ const res = await getNotes("");
471
+ expect(res.status).toBe(200);
472
+ const body: any = await res.json();
473
+ expect(Array.isArray(body)).toBe(true);
474
+ expect(body.length).toBeGreaterThan(0);
475
+ for (const entry of body) {
476
+ expect(entry).toHaveProperty("metadata");
477
+ expect(entry.metadata).toEqual({});
478
+ }
479
+ });
480
+
481
+ it("GET /api/notes?search= carries `metadata: {}` on metadata-less results", async () => {
482
+ await store.createNote("Findable via search, no metadata");
483
+ const res = await getNotes("search=Findable");
484
+ expect(res.status).toBe(200);
485
+ const body: any = await res.json();
486
+ expect(Array.isArray(body)).toBe(true);
487
+ expect(body.length).toBeGreaterThan(0);
488
+ for (const entry of body) {
489
+ expect(entry).toHaveProperty("metadata");
490
+ expect(entry.metadata).toEqual({});
491
+ }
492
+ });
493
+
494
+ it("a note WITH real metadata is unaffected — the fix only changes the empty case", async () => {
495
+ const note = await store.createNote("Has metadata", { metadata: { source: "import" } });
496
+ const res = await getNotes(`id=${note.id}`);
497
+ const body: any = await res.json();
498
+ expect(body.metadata).toEqual({ source: "import" });
499
+ });
500
+ });
@@ -305,6 +305,32 @@ describe("contract: search — escaping edge cases, tag-scope, warnings (#551)",
305
305
  expect(warnings!.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
306
306
  });
307
307
 
308
+ it("offset alongside search is a no-op that surfaces an ignored_param warning, results unchanged (V1.2)", async () => {
309
+ await store.createNote("findable one", { tags: ["probe"] });
310
+ await store.createNote("findable two", { tags: ["probe"] });
311
+
312
+ const plain = await search("search=findable&tag=probe");
313
+ const withOffset = await search("search=findable&tag=probe&offset=1");
314
+ expect(withOffset.status).toBe(200);
315
+
316
+ const plainBody = await bodyOf(plain);
317
+ const offsetBody = await bodyOf(withOffset);
318
+ // Page 1 either way — offset has no effect on FTS5 relevance ordering.
319
+ expect(offsetBody.map((n: any) => n.id).sort()).toEqual(plainBody.map((n: any) => n.id).sort());
320
+
321
+ const warnings = decodeWarningsHeader(withOffset);
322
+ expect(warnings).not.toBeNull();
323
+ expect(warnings!.some((w: any) => w.code === "ignored_param" && w.param === "offset")).toBe(true);
324
+
325
+ // No search → no warning (offset only means nothing UNDER search; the
326
+ // structured-query path honors it for real).
327
+ const structuredNoWarning = await search("tag=probe&offset=1");
328
+ const structuredWarnings = decodeWarningsHeader(structuredNoWarning);
329
+ expect(
330
+ structuredWarnings?.some((w: any) => w.code === "ignored_param" && w.param === "offset") ?? false,
331
+ ).toBe(false);
332
+ });
333
+
308
334
  it("an unrecognized search_mode value is a structured 400 invalid_query", async () => {
309
335
  const res = await search("search=widgets&search_mode=bogus");
310
336
  expect(res.status).toBe(400);
@@ -460,6 +486,21 @@ describe("contract: search — MCP parity (#551)", () => {
460
486
  expect(result.warnings.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
461
487
  });
462
488
 
489
+ it("query-notes: offset alongside search is a no-op that surfaces an ignored_param warning (V1.2)", async () => {
490
+ await store.createNote("mcp findable", { tags: ["probe"] });
491
+ const tools = generateMcpTools(store);
492
+ const query = tools.find((t) => t.name === "query-notes")!;
493
+ const result = (await query.execute({ search: "findable", offset: 5 })) as any;
494
+ expect(result.warnings).toBeDefined();
495
+ expect(result.warnings.some((w: any) => w.code === "ignored_param" && w.param === "offset")).toBe(true);
496
+
497
+ // Structured query (no search) — offset is honored for real, no warning.
498
+ const structured = (await query.execute({ tag: "probe", offset: 0 })) as any;
499
+ expect(
500
+ (structured.warnings ?? []).some((w: any) => w.code === "ignored_param" && w.param === "offset"),
501
+ ).toBe(false);
502
+ });
503
+
463
504
  it("query-notes: search_mode:\"advanced\" + malformed syntax throws a structured invalid_search_syntax error", async () => {
464
505
  const tools = generateMcpTools(store);
465
506
  const query = tools.find((t) => t.name === "query-notes")!;
package/src/routes.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  collectUnknownTagWarnings,
18
18
  emptySearchWarning,
19
19
  ignoredParamWarning,
20
+ truncatedResultsWarning,
20
21
  computeSearchDidYouMean,
21
22
  searchDidYouMeanWarning,
22
23
  type QueryWarning,
@@ -1254,6 +1255,23 @@ async function handleNotesInner(
1254
1255
  const sort = (parseQuery(url, "sort") as "asc" | "desc" | null) ?? undefined;
1255
1256
 
1256
1257
  const searchWarnings: QueryWarning[] = [];
1258
+ // `offset` under full-text search (vault contracts-brief V1.2):
1259
+ // `searchNotes` has no offset parameter at all — FTS5 ranks by
1260
+ // relevance, not a stable row order, so paging by offset over it
1261
+ // is unsound. Rather than silently return page 1 with no signal
1262
+ // (the pre-existing behavior — `?search=x&offset=50` == `?search=x`),
1263
+ // flag it. Presence check (not truthiness) so `offset=0` — a caller
1264
+ // being explicit about "start" — still warns; it's equally
1265
+ // meaningless here. The structured-query path (below) has real
1266
+ // offset support and is unaffected.
1267
+ if (parseQuery(url, "offset") !== null) {
1268
+ searchWarnings.push(
1269
+ ignoredParamWarning(
1270
+ "offset",
1271
+ "full-text search has no stable row order to offset into — results are always page 1, ranked by relevance",
1272
+ ),
1273
+ );
1274
+ }
1257
1275
  let rawResults: Note[];
1258
1276
  // "Only whitespace/quotes" (vault#551 edge case) — short-circuit
1259
1277
  // BEFORE ever calling FTS5 rather than risk a syntax error (or a
@@ -1558,6 +1576,15 @@ async function handleNotesInner(
1558
1576
  }
1559
1577
  throw e;
1560
1578
  }
1579
+ // Truncation-honesty (vault contracts-brief V1.3): captured BEFORE
1580
+ // near/tag-scope filtering, which can only shrink the set further —
1581
+ // this reflects whether the underlying store query actually hit its
1582
+ // `limit` (i.e. there may be more rows the caller never saw). Cursor
1583
+ // mode is exempt — it already carries `next_cursor` as the honest
1584
+ // "more may follow" signal, so a second warning would be redundant.
1585
+ if (!cursorMode && results.length === queryOpts.limit) {
1586
+ queryWarnings.push(truncatedResultsWarning(queryOpts.limit));
1587
+ }
1561
1588
 
1562
1589
  // Near-scope filter (graph neighborhood)
1563
1590
  const nearNoteId = parseQuery(url, "near[note_id]");
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, test, expect, beforeEach } from "bun:test";
10
- import { resolveScribeUrl, clearScribeUrlCache } from "./scribe-discovery.ts";
10
+ import { resolveScribeUrl, getCachedScribeUrl, clearScribeUrlCache } from "./scribe-discovery.ts";
11
11
 
12
12
  function mkManifest(services: Array<{ name: string; port: number; origin?: string }>): typeof import("./services-manifest.ts").readManifest {
13
13
  return () => ({
@@ -75,3 +75,78 @@ describe("resolveScribeUrl", () => {
75
75
  expect(resolveScribeUrl(env, manifest)).toBe("http://127.0.0.1:1943");
76
76
  });
77
77
  });
78
+
79
+ describe("getCachedScribeUrl (vault contracts-brief V1.5 — TTL, not process-lifetime)", () => {
80
+ // A manifest fn that counts calls, so tests can prove whether the cache
81
+ // actually skipped a re-read (rather than just asserting the RESULT,
82
+ // which would pass even if the cache silently didn't cache at all).
83
+ function mkCountingManifest(port: number): { impl: typeof import("./services-manifest.ts").readManifest; calls: () => number } {
84
+ let calls = 0;
85
+ const impl = (() => {
86
+ calls++;
87
+ return {
88
+ services: [
89
+ { name: "parachute-scribe", port, paths: ["/parachute-scribe"], health: "/health", version: "0.0.0-test" },
90
+ ],
91
+ };
92
+ }) as unknown as typeof import("./services-manifest.ts").readManifest;
93
+ return { impl, calls: () => calls };
94
+ }
95
+
96
+ test("within the TTL window, repeated calls reuse the cached value (manifest read once)", () => {
97
+ const env = {} as NodeJS.ProcessEnv;
98
+ const { impl, calls } = mkCountingManifest(1943);
99
+ let t = 1_000_000;
100
+ const now = () => t;
101
+
102
+ expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
103
+ t += 1_000; // well inside the 30s TTL
104
+ expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
105
+ expect(calls()).toBe(1);
106
+ });
107
+
108
+ test("after the TTL expires, the next call re-resolves — a scribe that appeared mid-process is picked up without a restart", () => {
109
+ const env = {} as NodeJS.ProcessEnv;
110
+ // First manifest: no scribe registered yet.
111
+ let manifestCalls = 0;
112
+ let portToReturn: number | null = null;
113
+ const impl = (() => {
114
+ manifestCalls++;
115
+ return {
116
+ services: portToReturn === null
117
+ ? []
118
+ : [{ name: "parachute-scribe", port: portToReturn, paths: ["/parachute-scribe"], health: "/health", version: "0.0.0-test" }],
119
+ };
120
+ }) as unknown as typeof import("./services-manifest.ts").readManifest;
121
+ let t = 1_000_000;
122
+ const now = () => t;
123
+
124
+ // Nothing registered yet — vault boots before scribe.
125
+ expect(getCachedScribeUrl(env, impl, console, now)).toBeUndefined();
126
+
127
+ // Scribe installs and registers itself mid-process — still within TTL,
128
+ // so the stale "undefined" is what a caller sees (this is the bound the
129
+ // TTL accepts, not the bug being fixed).
130
+ portToReturn = 1943;
131
+ t += 1_000;
132
+ expect(getCachedScribeUrl(env, impl, console, now)).toBeUndefined();
133
+ expect(manifestCalls).toBe(1); // still cached — didn't re-read
134
+
135
+ // TTL elapses — next probe re-reads and picks up the now-live scribe,
136
+ // with no vault restart in between.
137
+ t += 30_000;
138
+ expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
139
+ expect(manifestCalls).toBe(2);
140
+ });
141
+
142
+ test("clearScribeUrlCache forces a fresh resolve even within the TTL window", () => {
143
+ const env = {} as NodeJS.ProcessEnv;
144
+ const { impl, calls } = mkCountingManifest(1943);
145
+ const now = () => 1_000_000;
146
+
147
+ expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
148
+ clearScribeUrlCache();
149
+ expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
150
+ expect(calls()).toBe(2);
151
+ });
152
+ });
@@ -68,24 +68,43 @@ export function resolveScribeUrl(
68
68
  }
69
69
 
70
70
  /**
71
- * Process-lifetime cache. Computed at first call (typically during server
72
- * boot), reused for every subsequent transcription request. Operators who
73
- * change the scribe URL via `services.json` (re-install of scribe with a
74
- * different port) need to restart vault; we deliberately don't watch the
75
- * file because the v0.6 deploy model has a single restart-on-change story.
71
+ * Short-TTL cache (vault contracts-brief V1.5 was process-lifetime).
72
+ * `resolveScribeUrl` is just an env read + a `services.json` stat/read, cheap
73
+ * enough to redo on this cadence but the transcription capability probe
74
+ * (`GET /api/vault`, `src/transcription/capability.ts`) can be hit often
75
+ * enough that re-reading the manifest on EVERY call is wasteful. A TTL
76
+ * splits the difference: scribe installing/moving/starting mid-process (the
77
+ * real deploy sequencing — hub can bring scribe up after vault) is picked up
78
+ * within `SCRIBE_URL_CACHE_TTL_MS` instead of requiring a vault restart,
79
+ * while a steady-state deployment still avoids a filesystem read per
80
+ * request. Previously this was a `let ... = null` sentinel computed once at
81
+ * first call and reused for the rest of the process — that's the staleness
82
+ * this replaces.
76
83
  *
77
84
  * Tests should pass an explicit `env` + `readManifestImpl` to `resolveScribeUrl`
78
- * directly to bypass the cache.
85
+ * directly to bypass the cache, or use the `now` injection seam here to
86
+ * simulate TTL expiry without real sleeps.
79
87
  */
88
+ const SCRIBE_URL_CACHE_TTL_MS = 30_000;
89
+
80
90
  let cachedScribeUrl: string | undefined | null = null;
91
+ let cachedAt = 0;
81
92
 
82
- export function getCachedScribeUrl(): string | undefined {
83
- if (cachedScribeUrl === null) {
84
- cachedScribeUrl = resolveScribeUrl();
93
+ export function getCachedScribeUrl(
94
+ env: NodeJS.ProcessEnv = process.env,
95
+ readManifestImpl: typeof readManifest = readManifest,
96
+ logger: { warn?: (...args: unknown[]) => void } = console,
97
+ now: () => number = Date.now,
98
+ ): string | undefined {
99
+ const t = now();
100
+ if (cachedScribeUrl === null || t - cachedAt >= SCRIBE_URL_CACHE_TTL_MS) {
101
+ cachedScribeUrl = resolveScribeUrl(env, readManifestImpl, logger);
102
+ cachedAt = t;
85
103
  }
86
104
  return cachedScribeUrl;
87
105
  }
88
106
 
89
107
  export function clearScribeUrlCache(): void {
90
108
  cachedScribeUrl = null;
109
+ cachedAt = 0;
91
110
  }
package/src/vault.test.ts CHANGED
@@ -272,9 +272,9 @@ describe("metadata", async () => {
272
272
  expect(published[0].content).toBe("Published");
273
273
  });
274
274
 
275
- test("notes without metadata return undefined metadata", async () => {
275
+ test("notes without metadata return metadata: {} (vault V1.1 — not an absent key)", async () => {
276
276
  const note = await store.createNote("Plain note");
277
- expect(note.metadata).toBeUndefined();
277
+ expect(note.metadata).toEqual({});
278
278
  });
279
279
 
280
280
  test("creates link with metadata", async () => {
@@ -7602,6 +7602,31 @@ describe("extractApiKey", () => {
7602
7602
  expect(extractApiKey(req)).toBe("pvt_abc123");
7603
7603
  });
7604
7604
 
7605
+ // RFC 7235: the auth-scheme token is case-insensitive (V1.4). A client
7606
+ // sending `bearer`/`BEARER`/`BeArEr` must authenticate identically to
7607
+ // one sending the canonical `Bearer` — only the credentials that follow
7608
+ // the scheme are case-sensitive, and those come through verbatim.
7609
+ test("extracts from a lowercase `bearer` scheme (RFC 7235 case-insensitivity)", () => {
7610
+ const req = new Request("http://localhost/api/notes", {
7611
+ headers: { Authorization: "bearer pvt_abc123" },
7612
+ });
7613
+ expect(extractApiKey(req)).toBe("pvt_abc123");
7614
+ });
7615
+
7616
+ test("extracts from a mixed-case `BeArEr` scheme", () => {
7617
+ const req = new Request("http://localhost/api/notes", {
7618
+ headers: { Authorization: "BeArEr pvt_abc123" },
7619
+ });
7620
+ expect(extractApiKey(req)).toBe("pvt_abc123");
7621
+ });
7622
+
7623
+ test("the token itself stays case-sensitive — only the scheme is folded", () => {
7624
+ const req = new Request("http://localhost/api/notes", {
7625
+ headers: { Authorization: "bearer PvT_MixedCase123" },
7626
+ });
7627
+ expect(extractApiKey(req)).toBe("PvT_MixedCase123");
7628
+ });
7629
+
7605
7630
  test("extracts from X-API-Key header", () => {
7606
7631
  const req = new Request("http://localhost/api/notes", {
7607
7632
  headers: { "X-API-Key": "pvk_xyz789" },