@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 (152) 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 +217 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +50 -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__/self-registration-toggle.integration.test.ts +9 -14
  9. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  10. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +8 -0
  14. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  15. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  16. package/src/auth-email-password/i18n.ts +2 -0
  17. package/src/auth-email-password/seeding.ts +41 -23
  18. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  19. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  20. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  21. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  22. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  23. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  24. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  25. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  26. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/auth-client.ts +32 -10
  28. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  29. package/src/auth-email-password/web/client-plugin.ts +15 -2
  30. package/src/auth-email-password/web/index.ts +5 -1
  31. package/src/auth-email-password/web/login-screen.tsx +7 -3
  32. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  33. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  34. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  35. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  36. package/src/auth-foundation/boot-checks.ts +74 -69
  37. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  38. package/src/auth-foundation/types.ts +3 -3
  39. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  40. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  41. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
  42. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  43. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  44. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  45. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  46. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  47. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  48. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  49. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  50. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  52. package/src/auth-mfa/web/i18n.ts +4 -0
  53. package/src/auth-mfa/web/mfa-client.ts +13 -2
  54. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  55. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  56. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  57. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  58. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  59. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  61. package/src/config/db/queries/resolver.ts +3 -3
  62. package/src/config/handlers/reencrypt.job.ts +36 -24
  63. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  64. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  65. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  66. package/src/document-ingest-foundation/entity.ts +10 -3
  67. package/src/document-ingest-foundation/feature.ts +26 -8
  68. package/src/document-ingest-foundation/index.ts +1 -0
  69. package/src/document-ingest-foundation/pages.ts +34 -0
  70. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  71. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  72. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  73. package/src/files-provider-s3/s3-provider.ts +12 -1
  74. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  76. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  77. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  78. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  79. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  80. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  81. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  82. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  83. package/src/jobs/feature.ts +6 -5
  84. package/src/jobs/job-instant.ts +9 -0
  85. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  86. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  87. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  88. package/src/page-render/__tests__/markdown.test.ts +29 -0
  89. package/src/page-render/markdown.ts +1 -9
  90. package/src/personal-access-tokens/feature.ts +1 -1
  91. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  92. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  93. package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
  94. package/src/sessions/feature.ts +12 -0
  95. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  96. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  97. package/src/sessions/handlers/revoke.write.ts +13 -0
  98. package/src/sessions/index.ts +7 -0
  99. package/src/sessions/session-callbacks.ts +58 -5
  100. package/src/sessions/session-revoked-event.ts +29 -0
  101. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  102. package/src/shared/chunked-entity-migration.ts +2 -2
  103. package/src/shared/grace-period.test.ts +26 -0
  104. package/src/shared/grace-period.ts +20 -0
  105. package/src/shared/index.ts +2 -0
  106. package/src/shared/map-with-concurrency.test.ts +104 -0
  107. package/src/shared/map-with-concurrency.ts +38 -0
  108. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  109. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  110. package/src/subscription-mollie/verify-webhook.ts +1 -0
  111. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  112. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  113. package/src/subscription-stripe/verify-webhook.ts +3 -2
  114. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  115. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  116. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  117. package/src/tenant/handlers/invitations.query.ts +13 -15
  118. package/src/tenant/handlers/members.query.ts +12 -12
  119. package/src/tenant/seeding.ts +110 -16
  120. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  121. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
  122. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
  123. package/src/tenant-settings/config.ts +44 -0
  124. package/src/tenant-settings/constants.ts +16 -0
  125. package/src/tenant-settings/feature.ts +49 -0
  126. package/src/tenant-settings/index.ts +8 -0
  127. package/src/tenant-settings/tenant-defaults.ts +67 -0
  128. package/src/user/handlers/find-for-auth.query.ts +1 -1
  129. package/src/user/seeding.ts +8 -0
  130. package/src/user-data-rights/COMPLIANCE.md +1 -1
  131. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  132. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  133. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  134. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
  135. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  136. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  137. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
  138. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  139. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  140. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  141. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  142. package/src/user-data-rights/feature.ts +45 -45
  143. package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
  144. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  145. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  146. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  147. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  148. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  149. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  150. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  151. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  152. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -0,0 +1,115 @@
