@cosmicdrift/kumiko-bundled-features 0.48.1 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +2 -1
  2. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +51 -0
  3. package/src/auth-email-password/constants.ts +6 -0
  4. package/src/auth-email-password/errors.ts +19 -0
  5. package/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  6. package/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  7. package/src/auth-email-password/handlers/signup-confirm.write.ts +22 -12
  8. package/src/auth-email-password/handlers/signup-request.write.ts +12 -10
  9. package/src/auth-email-password/i18n.ts +4 -0
  10. package/src/auth-email-password/password-hashing.ts +1 -0
  11. package/src/auth-email-password/reset-token.ts +2 -0
  12. package/src/auth-email-password/seeding.ts +19 -4
  13. package/src/auth-email-password/signup-token-store.ts +1 -0
  14. package/src/auth-email-password/verification-token.ts +2 -0
  15. package/src/billing-foundation/aggregate-id.ts +1 -0
  16. package/src/cap-counter/aggregate-id.ts +2 -0
  17. package/src/config/__tests__/app-override-visibility.integration.test.ts +143 -0
  18. package/src/config/__tests__/cascade.integration.test.ts +111 -1
  19. package/src/config/__tests__/env-overrides.test.ts +134 -0
  20. package/src/config/__tests__/inherited-redaction.integration.test.ts +180 -0
  21. package/src/config/__tests__/read-redaction.test.ts +112 -0
  22. package/src/config/__tests__/settings-hub-feature-name.test.ts +14 -0
  23. package/src/config/constants.ts +3 -1
  24. package/src/config/handlers/cascade.query.ts +9 -1
  25. package/src/config/handlers/values.query.ts +34 -12
  26. package/src/config/index.ts +1 -1
  27. package/src/config/read-redaction.ts +54 -0
  28. package/src/config/resolver.ts +70 -1
  29. package/src/config/web/client-plugin.ts +24 -0
  30. package/src/config/web/i18n.ts +25 -0
  31. package/src/config/web/index.ts +3 -0
  32. package/src/custom-fields/aggregate-id.ts +1 -0
  33. package/src/custom-fields/wire-for-entity.ts +1 -0
  34. package/src/delivery/upsert-preference.ts +1 -0
  35. package/src/file-provider-inmemory/feature.ts +1 -1
  36. package/src/file-provider-s3/feature.ts +1 -1
  37. package/src/mail-transport-inmemory/feature.ts +1 -1
  38. package/src/mail-transport-smtp/feature.ts +1 -1
  39. package/src/step-dispatcher/mail-runner.ts +1 -0
  40. package/src/subscription-stripe/runtime.ts +1 -0
  41. package/src/subscription-stripe/verify-webhook.ts +1 -0
  42. package/src/tenant/__tests__/multi-tenant.integration.test.ts +48 -0
  43. package/src/tenant/handlers/list.query.ts +1 -1
  44. package/src/tenant/handlers/memberships.query.ts +16 -15
  45. package/src/tenant/handlers/toggle-enabled.write.ts +1 -1
  46. package/src/tenant/handlers/update.write.ts +1 -1
  47. package/src/text-content/api.ts +1 -0
  48. package/src/tier-engine/aggregate-id.ts +1 -0
  49. package/src/user/handlers/me.query.ts +1 -1
  50. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  51. package/src/user-data-rights/deletion-token.ts +2 -0
  52. package/src/user-data-rights/feature.ts +1 -1
  53. package/src/user-data-rights/run-export-jobs.ts +2 -0
  54. package/src/user-profile/handlers/change-email.write.ts +1 -0
@@ -1,13 +1,16 @@
1
1
  import {
2
+ type ConfigKeyDefinition,
2
3
  type ConfigScope,
3
4
  type ConfigValueSource,
4
5
  defineQueryHandler,
5
6
  } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import { z } from "zod";
7
8
  import { requireConfigResolver } from "../feature";
