@cosmicdrift/kumiko-bundled-features 0.15.0 → 0.18.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 (111) hide show
  1. package/package.json +1 -1
  2. package/src/billing-foundation/get-subscription-for-tenant.ts +2 -2
  3. package/src/cap-counter/__tests__/{cap-counter.integration.ts → cap-counter.integration.test.ts} +14 -3
  4. package/src/cap-counter/__tests__/enforce-cap.test.ts +8 -4
  5. package/src/cap-counter/__tests__/{with-cap-enforcement.integration.ts → with-cap-enforcement.integration.test.ts} +14 -3
  6. package/src/cap-counter/enforce-cap.ts +2 -4
  7. package/src/cap-counter/handlers/get-counter.query.ts +1 -3
  8. package/src/cap-counter/handlers/increment.write.ts +1 -2
  9. package/src/cap-counter/handlers/mark-soft-warned.write.ts +1 -2
  10. package/src/channel-in-app/in-app-channel.ts +1 -3
  11. package/src/compliance-profiles/_internal/parse-override.ts +19 -0
  12. package/src/compliance-profiles/handlers/for-tenant.query.ts +6 -25
  13. package/src/compliance-profiles/resolve-for-tenant.ts +6 -20
  14. package/src/custom-fields/__tests__/cross-tenant-field-delete.integration.test.ts +177 -0
  15. package/src/custom-fields/__tests__/{custom-fields.integration.ts → custom-fields.integration.test.ts} +105 -0
  16. package/src/custom-fields/db/queries/projection.ts +33 -4
  17. package/src/custom-fields/db/queries/retention.ts +2 -2
  18. package/src/custom-fields/db/queries/user-data-rights.ts +6 -3
  19. package/src/custom-fields/feature.ts +10 -4
  20. package/src/custom-fields/handlers/delete-system-field.write.ts +5 -1
  21. package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -1
  22. package/src/custom-fields/handlers/set-custom-field.write.ts +33 -17
  23. package/src/custom-fields/lib/field-access.ts +39 -14
  24. package/src/custom-fields/lib/value-schema.ts +45 -0
  25. package/src/custom-fields/run-retention.ts +1 -1
  26. package/src/custom-fields/wire-for-entity.ts +22 -4
  27. package/src/custom-fields/wire-user-data-rights.ts +3 -2
  28. package/src/delivery/delivery-service.ts +1 -1
  29. package/src/delivery/feature.ts +8 -1
  30. package/src/delivery/types.ts +2 -2
  31. package/src/feature-toggles/__tests__/{feature-toggles.integration.ts → feature-toggles.integration.test.ts} +6 -6
  32. package/src/feature-toggles/handlers/set.write.ts +10 -8
  33. package/src/jobs/feature.ts +4 -1
  34. package/src/subscription-stripe/__tests__/{stripe-foundation.integration.ts → stripe-foundation.integration.test.ts} +7 -10
  35. package/src/tier-engine/__tests__/{resolver.integration.ts → resolver.integration.test.ts} +4 -3
  36. package/src/user-data-rights/__tests__/{audit-log.integration.ts → audit-log.integration.test.ts} +12 -5
  37. package/src/user-data-rights/__tests__/{cross-data-matrix.integration.ts → cross-data-matrix.integration.test.ts} +29 -12
  38. package/src/user-data-rights/__tests__/{download.integration.ts → download.integration.test.ts} +15 -7
  39. package/src/user-data-rights/__tests__/{export-job-idempotency.integration.ts → export-job-idempotency.integration.test.ts} +13 -11
  40. package/src/user-data-rights/__tests__/{request-cancel-deletion.integration.ts → request-cancel-deletion.integration.test.ts} +8 -7
  41. package/src/user-data-rights/__tests__/{request-deletion-callback.integration.ts → request-deletion-callback.integration.test.ts} +8 -5
  42. package/src/user-data-rights/__tests__/{request-export.integration.ts → request-export.integration.test.ts} +6 -3
  43. package/src/user-data-rights/__tests__/{restriction-flow.integration.ts → restriction-flow.integration.test.ts} +11 -8
  44. package/src/user-data-rights/__tests__/{run-export-jobs.integration.ts → run-export-jobs.integration.test.ts} +25 -13
  45. package/src/user-data-rights/__tests__/{run-forget-cleanup.integration.ts → run-forget-cleanup.integration.test.ts} +6 -3
  46. package/src/user-data-rights/__tests__/{run-user-export.integration.ts → run-user-export.integration.test.ts} +6 -3
  47. package/src/user-data-rights/__tests__/{user-data-rights.integration.ts → user-data-rights.integration.test.ts} +3 -1
  48. package/src/user-data-rights/db/queries/export-jobs.ts +6 -5
  49. package/src/user-data-rights/db/queries/forget-cleanup.ts +11 -6
  50. package/src/user-data-rights/handlers/cancel-deletion.write.ts +5 -10
  51. package/src/user-data-rights/handlers/export-status.query.ts +12 -12
  52. package/src/user-data-rights/run-export-jobs.ts +2 -5
  53. package/src/user-data-rights/run-forget-cleanup.ts +0 -1
  54. package/src/user-data-rights-defaults/__tests__/{user-data-rights-defaults.integration.ts → user-data-rights-defaults.integration.test.ts} +2 -0
  55. /package/src/__tests__/{es-ops-e2e.integration.ts → es-ops-e2e.integration.test.ts} +0 -0
  56. /package/src/audit/__tests__/{audit.integration.ts → audit.integration.test.ts} +0 -0
  57. /package/src/auth-email-password/__tests__/{account-lockout-no-redis.integration.ts → account-lockout-no-redis.integration.test.ts} +0 -0
  58. /package/src/auth-email-password/__tests__/{account-lockout.integration.ts → account-lockout.integration.test.ts} +0 -0
  59. /package/src/auth-email-password/__tests__/{auth-claims.integration.ts → auth-claims.integration.test.ts} +0 -0
  60. /package/src/auth-email-password/__tests__/{auth.integration.ts → auth.integration.test.ts} +0 -0
  61. /package/src/auth-email-password/__tests__/{email-verification.integration.ts → email-verification.integration.test.ts} +0 -0
  62. /package/src/auth-email-password/__tests__/{identity-v3-login.integration.ts → identity-v3-login.integration.test.ts} +0 -0
  63. /package/src/auth-email-password/__tests__/{invite-flow.integration.ts → invite-flow.integration.test.ts} +0 -0
  64. /package/src/auth-email-password/__tests__/{multi-roles.integration.ts → multi-roles.integration.test.ts} +0 -0
  65. /package/src/auth-email-password/__tests__/{password-reset.integration.ts → password-reset.integration.test.ts} +0 -0
  66. /package/src/auth-email-password/__tests__/{public-routes-rate-limit.integration.ts → public-routes-rate-limit.integration.test.ts} +0 -0
  67. /package/src/auth-email-password/__tests__/{seed-admin.integration.ts → seed-admin.integration.test.ts} +0 -0
  68. /package/src/auth-email-password/__tests__/{session-callbacks.integration.ts → session-callbacks.integration.test.ts} +0 -0
  69. /package/src/auth-email-password/__tests__/{session-strict-mode.integration.ts → session-strict-mode.integration.test.ts} +0 -0
  70. /package/src/auth-email-password/__tests__/{signup-flow.integration.ts → signup-flow.integration.test.ts} +0 -0
  71. /package/src/billing-foundation/__tests__/{billing-foundation.integration.ts → billing-foundation.integration.test.ts} +0 -0
  72. /package/src/compliance-profiles/__tests__/{compliance-profiles.integration.ts → compliance-profiles.integration.test.ts} +0 -0
  73. /package/src/compliance-profiles/__tests__/{seeding.integration.ts → seeding.integration.test.ts} +0 -0
  74. /package/src/config/__tests__/{cascade.integration.ts → cascade.integration.test.ts} +0 -0
  75. /package/src/config/__tests__/{config.integration.ts → config.integration.test.ts} +0 -0
  76. /package/src/custom-fields/__tests__/{audit-integration.integration.ts → audit-integration.integration.test.ts} +0 -0
  77. /package/src/custom-fields/__tests__/{field-access.integration.ts → field-access.integration.test.ts} +0 -0
  78. /package/src/custom-fields/__tests__/{quota.integration.ts → quota.integration.test.ts} +0 -0
  79. /package/src/custom-fields/__tests__/{retention.integration.ts → retention.integration.test.ts} +0 -0
  80. /package/src/custom-fields/__tests__/{user-data-rights.integration.ts → user-data-rights.integration.test.ts} +0 -0
  81. /package/src/data-retention/__tests__/{data-retention.integration.ts → data-retention.integration.test.ts} +0 -0
  82. /package/src/data-retention/__tests__/{policy-for.integration.ts → policy-for.integration.test.ts} +0 -0
  83. /package/src/delivery/__tests__/{delivery-events.integration.ts → delivery-events.integration.test.ts} +0 -0
  84. /package/src/delivery/__tests__/{delivery.integration.ts → delivery.integration.test.ts} +0 -0
  85. /package/src/file-foundation/__tests__/{file-foundation.integration.ts → file-foundation.integration.test.ts} +0 -0
  86. /package/src/files/__tests__/{files.integration.ts → files.integration.test.ts} +0 -0
  87. /package/src/files-provider-s3/__tests__/{s3-provider.integration.ts → s3-provider.integration.test.ts} +0 -0
  88. /package/src/jobs/__tests__/{job-system-user.integration.ts → job-system-user.integration.test.ts} +0 -0
  89. /package/src/jobs/__tests__/{jobs-events.integration.ts → jobs-events.integration.test.ts} +0 -0
  90. /package/src/jobs/__tests__/{jobs-feature.integration.ts → jobs-feature.integration.test.ts} +0 -0
  91. /package/src/legal-pages/__tests__/{legal-pages.integration.ts → legal-pages.integration.test.ts} +0 -0
  92. /package/src/mail-foundation/__tests__/{mail-foundation.integration.ts → mail-foundation.integration.test.ts} +0 -0
  93. /package/src/rate-limiting/__tests__/{rate-limiting.integration.ts → rate-limiting.integration.test.ts} +0 -0
  94. /package/src/renderer-foundation/__tests__/{collect-plugins.integration.ts → collect-plugins.integration.test.ts} +0 -0
  95. /package/src/secrets/__tests__/{rotate.integration.ts → rotate.integration.test.ts} +0 -0
  96. /package/src/secrets/__tests__/{secrets-events.integration.ts → secrets-events.integration.test.ts} +0 -0
  97. /package/src/secrets/__tests__/{secrets.integration.ts → secrets.integration.test.ts} +0 -0
  98. /package/src/sessions/__tests__/{cleanup.integration.ts → cleanup.integration.test.ts} +0 -0
  99. /package/src/sessions/__tests__/{password-auto-revoke.integration.ts → password-auto-revoke.integration.test.ts} +0 -0
  100. /package/src/sessions/__tests__/{sessions.integration.ts → sessions.integration.test.ts} +0 -0
  101. /package/src/subscription-mollie/__tests__/{mollie-foundation.integration.ts → mollie-foundation.integration.test.ts} +0 -0
  102. /package/src/template-resolver/__tests__/{handlers.integration.ts → handlers.integration.test.ts} +0 -0
  103. /package/src/template-resolver/__tests__/{template-resolver.integration.ts → template-resolver.integration.test.ts} +0 -0
  104. /package/src/tenant/__tests__/{multi-tenant.integration.ts → multi-tenant.integration.test.ts} +0 -0
  105. /package/src/tenant/__tests__/{seed-testing.integration.ts → seed-testing.integration.test.ts} +0 -0
  106. /package/src/tenant/__tests__/{tenant.integration.ts → tenant.integration.test.ts} +0 -0
  107. /package/src/text-content/__tests__/{text-content.integration.ts → text-content.integration.test.ts} +0 -0
  108. /package/src/tier-engine/__tests__/{auto-default-tier.integration.ts → auto-default-tier.integration.test.ts} +0 -0
  109. /package/src/tier-engine/__tests__/{tier-engine.integration.ts → tier-engine.integration.test.ts} +0 -0
  110. /package/src/user/__tests__/{seed-testing.integration.ts → seed-testing.integration.test.ts} +0 -0
  111. /package/src/user/__tests__/{user.integration.ts → user.integration.test.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.15.0",
3
+ "version": "0.18.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,7 +1,6 @@
1
1
  // Resolver-helper: liest die current subscription-row für einen Tenant
2
2
  // aus der read_subscriptions-projection.
3
3
 
4
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
5
4
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
6
5
  import { subscriptionAggregateId } from "./aggregate-id";
7
6
  import { subscriptionsProjectionTable } from "./projection";
@@ -22,7 +21,8 @@ export async function getSubscriptionForTenant(
22
21
  tenantId: string,
23
22
  ): Promise<SubscriptionView | null> {
24
23
  const aggId = subscriptionAggregateId(tenantId);
25
- const [row] = await selectMany(ctx.db, subscriptionsProjectionTable, { id: aggId }, { limit: 1 });
24
+ const rows = await ctx.db.selectMany(subscriptionsProjectionTable, { id: aggId }, { limit: 1 });
25
+ const row = rows[0];
26
26
  if (!row) return null;
27
27
  // @cast-boundary db-row — drizzle-row carries column-as-unknown
28
28
  return {
@@ -6,10 +6,14 @@
6
6
  // assert. Mirrors the mail-foundation / file-foundation integration
7
7
  // test pattern.
8
8
 
9
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
10
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
11
- import { defineFeature, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
12
- import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import {
12
+ createEntityExecutor,
13
+ defineFeature,
14
+ type WriteHandlerDef,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
13
17
  import {
14
18
  createTestUser,
15
19
  setupTestStack,
@@ -18,6 +22,7 @@ import {
18
22
  testTenantId,
19
23
  unsafeCreateEntityTable,
20
24
  } from "@cosmicdrift/kumiko-framework/stack";
25
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
21
26
  import { z } from "zod";
22
27
  import { CapCounterHandlers, CapCounterQueries } from "../constants";
23
28
  import {
@@ -192,6 +197,12 @@ afterAll(async () => {
192
197
  // which month — just need a stable iso-string so `increment` +
193
198
  // `readCounter` hit the same aggregate.
194
199
  const PERIOD = "2026-05-01T00:00:00Z";
200
+ const { table: capCounterTable } = createEntityExecutor("cap-counter", capCounterEntity);
201
+
202
+ beforeEach(async () => {
203
+ await resetTestTables(db, [capCounterTable, eventsTable]);
204
+ });
205
+
195
206
  const sysadmin = TestUsers.systemAdmin;
196
207
 
197
208
  function adminFor(tenantNumber: number) {
@@ -17,15 +17,19 @@ import {
17
17
  enforceRollingCapAndMaybeNotify,
18
18
  } from "../enforce-cap";
19
19
 
20
- // Test-mock: ctx.db unterstützt sowohl bun-db's .unsafe() (selectMany ruft das)
21
- // als auch drizzle's .select().from().where() chain (rolling-path nutzt das
22
- // direkt). Beide pfade returnen denselben rows-set unabhängig von filtern.
20
+ // Test-mock: ctx.db exposes TenantDb-style selectMany (production path)
21
+ // plus legacy drizzle .select().from().where() chain stubs.
23
22
 
24
23
  function makeMockDb(rows: unknown[]) {
24
+ const selectMany = async (_table: unknown, _where?: unknown, options?: { limit?: number }) => {
25
+ const slice = options?.limit !== undefined ? rows.slice(0, options.limit) : rows;
26
+ return slice;
27
+ };
25
28
  return {
26
29
  unsafe: async () => rows,
30
+ selectMany,
27
31
  begin: async <T>(fn: (tx: unknown) => Promise<T>) =>
28
- fn({ unsafe: async () => rows, begin: async () => undefined }),
32
+ fn({ unsafe: async () => rows, selectMany, begin: async () => undefined }),
29
33
  select: () => ({
30
34
  from: () => ({
31
35
  where: Object.assign(async () => rows, {
@@ -8,10 +8,14 @@
8
8
  // 5. Failed handler: counter NICHT inkrementiert (cap-quota nicht
9
9
  // verbrannt für gescheiterte writes)
10
10
 
11
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
12
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
13
- import { defineFeature, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
14
- import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
13
+ import {
14
+ createEntityExecutor,
15
+ defineFeature,
16
+ type WriteHandlerDef,
17
+ } from "@cosmicdrift/kumiko-framework/engine";
18
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
19
  import {
16
20
  createTestUser,
17
21
  setupTestStack,
@@ -19,6 +23,7 @@ import {
19
23
  testTenantId,
20
24
  unsafeCreateEntityTable,
21
25
  } from "@cosmicdrift/kumiko-framework/stack";
26
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
22
27
  import { z } from "zod";
23
28
  import { CapCounterQueries } from "../constants";
24
29
  import type { SoftHitNotifier } from "../enforce-cap";
@@ -59,6 +64,8 @@ const innerSendHandler: WriteHandlerDef = {
59
64
 
60
65
  const PERIOD = "2026-07-01T00:00:00Z";
61
66
 
67
+ const { table: capCounterTable } = createEntityExecutor("cap-counter", capCounterEntity);
68
+
62
69
  const wrappedCalendar = withCapEnforcement(innerSendHandler, () => ({
63
70
  capName: "newsletter-cap",
64
71
  periodStartIso: PERIOD,
@@ -103,6 +110,10 @@ afterAll(async () => {
103
110
  await stack.cleanup();
104
111
  });
105
112
 
113
+ beforeEach(async () => {
114
+ await resetTestTables(db, [capCounterTable, eventsTable]);
115
+ });
116
+
106
117
  function adminFor(tenantNumber: number) {
107
118
  return createTestUser({
108
119
  id: tenantNumber,
@@ -1,4 +1,3 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { createEntityExecutor, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
3
  import { rollingCapAggregateId } from "./aggregate-id";
@@ -109,8 +108,7 @@ export async function enforceCap(
109
108
  const softThreshold = options.limit * tolerance.soft;
110
109
  const hardThreshold = options.limit * tolerance.hard;
111
110
 
112
- const rows = await selectMany(
113
- ctx.db,
111
+ const rows = await ctx.db.selectMany(
114
112
  table,
115
113
  { capName: options.capName, periodStart: options.periodStartIso },
116
114
  { limit: 1 },
@@ -188,7 +186,7 @@ export async function enforceRollingCap(
188
186
  // covers the prefix; the additional aggregate_id eq narrows to the
189
187
  // single rolling-stream. Postgres can use the index even with the
190
188
  // aggregate_id filter applied as a residual.
191
- const rows = await selectMany<{ payload: { amount?: number } }>(ctx.db, eventsTable, {
189
+ const rows = await ctx.db.selectMany<{ payload: { amount?: number } }>(eventsTable, {
192
190
  tenantId: ctx.user.tenantId,
193
191
  aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
194
192
  aggregateId,
@@ -1,4 +1,3 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { createEntityExecutor, type QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { z } from "zod";
4
3
  import { capCounterEntity } from "../entity";
@@ -25,8 +24,7 @@ export const getCounterQuery: QueryHandlerDef = {
25
24
  const { capName, periodStartIso } = query.payload as z.infer<typeof getCounterSchema>; // @cast-boundary engine-payload
26
25
 
27
26
  // ctx.db is tenant-scoped; filter by capName + periodStart explicitly.
28
- const rows = await selectMany(
29
- ctx.db,
27
+ const rows = await ctx.db.selectMany(
30
28
  table,
31
29
  { capName, periodStart: periodStartIso },
32
30
  { limit: 1 },
@@ -1,4 +1,3 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { Temporal } from "temporal-polyfill";
4
3
  import { z } from "zod";
@@ -55,7 +54,7 @@ export const incrementCapHandler: WriteHandlerDef = {
55
54
 
56
55
  // Read existing aggregate's projection-row to decide create vs update.
57
56
  // ctx.db is auto-tenant-scoped — id-lookup is unique per tenant.
58
- const existing = await selectMany(ctx.db, table, { id: aggregateId }, { limit: 1 });
57
+ const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
59
58
 
60
59
  if (existing.length === 0) {
61
60
  return executor.create(
@@ -1,4 +1,3 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { Temporal } from "temporal-polyfill";
4
3
  import { z } from "zod";
@@ -32,7 +31,7 @@ export const markSoftWarnedHandler: WriteHandlerDef = {
32
31
  payload.periodStartIso,
33
32
  );
34
33
 
35
- const existing = await selectMany(ctx.db, table, { id: aggregateId }, { limit: 1 });
34
+ const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
36
35
  if (existing.length === 0) {
37
36
  throw new Error(
38
37
  `cap-counter: cannot mark-soft-warned, no counter found for tenant=${event.user.tenantId} cap=${payload.capName} period=${payload.periodStartIso}`,
@@ -1,4 +1,3 @@
1
- import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { tenantChannel } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import type { DeliveryChannel } from "../delivery";
4
3
  import { inAppMessagesTable } from "./tables";
@@ -15,8 +14,7 @@ export const inAppChannel: DeliveryChannel = {
15
14
  // address is the user-id string after the ES migration — keep it as-is.
16
15
  const userId = address;
17
16
 
18
- const row = await insertOne<{ id: string }>(ctx.db, inAppMessagesTable, {
19
- tenantId: ctx.tenantId,
17
+ const row = await ctx.db.insertOne<{ id: string }>(inAppMessagesTable, {
20
18
  userId,
21
19
  notificationType: message.notificationType,
22
20
  title: message.title,
@@ -0,0 +1,19 @@
1
+ import type { ComplianceProfileOverride } from "@cosmicdrift/kumiko-framework/compliance";
2
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
3
+
4
+ export function parseComplianceProfileOverride(
5
+ raw: string | null,
6
+ tenantId: string,
7
+ callerLabel: string,
8
+ ): ComplianceProfileOverride | undefined {
9
+ if (!raw || raw.trim() === "") return undefined;
10
+ const parsed = parseJsonSafe<ComplianceProfileOverride | null>(raw, null);
11
+ if (parsed === null) {
12
+ // biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
13
+ console.warn(
14
+ `[${callerLabel}] tenant ${tenantId}: stored override is not valid JSON, ignoring.`,
15
+ );
16
+ return undefined;
17
+ }
18
+ return parsed;
19
+ }
@@ -1,12 +1,12 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
3
  type ComplianceProfileKey,
4
- type ComplianceProfileOverride,
5
4
  type EffectiveComplianceProfile,
6
5
  resolveComplianceProfile,
7
6
  } from "@cosmicdrift/kumiko-framework/compliance";
8
7
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
9
8
  import { z } from "zod";
9
+ import { parseComplianceProfileOverride } from "../_internal/parse-override";
10
10
  import { tenantComplianceProfileTable } from "../schema/profile-selection";
11
11
 
12
12
  // Liefert das effektive Compliance-Profile fuer den aktuellen Tenant.
@@ -29,33 +29,14 @@ export const forTenantQuery = defineQueryHandler({
29
29
  return resolveComplianceProfile({});
30
30
  }
31
31
 
32
- const override = parseOverride(row.override, query.user.tenantId);
32
+ const override = parseComplianceProfileOverride(
33
+ row.override,
34
+ query.user.tenantId,
35
+ "compliance-profiles:for-tenant",
36
+ );
33
37
  return resolveComplianceProfile({
34
38
  selection: row.profileKey as ComplianceProfileKey, // @cast-boundary engine-payload
35
39
  override,
36
40
  });
37
41
  },
38
42
  });
39
-
40
- function parseOverride(
41
- raw: string | null,
42
- tenantId: string,
43
- ): ComplianceProfileOverride | undefined {
44
- if (!raw || raw.trim() === "") return undefined;
45
- try {
46
- const parsed: unknown = JSON.parse(raw);
47
- return parsed as ComplianceProfileOverride; // @cast-boundary engine-payload
48
- } catch (e: unknown) {
49
- const reason = e instanceof Error ? e.message : String(e);
50
- // Defensiv: ungültiges JSON wird als "kein Override" behandelt. Der
51
- // set-profile-Handler validiert Zod das Override schon — invalides
52
- // JSON in der DB ist also nur möglich bei manueller DB-Manipulation
53
- // oder Migration-Bug. Resolver-Caller darf trotzdem nicht crashen.
54
- // Operator-Sichtbarkeit via console.warn — Telemetry-Hook spaeter.
55
- // biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
56
- console.warn(
57
- `[compliance-profiles:for-tenant] tenant ${tenantId}: stored override is not valid JSON, falling back to base profile. Reason: ${reason}`,
58
- );
59
- return undefined;
60
- }
61
- }
@@ -11,12 +11,12 @@
11
11
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
12
12
  import {
13
13
  type ComplianceProfileKey,
14
- type ComplianceProfileOverride,
15
14
  type EffectiveComplianceProfile,
16
15
  resolveComplianceProfile,
17
16
  } from "@cosmicdrift/kumiko-framework/compliance";
18
17
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
19
18
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { parseComplianceProfileOverride } from "./_internal/parse-override";
20
20
  import { tenantComplianceProfileTable } from "./schema/profile-selection";
21
21
 
22
22
  export interface ResolveProfileForTenantArgs {
@@ -35,27 +35,13 @@ export async function resolveProfileForTenant(
35
35
  return resolveComplianceProfile({});
36
36
  }
37
37
 
38
- const override = parseOverride(row.override, args.tenantId);
38
+ const override = parseComplianceProfileOverride(
39
+ row.override,
40
+ args.tenantId,
41
+ "compliance-profiles:resolve-for-tenant",
42
+ );
39
43
  return resolveComplianceProfile({
40
44
  selection: row.profileKey as ComplianceProfileKey, // @cast-boundary engine-payload
41
45
  override,
42
46
  });
43
47
  }
44
-
45
- function parseOverride(
46
- raw: string | null,
47
- tenantId: string,
48
- ): ComplianceProfileOverride | undefined {
49
- if (!raw || raw.trim() === "") return undefined;
50
- try {
51
- const parsed: unknown = JSON.parse(raw);
52
- return parsed as ComplianceProfileOverride; // @cast-boundary engine-payload
53
- } catch (e: unknown) {
54
- const reason = e instanceof Error ? e.message : String(e);
55
- // biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
56
- console.warn(
57
- `[compliance-profiles:resolve-for-tenant] tenant ${tenantId}: stored override is not valid JSON, ignoring. Reason: ${reason}`,
58
- );
59
- return undefined;
60
- }
61
- }
@@ -0,0 +1,177 @@
1
+ // Regression: deleting a tenant-scoped custom-field definition must only clear
2
+ // that tenant's rows — NOT every tenant's row that happens to use the same
3
+ // kebab fieldKey.
4
+ //
5
+ // Bug: the fieldDefinition.deleted MSP-handler stripped the jsonb key from
6
+ // every row of the host table (no tenant filter). Two tenants that each define
7
+ // their own field with the same key (e.g. "priority") share the jsonb key on
8
+ // the host-entity table; one tenant deleting their definition wiped the other
9
+ // tenant's values. The fix scopes cleanup by the deleted definition's owning
10
+ // tenant (system-scope still cascades to all).
11
+
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
15
+ import {
16
+ createEntity,
17
+ createEntityExecutor,
18
+ createTextField,
19
+ defineEntityListHandler,
20
+ defineFeature,
21
+ type SessionUser,
22
+ } from "@cosmicdrift/kumiko-framework/engine";
23
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
24
+ import {
25
+ createTestUser,
26
+ setupTestStack,
27
+ type TestStack,
28
+ testTenantId,
29
+ testUserId,
30
+ unsafeCreateEntityTable,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
32
+ import { z } from "zod";
33
+ import { fieldDefinitionEntity } from "../entity";
34
+ import { createCustomFieldsFeature } from "../feature";
35
+ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
36
+
37
+ const propertyEntity = createEntity({
38
+ table: "read_xt_properties",
39
+ fields: {
40
+ name: createTextField({ required: true }),
41
+ customFields: customFieldsField(),
42
+ },
43
+ });
44
+ const propertyTable = buildEntityTable("property", propertyEntity);
45
+
46
+ const propertyFeature = defineFeature("property-xt", (r) => {
47
+ r.entity("property", propertyEntity);
48
+ r.requires("custom-fields");
49
+ wireCustomFieldsFor(r, "property", propertyTable);
50
+
51
+ const { executor: propertyExecutor } = createEntityExecutor("property", propertyEntity);
52
+ r.writeHandler({
53
+ name: "property:create",
54
+ schema: z.object({ id: z.string(), name: z.string() }),
55
+ access: { roles: ["TenantAdmin"] },
56
+ handler: async (event, ctx) => {
57
+ const payload = event.payload as { id: string; name: string };
58
+ return propertyExecutor.create(
59
+ { id: payload.id, name: payload.name, customFields: {} },
60
+ event.user,
61
+ ctx.db,
62
+ );
63
+ },
64
+ });
65
+
66
+ r.queryHandler(
67
+ defineEntityListHandler("property", propertyEntity, { access: { roles: ["TenantAdmin"] } }),
68
+ );
69
+ });
70
+
71
+ const customFieldsFeature = createCustomFieldsFeature();
72
+
73
+ let stack: TestStack;
74
+
75
+ const adminA = createTestUser({
76
+ id: testUserId(1),
77
+ tenantId: testTenantId(1),
78
+ roles: ["TenantAdmin"],
79
+ });
80
+ const adminB = createTestUser({
81
+ id: testUserId(10),
82
+ tenantId: testTenantId(2),
83
+ roles: ["TenantAdmin"],
84
+ });
85
+
86
+ const PROP_A = "aaaaaaaa-aaaa-4000-8000-000000000001";
87
+ const PROP_B = "bbbbbbbb-bbbb-4000-8000-000000000002";
88
+
89
+ beforeAll(async () => {
90
+ stack = await setupTestStack({ features: [customFieldsFeature, propertyFeature] });
91
+ await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
92
+ await unsafeCreateEntityTable(stack.db, propertyEntity);
93
+ await createEventsTable(stack.db);
94
+ });
95
+
96
+ afterAll(async () => {
97
+ await stack.cleanup();
98
+ });
99
+
100
+ beforeEach(async () => {
101
+ await asRawClient(stack.db).unsafe(`DELETE FROM kumiko_events`);
102
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_xt_properties`);
103
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
104
+ });
105
+
106
+ async function defineField(user: SessionUser, fieldKey: string) {
107
+ return stack.http.writeOk(
108
+ "custom-fields:write:define-tenant-field",
109
+ {
110
+ entityName: "property",
111
+ fieldKey,
112
+ serializedField: { type: "text" },
113
+ required: false,
114
+ searchable: false,
115
+ displayOrder: 0,
116
+ },
117
+ user,
118
+ );
119
+ }
120
+
121
+ async function setCustomField(
122
+ user: SessionUser,
123
+ entityId: string,
124
+ fieldKey: string,
125
+ value: unknown,
126
+ ) {
127
+ return stack.http.writeOk(
128
+ "custom-fields:write:set-custom-field",
129
+ { entityName: "property", entityId, fieldKey, value },
130
+ user,
131
+ );
132
+ }
133
+
134
+ async function deleteField(user: SessionUser, fieldKey: string) {
135
+ return stack.http.writeOk(
136
+ "custom-fields:write:delete-tenant-field",
137
+ { entityName: "property", fieldKey },
138
+ user,
139
+ );
140
+ }
141
+
142
+ async function createProperty(user: SessionUser, id: string, name: string) {
143
+ return stack.http.writeOk("property-xt:write:property:create", { id, name }, user);
144
+ }
145
+
146
+ async function priorityOf(user: SessionUser, id: string): Promise<unknown> {
147
+ const { rows } = (await stack.http.queryOk("property-xt:query:property:list", {}, user)) as {
148
+ rows: Array<Record<string, unknown>>;
149
+ };
150
+ return rows.find((r) => r["id"] === id)?.["priority"];
151
+ }
152
+
153
+ describe("custom-fields cross-tenant isolation — fieldDefinition delete", () => {
154
+ test("deleting tenant A's field with a shared kebab key must NOT wipe tenant B's values", async () => {
155
+ // Both tenants independently define their own "priority" field on property.
156
+ await defineField(adminA, "priority");
157
+ await defineField(adminB, "priority");
158
+
159
+ await createProperty(adminA, PROP_A, "A-Prop");
160
+ await createProperty(adminB, PROP_B, "B-Prop");
161
+
162
+ await setCustomField(adminA, PROP_A, "priority", "A-value");
163
+ await setCustomField(adminB, PROP_B, "priority", "B-value");
164
+ await stack.eventDispatcher?.runOnce();
165
+
166
+ expect(await priorityOf(adminA, PROP_A)).toBe("A-value");
167
+ expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
168
+
169
+ // Tenant A deletes their "priority" field definition.
170
+ await deleteField(adminA, "priority");
171
+ await stack.eventDispatcher?.runOnce();
172
+
173
+ // A's value is correctly gone; B's value MUST survive (tenant-scoped cleanup).
174
+ expect(await priorityOf(adminA, PROP_A)).toBeUndefined();
175
+ expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
176
+ });
177
+ });
@@ -259,3 +259,108 @@ describe("custom-fields integration — Last-Wins on concurrent set", () => {
259
259
  expect(p6?.["status"]).toBe("published");
260
260
  });
261
261
  });
262
+
263
+ describe("custom-fields integration — value validation (Builder-Reuse)", () => {
264
+ async function setErr(entityId: string, fieldKey: string, value: unknown) {
265
+ return stack.http.writeErr(
266
+ "custom-fields:write:set-custom-field",
267
+ { entityName: "property", entityId, fieldKey, value },
268
+ admin,
269
+ );
270
+ }
271
+
272
+ async function countSetEvents(entityId: string): Promise<number> {
273
+ const rows = await asRawClient(stack.db).unsafe(
274
+ "SELECT count(*)::int AS n FROM kumiko_events WHERE aggregate_id = $1 AND type = $2",
275
+ [entityId, "custom-fields:event:custom-field-set"],
276
+ );
277
+ return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
278
+ }
279
+
280
+ async function rawCustomFields(entityId: string): Promise<Record<string, unknown>> {
281
+ const rows = await asRawClient(stack.db).unsafe(
282
+ "SELECT custom_fields FROM read_t1_properties WHERE id = $1",
283
+ [entityId],
284
+ );
285
+ const cf = (rows as ReadonlyArray<{ custom_fields: unknown }>)[0]?.custom_fields;
286
+ return cf && typeof cf === "object" && !Array.isArray(cf)
287
+ ? (cf as Record<string, unknown>)
288
+ : {};
289
+ }
290
+
291
+ test("type mismatch → 422, no event emitted, no jsonb key after projection", async () => {
292
+ const id = "77777777-7777-4000-8000-000000000007";
293
+ await defineField("property", "count", "number");
294
+ await createProperty(id, "TypeMismatch");
295
+
296
+ const err = await setErr(id, "count", "not-a-number");
297
+ expect(err.httpStatus).toBe(422);
298
+ expect(err.code).toBe("unprocessable");
299
+ expect(err.details).toMatchObject({ reason: "custom_field_value_invalid" });
300
+
301
+ // Plan-Promise: kein Event entsteht — Projection bleibt typed.
302
+ expect(await countSetEvents(id)).toBe(0);
303
+
304
+ await stack.eventDispatcher?.runOnce();
305
+ expect(await rawCustomFields(id)).not.toHaveProperty("count");
306
+ });
307
+
308
+ test("matching values pass — number/text/boolean land correctly", async () => {
309
+ const id = "88888888-8888-4000-8000-000000000008";
310
+ await defineField("property", "count", "number");
311
+ await defineField("property", "label", "text");
312
+ await defineField("property", "active", "boolean");
313
+ await createProperty(id, "Valid");
314
+
315
+ await setCustomField("property", id, "count", 42);
316
+ await setCustomField("property", id, "label", "ok");
317
+ await setCustomField("property", id, "active", true);
318
+ await stack.eventDispatcher?.runOnce();
319
+
320
+ const row = (await listProperties()).rows.find((r) => r["id"] === id);
321
+ expect(row?.["count"]).toBe(42);
322
+ expect(row?.["label"]).toBe("ok");
323
+ expect(row?.["active"]).toBe(true);
324
+ });
325
+
326
+ test("boolean field rejects a string value → 422, no event", async () => {
327
+ const id = "99999999-9999-4000-8000-000000000009";
328
+ await defineField("property", "flag", "boolean");
329
+ await createProperty(id, "BoolReject");
330
+
331
+ const err = await setErr(id, "flag", "yes");
332
+ expect(err.httpStatus).toBe(422);
333
+ expect(err.code).toBe("unprocessable");
334
+ expect(err.details).toMatchObject({ reason: "custom_field_value_invalid" });
335
+ expect(await countSetEvents(id)).toBe(0);
336
+ });
337
+
338
+ test("embedded field rejects a non-object value → 422, no event", async () => {
339
+ const id = "aaaaaaaa-aaaa-4000-8000-00000000000a";
340
+ // embedded carries a sub-field schema in serializedField — exercises
341
+ // fieldToZod's z.object(...) dispatch (the structurally complex path).
342
+ await stack.http.writeOk(
343
+ "custom-fields:write:define-tenant-field",
344
+ {
345
+ entityName: "property",
346
+ fieldKey: "geo",
347
+ serializedField: { type: "embedded", schema: { city: { type: "text", required: true } } },
348
+ required: false,
349
+ searchable: false,
350
+ displayOrder: 0,
351
+ },
352
+ admin,
353
+ );
354
+ await createProperty(id, "EmbeddedReject");
355
+
356
+ const err = await setErr(id, "geo", "not-an-object");
357
+ expect(err.httpStatus).toBe(422);
358
+ expect(err.details).toMatchObject({ reason: "custom_field_value_invalid" });
359
+ expect(await countSetEvents(id)).toBe(0);
360
+
361
+ // Positive: a matching object passes + lands.
362
+ await setCustomField("property", id, "geo", { city: "Bonn" });
363
+ await stack.eventDispatcher?.runOnce();
364
+ expect(await rawCustomFields(id)).toMatchObject({ geo: { city: "Bonn" } });
365
+ });
366
+ });