@cosmicdrift/kumiko-bundled-features 0.285.2 → 0.287.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +13 -9
  2. package/src/auth-email-password/changes.json +6 -0
  3. package/src/auth-email-password/invite-token-store.ts +47 -96
  4. package/src/auth-email-password/lockout-store.ts +13 -102
  5. package/src/auth-email-password/signed-token.ts +4 -86
  6. package/src/auth-email-password/signup-token-store.test.ts +29 -0
  7. package/src/auth-email-password/signup-token-store.ts +40 -103
  8. package/src/auth-mfa/changes.json +6 -0
  9. package/src/auth-mfa/mfa-verify-attempts.ts +10 -68
  10. package/src/billing-foundation/__tests__/billing-info-query.test.ts +103 -0
  11. package/src/billing-foundation/billing-info-query.ts +95 -0
  12. package/src/billing-foundation/changes.json +8 -1
  13. package/src/billing-foundation/index.ts +5 -0
  14. package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
  15. package/src/derivatives-sharp/changes.json +8 -1
  16. package/src/derivatives-sharp/render.ts +172 -5
  17. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
  18. package/src/file-derivatives/changes.json +6 -0
  19. package/src/file-derivatives/feature.ts +9 -1
  20. package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
  21. package/src/shared/changes.json +8 -1
  22. package/src/shared/index.ts +14 -0
  23. package/src/shared/lockout-counter.test.ts +91 -0
  24. package/src/shared/lockout-counter.ts +108 -0
  25. package/src/shared/row-bound-grant.test.ts +294 -0
  26. package/src/shared/row-bound-grant.ts +115 -0
  27. package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
  28. package/src/shared/signed-token.ts +101 -0
  29. package/src/shared/single-use-token-store.test.ts +75 -0
  30. package/src/shared/single-use-token-store.ts +136 -0
  31. package/src/tenant/seeding.ts +4 -0
  32. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
  33. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
  34. package/src/user-data-rights/changes.json +12 -0
  35. package/src/user-data-rights/deletion-token.ts +41 -47
  36. package/src/user-data-rights/feature.ts +27 -0
  37. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
  38. package/src/user-data-rights/run-export-jobs.ts +69 -1
  39. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
  40. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
@@ -2,7 +2,11 @@
2
2
  // `sharp({ create: {...} })` so every case decodes real bytes end-to-end.
3
3
 
4
4
  import { describe, expect, test } from "bun:test";
5
- import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
5
+ import type {
6
+ ResolvedOverlayLayer,
7
+ VariantSpec,
8
+ } from "@cosmicdrift/kumiko-types/derivatives-types";
9
+ import jsQR from "jsqr";
6
10
  import sharp from "sharp";
7
11
  import { imageMetadata, renderImage } from "../render";
8
12
 
@@ -285,6 +289,247 @@ describe("renderImage — validation", () => {
285
289
  });
286
290
  });
287
291
 
