@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.
- package/package.json +6 -8
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
- package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
- package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
- package/src/auth-email-password/constants.ts +0 -4
- package/src/auth-email-password/feature.ts +4 -8
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
- package/src/auth-email-password/handlers/login.write.ts +1 -4
- package/src/auth-email-password/password-hashing.ts +0 -11
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
- package/src/auth-email-password/web/auth-gate.tsx +4 -3
- package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
- package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
- package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
- package/src/auth-email-password/web/user-menu.tsx +1 -3
- package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
- package/src/config/__tests__/config.integration.test.ts +8 -7
- package/src/config/__tests__/deserialize-value.test.ts +6 -3
- package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
- package/src/config/feature.ts +8 -14
- package/src/config/handlers/set.write.ts +2 -2
- package/src/config/index.ts +3 -4
- package/src/config/resolver.ts +10 -34
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
- package/src/custom-fields/constants.ts +0 -1
- package/src/custom-fields/events.ts +0 -6
- package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
- package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
- package/src/custom-fields/wire-for-entity.ts +77 -116
- package/src/data-retention/__tests__/parse-override.test.ts +13 -13
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
- package/src/folders/__tests__/folders.integration.test.ts +0 -3
- package/src/folders/web/__tests__/tree.test.ts +0 -8
- package/src/folders/web/folder-manager.tsx +3 -1
- package/src/folders/web/i18n.ts +2 -0
- package/src/folders/web/tree.ts +2 -13
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
- package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
- package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
- package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
- package/src/ledger/recurring.ts +8 -17
- package/src/ledger/web/index.ts +2 -3
- package/src/legal-pages/feature.ts +3 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
- package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
- package/src/managed-pages/feature.ts +3 -1
- package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
- package/src/secrets/handlers/rotate.job.ts +98 -77
- package/src/secrets/secrets-context.ts +38 -7
- package/src/secrets/table.ts +7 -6
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
- package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
- package/src/sessions/feature.ts +18 -51
- package/src/sessions/index.ts +2 -2
- package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
- package/src/tags/__tests__/tags.integration.test.ts +1 -12
- package/src/tags/entity.ts +1 -6
- package/src/tags/schemas.ts +2 -9
- package/src/tags/web/tag-manager.tsx +1 -1
- package/src/template-resolver/testing.ts +1 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
- package/src/user/__tests__/user.integration.test.ts +0 -26
- package/src/user/handlers/find-for-auth.query.ts +5 -7
- package/src/user/index.ts +0 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
- package/src/user-data-rights/feature.ts +3 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
- package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
- package/src/user-data-rights/run-forget-cleanup.ts +0 -1
- package/src/user-data-rights/run-user-export.ts +4 -43
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
- package/src/user-data-rights/web/client-plugin.tsx +1 -6
- package/src/user-data-rights/web/i18n.ts +10 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
- package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
- package/src/user-profile/handlers/change-email.write.ts +8 -11
- package/src/user-profile/web/profile-screen.tsx +1 -12
- package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
- package/src/auth-email-password/password-hashing.test.ts +0 -35
- package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
- package/src/config/handlers/reencrypt.job.ts +0 -197
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
- package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
- package/src/personal-access-tokens/constants.ts +0 -33
- package/src/personal-access-tokens/feature.ts +0 -78
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
- package/src/personal-access-tokens/handlers/create.write.ts +0 -41
- package/src/personal-access-tokens/handlers/list.query.ts +0 -47
- package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
- package/src/personal-access-tokens/hash.ts +0 -17
- package/src/personal-access-tokens/index.ts +0 -45
- package/src/personal-access-tokens/resolver.ts +0 -58
- package/src/personal-access-tokens/roles.ts +0 -29
- package/src/personal-access-tokens/schema/api-token.ts +0 -56
- package/src/personal-access-tokens/scopes.ts +0 -43
- package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
- package/src/personal-access-tokens/web/i18n.ts +0 -69
- package/src/personal-access-tokens/web/index.ts +0 -7
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
- package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
- package/src/shared/chunked-entity-migration.ts +0 -89
- package/src/shared/index.ts +0 -7
- package/src/tags/__tests__/schemas.test.ts +0 -34
- package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
- package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
- 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 {
|
|
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
|
|
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,
|
|
41
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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]:
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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 {
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { parseRetentionOverrideOrNull } from "../_internal/parse-override";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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 =
|
|
78
|
-
resolver = createConfigResolver({
|
|
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
|
|
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
|
|
package/src/folders/web/i18n.ts
CHANGED
|
@@ -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",
|
package/src/folders/web/tree.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -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
|
|
20
|
-
// OPTIONAL:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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 {
|
|
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,
|
|
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 =
|
|
107
|
-
resolver = createConfigResolver({
|
|
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
|
|
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
|
|
375
|
-
return stack.http.writeOk<{ id: string
|
|
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
|
-
|
|
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 {
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
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
|
}
|