@cosmicdrift/kumiko-bundled-features 0.286.0 → 0.288.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 (28) hide show
  1. package/package.json +12 -9
  2. package/src/auth-email-password/signed-token.ts +4 -86
  3. package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +186 -0
  4. package/src/billing-foundation/changes.json +6 -0
  5. package/src/billing-foundation/subscription-tier-sync.ts +9 -4
  6. package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
  7. package/src/derivatives-sharp/changes.json +8 -1
  8. package/src/derivatives-sharp/render.ts +172 -5
  9. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
  10. package/src/file-derivatives/changes.json +6 -0
  11. package/src/file-derivatives/feature.ts +9 -1
  12. package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
  13. package/src/shared/changes.json +8 -1
  14. package/src/shared/index.ts +12 -0
  15. package/src/shared/row-bound-grant.test.ts +294 -0
  16. package/src/shared/row-bound-grant.ts +115 -0
  17. package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
  18. package/src/shared/signed-token.ts +101 -0
  19. package/src/tenant/seeding.ts +4 -0
  20. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
  21. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
  22. package/src/user-data-rights/changes.json +12 -0
  23. package/src/user-data-rights/deletion-token.ts +41 -47
  24. package/src/user-data-rights/feature.ts +27 -0
  25. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
  26. package/src/user-data-rights/run-export-jobs.ts +69 -1
  27. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
  28. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.286.0",
3
+ "version": "0.288.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>",
@@ -94,6 +94,8 @@
94
94
  "./auth-email-password/testing": "./src/auth-email-password/testing.ts",
95
95
  "./auth-email-password/web": "./src/auth-email-password/web/index.ts",
96
96
  "./shared/single-use-token-store": "./src/shared/single-use-token-store.ts",
97
+ "./shared/signed-token": "./src/shared/signed-token.ts",
98
+ "./shared/row-bound-grant": "./src/shared/row-bound-grant.ts",
97
99
  "./delivery": "./src/delivery/index.ts",
98
100
  "./delivery/web": "./src/delivery/web/index.ts",
99
101
  "./channel-in-app": "./src/channel-in-app/index.ts",
@@ -130,12 +132,12 @@
130
132
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
133
  },
132
134
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.286.0",
134
- "@cosmicdrift/kumiko-framework": "0.286.0",
135
- "@cosmicdrift/kumiko-headless": "0.286.0",
136
- "@cosmicdrift/kumiko-renderer": "0.286.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.286.0",
138
- "@cosmicdrift/kumiko-types": "0.286.0",
135
+ "@cosmicdrift/kumiko-dispatcher-live": "0.288.0",
136
+ "@cosmicdrift/kumiko-framework": "0.288.0",
137
+ "@cosmicdrift/kumiko-headless": "0.288.0",
138
+ "@cosmicdrift/kumiko-renderer": "0.288.0",
139
+ "@cosmicdrift/kumiko-renderer-web": "0.288.0",
140
+ "@cosmicdrift/kumiko-types": "0.288.0",
139
141
  "@mollie/api-client": "^4.5.0",
140
142
  "@node-rs/argon2": "^2.0.2",
141
143
  "@types/mailparser": "^3.4.6",
@@ -164,7 +166,8 @@
164
166
  ],
165
167
  "devDependencies": {
166
168
  "@testing-library/user-event": "^14.6.1",
167
- "@cosmicdrift/kumiko-locale-de": "0.286.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.286.0"
169
+ "@cosmicdrift/kumiko-locale-de": "0.288.0",
170
+ "@cosmicdrift/kumiko-locale-es": "0.288.0",
171
+ "jsqr": "^1.4.0"
169
172
  }
170
173
  }