1
+ // Verifies the core promise: set a tenant's tenant-settings:config:currency
2
+ // to CHF, and a new entity without an explicit currency inherits CHF instead
3
+ // of a hard-coded EUR literal.
4
+
5
+ import { afterAll, beforeAll, expect, test } from "bun:test";
6
+ import {
7
+ configValuesTable,
8
+ createConfigAccessorFactory,
9
+ createConfigFeature,
10
+ createConfigResolver,
11
+ } from "@cosmicdrift/kumiko-bundled-features/config";
12
+ import {
13
+ createEntity,
14
+ createMoneyField,
15
+ createSelectField,
16
+ createTextField,
17
+ defineFeature,
18
+ type SessionUser,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
21
+ import {
22
+ pushEntityProjectionTables,
23
+ setupTestStack,
24
+ type TestStack,
25
+ testTenantId,
26
+ unsafeCreateEntityTable,
27
+ unsafePushTables,
28
+ } from "@cosmicdrift/kumiko-framework/stack";
29
+ import { createTenantSettingsFeature } from "../feature";
30
+ import { defineCreateWithTenantDefaults } from "../tenant-defaults";
31
+
32
+ const invoiceEntity = createEntity({
33
+ table: "read_invoices",
34
+ fields: {
35
+ title: createTextField({ required: true }),
36
+ amount: createMoneyField({ required: true }),
37
+ language: createSelectField({ options: ["en", "de"] as const }),
38
+ },
39
+ });
40
+
41
+ const ACCESS = { roles: ["Admin"] } as const;
42
+
43
+ const invoiceFeature = defineFeature("invoice", (r) => {
44
+ r.entity("invoice", invoiceEntity);
45
+ r.writeHandler(
46
+ defineCreateWithTenantDefaults("invoice", invoiceEntity, {
47
+ access: ACCESS,
48
+ currencyFields: ["amount"],
49
+ localeField: "language",
50
+ }),
51
+ );
52
+ });
53
+
54
+ const tenantId = testTenantId(1);
55
+ const admin: SessionUser = { id: "admin-1", tenantId, roles: ["Admin"] };
56
+
57
+ let stack: TestStack;
58
+
59
+ beforeAll(async () => {
60
+ const resolver = createConfigResolver();
61
+ stack = await setupTestStack({
62
+ features: [createConfigFeature(), createTenantSettingsFeature(), invoiceFeature],
63
+ extraContext: ({ registry }) => ({
64
+ configResolver: resolver,
65
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
66
+ }),
67
+ });
68
+ await unsafePushTables(stack.db, { configValuesTable });
69
+ await unsafeCreateEntityTable(stack.db, invoiceEntity);
70
+ await createEventsTable(stack.db);
71
+ await pushEntityProjectionTables(stack, stack.registry);
72
+ });
73
+
74
+ afterAll(async () => {
75
+ await stack.cleanup();
76
+ });
77
+
78
+ test("ohne Tenant-Override übernimmt eine neue Entity den Feature-Default (EUR/en)", async () => {
79
+ const invoice = await stack.http.writeOk<{
80
+ data: { amount: { amount: number; currency: string }; language: string };
81
+ }>("invoice:write:invoice:create", { title: "Rechnung 1", amount: { amount: 1000 } }, admin);
82
+ expect(invoice.data.amount).toEqual({ amount: 1000, currency: "EUR" });
83
+ expect(invoice.data.language).toBe("en");
84
+ });
85
+
86
+ test("Tenant setzt CHF/de — neue Entity ohne explizite Werte übernimmt die Tenant-Config, nicht das Feature-Default", async () => {
87
+ await stack.http.writeOk(
88
+ "config:write:set",
89
+ { key: "tenant-settings:config:currency", value: "CHF" },
90
+ admin,
91
+ );
92
+ await stack.http.writeOk(
93
+ "config:write:set",
94
+ { key: "tenant-settings:config:locale", value: "de" },
95
+ admin,
96
+ );
97
+
98
+ const invoice = await stack.http.writeOk<{
99
+ data: { amount: { amount: number; currency: string }; language: string };
100
+ }>("invoice:write:invoice:create", { title: "Rechnung 2", amount: { amount: 2000 } }, admin);
101
+
102
+ expect(invoice.data.amount).toEqual({ amount: 2000, currency: "CHF" });
103
+ expect(invoice.data.language).toBe("de");
104
+ });
105
+
106
+ test("expliziter Wert im Payload gewinnt gegen die Tenant-Config", async () => {
107
+ const invoice = await stack.http.writeOk<{
108
+ data: { amount: { amount: number; currency: string } };
109
+ }>(
110
+ "invoice:write:invoice:create",
111
+ { title: "Rechnung 3", amount: { amount: 3000, currency: "USD" } },
112
+ admin,
113
+ );
114
+ expect(invoice.data.amount).toEqual({ amount: 3000, currency: "USD" });
115
+ });
@@ -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,67 @@
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
+ return executor.create(payload, event.user, ctx.db);
65
+ },
66
+ };
67
+ }
@@ -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();
@@ -10,13 +10,22 @@ import {
10
10
  } from "@cosmicdrift/kumiko-framework/engine";
