@cosmicdrift/kumiko-framework 0.193.1 → 0.195.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.
Files changed (38) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +71 -0
  3. package/src/api/server.ts +1 -1
  4. package/src/db/__tests__/migrate-generator.test.ts +17 -0
  5. package/src/db/__tests__/money.test.ts +41 -5
  6. package/src/db/event-store-executor-read.ts +1 -1
  7. package/src/db/index.ts +1 -1
  8. package/src/db/migrate-generator.ts +13 -3
  9. package/src/db/money.ts +34 -4
  10. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +73 -6
  11. package/src/derivatives/__tests__/derivatives-context.test.ts +9 -2
  12. package/src/derivatives/__tests__/variant-key.test.ts +2 -2
  13. package/src/derivatives/__tests__/variant-route.integration.test.ts +28 -0
  14. package/src/derivatives/derivatives-context.ts +7 -7
  15. package/src/derivatives/variant-key.ts +1 -1
  16. package/src/engine/__tests__/boot-validator.test.ts +140 -0
  17. package/src/engine/__tests__/embedded-derived.test.ts +35 -0
  18. package/src/engine/__tests__/engine.test.ts +20 -0
  19. package/src/engine/__tests__/schema-builder.test.ts +93 -0
  20. package/src/engine/boot-validator/entity-handler.ts +64 -5
  21. package/src/engine/boot-validator/screens.ts +57 -36
  22. package/src/engine/embedded-derived.ts +9 -1
  23. package/src/engine/schema-builder.ts +33 -23
  24. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +37 -6
  25. package/src/errors/zod-bridge.ts +4 -9
  26. package/src/event-store/__tests__/perf.integration.test.ts +2 -11
  27. package/src/files/file-routes.ts +20 -6
  28. package/src/files/storage-tracking.ts +2 -1
  29. package/src/jobs/job-runner.ts +18 -9
  30. package/src/logging/__tests__/fallback-logger.test.ts +43 -0
  31. package/src/logging/utils.ts +14 -1
  32. package/src/observability/__tests__/metric-validator.test.ts +10 -2
  33. package/src/observability/__tests__/metrics-handle.test.ts +30 -0
  34. package/src/observability/metric-validator.ts +4 -3
  35. package/src/observability/metrics-handle.ts +24 -12
  36. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +69 -5
  37. package/src/pipeline/dispatch-shared.ts +12 -9
  38. package/src/ui-types/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.193.1",
3
+ "version": "0.195.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.193.1",
189
+ "@cosmicdrift/kumiko-types": "0.195.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.193.1",
205
+ "@cosmicdrift/kumiko-dispatcher-live": "0.195.0",
206
206
  "bun-types": "^1.3.13",
207
207
  "pino-pretty": "^13.1.3"
208
208
  },
