@cosmicdrift/kumiko-bundled-features 0.221.0 → 0.223.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 (72) hide show
  1. package/package.json +10 -9
  2. package/src/audit/__tests__/audit-log-screen.test.tsx +5 -5
  3. package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
  4. package/src/audit/__tests__/audit.integration.test.ts +3 -1
  5. package/src/audit/__tests__/constants.test.ts +6 -1
  6. package/src/audit/constants.ts +10 -1
  7. package/src/audit/feature.ts +2 -0
  8. package/src/audit/i18n.ts +2 -0
  9. package/src/audit/web/audit-log-detail-screen.tsx +7 -3
  10. package/src/audit/web/audit-log-screen.tsx +27 -29
  11. package/src/auth-email-password/handlers/invite-create.write.ts +7 -1
  12. package/src/auth-email-password/seeding.ts +3 -1
  13. package/src/auth-mfa/hmac-token-codec.ts +75 -0
  14. package/src/auth-mfa/mfa-challenge-token.ts +10 -65
  15. package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
  16. package/src/cap-counter/stock-cap-guard.ts +1 -1
  17. package/src/channel-email/__tests__/email-channel.test.ts +19 -0
  18. package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
  19. package/src/channel-email/email-channel.ts +14 -7
  20. package/src/channel-email/pii-guard.ts +8 -0
  21. package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
  22. package/src/crypto-shredding/constants.ts +1 -0
  23. package/src/crypto-shredding/feature.ts +6 -1
  24. package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
  25. package/src/crypto-shredding/index.ts +2 -0
  26. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
  27. package/src/file-derivatives/feature.ts +2 -2
  28. package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
  29. package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
  30. package/src/files-provider-s3/s3-provider.ts +45 -0
  31. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
  32. package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
  33. package/src/files-tenant-data/changes.json +1 -0
  34. package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
  35. package/src/files-tenant-data/hooks.ts +93 -0
  36. package/src/files-tenant-data/index.ts +58 -0
  37. package/src/form-draft/db/queries/cleanup.ts +17 -0
  38. package/src/form-draft/handlers/cleanup.job.ts +20 -10
  39. package/src/form-draft/handlers/discard.write.ts +25 -7
  40. package/src/form-draft/handlers/get.query.ts +4 -2
  41. package/src/form-draft/handlers/list.query.ts +4 -1
  42. package/src/form-draft/lookup.ts +12 -5
  43. package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
  44. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +128 -3
  45. package/src/jobs/constants.ts +1 -0
  46. package/src/jobs/handlers/retry.write.ts +26 -6
  47. package/src/jobs/i18n.ts +3 -0
  48. package/src/sessions/session-callbacks.ts +1 -0
  49. package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
  50. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
  51. package/src/tenant/changes.json +1 -1
  52. package/src/tenant/constants.ts +5 -1
  53. package/src/tenant/handlers/remove-member.write.ts +12 -0
  54. package/src/tenant/handlers/update-member-roles.write.ts +10 -37
  55. package/src/tenant/last-tenant-admin.ts +39 -0
  56. package/src/tenant/screens.ts +2 -0
  57. package/src/tenant/seeding.ts +3 -1
  58. package/src/tenant-lifecycle/feature.ts +1 -0
  59. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
  60. package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  61. package/src/tenant-lifecycle/stages.ts +5 -0
  62. package/src/tier-engine/web/tier-admin-screen.tsx +1 -2
  63. package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
  64. package/src/user/changes.json +12 -5
  65. package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
  66. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
  67. package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
  68. package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
  69. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
  70. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +10 -6
  71. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
  72. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
@@ -11,6 +11,7 @@ import {
11
11
  loadAggregate,
12
12
  VersionConflictError,
13
13
  } from "@cosmicdrift/kumiko-framework/event-store";
14
+ import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
14
15
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
15
16
  import { tenantEntity, tenantTable } from "../tenant";
