@openparachute/vault 0.7.6 → 0.7.7

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 (54) hide show
  1. package/README.md +16 -16
  2. package/core/src/attachment/policy.test.ts +7 -0
  3. package/core/src/attachment/policy.ts +9 -0
  4. package/core/src/attachment-tickets-tool.test.ts +15 -0
  5. package/core/src/conformance.test.ts +78 -0
  6. package/core/src/conformance.ts +34 -5
  7. package/core/src/connection-pragmas.test.ts +27 -1
  8. package/core/src/core.test.ts +88 -3
  9. package/core/src/cursor.ts +2 -0
  10. package/core/src/do-param-cap.test.ts +167 -0
  11. package/core/src/lede.test.ts +60 -0
  12. package/core/src/mcp-manifest.ts +15 -1
  13. package/core/src/mcp.ts +37 -5
  14. package/core/src/notes.ts +120 -48
  15. package/core/src/query-operators.ts +87 -5
  16. package/core/src/query-warnings.ts +11 -3
  17. package/core/src/schema.ts +32 -0
  18. package/core/src/seed-packs.ts +74 -5
  19. package/core/src/sql-in.ts +32 -2
  20. package/core/src/store.ts +16 -49
  21. package/core/src/test-preload.ts +48 -3
  22. package/core/src/types.ts +13 -1
  23. package/core/src/wikilinks.test.ts +57 -0
  24. package/core/src/wikilinks.ts +63 -29
  25. package/package.json +1 -1
  26. package/src/attachment-tickets.test.ts +62 -0
  27. package/src/attachment-tickets.ts +2 -2
  28. package/src/cli.ts +36 -8
  29. package/src/config.ts +34 -1
  30. package/src/contract-honest-queries.test.ts +33 -1
  31. package/src/contract-search.test.ts +47 -0
  32. package/src/embedding/select.ts +16 -3
  33. package/src/live-match.test.ts +8 -0
  34. package/src/live-match.ts +15 -0
  35. package/src/mcp-http.test.ts +12 -0
  36. package/src/mcp-http.ts +1 -0
  37. package/src/mcp-tools.ts +51 -17
  38. package/src/mirror-routes.test.ts +22 -31
  39. package/src/onboarding-seed.test.ts +68 -0
  40. package/src/routes.ts +88 -25
  41. package/src/routing.test.ts +24 -0
  42. package/src/routing.ts +2 -0
  43. package/src/subscriptions.ts +18 -2
  44. package/src/tag-scope-note-tags.test.ts +476 -0
  45. package/src/tag-scope.ts +73 -5
  46. package/src/test-home-isolation.test.ts +137 -0
  47. package/src/test-support/spawn.ts +12 -0
  48. package/src/transcription/download.test.ts +187 -1
  49. package/src/transcription/download.ts +149 -2
  50. package/src/transcription/install-python.test.ts +23 -2
  51. package/src/transcription/install-python.ts +13 -4
  52. package/src/vault.test.ts +39 -4
  53. package/src/version.test.ts +8 -0
  54. package/src/ws-server.ts +12 -2
