@cosmicdrift/kumiko-bundled-features 0.48.0 → 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/__tests__/plugin-methods.test.ts +1 -1
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +1 -1
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +1 -1
- package/src/subscription-stripe/constants.ts +0 -4
- package/src/subscription-stripe/runtime.ts +5 -2
- 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
|
}
|
|
@@ -24,7 +24,7 @@ import type { StripeCtxRuntime } from "../runtime";
|
|
|
24
24
|
const TEST_API_KEY = "sk_test_dummy";
|
|
25
25
|
|
|
26
26
|
function buildStripe(): Stripe {
|
|
27
|
-
return new Stripe(TEST_API_KEY
|
|
27
|
+
return new Stripe(TEST_API_KEY);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/** Test-runtime: gibt den gespyten Client zurück + ein billing-live-Gate
|
|
@@ -66,7 +66,7 @@ let webhookApp: Hono;
|
|
|
66
66
|
let webhookAppWithSecrets: Hono;
|
|
67
67
|
let secretsCtx: SecretsContext;
|
|
68
68
|
|
|
69
|
-
const stripeForFixtures = new Stripe(TEST_API_KEY
|
|
69
|
+
const stripeForFixtures = new Stripe(TEST_API_KEY);
|
|
70
70
|
|
|
71
71
|
beforeAll(async () => {
|
|
72
72
|
// subscription-stripe requires jetzt config + secrets. Scenarios 1–4
|
|
@@ -27,7 +27,7 @@ const TEST_API_KEY = "sk_test_dummy_apikey";
|
|
|
27
27
|
// Test-helpers
|
|
28
28
|
// =============================================================================
|
|
29
29
|
|
|
30
|
-
const stripeForFixtures = new Stripe(TEST_API_KEY
|
|
30
|
+
const stripeForFixtures = new Stripe(TEST_API_KEY);
|
|
31
31
|
|
|
32
32
|
/** Test-runtime: liefert den fixture-Client + TEST_SECRET (statt sie aus
|
|
33
33
|
* system-secrets aufzulösen — die Resolution testet runtime.test.ts). */
|
|
@@ -6,10 +6,6 @@ export const SUBSCRIPTION_STRIPE_FEATURE = "subscription-stripe" as const;
|
|
|
6
6
|
// `/api/subscription/webhook/stripe`.
|
|
7
7
|
export const STRIPE_PROVIDER_NAME = "stripe" as const;
|
|
8
8
|
|
|
9
|
-
// Stripe-API-version-pin. Zentral, damit jeder Client (egal ob mount-time-
|
|
10
|
-
// fallback oder runtime-rotiert) dieselbe API-Version spricht.
|
|
11
|
-
export const STRIPE_API_VERSION = "2026-04-22.dahlia" as const;
|
|
12
|
-
|
|
13
9
|
// Secret- + config-key short-names. Qualified zu `subscription-stripe:<name>`
|
|
14
10
|
// (secrets) bzw. `subscription-stripe:config:<name>` (config) beim
|
|
15
11
|
// registry-build.
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
36
36
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
37
37
|
import Stripe from "stripe";
|
|
38
|
-
import {
|
|
38
|
+
import { SUBSCRIPTION_STRIPE_FEATURE } from "./constants";
|
|
39
39
|
|
|
40
40
|
const API_KEY_HINT =
|
|
41
41
|
"Set the system-scoped Stripe API key via secrets:write:set (or seed it from STRIPE_API_KEY during the env bridge).";
|
|
@@ -48,7 +48,9 @@ export function createStripeClientCache(): (apiKey: string) => Stripe {
|
|
|
48
48
|
return (apiKey) => {
|
|
49
49
|
const cached = cache.get(apiKey);
|
|
50
50
|
if (cached) return cached;
|
|
51
|
-
|
|
51
|
+
// No apiVersion pin — a string literal breaks consumers' typecheck on newer
|
|
52
|
+
// stripe SDKs (#256); the SDK's own default keeps wire-version and types aligned.
|
|
53
|
+
const client = new Stripe(apiKey);
|
|
52
54
|
cache.set(apiKey, client);
|
|
53
55
|
return client;
|
|
54
56
|
};
|
|
@@ -126,6 +128,7 @@ export function createStripeRuntimes(deps: StripeRuntimeDeps): StripeRuntimes {
|
|
|
126
128
|
|
|
127
129
|
return {
|
|
128
130
|
ctx: {
|
|
131
|
+
// @wrapper-known semantic-alias
|
|
129
132
|
clientForCtx: async (ctx) => clientFor(await ctxApiKey(ctx)),
|
|
130
133
|
assertBillingLive: async (ctx) => {
|
|
131
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
|