@openparachute/vault 0.5.1 → 0.5.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.
@@ -44,6 +44,7 @@ const {
44
44
  const { clearVaultStoreCache, getVaultStore } = await import("./vault-store.ts");
45
45
  const { vaultDbPath } = await import("./config.ts");
46
46
  const { resetJwksCache, resetRevocationCache } = await import("./hub-jwt.ts");
47
+ const { invalidateUsageCache } = await import("./usage.ts");
47
48
 
48
49
  // ---------------------------------------------------------------------------
49
50
  // Hub-JWT mint fixture (vault#282 Stage 2 — vault is a pure hub
@@ -135,6 +136,12 @@ function seedTagScopedRow(vaultName: string, scopedTags: string[], label = "test
135
136
 
136
137
  function reset(): void {
137
138
  clearVaultStoreCache();
139
+ // The usage dir-walk cache is module-level (process-wide) and survives the
140
+ // testDir wipe; its 60s TTL would otherwise leak a prior test's `journal`
141
+ // entry into the next test and flip a "first read" to cached:true. Clear
142
+ // the vault names these tests reuse so each test starts cache-cold.
143
+ invalidateUsageCache("journal");
144
+ invalidateUsageCache("other");
138
145
  if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
139
146
  mkdirSync(testDir, { recursive: true });
140
147
  mkdirSync(join(testDir, "vault", "data"), { recursive: true });
@@ -1980,3 +1987,181 @@ describe("/vault/<name>/.parachute/mirror/run-now — auth + dispatch", () => {
1980
1987
  expect([405, 503]).toContain(res.status);
1981
1988
  });
1982
1989
  });
1990
+
1991
+ // ---------------------------------------------------------------------------
1992
+ // /vault/<name>/.parachute/usage — per-vault data-footprint endpoint.
1993
+ //
1994
+ // READ-scoped (a vault's own user must see their own vault's size; the
1995
+ // operator inherits read via broad/admin). Reports counts + byte sizes; the
1996
+ // two dir-walks (assets, mirror) are TTL-cached, `?fresh=1` bypasses, and an
1997
+ // attachment upload invalidates the cache. These tests exercise the auth
1998
+ // gate, the response shape, the cache hit/bypass, and upload-invalidation
1999
+ // end-to-end through `route()` against the real (tmp) filesystem.
2000
+ // ---------------------------------------------------------------------------
2001
+
2002
+ describe("/vault/<name>/.parachute/usage — data-footprint endpoint", () => {
2003
+ const USAGE_PATH = "/vault/journal/.parachute/usage";
2004
+
2005
+ function authedGet(token: string, path = USAGE_PATH): Request {
2006
+ return new Request(`http://localhost:1940${path}`, {
2007
+ headers: { authorization: `Bearer ${token}` },
2008
+ });
2009
+ }
2010
+
2011
+ /** Upload an attachment through the real storage route (write-scoped). */
2012
+ async function uploadAttachment(token: string, bytes: number): Promise<Response> {
2013
+ const form = new FormData();
2014
+ const file = new File([new Uint8Array(bytes).fill(7)], "photo.png", { type: "image/png" });
2015
+ form.set("file", file);
2016
+ const p = "/vault/journal/api/storage/upload";
2017
+ return route(
2018
+ new Request(`http://localhost:1940${p}`, {
2019
+ method: "POST",
2020
+ headers: { authorization: `Bearer ${token}` },
2021
+ body: form,
2022
+ }),
2023
+ p,
2024
+ );
2025
+ }
2026
+
2027
+ test("unauthenticated → 401", async () => {
2028
+ createVault("journal");
2029
+ const res = await route(new Request(`http://localhost:1940${USAGE_PATH}`), USAGE_PATH);
2030
+ expect(res.status).toBe(401);
2031
+ });
2032
+
2033
+ test("read-scoped token → 200 with the full shape (owner sees own vault)", async () => {
2034
+ createVault("journal");
2035
+ // Seed two notes so counts + contentBytes are non-trivial.
2036
+ const store = getVaultStore("journal");
2037
+ await store.createNote("hello");
2038
+ await store.createNote("world!");
2039
+
2040
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2041
+ const res = await route(authedGet(token), USAGE_PATH);
2042
+ expect(res.status).toBe(200);
2043
+
2044
+ const body = (await res.json()) as {
2045
+ counts: { notes: number; attachments: number; links: number; tags: number };
2046
+ bytes: { content: number; db: number; assets: number; total: number; mirror?: number };
2047
+ computedAt: string;
2048
+ cached: boolean;
2049
+ };
2050
+
2051
+ // counts match getVaultStats
2052
+ const stats = await store.getVaultStats();
2053
+ expect(body.counts.notes).toBe(stats.totalNotes);
2054
+ expect(body.counts.notes).toBe(2);
2055
+ expect(body.counts.attachments).toBe(stats.attachmentCount);
2056
+ expect(body.counts.links).toBe(stats.linkCount);
2057
+ expect(body.counts.tags).toBe(stats.tagCount);
2058
+
2059
+ // bytes shape
2060
+ expect(body.bytes.content).toBe(stats.contentBytes);
2061
+ expect(body.bytes.content).toBe(11); // "hello"(5) + "world!"(6)
2062
+ expect(body.bytes.db).toBeGreaterThan(0); // a real SQLite file exists
2063
+ expect(body.bytes.assets).toBe(0); // no uploads yet
2064
+ // total = db + assets (NOT content, NOT mirror)
2065
+ expect(body.bytes.total).toBe(body.bytes.db + body.bytes.assets);
2066
+ // no mirror configured → omitted
2067
+ expect(body.bytes).not.toHaveProperty("mirror");
2068
+
2069
+ expect(typeof body.computedAt).toBe("string");
2070
+ expect(typeof body.cached).toBe("boolean");
2071
+ });
2072
+
2073
+ test("insufficient scope is impossible at read — but no vault: scope at all → 403", async () => {
2074
+ createVault("journal");
2075
+ // A token carrying only a non-vault scope satisfies neither read nor any
2076
+ // vault verb → 403 insufficient_scope.
2077
+ const token = await mintJwt({ vaultName: "journal", scopes: ["openid"] });
2078
+ const res = await route(authedGet(token), USAGE_PATH);
2079
+ expect(res.status).toBe(403);
2080
+ const body = (await res.json()) as { error_type?: string; required_scope?: string };
2081
+ expect(body.error_type).toBe("insufficient_scope");
2082
+ expect(body.required_scope).toBe("vault:read");
2083
+ });
2084
+
2085
+ test("wrong-vault scope is denied (narrowed scope names a different vault)", async () => {
2086
+ createVault("journal");
2087
+ createVault("other");
2088
+ // Token scoped to `other`, used against `journal` → denied.
2089
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:other:read"] });
2090
+ const res = await route(authedGet(token), USAGE_PATH);
2091
+ expect(res.status).toBe(403);
2092
+ const body = (await res.json()) as { error_type?: string };
2093
+ expect(body.error_type).toBe("insufficient_scope");
2094
+ });
2095
+
2096
+ test("write/admin scope inherits read → 200", async () => {
2097
+ createVault("journal");
2098
+ const token = await createAdminToken("journal");
2099
+ const res = await route(authedGet(token), USAGE_PATH);
2100
+ expect(res.status).toBe(200);
2101
+ });
2102
+
2103
+ test("non-GET method → 405", async () => {
2104
+ createVault("journal");
2105
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2106
+ const res = await route(
2107
+ new Request(`http://localhost:1940${USAGE_PATH}`, {
2108
+ method: "POST",
2109
+ headers: { authorization: `Bearer ${token}` },
2110
+ }),
2111
+ USAGE_PATH,
2112
+ );
2113
+ expect(res.status).toBe(405);
2114
+ });
2115
+
2116
+ test("second read within TTL is served from cache (cached:true)", async () => {
2117
+ createVault("journal");
2118
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2119
+
2120
+ const first = (await (await route(authedGet(token), USAGE_PATH)).json()) as { cached: boolean };
2121
+ expect(first.cached).toBe(false);
2122
+
2123
+ const second = (await (await route(authedGet(token), USAGE_PATH)).json()) as { cached: boolean };
2124
+ expect(second.cached).toBe(true);
2125
+ });
2126
+
2127
+ test("?fresh=1 bypasses the cache (cached:false even on a warm cache)", async () => {
2128
+ createVault("journal");
2129
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2130
+
2131
+ // Prime the cache.
2132
+ await route(authedGet(token), USAGE_PATH);
2133
+
2134
+ // ?fresh=1 must recompute regardless. The server passes `url.pathname`
2135
+ // (query stripped) as the `path` arg while the Request URL retains the
2136
+ // query — the route reads `fresh` from `req.url`, not `path`. Mirror that.
2137
+ const reqWithQuery = new Request(`http://localhost:1940${USAGE_PATH}?fresh=1`, {
2138
+ headers: { authorization: `Bearer ${token}` },
2139
+ });
2140
+ const res = await route(reqWithQuery, USAGE_PATH);
2141
+ const body = (await res.json()) as { cached: boolean };
2142
+ expect(body.cached).toBe(false);
2143
+ });
2144
+
2145
+ test("attachment upload invalidates the cache → assets bytes update", async () => {
2146
+ createVault("journal");
2147
+ const writeToken = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:write"] });
2148
+
2149
+ // Prime: assets empty.
2150
+ const before = (await (await route(authedGet(writeToken), USAGE_PATH)).json()) as {
2151
+ bytes: { assets: number };
2152
+ };
2153
+ expect(before.bytes.assets).toBe(0);
2154
+
2155
+ // Upload a 4096-byte attachment (write-scoped) — this invalidates usage.
2156
+ const up = await uploadAttachment(writeToken, 4096);
2157
+ expect(up.status).toBe(201);
2158
+
2159
+ // Next read must re-walk (cache was invalidated) and reflect the new file.
2160
+ const after = (await (await route(authedGet(writeToken), USAGE_PATH)).json()) as {
2161
+ bytes: { assets: number };
2162
+ cached: boolean;
2163
+ };
2164
+ expect(after.cached).toBe(false); // invalidated → recomputed
2165
+ expect(after.bytes.assets).toBeGreaterThanOrEqual(4096);
2166
+ });
2167
+ });
package/src/routing.ts CHANGED
@@ -49,7 +49,7 @@ import {
49
49
  authenticateGlobalRequest,
50
50
  extractApiKey,
51
51
  } from "./auth.ts";
52
- import { hasScopeForVault, SCOPE_ADMIN, scopeForMethod, verbForMethod } from "./scopes.ts";
52
+ import { hasScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
53
53
  import { getVaultStore } from "./vault-store.ts";
54
54
  import { handleScopedMcp } from "./mcp-http.ts";
55
55
  import { defaultAdminSpaDistDir, isAdminSpaPath, serveAdminSpa } from "./admin-spa.ts";
@@ -87,6 +87,7 @@ import {
87
87
  handleMirrorRunNow,
88
88
  } from "./mirror-routes.ts";
89
89
  import { getMirrorManager } from "./mirror-registry.ts";
90
+ import { buildUsageReport } from "./usage.ts";
90
91
 
91
92
  /**
92
93
  * Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
@@ -462,6 +463,35 @@ export async function route(
462
463
  });
463
464
  }
464
465
 
466
+ // /.parachute/usage — per-vault data-footprint report ("how big is this
467
+ // vault"). READ-scoped, deliberately: a vault's own user must be able to
468
+ // see their own vault's size, and the operator (who holds broad/admin)
469
+ // inherits read. This mirrors the bare-root stats precedent above (also
470
+ // read-gated by the method) — same data-disclosure class (counts + sizes,
471
+ // no note content). The expensive part (two dir-walks) is TTL-cached inside
472
+ // usage.ts; `?fresh=1` bypasses the cache. Hub-side aggregation/UI is a
473
+ // separate follow-up; this endpoint is the load-bearing primitive.
474
+ if (subpath === "/.parachute/usage") {
475
+ if (req.method !== "GET") {
476
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
477
+ }
478
+ if (!hasScopeForVault(auth.scopes, vaultName, "read")) {
479
+ return Response.json(
480
+ {
481
+ error: "Forbidden",
482
+ error_type: "insufficient_scope",
483
+ message: `This endpoint requires the '${SCOPE_READ}' scope (or '${SCOPE_READ.replace("vault:", `vault:${vaultName}:`)}').`,
484
+ required_scope: SCOPE_READ,
485
+ granted_scopes: auth.scopes,
486
+ },
487
+ { status: 403 },
488
+ );
489
+ }
490
+ const fresh = new URL(req.url).searchParams.get("fresh") === "1";
491
+ const stats = await store.getVaultStats();
492
+ return Response.json(buildUsageReport(vaultName, stats, { fresh }));
493
+ }
494
+
465
495
  // The per-vault `/tokens` REST surface (pvt_* mint/list/revoke) was removed
466
496
  // at 0.5.0 (vault#282 Stage 2 — vault is a pure hub resource-server). Hub
467
497
  // JWTs are minted via hub's registry (`/api/auth/mint-token`); a `/tokens`
@@ -708,7 +738,7 @@ export async function route(
708
738
  writeVaultConfig(vaultConfig);
709
739
  });
710
740
  }
711
- if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
741
+ if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store, tagScope);
712
742
  if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName, store, tagScope);
713
743
  if (apiPath === "/health") return Response.json({ status: "ok", vault: vaultName });
714
744
 
package/src/server.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  } from "./mirror-config.ts";
46
46
  import { GLOBAL_CONFIG_PATH } from "./config.ts";
47
47
  import { selfRegister } from "./self-register.ts";
48
+ import { warnLegacyGlobalApiKeys } from "./auth.ts";
48
49
  import pkg from "../package.json" with { type: "json" };
49
50
 
50
51
  // Register webhook triggers from global config. Replaces the old hardcoded
@@ -144,6 +145,12 @@ if (process.env.VAULT_AUTH_TOKEN?.trim()) {
144
145
  console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
145
146
  }
146
147
 
148
+ // Legacy GLOBAL api_keys warning (security review — multi-user hardening).
149
+ // Surfaced at boot so the operator rotates/removes cross-vault credentials
150
+ // before multi-user sharing. Warning only — never touches the keys. The
151
+ // logic lives in auth.ts (import-safe + unit-tested); see warnLegacyGlobalApiKeys.
152
+ warnLegacyGlobalApiKeys(readGlobalConfig().api_keys);
153
+
147
154
  // Auto-init: create a default vault if none exist (first run in Docker).
148
155
  // The vault name comes from PARACHUTE_VAULT_NAME when set + valid; otherwise
149
156
  // falls back to "default". Hub's first-boot wizard (hub#267) passes through
@@ -215,3 +215,165 @@ describe("storage GET tag-scope enforcement", () => {
215
215
  expect(res.status).toBe(403);
216
216
  });
217
217
  });
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // GET percent-encoded-slash handling (feedback finding D).
221
+ //
222
+ // `path` reaches handleStorage from `url.pathname`, which keeps an encoded
223
+ // `%2F` slash LITERAL (WHATWG). The old `path.match(/^\/([^/]+)\/(.+)$/)`
224
+ // required a literal slash, so `/api/storage/<date>%2F<file>` fell to the
225
+ // unconditional 404 — a trap-grade asymmetry with the single-note routes,
226
+ // which decode their first segment and therefore REQUIRE `%2F`. The fix
227
+ // decodes `path` before matching, accepting BOTH forms; the decoded path is
228
+ // also what the DB stores (`${date}/${filename}`), so tag-scope lookup and
229
+ // the traversal guard keep working. These tests pin both forms + the
230
+ // guard-safety of the decode.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ describe("storage GET percent-encoded slash (finding D)", () => {
234
+ const VAULT = "encode-vault";
235
+
236
+ async function setup(): Promise<{
237
+ store: SqliteStore;
238
+ assets: string;
239
+ inScopePath: string;
240
+ outScopePath: string;
241
+ }> {
242
+ const store = freshStore();
243
+ const assets = join(testDir, "assets", VAULT, "data");
244
+ mkdirSync(join(assets, "2026-05-28"), { recursive: true });
245
+ process.env.ASSETS_DIR = assets;
246
+
247
+ const workNote = await store.createNote("work note", { tags: ["work"] });
248
+ const healthNote = await store.createNote("health note", { tags: ["health"] });
249
+
250
+ const inScopePath = "2026-05-28/work-asset.pdf";
251
+ const outScopePath = "2026-05-28/health-asset.pdf";
252
+ writeFileSync(join(assets, inScopePath), Buffer.from([0x25, 0x50, 0x44, 0x46])); // %PDF
253
+ writeFileSync(join(assets, outScopePath), Buffer.from([0x25, 0x50, 0x44, 0x46]));
254
+
255
+ await store.addAttachment(workNote.id, inScopePath, "application/pdf");
256
+ await store.addAttachment(healthNote.id, outScopePath, "application/pdf");
257
+
258
+ return { store, assets, inScopePath, outScopePath };
259
+ }
260
+
261
+ // The request URL carries the encoded form; the `path` arg mirrors what the
262
+ // dispatcher hands the handler (derived from url.pathname, %2F kept literal).
263
+ function getReqEncoded(reqPath: string): { req: Request; path: string } {
264
+ const encoded = reqPath.replace(/\//g, "%2F");
265
+ return {
266
+ req: new Request(`http://localhost:1940/storage/${encoded}`, { method: "GET" }),
267
+ path: `/${encoded}`,
268
+ };
269
+ }
270
+
271
+ test("encoded %2F path serves the same bytes as the literal form (200)", async () => {
272
+ const { store, inScopePath } = await setup();
273
+ const ctx = await tagScopeCtx(store, ["work"]);
274
+ const { req, path } = getReqEncoded(inScopePath);
275
+ const res = await handleStorage(req, path, VAULT, store, ctx);
276
+ expect(res.status).toBe(200);
277
+ expect(res.headers.get("Content-Type")).toBe("application/pdf");
278
+ expect((await res.arrayBuffer()).byteLength).toBe(4);
279
+ });
280
+
281
+ test("literal-slash path still serves (regression)", async () => {
282
+ const { store, inScopePath } = await setup();
283
+ const ctx = await tagScopeCtx(store, ["work"]);
284
+ const res = await handleStorage(
285
+ new Request(`http://localhost:1940/storage/${inScopePath}`, { method: "GET" }),
286
+ `/${inScopePath}`,
287
+ VAULT,
288
+ store,
289
+ ctx,
290
+ );
291
+ expect(res.status).toBe(200);
292
+ expect((await res.arrayBuffer()).byteLength).toBe(4);
293
+ });
294
+
295
+ test("encoded traversal %2E%2E%2F… → 403 (decoded `..` hits the traversal guard)", async () => {
296
+ const { store } = await setup();
297
+ const ctx = await tagScopeCtx(store, ["work"]);
298
+ // Fully percent-encoded `/a/../../../../../../etc/passwd`. Decode yields
299
+ // the literal traversal, which resolves outside assetsDir → 403.
300
+ const evilDecoded = "/a/../../../../../../etc/passwd";
301
+ const evilEncoded = "/a%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2Fetc%2Fpasswd";
302
+ expect(decodeURIComponent(evilEncoded)).toBe(evilDecoded);
303
+ const res = await handleStorage(
304
+ new Request(`http://localhost:1940/storage${evilEncoded}`, { method: "GET" }),
305
+ evilEncoded,
306
+ VAULT,
307
+ store,
308
+ ctx,
309
+ );
310
+ expect(res.status).toBe(403);
311
+ });
312
+
313
+ test("tag-scoped token + out-of-scope owning note → still 404 with an encoded path", async () => {
314
+ const { store, outScopePath } = await setup();
315
+ const ctx = await tagScopeCtx(store, ["work"]);
316
+ const { req, path } = getReqEncoded(outScopePath);
317
+ const res = await handleStorage(req, path, VAULT, store, ctx);
318
+ expect(res.status).toBe(404);
319
+ });
320
+
321
+ test("malformed `%` (e.g. /api/storage/2026%2) → 404, not 500", async () => {
322
+ const { store } = await setup();
323
+ const ctx = await tagScopeCtx(store, ["work"]);
324
+ // `%2` is not a valid percent-escape → decodeURIComponent throws → 404.
325
+ const bad = "/2026%2";
326
+ expect(() => decodeURIComponent(bad)).toThrow();
327
+ const res = await handleStorage(
328
+ new Request(`http://localhost:1940/storage${bad}`, { method: "GET" }),
329
+ bad,
330
+ VAULT,
331
+ store,
332
+ ctx,
333
+ );
334
+ expect(res.status).toBe(404);
335
+ });
336
+
337
+ test("double-encoded %252F → 404 (decodes ONCE to literal %2F, no slash, no second decode)", async () => {
338
+ const { store } = await setup();
339
+ const ctx = await tagScopeCtx(store, ["work"]);
340
+ // `%252F` → decodeURIComponent once → `%2F` (a literal `%2F`, NOT a slash).
341
+ // The single decode is deliberate: a second decode would turn this into a
342
+ // real slash and risk serving / re-looping. With one decode the path has
343
+ // no `/` separator, so the date/file match fails → 404.
344
+ const doubleEncoded = "/2026-05-28%252Ffile.bin";
345
+ expect(decodeURIComponent(doubleEncoded)).toBe("/2026-05-28%2Ffile.bin");
346
+ const res = await handleStorage(
347
+ new Request(`http://localhost:1940/storage${doubleEncoded}`, { method: "GET" }),
348
+ doubleEncoded,
349
+ VAULT,
350
+ store,
351
+ ctx,
352
+ );
353
+ expect(res.status).toBe(404);
354
+ });
355
+ });
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // POST parity (finding D — decode block must not change upload behavior).
359
+ //
360
+ // The `POST /upload` branch returns BEFORE the decode block, so a real upload
361
+ // is untouched. A POST to a non-`/upload` storage path with a malformed `%`
362
+ // falls past the upload branch into the decode (the `try/catch` is not method-
363
+ // gated), where the throw → 404 — the same status the pre-fix unconditional
364
+ // final 404 produced for this request. Pins that the decode doesn't turn a
365
+ // malformed-`%` POST into a 500 and keeps POST behavior at parity.
366
+ // ---------------------------------------------------------------------------
367
+
368
+ describe("storage POST parity (finding D)", () => {
369
+ test("POST to a malformed-`%` storage path → 404, unchanged by the GET-side decode", async () => {
370
+ const bad = "/2026%2";
371
+ const res = await handleStorage(
372
+ new Request(`http://localhost:1940/storage${bad}`, { method: "POST" }),
373
+ bad,
374
+ "default",
375
+ uploadStore,
376
+ );
377
+ expect(res.status).toBe(404);
378
+ });
379
+ });
package/src/tag-scope.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * `rootOf` walk is needed at the boundary.
20
20
  */
21
21
 
22
- import type { Store, Note } from "../core/src/types.ts";
22
+ import type { Store, Note, HydratedLink, NoteSummary } from "../core/src/types.ts";
23
23
 
24
24
  /**
25
25
  * Build the effective tag-allowlist for a token: union of `{root} ∪
@@ -100,6 +100,73 @@ export function tagsWithinScope(
100
100
  return false;
101
101
  }
102
102
 
103
+ /**
104
+ * Build a per-note visibility predicate for wikilink expansion
105
+ * (`ExpandContext.isVisible` in core/src/expand.ts). When the token is
106
+ * unscoped (`rawRoots === null`) this returns `undefined` so the expander
107
+ * keeps its original scope-unaware behavior (no predicate installed). When
108
+ * scoped, it returns a closure over the SAME `noteWithinTagScope` allowlist
109
+ * logic every other read path uses — so a wikilink whose target is outside
110
+ * the token's tag scope is left unresolved during inlining, never embedded.
111
+ *
112
+ * This is the seam that keeps core scope-unaware: the predicate is built
113
+ * server-side from the tag-scope machinery and injected into the context;
114
+ * core only calls it.
115
+ */
116
+ export function buildExpandVisibility(
117
+ allowed: Set<string> | null,
118
+ rawRoots: string[] | null,
119
+ ): ((note: Note) => boolean) | undefined {
120
+ if (rawRoots === null) return undefined;
121
+ return (note: Note) => noteWithinTagScope(note, allowed, rawRoots);
122
+ }
123
+
124
+ /**
125
+ * Treat a hydrated link's endpoint summary as a scope-checkable note. The
126
+ * summary carries `id` + `tags`, which is all `noteWithinTagScope` needs.
127
+ * A summary with no tags is out of scope under a tag-scoped token (same as
128
+ * a real note with no tags).
129
+ */
130
+ function summaryWithinTagScope(
131
+ summary: NoteSummary | undefined,
132
+ allowed: Set<string> | null,
133
+ rawRoots: string[] | null,
134
+ ): boolean {
135
+ if (!summary) return true; // no summary → no out-of-scope info to leak (dangling/deleted)
136
+ return noteWithinTagScope({ id: summary.id, tags: summary.tags ?? [] } as Note, allowed, rawRoots);
137
+ }
138
+
139
+ /**
140
+ * Filter hydrated links for a tag-scoped token so no out-of-scope NEIGHBOR
141
+ * metadata leaks (security review — MINOR). A `query-notes`/REST response
142
+ * with `include_links=true` returns `sourceNote`/`targetNote` summaries
143
+ * ({id, path, tags, metadata, …}) for every link touching the returned
144
+ * note — INCLUDING links to notes the token can't otherwise see. Those
145
+ * summaries leak the out-of-scope neighbor's id, path, and tags.
146
+ *
147
+ * Policy: drop any link whose source OR target endpoint summary is outside
148
+ * the token's tag scope. The queried note itself is always in-scope (it had
149
+ * to be visible to be returned), so dropping out-of-scope-neighbor links
150
+ * removes exactly the leak while keeping every link between in-scope notes
151
+ * fully hydrated. Dropping the whole row (vs. just nulling the summary) is
152
+ * required because the raw row still carries the neighbor's note id.
153
+ *
154
+ * No-op when the token is unscoped (`rawRoots === null`) — identical to the
155
+ * pre-fix behavior.
156
+ */
157
+ export function filterHydratedLinksByTagScope(
158
+ links: HydratedLink[],
159
+ allowed: Set<string> | null,
160
+ rawRoots: string[] | null,
161
+ ): HydratedLink[] {
162
+ if (rawRoots === null) return links;
163
+ return links.filter(
164
+ (link) =>
165
+ summaryWithinTagScope(link.sourceNote, allowed, rawRoots) &&
166
+ summaryWithinTagScope(link.targetNote, allowed, rawRoots),
167
+ );
168
+ }
169
+
103
170
  /**
104
171
  * Standard 403 response shape for tag-scope rejections. Mirrors the
105
172
  * `insufficient_scope` 403 shape used elsewhere in the API so clients