@cosmicdrift/kumiko-bundled-features 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.193.1",
3
+ "version": "0.195.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -125,12 +125,12 @@
125
125
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
126
126
  },
127
127
  "dependencies": {
128
- "@cosmicdrift/kumiko-dispatcher-live": "0.193.1",
129
- "@cosmicdrift/kumiko-framework": "0.193.1",
130
- "@cosmicdrift/kumiko-headless": "0.193.1",
131
- "@cosmicdrift/kumiko-renderer": "0.193.1",
132
- "@cosmicdrift/kumiko-renderer-web": "0.193.1",
133
- "@cosmicdrift/kumiko-types": "0.193.1",
128
+ "@cosmicdrift/kumiko-dispatcher-live": "0.195.0",
129
+ "@cosmicdrift/kumiko-framework": "0.195.0",
130
+ "@cosmicdrift/kumiko-headless": "0.195.0",
131
+ "@cosmicdrift/kumiko-renderer": "0.195.0",
132
+ "@cosmicdrift/kumiko-renderer-web": "0.195.0",
133
+ "@cosmicdrift/kumiko-types": "0.195.0",
134
134
  "@mollie/api-client": "^4.5.0",
135
135
  "@node-rs/argon2": "^2.0.2",
136
136
  "@types/mailparser": "^3.4.6",
@@ -35,10 +35,10 @@ export type SmtpTransportOptions = {
35
35
  readonly user: string;
36
36
  readonly pass: string;
37
37
  };
38
- /** Default From-Adresse, app-weit. Eine einzelne Mail kann sie über
39
- * `EmailMessage.from` überschreiben (Reply aus einem bestimmten
40
- * Postfach); ohne Override greift diese. Format akzeptiert beides:
41
- * "noreply@ex.com" oder "Name <noreply@ex.com>". */
38
+ /** App-wide default From. A single send can override it via
39
+ * `EmailMessage.from` (reply from a specific mailbox); without an
40
+ * override this applies. Accepts both "noreply@ex.com" and
41
+ * "Name <noreply@ex.com>". */
42
42
  readonly from: string;
43
43
  };
44
44
 
@@ -216,6 +216,25 @@ describe("renderImage — validation", () => {
216
216
  const meta = await sharp(output).metadata();
217
217
  expect(meta.format).toBe("webp");
218
218
  });
219
+
220
+ test("a sourceMimeType matching an inherited Object.prototype key throws instead of using it as an encoder", async () => {
221
+ // Plain-object lookup on SOURCE_FORMAT_ENCODERS would resolve
222
+ // "constructor" to Object (an inherited key, not a real entry) without
223
+ // an Object.hasOwn guard — proving the throw path, not a silent pass-through.
224
+ const input = await unmappedFormatFixture(50, 50);
225
+ await expect(renderImage(input, {}, "constructor")).rejects.toThrow(/no output encoder/);
226
+ });
227
+
228
+ test("image/png ignores spec.quality instead of switching into lossy palette quantization", async () => {
229
+ const input = await pngFixture(50, 50);
230
+ const withQuality = await renderImage(input, { quality: 10 }, "image/png");
231
+ const withoutQuality = await renderImage(input, {}, "image/png");
232
+ const withQualityMeta = await sharp(withQuality).metadata();
233
+ // A quality-driven palette encode would quantize to a small color count;
234
+ // an ignored quality keeps the full-color (non-palette) PNG.
235
+ expect(withQualityMeta.format).toBe("png");
236
+ expect(Buffer.compare(withQuality, withoutQuality)).toBe(0);
237
+ });
219
238
  });
220
239
 
