@cosmicdrift/kumiko-bundled-features 0.39.0 → 0.40.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
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>",
@@ -78,6 +78,7 @@
78
78
  "dependencies": {
79
79
  "@cosmicdrift/kumiko-dispatcher-live": "0.38.0",
80
80
  "@cosmicdrift/kumiko-framework": "0.38.0",
81
+ "@cosmicdrift/kumiko-headless": "0.38.0",
81
82
  "@cosmicdrift/kumiko-renderer": "0.38.0",
82
83
  "@cosmicdrift/kumiko-renderer-web": "0.38.0",
83
84
  "@mollie/api-client": "^4.5.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
+ });
@@ -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>(ConfigQueries.readiness, {}, tenantAdmin);
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
- tenantAdmin,
760
+ admin,
747
761
  );
748
762
 
749
- const { missing } = await stack.http.queryOk<Missing>(ConfigQueries.readiness, {}, tenantAdmin);
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
- tenantAdmin,
772
+ admin,
758
773
  );
759
774
  await stack.http.writeOk(
760
775
  ConfigHandlers.set,
761
776
  { key: "transport:config:timeout", value: 30 },
762
- tenantAdmin,
777
+ admin,
763
778
  );
764
779
 
765
- const { missing } = await stack.http.queryOk<Missing>(ConfigQueries.readiness, {}, tenantAdmin);
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
- const missing: ReadinessMissingKey[] = [];
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)) continue;
77
- const value = await resolver.get(qualifiedKey, keyDef, user.tenantId, user.id, ctx.db);
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().Instant.fromEpochMilliseconds(Date.now() - 60_000);
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
- applyRetentionRemovals,
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 selectFieldDefinitionsWithSerialized(db, entityName, tenantId);
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.*` mirror
7
- // the i18nKeys the server-side handlers emit (e.g. `custom-fields:save-failed`).
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 { EXT_USER_DATA, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
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 = -100;
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 = 0;
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
- const open = runner.begin ?? runner.savepoint;
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",