@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.
- package/core/src/core.test.ts +183 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +77 -0
- package/core/src/mcp.ts +130 -22
- package/core/src/notes.ts +36 -0
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/schema.ts +7 -4
- package/core/src/store.ts +1 -1
- package/core/src/tag-schemas.ts +59 -44
- package/core/src/types.ts +31 -3
- package/package.json +1 -1
- package/src/auth.test.ts +37 -1
- package/src/auth.ts +29 -0
- package/src/cli.ts +125 -9
- package/src/config.test.ts +16 -0
- package/src/config.ts +39 -0
- package/src/mcp-tools.ts +60 -6
- package/src/routes.ts +486 -53
- package/src/routing.test.ts +185 -0
- package/src/routing.ts +32 -2
- package/src/server.ts +7 -0
- package/src/storage.test.ts +162 -0
- package/src/tag-scope.ts +68 -1
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +194 -2
- package/src/vault.test.ts +1064 -7
package/src/routing.test.ts
CHANGED
|
@@ -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
|
package/src/storage.test.ts
CHANGED
|
@@ -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
|