@@ -0,0 +1,71 @@
1
+ // kumiko-framework#1977: r.httpRoute's systemQuery used to always run with
2
+ // requestContext.get()?.ip === undefined, so a `rateLimit: {per: "ip"}`
3
+ // handler invoked through it silently never bucketed — see server.ts's
4
+ // systemQuery wiring. Proves the fix: repeated calls through the same
5
+ // httpRoute, same client IP, DO hit the limit.
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { z } from "zod";
9
+ import { createEntity, createTextField, defineFeature } from "../../engine";
10
+ import type { TenantId } from "../../engine/types/identifiers";
11
+ import { RateLimitError } from "../../errors";
12
+ import { setupTestStack, type TestStack } from "../../stack";
13
+
14
+ const SYSTEM_TENANT_ID = "00000000-0000-4000-8000-000000000000" as TenantId;
15
+
16
+ const ipLimitedFeature = defineFeature("rl-http", (r) => {
17
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
18
+ r.queryHandler("ping", z.object({}), async () => ({ ok: true }), {
19
+ access: { roles: ["anonymous"] },
20
+ rateLimit: { per: "ip", limit: 2, windowSeconds: 60 },
21
+ });
22
+ r.httpRoute({
23
+ method: "GET",
24
+ path: "/ping",
25
+ anonymous: true,
26
+ handler: async (c, deps) => {
27
+ try {
28
+ await deps.systemQuery("rl-http:query:ping", {}, SYSTEM_TENANT_ID);
29
+ return c.json({ ok: true });
30
+ } catch (err) {
31
+ if (err instanceof RateLimitError) return c.json({ ok: false }, 429);
32
+ throw err;
33
+ }
34
+ },
35
+ });
36
+ });
37
+
38
+ let stack: TestStack;
39
+
40
+ beforeAll(async () => {
41
+ stack = await setupTestStack({ features: [ipLimitedFeature] });
42
+ });
43
+
44
+ afterAll(async () => {
45
+ await stack.cleanup();
46
+ });
47
+
48
+ beforeEach(async () => {
49
+ await stack.redis.flushNamespace();
50
+ });
51
+
52
+ describe("r.httpRoute → systemQuery propagates requestContext for per-ip rate limiting", () => {
53
+ test("2 calls allowed, 3rd from the same IP is rate-limited", async () => {
54
+ const call = () => stack.app.request("/ping", { headers: { "x-forwarded-for": "9.9.9.1" } });
55
+
56
+ expect((await call()).status).toBe(200);
57
+ expect((await call()).status).toBe(200);
58
+ expect((await call()).status).toBe(429);
59
+ });
60
+
61
+ test("a different client IP gets its own bucket", async () => {
62
+ const callAs = (ip: string) =>
63
+ stack.app.request("/ping", { headers: { "x-forwarded-for": ip } });
64
+
65
+ expect((await callAs("9.9.9.2")).status).toBe(200);
66
+ expect((await callAs("9.9.9.2")).status).toBe(200);
67
+ expect((await callAs("9.9.9.2")).status).toBe(429);
68
+
69
+ expect((await callAs("9.9.9.3")).status).toBe(200);
70
+ });
71
+ });
package/src/api/server.ts CHANGED
@@ -778,7 +778,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
778
778
  // systemQuery is silent dead-code: enforceRateLimit reads
779
779
  // requestContext.get()?.ip, which is undefined here, so
780
780
  // buildBucketKey always returns {kind: "skip"}.
781
- requestContext.run(buildRequestContextData(c), () =>
781
+ requestContext.run(requestContext.get() ?? buildRequestContextData(c), () =>
782
782
  dispatcher.query(type, payload, createAnonymousUser(tenantId)),
783
783
  ),
784
784
  });
@@ -165,6 +165,23 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
165
165
  expect(sql).not.toContain("WARN: column-type-change");
166
166
  });
167
167
 
