@cosmicdrift/kumiko-bundled-features 0.165.0 → 0.165.2

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 (159) hide show
  1. package/package.json +10 -6
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +205 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +52 -21
  5. package/src/audit/handlers/list.query.ts +1 -0
  6. package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
  8. package/src/auth-email-password/__tests__/login-gates.test.ts +39 -0
  9. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  11. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  12. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  13. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  14. package/src/auth-email-password/handlers/login.write.ts +196 -155
  15. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  16. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  17. package/src/auth-email-password/i18n.ts +2 -0
  18. package/src/auth-email-password/seeding.ts +41 -23
  19. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  20. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  21. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  22. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  23. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  24. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  25. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  26. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  28. package/src/auth-email-password/web/auth-client.ts +32 -10
  29. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  30. package/src/auth-email-password/web/client-plugin.ts +15 -2
  31. package/src/auth-email-password/web/index.ts +5 -1
  32. package/src/auth-email-password/web/login-screen.tsx +7 -3
  33. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  34. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  35. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  36. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  37. package/src/auth-foundation/boot-checks.ts +74 -69
  38. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  39. package/src/auth-foundation/types.ts +3 -3
  40. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  41. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  42. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +157 -4
  43. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  44. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  45. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  46. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  47. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  48. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  49. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  50. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  51. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  52. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  53. package/src/auth-mfa/web/i18n.ts +4 -0
  54. package/src/auth-mfa/web/mfa-client.ts +13 -2
  55. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  56. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  57. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  58. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  59. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  60. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  61. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  62. package/src/config/db/queries/resolver.ts +3 -3
  63. package/src/config/handlers/reencrypt.job.ts +36 -24
  64. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  65. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  66. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  67. package/src/document-ingest-foundation/entity.ts +10 -3
  68. package/src/document-ingest-foundation/feature.ts +26 -8
  69. package/src/document-ingest-foundation/index.ts +1 -0
  70. package/src/document-ingest-foundation/pages.ts +34 -0
  71. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  72. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  73. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  74. package/src/files-provider-s3/s3-provider.ts +12 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  76. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  77. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  78. package/src/inbound-mail-foundation/entities.ts +3 -3
  79. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  80. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  81. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  82. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  83. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  84. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  85. package/src/jobs/feature.ts +6 -5
  86. package/src/jobs/job-instant.ts +9 -0
  87. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  88. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  89. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  90. package/src/page-render/__tests__/markdown.test.ts +29 -0
  91. package/src/page-render/markdown.ts +1 -9
  92. package/src/personal-access-tokens/feature.ts +3 -3
  93. package/src/personal-access-tokens/schema/api-token.ts +3 -3
  94. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  95. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  96. package/src/sessions/__tests__/sessions.integration.test.ts +88 -1
  97. package/src/sessions/feature.ts +21 -6
  98. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  99. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  100. package/src/sessions/handlers/revoke.write.ts +13 -0
  101. package/src/sessions/index.ts +7 -0
  102. package/src/sessions/schema/user-session.ts +6 -7
  103. package/src/sessions/session-callbacks.ts +58 -5
  104. package/src/sessions/session-revoked-event.ts +29 -0
  105. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  106. package/src/shared/chunked-entity-migration.ts +2 -2
  107. package/src/shared/grace-period.test.ts +26 -0
  108. package/src/shared/grace-period.ts +20 -0
  109. package/src/shared/index.ts +2 -0
  110. package/src/shared/map-with-concurrency.test.ts +104 -0
  111. package/src/shared/map-with-concurrency.ts +38 -0
  112. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  113. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  114. package/src/subscription-mollie/verify-webhook.ts +1 -0
  115. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  116. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  117. package/src/subscription-stripe/verify-webhook.ts +3 -2
  118. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  119. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  120. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  121. package/src/tenant/handlers/invitations.query.ts +13 -15
  122. package/src/tenant/handlers/members.query.ts +12 -12
  123. package/src/tenant/seeding.ts +110 -16
  124. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  125. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -6
  126. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +94 -0
  127. package/src/tenant-settings/config.ts +44 -0
  128. package/src/tenant-settings/constants.ts +16 -0
  129. package/src/tenant-settings/feature.ts +49 -0
  130. package/src/tenant-settings/index.ts +8 -0
  131. package/src/tenant-settings/tenant-defaults.ts +72 -0
  132. package/src/text-content/__tests__/seed-legal-content.integration.test.ts +33 -6
  133. package/src/text-content/handlers/set.write.ts +2 -2
  134. package/src/text-content/seeding.ts +31 -29
  135. package/src/user/handlers/find-for-auth.query.ts +1 -1
  136. package/src/user/seeding.ts +8 -0
  137. package/src/user-data-rights/COMPLIANCE.md +1 -1
  138. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  139. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  140. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  141. package/src/user-data-rights/__tests__/download.integration.test.ts +25 -41
  142. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  143. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  144. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +140 -0
  145. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  146. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  147. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  148. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  149. package/src/user-data-rights/feature.ts +45 -45
  150. package/src/user-data-rights/handlers/cancel-deletion.write.ts +6 -11
  151. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  152. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  153. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  154. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  155. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  156. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  157. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  158. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  159. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -2,8 +2,8 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
