@openparachute/vault 0.7.1 → 0.7.2-rc.4
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/aggregate.test.ts +23 -0
- package/core/src/contract-taxonomy.test.ts +22 -0
- package/core/src/core.test.ts +70 -4
- package/core/src/cursor-keyset-ms.test.ts +537 -0
- package/core/src/cursor.ts +76 -6
- package/core/src/hooks.ts +9 -0
- package/core/src/mcp.ts +12 -2
- package/core/src/notes.ts +175 -50
- package/core/src/paths.ts +4 -0
- package/core/src/portable-md.test.ts +161 -0
- package/core/src/portable-md.ts +140 -9
- package/core/src/query-operators.ts +56 -0
- package/core/src/schema.ts +128 -2
- package/core/src/seed-packs.ts +39 -13
- package/core/src/store.ts +20 -2
- package/core/src/tag-hierarchy.ts +13 -0
- package/core/src/txn.test.ts +100 -4
- package/core/src/txn.ts +119 -24
- package/core/src/wikilinks.test.ts +175 -0
- package/core/src/wikilinks.ts +151 -10
- package/package.json +1 -1
- package/src/cli.ts +6 -0
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +170 -7
- package/src/vault.test.ts +227 -1
package/src/vault.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { BunStore } from "./vault-store.ts";
|
|
|
11
11
|
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
12
12
|
import { getLinksHydrated } from "../core/src/links.ts";
|
|
13
13
|
import { buildVaultProjection } from "../core/src/vault-projection.ts";
|
|
14
|
-
import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks } from "./routes.ts";
|
|
14
|
+
import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks, MAX_JSON_BODY_BYTES } from "./routes.ts";
|
|
15
15
|
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
16
16
|
import type { TagScopeCtx } from "./routes.ts";
|
|
17
17
|
import { extractApiKey } from "./auth.ts";
|
|
@@ -539,6 +539,37 @@ describe("MCP tools", async () => {
|
|
|
539
539
|
expect(result.tags).toContain("daily");
|
|
540
540
|
});
|
|
541
541
|
|
|
542
|
+
// FIX 2 (vault#589) — the MCP door rejects illegal paths too. The core tool
|
|
543
|
+
// throws a `PathValidationError` (error_type invalid_path) exactly like
|
|
544
|
+
// ExtensionValidationError; mcp-http.ts's generic error_type mapping turns it
|
|
545
|
+
// into a structured domain error at the transport. Assert the MCP tool path
|
|
546
|
+
// itself refuses the write (parity with the REST-door tests below).
|
|
547
|
+
test("create-note MCP tool rejects a '..' path with error_type invalid_path", async () => {
|
|
548
|
+
const tools = generateMcpTools(store);
|
|
549
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
550
|
+
let thrown: any;
|
|
551
|
+
try {
|
|
552
|
+
await createNote.execute({ content: "x", path: "../escape" });
|
|
553
|
+
} catch (e) { thrown = e; }
|
|
554
|
+
expect(thrown).toBeTruthy();
|
|
555
|
+
expect(thrown.error_type).toBe("invalid_path");
|
|
556
|
+
expect(thrown.code).toBe("INVALID_PATH");
|
|
557
|
+
// Nothing written.
|
|
558
|
+
expect(await store.getNoteByPath("escape")).toBeNull();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("create-note MCP tool rejects a NUL path with error_type invalid_path", async () => {
|
|
562
|
+
const NUL = String.fromCharCode(0);
|
|
563
|
+
const tools = generateMcpTools(store);
|
|
564
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
565
|
+
let thrown: any;
|
|
566
|
+
try {
|
|
567
|
+
await createNote.execute({ content: "x", path: `bad${NUL}path` });
|
|
568
|
+
} catch (e) { thrown = e; }
|
|
569
|
+
expect(thrown).toBeTruthy();
|
|
570
|
+
expect(thrown.error_type).toBe("invalid_path");
|
|
571
|
+
});
|
|
572
|
+
|
|
542
573
|
test("every tool has inputSchema and execute", () => {
|
|
543
574
|
const tools = generateMcpTools(store);
|
|
544
575
|
for (const tool of tools) {
|
|
@@ -2664,6 +2695,63 @@ describe("HTTP /notes", async () => {
|
|
|
2664
2695
|
expect(body.error_type).toBe("invalid_extension");
|
|
2665
2696
|
});
|
|
2666
2697
|
|
|
2698
|
+
// FIX 2 (vault#589) — a note path with a NUL byte or a `..` segment is
|
|
2699
|
+
// rejected at the write surface (400 invalid_path), never persisted. A
|
|
2700
|
+
// NUL-in-path note otherwise slips the export traversal guard and then
|
|
2701
|
+
// aborts the entire vault export; a `..` note is silently un-round-trippable.
|
|
2702
|
+
test("POST /notes rejects a NUL-byte path with 400 invalid_path (not 201)", async () => {
|
|
2703
|
+
const NUL = String.fromCharCode(0);
|
|
2704
|
+
const res = await handleNotes(
|
|
2705
|
+
mkReq("POST", "/notes", { content: "x", path: `bad${NUL}path` }),
|
|
2706
|
+
store,
|
|
2707
|
+
"",
|
|
2708
|
+
);
|
|
2709
|
+
expect(res.status).toBe(400);
|
|
2710
|
+
const body = await res.json() as any;
|
|
2711
|
+
expect(body.error_type).toBe("invalid_path");
|
|
2712
|
+
// Nothing was written.
|
|
2713
|
+
expect(await store.getNoteByPath("bad")).toBeNull();
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
test("POST /notes rejects a '..' path with 400 invalid_path", async () => {
|
|
2717
|
+
const res = await handleNotes(
|
|
2718
|
+
mkReq("POST", "/notes", { content: "x", path: "../escape" }),
|
|
2719
|
+
store,
|
|
2720
|
+
"",
|
|
2721
|
+
);
|
|
2722
|
+
expect(res.status).toBe(400);
|
|
2723
|
+
const body = await res.json() as any;
|
|
2724
|
+
expect(body.error_type).toBe("invalid_path");
|
|
2725
|
+
});
|
|
2726
|
+
|
|
2727
|
+
test("POST /notes still accepts a legitimate path with dots (regression)", async () => {
|
|
2728
|
+
const res = await handleNotes(
|
|
2729
|
+
mkReq("POST", "/notes", { content: "ok", path: "Projects/v1.2/notes" }),
|
|
2730
|
+
store,
|
|
2731
|
+
"",
|
|
2732
|
+
);
|
|
2733
|
+
expect(res.status).toBeLessThan(400);
|
|
2734
|
+
const body = await res.json() as any;
|
|
2735
|
+
expect(body.path).toBe("Projects/v1.2/notes");
|
|
2736
|
+
});
|
|
2737
|
+
|
|
2738
|
+
test("PATCH /notes/:id rejects a '..' path with 400 invalid_path", async () => {
|
|
2739
|
+
const note = await store.createNote("hi", { id: "path-bad", path: "p" });
|
|
2740
|
+
const res = await handleNotes(
|
|
2741
|
+
mkReq("PATCH", "/notes/path-bad", {
|
|
2742
|
+
path: "../../etc/passwd",
|
|
2743
|
+
if_updated_at: note.updatedAt,
|
|
2744
|
+
}),
|
|
2745
|
+
store,
|
|
2746
|
+
"/path-bad",
|
|
2747
|
+
);
|
|
2748
|
+
expect(res.status).toBe(400);
|
|
2749
|
+
const body = await res.json() as any;
|
|
2750
|
+
expect(body.error_type).toBe("invalid_path");
|
|
2751
|
+
// The note's original path is untouched.
|
|
2752
|
+
expect((await store.getNote("path-bad"))!.path).toBe("p");
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2667
2755
|
test("GET /notes?extension=csv filters by extension", async () => {
|
|
2668
2756
|
await store.createNote("md note", { path: "a" });
|
|
2669
2757
|
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
@@ -4110,6 +4198,144 @@ describe("HTTP /notes", async () => {
|
|
|
4110
4198
|
});
|
|
4111
4199
|
});
|
|
4112
4200
|
|
|
4201
|
+
// ---------------------------------------------------------------------------
|
|
4202
|
+
// REST transport hardening — malformed/wrong-shape/oversize JSON bodies
|
|
4203
|
+
// (LB7). Before the fix: malformed JSON on POST /notes, POST
|
|
4204
|
+
// /notes/:id/attachments, and PUT /tags/:name threw past the handler into
|
|
4205
|
+
// server.ts's generic top-level catch — a 500 with no `error_type` (LB7a),
|
|
4206
|
+
// unlike the already-hardened /tags/merge and /tags/:name/rename routes,
|
|
4207
|
+
// which returned a clean 400 `invalid_json`. A syntactically-valid but
|
|
4208
|
+
// wrong-shape body (`null`, `42`, `[]`) either threw a TypeError deep in the
|
|
4209
|
+
// handler or sailed through property access as `undefined`, silently
|
|
4210
|
+
// creating a blank note / no-op update (LB7b). And nothing capped request
|
|
4211
|
+
// body size, so an oversized `content` field reached the store unbounded
|
|
4212
|
+
// (LB7c). Every assertion here MUST fail without the fix (parseJsonBody).
|
|
4213
|
+
// ---------------------------------------------------------------------------
|
|
4214
|
+
describe("REST transport hardening — malformed/wrong-shape/oversize JSON bodies (LB7)", async () => {
|
|
4215
|
+
/** Raw (non-JSON.stringify'd) request body — for malformed-JSON cases mkReq can't express. */
|
|
4216
|
+
function mkRawReq(method: string, path: string, rawBody: string): Request {
|
|
4217
|
+
return new Request(`${BASE}${path}`, {
|
|
4218
|
+
method,
|
|
4219
|
+
body: rawBody,
|
|
4220
|
+
headers: { "Content-Type": "application/json" },
|
|
4221
|
+
});
|
|
4222
|
+
}
|
|
4223
|
+
|
|
4224
|
+
describe("malformed JSON -> clean 400 invalid_json, not a 500 (LB7a)", () => {
|
|
4225
|
+
test("POST /notes", async () => {
|
|
4226
|
+
const res = await handleNotes(mkRawReq("POST", "/notes", "{not valid json"), store, "");
|
|
4227
|
+
expect(res.status).toBe(400);
|
|
4228
|
+
const body = await res.json() as any;
|
|
4229
|
+
expect(body.error_type).toBe("invalid_json");
|
|
4230
|
+
});
|
|
4231
|
+
|
|
4232
|
+
test("POST /notes/:id/attachments", async () => {
|
|
4233
|
+
await store.createNote("x", { id: "att-malformed" });
|
|
4234
|
+
const res = await handleNotes(
|
|
4235
|
+
mkRawReq("POST", "/notes/att-malformed/attachments", "{not valid json"),
|
|
4236
|
+
store,
|
|
4237
|
+
"/att-malformed/attachments",
|
|
4238
|
+
);
|
|
4239
|
+
expect(res.status).toBe(400);
|
|
4240
|
+
const body = await res.json() as any;
|
|
4241
|
+
expect(body.error_type).toBe("invalid_json");
|
|
4242
|
+
});
|
|
4243
|
+
|
|
4244
|
+
test("PATCH /notes/:idOrPath", async () => {
|
|
4245
|
+
await store.createNote("x", { id: "patch-malformed" });
|
|
4246
|
+
const res = await handleNotes(
|
|
4247
|
+
mkRawReq("PATCH", "/notes/patch-malformed", "{not valid json"),
|
|
4248
|
+
store,
|
|
4249
|
+
"/patch-malformed",
|
|
4250
|
+
);
|
|
4251
|
+
expect(res.status).toBe(400);
|
|
4252
|
+
const body = await res.json() as any;
|
|
4253
|
+
expect(body.error_type).toBe("invalid_json");
|
|
4254
|
+
});
|
|
4255
|
+
|
|
4256
|
+
test("PUT /tags/:name — parity with the already-hardened /tags/merge shape", async () => {
|
|
4257
|
+
const res = await handleTags(mkRawReq("PUT", "/tags/malformed", "{not valid json"), store, "/malformed");
|
|
4258
|
+
expect(res.status).toBe(400);
|
|
4259
|
+
const body = await res.json() as any;
|
|
4260
|
+
expect(body.error_type).toBe("invalid_json");
|
|
4261
|
+
});
|
|
4262
|
+
|
|
4263
|
+
test("PATCH /vault-info", async () => {
|
|
4264
|
+
const cfg = { name: "default" } as { name: string };
|
|
4265
|
+
const res = await handleVault(mkRawReq("PATCH", "/vault", "{not valid json"), store, cfg as any);
|
|
4266
|
+
expect(res.status).toBe(400);
|
|
4267
|
+
const body = await res.json() as any;
|
|
4268
|
+
expect(body.error_type).toBe("invalid_json");
|
|
4269
|
+
});
|
|
4270
|
+
});
|
|
4271
|
+
|
|
4272
|
+
// Wrong-SHAPE bodies parse fine as JSON but aren't the object a route
|
|
4273
|
+
// expects — the taxonomy calls that `invalid_request` (parallel to
|
|
4274
|
+
// /tags/merge's sources/target shape errors), NOT `invalid_json` (which is
|
|
4275
|
+
// reserved for a genuine parse failure, asserted in the LB7a block above).
|
|
4276
|
+
describe("wrong-shape (but syntactically valid) JSON body -> 400 invalid_request, not 500 or a silent blank write (LB7b)", () => {
|
|
4277
|
+
test("POST /notes with a `null` body -> 400 invalid_request, not a 500 TypeError", async () => {
|
|
4278
|
+
const res = await handleNotes(mkReq("POST", "/notes", null), store, "");
|
|
4279
|
+
expect(res.status).toBe(400);
|
|
4280
|
+
const body = await res.json() as any;
|
|
4281
|
+
expect(body.error_type).toBe("invalid_request");
|
|
4282
|
+
});
|
|
4283
|
+
|
|
4284
|
+
test("POST /notes with a bare number body -> 400 invalid_request, not a silently-created blank note", async () => {
|
|
4285
|
+
const res = await handleNotes(mkReq("POST", "/notes", 42), store, "");
|
|
4286
|
+
expect(res.status).toBe(400);
|
|
4287
|
+
const body = await res.json() as any;
|
|
4288
|
+
expect(body.error_type).toBe("invalid_request");
|
|
4289
|
+
const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
|
|
4290
|
+
expect(after).toHaveLength(0); // no blank note landed
|
|
4291
|
+
});
|
|
4292
|
+
|
|
4293
|
+
test("POST /notes with a bare array body -> 400 invalid_request, not a silently-created blank note", async () => {
|
|
4294
|
+
const res = await handleNotes(mkReq("POST", "/notes", []), store, "");
|
|
4295
|
+
expect(res.status).toBe(400);
|
|
4296
|
+
const body = await res.json() as any;
|
|
4297
|
+
expect(body.error_type).toBe("invalid_request");
|
|
4298
|
+
const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
|
|
4299
|
+
expect(after).toHaveLength(0);
|
|
4300
|
+
});
|
|
4301
|
+
|
|
4302
|
+
test("POST /notes with notes: \"not-an-array\" -> 400 invalid_request, not per-character blank notes", async () => {
|
|
4303
|
+
const res = await handleNotes(mkReq("POST", "/notes", { notes: "oops" }), store, "");
|
|
4304
|
+
expect(res.status).toBe(400);
|
|
4305
|
+
const body = await res.json() as any;
|
|
4306
|
+
expect(body.error_type).toBe("invalid_request");
|
|
4307
|
+
expect(body.field).toBe("notes");
|
|
4308
|
+
const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
|
|
4309
|
+
expect(after).toHaveLength(0);
|
|
4310
|
+
});
|
|
4311
|
+
|
|
4312
|
+
test("POST /notes with a non-object item inside notes[] -> 400 invalid_request", async () => {
|
|
4313
|
+
const res = await handleNotes(
|
|
4314
|
+
mkReq("POST", "/notes", { notes: [{ content: "ok", path: "ok-one" }, 42] }),
|
|
4315
|
+
store,
|
|
4316
|
+
"",
|
|
4317
|
+
);
|
|
4318
|
+
expect(res.status).toBe(400);
|
|
4319
|
+
const body = await res.json() as any;
|
|
4320
|
+
expect(body.error_type).toBe("invalid_request");
|
|
4321
|
+
const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
|
|
4322
|
+
expect(after).toHaveLength(0); // the whole batch is rejected, not a partial write
|
|
4323
|
+
});
|
|
4324
|
+
});
|
|
4325
|
+
|
|
4326
|
+
describe("oversize request body -> 413, not an unbounded write (LB7c)", () => {
|
|
4327
|
+
test("POST /notes with an oversized content field is rejected", async () => {
|
|
4328
|
+
const huge = "x".repeat(MAX_JSON_BODY_BYTES + 1024);
|
|
4329
|
+
const res = await handleNotes(mkReq("POST", "/notes", { content: huge, path: "huge-note" }), store, "");
|
|
4330
|
+
expect(res.status).toBe(413);
|
|
4331
|
+
const body = await res.json() as any;
|
|
4332
|
+
expect(body.error_type).toBe("payload_too_large");
|
|
4333
|
+
const stored = await store.getNoteByPath("huge-note");
|
|
4334
|
+
expect(stored).toBeNull();
|
|
4335
|
+
});
|
|
4336
|
+
});
|
|
4337
|
+
});
|
|
4338
|
+
|
|
4113
4339
|
// ---------------------------------------------------------------------------
|
|
4114
4340
|
// REST tag-scope confidentiality (security review). expand_links must not
|
|
4115
4341
|
// inline out-of-scope wikilinked content; include_links must not hydrate
|