@cosmicdrift/kumiko-bundled-features 0.38.0 → 0.40.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 +8 -5
- package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +57 -1
- package/src/auth-email-password/i18n.ts +4 -14
- package/src/auth-email-password/web/index.ts +1 -1
- package/src/config/__tests__/config.integration.test.ts +21 -6
- package/src/config/handlers/readiness.query.ts +29 -3
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +1 -1
- package/src/custom-fields/db/queries/retention.ts +0 -12
- package/src/custom-fields/handlers/update-tenant-field.write.ts +8 -0
- package/src/custom-fields/index.ts +4 -0
- package/src/custom-fields/run-retention.ts +3 -6
- package/src/custom-fields/web/i18n.ts +4 -4
- package/src/custom-fields/wire-user-data-rights.ts +6 -2
- package/src/mail-foundation/feature.ts +4 -0
- package/src/readiness/__tests__/readiness.integration.test.ts +35 -0
- package/src/readiness/handlers/status.query.ts +4 -0
- package/src/template-resolver/__tests__/handlers.integration.test.ts +59 -0
- package/src/user-data-rights/run-forget-cleanup.ts +9 -2
- package/src/user-profile/__tests__/change-email.integration.test.ts +222 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +101 -0
- package/src/user-profile/constants.ts +27 -0
- package/src/user-profile/feature.ts +26 -0
- package/src/user-profile/handlers/change-email.write.ts +83 -0
- package/src/user-profile/i18n.ts +83 -0
- package/src/user-profile/index.ts +11 -0
- package/src/user-profile/web/client-plugin.ts +28 -0
- package/src/user-profile/web/index.ts +6 -0
- package/src/user-profile/web/profile-screen.tsx +326 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"./user": "./src/user/index.ts",
|
|
49
49
|
"./user/seeding": "./src/user/seeding.ts",
|
|
50
50
|
"./user/testing": "./src/user/testing.ts",
|
|
51
|
+
"./user-profile": "./src/user-profile/index.ts",
|
|
52
|
+
"./user-profile/web": "./src/user-profile/web/index.ts",
|
|
51
53
|
"./auth-email-password": "./src/auth-email-password/index.ts",
|
|
52
54
|
"./auth-email-password/constants": "./src/auth-email-password/constants.ts",
|
|
53
55
|
"./auth-email-password/seeding": "./src/auth-email-password/seeding.ts",
|
|
@@ -74,10 +76,11 @@
|
|
|
74
76
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
75
77
|
},
|
|
76
78
|
"dependencies": {
|
|
77
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
78
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
79
|
-
"@cosmicdrift/kumiko-
|
|
80
|
-
"@cosmicdrift/kumiko-renderer
|
|
79
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.38.0",
|
|
80
|
+
"@cosmicdrift/kumiko-framework": "0.38.0",
|
|
81
|
+
"@cosmicdrift/kumiko-headless": "0.38.0",
|
|
82
|
+
"@cosmicdrift/kumiko-renderer": "0.38.0",
|
|
83
|
+
"@cosmicdrift/kumiko-renderer-web": "0.38.0",
|
|
81
84
|
"@mollie/api-client": "^4.5.0",
|
|
82
85
|
"@node-rs/argon2": "^2.0.2",
|
|
83
86
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -10,6 +10,7 @@ import { pbkdf2Sync, randomBytes } from "node:crypto";
|
|
|
10
10
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
11
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
12
12
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
13
14
|
import {
|
|
14
15
|
setupTestStack,
|
|
15
16
|
type TestStack,
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
import { createConfigFeature } from "../../config";
|
|
21
22
|
import { createConfigResolver } from "../../config/resolver";
|
|
22
23
|
import { configValuesTable } from "../../config/table";
|
|
23
|
-
import { createTenantFeature } from "../../tenant";
|
|
24
|
+
import { createTenantFeature, TenantHandlers } from "../../tenant";
|
|
24
25
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
25
26
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
26
27
|
import { seedTenantMembership } from "../../tenant/testing";
|
|
@@ -73,6 +74,7 @@ beforeAll(async () => {
|
|
|
73
74
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
74
75
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
75
76
|
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
77
|
+
await createEventsTable(stack.db);
|
|
76
78
|
});
|
|
77
79
|
|
|
78
80
|
afterAll(async () => {
|
|
@@ -149,3 +151,57 @@ describe("Identity-V3 password-hash compatibility", () => {
|
|
|
149
151
|
expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
|
|
150
152
|
});
|
|
151
153
|
});
|
|
154
|
+
|
|
155
|
+
// 273/2: Changeset-Zusage "disabled Tenants verschwinden aus der
|
|
156
|
+
// Login-Tenant-Wahl" — der Auto-Select (chosen = preferred ?? memberships[0])
|
|
157
|
+
// darf nie auf einem disabled Tenant landen. Der disabled Tenant ist hier
|
|
158
|
+
// bewusst die ERSTE Membership, also genau der memberships[0]-Kandidat.
|
|
159
|
+
describe("login auto-select skips disabled tenants", () => {
|
|
160
|
+
test("first membership disabled → login lands on the active tenant", async () => {
|
|
161
|
+
const password = "Active!Tenant-2026";
|
|
162
|
+
const salt = randomBytes(16);
|
|
163
|
+
const v3Hash = buildBmcStyleV3Hash(password, salt);
|
|
164
|
+
|
|
165
|
+
const disabledTenantId = "00000000-0000-4000-8000-000000000301" as TenantId;
|
|
166
|
+
const activeTenantId = "00000000-0000-4000-8000-000000000302" as TenantId;
|
|
167
|
+
await stack.http.writeOk(
|
|
168
|
+
TenantHandlers.create,
|
|
169
|
+
{ id: disabledTenantId, key: "ghost", name: "Ghost Corp" },
|
|
170
|
+
systemAdmin,
|
|
171
|
+
);
|
|
172
|
+
await stack.http.writeOk(
|
|
173
|
+
TenantHandlers.create,
|
|
174
|
+
{ id: activeTenantId, key: "alive", name: "Alive Corp" },
|
|
175
|
+
systemAdmin,
|
|
176
|
+
);
|
|
177
|
+
await stack.http.writeOk(TenantHandlers.disable, { id: disabledTenantId }, systemAdmin);
|
|
178
|
+
|
|
179
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
180
|
+
UserHandlers.create,
|
|
181
|
+
{
|
|
182
|
+
email: "carol@autoselect.example",
|
|
183
|
+
passwordHash: v3Hash,
|
|
184
|
+
displayName: "Carol Two-Tenants",
|
|
185
|
+
},
|
|
186
|
+
systemAdmin,
|
|
187
|
+
);
|
|
188
|
+
await seedTenantMembership(stack.db, {
|
|
189
|
+
userId: created.id,
|
|
190
|
+
tenantId: disabledTenantId,
|
|
191
|
+
roles: ["User"],
|
|
192
|
+
});
|
|
193
|
+
await seedTenantMembership(stack.db, {
|
|
194
|
+
userId: created.id,
|
|
195
|
+
tenantId: activeTenantId,
|
|
196
|
+
roles: ["User"],
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
200
|
+
email: "carol@autoselect.example",
|
|
201
|
+
password,
|
|
202
|
+
});
|
|
203
|
+
expect(res.status).toBe(200);
|
|
204
|
+
const body = await res.json();
|
|
205
|
+
expect(body.user).toMatchObject({ id: created.id, tenantId: activeTenantId });
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -207,17 +207,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
207
207
|
},
|
|
208
208
|
};
|
|
209
209
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
export
|
|
214
|
-
base: TranslationsByLocale,
|
|
215
|
-
override: TranslationsByLocale,
|
|
216
|
-
): TranslationsByLocale {
|
|
217
|
-
const locales = new Set([...Object.keys(base), ...Object.keys(override)]);
|
|
218
|
-
const merged: Record<string, Record<string, string>> = {};
|
|
219
|
-
for (const locale of locales) {
|
|
220
|
-
merged[locale] = { ...(base[locale] ?? {}), ...(override[locale] ?? {}) };
|
|
221
|
-
}
|
|
222
|
-
return merged;
|
|
223
|
-
}
|
|
210
|
+
// Kanonische Implementierung lebt jetzt im Renderer (neben
|
|
211
|
+
// TranslationsByLocale) — Re-Export hält die bestehende Import-Surface
|
|
212
|
+
// (auth-email-password/web) stabil.
|
|
213
|
+
export { mergeTranslations } from "@cosmicdrift/kumiko-renderer";
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// `@cosmicdrift/kumiko-bundled-features/auth-email-password` und hat keine
|
|
6
6
|
// React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
|
|
7
7
|
|
|
8
|
-
export { defaultTranslations } from "../i18n";
|
|
8
|
+
export { defaultTranslations, mergeTranslations } from "../i18n";
|
|
9
9
|
export type {
|
|
10
10
|
AuthTokenFailure,
|
|
11
11
|
CurrentUserProfile,
|
|
@@ -727,8 +727,21 @@ describe("config.schema query handler", () => {
|
|
|
727
727
|
describe("config.readiness query handler", () => {
|
|
728
728
|
type Missing = { missing: Array<{ key: string; scope: string; type: string }> };
|
|
729
729
|
|
|
730
|
+
// Pro Test ein frischer Tenant — die Tests mutieren required-Keys und
|
|
731
|
+
// dürfen sich nicht über Reihenfolge-Kopplung gegenseitig sehen (272/3).
|
|
732
|
+
function readinessAdminFor(n: number) {
|
|
733
|
+
return createTestUser({
|
|
734
|
+
id: 700 + n,
|
|
735
|
+
tenantId: `00000000-0000-4000-8000-0000000007${String(n).padStart(2, "0")}`,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
730
739
|
test("lists required keys without a usable value — and only those", async () => {
|
|
731
|
-
const { missing } = await stack.http.queryOk<Missing>(
|
|
740
|
+
const { missing } = await stack.http.queryOk<Missing>(
|
|
741
|
+
ConfigQueries.readiness,
|
|
742
|
+
{},
|
|
743
|
+
readinessAdminFor(1),
|
|
744
|
+
);
|
|
732
745
|
|
|
733
746
|
const keys = missing.map((m) => m.key);
|
|
734
747
|
expect(keys).toContain("transport:config:smtp-host");
|
|
@@ -740,29 +753,31 @@ describe("config.readiness query handler", () => {
|
|
|
740
753
|
});
|
|
741
754
|
|
|
742
755
|
test("whitespace-only text value still counts as missing (requireNonEmpty-Parität)", async () => {
|
|
756
|
+
const admin = readinessAdminFor(2);
|
|
743
757
|
await stack.http.writeOk(
|
|
744
758
|
ConfigHandlers.set,
|
|
745
759
|
{ key: "transport:config:api-url", value: " " },
|
|
746
|
-
|
|
760
|
+
admin,
|
|
747
761
|
);
|
|
748
762
|
|
|
749
|
-
const { missing } = await stack.http.queryOk<Missing>(ConfigQueries.readiness, {},
|
|
763
|
+
const { missing } = await stack.http.queryOk<Missing>(ConfigQueries.readiness, {}, admin);
|
|
750
764
|
expect(missing.map((m) => m.key)).toContain("transport:config:api-url");
|
|
751
765
|
});
|
|
752
766
|
|
|
753
767
|
test("a real value clears the key from the missing list", async () => {
|
|
768
|
+
const admin = readinessAdminFor(3);
|
|
754
769
|
await stack.http.writeOk(
|
|
755
770
|
ConfigHandlers.set,
|
|
756
771
|
{ key: "transport:config:api-url", value: "https://api.example.com" },
|
|
757
|
-
|
|
772
|
+
admin,
|
|
758
773
|
);
|
|
759
774
|
await stack.http.writeOk(
|
|
760
775
|
ConfigHandlers.set,
|
|
761
776
|
{ key: "transport:config:timeout", value: 30 },
|
|
762
|
-
|
|
777
|
+
admin,
|
|
763
778
|
);
|
|
764
779
|
|
|
765
|
-
const { missing } = await stack.http.queryOk<Missing>(ConfigQueries.readiness, {},
|
|
780
|
+
const { missing } = await stack.http.queryOk<Missing>(ConfigQueries.readiness, {}, admin);
|
|
766
781
|
const keys = missing.map((m) => m.key);
|
|
767
782
|
expect(keys).not.toContain("transport:config:api-url");
|
|
768
783
|
expect(keys).not.toContain("transport:config:timeout");
|
|
@@ -66,15 +66,41 @@ export async function collectMissingRequiredConfig(
|
|
|
66
66
|
callerQn: string,
|
|
67
67
|
user: SessionUser,
|
|
68
68
|
gate?: RequiredKeyGate,
|
|
69
|
+
options?: {
|
|
70
|
+
/** Verdict-Pfade (Rollup, selbst role-gated) MÜSSEN ungefiltert zählen:
|
|
71
|
+
* der Per-Key-read-Filter ist Info-Disclosure-Schutz für den
|
|
72
|
+
* openToAll-Handler — im Verdict droppte er SystemAdmin-gated
|
|
73
|
+
* required-Keys still und meldete ready:true trotz Lücke. */
|
|
74
|
+
readonly skipAccessFilter?: boolean;
|
|
75
|
+
},
|
|
69
76
|
): Promise<ReadinessMissingKey[]> {
|
|
70
77
|
const resolver = requireConfigResolver(ctx, callerQn);
|
|
71
78
|
const effectiveGate = gate ?? (await buildProviderSelectionGate(ctx, callerQn, user));
|
|
72
|
-
|
|
79
|
+
// Kandidaten erst sammeln, dann EIN Batch-Resolve — die sequentielle
|
|
80
|
+
// resolver.get-Schleife war ein N+1 über alle required Keys (272/1).
|
|
81
|
+
type KeyDef =
|
|
82
|
+
ReturnType<typeof ctx.registry.getAllConfigKeys> extends ReadonlyMap<string, infer D>
|
|
83
|
+
? D
|
|
84
|
+
: never;
|
|
85
|
+
const candidates = new Map<string, KeyDef>();
|
|
73
86
|
for (const [qualifiedKey, keyDef] of ctx.registry.getAllConfigKeys()) {
|
|
74
87
|
if (keyDef.required !== true) continue;
|
|
75
88
|
if (!effectiveGate(qualifiedKey)) continue;
|
|
76
|
-
if (!hasConfigAccess(keyDef.access.read, user.roles))
|
|
77
|
-
|
|
89
|
+
if (options?.skipAccessFilter !== true && !hasConfigAccess(keyDef.access.read, user.roles)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
candidates.set(qualifiedKey, keyDef);
|
|
93
|
+
}
|
|
94
|
+
const missing: ReadinessMissingKey[] = [];
|
|
95
|
+
const cascades = await resolver.getCascadeBatch(
|
|
96
|
+
[...candidates.keys()],
|
|
97
|
+
candidates,
|
|
98
|
+
user.tenantId,
|
|
99
|
+
user.id,
|
|
100
|
+
ctx.db,
|
|
101
|
+
);
|
|
102
|
+
for (const [qualifiedKey, keyDef] of candidates) {
|
|
103
|
+
const value = cascades.get(qualifiedKey)?.value;
|
|
78
104
|
if (isUnset(value, keyDef.type)) {
|
|
79
105
|
missing.push({ key: qualifiedKey, scope: keyDef.scope, type: keyDef.type });
|
|
80
106
|
}
|
|
@@ -614,6 +614,20 @@ describe("custom-fields integration — update-tenant-field (Bug-Bash D2)", () =
|
|
|
614
614
|
expect(sf["label"]).toEqual({ de: "Priorität", en: "Priority" });
|
|
615
615
|
});
|
|
616
616
|
|
|
617
|
+
test("update ohne label entfernt ein bestehendes Label (Vollersatz-Semantik)", async () => {
|
|
618
|
+
await defineField("property", "weight", "number");
|
|
619
|
+
await updateField("weight", {
|
|
620
|
+
serializedField: { type: "number" },
|
|
621
|
+
label: { de: "Gewicht", en: "Weight" },
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
await updateField("weight", { serializedField: { type: "number" } });
|
|
625
|
+
|
|
626
|
+
const row = await fetchDefinitionRow(admin.tenantId, "weight");
|
|
627
|
+
const sf = JSON.parse(String(row?.["serialized_field"])) as Record<string, unknown>;
|
|
628
|
+
expect(sf["label"]).toBeUndefined();
|
|
629
|
+
});
|
|
630
|
+
|
|
617
631
|
test("zwei sequentielle Updates ohne version_conflict (skipOptimisticLock)", async () => {
|
|
618
632
|
await defineField("property", "stage", "text");
|
|
619
633
|
await updateField("stage", { displayOrder: 1 });
|
|
@@ -63,7 +63,7 @@ import { wireCustomFieldsUserDataRightsFor } from "../wire-user-data-rights";
|
|
|
63
63
|
|
|
64
64
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
65
65
|
const NOW = (): Instant => getTemporal().Now.instant();
|
|
66
|
-
const PAST = (): Instant => getTemporal().
|
|
66
|
+
const PAST = (): Instant => getTemporal().Now.instant().subtract({ minutes: 1 });
|
|
67
67
|
|
|
68
68
|
const propertyEntity = createEntity({
|
|
69
69
|
table: "read_t15c_properties",
|
|
@@ -1,18 +1,6 @@
|
|
|
1
1
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
|
|
4
|
-
// guard:dup-ok — andere SQL als selectFieldDefinitionsForEntity; gleiche Bezeichner, verschiedene Queries
|
|
5
|
-
export async function selectFieldDefinitionsWithSerialized(
|
|
6
|
-
db: DbRunner,
|
|
7
|
-
entityName: string,
|
|
8
|
-
tenantId: string,
|
|
9
|
-
): Promise<readonly { field_key: string; serialized_field: unknown }[]> {
|
|
10
|
-
return asRawClient(db).unsafe(
|
|
11
|
-
"SELECT field_key, serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND tenant_id = $2",
|
|
12
|
-
[entityName, tenantId],
|
|
13
|
-
) as Promise<readonly { field_key: string; serialized_field: unknown }[]>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
4
|
export async function selectHostRowsWithCustomFields(
|
|
17
5
|
db: DbRunner,
|
|
18
6
|
tableName: string,
|
|
@@ -18,6 +18,14 @@ import { type UpdateFieldPayload, updateFieldPayloadSchema } from "../schemas";
|
|
|
18
18
|
// delete+redefine im update — das würde Event-Historie + Field-Ids
|
|
19
19
|
// zerstören, aber ein Type-Wechsel will genau diese Zäsur).
|
|
20
20
|
//
|
|
21
|
+
// **Bekannte MVP-Grenze (bewusst):** der Edit reconciled bestehende
|
|
22
|
+
// Host-Werte NICHT gegen die neue Definition — Constraint-Narrowing
|
|
23
|
+
// (enum-Wert weg, min/max enger) lässt alte Werte still non-conformant,
|
|
24
|
+
// required false→true macht Bestands-Rows unvollständig, searchable-Toggle
|
|
25
|
+
// re-indexed nicht. Werte werden beim NÄCHSTEN Write der Host-Row gegen
|
|
26
|
+
// die aktuelle Def validiert; eine Reject-mit-Konflikt-Liste-Variante
|
|
27
|
+
// wäre der Ausbau, wenn der Bedarf real wird.
|
|
28
|
+
//
|
|
21
29
|
// **skipOptimisticLock:** Definition-Edits sind admin-only + low-frequency
|
|
22
30
|
// (gleiche Abwägung wie der Quota-soft-cap in define). Last-write-wins
|
|
23
31
|
// statt version-Roundtrip durch den Edit-Screen.
|
|
@@ -23,6 +23,10 @@ export {
|
|
|
23
23
|
type SetCustomFieldPayload,
|
|
24
24
|
setCustomFieldPayloadSchema,
|
|
25
25
|
} from "./handlers/set-custom-field.write";
|
|
26
|
+
export {
|
|
27
|
+
isFieldDefinitionRow,
|
|
28
|
+
parseSerializedField,
|
|
29
|
+
} from "./lib/parse-serialized-field";
|
|
26
30
|
export {
|
|
27
31
|
type DefineFieldPayload,
|
|
28
32
|
type DeleteFieldPayload,
|
|
@@ -18,11 +18,8 @@
|
|
|
18
18
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
19
19
|
import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
20
20
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
selectFieldDefinitionsWithSerialized,
|
|
24
|
-
selectHostRowsWithCustomFields,
|
|
25
|
-
} from "./db/queries/retention";
|
|
21
|
+
import { applyRetentionRemovals, selectHostRowsWithCustomFields } from "./db/queries/retention";
|
|
22
|
+
import { selectFieldDefinitionsForEntity } from "./db/queries/user-data-rights";
|
|
26
23
|
import { isFieldDefinitionRow, parseSerializedField } from "./lib/parse-serialized-field";
|
|
27
24
|
|
|
28
25
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
@@ -156,7 +153,7 @@ async function loadRetentionPolicies(
|
|
|
156
153
|
tenantId: string,
|
|
157
154
|
entityName: string,
|
|
158
155
|
): Promise<Map<string, RetentionPolicy>> {
|
|
159
|
-
const rows = await
|
|
156
|
+
const rows = await selectFieldDefinitionsForEntity(db, entityName, tenantId);
|
|
160
157
|
const out = new Map<string, RetentionPolicy>();
|
|
161
158
|
for (const raw of rows) {
|
|
162
159
|
// skip: see asHostRow rationale.
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// hangs it into the LocaleProvider as a fallback bundle — apps override
|
|
4
4
|
// individual keys via `customFieldsClient({ translations: { de: { ... } } })`.
|
|
5
5
|
//
|
|
6
|
-
// Keys follow `custom-fields.<area>.<slug>`. `custom-fields.errors
|
|
7
|
-
//
|
|
6
|
+
// Keys follow `custom-fields.<area>.<slug>`. `custom-fields.errors.saveFailed`
|
|
7
|
+
// is a LOCAL fallback only — server handlers emit generic error i18nKeys
|
|
8
|
+
// (errors.unprocessable / errors.notFound via fail* defaults), never a
|
|
9
|
+
// custom-fields-specific key; the form prefers the server key when present.
|
|
8
10
|
|
|
9
11
|
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
10
12
|
|
|
@@ -15,7 +17,6 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
15
17
|
"custom-fields.form.empty": 'Keine Custom-Felder für "{entityName}" definiert.',
|
|
16
18
|
"custom-fields.form.save": "Custom-Felder speichern",
|
|
17
19
|
"custom-fields.form.saving": "Speichert…",
|
|
18
|
-
"custom-fields.errors.loadFailed": "Custom-Felder konnten nicht geladen werden.",
|
|
19
20
|
"custom-fields.errors.saveFailed": "Speichern fehlgeschlagen.",
|
|
20
21
|
},
|
|
21
22
|
en: {
|
|
@@ -24,7 +25,6 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
24
25
|
"custom-fields.form.empty": 'No custom fields defined for "{entityName}".',
|
|
25
26
|
"custom-fields.form.save": "Save custom fields",
|
|
26
27
|
"custom-fields.form.saving": "Saving…",
|
|
27
|
-
"custom-fields.errors.loadFailed": "Could not load custom fields.",
|
|
28
28
|
"custom-fields.errors.saveFailed": "Save failed.",
|
|
29
29
|
},
|
|
30
30
|
};
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
EXT_USER_DATA,
|
|
7
|
+
EXT_USER_DATA_ORDER,
|
|
8
|
+
type FeatureRegistrar,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
10
|
import {
|
|
7
11
|
selectCustomFieldsHostRows,
|
|
8
12
|
selectFieldDefinitionsForEntity,
|
|
@@ -38,7 +42,7 @@ function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
|
|
|
38
42
|
// jsonb PII silently retained (DSGVO Art. 17 violation). A negative order makes
|
|
39
43
|
// runForgetCleanup run this strip before any default-order (0) owner-nulling
|
|
40
44
|
// hook, independent of feature registration order.
|
|
41
|
-
const ORDER_REDACT_BEFORE_OWNER_MUTATION =
|
|
45
|
+
const ORDER_REDACT_BEFORE_OWNER_MUTATION = EXT_USER_DATA_ORDER.REDACT_BEFORE_OWNER;
|
|
42
46
|
|
|
43
47
|
export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<string>>(
|
|
44
48
|
r: TReg,
|
|
@@ -91,6 +91,10 @@ export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
91
91
|
// ("mailTransport").map(u => u.entityName)` as the option-list.
|
|
92
92
|
provider: createTenantConfig("text", {
|
|
93
93
|
default: "",
|
|
94
|
+
// required: ohne gewählten Provider wirft createTransportForTenant —
|
|
95
|
+
// readiness meldete vorher ready:true und der erste Mail-Send
|
|
96
|
+
// lieferte den UnconfiguredError (280/1).
|
|
97
|
+
required: true,
|
|
94
98
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
95
99
|
read: access.roles("TenantAdmin", "SystemAdmin", "User"),
|
|
96
100
|
}),
|
|
@@ -51,6 +51,15 @@ const probeFeature = defineFeature("readiness-probe", (r) => {
|
|
|
51
51
|
default: 30,
|
|
52
52
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
53
53
|
}),
|
|
54
|
+
// Operator-Key: required, aber read/write über dem TenantAdmin —
|
|
55
|
+
// der Verdict muss ihn TROTZDEM zählen (277/1: der Per-Key-read-
|
|
56
|
+
// Filter droppte ihn still und log ready:true).
|
|
57
|
+
operatorEndpoint: createTenantConfig("text", {
|
|
58
|
+
required: true,
|
|
59
|
+
default: "",
|
|
60
|
+
write: access.roles("SystemAdmin"),
|
|
61
|
+
read: access.roles("SystemAdmin"),
|
|
62
|
+
}),
|
|
54
63
|
},
|
|
55
64
|
});
|
|
56
65
|
|
|
@@ -169,6 +178,16 @@ function adminFor(tenantNumber: number) {
|
|
|
169
178
|
});
|
|
170
179
|
}
|
|
171
180
|
|
|
181
|
+
// Der 277/1-Probe-Key ist SystemAdmin-gated — Tests, die ready:true
|
|
182
|
+
// erwarten, setzen ihn über diesen Helper (gleicher Tenant, Operator-Rolle).
|
|
183
|
+
async function setOperatorEndpoint(admin: ReturnType<typeof adminFor>): Promise<void> {
|
|
184
|
+
await stack.http.writeOk(
|
|
185
|
+
"config:write:set",
|
|
186
|
+
{ key: "readiness-probe:config:operator-endpoint", value: "https://op.example.test" },
|
|
187
|
+
{ ...admin, roles: ["SystemAdmin"] },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
172
191
|
async function statusFor(admin: ReturnType<typeof adminFor>): Promise<StatusResult> {
|
|
173
192
|
return stack.http.queryOk<StatusResult>(ReadinessQueries.status, {}, admin);
|
|
174
193
|
}
|
|
@@ -206,6 +225,7 @@ describe("readiness:query:status", () => {
|
|
|
206
225
|
{ key: REQUIRED_SECRET_KEY, value: "token-xyz" },
|
|
207
226
|
admin,
|
|
208
227
|
);
|
|
228
|
+
await setOperatorEndpoint(admin);
|
|
209
229
|
|
|
210
230
|
const status = await statusFor(admin);
|
|
211
231
|
expect(status.missingConfig.map((k) => k.key)).not.toContain(REQUIRED_CONFIG_KEY);
|
|
@@ -213,6 +233,19 @@ describe("readiness:query:status", () => {
|
|
|
213
233
|
expect(status.ready).toBe(true);
|
|
214
234
|
});
|
|
215
235
|
|
|
236
|
+
test("SystemAdmin-gated required Key zählt im Verdict des TenantAdmin (277/1)", async () => {
|
|
237
|
+
const admin = adminFor(605);
|
|
238
|
+
|
|
239
|
+
const status = await statusFor(admin);
|
|
240
|
+
|
|
241
|
+
// Der Caller darf den Key nicht LESEN — fürs Verdict muss er trotzdem
|
|
242
|
+
// als missing erscheinen, sonst lügt ready:true.
|
|
243
|
+
expect(status.missingConfig.map((k) => k.key)).toContain(
|
|
244
|
+
"readiness-probe:config:operator-endpoint",
|
|
245
|
+
);
|
|
246
|
+
expect(status.ready).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
216
249
|
test("tenant isolation: tenant A's values don't make tenant B ready", async () => {
|
|
217
250
|
const adminA = adminFor(603);
|
|
218
251
|
const adminB = adminFor(604);
|
|
@@ -227,6 +260,7 @@ describe("readiness:query:status", () => {
|
|
|
227
260
|
{ key: REQUIRED_SECRET_KEY, value: "token-a" },
|
|
228
261
|
adminA,
|
|
229
262
|
);
|
|
263
|
+
await setOperatorEndpoint(adminA);
|
|
230
264
|
|
|
231
265
|
expect((await statusFor(adminA)).ready).toBe(true);
|
|
232
266
|
const statusB = await statusFor(adminB);
|
|
@@ -288,6 +322,7 @@ describe("readiness:query:status", () => {
|
|
|
288
322
|
{ key: REQUIRED_SECRET_KEY, value: "token-609" },
|
|
289
323
|
admin,
|
|
290
324
|
);
|
|
325
|
+
await setOperatorEndpoint(admin);
|
|
291
326
|
|
|
292
327
|
const status = await statusFor(admin);
|
|
293
328
|
expect(status.missingConfig).toEqual([]);
|
|
@@ -20,11 +20,15 @@ export const statusQuery = defineQueryHandler({
|
|
|
20
20
|
// One gate for both halves: required keys/secrets of provider-features
|
|
21
21
|
// count only while their provider is the selected one (r.extensionSelector).
|
|
22
22
|
const gate = await buildProviderSelectionGate(ctx, ReadinessQueries.status, query.user);
|
|
23
|
+
// skipAccessFilter: das Verdict muss ALLE required Keys zählen — der
|
|
24
|
+
// Handler selbst ist TenantAdmin-gated, der Per-Key-Filter wäre hier
|
|
25
|
+
// eine ready:true-Lüge für SystemAdmin-gated Keys (277/1).
|
|
23
26
|
const missingConfig = await collectMissingRequiredConfig(
|
|
24
27
|
ctx,
|
|
25
28
|
ReadinessQueries.status,
|
|
26
29
|
query.user,
|
|
27
30
|
gate,
|
|
31
|
+
{ skipAccessFilter: true },
|
|
28
32
|
);
|
|
29
33
|
|
|
30
34
|
// has() is metadata-only: no decryption, no read-audit event — a
|
|
@@ -358,6 +358,65 @@ describe("template-resolver :: list query", () => {
|
|
|
358
358
|
expect(slugs).not.toContain("list-system-2");
|
|
359
359
|
});
|
|
360
360
|
|
|
361
|
+
// Regressions-Pin 230/2 — SystemAdmin-Zweige der list-Query. Empirisch
|
|
362
|
+
// (und gewollt): auch SystemAdmin sieht über die TenantDb nur den
|
|
363
|
+
// [own, SYSTEM]-Scope — es gibt KEINE Cross-Tenant-Sicht auf fremde
|
|
364
|
+
// Tenant-Templates. TestUsers.systemAdmin lebt in testTenantId(1),
|
|
365
|
+
// NICHT im System-Tenant.
|
|
366
|
+
test("SystemAdmin + includeSystem=false → nur eigener Tenant, weder System- noch Fremd-Templates", async () => {
|
|
367
|
+
await stack.http.writeOk(
|
|
368
|
+
TemplateResolverHandlers.upsertSystem,
|
|
369
|
+
{ ...basePayload, slug: "sysown-system", locale: "pl", kind: "mail-html" },
|
|
370
|
+
systemAdmin,
|
|
371
|
+
);
|
|
372
|
+
await stack.http.writeOk(
|
|
373
|
+
TemplateResolverHandlers.upsertTenant,
|
|
374
|
+
{ ...basePayload, slug: "sysown-own", locale: "pl", kind: "mail-html" },
|
|
375
|
+
systemAdmin,
|
|
376
|
+
);
|
|
377
|
+
await stack.http.writeOk(
|
|
378
|
+
TemplateResolverHandlers.upsertTenant,
|
|
379
|
+
{ ...basePayload, slug: "sysown-tenant-a", locale: "pl", kind: "mail-html" },
|
|
380
|
+
tenantA_Admin,
|
|
381
|
+
);
|
|
382
|
+
const result = (await stack.http.queryOk(
|
|
383
|
+
TemplateResolverQueries.list,
|
|
384
|
+
{ kind: "mail-html", locale: "pl", includeSystem: false },
|
|
385
|
+
systemAdmin,
|
|
386
|
+
)) as Array<{ slug: string }>;
|
|
387
|
+
const slugs = result.map((r) => r.slug);
|
|
388
|
+
expect(slugs).toContain("sysown-own");
|
|
389
|
+
expect(slugs).not.toContain("sysown-system");
|
|
390
|
+
expect(slugs).not.toContain("sysown-tenant-a");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("SystemAdmin + includeSystem=true → eigene + System-Templates, Fremd-Tenant bleibt unsichtbar", async () => {
|
|
394
|
+
await stack.http.writeOk(
|
|
395
|
+
TemplateResolverHandlers.upsertSystem,
|
|
396
|
+
{ ...basePayload, slug: "syscross-system", locale: "nl", kind: "mail-html" },
|
|
397
|
+
systemAdmin,
|
|
398
|
+
);
|
|
399
|
+
await stack.http.writeOk(
|
|
400
|
+
TemplateResolverHandlers.upsertTenant,
|
|
401
|
+
{ ...basePayload, slug: "syscross-own", locale: "nl", kind: "mail-html" },
|
|
402
|
+
systemAdmin,
|
|
403
|
+
);
|
|
404
|
+
await stack.http.writeOk(
|
|
405
|
+
TemplateResolverHandlers.upsertTenant,
|
|
406
|
+
{ ...basePayload, slug: "syscross-tenant-b", locale: "nl", kind: "mail-html" },
|
|
407
|
+
tenantB_Admin,
|
|
408
|
+
);
|
|
409
|
+
const result = (await stack.http.queryOk(
|
|
410
|
+
TemplateResolverQueries.list,
|
|
411
|
+
{ kind: "mail-html", locale: "nl", includeSystem: true },
|
|
412
|
+
systemAdmin,
|
|
413
|
+
)) as Array<{ slug: string }>;
|
|
414
|
+
const slugs = result.map((r) => r.slug);
|
|
415
|
+
expect(slugs).toContain("syscross-system");
|
|
416
|
+
expect(slugs).toContain("syscross-own");
|
|
417
|
+
expect(slugs).not.toContain("syscross-tenant-b");
|
|
418
|
+
});
|
|
419
|
+
|
|
361
420
|
test("tenant-isolation: TenantA's templates nicht für TenantB", async () => {
|
|
362
421
|
await stack.http.writeOk(
|
|
363
422
|
TemplateResolverHandlers.upsertTenant,
|
|
@@ -36,6 +36,7 @@ import { fetchOne, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/
|
|
|
36
36
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
37
37
|
import {
|
|
38
38
|
EXT_USER_DATA,
|
|
39
|
+
EXT_USER_DATA_ORDER,
|
|
39
40
|
type Registry,
|
|
40
41
|
type TenantId,
|
|
41
42
|
type UserDataDeleteHook,
|
|
@@ -112,7 +113,7 @@ interface HookEntry {
|
|
|
112
113
|
// EXT_USER_DATA delete-hooks default here; a hook that redacts data keyed on an
|
|
113
114
|
// owner column it doesn't own must register a lower order so it runs BEFORE any
|
|
114
115
|
// hook that nulls that column. See custom-fields wire-user-data-rights.ts.
|
|
115
|
-
const HOOK_ORDER_DEFAULT =
|
|
116
|
+
const HOOK_ORDER_DEFAULT = EXT_USER_DATA_ORDER.DEFAULT;
|
|
116
117
|
|
|
117
118
|
export async function runForgetCleanup(
|
|
118
119
|
args: RunForgetCleanupArgs,
|
|
@@ -326,7 +327,13 @@ async function runInSubTransaction(
|
|
|
326
327
|
begin?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
|
|
327
328
|
savepoint?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
|
|
328
329
|
};
|
|
329
|
-
|
|
330
|
+
// savepoint-FIRST — empirisch (Bun 1.3.14) sind die Flächen NICHT
|
|
331
|
+
// mutually exclusive: eine TransactionSql exposed begin UND savepoint,
|
|
332
|
+
// nur die Top-Level-Connection hat ausschließlich begin. begin-first
|
|
333
|
+
// wählte im Tx-Fall das nested BEGIN (Prod-Incident-Klasse, s. Header);
|
|
334
|
+
// savepoint-first trifft im Tx-Fall den Savepoint und fällt top-level
|
|
335
|
+
// sauber auf begin zurück.
|
|
336
|
+
const open = runner.savepoint ?? runner.begin;
|
|
330
337
|
if (!open) {
|
|
331
338
|
throw new Error(
|
|
332
339
|
"runForgetCleanup: db exposes neither .begin nor .savepoint — cannot open a per-user sub-transaction",
|