11
11
  import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";
12
12
  import { z } from "zod";
13
- import { EXT_SESSION_STORE } from "../../auth-foundation";
13
+ import { authFoundationFeature } from "../../auth-foundation";
14
14
  import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
15
15
  import { createConfigFeature } from "../../config/feature";
16
16
  import { createDataRetentionFeature } from "../../data-retention/feature";
17
+ import { createPersonalAccessTokensFeature } from "../../personal-access-tokens";
17
18
  import { createSessionsFeature } from "../../sessions/feature";
18
19
  import { createTenantFeature } from "../../tenant/feature";
19
20
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle/feature";
21
+
22
+ function projectionProbeTable(name: string): SchemaTable {
23
+ return table(name, {
24
+ id: uuid("id").primaryKey(),
25
+ count: integer("count").notNull().default(0),
26
+ }) as unknown as SchemaTable; // @cast-boundary test-fixture — minimal probe shape, real schema-table irrelevant here
27
+ }
28
+
20
29
  import { createUserFeature } from "../../user/feature";
21
30
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults/feature";
22
31
  import { createUserDataRightsFeature } from "../feature";
@@ -30,18 +39,13 @@ import { createUserDataRightsFeature } from "../feature";
30
39
  // Unlike the old direct-function tests, these only run inside a real
31
40
  // validateBoot() pass — exercising the actual `r.bootCheck()` plumbing, not
32
41
  // just the guard logic.
33
- // Stub instead of the real auth-foundation — these boot-checks are about
34
- // GDPR-storage guards, not auth; only sessions' sessionStore registration
35
- // needs an owner present.
36
- const sessionStoreOwnerStub = defineFeature("auth-foundation", (r) => {
37
- r.extendsRegistrar(EXT_SESSION_STORE, {});
38
- });
39
-
40
42
  function baseFeatures() {
41
43
  return [
42
44
  createConfigFeature(),
43
45
  createUserFeature(),
44
- sessionStoreOwnerStub,
46
+ createTenantFeature(),
47
+ authFoundationFeature,
48
+ createPersonalAccessTokensFeature({ scopes: {} }),
45
49
  createSessionsFeature(),
46
50
  createDataRetentionFeature(),
47
51
  createComplianceProfilesFeature(),
@@ -84,10 +88,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
84
88
  });
85
89
 
