@cosmicdrift/kumiko-bundled-features 0.193.0 → 0.194.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.0",
3
+ "version": "0.194.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.0",
129
- "@cosmicdrift/kumiko-framework": "0.193.0",
130
- "@cosmicdrift/kumiko-headless": "0.193.0",
131
- "@cosmicdrift/kumiko-renderer": "0.193.0",
132
- "@cosmicdrift/kumiko-renderer-web": "0.193.0",
133
- "@cosmicdrift/kumiko-types": "0.193.0",
128
+ "@cosmicdrift/kumiko-dispatcher-live": "0.194.0",
129
+ "@cosmicdrift/kumiko-framework": "0.194.0",
130
+ "@cosmicdrift/kumiko-headless": "0.194.0",
131
+ "@cosmicdrift/kumiko-renderer": "0.194.0",
132
+ "@cosmicdrift/kumiko-renderer-web": "0.194.0",
133
+ "@cosmicdrift/kumiko-types": "0.194.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
+ });
@@ -57,18 +57,21 @@ async function releaseRowFileRefs(
57
57
  return;
58
58
  }
59
59
 
60
+ // Only storageKeys with a real file_refs row owned by this row's draft
61
+ // owner are releasable — `draft.values` is free-form JSON the owning user
62
+ // controls, so an unverified key could target someone else's file (see
63
+ // db/queries/owned-file-refs.ts). Not wrapped in try/catch: a query
64
+ // failure here (pool exhaustion, missing table) is a real error, not the
65
+ // "no provider resolvable" case below — let it propagate so the job retries.
66
+ const ownedKeys = await filterOwnedStorageKeys(
67
+ db,
68
+ row.tenantId,
69
+ row.ownerId,
70
+ keys,
71
+ row.insertedAt,
72
+ );
73
+
60
74
  try {
61
- // Only storageKeys with a real file_refs row owned by this row's
62
- // draft owner are releasable — `draft.values` is free-form JSON the
63
- // owning user controls, so an unverified key could target someone
64
- // else's file (see db/queries/owned-file-refs.ts).
65
- const ownedKeys = await filterOwnedStorageKeys(
66
- db,
67
- row.tenantId,
68
- row.ownerId,
69
- keys,
70
- row.insertedAt,
71
- );
72
75
  const provider = await fileProviderResolver(row.tenantId);
73
76
  await releaseDraftFileRefs(ownedKeys, (key) => provider.delete(key), log);
74
77
  } catch (err) {
@@ -12,6 +12,15 @@ export type ListDraftsResult = {
12
12
  }[];
13
13
  };
14
14
 
15
+ type DraftListItem = ListDraftsResult["drafts"][number];
16
+
17
+ // Two saves landing in the same millisecond tie on savedAt — there's no
18
+ // knowable "true" order between them, so `id` only buys a deterministic,
19
+ // repeatable result, not chronological accuracy.
20
+ export function byNewestFirst(a: DraftListItem, b: DraftListItem): number {
21
+ return b.savedAt.localeCompare(a.savedAt) || b.id.localeCompare(a.id);
22
+ }
23
+
15
24
  // list — the fallback path for resuming a draft whose draftId the client
16
25
  // lost (new tab, cleared storage, another device): returns just enough to
17
26
  // pick one (id, draftKey, stepIndex, savedAt), never the blob's `values`.
@@ -34,7 +43,7 @@ export const listDraftsQuery = defineQueryHandler({
34
43
  stepIndex: row.draft.stepIndex,
35
44
  savedAt: row.draft.savedAt,
36
45
  }))
37
- .sort((a, b) => b.savedAt.localeCompare(a.savedAt));
46
+ .sort(byNewestFirst);
38
47
  return { drafts };
39
48
  },
40
49
  });
