@cosmicdrift/kumiko-bundled-features 0.16.0 → 0.19.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 (106) 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/custom-fields/__tests__/cross-tenant-field-delete.integration.test.ts +177 -0
  12. package/src/custom-fields/__tests__/{custom-fields.integration.ts → custom-fields.integration.test.ts} +105 -0
  13. package/src/custom-fields/db/queries/projection.ts +33 -4
  14. package/src/custom-fields/db/queries/retention.ts +2 -2
  15. package/src/custom-fields/db/queries/user-data-rights.ts +6 -3
  16. package/src/custom-fields/feature.ts +10 -4
  17. package/src/custom-fields/handlers/delete-system-field.write.ts +5 -1
  18. package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -1
  19. package/src/custom-fields/handlers/set-custom-field.write.ts +33 -17
  20. package/src/custom-fields/lib/field-access.ts +39 -14
  21. package/src/custom-fields/lib/value-schema.ts +45 -0
  22. package/src/custom-fields/run-retention.ts +1 -1
  23. package/src/custom-fields/wire-for-entity.ts +22 -4
  24. package/src/custom-fields/wire-user-data-rights.ts +3 -2
  25. package/src/delivery/delivery-service.ts +1 -1
  26. package/src/delivery/types.ts +2 -2
  27. package/src/feature-toggles/__tests__/{feature-toggles.integration.ts → feature-toggles.integration.test.ts} +6 -6
  28. package/src/feature-toggles/handlers/set.write.ts +10 -8
  29. package/src/subscription-stripe/__tests__/{stripe-foundation.integration.ts → stripe-foundation.integration.test.ts} +7 -10
  30. package/src/tier-engine/__tests__/{resolver.integration.ts → resolver.integration.test.ts} +4 -3
  31. package/src/user-data-rights/__tests__/{audit-log.integration.ts → audit-log.integration.test.ts} +12 -5
  32. package/src/user-data-rights/__tests__/{cross-data-matrix.integration.ts → cross-data-matrix.integration.test.ts} +29 -12
  33. package/src/user-data-rights/__tests__/{download.integration.ts → download.integration.test.ts} +15 -7
  34. package/src/user-data-rights/__tests__/{export-job-idempotency.integration.ts → export-job-idempotency.integration.test.ts} +13 -11
  35. package/src/user-data-rights/__tests__/{request-cancel-deletion.integration.ts → request-cancel-deletion.integration.test.ts} +8 -7
  36. package/src/user-data-rights/__tests__/{request-deletion-callback.integration.ts → request-deletion-callback.integration.test.ts} +8 -5
  37. package/src/user-data-rights/__tests__/{request-export.integration.ts → request-export.integration.test.ts} +6 -3
  38. package/src/user-data-rights/__tests__/{restriction-flow.integration.ts → restriction-flow.integration.test.ts} +11 -8
  39. package/src/user-data-rights/__tests__/{run-export-jobs.integration.ts → run-export-jobs.integration.test.ts} +25 -13
  40. package/src/user-data-rights/__tests__/{run-forget-cleanup.integration.ts → run-forget-cleanup.integration.test.ts} +6 -3
  41. package/src/user-data-rights/__tests__/{run-user-export.integration.ts → run-user-export.integration.test.ts} +6 -3
  42. package/src/user-data-rights/__tests__/{user-data-rights.integration.ts → user-data-rights.integration.test.ts} +3 -1
  43. package/src/user-data-rights/db/queries/export-jobs.ts +6 -5
  44. package/src/user-data-rights/db/queries/forget-cleanup.ts +11 -6
  45. package/src/user-data-rights/handlers/cancel-deletion.write.ts +5 -10
  46. package/src/user-data-rights/handlers/export-status.query.ts +12 -12
  47. package/src/user-data-rights/run-export-jobs.ts +2 -5
  48. package/src/user-data-rights/run-forget-cleanup.ts +0 -1
  49. package/src/user-data-rights-defaults/__tests__/{user-data-rights-defaults.integration.ts → user-data-rights-defaults.integration.test.ts} +2 -0
  50. /package/src/__tests__/{es-ops-e2e.integration.ts → es-ops-e2e.integration.test.ts} +0 -0
  51. /package/src/audit/__tests__/{audit.integration.ts → audit.integration.test.ts} +0 -0
  52. /package/src/auth-email-password/__tests__/{account-lockout-no-redis.integration.ts → account-lockout-no-redis.integration.test.ts} +0 -0
  53. /package/src/auth-email-password/__tests__/{account-lockout.integration.ts → account-lockout.integration.test.ts} +0 -0
  54. /package/src/auth-email-password/__tests__/{auth-claims.integration.ts → auth-claims.integration.test.ts} +0 -0
  55. /package/src/auth-email-password/__tests__/{auth.integration.ts → auth.integration.test.ts} +0 -0
  56. /package/src/auth-email-password/__tests__/{email-verification.integration.ts → email-verification.integration.test.ts} +0 -0
  57. /package/src/auth-email-password/__tests__/{identity-v3-login.integration.ts → identity-v3-login.integration.test.ts} +0 -0
  58. /package/src/auth-email-password/__tests__/{invite-flow.integration.ts → invite-flow.integration.test.ts} +0 -0
  59. /package/src/auth-email-password/__tests__/{multi-roles.integration.ts → multi-roles.integration.test.ts} +0 -0
  60. /package/src/auth-email-password/__tests__/{password-reset.integration.ts → password-reset.integration.test.ts} +0 -0
  61. /package/src/auth-email-password/__tests__/{public-routes-rate-limit.integration.ts → public-routes-rate-limit.integration.test.ts} +0 -0
  62. /package/src/auth-email-password/__tests__/{seed-admin.integration.ts → seed-admin.integration.test.ts} +0 -0
  63. /package/src/auth-email-password/__tests__/{session-callbacks.integration.ts → session-callbacks.integration.test.ts} +0 -0
  64. /package/src/auth-email-password/__tests__/{session-strict-mode.integration.ts → session-strict-mode.integration.test.ts} +0 -0
  65. /package/src/auth-email-password/__tests__/{signup-flow.integration.ts → signup-flow.integration.test.ts} +0 -0
  66. /package/src/billing-foundation/__tests__/{billing-foundation.integration.ts → billing-foundation.integration.test.ts} +0 -0
  67. /package/src/compliance-profiles/__tests__/{compliance-profiles.integration.ts → compliance-profiles.integration.test.ts} +0 -0
  68. /package/src/compliance-profiles/__tests__/{seeding.integration.ts → seeding.integration.test.ts} +0 -0
  69. /package/src/config/__tests__/{cascade.integration.ts → cascade.integration.test.ts} +0 -0
  70. /package/src/config/__tests__/{config.integration.ts → config.integration.test.ts} +0 -0
  71. /package/src/custom-fields/__tests__/{audit-integration.integration.ts → audit-integration.integration.test.ts} +0 -0
  72. /package/src/custom-fields/__tests__/{field-access.integration.ts → field-access.integration.test.ts} +0 -0
  73. /package/src/custom-fields/__tests__/{quota.integration.ts → quota.integration.test.ts} +0 -0
  74. /package/src/custom-fields/__tests__/{retention.integration.ts → retention.integration.test.ts} +0 -0
  75. /package/src/custom-fields/__tests__/{user-data-rights.integration.ts → user-data-rights.integration.test.ts} +0 -0
  76. /package/src/data-retention/__tests__/{data-retention.integration.ts → data-retention.integration.test.ts} +0 -0
  77. /package/src/data-retention/__tests__/{policy-for.integration.ts → policy-for.integration.test.ts} +0 -0
  78. /package/src/delivery/__tests__/{delivery-events.integration.ts → delivery-events.integration.test.ts} +0 -0
  79. /package/src/delivery/__tests__/{delivery.integration.ts → delivery.integration.test.ts} +0 -0
  80. /package/src/file-foundation/__tests__/{file-foundation.integration.ts → file-foundation.integration.test.ts} +0 -0
  81. /package/src/files/__tests__/{files.integration.ts → files.integration.test.ts} +0 -0
  82. /package/src/files-provider-s3/__tests__/{s3-provider.integration.ts → s3-provider.integration.test.ts} +0 -0
  83. /package/src/jobs/__tests__/{job-system-user.integration.ts → job-system-user.integration.test.ts} +0 -0
  84. /package/src/jobs/__tests__/{jobs-events.integration.ts → jobs-events.integration.test.ts} +0 -0
  85. /package/src/jobs/__tests__/{jobs-feature.integration.ts → jobs-feature.integration.test.ts} +0 -0
  86. /package/src/legal-pages/__tests__/{legal-pages.integration.ts → legal-pages.integration.test.ts} +0 -0
  87. /package/src/mail-foundation/__tests__/{mail-foundation.integration.ts → mail-foundation.integration.test.ts} +0 -0
  88. /package/src/rate-limiting/__tests__/{rate-limiting.integration.ts → rate-limiting.integration.test.ts} +0 -0
  89. /package/src/renderer-foundation/__tests__/{collect-plugins.integration.ts → collect-plugins.integration.test.ts} +0 -0
  90. /package/src/secrets/__tests__/{rotate.integration.ts → rotate.integration.test.ts} +0 -0
  91. /package/src/secrets/__tests__/{secrets-events.integration.ts → secrets-events.integration.test.ts} +0 -0
  92. /package/src/secrets/__tests__/{secrets.integration.ts → secrets.integration.test.ts} +0 -0
  93. /package/src/sessions/__tests__/{cleanup.integration.ts → cleanup.integration.test.ts} +0 -0
  94. /package/src/sessions/__tests__/{password-auto-revoke.integration.ts → password-auto-revoke.integration.test.ts} +0 -0
  95. /package/src/sessions/__tests__/{sessions.integration.ts → sessions.integration.test.ts} +0 -0
  96. /package/src/subscription-mollie/__tests__/{mollie-foundation.integration.ts → mollie-foundation.integration.test.ts} +0 -0
  97. /package/src/template-resolver/__tests__/{handlers.integration.ts → handlers.integration.test.ts} +0 -0
  98. /package/src/template-resolver/__tests__/{template-resolver.integration.ts → template-resolver.integration.test.ts} +0 -0
  99. /package/src/tenant/__tests__/{multi-tenant.integration.ts → multi-tenant.integration.test.ts} +0 -0
  100. /package/src/tenant/__tests__/{seed-testing.integration.ts → seed-testing.integration.test.ts} +0 -0
  101. /package/src/tenant/__tests__/{tenant.integration.ts → tenant.integration.test.ts} +0 -0
  102. /package/src/text-content/__tests__/{text-content.integration.ts → text-content.integration.test.ts} +0 -0
  103. /package/src/tier-engine/__tests__/{auto-default-tier.integration.ts → auto-default-tier.integration.test.ts} +0 -0
  104. /package/src/tier-engine/__tests__/{tier-engine.integration.ts → tier-engine.integration.test.ts} +0 -0
  105. /package/src/user/__tests__/{seed-testing.integration.ts → seed-testing.integration.test.ts} +0 -0
  106. /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.16.0",
