@cosmicdrift/kumiko-bundled-features 0.48.1 → 0.51.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 +9 -6
- 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__/backing-secrets.integration.test.ts +188 -0
- package/src/config/__tests__/cascade.integration.test.ts +111 -1
- package/src/config/__tests__/config.integration.test.ts +60 -0
- 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/feature.ts +5 -2
- package/src/config/handlers/cascade.query.ts +13 -2
- package/src/config/handlers/readiness.query.ts +1 -0
- package/src/config/handlers/reset.write.ts +23 -2
- package/src/config/handlers/set.write.ts +36 -2
- package/src/config/handlers/values.query.ts +39 -13
- package/src/config/index.ts +1 -1
- package/src/config/read-redaction.ts +54 -0
- package/src/config/resolver.ts +163 -4
- 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/config/write-helpers.ts +37 -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/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
- package/src/jobs/feature.ts +13 -0
- package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
- package/src/legal-pages/README.md +16 -13
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
- package/src/legal-pages/feature.ts +9 -4
- package/src/legal-pages/markdown.ts +6 -56
- package/src/legal-pages/security-headers.ts +1 -0
- package/src/mail-transport-inmemory/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +1 -1
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
- package/src/managed-pages/branding.ts +142 -0
- package/src/managed-pages/css-gate.ts +24 -0
- package/src/managed-pages/feature.ts +246 -0
- package/src/managed-pages/handlers/branding.query.ts +30 -0
- package/src/managed-pages/handlers/by-slug.query.ts +35 -0
- package/src/managed-pages/handlers/set.write.ts +113 -0
- package/src/managed-pages/index.ts +30 -0
- package/src/managed-pages/screens/branding-screen.ts +85 -0
- package/src/managed-pages/screens/page-screens.ts +82 -0
- package/src/managed-pages/seeding.ts +99 -0
- package/src/managed-pages/table.ts +58 -0
- package/src/page-render/__tests__/branding.test.ts +57 -0
- package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
- package/src/page-render/__tests__/markdown.test.ts +41 -0
- package/src/page-render/branding.ts +99 -0
- package/src/page-render/css-sanitize.ts +344 -0
- package/src/page-render/index.ts +13 -0
- package/src/page-render/layout.ts +100 -0
- package/src/page-render/markdown.ts +39 -0
- package/src/page-render/security-headers.ts +16 -0
- 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
package/src/config/resolver.ts
CHANGED
|
@@ -5,11 +5,13 @@ import type {
|
|
|
5
5
|
ConfigCascadeLevel,
|
|
6
6
|
ConfigKeyDefinition,
|
|
7
7
|
ConfigResolver,
|
|
8
|
+
ConfigSecretsReader,
|
|
8
9
|
ConfigStoredRowWithSource,
|
|
9
10
|
ConfigValueSource,
|
|
10
11
|
ConfigValueWithSource,
|
|
11
12
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
13
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
13
15
|
import { assertUnreachable, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
14
16
|
import { selectConfigRowsForKeys, selectConfigRowsForScope } from "./db/queries/resolver";
|
|
15
17
|
import { configValuesTable } from "./table";
|
|
@@ -65,6 +67,26 @@ export type ConfigResolverOptions = {
|
|
|
65
67
|
appOverrides?: AppConfigOverrides;
|
|
66
68
|
};
|
|
67
69
|
|
|
70
|
+
// backing="secrets" keys store their value in the secrets store (flat per
|
|
71
|
+
// (tenant,key) at SYSTEM_TENANT_ID), not in config_values. Both read paths
|
|
72
|
+
// (getWithSource + buildCascade) route the system rung here. Missing reader =
|
|
73
|
+
// the app never wired extraContext.secrets → fail loud, never silently miss.
|
|
74
|
+
async function readBackingSecret(
|
|
75
|
+
secretsReader: ConfigSecretsReader | undefined,
|
|
76
|
+
qualifiedKey: string,
|
|
77
|
+
): Promise<string | undefined> {
|
|
78
|
+
if (!secretsReader) {
|
|
79
|
+
throw new InternalError({
|
|
80
|
+
message:
|
|
81
|
+
`[config] backing="secrets" key "${qualifiedKey}" was read without a secrets ` +
|
|
82
|
+
`reader — wire extraContext.secrets (and a MasterKeyProvider) so the secrets ` +
|
|
83
|
+
`store is reachable at request time.`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const secret = await secretsReader.get(SYSTEM_TENANT_ID, qualifiedKey);
|
|
87
|
+
return secret?.reveal();
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
// Shared cascade-builder. Single-key path passes a `findRow`-bound row
|
|
69
91
|
// fetcher (one SQL per lookup); batch path passes a closure over
|
|
70
92
|
// pre-loaded rows. The builder itself is unaware of which.
|
|
@@ -80,6 +102,7 @@ async function buildCascade(
|
|
|
80
102
|
) => Promise<ConfigRow | null> | ConfigRow | null,
|
|
81
103
|
appOverrides: AppConfigOverrides | undefined,
|
|
82
104
|
encryption: EncryptionProvider | undefined,
|
|
105
|
+
secretsReader: ConfigSecretsReader | undefined,
|
|
83
106
|
): Promise<ConfigCascade> {
|
|
84
107
|
type Lookup = {
|
|
85
108
|
tenantId: string;
|
|
@@ -93,6 +116,12 @@ async function buildCascade(
|
|
|
93
116
|
case "user":
|
|
94
117
|
lookups.push({ tenantId, userId, source: "user-row", label: "User" });
|
|
95
118
|
lookups.push({ tenantId, userId: null, source: "tenant-row", label: "Tenant" });
|
|
119
|
+
lookups.push({
|
|
120
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
121
|
+
userId: null,
|
|
122
|
+
source: "system-row",
|
|
123
|
+
label: "System",
|
|
124
|
+
});
|
|
96
125
|
break;
|
|
97
126
|
case "tenant":
|
|
98
127
|
lookups.push({ tenantId, userId: null, source: "tenant-row", label: "Tenant" });
|
|
@@ -119,6 +148,34 @@ async function buildCascade(
|
|
|
119
148
|
let activeIndex = -1;
|
|
120
149
|
|
|
121
150
|
for (const lookup of lookups) {
|
|
151
|
+
// backing="secrets" is system-only (boot-guard), so the single system-row
|
|
152
|
+
// rung reads from the secrets store instead of config_values. The secret
|
|
153
|
+
// value is the same JSON-serialized form a config row would hold (set.write
|
|
154
|
+
// serializes before handing it to secrets), so deserializeValue applies;
|
|
155
|
+
// no config-level decrypt — the secrets envelope already returned plaintext.
|
|
156
|
+
if (keyDef.backing === "secrets" && lookup.source === "system-row") {
|
|
157
|
+
const secret = await readBackingSecret(secretsReader, qualifiedKey);
|
|
158
|
+
if (secret !== undefined) {
|
|
159
|
+
if (activeIndex === -1) activeIndex = levels.length;
|
|
160
|
+
levels.push({
|
|
161
|
+
label: lookup.label,
|
|
162
|
+
value: deserializeValue(secret, keyDef.type),
|
|
163
|
+
source: lookup.source,
|
|
164
|
+
isActive: false,
|
|
165
|
+
hasValue: true,
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
levels.push({
|
|
169
|
+
label: lookup.label,
|
|
170
|
+
value: undefined,
|
|
171
|
+
source: lookup.source,
|
|
172
|
+
isActive: false,
|
|
173
|
+
hasValue: false,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
122
179
|
const row = await fetchRow(lookup.tenantId, lookup.userId);
|
|
123
180
|
if (row?.value !== null && row?.value !== undefined) {
|
|
124
181
|
let raw = row.value;
|
|
@@ -225,10 +282,17 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
225
282
|
}
|
|
226
283
|
|
|
227
284
|
return {
|
|
228
|
-
async get(qualifiedKey, keyDef, tenantId, userId, db) {
|
|
285
|
+
async get(qualifiedKey, keyDef, tenantId, userId, db, secretsReader) {
|
|
229
286
|
// get() is a thin wrapper around getWithSource that discards the
|
|
230
287
|
// source tag. Keeps the hot-path a single implementation.
|
|
231
|
-
const result = await this.getWithSource(
|
|
288
|
+
const result = await this.getWithSource(
|
|
289
|
+
qualifiedKey,
|
|
290
|
+
keyDef,
|
|
291
|
+
tenantId,
|
|
292
|
+
userId,
|
|
293
|
+
db,
|
|
294
|
+
secretsReader,
|
|
295
|
+
);
|
|
232
296
|
return result.value;
|
|
233
297
|
},
|
|
234
298
|
|
|
@@ -238,9 +302,31 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
238
302
|
tenantId,
|
|
239
303
|
userId,
|
|
240
304
|
db,
|
|
305
|
+
secretsReader,
|
|
241
306
|
): Promise<ConfigValueWithSource> {
|
|
307
|
+
// backing="secrets": the value lives in the secrets store at the system
|
|
308
|
+
// tenant, not config_values. Read it directly (system-only by boot-guard)
|
|
309
|
+
// and skip the config-row lookups; app-override/computed/default still
|
|
310
|
+
// form the fallback ladder when the secret is unset.
|
|
311
|
+
if (keyDef.backing === "secrets") {
|
|
312
|
+
const secret = await readBackingSecret(secretsReader, qualifiedKey);
|
|
313
|
+
if (secret !== undefined) {
|
|
314
|
+
return { value: deserializeValue(secret, keyDef.type), source: "system-row" };
|
|
315
|
+
}
|
|
316
|
+
if (appOverrides?.has(qualifiedKey)) {
|
|
317
|
+
return { value: appOverrides.get(qualifiedKey), source: "app-override" };
|
|
318
|
+
}
|
|
319
|
+
if (keyDef.computed) {
|
|
320
|
+
const value = await keyDef.computed({ tenantId, userId, db });
|
|
321
|
+
return { value, source: "computed" };
|
|
322
|
+
}
|
|
323
|
+
if (keyDef.default !== undefined) {
|
|
324
|
+
return { value: keyDef.default, source: "default" };
|
|
325
|
+
}
|
|
326
|
+
return { value: undefined, source: "missing" };
|
|
327
|
+
}
|
|
242
328
|
// Resolution cascade based on scope
|
|
243
|
-
// user: userId+tenantId → tenantId → default
|
|
329
|
+
// user: userId+tenantId → tenantId → SYSTEM_TENANT_ID → default
|
|
244
330
|
// tenant: tenantId → SYSTEM_TENANT_ID → default
|
|
245
331
|
// system: SYSTEM_TENANT_ID → default
|
|
246
332
|
const lookups: Array<{
|
|
@@ -253,6 +339,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
253
339
|
case "user":
|
|
254
340
|
lookups.push({ tenantId, userId, source: "user-row" });
|
|
255
341
|
lookups.push({ tenantId, userId: null, source: "tenant-row" });
|
|
342
|
+
lookups.push({ tenantId: SYSTEM_TENANT_ID, userId: null, source: "system-row" });
|
|
256
343
|
break;
|
|
257
344
|
case "tenant":
|
|
258
345
|
lookups.push({ tenantId, userId: null, source: "tenant-row" });
|
|
@@ -359,7 +446,14 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
359
446
|
return result;
|
|
360
447
|
},
|
|
361
448
|
|
|
362
|
-
async getCascade(
|
|
449
|
+
async getCascade(
|
|
450
|
+
qualifiedKey,
|
|
451
|
+
keyDef,
|
|
452
|
+
tenantId,
|
|
453
|
+
userId,
|
|
454
|
+
db,
|
|
455
|
+
secretsReader,
|
|
456
|
+
): Promise<ConfigCascade> {
|
|
363
457
|
// Single-key path uses findRow per cascade step. The batch path
|
|
364
458
|
// bulk-loads all rows up-front; both build identical levels arrays.
|
|
365
459
|
return buildCascade(
|
|
@@ -371,6 +465,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
371
465
|
(tid, uid) => findRow(qualifiedKey, tid, uid, db),
|
|
372
466
|
appOverrides,
|
|
373
467
|
encryption,
|
|
468
|
+
secretsReader,
|
|
374
469
|
);
|
|
375
470
|
},
|
|
376
471
|
|
|
@@ -380,6 +475,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
380
475
|
tenantId,
|
|
381
476
|
userId,
|
|
382
477
|
db,
|
|
478
|
+
secretsReader,
|
|
383
479
|
): Promise<ReadonlyMap<string, ConfigCascade>> {
|
|
384
480
|
if (keys.length === 0) return new Map();
|
|
385
481
|
|
|
@@ -411,6 +507,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
411
507
|
keyRows.find((r) => r.tenantId === tid && (r.userId ?? null) === uid) ?? null,
|
|
412
508
|
appOverrides,
|
|
413
509
|
encryption,
|
|
510
|
+
secretsReader,
|
|
414
511
|
);
|
|
415
512
|
result.set(key, cascade);
|
|
416
513
|
}
|
|
@@ -505,3 +602,65 @@ export function validateAppOverrides(
|
|
|
505
602
|
|
|
506
603
|
return validated;
|
|
507
604
|
}
|
|
605
|
+
|
|
606
|
+
// --- ENV → App-Override Bridge ---
|
|
607
|
+
//
|
|
608
|
+
// Realises the `env` field of a config key: at boot every config
|
|
609
|
+
// key that declares `env` reads its value from the process environment and
|
|
610
|
+
// injects it as an app-override default (the cascade rung between the
|
|
611
|
+
// tenant/system rows and the declared default). String env values are coerced
|
|
612
|
+
// to the key's type; a mismatch fails the boot loudly rather than silently
|
|
613
|
+
// resolving to the wrong value. Reuses validateAppOverrides for the
|
|
614
|
+
// existence / bounds / options / computed-conflict gates.
|
|
615
|
+
//
|
|
616
|
+
// undefined OR empty-string env vars are skipped — an unset (or `FOO=`)
|
|
617
|
+
// variable must not clobber a declared default.
|
|
618
|
+
|
|
619
|
+
type EnvSource = Readonly<Record<string, string | undefined>>;
|
|
620
|
+
|
|
621
|
+
type ConfigKeyRegistry = {
|
|
622
|
+
getAllConfigKeys: () => ReadonlyMap<string, ConfigKeyDefinition>;
|
|
623
|
+
getConfigKey: (key: string) => ConfigKeyDefinition | undefined;
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
function coerceEnvValue(
|
|
627
|
+
qualifiedKey: string,
|
|
628
|
+
envName: string,
|
|
629
|
+
type: ConfigKeyDefinition["type"],
|
|
630
|
+
raw: string,
|
|
631
|
+
): string | number | boolean {
|
|
632
|
+
if (type === "number") {
|
|
633
|
+
const n = Number(raw.trim());
|
|
634
|
+
if (!Number.isFinite(n)) {
|
|
635
|
+
throw new Error(
|
|
636
|
+
`ENV config bridge: "${envName}" → "${qualifiedKey}" expects a number, got "${raw}".`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
return n;
|
|
640
|
+
}
|
|
641
|
+
if (type === "boolean") {
|
|
642
|
+
const v = raw.trim().toLowerCase();
|
|
643
|
+
if (v === "true" || v === "1") return true;
|
|
644
|
+
if (v === "false" || v === "0") return false;
|
|
645
|
+
throw new Error(
|
|
646
|
+
`ENV config bridge: "${envName}" → "${qualifiedKey}" expects a boolean (true/false/1/0), got "${raw}".`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
// text | select — select-option membership is validated by validateAppOverrides
|
|
650
|
+
return raw;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function buildEnvConfigOverrides(
|
|
654
|
+
registry: ConfigKeyRegistry,
|
|
655
|
+
env: EnvSource,
|
|
656
|
+
): AppConfigOverrides {
|
|
657
|
+
const record: Record<string, string | number | boolean> = {};
|
|
658
|
+
for (const [qualifiedKey, keyDef] of registry.getAllConfigKeys()) {
|
|
659
|
+
const envName = keyDef.env;
|
|
660
|
+
if (!envName) continue;
|
|
661
|
+
const raw = env[envName];
|
|
662
|
+
if (raw === undefined || raw === "") continue;
|
|
663
|
+
record[qualifiedKey] = coerceEnvValue(qualifiedKey, envName, keyDef.type, raw);
|
|
664
|
+
}
|
|
665
|
+
return validateAppOverrides(registry, record);
|
|
666
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
17
|
import {
|
|
18
18
|
AccessDeniedError,
|
|
19
|
+
InternalError,
|
|
19
20
|
type KumikoError,
|
|
20
21
|
NotFoundError,
|
|
21
22
|
UnprocessableError,
|
|
@@ -262,3 +263,39 @@ export function validateBounds(
|
|
|
262
263
|
|
|
263
264
|
return null;
|
|
264
265
|
}
|
|
266
|
+
|
|
267
|
+
// Regex enforcement for text config keys (keyDef.pattern). Hard-reject on
|
|
268
|
+
// mismatch (same posture as validateBounds — never silent-coerce). The value
|
|
269
|
+
// is tenant-supplied, so this is the write-side gate behind the configEdit
|
|
270
|
+
// screen (which dispatches config:write:set per key). A malformed author-
|
|
271
|
+
// supplied regex is surfaced as an InternalError instead of throwing
|
|
272
|
+
// unhandled in the write path.
|
|
273
|
+
export function validatePattern(
|
|
274
|
+
value: string | number | boolean,
|
|
275
|
+
keyDef: ConfigKeyDefinition,
|
|
276
|
+
): KumikoError | null {
|
|
277
|
+
if (keyDef.type !== "text" || !keyDef.pattern) return null;
|
|
278
|
+
// skip: validateType runs first and guarantees a string for type==="text"
|
|
279
|
+
if (typeof value !== "string") return null;
|
|
280
|
+
|
|
281
|
+
let re: RegExp;
|
|
282
|
+
try {
|
|
283
|
+
re = new RegExp(keyDef.pattern.regex, keyDef.pattern.flags);
|
|
284
|
+
} catch {
|
|
285
|
+
return new InternalError({
|
|
286
|
+
message: `config key pattern is not a valid RegExp: ${keyDef.pattern.regex}`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (re.test(value)) return null;
|
|
290
|
+
|
|
291
|
+
return new ValidationError({
|
|
292
|
+
fields: [
|
|
293
|
+
{
|
|
294
|
+
path: "value",
|
|
295
|
+
code: "invalid_format",
|
|
296
|
+
i18nKey: "errors.validation.invalid_format",
|
|
297
|
+
params: { value, pattern: keyDef.pattern.regex },
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// #362: das `jobs`-Feature registriert den framework-eigenen Single-Run-Job
|
|
2
|
+
// `jobs:job:projection-rebuild`. Sobald jobs komponiert ist, dispatcht
|
|
3
|
+
// `enqueueProjectionRebuild` einen getrackten, retrybaren Rebuild über BullMQ;
|
|
4
|
+
// der Worker ruft `rebuildProjection`. Ohne jobs fällt der Helper auf einen
|
|
5
|
+
// inline-Rebuild zurück (in migrations/__tests__/pending-rebuilds.* abgedeckt).
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
asRawClient,
|
|
10
|
+
buildEntityTable,
|
|
11
|
+
createEventStoreExecutor,
|
|
12
|
+
createTenantDb,
|
|
13
|
+
type DbConnection,
|
|
14
|
+
integer,
|
|
15
|
+
table as pgTable,
|
|
16
|
+
selectMany,
|
|
17
|
+
type TenantDb,
|
|
18
|
+
uuid,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
20
|
+
import {
|
|
21
|
+
createEntity,
|
|
22
|
+
createRegistry,
|
|
23
|
+
createTextField,
|
|
24
|
+
defineApply,
|
|
25
|
+
defineFeature,
|
|
26
|
+
type ProjectionDefinition,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
29
|
+
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
30
|
+
import {
|
|
31
|
+
enqueueProjectionRebuild,
|
|
32
|
+
PROJECTION_REBUILD_JOB,
|
|
33
|
+
} from "@cosmicdrift/kumiko-framework/migrations";
|
|
34
|
+
import { createProjectionStateTable } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
35
|
+
import {
|
|
36
|
+
createTestDb,
|
|
37
|
+
createTestRedis,
|
|
38
|
+
type TestDb,
|
|
39
|
+
type TestRedis,
|
|
40
|
+
TestUsers,
|
|
41
|
+
unsafeCreateEntityTable,
|
|
42
|
+
unsafePushTables,
|
|
43
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
44
|
+
import { sleep } from "@cosmicdrift/kumiko-framework/testing";
|
|
45
|
+
import { createJobsFeature } from "../feature";
|
|
46
|
+
import { createJobRunLogger } from "../job-run-logger";
|
|
47
|
+
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
48
|
+
|
|
49
|
+
const itemEntity = createEntity({
|
|
50
|
+
table: "read_rebuild_items",
|
|
51
|
+
fields: {
|
|
52
|
+
groupId: createTextField({ required: true }),
|
|
53
|
+
name: createTextField({ required: true }),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const itemTable = buildEntityTable("rebuild-item", itemEntity);
|
|
57
|
+
const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "rebuild-item" });
|
|
58
|
+
|
|
59
|
+
const countsTable = pgTable("read_rebuild_counts", {
|
|
60
|
+
groupId: uuid("group_id").primaryKey(),
|
|
61
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
62
|
+
itemCount: integer("item_count").notNull().default(0),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Eigene (explizite) Projektion — der Executor füllt sie NICHT live, sie wird
|
|
66
|
+
// ausschließlich vom Rebuild materialisiert. Count==2 nach dem Job beweist also
|
|
67
|
+
// den Replay, nicht den Live-Write.
|
|
68
|
+
const countsProjection: ProjectionDefinition = {
|
|
69
|
+
name: "rebuild-counts",
|
|
70
|
+
source: "rebuild-item",
|
|
71
|
+
table: countsTable,
|
|
72
|
+
apply: {
|
|
73
|
+
"rebuild-item.created": defineApply<{ groupId: string }>(async (event, tx) => {
|
|
74
|
+
await asRawClient(tx).unsafe(
|
|
75
|
+
`INSERT INTO "read_rebuild_counts" (group_id, tenant_id, item_count) VALUES ($1::uuid, $2::uuid, 1)
|
|
76
|
+
ON CONFLICT (group_id) DO UPDATE SET item_count = read_rebuild_counts.item_count + 1`,
|
|
77
|
+
[event.payload.groupId, event.tenantId],
|
|
78
|
+
);
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const PROJECTION = "rebuildtest:projection:rebuild-counts";
|
|
84
|
+
const GROUP = "00000000-0000-4000-8000-000000000001";
|
|
85
|
+
|
|
86
|
+
const appFeature = defineFeature("rebuildtest", (r) => {
|
|
87
|
+
r.entity("rebuild-item", itemEntity);
|
|
88
|
+
r.projection(countsProjection);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const admin = TestUsers.admin;
|
|
92
|
+
const registry = createRegistry([appFeature, createJobsFeature()]);
|
|
93
|
+
|
|
94
|
+
let testDb: TestDb;
|
|
95
|
+
let testRedis: TestRedis;
|
|
96
|
+
let db: DbConnection;
|
|
97
|
+
let tdb: TenantDb;
|
|
98
|
+
let jobRunner: JobRunner;
|
|
99
|
+
|
|
100
|
+
beforeAll(async () => {
|
|
101
|
+
testDb = await createTestDb();
|
|
102
|
+
testRedis = await createTestRedis();
|
|
103
|
+
db = testDb.db;
|
|
104
|
+
|
|
105
|
+
await unsafeCreateEntityTable(db, itemEntity, "rebuild-item");
|
|
106
|
+
await createEventsTable(db);
|
|
107
|
+
await createProjectionStateTable(db);
|
|
108
|
+
await unsafePushTables(db, { readRebuildCounts: countsTable, jobRunsTable, jobRunLogsTable });
|
|
109
|
+
tdb = createTenantDb(db, admin.tenantId);
|
|
110
|
+
|
|
111
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
112
|
+
const logger = createJobRunLogger({ db, registry });
|
|
113
|
+
jobRunner = createJobRunner({
|
|
114
|
+
registry,
|
|
115
|
+
context: { db },
|
|
116
|
+
redisUrl,
|
|
117
|
+
consumerLane: "worker",
|
|
118
|
+
queueNamePrefix: `kumiko-projrebuild-test-${Date.now()}`,
|
|
119
|
+
...logger,
|
|
120
|
+
});
|
|
121
|
+
await jobRunner.start();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterAll(async () => {
|
|
125
|
+
await jobRunner.stop();
|
|
126
|
+
await testDb.cleanup();
|
|
127
|
+
await testRedis.cleanup();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
async function getCount(): Promise<number | undefined> {
|
|
131
|
+
const [row] = await selectMany<{ itemCount: number }>(db, countsTable, { groupId: GROUP });
|
|
132
|
+
return row?.itemCount;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe("projection-rebuild job (jobs feature composed)", () => {
|
|
136
|
+
test("jobs feature registers the framework rebuild job under its qualified name", () => {
|
|
137
|
+
expect(registry.getJob(PROJECTION_REBUILD_JOB)).toBeDefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("enqueueProjectionRebuild dispatches a tracked job that refills the projection", async () => {
|
|
141
|
+
await executor.create({ groupId: GROUP, name: "a" }, admin, tdb);
|
|
142
|
+
await executor.create({ groupId: GROUP, name: "b" }, admin, tdb);
|
|
143
|
+
// Live executor füllt die explizite Projektion nicht — Rebuild ist der einzige Weg.
|
|
144
|
+
expect(await getCount()).toBeUndefined();
|
|
145
|
+
|
|
146
|
+
const outcome = await enqueueProjectionRebuild(PROJECTION, { db, registry, jobRunner });
|
|
147
|
+
expect(outcome.mode).toBe("dispatched");
|
|
148
|
+
if (outcome.mode === "dispatched") {
|
|
149
|
+
expect(outcome.bullJobId).toBeTruthy();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Poll until the worker drained the queue and the rebuild refilled.
|
|
153
|
+
for (let i = 0; i < 40 && (await getCount()) !== 2; i++) await sleep(200);
|
|
154
|
+
expect(await getCount()).toBe(2);
|
|
155
|
+
|
|
156
|
+
const runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
|
|
157
|
+
jobName: PROJECTION_REBUILD_JOB,
|
|
158
|
+
});
|
|
159
|
+
expect(runs.length).toBeGreaterThanOrEqual(1);
|
|
160
|
+
expect(runs.some((r) => r.status === "completed")).toBe(true);
|
|
161
|
+
}, 30000);
|
|
162
|
+
});
|
package/src/jobs/feature.ts
CHANGED
|
@@ -12,6 +12,10 @@ import type { z } from "zod";
|
|
|
12
12
|
import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events";
|
|
13
13
|
import { detailQuery } from "./handlers/detail.query";
|
|
14
14
|
import { listQuery } from "./handlers/list.query";
|
|
15
|
+
import {
|
|
16
|
+
projectionRebuildJob,
|
|
17
|
+
projectionRebuildPayloadSchema,
|
|
18
|
+
} from "./handlers/projection-rebuild.job";
|
|
15
19
|
import { retryWrite } from "./handlers/retry.write";
|
|
16
20
|
import { triggerWrite } from "./handlers/trigger.write";
|
|
17
21
|
import {
|
|
@@ -149,6 +153,15 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
149
153
|
},
|
|
150
154
|
});
|
|
151
155
|
|
|
156
|
+
// Framework-provided single-run rebuild job (QN `jobs:job:projection-rebuild`).
|
|
157
|
+
// Available whenever `jobs` is composed — `enqueueProjectionRebuild` dispatches
|
|
158
|
+
// it; every run is tracked in read_job_runs + retryable via jobs:write:retry.
|
|
159
|
+
r.job(
|
|
160
|
+
"projectionRebuild",
|
|
161
|
+
{ trigger: { manual: true }, schema: projectionRebuildPayloadSchema },
|
|
162
|
+
projectionRebuildJob,
|
|
163
|
+
);
|
|
164
|
+
|
|
152
165
|
const handlers = {
|
|
153
166
|
trigger: r.writeHandler(triggerWrite),
|
|
154
167
|
retry: r.writeHandler(retryWrite),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Single-run projection-rebuild worker (QN `jobs:job:projection-rebuild`).
|
|
2
|
+
// Replays the event log into one projection via the framework's
|
|
3
|
+
// `rebuildProjection`. Triggered manually — typically through
|
|
4
|
+
// `enqueueProjectionRebuild` (migrations) as the self-service repair for an
|
|
5
|
+
// emptied projection, a deliberate manual rebuild, or a post-upcaster refill.
|
|
6
|
+
// Run-tracking (read_job_runs + read_job_run_logs) and retry come for free
|
|
7
|
+
// from the jobs feature that registers this worker.
|
|
8
|
+
|
|
9
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
12
|
+
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
export const projectionRebuildPayloadSchema = z.object({ projection: z.string().min(1) });
|
|
16
|
+
|
|
17
|
+
export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
18
|
+
const { projection } = projectionRebuildPayloadSchema.parse(rawPayload);
|
|
19
|
+
if (!ctx.db) {
|
|
20
|
+
throw new InternalError({
|
|
21
|
+
message:
|
|
22
|
+
"[jobs:projection-rebuild] ctx.db missing — job context requires a database connection.",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (!ctx.registry) {
|
|
26
|
+
throw new InternalError({
|
|
27
|
+
message:
|
|
28
|
+
"[jobs:projection-rebuild] ctx.registry missing — job context requires the registry.",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
32
|
+
const result = await rebuildProjection(projection, { db, registry: ctx.registry });
|
|
33
|
+
ctx.log?.info?.(
|
|
34
|
+
`[jobs:projection-rebuild] rebuilt ${projection}: ${result.eventsProcessed} events in ${result.durationMs}ms`,
|
|
35
|
+
);
|
|
36
|
+
};
|
|
@@ -156,19 +156,22 @@ datenschutz-generator.de).
|
|
|
156
156
|
|
|
157
157
|
---
|
|
158
158
|
|
|
159
|
-
## XSS
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
159
|
+
## XSS hardening (untrusted authors)
|
|
160
|
+
|
|
161
|
+
The server-render path is hardened for untrusted tenant authors —
|
|
162
|
+
no DOMPurify dependency needed:
|
|
163
|
+
|
|
164
|
+
- **Raw HTML is escaped, not passed through.** `renderMarkdownToHtml`
|
|
165
|
+
(`markdown.ts`) configures `marked` so block- and inline-level HTML
|
|
166
|
+
tokens are emitted as escaped text (`<script>` → `<script>`).
|
|
167
|
+
Markdown structure (headings, lists, links, code) stays intact.
|
|
168
|
+
- **Link/image hrefs are scheme-restricted** to `http(s)`/`mailto`/
|
|
169
|
+
relative; `javascript:`/`data:` hrefs are neutralised to `#`.
|
|
170
|
+
- **Defense-in-depth headers** on every response (`security-headers.ts`):
|
|
171
|
+
`content-security-policy: script-src 'none'; object-src 'none';
|
|
172
|
+
base-uri 'none'` (no script can run even if injection slips through),
|
|
173
|
+
plus `x-content-type-options`, `x-frame-options`, `referrer-policy`.
|
|
174
|
+
No `default-src`, so inline `<style>` layouts stay unaffected.
|
|
172
175
|
|
|
173
176
|
---
|
|
174
177
|
|