@@ -44,9 +44,9 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
44
44
  r.describe(
45
45
  [
46
46
  "Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail-html`, `document-pdf`, `ai-prompt`, `text-block`, `image-snapshot`).",
47
- "Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes.",
47
+ "Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers.",
48
48
  "Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation.",
49
- "How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links). Both offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format and wins over the built-in one.",
49
+ "How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning).",
50
50
  'A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side.',
51
51
  "Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.",
52
52
  ].join("\n\n"),
@@ -1,6 +1,66 @@
1
1
  import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ // mock.module replaces imports for all consumers — static imports before
3
+ // mock.module still see the mocked version because Bun intercepts at the
4
+ // loader level (same pattern as editor-read-only.test.tsx).
5
+ import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
2
6
  import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
7
+ import {
8
+ CONTENT_EDITOR_ELEMENT_ID,
9
+ type ContentEditorComponent,
10
+ ContentEditorsProvider,
11
+ createStaticLocaleResolver,
12
+ LocaleProvider,
13
+ PrimitivesProvider,
14
+ } from "@cosmicdrift/kumiko-renderer";
15
+ import { defaultPrimitives, PlainContentEditor } from "@cosmicdrift/kumiko-renderer-web";
16
+ import { render, screen } from "@testing-library/react";
17
+ import type { ReactNode } from "react";
3
18
  import { textBlocksClient } from "../client-plugin";
19
+ import { defaultTranslations } from "../i18n";
20
+
21
+ mock.module("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () => ({
22
+ useShellUser: mock(),
23
+ }));
24
+
25
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
26
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
27
+ ...actual_renderer,
28
+ useDispatcher: mock(() => ({ write: mock(), query: mock() })),
29
+ useQuery: mock(() => ({
30
+ data: {
31
+ slug: "reminder",
32
+ locale: "de",
33
+ title: "Reminder",
34
+ content: "Hi",
35
+ contentFormat: "plain",
36
+ folder: null,
37
+ },
38
+ loading: false,
39
+ error: null,
40
+ refetch: mock(),
41
+ })),
42
+ useAppFeatures: mock(() => [
43
+ {
44
+ featureName: "mail",
45
+ contentCollections: [
46
+ {
47
+ id: "prompts",
48
+ kind: "ai-prompt",
49
+ contentFormat: "plain",
50
+ variableSchema: { customerName: "Jane" },
51
+ nav: { label: "mail:nav.prompts" },
52
+ },
53
+ {
54
+ id: "richDocs",
55
+ kind: "mail-html",
56
+ contentFormat: "rich",
57
+ variableSchema: {},
58
+ nav: { label: "mail:nav.richDocs" },
59
+ },
60
+ ],
61
+ },
62
+ ]),
63
+ }));
4
64
 
5
65
  // Covers the three new migration paths (advisor gap): navId attach + SSE
6
66
  // entities, no-leak without navId (conditional spread), and the unwrap (the
@@ -192,3 +252,83 @@ describe("textBlocksClient — content collections", () => {
192
252
  expect(sentType).toBe("template-resolver:query:by-tenant");
193
253
  });
194
254
  });