8
- import { deserializeValue } from "../resolver";
9
+ import { redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
9
10
  import { hasConfigAccess } from "../write-helpers";
10
11
 
12
+ const MASKED = "••••••";
13
+
11
14
  export const valuesQuery = defineQueryHandler({
12
15
  name: "values",
13
16
  schema: z.object({}),
@@ -19,7 +22,24 @@ export const valuesQuery = defineQueryHandler({
19
22
  const resolver = requireConfigResolver(ctx, "config:query:values");
20
23
 
21
24
  const allKeys = registry.getAllConfigKeys();
22
- const storedValues = await resolver.getAllWithSource(query.user.tenantId, query.user.id, db);
25
+ const keyDefs = new Map<string, ConfigKeyDefinition>();
26
+ const filteredKeys: string[] = [];
27
+ for (const [qualifiedKey, keyDef] of allKeys) {
28
+ if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
29
+ keyDefs.set(qualifiedKey, keyDef);
30
+ filteredKeys.push(qualifiedKey);
31
+ }
32
+
33
+ // Resolve through the full cascade (rows → app-override → computed →
34
+ // default) — the same path as config:query:cascade — so the mask shows the
35
+ // inherited default (e.g. an ENV-bridged app-override), not only DB rows.
36
+ const cascades = await resolver.getCascadeBatch(
37
+ filteredKeys,
38
+ keyDefs,
39
+ query.user.tenantId,
40
+ query.user.id,
41
+ db,
42
+ );
23
43
 
24
44
  const result: Record<
25
45
  string,
@@ -30,22 +50,24 @@ export const valuesQuery = defineQueryHandler({
30
50
  }
31
51
  > = {};
32
52
 
33
- for (const [qualifiedKey, keyDef] of allKeys) {
34
- if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
53
+ for (const [qualifiedKey, keyDef] of keyDefs) {
54
+ const rawCascade = cascades.get(qualifiedKey);
55
+ if (!rawCascade) continue;
35
56
 
36
- const stored = storedValues.get(qualifiedKey);
37
- let value: string | number | boolean | undefined;
38
- const source: ConfigValueSource = stored?.source ?? "default";
57
+ // Redact inherited platform rungs BEFORE masking — masking alone leaves
58
+ // a value present and would leak "it is set" to a tenant-side viewer.
59
+ const cascade = shouldRedactInherited(keyDef, query.user.roles)
60
+ ? redactInheritedCascade(rawCascade)
61
+ : rawCascade;
39
62
 
63
+ let value: string | number | boolean | undefined;
40
64
  if (keyDef.encrypted) {
41
- value = stored ? "••••••" : undefined;
42
- } else if (stored?.value !== null && stored?.value !== undefined) {
43
- value = deserializeValue(stored.value, keyDef.type);
65
+ value = cascade.value !== undefined ? MASKED : undefined;
44
66
  } else {
45
- value = keyDef.default;
67
+ value = cascade.value;
46
68
  }
47
69
 
48
- result[qualifiedKey] = { value, scope: keyDef.scope, source };
70
+ result[qualifiedKey] = { value, scope: keyDef.scope, source: cascade.source };
49
71
  }
50
72
 
51
73
  return result;
@@ -21,7 +21,7 @@ export {
21
21
  collectMissingRequiredConfig,
22
22
  } from "./handlers/readiness.query";
23
23
  export type { AppConfigOverrides, ConfigResolver } from "./resolver";
24
- export { createConfigResolver, validateAppOverrides } from "./resolver";
24
+ export { buildEnvConfigOverrides, createConfigResolver, validateAppOverrides } from "./resolver";
25
25
  export { configValuesTable } from "./table";
26
26
 
27
27
  // Boot helper for runDevApp / runProdApp: pulls every ConfigSeedDef from
@@ -0,0 +1,54 @@
1
+ import type {
2
+ ConfigCascade,
3
+ ConfigCascadeLevel,
4
+ ConfigKeyDefinition,
5
+ ConfigValueSource,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ const SYSTEM_ADMIN_ROLE = "SystemAdmin";
9
+
10
+ // The viewer's own cascade rungs. Every other rung (system-row, app-override,
11
+ // computed, default) carries a platform-inherited value — for an
12
+ // inheritedToTenant:false key those must stay hidden from a tenant-side viewer.
13
+ const OWN_SOURCES: ReadonlySet<ConfigValueSource> = new Set(["user-row", "tenant-row"]);
14
+
15
+ // A SystemAdmin owns the platform-level values and may always see them. Every
16
+ // other viewer (TenantAdmin, User) is tenant-side — for an
17
+ // inheritedToTenant:false key they must learn neither the inherited platform
18
+ // value nor that it is set.
19
+ export function mayViewInheritedValue(roles: readonly string[]): boolean {
20
+ return roles.includes(SYSTEM_ADMIN_ROLE);
21
+ }
22
+
23
+ export function shouldRedactInherited(
24
+ keyDef: Pick<ConfigKeyDefinition, "inheritedToTenant">,
25
+ roles: readonly string[],
26
+ ): boolean {
27
+ return keyDef.inheritedToTenant === false && !mayViewInheritedValue(roles);
28
+ }
29
+
30
+ // Strips every platform-inherited level (system-row, app-override, computed,
31
+ // default — anything that is not the viewer's own user-/tenant-row) so a
32
+ // tenant-side viewer sees an inheritedToTenant:false key as if no platform
33
+ // value existed, then recomputes the winning level among the survivors. A
34
+ // no-op when the cascade carries no inherited value.
35
+ export function redactInheritedCascade(cascade: ConfigCascade): ConfigCascade {
36
+ let redacted = false;
37
+ const levels: ConfigCascadeLevel[] = cascade.levels.map((level) => {
38
+ if (!OWN_SOURCES.has(level.source) && level.hasValue) {
39
+ redacted = true;
40
+ return { ...level, value: undefined, hasValue: false, isActive: false };
41
+ }
42
+ return { ...level, isActive: false };
43
+ });
44
+ if (!redacted) return cascade;
45
+
46
+ for (let i = 0; i < levels.length; i++) {
47
+ const level = levels[i];
48
+ if (level?.hasValue) {
49
+ levels[i] = { ...level, isActive: true };
50
+ return { value: level.value, source: level.source, levels };
51
+ }
52
+ }
53
+ return { value: undefined, source: "missing", levels };
54
+ }
@@ -93,6 +93,12 @@ async function buildCascade(
93
93
  case "user":
94
94
  lookups.push({ tenantId, userId, source: "user-row", label: "User" });
95
95
  lookups.push({ tenantId, userId: null, source: "tenant-row", label: "Tenant" });
96
+ lookups.push({
97
+ tenantId: SYSTEM_TENANT_ID,
98
+ userId: null,
99
+ source: "system-row",
100
+ label: "System",
101
+ });
96
102
  break;
97
103
  case "tenant":
98
104
  lookups.push({ tenantId, userId: null, source: "tenant-row", label: "Tenant" });
@@ -240,7 +246,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
240
246
  db,
241
247
  ): Promise<ConfigValueWithSource> {
242
248
  // Resolution cascade based on scope
243
- // user: userId+tenantId → tenantId → default
249
+ // user: userId+tenantId → tenantId → SYSTEM_TENANT_ID → default
244
250
  // tenant: tenantId → SYSTEM_TENANT_ID → default
245
251
  // system: SYSTEM_TENANT_ID → default
246
252
  const lookups: Array<{
@@ -253,6 +259,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
253
259
  case "user":
254
260
  lookups.push({ tenantId, userId, source: "user-row" });
255
261
  lookups.push({ tenantId, userId: null, source: "tenant-row" });
262
+ lookups.push({ tenantId: SYSTEM_TENANT_ID, userId: null, source: "system-row" });
256
263
  break;
257
264
  case "tenant":
258
265
  lookups.push({ tenantId, userId: null, source: "tenant-row" });
@@ -505,3 +512,65 @@ export function validateAppOverrides(
505
512
 
506
513
  return validated;
507
514
  }
515
+
516
+ // --- ENV → App-Override Bridge ---
517
+ //
518
+ // Realises the `env` field of a config key: at boot every config
519
+ // key that declares `env` reads its value from the process environment and
520
+ // injects it as an app-override default (the cascade rung between the
521
+ // tenant/system rows and the declared default). String env values are coerced
522
+ // to the key's type; a mismatch fails the boot loudly rather than silently
523
+ // resolving to the wrong value. Reuses validateAppOverrides for the
524
+ // existence / bounds / options / computed-conflict gates.
525
+ //
526
+ // undefined OR empty-string env vars are skipped — an unset (or `FOO=`)
527
+ // variable must not clobber a declared default.
528
+
529
+ type EnvSource = Readonly<Record<string, string | undefined>>;
530
+
531
+ type ConfigKeyRegistry = {
532
+ getAllConfigKeys: () => ReadonlyMap<string, ConfigKeyDefinition>;
533
+ getConfigKey: (key: string) => ConfigKeyDefinition | undefined;
534
+ };
535
+
536
+ function coerceEnvValue(
537
+ qualifiedKey: string,
538
+ envName: string,
539
+ type: ConfigKeyDefinition["type"],
540
+ raw: string,
541
+ ): string | number | boolean {
542
+ if (type === "number") {
543
+ const n = Number(raw.trim());
544
+ if (!Number.isFinite(n)) {
545
+ throw new Error(
546
+ `ENV config bridge: "${envName}" → "${qualifiedKey}" expects a number, got "${raw}".`,
547
+ );
548
+ }
549
+ return n;
550
+ }
551
+ if (type === "boolean") {
552
+ const v = raw.trim().toLowerCase();
553
+ if (v === "true" || v === "1") return true;
554
+ if (v === "false" || v === "0") return false;
555
+ throw new Error(
556
+ `ENV config bridge: "${envName}" → "${qualifiedKey}" expects a boolean (true/false/1/0), got "${raw}".`,
557
+ );
558
+ }
559
+ // text | select — select-option membership is validated by validateAppOverrides
560
+ return raw;
561
+ }
562
+
563
+ export function buildEnvConfigOverrides(
564
+ registry: ConfigKeyRegistry,
565
+ env: EnvSource,
566
+ ): AppConfigOverrides {
567
+ const record: Record<string, string | number | boolean> = {};
568
+ for (const [qualifiedKey, keyDef] of registry.getAllConfigKeys()) {
569
+ const envName = keyDef.env;
570
+ if (!envName) continue;
571
+ const raw = env[envName];
572
+ if (raw === undefined || raw === "") continue;
573
+ record[qualifiedKey] = coerceEnvValue(qualifiedKey, envName, keyDef.type, raw);
574
+ }
575
+ return validateAppOverrides(registry, record);
576
+ }
@@ -0,0 +1,24 @@
1
+ // @runtime client
2
+ import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
+ import { CONFIG_FEATURE } from "../constants";
4
+ import { defaultTranslations } from "./i18n";
5
+
6
+ export type ConfigClientOptions = {
7
+ /** Key-wise overrides over the default bundles (de/en). */
8
+ readonly translations?: TranslationsByLocale;
9
+ };
10
+
11
+ export type ConfigClientFeature = {
12
+ readonly name: typeof CONFIG_FEATURE;
13
+ readonly translations: TranslationsByLocale;
14
+ };
15
+
16
+ // Ships the generic Settings-Hub labels (config.settings.*). Mount it in
17
+ // clientFeatures next to the app's own feature client — without it the
18
+ // audience groups render their raw i18n keys.
19
+ export function configClient(options?: ConfigClientOptions): ConfigClientFeature {
20
+ return {
21
+ name: CONFIG_FEATURE,
22
+ translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
23
+ };
24
+ }
@@ -0,0 +1,25 @@
1
+ // @runtime client
2
+ // Default labels for the auto-generated Settings-Hub. The generator
3
+ // (buildConfigFeatureSchema) emits `config.settings.<scope>` for the audience
4
+ // groups and `config.settings.title` for the synthetic workspace — generic
5
+ // across every app, so they ship here. configClient() hangs them into the
6
+ // LocaleProvider as a fallback; an app overrides individual keys via
7
+ // configClient({ translations: { de: { ... } } }). The app only adds labels
8
+ // for ITS keys (mask.title) and the per-feature group key `<feature>.settings`.
9
+
10
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
11
+
12
+ export const defaultTranslations: TranslationsByLocale = {
13
+ de: {
14
+ "config.settings.title": "Einstellungen",
15
+ "config.settings.system": "Plattform",
16
+ "config.settings.tenant": "Organisation",
17
+ "config.settings.user": "Persönlich",
18
+ },
19
+ en: {
20
+ "config.settings.title": "Settings",
21
+ "config.settings.system": "Platform",
22
+ "config.settings.tenant": "Organization",
23
+ "config.settings.user": "Personal",
24
+ },
25
+ };
@@ -0,0 +1,3 @@
1
+ // @runtime client
2
+ export { type ConfigClientFeature, type ConfigClientOptions, configClient } from "./client-plugin";
3
+ export { defaultTranslations } from "./i18n";
@@ -24,6 +24,7 @@ const FIELD_DEFINITION_NAMESPACE = "f1d3b2c7-4e5a-4b9c-8d1f-2a3b4c5d6e7f";
24
24
  * definieren (422 `fieldKey_conflict`). Verhindert Resolution-Ambiguität
25
25
  * beim Read.
26
26
  */
27
+ // @wrapper-known uuid-domain
27
28
  export function fieldDefinitionAggregateId(
28
29
  tenantId: string,
29
30
  entityName: string,
@@ -29,6 +29,7 @@ import { customFieldsFeature } from "./feature";
29
29
  //
30
30
  // Spec-Promise: customFields verhält sich wie Stammfelder. Default `{}`,
31
31
  // NOT NULL — analog zu embedded-Spalten.
32
+ // @wrapper-known semantic-alias
32
33
  export function customFieldsField(): JsonbFieldDef {
33
34
  return createJsonbField();
34
35
  }
@@ -27,6 +27,7 @@ const executor = createEventStoreExecutor(
27
27
  // im Helper (db-row-boundary), nicht 4× pro Callsite.
28
28
  type PreferenceLookupRow = { readonly id: string; readonly version: number };
29
29
 
30
+ // @wrapper-known semantic-alias
30
31
  async function lookup(
31
32
  db: TenantDb,
32
33
  tenantId: TenantId,
@@ -73,7 +73,7 @@ export const fileProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
73
73
  // Returnt den per-tenant Storage. Identitätsstabil zwischen calls
74
74
  // damit accumulated state erhalten bleibt.
75
75
  return getOrCreateProviderForTenant(tenantId);
76
- },
76
+ }, // @wrapper-known semantic-alias
77
77
  };
78
78
  r.useExtension("fileProvider", "inmemory", plugin);
79
79
  });
@@ -97,7 +97,7 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
97
97
  // Plugin-Registration. entityName "s3" ist was tenants in
98
98
  // file-foundation's `provider` config-key setzen.
99
99
  const plugin: FileProviderPlugin = {
100
- build: async (ctx: FileProviderContext, tenantId: string) => buildS3Provider(ctx, tenantId),
100
+ build: async (ctx: FileProviderContext, tenantId: string) => buildS3Provider(ctx, tenantId), // @wrapper-known semantic-alias
101
101
  };
102
102
  r.useExtension("fileProvider", "s3", plugin);
103
103
 
@@ -87,7 +87,7 @@ export const mailTransportInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
87
87
  // Returnt den per-tenant Buffer. Identitätsstabil zwischen calls
88
88
  // damit die Demo-Inbox accumulated bleibt.
89
89
  return getOrCreateTransportForTenant(tenantId);
90
- },
90
+ }, // @wrapper-known semantic-alias
91
91
  };
92
92
  r.useExtension("mailTransport", "inmemory", plugin);
93
93
  });
@@ -110,7 +110,7 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
110
110
  // `entityName` "smtp" is what tenants set in mail-foundation's
111
111
  // `provider` config-key to pick this transport.
112
112
  const plugin: MailTransportPlugin = {
113
- build: async (ctx: HandlerContext, tenantId: string) => buildSmtpTransport(ctx, tenantId),
113
+ build: async (ctx: HandlerContext, tenantId: string) => buildSmtpTransport(ctx, tenantId), // @wrapper-known semantic-alias
114
114
  };
115
115
  r.useExtension("mailTransport", "smtp", plugin);
116
116
 
@@ -27,6 +27,7 @@ export function setMailRunner(fn: (spec: MailSpec) => Promise<MailDispatchResult
27
27
  mailRunner = fn;
28
28
  }
29
29
 
30
+ // @wrapper-known entry-point
30
31
  export async function performMailDispatch(spec: MailSpec): Promise<MailDispatchResult> {
31
32
  return mailRunner(spec);
32
33
  }
@@ -128,6 +128,7 @@ export function createStripeRuntimes(deps: StripeRuntimeDeps): StripeRuntimes {
128
128
 
129
129
  return {
130
130
  ctx: {
131
+ // @wrapper-known semantic-alias
131
132
  clientForCtx: async (ctx) => clientFor(await ctxApiKey(ctx)),
132
133
  assertBillingLive: async (ctx) => {
133
134
  const live = ctx.config ? await ctx.config(deps.billingLiveHandle) : undefined;
@@ -207,6 +207,7 @@ export function mapStripeStatus(stripeStatus: Stripe.Subscription.Status): Subsc
207
207
  /** Holt die Subscription aus dem Event. Subscription-events haben sie
208
208
  * direkt im data.object; invoice-events haben nur die subscription-id
209
209
  * und brauchen einen lazy-fetch via stripe.subscriptions.retrieve. */
210
+ // @wrapper-known semantic-alias
210
211
  async function extractSubscriptionFromEvent(
211
212
  event: Stripe.Event,
212
213
  stripe: Stripe,
@@ -344,3 +344,51 @@ describe("disabled tenant", () => {
344
344
  expect(activeIds.data).toContain(testTenantId(2));
345
345
  });
346
346
  });
347
+
348
+ // --- Scenario 7: memberships batch-load (#324) ---
349
+ //
350
+ // Die memberships-Query reichert alle Tenants in EINEM IN-Batch an statt eines
351
+ // fetchOne pro Membership. Dieser Test deckt die zwei Dinge ab, die der
352
+ // Batch-Refactor brechen könnte: korrektes Keying jeder Membership auf IHREN
353
+ // Tenant (kein Kollaps/Vertauschen bei mehreren Memberships) und die Toleranz
354
+ // gegenüber einer fehlenden Tenant-Projection-Row (Drift → Membership bleibt
355
+ // gelistet, kein Login-Lockout). Läuft als letzter Block — eigener User, kein
356
+ // geteilter Tenant-State.
357
+
358
+ describe("memberships batch-load (#324)", () => {
359
+ const batchUserId = "11111111-0000-4000-8000-000000000020";
360
+ const driftTenantId = testTenantId(777);
361
+
362
+ test("enriches every membership from one IN-batch, keeps drift rows", async () => {
363
+ // tenant 1 (ACME) + tenant 2 (Beta Inc) existieren und sind enabled;
364
+ // driftTenant wurde nie erstellt → keine Projection-Row.
365
+ for (const tenantId of [testTenantId(1), testTenantId(2), driftTenantId]) {
366
+ const r = await writeApi(systemAdmin, TenantHandlers.addMember, {
367
+ userId: batchUserId,
368
+ tenantId,
369
+ roles: ["Viewer"],
370
+ });
371
+ expect(r.isSuccess).toBe(true);
372
+ }
373
+
374
+ const result = await queryApi(systemAdmin, TenantQueries.memberships, { userId: batchUserId });
375
+ const memberships: Array<Record<string, unknown>> = result.data;
376
+ expect(memberships.length).toBe(3);
377
+
378
+ const acme = memberships.find((m) => m["tenantId"] === testTenantId(1));
379
+ expect(acme?.["tenantName"]).toBe("ACME");
380
+ expect(acme?.["tenantKey"]).toBe("acme");
381
+
382
+ const beta = memberships.find((m) => m["tenantId"] === testTenantId(2));
383
+ expect(beta?.["tenantName"]).toBe("Beta Inc");
384
+ expect(beta?.["tenantKey"]).toBe("beta");
385
+
386
+ // Drift: Membership zeigt auf einen Tenant ohne Projection-Row → bleibt
387
+ // gelistet, nur ohne tenantName/tenantKey.
388
+ const drift = memberships.find((m) => m["tenantId"] === driftTenantId);
389
+ expect(drift).toBeDefined();
390
+ expect(drift?.["tenantName"]).toBeUndefined();
391
+ expect(drift?.["tenantKey"]).toBeUndefined();
392
+ expect(drift?.["roles"]).toEqual(["Viewer"]);
393
+ });
394
+ });
@@ -13,5 +13,5 @@ export const listQuery = defineQueryHandler({
13
13
  search: z.string().optional(),
14
14
  }),
15
15
  access: { roles: ["SystemAdmin"] },
16
- handler: async (query, ctx) => crud.list(query.payload, query.user, ctx.db),
16
+ handler: async (query, ctx) => crud.list(query.payload, query.user, ctx.db), // @wrapper-known semantic-alias
17
17
  });
@@ -1,4 +1,4 @@
1
- import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
4
4
  import { z } from "zod";
@@ -13,18 +13,20 @@ export const membershipsQuery = defineQueryHandler({
13
13
  access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
14
14
  handler: async (query, ctx) => {
15
15
  const rows = await selectMany(ctx.db, tenantMembershipsTable, { userId: query.payload.userId });
16
+ if (rows.length === 0) return [];
16
17
 
17
- // tenantName/tenantKey machen Memberships in der UI unterscheidbar
18
- // (Tenant-Switcher zeigte sonst nur das UUID-Präfix — bei Seed-Tenants
19
- // mit 00000000-…-Präfix sind die ununterscheidbar). Eine Handvoll
20
- // Memberships pro User Einzel-Fetches sind ok.
21
- const enriched = await Promise.all(
22
- rows.map(async (row) => {
23
- const tenant = await fetchOne<{ name?: unknown; key?: unknown; isEnabled?: unknown }>(
24
- ctx.db,
25
- tenantTable,
26
- { id: row["tenantId"] },
27
- );
18
+ // tenantName/tenantKey machen Memberships im UI unterscheidbar (sonst nur
19
+ // das UUID-Präfix — Seed-Tenants mit 00000000-…-Präfix wären ununterscheidbar).
20
+ // Ein einzelner IN-Batch über alle tenantIds statt fetchOne pro Membership (#324).
21
+ type TenantRow = { id: unknown; name?: unknown; key?: unknown; isEnabled?: unknown };
22
+ const tenants = await selectMany<TenantRow>(ctx.db, tenantTable, {
23
+ id: rows.map((row) => row["tenantId"]),
24
+ });
25
+ const tenantById = new Map<unknown, TenantRow>(tenants.map((t) => [t.id, t]));
26
+
27
+ return rows
28
+ .map((row) => {
29
+ const tenant = tenantById.get(row["tenantId"]);
28
30
  // Disabled Tenants (tenant:write:disable) zählen nicht als Membership:
29
31
  // Login wählt sie nicht, /auth/tenants listet sie nicht, switch-tenant
30
32
  // antwortet not_a_member. Nur das explizite false filtert — eine
@@ -37,8 +39,7 @@ export const membershipsQuery = defineQueryHandler({
37
39
  ...(typeof tenant?.name === "string" && { tenantName: tenant.name }),
38
40
  ...(typeof tenant?.key === "string" && { tenantKey: tenant.key }),
39
41
  };
40
- }),
41
- );
42
- return enriched.filter((m) => m !== null);
42
+ })
43
+ .filter((m) => m !== null);
43
44
  },
44
45
  });
@@ -15,7 +15,7 @@ function createToggleTenantHandler(enable: boolean) {
15
15
  handler: async (event, ctx) =>
16
16
  crud.update({ id: event.payload.id, changes: { isEnabled: enable } }, event.user, ctx.db, {
17
17
  skipOptimisticLock: true,
18
- }),
18
+ }), // @wrapper-known semantic-alias
19
19
  });
20
20
  }
21
21
 
@@ -16,5 +16,5 @@ export const updateWrite = defineWriteHandler({
16
16
  changes: z.object({ name: z.string().min(1).max(200).optional() }),
17
17
  }),
18
18
  access: { roles: ["Admin", "SystemAdmin"] },
19
- handler: async (event, ctx) => crud.update(event.payload, event.user, ctx.db),
19
+ handler: async (event, ctx) => crud.update(event.payload, event.user, ctx.db), // @wrapper-known semantic-alias
20
20
  });
