@cosmicdrift/kumiko-framework 0.191.0 → 0.193.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.191.0",
3
+ "version": "0.193.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.191.0",
189
+ "@cosmicdrift/kumiko-types": "0.193.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.191.0",
205
+ "@cosmicdrift/kumiko-dispatcher-live": "0.193.0",
206
206
  "bun-types": "^1.3.13",
207
207
  "pino-pretty": "^13.1.3"
208
208
  },
@@ -1,5 +1,5 @@
1
1
  import type { Context, Next } from "hono";
2
- import { requestContext } from "./request-context";
2
+ import { type RequestContextData, requestContext } from "./request-context";
3
3
 
4
4
  const REQUEST_ID_HEADER = "X-Request-ID";
5
5
  const CORRELATION_ID_HEADER = "X-Correlation-ID";
@@ -14,6 +14,47 @@ function sanitizeClientId(value: string | undefined): string | undefined {
14
14
  return value !== undefined && SAFE_ID_RE.test(value) ? value : undefined;
15
15
  }
16
16
 
17
+ /**
18
+ * Builds the RequestContextData record for a Hono request — requestId
19
+ * (client-supplied + sanitized, or generated), correlationId (mirrors
20
+ * requestId unless the client set its own), the underlying abort signal,
21
+ * and the client IP/User-Agent. Extracted out of `requestIdMiddleware` so
22
+ * call-sites that invoke a handler outside that middleware's `next()`
23
+ * chain (e.g. server.ts's httpRoute→systemQuery mount) can still populate
24
+ * the same AsyncLocalStorage record via `requestContext.run(...)`.
25
+ */
26
+ export function buildRequestContextData(c: Context): RequestContextData {
27
+ const requestId =
28
+ sanitizeClientId(c.req.header(REQUEST_ID_HEADER)) ?? requestContext.generateId();
29
+ const correlationId = sanitizeClientId(c.req.header(CORRELATION_ID_HEADER)) ?? requestId;
30
+
31
+ // Hono exposes the underlying Fetch Request — its `signal` aborts
32
+ // when the client disconnects (mobile back-press, tab close). We
33
+ // propagate it through requestContext so framework internals can
34
+ // honour cancellation at long-running checkpoints. Older Hono /
35
+ // adapter combos may not populate `c.req.raw.signal`; conditional
36
+ // spread keeps `signal: undefined` out of the stored record so
37
+ // downstream `signal?` checks behave as if no signal exists.
38
+ const signal = c.req.raw?.signal;
39
+ // Client IP for per-IP rate limiting. Trust `x-forwarded-for` when
40
+ // present (proxy/CDN) — first hop is the originating client. Adapter-
41
+ // specific socket-address fallback (bun, node) is not standardized
42
+ // in Hono; deployments behind a proxy should always set xff. Without
43
+ // either we leave `ip` undefined and skip ip-bucketed checks rather
44
+ // than fabricate one.
45
+ const xff = c.req.header("x-forwarded-for");
46
+ const ip = xff?.split(",")[0]?.trim();
47
+ const userAgent = c.req.header("user-agent");
48
+
49
+ return {
50
+ requestId,
51
+ correlationId,
52
+ ...(signal ? { signal } : {}),
53
+ ...(ip && ip.length > 0 ? { ip } : {}),
54
+ ...(userAgent !== undefined ? { userAgent } : {}),
55
+ };
56
+ }
57
+
17
58
  /**
18
59
  * Assigns a requestId + correlationId to every request and wraps execution
19
60
  * in AsyncLocalStorage. Runs BEFORE auth — both ids are available even for
@@ -25,39 +66,11 @@ function sanitizeClientId(value: string | undefined): string | undefined {
25
66
  */
