@openparachute/vault 0.4.3 → 0.4.4-rc.12
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/README.md +58 -2
- package/core/src/core.test.ts +232 -0
- package/core/src/mcp.ts +104 -4
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +19 -1
- package/core/src/store.ts +13 -0
- package/core/src/types.ts +15 -0
- package/package.json +1 -1
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/routes.ts +75 -5
- package/src/vault.test.ts +215 -0
package/src/routes.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { Store, Note } from "../core/src/types.ts";
|
|
15
15
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
16
16
|
import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
|
|
17
|
+
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
17
18
|
import * as linkOps from "../core/src/links.ts";
|
|
18
19
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
19
20
|
import {
|
|
@@ -733,7 +734,13 @@ export async function handleNotes(
|
|
|
733
734
|
}
|
|
734
735
|
}
|
|
735
736
|
|
|
736
|
-
|
|
737
|
+
// Attach `validation_status` so HTTP create-note matches the MCP
|
|
738
|
+
// surface (vault#287). Mirrors the MCP create-note attach site at
|
|
739
|
+
// `core/src/mcp.ts:451`. `attachValidationStatus` returns the note
|
|
740
|
+
// unchanged when no tag declares fields, so vaults without any tag
|
|
741
|
+
// schemas see no behavior change.
|
|
742
|
+
const final = created.map((n) => attachValidationStatus(store, db, n));
|
|
743
|
+
return json(body.notes ? final : final[0], 201);
|
|
737
744
|
}
|
|
738
745
|
|
|
739
746
|
return json({ error: "Method not allowed" }, 405);
|
|
@@ -847,15 +854,64 @@ export async function handleNotes(
|
|
|
847
854
|
// PATCH /notes/:idOrPath — update (content, path, metadata, tags, links)
|
|
848
855
|
if (method === "PATCH") {
|
|
849
856
|
try {
|
|
857
|
+
// Body is parsed up front so the `if_missing: "create"` branch
|
|
858
|
+
// (vault#309) can fire when the note doesn't exist. Pre-#309
|
|
859
|
+
// shape parsed the body only after the not-found check.
|
|
860
|
+
const body = await req.json() as any;
|
|
850
861
|
const note = await resolveNote(store, idOrPath);
|
|
851
|
-
if (!note)
|
|
862
|
+
if (!note) {
|
|
863
|
+
// vault#309 — `if_missing: "create"` turns this PATCH into a
|
|
864
|
+
// create using the same payload. POST /notes is the canonical
|
|
865
|
+
// create surface, but supporting it inline on PATCH lets sync
|
|
866
|
+
// loops use one endpoint for both branches. Returns the
|
|
867
|
+
// created note with `created: true` and HTTP 200 (not 201 —
|
|
868
|
+
// the response shape is "the note as it now exists," same
|
|
869
|
+
// contract as the update path; `created: true` carries the
|
|
870
|
+
// signal).
|
|
871
|
+
if (body.if_missing === "create") {
|
|
872
|
+
const idOrPathStr = idOrPath;
|
|
873
|
+
// Tag-scope check on the create branch: the prospective
|
|
874
|
+
// tag set must still satisfy scope. Compute from body.tags
|
|
875
|
+
// (create-shape: an array, not the {add,remove} dict).
|
|
876
|
+
const tagsArr = Array.isArray(body.tags)
|
|
877
|
+
? body.tags as string[]
|
|
878
|
+
: Array.isArray(body.tags?.add) ? body.tags.add as string[] : [];
|
|
879
|
+
if (tagScope.allowed && !tagsWithinScope(tagsArr, tagScope.allowed, tagScope.raw)) {
|
|
880
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
881
|
+
}
|
|
882
|
+
const idLooksLikePath = idOrPathStr.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPathStr);
|
|
883
|
+
const explicitPath = typeof body.path === "string" ? body.path as string : undefined;
|
|
884
|
+
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
885
|
+
...(idLooksLikePath ? { path: explicitPath ?? idOrPathStr } : { id: idOrPathStr, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
886
|
+
...(tagsArr.length > 0 ? { tags: tagsArr } : {}),
|
|
887
|
+
...(body.metadata !== undefined ? { metadata: body.metadata as Record<string, unknown> } : {}),
|
|
888
|
+
...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
|
|
889
|
+
...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
|
|
890
|
+
};
|
|
891
|
+
const content = (body.content as string | undefined) ?? "";
|
|
892
|
+
const created = await store.createNote(content, createOpts);
|
|
893
|
+
if (tagsArr.length > 0) {
|
|
894
|
+
await applySchemaDefaults(store, db, [created.id], tagsArr);
|
|
895
|
+
}
|
|
896
|
+
const final = await store.getNote(created.id);
|
|
897
|
+
if (!final) return json({ error: "Note disappeared" }, 500);
|
|
898
|
+
const validated = attachValidationStatus(store, db, final);
|
|
899
|
+
const includeContentResp = body.include_content !== false;
|
|
900
|
+
if (includeContentResp) return json({ ...validated, created: true });
|
|
901
|
+
const lean: any = toNoteIndex(validated);
|
|
902
|
+
const vs = (validated as any).validation_status;
|
|
903
|
+
if (vs !== undefined) lean.validation_status = vs;
|
|
904
|
+
lean.created = true;
|
|
905
|
+
return json(lean);
|
|
906
|
+
}
|
|
907
|
+
throw new NotFoundError(`Note not found: "${idOrPath}"`);
|
|
908
|
+
}
|
|
852
909
|
// Tag-scope: existing note must be in scope. Mirror the read-side
|
|
853
910
|
// 404-not-403 stance — a token can't see (and therefore can't
|
|
854
911
|
// discover-then-modify) notes outside its allowlist.
|
|
855
912
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
856
913
|
throw new NotFoundError(`Note not found: "${idOrPath}"`);
|
|
857
914
|
}
|
|
858
|
-
const body = await req.json() as any;
|
|
859
915
|
// Tag-scope: post-update tag set must still satisfy scope. Compute
|
|
860
916
|
// the prospective tag set (existing − removed + added) and reject
|
|
861
917
|
// before any write if it would drift outside the allowlist. This
|
|
@@ -1019,11 +1075,25 @@ export async function handleNotes(
|
|
|
1019
1075
|
// Response shape: full Note (back-compat default) or lean NoteIndex
|
|
1020
1076
|
// (vault#285 friction point 2.response — opt-out for callers making
|
|
1021
1077
|
// frequent small edits to large notes). Mirror the MCP `update-note`
|
|
1022
|
-
// `include_content` knob exactly
|
|
1078
|
+
// `include_content` knob exactly, *and* `validation_status` attachment
|
|
1079
|
+
// (vault#287) so HTTP and MCP consumers see the same schema-validation
|
|
1080
|
+
// signal. Recipe matches `core/src/mcp.ts:751` — attach to the full
|
|
1081
|
+
// Note first, then carry the field across the lean conversion (since
|
|
1082
|
+
// `toNoteIndex` drops unknown fields).
|
|
1023
1083
|
const updatedNote = await store.getNote(note.id);
|
|
1024
1084
|
if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
|
|
1085
|
+
const validated = attachValidationStatus(store, db, updatedNote);
|
|
1025
1086
|
const includeContentResp = body.include_content !== false;
|
|
1026
|
-
|
|
1087
|
+
// `created: false` is appended to every update-path response so
|
|
1088
|
+
// sync-loop callers using `if_missing: "create"` can distinguish
|
|
1089
|
+
// the two branches without a separate query (vault#309). The
|
|
1090
|
+
// create-branch response above carries `created: true`.
|
|
1091
|
+
if (includeContentResp) return json({ ...validated, created: false });
|
|
1092
|
+
const lean: any = toNoteIndex(validated);
|
|
1093
|
+
const vs = (validated as any).validation_status;
|
|
1094
|
+
if (vs !== undefined) lean.validation_status = vs;
|
|
1095
|
+
lean.created = false;
|
|
1096
|
+
return json(lean);
|
|
1027
1097
|
} catch (e: any) {
|
|
1028
1098
|
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
1029
1099
|
// Duck-type on `code` rather than `instanceof ConflictError`: this
|
package/src/vault.test.ts
CHANGED
|
@@ -2783,6 +2783,221 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
2783
2783
|
expect(body.id).toBe("big");
|
|
2784
2784
|
expect(body.path).toBe("big");
|
|
2785
2785
|
});
|
|
2786
|
+
|
|
2787
|
+
// vault#287 — HTTP must match MCP on validation_status attachment.
|
|
2788
|
+
// Pre-#287 fix: MCP `update-note` attached validation_status; HTTP
|
|
2789
|
+
// PATCH didn't. HTTP consumers using schema-validated vaults had no
|
|
2790
|
+
// way to see schema warnings without re-reading + replaying validation
|
|
2791
|
+
// client-side. These tests pin the symmetry on both response shapes
|
|
2792
|
+
// (`include_content: true` and `false`) and confirm the no-schema
|
|
2793
|
+
// case still returns no validation_status (advisory only — never
|
|
2794
|
+
// forced onto vaults that don't declare fields).
|
|
2795
|
+
|
|
2796
|
+
test("PATCH attaches validation_status with enum_mismatch warning when tag schema is violated", async () => {
|
|
2797
|
+
await store.upsertTagSchema("task287patch", {
|
|
2798
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
2799
|
+
});
|
|
2800
|
+
const note = await store.createNote("body", {
|
|
2801
|
+
id: "p287a",
|
|
2802
|
+
tags: ["task287patch"],
|
|
2803
|
+
metadata: { priority: "high" },
|
|
2804
|
+
});
|
|
2805
|
+
const res = await handleNotes(
|
|
2806
|
+
mkReq("PATCH", "/notes/p287a", {
|
|
2807
|
+
metadata: { priority: "ULTRA" },
|
|
2808
|
+
if_updated_at: note.updatedAt,
|
|
2809
|
+
}),
|
|
2810
|
+
store,
|
|
2811
|
+
"/p287a",
|
|
2812
|
+
);
|
|
2813
|
+
expect(res.status).toBe(200);
|
|
2814
|
+
const body = await res.json() as any;
|
|
2815
|
+
// The write still lands — validation is advisory.
|
|
2816
|
+
expect(body.metadata.priority).toBe("ULTRA");
|
|
2817
|
+
// …but the response carries the warning so the HTTP caller knows.
|
|
2818
|
+
expect(body.validation_status).toBeTruthy();
|
|
2819
|
+
expect(body.validation_status.schemas).toContain("task287patch");
|
|
2820
|
+
expect(body.validation_status.warnings.length).toBeGreaterThan(0);
|
|
2821
|
+
expect(body.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
2822
|
+
expect(body.validation_status.warnings[0].field).toBe("priority");
|
|
2823
|
+
});
|
|
2824
|
+
|
|
2825
|
+
test("PATCH preserves validation_status on the lean (include_content: false) response", async () => {
|
|
2826
|
+
await store.upsertTagSchema("task287lean", {
|
|
2827
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
2828
|
+
});
|
|
2829
|
+
const note = await store.createNote("body", {
|
|
2830
|
+
id: "p287b",
|
|
2831
|
+
tags: ["task287lean"],
|
|
2832
|
+
metadata: { priority: "high" },
|
|
2833
|
+
});
|
|
2834
|
+
const res = await handleNotes(
|
|
2835
|
+
mkReq("PATCH", "/notes/p287b", {
|
|
2836
|
+
metadata: { priority: "ULTRA" },
|
|
2837
|
+
include_content: false,
|
|
2838
|
+
if_updated_at: note.updatedAt,
|
|
2839
|
+
}),
|
|
2840
|
+
store,
|
|
2841
|
+
"/p287b",
|
|
2842
|
+
);
|
|
2843
|
+
expect(res.status).toBe(200);
|
|
2844
|
+
const body = await res.json() as any;
|
|
2845
|
+
// Lean shape: no `content`, has `byteSize` + `preview`.
|
|
2846
|
+
expect(body.content).toBeUndefined();
|
|
2847
|
+
expect(typeof body.byteSize).toBe("number");
|
|
2848
|
+
// …and validation_status survives the lean conversion.
|
|
2849
|
+
expect(body.validation_status).toBeTruthy();
|
|
2850
|
+
expect(body.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
2851
|
+
});
|
|
2852
|
+
|
|
2853
|
+
test("PATCH omits validation_status when no tag on the note declares fields", async () => {
|
|
2854
|
+
// No tag schemas configured for this note — the response should look
|
|
2855
|
+
// exactly like the pre-#287 shape (no validation_status). The behavior-
|
|
2856
|
+
// unchanged guarantee for callers that don't use tag schemas.
|
|
2857
|
+
await store.createNote("body", { id: "p287c", tags: ["plain"] });
|
|
2858
|
+
const res = await handleNotes(
|
|
2859
|
+
mkReq("PATCH", "/notes/p287c", { content: "updated", force: true }),
|
|
2860
|
+
store,
|
|
2861
|
+
"/p287c",
|
|
2862
|
+
);
|
|
2863
|
+
expect(res.status).toBe(200);
|
|
2864
|
+
const body = await res.json() as any;
|
|
2865
|
+
expect(body.content).toBe("updated");
|
|
2866
|
+
expect(body.validation_status).toBeUndefined();
|
|
2867
|
+
});
|
|
2868
|
+
});
|
|
2869
|
+
|
|
2870
|
+
describe("HTTP POST /notes — validation_status attachment (vault#287)", async () => {
|
|
2871
|
+
// Mirror of the PATCH cases for create. The MCP create-note path
|
|
2872
|
+
// attaches validation_status; HTTP POST must match (vault#287).
|
|
2873
|
+
|
|
2874
|
+
test("POST attaches validation_status with type_mismatch warning", async () => {
|
|
2875
|
+
await store.upsertTagSchema("task287post", {
|
|
2876
|
+
fields: { done: { type: "boolean" } },
|
|
2877
|
+
});
|
|
2878
|
+
const res = await handleNotes(
|
|
2879
|
+
mkReq("POST", "/notes", {
|
|
2880
|
+
content: "x",
|
|
2881
|
+
tags: ["task287post"],
|
|
2882
|
+
metadata: { done: "yes" }, // wrong type
|
|
2883
|
+
}),
|
|
2884
|
+
store,
|
|
2885
|
+
"",
|
|
2886
|
+
);
|
|
2887
|
+
expect(res.status).toBe(201);
|
|
2888
|
+
const body = await res.json() as any;
|
|
2889
|
+
expect(body.id).toBeTruthy();
|
|
2890
|
+
expect(body.validation_status).toBeTruthy();
|
|
2891
|
+
expect(body.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
2892
|
+
expect(body.validation_status.warnings[0].field).toBe("done");
|
|
2893
|
+
});
|
|
2894
|
+
|
|
2895
|
+
test("POST batch attaches validation_status per-note", async () => {
|
|
2896
|
+
await store.upsertTagSchema("task287batch", {
|
|
2897
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
2898
|
+
});
|
|
2899
|
+
const res = await handleNotes(
|
|
2900
|
+
mkReq("POST", "/notes", {
|
|
2901
|
+
notes: [
|
|
2902
|
+
{ content: "good", tags: ["task287batch"], metadata: { priority: "high" } },
|
|
2903
|
+
{ content: "bad", tags: ["task287batch"], metadata: { priority: "ULTRA" } },
|
|
2904
|
+
],
|
|
2905
|
+
}),
|
|
2906
|
+
store,
|
|
2907
|
+
"",
|
|
2908
|
+
);
|
|
2909
|
+
expect(res.status).toBe(201);
|
|
2910
|
+
const body = await res.json() as any[];
|
|
2911
|
+
expect(body).toHaveLength(2);
|
|
2912
|
+
expect(body[0].validation_status.warnings).toEqual([]);
|
|
2913
|
+
expect(body[1].validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
2914
|
+
});
|
|
2915
|
+
|
|
2916
|
+
test("POST omits validation_status when no tag declares fields (back-compat)", async () => {
|
|
2917
|
+
const res = await handleNotes(
|
|
2918
|
+
mkReq("POST", "/notes", { content: "no schema here", tags: ["plain287"] }),
|
|
2919
|
+
store,
|
|
2920
|
+
"",
|
|
2921
|
+
);
|
|
2922
|
+
expect(res.status).toBe(201);
|
|
2923
|
+
const body = await res.json() as any;
|
|
2924
|
+
expect(body.id).toBeTruthy();
|
|
2925
|
+
expect(body.validation_status).toBeUndefined();
|
|
2926
|
+
});
|
|
2927
|
+
});
|
|
2928
|
+
|
|
2929
|
+
// vault#309 — HTTP PATCH /notes/:id with if_missing: "create" mirrors
|
|
2930
|
+
// the MCP update-note path. Sync loops (Gitcoin Brain et al) use this
|
|
2931
|
+
// for idempotent upsert without a separate query-first round trip.
|
|
2932
|
+
|
|
2933
|
+
describe("HTTP PATCH /notes/:idOrPath if_missing=create (vault#309)", async () => {
|
|
2934
|
+
test("missing note + if_missing=create creates and returns created: true", async () => {
|
|
2935
|
+
const res = await handleNotes(
|
|
2936
|
+
mkReq("PATCH", `/notes/${encodeURIComponent("Inbox/m309a")}`, {
|
|
2937
|
+
content: "agenda body",
|
|
2938
|
+
tags: ["meeting309"],
|
|
2939
|
+
metadata: { priority: "high" },
|
|
2940
|
+
if_missing: "create",
|
|
2941
|
+
}),
|
|
2942
|
+
store,
|
|
2943
|
+
`/${encodeURIComponent("Inbox/m309a")}`,
|
|
2944
|
+
);
|
|
2945
|
+
expect(res.status).toBe(200);
|
|
2946
|
+
const body = await res.json() as any;
|
|
2947
|
+
expect(body.created).toBe(true);
|
|
2948
|
+
expect(body.path).toBe("Inbox/m309a");
|
|
2949
|
+
expect(body.content).toBe("agenda body");
|
|
2950
|
+
expect(body.tags).toContain("meeting309");
|
|
2951
|
+
expect(body.metadata.priority).toBe("high");
|
|
2952
|
+
// And the row landed.
|
|
2953
|
+
expect(await store.getNoteByPath("Inbox/m309a")).not.toBeNull();
|
|
2954
|
+
});
|
|
2955
|
+
|
|
2956
|
+
test("existing note + if_missing=create updates and returns created: false", async () => {
|
|
2957
|
+
await store.createNote("original", { path: "m309b", metadata: { v: 1 } });
|
|
2958
|
+
const res = await handleNotes(
|
|
2959
|
+
mkReq("PATCH", "/notes/m309b", {
|
|
2960
|
+
content: "updated body",
|
|
2961
|
+
metadata: { v: 2 },
|
|
2962
|
+
if_missing: "create",
|
|
2963
|
+
force: true,
|
|
2964
|
+
}),
|
|
2965
|
+
store,
|
|
2966
|
+
"/m309b",
|
|
2967
|
+
);
|
|
2968
|
+
expect(res.status).toBe(200);
|
|
2969
|
+
const body = await res.json() as any;
|
|
2970
|
+
expect(body.created).toBe(false);
|
|
2971
|
+
expect(body.content).toBe("updated body");
|
|
2972
|
+
expect(body.metadata.v).toBe(2);
|
|
2973
|
+
});
|
|
2974
|
+
|
|
2975
|
+
test("missing note without if_missing returns 404 (back-compat)", async () => {
|
|
2976
|
+
const res = await handleNotes(
|
|
2977
|
+
mkReq("PATCH", "/notes/m309c-nope", {
|
|
2978
|
+
content: "x",
|
|
2979
|
+
force: true,
|
|
2980
|
+
}),
|
|
2981
|
+
store,
|
|
2982
|
+
"/m309c-nope",
|
|
2983
|
+
);
|
|
2984
|
+
expect(res.status).toBe(404);
|
|
2985
|
+
});
|
|
2986
|
+
|
|
2987
|
+
test("regular update path now also carries created: false (response shape extended)", async () => {
|
|
2988
|
+
await store.createNote("body", { path: "m309d" });
|
|
2989
|
+
const res = await handleNotes(
|
|
2990
|
+
mkReq("PATCH", "/notes/m309d", {
|
|
2991
|
+
content: "new body",
|
|
2992
|
+
force: true,
|
|
2993
|
+
}),
|
|
2994
|
+
store,
|
|
2995
|
+
"/m309d",
|
|
2996
|
+
);
|
|
2997
|
+
expect(res.status).toBe(200);
|
|
2998
|
+
const body = await res.json() as any;
|
|
2999
|
+
expect(body.created).toBe(false);
|
|
3000
|
+
});
|
|
2786
3001
|
});
|
|
2787
3002
|
|
|
2788
3003
|
describe("HTTP /tags", async () => {
|