@cosmicdrift/kumiko-bundled-features 0.220.1 → 0.221.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 (92) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
  3. package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
  4. package/src/audit/__tests__/constants.test.ts +8 -0
  5. package/src/audit/web/audit-log-detail-screen.tsx +30 -14
  6. package/src/audit/web/audit-log-screen.tsx +21 -7
  7. package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
  8. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
  9. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
  10. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
  11. package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
  12. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
  13. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
  14. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
  15. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
  16. package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
  17. package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
  19. package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
  20. package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
  21. package/src/auth-email-password/i18n.ts +1 -0
  22. package/src/auth-email-password/magic-link-mail.ts +17 -1
  23. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
  24. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
  25. package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
  26. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
  27. package/src/custom-fields/__tests__/feature.test.ts +10 -12
  28. package/src/custom-fields/constants.ts +4 -3
  29. package/src/custom-fields/feature.ts +7 -8
  30. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
  31. package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
  32. package/src/delivery/db/queries/preferences.ts +3 -2
  33. package/src/delivery/handlers/log.query.ts +1 -0
  34. package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
  35. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
  36. package/src/file-derivatives/feature.ts +6 -1
  37. package/src/file-provider-s3/feature.ts +1 -2
  38. package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
  39. package/src/form-draft/handlers/cleanup.job.ts +31 -20
  40. package/src/form-draft/handlers/discard.write.ts +8 -8
  41. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
  42. package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
  43. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
  44. package/src/jobs/feature.ts +7 -2
  45. package/src/jobs/handlers/detail.query.ts +22 -11
  46. package/src/jobs/handlers/list.query.ts +17 -6
  47. package/src/jobs/job-run-logger.ts +78 -36
  48. package/src/jobs/web/job-run-detail-screen.tsx +4 -4
  49. package/src/mail-transport-smtp/feature.ts +1 -2
  50. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  51. package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
  52. package/src/notes-history/handlers/add-note.write.ts +7 -3
  53. package/src/notes-history/web/notes-section.tsx +10 -2
  54. package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
  55. package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
  56. package/src/secrets/feature.ts +9 -2
  57. package/src/shared/index.ts +1 -0
  58. package/src/shared/map-with-concurrency.test.ts +2 -2
  59. package/src/shared/session-field.ts +10 -0
  60. package/src/shared/session-locale-field.test.ts +9 -0
  61. package/src/shared/session-locale-field.ts +4 -1
  62. package/src/shared/session-timezone-field.ts +2 -1
  63. package/src/subscription-stripe/feature.ts +5 -11
  64. package/src/tags/feature.ts +7 -4
  65. package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
  66. package/src/template-resolver/seeding.ts +78 -6
  67. package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
  68. package/src/template-resolver/web/client-plugin.tsx +2 -1
  69. package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
  70. package/src/tenant/__tests__/tenant-security.integration.test.ts +72 -40
  71. package/src/tenant/handlers/members.query.ts +6 -1
  72. package/src/tenant/handlers/team-list.query.ts +2 -2
  73. package/src/tenant/handlers/update-member-roles.write.ts +1 -1
  74. package/src/tenant/i18n.ts +2 -2
  75. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
  76. package/src/tenant-settings/feature.ts +6 -30
  77. package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
  78. package/src/user/__tests__/roles-input-schema.test.ts +20 -0
  79. package/src/user/__tests__/user.integration.test.ts +156 -118
  80. package/src/user/handlers/create.write.ts +5 -4
  81. package/src/user/handlers/detail.query.ts +24 -2
  82. package/src/user/handlers/list.query.ts +19 -26
  83. package/src/user/handlers/update-roles.ts +18 -5
  84. package/src/user/handlers/update.write.ts +6 -5
  85. package/src/user/roles-input-schema.ts +20 -0
  86. package/src/user/schema/user.ts +2 -3
  87. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
  88. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
  89. package/src/user-data-rights/run-forget-cleanup.ts +34 -24
  90. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
  91. package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
  92. package/src/user-profile/web/profile-screen.tsx +1 -1
@@ -203,12 +203,10 @@ describe("createCustomFieldsFeature access-options", () => {
203
203
  expect(writeAccess(feature, "delete-system-field")).toEqual(["SystemAdmin"]);
204
204
  });
205
205
 