292
+ // jsQR needs a plain RGBA buffer, not sharp's own metadata/format handling —
293
+ // this is the one test proving the AC ("readable with a phone camera") for
294
+ // real: a pixel/byte-presence check would only prove placement, not that the
295
+ // QR survives resize + a lossy re-encode.
296
+ async function decodeQr(buffer: Uint8Array): Promise<string | null> {
297
+ const { data, info } = await sharp(buffer)
298
+ .ensureAlpha()
299
+ .raw()
300
+ .toBuffer({ resolveWithObject: true });
301
+ const result = jsQR(new Uint8ClampedArray(data), info.width, info.height);
302
+ return result?.data ?? null;
303
+ }
304
+
305
+ async function tinyImageOverlayBase64(
306
+ width: number,
307
+ height: number,
308
+ color: { r: number; g: number; b: number },
309
+ ): Promise<string> {
310
+ const buffer = await sharp({ create: { width, height, channels: 3, background: color } })
311
+ .png()
312
+ .toBuffer();
313
+ return buffer.toString("base64");
314
+ }
315
+
316
+ describe("renderImage — overlays", () => {
317
+ test("a qr overlay is actually scannable after resize + a lossy re-encode", async () => {
318
+ const input = await jpegFixture(800, 800);
319
+ const layer: ResolvedOverlayLayer = {
320
+ kind: "qr",
321
+ data: "https://example.com/v/abc123",
322
+ widthPct: 0.4,
323
+ gravity: "south-east",
324
+ };
325
+
326
+ const output = await renderImage(
327
+ input,
328
+ { maxEdge: 400, format: "webp", resolvedOverlays: [layer] },
329
+ "image/jpeg",
330
+ );
331
+
332
+ expect(await decodeQr(output)).toBe("https://example.com/v/abc123");
333
+ });
334
+
335
+ test("an unresolved `overlays` field is inert for the renderer — only `resolvedOverlays` composites", async () => {
336
+ const input = await jpegFixture(400, 400);
337
+ const spec: VariantSpec = {
338
+ overlays: [{ kind: "qr", dataToken: "vehicle-1", widthPct: 0.4, gravity: "center" }],
339
+ };
340
+
341
+ const output = await renderImage(input, spec, "image/jpeg");
342
+ const baseline = await renderImage(input, {}, "image/jpeg");
343
+ expect(Buffer.compare(output, baseline)).toBe(0);
344
+ });
345
+
346
+ test("the overlay width is the same fraction of the output at two different aspect ratios", async () => {
347
+ // PNG in and out (spec.format unset preserves the source format) — the
348
+ // fraction assertion below needs exact pixel equality, which a lossy
349
+ // jpeg re-encode wouldn't guarantee.
350
+ const input = await pngFixture(800, 800, { r: 250, g: 250, b: 250 });
351
+ const overlayColor = { r: 10, g: 200, b: 10 };
352
+ const imageBase64 = await tinyImageOverlayBase64(40, 40, overlayColor);
353
+ const layer: ResolvedOverlayLayer = {
354
+ kind: "image",
355
+ imageBase64,
356
+ widthPct: 0.25,
357
+ gravity: "south-east",
358
+ };
359
+
360
+ async function overlayPixelWidth(width: number, height: number): Promise<number> {
361
+ const output = await renderImage(
362
+ input,
363
+ { size: { width, height }, fit: "cover", resolvedOverlays: [layer] },
364
+ "image/png",
365
+ );
366
+ const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true });
367
+ const y = height - 1;
368
+ for (let x = 0; x < width; x++) {
369
+ const offset = (y * info.width + x) * info.channels;
370
+ if (
371
+ data[offset] === overlayColor.r &&
372
+ data[offset + 1] === overlayColor.g &&
373
+ data[offset + 2] === overlayColor.b
374
+ ) {
375
+ return width - x;
376
+ }
377
+ }
378
+ throw new Error("overlay color not found in bottom row");
379
+ }
380
+
381
+ expect(await overlayPixelWidth(640, 360)).toBe(Math.round(640 * 0.25));
382
+ expect(await overlayPixelWidth(400, 400)).toBe(Math.round(400 * 0.25));
383
+ });
384
+
385
+ test("a square overlay on a wide output is clamped to the output height instead of overrunning it", async () => {
386
+ // widthPct alone would ask for an 800x800 layer (aspect-preserved from a
387
+ // square source) on a 1600x400 output — taller than the base image, which
388
+ // sharp's composite() rejects unless the resize also bounds height.
389
+ const outputWidth = 1600;
390
+ const outputHeight = 400;
391
+ const input = await pngFixture(outputWidth, outputHeight, { r: 250, g: 250, b: 250 });
392
+ const overlayColor = { r: 10, g: 200, b: 10 };
393
+ const imageBase64 = await tinyImageOverlayBase64(100, 100, overlayColor);
394
+ const layer: ResolvedOverlayLayer = {
395
+ kind: "image",
396
+ imageBase64,
397
+ widthPct: 0.5,
398
+ gravity: "center",
399
+ };
400
+
401
+ const output = await renderImage(input, { resolvedOverlays: [layer] }, "image/png");
402
+ const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true });
403
+
404
+ const centerX = Math.floor(info.width / 2);
405
+ let minY = info.height;
406
+ let maxY = -1;
407
+ for (let y = 0; y < info.height; y++) {
408
+ const offset = (y * info.width + centerX) * info.channels;
409
+ if (
410
+ data[offset] === overlayColor.r &&
411
+ data[offset + 1] === overlayColor.g &&
412
+ data[offset + 2] === overlayColor.b
413
+ ) {
414
+ minY = Math.min(minY, y);
415
+ maxY = Math.max(maxY, y);
416
+ }
417
+ }
418
+
419
+ expect(maxY).toBeGreaterThan(-1);
420
+ expect(maxY - minY + 1).toBeLessThanOrEqual(outputHeight);
421
+ });
422
+
423
+ test("an SVG overlay layer is rejected the same way a source SVG is", async () => {
424
+ const input = await jpegFixture(200, 200);
425
+ const layer: ResolvedOverlayLayer = {
426
+ kind: "image",
427
+ imageBase64: Buffer.from(SVG_BYTES).toString("base64"),
428
+ widthPct: 0.3,
429
+ gravity: "center",
430
+ };
431
+
432
+ await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
433
+ /svg/i,
434
+ );
435
+ });
436
+
437
+ test("more than MAX_OVERLAY_LAYERS throws instead of decoding+resizing each one", async () => {
438
+ const input = await jpegFixture(200, 200);
439
+ const overlays: ResolvedOverlayLayer[] = Array.from({ length: 9 }, () => ({
440
+ kind: "image",
441
+ imageBase64: "",
442
+ widthPct: 0.1,
443
+ gravity: "center",
444
+ }));
445
+
446
+ await expect(renderImage(input, { resolvedOverlays: overlays }, "image/jpeg")).rejects.toThrow(
447
+ /overlays has 9 entries/,
448
+ );
449
+ });
450
+
451
+ test("widthPct outside (0, 1] throws", async () => {
452
+ const input = await jpegFixture(200, 200);
453
+ const layer: ResolvedOverlayLayer = {
454
+ kind: "image",
455
+ imageBase64: await tinyImageOverlayBase64(10, 10, { r: 1, g: 1, b: 1 }),
456
+ widthPct: 1.5,
457
+ gravity: "center",
458
+ };
459
+
460
+ await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
461
+ /widthPct/,
462
+ );
463
+ });
464
+
465
+ test("marginPct outside [0, 0.5) throws", async () => {
466
+ const input = await jpegFixture(200, 200);
467
+ const layer: ResolvedOverlayLayer = {
468
+ kind: "image",
469
+ imageBase64: await tinyImageOverlayBase64(10, 10, { r: 1, g: 1, b: 1 }),
470
+ widthPct: 0.2,
471
+ marginPct: 0.5,
472
+ gravity: "north-west",
473
+ };
474
+
475
+ await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
476
+ /marginPct/,
477
+ );
478
+ });
479
+
480
+ test("a qr data length beyond MAX_QR_DATA_LENGTH throws", async () => {
481
+ const input = await jpegFixture(200, 200);
482
+ const layer: ResolvedOverlayLayer = {
483
+ kind: "qr",
484
+ data: "x".repeat(1025),
485
+ widthPct: 0.3,
486
+ gravity: "center",
487
+ };
488
+
489
+ await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
490
+ /qr overlay data length/,
491
+ );
492
+ });
493
+
494
+ test("an empty qr data throws instead of encoding an empty QR", async () => {
495
+ const input = await jpegFixture(200, 200);
496
+ const layer: ResolvedOverlayLayer = { kind: "qr", data: "", widthPct: 0.3, gravity: "center" };
497
+
498
+ await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
499
+ /qr overlay data length/,
500
+ );
501
+ });
502
+
503
+ test("an overlay image beyond MAX_OVERLAY_IMAGE_BYTES throws", async () => {
504
+ const input = await jpegFixture(200, 200);
505
+ const oversized = Buffer.alloc(513 * 1024, 1).toString("base64");
506
+ const layer: ResolvedOverlayLayer = {
507
+ kind: "image",
508
+ imageBase64: oversized,
509
+ widthPct: 0.3,
510
+ gravity: "center",
511
+ };
512
+
513
+ await expect(renderImage(input, { resolvedOverlays: [layer] }, "image/jpeg")).rejects.toThrow(
514
+ /overlay image is/,
515
+ );
516
+ });
517
+
518
+ test("a qr overlay that would render below MIN_QR_PIXEL_WIDTH throws instead of caching an unscannable image", async () => {
519
+ const input = await jpegFixture(200, 200);
520
+ const layer: ResolvedOverlayLayer = {
521
+ kind: "qr",
522
+ data: "https://example.com/v/abc123",
523
+ widthPct: 0.05,
524
+ gravity: "center",
525
+ };
526
+
527
+ await expect(
528
+ renderImage(input, { maxEdge: 200, resolvedOverlays: [layer] }, "image/jpeg"),
529
+ ).rejects.toThrow(/below the 160px minimum/);
530
+ });
531
+ });
532
+
288
533
  describe("imageMetadata", () => {
289
534
  test("returns width/height/format", async () => {
290
535
  const input = await jpegFixture(320, 240);
@@ -1 +1,8 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.287.0",
4
+ "type": "improvement",
5
+ "title": "Render overlay layers (QR codes, badge images) onto image variants",
6
+ "detail": "The sharp renderer composites resolved overlay layers after resize and before final encoding. Overlay bytes go through the same SVG rejection as the source image, with DoS caps on layer count, size, and QR data length enforced in assertRenderSpecBounds."
7
+ }
8
+ ]
@@ -5,9 +5,12 @@
5
5
  import type {
6
6
  BlurRegion,
7
7
  DerivativeRendererPlugin,
8
+ OverlayGravity,
9
+ ResolvedOverlayLayer,
8
10
  VariantFit,
9
11
  VariantSpec,
10
12
  } from "@cosmicdrift/kumiko-types/derivatives-types";
13
+ import QRCode from "qrcode";
11
14
  import type { OverlayOptions, ResizeOptions, Sharp } from "sharp";
12
15
  import sharp from "sharp";
13
16
 
@@ -25,6 +28,16 @@ const MAX_OUTPUT_EDGE = 8192;
25
28
  // unbounded region list is a CPU/RAM-multiplying DoS knob independent of
26
29
  // output size.
27
30
  const MAX_BLUR_REGIONS = 64;
31
+ // Every layer decodes and resizes separately before the composite — an
32
+ // unbounded list is the same CPU/RAM multiplier MAX_BLUR_REGIONS caps.
33
+ const MAX_OVERLAY_LAYERS = 8;
34
+ // Layer bytes ride inside the spec, which is hashed on every variant() call.
35
+ const MAX_OVERLAY_IMAGE_BYTES = 512 * 1024;
36
+ const MAX_QR_DATA_LENGTH = 1024;
37
+ // Below roughly this, a QR's modules land on too few pixels to survive
38
+ // camera capture — the AC claims phone-scannable, so an under-sized layer
39
+ // throws instead of caching a silently unusable image.
40
+ const MIN_QR_PIXEL_WIDTH = 160;
28
41
  // sequentialRead trades random pixel access for lower peak memory on
29
42
  // streamed formats — applied at every decode entry point below since none
30
43
  // of them need non-sequential access to the *original* bytes (extract runs
@@ -39,6 +52,18 @@ function clampSigma(sigma: number): number {
39
52
  return Math.min(MAX_SHARP_SIGMA, Math.max(MIN_SHARP_SIGMA, sigma));
40
53
  }
41
54
 
55
+ // Shared trust boundary for every image byte source this renderer decodes —
56
+ // the original upload (renderImage) and an overlay layer's bytes (applyOverlays)
57
+ // alike: a base64 string in a field declaration isn't more trustworthy than
58
+ // an upload just because it came from app code.
59
+ function assertNotSvg(format: string | undefined): void {
60
+ if (format === "svg") {
61
+ throw new Error(
62
+ "derivatives-sharp: SVG is not supported — rendering untrusted SVG through libvips is a trust boundary this renderer doesn't need to cross.",
63
+ );
64
+ }
65
+ }
66
+
42
67
  function assertValidRegion(region: BlurRegion): void {
43
68
  const values = [region.x, region.y, region.width, region.height];
44
69
  if (values.some((v) => !Number.isFinite(v)) || region.width <= 0 || region.height <= 0) {
@@ -86,6 +111,102 @@ async function applyBlurRegions(
86
111
  return sharp(data, SHARP_INPUT_OPTIONS).composite(overlays).toBuffer();
87
112
  }
88
113
 
114
+ // Placement is anchored to the OUTPUT box (overlays run after resize, see
115
+ // applyOverlays), then clamped the same way applyBlurRegions clamps its
116
+ // region coordinates — a caller-declared marginPct can't push a layer
117
+ // off-canvas.
118
+ function overlayPosition(
119
+ gravity: OverlayGravity,
120
+ marginPx: number,
121
+ outputWidth: number,
122
+ outputHeight: number,
123
+ layerWidth: number,
124
+ layerHeight: number,
125
+ ): { left: number; top: number } {
126
+ let left: number;
127
+ let top: number;
128
+ switch (gravity) {
129
+ case "north-west":
130
+ left = marginPx;
131
+ top = marginPx;
132
+ break;
133
+ case "north-east":
134
+ left = outputWidth - layerWidth - marginPx;
135
+ top = marginPx;
136
+ break;
137
+ case "south-west":
138
+ left = marginPx;
139
+ top = outputHeight - layerHeight - marginPx;
140
+ break;
141
+ case "south-east":
142
+ left = outputWidth - layerWidth - marginPx;
143
+ top = outputHeight - layerHeight - marginPx;
144
+ break;
145
+ case "center":
146
+ left = (outputWidth - layerWidth) / 2;
147
+ top = (outputHeight - layerHeight) / 2;
148
+ break;
149
+ default:
150
+ throw new Error(`derivatives-sharp: unknown overlay gravity "${gravity satisfies never}".`);
151
+ }
152
+ const maxLeft = Math.max(0, outputWidth - layerWidth);
153
+ const maxTop = Math.max(0, outputHeight - layerHeight);
154
+ return {
155
+ left: Math.min(Math.max(Math.round(left), 0), maxLeft),
156
+ top: Math.min(Math.max(Math.round(top), 0), maxTop),
157
+ };
158
+ }
159
+
160
+ // Runs AFTER resize (see the AC in renderImage) so a layer sized as a
161
+ // fraction of the output is a fraction of what the caller actually gets,
162
+ // regardless of the source's own dimensions or `fit` crop.
163
+ async function applyOverlays(
164
+ pipeline: Sharp,
165
+ layers: readonly ResolvedOverlayLayer[],
166
+ ): Promise<Sharp> {
167
+ const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
168
+
169
+ const composites: OverlayOptions[] = [];
170
+ for (const layer of layers) {
171
+ const layerBytes =
172
+ layer.kind === "qr"
173
+ ? await QRCode.toBuffer(layer.data, { margin: 1, errorCorrectionLevel: "M" })
174
+ : Buffer.from(layer.imageBase64, "base64");
175
+
176
+ const layerMeta = await sharp(layerBytes, SHARP_INPUT_OPTIONS).metadata();
177
+ assertNotSvg(layerMeta.format);
178
+
179
+ const targetWidth = Math.max(1, Math.round(info.width * layer.widthPct));
180
+ if (layer.kind === "qr" && targetWidth < MIN_QR_PIXEL_WIDTH) {
181
+ throw new Error(
182
+ `derivatives-sharp: qr overlay would render at ${targetWidth}px wide, below the ${MIN_QR_PIXEL_WIDTH}px minimum a camera can reliably scan.`,
183
+ );
184
+ }
185
+
186
+ // PNG (not the layer's own format) to keep any alpha through the resize.
187
+ // height caps the other axis too — sharp's composite() rejects an input
188
+ // larger than the base image, which a tall/square layer would otherwise
189
+ // hit once widthPct alone drives its width past the output's height.
190
+ const resized = await sharp(layerBytes, SHARP_INPUT_OPTIONS)
191
+ .resize({ width: targetWidth, height: info.height, fit: "inside" })
192
+ .png()
193
+ .toBuffer({ resolveWithObject: true });
194
+
195
+ const marginPx = Math.round(info.width * (layer.marginPct ?? 0));
196
+ const { left, top } = overlayPosition(
197
+ layer.gravity,
198
+ marginPx,
199
+ info.width,
200
+ info.height,
201
+ resized.info.width,
202
+ resized.info.height,
203
+ );
204
+ composites.push({ input: resized.data, left, top });
205
+ }
206
+
207
+ return sharp(data, SHARP_INPUT_OPTIONS).composite(composites);
208
+ }
209
+
89
210
  function buildResizeOptions(width: number, height: number, fit: VariantFit): ResizeOptions {
90
211
  const options: ResizeOptions = { width, height, fit };
91
212
  if (fit === "inside") {
@@ -153,6 +274,51 @@ function applyEncoder(pipeline: Sharp, spec: VariantSpec, sourceMimeType: string
153
274
  return encode(pipeline, quality);
154
275
  }
155
276
 
277
+ // Split out of assertRenderSpecBounds (not inlined there) purely to stay
278
+ // under the AST-guard complexity budget — see assertRenderSpecBounds's own
279
+ // comment. Still the single call site for every overlay bound.
280
+ function assertOverlayLayerBounds(layer: ResolvedOverlayLayer): void {
281
+ if (!Number.isFinite(layer.widthPct) || layer.widthPct <= 0 || layer.widthPct > 1) {
282
+ throw new Error(
283
+ `derivatives-sharp: overlay widthPct ${layer.widthPct} is out of range — must be a finite number in (0, 1].`,
284
+ );
285
+ }
286
+ if (layer.marginPct !== undefined && (layer.marginPct < 0 || layer.marginPct >= 0.5)) {
287
+ throw new Error(
288
+ `derivatives-sharp: overlay marginPct ${layer.marginPct} is out of range — must be within [0, 0.5).`,
289
+ );
290
+ }
291
+ if (layer.kind === "qr") {
292
+ if (layer.data.length === 0 || layer.data.length > MAX_QR_DATA_LENGTH) {
293
+ throw new Error(
294
+ `derivatives-sharp: qr overlay data length ${layer.data.length} is out of range — must be 1–${MAX_QR_DATA_LENGTH} characters.`,
295
+ );
296
+ }
297
+ // skip: qr layers have no imageBase64 field to validate; this return also
298
+ // narrows the union below to the image variant without a cast.
299
+ return;
300
+ }
301
+ const byteLength = Buffer.byteLength(layer.imageBase64, "base64");
302
+ if (byteLength > MAX_OVERLAY_IMAGE_BYTES) {
303
+ throw new Error(
304
+ `derivatives-sharp: overlay image is ${byteLength} bytes, exceeding the limit of ${MAX_OVERLAY_IMAGE_BYTES}.`,
305
+ );
306
+ }
307
+ }
308
+
309
+ function assertOverlayBounds(layers: readonly ResolvedOverlayLayer[] | undefined): void {
310
+ // skip: no overlays configured for this variant, nothing to validate
311
+ if (!layers) return;
312
+ if (layers.length > MAX_OVERLAY_LAYERS) {
313
+ throw new Error(
314
+ `derivatives-sharp: overlays has ${layers.length} entries, exceeding the limit of ${MAX_OVERLAY_LAYERS} — each layer decodes and resizes separately before the composite.`,
315
+ );
316
+ }
317
+ for (const layer of layers) {
318
+ assertOverlayLayerBounds(layer);
319
+ }
320
+ }
321
+
156
322
  // Input-validation for the DoS caps above — kept out of renderImage so the
157
323
  // complexity hotspot stays under the AST-guard budget.
158
324
  function assertRenderSpecBounds(spec: VariantSpec): void {
@@ -181,6 +347,7 @@ function assertRenderSpecBounds(spec: VariantSpec): void {
181
347
  );
182
348
  }
183
349
  }
350
+ assertOverlayBounds(spec.resolvedOverlays);
184
351
  }
185
352
 
186
353
  export const renderImage: DerivativeRendererPlugin["render"] = async (
@@ -194,11 +361,7 @@ export const renderImage: DerivativeRendererPlugin["render"] = async (
194
361
  // the SVG rejection from the actual bytes so a mislabeled upload can't
195
362
  // reach libvips as SVG.
196
363
  const sniffed = await sharp(input, SHARP_INPUT_OPTIONS).metadata();
197
- if (sniffed.format === "svg") {
198
- throw new Error(
199
- "derivatives-sharp: SVG is not supported — rendering untrusted SVG through libvips is a trust boundary this renderer doesn't need to cross.",
200
- );
201
- }
364
+ assertNotSvg(sniffed.format);
202
365
 
203
366
  assertRenderSpecBounds(spec);
204
367
 
@@ -225,6 +388,10 @@ export const renderImage: DerivativeRendererPlugin["render"] = async (
225
388
  pipeline = pipeline.blur(spec.blur);
226
389
  }
227
390
 
391
+ if (spec.resolvedOverlays && spec.resolvedOverlays.length > 0) {
392
+ pipeline = await applyOverlays(pipeline, spec.resolvedOverlays);
393
+ }
394
+
228
395
  pipeline = applyEncoder(pipeline, spec, normalizedSource);
229
396
 
230
397
  const buffer = await pipeline.toBuffer();
@@ -77,9 +77,23 @@ const widgetEntity = createEntity({
77
77
  },
78
78
  });
79
79
 
80
+ // Registered as a real entity (so upload's registry-hardening resolves it)
81
+ // but with no EXT_DERIVATIVE_PUBLIC_PREDICATE registration — the "no
82
+ // predicate registered for this entityType" default-deny case below needs
83
+ // an entity that upload can attach to, distinct from "the entityType string
84
+ // isn't registered at all" (which upload now rejects before the file ever
85
+ // reaches file_refs, see kumiko-framework#3005).
86
+ const noPredicateEntity = createEntity({
87
+ table: "public_variant_no_predicate",
88
+ fields: {
89
+ img: createImageField({ variants: { thumb: { maxEdge: 160, format: "webp" } } }),
90
+ },
91
+ });
92
+
80
93
  let predicateCalls = 0;
81
94
  const widgetPredicateFeature = defineFeature("publicvariantroutetest", (r) => {
82
95
  r.entity("widget", widgetEntity);
96
+ r.entity("nopredicate", noPredicateEntity);
83
97
  r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "widget", {
84
98
  isPublic: (args: DerivativePublicPredicateArgs) => {
85
99
  predicateCalls++;
@@ -161,7 +175,7 @@ async function uploadFile(
161
175
 
162
176
  describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
163
177
  test("no predicate registered for the FileRef's entityType → 404", async () => {
164
- const fileId = await uploadFile(userA, { entityType: "unregistered-type", entityId: "x" });
178
+ const fileId = await uploadFile(userA, { entityType: "nopredicate", entityId: "x" });
165
179
 
166
180
  const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
167
181
 
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.287.0",
4
+ "type": "improvement",
5
+ "title": "Add overlay stage (QR codes, badge images) to derived image variants",
6
+ "detail": "VariantSpec now supports declarative overlay layers (qr via a resolver-token, or a base64 image) composited onto derived image variants. QR values are never free text — only a dataToken resolved server-side via a new extension point, keeping the anonymous derivative route from becoming an open image generator."
7
+ },
2
8
  {
3
9
  "version": "0.194.0",
4
10
  "type": "breaking",
@@ -9,6 +9,7 @@
9
9
  import { cachedResponse, computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
10
10
  import {
11
11
  defineFeature,
12
+ EXT_DERIVATIVE_OVERLAY_RESOLVER,
12
13
  EXT_DERIVATIVE_PUBLIC_PREDICATE,
13
14
  EXT_DERIVATIVE_RENDERER,
14
15
  type FeatureDefinition,
@@ -78,7 +79,7 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
78
79
 
79
80
  return defineFeature(FEATURE_NAME, (r) => {
80
81
  r.describe(
81
- "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. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it.",
82
+ "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. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
82
83
  );
83
84
  r.uiHints({
84
85
  displayLabel: "File Derivatives",
@@ -108,6 +109,13 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
108
109
  // entityType.
109
110
  },
110
111
  });
112
+ r.extendsRegistrar(EXT_DERIVATIVE_OVERLAY_RESOLVER, {
113
+ onRegister: () => {
114
+ // No side-effects at register-time — resolution happens in
115
+ // variant(), keyed by the FileRef's entityType, before the spec
116
+ // hash is computed.
117
+ },
118
+ });
111
119
 
112
120
  // Registered ONLY when resolveApexTenant is supplied — r.queryHandler
113
121
  // registers publicVariantQuery into the feature's dispatch table as a