@openparachute/vault 0.7.8 → 0.7.9-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp-tools.ts CHANGED
@@ -18,6 +18,7 @@ import { readVaultConfig, writeVaultConfig } from "./config.ts";
18
18
  import { getVaultStore } from "./vault-store.ts";
19
19
  import { hasScopeForVault, hasMigrateScopeForVault, parseScopes, validateMintedScopes, logStrictBypass } from "./scopes.ts";
20
20
  import type { AuthResult } from "./auth.ts";
21
+ import { refineMcpVia } from "./auth.ts";
21
22
  import {
22
23
  expandTokenTagScope,
23
24
  filterHydratedLinksByTagScope,
@@ -226,14 +227,19 @@ export function generateScopedMcpTools(
226
227
 
227
228
  // Write-attribution (vault#298). Every write through an MCP session arrives
228
229
  // on the `mcp` channel — so we REFINE the auth's base `via` (the generic
229
- // credential class) to `mcp` here, where the path/channel is known. The
230
- // operator bearer keeps `operator` (its credential class IS its channel and
231
- // is more informative than `mcp` for cross-container hub→vault writes); any
232
- // other credential's via becomes `mcp`. `actor` (the principal) passes
233
- // through unchanged.
234
- const writeContext = auth
235
- ? { actor: auth.actor, via: auth.via === "operator" ? "operator" : "mcp" }
236
- : undefined;
230
+ // credential class) to `mcp` here, where the path/channel is known. `actor`
231
+ // (the principal) passes through unchanged.
232
+ //
233
+ // Two classes are kept INSTEAD of `mcp`, because each is strictly more
234
+ // informative than the channel:
235
+ // - `operator` the env-var bearer's credential class IS its channel, and
236
+ // it names cross-container hub→vault writes (vault#298).
237
+ // - `nostr:<pubkey>` — the NIP-98 signer (vault#698). Every agent coming
238
+ // through the hub's `/mcp` door arrives on the `mcp` channel, so `mcp`
239
+ // cannot tell two agents apart; the signing key can. `created_by` is
240
+ // still the shared hub user, so this is the ONLY attribution axis that
241
+ // distinguishes them — flattening it here would reintroduce the bug.
242
+ const writeContext = auth ? { actor: auth.actor, via: refineMcpVia(auth.via) } : undefined;
237
243
 
238
244
  // Migration-bypass (vault#299): a `vault:migrate`-scoped MCP session skips
239
245
  // strict-schema enforcement and logs every bypassed write. Orthogonal to
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The publish-on-merge decision.
3
+ *
4
+ * Release logic that can double-publish or silently drop a release is exactly
5
+ * the kind that should not live untested in a YAML `run:` block. Every case
6
+ * here is one where a wrong answer costs something real. Ported from
7
+ * parachute-app's `scripts/release-plan.test.ts` onto bun:test (this repo's
8
+ * `bun test ./src/` surface).
9
+ */
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import {
13
+ compareVersions,
14
+ decidePublish,
15
+ distTagFor,
16
+ readRegistry,
17
+ unpublishedDrift,
18
+ } from "../scripts/release-plan.ts";
19
+
20
+ describe("distTagFor", () => {
21
+ test("prerelease → rc, release → latest", () => {
22
+ expect(distTagFor("0.7.9-rc.2")).toBe("rc");
23
+ expect(distTagFor("0.7.9")).toBe("latest");
24
+ });
25
+ });
26
+
27
+ describe("compareVersions", () => {
28
+ test("orders patch versions", () => {
29
+ expect(compareVersions("0.7.5", "0.7.4")).toBeGreaterThan(0);
30
+ expect(compareVersions("0.7.4", "0.7.5")).toBeLessThan(0);
31
+ expect(compareVersions("0.7.5", "0.7.5")).toBe(0);
32
+ });
33
+
34
+ test("orders rc chains numerically, not lexically", () => {
35
+ // The lexical trap: "rc.10" < "rc.9" as strings.
36
+ expect(compareVersions("0.7.5-rc.10", "0.7.5-rc.9")).toBeGreaterThan(0);
37
+ expect(compareVersions("0.7.5-rc.5", "0.7.5-rc.6")).toBeLessThan(0);
38
+ });
39
+
40
+ test("a release sorts above its own prereleases", () => {
41
+ expect(compareVersions("0.7.5", "0.7.5-rc.99")).toBeGreaterThan(0);
42
+ });
43
+
44
+ test("major/minor dominate the prerelease suffix", () => {
45
+ expect(compareVersions("0.8.0-rc.1", "0.7.9")).toBeGreaterThan(0);
46
+ });
47
+ });
48
+
49
+ describe("decidePublish", () => {
50
+ test("a fresh version publishes", () => {
51
+ const d = decidePublish("0.7.9-rc.2", {
52
+ versionExists: false,
53
+ currentDistTagVersion: "0.7.9-rc.1",
54
+ });
55
+ expect(d).toMatchObject({ publish: true });
56
+ });
57
+
58
+ test("an already-published version is skipped — the idempotency guarantee", () => {
59
+ const d = decidePublish("0.7.9-rc.1", { versionExists: true });
60
+ expect(d).toMatchObject({ publish: false });
61
+ });
62
+
63
+ test("a never-published package SKIPS on a branch push — a first publish is deliberate", () => {
64
+ // surface#220 / hub#930 / app#189: a 404 package used to read "0.1.0 is
65
+ // not on npm" → should_publish=true, and the OIDC publish 404'd. Vault's
66
+ // inline shell had the same hole: curling /vault/$VERSION 404s for both
67
+ // "version missing" and "package missing".
68
+ const d = decidePublish(
69
+ "0.1.0",
70
+ { versionExists: false, publishedVersions: [] },
71
+ { branch: "main" },
72
+ );
73
+ expect(d).toMatchObject({ publish: false });
74
+ expect("reason" in d && d.reason).toMatch(/first publish is a deliberate act/);
75
+ expect("reason" in d && d.reason).toMatch(/cannot create a package/);
76
+ });
77
+
78
+ test("an rc of a never-published package skips too — it's the package, not the channel", () => {
79
+ const d = decidePublish(
80
+ "0.1.0-rc.1",
81
+ { versionExists: false, publishedVersions: [] },
82
+ { branch: "next" },
83
+ );
84
+ expect(d).toMatchObject({ publish: false });
85
+ expect("reason" in d && d.reason).toMatch(/nothing is published under this name yet/);
86
+ });
87
+
88
+ test("omitted publishedVersions with no dist-tag reads as never-published — skip, don't publish", () => {
89
+ const d = decidePublish("0.1.0-rc.1", { versionExists: false });
90
+ expect(d).toMatchObject({ publish: false });
91
+ expect("reason" in d && d.reason).toMatch(/nothing is published under this name yet/);
92
+ });
93
+
94
+ test("a never-published package on an rc TAG PUSH still tries — a human said release this", () => {
95
+ const d = decidePublish(
96
+ "0.1.0-rc.1",
97
+ { versionExists: false, publishedVersions: [] },
98
+ { isTagPush: true },
99
+ );
100
+ expect(d).toMatchObject({ publish: true });
101
+ expect("reason" in d && d.reason).toMatch(/explicit tag push/);
102
+ });
103
+
104
+ test("a never-published STABLE on a tag push is still refused by the from-main gate", () => {
105
+ const d = decidePublish(
106
+ "0.1.0",
107
+ { versionExists: false, publishedVersions: [] },
108
+ { isTagPush: true },
109
+ );
110
+ expect(d).toMatchObject({ publish: false });
111
+ expect("reason" in d && d.reason).toMatch(/from main only/);
112
+ });
113
+
114
+ test("an existing package is unaffected — one published version is enough", () => {
115
+ const d = decidePublish(
116
+ "0.7.9-rc.2",
117
+ {
118
+ versionExists: false,
119
+ currentDistTagVersion: "0.7.9-rc.1",
120
+ publishedVersions: ["0.7.9-rc.1"],
121
+ },
122
+ { branch: "next" },
123
+ );
124
+ expect(d).toMatchObject({ publish: true });
125
+ expect("reason" in d && d.reason).toMatch(/is not on npm/);
126
+ });
127
+
128
+ test("an unreadable registry is still a REFUSAL, not a never-published skip", () => {
129
+ const ambiguous = decidePublish("0.7.9-rc.1", { ambiguous: true }, { branch: "next" });
130
+ expect(ambiguous).toMatchObject({ refuse: true });
131
+ expect("refuse" in ambiguous && ambiguous.reason).toMatch(/refusing to guess/);
132
+ });
133
+
134
+ test("REFUSES to move a dist-tag backwards — the parallel-merge hazard", () => {
135
+ const d = decidePublish("0.7.5-rc.5", {
136
+ versionExists: false,
137
+ currentDistTagVersion: "0.7.5-rc.6",
138
+ });
139
+ expect(d).toMatchObject({ refuse: true });
140
+ expect("refuse" in d && d.reason).toMatch(/OLDER than the current rc/);
141
+ expect("refuse" in d && d.reason).toMatch(/merged out of version order/);
142
+ });
143
+
144
+ test("rc and latest are tracked independently", () => {
145
+ const d = decidePublish("0.8.0-rc.1", {
146
+ versionExists: false,
147
+ currentDistTagVersion: "0.7.9-rc.5",
148
+ });
149
+ expect(d).toMatchObject({ publish: true });
150
+ });
151
+
152
+ test("an ambiguous registry REFUSES rather than guessing", () => {
153
+ const d = decidePublish("0.7.9", { ambiguous: true }, { branch: "main" });
154
+ expect(d).toMatchObject({ refuse: true });
155
+ expect("refuse" in d && d.reason).toMatch(/refusing to guess/);
156
+ });
157
+
158
+ test("an explicit rc tag push overrides the registry checks — a human said release this rc", () => {
159
+ const d = decidePublish(
160
+ "0.7.0-rc.1",
161
+ {
162
+ versionExists: false,
163
+ currentDistTagVersion: "0.8.0",
164
+ },
165
+ { isTagPush: true },
166
+ );
167
+ expect(d).toMatchObject({ publish: true });
168
+ });
169
+
170
+ test("an rc tag push even overrides ambiguity", () => {
171
+ const d = decidePublish("0.7.9-rc.1", { ambiguous: true }, { isTagPush: true });
172
+ expect(d).toMatchObject({ publish: true });
173
+ });
174
+
175
+ test("next skips a stable version — stables publish from main only", () => {
176
+ const d = decidePublish("0.7.9", { versionExists: false }, { branch: "next" });
177
+ expect(d).toMatchObject({ publish: false });
178
+ expect("reason" in d && d.reason).toMatch(/stable promotions publish from main only/);
179
+ });
180
+
181
+ test("next still publishes an rc", () => {
182
+ const d = decidePublish(
183
+ "0.7.9-rc.1",
184
+ { versionExists: false, currentDistTagVersion: "0.7.8-rc.5" },
185
+ { branch: "next" },
186
+ );
187
+ expect(d).toMatchObject({ publish: true });
188
+ });
189
+
190
+ test("main is unaffected — stable publishes as before", () => {
191
+ const d = decidePublish(
192
+ "0.7.9",
193
+ {
194
+ versionExists: false,
195
+ currentDistTagVersion: "0.7.8",
196
+ publishedVersions: ["0.7.8"],
197
+ },
198
+ { branch: "main" },
199
+ );
200
+ expect(d).toMatchObject({ publish: true });
201
+ });
202
+
203
+ test("a tag push of a stable does NOT override the main-only gate", () => {
204
+ const d = decidePublish(
205
+ "0.7.9",
206
+ { versionExists: false },
207
+ { branch: "next", isTagPush: true },
208
+ );
209
+ expect(d).toMatchObject({ publish: false });
210
+ expect("reason" in d && d.reason).toMatch(/from main only/);
211
+ });
212
+
213
+ test("a stable with no branch is refused — fail closed, don't guess the trigger", () => {
214
+ const d = decidePublish("0.7.9", { versionExists: false });
215
+ expect(d).toMatchObject({ publish: false });
216
+ expect("reason" in d && d.reason).toMatch(/from main only/);
217
+ });
218
+ });
219
+
220
+ describe("readRegistry", () => {
221
+ const json = (body: unknown, status = 200) =>
222
+ Promise.resolve(new Response(JSON.stringify(body), { status }));
223
+
224
+ test("reads existence + the relevant dist-tag", async () => {
225
+ const v = await readRegistry("@openparachute/vault", "0.7.9-rc.2", (() =>
226
+ json({
227
+ versions: { "0.7.9-rc.1": {}, "0.7.9-rc.2": {} },
228
+ "dist-tags": { rc: "0.7.9-rc.2", latest: "0.7.8" },
229
+ })) as unknown as typeof fetch);
230
+ expect(v).toMatchObject({ versionExists: true, currentDistTagVersion: "0.7.9-rc.2" });
231
+ });
232
+
233
+ test("picks the dist-tag matching the version's channel", async () => {
234
+ const v = await readRegistry("@openparachute/vault", "0.7.9", (() =>
235
+ json({
236
+ versions: {},
237
+ "dist-tags": { rc: "0.7.9-rc.2", latest: "0.7.8" },
238
+ })) as unknown as typeof fetch);
239
+ expect(v).toMatchObject({ currentDistTagVersion: "0.7.8" });
240
+ });
241
+
242
+ test("a never-published package is not ambiguous — a 404 is knowledge", async () => {
243
+ const v = await readRegistry("@openparachute/new", "0.1.0", (() =>
244
+ json({}, 404)) as unknown as typeof fetch);
245
+ expect(v).toMatchObject({ versionExists: false, publishedVersions: [] });
246
+ });
247
+
248
+ test("the 404 view composes into a skip — the two halves of surface#220 line up", async () => {
249
+ const v = await readRegistry("@openparachute/new", "0.1.0", (() =>
250
+ json({}, 404)) as unknown as typeof fetch);
251
+ expect("ambiguous" in v).toBe(false);
252
+ if ("ambiguous" in v) return;
253
+ const d = decidePublish("0.1.0", v, { branch: "main" });
254
+ expect(d).toMatchObject({ publish: false });
255
+ expect("reason" in d && d.reason).toMatch(/first publish is a deliberate act/);
256
+ });
257
+
258
+ test("a populated registry returns publishedVersions", async () => {
259
+ const v = await readRegistry("@openparachute/vault", "0.7.9-rc.3", (() =>
260
+ json({
261
+ versions: { "0.7.9-rc.1": {}, "0.7.9-rc.2": {} },
262
+ "dist-tags": { rc: "0.7.9-rc.2", latest: "0.7.8" },
263
+ })) as unknown as typeof fetch);
264
+ expect(v).toMatchObject({
265
+ versionExists: false,
266
+ currentDistTagVersion: "0.7.9-rc.2",
267
+ publishedVersions: ["0.7.9-rc.1", "0.7.9-rc.2"],
268
+ });
269
+ });
270
+
271
+ test("a 5xx is ambiguous", async () => {
272
+ const v = await readRegistry("@openparachute/vault", "1.0.0", (() =>
273
+ json({}, 503)) as unknown as typeof fetch);
274
+ expect(v).toMatchObject({ ambiguous: true });
275
+ });
276
+
277
+ test("a network throw is ambiguous, not a crash", async () => {
278
+ const v = await readRegistry("@openparachute/vault", "1.0.0", (() =>
279
+ Promise.reject(new Error("ECONNRESET"))) as unknown as typeof fetch);
280
+ expect(v).toMatchObject({ ambiguous: true });
281
+ });
282
+ });
283
+
284
+ describe("unpublishedDrift", () => {
285
+ test("no commits → not drifted", () => {
286
+ expect(unpublishedDrift([]).drifted).toBe(false);
287
+ expect(unpublishedDrift(["", " "]).drifted).toBe(false);
288
+ });
289
+
290
+ test("commits → drifted, counted, and LISTED", () => {
291
+ const d = unpublishedDrift(["abc feat: one", "def fix: two"]);
292
+ expect(d.drifted).toBe(true);
293
+ expect(d.count).toBe(2);
294
+ expect(d.summary).toContain("feat: one");
295
+ expect(d.summary).toContain("fix: two");
296
+ expect(d.summary).toMatch(/release PR/i);
297
+ });
298
+
299
+ test("blank lines from git's trailing newline don't inflate the count", () => {
300
+ expect(unpublishedDrift(["abc one", ""]).count).toBe(1);
301
+ });
302
+ });
package/src/routes.ts CHANGED
@@ -352,9 +352,26 @@ function parseLinkCountDirection(url: URL): "both" | "outbound" | "inbound" {
352
352
  return "both";
353
353
  }
354
354
 
355
+ /**
356
+ * Parse a repeatable, comma-list query param (`tag`, `exclude_tag`,
357
+ * `exclude_path_prefix`). Two accepted shapes, and they compose:
358
+ * - `?tag=a,b` (comma-list)
359
+ * - `?tag=a&tag=b` (repeated param)
360
+ * Both yield `["a", "b"]`. This used to be `searchParams.get()` + split,
361
+ * which silently kept only the FIRST occurrence — a caller passing
362
+ * `?tag=a&tag=b` got results filtered by `a` alone, with no signal that
363
+ * `b` had been dropped (vault#659). Mirrors `parseExtensionFilter` below,
364
+ * with two deliberate differences: this always returns an array (these
365
+ * callers take `string[]`), and it does NOT trim — `exclude_path_prefix`
366
+ * is a path fragment, where surrounding whitespace is data, not noise.
367
+ * Returns undefined when absent so the queryNotes filter is skipped.
368
+ */
355
369
  function parseQueryList(url: URL, key: string): string[] | undefined {
356
- const val = url.searchParams.get(key);
357
- return val ? val.split(",") : undefined;
370
+ const all = url.searchParams.getAll(key);
371
+ if (all.length === 0) return undefined;
372
+ // Flatten comma-lists inside each param.
373
+ const flat = all.flatMap((v) => v.split(",")).filter((s) => s.length > 0);
374
+ return flat.length > 0 ? flat : undefined;
358
375
  }
359
376
 
360
377
  /**
package/src/routing.ts CHANGED
@@ -1135,9 +1135,14 @@ export async function route(
1135
1135
 
1136
1136
  // Write-attribution context (vault#298). `auth.actor` is the principal;
1137
1137
  // `auth.via` is the credential class (`api` for hub JWTs + legacy keys,
1138
- // `operator` for the env-var bearer). The REST surface IS the `api` channel,
1139
- // so no refinement is needed here the base via stands (the MCP handler is
1140
- // the one that refines to `mcp`). Threaded only into the write handler.
1138
+ // `operator` for the env-var bearer, `nostr:<pubkey>` when the hub stamped a
1139
+ // NIP-98 signing key vault#698). The REST surface IS the `api` channel, so
1140
+ // no refinement is needed here the base via stands (the MCP handler is the
1141
+ // one that refines to `mcp`, via `refineMcpVia`). That gives the REST door
1142
+ // signer attribution for free and symmetric with MCP: `nostr:<pubkey>` is
1143
+ // already the base value, so it lands in `created_via`/`last_updated_via`
1144
+ // unchanged. Pinned by src/attribution-threading.test.ts. Threaded only into
1145
+ // the write handler.
1141
1146
  // Migration-bypass (vault#299): a `vault:migrate`-scoped caller may write
1142
1147
  // notes that violate `strict:true` field constraints (for backfill /
1143
1148
  // migration). Every bypassed write is logged. Orthogonal to read/write/admin
package/src/vault.test.ts CHANGED
@@ -2523,6 +2523,79 @@ describe("HTTP /notes", async () => {
2523
2523
  expect(body.map((n) => n.content).sort()).toEqual(["bare", "user"]);
2524
2524
  });
2525
2525
 
2526
+ // vault#659 — repeated query params used to take the first occurrence
2527
+ // only (`searchParams.get()` + comma-split), so `?tag=a&tag=b` silently
2528
+ // filtered by `a` alone. Both forms accumulate now, and they compose.
2529
+ test("GET /notes?tag=a&tag=b accumulates repeated params (vault#659)", async () => {
2530
+ await store.createNote("only-a", { tags: ["ta"] });
2531
+ await store.createNote("only-b", { tags: ["tb"] });
2532
+ await store.createNote("neither", { tags: ["tc"] });
2533
+ const res = await handleNotes(
2534
+ mkReq("GET", "/notes?tag=ta&tag=tb&include_content=true"),
2535
+ store,
2536
+ "",
2537
+ );
2538
+ expect(res.status).toBe(200);
2539
+ const body = await res.json() as any[];
2540
+ // >1 tag without an explicit tag_match defaults to "any".
2541
+ expect(body.map((n) => n.content).sort()).toEqual(["only-a", "only-b"]);
2542
+ });
2543
+
2544
+ test("GET /notes?tag=a,b&tag=c mixes comma-list and repeated params (vault#659)", async () => {
2545
+ await store.createNote("only-a", { tags: ["ma"] });
2546
+ await store.createNote("only-b", { tags: ["mb"] });
2547
+ await store.createNote("only-c", { tags: ["mc"] });
2548
+ await store.createNote("neither", { tags: ["md"] });
2549
+ const res = await handleNotes(
2550
+ mkReq("GET", "/notes?tag=ma,mb&tag=mc&include_content=true"),
2551
+ store,
2552
+ "",
2553
+ );
2554
+ const body = await res.json() as any[];
2555
+ expect(body.map((n) => n.content).sort()).toEqual(["only-a", "only-b", "only-c"]);
2556
+ });
2557
+
2558
+ test("GET /notes?exclude_path_prefix repeated params accumulate (vault#659)", async () => {
2559
+ await store.createNote("user", { path: "Projects/a" });
2560
+ await store.createNote("sys", { path: ".parachute/notes/settings" });
2561
+ await store.createNote("tmp", { path: "Scratch/x" });
2562
+ const res = await handleNotes(
2563
+ mkReq("GET", "/notes?exclude_path_prefix=.parachute/&exclude_path_prefix=Scratch/&include_content=true"),
2564
+ store,
2565
+ "",
2566
+ );
2567
+ const body = await res.json() as any[];
2568
+ expect(body.map((n) => n.content)).toEqual(["user"]);
2569
+ });
2570
+
2571
+ test("GET /notes?exclude_path_prefix mixes comma-list and repeated params (vault#659)", async () => {
2572
+ await store.createNote("user", { path: "Projects/a" });
2573
+ await store.createNote("sys", { path: ".parachute/notes/settings" });
2574
+ await store.createNote("tmp", { path: "Scratch/x" });
2575
+ await store.createNote("arch", { path: "Archive/old" });
2576
+ const res = await handleNotes(
2577
+ mkReq("GET", "/notes?exclude_path_prefix=.parachute/,Scratch/&exclude_path_prefix=Archive/&include_content=true"),
2578
+ store,
2579
+ "",
2580
+ );
2581
+ const body = await res.json() as any[];
2582
+ expect(body.map((n) => n.content)).toEqual(["user"]);
2583
+ });
2584
+
2585
+ // vault#659 — `_` is LIKE's single-char wildcard; an unescaped prefix
2586
+ // silently pulled in neighbors that merely looked alike.
2587
+ test("GET /notes?path_prefix=_tags/ escapes LIKE metachars (vault#659)", async () => {
2588
+ await store.createNote("under", { path: "_tags/x" });
2589
+ await store.createNote("decoy", { path: "atags/x" });
2590
+ const res = await handleNotes(
2591
+ mkReq("GET", "/notes?path_prefix=_tags/&include_content=true"),
2592
+ store,
2593
+ "",
2594
+ );
2595
+ const body = await res.json() as any[];
2596
+ expect(body.map((n) => n.content)).toEqual(["under"]);
2597
+ });
2598
+
2526
2599
  test("GET /notes?include_metadata=false strips metadata from list", async () => {
2527
2600
  await store.createNote("a", { tags: ["m"], metadata: { summary: "hello", status: "ok" } });
2528
2601
  await store.createNote("b", { tags: ["m"], metadata: { summary: "world" } });