@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.
- package/package.json +2 -1
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +51 -0
- package/src/auth-email-password/constants.ts +6 -0
- package/src/auth-email-password/errors.ts +19 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +22 -12
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -10
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/password-hashing.ts +1 -0
- package/src/auth-email-password/reset-token.ts +2 -0
- package/src/auth-email-password/seeding.ts +19 -4
- package/src/auth-email-password/signup-token-store.ts +1 -0
- package/src/auth-email-password/verification-token.ts +2 -0
- package/src/billing-foundation/aggregate-id.ts +1 -0
- package/src/cap-counter/aggregate-id.ts +2 -0
- package/src/config/__tests__/app-override-visibility.integration.test.ts +143 -0
- package/src/config/__tests__/cascade.integration.test.ts +111 -1
- package/src/config/__tests__/env-overrides.test.ts +134 -0
- package/src/config/__tests__/inherited-redaction.integration.test.ts +180 -0
- package/src/config/__tests__/read-redaction.test.ts +112 -0
- package/src/config/__tests__/settings-hub-feature-name.test.ts +14 -0
- package/src/config/constants.ts +3 -1
- package/src/config/handlers/cascade.query.ts +9 -1
- package/src/config/handlers/values.query.ts +34 -12
- package/src/config/index.ts +1 -1
- package/src/config/read-redaction.ts +54 -0
- package/src/config/resolver.ts +70 -1
- package/src/config/web/client-plugin.ts +24 -0
- package/src/config/web/i18n.ts +25 -0
- package/src/config/web/index.ts +3 -0
- package/src/custom-fields/aggregate-id.ts +1 -0
- package/src/custom-fields/wire-for-entity.ts +1 -0
- package/src/delivery/upsert-preference.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +1 -1
- package/src/file-provider-s3/feature.ts +1 -1
- package/src/mail-transport-inmemory/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +1 -1
- package/src/step-dispatcher/mail-runner.ts +1 -0
- package/src/subscription-stripe/runtime.ts +1 -0
- package/src/subscription-stripe/verify-webhook.ts +1 -0
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +48 -0
- package/src/tenant/handlers/list.query.ts +1 -1
- package/src/tenant/handlers/memberships.query.ts +16 -15
- package/src/tenant/handlers/toggle-enabled.write.ts +1 -1
- package/src/tenant/handlers/update.write.ts +1 -1
- package/src/text-content/api.ts +1 -0
- package/src/tier-engine/aggregate-id.ts +1 -0
- package/src/user/handlers/me.query.ts +1 -1
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/deletion-token.ts +2 -0
- package/src/user-data-rights/feature.ts +1 -1
- package/src/user-data-rights/run-export-jobs.ts +2 -0
- 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 {
|
|
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
|
|
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
|
|
34
|
-
|
|
53
|
+
for (const [qualifiedKey, keyDef] of keyDefs) {
|
|
54
|
+
const rawCascade = cascades.get(qualifiedKey);
|
|
55
|
+
if (!rawCascade) continue;
|
|
35
56
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
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 =
|
|
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 =
|
|
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;
|
package/src/config/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/config/resolver.ts
CHANGED
|
@@ -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
|
+
};
|
|
@@ -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 {
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
rows.map(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
});
|
package/src/text-content/api.ts
CHANGED
|
@@ -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
|
});
|
|
@@ -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, {
|