@openparachute/vault 0.6.4-rc.9 → 0.6.5-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/README.md +6 -4
- package/core/src/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- 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 +313 -316
- package/core/src/notes.ts +73 -57
- package/core/src/portable-md-batching.test.ts +67 -0
- package/core/src/portable-md-golden.test.ts +55 -0
- package/core/src/portable-md.ts +579 -435
- package/core/src/schema.ts +21 -43
- package/core/src/store.ts +79 -11
- package/core/src/tag-hierarchy.ts +26 -0
- package/core/src/txn.test.ts +161 -0
- package/core/src/txn.ts +78 -0
- package/core/src/types.ts +24 -0
- package/package.json +1 -1
- package/src/bare-tag-guard-routes.test.ts +72 -0
- package/src/cli.ts +1 -1
- package/src/first-boot-create.test.ts +155 -0
- package/src/routes.ts +198 -32
- package/src/routing.test.ts +125 -0
- package/src/routing.ts +10 -2
- package/src/self-register.test.ts +4 -4
- package/src/self-register.ts +1 -2
- package/src/server.ts +58 -38
- package/src/storage.test.ts +75 -8
- 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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical-bare-tag guard (vault#XXX) — REST layer. Confirms the PUT
|
|
3
|
+
* /tags/:name route normalizes the upserted tag NAME (so the partial-merge read
|
|
4
|
+
* + write land on the bare row) and leaves the rename SOURCE-name lookup literal
|
|
5
|
+
* (the migration escape hatch — a #-prefixed legacy tag must still be
|
|
6
|
+
* rename-able away). The query + note-tag write paths normalize in the store and
|
|
7
|
+
* are covered by core/src/bare-tag-guard.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
13
|
+
import { initSchema } from "../core/src/schema.ts";
|
|
14
|
+
import { handleTags } from "./routes.ts";
|
|
15
|
+
|
|
16
|
+
let store: SqliteStore;
|
|
17
|
+
let db: Database;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
db = new Database(":memory:");
|
|
21
|
+
initSchema(db);
|
|
22
|
+
store = new SqliteStore(db);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function put(name: string, body: unknown): Promise<Response> {
|
|
26
|
+
const req = new Request(`http://localhost/api/tags/${encodeURIComponent(name)}`, {
|
|
27
|
+
method: "PUT",
|
|
28
|
+
body: JSON.stringify(body),
|
|
29
|
+
});
|
|
30
|
+
return handleTags(req, store, `/${encodeURIComponent(name)}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("PUT /tags/:name — bare-tag guard", () => {
|
|
34
|
+
it("PUT /tags/%23foo with parent_names [#bar] stores the bare row + bare parents", async () => {
|
|
35
|
+
const res = await put("#foo", { parent_names: ["#bar"], description: "child" });
|
|
36
|
+
expect(res.status).toBe(200);
|
|
37
|
+
|
|
38
|
+
const record = await store.getTagRecord("foo");
|
|
39
|
+
expect(record).not.toBeNull();
|
|
40
|
+
expect(record!.parent_names).toEqual(["bar"]);
|
|
41
|
+
expect(await store.getTagRecord("#foo")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("a #-decorated PUT preserves the existing bare record's fields (merge correctness)", async () => {
|
|
45
|
+
await put("thing", { description: "first" });
|
|
46
|
+
await put("#thing", { parent_names: [] });
|
|
47
|
+
const record = await store.getTagRecord("thing");
|
|
48
|
+
expect(record!.description).toBe("first");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("POST /tags/:name/rename — source name stays literal", () => {
|
|
53
|
+
it("rename #agent/message → agent/message finds the literal #-prefixed row", async () => {
|
|
54
|
+
db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('#agent/message')").run();
|
|
55
|
+
const note = await store.createNote("legacy inbound");
|
|
56
|
+
db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, '#agent/message')").run(note.id);
|
|
57
|
+
|
|
58
|
+
const sub = "/" + encodeURIComponent("#agent/message") + "/rename";
|
|
59
|
+
const req = new Request(`http://localhost/api/tags${sub}`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
body: JSON.stringify({ new_name: "agent/message" }),
|
|
62
|
+
});
|
|
63
|
+
const res = await handleTags(req, store, sub);
|
|
64
|
+
expect(res.status).toBe(200);
|
|
65
|
+
const body = (await res.json()) as { renamed?: number };
|
|
66
|
+
expect(body.renamed).toBeGreaterThan(0);
|
|
67
|
+
|
|
68
|
+
const tags = await store.listTags();
|
|
69
|
+
expect(tags.map((t) => t.name)).not.toContain("#agent/message");
|
|
70
|
+
expect(tags.map((t) => t.name)).toContain("agent/message");
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -1664,7 +1664,7 @@ function cmdRemove(args: string[]) {
|
|
|
1664
1664
|
configDirty = true;
|
|
1665
1665
|
console.log(
|
|
1666
1666
|
` Last vault removed — wrote auto_create: false to ${GLOBAL_CONFIG_PATH} so the` +
|
|
1667
|
-
` server won't auto-
|
|
1667
|
+
` server won't auto-create a first vault on next boot. Create one with:` +
|
|
1668
1668
|
` parachute-vault create <name>`,
|
|
1669
1669
|
);
|
|
1670
1670
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the first-boot vault creation gate introduced in
|
|
3
|
+
* vault#478 Part 2 — server creates a vault ONLY when PARACHUTE_VAULT_NAME
|
|
4
|
+
* is explicitly set.
|
|
5
|
+
*
|
|
6
|
+
* Each test spawns a real server subprocess against a fresh PARACHUTE_HOME
|
|
7
|
+
* tmpdir (the stop-signal.test.ts pattern) and probes over HTTP so the
|
|
8
|
+
* actual Bun.serve boot path is exercised, not a mocked code path.
|
|
9
|
+
*
|
|
10
|
+
* Port assignments (chosen to avoid collisions with the running daemon on
|
|
11
|
+
* 1940 and stop-signal.test.ts on 19404):
|
|
12
|
+
* 19410 — test 1: PARACHUTE_VAULT_NAME unset → zero vaults
|
|
13
|
+
* 19411 — test 2: PARACHUTE_VAULT_NAME=testvault → vault created
|
|
14
|
+
*
|
|
15
|
+
* NOTE: the unit-level assertions for resolveFirstBootVaultName (source:
|
|
16
|
+
* "default" / "env" / "env-invalid") already live in vault-name.test.ts.
|
|
17
|
+
* These tests cover the SERVER-LEVEL behaviour — whether a vault is actually
|
|
18
|
+
* created on disk — so they are intentionally non-duplicative.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
22
|
+
import { mkdtempSync, rmSync, mkdirSync, existsSync } from "fs";
|
|
23
|
+
import { tmpdir } from "os";
|
|
24
|
+
import { join, resolve } from "path";
|
|
25
|
+
import { waitForHealthy } from "./health.ts";
|
|
26
|
+
import { listVaults } from "./config.ts";
|
|
27
|
+
|
|
28
|
+
const SERVER_PATH = resolve(import.meta.dir, "server.ts");
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Test 1 — PARACHUTE_VAULT_NAME unset
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const PORT_NO_ENV = 19_410;
|
|
35
|
+
let tmpHomeNoEnv: string;
|
|
36
|
+
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
tmpHomeNoEnv = mkdtempSync(join(tmpdir(), "vault-first-boot-no-env-"));
|
|
39
|
+
mkdirSync(join(tmpHomeNoEnv, "vault"), { recursive: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
rmSync(tmpHomeNoEnv, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("first-boot: PARACHUTE_VAULT_NAME unset → zero vaults", () => {
|
|
47
|
+
test("server is healthy AND no vault was auto-created", async () => {
|
|
48
|
+
const proc = Bun.spawn({
|
|
49
|
+
cmd: ["bun", SERVER_PATH],
|
|
50
|
+
env: {
|
|
51
|
+
...process.env,
|
|
52
|
+
PARACHUTE_HOME: tmpHomeNoEnv,
|
|
53
|
+
PORT: String(PORT_NO_ENV),
|
|
54
|
+
// Must be unset — do NOT inherit from the test runner's shell.
|
|
55
|
+
PARACHUTE_VAULT_NAME: undefined as unknown as string,
|
|
56
|
+
SCRIBE_URL: "",
|
|
57
|
+
},
|
|
58
|
+
stdout: "pipe",
|
|
59
|
+
stderr: "pipe",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const health = await waitForHealthy(PORT_NO_ENV, { totalMs: 15_000 });
|
|
64
|
+
expect(health.status).toBe("healthy");
|
|
65
|
+
|
|
66
|
+
// Verify zero vaults were created inside the tmp home.
|
|
67
|
+
const originalHome = process.env.PARACHUTE_HOME;
|
|
68
|
+
try {
|
|
69
|
+
process.env.PARACHUTE_HOME = tmpHomeNoEnv;
|
|
70
|
+
const vaults = listVaults();
|
|
71
|
+
expect(vaults).toHaveLength(0);
|
|
72
|
+
expect(vaults).not.toContain("default");
|
|
73
|
+
} finally {
|
|
74
|
+
if (originalHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
75
|
+
else process.env.PARACHUTE_HOME = originalHome;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Also assert no vault.yaml was written (belt-and-suspenders).
|
|
79
|
+
// Path structure: <PARACHUTE_HOME>/vault/data/<name>/vault.yaml
|
|
80
|
+
const defaultVaultConfig = join(tmpHomeNoEnv, "vault", "data", "default", "vault.yaml");
|
|
81
|
+
expect(existsSync(defaultVaultConfig)).toBe(false);
|
|
82
|
+
} finally {
|
|
83
|
+
if (!proc.killed) proc.kill();
|
|
84
|
+
await proc.exited;
|
|
85
|
+
}
|
|
86
|
+
}, 25_000);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Test 2 — PARACHUTE_VAULT_NAME=testvault → vault created
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
const PORT_WITH_ENV = 19_411;
|
|
94
|
+
let tmpHomeWithEnv: string;
|
|
95
|
+
|
|
96
|
+
// We need separate beforeAll/afterAll scopes for the two server lifetimes.
|
|
97
|
+
// Bun:test describe blocks share the outer beforeAll/afterAll, so we use
|
|
98
|
+
// module-level variables and a nested describe with its own lifecycle hooks.
|
|
99
|
+
describe("first-boot: PARACHUTE_VAULT_NAME=testvault → vault created", () => {
|
|
100
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
101
|
+
|
|
102
|
+
beforeAll(async () => {
|
|
103
|
+
tmpHomeWithEnv = mkdtempSync(join(tmpdir(), "vault-first-boot-with-env-"));
|
|
104
|
+
mkdirSync(join(tmpHomeWithEnv, "vault"), { recursive: true });
|
|
105
|
+
|
|
106
|
+
proc = Bun.spawn({
|
|
107
|
+
cmd: ["bun", SERVER_PATH],
|
|
108
|
+
env: {
|
|
109
|
+
...process.env,
|
|
110
|
+
PARACHUTE_HOME: tmpHomeWithEnv,
|
|
111
|
+
PORT: String(PORT_WITH_ENV),
|
|
112
|
+
PARACHUTE_VAULT_NAME: "testvault",
|
|
113
|
+
SCRIBE_URL: "",
|
|
114
|
+
},
|
|
115
|
+
stdout: "pipe",
|
|
116
|
+
stderr: "pipe",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const health = await waitForHealthy(PORT_WITH_ENV, { totalMs: 15_000 });
|
|
120
|
+
if (health.status !== "healthy") {
|
|
121
|
+
proc.kill();
|
|
122
|
+
throw new Error(`server failed to become healthy: ${health.status} (${health.error ?? ""})`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterAll(async () => {
|
|
127
|
+
if (proc && !proc.killed) proc.kill();
|
|
128
|
+
await proc?.exited;
|
|
129
|
+
rmSync(tmpHomeWithEnv, { recursive: true, force: true });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('vault "testvault" was created (not "default")', () => {
|
|
133
|
+
const originalHome = process.env.PARACHUTE_HOME;
|
|
134
|
+
try {
|
|
135
|
+
process.env.PARACHUTE_HOME = tmpHomeWithEnv;
|
|
136
|
+
const vaults = listVaults();
|
|
137
|
+
expect(vaults).toContain("testvault");
|
|
138
|
+
expect(vaults).not.toContain("default");
|
|
139
|
+
expect(vaults).toHaveLength(1);
|
|
140
|
+
} finally {
|
|
141
|
+
if (originalHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
142
|
+
else process.env.PARACHUTE_HOME = originalHome;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("vault config dir exists at the correct path", () => {
|
|
147
|
+
// Path structure: <PARACHUTE_HOME>/vault/data/<name>/vault.yaml
|
|
148
|
+
const vaultConfigPath = join(tmpHomeWithEnv, "vault", "data", "testvault", "vault.yaml");
|
|
149
|
+
expect(existsSync(vaultConfigPath)).toBe(true);
|
|
150
|
+
|
|
151
|
+
// "default" must NOT have been created.
|
|
152
|
+
const defaultConfigPath = join(tmpHomeWithEnv, "vault", "data", "default", "vault.yaml");
|
|
153
|
+
expect(existsSync(defaultConfigPath)).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/routes.ts
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
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
|
+
import { transactionAsync } from "../core/src/txn.ts";
|
|
17
18
|
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
18
19
|
import {
|
|
19
20
|
parseContentRange,
|
|
@@ -27,6 +28,8 @@ import { logStrictBypass } from "./scopes.ts";
|
|
|
27
28
|
import * as linkOps from "../core/src/links.ts";
|
|
28
29
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
29
30
|
import { IndexedFieldError } from "../core/src/indexed-fields.ts";
|
|
31
|
+
import { buildVaultProjection, resolveTagInheritance } from "../core/src/vault-projection.ts";
|
|
32
|
+
import { loadSchemaConfig } from "../core/src/schema-defaults.ts";
|
|
30
33
|
import {
|
|
31
34
|
buildExpandVisibility,
|
|
32
35
|
filterHydratedLinksByTagScope,
|
|
@@ -1141,17 +1144,16 @@ async function handleNotesInner(
|
|
|
1141
1144
|
// don't collide with concurrent single-item callers on the shared
|
|
1142
1145
|
// bun:sqlite connection.
|
|
1143
1146
|
const batched = items.length > 1;
|
|
1144
|
-
|
|
1145
|
-
try {
|
|
1147
|
+
const runBatch = async (): Promise<void> => {
|
|
1146
1148
|
for (const item of items) {
|
|
1147
1149
|
// Validate extension before reaching the Store (vault#328).
|
|
1148
|
-
// Thrown inside the
|
|
1149
|
-
//
|
|
1150
|
+
// Thrown inside the batch transaction — rolls the batch back,
|
|
1151
|
+
// same shape as the path-conflict path.
|
|
1150
1152
|
const extension = item.extension !== undefined
|
|
1151
1153
|
? validateExtension(item.extension)
|
|
1152
1154
|
: undefined;
|
|
1153
1155
|
// Strict-schema gate (vault#299) — reject before any write so a
|
|
1154
|
-
// mid-batch violation rolls back
|
|
1156
|
+
// mid-batch violation rolls back the batch transaction.
|
|
1155
1157
|
gateStrictWrite(store, writeCtx, {
|
|
1156
1158
|
path: item.path,
|
|
1157
1159
|
tags: item.tags,
|
|
@@ -1179,9 +1181,10 @@ async function handleNotesInner(
|
|
|
1179
1181
|
|
|
1180
1182
|
created.push((await store.getNote(note.id)) ?? note);
|
|
1181
1183
|
}
|
|
1182
|
-
|
|
1184
|
+
};
|
|
1185
|
+
try {
|
|
1186
|
+
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
1183
1187
|
} catch (e: any) {
|
|
1184
|
-
if (batched) db.exec("ROLLBACK");
|
|
1185
1188
|
// Duck-type for module-boundary robustness (matches the PATCH branch).
|
|
1186
1189
|
if (e && e.code === "PATH_CONFLICT") {
|
|
1187
1190
|
return json(
|
|
@@ -1997,6 +2000,64 @@ export async function handleTags(
|
|
|
1997
2000
|
return json(result);
|
|
1998
2001
|
}
|
|
1999
2002
|
|
|
2003
|
+
// POST /tags/:name/conformance — count existing notes that would VIOLATE a
|
|
2004
|
+
// proposed field spec for the tag (vault#283 tightening warning). Read-only
|
|
2005
|
+
// (POST because it carries a proposed `fields` body). Must precede the
|
|
2006
|
+
// /:name matcher so "conformance" isn't read as a tag name.
|
|
2007
|
+
const conformanceMatch = subpath.match(/^\/([^/]+)\/conformance$/);
|
|
2008
|
+
if (conformanceMatch) {
|
|
2009
|
+
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
2010
|
+
const cTag = decodeURIComponent(conformanceMatch[1]!);
|
|
2011
|
+
if (tagScope.allowed && !tagScope.allowed.has(cTag)) {
|
|
2012
|
+
return json({ error: "Tag not found", tag: cTag }, 404);
|
|
2013
|
+
}
|
|
2014
|
+
const body = (await req.json().catch(() => null)) as
|
|
2015
|
+
| { fields?: Record<string, unknown> | null }
|
|
2016
|
+
| null;
|
|
2017
|
+
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2018
|
+
// The proposed fields the operator intends to save. Sanitized through the
|
|
2019
|
+
// same parse the resolver uses (drop non-object specs). Empty/absent →
|
|
2020
|
+
// nothing to enforce → zero violations.
|
|
2021
|
+
const proposed: Record<string, tagSchemaOps.TagFieldSchema> = {};
|
|
2022
|
+
if (body.fields && typeof body.fields === "object" && !Array.isArray(body.fields)) {
|
|
2023
|
+
for (const [k, v] of Object.entries(body.fields)) {
|
|
2024
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
2025
|
+
proposed[k] = v as tagSchemaOps.TagFieldSchema;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
const report = await store.countTagConformance(cTag, proposed);
|
|
2030
|
+
return json(report);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// GET /tags/:name/effective — the tag's effective (own ∪ inherited) fields +
|
|
2034
|
+
// direct/effective parents + schema-conflict info, drawn from the same
|
|
2035
|
+
// projection vault-info exposes. Read-only inheritance preview for the
|
|
2036
|
+
// Schema editor (vault#283). Must precede the /:name matcher.
|
|
2037
|
+
const effectiveMatch = subpath.match(/^\/([^/]+)\/effective$/);
|
|
2038
|
+
if (effectiveMatch) {
|
|
2039
|
+
if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
|
|
2040
|
+
const eTag = decodeURIComponent(effectiveMatch[1]!);
|
|
2041
|
+
if (tagScope.allowed && !tagScope.allowed.has(eTag)) {
|
|
2042
|
+
return json({ error: "Tag not found", tag: eTag }, 404);
|
|
2043
|
+
}
|
|
2044
|
+
const projection = buildVaultProjection(store.db);
|
|
2045
|
+
const record = await store.getTagRecord(eTag);
|
|
2046
|
+
// Resolve inheritance directly (not via projection.tags, which omits
|
|
2047
|
+
// hierarchy-only tags carrying no own schema) so the editor's preview
|
|
2048
|
+
// works even for a tag the operator is just starting to give fields.
|
|
2049
|
+
const resolved = loadSchemaConfig(store.db);
|
|
2050
|
+
const { effective_parents, effective_fields } = resolveTagInheritance(resolved, eTag);
|
|
2051
|
+
return json({
|
|
2052
|
+
name: eTag,
|
|
2053
|
+
parents: record?.parent_names ?? [],
|
|
2054
|
+
effective_parents,
|
|
2055
|
+
fields: record?.fields ?? null,
|
|
2056
|
+
effective_fields,
|
|
2057
|
+
indexed_fields: projection.indexed_fields,
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2000
2061
|
// Routes with tag name
|
|
2001
2062
|
const nameMatch = subpath.match(/^\/([^/]+)$/);
|
|
2002
2063
|
if (!nameMatch) return json({ error: "Not found" }, 404);
|
|
@@ -2026,7 +2087,15 @@ export async function handleTags(
|
|
|
2026
2087
|
// of { description, fields, relationships, parent_names }; omitted keys
|
|
2027
2088
|
// are preserved, explicit null clears. See patterns/tag-data-model.md.
|
|
2028
2089
|
if (req.method === "PUT") {
|
|
2029
|
-
|
|
2090
|
+
// Canonical-bare-tag guard (vault#XXX): normalize the upserted tag NAME so
|
|
2091
|
+
// the existing-field merge read (store.getTagSchema below) and the upsert
|
|
2092
|
+
// both target the bare row. store.upsertTagRecord re-normalizes (idempotent)
|
|
2093
|
+
// for the write; this keeps the partial-merge read correct for a
|
|
2094
|
+
// `#`-decorated PUT path. (GET/DELETE/rename keep their literal lookups —
|
|
2095
|
+
// rename's source-name must still match a `#`-prefixed legacy row so the
|
|
2096
|
+
// data migration can rename it away.)
|
|
2097
|
+
const putTagName = stripTagHash(tagName);
|
|
2098
|
+
if (tagScope.allowed && !tagScope.allowed.has(putTagName)) {
|
|
2030
2099
|
return tagScopeForbidden(tagScope.raw ?? []);
|
|
2031
2100
|
}
|
|
2032
2101
|
const body = (await req.json()) as {
|
|
@@ -2034,7 +2103,18 @@ export async function handleTags(
|
|
|
2034
2103
|
fields?: Record<string, unknown> | null;
|
|
2035
2104
|
relationships?: Record<string, unknown> | null;
|
|
2036
2105
|
parent_names?: unknown;
|
|
2106
|
+
/**
|
|
2107
|
+
* When true, `fields` is treated as the FULL intended field map for the
|
|
2108
|
+
* tag — fields absent from the payload are DROPPED (a replace, not a
|
|
2109
|
+
* merge). Default false preserves the historical partial-update merge
|
|
2110
|
+
* the MCP `update-tag` tool relies on (omitted keys preserved). The
|
|
2111
|
+
* Schema editor (vault#283) sends the full map + `replace_fields: true`
|
|
2112
|
+
* so removing a field row actually deletes the field. See
|
|
2113
|
+
* patterns/tag-data-model.md.
|
|
2114
|
+
*/
|
|
2115
|
+
replace_fields?: unknown;
|
|
2037
2116
|
};
|
|
2117
|
+
const replaceFields = body.replace_fields === true;
|
|
2038
2118
|
|
|
2039
2119
|
// Validate the relationships payload up front so a bad payload returns
|
|
2040
2120
|
// 400, not a thrown 500. `relationships` is an opaque vocabulary map
|
|
@@ -2071,7 +2151,10 @@ export async function handleTags(
|
|
|
2071
2151
|
}
|
|
2072
2152
|
|
|
2073
2153
|
// Field merge mirrors MCP update-tag — preserves prior keys when the
|
|
2074
|
-
// payload only declares new ones.
|
|
2154
|
+
// payload only declares new ones. UNLESS `replace_fields: true`, in which
|
|
2155
|
+
// case `fields` is the full intended map and absent keys are dropped (the
|
|
2156
|
+
// Schema editor's full-replacement save — vault#283; without this a
|
|
2157
|
+
// removed field row is silently resurrected by the merge).
|
|
2075
2158
|
let fieldsPatch:
|
|
2076
2159
|
| Record<string, tagSchemaOps.TagFieldSchema>
|
|
2077
2160
|
| null
|
|
@@ -2079,12 +2162,17 @@ export async function handleTags(
|
|
|
2079
2162
|
if (body.fields === null) {
|
|
2080
2163
|
fieldsPatch = null;
|
|
2081
2164
|
} else if (body.fields !== undefined) {
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2165
|
+
if (replaceFields) {
|
|
2166
|
+
const full = body.fields as Record<string, tagSchemaOps.TagFieldSchema>;
|
|
2167
|
+
fieldsPatch = Object.keys(full).length > 0 ? full : null;
|
|
2168
|
+
} else {
|
|
2169
|
+
const existing = await store.getTagSchema(putTagName);
|
|
2170
|
+
const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
|
|
2171
|
+
...(existing?.fields ?? {}),
|
|
2172
|
+
...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
|
|
2173
|
+
};
|
|
2174
|
+
fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
|
|
2175
|
+
}
|
|
2088
2176
|
}
|
|
2089
2177
|
|
|
2090
2178
|
// A bad indexed-field name (or an unindexable type, or a cross-tag type
|
|
@@ -2093,7 +2181,7 @@ export async function handleTags(
|
|
|
2093
2181
|
// unchanged on failure (no orphan/lying index). vault#478.
|
|
2094
2182
|
let result;
|
|
2095
2183
|
try {
|
|
2096
|
-
result = await store.upsertTagRecord(
|
|
2184
|
+
result = await store.upsertTagRecord(putTagName, {
|
|
2097
2185
|
...(body.description !== undefined ? { description: body.description } : {}),
|
|
2098
2186
|
...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
|
|
2099
2187
|
...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
|
|
@@ -2821,32 +2909,96 @@ async function handleRetryLegacyInBody(
|
|
|
2821
2909
|
|
|
2822
2910
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
|
2823
2911
|
|
|
2824
|
-
// Storage
|
|
2825
|
-
//
|
|
2826
|
-
//
|
|
2827
|
-
//
|
|
2828
|
-
//
|
|
2829
|
-
//
|
|
2830
|
-
//
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2912
|
+
// Storage upload policy: DENY-LIST (vault#517). A knowledge vault stores
|
|
2913
|
+
// arbitrary files — ebooks, office docs, datasets, archives, binaries — so we
|
|
2914
|
+
// accept ANY upload EXCEPT the handful of types a browser can execute as
|
|
2915
|
+
// active content in our origin when served back from /storage/. (The prior
|
|
2916
|
+
// allowlist rejected the long tail: .epub/.csv/.zip/… all came back "File type
|
|
2917
|
+
// not allowed".)
|
|
2918
|
+
//
|
|
2919
|
+
// BLOCKED — same-origin-XSS / active-content set:
|
|
2920
|
+
// .html/.htm/.xhtml/.shtml/.xht HTML — embeds <script>
|
|
2921
|
+
// .svg XML image — embeds <script>
|
|
2922
|
+
// .xml can carry XSLT / be parsed as XHTML
|
|
2923
|
+
// .js/.mjs/.cjs JavaScript
|
|
2924
|
+
// .css style-injection / UI-redress vector
|
|
2925
|
+
//
|
|
2926
|
+
// Two independent guards keep every STORED file inert when served:
|
|
2927
|
+
// 1. Only the curated MIME_TYPES below map to a real (always passive) type;
|
|
2928
|
+
// every other extension serves as application/octet-stream — a download,
|
|
2929
|
+
// never rendered.
|
|
2930
|
+
// 2. The GET byte-serve response pins `X-Content-Type-Options: nosniff`, so
|
|
2931
|
+
// a browser can't sniff an octet-stream body into an executable type.
|
|
2932
|
+
// The blocklist is belt-and-suspenders on top of those: even if a future MIME
|
|
2933
|
+
// entry or an upstream proxy weakened (1) or (2), these extensions still never
|
|
2934
|
+
// land on disk. If a future use case needs SVG, sanitize on read (strip
|
|
2935
|
+
// <script>/<foreignObject>) and revisit.
|
|
2936
|
+
const BLOCKED_EXTENSIONS = new Set([
|
|
2937
|
+
".html", ".htm", ".xhtml", ".shtml", ".xht",
|
|
2938
|
+
".svg",
|
|
2939
|
+
".xml",
|
|
2940
|
+
".js", ".mjs", ".cjs",
|
|
2941
|
+
".css",
|
|
2835
2942
|
]);
|
|
2836
2943
|
|
|
2944
|
+
// Explicit MIME types for the commonly-previewed formats. Anything accepted
|
|
2945
|
+
// but absent here serves as application/octet-stream — a download, never
|
|
2946
|
+
// rendered (e.g. .pages/.key/.numbers/.azw3/.exe/arbitrary binaries). None of
|
|
2947
|
+
// these map to an active type (text/html, image/svg+xml), so a served asset
|
|
2948
|
+
// can't execute script; `nosniff` on the GET response makes that ironclad.
|
|
2949
|
+
//
|
|
2950
|
+
// INVARIANT: never add an entry that maps to a browser-active type —
|
|
2951
|
+
// text/html, image/svg+xml, application/xhtml+xml, text/javascript,
|
|
2952
|
+
// application/wasm, text/css. Doing so re-enables same-origin execution for
|
|
2953
|
+
// that extension (and would mean it must also join BLOCKED_EXTENSIONS).
|
|
2837
2954
|
const MIME_TYPES: Record<string, string> = {
|
|
2955
|
+
// Audio
|
|
2838
2956
|
".wav": "audio/wav",
|
|
2839
2957
|
".mp3": "audio/mpeg",
|
|
2840
2958
|
".m4a": "audio/mp4",
|
|
2841
2959
|
".ogg": "audio/ogg",
|
|
2960
|
+
".oga": "audio/ogg",
|
|
2961
|
+
".opus": "audio/opus",
|
|
2962
|
+
".aac": "audio/aac",
|
|
2963
|
+
".flac": "audio/flac",
|
|
2842
2964
|
".webm": "audio/webm",
|
|
2965
|
+
// Image
|
|
2843
2966
|
".png": "image/png",
|
|
2844
2967
|
".jpg": "image/jpeg",
|
|
2845
2968
|
".jpeg": "image/jpeg",
|
|
2846
2969
|
".gif": "image/gif",
|
|
2847
2970
|
".webp": "image/webp",
|
|
2848
|
-
".
|
|
2971
|
+
".bmp": "image/bmp",
|
|
2972
|
+
".tiff": "image/tiff",
|
|
2973
|
+
".tif": "image/tiff",
|
|
2974
|
+
".heic": "image/heic",
|
|
2975
|
+
".heif": "image/heif",
|
|
2976
|
+
".avif": "image/avif",
|
|
2977
|
+
// Video
|
|
2849
2978
|
".mp4": "video/mp4",
|
|
2979
|
+
".m4v": "video/x-m4v",
|
|
2980
|
+
".mov": "video/quicktime",
|
|
2981
|
+
// Documents / ebooks / data
|
|
2982
|
+
".pdf": "application/pdf",
|
|
2983
|
+
".epub": "application/epub+zip",
|
|
2984
|
+
".mobi": "application/x-mobipocket-ebook",
|
|
2985
|
+
".txt": "text/plain; charset=utf-8",
|
|
2986
|
+
".md": "text/markdown; charset=utf-8",
|
|
2987
|
+
".markdown": "text/markdown; charset=utf-8",
|
|
2988
|
+
".rtf": "application/rtf",
|
|
2989
|
+
".csv": "text/csv; charset=utf-8",
|
|
2990
|
+
".tsv": "text/tab-separated-values; charset=utf-8",
|
|
2991
|
+
".json": "application/json; charset=utf-8",
|
|
2992
|
+
".doc": "application/msword",
|
|
2993
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
2994
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
2995
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
2996
|
+
".xls": "application/vnd.ms-excel",
|
|
2997
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
2998
|
+
".odt": "application/vnd.oasis.opendocument.text",
|
|
2999
|
+
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
|
3000
|
+
".odp": "application/vnd.oasis.opendocument.presentation",
|
|
3001
|
+
".zip": "application/zip",
|
|
2850
3002
|
};
|
|
2851
3003
|
|
|
2852
3004
|
export async function handleStorage(
|
|
@@ -2867,9 +3019,17 @@ export async function handleStorage(
|
|
|
2867
3019
|
if (file.size > MAX_UPLOAD_BYTES) {
|
|
2868
3020
|
return json({ error: `File too large (${Math.round(file.size / 1024 / 1024)}MB). Max: 100MB` }, 413);
|
|
2869
3021
|
}
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
3022
|
+
// Strip trailing dots/whitespace before extracting the extension so a
|
|
3023
|
+
// `evil.html.` / `evil.svg ` can't slip past the blocklist
|
|
3024
|
+
// (extname("evil.html.") === "."). The blocklist is belt-and-suspenders;
|
|
3025
|
+
// anything that still gets through serves as octet-stream + nosniff anyway.
|
|
3026
|
+
const ext = extname(file.name.replace(/[.\s]+$/, "")).toLowerCase();
|
|
3027
|
+
if (BLOCKED_EXTENSIONS.has(ext)) {
|
|
3028
|
+
// Active-content types only — blocked because they execute as script when
|
|
3029
|
+
// served same-origin from /storage/ (see BLOCKED_EXTENSIONS). Everything
|
|
3030
|
+
// else (incl. unknown/arbitrary files) is accepted and served as a
|
|
3031
|
+
// download (octet-stream + nosniff).
|
|
3032
|
+
return json({ error: `File type ${ext} not allowed (active/executable content)` }, 400);
|
|
2873
3033
|
}
|
|
2874
3034
|
|
|
2875
3035
|
const date = new Date().toISOString().split("T")[0]!;
|
|
@@ -2970,6 +3130,12 @@ export async function handleStorage(
|
|
|
2970
3130
|
headers: {
|
|
2971
3131
|
"Content-Type": contentType,
|
|
2972
3132
|
"Content-Length": String(stat.size),
|
|
3133
|
+
// Defense-in-depth: never let a browser MIME-sniff a stored asset into
|
|
3134
|
+
// an active type (e.g. an octet-stream body sniffed as text/html).
|
|
3135
|
+
// Combined with the upload blocklist (no .svg/.html) this closes the
|
|
3136
|
+
// same-origin XSS surface for served attachments. Mirrors routing.ts's
|
|
3137
|
+
// SPA-asset stance.
|
|
3138
|
+
"X-Content-Type-Options": "nosniff",
|
|
2973
3139
|
},
|
|
2974
3140
|
});
|
|
2975
3141
|
}
|