@@ -36,6 +36,7 @@ export type TextContentApi = {
36
36
  }) => Promise<TextBlock | null>;
37
37
  };
38
38
 
39
+ // @wrapper-known semantic-alias
39
40
  export function createTextContentApi(db: DbConnection): TextContentApi {
40
41
  return {
41
42
  getBlock: async ({ tenantId, slug, lang }) => {
@@ -22,6 +22,7 @@ const TIER_ASSIGNMENT_NAMESPACE = "8e91d2fc-8b7a-4d3e-9f4a-1c5d6e7f8a9b";
22
22
  * UUID via `gen_random_uuid()`. Die Funktion lebt hier als Utility-
23
23
  * Export bereit für Sprint 5.
24
24
  */
25
+ // @wrapper-known uuid-domain
25
26
  export function tierAssignmentAggregateId(tenantId: string): string {
26
27
  return uuidv5(tenantId, TIER_ASSIGNMENT_NAMESPACE);
27
28
  }
@@ -11,5 +11,5 @@ export const meQuery = defineQueryHandler({
11
11
  name: "user:me",
12
12
  schema: z.object({}),
13
13
  access: { openToAll: true },
14
- handler: async (query, ctx) => crud.detail({ id: query.user.id }, query.user, ctx.db),
14
+ handler: async (query, ctx) => crud.detail({ id: query.user.id }, query.user, ctx.db), // @wrapper-known semantic-alias
15
15
  });
@@ -13,6 +13,7 @@ export type ExportJobCleanupCandidate = {
13
13
  readonly expiresAt: Temporal.Instant | null;
14
14
  };
15
15
 
16
+ // @wrapper-known semantic-alias
16
17
  export async function selectExportJobsForStorageCleanup(
17
18
  db: DbConnection,
18
19
  doneStatus: string,
@@ -15,6 +15,7 @@ export type VerifyResult =
15
15
  | { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
16
16
  | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
17
17
 
18
+ // @wrapper-known semantic-alias
18
19
  export function signDeletionToken(
19
20
  userId: string,
20
21
  ttlMinutes: number,
@@ -24,6 +25,7 @@ export function signDeletionToken(
24
25
  return signToken(userId, DELETION_REQUEST_PURPOSE, ttlMinutes, secret, now);
25
26
  }
26
27
 
28
+ // @wrapper-known semantic-alias
27
29
  export function verifyDeletionToken(
28
30
  token: string,
29
31
  secret: string,
@@ -285,7 +285,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
285
285
  db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection, // @cast-boundary db-operator
286
286
  registry: ctx.registry,
287
287
  buildStorageProvider: async (tenantId) =>
288
- createFileProviderForTenant(providerCtx, tenantId, "user-data-rights:run-export-jobs"),
288
+ createFileProviderForTenant(providerCtx, tenantId, "user-data-rights:run-export-jobs"), // @wrapper-known semantic-alias
289
289
  now: T.Now.instant(),
290
290
  // Atom 5 — App-Author-Callbacks fuer Email-Notification.
291
291
  // Optional: wenn nicht gesetzt, kein Email; User pollt
@@ -302,6 +302,7 @@ interface JobRow {
302
302
  readonly requestedFromTenantId: TenantId;
303
303
  }
304
304
 
305
+ // @wrapper-known semantic-alias
305
306
  async function fetchPendingJobs(db: DbRunner): Promise<readonly JobRow[]> {
306
307
  return selectMany<JobRow>(
307
308
  db,
@@ -649,6 +650,7 @@ async function storageCleanupPass(args: {
649
650
  // Tenant-Context. system-mode TenantDb bypassed den auto-tenant-filter
650
651
  // — wir nutzen `requestedFromTenantId` als Stream-Identity damit der
651
652
  // Stream-Counter konsistent bleibt (Memory feedback_event_store_tenant_consistency).
653
+ // @wrapper-known semantic-alias
652
654
  function systemTenantDb(db: DbConnection, tenantId: TenantId) {
653
655
  return createTenantDb(db, tenantId, "system");
654
656
  }
@@ -9,6 +9,7 @@ import { UserProfileErrors } from "../constants";
9
9
  // Gleiche Failure-Shape wie auth-email-password (anti-enumeration):
10
10
  // dessen errors.ts ist nicht Teil des Feature-Barrels, der Reason-Code
11
11
  // + i18nKey sind aber stabile Public-API.
12
+ // @wrapper-known error-helper
12
13
  function invalidCredentials() {
13
14
  return writeFailure(
14
15
  new UnprocessableError(AuthErrors.invalidCredentials, {