@openparachute/vault 0.6.4-rc.8 → 0.6.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/README.md +6 -4
- package/core/src/bare-tag-guard.test.ts +246 -0
- package/core/src/conformance.test.ts +112 -0
- package/core/src/conformance.ts +214 -0
- package/core/src/mcp.ts +7 -2
- package/core/src/notes.ts +52 -10
- package/core/src/store.ts +65 -2
- package/core/src/tag-hierarchy.ts +26 -0
- package/core/src/types.ts +15 -0
- package/package.json +1 -1
- package/src/bare-tag-guard-routes.test.ts +72 -0
- package/src/cli.ts +6 -6
- package/src/first-boot-create.test.ts +155 -0
- package/src/routes.ts +190 -25
- package/src/routing.test.ts +125 -0
- package/src/routing.ts +10 -2
- package/src/self-register.test.ts +8 -8
- package/src/self-register.ts +9 -5
- package/src/server.ts +58 -38
- package/src/storage.test.ts +75 -8
- package/src/vault-remove.test.ts +4 -5
- package/web/ui/dist/assets/{index-UlvD8KHr.css → index-C5XqQM-c.css} +1 -1
- package/web/ui/dist/assets/index-TJ-XN4OZ.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DwYo23aY.js +0 -61
package/src/routes.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
15
|
-
import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
15
|
+
import { TAG_EXPAND_MODES, stripTagHash, 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, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
18
18
|
import {
|
|
@@ -27,6 +27,8 @@ import { logStrictBypass } from "./scopes.ts";
|
|
|
27
27
|
import * as linkOps from "../core/src/links.ts";
|
|
28
28
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
29
29
|
import { IndexedFieldError } from "../core/src/indexed-fields.ts";
|
|
30
|
+
import { buildVaultProjection, resolveTagInheritance } from "../core/src/vault-projection.ts";
|
|
31
|
+
import { loadSchemaConfig } from "../core/src/schema-defaults.ts";
|
|
30
32
|
import {
|
|
31
33
|
buildExpandVisibility,
|
|
32
34
|
filterHydratedLinksByTagScope,
|
|
@@ -1997,6 +1999,64 @@ export async function handleTags(
|
|
|
1997
1999
|
return json(result);
|
|
1998
2000
|
}
|
|
1999
2001
|
|
|
2002
|
+
// POST /tags/:name/conformance — count existing notes that would VIOLATE a
|
|
2003
|
+
// proposed field spec for the tag (vault#283 tightening warning). Read-only
|
|
2004
|
+
// (POST because it carries a proposed `fields` body). Must precede the
|
|
2005
|
+
// /:name matcher so "conformance" isn't read as a tag name.
|
|
2006
|
+
const conformanceMatch = subpath.match(/^\/([^/]+)\/conformance$/);
|
|
2007
|
+
if (conformanceMatch) {
|
|
2008
|
+
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
2009
|
+
const cTag = decodeURIComponent(conformanceMatch[1]!);
|
|
2010
|
+
if (tagScope.allowed && !tagScope.allowed.has(cTag)) {
|
|
2011
|
+
return json({ error: "Tag not found", tag: cTag }, 404);
|
|
2012
|
+
}
|
|
2013
|
+
const body = (await req.json().catch(() => null)) as
|
|
2014
|
+
| { fields?: Record<string, unknown> | null }
|
|
2015
|
+
| null;
|
|
2016
|
+
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2017
|
+
// The proposed fields the operator intends to save. Sanitized through the
|
|
2018
|
+
// same parse the resolver uses (drop non-object specs). Empty/absent →
|
|
2019
|
+
// nothing to enforce → zero violations.
|
|
2020
|
+
const proposed: Record<string, tagSchemaOps.TagFieldSchema> = {};
|
|
2021
|
+
if (body.fields && typeof body.fields === "object" && !Array.isArray(body.fields)) {
|
|
2022
|
+
for (const [k, v] of Object.entries(body.fields)) {
|
|
2023
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
2024
|
+
proposed[k] = v as tagSchemaOps.TagFieldSchema;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
const report = await store.countTagConformance(cTag, proposed);
|
|
2029
|
+
return json(report);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// GET /tags/:name/effective — the tag's effective (own ∪ inherited) fields +
|
|
2033
|
+
// direct/effective parents + schema-conflict info, drawn from the same
|
|
2034
|
+
// projection vault-info exposes. Read-only inheritance preview for the
|
|
2035
|
+
// Schema editor (vault#283). Must precede the /:name matcher.
|
|
2036
|
+
const effectiveMatch = subpath.match(/^\/([^/]+)\/effective$/);
|
|
2037
|
+
if (effectiveMatch) {
|
|
2038
|
+
if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
|
|
2039
|
+
const eTag = decodeURIComponent(effectiveMatch[1]!);
|
|
2040
|
+
if (tagScope.allowed && !tagScope.allowed.has(eTag)) {
|
|
2041
|
+
return json({ error: "Tag not found", tag: eTag }, 404);
|
|
2042
|
+
}
|
|
2043
|
+
const projection = buildVaultProjection(store.db);
|
|
2044
|
+
const record = await store.getTagRecord(eTag);
|
|
2045
|
+
// Resolve inheritance directly (not via projection.tags, which omits
|
|
2046
|
+
// hierarchy-only tags carrying no own schema) so the editor's preview
|
|
2047
|
+
// works even for a tag the operator is just starting to give fields.
|
|
2048
|
+
const resolved = loadSchemaConfig(store.db);
|
|
2049
|
+
const { effective_parents, effective_fields } = resolveTagInheritance(resolved, eTag);
|
|
2050
|
+
return json({
|
|
2051
|
+
name: eTag,
|
|
2052
|
+
parents: record?.parent_names ?? [],
|
|
2053
|
+
effective_parents,
|
|
2054
|
+
fields: record?.fields ?? null,
|
|
2055
|
+
effective_fields,
|
|
2056
|
+
indexed_fields: projection.indexed_fields,
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2000
2060
|
// Routes with tag name
|
|
2001
2061
|
const nameMatch = subpath.match(/^\/([^/]+)$/);
|
|
2002
2062
|
if (!nameMatch) return json({ error: "Not found" }, 404);
|
|
@@ -2026,7 +2086,15 @@ export async function handleTags(
|
|
|
2026
2086
|
// of { description, fields, relationships, parent_names }; omitted keys
|
|
2027
2087
|
// are preserved, explicit null clears. See patterns/tag-data-model.md.
|
|
2028
2088
|
if (req.method === "PUT") {
|
|
2029
|
-
|
|
2089
|
+
// Canonical-bare-tag guard (vault#XXX): normalize the upserted tag NAME so
|
|
2090
|
+
// the existing-field merge read (store.getTagSchema below) and the upsert
|
|
2091
|
+
// both target the bare row. store.upsertTagRecord re-normalizes (idempotent)
|
|
2092
|
+
// for the write; this keeps the partial-merge read correct for a
|
|
2093
|
+
// `#`-decorated PUT path. (GET/DELETE/rename keep their literal lookups —
|
|
2094
|
+
// rename's source-name must still match a `#`-prefixed legacy row so the
|
|
2095
|
+
// data migration can rename it away.)
|
|
2096
|
+
const putTagName = stripTagHash(tagName);
|
|
2097
|
+
if (tagScope.allowed && !tagScope.allowed.has(putTagName)) {
|
|
2030
2098
|
return tagScopeForbidden(tagScope.raw ?? []);
|
|
2031
2099
|
}
|
|
2032
2100
|
const body = (await req.json()) as {
|
|
@@ -2034,7 +2102,18 @@ export async function handleTags(
|
|
|
2034
2102
|
fields?: Record<string, unknown> | null;
|
|
2035
2103
|
relationships?: Record<string, unknown> | null;
|
|
2036
2104
|
parent_names?: unknown;
|
|
2105
|
+
/**
|
|
2106
|
+
* When true, `fields` is treated as the FULL intended field map for the
|
|
2107
|
+
* tag — fields absent from the payload are DROPPED (a replace, not a
|
|
2108
|
+
* merge). Default false preserves the historical partial-update merge
|
|
2109
|
+
* the MCP `update-tag` tool relies on (omitted keys preserved). The
|
|
2110
|
+
* Schema editor (vault#283) sends the full map + `replace_fields: true`
|
|
2111
|
+
* so removing a field row actually deletes the field. See
|
|
2112
|
+
* patterns/tag-data-model.md.
|
|
2113
|
+
*/
|
|
2114
|
+
replace_fields?: unknown;
|
|
2037
2115
|
};
|
|
2116
|
+
const replaceFields = body.replace_fields === true;
|
|
2038
2117
|
|
|
2039
2118
|
// Validate the relationships payload up front so a bad payload returns
|
|
2040
2119
|
// 400, not a thrown 500. `relationships` is an opaque vocabulary map
|
|
@@ -2071,7 +2150,10 @@ export async function handleTags(
|
|
|
2071
2150
|
}
|
|
2072
2151
|
|
|
2073
2152
|
// Field merge mirrors MCP update-tag — preserves prior keys when the
|
|
2074
|
-
// payload only declares new ones.
|
|
2153
|
+
// payload only declares new ones. UNLESS `replace_fields: true`, in which
|
|
2154
|
+
// case `fields` is the full intended map and absent keys are dropped (the
|
|
2155
|
+
// Schema editor's full-replacement save — vault#283; without this a
|
|
2156
|
+
// removed field row is silently resurrected by the merge).
|
|
2075
2157
|
let fieldsPatch:
|
|
2076
2158
|
| Record<string, tagSchemaOps.TagFieldSchema>
|
|
2077
2159
|
| null
|
|
@@ -2079,12 +2161,17 @@ export async function handleTags(
|
|
|
2079
2161
|
if (body.fields === null) {
|
|
2080
2162
|
fieldsPatch = null;
|
|
2081
2163
|
} else if (body.fields !== undefined) {
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2164
|
+
if (replaceFields) {
|
|
2165
|
+
const full = body.fields as Record<string, tagSchemaOps.TagFieldSchema>;
|
|
2166
|
+
fieldsPatch = Object.keys(full).length > 0 ? full : null;
|
|
2167
|
+
} else {
|
|
2168
|
+
const existing = await store.getTagSchema(putTagName);
|
|
2169
|
+
const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
|
|
2170
|
+
...(existing?.fields ?? {}),
|
|
2171
|
+
...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
|
|
2172
|
+
};
|
|
2173
|
+
fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
|
|
2174
|
+
}
|
|
2088
2175
|
}
|
|
2089
2176
|
|
|
2090
2177
|
// A bad indexed-field name (or an unindexable type, or a cross-tag type
|
|
@@ -2093,7 +2180,7 @@ export async function handleTags(
|
|
|
2093
2180
|
// unchanged on failure (no orphan/lying index). vault#478.
|
|
2094
2181
|
let result;
|
|
2095
2182
|
try {
|
|
2096
|
-
result = await store.upsertTagRecord(
|
|
2183
|
+
result = await store.upsertTagRecord(putTagName, {
|
|
2097
2184
|
...(body.description !== undefined ? { description: body.description } : {}),
|
|
2098
2185
|
...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
|
|
2099
2186
|
...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
|
|
@@ -2821,32 +2908,96 @@ async function handleRetryLegacyInBody(
|
|
|
2821
2908
|
|
|
2822
2909
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
|
2823
2910
|
|
|
2824
|
-
// Storage
|
|
2825
|
-
//
|
|
2826
|
-
//
|
|
2827
|
-
//
|
|
2828
|
-
//
|
|
2829
|
-
//
|
|
2830
|
-
//
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2911
|
+
// Storage upload policy: DENY-LIST (vault#517). A knowledge vault stores
|
|
2912
|
+
// arbitrary files — ebooks, office docs, datasets, archives, binaries — so we
|
|
2913
|
+
// accept ANY upload EXCEPT the handful of types a browser can execute as
|
|
2914
|
+
// active content in our origin when served back from /storage/. (The prior
|
|
2915
|
+
// allowlist rejected the long tail: .epub/.csv/.zip/… all came back "File type
|
|
2916
|
+
// not allowed".)
|
|
2917
|
+
//
|
|
2918
|
+
// BLOCKED — same-origin-XSS / active-content set:
|
|
2919
|
+
// .html/.htm/.xhtml/.shtml/.xht HTML — embeds <script>
|
|
2920
|
+
// .svg XML image — embeds <script>
|
|
2921
|
+
// .xml can carry XSLT / be parsed as XHTML
|
|
2922
|
+
// .js/.mjs/.cjs JavaScript
|
|
2923
|
+
// .css style-injection / UI-redress vector
|
|
2924
|
+
//
|
|
2925
|
+
// Two independent guards keep every STORED file inert when served:
|
|
2926
|
+
// 1. Only the curated MIME_TYPES below map to a real (always passive) type;
|
|
2927
|
+
// every other extension serves as application/octet-stream — a download,
|
|
2928
|
+
// never rendered.
|
|
2929
|
+
// 2. The GET byte-serve response pins `X-Content-Type-Options: nosniff`, so
|
|
2930
|
+
// a browser can't sniff an octet-stream body into an executable type.
|
|
2931
|
+
// The blocklist is belt-and-suspenders on top of those: even if a future MIME
|
|
2932
|
+
// entry or an upstream proxy weakened (1) or (2), these extensions still never
|
|
2933
|
+
// land on disk. If a future use case needs SVG, sanitize on read (strip
|
|
2934
|
+
// <script>/<foreignObject>) and revisit.
|
|
2935
|
+
const BLOCKED_EXTENSIONS = new Set([
|
|
2936
|
+
".html", ".htm", ".xhtml", ".shtml", ".xht",
|
|
2937
|
+
".svg",
|
|
2938
|
+
".xml",
|
|
2939
|
+
".js", ".mjs", ".cjs",
|
|
2940
|
+
".css",
|
|
2835
2941
|
]);
|
|
2836
2942
|
|
|
2943
|
+
// Explicit MIME types for the commonly-previewed formats. Anything accepted
|
|
2944
|
+
// but absent here serves as application/octet-stream — a download, never
|
|
2945
|
+
// rendered (e.g. .pages/.key/.numbers/.azw3/.exe/arbitrary binaries). None of
|
|
2946
|
+
// these map to an active type (text/html, image/svg+xml), so a served asset
|
|
2947
|
+
// can't execute script; `nosniff` on the GET response makes that ironclad.
|
|
2948
|
+
//
|
|
2949
|
+
// INVARIANT: never add an entry that maps to a browser-active type —
|
|
2950
|
+
// text/html, image/svg+xml, application/xhtml+xml, text/javascript,
|
|
2951
|
+
// application/wasm, text/css. Doing so re-enables same-origin execution for
|
|
2952
|
+
// that extension (and would mean it must also join BLOCKED_EXTENSIONS).
|
|
2837
2953
|
const MIME_TYPES: Record<string, string> = {
|
|
2954
|
+
// Audio
|
|
2838
2955
|
".wav": "audio/wav",
|
|
2839
2956
|
".mp3": "audio/mpeg",
|
|
2840
2957
|
".m4a": "audio/mp4",
|
|
2841
2958
|
".ogg": "audio/ogg",
|
|
2959
|
+
".oga": "audio/ogg",
|
|
2960
|
+
".opus": "audio/opus",
|
|
2961
|
+
".aac": "audio/aac",
|
|
2962
|
+
".flac": "audio/flac",
|
|
2842
2963
|
".webm": "audio/webm",
|
|
2964
|
+
// Image
|
|
2843
2965
|
".png": "image/png",
|
|
2844
2966
|
".jpg": "image/jpeg",
|
|
2845
2967
|
".jpeg": "image/jpeg",
|
|
2846
2968
|
".gif": "image/gif",
|
|
2847
2969
|
".webp": "image/webp",
|
|
2848
|
-
".
|
|
2970
|
+
".bmp": "image/bmp",
|
|
2971
|
+
".tiff": "image/tiff",
|
|
2972
|
+
".tif": "image/tiff",
|
|
2973
|
+
".heic": "image/heic",
|
|
2974
|
+
".heif": "image/heif",
|
|
2975
|
+
".avif": "image/avif",
|
|
2976
|
+
// Video
|
|
2849
2977
|
".mp4": "video/mp4",
|
|
2978
|
+
".m4v": "video/x-m4v",
|
|
2979
|
+
".mov": "video/quicktime",
|
|
2980
|
+
// Documents / ebooks / data
|
|
2981
|
+
".pdf": "application/pdf",
|
|
2982
|
+
".epub": "application/epub+zip",
|
|
2983
|
+
".mobi": "application/x-mobipocket-ebook",
|
|
2984
|
+
".txt": "text/plain; charset=utf-8",
|
|
2985
|
+
".md": "text/markdown; charset=utf-8",
|
|
2986
|
+
".markdown": "text/markdown; charset=utf-8",
|
|
2987
|
+
".rtf": "application/rtf",
|
|
2988
|
+
".csv": "text/csv; charset=utf-8",
|
|
2989
|
+
".tsv": "text/tab-separated-values; charset=utf-8",
|
|
2990
|
+
".json": "application/json; charset=utf-8",
|
|
2991
|
+
".doc": "application/msword",
|
|
2992
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
2993
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
2994
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
2995
|
+
".xls": "application/vnd.ms-excel",
|
|
2996
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
2997
|
+
".odt": "application/vnd.oasis.opendocument.text",
|
|
2998
|
+
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
|
2999
|
+
".odp": "application/vnd.oasis.opendocument.presentation",
|
|
3000
|
+
".zip": "application/zip",
|
|
2850
3001
|
};
|
|
2851
3002
|
|
|
2852
3003
|
export async function handleStorage(
|
|
@@ -2867,9 +3018,17 @@ export async function handleStorage(
|
|
|
2867
3018
|
if (file.size > MAX_UPLOAD_BYTES) {
|
|
2868
3019
|
return json({ error: `File too large (${Math.round(file.size / 1024 / 1024)}MB). Max: 100MB` }, 413);
|
|
2869
3020
|
}
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
3021
|
+
// Strip trailing dots/whitespace before extracting the extension so a
|
|
3022
|
+
// `evil.html.` / `evil.svg ` can't slip past the blocklist
|
|
3023
|
+
// (extname("evil.html.") === "."). The blocklist is belt-and-suspenders;
|
|
3024
|
+
// anything that still gets through serves as octet-stream + nosniff anyway.
|
|
3025
|
+
const ext = extname(file.name.replace(/[.\s]+$/, "")).toLowerCase();
|
|
3026
|
+
if (BLOCKED_EXTENSIONS.has(ext)) {
|
|
3027
|
+
// Active-content types only — blocked because they execute as script when
|
|
3028
|
+
// served same-origin from /storage/ (see BLOCKED_EXTENSIONS). Everything
|
|
3029
|
+
// else (incl. unknown/arbitrary files) is accepted and served as a
|
|
3030
|
+
// download (octet-stream + nosniff).
|
|
3031
|
+
return json({ error: `File type ${ext} not allowed (active/executable content)` }, 400);
|
|
2873
3032
|
}
|
|
2874
3033
|
|
|
2875
3034
|
const date = new Date().toISOString().split("T")[0]!;
|
|
@@ -2970,6 +3129,12 @@ export async function handleStorage(
|
|
|
2970
3129
|
headers: {
|
|
2971
3130
|
"Content-Type": contentType,
|
|
2972
3131
|
"Content-Length": String(stat.size),
|
|
3132
|
+
// Defense-in-depth: never let a browser MIME-sniff a stored asset into
|
|
3133
|
+
// an active type (e.g. an octet-stream body sniffed as text/html).
|
|
3134
|
+
// Combined with the upload blocklist (no .svg/.html) this closes the
|
|
3135
|
+
// same-origin XSS surface for served attachments. Mirrors routing.ts's
|
|
3136
|
+
// SPA-asset stance.
|
|
3137
|
+
"X-Content-Type-Options": "nosniff",
|
|
2973
3138
|
},
|
|
2974
3139
|
});
|
|
2975
3140
|
}
|
package/src/routing.test.ts
CHANGED
|
@@ -1956,6 +1956,131 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1956
1956
|
expect(res.status).toBe(200);
|
|
1957
1957
|
});
|
|
1958
1958
|
|
|
1959
|
+
// ----- vault#283: Schema editor read endpoints -------------------------
|
|
1960
|
+
|
|
1961
|
+
test("POST /api/tags/:name/conformance → counts notes violating a proposed spec", async () => {
|
|
1962
|
+
createVault("journal");
|
|
1963
|
+
const store = getVaultStore("journal");
|
|
1964
|
+
await store.createNote("ok", { tags: ["task"], metadata: { status: "open" } });
|
|
1965
|
+
await store.createNote("missing", { tags: ["task"], metadata: {} });
|
|
1966
|
+
const admin = await createAdminToken("journal");
|
|
1967
|
+
|
|
1968
|
+
const path = "/vault/journal/api/tags/task/conformance";
|
|
1969
|
+
const res = await route(
|
|
1970
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1971
|
+
method: "POST",
|
|
1972
|
+
headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
|
|
1973
|
+
body: JSON.stringify({ fields: { status: { type: "string", required: true } } }),
|
|
1974
|
+
}),
|
|
1975
|
+
path,
|
|
1976
|
+
);
|
|
1977
|
+
expect(res.status).toBe(200);
|
|
1978
|
+
const body = (await res.json()) as {
|
|
1979
|
+
tag: string;
|
|
1980
|
+
total_notes: number;
|
|
1981
|
+
violating_notes: number;
|
|
1982
|
+
checked_fields: string[];
|
|
1983
|
+
};
|
|
1984
|
+
expect(body.tag).toBe("task");
|
|
1985
|
+
expect(body.total_notes).toBe(2);
|
|
1986
|
+
expect(body.violating_notes).toBe(1);
|
|
1987
|
+
expect(body.checked_fields).toEqual(["status"]);
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
test("POST /api/tags/:name/conformance is read-only — a read token may run it", async () => {
|
|
1991
|
+
createVault("journal");
|
|
1992
|
+
const store = getVaultStore("journal");
|
|
1993
|
+
await store.createNote("missing", { tags: ["task"], metadata: {} });
|
|
1994
|
+
const readToken = await mintToken("journal", {
|
|
1995
|
+
permission: "read",
|
|
1996
|
+
scopes: ["vault:read"],
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
const path = "/vault/journal/api/tags/task/conformance";
|
|
2000
|
+
const res = await route(
|
|
2001
|
+
new Request(`http://localhost:1940${path}`, {
|
|
2002
|
+
method: "POST",
|
|
2003
|
+
headers: { authorization: `Bearer ${readToken}`, "content-type": "application/json" },
|
|
2004
|
+
body: JSON.stringify({ fields: { status: { type: "string", required: true } } }),
|
|
2005
|
+
}),
|
|
2006
|
+
path,
|
|
2007
|
+
);
|
|
2008
|
+
expect(res.status).toBe(200);
|
|
2009
|
+
const body = (await res.json()) as { violating_notes: number };
|
|
2010
|
+
expect(body.violating_notes).toBe(1);
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
test("PUT /api/tags/:name with replace_fields drops omitted fields (no merge resurrection)", async () => {
|
|
2014
|
+
createVault("journal");
|
|
2015
|
+
const store = getVaultStore("journal");
|
|
2016
|
+
await store.upsertTagRecord("task", {
|
|
2017
|
+
fields: { status: { type: "string" }, due: { type: "string" } },
|
|
2018
|
+
});
|
|
2019
|
+
const admin = await createAdminToken("journal");
|
|
2020
|
+
|
|
2021
|
+
const path = "/vault/journal/api/tags/task";
|
|
2022
|
+
// Send only `status` WITH replace_fields → `due` must be dropped.
|
|
2023
|
+
const res = await route(
|
|
2024
|
+
new Request(`http://localhost:1940${path}`, {
|
|
2025
|
+
method: "PUT",
|
|
2026
|
+
headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
|
|
2027
|
+
body: JSON.stringify({ fields: { status: { type: "string" } }, replace_fields: true }),
|
|
2028
|
+
}),
|
|
2029
|
+
path,
|
|
2030
|
+
);
|
|
2031
|
+
expect(res.status).toBe(200);
|
|
2032
|
+
const rec = await store.getTagRecord("task");
|
|
2033
|
+
expect(Object.keys(rec?.fields ?? {})).toEqual(["status"]);
|
|
2034
|
+
expect(rec?.fields?.due).toBeUndefined();
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
test("PUT /api/tags/:name WITHOUT replace_fields preserves omitted fields (merge — MCP contract)", async () => {
|
|
2038
|
+
createVault("journal");
|
|
2039
|
+
const store = getVaultStore("journal");
|
|
2040
|
+
await store.upsertTagRecord("task", {
|
|
2041
|
+
fields: { status: { type: "string" }, due: { type: "string" } },
|
|
2042
|
+
});
|
|
2043
|
+
const admin = await createAdminToken("journal");
|
|
2044
|
+
|
|
2045
|
+
const path = "/vault/journal/api/tags/task";
|
|
2046
|
+
// Send only `status` WITHOUT the flag → `due` preserved (partial update).
|
|
2047
|
+
const res = await route(
|
|
2048
|
+
new Request(`http://localhost:1940${path}`, {
|
|
2049
|
+
method: "PUT",
|
|
2050
|
+
headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
|
|
2051
|
+
body: JSON.stringify({ fields: { status: { type: "string", enum: ["a"] } } }),
|
|
2052
|
+
}),
|
|
2053
|
+
path,
|
|
2054
|
+
);
|
|
2055
|
+
expect(res.status).toBe(200);
|
|
2056
|
+
const rec = await store.getTagRecord("task");
|
|
2057
|
+
expect(Object.keys(rec?.fields ?? {}).sort()).toEqual(["due", "status"]);
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
test("GET /api/tags/:name/effective → surfaces inherited fields + parents", async () => {
|
|
2061
|
+
createVault("journal");
|
|
2062
|
+
const store = getVaultStore("journal");
|
|
2063
|
+
// dev declares a field; dev/log inherits it via parent_names.
|
|
2064
|
+
await store.upsertTagRecord("dev", {
|
|
2065
|
+
fields: { area: { type: "string" } },
|
|
2066
|
+
});
|
|
2067
|
+
await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
|
|
2068
|
+
const admin = await createAdminToken("journal");
|
|
2069
|
+
|
|
2070
|
+
const path = "/vault/journal/api/tags/dev%2Flog/effective";
|
|
2071
|
+
const res = await route(authed(admin, "GET", path), path);
|
|
2072
|
+
expect(res.status).toBe(200);
|
|
2073
|
+
const body = (await res.json()) as {
|
|
2074
|
+
name: string;
|
|
2075
|
+
parents: string[];
|
|
2076
|
+
effective_parents: string[];
|
|
2077
|
+
effective_fields: Record<string, { type?: string }>;
|
|
2078
|
+
};
|
|
2079
|
+
expect(body.parents).toEqual(["dev"]);
|
|
2080
|
+
expect(body.effective_parents).toContain("dev");
|
|
2081
|
+
expect(body.effective_fields.area?.type).toBe("string");
|
|
2082
|
+
});
|
|
2083
|
+
|
|
1959
2084
|
test("POST /api/tags/:name/rename → 200 cascades token allowlists (vault#240)", async () => {
|
|
1960
2085
|
createVault("journal");
|
|
1961
2086
|
const store = getVaultStore("journal");
|
package/src/routing.ts
CHANGED
|
@@ -831,9 +831,17 @@ export async function route(
|
|
|
831
831
|
// and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
|
|
832
832
|
// `vault:<vaultName>:<verb>` from hub JWTs) are handled by
|
|
833
833
|
// `hasScopeForVault`.
|
|
834
|
-
|
|
834
|
+
//
|
|
835
|
+
// Read-only POST carve-out (vault#283): the conformance check is a POST
|
|
836
|
+
// (it carries a proposed-fields body) but mutates nothing — it counts
|
|
837
|
+
// notes. Gate it on `read`, not `write`, so a read-scoped caller can
|
|
838
|
+
// preview a schema tightening without holding a write credential.
|
|
839
|
+
const apiSubpath = apiMatch[1] ?? "";
|
|
840
|
+
const isReadOnlyPost =
|
|
841
|
+
req.method === "POST" && /^\/tags\/[^/]+\/conformance$/.test(apiSubpath);
|
|
842
|
+
const requiredVerb = isReadOnlyPost ? "read" : verbForMethod(req.method);
|
|
835
843
|
if (!hasScopeForVault(auth.scopes, vaultName, requiredVerb)) {
|
|
836
|
-
const requiredApiScope = scopeForMethod(req.method);
|
|
844
|
+
const requiredApiScope = isReadOnlyPost ? SCOPE_READ : scopeForMethod(req.method);
|
|
837
845
|
return Response.json(
|
|
838
846
|
{
|
|
839
847
|
error: "Forbidden",
|
|
@@ -61,20 +61,18 @@ function captureLogs(): {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
describe("self-register", () => {
|
|
64
|
-
test("buildVaultServicePaths — no vaults yet →
|
|
65
|
-
expect(buildVaultServicePaths(undefined, []
|
|
66
|
-
"/vault/default",
|
|
67
|
-
]);
|
|
64
|
+
test("buildVaultServicePaths — no vaults yet → empty paths (no phantom /vault/default, #478)", () => {
|
|
65
|
+
expect(buildVaultServicePaths(undefined, [])).toEqual([]);
|
|
68
66
|
});
|
|
69
67
|
|
|
70
68
|
test("buildVaultServicePaths — default vault sorts first", () => {
|
|
71
69
|
expect(
|
|
72
|
-
buildVaultServicePaths("default", ["alpha", "default", "beta"]
|
|
70
|
+
buildVaultServicePaths("default", ["alpha", "default", "beta"]),
|
|
73
71
|
).toEqual(["/vault/default", "/vault/alpha", "/vault/beta"]);
|
|
74
72
|
});
|
|
75
73
|
|
|
76
74
|
test("buildVaultServicePaths — no default → map by listed order", () => {
|
|
77
|
-
expect(buildVaultServicePaths(undefined, ["alpha", "beta"]
|
|
75
|
+
expect(buildVaultServicePaths(undefined, ["alpha", "beta"])).toEqual([
|
|
78
76
|
"/vault/alpha",
|
|
79
77
|
"/vault/beta",
|
|
80
78
|
]);
|
|
@@ -82,7 +80,7 @@ describe("self-register", () => {
|
|
|
82
80
|
|
|
83
81
|
test("buildVaultServicePaths — default points to missing vault → ignore default", () => {
|
|
84
82
|
expect(
|
|
85
|
-
buildVaultServicePaths("missing", ["alpha", "beta"]
|
|
83
|
+
buildVaultServicePaths("missing", ["alpha", "beta"]),
|
|
86
84
|
).toEqual(["/vault/alpha", "/vault/beta"]);
|
|
87
85
|
});
|
|
88
86
|
|
|
@@ -108,7 +106,9 @@ describe("self-register", () => {
|
|
|
108
106
|
const row = parsed.services[0] as Record<string, unknown>;
|
|
109
107
|
expect(row.name).toBe("parachute-vault");
|
|
110
108
|
expect(row.port).toBe(1940);
|
|
111
|
-
|
|
109
|
+
// Zero vaults → paths: [] (no phantom /vault/default, #478).
|
|
110
|
+
// Health still well-formed: paths[0] ?? "/vault/default" fallback.
|
|
111
|
+
expect(row.paths).toEqual([]);
|
|
112
112
|
expect(row.health).toBe("/vault/default/health");
|
|
113
113
|
expect(row.version).toBe("0.4.8-rc.3");
|
|
114
114
|
expect(row.installDir).toBe("/fake/install/dir");
|
package/src/self-register.ts
CHANGED
|
@@ -56,17 +56,21 @@ import { listVaults, readGlobalConfig, DEFAULT_PORT } from "./config.ts";
|
|
|
56
56
|
*
|
|
57
57
|
* Mirrors `buildVaultServicePaths` in `cli.ts` so the self-register pass
|
|
58
58
|
* produces the same multi-vault path advertisement as `parachute-vault
|
|
59
|
-
* init` / `vault create`.
|
|
60
|
-
*
|
|
59
|
+
* init` / `vault create`.
|
|
60
|
+
*
|
|
61
|
+
* At zero vaults, returns an empty array (`paths: []`). The row remains
|
|
62
|
+
* present in services.json (name, port, health, version, installDir intact
|
|
63
|
+
* — so the module is still detected as installed), but it advertises no
|
|
64
|
+
* `/vault/<name>` path and the hub must not resolve it to a phantom
|
|
65
|
+
* `/vault/default`. Closes #478.
|
|
61
66
|
*
|
|
62
67
|
* Exported for tests; not part of the public module surface.
|
|
63
68
|
*/
|
|
64
69
|
export function buildVaultServicePaths(
|
|
65
70
|
defaultVault: string | undefined,
|
|
66
71
|
vaults: readonly string[],
|
|
67
|
-
fallbackFromManifest: readonly string[],
|
|
68
72
|
): string[] {
|
|
69
|
-
if (vaults.length === 0) return [
|
|
73
|
+
if (vaults.length === 0) return [];
|
|
70
74
|
if (defaultVault && vaults.includes(defaultVault)) {
|
|
71
75
|
return [
|
|
72
76
|
`/vault/${defaultVault}`,
|
|
@@ -160,7 +164,7 @@ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
|
|
|
160
164
|
return { status: "failed", reason: msg };
|
|
161
165
|
}
|
|
162
166
|
|
|
163
|
-
const paths = buildVaultServicePaths(globalConfig.default_vault, vaults
|
|
167
|
+
const paths = buildVaultServicePaths(globalConfig.default_vault, vaults);
|
|
164
168
|
const port = globalConfig.port ?? DEFAULT_PORT;
|
|
165
169
|
|
|
166
170
|
// Derive the health path from the primary path (paths[0]) rather than
|