255
+
256
+ describe("textBlocksClient — plain contentFormat editor wiring", () => {
257
+ const localeResolver = createStaticLocaleResolver({ locale: "de" });
258
+
259
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
260
+ return (
261
+ <LocaleProvider resolver={localeResolver} fallbackBundles={[defaultTranslations]}>
262
+ <PrimitivesProvider value={defaultPrimitives}>
263
+ <ContentEditorsProvider value={textBlocksClient().contentEditors ?? {}}>
264
+ {children}
265
+ </ContentEditorsProvider>
266
+ </PrimitivesProvider>
267
+ </LocaleProvider>
268
+ );
269
+ }
270
+
271
+ test("contentEditors.plain is registered as PlainContentEditor", () => {
272
+ expect(textBlocksClient().contentEditors?.["plain"]).toBe(PlainContentEditor);
273
+ });
274
+
275
+ // Deleting the registry line would leave useContentEditor("markdown")
276
+ // silently falling back to TextareaContentEditor — chips gone, suite green.
277
+ test("contentEditors.markdown is registered as PlainContentEditor", () => {
278
+ expect(textBlocksClient().contentEditors?.["markdown"]).toBe(PlainContentEditor);
279
+ });
280
+
281
+ test("a collection's variableSchema renders as chip placeholders on its plain editor", () => {
282
+ // biome-ignore lint/suspicious/noExplicitAny: Bun mock function
283
+ (useShellUser as any).mockReturnValue({ id: "u1", roles: ["TenantAdmin"] });
284
+ const Editor = textBlocksClient().resolvers?.["template-resolver:edit"];
285
+ if (Editor === undefined) throw new Error("Editor not registered");
286
+
287
+ const target = {
288
+ featureId: "template-resolver",
289
+ action: "edit",
290
+ args: { slug: "reminder", locale: "de", collectionId: "prompts" },
291
+ } as const;
292
+ render(<Editor target={target} onClose={() => {}} />, { wrapper: Wrapper });
293
+
294
+ // A typo in the registry key or a field rename on contentCollections
295
+ // silently falls back to variables={[]} — no chip renders, no red test.
296
+ expect(screen.getByText("{{customerName}}")).toBeTruthy();
297
+ });
298
+ });
299
+
300
+ describe("textBlocksClient — findContentFormat resolves collectionId to the registered editor", () => {
301
+ const localeResolver = createStaticLocaleResolver({ locale: "de" });
302
+ const RichStub: ContentEditorComponent = () => <div data-testid="rich-stub-editor" />;
303
+
304
+ function RichStubWrapper({ children }: { readonly children: ReactNode }): ReactNode {
305
+ return (
306
+ <LocaleProvider resolver={localeResolver} fallbackBundles={[defaultTranslations]}>
307
+ <PrimitivesProvider value={defaultPrimitives}>
308
+ <ContentEditorsProvider value={{ rich: RichStub }}>{children}</ContentEditorsProvider>
309
+ </PrimitivesProvider>
310
+ </LocaleProvider>
311
+ );
312
+ }
313
+
314
+ test("a collection declared with contentFormat 'rich' renders the registered rich editor, not the textarea fallback", () => {
315
+ // biome-ignore lint/suspicious/noExplicitAny: Bun mock function
316
+ (useShellUser as any).mockReturnValue({ id: "u1", roles: ["TenantAdmin"] });
317
+ const Editor = textBlocksClient().resolvers?.["template-resolver:edit"];
318
+ if (Editor === undefined) throw new Error("Editor not registered");
319
+
320
+ const target = {
321
+ featureId: "template-resolver",
322
+ action: "edit",
323
+ args: { slug: "reminder", locale: "de", collectionId: "richDocs" },
324
+ } as const;
325
+ render(<Editor target={target} onClose={() => {}} />, { wrapper: RichStubWrapper });
326
+
327
+ // A broken collectionId → contentFormat mapping (wrong field name,
328
+ // array-vs-record drift on the schema) resolves contentFormat to
329
+ // undefined, and useContentEditor's own fallback silently swaps in the
330
+ // plain textarea instead — no red test without this assertion.
331
+ expect(screen.getByTestId("rich-stub-editor")).toBeTruthy();
332
+ expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).toBeNull();
333
+ });
334
+ });
@@ -206,44 +206,38 @@ function makeTreeProvider(tenantIdOverride?: string, collectionId?: string): Tre
206
206
  };
207
207
  }
208
208
 