86
90
  test("V3: pii field on a projection-only entity (no r.entity) is still caught", () => {
87
- const projectionTable = table("crm_contact_summary", {
88
- id: uuid("id").primaryKey(),
89
- count: integer("count").notNull().default(0),
90
- }) as unknown as SchemaTable;
91
+ const projectionTable = projectionProbeTable("crm_contact_summary");
91
92
  const bad = defineFeature("crm", (r) => {
92
93
  const contactCreated = r.defineEvent("contact-created", z.unknown());
93
94
  r.projection({
@@ -102,10 +103,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
102
103
  });
103
104
 
104
105
  test("V3: projection-only pii entity WITH EXT_USER_DATA hook registered for its entity name → no throw", () => {
105
- const projectionTable = table("crm_contact_summary_hooked", {
106
- id: uuid("id").primaryKey(),
107
- count: integer("count").notNull().default(0),
108
- }) as unknown as SchemaTable;
106
+ const projectionTable = projectionProbeTable("crm_contact_summary_hooked");
109
107
  const hooked = defineFeature("crm-hooked", (r) => {
110
108
  r.requires("user-data-rights");
111
109
  const contactCreated = r.defineEvent("contact-hooked-created", z.unknown());
@@ -155,11 +155,15 @@ describe("download-by-token under resolverTrust: authoritative, no defaultTenant
155
155
  }
156
156
 
157
157
  const res = await stack.app.fetch(
158
- new Request(`http://test/user-export/by-token?token=${plainToken}`, { method: "GET" }),
158
+ new Request("http://test/user-export/by-token", {
159
+ method: "POST",
160
+ headers: { "content-type": "application/json" },
161
+ body: JSON.stringify({ token: plainToken }),
162
+ }),
159
163
  );
160
- expect(res.status).toBe(302);
161
- const location = res.headers.get("location");
162
- expect(location).toMatch(/^memory:\/\//);
163
- expect(location).toContain(`${jobTenant}/exports/${jobId}.zip`);
164
+ expect(res.status).toBe(200);
165
+ const body = (await res.json()) as { data?: { url?: string } };
166
+ expect(body.data?.url).toMatch(/^memory:\/\//);
167
+ expect(body.data?.url).toContain(`${jobTenant}/exports/${jobId}.zip`);
164
168
  });
165
169
  });
@@ -437,8 +437,8 @@ describe("download-by-job :: happy path", () => {
437
437
  });
438
438
 
439
439
  describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
440
- test("happy: 302-Redirect mit Location-Header zur signed-URL", async () => {
441
- const { plainToken } = await seedDoneJobWithToken();
440
+ test("`?token=` in query is ignored — serves interstitial HTML instead of resolving it (no access-log leak)", async () => {
441
+ const { jobId, plainToken } = await seedDoneJobWithToken();
442
442
 
443
443
  const res = await stack.app.fetch(
444
444
  new Request(`http://test/user-export/by-token?token=${plainToken}`, {
@@ -449,21 +449,15 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
449
449
  },
450
450
  }),
451
451
  );
452
- expect(res.status).toBe(302);
453
- const location = res.headers.get("location");
454
- expect(location).toBeTruthy();
455
- expect(location).toMatch(/^memory:\/\//);
456
- });
452
+ expect(res.status).toBe(200);
453
+ expect(res.headers.get("content-type")).toContain("text/html");
454
+ const body = await res.text();
455
+ expect(body).toContain("/user-export/by-token.js");
457
456
 
458
- test("invalid token → 404 passthrough mit i18nKey", async () => {
459
- const res = await stack.app.fetch(
460
- new Request("http://test/user-export/by-token?token=fake-xxxxx", {
461
- method: "GET",
462
- }),
463
- );
464
- expect(res.status).toBe(404);
465
- const body = (await res.json()) as { error?: { i18nKey?: string } };
466
- expect(body.error?.i18nKey).toBe("userDataRights.errors.download.notFound");
457
+ const [row] = (await selectMany(stack.db, exportDownloadTokensTable, { jobId })) as Array<{
458
+ useCount: number;
459
+ }>;
460
+ expect(row?.useCount).toBe(0);
467
461
  });
468
462
 
469
463
  test("no query token (fragment-Pfad) → serviert Interstitial-HTML statt 400", async () => {
@@ -487,29 +481,6 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
487
481
  expect(body).toContain("/user-export/by-token");
488
482
  });
489
483
 
490
- test("Audit-Update: useCount + IP/UA aus httpRoute-Headers (nicht aus payload)", async () => {
491
- const { jobId, plainToken } = await seedDoneJobWithToken();
492
- await stack.app.fetch(
493
- new Request(`http://test/user-export/by-token?token=${plainToken}`, {
494
- method: "GET",
495
- headers: {
496
- "user-agent": "e2e-test/2.0",
497
- "x-forwarded-for": "198.51.100.7, 10.0.0.1",
498
- },
499
- }),
500
- );
501
-
502
- const [row] = (await selectMany(stack.db, exportDownloadTokensTable, { jobId })) as Array<{
503
- useCount: number;
504
- lastUsedFromIp: string | null;
505
- lastUsedUserAgent: string | null;
506
- }>;
507
- expect(row?.useCount).toBe(1);
508
- // X-Forwarded-For: erster Wert, comma-trimmed
509
- expect(row?.lastUsedFromIp).toBe("198.51.100.7");
510
- expect(row?.lastUsedUserAgent).toBe("e2e-test/2.0");
511
- });
512
-
513
484
  test("POST-Exchange (Fragment-Pfad): happy-path + IP/UA-Audit aus httpRoute-Headers", async () => {
514
485
  const { jobId, plainToken } = await seedDoneJobWithToken();
515
486
 
@@ -569,8 +540,8 @@ describe("download-by-job :: cross-user + cross-tenant", () => {
569
540
  });
570
541
 
571
542
  test("provider ohne getSignedUrl → 422 unprocessable signedUrlNotSupported (job-Pfad)", async () => {
572
- // Symmetrisch zum token-Pfad: derselbe Operator-Konfig-Bug muss auch
573
- // beim UI-Klick-Pfad als 422 raus, nicht 404.
543
+ // Same as the token path: the operator misconfig must also surface as
544
+ // 422 on the UI click path, not 404.
574
545
  const { jobId } = await seedDoneJobWithToken();
575
546
  await stack.http.writeOk(
576
547
  ConfigHandlers.set,
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { access, defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";
4
- import { EXT_SESSION_STORE } from "../../auth-foundation";
4
+ import { authFoundationFeature } from "../../auth-foundation";
5
5
  import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
6
6
  import { createConfigFeature } from "../../config/feature";
7
7
  import { createDataRetentionFeature } from "../../data-retention/feature";
8
+ import { createPersonalAccessTokensFeature } from "../../personal-access-tokens";
8
9
  import { createSessionsFeature } from "../../sessions/feature";
10
+ import { createTenantFeature } from "../../tenant/feature";
9
11
  import { createUserFeature } from "../../user/feature";
10
12
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults/feature";
11
13
  import { createUserDataRightsFeature } from "../feature";
@@ -18,13 +20,6 @@ import { createUserDataRightsFeature } from "../feature";
18
20
  // entities (export-job, download-attempt) are r.entity, not direct-write stores,
19
21
  // so an entityList binding is rebuild-safe (unlike jobs/sessions read-models).
20
22
 
21
- // Stub instead of the real auth-foundation — this test is about screen
22
- // wiring, not auth; only sessions' sessionStore registration needs an owner
23
- // present.
24
- const sessionStoreOwnerStub = defineFeature("auth-foundation", (r) => {
25
- r.extendsRegistrar(EXT_SESSION_STORE, {});
26
- });
27
-
28
23
  describe("user-data-rights read-only inspector screens", () => {
29
24
  // A realistic assembly: mounting user-data-rights without its default hooks
30
25
  // now fails the V3 boot gate (the framework `user` entity holds PII with no
@@ -33,7 +28,9 @@ describe("user-data-rights read-only inspector screens", () => {
33
28
  const features = [
34
29
  createConfigFeature(),
35
30
  createUserFeature(),
36
- sessionStoreOwnerStub,
31
+ createTenantFeature(),
32
+ authFoundationFeature,
33
+ createPersonalAccessTokensFeature({ scopes: {} }),
37
34
  createSessionsFeature(),
38
35
  createDataRetentionFeature(),
39
36
  createComplianceProfilesFeature(),