168
+ test("unmanaged: date → timestamptz type change emits the symmetric UTC-anchored USING clause", () => {
169
+ const prev = snapshotFromMetas([
170
+ meta("store_invoices", { name: "period_from", pgType: "date", notNull: true }),
171
+ ]);
172
+ const next = snapshotFromMetas([
173
+ meta("store_invoices", { name: "period_from", pgType: "timestamptz", notNull: true }),
174
+ ]);
175
+ const sql = renderMigrationSql(diffSnapshots(prev, next), {
176
+ name: "invoice-date-widen",
177
+ sequenceNumber: 8,
178
+ });
179
+ expect(sql).toContain(
180
+ 'ALTER TABLE "store_invoices" ALTER COLUMN "period_from" TYPE timestamptz USING ("period_from"::timestamp AT TIME ZONE \'UTC\');',
181
+ );
182
+ expect(sql).not.toContain("WARN: column-type-change");
183
+ });
184
+
168
185
  test("managed: multiple recreate reasons at once → all named in the warning", () => {
169
186
  const prev = snapshotFromMetas([
170
187
  meta("read_b", { name: "old_col", pgType: "text", notNull: false }, "managed"),
@@ -7,7 +7,7 @@
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import { createEntity, createMoneyField, createTextField } from "../../engine";
9
9
  import type { EntityDefinition } from "../../engine/types";
10
- import { flattenMoney, rehydrateMoney } from "../money";
10
+ import { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "../money";
11
11
 
12
12
  const orderEntity: EntityDefinition = createEntity({
13
13
  defaultCurrency: "EUR",
@@ -115,17 +115,16 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
115
115
  expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR", amountMinor: 45000 });
116
116
  });
117
117
 
118
- test("amountMinor bleibt exakter Integer, amount kann Float-Drift haben (fw#1830)", () => {
118
+ test("amountMinor bleibt exakter Integer über mehrere Additionen (fw#1830)", () => {
119
119
  const rows = [10, 20, 30].map(
120
120
  (minor) =>
121
121
  rehydrateMoney({ buyingPrice: minor, buyingPriceCurrency: "EUR" }, orderEntity)[
122
122
  "buyingPrice"
123
- ] as { amount: number; amountMinor: number },
123
+ ] as MoneyRead,
124
124
  );
125
125
  const [a, b, c] = rows;
126
126
 
127
127
  expect(a!.amountMinor + b!.amountMinor).toBe(c!.amountMinor);
128
- expect(a!.amount + b!.amount).not.toBe(c!.amount);
129
128
  });
130
129
 
131
130
  test("null/undefined amount → Field wird aus Output entfernt", () => {
@@ -178,7 +177,19 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
178
177
  test("korrupte string-amount (kein number) → loud throw, kein silent drop", () => {
179
178
  expect(() =>
180
179
  rehydrateMoney({ buyingPrice: "not-a-number", buyingPriceCurrency: "EUR" }, orderEntity),
181
- ).toThrow(/not a number — DB corruption/);
180
+ ).toThrow(/not a safe integer — DB corruption/);
181
+ });
182
+
183
+ test("fractional string amount (fw#1833) → loud throw statt amountMinor mit Nachkommastelle", () => {
184
+ expect(() =>
185
+ rehydrateMoney({ buyingPrice: "45000.7", buyingPriceCurrency: "EUR" }, orderEntity),
186
+ ).toThrow(/not a safe integer — DB corruption/);
187
+ });
188
+
189
+ test("string amount jenseits von Number.MAX_SAFE_INTEGER → loud throw statt Präzisionsverlust", () => {
190
+ expect(() =>
191
+ rehydrateMoney({ buyingPrice: "9007199254740993", buyingPriceCurrency: "EUR" }, orderEntity),
192
+ ).toThrow(/not a safe integer — DB corruption/);
182
193
  });
183
194
 
184
195
  test("unerwarteter amount-Typ (boolean) → loud throw", () => {
@@ -222,3 +233,28 @@ describe("flattenMoney — Strict-Mode Throw", () => {
222
233
  );
223
234
  });
224
235
  });
236
+
237
+ // kumiko-framework#1972: the write-handler-facing counterpart a custom
238
+ // totals check needs when reconciling a top-level money field's payload
239
+ // (major units, `{amount, currency}` or bare number) against an
240
+ // embedded-list row sum (minor-unit integers by convention).
241
+ describe("moneyPayloadToMinorUnits", () => {
242
+ test("{ amount, currency } object, crooked amount, rounds to the exact cent", () => {
243
+ expect(moneyPayloadToMinorUnits({ amount: 1234.56, currency: "EUR" })).toBe(123456);
244
+ });
245
+
246
+ test("legacy bare-number payload (also major units, per flattenMoney's permissive-insert contract)", () => {
247
+ expect(moneyPayloadToMinorUnits(1234.56)).toBe(123456);
248
+ });
249
+
250
+ test("a round amount does not mask a factor-of-100 regression: 30 major -> 3000 minor, not 30", () => {
251
+ expect(moneyPayloadToMinorUnits({ amount: 30, currency: "EUR" })).toBe(3000);
252
+ });
253
+
254
+ test("undefined for a payload without a numeric amount (nothing to compare)", () => {
255
+ expect(moneyPayloadToMinorUnits({ currency: "EUR" })).toBeUndefined();
256
+ expect(moneyPayloadToMinorUnits("100")).toBeUndefined();
257
+ expect(moneyPayloadToMinorUnits(null)).toBeUndefined();
258
+ expect(moneyPayloadToMinorUnits(undefined)).toBeUndefined();
259
+ });
260
+ });
@@ -166,7 +166,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
166
166
  const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
167
167
 
168
168
  const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
169
- // Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen.
169
+ // Per-row read-side rehydrate + snake→camel coercion for driver-agnostic field names.
170
170
  // Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
171
171
  // column names, while compound-type lookups (rehydrateMoney et al.) and the
172
172
  // encrypted/pii field lists are all camelCase — running either first on a
package/src/db/index.ts CHANGED
@@ -100,7 +100,7 @@ export {
100
100
  runMigrationsFromDir,
101
101
  splitSqlStatements,
102
102
  } from "./migrate-runner";
103
- export { flattenMoney, rehydrateMoney } from "./money";
103
+ export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money";
104
104
  export {
105
105
  constraintOf,
106
106
  extractPgError,
@@ -284,10 +284,20 @@ function renderColumnChange(tableName: string, change: ColumnChange): readonly s
284
284
  out.push(
285
285
  `ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE date USING (${col} AT TIME ZONE 'UTC')::date;`,
286
286
  );
287
+ } else if (from === "date" && (to === "timestamptz" || to === "timestamptz(3)")) {
288
+ // Same non-determinism, reversed: a bare `date → timestamptz` cast
289
+ // interprets the calendar date at session-TimeZone midnight. Anchor
290
+ // explicitly at UTC midnight instead.
291
+ out.push(
292
+ `-- date → ${to}: explicit UTC anchor — a bare cast would use the session TimeZone (non-deterministic).`,
293
+ );
294
+ out.push(
295
+ `ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to} USING (${col}::timestamp AT TIME ZONE 'UTC');`,
296
+ );
287
297
  } else {
288
- // pg ALTER TYPE braucht oft USING-clause für nicht-implicit-castable
289
- // type-changes. Wir emittieren das als Reviewer-Kommentar + raw cast —
290
- // App-Author muss prüfen ob das gewünscht ist.
298
+ // pg ALTER TYPE often needs a USING clause for non-implicitly-castable
299
+ // type changes. Emitted as a reviewer comment + raw cast — the app
300
+ // author has to confirm it's intended.
291
301
  out.push(`-- WARN: column-type-change ${from} → ${to}. Review USING-clause if needed.`);
292
302
  out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to};`);
293
303
  }
package/src/db/money.ts CHANGED
@@ -44,6 +44,27 @@ function toMajorUnits(amountMinor: number): number {
44
44
  return amountMinor / MINOR_UNIT_SCALE;
45
45
  }
46
46
 
47
+ // One money field's write payload — `{ amount, currency }` or a bare number
48
+ // (both MAJOR units, see file header) — reduced to minor units. This is the
49
+ // counterpart a write-handler needs when comparing a top-level money field
50
+ // against an `embeddedSubFieldToZod` list-row sum: rows are minor-unit
51
+ // integers by convention (currency lives on the head aggregate, not the
52
+ // row), so the two can only be compared once the sibling amount has been
53
+ // scaled the same way. `applyTotalsMatchRefinements` (schema-builder.ts)
54
+ // uses this internally for `EmbeddedFieldDef.totalsMatch`; exported so a
55
+ // custom write-handler doing its own total check doesn't have to re-derive
56
+ // this unwrap-and-scale step by hand (kumiko-framework#1972 — a hand-rolled
57
+ // comparison of a raw `{amount}` against a raw minor-unit row sum is exactly
58
+ // what silently fails 100x off).
59
+ export function moneyPayloadToMinorUnits(raw: unknown): number | undefined {
60
+ if (typeof raw === "number") return toMinorUnits(raw);
61
+ if (typeof raw === "object" && raw !== null && "amount" in raw) {
62
+ const amount = (raw as { amount: unknown }).amount;
63
+ if (typeof amount === "number") return toMinorUnits(amount);
64
+ }
65
+ return undefined;
66
+ }
67
+
47
68
  /**
48
69
  * API → DB: money-Felder zu zwei flachen Spalten flatten.
49
70
  *
@@ -101,6 +122,15 @@ export function flattenMoney(
101
122
  return result;
102
123
  }
103
124
 
125
+ /** Shape of a single rehydrated money field — {amount major, currency,
126
+ * amountMinor exact integer cents}. Exported so consumers type their own
127
+ * copy against this instead of re-declaring the shape by hand. */
128
+ export type MoneyRead = {
129
+ readonly amount: number;
130
+ readonly currency: string;
131
+ readonly amountMinor: number;
132
+ };
133
+
104
134
  /**
105
135
  * DB → API: zwei flache Spalten zu combined { amount, currency } rehydraten.
106
136
  *
@@ -134,15 +164,15 @@ export function rehydrateMoney(
134
164
  amountMinor = amountRaw;
135
165
  } else if (typeof amountRaw === "bigint") {
136
166
  amountMinor = Number(amountRaw);
137
- if (Number.isNaN(amountMinor)) {
138
- throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a number`);
167
+ if (!Number.isSafeInteger(amountMinor)) {
168
+ throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a safe integer`);
139
169
  }
140
170
  } else if (typeof amountRaw === "string" && amountRaw !== "") {
141
171
  // PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
142
172
  amountMinor = Number(amountRaw);
143
- if (Number.isNaN(amountMinor)) {
173
+ if (!Number.isSafeInteger(amountMinor)) {
144
174
  throw new Error(
145
- `rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a number — DB corruption?`,
175
+ `rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`,
146
176
  );
147
177
  }
148
178
  } else {
@@ -8,7 +8,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
8
  import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
9
9
  import { z } from "zod";
10
10
  import { defineFeature, EXT_DERIVATIVE_RENDERER } from "../../engine";
11
- import { InternalError, writeFailure } from "../../errors";
11
+ import { InternalError, NotFoundError, writeFailure } from "../../errors";
12
12
  import { createFilesFeature } from "../../files/feature";
13
13
  import { createInMemoryFileProvider } from "../../files/in-memory-provider";
14
14
  import { createTestUser, setupTestStack, type TestStack, testTenantId } from "../../stack";
@@ -41,6 +41,37 @@ const derivativesTestFeature = defineFeature("derivativestest", (r) => {
41
41
  { access: { openToAll: true } },
42
42
  );
43
43
 
44
+ // Catches the error INSIDE the handler (not via HTTP serialization) so the
45
+ // test can assert `instanceof` on the concrete error class — the wire
46
+ // response only carries the serialized WireErrorInfo, which loses that.
47
+ r.writeHandler(
48
+ "probe-typed-error",
49
+ z.object({ fileRefId: z.string() }),
50
+ async (event, ctx) => {
51
+ if (!ctx.derivatives) {
52
+ return writeFailure(
53
+ new InternalError({ message: "no ctx.derivatives on write-handler ctx" }),
54
+ );
55
+ }
56
+ try {
57
+ await ctx.derivatives.variant(event.payload.fileRefId, { maxEdge: 100 }, "thumb");
58
+ return { isSuccess: true as const, data: { threw: false } };
59
+ } catch (err) {
60
+ return {
61
+ isSuccess: true as const,
62
+ data: {
63
+ threw: true,
64
+ isNotFoundError: err instanceof NotFoundError,
65
+ notFoundHttpStatus: err instanceof NotFoundError ? err.httpStatus : undefined,
66
+ isInternalError: err instanceof InternalError,
67
+ internalHttpStatus: err instanceof InternalError ? err.httpStatus : undefined,
68
+ },
69
+ };
70
+ }
71
+ },
72
+ { access: { openToAll: true } },
73
+ );
74
+
44
75
  r.job("record", { trigger: { manual: true }, runIn: "worker" }, async (payload, ctx) => {
45
76
  const fileRefId = (payload as { fileRefId: string }).fileRefId;
46
77
  if (!ctx.derivatives) {
@@ -70,10 +101,13 @@ afterAll(async () => {
70
101
  await stack.cleanup();
71
102
  });
72
103
 
73
- async function uploadFile(asUser = user): Promise<string> {
104
+ async function uploadFile(
105
+ asUser = user,
106
+ file = new File([Buffer.from([1, 2, 3])], "photo.jpg", { type: "image/jpeg" }),
107
+ ): Promise<string> {
74
108
  const token = await stack.jwt.sign(asUser);
75
109
  const fd = new FormData();
76
- fd.append("file", new File([Buffer.from([1, 2, 3])], "photo.jpg", { type: "image/jpeg" }));
110
+ fd.append("file", file);
77
111
  const { body, contentType } = await buildMultipartBody(fd);
78
112
  const res = await stack.app.request("/api/files", {
79
113
  method: "POST",
@@ -129,7 +163,7 @@ describe("ctx.derivatives — the mandatory id/tenant/isDeleted filters", () =>
129
163
  { fileRefId: "00000000-0000-4000-8000-999999999999" },
130
164
  user,
131
165
  );
132
- expect(err.httpStatus).toBeGreaterThanOrEqual(400);
166
+ expect(err.httpStatus).toBe(404);
133
167
  });
134
168
 
135
169
  test("a soft-deleted fileRef throws", async () => {
@@ -146,7 +180,7 @@ describe("ctx.derivatives — the mandatory id/tenant/isDeleted filters", () =>
146
180
  { fileRefId: fileId },
147
181
  user,
148
182
  );
149
- expect(err.httpStatus).toBeGreaterThanOrEqual(400);
183
+ expect(err.httpStatus).toBe(404);
150
184
  });
151
185
 
152
186
  test("a fileRef uploaded under a different tenant is not resolvable", async () => {
@@ -157,6 +191,39 @@ describe("ctx.derivatives — the mandatory id/tenant/isDeleted filters", () =>
157
191
  { fileRefId: foreignFileId },
158
192
  user,
159
193
  );
160
- expect(err.httpStatus).toBeGreaterThanOrEqual(400);
194
+ expect(err.httpStatus).toBe(404);
195
+ });
196
+ });
197
+
198
+ describe("ctx.derivatives — typed errors from variant()", () => {
199
+ test("an unknown fileRefId throws NotFoundError with httpStatus 404", async () => {
200
+ const result = await stack.http.writeOk<{
201
+ threw: boolean;
202
+ isNotFoundError: boolean;
203
+ notFoundHttpStatus?: number;
204
+ }>(
205
+ "derivativestest:write:probe-typed-error",
206
+ { fileRefId: "00000000-0000-4000-8000-999999999999" },
207
+ user,
208
+ );
209
+ expect(result.threw).toBe(true);
210
+ expect(result.isNotFoundError).toBe(true);
211
+ expect(result.notFoundHttpStatus).toBe(404);
212
+ });
213
+
214
+ test("no renderer registered for the mimeType throws InternalError with httpStatus 500", async () => {
215
+ const fileId = await uploadFile(
216
+ user,
217
+ new File([Buffer.from([1, 2, 3])], "doc.pdf", { type: "application/pdf" }),
218
+ );
219
+
220
+ const result = await stack.http.writeOk<{
221
+ threw: boolean;
222
+ isInternalError: boolean;
223
+ internalHttpStatus?: number;
224
+ }>("derivativestest:write:probe-typed-error", { fileRefId: fileId }, user);
225
+ expect(result.threw).toBe(true);
226
+ expect(result.isInternalError).toBe(true);
227
+ expect(result.internalHttpStatus).toBe(500);
161
228
  });
162
229
  });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
3
3
  import type { Registry } from "../../engine/types";
4
+ import { InternalError } from "../../errors";
4
5
  import { createFileContext } from "../../files/file-handle";
5
6
  import { createInMemoryFileProvider } from "../../files/in-memory-provider";
6
7
  import { createDerivativesContext, resolveRenderer } from "../derivatives-context";
@@ -133,7 +134,7 @@ describe("createDerivativesContext — variant()", () => {
133
134
  expect(second.storageKey).not.toBe(first.storageKey);
134
135
  });
135
136
 
136
- test("no renderer registered for the mimeType throws, naming the known patterns", async () => {
137
+ test("no renderer registered for the mimeType throws InternalError, naming the known patterns in details", async () => {
137
138
  const provider = createInMemoryFileProvider();
138
139
  await provider.write("tenant/doc.pdf", new Uint8Array([1]));
139
140
  const files = createFileContext(() => Promise.resolve(provider));
@@ -141,7 +142,13 @@ describe("createDerivativesContext — variant()", () => {
141
142
  const db = fakeDbWithFileRef({ storageKey: "tenant/doc.pdf", mimeType: "application/pdf" });
142
143
  const ctx = createDerivativesContext({ files, registry, db, tenantId: TENANT_ID });
143
144
 
144
- await expect(ctx.variant(FILE_REF_ID, {}, "thumb")).rejects.toThrow(/image\/\*/);
145
+ // The renderer list moved out of the client-visible `message` into
146
+ // `details` — InternalError's serialize() drops `details` from the wire
147
+ // response, so it must not be the only place a caller can find it.
148
+ const err = await ctx.variant(FILE_REF_ID, {}, "thumb").catch((e) => e);
149
+ expect(err).toBeInstanceOf(InternalError);
150
+ expect((err as InternalError).httpStatus).toBe(500);
151
+ expect((err as InternalError).details).toEqual({ knownRenderers: "image/*" });
145
152
  });
146
153
 
147
154
  test("mimeType is consistent across a fresh render and a cache hit for the same spec", async () => {
@@ -33,8 +33,8 @@ describe("variantSuffix", () => {
33
33
  expect(variantSuffix("thumb", spec)).not.toBe(variantSuffix("card", spec));
34
34
  });
35
35
 
36
- test("shape is `<name>-<8 hex chars>`", () => {
37
- expect(variantSuffix("thumb", { maxEdge: 512 })).toMatch(/^thumb-[0-9a-f]{8}$/);
36
+ test("shape is `<name>-<16 hex chars>`", () => {
37
+ expect(variantSuffix("thumb", { maxEdge: 512 })).toMatch(/^thumb-[0-9a-f]{16}$/);
38
38
  });
39
39
 
40
40
  test("a path-traversal name is rejected — it would escape the tenant prefix in the derived key", () => {
@@ -94,6 +94,7 @@ describe("GET /api/files/:id/variant/:name", () => {
94
94
 
95
95
  expect(res.status).toBe(200);
96
96
  expect(res.headers.get("Content-Type")).toBe("image/webp");
97
+ expect(res.headers.get("Cache-Control")).toBe("private, max-age=31536000, immutable");
97
98
  expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
98
99
  });
99
100
 
@@ -166,4 +167,31 @@ describe("GET /api/files/:id/variant/:name", () => {
166
167
 
167
168
  expect(res.status).toBe(401);
168
169
  });
170
+
171
+ // createImageField has no `accept` restriction here, so validateFile
172
+ // never rejects a non-image upload against this field — the source
173
+ // mimeType a renderer has to handle is client-controlled, not just a
174
+ // deployment-time mount decision.
175
+ test("a source mimeType with no matching renderer (client uploaded a non-image) returns 415, not a 500", async () => {
176
+ const token = await stack.jwt.sign(user);
177
+ const fd = new FormData();
178
+ fd.append("file", new File([Buffer.from([1, 2, 3])], "doc.pdf", { type: "application/pdf" }));
179
+ fd.append("entityType", "photo");
180
+ fd.append("fieldName", "avatar");
181
+ const { body, contentType } = await buildMultipartBody(fd);
182
+ const uploadRes = await stack.app.request("/api/files", {
183
+ method: "POST",
184
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
185
+ body,
186
+ });
187
+ expect(uploadRes.status).toBe(201);
188
+ const { id: fileId } = (await uploadRes.json()) as { id: string };
189
+
190
+ const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
191
+ headers: { Authorization: `Bearer ${token}` },
192
+ });
193
+
194
+ expect(res.status).toBe(415);
195
+ expect(await res.json()).toEqual({ error: "unsupported_media_type" });
196
+ });
169
197
  });
@@ -6,6 +6,7 @@ import type {
6
6
  import { type AnyDb, fetchOne } from "../bun-db/query";
7
7
  import { EXT_DERIVATIVE_RENDERER } from "../engine/extension-names";
8
8
  import type { Registry, TenantId } from "../engine/types";
9
+ import { InternalError, NotFoundError } from "../errors";
9
10
  import type { FileContext } from "../files/file-handle";
10
11
  import { fileRefsTable } from "../files/file-ref-table";
11
12
  import { assertSafeStorageKey } from "../files/types";
@@ -94,12 +95,10 @@ export function createDerivativesContext(deps: DerivativesContextDeps): Derivati
94
95
  isDeleted: false,
95
96
  });
96
97
  if (!row) {
97
- throw new Error(`derivatives.variant: no fileRef found for id "${fileRefId}"`);
98
+ throw new NotFoundError("fileRef", fileRefId);
98
99
  }
99
100
  if (!isFileRefRow(row)) {
100
- throw new Error(
101
- `derivatives.variant: fileRef "${fileRefId}" is missing storageKey/mimeType`,
102
- );
101
+ throw new NotFoundError("fileRef", fileRefId);
103
102
  }
104
103
 
105
104
  const renderer = resolveRenderer(deps.registry, row.mimeType);
@@ -109,9 +108,10 @@ export function createDerivativesContext(deps: DerivativesContextDeps): Derivati
109
108
  .getExtensionUsages(EXT_DERIVATIVE_RENDERER)
110
109
  .map((u) => u.entityName)
111
110
  .join(", ") || "<none>";
112
- throw new Error(
113
- `derivatives.variant: no renderer registered for mimeType "${row.mimeType}". Known: ${known}.`,
114
- );
111
+ throw new InternalError({
112
+ message: `derivatives.variant: no renderer registered for mimeType "${row.mimeType}".`,
113
+ details: { knownRenderers: known },
114
+ });
115
115
  }
116
116
 
117
117
  const src = deps.files.ref(row.storageKey);
@@ -22,7 +22,7 @@ export function canonicalJson(value: unknown): string {
22
22
  // forever after a spec change; hashing the spec into the key means a
23
23
  // changed spec is automatically a new URL.
24
24
  export function specHash(spec: VariantSpec): string {
25
- return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0, 8);
25
+ return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0, 16);
26
26
  }
27
27
 
28
28
  export const VARIANT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/i;