@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.3

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 (45) hide show
  1. package/README.md +3 -5
  2. package/core/src/content-range.test.ts +0 -127
  3. package/core/src/content-range.ts +0 -100
  4. package/core/src/core.test.ts +4 -66
  5. package/core/src/expand.ts +3 -11
  6. package/core/src/mcp.ts +6 -577
  7. package/core/src/notes.ts +4 -201
  8. package/core/src/search-fts-v25.test.ts +1 -9
  9. package/core/src/search-query.test.ts +0 -42
  10. package/core/src/search-query.ts +0 -27
  11. package/core/src/seed-packs.test.ts +1 -117
  12. package/core/src/seed-packs.ts +1 -217
  13. package/core/src/types.ts +0 -6
  14. package/core/src/vault-projection.ts +0 -55
  15. package/package.json +1 -1
  16. package/src/add-pack.test.ts +0 -32
  17. package/src/auth-hub-jwt.test.ts +1 -118
  18. package/src/auth.ts +0 -64
  19. package/src/cli.ts +1 -5
  20. package/src/mcp-http.ts +4 -33
  21. package/src/mcp-tools.ts +14 -61
  22. package/src/oauth-discovery.ts +0 -31
  23. package/src/onboarding-seed.test.ts +0 -64
  24. package/src/routes.ts +95 -92
  25. package/src/routing.test.ts +4 -229
  26. package/src/routing.ts +23 -152
  27. package/src/scopes.ts +0 -22
  28. package/src/server.ts +0 -7
  29. package/src/storage.test.ts +1 -200
  30. package/src/transcription-worker.test.ts +0 -151
  31. package/src/transcription-worker.ts +52 -113
  32. package/src/vault.test.ts +11 -48
  33. package/core/src/attachment/bytes-provider.ts +0 -65
  34. package/core/src/attachment/policy.test.ts +0 -66
  35. package/core/src/attachment/policy.ts +0 -131
  36. package/core/src/attachment/tickets.test.ts +0 -45
  37. package/core/src/attachment/tickets.ts +0 -117
  38. package/core/src/attachment-tickets-tool.test.ts +0 -286
  39. package/core/src/display-title.test.ts +0 -190
  40. package/core/src/lede.test.ts +0 -96
  41. package/core/src/search-title-boost.test.ts +0 -125
  42. package/src/attachment-bytes.ts +0 -68
  43. package/src/attachment-tickets.test.ts +0 -475
  44. package/src/attachment-tickets.ts +0 -340
  45. package/src/read-attachment.test.ts +0 -436
@@ -14,13 +14,6 @@
14
14
  * surface (UI) over the vault. NOT default-seeded (ratified 2026-07-02:
15
15
  * Surface Starter is out of the default seed) — added on demand via
16
16
  * `parachute-vault add-pack surface-starter` or a console affordance.
17
- * - `starter-ontology` — four starter **meta tags** (`view`, `archived`,
18
- * `pinned`, `capture`) with constitution-style descriptions, plus seed
19
- * `#view` notes mirroring the app's default pages (All notes, Recent,
20
- * Pinned, Archive). NOT default-seeded (ratified 2026-07-16 design
21
- * dialogue — deliberately opt-in; a fresh-vault materialization change
22
- * needs its own decision) — added on demand via `parachute-vault
23
- * add-pack starter-ontology` or a console affordance.
24
17
  *
25
18
  * Guides are the vault's skill files — the Parachute equivalent of a
26
19
  * `SKILL.md`. They're tagged `#guide` (GUIDE_TAG): notes that teach how this
@@ -805,172 +798,6 @@ export const SURFACE_STARTER_PACK: SeedPack = {
805
798
  ],
806
799
  };
807
800
 