3
+ "version": "0.19.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,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
+ });
@@ -5,18 +5,27 @@ function quoteTable(tableName: string): string {
5
5
  return `"${tableName.replace(/"/g, '""')}"`;
6
6
  }
7
7
 
8
+ function bindJsonbParam(value: unknown): { sql: string; bound: unknown } {
9
+ // postgres-js infers boolean params as boolean[] candidates — route via text::jsonb.
10
+ if (typeof value === "boolean") {
11
+ return { sql: "$1::text::jsonb", bound: JSON.stringify(value) };
12
+ }
13
+ return { sql: "$1::jsonb", bound: value };
14
+ }
15
+
8
16
  export async function setCustomFieldValue(
9
17
  db: DbRunner,
10
18
  tableName: string,
11
19
  fieldKey: string,
12
- valueJson: string,
20
+ value: unknown,
13
21
  aggregateId: string,
14
22
  ): Promise<void> {
15
23
  const tbl = quoteTable(tableName);
16
24
  const escapedKey = fieldKey.replace(/'/g, "''");
25
+ const jsonb = bindJsonbParam(value);
17
26
  await asRawClient(db).unsafe(
18
- `UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', $1::jsonb, true) WHERE id = $2`,
19
- [valueJson, aggregateId],
27
+ `UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', ${jsonb.sql}, true) WHERE id = $2`,
28
+ [jsonb.bound, aggregateId],
20
29
  );
21
30
  }
22
31
 
@@ -33,7 +42,27 @@ export async function clearCustomFieldKey(
33
42
  );
34
43
  }