16
17
  import {
@@ -214,6 +215,7 @@ export async function runNextDestructionStage(args: {
214
215
  readonly registry: Registry;
215
216
  readonly tenantId: TenantId;
216
217
  readonly log?: (message: string) => void;
218
+ readonly fileProviderResolver?: FileProviderResolver;
217
219
  }): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
218
220
  const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
219
221
  const { completed, abandoned, attemptsByStage } = replayStageState(events);
@@ -235,6 +237,7 @@ export async function runNextDestructionStage(args: {
235
237
  registry: args.registry,
236
238
  tenantId: args.tenantId,
237
239
  log: args.log,
240
+ fileProviderResolver: args.fileProviderResolver,
238
241
  };
239
242
 
240
243
  let version = lastEventVersion(events);
@@ -300,6 +303,7 @@ export async function runTenantDestructionSweep(args: {
300
303
  readonly registry: Registry;
301
304
  readonly now?: Temporal.Instant;
302
305
  readonly log?: (message: string) => void;
306
+ readonly fileProviderResolver?: FileProviderResolver;
303
307
  }): Promise<{ readonly triggered: number; readonly advanced: number }> {
304
308
  const T = getTemporal();
305
309
  const now = args.now ?? T.Now.instant();
@@ -348,6 +352,7 @@ export async function runTenantDestructionSweep(args: {
348
352
  registry: args.registry,
349
353
  tenantId: row.id as TenantId,
350
354
  log: args.log,
355
+ fileProviderResolver: args.fileProviderResolver,
351
356
  });
352
357
  if (!result.error && !result.halted) advanced++;
353
358
  } catch (err) {
@@ -15,6 +15,7 @@ import {
15
15
  type Registry,
16
16
  type TenantId,
17
17
  } from "@cosmicdrift/kumiko-framework/engine";
18
+ import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
18
19
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
19
20
  import {
20
21
  tenantEntity,
@@ -30,6 +31,10 @@ export type DestructionStageCtx = {
30
31
  readonly registry: Registry;
31
32
  readonly tenantId: TenantId;
32
33
  readonly log?: (message: string) => void;
34
+ // Only threaded through for the "files" stage (EXT_STORAGE_PROVIDER
35
+ // "destroyTenant" hooks) — undefined when no file-provider is wired,
36
+ // which those hooks must treat as "nothing to clean up", not an error.
37
+ readonly fileProviderResolver?: FileProviderResolver;
33
38
  };
34
39
 
35
40
  export type DestructionStage = {
@@ -60,8 +60,7 @@ export function TierAdminScreen(): ReactNode {
60
60
  // shown (UI-level mis-grant guard). tenantId is a pure trigger here (the
61
61
  // body doesn't read it) — Biome's "extra dep" autofix would empty the deps
62
62
  // and limit the reset to mount.
63
- // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
64
- // biome-ignore lint/correctness/useExhaustiveDependencies: tenantId is the intended reset trigger, do not remove.
63
+ // biome-ignore lint/correctness/useExhaustiveDependencies: tenantId is the intended reset trigger, do not remove. kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
65
64
  useEffect(() => {
66
65
  setTier("");
67
66
  setStatus({ kind: "idle" });
@@ -106,15 +106,16 @@ describe("fw#2134 — email unique constraint on the blind-index column", () =>
106
106
  expect(survivors[0]?.n).toBe(1);
107
107
  });
108
108
 
109
- test("a soft-deleted user still holds its email: constraint agrees with the pre-existing pre-flight behavior", async () => {
109
+ test("a soft-deleted user's email becomes available for reuse (fw#2464)", async () => {
110
110
  // Soft-delete only flips isDeleted/deletedAt (apply-entity-event.ts) —
111
- // it never touches email/email_bidx, so the row still occupies the
112
- // unique slot. This isn't new behavior from fw#2134: the handler's
113
- // pre-flight fetchOne is a raw, unfiltered query (see create.write.ts)
114
- // and already saw soft-deleted rows before this fix. This test pins
115
- // that the new DB constraint doesn't diverge from that — only the
116
- // GDPR "forgotten" hard-delete (see forget-cleanup) actually frees the
117
- // email slot.
111
+ // it never touches email/email_bidx, so the old row keeps carrying the
112
+ // email's bidx hash. Before fw#2464 the partial bidx unique index
113
+ // covered every row regardless of isDeleted, so that hash permanently
114
+ // blocked reuse of the email by a new or restored user. The index's
115
+ // WHERE clause now also excludes soft-deleted rows (isDeleted = false)
116
+ // whenever the entity has softDelete enabled, so a fresh create with
117
+ // the same email succeeds once the original row is soft-deleted — only
118
+ // a live (non-deleted) row still occupying the email blocks the slot.
118
119
  const email = "soft-deleted@example.com";
119
120
  const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
120
121
  const systemUser = createSystemUser(SYSTEM_TENANT_ID);
@@ -130,11 +131,22 @@ describe("fw#2134 — email unique constraint on the blind-index column", () =>
130
131
  if (!deleted.isSuccess) throw new Error("expected soft-delete to succeed");
131
132
 
132
133
  const reCreated = await executor.create({ email, displayName: "Squatter" }, systemUser, tdb);
133
- if (reCreated.isSuccess) {
134
- throw new Error("expected re-create with the same email to be rejected while soft-deleted");
134
+ if (!reCreated.isSuccess) {
135
+ throw new Error(
136
+ "expected re-create with the same email to succeed once the original row is soft-deleted",
137
+ );
135
138
  }
136
- expect(reCreated.error.code).toBe("unique_violation");
137
- const details = reCreated.error.details as { constraintName?: string };
138
- expect(details.constraintName).toBe("read_users_email_unique_bidx");
139
+ expect(reCreated.data.id).not.toBe(created.data.id);
140
+
141
+ // DB-proof: both rows survive — the soft-deleted original still holds
142
+ // the bidx hash, and the new live row now holds it too, since the
143
+ // partial index only enforces uniqueness among non-deleted rows.
144
+ const rows = (await asRawClient(testDb.db).unsafe(
145
+ `SELECT "id", "is_deleted" FROM "read_users" WHERE "email_bidx" = $1 ORDER BY "is_deleted"`,
146
+ [computeBlindIndex(TEST_KEY, email)],
147
+ )) as ReadonlyArray<{ id: string; is_deleted: boolean }>;
148
+ expect(rows).toHaveLength(2);
149
+ expect(rows[0]).toEqual({ id: String(reCreated.data.id), is_deleted: false });
150
+ expect(rows[1]).toEqual({ id: String(created.data.id), is_deleted: true });
139
151
  });
140
152
  });
@@ -1,30 +1,37 @@
1
1
  [
2
+ {
3
+ "version": "0.218.1",
4
+ "type": "improvement",
5
+ "title": "user.email searchable \u2014 reindex required after upgrade",
6
+ "detail": "email gained searchable for roster lookup. buildSearchDocument only runs on write; existing rows need reindexEntity(\"user\") (or the reindex job) plus Meili configure for the new attribute.",
7
+ "migration": "After deploying, run reindexEntity(\"user\") (or the existing reindex job) so pre-upgrade user rows appear in roster search."
8
+ },
2
9
  {
3
10
  "version": "0.218.0",
4
11
  "type": "fix",
5
12
  "title": "user-list: system-scope search; membership roles on tenants; emailVerified sort",
6
- "detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant → Meili miss/500). Tenants column shows membership roles (Offlot Demo (TenantAdmin)). emailVerified sortable; email also searchable for roster lookup. Global roles stay SystemAdmin-only — TenantAdmin/Admin live on membership (Team → Members)."
13
+ "detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant \u2192 Meili miss/500). Tenants column shows membership roles (Offlot Demo (TenantAdmin)). emailVerified sortable; email also searchable for roster lookup. Global roles stay SystemAdmin-only \u2014 TenantAdmin/Admin live on membership (Team \u2192 Members)."
7
14
  },
8
15
  {
9
16
  "version": "0.217.0",
10
17
  "type": "improvement",
11
18
  "title": "user-list: roles multiSelect + tenants column; roles editable as SystemAdmin (fw#2384 follow-up)",
12
- "detail": "user.roles is now createMultiSelectField (jsonb string[], options=[SystemAdmin]) so entityEdit shows a real multi-select instead of a raw JSON text box — SystemAdmins can promote another SystemAdmin from the UI. user-list columns gain roles + tenants; tenants is a derived field filled by the list handler from memberships (platform roster was unusable without tenant context). switch-tenant + auth-client parseRoles accept jsonb arrays (string-only deserialize dropped SystemAdmin on tenant switch). Soft-deleted users stay hidden by default.",
13
- "migration": "Run `kumiko-schema generate` and apply — migrates read_users.roles from text to jsonb USING roles::jsonb. Existing JSON array strings (`[]`, `[\"SystemAdmin\"]`) convert cleanly. Consumers that wrote roles as a JSON-encoded string via user:create/update should pass string[] instead (parseRoles still accepts both on read)."
19
+ "detail": "user.roles is now createMultiSelectField (jsonb string[], options=[SystemAdmin]) so entityEdit shows a real multi-select instead of a raw JSON text box \u2014 SystemAdmins can promote another SystemAdmin from the UI. user-list columns gain roles + tenants; tenants is a derived field filled by the list handler from memberships (platform roster was unusable without tenant context). switch-tenant + auth-client parseRoles accept jsonb arrays (string-only deserialize dropped SystemAdmin on tenant switch). Soft-deleted users stay hidden by default.",
20
+ "migration": "Run `kumiko-schema generate` and apply \u2014 migrates read_users.roles from text to jsonb USING roles::jsonb. Existing JSON array strings (`[]`, `[\"SystemAdmin\"]`) convert cleanly. Consumers that wrote roles as a JSON-encoded string via user:create/update should pass string[] instead (parseRoles still accepts both on read)."
14
21
  },
15
22
  {
16
23
  "version": "0.167.1",
17
24
  "type": "breaking",
18
25
  "title": "user.locale no longer defaults to \"de\" (fw#1637).",
19
26
  "detail": "The entity-level default contradicted tenant-settings' own \"en\" default and silently overrode any app or tenant locale configuration for every new user. locale now stays unset until the client or a resolution chain provides one. Consumers that already fall back with `user.locale ?? \"en\"` are unaffected in shape but now see null instead of \"de\".",
20
- "migration": "Run `kumiko-schema generate` and apply — the migration emits `ALTER TABLE read_users ALTER COLUMN locale DROP DEFAULT`. Note that DROP DEFAULT only affects future inserts: existing rows keep the \"de\" the old default wrote, which no user ever chose. Your app therefore splits into pre-bump users on \"de\" and post-bump users on null, and each group resolves to a different language. Decide deliberately: either keep the old values (and state that pre-bump users stay German), or run `UPDATE read_users SET locale = NULL WHERE locale = 'de'` once so every user follows the same chain — the latter only if no UI ever let users edit the field, otherwise it erases real choices. If your app relied on the implicit German default, set an explicit fallback instead (fw#1653)."
27
+ "migration": "Run `kumiko-schema generate` and apply \u2014 the migration emits `ALTER TABLE read_users ALTER COLUMN locale DROP DEFAULT`. Note that DROP DEFAULT only affects future inserts: existing rows keep the \"de\" the old default wrote, which no user ever chose. Your app therefore splits into pre-bump users on \"de\" and post-bump users on null, and each group resolves to a different language. Decide deliberately: either keep the old values (and state that pre-bump users stay German), or run `UPDATE read_users SET locale = NULL WHERE locale = 'de'` once so every user follows the same chain \u2014 the latter only if no UI ever let users edit the field, otherwise it erases real choices. If your app relied on the implicit German default, set an explicit fallback instead (fw#1653)."
21
28
  },
22
29
  {
23
30
  "version": "0.167.1",
24
31
  "type": "improvement",
25
32
  "title": "user.timezone field, wired into ctx.tz (fw#1636).",
26
33
  "detail": "The user entity gains a `timezone` field (createTzField, no default). buildHandlerContext passes it to ctx.tz.user; the fallback chain stays tenant.timezone, then \"UTC\". Before this, ctx.tz.tenant and ctx.tz.user both hardcoded \"UTC\" while the plumbing already accepted per-request values.",
27
- "migration": "Run `kumiko-schema generate` and apply — the migration adds `read_users.timezone`. No backfill: an unset timezone falls through to the tenant's, exactly as before the field existed."
34
+ "migration": "Run `kumiko-schema generate` and apply \u2014 the migration adds `read_users.timezone`. No backfill: an unset timezone falls through to the tenant's, exactly as before the field existed."
28
35
  },
29
36
  {
30
37
  "version": "0.165.3",
@@ -33,7 +33,7 @@ export const cancelDeletionWrite = defineWriteHandler({
33
33
  if (!row) {
34
34
  return writeFailure(
35
35
  new UnprocessableError("user_not_found", {
36
- details: { reason: "user_not_found", userId: event.user.id },
36
+ details: { userId: event.user.id },
37
37
  }),
38
38
  );
39
39
  }
@@ -41,20 +41,13 @@ export const cancelDeletionWrite = defineWriteHandler({
41
41
  if (row.status !== USER_STATUS.DeletionRequested) {
42
42
  return writeFailure(
43
43
  new UnprocessableError("no_pending_deletion", {
44
- details: {
45
- reason: "no_pending_deletion",
46
- currentStatus: row.status,
47
- },
44
+ details: { currentStatus: row.status },
48
45
  }),
49
46
  );
50
47
  }
51
48
 
52
49
  if (!isWithinGracePeriod(row.gracePeriodEnd)) {
53
- return writeFailure(
54
- new UnprocessableError("grace_period_expired", {
55
- details: { reason: "grace_period_expired" },
56
- }),
57
- );
50
+ return writeFailure(new UnprocessableError("grace_period_expired"));
58
51
  }
59
52
 
60
53
  await updateUserLifecycle(ctx.db.raw, event.user.id, {
@@ -13,9 +13,7 @@ export type ConfirmDeletionByTokenOptions = {
13
13
  // Generischer 422 für jeden Token-Fehlerpfad (malformed / bad_signature /
14
14
  // expired / kein Secret) — kein Signal ob ein Token zu einem User gehört.
15
15
  function invalidToken(): UnprocessableError {
16
- return new UnprocessableError("invalid_or_expired_token", {
17
- details: { reason: "invalid_or_expired_token" },
18
- });
16
+ return new UnprocessableError("invalid_or_expired_token");
19
17
  }
20
18
 
21
19
  // userId stammt aus dem noch-unverifizierten Token (Angreifer-Eingabe). Ein
@@ -81,11 +79,7 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
81
79
  // user_not_in_active_state) und würde einem Token-Inhaber das Proben des
82
80
  // Account-Status erlauben (#354/2). Der authentifizierte request-deletion-
83
81
  // Pfad zeigt dem User legitim seinen eigenen Status.
84
- return writeFailure(
85
- new UnprocessableError("cannot_process_deletion", {
86
- details: { reason: "cannot_process_deletion" },
87
- }),
88
- );
82
+ return writeFailure(new UnprocessableError("cannot_process_deletion"));
89
83
  }
90
84
 
91
85
  return {
@@ -41,7 +41,7 @@ export async function startDeletionGracePeriod(
41
41
  return {
42
42
  ok: false,
43
43
  error: new UnprocessableError("user_not_found", {
44
- details: { reason: "user_not_found", userId },
44
+ details: { userId },
45
45
  }),
46
46
  };
47
47
  }
@@ -49,7 +49,7 @@ export async function startDeletionGracePeriod(
49
49
  return {
50
50
  ok: false,
51
51
  error: new UnprocessableError("user_not_in_active_state", {
52
- details: { reason: "user_not_in_active_state", currentStatus: userRow["status"] },
52
+ details: { currentStatus: userRow["status"] },
53
53
  }),
54
54
  };
55
55
  }
@@ -46,7 +46,7 @@ export const liftRestrictionWrite = defineWriteHandler({
46
46
  if (!userRow) {
47
47
  return writeFailure(
48
48
  new UnprocessableError("user_not_found", {
49
- details: { reason: "user_not_found", userId: targetUserId },
49
+ details: { userId: targetUserId },
50
50
  }),
51
51
  );
52
52
  }
@@ -55,7 +55,7 @@ export const liftRestrictionWrite = defineWriteHandler({
55
55
  if (currentStatus !== USER_STATUS.Restricted) {
56
56
  return writeFailure(
57
57
  new UnprocessableError("not_restricted", {
58
- details: { reason: "not_restricted", currentStatus },
58
+ details: { currentStatus },
59
59
  }),
60
60
  );
61
61
  }
@@ -69,7 +69,7 @@ export const restrictAccountWrite = defineWriteHandler({
69
69
  if (!userRow) {
70
70
  return writeFailure(
71
71
  new UnprocessableError("user_not_found", {
72
- details: { reason: "user_not_found", userId: targetUserId },
72
+ details: { userId: targetUserId },
73
73
  }),
74
74
  );
75
75
  }
@@ -78,14 +78,14 @@ export const restrictAccountWrite = defineWriteHandler({
78
78
  if (currentStatus === USER_STATUS.Restricted) {
79
79
  return writeFailure(
80
80
  new UnprocessableError("already_restricted", {
81
- details: { reason: "already_restricted", currentStatus },
81
+ details: { currentStatus },
82
82
  }),
83
83
  );
84
84
  }
85
85
  if (currentStatus !== USER_STATUS.Active) {
86
86
  return writeFailure(
87
87
  new UnprocessableError("user_not_in_active_state", {
88
- details: { reason: "user_not_in_active_state", currentStatus },
88
+ details: { currentStatus },
89
89
  }),
90
90
  );
91
91
  }
@@ -132,15 +132,19 @@ async function waitForMount(view: ReturnType<typeof render>): Promise<void> {
132
132
  });
133
133
  }
134
134
 
135
- /** Export status is a second async query — mount alone can race before Done UI. */
136
- async function waitForDownloadReady(view: ReturnType<typeof render>): Promise<void> {
135
+ /** Export status is a second async query — mount alone races status-dependent UI. */
136
+ async function waitForTestId(view: ReturnType<typeof render>, testId: string): Promise<void> {
137
137
  await waitFor(() => {
138
- if (view.queryByTestId("privacy-export-download") === null) {
139
- throw new Error("download button not ready");
138
+ if (view.queryByTestId(testId) === null) {
139
+ throw new Error(`${testId} not ready`);
140
140
  }
141
141
  });
142
142
  }
143
143
 
144
+ async function waitForDownloadReady(view: ReturnType<typeof render>): Promise<void> {
145
+ await waitForTestId(view, "privacy-export-download");
146
+ }
147
+
144
148
  // CI runs this file in its own `bun test` process (own ci.yml step), NOT in the
145
149
  // shared `kumiko check` run — see bunfig.ci.toml pathIgnorePatterns. In the shared
146
150
  // single-process happy-dom, the global `afterEach` from `test-setup/dom.preload.ts`
@@ -180,7 +184,7 @@ describe("PrivacyCenterScreen", () => {
180
184
  me: activeMe,
181
185
  exportStatus: { hasJob: true, job: { id: "job-9", status: EXPORT_JOB_STATUS.Failed } },
182
186
  });
183
- await waitForMount(view);
187
+ await waitForTestId(view, "privacy-export-failed");
184
188
  expect(view.getByTestId("privacy-export-failed")).toBeTruthy();
185
189
  expect(view.getByTestId("privacy-export-request")).toBeTruthy();
186
190
  });
@@ -190,7 +194,7 @@ describe("PrivacyCenterScreen", () => {
190
194
  me: activeMe,
191
195
  exportStatus: { hasJob: true, job: { id: "job-1", status: EXPORT_JOB_STATUS.Pending } },
192
196
  });
193
- await waitForMount(view);
197
+ await waitForTestId(view, "privacy-export-pending");
194
198
  expect(view.getByTestId("privacy-export-pending")).toBeTruthy();
195
199
  expect(view.queryByTestId("privacy-export-request")).toBeNull();
196
200
  });
@@ -13,7 +13,12 @@
13
13
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
14
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
15
15
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
16
- import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
16
+ import { variantSuffix } from "@cosmicdrift/kumiko-framework/derivatives";
17
+ import {
18
+ createInMemoryFileProvider,
19
+ deriveKey,
20
+ fileRefsTable,
21
+ } from "@cosmicdrift/kumiko-framework/files";
17
22
  import {
18
23
  setupTestStack,
19
24
  type TestStack,
@@ -357,3 +362,111 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
357
362
  expect(afterSecond).toHaveLength(0);
358
363
  });
359
364
  });
365
+
366
+ describe("S2.H2 :: fileRefDeleteHook — GDPR derivatives survive forget (issue #2461)", () => {
367
+ async function seedFileRefWithKey(
368
+ id: string,
369
+ tenantId: string,
370
+ insertedById: string,
371
+ storageKey: string,
372
+ ): Promise<void> {
373
+ await asRawClient(stack.db).unsafe(
374
+ `
375
+ INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
376
+ VALUES ($1, $2, $3, 'photo.jpg', 'image/jpeg', 2048, $4)
377
+ ON CONFLICT (id) DO NOTHING
378
+ `,
379
+ [id, tenantId, storageKey, insertedById],
380
+ );
381
+ }
382
+
383
+ test('strategy="delete" removes the original AND its derivatives, but spares an unrelated same-prefix sibling', async () => {
384
+ const userId = "user-derivatives-files";
385
+ const originalKey = `derivatives-test/${uuid(501)}.jpg`;
386
+ const derivedKey = deriveKey(originalKey, variantSuffix("thumb", { maxEdge: 256 }));
387
+ // Same base as originalKey (only the extension differs) — a plain
388
+ // prefix-delete would sweep this up too; the grammar-anchored filter
389
+ // must not.
390
+ const siblingKey = `derivatives-test/${uuid(501)}.png`;
391
+
392
+ const provider = createInMemoryFileProvider();
393
+ await provider.write(originalKey, new Uint8Array([1]));
394
+ await provider.write(derivedKey, new Uint8Array([2]));
395
+ await provider.write(siblingKey, new Uint8Array([3]));
396
+
397
+ await seedFileRefWithKey(uuid(501), TENANT_A, userId, originalKey);
398
+
399
+ await fileRefDeleteHook(
400
+ {
401
+ db: stack.db,
402
+ registry: stack.registry,
403
+ tenantId: TENANT_A,
404
+ userId,
405
+ buildStorageProvider: async () => provider,
406
+ },
407
+ "delete",
408
+ );
409
+
410
+ expect(await provider.exists(originalKey)).toBe(false);
411
+ expect(await provider.exists(derivedKey)).toBe(false);
412
+ expect(await provider.exists(siblingKey)).toBe(true);
413
+
414
+ const remaining = await fetchFileRefs(TENANT_A, userId);
415
+ expect(remaining).toHaveLength(0);
416
+ });
417
+
418
+ test('strategy="anonymize" leaves original AND derivative binaries untouched', async () => {
419
+ const userId = "user-anon-derivatives-files";
420
+ const originalKey = `derivatives-test/${uuid(502)}.jpg`;
421
+ const derivedKey = deriveKey(originalKey, variantSuffix("card", { maxEdge: 1024 }));
422
+
423
+ const provider = createInMemoryFileProvider();
424
+ await provider.write(originalKey, new Uint8Array([1]));
425
+ await provider.write(derivedKey, new Uint8Array([2]));
426
+
427
+ await seedFileRefWithKey(uuid(502), TENANT_A, userId, originalKey);
428
+
429
+ await fileRefDeleteHook(
430
+ {
431
+ db: stack.db,
432
+ registry: stack.registry,
433
+ tenantId: TENANT_A,
434
+ userId,
435
+ buildStorageProvider: async () => provider,
436
+ },
437
+ "anonymize",
438
+ );
439
+
440
+ expect(await provider.exists(originalKey)).toBe(true);
441
+ expect(await provider.exists(derivedKey)).toBe(true);
442
+ });
443
+
444
+ test("a provider.list() failure (e.g. missing s3:ListBucket) throws a wrapped, actionable error instead of a raw provider error", async () => {
445
+ const userId = "user-list-fails-derivatives";
446
+ const originalKey = `derivatives-test/${uuid(503)}.jpg`;
447
+
448
+ const base = createInMemoryFileProvider();
449
+ await base.write(originalKey, new Uint8Array([1]));
450
+ const provider = {
451
+ ...base,
452
+ list: async () => {
453
+ throw new Error("AccessDenied");
454
+ },
455
+ };
456
+
457
+ await seedFileRefWithKey(uuid(503), TENANT_A, userId, originalKey);
458
+
459
+ await expect(
460
+ fileRefDeleteHook(
461
+ {
462
+ db: stack.db,
463
+ registry: stack.registry,
464
+ tenantId: TENANT_A,
465
+ userId,
466
+ buildStorageProvider: async () => provider,
467
+ },
468
+ "delete",
469
+ ),
470
+ ).rejects.toThrow(/list permission/);
471
+ });
472
+ });
@@ -4,6 +4,7 @@ import {
4
4
  createTenantDb,
5
5
  type TenantDb,
6
6
  } from "@cosmicdrift/kumiko-framework/db";
7
+ import { derivativeListPrefix, isDerivativeKeyOf } from "@cosmicdrift/kumiko-framework/derivatives";
7
8
  import {
8
9
  createSystemUser,
9
10
  type SessionUser,
@@ -12,7 +13,11 @@ import {
12
13
  type UserDataHookCtx,
13
14
  type UserDataStorageProvider,
14
15
  } from "@cosmicdrift/kumiko-framework/engine";
15
- import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
16
+ import {
17
+ assertSafeStorageKey,
18
+ fileRefEntity,
19
+ fileRefsTable,
20
+ } from "@cosmicdrift/kumiko-framework/files";
16
21
 
17
22
  // Forget writes go through the executor (events), not deleteMany/updateMany:
18
23
  // a projection rebuild replays the events, so the erasure survives. Eventless
@@ -123,9 +128,52 @@ async function resolveProvider(ctx: UserDataHookCtx): Promise<UserDataStoragePro
123
128
  }
124
129
  }
125
130
 
126
- // Delete every row's binary via the provider. Returns the keys whose delete
127
- // threw — the caller fails closed on a non-empty list so the sub-tx rolls back
128
- // and the next forget run retries (delete is idempotent → converges).
131
+ // Derivatives (thumbnails/resized variants — see derivatives-context.ts) are
132
+ // never tracked anywhere but the storage layer: they're rendered under a
133
+ // deterministic key (deriveKey + variantSuffix) computed from the original's
134
+ // key + a render spec, on demand, and nothing writes that key back onto the
135
+ // FileRef row. List the original's key-prefix and keep only candidates whose
136
+ // suffix matches the derivative grammar, anchored to the original's own
137
+ // basename AND extension — a same-directory sibling original (a different
138
+ // file, possibly another user's) shares the same list prefix but fails the
139
+ // extension/suffix check and is never touched.
140
+ async function listDerivativeKeys(
141
+ originalKey: string,
142
+ provider: UserDataStorageProvider,
143
+ ): Promise<readonly string[]> {
144
+ let candidates: readonly string[];
145
+ try {
146
+ candidates = await provider.list(derivativeListPrefix(originalKey));
147
+ } catch (err) {
148
+ // Wrap rather than let a raw provider error (e.g. S3 AccessDenied) surface
149
+ // unexplained — list() is a NEW requirement this hook added on top of
150
+ // delete(); a bucket policy granting only s3:DeleteObject now fails forget
151
+ // every run instead of just leaving derivatives behind. Name the likely
152
+ // cause so an operator doesn't have to guess.
153
+ throw new Error(
154
+ `[user-data-rights-defaults:fileRef] provider.list() failed while looking up derivatives of key=${originalKey} — forget/erasure requires list permission on the storage bucket (e.g. s3:ListBucket), not just delete. Original error: ${err instanceof Error ? err.message : String(err)}`,
155
+ );
156
+ }
157
+ const derivativeKeys: string[] = [];
158
+ for (const candidate of candidates) {
159
+ if (!isDerivativeKeyOf(originalKey, candidate)) continue;
160
+ try {
161
+ assertSafeStorageKey(candidate);
162
+ } catch {
163
+ // ponytail: a listing hit that fails the storage-key grammar can't be
164
+ // a real derivative (every derivative is written via a validated
165
+ // write()) — skip rather than pass a malformed key to delete().
166
+ continue;
167
+ }
168
+ derivativeKeys.push(candidate);
169
+ }
170
+ return derivativeKeys;
171
+ }
172
+
173
+ // Delete every row's binary (original + any derivatives) via the provider.
174
+ // Returns the keys whose delete threw — the caller fails closed on a
175
+ // non-empty list so the sub-tx rolls back and the next forget run retries
176
+ // (delete is idempotent → converges).
129
177
  async function deleteBinaries(
130
178
  rows: readonly Record<string, unknown>[],
131
179
  provider: UserDataStorageProvider,
@@ -134,14 +182,17 @@ async function deleteBinaries(
134
182
  for (const row of rows) {
135
183
  const key = row["storageKey"]; // @cast-boundary db-row
136
184
  if (typeof key !== "string" || key.length === 0) continue;
137
- try {
138
- await provider.delete(key);
139
- } catch (err) {
140
- // biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
141
- console.warn(
142
- `[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
143
- );
144
- failedKeys.push(key);
185
+ const keysToDelete = [key, ...(await listDerivativeKeys(key, provider))];
186
+ for (const k of keysToDelete) {
187
+ try {
188
+ await provider.delete(k);
189
+ } catch (err) {
190
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
191
+ console.warn(
192
+ `[user-data-rights-defaults:fileRef] storage delete failed key=${k} err=${err instanceof Error ? err.message : String(err)}`,
193
+ );
194
+ failedKeys.push(k);
195
+ }
145
196
  }
146
197
  }
147
198
  return failedKeys;