221
240
  describe("imageMetadata", () => {
@@ -98,7 +98,10 @@ const SOURCE_FORMAT_ENCODERS: Record<string, (pipeline: Sharp, quality?: number)
98
98
  // The upload whitelist admits the sloppy-but-common image/jpg alias
99
99
  "image/jpg": (pipeline, quality) =>
100
100
  pipeline.jpeg(quality !== undefined ? { quality } : undefined),
101
- "image/png": (pipeline, quality) => pipeline.png(quality !== undefined ? { quality } : undefined),
101
+ // sharp's PngOptions.quality doesn't mean JPEG/WebP-style compression
102
+ // it switches PNG into palette quantization (lossless -> color-reduced,
103
+ // banding instead of a softer/smaller image). Ignore it, same as gif.
104
+ "image/png": (pipeline) => pipeline.png(),
102
105
  "image/webp": (pipeline, quality) =>
103
106
  pipeline.webp(quality !== undefined ? { quality } : undefined),
104
107
  "image/avif": (pipeline, quality) =>
@@ -125,7 +128,7 @@ function applyEncoder(pipeline: Sharp, spec: VariantSpec, sourceMimeType: string
125
128
  }
126
129
 
127
130
  const encode = SOURCE_FORMAT_ENCODERS[sourceMimeType];
128
- if (!encode) {
131
+ if (!Object.hasOwn(SOURCE_FORMAT_ENCODERS, sourceMimeType) || encode === undefined) {
129
132
  throw new Error(
130
133
  `derivatives-sharp: no output encoder for source mimeType "${sourceMimeType}" — set spec.format to pick an explicit output format.`,
131
134
  );
@@ -1,13 +1,15 @@
1
1
  // Proves GET {basePath}/:fileRefId/:variant end-to-end: anonymous, no
2
2
  // Authorization header, tenant resolved ONLY from Host (never the payload),
3
3
  // default-deny when no `derivativePublicPredicate` is registered for the
4
- // FileRef's entityType (or it returns false), the fixed preset-name
5
- // pre-check running BEFORE any DB/systemQuery work, and the Step-1
6
- // requestContext fix that makes `rateLimit: {per: "ip"}` actually gate an
7
- // r.httpRoute handler invoked via systemQuery (#1951).
4
+ // FileRef's entityType (or it returns false), the variant spec resolving
5
+ // from the FileRef's field declaration (not a fixed preset map, #1985), and
6
+ // the Step-1 requestContext fix that makes `rateLimit: {per: "ip"}` actually
7
+ // gate an r.httpRoute handler invoked via systemQuery (#1951).
8
8
 
9
9
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
10
  import {
11
+ createEntity,
12
+ createImageField,
11
13
  defineFeature,
12
14
  EXT_DERIVATIVE_PUBLIC_PREDICATE,
13
15
  EXT_DERIVATIVE_RENDERER,
@@ -26,7 +28,10 @@ import {
26
28
  buildMultipartBody,
27
29
  patchFileInstanceofForBunTest,
28
30
  } from "@cosmicdrift/kumiko-framework/testing";
29
- import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
31
+ import type {
32
+ DerivativeRendererPlugin,
33
+ VariantSpec,
34
+ } from "@cosmicdrift/kumiko-types/derivatives-types";
30
35
  import { createConfigFeature } from "../../config";
31
36
  import { fileFoundationFeature } from "../../file-foundation";
32
37
  import { createFileDerivativesFeature } from "../feature";
@@ -35,16 +40,43 @@ import type { DerivativePublicPredicateArgs } from "../handlers/public-variant.q
35
40
  const VARIANT_BYTES = new Uint8Array([7, 7, 7]);
36
41
 
37
42
  let renderCalls = 0;
38
- const fakeRender: DerivativeRendererPlugin["render"] = async () => {
43
+ let lastRenderSpec: VariantSpec | null = null;
44
+ const fakeRender: DerivativeRendererPlugin["render"] = async (_input, spec) => {
39
45
  renderCalls++;
46
+ lastRenderSpec = spec;
40
47
  return VARIANT_BYTES;
41
48
  };
42
49
 
43
50
  const PUBLIC_WIDGET_ID = "widget-public";
44
51
  const OTHER_WIDGET_ID = "widget-other";
45
52
 
53
+ // `full` deliberately overrides the built-in preset's maxEdge (2560) — this
54
+ // is what proves the route resolves the spec from the field declaration
55
+ // (#1985), not from the frozen preset constants. `plan` is a name with no
56
+ // preset counterpart at all — the exact motivating case from #1985 (an app
57
+ // declaring its own variant name). `heroWide` uses characters (uppercase,
58
+ // underscore-adjacent camelCase) outside a naive `[a-z0-9-]` guard — the
59
+ // `variants` map has no runtime charset constraint, so the route's
60
+ // syntactic pre-check must not narrow it. `plain` has no variants at all,
61
+ // for the "field declares no variants" 404 case.
62
+ const widgetEntity = createEntity({
63
+ table: "public_variant_widgets",
64
+ fields: {
65
+ img: createImageField({
66
+ variants: {
67
+ thumb: { maxEdge: 160, format: "webp" },
68
+ full: { maxEdge: 4096, format: "webp" },
69
+ plan: { maxEdge: 4096, format: "webp" },
70
+ heroWide: { maxEdge: 3200, format: "webp" },
71
+ },
72
+ }),
73
+ plain: createImageField(),
74
+ },
75
+ });
76
+
46
77
  let predicateCalls = 0;
47
78
  const widgetPredicateFeature = defineFeature("publicvariantroutetest", (r) => {
79
+ r.entity("widget", widgetEntity);
48
80
  r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "widget", {
49
81
  isPublic: (args: DerivativePublicPredicateArgs) => {
50
82
  predicateCalls++;
@@ -91,6 +123,7 @@ afterAll(async () => {
91
123
 
92
124
  beforeEach(async () => {
93
125
  renderCalls = 0;
126
+ lastRenderSpec = null;
94
127
  predicateCalls = 0;
95
128
  // Fresh rate-limit bucket per test — no carry-over.
96
129
  await stack.redis.flushNamespace();
@@ -99,13 +132,14 @@ beforeEach(async () => {
99
132
  async function uploadFile(
100
133
  asUser: typeof userA,
101
134
  attach: { entityType: string; entityId: string },
135
+ fieldName = "img",
102
136
  ): Promise<string> {
103
137
  const token = await stack.jwt.sign(asUser);
104
138
  const fd = new FormData();
105
139
  fd.append("file", new File([Buffer.from([1, 2, 3])], "img.jpg", { type: "image/jpeg" }));
106
140
  fd.append("entityType", attach.entityType);
107
141
  fd.append("entityId", attach.entityId);
108
- fd.append("fieldName", "img");
142
+ fd.append("fieldName", fieldName);
109
143
  const { body, contentType } = await buildMultipartBody(fd);
110
144
  const res = await stack.app.request("/api/files", {
111
145
  method: "POST",
@@ -174,21 +208,84 @@ describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
174
208
  expect(await revalidate.arrayBuffer()).toEqual(new ArrayBuffer(0));
175
209
  });
176
210
 
177
- test("invalid variant name → 404, pre-check runs before any DB/systemQuery work", async () => {
211
+ test("syntactically malformed variant names → 404, pre-check runs before any DB/systemQuery work", async () => {
212
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
213
+ // Well-formed-looking but unregistered names (e.g. "not-a-preset") now
214
+ // reach the DB — the pre-check is purely syntactic since #1985, not a
215
+ // name list. Only pathological input is rejected here.
216
+ const malformedNames = [
217
+ "a".repeat(65), // over the 64-char cap
218
+ "foo%2Fbar", // decodes to a path separator
219
+ "foo..bar", // "." isn't in the allowed charset either
220
+ "foo%20bar", // decodes to a space
221
+ ];
222
+
223
+ for (const name of malformedNames) {
224
+ const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/${name}`);
225
+ expect(res.status).toBe(404);
226
+ }
227
+ expect(predicateCalls).toBe(0);
228
+ expect(renderCalls).toBe(0);
229
+ });
230
+
231
+ test("the field's own spec is used, not the built-in preset — proves resolution comes from the field declaration (#1985)", async () => {
232
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
233
+
234
+ const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/full`);
235
+
236
+ expect(res.status).toBe(200);
237
+ // widgetEntity's "img" field declares maxEdge:4096 for "full" — the
238
+ // built-in `full` preset (presets.ts) is maxEdge:2560. Getting 4096 here
239
+ // proves the spec came from the field, not the frozen preset constant.
240
+ expect(lastRenderSpec?.maxEdge).toBe(4096);
241
+ });
242
+
243
+ test("a field-declared name with no preset counterpart is served end-to-end — the exact case #1985 reported", async () => {
178
244
  const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
179
245
 
180
- const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/not-a-preset`);
246
+ const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/plan`);
247
+
248
+ expect(res.status).toBe(200);
249
+ expect(lastRenderSpec?.maxEdge).toBe(4096);
250
+ });
251
+
252
+ test("a declared name outside [a-z0-9-] (camelCase) is still reachable — the syntactic gate isn't a narrower name list", async () => {
253
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
254
+
255
+ const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/heroWide`);
256
+
257
+ expect(res.status).toBe(200);
258
+ expect(lastRenderSpec?.maxEdge).toBe(3200);
259
+ });
260
+
261
+ test("a preset name the field doesn't declare in its variants → 404", async () => {
262
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
263
+
264
+ // widgetEntity's "img" field declares only thumb/full — "card" and
265
+ // "hero" aren't in its variants map.
266
+ const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/card`);
181
267
 
182
268
  expect(res.status).toBe(404);
183
- expect(predicateCalls).toBe(0);
269
+ expect(predicateCalls).toBeGreaterThan(0);
270
+ expect(renderCalls).toBe(0);
271
+ });
272
+
273
+ test("a FileRef whose field declares no variants at all → 404", async () => {
274
+ const fileId = await uploadFile(
275
+ userA,
276
+ { entityType: "widget", entityId: PUBLIC_WIDGET_ID },
277
+ "plain",
278
+ );
279
+
280
+ const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
281
+
282
+ expect(res.status).toBe(404);
283
+ expect(predicateCalls).toBeGreaterThan(0);
184
284
  expect(renderCalls).toBe(0);
185
285
  });
186
286
 
187
287
  test("unknown fileRefId → 404", async () => {
188
- // Valid UUID shape (the id column's type) but no matching row — a
189
- // malformed/non-UUID id is a separate concern shared with the
190
- // already-merged #1950 route (same fetchOne-by-id pattern), out of
191
- // scope here.
288
+ // Valid UUID shape (the id column's type) but no matching row.
192
289
  const res = await stack.app.request(
193
290
  `http://${HOST_A}/media/00000000-0000-4000-8000-000000000000/thumb`,
194
291
  );
@@ -196,6 +293,17 @@ describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
196
293
  expect(res.status).toBe(404);
197
294
  });
198
295
 
296
+ test("malformed fileRefId → 404, pre-check runs before any DB/systemQuery work", async () => {
297
+ // Unlike #1950 (auth-gated), this route is anonymous — a non-UUID id
298
+ // reaching fetchOne() would throw Postgres 22P02 on an unauthenticated
299
+ // request, an unauth DoS primitive. Must 404 at the httpRoute pre-check.
300
+ const res = await stack.app.request(`http://${HOST_A}/media/not-a-uuid/thumb`);
301
+
302
+ expect(res.status).toBe(404);
303
+ expect(predicateCalls).toBe(0);
304
+ expect(renderCalls).toBe(0);
305
+ });
306
+
199
307
  test("cross-tenant: FileRef under tenant A, request resolves to tenant B → 404", async () => {
200
308
  const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
201
309
 
@@ -16,13 +16,27 @@ import {
16
16
  } from "@cosmicdrift/kumiko-framework/engine";
17
17
  import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
18
18
  import { PUBLIC_VARIANT_QN, publicVariantQuery } from "./handlers/public-variant.query";
19
- import { PRESET_VARIANT_NAMES } from "./presets";
20
19
 
21
20
  const FEATURE_NAME = "file-derivatives";
22
21
 
23
- // Widened from the readonly-tuple type of PRESET_VARIANT_NAMES so
24
- // `.includes(variant)` accepts a plain `string` param without an `as` cast.
25
- const VALID_VARIANT_NAMES: readonly string[] = PRESET_VARIANT_NAMES;
22
+ // fileRefsTable.id is a UUID column a malformed id must fail here, not at
23
+ // the DB. Without this, an anonymous, internet-facing caller can throw a
24
+ // Postgres 22P02 on every request (Bun.SQL pools the failed connection),
25
+ // a DoS primitive with no auth required.
26
+ const FILE_REF_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
27
+
28
+ // Syntactic only, no registry/name-list lookup: a valid preset name plus a
29
+ // random UUID reaches the same DB read anyway, so an allow-list of known
30
+ // names defends nothing a determined caller can't route around — it only
31
+ // blocks the cheaper of two equally-costly attacks. What a name actually
32
+ // resolves to is decided by the field declaration in publicVariantQuery;
33
+ // this just keeps pathological input (path separators, `..`, oversized
34
+ // strings) out before that DB round-trip. `variants` keys have no runtime
35
+ // charset constraint (`Readonly<Record<string, VariantSpec>>`), so this
36
+ // stays permissive rather than guessing a convention — narrower than this
37
+ // would silently 404 a legitimately declared name again. Do not reintroduce
38
+ // a name list here "for safety" — see #1985.
39
+ const VARIANT_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
26
40
 
27
41
  export type PublicVariantResolveApexTenant = (
28
42
  host: string,
@@ -46,21 +60,23 @@ type PublicVariantQueryResult = {
46
60
  } | null;
47
61
 
48
62
  // file-derivatives — derive-on-first-use file variants (thumbnails,
49
- // resized/reformatted images) plus an opt-in ANONYMOUS route that serves
50
- // one of 4 fixed presets (thumb/card/hero/full) for a FileRef whose
63
+ // resized/reformatted images) plus an opt-in ANONYMOUS route that serves any
64
+ // variant an app declared on the FileRef's field (`createImageField({
65
+ // variants: {...} })`, including but not limited to the `thumb`/`card`/
66
+ // `hero`/`full` presets exported from `./presets`), for a FileRef whose
51
67
  // entityType has a registered, default-deny `derivativePublicPredicate`.
52
68
  //
53
69
  // The route never serves the original and never accepts a free VariantSpec
54
- // — only a preset name from the fixed list, and only after the app's
55
- // registered `isPublic` predicate for the FileRef's entityType says yes.
56
- // tenantId is resolved from the request Host via `resolveApexTenant`, NEVER
57
- // read from the request payload.
70
+ // — only a name, resolved against the field's own `variants` declaration
71
+ // and only after the app's registered `isPublic` predicate for the FileRef's
72
+ // entityType says yes. tenantId is resolved from the request Host via
73
+ // `resolveApexTenant`, NEVER read from the request payload.
58
74
  export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}): FeatureDefinition {
59
75
  const basePath = opts.basePath ?? "/media";
60
76
 
61
77
  return defineFeature(FEATURE_NAME, (r) => {
62
78
  r.describe(
63
- "Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves ONE of the 4 fixed presets (thumb/card/hero/full) for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef.",
79
+ "Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name.",
64
80
  );
65
81
  r.uiHints({
66
82
  displayLabel: "File Derivatives",
@@ -100,12 +116,14 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
100
116
  // template, so Hono can't infer the param type from a literal.
101
117
  const fileRefId = c.req.param("fileRefId");
102
118
  const variant = c.req.param("variant");
103
- // Pre-check against the FIXED preset list before systemQuery runs
104
- // at all stops a caller from burning rate-limit budget/DB
105
- // lookups on arbitrary variant names (thumb-a, thumb-b, ...).
106
- // 404, not 400 — an invalid name and "doesn't exist" must answer
107
- // identically, no name-oracle.
108
- if (!fileRefId || !variant || !VALID_VARIANT_NAMES.includes(variant)) {
119
+ // 404, not 400 a syntactically bad name and "doesn't exist" must
120
+ // answer identically, no name-oracle.
121
+ if (
122
+ !fileRefId ||
123
+ !variant ||
124
+ !VARIANT_NAME_RE.test(variant) ||
125
+ !FILE_REF_ID_RE.test(fileRefId)
126
+ ) {
109
127
  return c.text("not found", 404);
110
128
  }
111
129
 
@@ -1,7 +1,9 @@
1
- // Public-Read of a derived FileRef variant (thumbnail/card/hero/full) — the
2
- // anonymous counterpart to GET /files/:id/variant/:name (#1950, auth-gated,
3
- // field-declared variants). This route never serves the original, never
4
- // accepts a free VariantSpec, and only serves one of the 4 fixed presets.
1
+ // Public-Read of a derived FileRef variant — the anonymous counterpart to
2
+ // GET /files/:id/variant/:name (#1950, auth-gated). Both routes resolve the
3
+ // same way: the variant spec comes from the FileRef's field declaration
4
+ // (`createImageField({ variants: {...} })`), not a fixed list an app that
5
+ // declares its own variant name gets it served here too. This route never
6
+ // serves the original and never accepts a free VariantSpec, only a name.
5
7
  // Default-deny: an entityType with no `EXT_DERIVATIVE_PUBLIC_PREDICATE`
6
8
  // registration, or a predicate that returns false, both answer `null` —
7
9
  // the httpRoute wrapper in feature.ts turns that into a 404, same as a
@@ -11,6 +13,7 @@
11
13
  // httpRoute wrapper's `resolveApexTenant`), never from the payload.
12
14
 
13
15
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { resolveFieldVariant } from "@cosmicdrift/kumiko-framework/derivatives";
14
17
  import {
15
18
  defineQueryHandler,
16
19
  EXT_DERIVATIVE_PUBLIC_PREDICATE,
@@ -19,13 +22,11 @@ import {
19
22
  } from "@cosmicdrift/kumiko-framework/engine";
20
23
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
21
24
  import { z } from "zod";
22
- import { card, full, hero, PRESET_VARIANT_NAMES, thumb } from "../presets";
23
-
24
- const VARIANT_SPECS = { thumb, card, hero, full } as const;
25
25
 
26
26
  type FileRefRow = {
27
27
  readonly entityType: string | null;
28
28
  readonly entityId: string | null;
29
+ readonly fieldName: string | null;
29
30
  };
30
31
 
31
32
  export type DerivativePublicPredicateArgs = {
@@ -58,7 +59,7 @@ export const publicVariantQuery = defineQueryHandler({
58
59
  name: "public-variant",
59
60
  schema: z.object({
60
61
  fileRefId: z.string(),
61
- variant: z.enum(PRESET_VARIANT_NAMES),
62
+ variant: z.string().min(1).max(64),
62
63
  }),
63
64
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
64
65
  // ponytail: "ip" trusts the first x-forwarded-for hop (buildRequestContextData
@@ -76,8 +77,8 @@ export const publicVariantQuery = defineQueryHandler({
76
77
  isDeleted: false,
77
78
  });
78
79
  if (!row) return null;
79
- const { entityType, entityId } = row;
80
- if (entityType === null || entityId === null) return null;
80
+ const { entityType, entityId, fieldName } = row;
81
+ if (entityType === null || entityId === null || fieldName === null) return null;
81
82
 
82
83
  const usage = ctx.registry
83
84
  .getExtensionUsages(EXT_DERIVATIVE_PUBLIC_PREDICATE)
@@ -103,7 +104,8 @@ export const publicVariantQuery = defineQueryHandler({
103
104
  );
104
105
  }
105
106
 
106
- const spec = VARIANT_SPECS[query.payload.variant];
107
+ const spec = resolveFieldVariant(ctx.registry, entityType, fieldName, query.payload.variant);
108
+ if (!spec) return null;
107
109
  const result = await derivatives.variant(query.payload.fileRefId, spec, query.payload.variant);
108
110
  const bytes = await files.ref(result.storageKey).read();
109
111
 
@@ -1,3 +1,3 @@
1
1
  export type { FileDerivativesOptions, PublicVariantResolveApexTenant } from "./feature";
2
2
  export { createFileDerivativesFeature, fileDerivativesFeature } from "./feature";
3
- export { card, full, hero, PRESET_VARIANT_NAMES, thumb } from "./presets";
3
+ export { card, full, hero, thumb } from "./presets";
@@ -1,13 +1,10 @@
1
1
  import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
2
2
 
3
+ // Ready-made specs to spread into a field's own `variants` declaration
4
+ // (`createImageField({ variants: { thumb, full: { ...full, maxEdge: 4096 } } })`)
5
+ // — not an allow-list. Which names a route serves is decided entirely by
6
+ // what a field declares (#1985), these are just common defaults.
3
7
  export const thumb = { maxEdge: 160, fit: "cover", format: "webp" } as const satisfies VariantSpec;
4
8
  export const card = { maxEdge: 640, fit: "inside", format: "webp" } as const satisfies VariantSpec;
5
9
  export const hero = { maxEdge: 1600, fit: "inside", format: "webp" } as const satisfies VariantSpec;
6
10
  export const full = { maxEdge: 2560, fit: "inside", format: "webp" } as const satisfies VariantSpec;
7
-
8
- // Single source of truth for the 4 preset variant names — the public
9
- // variant route (#1951) validates its `:variant` path param against this
10
- // list BEFORE any DB lookup or systemQuery dispatch, and the query
11
- // handler's Zod schema enums against it too. Keep in sync with the
12
- // preset exports above.
13
- export const PRESET_VARIANT_NAMES = ["thumb", "card", "hero", "full"] as const;
@@ -120,6 +120,20 @@ async function backdate(draftKey: string, daysAgo: number): Promise<void> {
120
120
  );
121
121
  }
122
122
 
123
+ // applyEntityEvent only stamps modified_at on updated/deleted/restored, never
124
+ // on created (framework/src/db/apply-entity-event.ts) — a draft saved exactly
125
+ // once has modified_at IS NULL, which is the real-world case the cleanup
126
+ // query's COALESCE(modified_at, inserted_at) fallback exists for.
127
+ async function backdateInsertedOnly(draftKey: string, daysAgo: number): Promise<void> {
128
+ await asRawClient(stack.db).unsafe(
129
+ `UPDATE "read_form_drafts"
130
+ SET "inserted_at" = now() - ($1::int * interval '1 day'),
131
+ "modified_at" = NULL
132
+ WHERE "draft_key" = $2`,
133
+ [daysAgo, draftKey],
134
+ );
135
+ }
136
+
123
137
  async function backdateFileRef(storageKey: string, daysAgo: number): Promise<void> {
124
138
  await asRawClient(stack.db).unsafe(
125
139
  `UPDATE "file_refs" SET "inserted_at" = now() - ($1::int * interval '1 day')
@@ -190,6 +204,19 @@ describe("form-draft cleanup job", () => {
190
204
  });
191
205
  expect(await draftExists("wizard:three-days-old")).toBe(true);
192
206
  });
207
+
208
+ test("deletes a draft saved exactly once (modified_at IS NULL), falling back to inserted_at", async () => {
209
+ await saveDraft("wizard:once-saved");
210
+ await backdateInsertedOnly("wizard:once-saved", 31);
211
+ await saveDraft("wizard:fresh-once-saved");
212
+ await backdateInsertedOnly("wizard:fresh-once-saved", 1);
213
+ await dispatchCleanup();
214
+
215
+ await waitFor(async () => {
216
+ expect(await draftExists("wizard:once-saved")).toBe(false);
217
+ });
218
+ expect(await draftExists("wizard:fresh-once-saved")).toBe(true);
219
+ });
193
220
  });
194
221
 
195
222
  describe("form-draft cleanup job — FileRef release (#1915)", () => {
@@ -184,13 +184,22 @@ describe("form-draft integration — ownership isolation", () => {
184
184
  describe("form-draft integration — list", () => {
185
185
  test("list returns open drafts for a screenId, newest first, without the blob's values", async () => {
186
186
  await saveDraft("wizard:a", { name: "First" }, 0);
187
+ // savedAt is a millisecond stamp (#1960) — sleep is best-effort only;
188
+ // under CI load two saves can still tie, so assert the comparator
189
+ // contract (savedAt desc, then id desc) rather than a fixed key order.
190
+ await Bun.sleep(20);
187
191
  await saveDraft("wizard:b", { name: "Second" }, 1);
188
192
 
189
193
  const { drafts } = await listDrafts("wizard");
190
- expect(drafts.map((d) => d.draftKey)).toEqual(["wizard:b", "wizard:a"]);
191
- expect(drafts[0]?.stepIndex).toBe(1);
192
- expect(drafts[0]?.savedAt).toBeTruthy();
193
- expect(drafts[0]).not.toHaveProperty("values");
194
+ expect(drafts).toHaveLength(2);
195
+ expect(new Set(drafts.map((d) => d.draftKey))).toEqual(new Set(["wizard:a", "wizard:b"]));
196
+ const [first, second] = drafts;
197
+ expect(first?.savedAt).toBeTruthy();
198
+ expect(first).not.toHaveProperty("values");
199
+ const byTime = second!.savedAt.localeCompare(first!.savedAt);
200
+ const byId = second!.id.localeCompare(first!.id);
201
+ expect(byTime < 0 || (byTime === 0 && byId < 0)).toBe(true);
202
+ if (first!.draftKey === "wizard:b") expect(first!.stepIndex).toBe(1);
194
203
  });
195
204
 
196
205
  test("list excludes a draft whose screenId is only a prefix, not a full segment match", async () => {
@@ -209,6 +218,22 @@ describe("form-draft integration — list", () => {
209
218
  expect(drafts.map((d) => d.draftKey)).toEqual(["scr%en:a"]);
210
219
  });
211
220
 
221
+ test("a screenId containing an underscore is matched literally, not as a single-char wildcard", async () => {
222
+ await saveDraft("wizar_:a", {}, 0);
223
+ await saveDraft("wizardX:a", {}, 0);
224
+
225
+ const { drafts } = await listDrafts("wizar_");
226
+ expect(drafts.map((d) => d.draftKey)).toEqual(["wizar_:a"]);
227
+ });
228
+
229
+ test("a screenId containing a literal backslash is matched literally", async () => {
230
+ await saveDraft("wiz\\ard:a", {}, 0);
231
+ await saveDraft("wizard:a", {}, 0);
232
+
233
+ const { drafts } = await listDrafts("wiz\\ard");
234
+ expect(drafts.map((d) => d.draftKey)).toEqual(["wiz\\ard:a"]);
235
+ });
236
+
212
237
  test("list resolves an empty array when nothing was ever saved for the screenId", async () => {
213
238
  expect((await listDrafts("no-such-screen")).drafts).toEqual([]);
214
239
  });
@@ -24,7 +24,7 @@ export const formDraftEntity = createEntity({
24
24
  // feeds the GDPR-hook-coverage boot guard (it's a plain FK into `user`,
25
25
  // not content of its own) — see ../form-draft-user-data for the
26
26
  // required export/delete hook coverage.
27
- ownerId: createTextField({ subjectRef: true }),
27
+ ownerId: createTextField({ subjectRef: true, required: true }),
28
28
  draftKey: createTextField({ required: true, maxLength: FORM_DRAFT_KEY_MAX_LENGTH }),
29
29
  // The blob shape is fixed by issue #1889, not left to the caller:
30
30
  // { values: Record<string, unknown>, stepIndex: number, savedAt: string }.
@@ -13,7 +13,6 @@ import { discardDraftWrite } from "./handlers/discard.write";
13
13
  import { getDraftQuery } from "./handlers/get.query";
14
14
  import { listDraftsQuery } from "./handlers/list.query";
15
15
  import { saveDraftWrite } from "./handlers/save.write";
16
- import { FORM_DRAFT_FEATURE_I18N } from "./i18n";
17
16
 
18
17
  function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>): void {
19
18
  r.describe(
@@ -41,8 +40,6 @@ function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>):
41
40
 
42
41
  r.config({ keys: { retentionDays: formDraftRetentionDaysConfig } });
43
42
  r.job("cleanup", { trigger: { cron: "0 3 * * *" }, concurrency: "skip" }, cleanupDraftsJob);
44
-
45
- r.translations({ keys: FORM_DRAFT_FEATURE_I18N });
46
43
  }
47
44
 
48
45
  export const formDraftFeature = defineFeature(FORM_DRAFT_FEATURE_NAME, registerFormDraft);
@@ -0,0 +1,26 @@
1
+ // byNewestFirst — the sort issue #1960 traced the flake to: form-draft.integration.test.ts
2
+ // creates two drafts fast enough to land on the same millisecond savedAt, and the query had
3
+ // no ORDER BY at all, so ties fell back to Postgres' undefined row order. These cases can't be
4
+ // forced through the integration stack (no clock control on the real save path, and seeding a
5
+ // row directly would bypass the event store), so the comparator is unit-tested directly here.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { byNewestFirst } from "../list.query";
9
+
10
+ function draft(id: string, savedAt: string) {
11
+ return { id, draftKey: `wizard:${id}`, stepIndex: 0, savedAt };
12
+ }
13
+
14
+ describe("byNewestFirst", () => {
15
+ test("a later savedAt sorts before an earlier one", () => {
16
+ const older = draft("a", "2026-01-01T00:00:00.000Z");
17
+ const newer = draft("b", "2026-01-01T00:00:00.001Z");
18
+ expect([older, newer].sort(byNewestFirst)).toEqual([newer, older]);
19
+ });
20
+
21
+ test("a tied savedAt still sorts deterministically, regardless of input order", () => {
22
+ const a = draft("aaaa", "2026-01-01T00:00:00.000Z");
23
+ const b = draft("bbbb", "2026-01-01T00:00:00.000Z");
24
+ expect([a, b].sort(byNewestFirst)).toEqual([b, a].sort(byNewestFirst));
25
+ });
26
+ });