@cosmicdrift/kumiko-bundled-features 0.113.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +6 -8
  2. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
  3. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
  4. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
  5. package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
  6. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
  7. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
  8. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  9. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
  11. package/src/auth-email-password/constants.ts +0 -4
  12. package/src/auth-email-password/feature.ts +4 -8
  13. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  14. package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
  15. package/src/auth-email-password/handlers/login.write.ts +1 -4
  16. package/src/auth-email-password/password-hashing.ts +0 -11
  17. package/src/auth-email-password/stream-tenant.ts +31 -0
  18. package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
  19. package/src/auth-email-password/web/auth-gate.tsx +4 -3
  20. package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
  21. package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
  22. package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
  23. package/src/auth-email-password/web/user-menu.tsx +1 -3
  24. package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
  25. package/src/config/__tests__/config.integration.test.ts +8 -7
  26. package/src/config/__tests__/deserialize-value.test.ts +6 -3
  27. package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
  28. package/src/config/feature.ts +8 -14
  29. package/src/config/handlers/set.write.ts +2 -2
  30. package/src/config/index.ts +3 -4
  31. package/src/config/resolver.ts +10 -34
  32. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
  33. package/src/custom-fields/constants.ts +0 -1
  34. package/src/custom-fields/events.ts +0 -6
  35. package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
  36. package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
  37. package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  38. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
  39. package/src/custom-fields/wire-for-entity.ts +77 -116
  40. package/src/data-retention/__tests__/parse-override.test.ts +13 -13
  41. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
  42. package/src/folders/__tests__/folders.integration.test.ts +0 -3
  43. package/src/folders/web/__tests__/tree.test.ts +0 -8
  44. package/src/folders/web/folder-manager.tsx +3 -1
  45. package/src/folders/web/i18n.ts +2 -0
  46. package/src/folders/web/tree.ts +2 -13
  47. package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
  48. package/src/folders-user-data/index.ts +4 -4
  49. package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
  50. package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
  51. package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
  52. package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
  53. package/src/ledger/recurring.ts +8 -17
  54. package/src/ledger/web/index.ts +2 -3
  55. package/src/legal-pages/feature.ts +3 -1
  56. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
  57. package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
  58. package/src/managed-pages/feature.ts +3 -1
  59. package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
  60. package/src/secrets/handlers/rotate.job.ts +98 -77
  61. package/src/secrets/secrets-context.ts +38 -7
  62. package/src/secrets/table.ts +7 -6
  63. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
  64. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  65. package/src/sessions/feature.ts +18 -51
  66. package/src/sessions/index.ts +2 -2
  67. package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
  68. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
  69. package/src/tags/__tests__/tags.integration.test.ts +1 -12
  70. package/src/tags/entity.ts +1 -6
  71. package/src/tags/schemas.ts +2 -9
  72. package/src/tags/web/tag-manager.tsx +1 -1
  73. package/src/template-resolver/testing.ts +1 -2
  74. package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
  75. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
  76. package/src/user/__tests__/user.integration.test.ts +0 -26
  77. package/src/user/handlers/find-for-auth.query.ts +5 -7
  78. package/src/user/index.ts +0 -4
  79. package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
  80. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  81. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  82. package/src/user-data-rights/feature.ts +3 -1
  83. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
  84. package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
  85. package/src/user-data-rights/run-forget-cleanup.ts +0 -1
  86. package/src/user-data-rights/run-user-export.ts +4 -43
  87. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
  88. package/src/user-data-rights/web/client-plugin.tsx +1 -6
  89. package/src/user-data-rights/web/i18n.ts +10 -0
  90. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
  91. package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
  92. package/src/user-profile/handlers/change-email.write.ts +8 -11
  93. package/src/user-profile/web/profile-screen.tsx +1 -12
  94. package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
  95. package/src/auth-email-password/password-hashing.test.ts +0 -35
  96. package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
  97. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
  98. package/src/config/handlers/reencrypt.job.ts +0 -197
  99. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
  100. package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
  101. package/src/personal-access-tokens/constants.ts +0 -33
  102. package/src/personal-access-tokens/feature.ts +0 -78
  103. package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
  104. package/src/personal-access-tokens/handlers/create.write.ts +0 -41
  105. package/src/personal-access-tokens/handlers/list.query.ts +0 -47
  106. package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
  107. package/src/personal-access-tokens/hash.ts +0 -17
  108. package/src/personal-access-tokens/index.ts +0 -45
  109. package/src/personal-access-tokens/resolver.ts +0 -58
  110. package/src/personal-access-tokens/roles.ts +0 -29
  111. package/src/personal-access-tokens/schema/api-token.ts +0 -56
  112. package/src/personal-access-tokens/scopes.ts +0 -43
  113. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  114. package/src/personal-access-tokens/web/i18n.ts +0 -69
  115. package/src/personal-access-tokens/web/index.ts +0 -7
  116. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
  117. package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
  118. package/src/shared/chunked-entity-migration.ts +0 -89
  119. package/src/shared/index.ts +0 -7
  120. package/src/tags/__tests__/schemas.test.ts +0 -34
  121. package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
  122. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
  123. package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
  124. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
  125. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
