@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/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
- if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
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
- const existing = await store.getTagSchema(tagName);
2083
- const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
2084
- ...(existing?.fields ?? {}),
2085
- ...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
2086
- };
2087
- fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
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(tagName, {
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 allowlist policy:
2825
- // - audio + image + .pdf (knowledge-vault content: papers, scans, receipts)
2826
- // + .mp4 (mobile capture default; iOS records mp4, not webm).
2827
- // - .svg and .html are deliberately excluded both can embed `<script>`
2828
- // tags, which would turn an upload into a same-origin XSS vector when
2829
- // the asset is served back from /storage/. If a future use case needs
2830
- // SVG, sanitize on read (strip <script>/<foreignObject>) and revisit.
2831
- const ALLOWED_EXTENSIONS = new Set([
2832
- ".wav", ".mp3", ".m4a", ".ogg", ".webm",
2833
- ".png", ".jpg", ".jpeg", ".gif", ".webp",
2834
- ".pdf", ".mp4",
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
- ".pdf": "application/pdf",
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
- const ext = extname(file.name).toLowerCase();
2871
- if (!ALLOWED_EXTENSIONS.has(ext)) {
2872
- return json({ error: `File type ${ext} not allowed` }, 400);
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
  }
@@ -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
- const requiredVerb = verbForMethod(req.method);
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 → manifest fallback", () => {
65
- expect(buildVaultServicePaths(undefined, [], ["/vault/default"])).toEqual([
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"], ["/"])).toEqual([
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
- expect(row.paths).toEqual(["/vault/default"]);
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");
@@ -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`. With no vaults yet, falls back to the manifest's
60
- * canonical `paths[0]` so early-boot registration is still well-formed.
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 [...fallbackFromManifest];
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, manifest.paths);
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