35
44
 
36
- export async function removeCustomFieldKeyFromAllRows(
45
+ // Tenant-scoped orphan-cleanup: removes the jsonb key only from the deleting
46
+ // tenant's rows. This is the default path for tenant-field deletions — without
47
+ // the tenant_id filter, deleting tenant A's field strips the same kebab key
48
+ // from every tenant's rows (cross-tenant data loss).
49
+ export async function removeCustomFieldKeyForTenant(
50
+ db: DbRunner,
51
+ tableName: string,
52
+ fieldKey: string,
53
+ tenantId: string,
54
+ ): Promise<void> {
55
+ const tbl = quoteTable(tableName);
56
+ await asRawClient(db).unsafe(
57
+ `UPDATE ${tbl} SET custom_fields = custom_fields - $1 WHERE tenant_id = $2`,
58
+ [fieldKey, tenantId],
59
+ );
60
+ }
61
+
62
+ // Cross-tenant cleanup: strips the key from EVERY tenant's rows. Only valid for
63
+ // system-scope field-definition deletions (the field applied to all tenants).
64
+ // Never call this for a tenant-scoped deletion — use removeCustomFieldKeyForTenant.
65
+ export async function removeCustomFieldKeyFromAllTenants(
37
66
  db: DbRunner,
38
67
  tableName: string,
39
68
  fieldKey: string,
@@ -28,12 +28,12 @@ export async function selectHostRowsWithCustomFields(
28
28
  export async function updateHostRowCustomFields(
29
29
  db: DbRunner,
30
30
  tableName: string,
31
- customFieldsJson: string,
31
+ customFields: Record<string, unknown>,
32
32
  rowId: string,
33
33
  ): Promise<void> {
34
34
  const quoted = `"${tableName.replace(/"/g, '""')}"`;
35
35
  await asRawClient(db).unsafe(`UPDATE ${quoted} SET custom_fields = $1::jsonb WHERE id = $2`, [
36
- customFieldsJson,
36
+ customFields,
37
37
  rowId,
38
38
  ]);
39
39
  }
@@ -35,10 +35,13 @@ export async function stripSensitiveCustomFieldKeys(
35
35
  ): Promise<void> {
36
36
  const tbl = quoteTable(tableName);
37
37
  const userCol = quoteColumn(userIdColumn);
38
- const placeholders = sensitiveKeys.map((_, i) => `$${i + 1}`).join(" - ");
39
38
  await asRawClient(db).unsafe(
40
- `UPDATE ${tbl} SET custom_fields = custom_fields - ${placeholders} WHERE ${userCol} = $${sensitiveKeys.length + 1} AND tenant_id = $${sensitiveKeys.length + 2}`,
41
- [...sensitiveKeys, userId, tenantId],
39
+ `UPDATE ${tbl} SET custom_fields = CASE
40
+ WHEN jsonb_typeof(custom_fields) = 'object' THEN custom_fields - $1::text[]
41
+ ELSE custom_fields
42
+ END
43
+ WHERE ${userCol} = $2 AND tenant_id = $3`,
44
+ [sensitiveKeys, userId, tenantId],
42
45
  );
43
46
  }
44
47
 
@@ -30,12 +30,12 @@
30
30
  // hand-gebauten Template-Literals mehr (T1 hat den toKebab-collapse-drift
31
31
  // aufgedeckt, siehe Memory feedback_event_def_exports_pattern).
32
32
  //
33
- // **Out-of-B2 (future iterations)**:
33
+ // **Noch offen (future iterations)**:
34
34
  // - Cross-scope-conflict-Detection (Tenant überschreibt system fieldKey)
35
- // - cap-counter quota-Check beim fieldDefinition-create
36
- // - user-data-rights anonymization-Wiring für sensitive customFields
37
- // - Value-Validation gegen fieldDefinition.serializedField
38
35
  // - Cross-Scope-Read-UNION (system + tenant fieldDefinitions in einem List)
36
+ // (cap-counter-quota → T1.5e, user-data-rights-anonymization → T1.5c,
37
+ // Value-Validation gegen serializedField → set-custom-field via fieldToZod —
38
+ // alle erledigt.)
39
39
 
40
40
  import { defineEntityListHandler, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
41
41
  import { z } from "zod";
@@ -63,6 +63,12 @@ const tenantAdminAccess = { access: { roles: ["TenantAdmin"] } } as const;
63
63
  const fieldDefinitionDeletedSchema = z.object({
64
64
  entityName: z.string(),
65
65
  fieldKey: z.string(),
66
+ // Owning tenant of the deleted definition: a specific tenant for tenant-scope
67
+ // deletions, SYSTEM_TENANT_ID for system-scope. The cascade-MSP scopes its
68
+ // orphan-cleanup by this so a tenant deletion never touches other tenants'
69
+ // rows. Optional for backward-compat with events appended before this field
70
+ // existed — the MSP falls back to the event's stream tenantId.
71
+ tenantId: z.string().optional(),
66
72
  });
67
73
 
68
74
  // Singleton feature-definition mit typed exports. Handler + wire-for-entity
@@ -38,7 +38,11 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
38
38
  aggregateId,
39
39
  aggregateType: "field-definition",
40
40
  type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
41
- payload: { entityName: payload.entityName, fieldKey: payload.fieldKey },
41
+ payload: {
42
+ entityName: payload.entityName,
43
+ fieldKey: payload.fieldKey,
44
+ tenantId: SYSTEM_TENANT_ID,
45
+ },
42
46
  });
43
47
  }
44
48
 
@@ -46,7 +46,7 @@ export const deleteTenantFieldHandler: WriteHandlerDef = {
46
46
  aggregateId,
47
47
  aggregateType: "field-definition",
48
48
  type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
49
- payload: { entityName: payload.entityName, fieldKey: payload.fieldKey },
49
+ payload: { entityName: payload.entityName, fieldKey: payload.fieldKey, tenantId },
50
50
  });
51
51
  }
52
52