@cosmicdrift/kumiko-framework 0.190.0 → 0.192.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 +3 -3
- package/src/derivatives/__tests__/field-variants.test.ts +62 -0
- package/src/derivatives/__tests__/variant-key.test.ts +6 -0
- package/src/derivatives/__tests__/variant-route.integration.test.ts +169 -0
- package/src/derivatives/field-variants.ts +20 -0
- package/src/derivatives/index.ts +2 -1
- package/src/derivatives/variant-key.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +134 -0
- package/src/engine/boot-validator/entity-handler.ts +52 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/files/file-routes.ts +44 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.192.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>",
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
"./package.json": "./package.json"
|
|
187
187
|
},
|
|
188
188
|
"dependencies": {
|
|
189
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
189
|
+
"@cosmicdrift/kumiko-types": "0.192.0",
|
|
190
190
|
"bullmq": "^5.76.7",
|
|
191
191
|
"bun-types": "^1.3.13",
|
|
192
192
|
"hono": "^4.13.1",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"zod": "^4.4.3"
|
|
203
203
|
},
|
|
204
204
|
"devDependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
205
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.192.0",
|
|
206
206
|
"bun-types": "^1.3.13",
|
|
207
207
|
"pino-pretty": "^13.1.3"
|
|
208
208
|
},
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
3
|
+
import type { EntityDefinition, FieldDefinition, Registry } from "../../engine/types";
|
|
4
|
+
import { resolveFieldVariant } from "../field-variants";
|
|
5
|
+
|
|
6
|
+
// Minimal fake Registry — same pattern as derivatives-context.test.ts's
|
|
7
|
+
// fakeRegistry: only getEntity is exercised by resolveFieldVariant.
|
|
8
|
+
function fakeRegistry(entities: Readonly<Record<string, EntityDefinition>>): Registry {
|
|
9
|
+
const map = new Map(Object.entries(entities));
|
|
10
|
+
return {
|
|
11
|
+
getEntity: (name: string) => map.get(name),
|
|
12
|
+
} as unknown as Registry;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const THUMB_SPEC: VariantSpec = { maxEdge: 200, format: "webp" };
|
|
16
|
+
|
|
17
|
+
function photoEntityWith(field: FieldDefinition): Readonly<Record<string, EntityDefinition>> {
|
|
18
|
+
return {
|
|
19
|
+
photo: { fields: { avatar: field } },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("resolveFieldVariant", () => {
|
|
24
|
+
test("returns the declared spec for a known variant name", () => {
|
|
25
|
+
const registry = fakeRegistry(
|
|
26
|
+
photoEntityWith({ type: "image", variants: { thumb: THUMB_SPEC } }),
|
|
27
|
+
);
|
|
28
|
+
expect(resolveFieldVariant(registry, "photo", "avatar", "thumb")).toBe(THUMB_SPEC);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("unknown variant name returns undefined", () => {
|
|
32
|
+
const registry = fakeRegistry(
|
|
33
|
+
photoEntityWith({ type: "image", variants: { thumb: THUMB_SPEC } }),
|
|
34
|
+
);
|
|
35
|
+
expect(resolveFieldVariant(registry, "photo", "avatar", "nope")).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("__proto__ as a name returns undefined — the prototype-pollution case Object.hasOwn catches", () => {
|
|
39
|
+
const registry = fakeRegistry(
|
|
40
|
+
photoEntityWith({ type: "image", variants: { thumb: THUMB_SPEC } }),
|
|
41
|
+
);
|
|
42
|
+
expect(resolveFieldVariant(registry, "photo", "avatar", "__proto__")).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("null entityType or fieldName returns undefined", () => {
|
|
46
|
+
const registry = fakeRegistry(
|
|
47
|
+
photoEntityWith({ type: "image", variants: { thumb: THUMB_SPEC } }),
|
|
48
|
+
);
|
|
49
|
+
expect(resolveFieldVariant(registry, null, "avatar", "thumb")).toBeUndefined();
|
|
50
|
+
expect(resolveFieldVariant(registry, "photo", null, "thumb")).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a non-image field returns undefined even when its own value looks variant-shaped", () => {
|
|
54
|
+
const registry = fakeRegistry(photoEntityWith({ type: "text" }));
|
|
55
|
+
expect(resolveFieldVariant(registry, "photo", "avatar", "thumb")).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("an image field without variants returns undefined", () => {
|
|
59
|
+
const registry = fakeRegistry(photoEntityWith({ type: "image" }));
|
|
60
|
+
expect(resolveFieldVariant(registry, "photo", "avatar", "thumb")).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -19,6 +19,12 @@ describe("specHash — key stability", () => {
|
|
|
19
19
|
test("a changed value produces a different hash — the whole point of hashing the spec", () => {
|
|
20
20
|
expect(specHash({ maxEdge: 512 })).not.toBe(specHash({ maxEdge: 640 }));
|
|
21
21
|
});
|
|
22
|
+
|
|
23
|
+
test("different blurRegions produce different hashes — corrected regions need a fresh URL", () => {
|
|
24
|
+
expect(specHash({ blurRegions: [{ x: 0, y: 0, width: 0.5, height: 0.5 }] })).not.toBe(
|
|
25
|
+
specHash({ blurRegions: [{ x: 0.5, y: 0.5, width: 0.5, height: 0.5 }] }),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
22
28
|
});
|
|
23
29
|
|
|
24
30
|
describe("variantSuffix", () => {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Proves GET /api/files/:id/variant/:name end-to-end: real HTTP calls
|
|
2
|
+
// through the auth+tenant+guard gate, resolveFieldVariant's whitelist, and
|
|
3
|
+
// createDerivativesContext's cache path — same "wiring, not unit logic"
|
|
4
|
+
// rationale as derivatives-context.integration.test.ts.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
8
|
+
import {
|
|
9
|
+
createEntity,
|
|
10
|
+
createImageField,
|
|
11
|
+
defineFeature,
|
|
12
|
+
EXT_DERIVATIVE_RENDERER,
|
|
13
|
+
} from "../../engine";
|
|
14
|
+
import { createFilesFeature } from "../../files/feature";
|
|
15
|
+
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
16
|
+
import { createTestUser, setupTestStack, type TestStack, testTenantId } from "../../stack";
|
|
17
|
+
import { buildMultipartBody, patchFileInstanceofForBunTest } from "../../testing";
|
|
18
|
+
|
|
19
|
+
const VARIANT_BYTES = new Uint8Array([9, 9, 9]);
|
|
20
|
+
|
|
21
|
+
let renderCalls = 0;
|
|
22
|
+
const fakeRender: DerivativeRendererPlugin["render"] = async () => {
|
|
23
|
+
renderCalls++;
|
|
24
|
+
return VARIANT_BYTES;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const photoEntity = createEntity({
|
|
28
|
+
table: "variant_route_photos",
|
|
29
|
+
fields: {
|
|
30
|
+
avatar: createImageField({ variants: { thumb: { maxEdge: 100, format: "webp" } } }),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const photoFeature = defineFeature("variantroutetest", (r) => {
|
|
35
|
+
r.entity("photo", photoEntity);
|
|
36
|
+
r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, { onRegister: () => {} });
|
|
37
|
+
r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", { render: fakeRender });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
let stack: TestStack;
|
|
41
|
+
const tenantId = testTenantId(1);
|
|
42
|
+
const user = createTestUser({ id: 1, tenantId, roles: ["Admin"] });
|
|
43
|
+
const otherTenantId = testTenantId(2);
|
|
44
|
+
const otherTenantUser = createTestUser({ id: 10, tenantId: otherTenantId, roles: ["Admin"] });
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
patchFileInstanceofForBunTest();
|
|
48
|
+
stack = await setupTestStack({
|
|
49
|
+
features: [createFilesFeature(), photoFeature],
|
|
50
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
await stack.cleanup();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const DEFAULT_ATTACH = { entityType: "photo", fieldName: "avatar" };
|
|
59
|
+
|
|
60
|
+
// `null` (not the default-param-triggering `undefined`) is the explicit
|
|
61
|
+
// "unattached upload" sentinel — a caller passing `undefined` here would
|
|
62
|
+
// silently fall back to DEFAULT_ATTACH instead.
|
|
63
|
+
async function uploadFile(
|
|
64
|
+
asUser = user,
|
|
65
|
+
attach: { entityType: string; fieldName: string } | null = DEFAULT_ATTACH,
|
|
66
|
+
): Promise<string> {
|
|
67
|
+
const token = await stack.jwt.sign(asUser);
|
|
68
|
+
const fd = new FormData();
|
|
69
|
+
fd.append("file", new File([Buffer.from([1, 2, 3])], "avatar.jpg", { type: "image/jpeg" }));
|
|
70
|
+
if (attach !== null) {
|
|
71
|
+
fd.append("entityType", attach.entityType);
|
|
72
|
+
fd.append("fieldName", attach.fieldName);
|
|
73
|
+
}
|
|
74
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
75
|
+
const res = await stack.app.request("/api/files", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
78
|
+
body,
|
|
79
|
+
});
|
|
80
|
+
expect(res.status).toBe(201);
|
|
81
|
+
const json = (await res.json()) as { id: string };
|
|
82
|
+
return json.id;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("GET /api/files/:id/variant/:name", () => {
|
|
86
|
+
test("returns the rendered bytes with the spec's mimeType", async () => {
|
|
87
|
+
renderCalls = 0;
|
|
88
|
+
const fileId = await uploadFile();
|
|
89
|
+
const token = await stack.jwt.sign(user);
|
|
90
|
+
|
|
91
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
92
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
expect(res.headers.get("Content-Type")).toBe("image/webp");
|
|
97
|
+
expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a second call hits the cache — the renderer runs only once", async () => {
|
|
101
|
+
renderCalls = 0;
|
|
102
|
+
const fileId = await uploadFile();
|
|
103
|
+
const token = await stack.jwt.sign(user);
|
|
104
|
+
|
|
105
|
+
const first = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
106
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
107
|
+
});
|
|
108
|
+
const second = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
109
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(first.status).toBe(200);
|
|
113
|
+
expect(second.status).toBe(200);
|
|
114
|
+
expect(new Uint8Array(await second.arrayBuffer())).toEqual(VARIANT_BYTES);
|
|
115
|
+
expect(renderCalls).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("an undeclared variant name returns 404", async () => {
|
|
119
|
+
const fileId = await uploadFile();
|
|
120
|
+
const token = await stack.jwt.sign(user);
|
|
121
|
+
|
|
122
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/nope`, {
|
|
123
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(res.status).toBe(404);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("__proto__ as the variant name returns 404", async () => {
|
|
130
|
+
const fileId = await uploadFile();
|
|
131
|
+
const token = await stack.jwt.sign(user);
|
|
132
|
+
|
|
133
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/__proto__`, {
|
|
134
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(res.status).toBe(404);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("an unattached upload has no field to resolve a variant from — 404", async () => {
|
|
141
|
+
const fileId = await uploadFile(user, null);
|
|
142
|
+
const token = await stack.jwt.sign(user);
|
|
143
|
+
|
|
144
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
145
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(res.status).toBe(404);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a user from a different tenant gets 404 — tenant isolation", async () => {
|
|
152
|
+
const fileId = await uploadFile();
|
|
153
|
+
const token = await stack.jwt.sign(otherTenantUser);
|
|
154
|
+
|
|
155
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
156
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(res.status).toBe(404);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("no Authorization header does not return 200", async () => {
|
|
163
|
+
const fileId = await uploadFile();
|
|
164
|
+
|
|
165
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`);
|
|
166
|
+
|
|
167
|
+
expect(res.status).toBe(401);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
2
|
+
import type { Registry } from "../engine/types";
|
|
3
|
+
|
|
4
|
+
// The field definition IS the whitelist: a request carries a variant NAME,
|
|
5
|
+
// never a spec, so no caller can drive an arbitrary render. `hasOwn` and not
|
|
6
|
+
// a plain index — `name` comes off a URL segment, and `__proto__` would
|
|
7
|
+
// otherwise resolve to something truthy that is not a VariantSpec.
|
|
8
|
+
export function resolveFieldVariant(
|
|
9
|
+
registry: Registry,
|
|
10
|
+
entityType: string | null,
|
|
11
|
+
fieldName: string | null,
|
|
12
|
+
name: string,
|
|
13
|
+
): VariantSpec | undefined {
|
|
14
|
+
if (entityType === null || fieldName === null) return undefined;
|
|
15
|
+
const field = registry.getEntity(entityType)?.fields[fieldName];
|
|
16
|
+
if (field === undefined) return undefined;
|
|
17
|
+
if (field.type !== "image" && field.type !== "images") return undefined;
|
|
18
|
+
if (field.variants === undefined || !Object.hasOwn(field.variants, name)) return undefined;
|
|
19
|
+
return field.variants[name];
|
|
20
|
+
}
|
package/src/derivatives/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export type { DerivativesContextDeps } from "./derivatives-context";
|
|
2
2
|
export { createDerivativesContext, resolveRenderer } from "./derivatives-context";
|
|
3
|
-
export {
|
|
3
|
+
export { resolveFieldVariant } from "./field-variants";
|
|
4
|
+
export { canonicalJson, specHash, VARIANT_NAME_PATTERN, variantSuffix } from "./variant-key";
|
|
@@ -25,7 +25,7 @@ export function specHash(spec: VariantSpec): string {
|
|
|
25
25
|
return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0, 8);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const VARIANT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/i;
|
|
28
|
+
export const VARIANT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/i;
|
|
29
29
|
|
|
30
30
|
export function variantSuffix(name: string, spec: VariantSpec): string {
|
|
31
31
|
// `name` ends up in a storage key (see deriveKey) — an unvalidated value
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
createEmbeddedListField,
|
|
12
12
|
createEntity,
|
|
13
13
|
createFilesField,
|
|
14
|
+
createImageField,
|
|
15
|
+
createImagesField,
|
|
14
16
|
createJsonbField,
|
|
15
17
|
createMultiSelectField,
|
|
16
18
|
createTextField,
|
|
@@ -1355,6 +1357,138 @@ describe("boot-validator", () => {
|
|
|
1355
1357
|
});
|
|
1356
1358
|
});
|
|
1357
1359
|
|
|
1360
|
+
describe("image variants", () => {
|
|
1361
|
+
test("accepts a valid variant spec", () => {
|
|
1362
|
+
process.env["FILE_STORAGE_PROVIDER"] = "local";
|
|
1363
|
+
try {
|
|
1364
|
+
const features = [
|
|
1365
|
+
defineFeature("profile", (r) => {
|
|
1366
|
+
r.entity(
|
|
1367
|
+
"person",
|
|
1368
|
+
createEntity({
|
|
1369
|
+
fields: {
|
|
1370
|
+
avatar: createImageField({
|
|
1371
|
+
variants: {
|
|
1372
|
+
profile: { fit: "cover", size: { width: 512, height: 512 }, format: "webp" },
|
|
1373
|
+
},
|
|
1374
|
+
}),
|
|
1375
|
+
},
|
|
1376
|
+
}),
|
|
1377
|
+
);
|
|
1378
|
+
}),
|
|
1379
|
+
];
|
|
1380
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1381
|
+
} finally {
|
|
1382
|
+
delete process.env["FILE_STORAGE_PROVIDER"];
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
test("accepts a variant with only `format` — the legitimate format-only case, no size/maxEdge required", () => {
|
|
1387
|
+
process.env["FILE_STORAGE_PROVIDER"] = "local";
|
|
1388
|
+
try {
|
|
1389
|
+
const features = [
|
|
1390
|
+
defineFeature("profile", (r) => {
|
|
1391
|
+
r.entity(
|
|
1392
|
+
"person",
|
|
1393
|
+
createEntity({
|
|
1394
|
+
fields: {
|
|
1395
|
+
avatar: createImageField({ variants: { web: { format: "webp" } } }),
|
|
1396
|
+
},
|
|
1397
|
+
}),
|
|
1398
|
+
);
|
|
1399
|
+
}),
|
|
1400
|
+
];
|
|
1401
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1402
|
+
} finally {
|
|
1403
|
+
delete process.env["FILE_STORAGE_PROVIDER"];
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("rejects a variant setting both `size` and `maxEdge`", () => {
|
|
1408
|
+
const features = [
|
|
1409
|
+
defineFeature("profile", (r) => {
|
|
1410
|
+
r.entity(
|
|
1411
|
+
"person",
|
|
1412
|
+
createEntity({
|
|
1413
|
+
fields: {
|
|
1414
|
+
avatar: createImageField({
|
|
1415
|
+
variants: {
|
|
1416
|
+
both: { size: { width: 100, height: 100 }, maxEdge: 200 },
|
|
1417
|
+
},
|
|
1418
|
+
}),
|
|
1419
|
+
},
|
|
1420
|
+
}),
|
|
1421
|
+
);
|
|
1422
|
+
}),
|
|
1423
|
+
];
|
|
1424
|
+
expect(() => validateBoot(features)).toThrow(/both/);
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test("rejects a variant name containing a slash", () => {
|
|
1428
|
+
const features = [
|
|
1429
|
+
defineFeature("profile", (r) => {
|
|
1430
|
+
r.entity(
|
|
1431
|
+
"person",
|
|
1432
|
+
createEntity({
|
|
1433
|
+
fields: {
|
|
1434
|
+
avatar: createImageField({ variants: { "a/b": { maxEdge: 200 } } }),
|
|
1435
|
+
},
|
|
1436
|
+
}),
|
|
1437
|
+
);
|
|
1438
|
+
}),
|
|
1439
|
+
];
|
|
1440
|
+
expect(() => validateBoot(features)).toThrow(/must match/);
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
test("rejects maxEdge: 0", () => {
|
|
1444
|
+
const features = [
|
|
1445
|
+
defineFeature("profile", (r) => {
|
|
1446
|
+
r.entity(
|
|
1447
|
+
"person",
|
|
1448
|
+
createEntity({
|
|
1449
|
+
fields: {
|
|
1450
|
+
avatar: createImageField({ variants: { thumb: { maxEdge: 0 } } }),
|
|
1451
|
+
},
|
|
1452
|
+
}),
|
|
1453
|
+
);
|
|
1454
|
+
}),
|
|
1455
|
+
];
|
|
1456
|
+
expect(() => validateBoot(features)).toThrow(/maxEdge/);
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
test("rejects quality: 101", () => {
|
|
1460
|
+
const features = [
|
|
1461
|
+
defineFeature("profile", (r) => {
|
|
1462
|
+
r.entity(
|
|
1463
|
+
"person",
|
|
1464
|
+
createEntity({
|
|
1465
|
+
fields: {
|
|
1466
|
+
avatar: createImageField({ variants: { thumb: { maxEdge: 200, quality: 101 } } }),
|
|
1467
|
+
},
|
|
1468
|
+
}),
|
|
1469
|
+
);
|
|
1470
|
+
}),
|
|
1471
|
+
];
|
|
1472
|
+
expect(() => validateBoot(features)).toThrow(/quality/);
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
test("the same validation applies to `images` (multi) fields", () => {
|
|
1476
|
+
const features = [
|
|
1477
|
+
defineFeature("profile", (r) => {
|
|
1478
|
+
r.entity(
|
|
1479
|
+
"person",
|
|
1480
|
+
createEntity({
|
|
1481
|
+
fields: {
|
|
1482
|
+
gallery: createImagesField({ variants: { "a/b": { maxEdge: 200 } } }),
|
|
1483
|
+
},
|
|
1484
|
+
}),
|
|
1485
|
+
);
|
|
1486
|
+
}),
|
|
1487
|
+
];
|
|
1488
|
+
expect(() => validateBoot(features)).toThrow(/must match/);
|
|
1489
|
+
});
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1358
1492
|
// --- entityList column-renderer form-check ---
|
|
1359
1493
|
// Validator akzeptiert die `{ react: { __component: "Name" } }`-Form
|
|
1360
1494
|
// (PlatformComponent → client-side Registry-Lookup) und prüft sie
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
2
|
+
import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key";
|
|
1
3
|
import { parseRefTarget } from "../parse-ref-target";
|
|
2
4
|
import type { EmbeddedFieldDef, EntityDefinition, FeatureDefinition } from "../types";
|
|
3
5
|
|
|
@@ -685,6 +687,56 @@ export function validateMultiSelectFields(feature: FeatureDefinition): void {
|
|
|
685
687
|
}
|
|
686
688
|
}
|
|
687
689
|
|
|
690
|
+
// --- Image variant validation ---
|
|
691
|
+
|
|
692
|
+
function isPositiveInt(value: number): boolean {
|
|
693
|
+
return Number.isInteger(value) && value > 0;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function assertValidVariantSpec(name: string, spec: VariantSpec, where: string): void {
|
|
697
|
+
if (!VARIANT_NAME_PATTERN.test(name)) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
`Image variant "${name}" ${where} must match ${VARIANT_NAME_PATTERN.source} — the name becomes part of a storage key and a URL segment.`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
if (spec.size !== undefined && spec.maxEdge !== undefined) {
|
|
703
|
+
throw new Error(`Image variant "${name}" ${where} sets both "size" and "maxEdge" — pick one.`);
|
|
704
|
+
}
|
|
705
|
+
if (
|
|
706
|
+
spec.size !== undefined &&
|
|
707
|
+
!(isPositiveInt(spec.size.width) && isPositiveInt(spec.size.height))
|
|
708
|
+
) {
|
|
709
|
+
throw new Error(
|
|
710
|
+
`Image variant "${name}" ${where} has a non-positive-integer "size" (${spec.size.width}x${spec.size.height}).`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
if (spec.maxEdge !== undefined && !isPositiveInt(spec.maxEdge)) {
|
|
714
|
+
throw new Error(
|
|
715
|
+
`Image variant "${name}" ${where} has a non-positive-integer "maxEdge" (${spec.maxEdge}).`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
if (spec.quality !== undefined && !(isPositiveInt(spec.quality) && spec.quality <= 100)) {
|
|
719
|
+
throw new Error(
|
|
720
|
+
`Image variant "${name}" ${where} has "quality" ${spec.quality} — must be an integer in 1..100.`,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// A bad spec is only visible when someone finally requests that variant —
|
|
726
|
+
// possibly months later, in production. Catch it at boot instead.
|
|
727
|
+
export function validateImageVariants(feature: FeatureDefinition): void {
|
|
728
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
729
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
730
|
+
if (field.type !== "image" && field.type !== "images") continue;
|
|
731
|
+
if (field.variants === undefined) continue;
|
|
732
|
+
const where = `on "${entityName}.${fieldName}" in feature "${feature.name}"`;
|
|
733
|
+
for (const [name, spec] of Object.entries(field.variants)) {
|
|
734
|
+
assertValidVariantSpec(name, spec, where);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
688
740
|
// --- Transition validation ---
|
|
689
741
|
|
|
690
742
|
export function validateTransitions(feature: FeatureDefinition): void {
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
validateExtensionPreSaveWiring,
|
|
26
26
|
validateFileFields,
|
|
27
27
|
validateHandlerAccess,
|
|
28
|
+
validateImageVariants,
|
|
28
29
|
validateLocatedTimestamps,
|
|
29
30
|
validateMultiSelectFields,
|
|
30
31
|
validateMultiStreamProjections,
|
|
@@ -168,6 +169,7 @@ export function validateBoot(
|
|
|
168
169
|
validateApiExposureMatching(feature, allExposedApis, featureMap);
|
|
169
170
|
validateEmbeddedFields(feature, featureMap);
|
|
170
171
|
validateMultiSelectFields(feature);
|
|
172
|
+
validateImageVariants(feature);
|
|
171
173
|
validateReferenceFields(feature, featureMap);
|
|
172
174
|
validateTransitions(feature);
|
|
173
175
|
validateExtensionUsages(feature, extensionProviders);
|
package/src/files/file-routes.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { getUser } from "../api/auth-middleware";
|
|
|
4
4
|
import type { DbConnection } from "../db/connection";
|
|
5
5
|
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
6
6
|
import { createTenantDb } from "../db/tenant-db";
|
|
7
|
+
import { createDerivativesContext, resolveFieldVariant } from "../derivatives";
|
|
7
8
|
import { isFileField, type Registry, type SessionUser, type TenantId } from "../engine/types";
|
|
8
9
|
import { generateId } from "../utils";
|
|
9
10
|
import { buildContentDispositionHeader } from "./content-disposition";
|
|
11
|
+
import { createFileContext } from "./file-handle";
|
|
10
12
|
import { fileRefEntity } from "./file-ref-entity";
|
|
11
13
|
import { fileRefsTable } from "./file-ref-table";
|
|
12
14
|
import type { FileProviderResolver } from "./provider-resolver";
|
|
@@ -235,6 +237,48 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
235
237
|
});
|
|
236
238
|
});
|
|
237
239
|
|
|
240
|
+
// GET /files/:id/variant/:name — a named derived version of the file.
|
|
241
|
+
// Same tenant + guard gate as the download above; the original stays
|
|
242
|
+
// reachable only through that route. The public, anonymous counterpart is
|
|
243
|
+
// a separate cut (#1951) and does not go through createFileRoutes.
|
|
244
|
+
api.get("/files/:id/variant/:name", async (c) => {
|
|
245
|
+
const user = getUser(c);
|
|
246
|
+
const id = c.req.param("id");
|
|
247
|
+
const name = c.req.param("name");
|
|
248
|
+
const fileRef = await loadFileForTenant(id, user.tenantId);
|
|
249
|
+
if (!fileRef) return c.json({ error: "not_found" }, 404);
|
|
250
|
+
|
|
251
|
+
const decision = await guard({ fileRef, user, operation: "read" });
|
|
252
|
+
if (decision === "deny") return c.json({ error: "not_found" }, 404);
|
|
253
|
+
|
|
254
|
+
// An unknown name and a denied file answer alike, so the route never
|
|
255
|
+
// confirms what exists. A missing renderer is NOT folded in here — that
|
|
256
|
+
// throws out of variant() as a 500, because a mount gap is a config
|
|
257
|
+
// error, not a missing variant.
|
|
258
|
+
const registry = options.registry;
|
|
259
|
+
const spec = registry
|
|
260
|
+
? resolveFieldVariant(registry, fileRef.entityType, fileRef.fieldName, name)
|
|
261
|
+
: undefined;
|
|
262
|
+
if (!registry || !spec) return c.json({ error: "not_found" }, 404);
|
|
263
|
+
|
|
264
|
+
// Built per request: createFileContext caches the resolved provider, so
|
|
265
|
+
// one shared across requests would serve tenant A's store to tenant B.
|
|
266
|
+
const files = createFileContext(() => options.resolveProvider(user.tenantId));
|
|
267
|
+
const derivatives = createDerivativesContext({
|
|
268
|
+
files,
|
|
269
|
+
registry,
|
|
270
|
+
db,
|
|
271
|
+
tenantId: user.tenantId,
|
|
272
|
+
});
|
|
273
|
+
const result = await derivatives.variant(id, spec, name);
|
|
274
|
+
const data = await files.ref(result.storageKey).read();
|
|
275
|
+
// No Content-Length/Content-Disposition: fileRef.size is the ORIGINAL's
|
|
276
|
+
// size, and a variant is rendered for display, not for download.
|
|
277
|
+
return new Response(Buffer.from(data), {
|
|
278
|
+
headers: { "Content-Type": result.mimeType },
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
238
282
|
// DELETE /files/:id — same guard, "delete" operation. Apps can differentiate
|
|
239
283
|
// read vs delete in their custom guard (e.g. only uploaders delete).
|
|
240
284
|
api.delete("/files/:id", async (c) => {
|