@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/core/src/mcp-manifest.ts +3 -3
- package/core/src/notes.ts +20 -10
- package/core/src/schema.ts +1 -1
- package/core/src/types.ts +5 -2
- package/package.json +1 -1
- package/src/attribution-threading.test.ts +295 -5
- package/src/auth.ts +107 -3
- package/src/live-match.test.ts +17 -0
- package/src/live-match.ts +6 -2
- package/src/mcp-tools.ts +14 -8
- package/src/release-plan.test.ts +302 -0
- package/src/routes.ts +19 -2
- package/src/routing.ts +8 -3
- package/src/vault.test.ts +73 -0
- package/web/ui/dist/assets/{index-g3_KwmRE.js → index-xHUke-uy.js} +9 -9
- package/web/ui/dist/index.html +1 -1
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.
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
357
|
-
|
|
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
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
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" } });
|