@openparachute/vault 0.6.0 → 0.6.2-rc.1
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 +31 -6
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/links.ts +76 -21
- package/core/src/mcp.ts +53 -1
- package/core/src/notes.ts +128 -40
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +30 -1
- package/package.json +1 -1
- package/src/cli.ts +90 -25
- package/src/content-range-routes.test.ts +178 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/init-summary.test.ts +125 -125
- package/src/init-summary.ts +89 -54
- package/src/init.test.ts +128 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-remote-guard.test.ts +269 -0
- package/src/mirror-remote-guard.ts +273 -0
- package/src/mirror-routes.test.ts +1118 -46
- package/src/mirror-routes.ts +405 -32
- package/src/routes.ts +69 -3
- package/src/routing.ts +8 -0
- package/src/vault.test.ts +56 -0
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-CGL256oe.js +0 -60
package/src/routes.ts
CHANGED
|
@@ -15,6 +15,12 @@ import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
|
15
15
|
import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
16
16
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
17
17
|
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
18
|
+
import {
|
|
19
|
+
parseContentRange,
|
|
20
|
+
applyContentRange,
|
|
21
|
+
contentRangeRequiresContent,
|
|
22
|
+
type ContentRange,
|
|
23
|
+
} from "../core/src/content-range.ts";
|
|
18
24
|
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
19
25
|
import * as linkOps from "../core/src/links.ts";
|
|
20
26
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
@@ -103,6 +109,37 @@ function parseQueryList(url: URL, key: string): string[] | undefined {
|
|
|
103
109
|
return val ? val.split(",") : undefined;
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Parse `?content_offset=` / `?content_length=` (content range — bounded
|
|
114
|
+
* reads for large notes; unit is UTF-8 bytes, see core/src/content-range.ts
|
|
115
|
+
* for the codepoint-boundary slicing rules). Returns `{ range: null }` when
|
|
116
|
+
* neither param is present (response byte-identical to the no-pagination
|
|
117
|
+
* shape), or `{ error }` (400 INVALID_QUERY) on invalid values or when the
|
|
118
|
+
* response shape excludes content — range params on a content-less shape
|
|
119
|
+
* error loudly rather than silently no-op, same policy as `?expand=`.
|
|
120
|
+
*/
|
|
121
|
+
function parseContentRangeQuery(
|
|
122
|
+
url: URL,
|
|
123
|
+
includeContent: boolean,
|
|
124
|
+
): { range: ContentRange | null; error?: Response } {
|
|
125
|
+
try {
|
|
126
|
+
const range = parseContentRange(
|
|
127
|
+
parseQuery(url, "content_offset") ?? undefined,
|
|
128
|
+
parseQuery(url, "content_length") ?? undefined,
|
|
129
|
+
);
|
|
130
|
+
if (range && !includeContent) throw contentRangeRequiresContent();
|
|
131
|
+
return { range };
|
|
132
|
+
} catch (e: any) {
|
|
133
|
+
// Duck-type on `name` — core is a separate module, so `instanceof`
|
|
134
|
+
// is fragile across bundling boundaries (same note as the QueryError
|
|
135
|
+
// handling in the structured-query path below).
|
|
136
|
+
if (e && e.name === "QueryError") {
|
|
137
|
+
return { range: null, error: json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400) };
|
|
138
|
+
}
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
/**
|
|
107
144
|
* Parse the extension query parameter (vault#328). Two accepted shapes:
|
|
108
145
|
* - `?extension=csv` (single value → string)
|
|
@@ -706,12 +743,18 @@ async function handleNotesInner(
|
|
|
706
743
|
return json({ error: "Note not found", id }, 404);
|
|
707
744
|
}
|
|
708
745
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
746
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
747
|
+
if (contentRange.error) return contentRange.error;
|
|
709
748
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
710
749
|
const expand = parseExpandParams(url, db, tagScope);
|
|
711
750
|
if (expand && includeContent && typeof result.content === "string") {
|
|
712
751
|
expand.ctx.expanded.add(note.id);
|
|
713
752
|
result.content = expandContent(result.content, expand.ctx, expand.depth);
|
|
714
753
|
}
|
|
754
|
+
// Content range applies to the FINAL returned content (post-
|
|
755
|
+
// expansion) — the window the client pages through is the same
|
|
756
|
+
// document it would have received unpaged.
|
|
757
|
+
if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
|
|
715
758
|
result = filterMetadata(result, parseIncludeMetadata(url));
|
|
716
759
|
if (parseBool(parseQuery(url, "include_links"), false)) {
|
|
717
760
|
// Tag-scope: drop links whose neighbor is out of scope so the
|
|
@@ -769,6 +812,8 @@ async function handleNotesInner(
|
|
|
769
812
|
// returns 200 [] (consistent with "no matches"), not 403.
|
|
770
813
|
const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
|
|
771
814
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
815
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
816
|
+
if (contentRange.error) return contentRange.error;
|
|
772
817
|
const inclMeta = parseIncludeMetadata(url);
|
|
773
818
|
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
|
|
774
819
|
const expand = parseExpandParams(url, db, tagScope);
|
|
@@ -780,6 +825,10 @@ async function handleNotesInner(
|
|
|
780
825
|
}
|
|
781
826
|
}
|
|
782
827
|
}
|
|
828
|
+
// Content range — per-note, post-expansion (see core/src/content-range.ts).
|
|
829
|
+
if (contentRange.range && includeContent) {
|
|
830
|
+
for (const n of output) applyContentRange(n, contentRange.range);
|
|
831
|
+
}
|
|
783
832
|
if (inclMeta !== undefined && inclMeta !== true) {
|
|
784
833
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
785
834
|
}
|
|
@@ -910,6 +959,8 @@ async function handleNotesInner(
|
|
|
910
959
|
results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
|
|
911
960
|
|
|
912
961
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
962
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
963
|
+
if (contentRange.error) return contentRange.error;
|
|
913
964
|
const includeLinks = parseBool(parseQuery(url, "include_links"), false);
|
|
914
965
|
const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
|
|
915
966
|
const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
|
|
@@ -924,6 +975,10 @@ async function handleNotesInner(
|
|
|
924
975
|
}
|
|
925
976
|
}
|
|
926
977
|
}
|
|
978
|
+
// Content range — per-note, post-expansion (see core/src/content-range.ts).
|
|
979
|
+
if (contentRange.range && includeContent) {
|
|
980
|
+
for (const n of output) applyContentRange(n, contentRange.range);
|
|
981
|
+
}
|
|
927
982
|
if (inclMeta !== undefined && inclMeta !== true) {
|
|
928
983
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
929
984
|
}
|
|
@@ -952,8 +1007,9 @@ async function handleNotesInner(
|
|
|
952
1007
|
const nodes = output.map((n: any) => ({ id: n.id, path: n.path ?? null, tags: n.tags ?? [] }));
|
|
953
1008
|
const edges: { source: string; target: string; relationship: string }[] = [];
|
|
954
1009
|
if (includeLinks) {
|
|
1010
|
+
const linksByNote = linkOps.getLinksHydratedForNotes(db, results.map((n) => n.id));
|
|
955
1011
|
for (const n of results) {
|
|
956
|
-
for (const link of
|
|
1012
|
+
for (const link of linksByNote.get(n.id) ?? []) {
|
|
957
1013
|
// Only include edges where source is this note and target is in the result set
|
|
958
1014
|
if (link.sourceId === n.id && resultIds.has(link.targetId)) {
|
|
959
1015
|
edges.push({ source: link.sourceId, target: link.targetId, relationship: link.relationship });
|
|
@@ -965,13 +1021,19 @@ async function handleNotesInner(
|
|
|
965
1021
|
}
|
|
966
1022
|
|
|
967
1023
|
if (includeLinks || includeAttachments) {
|
|
1024
|
+
// Whole-page link hydration in a constant number of queries — the
|
|
1025
|
+
// per-note variant cost (1 link query + 1 summary query + N tag
|
|
1026
|
+
// queries) × page size. 2026-06-10 perf measurements.
|
|
1027
|
+
const linksByNote = includeLinks
|
|
1028
|
+
? linkOps.getLinksHydratedForNotes(db, output.map((n: any) => n.id))
|
|
1029
|
+
: null;
|
|
968
1030
|
const enrichedOut: any[] = [];
|
|
969
1031
|
for (const n of output) {
|
|
970
1032
|
const enriched: any = { ...n };
|
|
971
|
-
if (
|
|
1033
|
+
if (linksByNote) {
|
|
972
1034
|
// Tag-scope: strip out-of-scope-neighbor links (no-op unscoped).
|
|
973
1035
|
enriched.links = filterHydratedLinksByTagScope(
|
|
974
|
-
|
|
1036
|
+
linksByNote.get(n.id) ?? [],
|
|
975
1037
|
tagScope.allowed,
|
|
976
1038
|
tagScope.raw,
|
|
977
1039
|
);
|
|
@@ -1252,12 +1314,16 @@ async function handleNotesInner(
|
|
|
1252
1314
|
return json({ error: "Not found" }, 404);
|
|
1253
1315
|
}
|
|
1254
1316
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
1317
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1318
|
+
if (contentRange.error) return contentRange.error;
|
|
1255
1319
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
1256
1320
|
const expand = parseExpandParams(url, db, tagScope);
|
|
1257
1321
|
if (expand && includeContent && typeof result.content === "string") {
|
|
1258
1322
|
expand.ctx.expanded.add(note.id);
|
|
1259
1323
|
result.content = expandContent(result.content, expand.ctx, expand.depth);
|
|
1260
1324
|
}
|
|
1325
|
+
// Content range applies to the FINAL returned content (post-expansion).
|
|
1326
|
+
if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
|
|
1261
1327
|
result = filterMetadata(result, parseIncludeMetadata(url));
|
|
1262
1328
|
if (parseBool(parseQuery(url, "include_links"), false)) {
|
|
1263
1329
|
// Tag-scope: drop out-of-scope-neighbor links (no-op unscoped).
|
package/src/routing.ts
CHANGED
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
handleAuthGet,
|
|
88
88
|
handleAuthGithubCreateRepo,
|
|
89
89
|
handleAuthGithubDeviceCode,
|
|
90
|
+
handleAuthGithubInstallations,
|
|
90
91
|
handleAuthGithubPoll,
|
|
91
92
|
handleAuthGithubRepos,
|
|
92
93
|
handleAuthGithubSelectRepo,
|
|
@@ -735,6 +736,13 @@ export async function route(
|
|
|
735
736
|
if (req.method === "POST") return handleAuthGithubPoll(req, manager);
|
|
736
737
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
737
738
|
}
|
|
739
|
+
if (subpath === "/.parachute/mirror/auth/github/installations") {
|
|
740
|
+
// Install state (vault#480) — which app, installed-anywhere?, install
|
|
741
|
+
// link, per-account installations. Explicitly-network (probes GitHub);
|
|
742
|
+
// the offline status read stays on GET /.parachute/mirror/auth.
|
|
743
|
+
if (req.method === "GET") return handleAuthGithubInstallations(manager);
|
|
744
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
745
|
+
}
|
|
738
746
|
if (subpath === "/.parachute/mirror/auth/github/repos") {
|
|
739
747
|
if (req.method === "GET") return handleAuthGithubRepos(manager);
|
|
740
748
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
package/src/vault.test.ts
CHANGED
|
@@ -4543,6 +4543,62 @@ describe("HTTP PATCH /notes/:idOrPath if_missing=create (vault#309)", async () =
|
|
|
4543
4543
|
expect(body.metadata.v).toBe(2);
|
|
4544
4544
|
});
|
|
4545
4545
|
|
|
4546
|
+
// Cross-repo guard for the parachute-agent #agent/thread upsert seam: a single-threaded
|
|
4547
|
+
// thread note lives at a SLASH path (e.g. "Threads/<channel>/<name>") and the agent
|
|
4548
|
+
// module addresses it as ONE URL segment via encodeURIComponent (so `/`→`%2F`). The
|
|
4549
|
+
// full round-trip it relies on — GET an existing note by the encoded-slash path
|
|
4550
|
+
// (readThreadNote read-back) then PATCH-update it by the same encoded-slash path
|
|
4551
|
+
// (if_missing:create upsert, turn 2) — must resolve to the decoded path, or the agent's
|
|
4552
|
+
// turn_count/usage aggregates silently reset every turn. This proves the route resolves
|
|
4553
|
+
// the encoded slash on GET + PATCH-update of an EXISTING note (4505 above only covers
|
|
4554
|
+
// create). See parachute-agent#110.
|
|
4555
|
+
test("encoded-slash path round-trips: GET read-back + PATCH-update resolve a %2F path (the #agent/thread upsert seam)", async () => {
|
|
4556
|
+
const enc = encodeURIComponent("Threads/eng/eng");
|
|
4557
|
+
expect(enc).toBe("Threads%2Feng%2Feng"); // no literal slash — one URL segment.
|
|
4558
|
+
|
|
4559
|
+
// Turn 1 — PATCH if_missing:create by the encoded-slash path → CREATES.
|
|
4560
|
+
const create = await handleNotes(
|
|
4561
|
+
mkReq("PATCH", `/notes/${enc}`, {
|
|
4562
|
+
content: "## Summary\n\nturn 1",
|
|
4563
|
+
tags: ["#agent/thread"],
|
|
4564
|
+
metadata: { turn_count: "1", status: "ok" },
|
|
4565
|
+
if_missing: "create",
|
|
4566
|
+
force: true,
|
|
4567
|
+
}),
|
|
4568
|
+
store,
|
|
4569
|
+
`/${enc}`,
|
|
4570
|
+
);
|
|
4571
|
+
expect(create.status).toBe(200);
|
|
4572
|
+
expect((await create.json() as any).created).toBe(true);
|
|
4573
|
+
|
|
4574
|
+
// Read-back — GET by the SAME encoded-slash path resolves the created note (NOT 404).
|
|
4575
|
+
const get = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
|
|
4576
|
+
expect(get.status).toBe(200);
|
|
4577
|
+
const got = await get.json() as any;
|
|
4578
|
+
expect(got.path).toBe("Threads/eng/eng");
|
|
4579
|
+
expect(got.metadata.turn_count).toBe("1");
|
|
4580
|
+
|
|
4581
|
+
// Turn 2 — PATCH if_missing:create by the SAME encoded-slash path → UPDATES in place.
|
|
4582
|
+
const update = await handleNotes(
|
|
4583
|
+
mkReq("PATCH", `/notes/${enc}`, {
|
|
4584
|
+
content: "## Summary\n\nturn 2",
|
|
4585
|
+
metadata: { turn_count: "2", status: "ok" },
|
|
4586
|
+
if_missing: "create",
|
|
4587
|
+
force: true,
|
|
4588
|
+
}),
|
|
4589
|
+
store,
|
|
4590
|
+
`/${enc}`,
|
|
4591
|
+
);
|
|
4592
|
+
expect(update.status).toBe(200);
|
|
4593
|
+
expect((await update.json() as any).created).toBe(false); // updated, NOT a second note.
|
|
4594
|
+
|
|
4595
|
+
// Exactly ONE note at the decoded path, updated (the upsert worked end-to-end).
|
|
4596
|
+
const final = await store.getNoteByPath("Threads/eng/eng");
|
|
4597
|
+
expect(final).not.toBeNull();
|
|
4598
|
+
expect(String(final!.metadata.turn_count)).toBe("2");
|
|
4599
|
+
expect(final!.content).toContain("turn 2");
|
|
4600
|
+
});
|
|
4601
|
+
|
|
4546
4602
|
test("missing note without if_missing returns 404 (back-compat)", async () => {
|
|
4547
4603
|
const res = await handleNotes(
|
|
4548
4604
|
mkReq("PATCH", "/notes/m309c-nope", {
|