206
- test("fieldDefinitionWriteRoles feeds into the list-default (a definer can load what they defined)", () => {
206
+ test("fieldDefinitionWriteRoles feeds into list roles without keeping default TenantAdmin", () => {
207
207
  const feature = createCustomFieldsFeature({ fieldDefinitionWriteRoles: ["Admin", "Editor"] });
208
208
  const roles = listAccess(feature);
209
- expect(roles).toContain("Admin");
210
- expect(roles).toContain("Editor");
211
- expect(roles).toContain("TenantAdmin");
209
+ expect(roles).toEqual(["Admin", "Editor"]);
212
210
  });
213
211
 
214
212
  test("fieldDefinitionWriteRoles and valueWriteRoles are independent — setting one leaves the other's default", () => {
@@ -219,23 +217,23 @@ describe("createCustomFieldsFeature access-options", () => {
219
217
  });
220
218
 
221
219
  describe("resolveFieldDefinitionListRoles", () => {
222
- test("nichts gesetzt → reiner Default", () => {
220
+ test("nothing set → default only", () => {
223
221
  expect(resolveFieldDefinitionListRoles({})).toEqual(["TenantAdmin"]);
224
222
  });
225
223
 
226
- test("valueWriteRoles gesetzt, list ungesetzt → Union mit Default, dedupliziert", () => {
224
+ test("valueWriteRoles set, list unset → union with default, deduped", () => {
227
225
  expect(resolveFieldDefinitionListRoles({ valueWriteRoles: ["Admin", "Editor"] })).toEqual([
228
226
  "Admin",
229
227
  "Editor",
230
228
  "TenantAdmin",
231
229
  ]);
232
- // TenantAdmin schon in valueWriteRoles → keine Dublette.
230
+ // TenantAdmin already in valueWriteRoles → no duplicate.
233
231
  expect(resolveFieldDefinitionListRoles({ valueWriteRoles: ["TenantAdmin", "Editor"] })).toEqual(
234
232
  ["TenantAdmin", "Editor"],
235
233
  );
236
234
  });
237
235
 
238
- test("explizite list-Rollen gewinnen immer (auch über valueWriteRoles)", () => {
236
+ test("explicit list roles always win (even over valueWriteRoles)", () => {
239
237
  expect(
240
238
  resolveFieldDefinitionListRoles({
241
239
  valueWriteRoles: ["Admin"],
@@ -244,18 +242,18 @@ describe("resolveFieldDefinitionListRoles", () => {
244
242
  ).toEqual(["Viewer"]);
245
243
  });
246
244
 
247
- test("fieldDefinitionWriteRoles gesetzt, list ungesetzt → Union mit Default, dedupliziert", () => {
245
+ test("fieldDefinitionWriteRoles set, list unset → write roles only (no default TenantAdmin)", () => {
248
246
  expect(
249
247
  resolveFieldDefinitionListRoles({ fieldDefinitionWriteRoles: ["Admin", "Editor"] }),
250
- ).toEqual(["Admin", "Editor", "TenantAdmin"]);
248
+ ).toEqual(["Admin", "Editor"]);
251
249
  });
252
250
 
253
- test("valueWriteRoles UND fieldDefinitionWriteRoles gesetzt → Union aus beiden plus Default", () => {
251
+ test("valueWriteRoles AND fieldDefinitionWriteRoles → union of both, no default", () => {
254
252
  expect(
255
253
  resolveFieldDefinitionListRoles({
256
254
  valueWriteRoles: ["Member"],
257
255
  fieldDefinitionWriteRoles: ["Admin"],
258
256
  }),
259
- ).toEqual(["Member", "Admin", "TenantAdmin"]);
257
+ ).toEqual(["Member", "Admin"]);
260
258
  });
261
259
  });
@@ -23,9 +23,10 @@ export const CustomFieldsQueries = {
23
23
  fieldDefinitionList: "custom-fields:query:field-definition:list",
24
24
  } as const;
25
25
 
26
- // Name unter dem die web-component im ExtensionSectionsProvider
27
- // registriert wird — Apps referenzieren ihn im Screen-Schema via
28
- // `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`.
26
+ // Name the web component registers under in ExtensionSectionsProvider —
27
+ // apps reference it in the screen schema via:
28
+ // { kind: "extension", title, contributesToFormSubmit: true,
29
+ // component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } } }
29
30
  export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
30
31
 
31
32
  // Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
@@ -206,7 +206,7 @@ export type CustomFieldsFeatureOptions = {
206
206
  * before #2296. Apps with their own role vocabulary (e.g. ["Admin","Editor"])
207
207
  * MUST set this, else field-definition management is access_denied for every
208
208
  * app-user without a "TenantAdmin" role. Only covers the tenant-scope triad —
209
- * define-/delete-system-field stay hard-wired to ["TenantAdmin"], since
209
+ * define-/delete-system-field stay hard-wired to ["SystemAdmin"], since
210
210
  * system-scope is a separate trust boundary that must not become reachable
211
211
  * through a tenant role vocabulary. Also feeds into
212
212
  * resolveFieldDefinitionListRoles (below) so a definer without an explicit
@@ -225,13 +225,12 @@ export function resolveFieldDefinitionListRoles(
225
225
  if (opts.valueWriteRoles === undefined && opts.fieldDefinitionWriteRoles === undefined) {
226
226
  return DEFAULT_FIELD_DEFINITION_LIST_ROLES;
227
227
  }
228
- return [
229
- ...new Set([
230
- ...(opts.valueWriteRoles ?? []),
231
- ...(opts.fieldDefinitionWriteRoles ?? []),
232
- ...DEFAULT_FIELD_DEFINITION_LIST_ROLES,
233
- ]),
234
- ];
228
+ // Write-roles override replaces the default role vocab — don't keep unioning
229
+ // TenantAdmin onto list when the app opted into its own write roles.
230
+ if (opts.fieldDefinitionWriteRoles !== undefined) {
231
+ return [...new Set([...(opts.valueWriteRoles ?? []), ...opts.fieldDefinitionWriteRoles])];
232
+ }
233
+ return [...new Set([...(opts.valueWriteRoles ?? []), ...DEFAULT_FIELD_DEFINITION_LIST_ROLES])];
235
234
  }
236
235
 
237
236
  // Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
@@ -17,6 +17,7 @@ import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
17
17
 
18
18
  type LogRow = {
19
19
  readonly id: string;
20
+ readonly tenantId?: string;
20
21
  readonly type: string;
21
22
  readonly channel: string;
22
23
  readonly recipient: string | null;
@@ -25,7 +26,9 @@ type LogRow = {
25
26
 
26
27
  let stack: TestStack;
27
28
  const tenantId = testTenantId(701);
29
+ const otherTenantId = testTenantId(702);
28
30
  const admin = createTestUser({ id: 701, roles: ["TenantAdmin"], tenantId });
31
+ const systemAdmin = createTestUser({ id: 709, roles: ["SystemAdmin"], tenantId });
29
32
 
30
33
  beforeAll(async () => {
31
34
  stack = await setupTestStack({ features: [createDeliveryFeature()] });
@@ -55,6 +58,14 @@ beforeAll(async () => {
55
58
  recipientAddress: null,
56
59
  status: "queued",
57
60
  },
61
+ {
62
+ id: crypto.randomUUID(),
63
+ tenantId: otherTenantId,
64
+ notificationType: "logtest:other",
65
+ channel: "email",
66
+ recipientAddress: "other@example.com",
67
+ status: "sent",
68
+ },
58
69
  ]);
59
70
  });
60
71
 
@@ -115,6 +126,23 @@ describe("delivery:query:log — sort", () => {
115
126
  );
116
127
  expect(withInvalidSort.rows.map((r) => r.id)).toEqual(withNoSort.rows.map((r) => r.id));
117
128
  });
129
+
130
+ test("sort=tenantId is whitelisted and reverses for SystemAdmin cross-tenant", async () => {
131
+ const asc = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
132
+ DeliveryQueries.log,
133
+ { sort: "tenantId", sortDirection: "asc" },
134
+ systemAdmin,
135
+ );
136
+ const desc = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
137
+ DeliveryQueries.log,
138
+ { sort: "tenantId", sortDirection: "desc" },
139
+ systemAdmin,
140
+ );
141
+ const ascTenants = [...new Set(asc.rows.map((r) => r.tenantId))];
142
+ const descTenants = [...new Set(desc.rows.map((r) => r.tenantId))];
143
+ expect(new Set(ascTenants)).toEqual(new Set([tenantId, otherTenantId]));
144
+ expect(descTenants).toEqual([...ascTenants].reverse());
145
+ });
118
146
  });
119
147
 
120
148
  describe("delivery:query:log — field mapping", () => {
@@ -169,7 +169,7 @@ describe("delivery log SystemAdmin cross-tenant scope", () => {
169
169
  status: string;
170
170
  error: string | null;
171
171
  }[];
172
- }>(DeliveryQueries.log, {}, systemAdmin);
172
+ }>(DeliveryQueries.log, { limit: 100 }, systemAdmin);
173
173
 
174
174
  const byId = new Map(res.rows.map((r) => [r.id, r]));
175
175
  expect(byId.get(systemConfirmationId)?.tenantId).toBe(SYSTEM_TENANT_ID);
@@ -189,6 +189,97 @@ describe("delivery log SystemAdmin cross-tenant scope", () => {
189
189
  );
190
190
  });
191
191
 
192
+ test("SystemAdmin cursor-paginates the unfiltered cross-tenant result set, seeing every row exactly once", async () => {
193
+ const pagingTenant = testTenantId(53);
194
+ const seededRows = Array.from({ length: 7 }, (_, i) => ({
195
+ id: crypto.randomUUID(),
196
+ tenantId: i % 2 === 0 ? SYSTEM_TENANT_ID : pagingTenant,
197
+ // unique per row so we can sort on it — the default sort (createdAt)
198
+ // ties across rows inserted in the same insertMany statement, and the
199
+ // cursor WHERE only excludes strictly-equal sort values (see
200
+ // log.query.ts), so equal timestamps would silently drop rows.
201
+ notificationType: `paging-test:${i}`,
202
+ channel: "email",
203
+ recipientAddress: null,
204
+ status: "sent",
205
+ }));
206
+ await insertMany(stack.db, deliveryAttemptsTable, seededRows);
207
+
208
+ const systemAdmin = createTestUser({
209
+ id: 52,
210
+ roles: ["SystemAdmin"],
211
+ tenantId: pagingTenant,
212
+ });
213
+
214
+ const seenCounts = new Map<string, number>();
215
+ let cursor: string | undefined;
216
+ let pageCount = 0;
217
+ do {
218
+ const page = await stack.http.queryOk<{
219
+ rows: readonly { id: string }[];
220
+ nextCursor: string | null;
221
+ }>(
222
+ DeliveryQueries.log,
223
+ { limit: 3, sort: "type", sortDirection: "asc", cursor },
224
+ systemAdmin,
225
+ );
226
+ for (const row of page.rows) {
227
+ seenCounts.set(row.id, (seenCounts.get(row.id) ?? 0) + 1);
228
+ }
229
+ cursor = page.nextCursor ?? undefined;
230
+ pageCount += 1;
231
+ expect(pageCount).toBeLessThan(50);
232
+ } while (cursor);
233
+
234
+ expect(pageCount).toBeGreaterThan(1);
235
+ for (const row of seededRows) {
236
+ expect(seenCounts.get(row.id)).toBe(1);
237
+ }
238
+ expect([...seenCounts.values()].every((count) => count === 1)).toBe(true);
239
+ });
240
+
241
+ test("SystemAdmin with no demo-tenant session (session tenantId is SYSTEM_TENANT_ID itself) still sees cross-tenant rows", async () => {
242
+ const otherTenant = testTenantId(54);
243
+ const systemScopedId = crypto.randomUUID();
244
+ const otherTenantRowId = crypto.randomUUID();
245
+
246
+ await insertMany(stack.db, deliveryAttemptsTable, [
247
+ {
248
+ id: systemScopedId,
249
+ tenantId: SYSTEM_TENANT_ID,
250
+ notificationType: "no-session-tenant-test:system",
251
+ channel: "email",
252
+ recipientAddress: null,
253
+ status: "sent",
254
+ },
255
+ {
256
+ id: otherTenantRowId,
257
+ tenantId: otherTenant,
258
+ notificationType: "no-session-tenant-test:other",
259
+ channel: "email",
260
+ recipientAddress: null,
261
+ status: "sent",
262
+ },
263
+ ]);
264
+
265
+ // Mirrors a platform-level caller (e.g. an API key) that has no demo
266
+ // tenant on its session at all, as opposed to the other SystemAdmin
267
+ // tests above which browse from a demo tenant panel.
268
+ const systemAdmin = createTestUser({
269
+ id: 53,
270
+ roles: ["SystemAdmin"],
271
+ tenantId: SYSTEM_TENANT_ID,
272
+ });
273
+
274
+ const res = await stack.http.queryOk<{
275
+ rows: readonly { id: string; tenantId: string }[];
276
+ }>(DeliveryQueries.log, { limit: 100 }, systemAdmin);
277
+
278
+ const byId = new Map(res.rows.map((r) => [r.id, r]));
279
+ expect(byId.get(systemScopedId)?.tenantId).toBe(SYSTEM_TENANT_ID);
280
+ expect(byId.get(otherTenantRowId)?.tenantId).toBe(otherTenant);
281
+ });
282
+
192
283
  test("TenantAdmin cannot read SYSTEM_TENANT_ID or foreign-tenant delivery attempts", async () => {
193
284
  const demoTenant = testTenantId(61);
194
285
  const otherTenant = testTenantId(62);
@@ -1,4 +1,4 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
 
@@ -15,7 +15,8 @@ export async function selectNotificationPreferences(
15
15
  notificationType: string,
16
16
  channelName: string,
17
17
  ): Promise<readonly NotificationPreferenceRow[]> {
18
- return asRawClient(db).unsafe<NotificationPreferenceRow>(
18
+ return unsafeReadRetrying<NotificationPreferenceRow>(
19
+ db,
19
20
  `SELECT notification_type AS "notificationType", channel, enabled
20
21
  FROM read_notification_preferences
21
22
  WHERE tenant_id = $1
@@ -34,6 +34,7 @@ type DeliveryLogRow = {
34
34
  // application code, so there is no column to ORDER BY in SQL for it.
35
35
  const DELIVERY_LOG_SORT_COLUMNS = {
36
36
  createdAt: "createdAt",
37
+ tenantId: "tenantId",
37
38
  type: "notificationType",
38
39
  channel: "channel",
39
40
  status: "status",
@@ -8,7 +8,7 @@
8
8
  // fetchOne(), throwing a Postgres 22P02 (malformed uuid literal) that
9
9
  // poisons the pooled Bun.SQL connection, an unauth DoS primitive.
10
10
 
11
- import { describe, expect, test } from "bun:test";
11
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
12
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
13
13
  import {
14
14
  createFilesFeature,
@@ -21,8 +21,10 @@ import { createFileDerivativesFeature } from "../feature";
21
21
  import { PUBLIC_VARIANT_QN } from "../handlers/public-variant.query";
22
22
 
23
23
  describe("file-derivatives :: publicVariant query fileRefId validation", () => {
24
- test("non-UUID fileRefId via /api/query → 400 validation_error, never reaches the DB", async () => {
25
- const stack: TestStack = await setupTestStack({
24
+ let stack: TestStack;
25
+
26
+ beforeAll(async () => {
27
+ stack = await setupTestStack({
26
28
  features: [
27
29
  createConfigFeature(),
28
30
  fileFoundationFeature,
@@ -32,44 +34,31 @@ describe("file-derivatives :: publicVariant query fileRefId validation", () => {
32
34
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
33
35
  files: { storageProvider: createInMemoryFileProvider() },
34
36
  });
37
+ });
38
+
39
+ afterAll(async () => {
40
+ await stack.cleanup();
41
+ });
35
42
 
36
- try {
37
- const res = await stack.http.raw("POST", "/api/query", {
38
- type: PUBLIC_VARIANT_QN,
39
- payload: { fileRefId: "not-a-uuid; DROP TABLE file_refs;--", variant: "thumb" },
40
- });
43
+ test("non-UUID fileRefId via /api/query → 400 validation_error, never reaches the DB", async () => {
44
+ const res = await stack.http.raw("POST", "/api/query", {
45
+ type: PUBLIC_VARIANT_QN,
46
+ payload: { fileRefId: "not-a-uuid; DROP TABLE file_refs;--", variant: "thumb" },
47
+ });
41
48
 
42
- expect(res.status).toBe(400);
43
- const body = (await res.json()) as { error?: { code?: string } };
44
- expect(body.error?.code).toBe("validation_error");
45
- } finally {
46
- await stack.cleanup();
47
- }
49
+ expect(res.status).toBe(400);
50
+ const body = (await res.json()) as { error?: { code?: string } };
51
+ expect(body.error?.code).toBe("validation_error");
48
52
  });
49
53
 
50
54
  test("well-formed but unknown UUID fileRefId via /api/query → 200 with null (schema passes, DB lookup just finds nothing)", async () => {
51
- const stack: TestStack = await setupTestStack({
52
- features: [
53
- createConfigFeature(),
54
- fileFoundationFeature,
55
- createFilesFeature(),
56
- createFileDerivativesFeature({ resolveApexTenant: () => SYSTEM_TENANT_ID }),
57
- ],
58
- anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
59
- files: { storageProvider: createInMemoryFileProvider() },
55
+ const res = await stack.http.raw("POST", "/api/query", {
56
+ type: PUBLIC_VARIANT_QN,
57
+ payload: { fileRefId: "00000000-0000-4000-8000-000000000000", variant: "thumb" },
60
58
  });
61
59
 
62
- try {
63
- const res = await stack.http.raw("POST", "/api/query", {
64
- type: PUBLIC_VARIANT_QN,
65
- payload: { fileRefId: "00000000-0000-4000-8000-000000000000", variant: "thumb" },
66
- });
67
-
68
- expect(res.status).toBe(200);
69
- const body = (await res.json()) as { data: unknown };
70
- expect(body.data).toBeNull();
71
- } finally {
72
- await stack.cleanup();
73
- }
60
+ expect(res.status).toBe(200);
61
+ const body = (await res.json()) as { data: unknown };
62
+ expect(body.data).toBeNull();
74
63
  });
75
64
  });
@@ -279,8 +279,8 @@ describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
279
279
  test("a preset name the field doesn't declare in its variants → 404", async () => {
280
280
  const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
281
281
 
282
- // widgetEntity's "img" field declares only thumb/full — "card" and
283
- // "hero" aren't in its variants map.
282
+ // widgetEntity's "img" field declares thumb/full/plan/heroWide/raw —
283
+ // "card" and "hero" aren't in its variants map.
284
284
  const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/card`);
285
285
 
286
286
  expect(res.status).toBe(404);
@@ -33,6 +33,10 @@ const FEATURE_NAME = "file-derivatives";
33
33
  // a name list here "for safety" — see #1985.
34
34
  const VARIANT_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
35
35
 
36
+ /** Host → tenantId for the anonymous media route.
37
+ * MUST be cheap/synchronous (in-memory map or cached lookup): it runs
38
+ * *before* the query-handler rate limit, so a DB round-trip here is an
39
+ * unthrottled DoS surface for anonymous callers rotating Host headers (#1977). */
36
40
  export type PublicVariantResolveApexTenant = (
37
41
  host: string,
38
42
  ) => Promise<TenantId | null> | TenantId | null;
@@ -42,7 +46,8 @@ export type FileDerivativesOptions = {
42
46
  * Without this option, neither the httpRoute NOR the `publicVariant`
43
47
  * query is registered — it is unreachable via the httpRoute path and via
44
48
  * the generic `/api` query dispatch. Existing consumers that only need
45
- * `ctx.derivatives` are unaffected. */
49
+ * `ctx.derivatives` are unaffected. See {@link PublicVariantResolveApexTenant}
50
+ * for the in-memory/sync contract. */
46
51
  readonly resolveApexTenant?: PublicVariantResolveApexTenant;
47
52
  /** Base path of the public variant route. Default "/media". */
48
53
  readonly basePath?: string;
@@ -52,9 +52,8 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
52
52
  r.requires("file-foundation");
53
53
 
54
54
  const secretAccessKey = r.secret("s3.secretAccessKey", {
55
- label: { de: "S3 Secret Access Key", en: "S3 Secret Access Key" },
55
+ label: { en: "S3 Secret Access Key" },
56
56
  hint: {
57
- de: "Privater Teil des S3-Schlüsselpaares. Bei Hetzner Object Storage 'Secret Key', bei AWS S3 'Secret Access Key'.",
58
57
  en: "Private half of the S3 key pair. Hetzner calls it 'Secret Key', AWS calls it 'Secret Access Key'.",
59
58
  },
60
59
  redact: (plaintext) => {
@@ -165,15 +165,14 @@ async function dispatchCleanup(): Promise<void> {
165
165
 
166
166
  describe("form-draft cleanup job", () => {
167
167
  // FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY (cleanup.job.ts) is a hardcoded
168
- // literal that must match the qualified name r.config({ keys: {
169
- // retentionDays: ... } }) derives internally in feature.ts — nothing
170
- // else ties the two together, so a rename of the `retentionDays` config
171
- // key there would silently desync them. This recomputes the derivation
172
- // and fails loudly if that ever happens.
173
- test("FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY matches the qualified name feature.ts derives for retentionDays", () => {
174
- expect(FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY).toBe(
175
- qn(toKebab(FORM_DRAFT_FEATURE_NAME), "config", toKebab("retentionDays")),
168
+ // literal that must match a key actually registered by formDraftFeature.
169
+ // Assert against the feature's configKeys (not a hand-rebuilt qn string),
170
+ // so renaming the key in feature.ts without updating the constant fails.
171
+ test("FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY is among formDraftFeature registered config keys", () => {
172
+ const registered = Object.keys(formDraftFeature.configKeys ?? {}).map((key) =>
173
+ qn(toKebab(FORM_DRAFT_FEATURE_NAME), "config", toKebab(key)),
176
174
  );
175
+ expect(registered).toContain(FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY);
177
176
  });
178
177
 
179
178
  test("deletes a draft older than the default retention window, leaves a fresh one alone", async () => {
@@ -6,7 +6,11 @@
6
6
  // Chunked DELETE (default 1000/batch) keeps lock durations bounded, mirror
7
7
  // of sessions/handlers/cleanup.job.ts.
8
8
 
9
- import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
+ import {
10
+ createEventStoreExecutor,
11
+ createTenantDb,
12
+ type DbConnection,
13
+ } from "@cosmicdrift/kumiko-framework/db";
10
14
  import {
11
15
  type AppContext,
12
16
  access,
@@ -19,11 +23,16 @@ import {
19
23
  type TenantId,
20
24
  } from "@cosmicdrift/kumiko-framework/engine";
21
25
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
26
+ import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
22
27
  import { type StaleDraftRow, selectStaleDraftsBatch } from "../db/queries/cleanup";
23
- import { filterOwnedStorageKeys } from "../db/queries/owned-file-refs";
28
+ import { filterOwnedFileRefs } from "../db/queries/owned-file-refs";
24
29
  import { formDraftExecutor } from "../executor";
25
30
  import { collectDraftFileRefKeys, releaseDraftFileRefs } from "../release-file-refs";
26
31
 
32
+ const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
33
+ entityName: "fileRef",
34
+ });
35
+
27
36
  export const FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY = "form-draft:config:retention-days";
28
37
  export const FORM_DRAFT_DEFAULT_RETENTION_DAYS = 30;
29
38
  const DEFAULT_BATCH_SIZE = 1000;
@@ -48,27 +57,15 @@ async function releaseRowFileRefs(
48
57
  // skip: no FileRefs in this row's blob — nothing to release.
49
58
  if (keys.length === 0) return;
50
59
  if (!fileProviderResolver) {
51
- // skip: no resolver wired (files feature not mounted) — row still
52
- // gets deleted below, but its FileRefs leak as storage orphans.
53
60
  log?.warn?.(
54
61
  `[form-draft:cleanup] tenant=${row.tenantId} has ${keys.length} FileRef(s) but no _fileProviderResolver is wired — row will be deleted without releasing storage`,
55
62
  );
56
- // skip: warning already logged above — nothing more to do for this row.
63
+ // skip: no provider wired — cannot release storage; row deletion proceeds anyway.
57
64
  return;
58
65
  }
59
66
 
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
- //
67
- // Create-mode draftKey (`${screenId}:new:${draftId}`) mints its draftId
68
- // lazily on the first step-change — a file uploaded before that exists
69
- // predates the draft row, so the insertedAt filter must not apply.
70
67
  const isCreateMode = row.draftKey.includes(":new:");
71
- const ownedKeys = await filterOwnedStorageKeys(
68
+ const ownedRefs = await filterOwnedFileRefs(
72
69
  db,
73
70
  row.tenantId,
74
71
  row.ownerId,
@@ -76,10 +73,23 @@ async function releaseRowFileRefs(
76
73
  row.insertedAt,
77
74
  isCreateMode,
78
75
  );
79
-
76
+ const systemUser = createSystemUser(row.tenantId);
77
+ const tenantDb = createTenantDb(db, row.tenantId, "system");
78
+ for (const ref of ownedRefs) {
79
+ const forgetResult = await fileRefExecutor.forget({ id: ref.id }, systemUser, tenantDb);
80
+ if (!forgetResult.isSuccess) {
81
+ log?.warn?.(
82
+ `[form-draft:cleanup] fileRef.forget failed for id=${ref.id} tenant=${row.tenantId}`,
83
+ );
84
+ }
85
+ }
80
86
  try {
81
87
  const provider = await fileProviderResolver(row.tenantId);
82
- await releaseDraftFileRefs(ownedKeys, (key) => provider.delete(key), log);
88
+ await releaseDraftFileRefs(
89
+ ownedRefs.map((ref) => ref.storageKey),
90
+ (key) => provider.delete(key),
91
+ log,
92
+ );
83
93
  } catch (err) {
84
94
  log?.warn?.(
85
95
  `[form-draft:cleanup] no file provider resolvable for tenant=${row.tenantId} — FileRefs NOT released (row still deleted): ${err instanceof Error ? err.message : String(err)}`,
@@ -132,9 +142,9 @@ async function deleteStaleDraftsBatch(
132
142
  }
133
143
 
134
144
  export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
135
- if (!ctx.db || !ctx.registry) {
145
+ if (!ctx.db) {
136
146
  throw new InternalError({
137
- message: "[form-draft:cleanup] ctx.db + ctx.registry required (JobContext incomplete)",
147
+ message: "[form-draft:cleanup] ctx.db required (JobContext incomplete)",
138
148
  });
139
149
  }
140
150
  const db = ctx.db as DbConnection;
@@ -163,6 +173,7 @@ export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
163
173
 
164
174
  let deleted = 0;
165
175
  while (true) {
176
+ if (ctx.signal?.aborted) break;
166
177
  const batch = await selectStaleDraftsBatch(db, retentionDays, DEFAULT_BATCH_SIZE);
167
178
  if (batch.length === 0) break;
168
179
 
@@ -66,17 +66,17 @@ export const discardDraftWrite = defineWriteHandler({
66
66
  existing.insertedAt,
67
67
  isCreateMode,
68
68
  );
69
- const refsByStorageKey = new Map(ownedRefs.map((ref) => [ref.storageKey, ref]));
69
+ // Fail-closed erase of file_refs rows FIRST (outside the best-effort
70
+ // storage-hygiene loop). releaseDraftFileRefs swallows per-key errors
71
+ // by design — routing forget through it would report discarded:true
72
+ // while leaving the row behind (#1922 / review #2009).
73
+ for (const ref of ownedRefs) {
74
+ const forgetResult = await fileRefExecutor.forget({ id: ref.id }, event.user, ctx.db);
75
+ if (!forgetResult.isSuccess) return forgetResult;
76
+ }
70
77
  await releaseDraftFileRefs(
71
78
  ownedRefs.map((ref) => ref.storageKey),
72
79
  async (key) => {
73
- // Hard-erase via the executor first (fileRef.forgotten — row +
74
- // event, rebuild-safe), then the binary. A raw provider.delete()
75
- // alone left the file_refs row behind forever (kumiko-framework
76
- // review #1922): no event, no projection update, the row just
77
- // pointed at a storage key that no longer existed.
78
- const ref = refsByStorageKey.get(key);
79
- if (ref !== undefined) await fileRefExecutor.forget({ id: ref.id }, event.user, ctx.db);
80
80
  await files.ref(key).delete();
81
81
  },
82
82
  ctx.log,
@@ -569,4 +569,64 @@ describe("watch-supervisor — multi-worker watch coordination (#1719)", () => {
569
569
  await supervisorB.stop();
570
570
  }
571
571
  });
572
+
573
+ test("losing the lease (renew fails) tears down the local watcher and lets the peer take over", async () => {
574
+ const admin = adminFor(4202);
575
+ const accountId = await connectSharedAccount(admin);
576
+ const lock = createDistributedLock(stack.redis.redis, "test-watch-lease-lost:");
577
+ const a = makeWatchIngestCounter();
578
+ const b = makeWatchIngestCounter();
579
+ const supervisorA = createInboundMailSupervisor({
580
+ providerCtx: { registry: stack.registry },
581
+ db,
582
+ dispatchWrite: a.dispatchWrite,
583
+ lock,
584
+ watchLeaseTtlSeconds: 1,
585
+ pollIntervalMs: 60_000,
586
+ });
587
+ const supervisorB = createInboundMailSupervisor({
588
+ providerCtx: { registry: stack.registry },
589
+ db,
590
+ dispatchWrite: b.dispatchWrite,
591
+ lock,
592
+ watchLeaseTtlSeconds: 1,
593
+ pollIntervalMs: 60_000,
594
+ });
595
+
596
+ try {
597
+ await supervisorA.start();
598
+ await waitFor(() => {
599
+ expect(isWatching(accountId)).toBe(true);
600
+ });
601
+
602
+ // B's own start() polls too but must not claim the watch lease — A
603
+ // already holds it. Priming `running` here (rather than only calling
604
+ // pollOnce() later) mirrors the "peer takes over" test above.
605
+ await supervisorB.start();
606
+
607
+ // Simulate the lease expiring behind A's back (Redis TTL lapse, or an
608
+ // operator flushing the key) by deleting it directly. A's next renew
609
+ // heartbeat (`lock.renew`, every ttl/3) then finds a token mismatch,
610
+ // reports failure, and must tear down A's local IDLE connection.
611
+ await stack.redis.redis.del(`test-watch-lease-lost:${accountId}`);
612
+
613
+ await waitFor(() => {
614
+ expect(isWatching(accountId)).toBe(false);
615
+ });
616
+
617
+ await supervisorB.pollOnce();
618
+ await waitFor(() => {
619
+ expect(isWatching(accountId)).toBe(true);
620
+ });
621
+
622
+ await seedInboundMessage(accountId, rawMsg({ providerMessageId: "mw-lost-1" }));
623
+ await waitFor(() => {
624
+ expect(b.watchIngestCount()).toBe(1);
625
+ });
626
+ expect(a.watchIngestCount()).toBe(0);
627
+ } finally {
628
+ await supervisorA.stop();
629
+ await supervisorB.stop();
630
+ }
631
+ });
572
632
  });