package/src/routes.ts CHANGED
@@ -68,6 +68,8 @@ import {
68
68
  filterNotesByTagScope,
69
69
  noteWithinTagScope,
70
70
  scrubIndexedFieldConflictError,
71
+ scrubNotesTagsByScope,
72
+ scrubNoteTagsByScope,
71
73
  scrubParentCycleError,
72
74
  scrubReferencingTagsByScope,
73
75
  scrubTagFieldViolationsByScope,
@@ -804,8 +806,7 @@ function parseMetadataJsonAlias(url: URL): {
804
806
 
805
807
  /**
806
808
  * Parse + validate the `?expand=` tag-expansion axis (vault tag `expand` axis).
807
- * Shared by `parseNotesQueryOpts` (structured + subscribe) AND the full-text
808
- * search branch of `handleNotes` (which bypasses `parseNotesQueryOpts`), so the
809
+ * Shared by `parseNotesQueryOpts` (structured, subscribe, and search) so the
809
810
  * enum lives in exactly one place and `GET /notes?search=...&expand=bogus` is
810
811
  * validated identically to the structured path.
811
812
  *
@@ -875,8 +876,10 @@ function parseSearchModeParam(url: URL): { mode?: SearchMode; error?: Response }
875
876
  * Factored out of `handleNotesInner`'s structured-query branch so the
876
877
  * `/subscribe` route evaluates the SAME predicate the snapshot query does —
877
878
  * predicate parity by construction, not copy-paste. `handleNotesInner` keeps
878
- * its own inline parsing for the single-note (`id`) and full-text (`search`)
879
- * branches; this helper covers the structured-query shape both endpoints share.
879
+ * its own inline parsing for the single-note (`id`) branch; the full-text
880
+ * (`search`) branch now reuses this helper for every structured filter
881
+ * (vault#647) so `exclude_tag` / date / path / metadata compose with
882
+ * `?search=` instead of being silently dropped.
880
883
  *
881
884
  * Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
882
885
  * as the inline code did. `hasSearch` is surfaced from the raw `search` param
@@ -942,6 +945,7 @@ export function parseNotesQueryOpts(url: URL): {
942
945
  hasBrokenLinks: parseBoolOrUndef(parseQuery(url, "has_broken_links")),
943
946
  path: parseQuery(url, "path") ?? undefined,
944
947
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
948
+ excludePathPrefix: parseQueryList(url, "exclude_path_prefix"),
945
949
  extension: parseExtensionFilter(url),
946
950
  metadata: bracket.metadata ?? metadataAlias.metadata,
947
951
  // Write-attribution filters (vault#298) — symmetric with the MCP
@@ -1191,6 +1195,10 @@ async function handleNotesInner(
1191
1195
  const contentRange = parseContentRangeQuery(url, includeContent);
1192
1196
  if (contentRange.error) return contentRange.error;
1193
1197
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
1198
+ // Tag-scope (vault#568): the note is visible via at least one
1199
+ // in-scope tag, but its `.tags` array would otherwise name every
1200
+ // out-of-scope co-tag it carries. No-op unscoped.
1201
+ result = scrubNoteTagsByScope(result, tagScope.allowed, tagScope.raw);
1194
1202
  const expand = parseExpandParams(url, db, tagScope);
1195
1203
  if (expand && includeContent && typeof result.content === "string") {
1196
1204
  expand.ctx.expanded.add(note.id);
@@ -1291,6 +1299,10 @@ async function handleNotesInner(
1291
1299
  if (contentRange.error) return contentRange.error;
1292
1300
  const inclMeta = parseIncludeMetadata(url);
1293
1301
  let output: any[] = includeContent ? filtered.map((n) => ({ ...n })) : filtered.map(toNoteIndex);
1302
+ // Tag-scope (vault#568): filter each surviving note's own `.tags`
1303
+ // to the in-scope subset — being visible via one tag must not
1304
+ // disclose the NAMES of its out-of-scope co-tags. No-op unscoped.
1305
+ output = scrubNotesTagsByScope(output, tagScope.allowed, tagScope.raw);
1294
1306
  const expand = parseExpandParams(url, db, tagScope);
1295
1307
  if (expand && includeContent) {
1296
1308
  for (const n of output) expand.ctx.expanded.add(n.id);
@@ -1364,23 +1376,17 @@ async function handleNotesInner(
1364
1376
 
1365
1377
  // Full-text search
1366
1378
  if (search) {
1367
- const searchTags = parseQueryList(url, "tag");
1368
- const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
1369
- // Tag-expansion axis (vault tag `expand` axis). This branch bypasses
1370
- // `parseNotesQueryOpts`, so validate `?expand=` here too otherwise
1371
- // `GET /notes?search=x&expand=bogus` would silently ignore the bad
1372
- // value. The validated mode is threaded into the search tag-narrowing.
1373
- const tagExpand = parseExpandParam(url);
1374
- if (tagExpand.error) return tagExpand.error;
1375
- // `search_mode` (vault#551) — same loud-validation policy as
1376
- // `expand` above; also bypasses `parseNotesQueryOpts` so it needs
1377
- // its own check here.
1379
+ // vault#647: parse the structured filter grammar here too. Pre-fix
1380
+ // this branch bypassed `parseNotesQueryOpts` and only forwarded
1381
+ // tag/limit/expand/mode/sort, so `exclude_tag` and date/path/metadata
1382
+ // were silently dropped a well-formed result set answering a
1383
+ // different question. `search_mode` is still parsed here because it
1384
+ // is search-specific (the helper does not know about it).
1385
+ const parsed = parseNotesQueryOpts(url);
1386
+ if (parsed.error) return parsed.error;
1378
1387
  const searchModeParsed = parseSearchModeParam(url);
1379
1388
  if (searchModeParsed.error) return searchModeParsed.error;
1380
1389
  const mode: SearchMode = searchModeParsed.mode ?? "literal";
1381
- // `sort` under search (vault#551 item 3): omit for FTS5 relevance
1382
- // (default, unchanged); explicit asc/desc switches to created_at.
1383
- const sort = (parseQuery(url, "sort") as "asc" | "desc" | null) ?? undefined;
1384
1390
 
1385
1391
  const searchWarnings: QueryWarning[] = [...nearTextIgnored];
1386
1392
  // `offset` under full-text search (vault contracts-brief V1.2):
@@ -1414,11 +1420,13 @@ async function handleNotesInner(
1414
1420
  // the pre-#551 silent `[]` — caught below and formatted the
1415
1421
  // same way the structured-query path formats a `QueryError`.
1416
1422
  rawResults = await store.searchNotes(search, {
1417
- tags: searchTags,
1418
- limit,
1419
- expand: tagExpand.expand,
1423
+ ...parsed.queryOpts,
1420
1424
  mode,
1421
- sort,
1425
+ // Search has no offset/cursor/orderBy contract (offset is
1426
+ // warned above; cursor is rejected before this branch).
1427
+ offset: undefined,
1428
+ cursor: undefined,
1429
+ orderBy: undefined,
1422
1430
  });
1423
1431
  } catch (e: any) {
1424
1432
  if (e && e.name === "QueryError") {
@@ -1467,6 +1475,9 @@ async function handleNotesInner(
1467
1475
  if (contentRange.error) return contentRange.error;
1468
1476
  const inclMeta = parseIncludeMetadata(url);
1469
1477
  let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
1478
+ // Tag-scope (vault#568): filter each surviving note's own `.tags` to
1479
+ // the in-scope subset — see the structured-query branch below.
1480
+ output = scrubNotesTagsByScope(output, tagScope.allowed, tagScope.raw);
1470
1481
  const expand = parseExpandParams(url, db, tagScope);
1471
1482
  if (expand && includeContent) {
1472
1483
  for (const n of output) expand.ctx.expanded.add(n.id);
@@ -1711,7 +1722,9 @@ async function handleNotesInner(
1711
1722
  // `limit` (i.e. there may be more rows the caller never saw). Cursor
1712
1723
  // mode is exempt — it already carries `next_cursor` as the honest
1713
1724
  // "more may follow" signal, so a second warning would be redundant.
1714
- if (!cursorMode && results.length === queryOpts.limit) {
1725
+ // Explicit offset is also exempt (vault#601): the caller is already
1726
+ // paging, not hitting an accidental full default page.
1727
+ if (!cursorMode && queryOpts.offset === undefined && results.length === queryOpts.limit) {
1715
1728
  queryWarnings.push(truncatedResultsWarning(queryOpts.limit));
1716
1729
  }
1717
1730
 
@@ -1762,6 +1775,15 @@ async function handleNotesInner(
1762
1775
  const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
1763
1776
  const inclMeta = parseIncludeMetadata(url);
1764
1777
  let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
1778
+ // Tag-scope (vault#568): filter each surviving note's own `.tags` to
1779
+ // the in-scope subset. Deliberately applied to `output`, NOT `results`:
1780
+ // `results` still feeds `store.validateNoteAgainstSchemas` below (which
1781
+ // needs the FULL tag set to compute the status that then gets its own
1782
+ // scope scrub) and the graph-edge walk. Because `nodes` (graph format),
1783
+ // `enrichedOut`, and the cursor/plain envelopes are all derived from
1784
+ // `output`, this one call covers every shape this branch can return.
1785
+ // No-op unscoped.
1786
+ output = scrubNotesTagsByScope(output, tagScope.allowed, tagScope.raw);
1765
1787
  const expand = parseExpandParams(url, db, tagScope);
1766
1788
  if (expand && includeContent) {
1767
1789
  for (const n of output) expand.ctx.expanded.add(n.id);
@@ -2298,6 +2320,19 @@ async function handleNotesInner(
2298
2320
  let out: any = validated;
2299
2321
  if (warnings && warnings.length > 0) out = { ...out, warnings };
2300
2322
  if (existed !== undefined) out = { ...out, existed };
2323
+ // Tag-scope (vault#568): a create response echoes the STORED note,
2324
+ // and under `if_exists: ignore|update|replace` that's a note that
2325
+ // already existed with tags this token never supplied. Without the
2326
+ // scrub a scoped caller could recover any co-tagged note's full tag
2327
+ // set through the write door. Same treatment on the validation_status
2328
+ // this response carries — the #555 scrub was only wired to the read
2329
+ // paths, so the write door still named out-of-scope schemas.
2330
+ out = scrubNoteTagsByScope(out, tagScope.allowed, tagScope.raw);
2331
+ if (out.validation_status) {
2332
+ const vs = scrubValidationStatusByScope(out.validation_status, tagScope.allowed, tagScope.raw);
2333
+ if (vs === undefined) { const { validation_status: _d, ...rest } = out; out = rest; }
2334
+ else out = { ...out, validation_status: vs };
2335
+ }
2301
2336
  return out;
2302
2337
  });
2303
2338
 
@@ -2538,6 +2573,11 @@ async function handleNotesInner(
2538
2573
  const contentRange = parseContentRangeQuery(url, includeContent);
2539
2574
  if (contentRange.error) return contentRange.error;
2540
2575
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
2576
+ // Tag-scope (vault#568): filter `.tags` to the in-scope subset. Placed
2577
+ // before the validation_status block on purpose — that block reads
2578
+ // `note.tags` (the full set) to COMPUTE the status and then scrubs the
2579
+ // status itself, so the two scrubs are independent.
2580
+ result = scrubNoteTagsByScope(result, tagScope.allowed, tagScope.raw);
2541
2581
  // vault#555 fix 3 — mirror the MCP query-notes fix: attach
2542
2582
  // validation_status on reads too, not just on the one-time create/update
2543
2583
  // write response. See core/src/mcp.ts's query-notes handler for the
@@ -2692,8 +2732,20 @@ async function handleNotesInner(
2692
2732
  }
2693
2733
  const final = await store.getNote(created.id);
2694
2734
  if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
2695
- const validated: any = attachValidationStatus(store, db, final);
2735
+ let validated: any = attachValidationStatus(store, db, final);
2696
2736
  if (createWarnings.length > 0) validated.warnings = createWarnings;
2737
+ // Tag-scope (vault#568): scrub the echoed note so create-then-read
2738
+ // returns the SAME shape. A scoped token may legitimately attach an
2739
+ // out-of-scope co-tag on write (`tagsWithinScope` only requires ONE
2740
+ // in-scope tag), so this isn't a leak of anything it didn't send —
2741
+ // but leaving it unscrubbed would make the write door the one place
2742
+ // the full tag set is observable. Same for validation_status.
2743
+ validated = scrubNoteTagsByScope(validated, tagScope.allowed, tagScope.raw);
2744
+ if (validated.validation_status) {
2745
+ const vs = scrubValidationStatusByScope(validated.validation_status, tagScope.allowed, tagScope.raw);
2746
+ if (vs === undefined) { const { validation_status: _d, ...rest } = validated; validated = rest; }
2747
+ else validated = { ...validated, validation_status: vs };
2748
+ }
2697
2749
  const includeContentResp = body.include_content !== false;
2698
2750
  if (includeContentResp) return json({ ...validated, created: true });
2699
2751
  const lean: any = toNoteIndex(validated);
@@ -3013,7 +3065,18 @@ async function handleNotesInner(
3013
3065
  if (contentChanged) {
3014
3066
  linkWarnings.push(...getContentWikilinkWarnings(db, note.id, updatedNote.content));
3015
3067
  }
3016
- const validated: any = attachValidationStatus(store, db, updatedNote);
3068
+ let validated: any = attachValidationStatus(store, db, updatedNote);
3069
+ // Tag-scope (vault#568): the update response echoes the STORED note, so
3070
+ // without this a scoped caller could recover a co-tagged note's full tag
3071
+ // set with a no-op PATCH — the read-path scrub would be trivially
3072
+ // bypassable. The validation_status scrub rides along for the same
3073
+ // reason (#555 only wired it to the read paths).
3074
+ validated = scrubNoteTagsByScope(validated, tagScope.allowed, tagScope.raw);
3075
+ if (validated.validation_status) {
3076
+ const vs = scrubValidationStatusByScope(validated.validation_status, tagScope.allowed, tagScope.raw);
3077
+ if (vs === undefined) { const { validation_status: _d, ...rest } = validated; validated = rest; }
3078
+ else validated = { ...validated, validation_status: vs };
3079
+ }
3017
3080
  // Echo hydrated links when a link mutation was part of this request,
3018
3081
  // OR the caller explicitly asked for them via `?include_links=true`
3019
3082
  // (vault feedback #8). Previously the update response omitted links
@@ -678,6 +678,30 @@ describe("per-vault routing under /vault/<name>/", () => {
678
678
  expect(res.status).toBe(401);
679
679
  });
680
680
 
681
+ test("authed GET /api/subscribe without Upgrade → 410 SSE_TRANSPORT_REMOVED (vault#543)", async () => {
682
+ createVault("journal");
683
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
684
+ const path = "/vault/journal/api/subscribe";
685
+ const res = await route(
686
+ new Request(`http://localhost:1940${path}`, {
687
+ headers: { authorization: `Bearer ${token}` },
688
+ }),
689
+ path,
690
+ );
691
+ expect(res.status).toBe(410);
692
+ const body = (await res.json()) as { code?: string };
693
+ expect(body.code).toBe("SSE_TRANSPORT_REMOVED");
694
+ });
695
+
696
+ test("anon GET /api/subscribe → 401, not the SSE tombstone (vault#543)", async () => {
697
+ createVault("journal");
698
+ const path = "/vault/journal/api/subscribe";
699
+ const res = await route(new Request(`http://localhost:1940${path}`), path);
700
+ expect(res.status).toBe(401);
701
+ const body = (await res.json()) as { code?: string };
702
+ expect(body.code).not.toBe("SSE_TRANSPORT_REMOVED");
703
+ });
704
+
681
705
  test("/vault/<name>/oauth/* returns 410 Gone (standalone issuer retired — workstream E)", async () => {
682
706
  // The standalone OAuth issuer on vault was removed in vault#366 once hub
683
707
  // became required. The 410 carries a pointer to the protected-resource
package/src/routing.ts CHANGED
@@ -1156,6 +1156,8 @@ export async function route(
1156
1156
  // straggler on a cached pre-WS notes-ui bundle — gets a clean 410 Gone
1157
1157
  // pointing at the WS binding so its consumer degrades to polling gracefully;
1158
1158
  // NEVER a 500 or an unhandled path.
1159
+ // Not method-gated: a POST here is still a straggler on the removed SSE
1160
+ // transport, not a missing verb on a live resource, so 410 (not 405).
1159
1161
  if (apiPath === "/subscribe") {
1160
1162
  return Response.json(
1161
1163
  {
@@ -65,7 +65,7 @@ import type { DeletedNoteRef, HookEvent, NoteHookPayload } from "../core/src/hoo
65
65
  import { defaultHookRegistry } from "../core/src/hooks.ts";
66
66
  import { toNoteIndex } from "../core/src/notes.ts";
67
67
  import { getVaultNameForStore } from "./vault-store.ts";
68
- import { noteWithinTagScope } from "./tag-scope.ts";
68
+ import { noteWithinTagScope, scrubNoteTagsByScope } from "./tag-scope.ts";
69
69
  import type { LiveMatcher } from "./live-match.ts";
70
70
 
71
71
  /** Default per-vault concurrent-subscription cap. Over it → 503. */
@@ -332,7 +332,23 @@ export class SubscriptionManager {
332
332
  if (matches) {
333
333
  // A lean subscription (list view, `include_content=false`) carries the
334
334
  // same `NoteIndex` projection REST lists return — never the full body.
335
- this.emit(sub, "upsert", { note: sub.lean ? toNoteIndex(note) : note });
335
+ //
336
+ // vault#568 — then scrub `.tags` to this subscriber's in-scope subset.
337
+ // A live event is a read: a `mine`-scoped socket watching a co-tagged
338
+ // note must not receive `project-manhattan` in the payload any more
339
+ // than `GET /api/notes/:id` would hand it over. Order matters twice:
340
+ // the matcher and the scope gate above both read the FULL tag set (a
341
+ // live predicate must evaluate against what's actually stored), and
342
+ // the scrub is NON-MUTATING — `note` is ONE payload fanned out to
343
+ // every subscriber in this loop, each with its own allowlist, so
344
+ // mutating it would cross-contaminate the next subscriber's frame.
345
+ this.emit(sub, "upsert", {
346
+ note: scrubNoteTagsByScope(
347
+ sub.lean ? toNoteIndex(note) : note,
348
+ sub.tagScopeAllowed,
349
+ sub.tagScopeRaw,
350
+ ),
351
+ });
336
352
  } else if (event === "updated" && inScope) {
337
353
  // Left the set (predicate no longer true) BUT still within this
338
354
  // token's scope, so the sub could have held it — idempotent remove