209
- // contentFormat lives on the collection the node came from, not on the
210
- // entity — a node without a collectionId (the hand-wired text-block tree)
211
- // has none, useContentEditor then falls back to "plain" on its own.
212
- function findContentFormat(
209
+ type ContentCollectionSchema = NonNullable<FeatureSchema["contentCollections"]>[number];
210
+
211
+ // A node without a collectionId (the hand-wired text-block tree) has no
212
+ // collection to look up — contentFormat/variableSchema then fall back to
213
+ // their own callers' defaults.
214
+ function findCollection(
213
215
  features: readonly FeatureSchema[],
214
216
  collectionId?: string,
215
- ): string | undefined {
217
+ ): ContentCollectionSchema | undefined {
216
218
  if (collectionId === undefined) return undefined;
217
219
  for (const feature of features) {
218
220
  const match = feature.contentCollections?.find((c) => c.id === collectionId);
219
- if (match !== undefined) return match.contentFormat;
221
+ if (match !== undefined) return match;
220
222
  }
221
223
  return undefined;
222
224
  }
223
225
 
224
- // Same lookup as findContentFormat, for the collection's fixed variable
225
- // names (ContentCollectionDefinition.variableSchema) — the chip bar's data
226
- // source. A node without a collectionId gets no chips: the hand-wired
227
- // text-block tree has no collection to declare variables on.
228
- function findVariableSchema(
226
+ function findContentFormat(
229
227
  features: readonly FeatureSchema[],
230
228
  collectionId?: string,
231
- ): readonly string[] {
232
- return Object.keys(findVariableExamples(features, collectionId));
229
+ ): string | undefined {
230
+ return findCollection(features, collectionId)?.contentFormat;
233
231
  }
234
232
 
235
- // Same lookup, for the example values the Preview substitutes in for
236
- // `{{name}}` — the values half of the same variableSchema map.
233
+ // The collection's fixed variable names (ContentCollectionDefinition.
234
+ // variableSchema) — the chip bar's data source and, keyed, the example
235
+ // values the Preview substitutes in for `{{name}}`.
237
236
  function findVariableExamples(
238
237
  features: readonly FeatureSchema[],
239
238
  collectionId?: string,
240
239
  ): Readonly<Record<string, string>> {
241
- if (collectionId === undefined) return {};
242
- for (const feature of features) {
243
- const match = feature.contentCollections?.find((c) => c.id === collectionId);
244
- if (match !== undefined) return match.variableSchema ?? {};
245
- }
246
- return {};
240
+ return findCollection(features, collectionId)?.variableSchema ?? {};
247
241
  }
248
242
 
249
243
  // Edit form: loads the current values via by-slug, lets TenantAdmin and
@@ -311,8 +305,8 @@ function TextBlockEditor({
311
305
  const t = useTranslation();
312
306
  const features = useAppFeatures();
313
307
  const contentFormat = findContentFormat(features, collectionId);
314
- const variables = findVariableSchema(features, collectionId);
315
308
  const variableExamples = findVariableExamples(features, collectionId);
309
+ const variables = Object.keys(variableExamples);
316
310
  const ContentEditor = useContentEditor(contentFormat);
317
311
  const canWrite =
318
312
  user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
@@ -11,15 +11,13 @@ import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table";
11
11
  // sequential loop caps concurrency at 1 and leaves 3 pool slots idle.
12
12
  const KMS_POOL_CONCURRENCY = 4;
13
13
 
14
- // Pending-Invitations-Liste für den aktuellen Tenant. Admin-only.
15
- // Filter: status="pending" — accepted/cancelled/expired sind für die
16
- // UI uninteressant (UI zeigt nur "ausstehende Einladungen"; Audit-Log
17
- // für historische gehört in ein separates Audit-Feature).
14
+ // Pending invitations for the current tenant, admin-only, filtered to
15
+ // status="pending" — accepted/cancelled/expired don't belong in this UI
16
+ // (historical entries belong in a separate audit feature).
18
17
  //
19
- // SQL-side filter (vorher JS-side .filter): bei Tenants mit vielen
20
- // historischen invitations lädt die Query sonst alle Rows in den
21
- // Node-process um die meisten wegzuwerfen — DB indexed das auf den
22
- // (tenantId, …)-key, JS-filter ist redundant.
18
+ // SQL-side filter (was JS-side .filter): a tenant with many historical
19
+ // invitations would otherwise load every row into the node process just to
20
+ // discard most of themthe DB indexes on (tenantId, …), a JS filter is redundant.
23
21
  export const invitationsQuery = defineQueryHandler({
24
22
  name: "invitations",
25
23
  schema: z.object({}),
@@ -80,7 +80,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
80
80
  ).not.toThrow();
81
81
  });
82
82
 
83
- test("form-draft mounted WITHOUT form-draft-user-data → V3 throws (userOwned draft field has no hook)", () => {
83
+ test("form-draft mounted WITHOUT form-draft-user-data → V3 throws (subjectRef ownerId has no EXT_USER_DATA hook)", () => {
84
84
  expect(() => validateBoot([...baseFeatures(), formDraftFeature])).toThrow(
85
85
  /EXT_USER_DATA hook.*Art\.17 gap/,
86
86
  );
@@ -26,7 +26,7 @@ import {
26
26
  } from "@cosmicdrift/kumiko-framework/crypto";
27
27
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
28
28
  import { createSystemUser, type UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
29
- import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
29
+ import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
30
30
  import {
31
31
  setupTestStack,
32
32
  type TestStack,
@@ -48,6 +48,7 @@ import { createSessionsFeature, userSessionEntity } from "../../sessions";
48
48
  import { createTenantFeature, tenantInvitationEntity, tenantInvitationsTable } from "../../tenant";
49
49
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
50
50
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
51
+ import { fileRefExportHook } from "../../user-data-rights-defaults/hooks/file-ref.userdata-hook";
51
52
  import { tenantInvitationExportHook } from "../../user-data-rights-defaults/hooks/tenant-invitation.userdata-hook";
52
53
  import { createUserDataRightsFeature } from "../feature";
53
54
  import { runUserExport } from "../run-user-export";
@@ -396,12 +397,33 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
396
397
  // KMS + bidx MUST be configured before the executor write so the row
397
398
  // lands encrypted with a real blind index, matching the KMS-active
398
399
  // production shape (order matches anonymous-deletion-kms.integration.test.ts).
399
- configurePiiSubjectKms(new InMemoryKmsAdapter());
400
+ const kms = new InMemoryKmsAdapter();
401
+ configurePiiSubjectKms(kms);
400
402
  configureBlindIndexKey(BIDX_KEY);
401
403
 
402
404
  await seedUser(ALICE_ID, { email: INVITE_EMAIL, displayName: "Alice Invite" });
403
405
  await seedMembership(ALICE_ID, TENANT_A);
404
406
 
407
+ // seedUser/seedRow does not encrypt — encrypt the user row's email the
408
+ // same way an executor write would, so resolveUserEmail's decryptStoredPii
409
+ // branch runs over real ciphertext, not plaintext it happens to pass through.
410
+ const encryptedUser = await encryptPiiFieldValues(
411
+ { id: ALICE_ID, email: INVITE_EMAIL },
412
+ userEntity,
413
+ ["email"],
414
+ kms,
415
+ { requestId: "test" },
416
+ );
417
+ await asRawClient(stack.db).unsafe(`UPDATE read_users SET email = $1 WHERE id = $2`, [
418
+ String(encryptedUser["email"]),
419
+ ALICE_ID,
420
+ ]);
421
+ const userRow = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
422
+ `SELECT email FROM read_users WHERE id = $1`,
423
+ [ALICE_ID],
424
+ );
425
+ expect(isPiiCiphertext(userRow[0]?.["email"])).toBe(true);
426
+
405
427
  // Real executor write path — the same one tenantInvitationExportHook's
406
428
  // sibling delete hook and the invite-create handler use — so the row
407
429
  // carries genuine ciphertext + a real email_bidx, not a hand-seeded stand-in.
@@ -435,6 +457,38 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
435
457
  expect(isPiiCiphertext(rawRows[0]?.["email"])).toBe(true);
436
458
  expect(String(rawRows[0]?.["email_bidx"])).toStartWith("kumiko-bidx:v1:");
437
459
 
460
+ // Cross-user isolation: a foreign invitee's invite in the same tenant
461
+ // must not leak into Alice's export via the entity-wide selectMany.
462
+ const foreignInviteeResult = await invitationExecutor.create(
463
+ {
464
+ email: "bob.invite@example.com",
465
+ role: "Member",
466
+ status: "pending",
467
+ invitedBy: ALICE_ID,
468
+ expiresAt: NOW().add({ seconds: 7 * 24 * 60 * 60 }),
469
+ },
470
+ systemUser,
471
+ tenantDb,
472
+ );
473
+ expect(foreignInviteeResult.isSuccess).toBe(true);
474
+
475
+ // Cross-tenant isolation: Alice's own email invited in a different
476
+ // tenant must land in that tenant's section, not TENANT_A's.
477
+ const foreignTenantSystemUser = createSystemUser(TENANT_B);
478
+ const foreignTenantDb = createTenantDb(stack.db, TENANT_B, "system");
479
+ const foreignTenantResult = await invitationExecutor.create(
480
+ {
481
+ email: INVITE_EMAIL,
482
+ role: "Member",
483
+ status: "pending",
484
+ invitedBy: ALICE_ID,
485
+ expiresAt: NOW().add({ seconds: 7 * 24 * 60 * 60 }),
486
+ },
487
+ foreignTenantSystemUser,
488
+ foreignTenantDb,
489
+ );
490
+ expect(foreignTenantResult.isSuccess).toBe(true);
491
+
438
492
  // Pins the hook boundary directly: tenantInvitationExportHook itself must
439
493
  // hand back ciphertext, not plaintext — decryption is the central sweep's
440
494
  // job (decryptSnippetFields in run-user-export.ts), not the hook's. If the
@@ -463,7 +517,80 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
463
517
  // mean the hook found nothing and the bidx hypothesis is wrong.
464
518
  const invitationSnippet = tenantA?.entities.find((e) => e.entity === "tenant-invitation");
465
519
  expect(invitationSnippet).toBeDefined();
520
+ // Isolation: neither the foreign-email invite in the same tenant nor
521
+ // Alice's own email invited in TENANT_B leak into TENANT_A's snippet.
466
522
  expect(invitationSnippet?.rows).toHaveLength(1);
467
523
  expect(invitationSnippet?.rows[0]?.["email"]).toBe(INVITE_EMAIL);
524
+ expect(JSON.stringify(bundle)).not.toContain("bob.invite@example.com");
525
+ });
526
+ });
527
+
528
+ describe("runUserExport :: fileRef PII export via the fileRefs side-channel (#1955)", () => {
529
+ afterEach(() => {
530
+ resetPiiSubjectKmsForTests();
531
+ });
532
+
533
+ test("bundle.fileRefs carries plaintext fileName + a readable zipPath, even though the hook returns ciphertext", async () => {
534
+ const kms = new InMemoryKmsAdapter();
535
+ configurePiiSubjectKms(kms);
536
+
537
+ await seedUser(ALICE_ID, { email: "alice.fileref@example.com" });
538
+ await seedMembership(ALICE_ID, TENANT_A);
539
+
540
+ const fileRefId = uuid(401);
541
+ const PLAINTEXT_NAME = "alice-cv.pdf";
542
+ // fileRefEntity.fileName is `pii: true` with no `userOwned` override, so
543
+ // resolveSubjectForField treats the row itself as the subject (its own
544
+ // `id`), not `insertedById`. Encrypting with that subject reproduces the
545
+ // real write-path ciphertext shape.
546
+ const encrypted = await encryptPiiFieldValues(
547
+ { id: fileRefId, fileName: PLAINTEXT_NAME },
548
+ fileRefEntity,
549
+ ["fileName"],
550
+ kms,
551
+ { requestId: "test" },
552
+ );
553
+ await asRawClient(stack.db).unsafe(
554
+ `
555
+ INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
556
+ VALUES ($1, $2, $3, $4, 'application/pdf', 2048, $5)
557
+ ON CONFLICT (id) DO NOTHING
558
+ `,
559
+ [fileRefId, TENANT_A, `storage/${fileRefId}`, String(encrypted["fileName"]), ALICE_ID],
560
+ );
561
+
562
+ // Pins the hook boundary directly: fileRefExportHook must still hand
563
+ // back ciphertext in the fileRefs side-channel — decryption is the
564
+ // central sweep's job (decryptSnippetFields in run-user-export.ts), not
565
+ // the hook's. If the hook ever started decrypting fileRefs locally, this
566
+ // assertion would catch it even though the runUserExport() assertions
567
+ // below would stay green either way.
568
+ const hookCtx: UserDataHookCtx = {
569
+ db: stack.db,
570
+ registry: stack.registry,
571
+ tenantId: TENANT_A,
572
+ userId: ALICE_ID,
573
+ };
574
+ const rawSnippet = await fileRefExportHook(hookCtx);
575
+ expect(isPiiCiphertext(rawSnippet?.fileRefs?.[0]?.fileName)).toBe(true);
576
+
577
+ const bundle = await runUserExport({
578
+ db: stack.db,
579
+ registry: stack.registry,
580
+ userId: ALICE_ID,
581
+ now: NOW(),
582
+ });
583
+
584
+ expect(bundle.fileRefs).toHaveLength(1);
585
+ const ref = bundle.fileRefs[0];
586
+ // Bug #1955: this used to be the raw `kumiko-pii:v2:...` ciphertext
587
+ // string instead of the person's actual file name.
588
+ expect(ref?.fileName).toBe(PLAINTEXT_NAME);
589
+ // Bug #1955 effect 2: buildFileRefZipPath sanitized the ciphertext into
590
+ // a garbled underscore-mangled path — the ZIP entry must carry the
591
+ // readable name instead.
592
+ expect(ref?.zipPath).toContain("alice-cv");
593
+ expect(ref?.zipPath).not.toContain("kumiko-pii");
594
+ expect(JSON.stringify(bundle)).not.toContain("kumiko-pii:");
468
595
  });
469
596
  });
@@ -223,36 +223,35 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
223
223
  // base64-Blob als "Wert" auszuliefern (leak-by-confusion).
224
224
  const ENCRYPTED_UNAVAILABLE = "[encrypted:unavailable]";
225
225
 
226
- async function decryptSnippetFields(
227
- registry: Registry,
228
- hookEntityName: string,
229
- snippet: UserDataExportSnippet,
230
- ): Promise<UserDataExportSnippet> {
231
- let rows = snippet.rows;
226
+ // Shared decrypt sweep, used for both `snippet.rows` and `snippet.fileRefs`
227
+ // (#1955) — `encryptedFields` is keyed by field NAME, so it stays a no-op
228
+ // for today's `fileRef` entity but covers a future generically-encrypted
229
+ // side-channel field too.
230
+ async function decryptRecords(
231
+ records: ReadonlyArray<Record<string, unknown>>,
232
+ encryptedFields: ReadonlySet<string>,
233
+ kms: ReturnType<typeof configuredPiiSubjectKms>,
234
+ ): Promise<Record<string, unknown>[]> {
235
+ let rows: Record<string, unknown>[] = [...records];
232
236
 
233
- const entity = registry.getEntity(snippet.entity) ?? registry.getEntity(hookEntityName);
234
- if (entity) {
235
- const encryptedFields = collectEncryptedFieldNames(entity);
236
- if (encryptedFields.size > 0) {
237
- const cipher = configuredEntityFieldEncryption();
238
- rows = await Promise.all(
239
- rows.map(async (row) => {
240
- if (cipher) return decryptEntityFieldValues(row, encryptedFields, cipher);
241
- const out = { ...row };
242
- for (const name of encryptedFields) {
243
- if (typeof out[name] === "string") out[name] = ENCRYPTED_UNAVAILABLE;
244
- }
245
- return out;
246
- }),
247
- );
248
- }
237
+ if (encryptedFields.size > 0) {
238
+ const cipher = configuredEntityFieldEncryption();
239
+ rows = await Promise.all(
240
+ rows.map(async (row) => {
241
+ if (cipher) return decryptEntityFieldValues(row, encryptedFields, cipher);
242
+ const out = { ...row };
243
+ for (const name of encryptedFields) {
244
+ if (typeof out[name] === "string") out[name] = ENCRYPTED_UNAVAILABLE;
245
+ }
246
+ return out;
247
+ }),
248
+ );
249
249
  }
250
250
 
251
- // PII-Subject-Ciphertexte (kumiko-pii:) sind self-describing — kein
252
- // Entity-Kontext noetig, deckt jeden heutigen und kuenftigen Hook ab.
253
- // Erased Subject → Sentinel (ehrlich: der Wert ist geshreddet).
254
- const kms = configuredPiiSubjectKms();
255
- rows = await Promise.all(
251
+ // PII-subject ciphertexts (kumiko-pii:) are self-describing — no entity
252
+ // context needed, covers every hook past and future. Erased subject →
253
+ // sentinel (honest: the value is shredded).
254
+ return Promise.all(
256
255
  rows.map(async (row) => {
257
256
  const piiKeys = Object.keys(row).filter((k) => isPiiCiphertext(row[k]));
258
257
  if (piiKeys.length === 0) return row;
@@ -266,7 +265,31 @@ async function decryptSnippetFields(
266
265
  });
267
266
  }),
268
267
  );
269
- return { ...snippet, rows };
268
+ }
269
+
270
+ async function decryptSnippetFields(
271
+ registry: Registry,
272
+ hookEntityName: string,
273
+ snippet: UserDataExportSnippet,
274
+ ): Promise<UserDataExportSnippet> {
275
+ const entity = registry.getEntity(snippet.entity) ?? registry.getEntity(hookEntityName);
276
+ const encryptedFields = entity ? collectEncryptedFieldNames(entity) : new Set<string>();
277
+ const kms = configuredPiiSubjectKms();
278
+
279
+ const rows = await decryptRecords(snippet.rows, encryptedFields, kms);
280
+
281
+ if (!snippet.fileRefs || snippet.fileRefs.length === 0) {
282
+ return { ...snippet, rows };
283
+ }
284
+
285
+ const decryptedFileRefs = await decryptRecords(snippet.fileRefs, encryptedFields, kms);
286
+ const fileRefs = decryptedFileRefs.map((r) => ({
287
+ fileRefId: String(r["fileRefId"]),
288
+ storageKey: String(r["storageKey"]),
289
+ fileName: String(r["fileName"]),
290
+ }));
291
+
292
+ return { ...snippet, rows, fileRefs };
270
293
  }
271
294
 
272
295
  // Pseudo-Tenant fuer User ohne aktive Memberships. Identisch zum
@@ -1,3 +0,0 @@
1
- type LocalizedString = { readonly de: string; readonly en: string };
2
-
3
- export const FORM_DRAFT_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {};