@openparachute/vault 0.7.0-rc.4 → 0.7.0-rc.5
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/contract-taxonomy.test.ts +193 -17
- package/core/src/core.test.ts +29 -6
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +330 -0
- package/core/src/mcp.ts +124 -5
- package/core/src/notes.ts +98 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/store.ts +17 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +45 -0
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +90 -5
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +74 -1
- package/src/routing.ts +29 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +43 -0
- package/src/vault.test.ts +25 -8
package/src/routes.ts
CHANGED
|
@@ -42,10 +42,13 @@ import {
|
|
|
42
42
|
filterNotesByTagScope,
|
|
43
43
|
noteWithinTagScope,
|
|
44
44
|
scrubIndexedFieldConflictError,
|
|
45
|
+
scrubParentCycleError,
|
|
46
|
+
scrubReferencingTagsByScope,
|
|
45
47
|
scrubTagFieldViolationsByScope,
|
|
46
48
|
tagScopeForbidden,
|
|
47
49
|
tagsWithinScope,
|
|
48
50
|
} from "./tag-scope.ts";
|
|
51
|
+
import { ParentCycleError } from "../core/src/tag-schemas.ts";
|
|
49
52
|
import { findTokensReferencingTag } from "./token-store.ts";
|
|
50
53
|
|
|
51
54
|
/**
|
|
@@ -2665,6 +2668,29 @@ export async function handleTags(
|
|
|
2665
2668
|
400,
|
|
2666
2669
|
);
|
|
2667
2670
|
}
|
|
2671
|
+
if (err instanceof ParentCycleError) {
|
|
2672
|
+
// vault#552: parent_names would close a cycle. Nothing was
|
|
2673
|
+
// persisted. Scope-scrub the cycle path for a tag-scoped caller —
|
|
2674
|
+
// the write stays rejected either way. `error` is a SHORT code and
|
|
2675
|
+
// `message` the human sentence — the same split every sibling 409 in
|
|
2676
|
+
// this file uses (target_exists, tag_in_use_by_tokens,
|
|
2677
|
+
// tag_referenced_as_parent). parachute-surface's shared VaultClient
|
|
2678
|
+
// 409 handler reads `body.message` (falling back to a hardcoded
|
|
2679
|
+
// "Note was edited elsewhere"), so the message key is load-bearing:
|
|
2680
|
+
// without it the Tags parent_names editor would show the wrong
|
|
2681
|
+
// conflict text.
|
|
2682
|
+
const scrubbed = scrubParentCycleError(err, tagScope.allowed);
|
|
2683
|
+
return json(
|
|
2684
|
+
{
|
|
2685
|
+
error: "ParentCycle",
|
|
2686
|
+
error_type: "parent_cycle",
|
|
2687
|
+
tag: scrubbed.tag,
|
|
2688
|
+
cycle: scrubbed.cycle,
|
|
2689
|
+
message: scrubbed.message,
|
|
2690
|
+
},
|
|
2691
|
+
409,
|
|
2692
|
+
);
|
|
2693
|
+
}
|
|
2668
2694
|
throw err;
|
|
2669
2695
|
}
|
|
2670
2696
|
return json(result);
|
|
@@ -2692,7 +2718,33 @@ export async function handleTags(
|
|
|
2692
2718
|
409,
|
|
2693
2719
|
);
|
|
2694
2720
|
}
|
|
2695
|
-
|
|
2721
|
+
// `?cascade=true` / `?detach=true` (synonyms — see DeleteTagOpts):
|
|
2722
|
+
// required to delete a tag another tag's parent_names still references
|
|
2723
|
+
// (vault#552). Query params, not a DELETE body, so the flag is visible
|
|
2724
|
+
// in access logs and works with any HTTP client without body support.
|
|
2725
|
+
const cascade = parseBool(parseQuery(url, "cascade"), false);
|
|
2726
|
+
const detach = parseBool(parseQuery(url, "detach"), false);
|
|
2727
|
+
const result = await store.deleteTag(tagName, { cascade, detach });
|
|
2728
|
+
if ("error" in result) {
|
|
2729
|
+
// Referential-integrity refusal (vault#552). Scope-scrub the
|
|
2730
|
+
// referencing tag names for a tag-scoped caller — the delete is
|
|
2731
|
+
// still refused (integrity is scope-independent), but an
|
|
2732
|
+
// out-of-scope referrer's name must not leak. Same posture as
|
|
2733
|
+
// `tag_field_conflict`'s scrub.
|
|
2734
|
+
const referencing_tags = scrubReferencingTagsByScope(result.referencing_tags, tagScope.allowed);
|
|
2735
|
+
return json(
|
|
2736
|
+
{
|
|
2737
|
+
error: "TagReferencedAsParent",
|
|
2738
|
+
error_type: "tag_referenced_as_parent",
|
|
2739
|
+
tag: tagName,
|
|
2740
|
+
referencing_tags,
|
|
2741
|
+
message: `Tag "${tagName}" is referenced by ${referencing_tags.length} other tag(s)' parent_names; pass ?cascade=true or ?detach=true to also remove the reference, or update the referencing tag(s) first.`,
|
|
2742
|
+
hint: "pass ?cascade=true or ?detach=true, or fix the referencing tag(s)' parent_names first",
|
|
2743
|
+
},
|
|
2744
|
+
409,
|
|
2745
|
+
);
|
|
2746
|
+
}
|
|
2747
|
+
return json(result);
|
|
2696
2748
|
}
|
|
2697
2749
|
|
|
2698
2750
|
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
@@ -2924,6 +2976,27 @@ export function handleUnresolvedWikilinks(
|
|
|
2924
2976
|
return Response.json({ unresolved: filtered, count: filtered.length });
|
|
2925
2977
|
}
|
|
2926
2978
|
|
|
2979
|
+
// ---------------------------------------------------------------------------
|
|
2980
|
+
// Doctor — GET /api/doctor (vault#552, admin-gated in routing.ts)
|
|
2981
|
+
// ---------------------------------------------------------------------------
|
|
2982
|
+
|
|
2983
|
+
/**
|
|
2984
|
+
* `GET /vault/{name}/api/doctor` — read-only taxonomy/metadata integrity
|
|
2985
|
+
* scan. Method + `vault:admin` scope are already enforced by the caller
|
|
2986
|
+
* (`src/routing.ts`, dispatched before the generic read/write gate — same
|
|
2987
|
+
* shape as `/api/triggers`); this handler just runs the scan with the
|
|
2988
|
+
* caller's tag-scope allowlist. See `core/src/doctor.ts` for the finding
|
|
2989
|
+
* catalog.
|
|
2990
|
+
*/
|
|
2991
|
+
export async function handleDoctor(
|
|
2992
|
+
_req: Request,
|
|
2993
|
+
store: Store,
|
|
2994
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
2995
|
+
): Promise<Response> {
|
|
2996
|
+
const report = await store.doctor({ allowedTags: tagScope.allowed });
|
|
2997
|
+
return json(report);
|
|
2998
|
+
}
|
|
2999
|
+
|
|
2927
3000
|
// ---------------------------------------------------------------------------
|
|
2928
3001
|
// Published notes — public, no-auth HTML rendering
|
|
2929
3002
|
// ---------------------------------------------------------------------------
|
package/src/routing.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
handleUnresolvedWikilinks,
|
|
71
71
|
handleStorage,
|
|
72
72
|
handleViewNote,
|
|
73
|
+
handleDoctor,
|
|
73
74
|
type TagScopeCtx,
|
|
74
75
|
type WriteCtx,
|
|
75
76
|
} from "./routes.ts";
|
|
@@ -825,6 +826,34 @@ export async function route(
|
|
|
825
826
|
}
|
|
826
827
|
}
|
|
827
828
|
|
|
829
|
+
// /api/doctor — read-only taxonomy/metadata integrity scan (vault#552).
|
|
830
|
+
// Same "dispatch before the generic read/write gate" shape as /triggers
|
|
831
|
+
// above, because this is ADMIN-tier regardless of method (GET-only, but
|
|
832
|
+
// admin — not read — since it's a whole-vault diagnostic, same tier as
|
|
833
|
+
// the MCP `doctor` tool and `prune-schema`).
|
|
834
|
+
if (apiMatch[1] === "/doctor") {
|
|
835
|
+
if (req.method !== "GET") {
|
|
836
|
+
return Response.json({ error: "Method not allowed", error_type: "method_not_allowed" }, { status: 405 });
|
|
837
|
+
}
|
|
838
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
839
|
+
return Response.json(
|
|
840
|
+
{
|
|
841
|
+
error: "Forbidden",
|
|
842
|
+
error_type: "insufficient_scope",
|
|
843
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
844
|
+
required_scope: SCOPE_ADMIN,
|
|
845
|
+
granted_scopes: auth.scopes,
|
|
846
|
+
},
|
|
847
|
+
{ status: 403 },
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
const doctorTagScope: TagScopeCtx = {
|
|
851
|
+
allowed: await expandTokenTagScope(store, auth.scoped_tags),
|
|
852
|
+
raw: auth.scoped_tags,
|
|
853
|
+
};
|
|
854
|
+
return handleDoctor(req, store, doctorTagScope);
|
|
855
|
+
}
|
|
856
|
+
|
|
828
857
|
// REST API — scope gate. GET/HEAD/OPTIONS → vault:read,
|
|
829
858
|
// POST/PATCH/PUT/DELETE → vault:write. Inheritance (admin ⊇ write ⊇ read)
|
|
830
859
|
// and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end coverage for the vault#552 MCP + REST surface: rename-tag and
|
|
3
|
+
* merge-tags tool wiring (verb + tag-scope gating), delete-tag's
|
|
4
|
+
* cascade/detach flags flowing through the MCP wrapper, and the `doctor`
|
|
5
|
+
* tool/endpoint's admin-gate + tag-scope filtering. Fixture pattern mirrors
|
|
6
|
+
* tag-field-conflict-scope.test.ts (PARACHUTE_HOME temp home + hand-built
|
|
7
|
+
* AuthResult).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
10
|
+
import { mkdirSync, rmSync, existsSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { writeVaultConfig } from "./config.ts";
|
|
14
|
+
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
15
|
+
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
16
|
+
import { handleTags, handleDoctor, type TagScopeCtx } from "./routes.ts";
|
|
17
|
+
import type { AuthResult } from "./auth.ts";
|
|
18
|
+
|
|
19
|
+
let tmpHome: string;
|
|
20
|
+
let prevHome: string | undefined;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmpHome = join(tmpdir(), `vault-tag-integrity-mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
24
|
+
mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
|
|
25
|
+
prevHome = process.env.PARACHUTE_HOME;
|
|
26
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
27
|
+
clearVaultStoreCache();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
clearVaultStoreCache();
|
|
32
|
+
if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
33
|
+
else process.env.PARACHUTE_HOME = prevHome;
|
|
34
|
+
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function seedVault(name: string): void {
|
|
38
|
+
writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
|
|
39
|
+
getVaultStore(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function authFor(vaultName: string, scopedTags: string[] | null, verb: "read" | "write" | "admin" = "write"): AuthResult {
|
|
43
|
+
const verbs = verb === "admin" ? ["read", "write", "admin"] : verb === "write" ? ["read", "write"] : ["read"];
|
|
44
|
+
return {
|
|
45
|
+
permission: "full",
|
|
46
|
+
scopes: verbs.map((v) => `vault:${vaultName}:${v}`),
|
|
47
|
+
legacyDerived: false,
|
|
48
|
+
scoped_tags: scopedTags,
|
|
49
|
+
vault_name: null,
|
|
50
|
+
caller_jti: null,
|
|
51
|
+
actor: "test-user",
|
|
52
|
+
via: "api",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function toolsFor(vaultName: string, scopedTags: string[] | null, verb: "read" | "write" | "admin" = "write") {
|
|
57
|
+
return generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags, verb));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("rename-tag / merge-tags — tag-scope gating (vault#552)", () => {
|
|
61
|
+
test("rename-tag: old_name or new_name outside the allowlist is refused before reaching core", async () => {
|
|
62
|
+
seedVault("journal");
|
|
63
|
+
const store = getVaultStore("journal");
|
|
64
|
+
await store.upsertTagRecord("mine", {});
|
|
65
|
+
await store.upsertTagRecord("theirs", {});
|
|
66
|
+
|
|
67
|
+
const tools = await toolsFor("journal", ["mine"]);
|
|
68
|
+
const renameTag = tools.find((t) => t.name === "rename-tag")!;
|
|
69
|
+
|
|
70
|
+
const result = await renameTag.execute({ old_name: "theirs", new_name: "mine2" }) as any;
|
|
71
|
+
expect(result.error_type).toBe("tag_scope_violation");
|
|
72
|
+
|
|
73
|
+
// Untouched.
|
|
74
|
+
expect(await store.getTagRecord("theirs")).not.toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("rename-tag: both names in-scope reaches core (wrapper doesn't spuriously block), which reports target_exists for an already-live target", async () => {
|
|
78
|
+
seedVault("journal");
|
|
79
|
+
const store = getVaultStore("journal");
|
|
80
|
+
await store.upsertTagRecord("mine", {});
|
|
81
|
+
await store.upsertTagRecord("mine-other", {});
|
|
82
|
+
|
|
83
|
+
// Both names are directly scoped (each tag is trivially a member of its
|
|
84
|
+
// own descendant set), so the wrapper's scope check passes and the call
|
|
85
|
+
// reaches core — which correctly refuses because "mine-other" already
|
|
86
|
+
// exists (rename never silently overwrites; that's what merge-tags is
|
|
87
|
+
// for). Proves the wrapper isn't the thing blocking this, core is.
|
|
88
|
+
const tools = await toolsFor("journal", ["mine", "mine-other"]);
|
|
89
|
+
const renameTag = tools.find((t) => t.name === "rename-tag")!;
|
|
90
|
+
let caught: any;
|
|
91
|
+
try {
|
|
92
|
+
await renameTag.execute({ old_name: "mine", new_name: "mine-other" });
|
|
93
|
+
} catch (e) { caught = e; }
|
|
94
|
+
expect(caught?.error_type).toBe("target_exists");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("merge-tags: a source outside the allowlist is refused before reaching core", async () => {
|
|
98
|
+
seedVault("journal");
|
|
99
|
+
const store = getVaultStore("journal");
|
|
100
|
+
await store.upsertTagRecord("mine", {});
|
|
101
|
+
await store.upsertTagRecord("theirs", {});
|
|
102
|
+
|
|
103
|
+
const tools = await toolsFor("journal", ["mine"]);
|
|
104
|
+
const mergeTags = tools.find((t) => t.name === "merge-tags")!;
|
|
105
|
+
const result = await mergeTags.execute({ sources: ["theirs"], target: "mine" }) as any;
|
|
106
|
+
expect(result.error_type).toBe("tag_scope_violation");
|
|
107
|
+
expect(await store.getTagRecord("theirs")).not.toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("merge-tags: a source referenced by a tag-scoped token is refused (409-equivalent), independent of the CALLER's own scope", async () => {
|
|
111
|
+
seedVault("journal");
|
|
112
|
+
const store = getVaultStore("journal");
|
|
113
|
+
await store.upsertTagRecord("mine", {});
|
|
114
|
+
await store.upsertTagRecord("locked", {});
|
|
115
|
+
// A DIFFERENT (vestigial) tag-scoped token row references "locked" —
|
|
116
|
+
// merging it away would orphan that token's allowlist. Raw INSERT, same
|
|
117
|
+
// fixture pattern as vault.test.ts's "MCP delete-tag returns
|
|
118
|
+
// tag_in_use_by_tokens..." — vault no longer mints these post-0.5.0, but
|
|
119
|
+
// findTokensReferencingTag still guards against leftover rows.
|
|
120
|
+
store.db
|
|
121
|
+
.prepare(
|
|
122
|
+
"INSERT INTO tokens (token_hash, label, permission, scoped_tags, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
123
|
+
)
|
|
124
|
+
.run(
|
|
125
|
+
`sha256:locked-claw-${Math.random().toString(36).slice(2)}`,
|
|
126
|
+
"locked-claw",
|
|
127
|
+
"read",
|
|
128
|
+
JSON.stringify(["locked"]),
|
|
129
|
+
new Date().toISOString(),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const tools = await toolsFor("journal", null); // unscoped caller — still blocked by the token-reference guard
|
|
133
|
+
const mergeTags = tools.find((t) => t.name === "merge-tags")!;
|
|
134
|
+
const result = await mergeTags.execute({ sources: ["locked"], target: "mine" }) as any;
|
|
135
|
+
expect(result.error_type).toBe("tag_in_use_by_tokens");
|
|
136
|
+
expect(await store.getTagRecord("locked")).not.toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("delete-tag cascade/detach — MCP wrapper pass-through (vault#552)", () => {
|
|
141
|
+
test("cascade:true flows through the tag-scope wrapper to the underlying store call", async () => {
|
|
142
|
+
seedVault("journal");
|
|
143
|
+
const store = getVaultStore("journal");
|
|
144
|
+
await store.upsertTagRecord("mine", { description: "root" });
|
|
145
|
+
await store.upsertTagRecord("mine-child", { parent_names: ["mine"] });
|
|
146
|
+
|
|
147
|
+
const tools = await toolsFor("journal", ["mine"]); // "mine-child" is a descendant, also in-scope
|
|
148
|
+
const deleteTag = tools.find((t) => t.name === "delete-tag")!;
|
|
149
|
+
|
|
150
|
+
const refused = await deleteTag.execute({ tag: "mine" }) as any;
|
|
151
|
+
expect(refused.error).toBe("tag_referenced_as_parent");
|
|
152
|
+
|
|
153
|
+
const cascaded = await deleteTag.execute({ tag: "mine", cascade: true }) as any;
|
|
154
|
+
expect(cascaded.deleted).toBe(true);
|
|
155
|
+
expect(await store.getTagRecord("mine")).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("doctor — admin gate + tag-scope filtering (vault#552)", () => {
|
|
160
|
+
test("MCP: doctor is invisible in tools/list to a write-only (non-admin) session, and excluded-tool-called-explicitly still refuses it", async () => {
|
|
161
|
+
// `generateScopedMcpTools` (the `toolsFor` helper) always returns the
|
|
162
|
+
// FULL tool set — the `requiredVerb` visibility filter lives one layer
|
|
163
|
+
// up, in `handleScopedMcp`'s tools/list dispatch (mcp-http.ts). Drive
|
|
164
|
+
// through that layer, same pattern as vault.test.ts's `listToolNames`.
|
|
165
|
+
seedVault("journal");
|
|
166
|
+
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
167
|
+
const listReq = new Request("http://localhost:1940/vault/journal/mcp", {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
170
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
|
|
171
|
+
});
|
|
172
|
+
const listRes = await handleScopedMcp(listReq, "journal", authFor("journal", null, "write"));
|
|
173
|
+
const listBody = await listRes.json() as any;
|
|
174
|
+
const names: string[] = listBody.result.tools.map((t: any) => t.name);
|
|
175
|
+
expect(names).not.toContain("doctor");
|
|
176
|
+
|
|
177
|
+
// Explicitly calling it anyway is refused, not silently executed.
|
|
178
|
+
const callReq = new Request("http://localhost:1940/vault/journal/mcp", {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
181
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "doctor", arguments: {} } }),
|
|
182
|
+
});
|
|
183
|
+
const callRes = await handleScopedMcp(callReq, "journal", authFor("journal", null, "write"));
|
|
184
|
+
const callBody = await callRes.json() as any;
|
|
185
|
+
expect(callBody.result?.isError).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("MCP: doctor is visible + callable for an admin session, and a scoped admin session only reports in-scope findings", async () => {
|
|
189
|
+
seedVault("journal");
|
|
190
|
+
const store = getVaultStore("journal");
|
|
191
|
+
// In-scope dangling parent_names.
|
|
192
|
+
await store.upsertTagRecord("mine-broken", { parent_names: ["mine-ghost-parent"] });
|
|
193
|
+
// Out-of-scope dangling parent_names — must not appear in a scoped report.
|
|
194
|
+
await store.upsertTagRecord("theirs-broken", { parent_names: ["theirs-ghost-parent"] });
|
|
195
|
+
|
|
196
|
+
const unscopedTools = await toolsFor("journal", null, "admin");
|
|
197
|
+
const unscopedDoctor = unscopedTools.find((t) => t.name === "doctor")!;
|
|
198
|
+
const unscopedReport = await unscopedDoctor.execute({}) as any;
|
|
199
|
+
const unscopedSubjects = unscopedReport.findings.map((f: any) => f.subject);
|
|
200
|
+
expect(unscopedSubjects).toContain("mine-broken");
|
|
201
|
+
expect(unscopedSubjects).toContain("theirs-broken");
|
|
202
|
+
|
|
203
|
+
const scopedTools = await toolsFor("journal", ["mine-broken"], "admin");
|
|
204
|
+
const scopedDoctor = scopedTools.find((t) => t.name === "doctor")!;
|
|
205
|
+
const scopedReport = await scopedDoctor.execute({}) as any;
|
|
206
|
+
const scopedSubjects = scopedReport.findings.map((f: any) => f.subject);
|
|
207
|
+
expect(scopedSubjects).toContain("mine-broken");
|
|
208
|
+
expect(scopedSubjects).not.toContain("theirs-broken");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("REST: GET /api/doctor reports findings filtered to the tag-scoped caller's allowlist", async () => {
|
|
212
|
+
seedVault("journal");
|
|
213
|
+
const store = getVaultStore("journal");
|
|
214
|
+
await store.upsertTagRecord("mine-broken", { parent_names: ["mine-ghost-parent"] });
|
|
215
|
+
await store.upsertTagRecord("theirs-broken", { parent_names: ["theirs-ghost-parent"] });
|
|
216
|
+
|
|
217
|
+
const tagScope: TagScopeCtx = { allowed: new Set(["mine-broken"]), raw: ["mine-broken"] };
|
|
218
|
+
const res = await handleDoctor(new Request("http://localhost/api/doctor"), store, tagScope);
|
|
219
|
+
expect(res.status).toBe(200);
|
|
220
|
+
const body: any = await res.json();
|
|
221
|
+
const subjects = body.findings.map((f: any) => f.subject);
|
|
222
|
+
expect(subjects).toContain("mine-broken");
|
|
223
|
+
expect(subjects).not.toContain("theirs-broken");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("parent_cycle — REST PUT /api/tags/:name surfaces the 409 (vault#552)", () => {
|
|
228
|
+
test("A→B then B→A is rejected with error_type parent_cycle, naming the cycle", async () => {
|
|
229
|
+
seedVault("journal");
|
|
230
|
+
const store = getVaultStore("journal");
|
|
231
|
+
await store.upsertTagRecord("b", {});
|
|
232
|
+
await store.upsertTagRecord("a", { parent_names: ["b"] });
|
|
233
|
+
|
|
234
|
+
const req = new Request("http://localhost/api/tags/b", {
|
|
235
|
+
method: "PUT",
|
|
236
|
+
body: JSON.stringify({ parent_names: ["a"] }),
|
|
237
|
+
});
|
|
238
|
+
const res = await handleTags(req, store, "/b");
|
|
239
|
+
expect(res.status).toBe(409);
|
|
240
|
+
const body: any = await res.json();
|
|
241
|
+
expect(body.error_type).toBe("parent_cycle");
|
|
242
|
+
expect(body.tag).toBe("b");
|
|
243
|
+
expect(body.cycle).toContain("a");
|
|
244
|
+
expect(body.cycle).toContain("b");
|
|
245
|
+
// MUST-FIX 5 (wire review): a `message` key is load-bearing — the shared
|
|
246
|
+
// parachute-surface VaultClient 409 handler reads `body.message` (else it
|
|
247
|
+
// shows a hardcoded "Note was edited elsewhere"). `error` is the short
|
|
248
|
+
// code, `message` the human sentence — same split every sibling 409 uses.
|
|
249
|
+
expect(typeof body.message).toBe("string");
|
|
250
|
+
expect(body.message.length).toBeGreaterThan(0);
|
|
251
|
+
expect(body.message).toMatch(/cycle/i);
|
|
252
|
+
expect(body.error).toBe("ParentCycle");
|
|
253
|
+
expect(body.error).not.toContain(" "); // short code, not the full sentence
|
|
254
|
+
|
|
255
|
+
// Nothing persisted.
|
|
256
|
+
expect((await store.getTagRecord("b"))?.parent_names ?? null).toBeFalsy();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("tag_referenced_as_parent — REST DELETE /api/tags/:name surfaces the 409 + flags (vault#552)", () => {
|
|
261
|
+
test("refused by default, cascade query param proceeds", async () => {
|
|
262
|
+
seedVault("journal");
|
|
263
|
+
const store = getVaultStore("journal");
|
|
264
|
+
await store.upsertTagRecord("root", {});
|
|
265
|
+
await store.upsertTagRecord("child", { parent_names: ["root"] });
|
|
266
|
+
|
|
267
|
+
const refusedRes = await handleTags(new Request("http://localhost/api/tags/root", { method: "DELETE" }), store, "/root");
|
|
268
|
+
expect(refusedRes.status).toBe(409);
|
|
269
|
+
const refusedBody: any = await refusedRes.json();
|
|
270
|
+
expect(refusedBody.error_type).toBe("tag_referenced_as_parent");
|
|
271
|
+
expect(refusedBody.referencing_tags).toEqual(["child"]);
|
|
272
|
+
// Same short-code-`error` + human-`message` split as every sibling 409
|
|
273
|
+
// (wire consistency — parachute-surface reads `body.message`).
|
|
274
|
+
expect(refusedBody.error).toBe("TagReferencedAsParent");
|
|
275
|
+
expect(typeof refusedBody.message).toBe("string");
|
|
276
|
+
expect(refusedBody.message.length).toBeGreaterThan(0);
|
|
277
|
+
|
|
278
|
+
const cascadedRes = await handleTags(
|
|
279
|
+
new Request("http://localhost/api/tags/root?cascade=true", { method: "DELETE" }),
|
|
280
|
+
store,
|
|
281
|
+
"/root",
|
|
282
|
+
);
|
|
283
|
+
expect(cascadedRes.status).toBe(200);
|
|
284
|
+
const cascadedBody: any = await cascadedRes.json();
|
|
285
|
+
expect(cascadedBody.deleted).toBe(true);
|
|
286
|
+
expect(cascadedBody.parent_refs_detached).toBe(1);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tag-scope scrub on the two vault#552 taxonomy-integrity guards:
|
|
3
|
+
* `tag_referenced_as_parent` (delete-tag's referential-integrity refusal)
|
|
4
|
+
* and `parent_cycle` (upsertTagRecord's write-time cycle guard). Same "in
|
|
5
|
+
* doubt, scrub" posture as `tag_field_conflict` (see
|
|
6
|
+
* tag-field-conflict-scope.test.ts) — the write stays rejected regardless
|
|
7
|
+
* of scope, but an out-of-scope tag name must never appear in the response.
|
|
8
|
+
*
|
|
9
|
+
* Reachability note: for BOTH guards, the offending tag(s) the scrub would
|
|
10
|
+
* redact are, by construction, always descendants of the tag the caller is
|
|
11
|
+
* already scoped to (deleteTag's `referencing_tags` are children of the
|
|
12
|
+
* doomed tag; `findParentCycle`'s conflicting parent + path are members of
|
|
13
|
+
* `getTagDescendants(hierarchy, tag)`, the SAME computation
|
|
14
|
+
* `expandTokenTagScope` uses to build the caller's allowlist for that same
|
|
15
|
+
* `tag`). So under the CURRENT hierarchy-based tag-scope model, a live
|
|
16
|
+
* end-to-end request can't actually construct an out-of-scope referrer —
|
|
17
|
+
* the scrub is defense-in-depth for a future change to either guard (e.g. if
|
|
18
|
+
* `deleteTag`'s search ever widens past direct parent_names children). These
|
|
19
|
+
* tests exercise the scrub functions directly against a synthetic
|
|
20
|
+
* out-of-scope input to pin the CONTRACT ("an out-of-scope name never
|
|
21
|
+
* survives"), independent of today's reachability.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, test, expect } from "bun:test";
|
|
24
|
+
import { scrubReferencingTagsByScope, scrubParentCycleError } from "./tag-scope.ts";
|
|
25
|
+
import { ParentCycleError } from "../core/src/tag-schemas.ts";
|
|
26
|
+
|
|
27
|
+
describe("scrubReferencingTagsByScope (vault#552)", () => {
|
|
28
|
+
test("unscoped caller (allowed === null): passthrough, no redaction", () => {
|
|
29
|
+
const result = scrubReferencingTagsByScope(["child-a", "secret-child"], null);
|
|
30
|
+
expect(result).toEqual(["child-a", "secret-child"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("scoped caller: in-scope names kept, out-of-scope names redacted to a generic label — never the real name", () => {
|
|
34
|
+
const allowed = new Set(["child-a"]);
|
|
35
|
+
const result = scrubReferencingTagsByScope(["child-a", "secret-child"], allowed);
|
|
36
|
+
expect(result[0]).toBe("child-a");
|
|
37
|
+
expect(result[1]).not.toBe("secret-child");
|
|
38
|
+
expect(result.join(" ")).not.toContain("secret-child");
|
|
39
|
+
expect(result[1]).toContain("outside your token's tag scope");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("every referencing tag out of scope: every entry redacted, count preserved", () => {
|
|
43
|
+
const allowed = new Set<string>(["unrelated"]);
|
|
44
|
+
const result = scrubReferencingTagsByScope(["secret-1", "secret-2"], allowed);
|
|
45
|
+
expect(result).toHaveLength(2);
|
|
46
|
+
for (const r of result) {
|
|
47
|
+
expect(r).not.toBe("secret-1");
|
|
48
|
+
expect(r).not.toBe("secret-2");
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("empty referencing list stays empty", () => {
|
|
53
|
+
expect(scrubReferencingTagsByScope([], new Set())).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("scrubParentCycleError (vault#552)", () => {
|
|
58
|
+
test("unscoped caller (allowed === null): passthrough, same instance semantics (message/tag/cycle unchanged)", () => {
|
|
59
|
+
const err = new ParentCycleError("mine", ["mine", "secret-hop", "mine"]);
|
|
60
|
+
const scrubbed = scrubParentCycleError(err, null);
|
|
61
|
+
expect(scrubbed).toBe(err);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("every hop in scope: passthrough, original error returned untouched", () => {
|
|
65
|
+
const err = new ParentCycleError("mine", ["mine", "child", "mine"]);
|
|
66
|
+
const allowed = new Set(["mine", "child"]);
|
|
67
|
+
const scrubbed = scrubParentCycleError(err, allowed);
|
|
68
|
+
expect(scrubbed).toBe(err);
|
|
69
|
+
expect(scrubbed.cycle).toEqual(["mine", "child", "mine"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("an out-of-scope hop is generalized: the caller's own tag stays named, the out-of-scope hop does not, and the write is still reported rejected", () => {
|
|
73
|
+
const err = new ParentCycleError("mine", ["mine", "secret-hop", "mine"]);
|
|
74
|
+
const allowed = new Set(["mine"]); // "secret-hop" deliberately excluded
|
|
75
|
+
const scrubbed = scrubParentCycleError(err, allowed);
|
|
76
|
+
|
|
77
|
+
expect(scrubbed).not.toBe(err); // a REPLACEMENT error, not the original
|
|
78
|
+
expect(scrubbed.error_type).toBe("parent_cycle");
|
|
79
|
+
expect(scrubbed.tag).toBe("mine"); // the caller's own tag — always in-scope, never redacted
|
|
80
|
+
expect(scrubbed.cycle[0]).toBe("mine");
|
|
81
|
+
expect(scrubbed.cycle[2]).toBe("mine");
|
|
82
|
+
expect(scrubbed.cycle[1]).not.toBe("secret-hop");
|
|
83
|
+
expect(scrubbed.cycle.join(" ")).not.toContain("secret-hop");
|
|
84
|
+
expect(scrubbed.message).not.toContain("secret-hop");
|
|
85
|
+
expect(scrubbed.message).toContain("cycle");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("multiple out-of-scope hops in a longer chain are each redacted independently", () => {
|
|
89
|
+
const err = new ParentCycleError("mine", ["mine", "secret-1", "secret-2", "mine"]);
|
|
90
|
+
const allowed = new Set(["mine"]);
|
|
91
|
+
const scrubbed = scrubParentCycleError(err, allowed);
|
|
92
|
+
expect(scrubbed.cycle[1]).not.toBe("secret-1");
|
|
93
|
+
expect(scrubbed.cycle[2]).not.toBe("secret-2");
|
|
94
|
+
expect(scrubbed.cycle.join(" ")).not.toContain("secret-1");
|
|
95
|
+
expect(scrubbed.cycle.join(" ")).not.toContain("secret-2");
|
|
96
|
+
});
|
|
97
|
+
});
|
package/src/tag-scope.ts
CHANGED
|
@@ -21,8 +21,12 @@
|
|
|
21
21
|
|
|
22
22
|
import type { Store, Note, HydratedLink, NoteSummary } from "../core/src/types.ts";
|
|
23
23
|
import type { TagFieldViolation } from "../core/src/tag-schemas.ts";
|
|
24
|
+
import { ParentCycleError } from "../core/src/tag-schemas.ts";
|
|
24
25
|
import { IndexedFieldError } from "../core/src/indexed-fields.ts";
|
|
25
26
|
|
|
27
|
+
/** Generic replacement for a redacted out-of-scope tag name — never the real name. */
|
|
28
|
+
const OUT_OF_SCOPE_LABEL = "(outside your token's tag scope)";
|
|
29
|
+
|
|
26
30
|
/**
|
|
27
31
|
* Build the effective tag-allowlist for a token: union of `{root} ∪
|
|
28
32
|
* descendants(root)` for each root in `scoped_tags`. Returns null when the
|
|
@@ -233,6 +237,45 @@ export function scrubIndexedFieldConflictError(
|
|
|
233
237
|
);
|
|
234
238
|
}
|
|
235
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Scrub the `referencing_tags` list on a `tag_referenced_as_parent` refusal
|
|
242
|
+
* (vault#552 — deleteTag's referential-integrity guard) for a tag-scoped
|
|
243
|
+
* caller. The delete is refused regardless of scope (referential integrity
|
|
244
|
+
* is scope-independent, same posture as `tag_field_conflict`), but a
|
|
245
|
+
* referencing tag OUTSIDE the caller's allowlist must not be named — same
|
|
246
|
+
* "in doubt, scrub" convention `scrubTagFieldViolationsByScope` uses.
|
|
247
|
+
* No-op for unscoped callers (`allowed === null`).
|
|
248
|
+
*/
|
|
249
|
+
export function scrubReferencingTagsByScope(
|
|
250
|
+
referencing: string[],
|
|
251
|
+
allowed: Set<string> | null,
|
|
252
|
+
): string[] {
|
|
253
|
+
if (allowed === null) return referencing;
|
|
254
|
+
return referencing.map((t) => (allowed.has(t) ? t : OUT_OF_SCOPE_LABEL));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Scrub a `ParentCycleError`'s `cycle` path (vault#552 — the `parent_names`
|
|
259
|
+
* cycle guard) for a tag-scoped caller. The write is still rejected — a
|
|
260
|
+
* cycle is a structural problem independent of who's asking — but the
|
|
261
|
+
* cycle path can walk through tags OUTSIDE the caller's allowlist (the
|
|
262
|
+
* hierarchy `upsertTagRecord` validates against is vault-wide, scope-unaware
|
|
263
|
+
* by architecture, same as the cross-tag field checks). Returns a
|
|
264
|
+
* REPLACEMENT error with out-of-scope hops in `cycle` generalized; the
|
|
265
|
+
* caller's own tag (`err.tag`) is always in-scope by the time this runs (the
|
|
266
|
+
* update-tag wrapper already gates on it) so it's never redacted. No-op for
|
|
267
|
+
* unscoped callers or a cycle with no out-of-scope hop.
|
|
268
|
+
*/
|
|
269
|
+
export function scrubParentCycleError(
|
|
270
|
+
err: ParentCycleError,
|
|
271
|
+
allowed: Set<string> | null,
|
|
272
|
+
): ParentCycleError {
|
|
273
|
+
if (allowed === null) return err;
|
|
274
|
+
if (err.cycle.every((t) => allowed.has(t))) return err;
|
|
275
|
+
const scrubbedCycle = err.cycle.map((t) => (allowed.has(t) ? t : OUT_OF_SCOPE_LABEL));
|
|
276
|
+
return new ParentCycleError(err.tag, scrubbedCycle);
|
|
277
|
+
}
|
|
278
|
+
|
|
236
279
|
/**
|
|
237
280
|
* Standard 403 response shape for tag-scope rejections. Mirrors the
|
|
238
281
|
* `insufficient_scope` 403 shape used elsewhere in the API so clients
|