@@ -1,86 +1,4 @@
1
- // HMAC-signed single-purpose tokens for out-of-band auth flows
2
- // (password-reset, email-verification, future: magic-link).
3
- //
4
- // Format: <userId>.<expiresAtMs>.<hmac-base64url>
5
- //
6
- // The `purpose` is mixed INTO the HMAC input so a token minted for one
7
- // purpose (e.g. password-reset) can't be replayed against an endpoint that
8
- // expects another (e.g. verify-email), even if the caller knows the
9
- // userId and a valid expiry. Purpose is NOT carried in the token body —
10
- // verify() takes the purpose as argument and recomputes.
11
- //
12
- // Timing-safe comparison on verify so a valid-length forgery can't leak
13
- // signal through a short-circuit.
14
-
15
- import { createHmac, timingSafeEqual } from "node:crypto";
16
- import { Temporal } from "temporal-polyfill";
17
-
18
- export type VerifyResult =
19
- | { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
20
- | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
21
-
22
- function sign(input: string, secret: string): string {
23
- return createHmac("sha256", secret).update(input).digest("base64url");
24
- }
25
-
26
- function payload(purpose: string, userId: string, expiresAtMs: number): string {
27
- return `${purpose}:${userId}.${expiresAtMs}`;
28
- }
29
-
30
- export function signToken(
31
- userId: string,
32
- purpose: string,
33
- ttlMinutes: number,
34
- secret: string,
35
- now: Temporal.Instant = Temporal.Now.instant(),
36
- ): { token: string; expiresAt: Temporal.Instant } {
37
- const expiresAt = now.add({ minutes: ttlMinutes });
38
- const expiresAtMs = expiresAt.epochMilliseconds;
39
- const signature = sign(payload(purpose, userId, expiresAtMs), secret);
40
- return {
41
- token: `${userId}.${expiresAtMs}.${signature}`,
42
- expiresAt,
43
- };
44
- }
45
-
46
- export function verifyToken(
47
- token: string,
48
- purpose: string,
49
- secret: string,
50
- now: Temporal.Instant = Temporal.Now.instant(),
51
- ): VerifyResult {
52
- const parts = token.split(".");
53
- if (parts.length !== 3) return { ok: false, reason: "malformed" };
54
- const [userId, expiresAtRaw, providedSig] = parts;
55
- if (!userId || !expiresAtRaw || !providedSig) return { ok: false, reason: "malformed" };
56
-
57
- const expiresAtMs = Number(expiresAtRaw);
58
- if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) {
59
- return { ok: false, reason: "malformed" };
60
- }
61
-
62
- const expected = sign(payload(purpose, userId, expiresAtMs), secret);
63
- const expectedBuf = Buffer.from(expected, "base64url");
64
- const providedBuf = Buffer.from(providedSig, "base64url");
65
- // Length mismatch fails BEFORE timingSafeEqual, which throws on different
66
- // lengths — but that throw itself leaks via timing. Explicit length check
67
- // keeps the path uniform.
68
- if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
69
- if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
70
-
71
- if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
72
- return { ok: false, reason: "expired" };
73
- }
74
-
75
- // expiresAtMs surfaces so callers (burn-store TTL, telemetry, …) don't
76
- // have to re-parse the token themselves.
77
- return { ok: true, userId, expiresAtMs };
78
- }
79
-
80
- // Canonical purposes baked into the framework. Features that introduce new
81
- // flows extend this set (or just pass an inline string).
82
- export const TokenPurpose = {
83
- passwordReset: "reset",
84
- emailVerification: "verify",
85
- accountUnlock: "unlock",
86
- } as const;
1
+ // Moved to shared/signed-token.ts — the mechanism was never specific to
2
+ // email/password auth (user-data-rights and any row-bound grant use it too).
3
+ // Re-exported so this feature's barrel and external importers keep working.
4
+ export * from "../shared/signed-token";
@@ -0,0 +1,186 @@
1
+ // Integration test for createSubscriptionTierSync()'s onSyncError option
2
+ // (#3027). Forces a sync failure via a dispatchSystemWrite stub that fails
3
+ // only the tier-engine handlers (the primary billing-foundation writes run
4
+ // for real against the test DB) — proving that "log" (default) still
5
+ // answers the webhook successfully, while "fail-webhook" surfaces the sync
6
+ // error all the way into the HTTP response.
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
10
+ import { buildEntityTable, type EntityTable } from "@cosmicdrift/kumiko-framework/db";
11
+ import {
12
+ defineFeature,
13
+ type EntityDefinition,
14
+ type TenantId,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ createTestUser,
19
+ setupTestStack,
20
+ type TestStack,
21
+ testTenantId,
22
+ unsafeCreateEntityTable,
23
+ unsafePushTables,
24
+ } from "@cosmicdrift/kumiko-framework/stack";
25
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
26
+ import { Hono } from "hono";
27
+ import {
28
+ createComplianceProfilesFeature,
29
+ tenantComplianceProfileEntity,
30
+ } from "../../compliance-profiles";
31
+ import { createConfigFeature } from "../../config";
32
+ import { createTenantFeature } from "../../tenant/feature";
33
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
34
+ import type { TierMap } from "../../tier-engine/compose-app";
35
+ import { TierEngineHandlers } from "../../tier-engine/constants";
36
+ import { tierAssignmentEntity } from "../../tier-engine/entity";
37
+ import { createTierEngineFeature } from "../../tier-engine/feature";
38
+ import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
39
+ import { billingFoundationFeature } from "../feature";
40
+ import {
41
+ createSubscriptionTierSync,
42
+ SUBSCRIPTION_WEBHOOK_PATH,
43
+ type SystemWriteResult,
44
+ } from "../subscription-tier-sync";
45
+ import type { SubscriptionEvent, SubscriptionProviderPlugin } from "../types";
46
+
47
+ type TestTier = "free" | "pro";
48
+ const isTierName = (v: string): v is TestTier => v === "free" || v === "pro";
49
+ const TEST_TIER_MAP: TierMap<{ readonly maxItems: number }> = {
50
+ free: { features: [], caps: { maxItems: 1 } },
51
+ pro: { features: [], caps: { maxItems: 100 } },
52
+ };
53
+
54
+ const mockProviderFeature = defineFeature("test-mock-tier-sync-provider", (r) => {
55
+ r.requires("billing-foundation");
56
+ const plugin: SubscriptionProviderPlugin = {
57
+ verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent,
58
+ };
59
+ r.useExtension("subscriptionProvider", "mock", plugin);
60
+ });
61
+
62
+ // Boundary-cast: SubscriptionTierSyncDeps deliberately erases the entity's
63
+ // concrete field types to EntityDefinition (generic factory), which TS
64
+ // can't structurally widen to on its own.
65
+ const tierAssignmentTable = buildEntityTable(
66
+ "tier-assignment",
67
+ tierAssignmentEntity,
68
+ ) as unknown as EntityTable<EntityDefinition>;
69
+
70
+ let stack: TestStack;
71
+ let forceTierSyncFailure = false;
72
+
73
+ async function dispatchSystemWrite(args: {
74
+ readonly handlerQn: string;
75
+ readonly payload: unknown;
76
+ readonly tenantId: TenantId;
77
+ }): Promise<SystemWriteResult> {
78
+ if (
79
+ forceTierSyncFailure &&
80
+ (args.handlerQn === TierEngineHandlers.create || args.handlerQn === TierEngineHandlers.update)
81
+ ) {
82
+ return {
83
+ isSuccess: false,
84
+ error: { code: "test_forced_failure", message: "forced tier-sync failure" },
85
+ };
86
+ }
87
+ const systemUser = createTestUser({ id: 1, tenantId: args.tenantId, roles: ["SystemAdmin"] });
88
+ const res = await stack.http.write(args.handlerQn, args.payload, systemUser);
89
+ const body = (await res.json()) as {
90
+ isSuccess: boolean;
91
+ data?: unknown;
92
+ error?: { readonly code?: string; readonly message?: string };
93
+ };
94
+ return body.isSuccess
95
+ ? { isSuccess: true, data: body.data }
96
+ : { isSuccess: false, error: body.error };
97
+ }
98
+
99
+ let logApp: Hono;
100
+ let failApp: Hono;
101
+
102
+ beforeAll(async () => {
103
+ stack = await setupTestStack({
104
+ features: [
105
+ createConfigFeature(),
106
+ createTenantFeature(),
107
+ createComplianceProfilesFeature(),
108
+ createTenantLifecycleFeature(),
109
+ billingFoundationFeature,
110
+ createTierEngineFeature({ defaultTier: "free", tierMap: TEST_TIER_MAP }),
111
+ mockProviderFeature,
112
+ ],
113
+ });
114
+ await createEventsTable(stack.db);
115
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
116
+ await unsafePushTables(stack.db, { tier_assignments: tierAssignmentTable });
117
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
118
+
119
+ const sharedDeps = {
120
+ db: stack.db,
121
+ registry: stack.registry,
122
+ dispatchSystemWrite,
123
+ tierAssignmentTable,
124
+ isTierName,
125
+ defaultTier: "free" as const,
126
+ };
127
+
128
+ logApp = new Hono();
129
+ createSubscriptionTierSync<TestTier>(sharedDeps).wireSubscriptionWebhookRoute(logApp);
130
+
131
+ failApp = new Hono();
132
+ createSubscriptionTierSync<TestTier>({
133
+ ...sharedDeps,
134
+ onSyncError: "fail-webhook",
135
+ }).wireSubscriptionWebhookRoute(failApp);
136
+ });
137
+
138
+ afterAll(async () => {
139
+ await stack.cleanup();
140
+ resetPiiSubjectKmsForTests();
141
+ });
142
+
143
+ const webhookPath = SUBSCRIPTION_WEBHOOK_PATH.replace(":providerName", "mock");
144
+
145
+ function buildEvent(tenantId: string, providerEventId: string): SubscriptionEvent {
146
+ return {
147
+ providerEventId,
148
+ providerName: "mock",
149
+ type: SubscriptionEventTypes.created,
150
+ tenantId,
151
+ providerCustomerId: `cus_${providerEventId}`,
152
+ providerSubscriptionId: `sub_${providerEventId}`,
153
+ status: SubscriptionStatuses.active,
154
+ tier: "pro",
155
+ currentPeriodEnd: "2026-06-01T00:00:00Z",
156
+ rawPayload: "{}",
157
+ };
158
+ }
159
+
160
+ describe("createSubscriptionTierSync onSyncError", () => {
161
+ test("default ('log'): a tier-sync failure still returns a successful webhook response", async () => {
162
+ forceTierSyncFailure = true;
163
+ const tenantId = testTenantId(9001);
164
+ const res = await logApp.request(webhookPath, {
165
+ method: "POST",
166
+ body: JSON.stringify(buildEvent(tenantId, "evt_log_1")),
167
+ });
168
+ forceTierSyncFailure = false;
169
+
170
+ expect(res.status).toBe(200);
171
+ });
172
+
173
+ test("'fail-webhook': a tier-sync failure also fails the webhook response", async () => {
174
+ forceTierSyncFailure = true;
175
+ const tenantId = testTenantId(9002);
176
+ const res = await failApp.request(webhookPath, {
177
+ method: "POST",
178
+ body: JSON.stringify(buildEvent(tenantId, "evt_fail_1")),
179
+ });
180
+ forceTierSyncFailure = false;
181
+
182
+ expect(res.status).toBe(500);
183
+ const body = (await res.json()) as { error: { code: string } };
184
+ expect(body.error.code).toBe("subscription_webhook_processing_failed");
185
+ });
186
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.288.0",
4
+ "type": "improvement",
5
+ "title": "add onSyncError option to createSubscriptionTierSync",
6
+ "detail": "`SubscriptionTierSyncDeps<TTier>` gets an optional `onSyncError?: \"log\" | \"fail-webhook\"`\n(default `\"log\"`, matching prior behavior exactly). `\"fail-webhook\"` makes a tier-sync\nfailure also fail the webhook response, so an idempotent-retry caller (whose retry\nno-ops the already-committed primary write) gets another attempt at the sync step."
7
+ },
2
8
  {
3
9
  "version": "0.286.0",
4
10
  "type": "improvement",
@@ -35,6 +35,12 @@ export type SubscriptionTierSyncDeps<TTier extends string> = {
35
35
  readonly tierAssignmentTable: EntityTable<EntityDefinition>;
36
36
  readonly isTierName: (value: string) => value is TTier;
37
37
  readonly defaultTier: TTier;
38
+ // "log" (default): sync failure only warns, the webhook still reports
39
+ // success — right when the caller has no idempotent retry to lean on.
40
+ // "fail-webhook": sync failure fails the webhook response too, so an
41
+ // idempotent caller (e.g. Stripe, whose retry re-runs the already-committed
42
+ // primary write as a no-op) gets a second attempt at the sync step itself.
43
+ readonly onSyncError?: "log" | "fail-webhook";
38
44
  };
39
45
 
40
46
  export function effectiveTierFromSubscription<TTier extends string>(
@@ -116,16 +122,15 @@ export function createSubscriptionTierSync<TTier extends string>(
116
122
  tenantId: targetTenantId,
117
123
  });
118
124
  if (!result.isSuccess) return result;
119
- // The primary write already committed — a webhook caller (Stripe/
120
- // PayPal) that sees isSuccess:false here retries the whole event,
121
- // re-running an already-succeeded side effect. Log the tier-sync
122
- // failure instead of masking the primary write's success.
123
125
  const syncError = await syncTierFromSubscription(targetTenantId);
124
126
  if (syncError) {
125
127
  // biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
126
128
  console.warn(
127
129
  `[subscription-tier-sync] tier sync failed for tenant ${targetTenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
128
130
  );
131
+ if (deps.onSyncError === "fail-webhook") {
132
+ return { isSuccess: false, error: syncError };
133
+ }
129
134
  }
130
135
  return result;
131
136
  },
@@ -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
+ ]