5
- import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
6
5
  import { z } from "zod";
6
+ import { isWithinGracePeriod } from "../../shared";
7
7
  import { type TenantLifecycleStatus, tenantEntity, tenantTable } from "../../tenant";
8
8
  import { DESTRUCTION_CANCELLED_EVENT_QN } from "../constants";
9
9
  import { invalidateTenantLifecycleGate } from "../lifecycle-gate";
@@ -33,11 +33,7 @@ export const cancelDestructionWrite = defineWriteHandler({
33
33
  );
34
34
  }
35
35
 
36
- const gracePeriodEnd = row.gracePeriodEnd;
37
- const inGrace =
38
- gracePeriodEnd != null &&
39
- Temporal.Instant.compare(gracePeriodEnd, getTemporal().Now.instant()) > 0;
40
- if (!inGrace) {
36
+ if (!isWithinGracePeriod(row.gracePeriodEnd)) {
41
37
  return writeFailure(
42
38
  new UnprocessableError("grace_period_expired", {
43
39
  details: { reason: "grace_period_expired" },
@@ -0,0 +1,94 @@
1
+ // Package-level cases the recipe does NOT cover: unknown field at define time,
2
+ // and missing tenant-settings mount fails loud when currency is omitted.
3
+
4
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
+ import {
6
+ configValuesTable,
7
+ createConfigAccessorFactory,
8
+ createConfigFeature,
9
+ createConfigResolver,
10
+ } from "@cosmicdrift/kumiko-bundled-features/config";
11
+ import {
12
+ createEntity,
13
+ createMoneyField,
14
+ createSelectField,
15
+ createTextField,
16
+ defineFeature,
17
+ type SessionUser,
18
+ } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
20
+ import {
21
+ pushEntityProjectionTables,
22
+ setupTestStack,
23
+ type TestStack,
24
+ testTenantId,
25
+ unsafeCreateEntityTable,
26
+ unsafePushTables,
27
+ } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { defineCreateWithTenantDefaults } from "../tenant-defaults";
29
+
30
+ const invoiceEntity = createEntity({
31
+ table: "read_invoices",
32
+ fields: {
33
+ title: createTextField({ required: true }),
34
+ amount: createMoneyField({ required: true }),
35
+ language: createSelectField({ options: ["en", "de"] as const }),
36
+ },
37
+ });
38
+
39
+ const ACCESS = { roles: ["Admin"] } as const;
40
+
41
+ test("defineCreateWithTenantDefaults throws on an unknown currency field", () => {
42
+ expect(() =>
43
+ defineCreateWithTenantDefaults("invoice", invoiceEntity, {
44
+ access: ACCESS,
45
+ currencyFields: ["notARealField"],
46
+ }),
47
+ ).toThrow(/unknown field "notARealField"/);
48
+ });
49
+
50
+ describe("without tenant-settings mount", () => {
51
+ const invoiceFeature = defineFeature("invoice", (r) => {
52
+ r.entity("invoice", invoiceEntity);
53
+ r.writeHandler(
54
+ defineCreateWithTenantDefaults("invoice", invoiceEntity, {
55
+ access: ACCESS,
56
+ currencyFields: ["amount"],
57
+ localeField: "language",
58
+ }),
59
+ );
60
+ });
61
+
62
+ const tenantId = testTenantId(1);
63
+ const admin: SessionUser = { id: "admin-1", tenantId, roles: ["Admin"] };
64
+ let stack: TestStack;
65
+
66
+ beforeAll(async () => {
67
+ const resolver = createConfigResolver();
68
+ // config feature only — no createTenantSettingsFeature()
69
+ stack = await setupTestStack({
70
+ features: [createConfigFeature(), invoiceFeature],
71
+ extraContext: ({ registry }) => ({
72
+ configResolver: resolver,
73
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
74
+ }),
75
+ });
76
+ await unsafePushTables(stack.db, { configValuesTable });
77
+ await unsafeCreateEntityTable(stack.db, invoiceEntity);
78
+ await createEventsTable(stack.db);
79
+ await pushEntityProjectionTables(stack, stack.registry);
80
+ });
81
+
82
+ afterAll(async () => {
83
+ await stack.cleanup();
84
+ });
85
+
86
+ test("omitting money.currency fails loud (no silent feature-default fill)", async () => {
87
+ const err = await stack.http.writeErr(
88
+ "invoice:write:invoice:create",
89
+ { title: "Invoice", amount: { amount: 1000 } },
90
+ admin,
91
+ );
92
+ expect(err.httpStatus).toBeGreaterThanOrEqual(400);
93
+ });
94
+ });
@@ -0,0 +1,44 @@
1
+ import {
2
+ access,
3
+ type ConfigKeyDefinition,
4
+ createTenantConfig,
5
+ DEFAULT_CURRENCIES,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ // ISO-639-1, optional ISO-3166-1 region ("de" or "de-DE") — apps constrain
9
+ // further via their own i18n setup; this only guards against garbage input.
10
+ const LOCALE_PATTERN = { regex: "^[a-z]{2}(-[A-Z]{2})?$" } as const;
11
+
12
+ export type TenantSettingsKeyOptions = {
13
+ /** Selectable currencies. Default: DEFAULT_CURRENCIES (engine). */
14
+ readonly currencies?: readonly string[];
15
+ /** Fallback when a tenant never set the key. Default: "EUR". */
16
+ readonly defaultCurrency?: string;
17
+ /** Fallback when a tenant never set the key. Default: "en". */
18
+ readonly defaultLocale?: string;
19
+ /** Who may change the tenant's settings. Default: access.admin. */
20
+ readonly write?: readonly string[];
21
+ };
22
+
23
+ export function buildTenantSettingsKeys(
24
+ opts: TenantSettingsKeyOptions = {},
25
+ ): Record<string, ConfigKeyDefinition> {
26
+ const write = opts.write ?? access.admin;
27
+ return {
28
+ // `mask` surfaces the key in the self-populating Settings-Hub (Tenant-
29
+ // Audience) without a hand-written r.screen/r.nav — see
30
+ // samples/recipes/managed-config for the mechanism.
31
+ currency: createTenantConfig("select", {
32
+ default: opts.defaultCurrency ?? "EUR",
33
+ options: opts.currencies ?? DEFAULT_CURRENCIES,
34
+ write,
35
+ mask: { title: "tenant-settings.currency", icon: "coins", order: 1 },
36
+ }),
37
+ locale: createTenantConfig("text", {
38
+ default: opts.defaultLocale ?? "en",
39
+ pattern: LOCALE_PATTERN,
40
+ write,
41
+ mask: { title: "tenant-settings.locale", icon: "languages", order: 2 },
42
+ }),
43
+ } satisfies Record<string, ConfigKeyDefinition>;
44
+ }
@@ -0,0 +1,16 @@
1
+ // tenant-settings bundle constants — feature-name + qualified config names.
2
+ //
3
+ // Motivation (solon#P19): apps kept hard-coding a single currency/locale
4
+ // literal per money/select field ("EUR", "de") instead of one per-tenant
5
+ // setting — expensive to retrofit once a second tenant needs a different
6
+ // default. This bundle is the one place that setting lives.
7
+
8
+ export const TENANT_SETTINGS_FEATURE_NAME = "tenant-settings";
9
+
10
+ // Qualified config key names (QN format: scope:config:name) for
11
+ // ctx.config(...) reads. Clients reference the object instead of magic
12
+ // strings (mirror tags' Handlers/Queries constants).
13
+ export const TenantSettingsConfig = {
14
+ currency: "tenant-settings:config:currency",
15
+ locale: "tenant-settings:config:locale",
16
+ } as const;
@@ -0,0 +1,49 @@
1
+ // tenant-settings — per-tenant Currency + Locale defaults.
2
+ //
3
+ // Any handler that needs a default (money-field currency, a locale-select
4
+ // field, ...) reads it via `ctx.config(TenantSettingsConfig.currency)` /
5
+ // `ctx.config(TenantSettingsConfig.locale)` — or use
6
+ // `defineCreateWithTenantDefaults` (tenant-defaults.ts) to wire it into an
7
+ // entity's create-handler without writing that plumbing by hand.
8
+ //
9
+ // No entities, no hand-written screen — `mask` on both keys makes them
10
+ // surface in the self-populating Settings-Hub (Tenant-Audience) instead;
11
+ // `buildConfigFeatureSchema` (config feature) derives the configEdit
12
+ // screen + nav entry from the key's scope/type/mask, same mechanism as
13
+ // samples/recipes/managed-config.
14
+
15
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { buildTenantSettingsKeys, type TenantSettingsKeyOptions } from "./config";
17
+ import { TENANT_SETTINGS_FEATURE_NAME } from "./constants";
18
+
19
+ export type TenantSettingsFeatureOptions = TenantSettingsKeyOptions;
20
+
21
+ export function createTenantSettingsFeature(
22
+ opts: TenantSettingsFeatureOptions = {},
23
+ ): FeatureDefinition {
24
+ return defineFeature(TENANT_SETTINGS_FEATURE_NAME, (r) => {
25
+ r.requires("config");
26
+ r.describe(
27
+ "Per-tenant Currency + Locale defaults, exposed as two config keys (tenant-settings:config:currency, tenant-settings:config:locale) that surface in the self-populating Settings-Hub via `mask`. Pair with defineCreateWithTenantDefaults to auto-fill a money field's currency or a locale field on create instead of hard-coding a literal per entity.",
28
+ );
29
+ r.config({ keys: buildTenantSettingsKeys(opts) });
30
+ r.translations({
31
+ keys: {
32
+ "tenant-settings.currency": { de: "Standard-Währung", en: "Default Currency" },
33
+ "tenant-settings.locale": { de: "Standard-Sprache", en: "Default Locale" },
34
+ // Settings-Hub-derived configEdit screen title (buildConfigFeatureSchema) —
35
+ // required by the i18n boot-validator whenever a feature masks config keys.
36
+ "screen:tenant-settings-system.title": {
37
+ de: "Tenant-Einstellungen",
38
+ en: "Tenant Settings",
39
+ },
40
+ "screen:tenant-settings-tenant.title": {
41
+ de: "Tenant-Einstellungen",
42
+ en: "Tenant Settings",
43
+ },
44
+ },
45
+ });
46
+ });
47
+ }
48
+
49
+ export const tenantSettingsFeature: FeatureDefinition = createTenantSettingsFeature();
@@ -0,0 +1,8 @@
1
+ export { buildTenantSettingsKeys, type TenantSettingsKeyOptions } from "./config";
2
+ export { TENANT_SETTINGS_FEATURE_NAME, TenantSettingsConfig } from "./constants";
3
+ export {
4
+ createTenantSettingsFeature,
5
+ type TenantSettingsFeatureOptions,
6
+ tenantSettingsFeature,
7
+ } from "./feature";
8
+ export { defineCreateWithTenantDefaults } from "./tenant-defaults";
@@ -0,0 +1,72 @@
1
+ // Wrapper around defineEntityCreateHandler: fills currency/locale fields from
2
+ // tenant-settings config when the caller omits them — instead of repeating
3
+ // field literals ("EUR"/"de") on every entity.
4
+ //
5
+ // buildInsertSchema always requires `currency` inside a money field (even when
6
+ // the field itself is optional), so the caller cannot simply omit currency
7
+ // without failing schema validation before the handler runs. This helper uses
8
+ // a schema extension that makes those sub-keys optional, instead of the
9
+ // generic defineEntityCreateHandler.
10
+
11
+ import {
12
+ type AccessRule,
13
+ buildInsertSchema,
14
+ createEntityExecutor,
15
+ type EntityDefinition,
16
+ type WriteHandlerDef,
17
+ } from "@cosmicdrift/kumiko-framework/engine";
18
+ import { z } from "zod";
19
+ import { TenantSettingsConfig } from "./constants";
20
+
21
+ const OPTIONAL_CURRENCY_MONEY = z.object({ amount: z.number(), currency: z.string().optional() });
22
+
23
+ export function defineCreateWithTenantDefaults(
24
+ entityName: string,
25
+ entity: EntityDefinition,
26
+ options: {
27
+ readonly access?: AccessRule;
28
+ /** Money field names whose `currency` is filled from tenant-settings when missing. */
29
+ readonly currencyFields?: readonly string[];
30
+ /** Field name (select/text) that receives the tenant locale when missing. */
31
+ readonly localeField?: string;
32
+ },
33
+ ): WriteHandlerDef {
34
+ const { executor } = createEntityExecutor(entityName, entity);
35
+ const relax: Record<string, z.ZodTypeAny> = {};
36
+ for (const field of options.currencyFields ?? []) {
37
+ const def = entity.fields[field];
38
+ if (!def) throw new Error(`defineCreateWithTenantDefaults: unknown field "${field}"`);
39
+ const isRequired = "required" in def && def.required === true;
40
+ relax[field] = isRequired ? OPTIONAL_CURRENCY_MONEY : OPTIONAL_CURRENCY_MONEY.optional();
41
+ }
42
+ const baseSchema = buildInsertSchema(entity);
43
+ const schema = Object.keys(relax).length > 0 ? baseSchema.extend(relax) : baseSchema;
44
+
45
+ return {
46
+ name: `${entityName}:create`,
47
+ schema,
48
+ ...(options.access && { access: options.access }),
49
+ handler: async (event, ctx) => {
50
+ const payload = { ...(event.payload as Record<string, unknown>) };
51
+
52
+ for (const field of options.currencyFields ?? []) {
53
+ const value = payload[field] as { amount?: number; currency?: string } | undefined;
54
+ if (value && typeof value === "object" && !value.currency) {
55
+ const currency = await ctx.config?.(TenantSettingsConfig.currency);
56
+ if (typeof currency === "string") payload[field] = { ...value, currency };
57
+ }
58
+ }
59
+ if (options.localeField && !payload[options.localeField]) {
60
+ const locale = await ctx.config?.(TenantSettingsConfig.locale);
61
+ if (typeof locale === "string") payload[options.localeField] = locale;
62
+ }
63
+
64
+ // Re-validate against the unrelaxed entity schema so config-filled
65
+ // values still honor select/enum field constraints (fw#1581) — and so
66
+ // a missing currency after failed/missing tenant-settings mount fails
67
+ // loud instead of inserting half-filled money rows.
68
+ const filled = baseSchema.parse(payload);
69
+ return executor.create(filled, event.user, ctx.db);
70
+ },
71
+ };
72
+ }
@@ -1,7 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { type DbConnection, fetchOne } from "@cosmicdrift/kumiko-framework/db";
3
- import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
- import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
2
+ import { type DbConnection, fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
3
+ import { SYSTEM_TENANT_ID, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
5
5
  import {
6
6
  setupTestStack,
7
7
  type TestStack,
@@ -34,14 +34,19 @@ function read(slug: string, lang: string) {
34
34
  }
35
35
 
36
36
  describe("seedLegalContentFromJson", () => {
37
- test("seeds all blocks into SYSTEM_TENANT_ID by default", async () => {
37
+ test("seeds all blocks into SYSTEM_TENANT_ID as SYSTEM_USER", async () => {
38
38
  const blocks: LegalContentBlock[] = [
39
39
  { slug: "imprint", lang: "de", title: "Impressum", body: "Marc Frost" },
40
40
  { slug: "imprint", lang: "en", title: "Imprint", body: "Marc Frost" },
41
41
  ];
42
42
  await seedLegalContentFromJson(db, blocks);
43
43
 
44
- expect(await read("imprint", "de")).toMatchObject({ title: "Impressum", body: "Marc Frost" });
44
+ const de = (await read("imprint", "de")) as TextBlockRow & {
45
+ insertedById: string;
46
+ modifiedById: string | null;
47
+ };
48
+ expect(de).toMatchObject({ title: "Impressum", body: "Marc Frost" });
49
+ expect(de.insertedById).toBe(SYSTEM_USER_ID);
45
50
  expect(await read("imprint", "en")).toMatchObject({ title: "Imprint", body: "Marc Frost" });
46
51
  });
47
52
 
@@ -56,6 +61,28 @@ describe("seedLegalContentFromJson", () => {
56
61
  { slug: "privacy", lang: "de", title: "Datenschutz", body: "v2 + Sub-Processor-Tabelle" },
57
62
  ];
58
63
  await seedLegalContentFromJson(db, v2);
59
- expect(await read("privacy", "de")).toMatchObject({ body: "v2 + Sub-Processor-Tabelle" });
64
+ const row = (await read("privacy", "de")) as TextBlockRow & { modifiedById: string };
65
+ expect(row).toMatchObject({ body: "v2 + Sub-Processor-Tabelle" });
66
+ expect(row.modifiedById).toBe(SYSTEM_USER_ID);
67
+ });
68
+
69
+ test("identical re-seed is a no-op (no update event, version/modifiedAt stable)", async () => {
70
+ const blocks: LegalContentBlock[] = [
71
+ { slug: "terms", lang: "de", title: "AGB", body: "stable" },
72
+ ];
73
+ await seedLegalContentFromJson(db, blocks);
74
+ const before = (await read("terms", "de")) as TextBlockRow & {
75
+ modifiedAt: unknown;
76
+ };
77
+ expect(before).not.toBeNull();
78
+
79
+ await seedLegalContentFromJson(db, blocks);
80
+ const after = (await read("terms", "de")) as TextBlockRow & { modifiedAt: unknown };
81
+ expect(after.version).toBe(before.version);
82
+ expect(after.body).toBe("stable");
83
+ expect(after.modifiedAt).toEqual(before.modifiedAt);
84
+
85
+ const events = await selectMany(db, eventsTable, { aggregateId: String(before.id) });
86
+ expect(events.map((e) => e.type)).toEqual(["text-block.created"]);
60
87
  });
61
88
  });
@@ -83,8 +83,8 @@ export const setWrite = defineWriteHandler({
83
83
  // umgestellt werden, sonst läuft der event-store-Lookup
84
84
  // (getStreamVersion) gegen user.tenantId statt tenantId — und findet
85
85
  // den stream nicht → version_conflict obwohl die projection-row da ist.
86
- // Symmetrisch zu seedTextBlock, das TestUsers.systemAdmin (tenantId =
87
- // SYSTEM_TENANT) als by verwendet.
86
+ // Symmetric with seedTextBlock, which defaults by to
87
+ // createSystemUser(tenantId) so stream lookup and projection tenant match.
88
88
  const executorUser =
89
89
  override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
90
90
 
@@ -1,7 +1,7 @@
1
- // Test-Helper für text-content. Legt einen TextBlock direkt über den
2
- // Event-Store-Executor an — gleicher Pfad wie der echte set-Handler,
3
- // aber ohne Access-Check. Default ifExists="skip": nur fehlende Blocks
4
- // anlegen; opt-in update für Demo-Fixtures.
1
+ // Boot-/test-seed helpers for text-content. Creates a TextBlock via the
2
+ // event-store executor — same path as the real set-handler, without the
3
+ // access check. Default ifExists="skip": only missing blocks; opt-in
4
+ // update for demo fixtures / legal template authority.
5
5
 
6
6
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
7
7
  import {
@@ -10,12 +10,12 @@ import {
10
10
  type DbConnection,
11
11
  } from "@cosmicdrift/kumiko-framework/db";
12
12
  import {
13
+ createSystemUser,
13
14
  type SessionUser,
14
15
  SYSTEM_TENANT_ID,
15
16
  type TenantId,
16
17
  } from "@cosmicdrift/kumiko-framework/engine";
17
18
  import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
18
- import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
19
19
  import { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
20
20
 
21
21
  const executor = createEventStoreExecutor(textBlocksTable, textBlockEntity, {
@@ -28,10 +28,10 @@ export type SeedTextBlockOptions = {
28
28
  readonly lang: string;
29
29
  readonly title: string;
30
30
  readonly body?: string | null;
31
- /** V.1.4: Folder-Pfad für Visual-Tree-Gruppierung. Optional + null =
32
- * root-node. Seed-Pfad bypasst slugSchema/folderSchema-Validation
33
- * (system-trusted), aber App-Builder sollten kebab-only nutzen damit
34
- * set.write die geseedete Row später überschreiben kann. */
31
+ /** V.1.4: Folder path for visual-tree grouping. Optional + null =
32
+ * root node. Seed path bypasses slugSchema/folderSchema validation
33
+ * (system-trusted), but app builders should keep kebab-only so
34
+ * set.write can overwrite the seeded row later. */
35
35
  readonly folder?: string | null;
36
36
  readonly by?: SessionUser;
37
37
  readonly ifExists?: SeedIfExists;
@@ -41,15 +41,11 @@ export async function seedTextBlock(
41
41
  db: DbConnection,
42
42
  opts: SeedTextBlockOptions,
43
43
  ): Promise<{ id: string }> {
44
- // Default-user muss user.tenantId === opts.tenantId haben, sonst
45
- // landet der event-store-stream im user.tenantId-bucket aber die
46
- // projection-row im opts.tenantId-bucket. Spätere echte writes via
47
- // set-handler (mit korrektem tenant-context) finden den stream
48
- // nicht → version_conflict. TestUsers.systemAdmin ist hardcoded
49
- // testTenantId(1), nicht opts.tenantId — explizit überschreiben.
50
- const by = opts.by ?? { ...TestUsers.systemAdmin, tenantId: opts.tenantId };
51
- // executor.create erwartet TenantDb — wrapping nötig damit die runtime-
52
- // checks (tenant-scope-validation) greifen.
44
+ // Default actor is createSystemUser(tenantId) — same as config-seed —
45
+ // so prod boot seeds (legal pages) do not pull TestUsers / stack into
46
+ // the app bundle or stamp phantom test fixture IDs into the audit trail.
47
+ const by = opts.by ?? createSystemUser(opts.tenantId);
48
+ // executor.create expects TenantDb — wrap so runtime tenant-scope checks apply.
53
49
  const tdb = createTenantDb(db, opts.tenantId, "system");
54
50
 
55
51
  const existing = await fetchOne<TextBlockRow>(db, textBlocksTable, {
@@ -59,6 +55,7 @@ export async function seedTextBlock(
59
55
  });
60
56
 
61
57
  const folder = opts.folder ?? null;
58
+ const body = opts.body ?? null;
62
59
 
63
60
  return runEventStoreSeed({
64
61
  existing,
@@ -69,7 +66,7 @@ export async function seedTextBlock(
69
66
  slug: opts.slug,
70
67
  lang: opts.lang,
71
68
  title: opts.title,
72
- body: opts.body ?? null,
69
+ body,
73
70
  folder,
74
71
  tenantId: opts.tenantId,
75
72
  },
@@ -79,9 +76,9 @@ export async function seedTextBlock(
79
76
  if (!result.isSuccess) {
80
77
  throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
81
78
  }
82
- // @cast-boundary db-row: executor.create result.data ist die
83
- // inserted Drizzle-Row (Record<string, unknown>), projected
84
- // nach INSERT/RETURNING auf TextBlockRow. Runtime-Check unten.
79
+ // @cast-boundary db-row: executor.create result.data is the
80
+ // inserted Drizzle row (Record<string, unknown>), projected
81
+ // after INSERT/RETURNING onto TextBlockRow. Runtime check below.
85
82
  const data = result.data as Partial<TextBlockRow>;
86
83
  if (data.id === undefined) {
87
84
  throw new Error("seedTextBlock: executor.create did not return an id");
@@ -89,11 +86,16 @@ export async function seedTextBlock(
89
86
  return { id: data.id };
90
87
  },
91
88
  update: async (row) => {
89
+ // Skip no-op updates so legal re-seed on every pod boot does not
90
+ // bump version/updatedAt (ETag / "last changed" / event-store growth).
91
+ if (row.title === opts.title && row.body === body && row.folder === folder) {
92
+ return { id: row.id };
93
+ }
92
94
  const result = await executor.update(
93
95
  {
94
96
  id: row.id,
95
97
  version: row.version,
96
- changes: { title: opts.title, body: opts.body ?? null, folder },
98
+ changes: { title: opts.title, body, folder },
97
99
  },
98
100
  by,
99
101
  tdb,
@@ -113,17 +115,16 @@ export type LegalContentBlock = {
113
115
  readonly body: string;
114
116
  };
115
117
 
116
- // Boot-Seed für Legal-/Marketing-Texte aus einer JSON-Vorlage. `ifExists:
117
- // "update"` ist load-bearing: die Vorlage ist autoritativ, jeder Boot hebt
118
- // bestehende Blöcke auf den kompilierten Stand — sonst erreichen gesetzlich
119
- // vorgeschriebene Zusätze (z.B. Sub-Processor-Tabelle) bereits geseedete
120
- // Prod-Records nie. Genau diese Entscheidung soll keine App selbst treffen.
118
+ // Boot-seed for legal/marketing copy from a JSON template. `ifExists:
119
+ // "update"` is load-bearing: the template is authoritative — otherwise
120
+ // legally required additions never reach already-seeded prod rows.
121
121
  export async function seedLegalContentFromJson(
122
122
  db: DbConnection,
123
123
  blocks: readonly LegalContentBlock[],
124
- opts: { readonly tenantId?: TenantId } = {},
124
+ opts: { readonly tenantId?: TenantId; readonly by?: SessionUser } = {},
125
125
  ): Promise<void> {
126
126
  const tenantId = opts.tenantId ?? SYSTEM_TENANT_ID;
127
+ const by = opts.by ?? createSystemUser(tenantId);
127
128
  for (const block of blocks) {
128
129
  await seedTextBlock(db, {
129
130
  tenantId,
@@ -132,6 +133,7 @@ export async function seedLegalContentFromJson(
132
133
  title: block.title,
133
134
  body: block.body,
134
135
  ifExists: "update",
136
+ by,
135
137
  });
136
138
  }
137
139
  }
@@ -28,7 +28,7 @@ export const findForAuthQuery = defineQueryHandler({
28
28
  ),
29
29
  access: { roles: access.system },
30
30
  handler: async (query, ctx) => {
31
- const where =
31
+ const where: { email: string } | { id: string } =
32
32
  query.payload.email !== undefined
33
33
  ? { email: query.payload.email }
34
34
  : { id: query.payload.id as string }; // @cast-boundary engine-payload
@@ -18,6 +18,8 @@ import {
18
18
  } from "@cosmicdrift/kumiko-framework/db";
19
19
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
20
20
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
21
+ // kumiko-lint-ignore cross-feature-import shares the postSave-hooks fixture contract with tenant/seeding (#1478)
22
+ import { fireEntityPostSave, type SeedTenantHooks } from "../tenant/seeding";
21
23
  import { userEntity, userTable } from "./schema/user";
22
24
 
23
25
  const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
@@ -52,6 +54,11 @@ export type SeedUserOptions = {
52
54
  export async function seedUser(
53
55
  db: DbConnection,
54
56
  options: SeedUserOptions,
57
+ // Optional, append-only — same rationale as tenant/seeding's seedTenant
58
+ // hooks param (#1478): fires user's postSave hooks (e.g. a Welcome-
59
+ // notification hook keyed off `allOf: "user"`) for fixtures/self-signup
60
+ // that opt in. Omit to keep today's hook-less behavior.
61
+ hooks?: SeedTenantHooks,
55
62
  ): Promise<{ id: string }> {
56
63
  const by = options.by ?? TestUsers.systemAdmin;
57
64
  // executor.create erwartet eine TenantDb (mit .insert()-API). User
@@ -81,6 +88,7 @@ export async function seedUser(
81
88
  `seedUser failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
82
89
  );
83
90
  }
91
+ await fireEntityPostSave(hooks, "user:seed", result.data);
84
92
  return { id: extractId(result.data, "seedUser") };
85
93
  }
86
94
 
@@ -20,7 +20,7 @@ Dieses Dokument bündelt die **technischen Fakten** zur DSGVO-Pipeline
20
20
  |---------|-------------------|--------------|--------------|
21
21
  | **Art. 15 (Auskunft, light)** | `user-data-rights:query:my-audit-log` | User | Liest eigene Framework-Events aus `kumiko_events` (account-weit über alle Memberships). Filterbar nach `eventType`, `aggregateType`, `from`/`to`. Domain-Entities ohne `ctx.appendEvent` erscheinen NICHT — die kommen im Export-Bundle (Art. 20). |
22
22
  | **Art. 15 + 20 (Auskunft + Portabilität)** | `user-data-rights:write:request-export` | User | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten (cross-tenant) + signed Magic-Link per Email. Idempotent: nur 1 active Job pro User (`ACTIVE_JOB_CONSTRAINT`). |
23
- | **Art. 15 + 20** | `GET /user-export/by-token?token=…` | Anonym (Magic-Link-Pfad) | Token-Hash-Lookup → 302-Redirect auf signed Storage-URL. Multi-use innerhalb TTL. Audit: `lastUsedAt`, `lastUsedFromIp`, `lastUsedUserAgent`. |
23
+ | **Art. 15 + 20** | `GET /user-export/by-token` (interstitial) + `POST /user-export/by-token` (exchange) | Anonym (Magic-Link-Pfad) | Email-Link trägt den Token als URL-Fragment (`#token=`, nie an den Server gesendet, issue #1271); Interstitial-Script liest das Fragment client-seitig und tauscht es per POST-Body gegen die signed Storage-URL. Kein `?token=`-Query-Fallback (#1512) — Query-Param landet unveränderlich in Proxy-/Access-Logs. Multi-use innerhalb TTL. Audit: `lastUsedAt`, `lastUsedFromIp`, `lastUsedUserAgent`. |
24
24
  | **Art. 15 + 20** | `GET /user-export/by-job/:jobId` | User (Session-Auth) | UI-Klick-Pfad. Cross-tenant-same-user: User in Tenant B kann Job aus Tenant A laden wenn er der Owner ist. |
25
25
  | **Art. 17 (Löschung)** | `user-data-rights:write:request-deletion` | User | Soft-Delete: `status=DeletionRequested` + `gracePeriodEnd = now + profile.gracePeriod`. Cron `run-forget-cleanup` führt nach Grace die Anonymisierung durch. |
26
26
  | **Art. 17** | `user-data-rights:write:cancel-deletion` | User | Während Grace: status zurück auf `Active`. |
@@ -22,6 +22,8 @@ import { createSessionsFeature } from "../../sessions";
22
22
  import { USER_STATUS, userEntity, userTable } from "../../user";
23
23
  import { createUserFeature } from "../../user/feature";
24
24
  import { createUserDataRightsFeature } from "../feature";
25
+ import { listDownloadAttemptsQuery } from "../handlers/list-download-attempts.query";
26
+ import { myAuditLogQuery } from "../handlers/my-audit-log.query";
25
27
  import { downloadAttemptEntity, downloadAttemptsTable } from "../schema/download-attempt";
26
28
 
27
29
  const MY_AUDIT = "user-data-rights:query:my-audit-log";
@@ -158,6 +160,69 @@ describe("my-audit-log", () => {
158
160
  });
159
161
  });
160
162
 
163
+ describe("my-audit-log — kumiko-framework#1525: no ambient Temporal global", () => {
164
+ test("from/to filter works without deleting globalThis.Temporal for handler-internal state", async () => {
165
+ // Seed with Temporal intact — only the handler call runs ambient-free,
166
+ // mirroring the audit feature's #1525 regression test (going through
167
+ // full HTTP dispatch would also break buildHandlerContext's unrelated
168
+ // ctx.tz setup, which isn't what this pins down).
169
+ await seedEvent(alice.id, tenantA, "user.x", { from: "range-test" });
170
+
171
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
172
+ delete (globalThis as { Temporal?: unknown }).Temporal;
173
+ try {
174
+ const res = await myAuditLogQuery.handler(
175
+ {
176
+ payload: { limit: 50, from: "2020-01-01T00:00:00Z", to: "2030-01-01T00:00:00Z" },
177
+ user: alice,
178
+ } as Parameters<typeof myAuditLogQuery.handler>[0],
179
+ { db: { raw: stack.db } } as unknown as Parameters<typeof myAuditLogQuery.handler>[1],
180
+ );
181
+ expect(res.rows.length).toBeGreaterThanOrEqual(1);
182
+ } finally {
183
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
184
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
185
+ }
186
+ });
187
+ });
188
+
189
+ describe("list-download-attempts — kumiko-framework#1525: no ambient Temporal global", () => {
190
+ test("from/to filter works without relying on globalThis.Temporal", async () => {
191
+ const T = await import("@cosmicdrift/kumiko-framework/time");
192
+ const now = T.getTemporal().Now.instant();
193
+ await seedRows(stack.db, downloadAttemptsTable, [
194
+ {
195
+ id: "33333333-3333-4333-8333-333333333333",
196
+ tenantId: tenantA,
197
+ result: "notFound",
198
+ via: "token",
199
+ tokenHash: "abc",
200
+ jobId: null,
201
+ attemptedByUserId: null,
202
+ ip: "1.2.3.4",
203
+ userAgent: "test",
204
+ attemptedAt: now,
205
+ },
206
+ ]);
207
+
208
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
209
+ delete (globalThis as { Temporal?: unknown }).Temporal;
210
+ try {
211
+ const res = await listDownloadAttemptsQuery.handler(
212
+ {
213
+ payload: { limit: 50, from: "2020-01-01T00:00:00Z", to: "2030-01-01T00:00:00Z" },
214
+ user: admin,
215
+ } as Parameters<typeof listDownloadAttemptsQuery.handler>[0],
216
+ { db: stack.db } as unknown as Parameters<typeof listDownloadAttemptsQuery.handler>[1],
217
+ );
218
+ expect(res.rows.length).toBeGreaterThanOrEqual(1);
219
+ } finally {
220
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
221
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
222
+ }
223
+ });
224
+ });
225
+
161
226
  describe("download-attempt retention :: disk-bomb-Schutz bei Brute-Force", () => {
162
227
  test("Entity-Default ist 90d hardDelete (kein unbounded growth)", () => {
163
228
  expect(downloadAttemptEntity.retention).toBeDefined();