26
67
  export function requestIdMiddleware() {
27
68
  return async (c: Context, next: Next) => {
28
- const requestId =
29
- sanitizeClientId(c.req.header(REQUEST_ID_HEADER)) ?? requestContext.generateId();
30
- const correlationId = sanitizeClientId(c.req.header(CORRELATION_ID_HEADER)) ?? requestId;
31
- c.header(REQUEST_ID_HEADER, requestId);
32
- c.header(CORRELATION_ID_HEADER, correlationId);
33
- c.set("requestId", requestId);
34
-
35
- // Hono exposes the underlying Fetch Request — its `signal` aborts
36
- // when the client disconnects (mobile back-press, tab close). We
37
- // propagate it through requestContext so framework internals can
38
- // honour cancellation at long-running checkpoints. Older Hono /
39
- // adapter combos may not populate `c.req.raw.signal`; conditional
40
- // spread keeps `signal: undefined` out of the stored record so
41
- // downstream `signal?` checks behave as if no signal exists.
42
- const signal = c.req.raw?.signal;
43
- // Client IP for per-IP rate limiting. Trust `x-forwarded-for` when
44
- // present (proxy/CDN) — first hop is the originating client. Adapter-
45
- // specific socket-address fallback (bun, node) is not standardized
46
- // in Hono; deployments behind a proxy should always set xff. Without
47
- // either we leave `ip` undefined and skip ip-bucketed checks rather
48
- // than fabricate one.
49
- const xff = c.req.header("x-forwarded-for");
50
- const ip = xff?.split(",")[0]?.trim();
51
- const userAgent = c.req.header("user-agent");
52
- await requestContext.run(
53
- {
54
- requestId,
55
- correlationId,
56
- ...(signal ? { signal } : {}),
57
- ...(ip && ip.length > 0 ? { ip } : {}),
58
- ...(userAgent !== undefined ? { userAgent } : {}),
59
- },
60
- () => next(),
61
- );
69
+ const data = buildRequestContextData(c);
70
+ c.header(REQUEST_ID_HEADER, data.requestId);
71
+ c.header(CORRELATION_ID_HEADER, data.correlationId);
72
+ c.set("requestId", data.requestId);
73
+
74
+ await requestContext.run(data, () => next());
62
75
  };
63
76
  }
package/src/api/server.ts CHANGED
@@ -57,7 +57,8 @@ import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
57
57
  import { observabilityMiddleware } from "./observability-middleware";
58
58
  import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
59
59
  import { piiCiphertextResponseGuard } from "./pii-leak-guard";
60
- import { requestIdMiddleware } from "./request-id-middleware";
60
+ import { requestContext } from "./request-context";
61
+ import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware";
61
62
  import {
62
63
  DEFAULT_MAX_REQUEST_BYTES,
63
64
  registerBodyLimit,
@@ -769,7 +770,17 @@ export function buildServer(options: ServerOptions): KumikoServer {
769
770
  // it into a public response. The forced tenant already comes
770
771
  // from bypassing the HTTP layer entirely; no elevated role
771
772
  // is needed or wanted on top of that.
772
- dispatcher.query(type, payload, createAnonymousUser(tenantId)),
773
+ //
774
+ // httpRoute handlers run OUTSIDE /api/* — requestIdMiddleware
775
+ // (which wraps requestContext.run with ip/requestId/
776
+ // correlationId) never sees this request. Without this wrap,
777
+ // `rateLimit: {per: "ip", ...}` on a handler invoked via
778
+ // systemQuery is silent dead-code: enforceRateLimit reads
779
+ // requestContext.get()?.ip, which is undefined here, so
780
+ // buildBucketKey always returns {kind: "skip"}.
781
+ requestContext.run(buildRequestContextData(c), () =>
782
+ dispatcher.query(type, payload, createAnonymousUser(tenantId)),
783
+ ),
773
784
  });
774
785
  switch (route.method) {
775
786
  case "GET":
@@ -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
+ }
@@ -1,3 +1,4 @@
1
1
  export type { DerivativesContextDeps } from "./derivatives-context";
2
2
  export { createDerivativesContext, resolveRenderer } from "./derivatives-context";
3
- export { canonicalJson, specHash, variantSuffix } from "./variant-key";
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
@@ -814,6 +814,28 @@ describe("createApp", () => {
814
814
  );
815
815
  });
816
816
 
817
+ // hasMoneyField used to only look at top-level fields, so an entity whose
818
+ // only money lives inside an embedded-list sub-schema (e.g. invoice lines
819
+ // with no top-level money field) slipped past this check — its cells and
820
+ // totals would then silently render in entity.defaultCurrency ?? "EUR"
821
+ // regardless of the app's actual currency.
822
+ test("rejects an entity whose only money field is nested in an embedded list", () => {
823
+ const feature = defineFeature("test", (r) => {
824
+ r.entity(
825
+ "invoice",
826
+ createEntity({
827
+ table: "Invoices",
828
+ fields: {
829
+ lines: createEmbeddedListField({ amount: { type: "money", required: true } }),
830
+ },
831
+ }),
832
+ );
833
+ });
834
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
835
+ "has money fields but no defaultCurrency",
836
+ );
837
+ });
838
+
817
839
  test("rejects unknown defaultCurrency", () => {
818
840
  const feature = defineFeature("test", (r) => {
819
841
  r.entity(
@@ -955,6 +977,9 @@ describe("createApp", () => {
955
977
  qty: { type: "decimal", scale: 3 },
956
978
  }),
957
979
  },
980
+ // meta.amount is money nested in an embedded field — needs a
981
+ // defaultCurrency the same as a top-level money field would.
982
+ defaultCurrency: "EUR",
958
983
  }),
959
984
  );
960
985
  });