808
- // ---------------------------------------------------------------------------
809
- // `starter-ontology` pack — four starter meta tags (opt-in)
810
- // ---------------------------------------------------------------------------
811
-
812
- /**
813
- * The starter ontology, ratified in the 2026-07-16 design dialogue (Letter 1,
814
- * Q5: "yes — tiny, opinionated, removable"): exactly four tags, no more.
815
- * "Really want to start simple" / "don't be too prescriptive" — the vault
816
- * ships the mechanisms; each parachute writes its own rules on top.
817
- *
818
- * A **meta tag** (the ratified user-facing term for "a tag with a schema")
819
- * is `view` alone here — it carries `fields`. `archived`, `pinned`, and
820
- * `capture` are plain tags: no schema, just a description that teaches the
821
- * convention. Every description below is written as a constitution — it's
822
- * read by both a human deciding whether to use the tag and an agent deciding
823
- * how to behave around it — plain and warm, describing the DEFAULT
824
- * convention rather than dictating one ("different people will draw that
825
- * line differently").
826
- *
827
- * `capture` is NOT redeclared with new text — it's the exact `NOTES_REQUIRED_TAGS`
828
- * entry, reused verbatim, because notes-ui's connect-time schema audit
829
- * compares its `description` byte-for-byte (see the comment on
830
- * `NOTES_REQUIRED_TAGS` above). Applying this pack must never be able to
831
- * drift that string out from under the welcome pack, in either apply order.
832
- *
833
- * `pinned` shipped once before and was dropped as vestigial (PR #547,
834
- * 2026-07-07): it was seeded onto `Welcome` with old sort-first semantics,
835
- * which did nothing visible on a fresh vault (the Notes app had no list
836
- * badge, and Welcome was already first). This is a different tag: the
837
- * ratified semantics are partition-not-sort, it isn't stamped onto any
838
- * existing note, and the pack ships a `Pinned` view that actually surfaces
839
- * it.
840
- */
841
- export const VIEW_TAG: SeedPackTag = {
842
- name: "view",
843
- description:
844
- 'A meta tag — it carries a schema, which is what makes a #view note a saved view rather than just a label. Tag a note #view and its metadata becomes the definition: `query` is the saved query itself (the same JSON shape query-notes accepts — tag, exclude_tags, search, metadata, order_by, ...), and `kind` says how to render the result (list, board, calendar, or gallery; an unrecognized or missing kind should degrade to list rather than fail — a view is never wrong to render as a list). `lane_by` names the metadata field a board\'s lanes come from; `date_field` names the date-typed field a calendar plots against — both only meaningful for their matching `kind`, and unset otherwise. The note\'s body is for people: what this view is for, when to reach for it. Because the definition lives in the vault as an ordinary note, any surface can read it and render the same view, and any agent can write one — "make me a board of my active drafts" is just creating a note. Authoring convention worth keeping: most views should write `exclude_tags: ["archived"]` into their own query rather than leaning on some surface-side default — an Archive view is the deliberate exception.',
845
- fields: {
846
- kind: {
847
- type: "string",
848
- description:
849
- "How to render this view. Unrecognized or absent degrades to list — a view is never wrong to render as a list.",
850
- enum: ["list", "board", "calendar", "gallery"],
851
- default: "list",
852
- },
853
- query: {
854
- type: "string",
855
- description:
856
- "The saved query — a JSON string of query-notes parameters (tag, exclude_tags, search, metadata, order_by, sort, ...). This is what actually determines which notes the view shows; kind only says how to draw them.",
857
- },
858
- lane_by: {
859
- type: "string",
860
- description:
861
- "Board views only: the metadata field whose distinct values become the board's lanes/columns. Unset outside kind: board.",
862
- },
863
- date_field: {
864
- type: "string",
865
- description:
866
- "Calendar views only: the date-typed metadata field the calendar plots notes against. Unset outside kind: calendar.",
867
- },
868
- },
869
- };
870
-
871
- export const ARCHIVED_TAG: SeedPackTag = {
872
- name: "archived",
873
- description:
874
- "A plain tag — no schema, just a convention: this note is out of the flow of the present. Not deleted, not wrong, just done, closed, or set aside for now. The one behavior worth relying on: default views exclude archived notes unless a view's own query says otherwise (an Archive view, for instance, asks for them back on purpose). Archiving a note doesn't touch its content, tags, or links — it only changes whether it shows up by default. What actually gets archived, and when, is yours to decide: a finished project, last month's drafts, a conversation that's resolved. Different people will draw that line differently, and that's fine — the tag only promises the one thing above.",
875
- };
876
-
877
- export const PINNED_TAG: SeedPackTag = {
878
- name: "pinned",
879
- description:
880
- "A plain tag — no schema, just a convention: this note gets surfaced first. Pinning is a partition, not a sort — a view that honors it renders pinned notes as their own small group above everything else, rather than nudging them up the existing order. That also means pinning doesn't fight with however the rest of a view is sorted; it just carves out a \"see this first\" spot above it. There's no built-in limit on how many notes can be pinned, and no rule about what belongs there — a current focus, something you keep needing to find, a note you're mid-thought on. Unpin by removing the tag. Different people, and different views, will use it differently; the tag only promises the placement, not the policy.",
881
- };
882
-
883
- export const VIEWS_PATH_PREFIX = "Views/";
884
- export const ALL_NOTES_VIEW_PATH = `${VIEWS_PATH_PREFIX}All notes`;
885
- export const RECENT_VIEW_PATH = `${VIEWS_PATH_PREFIX}Recent`;
886
- export const PINNED_VIEW_PATH = `${VIEWS_PATH_PREFIX}Pinned`;
887
- export const ARCHIVE_VIEW_PATH = `${VIEWS_PATH_PREFIX}Archive`;
888
-
889
- /**
890
- * Build the `starter-ontology` pack: the four meta tags above, plus four
891
- * seed `#view` notes mirroring the app's default pages — All notes, Recent,
892
- * Pinned, Archive — so "default app pages are shipped views" has its data
893
- * half from day one. Each view's `query` is written out explicitly
894
- * (`exclude_tags: ["archived"]` where it applies) rather than relying on any
895
- * surface-side default, per the authoring convention in `VIEW_TAG`'s own
896
- * description. Kept tiny on purpose — these are starting points, not a
897
- * demonstration of everything the schema can do.
898
- *
899
- * NOT applied by `src/onboarding-seed.ts`'s default materialization — opt-in
900
- * only, via `parachute-vault add-pack starter-ontology` or a console
901
- * affordance. A fresh-vault UX change (making this part of the default seed)
902
- * is a separate decision; this pack existing makes that a one-line flip
903
- * later, deliberately not taken here.
904
- */
905
- export const STARTER_ONTOLOGY_PACK: SeedPack = {
906
- name: "starter-ontology",
907
- description:
908
- "The starter ontology: one meta tag (view, with a schema) and three plain tags (archived, pinned, capture), each with a constitution-style description — plus four seed #view notes (All notes, Recent, Pinned, Archive) mirroring the app's default pages. Opt-in — not seeded by default.",
909
- // `capture` reused verbatim from NOTES_REQUIRED_TAGS (byte-equal to the
910
- // welcome pack's — see the module doc above and NOTES_REQUIRED_TAGS'
911
- // comment). Order matches the constitution ordering, not upsert order.
912
- tags: [VIEW_TAG, ARCHIVED_TAG, PINNED_TAG, ...NOTES_REQUIRED_TAGS],
913
- notes: [
914
- {
915
- path: ALL_NOTES_VIEW_PATH,
916
- tags: ["view"],
917
- content: `# All notes
918
-
919
- Everything in the vault except what's archived. The default view — no tag
920
- filter, no sort override, just the whole thing minus the notes you've set
921
- aside.
922
- `,
923
- metadata: {
924
- kind: "list",
925
- query: JSON.stringify({ exclude_tags: ["archived"] }),
926
- },
927
- },
928
- {
929
- path: RECENT_VIEW_PATH,
930
- tags: ["view"],
931
- content: `# Recent
932
-
933
- The vault, newest edits first. Same notes as [[${ALL_NOTES_VIEW_PATH}]] —
934
- archived excluded — just ordered by when they last changed.
935
- `,
936
- metadata: {
937
- kind: "list",
938
- query: JSON.stringify({
939
- exclude_tags: ["archived"],
940
- order_by: "updated_at",
941
- sort: "desc",
942
- }),
943
- },
944
- },
945
- {
946
- path: PINNED_VIEW_PATH,
947
- tags: ["view"],
948
- content: `# Pinned
949
-
950
- Whatever you've marked #pinned — the notes surfaced first, regardless of how
951
- anything else in the vault is organized.
952
- `,
953
- metadata: {
954
- kind: "list",
955
- query: JSON.stringify({ tag: "pinned" }),
956
- },
957
- },
958
- {
959
- path: ARCHIVE_VIEW_PATH,
960
- tags: ["view"],
961
- content: `# Archive
962
-
963
- Everything tagged #archived — out of the flow of the present, but never
964
- gone. The one view that asks archived notes back on purpose.
965
- `,
966
- metadata: {
967
- kind: "list",
968
- query: JSON.stringify({ tag: "archived" }),
969
- },
970
- },
971
- ],
972
- };
973
-
974
801
  // ---------------------------------------------------------------------------
