@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.
@@ -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-recreate "default" on next boot. Create a vault with:` +
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
- if (batched) db.exec("BEGIN");
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 BEGIN blockouter catch rolls the batch
1149
- // back, same shape as the path-conflict path.
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 via the outer BEGIN/ROLLBACK.
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
- if (batched) db.exec("COMMIT");
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
- if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
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
- 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;
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(tagName, {
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 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",
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
- ".pdf": "application/pdf",
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
- const ext = extname(file.name).toLowerCase();
2871
- if (!ALLOWED_EXTENSIONS.has(ext)) {
2872
- return json({ error: `File type ${ext} not allowed` }, 400);
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
  }