@cosmicdrift/kumiko-framework 0.316.0 → 0.318.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.316.0",
3
+ "version": "0.318.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.316.0",
202
- "@cosmicdrift/kumiko-types": "0.316.0",
201
+ "@cosmicdrift/kumiko-http": "0.318.0",
202
+ "@cosmicdrift/kumiko-types": "0.318.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.316.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.318.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -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.318.0",
4
+ "type": "improvement",
5
+ "title": "Generic create/update entity handlers can exclude individual fields",
6
+ "detail": "`excludeFields` on defineEntityCreateHandler/defineEntityUpdateHandler, and `excludeFields: { create?, update? }` on registerEntityCrud/r.crud. A payload that still carries an excluded field fails validation instead of being stripped silently, so an app no longer needs a hand-written handler just to make one field read-only (e.g. a VIN after creation)."
7
+ },
8
+ {
9
+ "version": "0.317.0",
10
+ "type": "fix",
11
+ "title": "Changeset block parser keeps blank lines inside a multiline field",
12
+ "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."
13
+ },
14
+ {
15
+ "version": "0.317.0",
16
+ "type": "breaking",
17
+ "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\"",
18
+ "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.",
19
+ "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."
20
+ },
21
+ {
22
+ "version": "0.317.0",
23
+ "type": "improvement",
24
+ "title": "runInOwnTransaction(tenantDb, fn) runs fn in a fresh transaction on a TenantDb's pool runner",
25
+ "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."
26
+ },
2
27
  {
3
28
  "version": "0.316.0",
4
29
  "type": "fix",
@@ -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(
@@ -0,0 +1,134 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { asRawClient } from "../../db/query";
3
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
4
+ import { defineFeature } from "../define-feature";
5
+ import { defineEntityUpdateHandler, defineEntityWriteHandler } from "../entity-handlers";
6
+ import { createEntity, createTextField } from "../factories";
7
+
8
+ const carEntity = createEntity({
9
+ table: "exclude_fields_cars",
10
+ fields: {
11
+ vin: createTextField({ required: true, personal: false, reason: "test_fixture" }),
12
+ model: createTextField({ personal: false, reason: "test_fixture" }),
13
+ internalNote: createTextField({ personal: false, reason: "test_fixture" }),
14
+ },
15
+ });
16
+
17
+ const carFeature = defineFeature("exclude-fields", (r) => {
18
+ r.crud("car", carEntity, {
19
+ write: { access: { roles: ["User"] } },
20
+ read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
21
+ excludeFields: { create: ["internalNote"], update: ["vin"] },
22
+ });
23
+ });
24
+
25
+ const CREATE = "exclude-fields:write:car:create";
26
+ const UPDATE = "exclude-fields:write:car:update";
27
+
28
+ type WriteBody = {
29
+ readonly isSuccess: boolean;
30
+ readonly data?: { readonly data: Record<string, unknown> & { id: string; version: number } };
31
+ readonly error?: {
32
+ readonly code: string;
33
+ readonly details?: { readonly fields?: readonly { readonly path: string }[] };
34
+ };
35
+ };
36
+
37
+ describe("excludeFields on the generic create/update handlers", () => {
38
+ let stack: TestStack;
39
+
40
+ beforeAll(async () => {
41
+ stack = await setupTestStack({ features: [carFeature] });
42
+ await unsafeCreateEntityTable(stack.db, carEntity);
43
+ });
44
+
45
+ afterAll(async () => {
46
+ await stack.cleanup();
47
+ });
48
+
49
+ beforeEach(async () => {
50
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
51
+ await asRawClient(stack.db).unsafe('DELETE FROM "exclude_fields_cars"');
52
+ });
53
+
54
+ async function createCar(): Promise<{ id: string; version: number }> {
55
+ const res = await stack.http.write(CREATE, { vin: "WVW123", model: "Golf" }, TestUsers.user);
56
+ expect(res.status).toBe(200);
57
+ const body = (await res.json()) as WriteBody;
58
+ if (!body.data) throw new Error("create returned no data");
59
+ return body.data.data;
60
+ }
61
+
62
+ test("update rejects a payload that still carries an excluded field and keeps the row unchanged", async () => {
63
+ const car = await createCar();
64
+
65
+ const res = await stack.http.write(
66
+ UPDATE,
67
+ { id: car.id, version: car.version, changes: { vin: "HACKED", model: "Polo" } },
68
+ TestUsers.user,
69
+ );
70
+ expect(res.status).toBe(400);
71
+ const body = (await res.json()) as WriteBody;
72
+ expect(body.error?.code).toBe("validation_error");
73
+ expect(body.error?.details?.fields?.map((field) => field.path)).toEqual(["changes.vin"]);
74
+
75
+ const rows = await asRawClient(stack.db).unsafe(
76
+ 'SELECT vin, model FROM "exclude_fields_cars" WHERE id = $1',
77
+ [car.id],
78
+ );
79
+ expect(rows[0]).toMatchObject({ vin: "WVW123", model: "Golf" });
80
+ });
81
+
82
+ test("update without the excluded field writes the other fields", async () => {
83
+ const car = await createCar();
84
+
85
+ const res = await stack.http.write(
86
+ UPDATE,
87
+ { id: car.id, version: car.version, changes: { model: "Polo" } },
88
+ TestUsers.user,
89
+ );
90
+ expect(res.status).toBe(200);
91
+ const body = (await res.json()) as WriteBody;
92
+ expect(body.data?.data).toMatchObject({ vin: "WVW123", model: "Polo" });
93
+ });
94
+
95
+ test("create honors its own exclusion list independently of update's", async () => {
96
+ const rejected = await stack.http.write(
97
+ CREATE,
98
+ { vin: "WVW999", internalNote: "set by client" },
99
+ TestUsers.user,
100
+ );
101
+ expect(rejected.status).toBe(400);
102
+ const body = (await rejected.json()) as WriteBody;
103
+ expect(body.error?.details?.fields?.map((field) => field.path)).toEqual(["internalNote"]);
104
+ });
105
+ });
106
+
107
+ describe("excludeFields definition-time checks", () => {
108
+ test("rejects an unknown field name", () => {
109
+ expect(() =>
110
+ defineEntityUpdateHandler("car", carEntity, {
111
+ access: { roles: ["User"] },
112
+ excludeFields: ["colour"],
113
+ }),
114
+ ).toThrow(/unknown field "colour"/);
115
+ });
116
+
117
+ test("rejects excluding a required field without default on create", () => {
118
+ expect(() =>
119
+ defineEntityWriteHandler("car:create", carEntity, {
120
+ access: { roles: ["User"] },
121
+ excludeFields: ["vin"],
122
+ }),
123
+ ).toThrow(/required without a default/);
124
+ });
125
+
126
+ test("rejects excludeFields on delete", () => {
127
+ expect(() =>
128
+ defineEntityWriteHandler("car:delete", carEntity, {
129
+ access: { roles: ["User"] },
130
+ excludeFields: ["model"],
131
+ }),
132
+ ).toThrow(/only applies to create and update/);
133
+ });
134
+ });
@@ -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;
@@ -29,6 +29,7 @@ import type {
29
29
  EntityCrudVerb,
30
30
  EntityHandlerOptions,
31
31
  EntityQueryHandlerOptions,
32
+ EntityWriteHandlerOptions,
32
33
  RegisterEntityCrudOptions,
33
34
  } from "./types/entity-handlers";
34
35
 
@@ -38,6 +39,7 @@ export type {
38
39
  EntityCrudVerb,
39
40
  EntityHandlerOptions,
40
41
  EntityQueryHandlerOptions,
42
+ EntityWriteHandlerOptions,
41
43
  RegisterEntityCrudOptions,
42
44
  } from "./types/entity-handlers";
43
45
 
@@ -202,10 +204,48 @@ function parseHandlerName<TVerb extends string>(
202
204
  return { entityName, verb: verbCandidate as TVerb }; // @cast-boundary engine-bridge
203
205
  }
204
206
 
207
+ function isInTotalsMatchPair(entity: EntityDefinition, fieldName: string): boolean {
208
+ return Object.entries(entity.fields).some(
209
+ ([name, field]) =>
210
+ field.type === "embedded" &&
211
+ field.totalsMatch !== undefined &&
212
+ (name === fieldName || Object.values(field.totalsMatch).includes(fieldName)),
213
+ );
214
+ }
215
+
216
+ function assertExcludableFields(
217
+ name: string,
218
+ verb: (typeof WRITE_VERBS)[number],
219
+ entity: EntityDefinition,
220
+ excludedFields: readonly string[],
221
+ ): void {
222
+ if (excludedFields.length > 0 && verb !== "create" && verb !== "update") {
223
+ throw new Error(`"${name}": excludeFields only applies to create and update handlers.`);
224
+ }
225
+ for (const fieldName of excludedFields) {
226
+ const field = entity.fields[fieldName];
227
+ if (!field) {
228
+ throw new Error(`"${name}": excludeFields names unknown field "${fieldName}".`);
229
+ }
230
+ if (isInTotalsMatchPair(entity, fieldName)) {
231
+ throw new Error(
232
+ `"${name}": excludeFields cannot name "${fieldName}" — it is part of a totalsMatch pair, which must be written together.`,
233
+ );
234
+ }
235
+ const isRequired = "required" in field && field.required === true;
236
+ const hasDefault = "default" in field && field.default !== undefined;
237
+ if (verb === "create" && isRequired && !hasDefault) {
238
+ throw new Error(
239
+ `"${name}": excludeFields cannot name "${fieldName}" — it is required without a default, so no row could ever be created.`,
240
+ );
241
+ }
242
+ }
243
+ }
244
+
205
245
  export function defineEntityWriteHandler(
206
246
  name: string,
207
247
  entity: EntityDefinition,
208
- options: EntityHandlerOptions,
248
+ options: EntityWriteHandlerOptions,
209
249
  ): WriteHandlerDef {
210
250
  const { entityName, verb } = parseHandlerName(name, WRITE_VERBS);
211
251
  if (verb === "restore" && !entity.softDelete) {
@@ -213,6 +253,8 @@ export function defineEntityWriteHandler(
213
253
  `"${name}": restore is only valid for entities declared with softDelete: true.`,
214
254
  );
215
255
  }
256
+ const excludedFields = options.excludeFields ?? [];
257
+ assertExcludableFields(name, verb, entity, excludedFields);
216
258
 
217
259
  const table = buildEntityTable(entityName, entity);
218
260
  const executor = createEventStoreExecutor(table, entity, { entityName });
@@ -263,7 +305,7 @@ export function defineEntityWriteHandler(
263
305
 
264
306
  switch (verb) {
265
307
  case "create":
266
- schema = buildInsertSchema(entity);
308
+ schema = buildInsertSchema(entity, undefined, excludedFields);
267
309
  handler = async (event, ctx) => {
268
310
  const { runPreSave } = ctx;
269
311
  // A caller-chosen id (deterministic-id idempotent creates) is only
@@ -284,7 +326,7 @@ export function defineEntityWriteHandler(
284
326
  schema = z.object({
285
327
  id: z.uuid(),
286
328
  version: z.number(),
287
- changes: buildUpdateSchema(entity),
329
+ changes: buildUpdateSchema(entity, undefined, excludedFields),
288
330
  });
289
331
  handler = async (event, ctx) => {
290
332
  const { runPreSave } = ctx;
@@ -507,7 +549,7 @@ export function defineEntityQueryHandler(
507
549
  export function defineEntityCreateHandler(
508
550
  entityName: string,
509
551
  entity: EntityDefinition,
510
- options: EntityHandlerOptions,
552
+ options: EntityWriteHandlerOptions,
511
553
  ): WriteHandlerDef {
512
554
  return defineEntityWriteHandler(`${entityName}:create`, entity, options);
513
555
  }
@@ -516,7 +558,7 @@ export function defineEntityCreateHandler(
516
558
  export function defineEntityUpdateHandler(
517
559
  entityName: string,
518
560
  entity: EntityDefinition,
519
- options: EntityHandlerOptions,
561
+ options: EntityWriteHandlerOptions,
520
562
  ): WriteHandlerDef {
521
563
  return defineEntityWriteHandler(`${entityName}:update`, entity, options);
522
564
  }
@@ -651,11 +693,16 @@ export function registerEntityCrud(
651
693
  }
652
694
  return access;
653
695
  };
654
- const resolveWriteOpts = (verb: EntityCrudVerb): EntityHandlerOptions => ({
655
- ...writeOpts,
656
- access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
657
- description: options?.descriptions?.[verb] ?? writeOpts?.description,
658
- });
696
+ const resolveWriteOpts = (verb: EntityCrudVerb): EntityWriteHandlerOptions => {
697
+ const excludeFields =
698
+ verb === "create" || verb === "update" ? options?.excludeFields?.[verb] : undefined;
699
+ return {
700
+ ...writeOpts,
701
+ access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
702
+ description: options?.descriptions?.[verb] ?? writeOpts?.description,
703
+ ...(excludeFields !== undefined && { excludeFields }),
704
+ };
705
+ };
659
706
  const resolveReadOpts = (verb: EntityCrudVerb): EntityQueryHandlerOptions => ({
660
707
  ...readOpts,
661
708
  access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
@@ -367,13 +367,25 @@ function applyTotalsMatchRefinements(
367
367
  return result;
368
368
  }
369
369
 
370
+ // An excluded field stays in the shape as "must be absent" instead of being
371
+ // dropped: zod strips unknown keys silently, so a client still sending it
372
+ // would otherwise get a success without the write happening.
373
+ function notWritableField(): z.ZodTypeAny {
374
+ return z.never().optional();
375
+ }
376
+
370
377
  export function buildInsertSchema(
371
378
  entity: EntityDefinition,
372
379
  currencies: readonly string[] = [...DEFAULT_CURRENCIES],
380
+ excludedFields: readonly string[] = [],
373
381
  ): z.ZodObject<Record<string, z.ZodTypeAny>> {
374
382
  const shape: Record<string, z.ZodTypeAny> = {};
375
383
 
376
384
  for (const [name, field] of Object.entries(entity.fields)) {
385
+ if (excludedFields.includes(name)) {
386
+ shape[name] = notWritableField();
387
+ continue;
388
+ }
377
389
  const zodField = fieldToZod(field, currencies);
378
390
  const hasDefault = "default" in field && field.default !== undefined;
379
391
  const isRequired = "required" in field && field.required === true;
@@ -392,10 +404,15 @@ export function buildInsertSchema(
392
404
  export function buildUpdateSchema(
393
405
  entity: EntityDefinition,
394
406
  currencies: readonly string[] = [...DEFAULT_CURRENCIES],
407
+ excludedFields: readonly string[] = [],
395
408
  ): z.ZodObject<Record<string, z.ZodTypeAny>> {
396
409
  const shape: Record<string, z.ZodTypeAny> = {};
397
410
 
398
411
  for (const [name, field] of Object.entries(entity.fields)) {
412
+ if (excludedFields.includes(name)) {
413
+ shape[name] = notWritableField();
414
+ continue;
415
+ }
399
416
  // Update schemas never apply defaults for OMITTED fields — a user that
400
417
  // sends only `{ title }` means "only change title"; zod defaults would
401
418
  // silently inject default values for every omitted field and clobber
@@ -63,6 +63,7 @@ export type {
63
63
  EntityCrudVerb,
64
64
  EntityHandlerOptions,
65
65
  EntityQueryHandlerOptions,
66
+ EntityWriteHandlerOptions,
66
67
  RegisterEntityCrudOptions,
67
68
  } from "@cosmicdrift/kumiko-types/entity-handlers";
68
69
  // Cross-Feature Compile-Time-Type-Map — features extend per declare-module.
@@ -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
  }
@@ -0,0 +1,7 @@
1
+ export type Formality = "formal" | "informal";
2
+
3
+ // BCP-47 private-use subtag: a translation bundle keyed "de-x-formal" holds the
4
+ // formal ("Sie") wording and only needs the keys that differ from plain "de".
5
+ export function formalLocaleTag<Locale extends string>(locale: Locale): `${Locale}-x-formal` {
6
+ return `${locale}-x-formal`;
7
+ }
@@ -137,6 +137,7 @@ export type {
137
137
  QualifiedContentCollection,
138
138
  WorkspaceSchema,
139
139
  } from "./app-schema";
140
+ export { type Formality, formalLocaleTag } from "./formality";
140
141
  export type {
141
142
  ListRowMetaColumnType,
142
143
  ListRowMetaReference,