@cosmicdrift/kumiko-framework 0.316.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 +4 -4
- package/src/bun-db/query.ts +18 -0
- package/src/changes.json +19 -0
- package/src/db/__tests__/run-in-own-transaction.integration.test.ts +166 -0
- package/src/db/index.ts +1 -0
- package/src/db/query.ts +1 -0
- package/src/db/tenant-db.ts +34 -0
- package/src/engine/__tests__/changeset-changes.test.ts +9 -0
- package/src/engine/changeset-changes.ts +17 -4
- package/src/files/__tests__/files.integration.test.ts +115 -15
- package/src/files/file-routes.ts +22 -13
- package/src/files/index.ts +1 -0
- package/src/files/types.ts +50 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.317.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
package/src/bun-db/query.ts
CHANGED
|
@@ -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,23 @@
|
|
|
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
|
+
},
|
|
2
21
|
{
|
|
3
22
|
"version": "0.316.0",
|
|
4
23
|
"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
package/src/db/query.ts
CHANGED
package/src/db/tenant-db.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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)).
|
|
270
|
-
expect(validateFileContent("report.docx", docxBytes)).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)).
|
|
298
|
-
expect(validateFileContent("notes.txt", text)).
|
|
299
|
-
expect(validateFileContent("readme.md", text)).
|
|
300
|
-
expect(validateFileContent("data.json", text)).
|
|
301
|
-
expect(validateFileContent("logo.svg", text)).
|
|
302
|
-
expect(validateFileContent("file.unknownext", text)).
|
|
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)).
|
|
307
|
-
expect(validateFileContent("evil.__proto__", docBytes)).
|
|
308
|
-
expect(validateFileContent("evil.toString", docBytes)).
|
|
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
|
package/src/files/file-routes.ts
CHANGED
|
@@ -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
|
package/src/files/index.ts
CHANGED
package/src/files/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
252
|
+
if (!ext) return { kind: "ok" };
|
|
226
253
|
const signatureMimeTypes = signatureMimeTypesForExtension(ext);
|
|
227
|
-
if (signatureMimeTypes.length === 0) return
|
|
254
|
+
if (signatureMimeTypes.length === 0) return { kind: "ok" };
|
|
228
255
|
const sniffed = sniffMimeType(content);
|
|
229
|
-
if (sniffed && signatureMimeTypes.includes(sniffed)) return
|
|
230
|
-
|
|
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
|
}
|