@@ -65,16 +65,6 @@ export const updateTenantFieldHandler: WriteHandlerDef = {
65
65
  // values already written to host rows by past sets (set-custom-field only
66
66
  // routes the PII-safe path at write time). To change sensitivity, delete +
67
67
  // re-define (the honest cut — same rationale as the type guard above).
68
- //
69
- // 527/2 — even that "honest cut" is incomplete: it clears the projection
70
- // row, but kumiko_events is immutable, so historical customField.set
71
- // events from the field's non-sensitive era keep their plaintext values
72
- // in the log forever. There is no event-payload redaction mechanism for
73
- // this today (EventStoreExecutor.forget() hard-deletes a projection row
74
- // and is rebuild-safe, but doesn't touch or redact past event payloads).
75
- // A tenant that genuinely needs those values erased needs an operator-run
76
- // data-retention pass over the raw event log — not something this
77
- // handler, or delete-and-redefine, can give them.
78
68
  const currentSensitive = parseSerializedField(existing["serializedField"])?.sensitive === true;
79
69
  const requestedSensitive = payload.serializedField.sensitive === true;
80
70
  if (currentSensitive !== requestedSensitive) {
@@ -1,4 +1,4 @@
1
- import { type DbRunner, extractTableName } from "@cosmicdrift/kumiko-framework/db";
1
+ import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  createJsonbField,
4
4
  type FeatureRegistrar,
@@ -6,8 +6,7 @@ import {
6
6
  type JsonbFieldDef,
7
7
  type TenantId,
8
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
- import type { StoredEvent } from "@cosmicdrift/kumiko-framework/event-store";
10
- import { CUSTOM_FIELDS_EXTENSION, FIELD_DEFINITION_AGGREGATE_TYPE } from "./constants";
9
+ import { CUSTOM_FIELDS_EXTENSION } from "./constants";
11
10
  import {
12
11
  clearCustomFieldKey,
13
12
  removeCustomFieldKeyForTenant,
@@ -37,9 +36,8 @@ export function customFieldsField(): JsonbFieldDef {
37
36
 
38
37
  // Vollständige integration der custom-fields-Bundle für eine spezifische
39
38
  // host-entity. Eine einzige Aufruf-Stelle pro consumer registriert ALLE
40
- // wiring-Aspekte: extension-tracking, MSP für value-projection, rebuild-
41
- // replay via extendEntityProjection, postQuery-hook für API-flatten,
42
- // search-payload-extension für indexable customFields.
39
+ // wiring-Aspekte: extension-tracking, MSP für value-projection, postQuery-
40
+ // hook für API-flatten, search-payload-extension für indexable customFields.
43
41
  //
44
42
  // Consumer-side:
45
43
  //
@@ -63,20 +61,15 @@ export function customFieldsField(): JsonbFieldDef {
63
61
  // emittiert haben. Updated entityTable.customFields jsonb über
64
62
  // jsonb_set / jsonb-key-removal.
65
63
  //
66
- // 3. r.extendEntityProjectionhängt dieselben apply-Handler in die
67
- // implicit projection der host-entity, damit rebuildProjection die
68
- // customField-Events mit-replayt. Ohne das fällt customFields bei
69
- // jedem Entity-Rebuild (Schema-Migration!) auf `{}` zurück (#759).
70
- //
71
- // 4. r.entityHook("postQuery", entity, flatten-fn) — bei JEDEM Read auf
64
+ // 3. r.entityHook("postQuery", entity, flatten-fn) bei JEDEM Read auf
72
65
  // diese entity wird `row.customFields` jsonb auf root-level expanded
73
66
  // damit die API-response wie Stammfelder aussieht.
74
67
  //
75
- // 5. r.searchPayloadExtension(entity, contributor) — searchable
68
+ // 4. r.searchPayloadExtension(entity, contributor) — searchable
76
69
  // customFields-keys werden flach ins Meilisearch-Index-Doc beigetragen
77
70
  // (F3-wiring).
78
71
  //
79
- // 6. fieldDefinition.deleted-Event-Handler im selben MSP — bei delete
72
+ // 5. fieldDefinition.deleted-Event-Handler im selben MSP — bei delete
80
73
  // einer fieldDefinition werden orphan values aus allen entity-rows
81
74
  // entfernt (key-removal pro fieldKey).
82
75
  export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
@@ -94,112 +87,80 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
94
87
  const clearedEventType = customFieldsFeature.exports.clearedEvent.name;
95
88
  const fieldDefDeletedType = customFieldsFeature.exports.fieldDefinitionDeletedEvent.name;
96
89
 
97
- // Geteilte apply-Bodies: die MSP liefert sie live (async cursor), die
98
- // entity-projection-extension replayt sie in rebuildProjection — gleiche
99
- // SQL-Helper, beide idempotent. 2-Param-Signatur ist die gemeinsame
100
- // Teilmenge von MultiStreamApplyFn und SingleStreamApplyFn.
101
- const applyCustomFieldSet = async (event: StoredEvent, tx: DbRunner): Promise<void> => {
102
- // skip: feuert für ALLE aggregate-types die customField.set emittieren —
103
- // wir wollen nur die unserer wired host-entity. Andere consumers haben
104
- // eigene Wirings für ihre Entities.
105
- if (event.aggregateType !== entityName) return;
106
- const payload = event.payload as CustomFieldSetPayload; // @cast-boundary engine-payload
107
-
108
- // skip: sensitive fields self-project in the write handler (see
109
- // set-custom-field) and persist a value-less event so PII never enters
110
- // the log. Skipping is correct both live (the handler already wrote the
111
- // row) and on replay (the value is intentionally gone, the accepted
112
- // rebuild-loss). `value === undefined` stays the actual branch condition
113
- // (historical events predate `_sensitive`) — the warning below is a
114
- // canary (527/1): a NEW event missing both means something emitted a
115
- // value-less customField.set outside the sensitive-field path.
116
- if (payload.value === undefined) {
117
- if (payload._sensitive !== true) {
118
- // biome-ignore lint/suspicious/noConsole: boot-adjacent correctness canary, no logger available in an apply function
119
- console.warn(
120
- `[custom-fields] customField.set for "${payload.fieldKey}" on ${event.aggregateType}/${event.aggregateId} has no value and no _sensitive marker — skipping, but this event didn't come from the known sensitive-field path.`,
121
- );
122
- }
123
- // skip: value-less set, handled above (warned if unexpectedly so)
124
- return;
125
- }
126
-
127
- // jsonb_set: setze key auf value. Wenn key noch nicht existiert →
128
- // wird angelegt (create_missing=true ist default). value muss als
129
- // jsonb-literal kommen.
130
- const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
131
- await setCustomFieldValue(
132
- tx,
133
- tableName,
134
- payload.fieldKey,
135
- payload.value,
136
- event.aggregateId,
137
- event.tenantId,
138
- );
139
- };
140
-
141
- const applyCustomFieldCleared = async (event: StoredEvent, tx: DbRunner): Promise<void> => {
142
- // skip: feuert für alle aggregate-types — nur unsere host-entity
143
- // verarbeiten.
144
- if (event.aggregateType !== entityName) return;
145
- const payload = event.payload as CustomFieldClearedPayload; // @cast-boundary engine-payload
146
-
147
- // jsonb minus operator (`-`) entfernt key aus jsonb-object.
148
- const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
149
- await clearCustomFieldKey(tx, tableName, payload.fieldKey, event.aggregateId, event.tenantId);
150
- };
151
-
152
- const applyFieldDefinitionDeleted = async (event: StoredEvent, tx: DbRunner): Promise<void> => {
153
- // fieldDefinition.deleted fires nur einmal pro fieldDef-delete
154
- // (NICHT per-entity). Wir entfernen den key aus ALLEN rows der host-
155
- // entity falls die deleted-fieldDef für diese entity galt.
156
- const payload = event.payload as {
157
- entityName: string;
158
- fieldKey: string;
159
- tenantId?: TenantId;
160
- }; // @cast-boundary engine-payload
161
- // skip: fieldDefinition.deleted feuert für ALLE fieldDefs cross-entity;
162
- // nur wenn die deleted-fieldDef diese host-entity betraf, cleanen wir
163
- // ihre Rows.
164
- if (payload.entityName !== entityName) return;
165
-
166
- const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
167
- // Scope cleanup to the deleted definition's owning tenant. System-scope
168
- // definitions apply to every tenant → cascade across all rows; tenant-
169
- // scope deletions must only touch that tenant's rows, else deleting one
170
- // tenant's field strips the same kebab key from every tenant (data loss).
171
- // Fallback to the event's stream tenantId for events appended before the
172
- // payload carried tenantId.
173
- const defTenantId = payload.tenantId ?? event.tenantId;
174
- if (isSystemTenant(defTenantId)) {
175
- await removeCustomFieldKeyFromAllTenants(tx, tableName, payload.fieldKey);
176
- } else {
177
- await removeCustomFieldKeyForTenant(tx, tableName, payload.fieldKey, defTenantId);
178
- }
179
- };
180
-
181
90
  r.multiStreamProjection({
182
91
  name: `custom-fields-${entityName}-projection`,
183
92
  apply: {
184
- [setEventType]: applyCustomFieldSet,
185
- [clearedEventType]: applyCustomFieldCleared,
186
- [fieldDefDeletedType]: applyFieldDefinitionDeleted,
187
- },
188
- });
93
+ [setEventType]: async (event, tx) => {
94
+ // skip: MSP feuert für ALLE aggregate-types die customField.set
95
+ // emittieren — wir wollen nur die unserer wired host-entity.
96
+ // Andere consumers haben eigene MSPs für ihre Entities.
97
+ if (event.aggregateType !== entityName) return;
98
+ const payload = event.payload as CustomFieldSetPayload; // @cast-boundary engine-payload
189
99
 
190
- // Rebuild-wiring (#759): ohne das replayt rebuildProjection nur die
191
- // entity-lifecycle-Events und resettet jede customFields-jsonb auf ihr
192
- // Default `{}` die Werte existieren ausschließlich als customField.set/
193
- // .cleared-Events. set/cleared reiten auf dem HOST-entity-Stream
194
- // (aggregateType = entityName, von source schon abgedeckt);
195
- // fieldDefinition.deleted lebt auf dem "field-definition"-Stream und
196
- // kommt deshalb als extra source dazu.
197
- r.extendEntityProjection(entityName, {
198
- sources: [FIELD_DEFINITION_AGGREGATE_TYPE],
199
- apply: {
200
- [setEventType]: applyCustomFieldSet,
201
- [clearedEventType]: applyCustomFieldCleared,
202
- [fieldDefDeletedType]: applyFieldDefinitionDeleted,
100
+ // skip: sensitive fields self-project in the write handler (see
101
+ // set-custom-field) and persist a value-less event so PII never enters
102
+ // the log. Such events arrive here with value === undefined — skipping
103
+ // is correct both live (the handler already wrote the row) and on replay
104
+ // (the value is intentionally gone, the accepted rebuild-loss).
105
+ if (payload.value === undefined) return;
106
+
107
+ // jsonb_set: setze key auf value. Wenn key noch nicht existiert →
108
+ // wird angelegt (create_missing=true ist default). value muss als
109
+ // jsonb-literal kommen.
110
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
111
+ await setCustomFieldValue(
112
+ tx,
113
+ tableName,
114
+ payload.fieldKey,
115
+ payload.value,
116
+ event.aggregateId,
117
+ event.tenantId,
118
+ );
119
+ },
120
+ [clearedEventType]: async (event, tx) => {
121
+ // skip: MSP feuert für alle aggregate-types — nur unsere host-entity
122
+ // verarbeiten.
123
+ if (event.aggregateType !== entityName) return;
124
+ const payload = event.payload as CustomFieldClearedPayload; // @cast-boundary engine-payload
125
+
126
+ // jsonb minus operator (`-`) entfernt key aus jsonb-object.
127
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
128
+ await clearCustomFieldKey(
129
+ tx,
130
+ tableName,
131
+ payload.fieldKey,
132
+ event.aggregateId,
133
+ event.tenantId,
134
+ );
135
+ },
136
+ [fieldDefDeletedType]: async (event, tx) => {
137
+ // fieldDefinition.deleted fires nur einmal pro fieldDef-delete
138
+ // (NICHT per-entity). Wir entfernen den key aus ALLEN rows der host-
139
+ // entity falls die deleted-fieldDef für diese entity galt.
140
+ const payload = event.payload as {
141
+ entityName: string;
142
+ fieldKey: string;
143
+ tenantId?: TenantId;
144
+ }; // @cast-boundary engine-payload
145
+ // skip: fieldDefinition.deleted feuert für ALLE fieldDefs cross-entity;
146
+ // nur wenn die deleted-fieldDef diese host-entity betraf, cleanen wir
147
+ // ihre Rows.
148
+ if (payload.entityName !== entityName) return;
149
+
150
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
151
+ // Scope cleanup to the deleted definition's owning tenant. System-scope
152
+ // definitions apply to every tenant → cascade across all rows; tenant-
153
+ // scope deletions must only touch that tenant's rows, else deleting one
154
+ // tenant's field strips the same kebab key from every tenant (data loss).
155
+ // Fallback to the event's stream tenantId for events appended before the
156
+ // payload carried tenantId.
157
+ const defTenantId = payload.tenantId ?? event.tenantId;
158
+ if (isSystemTenant(defTenantId)) {
159
+ await removeCustomFieldKeyFromAllTenants(tx, tableName, payload.fieldKey);
160
+ } else {
161
+ await removeCustomFieldKeyForTenant(tx, tableName, payload.fieldKey, defTenantId);
162
+ }
163
+ },
203
164
  },
204
165
  });
205
166
 
@@ -1,16 +1,13 @@
1
- import { afterEach, describe, expect, spyOn, test } from "bun:test";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import { parseRetentionOverrideOrNull } from "../_internal/parse-override";
3
3
 
4
- // A mid-test assertion throw skips the trailing `warn.mockRestore()` in that
5
- // test, leaving the spy live for every test after it (551/1) this backstop
6
- // restores unconditionally regardless of how the test exited.
7
- afterEach(() => {
8
- const warn = console.warn as unknown as { mockRestore?: () => void };
9
- warn.mockRestore?.();
10
- });
11
-
12
- // DSGVO invariant: corrupt or schema-drifted overrides collapse to null +
13
- // warn, never leak through (schema itself is covered by override-schema.test.ts).
4
+ // parseRetentionOverrideOrNull is the read boundary for a tenant's stored
5
+ // data-retention override (DSGVO-relevant policy in a config column). It must
6
+ // never let a corrupt or schema-violating value reach the retention decision:
7
+ // invalid JSON and schema drift both collapse to null (the resolver then falls
8
+ // back to preset/entity defaults) AND surface one operator warning. The schema
9
+ // itself is covered by override-schema.test.ts — this pins the parser's
10
+ // defensive wrapping: empty guard, no-throw on corruption, drop-not-leak on drift.
14
11
 
15
12
  const parse = (raw: string | null) => parseRetentionOverrideOrNull(raw, "tenant-1", "test");
16
13
 
@@ -34,17 +31,19 @@ describe("parseRetentionOverrideOrNull", () => {
34
31
  });
35
32
 
36
33
  test("corrupt JSON returns null without throwing", () => {
37
- spyOn(console, "warn").mockImplementation(() => {});
34
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
38
35
  expect(() => parse("{not json")).not.toThrow();
39
36
  expect(parse("{not json")).toBeNull();
37
+ warn.mockRestore();
40
38
  });
41
39
 
42
40
  test("JSON that parses but violates the schema is dropped to null — never leaked through", () => {
43
- spyOn(console, "warn").mockImplementation(() => {});
41
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
44
42
  expect(parse('{"strategy":"delete"}')).toBeNull(); // enum drift
45
43
  expect(parse('{"keepFor":"30days"}')).toBeNull(); // keepFor format drift
46
44
  expect(parse('{"keepFor":42}')).toBeNull(); // wrong type
47
45
  expect(parse('{"unknownKey":1}')).toBeNull(); // strict() rejects extra keys
46
+ warn.mockRestore();
48
47
  });
49
48
 
50
49
  test("each dropped value surfaces exactly one operator warning", () => {
@@ -52,5 +51,6 @@ describe("parseRetentionOverrideOrNull", () => {
52
51
  parse("{not json");
53
52
  parse('{"strategy":"delete"}');
54
53
  expect(warn).toHaveBeenCalledTimes(2);
54
+ warn.mockRestore();
55
55
  });
56
56
  });
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
6
  import { randomBytes } from "node:crypto";
7
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
7
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
8
  import { defineFeature, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
10
10
  import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
@@ -18,7 +18,6 @@ import {
18
18
  } from "@cosmicdrift/kumiko-framework/stack";
19
19
  import {
20
20
  createMutableMasterKeyProvider,
21
- createTestEnvelopeCipher,
22
21
  type MutableMasterKeyProvider,
23
22
  } from "@cosmicdrift/kumiko-framework/testing";
24
23
  import { z } from "zod";
@@ -74,8 +73,8 @@ let providerRef: MutableMasterKeyProvider;
74
73
  const testEncryptionKey = randomBytes(32).toString("base64");
75
74
 
76
75
  beforeAll(async () => {
77
- const encryption = createTestEnvelopeCipher(testEncryptionKey);
78
- resolver = createConfigResolver({ cipher: encryption });
76
+ const encryption = createEncryptionProvider(testEncryptionKey);
77
+ resolver = createConfigResolver({ encryption });
79
78
 
80
79
  const initialKp = createEnvMasterKeyProvider({
81
80
  env: {
@@ -331,7 +331,6 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
331
331
  await seedOneFolderWithAssignment();
332
332
  const ctx = {
333
333
  db: stack.db,
334
- registry: stack.registry,
335
334
  tenantId: admin.tenantId,
336
335
  userId: admin.id,
337
336
  tenantModel: "multi-user" as const,
@@ -346,7 +345,6 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
346
345
  await seedOneFolderWithAssignment();
347
346
  const ctx = {
348
347
  db: stack.db,
349
- registry: stack.registry,
350
348
  tenantId: admin.tenantId,
351
349
  userId: admin.id,
352
350
  tenantModel: "single-user" as const,
@@ -361,7 +359,6 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
361
359
  await seedOneFolderWithAssignment();
362
360
  const ctx = {
363
361
  db: stack.db,
364
- registry: stack.registry,
365
362
  tenantId: admin.tenantId,
366
363
  userId: admin.id,
367
364
  tenantModel: "single-user" as const,
@@ -55,12 +55,4 @@ describe("folderPath", () => {
55
55
  test("unknown id → empty string", () => {
56
56
  expect(folderPath(rows, "nope")).toBe("");
57
57
  });
58
-
59
- // 658/4: a parentId cycle must terminate at the row-count cap, not loop
60
- // forever or run one iteration past it.
61
- test("a parentId cycle terminates instead of looping forever", () => {
62
- const cyclic = [f("x", "X", "y"), f("y", "Y", "x")];
63
- expect(() => folderPath(cyclic, "x")).not.toThrow();
64
- expect(folderPath(cyclic, "x").split(" / ")).toHaveLength(cyclic.length);
65
- });
66
58
  });
@@ -47,7 +47,9 @@ export type FolderLeaf = {
47
47
  readonly label: string;
48
48
  readonly trailing?: ReactNode;
49
49
  readonly onOpen?: () => void;
50
- // Overrides filing.entityType (mixed-entity-type trees); omit for single-type trees.
50
+ // Overrides filing.entityType for this leaf lets one filing tree hold leaves
51
+ // of mixed entity types (e.g. credits + Bausparverträge), each filed/cleared
52
+ // under its own type. Omit for single-type trees.
51
53
  readonly entityType?: string;
52
54
  };
53
55
 
@@ -18,6 +18,7 @@ export const defaultTranslations: TranslationsByLocale = {
18
18
  "folders.section.working": "Speichert…",
19
19
 
20
20
  "folders.manager.loading": "Lädt…",
21
+ "folders.manager.empty": "Noch keine Ordner.",
21
22
  "folders.manager.newRoot": "Neuer Ordner",
22
23
  "folders.manager.add": "Anlegen",
23
24
  "folders.manager.addChild": "Unterordner",
@@ -43,6 +44,7 @@ export const defaultTranslations: TranslationsByLocale = {
43
44
  "folders.section.working": "Saving…",
44
45
 
45
46
  "folders.manager.loading": "Loading…",
47
+ "folders.manager.empty": "No folders yet.",
46
48
  "folders.manager.newRoot": "New folder",
47
49
  "folders.manager.add": "Create",
48
50
  "folders.manager.addChild": "Subfolder",
@@ -31,15 +31,6 @@ export function buildFolderTree(rows: readonly FolderRow[]): readonly FolderNode
31
31
  if (siblings) siblings.push(row);
32
32
  else byParent.set(key, [row]);
33
33
  }
34
- // visited guards against a parent cycle recursing forever (658/4) —
35
- // currently impossible (no reparent feature yet), but cheap insurance for
36
- // when one lands; a cyclic node's subtree just stops re-descending.
37
- // No cycle guard needed (658/4, verified not just asserted): build() only
38
- // descends via byParent[parentId] starting from roots (parentId === null
39
- // or dangling) — a row in a cycle has a non-null, existing parentId by
40
- // definition, so it can never be a root, and each row contributes exactly
41
- // one parentId edge, so nothing outside a cycle points into it either. A
42
- // pure a<->b cycle is structurally unreachable from build(null).
43
34
  const build = (parentId: string | null, depth: number): readonly FolderNode[] =>
44
35
  (byParent.get(parentId) ?? [])
45
36
  .slice()
@@ -50,14 +41,12 @@ export function buildFolderTree(rows: readonly FolderRow[]): readonly FolderNode
50
41
 
51
42
  // "Immobilie Berlin / Person Müller" for a folder id, walking parentId up.
52
43
  // Empty string if the id is unknown. The row-count cap stops a (currently
53
- // impossible — no reparent yet) parent cycle from spinning forever — `< `,
54
- // not `<=` (658/4): a cycle of exactly rows.length nodes must stop AT the
55
- // cap, not run one iteration past it.
44
+ // impossible — no reparent yet) parent cycle from spinning forever.
56
45
  export function folderPath(rows: readonly FolderRow[], id: string, separator = " / "): string {
57
46
  const byId = new Map(rows.map((r) => [r.id, r]));
58
47
  const names: string[] = [];
59
48
  let current = byId.get(id);
60
- for (let i = 0; current !== undefined && i < rows.length; i += 1) {
49
+ for (let i = 0; current !== undefined && i <= rows.length; i += 1) {
61
50
  names.unshift(current.name);
62
51
  current = current.parentId !== null ? byId.get(current.parentId) : undefined;
63
52
  }
@@ -57,7 +57,6 @@ describe("folderAssignmentExportHook", () => {
57
57
 
58
58
  const snippet = await folderAssignmentExportHook({
59
59
  db: stack.db,
60
- registry: stack.registry,
61
60
  tenantId: admin.tenantId,
62
61
  userId: admin.id,
63
62
  });
@@ -16,10 +16,10 @@ export const foldersUserDataFeature = defineFeature("folders-user-data", (r) =>
16
16
  r.describe(
17
17
  "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `folders` feature's `folder` + `folder-assignment` entities. Mounts the EXT_USER_DATA export + delete hooks so a tenant's folder tree and its entity-to-folder assignments are included in the user-data export bundle and erased on a tenant-scoped forget (single-user tenants only; multi-user + anonymize are no-ops since folder rows carry no per-user PII). Kept separate from `folders` so folder consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `folders`.",
18
18
  );
19
- // user-data-rights is the hard dependency (EXT_USER_DATA host). `folders` is
20
- // OPTIONAL: if it's mounted toggleable(default=false) (e.g. per-tenant via
21
- // tier), a hard r.requires would throw an "effectively disabled" boot
22
- // warning even though the folder entities exist and the hooks work fine.
19
+ // user-data-rights ist die harte Abhängigkeit (EXT_USER_DATA-Host). `folders` ist
20
+ // OPTIONAL: ist es toggleable(default=false) gemountet (z.B. per-Tenant via Tier),
21
+ // würde ein hartes r.requires eine effectively disabled"-Boot-Warnung werfen,
22
+ // obwohl die folder-Entities existieren und die Hooks korrekt greifen.
23
23
  r.requires("user-data-rights");
24
24
  r.optionalRequires("folders");
25
25
  r.useExtension(EXT_USER_DATA, "folder", {
@@ -1,6 +1,10 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ createEncryptionProvider,
5
+ createTenantDb,
6
+ type DbConnection,
7
+ } from "@cosmicdrift/kumiko-framework/db";
4
8
  import {
5
9
  access,
6
10
  createRegistry,
@@ -22,7 +26,7 @@ import {
22
26
  TestUsers,
23
27
  unsafePushTables,
24
28
  } from "@cosmicdrift/kumiko-framework/stack";
25
- import { bridgeStub, createTestEnvelopeCipher, sleep } from "@cosmicdrift/kumiko-framework/testing";
29
+ import { bridgeStub, sleep } from "@cosmicdrift/kumiko-framework/testing";
26
30
  import { ConfigHandlers } from "../../config/constants";
27
31
  import { createConfigAccessor, createConfigFeature } from "../../config/feature";
28
32
  import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
@@ -103,8 +107,8 @@ beforeAll(async () => {
103
107
  await createEventsTable(db);
104
108
  await createArchivedStreamsTable(db);
105
109
 
106
- const encryption = createTestEnvelopeCipher(testEncryptionKey);
107
- resolver = createConfigResolver({ cipher: encryption });
110
+ const encryption = createEncryptionProvider(testEncryptionKey);
111
+ resolver = createConfigResolver({ encryption });
108
112
 
109
113
  registry = createRegistry([configFeature, billingFeature]);
110
114
 
@@ -6,15 +6,10 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
7
7
  import { z } from "zod";
8
8
 
9
- export const projectionRebuildPayloadSchema = z.object({
10
- projection: z.string().min(1),
11
- // Quarantine mode (#760): skip + dead-letter poison events instead of
12
- // failing the whole rebuild. Operator opt-in per run.
13
- skipApplyErrors: z.boolean().optional(),
14
- });
9
+ export const projectionRebuildPayloadSchema = z.object({ projection: z.string().min(1) });
15
10
 
16
11
  export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
17
- const { projection, skipApplyErrors } = projectionRebuildPayloadSchema.parse(rawPayload);
12
+ const { projection } = projectionRebuildPayloadSchema.parse(rawPayload);
18
13
  if (!ctx.db) {
19
14
  throw new InternalError({
20
15
  message:
@@ -28,13 +23,8 @@ export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promi
28
23
  });
29
24
  }
30
25
  const db = ctx.db as DbConnection; // @cast-boundary db-operator
31
- const result = await rebuildProjection(projection, {
32
- db,
33
- registry: ctx.registry,
34
- ...(skipApplyErrors === true && { errorPolicy: { skipApplyErrors: true } }),
35
- });
26
+ const result = await rebuildProjection(projection, { db, registry: ctx.registry });
36
27
  ctx.log?.info?.(
37
- `[jobs:projection-rebuild] rebuilt ${projection}: ${result.eventsProcessed} events` +
38
- `${result.eventsSkipped > 0 ? ` (${result.eventsSkipped} quarantined)` : ""} in ${result.durationMs}ms`,
28
+ `[jobs:projection-rebuild] rebuilt ${projection}: ${result.eventsProcessed} events in ${result.durationMs}ms`,
39
29
  );
40
30
  };
@@ -371,8 +371,8 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
371
371
  scheduleId: string,
372
372
  period: string,
373
373
  amount?: number,
374
- ): Promise<{ id: string; alreadyBooked: boolean }> {
375
- return stack.http.writeOk<{ id: string; alreadyBooked: boolean }>(
374
+ ): Promise<{ id: string }> {
375
+ return stack.http.writeOk<{ id: string }>(
376
376
  LedgerHandlers.confirmSchedulePeriod,
377
377
  amount === undefined ? { scheduleId, period } : { scheduleId, period, amount },
378
378
  admin,
@@ -384,10 +384,7 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
384
384
  const rent = await createAccount("Mieterträge", "income");
385
385
  const scheduleId = await createSchedule(bank, rent);
386
386
 
387
- const fresh = await confirm(scheduleId, "2026-01");
388
- // 684/6: fresh bookings carry alreadyBooked: false, same shape as the
389
- // idempotent-path response — no "does the field even exist" check needed.
390
- expect(fresh.alreadyBooked).toBe(false);
387
+ await confirm(scheduleId, "2026-01");
391
388
 
392
389
  const rows = await listTransactions();
393
390
  expect(rows).toHaveLength(1);
@@ -408,8 +405,6 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
408
405
  const second = await confirm(scheduleId, "2026-01");
409
406
 
410
407
  expect(second.id).toBe(first.id);
411
- expect(first.alreadyBooked).toBe(false);
412
- expect(second.alreadyBooked).toBe(true);
413
408
  expect(await listTransactions()).toHaveLength(1);
414
409
  });
415
410
 
@@ -13,7 +13,7 @@ import {
13
13
  transactionExecutor,
14
14
  transactionTable,
15
15
  } from "../executor";
16
- import { findReversedIds, isoMonth, scheduleReference } from "../recurring";
16
+ import { isoMonth, scheduleReference } from "../recurring";
17
17
  import { type ConfirmSchedulePeriodPayload, confirmSchedulePeriodPayloadSchema } from "../schemas";
18
18
 
19
19
  // confirm-schedule-period — turn ONE projected period of a schedule into a posted,
@@ -78,13 +78,8 @@ export function createConfirmSchedulePeriodHandler(
78
78
  reference: { in: candidateIds },
79
79
  })
80
80
  : [];
81
- const reversedIds = findReversedIds(
82
- [...candidates, ...stornos].map((r) => ({
83
- id: String(r["id"]),
84
- reference: r["reference"] === null ? null : String(r["reference"]),
85
- })),
86
- );
87
- const active = candidates.find((r) => !reversedIds.has(String(r["id"])));
81
+ const reversedCandidateIds = new Set(stornos.map((r) => String(r["reference"])));
82
+ const active = candidates.find((r) => !reversedCandidateIds.has(String(r["id"])));
88
83
  if (active) {
89
84
  const ok: WriteResult<{ id: string; alreadyBooked: true }> = {
90
85
  isSuccess: true,
@@ -94,7 +89,7 @@ export function createConfirmSchedulePeriodHandler(
94
89
  }
95
90
 
96
91
  const amount = payload.amount ?? Number(schedule["amount"]);
97
- const created = await transactionExecutor.create(
92
+ return transactionExecutor.create(
98
93
  {
99
94
  id: generateId(),
100
95
  date: payload.date ?? `${payload.period}-01`,
@@ -109,14 +104,6 @@ export function createConfirmSchedulePeriodHandler(
109
104
  event.user,
110
105
  ctx.db,
111
106
  );
112
- // Same shape as the idempotent-path return above (684/6) — a caller
113
- // shouldn't have to check "does alreadyBooked exist" to know which
114
- // branch fired.
115
- if (!created.isSuccess) return created;
116
- return {
117
- isSuccess: true,
118
- data: { ...created.data, alreadyBooked: false as const },
119
- };
120
107
  },
121
108
  };
122
109
  }