@cosmicdrift/kumiko-framework 0.315.0 → 0.317.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.315.0",
3
+ "version": "0.317.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.315.0",
202
- "@cosmicdrift/kumiko-types": "0.315.0",
201
+ "@cosmicdrift/kumiko-http": "0.317.0",
202
+ "@cosmicdrift/kumiko-types": "0.317.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.315.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.317.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -108,6 +108,15 @@ describe("runSchemaCli — no-DB paths", () => {
108
108
  expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
109
109
  });
110
110
 
111
+ test("generate <name> --dry-run is rejected, exits 1, writes no migration (there is no such flag)", async () => {
112
+ writeSchemaFile(appCwd, "tbl_a");
113
+ const cap = captureOut();
114
+ const code = await runSchemaCli(["generate", "init", "--dry-run"], appCwd, cap.out);
115
+ expect(code).toBe(1);
116
+ expect(cap.err.join("\n")).toContain("Unrecognized argument(s): --dry-run");
117
+ expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
118
+ });
119
+
111
120
  test("generate with a hyphenated name still works", async () => {
112
121
  writeSchemaFile(appCwd, "tbl_a");
113
122
  const cap = captureOut();
@@ -1237,3 +1237,21 @@ export async function deleteManyBatched(
1237
1237
  export async function transaction<T>(db: AnyDb, fn: (tx: BunDbRunner) => Promise<T>): Promise<T> {
1238
1238
  return (await asRawClient(db).begin(async (tx) => fn(tx as BunDbRunner))) as T;
1239
1239
  }
1240
+
1241
+ // A tx/savepoint handle has no begin(); fail closed instead of nesting silently or TypeError-ing.
1242
+ export async function runInNewTransaction<T>(
1243
+ db: unknown,
1244
+ fn: (tx: DbRunner) => Promise<T>,
1245
+ ): Promise<T> {
1246
+ const raw = asRawClient(db) as unknown as {
1247
+ begin?: <TResult>(cb: (tx: unknown) => Promise<TResult>) => Promise<TResult>;
1248
+ };
1249
+ if (typeof raw.begin !== "function") {
1250
+ throw new InternalError({
1251
+ message:
1252
+ "runInNewTransaction: runner has no begin() — already inside a transaction or savepoint.",
1253
+ });
1254
+ }
1255
+ // @cast-boundary driver tx handle → DbRunner at the single seam
1256
+ return raw.begin((tx) => fn(tx as DbRunner));
1257
+ }
package/src/changes.json CHANGED
@@ -1,4 +1,29 @@
1
1
  [
2
+ {
3
+ "version": "0.317.0",
4
+ "type": "fix",
5
+ "title": "Changeset block parser keeps blank lines inside a multiline field",
6
+ "detail": "A blank line inside a `detail:`/`migration:` `|` block ended the\ncontinuation early because the check only looked for a leading two-space\nindent. The next indented line then no longer matched `key: value` on its\nown and parseChangesetChanges threw \"invalid kumiko-changes line\" instead\nof the real cause. Blank lines inside the block are now kept as paragraph\nbreaks in the field value."
7
+ },
8
+ {
9
+ "version": "0.317.0",
10
+ "type": "breaking",
11
+ "title": "\"files: a valid upload (png/jpeg/gif/webp/pdf/doc/docx) under the wrong extension is normalized to its real type instead of rejected, when that type is in the field's accept list or the upload has no accept restriction at all\"",
12
+ "detail": "A real PNG or WebP saved with a .jpg extension (a common case for renamed\nscreenshots or messenger downloads) was rejected with content_mismatch\neven when the field's accept explicitly allows png/webp, or when the upload\nis unattached and has no accept restriction at all. validateFileContent now\nalso accepts the field's accept list; when the sniffed byte signature\ndoesn't match the declared extension but the sniffed type's own extension is\nin accept, the upload proceeds under the sniffed mimeType with a storage key\nbuilt from the corrected extension, and the response reports the corrected\nmimeType. When there is no accept restriction at all, any recognized\nsignature (png, jpeg, gif, webp, pdf, doc, docx) normalizes the same way.\nThe original filename is kept as the display name. A sniffed type whose\nextension is not in a non-empty accept is still rejected with\ncontent_mismatch.",
13
+ "migration": "validateFileContent's signature changes from (fileName: string, content: Uint8Array): string | null to (fileName: string, content: Uint8Array, accept?: readonly string[]): FileContentValidationResult ({kind:\"ok\"} | {kind:\"normalized\", extension, mimeType} | {kind:\"rejected\", error}). A direct caller that checked \"if (result)\" for an error must switch to checking result.kind === \"rejected\" and reading result.error — every branch of the new return type is a truthy object, so an unmigrated truthy check would now reject every upload. No caller besides the built-in POST /api/files route calls this function directly today (checked across kumiko-framework, kumiko-enterprise, kumiko-platform, kumiko-studio and every app consumer). buildStorageKey gains an optional trailing extensionOverride parameter; existing calls without it are unaffected."
14
+ },
15
+ {
16
+ "version": "0.317.0",
17
+ "type": "improvement",
18
+ "title": "runInOwnTransaction(tenantDb, fn) runs fn in a fresh transaction on a TenantDb's pool runner",
19
+ "detail": "Exported from @cosmicdrift/kumiko-framework/db. fn receives a TenantDb with the same tenant, mode and grants bound to the new transaction. Fails closed for a TenantDb not built by createTenantDb, a memberReadOnly TenantDb, and a runner that is already inside a transaction."
20
+ },
21
+ {
22
+ "version": "0.316.0",
23
+ "type": "fix",
24
+ "title": "schema generate rejects unrecognized trailing arguments",
25
+ "detail": "`kumiko-schema generate <name>` only ever read `argv[1]` as the migration\nname; any further argument (e.g. a typo'd `generate my-migration --dry-run`\n— there is no dry-run flag) was silently ignored and the migration was\nwritten anyway, with no indication the trailing argument did nothing.\n`generate` now exits 1 with \"Unrecognized argument(s): ...\" and writes\nnothing when called with more than a name."
26
+ },
2
27
  {
3
28
  "version": "0.315.0",
4
29
  "type": "breaking",
@@ -0,0 +1,166 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
3
+ import { createBooleanField, createEntity, createTextField } from "../../engine";
4
+ import { AccessDeniedError, InternalError } from "../../errors";
5
+ import {
6
+ createTestDb,
7
+ type TestDb,
8
+ TestUsers,
9
+ testTenantId,
10
+ unsafeCreateEntityTable,
11
+ } from "../../stack";
12
+ import type { TableColumns } from "../dialect";
13
+ import { buildEntityTable } from "../table-builder";
14
+ import {
15
+ assertPersonalDataWrite,
16
+ createTenantDb,
17
+ type PersonalDataGate,
18
+ runInOwnTransaction,
19
+ } from "../tenant-db";
20
+
21
+ const entity = createEntity({
22
+ table: "run_in_own_tx_items",
23
+ fields: {
24
+ name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
25
+ isActive: createBooleanField({ default: true }),
26
+ },
27
+ softDelete: true,
28
+ });
29
+
30
+ const table: TableColumns = buildEntityTable("runInOwnTxItem", entity);
31
+
32
+ let testDb: TestDb;
33
+ const tenant1 = TestUsers.admin; // tenantId: 1
34
+ const tenant2 = TestUsers.otherTenant; // tenantId: 2
35
+
36
+ beforeAll(async () => {
37
+ testDb = await createTestDb();
38
+ await unsafeCreateEntityTable(testDb.db, entity, "runInOwnTxItem");
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await testDb.cleanup();
43
+ });
44
+
45
+ describe("runInOwnTransaction", () => {
46
+ test("the tenant filter holds on the tx-bound db", async () => {
47
+ const tdb1 = createTenantDb(testDb.db, tenant1.tenantId);
48
+ const tdb2 = createTenantDb(testDb.db, tenant2.tenantId);
49
+ const marker = `shared-name-${crypto.randomUUID()}`;
50
+ await tdb2.insertOne(table, { name: marker });
51
+
52
+ const rows = await runInOwnTransaction(tdb1, async (txDb) => {
53
+ await txDb.insertOne(table, { name: marker });
54
+ return txDb.selectMany(table, { name: marker });
55
+ });
56
+
57
+ expect(rows).toHaveLength(1);
58
+ expect(rows[0]!["tenantId"]).toBe(testTenantId(1));
59
+ });
60
+
61
+ test("a throw in fn rolls back the tx's own writes", async () => {
62
+ const tdb = createTenantDb(testDb.db, tenant1.tenantId);
63
+ const marker = `rollback-${crypto.randomUUID()}`;
64
+
65
+ await expect(
66
+ runInOwnTransaction(tdb, async (txDb) => {
67
+ await txDb.insertOne(table, { name: marker });
68
+ throw new Error("boom");
69
+ }),
70
+ ).rejects.toThrow("boom");
71
+
72
+ const rows = await tdb.selectMany(table, { name: marker });
73
+ expect(rows).toHaveLength(0);
74
+ });
75
+
76
+ test("commits the tx's writes on success", async () => {
77
+ const tdb = createTenantDb(testDb.db, tenant1.tenantId);
78
+ const marker = `commit-${crypto.randomUUID()}`;
79
+
80
+ await runInOwnTransaction(tdb, async (txDb) => {
81
+ await txDb.insertOne(table, { name: marker });
82
+ });
83
+
84
+ const rows = await tdb.selectMany(table, { name: marker });
85
+ expect(rows).toHaveLength(1);
86
+ });
87
+
88
+ test("throws InternalError for a TenantDb not built by createTenantDb", async () => {
89
+ const handBuilt = { tenantId: testTenantId(1), mode: "tenant" } as unknown as TenantDb;
90
+
91
+ await expect(runInOwnTransaction(handBuilt, async () => undefined)).rejects.toThrow(
92
+ InternalError,
93
+ );
94
+ });
95
+
96
+ test("throws InternalError for a throwing guard Proxy, without the proxy's get trap firing", async () => {
97
+ let getTrapFired = false;
98
+ const guardProxy = new Proxy(
99
+ {},
100
+ {
101
+ get() {
102
+ getTrapFired = true;
103
+ throw new Error("systemScope guard: property access denied");
104
+ },
105
+ },
106
+ ) as unknown as TenantDb;
107
+
108
+ await expect(runInOwnTransaction(guardProxy, async () => undefined)).rejects.toThrow(
109
+ InternalError,
110
+ );
111
+ expect(getTrapFired).toBe(false);
112
+ });
113
+
114
+ test("throws InternalError for a memberReadOnly TenantDb", async () => {
115
+ const tdb = createTenantDb(
116
+ testDb.db,
117
+ tenant1.tenantId,
118
+ "tenant",
119
+ undefined,
120
+ undefined,
121
+ undefined,
122
+ { memberReadOnly: true },
123
+ );
124
+
125
+ await expect(runInOwnTransaction(tdb, async () => undefined)).rejects.toThrow(InternalError);
126
+ });
127
+
128
+ test("throws InternalError when the runner has no begin() (already inside a transaction)", async () => {
129
+ const tdb = createTenantDb(testDb.db, tenant1.tenantId);
130
+
131
+ await expect(
132
+ runInOwnTransaction(tdb, async (txDb) => runInOwnTransaction(txDb, async () => undefined)),
133
+ ).rejects.toThrow(InternalError);
134
+ });
135
+
136
+ test("a runner-bound personalDataGate (not declared on this TenantDb) still applies on the tx-bound db", async () => {
137
+ const denyEmail: PersonalDataGate = (_tableName, keys) => {
138
+ if (keys.includes("email")) {
139
+ throw new AccessDeniedError({ message: "personal data blocked" });
140
+ }
141
+ };
142
+ const tdb = createTenantDb(
143
+ testDb.db,
144
+ tenant1.tenantId,
145
+ "tenant",
146
+ undefined,
147
+ undefined,
148
+ undefined,
149
+ { personalDataGate: denyEmail, unsafeRaw: { reason: "test: derive runner-bound gate" } },
150
+ );
151
+ // Mirrors createTenantDb(ctx.db.unsafeRaw(reason), ...): the gate now lives on the
152
+ // runner, not in this new TenantDb's own (unset) grants.
153
+ const gatedRunner = tdb.unsafeRaw("test: derive runner-bound gate");
154
+ const runnerBoundTdb = createTenantDb(gatedRunner, tenant1.tenantId);
155
+
156
+ expect(() =>
157
+ assertPersonalDataWrite(runnerBoundTdb, "run_in_own_tx_items", ["email"], entity),
158
+ ).toThrow(AccessDeniedError);
159
+
160
+ await runInOwnTransaction(runnerBoundTdb, async (txDb) => {
161
+ expect(() => assertPersonalDataWrite(txDb, "run_in_own_tx_items", ["email"], entity)).toThrow(
162
+ AccessDeniedError,
163
+ );
164
+ });
165
+ });
166
+ });
package/src/db/index.ts CHANGED
@@ -157,5 +157,6 @@ export {
157
157
  castTenantRows,
158
158
  createSystemDbView,
159
159
  createTenantDb,
160
+ runInOwnTransaction,
160
161
  SYSTEM_SCOPE_CHECK_BRAND,
161
162
  } from "./tenant-db";
package/src/db/query.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  insertMany,
18
18
  insertOne,
19
19
  type OrderByClause,
20
+ runInNewTransaction,
20
21
  runInSavepoint,
21
22
  runInSavepointIfSupported,
22
23
  type SelectOptions,
@@ -21,6 +21,7 @@ import {
21
21
  insertOne as bunInsertOne,
22
22
  selectMany as bunSelectMany,
23
23
  updateMany as bunUpdateMany,
24
+ runInNewTransaction,
24
25
  type SelectOptions,
25
26
  type WhereObject,
26
27
  } from "../db/query";
@@ -378,6 +379,25 @@ export function acknowledgeConventionCrossTenant(tenantDb: TenantDb, reason: str
378
379
  return rebind(reason);
379
380
  }
380
381
 
382
+ type OwnTransactionRunner = <T>(fn: (txDb: TenantDb) => Promise<T>) => Promise<T>;
383
+
384
+ const ownTransactionRebinders = new WeakMap<TenantDb, OwnTransactionRunner>();
385
+
386
+ export async function runInOwnTransaction<T>(
387
+ tenantDb: TenantDb,
388
+ fn: (txDb: TenantDb) => Promise<T>,
389
+ ): Promise<T> {
390
+ const rebind = ownTransactionRebinders.get(tenantDb);
391
+ if (!rebind) {
392
+ throw new InternalError({
393
+ message:
394
+ "runInOwnTransaction received a TenantDb not built by createTenantDb — " +
395
+ "no transaction rebinder bound.",
396
+ });
397
+ }
398
+ return rebind(fn);
399
+ }
400
+
381
401
  export function createTenantDb(
382
402
  db: DbRunner,
383
403
  tenantId: TenantId,
@@ -685,6 +705,20 @@ export function createTenantDb(
685
705
  report("acknowledge-cross-tenant", reason);
686
706
  return createTenantDb(db, tenantId, "system", tracer, meter, signal, grants);
687
707
  });
708
+ ownTransactionRebinders.set(tenantDb, async (fn) => {
709
+ if (grants?.memberReadOnly) {
710
+ throw new InternalError({
711
+ message:
712
+ "runInOwnTransaction: memberReadOnly TenantDb — a fresh transaction would escape " +
713
+ "the enforced read-only scope.",
714
+ });
715
+ }
716
+ // Carries a runner-bound gate (grants.personalDataGate unset, resolved via the fallback above) forward explicitly, since the fresh tx-handle isn't itself registered in runnerPersonalDataGates.
717
+ const txGrants = personalDataGate ? { ...grants, personalDataGate } : grants;
718
+ return runInNewTransaction(db, (tx) =>
719
+ fn(createTenantDb(tx, tenantId, mode, tracer, meter, signal, txGrants)),
720
+ );
721
+ });
688
722
  bindTenantDbRunner(tenantDb, db);
689
723
  if (personalDataGate) personalDataGates.set(tenantDb, personalDataGate);
690
724
  return tenantDb;
@@ -35,6 +35,15 @@ describe("parseChangesetChanges", () => {
35
35
  ]);
36
36
  });
37
37
 
38
+ test("keeps a blank line inside a multiline block as a paragraph break, not a truncation", () => {
39
+ const changes = parseChangesetChanges(
40
+ `---\n"@cosmicdrift/kumiko-framework": minor\n---\n\n<!-- kumiko-changes\nfeature: framework\ntype: breaking\ntitle: Removes the old flow\nmigration: |\n First paragraph.\n\n Second paragraph after a blank line.\n-->`,
41
+ ".changeset/blank-line.md",
42
+ );
43
+
44
+ expect(changes[0]?.migration).toBe("First paragraph.\n\nSecond paragraph after a blank line.");
45
+ });
46
+
38
47
  test("rejects a breaking change without migration", () => {
39
48
  expect(() =>
40
49
  parseChangesetChanges(
@@ -36,10 +36,23 @@ function parseBlock(raw: string, source: string): ReadonlyMap<string, string> {
36
36
  const value = match[2];
37
37
  if (value === "|") {
38
38
  const continuation: string[] = [];
39
- while (index + 1 < lines.length && (lines[index + 1]?.startsWith(" ") ?? false)) {
40
- index++;
41
- const continuationLine = lines[index];
42
- if (continuationLine) continuation.push(continuationLine.slice(2));
39
+ // A blank line inside the block is a paragraph break, not the end of it;
40
+ // stopping on the first one truncated the field and then threw a
41
+ // misleading "invalid line" error on the next indented line, which no
42
+ // longer looked like a `key: value` pair on its own.
43
+ while (index + 1 < lines.length) {
44
+ const next = lines[index + 1] ?? "";
45
+ if (next.startsWith(" ")) {
46
+ index++;
47
+ continuation.push(next.slice(2));
48
+ continue;
49
+ }
50
+ if (next.trim() === "") {
51
+ index++;
52
+ continuation.push("");
53
+ continue;
54
+ }
55
+ break;
43
56
  }
44
57
  fields.set(key, continuation.join("\n").trim());
45
58
  continue;
@@ -66,6 +66,7 @@ const testTenantEntity = createEntity({
66
66
  fields: {
67
67
  name: createTextField({ personal: false, reason: "test_fixture", required: true }),
68
68
  logo: createImageField({ maxSize: "2mb", accept: ["png", "jpg"] }),
69
+ strictLogo: createImageField({ maxSize: "2mb", accept: ["jpg"] }),
69
70
  importFile: createFileField({ maxSize: "2mb", accept: ["csv"] }),
70
71
  },
71
72
  });
@@ -266,8 +267,8 @@ describe("file validation", () => {
266
267
  });
267
268
 
268
269
  test("validateFileContent accepts .doc/.docx bytes matching their declared extension", () => {
269
- expect(validateFileContent("report.doc", docBytes)).toBeNull();
270
- expect(validateFileContent("report.docx", docxBytes)).toBeNull();
270
+ expect(validateFileContent("report.doc", docBytes)).toEqual({ kind: "ok" });
271
+ expect(validateFileContent("report.docx", docxBytes)).toEqual({ kind: "ok" });
271
272
  });
272
273
 
273
274
  test("validateFileContent rejects .doc/.docx whose content doesn't match the extension", () => {
@@ -275,13 +276,15 @@ describe("file validation", () => {
275
276
  "fake.doc",
276
277
  new TextEncoder().encode("not actually a Word document, just text"),
277
278
  );
278
- expectErrorIncludes(fakeDoc, "content_mismatch");
279
+ expect(fakeDoc.kind).toBe("rejected");
280
+ expectErrorIncludes(fakeDoc.kind === "rejected" ? fakeDoc.error : null, "content_mismatch");
279
281
 
280
282
  const fakeDocx = validateFileContent(
281
283
  "fake.docx",
282
284
  new TextEncoder().encode("not actually a docx, just text"),
283
285
  );
284
- expectErrorIncludes(fakeDocx, "content_mismatch");
286
+ expect(fakeDocx.kind).toBe("rejected");
287
+ expectErrorIncludes(fakeDocx.kind === "rejected" ? fakeDocx.error : null, "content_mismatch");
285
288
  });
286
289
 
287
290
  test("validateFileContent now content-verifies every signature-bearing extension (e.g. pdf), not just doc/docx", () => {
@@ -289,23 +292,73 @@ describe("file validation", () => {
289
292
  "fake.pdf",
290
293
  new TextEncoder().encode("not a PDF at all, just text"),
291
294
  );
292
- expectErrorIncludes(mismatch, "content_mismatch");
295
+ expect(mismatch.kind).toBe("rejected");
296
+ expectErrorIncludes(mismatch.kind === "rejected" ? mismatch.error : null, "content_mismatch");
293
297
  });
294
298
 
295
299
  test("validateFileContent leaves extensions without a magic-byte signature unchecked (csv/txt/svg/md/json/unknown)", () => {
296
300
  const text = new TextEncoder().encode("just plain text, not any binary format");
297
- expect(validateFileContent("data.csv", text)).toBeNull();
298
- expect(validateFileContent("notes.txt", text)).toBeNull();
299
- expect(validateFileContent("readme.md", text)).toBeNull();
300
- expect(validateFileContent("data.json", text)).toBeNull();
301
- expect(validateFileContent("logo.svg", text)).toBeNull();
302
- expect(validateFileContent("file.unknownext", text)).toBeNull();
301
+ expect(validateFileContent("data.csv", text)).toEqual({ kind: "ok" });
302
+ expect(validateFileContent("notes.txt", text)).toEqual({ kind: "ok" });
303
+ expect(validateFileContent("readme.md", text)).toEqual({ kind: "ok" });
304
+ expect(validateFileContent("data.json", text)).toEqual({ kind: "ok" });
305
+ expect(validateFileContent("logo.svg", text)).toEqual({ kind: "ok" });
306
+ expect(validateFileContent("file.unknownext", text)).toEqual({ kind: "ok" });
303
307
  });
304
308
 
305
309
  test("validateFileContent treats a prototype-property extension as unknown instead of crashing", () => {
306
- expect(validateFileContent("evil.constructor", docBytes)).toBeNull();
307
- expect(validateFileContent("evil.__proto__", docBytes)).toBeNull();
308
- expect(validateFileContent("evil.toString", docBytes)).toBeNull();
310
+ expect(validateFileContent("evil.constructor", docBytes)).toEqual({ kind: "ok" });
311
+ expect(validateFileContent("evil.__proto__", docBytes)).toEqual({ kind: "ok" });
312
+ expect(validateFileContent("evil.toString", docBytes)).toEqual({ kind: "ok" });
313
+ });
314
+
315
+ const pngBytesForNormalization = new Uint8Array([
316
+ 0x89,
317
+ 0x50,
318
+ 0x4e,
319
+ 0x47,
320
+ 0x0d,
321
+ 0x0a,
322
+ 0x1a,
323
+ 0x0a,
324
+ ...Array(20).fill(0),
325
+ ]);
326
+
327
+ test("validateFileContent normalizes PNG bytes uploaded as .jpg when png is in accept", () => {
328
+ expect(validateFileContent("photo.jpg", pngBytesForNormalization, ["jpg", "png"])).toEqual({
329
+ kind: "normalized",
330
+ extension: "png",
331
+ mimeType: "image/png",
332
+ });
333
+ });
334
+
335
+ test("validateFileContent still rejects PNG bytes uploaded as .jpg when png is not in accept", () => {
336
+ const result = validateFileContent("photo.jpg", pngBytesForNormalization, ["jpg"]);
337
+ expect(result.kind).toBe("rejected");
338
+ });
339
+
340
+ test("validateFileContent rejects a format not covered by accept at all, even with accept present", () => {
341
+ const result = validateFileContent("photo.jpg", new TextEncoder().encode("%PDF-1.4 minimal"), [
342
+ "jpg",
343
+ "png",
344
+ ]);
345
+ expect(result.kind).toBe("rejected");
346
+ });
347
+
348
+ test("validateFileContent without an accept list normalizes too — no accept means no restriction to widen", () => {
349
+ expect(validateFileContent("photo.jpg", pngBytesForNormalization)).toEqual({
350
+ kind: "normalized",
351
+ extension: "png",
352
+ mimeType: "image/png",
353
+ });
354
+ });
355
+
356
+ test("validateFileContent with an empty accept list normalizes the same as no accept at all", () => {
357
+ expect(validateFileContent("photo.jpg", pngBytesForNormalization, [])).toEqual({
358
+ kind: "normalized",
359
+ extension: "png",
360
+ mimeType: "image/png",
361
+ });
309
362
  });
310
363
 
311
364
  test("validateFile with accept: ['constructor'] and a matching filename never throws", () => {
@@ -667,7 +720,7 @@ describe("content_mismatch is enforced by filename extension for every signature
667
720
  ]);
668
721
  const docxBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, ...Array(20).fill(0)]);
669
722
 
670
- test("PDF bytes uploaded as x.jpg against an accept: [jpg] field are rejected with content_mismatch", async () => {
723
+ test("PDF bytes uploaded as x.jpg against an accept: [png, jpg] field are rejected with content_mismatch", async () => {
671
724
  const res = await uploadFile(adminUser, "x.jpg", pdfBytes, "image/jpeg", {
672
725
  entityType: "tenant",
673
726
  entityId: "1",
@@ -678,6 +731,44 @@ describe("content_mismatch is enforced by filename extension for every signature
678
731
  expect(body.error).toContain("content_mismatch");
679
732
  });
680
733
 
734
+ test("PNG bytes uploaded as x.jpg are normalized and stored as image/png when png is in the field's accept", async () => {
735
+ const res = await uploadFile(adminUser, "x.jpg", pngBytes, "image/jpeg", {
736
+ entityType: "tenant",
737
+ entityId: "1",
738
+ fieldName: "logo",
739
+ });
740
+ expect(res.status).toBe(201);
741
+ const body = await res.json();
742
+ expect(body.fileName).toBe("x.jpg");
743
+ expect(body.mimeType).toBe("image/png");
744
+ expect(body.storageKey).toMatch(/\.png$/);
745
+ });
746
+
747
+ test("PNG bytes normalized on upload are served back with the matching image/png Content-Type", async () => {
748
+ const uploadRes = await uploadFile(adminUser, "x.jpg", pngBytes, "image/jpeg", {
749
+ entityType: "tenant",
750
+ entityId: "1",
751
+ fieldName: "logo",
752
+ });
753
+ expect(uploadRes.status).toBe(201);
754
+ const { id } = await uploadRes.json();
755
+
756
+ const res = await getFile(adminUser, id);
757
+ expect(res.status).toBe(200);
758
+ expect(res.headers.get("Content-Type")).toBe("image/png");
759
+ });
760
+
761
+ test("PNG bytes uploaded as x.jpg against a field whose accept doesn't include png are still rejected with content_mismatch", async () => {
762
+ const res = await uploadFile(adminUser, "x.jpg", pngBytes, "image/jpeg", {
763
+ entityType: "tenant",
764
+ entityId: "1",
765
+ fieldName: "strictLogo",
766
+ });
767
+ expect(res.status).toBe(400);
768
+ const body = await res.json();
769
+ expect(body.error).toContain("content_mismatch");
770
+ });
771
+
681
772
  test("real signature bytes with a matching extension upload successfully", async () => {
682
773
  for (const [fileName, bytes, mimeType] of [
683
774
  ["real.jpg", jpegBytes, "image/jpeg"],
@@ -710,6 +801,15 @@ describe("content_mismatch is enforced by filename extension for every signature
710
801
  expect(protoRes.status).toBe(201);
711
802
  });
712
803
 
804
+ test("an unattached upload (no entityType/fieldName, so no accept restriction) also normalizes mislabeled bytes", async () => {
805
+ const res = await uploadFile(adminUser, "vin.jpg", pngBytes, "image/jpeg");
806
+ expect(res.status).toBe(201);
807
+ const body = await res.json();
808
+ expect(body.fileName).toBe("vin.jpg");
809
+ expect(body.mimeType).toBe("image/png");
810
+ expect(body.storageKey).toMatch(/\.png$/);
811
+ });
812
+
713
813
  test("a too-short .png upload (fewer bytes than the PNG signature) is rejected with content_mismatch", async () => {
714
814
  // A zero-byte file part is dropped by Bun's multipart parser before it
715
815
  // ever reaches the route handler as `file`, so this uses one byte
@@ -224,6 +224,27 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
224
224
  return c.json({ error: validationError }, 400);
225
225
  }
226
226
 
227
+ const data = new Uint8Array(await file.arrayBuffer());
228
+
229
+ const contentValidation = validateFileContent(file.name, data, accept);
230
+ if (contentValidation.kind === "rejected") {
231
+ return c.json({ error: contentValidation.error }, 400);
232
+ }
233
+
234
+ // A normalized upload's real type is already known from the sniffed
235
+ // bytes — resolveUploadMimeType's alias/rewrite step only applies to the
236
+ // declared mimeType of an unnormalized upload.
237
+ let mimeType: string;
238
+ if (contentValidation.kind === "normalized") {
239
+ mimeType = contentValidation.mimeType;
240
+ } else {
241
+ const mimeTypeResolution = resolveUploadMimeType(file.name, file.type, data);
242
+ if (mimeTypeResolution.kind === "rejected") {
243
+ return c.json({ error: mimeTypeResolution.error }, 400);
244
+ }
245
+ mimeType = mimeTypeResolution.mimeType;
246
+ }
247
+
227
248
  const fileRefId = generateId();
228
249
  const storageKey = buildStorageKey(
229
250
  user.tenantId,
@@ -232,21 +253,9 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
232
253
  fieldName ?? "file",
233
254
  file.name,
234
255
  generateId(),
256
+ contentValidation.kind === "normalized" ? contentValidation.extension : undefined,
235
257
  );
236
258
 
237
- const data = new Uint8Array(await file.arrayBuffer());
238
-
239
- const contentValidationError = validateFileContent(file.name, data);
240
- if (contentValidationError) {
241
- return c.json({ error: contentValidationError }, 400);
242
- }
243
-
244
- const mimeTypeResolution = resolveUploadMimeType(file.name, file.type, data);
245
- if (mimeTypeResolution.kind === "rejected") {
246
- return c.json({ error: mimeTypeResolution.error }, 400);
247
- }
248
- const mimeType = mimeTypeResolution.mimeType;
249
-
250
259
  // Write binary FIRST (outside the tx — network/disk I/O doesn't belong
251
260
  // inside a PG connection's tx window). On DB-tx rollback below the bytes
252
261
  // are orphaned in the provider; cleanup-jobs sweep those later. Losing a
@@ -33,6 +33,7 @@ export {
33
33
  transferTenantStorageUsage,
34
34
  } from "./storage-tracking";
35
35
  export type {
36
+ FileContentValidationResult,
36
37
  FileMetadata,
37
38
  FileStorageProvider,
38
39
  FileValidationOptions,
@@ -79,6 +79,15 @@ function lookupExtensionWhitelist(ext: string): readonly string[] | undefined {
79
79
  return EXTENSION_MIME_WHITELIST[ext];
80
80
  }
81
81
 
82
+ // Reverse of lookupExtensionWhitelist: every extension whose whitelist entry
83
+ // includes this MIME type (e.g. "image/jpeg" -> ["jpg", "jpeg"]) — backs
84
+ // validateFileContent's accept-based normalization below.
85
+ function extensionsForMimeType(mimeType: string): readonly string[] {
86
+ return Object.entries(EXTENSION_MIME_WHITELIST)
87
+ .filter(([, mimeTypes]) => mimeTypes.includes(mimeType))
88
+ .map(([ext]) => ext);
89
+ }
90
+
82
91
  // Magic-byte signatures for the subset of EXTENSION_MIME_WHITELIST that has
83
92
  // a reliable binary signature. Used both at upload time (validateFileContent)
84
93
  // and at serve time (resolveServedContentType) — never trust the stored/
@@ -217,17 +226,48 @@ function signatureMimeTypesForExtension(ext: string): readonly string[] {
217
226
  );
218
227
  }
219
228
 
229
+ export type FileContentValidationResult =
230
+ | { readonly kind: "ok" }
231
+ | { readonly kind: "normalized"; readonly extension: string; readonly mimeType: string }
232
+ | { readonly kind: "rejected"; readonly error: string };
233
+
220
234
  // Separate from validateFile: metadata is known before the upload body is
221
235
  // fully read, content-verification only after — callers reject early on
222
236
  // metadata without buffering bytes they'll then have to reject anyway.
223
- export function validateFileContent(fileName: string, content: Uint8Array): string | null {
237
+ //
238
+ // When the sniffed signature doesn't match the declared extension but the
239
+ // sniffed type's OWN extension is in a non-empty `accept`, the upload
240
+ // normalizes to that type instead of being rejected — this only widens which
241
+ // of the field's own allowed formats a mislabeled upload can settle into,
242
+ // never which formats are accepted. With no `accept` restriction at all
243
+ // (undefined or empty, same "unrestricted" meaning validateFile gives it),
244
+ // any recognized signature normalizes the same way, since there is no
245
+ // declared list left to widen.
246
+ export function validateFileContent(
247
+ fileName: string,
248
+ content: Uint8Array,
249
+ accept?: readonly string[],
250
+ ): FileContentValidationResult {
224
251
  const ext = extractExtension(fileName);
225
- if (!ext) return null;
252
+ if (!ext) return { kind: "ok" };
226
253
  const signatureMimeTypes = signatureMimeTypesForExtension(ext);
227
- if (signatureMimeTypes.length === 0) return null;
254
+ if (signatureMimeTypes.length === 0) return { kind: "ok" };
228
255
  const sniffed = sniffMimeType(content);
229
- if (sniffed && signatureMimeTypes.includes(sniffed)) return null;
230
- return `content_mismatch: ".${ext}" upload bytes are ${sniffed ?? "unrecognized"}, expected ${signatureMimeTypes.join(" or ")}`;
256
+ if (sniffed && signatureMimeTypes.includes(sniffed)) return { kind: "ok" };
257
+ if (sniffed) {
258
+ const candidateExtensions = extensionsForMimeType(sniffed);
259
+ const hasAcceptRestriction = accept !== undefined && accept.length > 0;
260
+ const normalizedExtension = hasAcceptRestriction
261
+ ? candidateExtensions.find((candidate) => accept.includes(candidate))
262
+ : candidateExtensions[0];
263
+ if (normalizedExtension) {
264
+ return { kind: "normalized", extension: normalizedExtension, mimeType: sniffed };
265
+ }
266
+ }
267
+ return {
268
+ kind: "rejected",
269
+ error: `content_mismatch: ".${ext}" upload bytes are ${sniffed ?? "unrecognized"}, expected ${signatureMimeTypes.join(" or ")}`,
270
+ };
231
271
  }
232
272
 
233
273
  export type UploadMimeTypeResolution =
@@ -270,8 +310,12 @@ export function buildStorageKey(
270
310
  fieldName: string,
271
311
  fileName: string,
272
312
  uniqueId: string,
313
+ // Set when validateFileContent normalized the upload to a different real
314
+ // type — the storage key then reflects the sniffed type, not the user's
315
+ // (wrong) original extension.
316
+ extensionOverride?: string,
273
317
  ): string {
274
- const rawExt = fileName.split(".").pop() ?? "";
318
+ const rawExt = extensionOverride ?? fileName.split(".").pop() ?? "";
275
319
  const ext = /^[A-Za-z0-9]+$/.test(rawExt) ? rawExt.toLowerCase() : "bin";
276
320
  return `${tenantId}/${entityType}/${entityId}/${fieldName}/${uniqueId}.${ext}`;
277
321
  }
package/src/schema-cli.ts CHANGED
@@ -141,6 +141,14 @@ export async function runSchemaCli(
141
141
  out.err(" Usage: schema generate <name>");
142
142
  return 1;
143
143
  }
144
+ // Trailing args (e.g. a typo'd `--dry-run` — there is no such flag) were
145
+ // silently ignored and the migration got written anyway. Reject them
146
+ // before any IO instead of pretending they did something.
147
+ if (argv.length > 2) {
148
+ out.err(` Unrecognized argument(s): ${argv.slice(2).join(" ")}`);
149
+ out.err(" Usage: schema generate <name>");
150
+ return 1;
151
+ }
144
152
  if (!existsSync(schemaFile)) {
145
153
  out.err(` ${schemaFile} fehlt.`);
146
154
  out.err(" App-Convention: kumiko/schema.ts mit");