@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
@@ -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
 
@@ -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> {
@@ -6,12 +6,19 @@ import { deliveryAttemptSchema } from "./events";
6
6
  import { logQuery } from "./handlers/log.query";
7
7
  import { preferencesQuery } from "./handlers/preferences.query";
8
8
  import { setPreferenceWrite } from "./handlers/set-preference.write";
9
- import { deliveryAttemptsTable, notificationPreferenceEntity } from "./tables";
9
+ import {
10
+ deliveryAttemptsTable,
11
+ deliveryAttemptsTableMeta,
12
+ notificationPreferenceEntity,
13
+ } from "./tables";
10
14
 
11
15
  export function createDeliveryFeature(): FeatureDefinition {
12
16
  return defineFeature("delivery", (r) => {
13
17
  r.systemScope();
14
18
  r.entity("notification-preference", notificationPreferenceEntity);
19
+ r.unmanagedTable(deliveryAttemptsTableMeta, {
20
+ reason: "read_side.delivery_attempt_log",
21
+ });
15
22
 
16
23
  // Events-only projection source: "deliveryAttempt" is the aggregate-
17
24
  // type on the events-table, but there's no r.entity for it — each
@@ -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(
@@ -19,11 +19,14 @@ import {
19
19
  JOB_RUN_FAILED_EVENT,
20
20
  JOB_RUN_STARTED_EVENT,
21
21
  } from "./job-run-logger";
22
- import { jobRunLogsTable, jobRunsTable } from "./job-run-table";
22
+ import { jobRunLogsTable, jobRunLogsTableMeta, jobRunsTable } from "./job-run-table";
23
23
 
24
24
  export function createJobsFeature(): FeatureDefinition {
25
25
  return defineFeature("jobs", (r) => {
26
26
  r.systemScope();
27
+ r.unmanagedTable(jobRunLogsTableMeta, {
28
+ reason: "read_side.job_run_logs",
29
+ });
27
30
  // Events-only aggregate: "jobRun" has no r.entity registration, because
28
31
  // the entire lifecycle is driven by BullMQ-callback → r.defineEvent
29
32
  // (no executor, no CRUD). The boot-validator accepts the two
@@ -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
  });