@@ -1073,6 +1098,7 @@ describe("createApp", () => {
1073
1098
  { totalsMatch: { amount: "title" } },
1074
1099
  ),
1075
1100
  },
1101
+ defaultCurrency: "EUR",
1076
1102
  }),
1077
1103
  );
1078
1104
  });
@@ -1093,6 +1119,7 @@ describe("createApp", () => {
1093
1119
  { totalsMatch: { amount: "ghostTotal" } },
1094
1120
  ),
1095
1121
  },
1122
+ defaultCurrency: "EUR",
1096
1123
  }),
1097
1124
  );
1098
1125
  });
@@ -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);
@@ -85,7 +85,17 @@ export function createApp(config: AppConfig): App {
85
85
  // Validate defaultCurrency on entities that have money fields
86
86
  for (const feature of config.features) {
87
87
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
88
- const hasMoneyField = Object.values(entity.fields).some((f) => f.type === "money");
88
+ // A top-level money field isn't the only way an entity can hold money
89
+ // an embedded-list's sub-schema (e.g. invoice lines) can carry a money
90
+ // cell with no top-level money field at all. Without this, that entity
91
+ // slips past the defaultCurrency check and its cells/totals render in
92
+ // the entity.defaultCurrency ?? "EUR" fallback regardless of the app's
93
+ // actual currency.
94
+ const hasMoneyField = Object.values(entity.fields).some(
95
+ (f) =>
96
+ f.type === "money" ||
97
+ (f.type === "embedded" && Object.values(f.schema).some((s) => s.type === "money")),
98
+ );
89
99
  if (entity.defaultCurrency && !currencies.includes(entity.defaultCurrency)) {
90
100
  throw new Error(
91
101
  `Entity "${entityName}" in feature "${feature.name}" has defaultCurrency "${entity.defaultCurrency}" which is not in the currencies list. Available: ${currencies.join(", ")}`,
@@ -104,6 +104,22 @@ export const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider" as con
104
104
  */
105
105
  export const EXT_DERIVATIVE_RENDERER = "derivativeRenderer" as const;
106
106
 
107
+ /**
108
+ * `derivativePublicPredicate` — per-entityType "is this FileRef's derivative
109
+ * publicly readable?" gate for the anonymous variant route (file-derivatives).
110
+ *
111
+ * Apps register via
112
+ * `r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "<entityType>", { isPublic:
113
+ * (args, ctx) => boolean | Promise<boolean> })`, where `<entityType>` is the
114
+ * `entityType` string a FileRef carries from upload (e.g. "vehicle", "event").
115
+ * No registration for a given entityType is default-deny — the public route
116
+ * serves nothing for that entityType, not even a 403 (404, so existence isn't
117
+ * confirmed to an unauthorised caller).
118
+ *
119
+ * Registered/consumed by: `file-derivatives`' public variant route (#1951).
120
+ */
121
+ export const EXT_DERIVATIVE_PUBLIC_PREDICATE = "derivativePublicPredicate" as const;
122
+
107
123
  /**
108
124
  * `searchAdapter` — Search-Adapter-Forget-Hooks (Meilisearch-Index-Cleanup
109
125
  * bei User-Forget oder Tenant-Destroy).
@@ -77,6 +77,7 @@ export type { EmitCtx } from "./event-helpers";
77
77
  export { emitEvent, typedPayload } from "./event-helpers";
78
78
  export type { KumikoExtensionName } from "./extension-names";
79
79
  export {
80
+ EXT_DERIVATIVE_PUBLIC_PREDICATE,
80
81
  EXT_DERIVATIVE_RENDERER,
81
82
  EXT_EXTERNAL_RESOURCE,
82
83
  EXT_FILE_PROVIDER,
@@ -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) => {
@@ -0,0 +1,55 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ // Simulates an unreachable Redis: waitUntilReady() never resolves. Everything
4
+ // else start() touches (Queue.on/close/add/upsertJobScheduler) is a no-op —
5
+ // with an empty registry those code paths aren't exercised anyway.
6
+ mock.module("bullmq", () => {
7
+ class FakeQueue {
8
+ on() {}
9
+ close() {
10
+ return Promise.resolve();
11
+ }
12
+ getJobCounts() {
13
+ return Promise.resolve({});
14
+ }
15
+ removeJobScheduler() {
16
+ return Promise.resolve();
17
+ }
18
+ upsertJobScheduler() {
19
+ return Promise.resolve();
20
+ }
21
+ add() {
22
+ return Promise.resolve();
23
+ }
24
+ }
25
+ class FakeWorker {
26
+ on() {}
27
+ waitUntilReady() {
28
+ return new Promise(() => {});
29
+ }
30
+ close() {
31
+ return Promise.resolve();
32
+ }
33
+ }
34
+ return { Queue: FakeQueue, Worker: FakeWorker };
35
+ });
36
+
37
+ import { createRegistry } from "../../engine";
38
+ import type { AppContext } from "../../engine/types";
39
+ import { createJobRunner } from "../job-runner";
40
+
41
+ describe("createJobRunner start() boot timeout", () => {
42
+ test("rejects instead of hanging forever when the worker's Redis connection never becomes ready", async () => {
43
+ const registry = createRegistry([]);
44
+ const context: AppContext = {};
45
+ const runner = createJobRunner({
46
+ registry,
47
+ context,
48
+ redisUrl: "redis://localhost:6379",
49
+ consumerLane: "worker",
50
+ bootRedisTimeoutMs: 50,
51
+ });
52
+
53
+ await expect(runner.start()).rejects.toThrow(/Redis not reachable within 50ms \(lane=worker\)/);
54
+ });
55
+ });
@@ -132,6 +132,10 @@ export type JobRunnerOptions = {
132
132
  // Tests set a unique prefix (e.g. `"test-${Date.now()}"`) for isolation —
133
133
  // two parallel test-runners never see each other's jobs.
134
134
  queueNamePrefix?: string | undefined;
135
+ // Override how long start() waits for the worker's Redis connection
136
+ // before failing boot. Defaults to BOOT_REDIS_TIMEOUT_MS; tests shrink it
137
+ // to keep an unreachable-Redis assertion fast.
138
+ bootRedisTimeoutMs?: number | undefined;
135
139
  getActiveTenantIds?: () => Promise<TenantId[]>;
136
140
  onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
137
141
  onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[]) => void;
@@ -192,9 +196,20 @@ function parseRedisOpts(url: string): { host: string; port: number; db?: number
192
196
  return result;
193
197
  }
194
198
 
199
+ // redisOpts carries no connectTimeout/retry cap, so an unreachable Redis
200
+ // would otherwise hang start() forever with no health endpoint to notice.
201
+ const BOOT_REDIS_TIMEOUT_MS = 10_000;
202
+
203
+ function timeoutReject(ms: number, message: string): Promise<never> {
204
+ return new Promise((_, reject) => {
205
+ setTimeout(() => reject(new Error(message)), ms);
206
+ });
207
+ }
208
+
195
209
  export function createJobRunner(options: JobRunnerOptions): JobRunner {
196
210
  const { registry, context, redisUrl, consumerLane } = options;
197
211
  const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
212
+ const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
198
213
  const redisOpts = parseRedisOpts(redisUrl);
199
214
  // Use the context's tracer when present (observability-provider injected at
200
215
  // boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
@@ -531,7 +546,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
531
546
  // worker's connections (main + blocking) are ready (fw#1805). This
532
547
  // mirrors the wait BullMQ already does internally for
533
548
  // upsertJobScheduler()/add() below when the lane has a cron/boot job.
534
- await worker.waitUntilReady();
549
+ // Racing a timeout against it keeps an unreachable Redis from hanging
550
+ // start() forever — there's no worker health endpoint to notice.
551
+ await Promise.race([
552
+ worker.waitUntilReady(),
553
+ timeoutReject(
554
+ bootRedisTimeoutMs,
555
+ `job-runner: Redis not reachable within ${bootRedisTimeoutMs}ms (lane=${consumerLane})`,
556
+ ),
557
+ ]);
535
558
 
536
559
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
537
560
  // assigned to the other lane get their cron/boot wiring from the