@cosmicdrift/kumiko-bundled-features 0.16.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 (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
@@ -2,7 +2,8 @@ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { z } from "zod";
4
4
  import { customFieldsFeature } from "../feature";
5
- import { checkFieldAccessForWrite } from "../lib/field-access";
5
+ import { fieldWriteAccessDeniedRoles, loadFieldDefinition } from "../lib/field-access";
6
+ import { buildCustomFieldValueSchema } from "../lib/value-schema";
6
7
 
7
8
  export const setCustomFieldPayloadSchema = z.object({
8
9
  entityName: z.string().min(1).max(64),
@@ -24,16 +25,18 @@ export type SetCustomFieldPayload = z.infer<typeof setCustomFieldPayloadSchema>;
24
25
  // concurrent writes auf gleiches Field gehen beide durch (Plan-Doc v2
25
26
  // Concurrency-Tabelle).
26
27
  //
27
- // **WAS DIESER HANDLER NICHT MACHT (yet)**:
28
- // - Validation des Werts gegen fieldDefinition-type (B2-todo: rehydriere
29
- // r.field.X() aus serializedField, .schema.safeParse(value))
30
- // - cap-counter-quota-Check (T1.5e)
31
- // Diese Aspekte kommen als Folgekommits oder durch consumer-side hooks.
28
+ // **Write-Pfad (Single-Fetch)** eine fieldDefinition-Ladung, drei Gates:
29
+ // 1. Definition fehlt 404.
30
+ // 2. field-access (T1.5b): fieldAccess.write-Rollen müssen intersecten, sonst
31
+ // 403/422. Handler-level RBAC (TenantAdmin/Member) bleibt zusätzlich.
32
+ // 3. Value-Validation (Builder-Reuse): der Wert wird gegen das aus
33
+ // serializedField rehydrierte fieldToZod-Schema geparst. Type-Mismatch →
34
+ // 422, KEIN Event entsteht (Projection bleibt typed — Plan-Doc
35
+ // Stammfeld-Identität). `value: null` auf einem typisierten Feld ist ein
36
+ // Type-Mismatch → 422; zum Entfernen eines Werts dient clear-custom-field.
32
37
  //
33
- // **WAS NEU IST (T1.5b)**:
34
- // - field-access-check: wenn fieldDefinition.serializedField.fieldAccess.write
35
- // gesetzt ist, muss der calling user mindestens eine der Rollen halten.
36
- // Handler-level RBAC (TenantAdmin/Member) bleibt zusätzlich.
38
+ // Scope: NUR Type-Validation. Required-on-set + Default-Application sind
39
+ // out-of-scope (Plan-Doc "Stammfeld-Identität" listet sie als eigene Zeilen).
37
40
  export const setCustomFieldHandler: WriteHandlerDef = {
38
41
  name: "set-custom-field",
39
42
  schema: setCustomFieldPayloadSchema,
@@ -41,23 +44,36 @@ export const setCustomFieldHandler: WriteHandlerDef = {
41
44
  handler: async (event, ctx) => {
42
45
  const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
43
46
 
44
- const accessCheck = await checkFieldAccessForWrite(
47
+ const loaded = await loadFieldDefinition(
45
48
  ctx.db,
46
49
  event.user.tenantId,
47
50
  payload.entityName,
48
51
  payload.fieldKey,
49
- event.user.roles,
50
52
  );
51
- if (!accessCheck.ok) {
52
- if (accessCheck.reason === "field_definition_not_found") {
53
- return failNotFound("fieldDefinition", payload.fieldKey);
54
- }
53
+ if (!loaded.found) {
54
+ return failNotFound("fieldDefinition", payload.fieldKey);
55
+ }
56
+
57
+ const deniedRoles = fieldWriteAccessDeniedRoles(loaded.field, event.user.roles);
58
+ if (deniedRoles) {
55
59
  return failUnprocessable("field_access_denied", {
56
60
  fieldKey: payload.fieldKey,
57
- requiredRoles: accessCheck.requiredRoles ?? [],
61
+ requiredRoles: deniedRoles,
58
62
  });
59
63
  }
60
64
 
65
+ const valueSchema = buildCustomFieldValueSchema(loaded.field);
66
+ if (valueSchema) {
67
+ const parsed = valueSchema.safeParse(payload.value);
68
+ if (!parsed.success) {
69
+ return failUnprocessable("custom_field_value_invalid", {
70
+ fieldKey: payload.fieldKey,
71
+ fieldType: loaded.field?.type,
72
+ issues: parsed.error.issues.map((i) => i.message),
73
+ });
74
+ }
75
+ }
76
+
61
77
  // Emit customField.set on host-aggregate stream. unsafeAppendEvent
62
78
  // (statt strict appendEvent) weil event-type-map keine cross-feature-
63
79
  // augmentation für diesen event-typ hat — wir nutzen den qualified
@@ -1,8 +1,10 @@
1
1
  // T1.5b — per-field write access-check for the set/clear handlers.
2
+ // Plus single-fetch loader so set-custom-field can run access-check AND
3
+ // value-validation off one DB read (no double fetch).
2
4
 
3
5
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
4
6
  import { selectSerializedFieldDefinition } from "../db/queries/field-access";
5
- import { parseSerializedField } from "./parse-serialized-field";
7
+ import { parseSerializedField, type SerializedFieldShape } from "./parse-serialized-field";
6
8
 
7
9
  export type FieldAccessCheckResult =
8
10
  | { ok: true }
@@ -12,6 +14,37 @@ export type FieldAccessCheckResult =
12
14
  requiredRoles?: ReadonlyArray<string>;
13
15
  };
14
16
 
17
+ export type LoadedFieldDefinition =
18
+ | { found: false }
19
+ // `field` is null when the row exists but its serialized_field is corrupt —
20
+ // callers treat that as "no restriction / no schema" (lenient), distinct
21
+ // from `found: false` (no definition → 404).
22
+ | { found: true; field: SerializedFieldShape | null };
23
+
24
+ export async function loadFieldDefinition(
25
+ db: TenantDb,
26
+ tenantId: string,
27
+ entityName: string,
28
+ fieldKey: string,
29
+ ): Promise<LoadedFieldDefinition> {
30
+ const serialized = await selectSerializedFieldDefinition(db, tenantId, entityName, fieldKey);
31
+ if (serialized === null) return { found: false };
32
+ return { found: true, field: parseSerializedField(serialized) };
33
+ }
34
+
35
+ // Pure access-check on an already-loaded definition. Returns the required
36
+ // roles when the caller is denied, or `null` when access is allowed.
37
+ export function fieldWriteAccessDeniedRoles(
38
+ field: SerializedFieldShape | null,
39
+ userRoles: ReadonlyArray<string>,
40
+ ): ReadonlyArray<string> | null {
41
+ const required = field?.fieldAccess?.write;
42
+ if (!required || required.length === 0) return null;
43
+ return userRoles.some((role) => required.includes(role)) ? null : required;
44
+ }
45
+
46
+ // Convenience wrapper retained for clear-custom-field (no value-validation
47
+ // needed there) — does the load + access-check in one call.
15
48
  export async function checkFieldAccessForWrite(
16
49
  db: TenantDb,
17
50
  tenantId: string,
@@ -19,18 +52,10 @@ export async function checkFieldAccessForWrite(
19
52
  fieldKey: string,
20
53
  userRoles: ReadonlyArray<string>,
21
54
  ): Promise<FieldAccessCheckResult> {
22
- const serialized = await selectSerializedFieldDefinition(db, tenantId, entityName, fieldKey);
23
- if (serialized === null) {
24
- return { ok: false, reason: "field_definition_not_found" };
25
- }
26
-
27
- const parsed = parseSerializedField(serialized);
28
- if (!parsed) return { ok: true };
55
+ const loaded = await loadFieldDefinition(db, tenantId, entityName, fieldKey);
56
+ if (!loaded.found) return { ok: false, reason: "field_definition_not_found" };
29
57
 
30
- const required = parsed.fieldAccess?.write;
31
- if (!required || required.length === 0) {
32
- return { ok: true };
33
- }
34
- const hit = userRoles.some((role) => required.includes(role));
35
- return hit ? { ok: true } : { ok: false, reason: "field_access_denied", requiredRoles: required };
58
+ const deniedRoles = fieldWriteAccessDeniedRoles(loaded.field, userRoles);
59
+ if (!deniedRoles) return { ok: true };
60
+ return { ok: false, reason: "field_access_denied", requiredRoles: deniedRoles };
36
61
  }
@@ -0,0 +1,45 @@
1
+ import {
2
+ DEFAULT_CURRENCIES,
3
+ type FieldDefinition,
4
+ fieldToZod,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import type { z } from "zod";
7
+
8
+ // Builds a Zod schema that validates a custom-field VALUE against its
9
+ // fieldDefinition. Reuses the framework's `fieldToZod` (Builder-Reuse /
10
+ // Stammfeld-Identität, Plan-Doc) — one field-type-schema source, no drift
11
+ // between Stammfeld- and Custom-Field-validation.
12
+ //
13
+ // Vocabulary bridge: custom-fields expose `enum` (Plan + `r.field.enum([...])`)
14
+ // where the framework's FieldDefinition calls the equivalent type `select`
15
+ // with an `options` array. This single boundary translates it; everything
16
+ // else is already FieldDefinition-shaped (the serialized dehydrated builder).
17
+ //
18
+ // Returns `null` when the serialized field is unparseable or names a type
19
+ // `fieldToZod` cannot interpret — callers then skip value-validation rather
20
+ // than hard-rejecting a field they cannot understand.
21
+ export function buildCustomFieldValueSchema(parsedField: unknown): z.ZodTypeAny | null {
22
+ if (!parsedField || typeof parsedField !== "object") return null;
23
+ const obj = parsedField as Record<string, unknown>;
24
+ if (typeof obj["type"] !== "string") return null;
25
+
26
+ const fieldDef =
27
+ obj["type"] === "enum"
28
+ ? { ...obj, type: "select", options: obj["values"] ?? obj["options"] ?? [] }
29
+ : obj;
30
+
31
+ // fieldToZod's money case validates `currency` against the passed list, not
32
+ // a field-level key — so surface the field's own currency when it declares
33
+ // one, else fall back to the framework defaults.
34
+ const currencies =
35
+ typeof obj["currency"] === "string" ? [obj["currency"] as string] : DEFAULT_CURRENCIES;
36
+
37
+ try {
38
+ // @cast-boundary serialized-field is the dehydrated r.field.X() output =
39
+ // a FieldDefinition; fieldToZod reads only its type-specific keys (the
40
+ // extra fieldAccess/sensitive/retention/label keys are ignored).
41
+ return fieldToZod(fieldDef as unknown as FieldDefinition, currencies);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
@@ -132,7 +132,7 @@ export async function runCustomFieldsRetention(
132
132
  removalsByFieldKey[key] = (removalsByFieldKey[key] ?? 0) + 1;
133
133
  }
134
134
 
135
- await updateHostRowCustomFields(opts.db, tableName, JSON.stringify(mutated), row.id);
135
+ await updateHostRowCustomFields(opts.db, tableName, mutated, row.id);
136
136
  rowsUpdated++;
137
137
  }
138
138
 
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  createJsonbField,
3
3
  type FeatureRegistrar,
4
+ isSystemTenant,
4
5
  type JsonbFieldDef,
6
+ type TenantId,
5
7
  } from "@cosmicdrift/kumiko-framework/engine";
6
8
  import { CUSTOM_FIELDS_EXTENSION } from "./constants";
7
9
  import {
8
10
  clearCustomFieldKey,
9
- removeCustomFieldKeyFromAllRows,
11
+ removeCustomFieldKeyForTenant,
12
+ removeCustomFieldKeyFromAllTenants,
10
13
  setCustomFieldValue,
11
14
  } from "./db/queries/projection";
12
15
 
@@ -109,7 +112,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
109
112
  tx,
110
113
  tableName,
111
114
  payload.fieldKey,
112
- JSON.stringify(payload.value),
115
+ payload.value,
113
116
  event.aggregateId,
114
117
  );
115
118
  },
@@ -127,14 +130,29 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
127
130
  // fieldDefinition.deleted fires nur einmal pro fieldDef-delete
128
131
  // (NICHT per-entity). Wir entfernen den key aus ALLEN rows der host-
129
132
  // entity falls die deleted-fieldDef für diese entity galt.
130
- const payload = event.payload as { entityName: string; fieldKey: string }; // @cast-boundary engine-payload
133
+ const payload = event.payload as {
134
+ entityName: string;
135
+ fieldKey: string;
136
+ tenantId?: TenantId;
137
+ }; // @cast-boundary engine-payload
131
138
  // skip: fieldDefinition.deleted feuert für ALLE fieldDefs cross-entity;
132
139
  // nur wenn die deleted-fieldDef diese host-entity betraf, cleanen wir
133
140
  // ihre Rows.
134
141
  if (payload.entityName !== entityName) return;
135
142
 
136
143
  const tableName = getTableName(entityTable);
137
- await removeCustomFieldKeyFromAllRows(tx, tableName, payload.fieldKey);
144
+ // Scope cleanup to the deleted definition's owning tenant. System-scope
145
+ // definitions apply to every tenant → cascade across all rows; tenant-
146
+ // scope deletions must only touch that tenant's rows, else deleting one
147
+ // tenant's field strips the same kebab key from every tenant (data loss).
148
+ // Fallback to the event's stream tenantId for events appended before the
149
+ // payload carried tenantId.
150
+ const defTenantId = payload.tenantId ?? event.tenantId;
151
+ if (isSystemTenant(defTenantId)) {
152
+ await removeCustomFieldKeyFromAllTenants(tx, tableName, payload.fieldKey);
153
+ } else {
154
+ await removeCustomFieldKeyForTenant(tx, tableName, payload.fieldKey, defTenantId);
155
+ }
138
156
  },
139
157
  },
140
158
  });
@@ -32,8 +32,9 @@ interface CustomFieldsHostRow {
32
32
  function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
33
33
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
34
34
  if (!("id" in value) || typeof value.id !== "string") return null;
35
- if (!("custom_fields" in value)) return null;
36
- const cf = value.custom_fields;
35
+ const record = value as Record<string, unknown>;
36
+ const cf = record["custom_fields"] ?? record["customFields"];
37
+ if (cf === undefined) return null;
37
38
  if (cf === null) return { id: value.id, customFields: null };
38
39
  if (!cf || typeof cf !== "object" || Array.isArray(cf)) return null;
39
40
  return { id: value.id, customFields: Object.fromEntries(Object.entries(cf)) };
@@ -153,7 +153,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
153
153
  }
154
154
 
155
155
  function buildChannelContext(tenantId: TenantId): ChannelContext {
156
- return { db, registry, sseBroker, tenantId };
156
+ return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId };
157
157
  }
158
158
 
159
159
  async function logDelivery(entry: DeliveryLogEntry): Promise<void> {
@@ -1,5 +1,5 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type {
4
4
  NotifyOptions,
5
5
  Registry,
@@ -10,7 +10,7 @@ import type {
10
10
  // --- Channel Interface ---
11
11
 
12
12
  export type ChannelContext = {
13
- readonly db: DbConnection;
13
+ readonly db: TenantDb;
14
14
  readonly registry: Registry;
15
15
  readonly sseBroker: SseBroker | undefined;
16
16
  readonly tenantId: TenantId;
@@ -16,6 +16,7 @@ import {
16
16
  type FeatureDefinition,
17
17
  SYSTEM_TENANT_ID,
18
18
  } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
19
20
  import { createEventDispatcher, type EventConsumer } from "@cosmicdrift/kumiko-framework/pipeline";
20
21
  import {
21
22
  createTestUser,
@@ -517,12 +518,11 @@ describe("feature-toggles queries + audit automation", () => {
517
518
  admin,
518
519
  );
519
520
 
520
- const events = (await asRawClient(stack.db).unsafe(
521
- `SELECT type, payload FROM kumiko_events WHERE type = 'feature-toggles:event:toggle-set'`,
522
- )) as unknown as readonly {
523
- type: string;
524
- payload: Record<string, unknown>;
525
- }[];
521
+ const events = await selectMany<{ type: string; payload: Record<string, unknown> }>(
522
+ stack.db,
523
+ eventsTable,
524
+ { type: "feature-toggles:event:toggle-set" },
525
+ );
526
526
 
527
527
  expect(events).toHaveLength(1);
528
528
  expect(events[0]?.payload).toMatchObject({
@@ -12,7 +12,6 @@ import {
12
12
  FEATURE_TOGGLE_SET_EVENT_NAME,
13
13
  FeatureToggleErrors,
14
14
  } from "../constants";
15
- import { updateFeatureToggleOptimistic } from "../db/queries/toggle-state";
16
15
  import { globalFeatureStateTable } from "../global-feature-state-table";
17
16
  import type { GlobalFeatureToggleRuntime } from "../toggle-runtime";
18
17
 
@@ -101,13 +100,16 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
101
100
  // Upsert with optimistic lock. Two operators flipping the same
102
101
  // toggle simultaneously is rare but possible — the version-WHERE
103
102
  // ensures only one wins; the loser sees VersionConflictError.
104
- const updated = await updateFeatureToggleOptimistic(ctx.db, {
105
- enabled,
106
- updatedBy: event.user.id,
107
- updatedAt: Temporal.Now.instant(),
108
- featureName,
109
- expectedVersion: existing.version,
110
- });
103
+ const updated = await ctx.db.updateMany(
104
+ globalFeatureStateTable,
105
+ {
106
+ enabled,
107
+ updatedBy: event.user.id,
108
+ updatedAt: Temporal.Now.instant(),
109
+ version: existing.version + 1,
110
+ },
111
+ { featureName, version: existing.version },
112
+ );
111
113
 
112
114
  if (updated.length === 0) {
113
115
  return writeFailure(
@@ -142,10 +142,10 @@ function buildStripeSubscriptionEvent(overrides: {
142
142
  };
143
143
  }
144
144
 
145
- function signEvent(payload: string): string {
146
- return stripeForFixtures.webhooks.generateTestHeaderString({
145
+ async function signEvent(payload: string, secret = TEST_SECRET): Promise<string> {
146
+ return stripeForFixtures.webhooks.generateTestHeaderStringAsync({
147
147
  payload,
148
- secret: TEST_SECRET,
148
+ secret,
149
149
  });
150
150
  }
151
151
 
@@ -172,7 +172,7 @@ describe("scenario 1: Stripe-event → DB happy path", () => {
172
172
  priceId: "price_business_yearly",
173
173
  });
174
174
  const payload = JSON.stringify(stripeEvent);
175
- const sig = signEvent(payload);
175
+ const sig = await signEvent(payload);
176
176
 
177
177
  const res = await postStripeWebhook(payload, sig);
178
178
  expect(res.status).toBe(200);
@@ -225,10 +225,7 @@ describe("scenario 2: invalid sig → 401, kein DB-write", () => {
225
225
  });
226
226
  const payload = JSON.stringify(stripeEvent);
227
227
  // Wrong secret = invalid sig.
228
- const wrongSig = stripeForFixtures.webhooks.generateTestHeaderString({
229
- payload,
230
- secret: "whsec_wrong_secret",
231
- });
228
+ const wrongSig = await signEvent(payload, "whsec_wrong_secret");
232
229
 
233
230
  const res = await postStripeWebhook(payload, wrongSig);
234
231
  expect(res.status).toBe(401);
@@ -260,7 +257,7 @@ describe("scenario 3: idempotency via Stripe-retry", () => {
260
257
  subscriptionId: "sub_4003",
261
258
  });
262
259
  const payload = JSON.stringify(stripeEvent);
263
- const sig = signEvent(payload);
260
+ const sig = await signEvent(payload);
264
261
 
265
262
  const res1 = await postStripeWebhook(payload, sig);
266
263
  expect(res1.status).toBe(200);
@@ -292,7 +289,7 @@ describe("scenario 4: ignored event-types pass through", () => {
292
289
  tenantId: tenantStringId,
293
290
  });
294
291
  const payload = JSON.stringify(stripeEvent);
295
- const sig = signEvent(payload);
292
+ const sig = await signEvent(payload);
296
293
 
297
294
  const res = await postStripeWebhook(payload, sig);
298
295
  expect(res.status).toBe(200);
@@ -52,6 +52,7 @@ const TEST_TIER_MAP: TierMap<TestCaps> = {
52
52
  // Toy-feature mit einem tenantadmin-only-handler. Wenn das feature im
53
53
  // effective-Set ist, dispatcher passt durch — wenn nicht, 403 feature_disabled.
54
54
  const featProFeature = defineFeature("feat-pro", (r) => {
55
+ r.toggleable({ default: false });
55
56
  r.queryHandler(
56
57
  "ping",
57
58
  {
@@ -174,10 +175,10 @@ describe("createTierEngineFeature — per-tenant resolver", () => {
174
175
  const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
175
176
  const systemSet = resolver(SYSTEM_TENANT_ID);
176
177
 
177
- // Union: feat-pro (in pro+business) + feat-business (in business only).
178
+ // Union of tier-map features (feat-pro in pro+business, feat-business in business).
178
179
  expect(systemSet.has("feat-pro")).toBe(true);
179
180
  expect(systemSet.has("feat-business")).toBe(true);
180
- // free has no features nothing extra
181
- expect(systemSet.size).toBe(2);
181
+ // SYSTEM tenant also receives always-on non-toggleable features from includeBundled.
182
+ expect(systemSet.size).toBeGreaterThanOrEqual(2);
182
183
  });
183
184
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { asRawClient, insertMany, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
5
- import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
5
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
6
6
  import {
7
7
  createTestUser,
8
8
  setupTestStack,
@@ -10,11 +10,14 @@ import {
10
10
  testTenantId,
11
11
  unsafeCreateEntityTable,
12
12
  } from "@cosmicdrift/kumiko-framework/stack";
13
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
13
14
  import {
14
15
  createComplianceProfilesFeature,
15
16
  tenantComplianceProfileEntity,
17
+ tenantComplianceProfileTable,
16
18
  } from "../../compliance-profiles";
17
19
  import { createDataRetentionFeature } from "../../data-retention";
20
+ import { createSessionsFeature } from "../../sessions";
18
21
  import { USER_STATUS, userEntity, userTable } from "../../user";
19
22
  import { createUserFeature } from "../../user/feature";
20
23
  import { createUserDataRightsFeature } from "../feature";
@@ -36,6 +39,8 @@ beforeAll(async () => {
36
39
  createUserFeature(),
37
40
  createDataRetentionFeature(),
38
41
  createComplianceProfilesFeature(),
42
+ createSessionsFeature(),
43
+
39
44
  createUserDataRightsFeature(),
40
45
  ],
41
46
  });
@@ -50,10 +55,12 @@ afterAll(async () => {
50
55
  });
51
56
 
52
57
  beforeEach(async () => {
53
- await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
54
- await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_compliance_profiles`);
55
- await asRawClient(stack.db).unsafe(`DELETE FROM read_download_attempts`);
56
- await asRawClient(stack.db).unsafe(`DELETE FROM kumiko_events`);
58
+ await resetTestTables(stack.db, [
59
+ userTable,
60
+ tenantComplianceProfileTable,
61
+ downloadAttemptsTable,
62
+ eventsTable,
63
+ ]);
57
64
  });
58
65
 
59
66
  async function seedUser(u: typeof alice, email: string): Promise<void> {
@@ -13,18 +13,20 @@
13
13
  // Alices Forget; Bobs Daten landen NICHT in Alices Export-Bundle.
14
14
 
15
15
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
16
- import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { asRawClient, deleteMany, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
17
17
  import {
18
18
  defineFeature,
19
19
  EXT_USER_DATA,
20
20
  type UserDataDeleteHook,
21
21
  type UserDataExportHook,
22
22
  } from "@cosmicdrift/kumiko-framework/engine";
23
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
23
24
  import {
24
25
  setupTestStack,
25
26
  type TestStack,
26
27
  unsafeCreateEntityTable,
27
28
  } from "@cosmicdrift/kumiko-framework/stack";
29
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
28
30
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
29
31
  import {
30
32
  createComplianceProfilesFeature,
@@ -32,6 +34,8 @@ import {
32
34
  } from "../../compliance-profiles";
33
35
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
34
36
  import { createFilesFeature } from "../../files";
37
+ import { createSessionsFeature } from "../../sessions";
38
+ import { tenantMembershipsTable } from "../../tenant";
35
39
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
36
40
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
37
41
  import { createUserDataRightsFeature } from "../feature";
@@ -55,6 +59,18 @@ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
55
59
  const NOW = (): Instant => getTemporal().Now.instant();
56
60
  const PAST = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
57
61
 
62
+ const KUMIKO_NAME = Symbol.for("kumiko:schema:Name");
63
+ const KUMIKO_COLUMNS = Symbol.for("kumiko:schema:Columns");
64
+
65
+ /** Minimal bun-db table descriptor for the synthetic test_notes table. */
66
+ const testNotesTable = {
67
+ [KUMIKO_NAME]: "test_notes",
68
+ [KUMIKO_COLUMNS]: {
69
+ tenantId: { name: "tenant_id", getSQLType: () => "uuid" },
70
+ authorId: { name: "author_id", getSQLType: () => "text" },
71
+ },
72
+ };
73
+
58
74
  // Synthetic third-party Domain-Feature: "note" mit export- + delete-Hook.
59
75
  // Stellvertretend fuer App-spezifische Entities (Chat-Message, Blog-Post
60
76
  // etc.), die ueber EXT_USER_DATA sauber in die Pipeline integrieren.
@@ -81,13 +97,10 @@ const exportNotes: UserDataExportHook = async (ctx) => {
81
97
  };
82
98
 
83
99
  const deleteNotes: UserDataDeleteHook = async (ctx, _strategy) => {
84
- await asRawClient(ctx.db).unsafe(
85
- `
86
- DELETE FROM test_notes
87
- WHERE tenant_id = $1 AND author_id = $2
88
- `,
89
- [ctx.tenantId, ctx.userId],
90
- );
100
+ await deleteMany(ctx.db, testNotesTable, {
101
+ tenantId: ctx.tenantId,
102
+ authorId: ctx.userId,
103
+ });
91
104
  };
92
105
 
93
106
  const testNotesFeature = defineFeature("test-notes", (r) => {
@@ -104,6 +117,8 @@ beforeAll(async () => {
104
117
  createFilesFeature(),
105
118
  createDataRetentionFeature(),
106
119
  createComplianceProfilesFeature(),
120
+ createSessionsFeature(),
121
+
107
122
  createUserDataRightsFeature(),
108
123
  createUserDataRightsDefaultsFeature(),
109
124
  testNotesFeature,
@@ -161,10 +176,12 @@ afterAll(async () => {
161
176
  });
162
177
 
163
178
  beforeEach(async () => {
164
- await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
165
- await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
166
- await asRawClient(stack.db).unsafe(`DELETE FROM file_refs`);
167
- await asRawClient(stack.db).unsafe(`DELETE FROM test_notes`);
179
+ await resetTestTables(stack.db, [
180
+ userTable,
181
+ tenantMembershipsTable,
182
+ fileRefsTable,
183
+ testNotesTable,
184
+ ]);
168
185
  });
169
186
 
170
187
  async function seedUser(
@@ -12,7 +12,7 @@ import { randomBytes } from "node:crypto";
12
12
  import { asRawClient, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
13
  import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
14
14
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
15
- import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
16
  import {
17
17
  createInMemoryFileProvider,
18
18
  type FileStorageProvider,
@@ -25,10 +25,12 @@ import {
25
25
  unsafeCreateEntityTable,
26
26
  unsafePushTables,
27
27
  } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
28
29
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
29
30
  import {
30
31
  createComplianceProfilesFeature,
31
32
  tenantComplianceProfileEntity,
33
+ tenantComplianceProfileTable,
32
34
  } from "../../compliance-profiles";
33
35
  import { createConfigFeature } from "../../config";
34
36
  import { ConfigHandlers } from "../../config/constants";
@@ -38,6 +40,8 @@ import { configValuesTable } from "../../config/table";
38
40
  import { createDataRetentionFeature } from "../../data-retention";
39
41
  import { fileFoundationFeature } from "../../file-foundation";
40
42
  import { fileProviderInMemoryFeature } from "../../file-provider-inmemory";
43
+ import { createSessionsFeature } from "../../sessions";
44
+ import { tenantMembershipsTable } from "../../tenant";
41
45
  import { createUserFeature } from "../../user";
42
46
  import { createUserDataRightsFeature } from "../feature";
43
47
  import { runExportJobs } from "../run-export-jobs";
@@ -108,6 +112,8 @@ beforeAll(async () => {
108
112
  fileFoundationFeature,
109
113
  fileProviderInMemoryFeature,
110
114
  noSignedUrlProviderFeature,
115
+ createSessionsFeature(),
116
+
111
117
  createUserDataRightsFeature(),
112
118
  ],
113
119
  extraContext: ({ registry }) => ({
@@ -151,12 +157,14 @@ afterAll(async () => {
151
157
  });
152
158
 
153
159
  beforeEach(async () => {
154
- await asRawClient(stack.db).unsafe(`DELETE FROM "${exportDownloadTokensTable.tableName}"`);
155
- await asRawClient(stack.db).unsafe(`DELETE FROM "${exportJobsTable.tableName}"`);
156
- await asRawClient(stack.db).unsafe(`DELETE FROM kumiko_events`);
157
- await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_compliance_profiles`);
158
- await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
159
- await asRawClient(stack.db).unsafe(`DELETE FROM $1`, [configValuesTable]);
160
+ await resetTestTables(stack.db, [
161
+ exportDownloadTokensTable,
162
+ exportJobsTable,
163
+ eventsTable,
164
+ tenantComplianceProfileTable,
165
+ tenantMembershipsTable,
166
+ configValuesTable,
167
+ ]);
160
168
  providerPerTenant = new Map();
161
169
 
162
170
  // Setup file-foundation provider="inmemory" pro Tenant.