975
802
  // Registry
976
803
  // ---------------------------------------------------------------------------
@@ -980,7 +807,6 @@ export const SEED_PACK_NAMES = [
980
807
  "welcome",
981
808
  "getting-started",
982
809
  "surface-starter",
983
- "starter-ontology",
984
810
  ] as const;
985
811
 
986
812
  export type SeedPackName = (typeof SEED_PACK_NAMES)[number];
@@ -1009,8 +835,6 @@ export function getSeedPack(
1009
835
  return GETTING_STARTED_PACK;
1010
836
  case "surface-starter":
1011
837
  return SURFACE_STARTER_PACK;
1012
- case "starter-ontology":
1013
- return STARTER_ONTOLOGY_PACK;
1014
838
  default:
1015
839
  return null;
1016
840
  }
@@ -1025,14 +849,6 @@ export interface ApplySeedPackResult {
1025
849
  pack: string;
1026
850
  /** Tag names upserted (upserts are idempotent; always every declared tag). */
1027
851
  tags: string[];
1028
- /**
1029
- * Subset of `tags` whose DESCRIPTION was left untouched this run because it
1030
- * had been hand-edited away from the pack's canonical text (see
1031
- * `applySeedPack`'s description-preservation rule below). `fields` /
1032
- * `parent_names` / `relationships` are still upserted unconditionally for
1033
- * these tags — only the description write was skipped.
1034
- */
1035
- preservedTagDescriptions: string[];
1036
852
  /** Note paths written this run (absent ones). */
1037
853
  seededNotes: string[];
1038
854
  /** Note paths skipped because a note already lives there (idempotency). */
@@ -1045,30 +861,6 @@ export interface ApplySeedPackResult {
1045
861
  * rows — so a re-run can never duplicate, and never clobbers a note the
1046
862
  * operator/AI has since edited or recreated.
1047
863
  *
1048
- * **Tag description preservation** (Aaron-ratified 2026-07-17): a pack writes
1049
- * a tag's `description` only when (a) the tag has no prior description (new
1050
- * tag, or an existing bare row nothing ever described), or (b) the prior
1051
- * description is still byte-identical to the pack's own text — i.e. no one
1052
- * has touched it. The moment a description differs from the pack's current
1053
- * text, it's treated as a deliberate hand edit (a user-tuned "constitution")
1054
- * and is never overwritten by a re-apply; omitting the `description` key from
1055
- * the `upsertTagRecord` patch is what preserves it (the store keeps whatever
1056
- * is already there). `fields` / `parent_names` / `relationships` are NOT
1057
- * given this treatment here — they still upsert unconditionally, same as
1058
- * before this change; those axes are more entangled with schema-validation
1059
- * side effects (indexed-column lifecycle, cross-tag type agreement) to
1060
- * safely split out in this pass. Description-only is the ratified scope.
1061
- *
1062
- * Known, accepted tradeoff: this compares against the pack's CURRENT text
1063
- * only, not any prior canonical version. If a pack's canonical description
1064
- * changes upstream, a vault that never touched it reads as "matches the OLD
1065
- * text, therefore edited" from the new pack's point of view, and keeps the
1066
- * old text too — same outcome as a genuine hand edit. That's fine:
1067
- * constitutions aren't security patches that must propagate; a future
1068
- * doctor-style scan can surface "description drifted from the current pack
1069
- * text" as an informational finding without this applier needing to guess
1070
- * intent. See CHANGELOG.
1071
- *
1072
864
  * Errors propagate — best-effort semantics (seed-must-never-fail-a-create)
1073
865
  * belong to the caller, which knows its failure policy.
1074
866
  *
@@ -1083,7 +875,6 @@ export async function applySeedPack(
1083
875
  const result: ApplySeedPackResult = {
1084
876
  pack: pack.name,
1085
877
  tags: [],
1086
- preservedTagDescriptions: [],
1087
878
  seededNotes: [],
1088
879
  skippedNotes: [],
1089
880
  };
@@ -1091,19 +882,12 @@ export async function applySeedPack(
1091
882
  // Parents first so `parent_names` reads naturally in logs (the store accepts
1092
883
  // forward references, but the order matches the conceptual model).
1093
884
  for (const decl of pack.tags) {
1094
- const existing = await store.getTagRecord(decl.name);
1095
- const priorDescription = existing?.description ?? null;
1096
- const isUserEdited = priorDescription != null && priorDescription !== decl.description;
1097
-
1098
885
  await store.upsertTagRecord(decl.name, {
1099
- // Omitted (not `undefined`-valued) when preserving — `upsertTagRecord`
1100
- // treats an absent `description` key as "keep the current value".
1101
- ...(isUserEdited ? {} : { description: decl.description }),
886
+ description: decl.description,
1102
887
  ...(decl.parent_names ? { parent_names: decl.parent_names } : {}),
1103
888
  ...(decl.fields ? { fields: decl.fields } : {}),
1104
889
  });
1105
890
  result.tags.push(decl.name);
1106
- if (isUserEdited) result.preservedTagDescriptions.push(decl.name);
1107
891
  }
1108
892
 
1109
893
  for (const note of pack.notes) {
package/core/src/types.ts CHANGED
@@ -400,12 +400,6 @@ export interface NoteIndex {
400
400
  metadata?: Record<string, unknown>;
401
401
  byteSize: number;
402
402
  preview: string;
403
- /** Derived from the first non-empty line of content (title axis, ratified
404
- * 2026-07-17) — NEVER stored, computed fresh at read time by
405
- * `computeDisplayTitle`. `null` when content has no non-empty line.
406
- * Surfaces decide how to render a `null` title (e.g. a timestamp/path
407
- * fallback); core just reports the honest content-derived value. */
408
- displayTitle: string | null;
409
403
  /** Opt-in link degree (see `Note.linkCount`). */
410
404
  linkCount?: number;
411
405
  /** Full-text search relevance score (see `Note.score`). Carried onto the
@@ -272,49 +272,6 @@ function sqliteToUserType(t: string): string {
272
272
  return t.toLowerCase();
273
273
  }
274
274
 
275
- // ---------------------------------------------------------------------------
276
- // Attachments orientation block (attachment-tickets design, Wave 0+1)
277
- // ---------------------------------------------------------------------------
278
-
279
- /**
280
- * The Attachments orientation block, rendered into the connect-time
281
- * markdown brief (`projectionToMarkdown`) so every agent learns, every
282
- * session, how to move file bytes into/out of this vault without spending
283
- * its own context on them (bytes never ride MCP either way).
284
- *
285
- * `ticketsEnabled` reflects whether THIS door has wired an
286
- * `AttachmentTicketProvider` (bun: always, as of the Wave 1 PR; cloud: not
287
- * yet — its mirror is a separate PR). `readEnabled` reflects whether it's
288
- * wired an `AttachmentBytesProvider` (bun: always, as of this PR — Wave 2).
289
- * An unwired seam omits BOTH its tool(s) from `tools/list` (see
290
- * `generateMcpTools`'s `attachmentTickets` / `attachmentBytes` opts) AND
291
- * its sentence here — the brief never dangles a pointer at a tool the
292
- * agent can't actually call.
293
- *
294
- * Kept as a single dense paragraph (not its own multi-line list) to stay
295
- * inside the connect-time brief's token budget — see
296
- * `projectionToMarkdown`'s doc comment.
297
- */
298
- export function attachmentsInstructionBlock(opts: { ticketsEnabled: boolean; readEnabled?: boolean }): string {
299
- const sentences: string[] = [
300
- "Notes can carry file attachments (`include_attachments: true` on `query-notes` returns their rows; bytes don't ride MCP tool RESULTS unless you ask for them).",
301
- ];
302
- if (opts.ticketsEnabled) {
303
- sentences.push(
304
- "To move bytes without spending your own context, call `request-attachment-upload` / `request-attachment-download` — each mints a short-lived, single-use URL (with a ready-to-run `curl_example`) your shell spends directly; no MCP session credential is needed to spend it.",
305
- );
306
- }
307
- if (opts.readEnabled) {
308
- sentences.push(
309
- "To read an attachment directly into this conversation, call `read-attachment` — text comes back as a paginated `content` slice, images as a real image you can see, audio/video as a transcript pointer (never raw bytes), and PDF/other binary formats point you at a download ticket instead.",
310
- );
311
- }
312
- sentences.push(
313
- "If your runtime holds this vault's own API token, REST works too: upload = `POST {base}/storage/upload` (multipart `file`, ≤100 MB) then `POST {base}/notes/{id}/attachments` `{path, mimeType, transcribe?}`; download = `GET {base}/storage/{path}` with the same `Authorization: Bearer` (honors a `Range: bytes=a-b` header for partial reads). Audio attached with `transcribe: true` is transcribed automatically.",
314
- );
315
- return sentences.join(" ");
316
- }
317
-
318
275
  // ---------------------------------------------------------------------------
319
276
  // Markdown rendering — for getServerInstruction
320
277
  // ---------------------------------------------------------------------------
@@ -345,13 +302,6 @@ export function projectionToMarkdown(args: {
345
302
  * known and always surfaced. See `chooseHubOrigin` (src/mcp-install.ts).
346
303
  */
347
304
  coordinates?: { hubOrigin: string; hubOriginKnown: boolean };
348
- /**
349
- * Attachments orientation (see `attachmentsInstructionBlock`). Omitted
350
- * defaults to `{ ticketsEnabled: false }` — safe-by-default so a caller
351
- * that hasn't wired ticket support yet (or a test fixture) never
352
- * accidentally advertises tools it can't back.
353
- */
354
- attachments?: { ticketsEnabled: boolean; readEnabled?: boolean };
355
305
  }): string {
356
306
  const { vaultName, description, projection, coordinates } = args;
357
307
  const stats = projection.stats;
@@ -468,11 +418,6 @@ export function projectionToMarkdown(args: {
468
418
  lines.push("");
469
419
  lines.push("If schema or tags change during this session, call `vault-info` to refresh the full projection. Call `list-tags { include_schema: true }` for tag-only details.");
470
420
 
471
- lines.push("");
472
- lines.push("## Attachments");
473
- lines.push("");
474
- lines.push(attachmentsInstructionBlock(args.attachments ?? { ticketsEnabled: false }));
475
-
476
421
  // Scripting pointer block: the connect-time brief used to dead-end on
477
422
  // querying — an agent had no path to "how do I script/automate against this
478
423
  // vault." Point at the guide rather than inlining it, to keep this brief
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.3-rc.13",
3
+ "version": "0.7.3-rc.3",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -140,35 +140,3 @@ describe("add-pack surface-starter", () => {
140
140
  expect(stdout).toContain("~ tag capture (upserted)");
141
141
  });
142
142
  });
143
-
144
- describe("add-pack — tag description preservation on re-apply (Aaron-ratified 2026-07-17)", () => {
145
- test("a hand-edited description survives a re-apply and is reported 'kept', not 'upserted'", () => {
146
- expect(runCli(["create", "packed", "--json"], { PARACHUTE_HOME: home }).exitCode).toBe(0);
147
-
148
- // Simulate an operator/AI hand-editing the capture tag's constitution.
149
- const dbPath = join(home, "vault", "data", "packed", "vault.db");
150
- const editedDescription = "Our house rules for capture — not the default text.";
151
- const writeDb = new Database(dbPath);
152
- writeDb.query("UPDATE tags SET description = ? WHERE name = ?").run(
153
- editedDescription,
154
- "capture",
155
- );
156
- writeDb.close();
157
-
158
- const { exitCode, stdout } = runCli(
159
- ["add-pack", "welcome", "--vault", "packed"],
160
- { PARACHUTE_HOME: home },
161
- );
162
- expect(exitCode).toBe(0);
163
- expect(stdout).toContain("~ tag capture (kept user description)");
164
- // guide wasn't edited, so it still reports the old vocabulary.
165
- expect(stdout).toContain("~ tag guide (upserted)");
166
-
167
- const readDb = new Database(dbPath, { readonly: true });
168
- const row = readDb
169
- .query("SELECT description FROM tags WHERE name = ?")
170
- .get("capture") as { description: string };
171
- readDb.close();
172
- expect(row.description).toBe(editedDescription);
173
- });
174
- });
@@ -28,7 +28,7 @@ import { tmpdir } from "os";
28
28
  import { generateKeyPair, exportJWK, SignJWT } from "jose";
29
29
  import { writeVaultConfig, readVaultConfig } from "./config.ts";
30
30
  import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
31
- import { authenticateVaultRequest, authenticateGlobalRequest, deriveVaultFromToken } from "./auth.ts";
31
+ import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
32
32
  import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
33
33
 
34
34
  interface Keypair {
@@ -705,120 +705,3 @@ describe("pvt_* DROP (vault#282 Stage 2 — unvalidatable)", () => {
705
705
  expect("error" in result).toBe(false);
706
706
  });
707
707
  });
708
-
709
- // ---------------------------------------------------------------------------
710
- // deriveVaultFromToken — the derivation precedence for the canonical root
711
- // `/mcp` endpoint (U1). This function READS a validated token's claims to name
712
- // the target vault; it never authorizes (the router re-dispatches through the
713
- // full per-vault machinery). These cases isolate the three naming sources —
714
- // narrowed scope / `aud=vault.<name>` / single-element `vault_scope` — and the
715
- // fail-closed rules (agree → one name; disagree or none → `not_derivable`).
716
- // The end-to-end routing.test.ts covers the wired behavior; this pins the
717
- // precedence logic directly.
718
- // ---------------------------------------------------------------------------
719
- describe("deriveVaultFromToken — root /mcp vault derivation (U1)", () => {
720
- test("all three sources agree → that vault", async () => {
721
- const token = await signJwt(kp, {
722
- iss: fixture.origin,
723
- aud: "vault.journal",
724
- scope: "vault:journal:write",
725
- vaultScope: ["journal"],
726
- });
727
- expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
728
- });
729
-
730
- test("narrowed scope alone names the vault (non-vault aud, no vault_scope)", async () => {
731
- const token = await signJwt(kp, {
732
- iss: fixture.origin,
733
- aud: "urn:opaque-resource",
734
- scope: "vault:journal:read",
735
- });
736
- expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
737
- });
738
-
739
- test("aud=vault.<name> alone names the vault (broad scope names nothing)", async () => {
740
- const token = await signJwt(kp, {
741
- iss: fixture.origin,
742
- aud: "vault.journal",
743
- scope: "vault:read",
744
- });
745
- expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
746
- });
747
-
748
- test("single-element vault_scope alone names the vault", async () => {
749
- const token = await signJwt(kp, {
750
- iss: fixture.origin,
751
- aud: "urn:opaque-resource",
752
- scope: "vault:read",
753
- vaultScope: ["journal"],
754
- });
755
- expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
756
- });
757
-
758
- test("multi-element vault_scope is NOT a single name → not_derivable (nothing else names)", async () => {
759
- // Phase-2 multi-vault shape: a multi-element vault_scope doesn't name ONE
760
- // vault, so at the single-vault root it can't be the sole source.
761
- const token = await signJwt(kp, {
762
- iss: fixture.origin,
763
- aud: "urn:opaque-resource",
764
- scope: "vault:read",
765
- vaultScope: ["journal", "work"],
766
- });
767
- expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
768
- });
769
-
770
- test("sources disagree (scope vs aud) → not_derivable, never a guess", async () => {
771
- const token = await signJwt(kp, {
772
- iss: fixture.origin,
773
- aud: "vault.work",
774
- scope: "vault:journal:write",
775
- });
776
- expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
777
- });
778
-
779
- test("no source names a vault → not_derivable", async () => {
780
- const token = await signJwt(kp, {
781
- iss: fixture.origin,
782
- aud: "urn:opaque-resource",
783
- scope: "vault:read",
784
- });
785
- expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
786
- });
787
-
788
- test("no bearer → no_bearer", async () => {
789
- const req = new Request("https://vault.test/mcp");
790
- expect(await deriveVaultFromToken(req)).toEqual({ error: "no_bearer" });
791
- });
792
-
793
- test("non-JWT bearer (operator / legacy shape) names no vault → not_derivable", async () => {
794
- // The operator VAULT_AUTH_TOKEN and legacy YAML keys are vault-agnostic —
795
- // they can't route the token-derived root endpoint (they keep working at
796
- // the per-vault URL).
797
- expect(await deriveVaultFromToken(bearer("opaque-operator-secret"))).toEqual({
798
- error: "not_derivable",
799
- });
800
- });
801
-
802
- test("expired JWT → not_derivable (validated with the full trust kernel)", async () => {
803
- const token = await signJwt(kp, {
804
- iss: fixture.origin,
805
- aud: "vault.journal",
806
- scope: "vault:journal:write",
807
- ttlSeconds: -10, // already expired
808
- });
809
- expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
810
- });
811
-
812
- test("revoked JWT → not_derivable (revocation runs in derivation too)", async () => {
813
- const jti = "u1-derive-revoked";
814
- const token = await signJwt(kp, {
815
- iss: fixture.origin,
816
- aud: "vault.journal",
817
- scope: "vault:journal:write",
818
- jti,
819
- });
820
- fixture.setRevoked([jti]);
821
- resetRevocationCache();
822
- expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
823
- });
824
- });
package/src/auth.ts CHANGED
@@ -30,7 +30,6 @@ import {
30
30
  hasScope,
31
31
  hasScopeForVault,
32
32
  legacyPermissionToScopes,
33
- narrowedVaultNames,
34
33
  SCOPE_ADMIN,
35
34
  SCOPE_READ,
36
35
  SCOPE_WRITE,
@@ -713,66 +712,3 @@ export async function authenticateGlobalRequest(
713
712
  }
714
713
  return { error: Response.json({ error: "Unauthorized", message: "Invalid API key" }, { status: 401 }) };
715
714
  }
716
-
717
- /**
718
- * Outcome of deriving a target vault from a request's bearer at the canonical
719
- * root `/mcp` endpoint (U1). `vaultName` on success; a coarse failure `error`
720
- * otherwise. Both failure reasons map to the SAME 401 + root-PRM challenge at
721
- * the router — the distinction exists for logging/tests, never leaks to the
722
- * client. `no_bearer` = no credential presented; `not_derivable` = a credential
723
- * that names no single vault (non-JWT operator/legacy bearer, invalid /
724
- * expired / revoked JWT, or a JWT whose scope / `aud` / `vault_scope` sources
725
- * name zero or conflicting vaults).
726
- */
727
- export type VaultDerivation =
728
- | { vaultName: string }
729
- | { error: "no_bearer" | "not_derivable" };
730
-
731
- /**
732
- * Derive the target vault from a request's bearer token WITHOUT authorizing the
733
- * request. The caller re-dispatches the derived name through the full per-vault
734
- * auth machinery (`authenticateVaultRequest`), which re-validates the token
735
- * WITH the audience pin — derive-then-redispatch, so a bad derivation FAILS the
736
- * inner check rather than bypassing it (defense in depth). This function only
737
- * reads the claims well enough to name the vault; it is never the authorization
738
- * gate.
739
- *
740
- * Only hub-issued JWTs name a vault. The operator `VAULT_AUTH_TOKEN` and legacy
741
- * YAML keys are vault-agnostic (they name no resource — see scopes.ts), so they
742
- * return `not_derivable` here and keep working at the URL-addressed
743
- * `/vault/<name>/*` surface. The JWT is validated with the SAME scope-guard
744
- * trust kernel the per-vault path uses (signature, `iss` pin, `jti` +
745
- * revocation, expiry) but WITHOUT `expectedAudience` — at the root we don't yet
746
- * know which audience to expect; that pin is re-applied by the re-dispatch.
747
- *
748
- * From the validated claims, three independent sources can name a vault:
749
- * 1. a narrowed `vault:<name>:<verb>` scope,
750
- * 2. an `aud` of the form `vault.<name>`,
751
- * 3. a single-element `vault_scope` claim.
752
- * On a hub-minted token these AGREE. We collect every name any source provides
753
- * and require EXACTLY ONE distinct name: zero (nothing named a vault) and
754
- * two-or-more (the sources disagree) both fail closed with `not_derivable`. We
755
- * never pick a winner from a precedence order — an ambiguous or unnamed token
756
- * gets the discovery challenge, not a silent guess.
757
- */
758
- export async function deriveVaultFromToken(req: Request): Promise<VaultDerivation> {
759
- const key = extractApiKey(req);
760
- if (!key) return { error: "no_bearer" };
761
- if (!looksLikeJwt(key)) return { error: "not_derivable" };
762
- let claims;
763
- try {
764
- // No `expectedAudience`: the per-vault trust kernel minus the aud pin
765
- // (which the re-dispatch re-applies). A bad signature / iss / expiry /
766
- // revoked jti throws here → not_derivable → the standard 401 challenge.
767
- claims = await validateHubJwt(key, {});
768
- } catch {
769
- return { error: "not_derivable" };
770
- }
771
- const named = new Set<string>();
772
- for (const name of narrowedVaultNames(claims.scopes)) named.add(name);
773
- const audMatch = claims.aud?.match(/^vault\.(.+)$/);
774
- if (audMatch) named.add(audMatch[1]!);
775
- if (claims.vaultScope.length === 1) named.add(claims.vaultScope[0]!);
776
- if (named.size !== 1) return { error: "not_derivable" };
777
- return { vaultName: [...named][0]! };
778
- }
package/src/cli.ts CHANGED
@@ -4842,11 +4842,7 @@ async function cmdAddPack(args: string[]) {
4842
4842
  console.log(` = note ${path} (already exists — left untouched)`);
4843
4843
  }
4844
4844
  for (const tag of result.tags) {
4845
- if (result.preservedTagDescriptions.includes(tag)) {
4846
- console.log(` ~ tag ${tag} (kept user description)`);
4847
- } else {
4848
- console.log(` ~ tag ${tag} (upserted)`);
4849
- }
4845
+ console.log(` ~ tag ${tag} (upserted)`);
4850
4846
  }
4851
4847
  if (result.seededNotes.length === 0 && result.tags.length === 0) {
4852
4848
  console.log(" Nothing to add — everything was already in place.");