@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.
Files changed (90) hide show
  1. package/package.json +9 -6
  2. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +51 -0
  3. package/src/auth-email-password/constants.ts +6 -0
  4. package/src/auth-email-password/errors.ts +19 -0
  5. package/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  6. package/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  7. package/src/auth-email-password/handlers/signup-confirm.write.ts +22 -12
  8. package/src/auth-email-password/handlers/signup-request.write.ts +12 -10
  9. package/src/auth-email-password/i18n.ts +4 -0
  10. package/src/auth-email-password/password-hashing.ts +1 -0
  11. package/src/auth-email-password/reset-token.ts +2 -0
  12. package/src/auth-email-password/seeding.ts +19 -4
  13. package/src/auth-email-password/signup-token-store.ts +1 -0
  14. package/src/auth-email-password/verification-token.ts +2 -0
  15. package/src/billing-foundation/aggregate-id.ts +1 -0
  16. package/src/cap-counter/aggregate-id.ts +2 -0
  17. package/src/config/__tests__/app-override-visibility.integration.test.ts +143 -0
  18. package/src/config/__tests__/backing-secrets.integration.test.ts +188 -0
  19. package/src/config/__tests__/cascade.integration.test.ts +111 -1
  20. package/src/config/__tests__/config.integration.test.ts +60 -0
  21. package/src/config/__tests__/env-overrides.test.ts +134 -0
  22. package/src/config/__tests__/inherited-redaction.integration.test.ts +180 -0
  23. package/src/config/__tests__/read-redaction.test.ts +112 -0
  24. package/src/config/__tests__/settings-hub-feature-name.test.ts +14 -0
  25. package/src/config/constants.ts +3 -1
  26. package/src/config/feature.ts +5 -2
  27. package/src/config/handlers/cascade.query.ts +13 -2
  28. package/src/config/handlers/readiness.query.ts +1 -0
  29. package/src/config/handlers/reset.write.ts +23 -2
  30. package/src/config/handlers/set.write.ts +36 -2
  31. package/src/config/handlers/values.query.ts +39 -13
  32. package/src/config/index.ts +1 -1
  33. package/src/config/read-redaction.ts +54 -0
  34. package/src/config/resolver.ts +163 -4
  35. package/src/config/web/client-plugin.ts +24 -0
  36. package/src/config/web/i18n.ts +25 -0
  37. package/src/config/web/index.ts +3 -0
  38. package/src/config/write-helpers.ts +37 -0
  39. package/src/custom-fields/aggregate-id.ts +1 -0
  40. package/src/custom-fields/wire-for-entity.ts +1 -0
  41. package/src/delivery/upsert-preference.ts +1 -0
  42. package/src/file-provider-inmemory/feature.ts +1 -1
  43. package/src/file-provider-s3/feature.ts +1 -1
  44. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
  45. package/src/jobs/feature.ts +13 -0
  46. package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
  47. package/src/legal-pages/README.md +16 -13
  48. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
  49. package/src/legal-pages/feature.ts +9 -4
  50. package/src/legal-pages/markdown.ts +6 -56
  51. package/src/legal-pages/security-headers.ts +1 -0
  52. package/src/mail-transport-inmemory/feature.ts +1 -1
  53. package/src/mail-transport-smtp/feature.ts +1 -1
  54. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
  55. package/src/managed-pages/branding.ts +142 -0
  56. package/src/managed-pages/css-gate.ts +24 -0
  57. package/src/managed-pages/feature.ts +246 -0
  58. package/src/managed-pages/handlers/branding.query.ts +30 -0
  59. package/src/managed-pages/handlers/by-slug.query.ts +35 -0
  60. package/src/managed-pages/handlers/set.write.ts +113 -0
  61. package/src/managed-pages/index.ts +30 -0
  62. package/src/managed-pages/screens/branding-screen.ts +85 -0
  63. package/src/managed-pages/screens/page-screens.ts +82 -0
  64. package/src/managed-pages/seeding.ts +99 -0
  65. package/src/managed-pages/table.ts +58 -0
  66. package/src/page-render/__tests__/branding.test.ts +57 -0
  67. package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
  68. package/src/page-render/__tests__/markdown.test.ts +41 -0
  69. package/src/page-render/branding.ts +99 -0
  70. package/src/page-render/css-sanitize.ts +344 -0
  71. package/src/page-render/index.ts +13 -0
  72. package/src/page-render/layout.ts +100 -0
  73. package/src/page-render/markdown.ts +39 -0
  74. package/src/page-render/security-headers.ts +16 -0
  75. package/src/step-dispatcher/mail-runner.ts +1 -0
  76. package/src/subscription-stripe/runtime.ts +1 -0
  77. package/src/subscription-stripe/verify-webhook.ts +1 -0
  78. package/src/tenant/__tests__/multi-tenant.integration.test.ts +48 -0
  79. package/src/tenant/handlers/list.query.ts +1 -1
  80. package/src/tenant/handlers/memberships.query.ts +16 -15
  81. package/src/tenant/handlers/toggle-enabled.write.ts +1 -1
  82. package/src/tenant/handlers/update.write.ts +1 -1
  83. package/src/text-content/api.ts +1 -0
  84. package/src/tier-engine/aggregate-id.ts +1 -0
  85. package/src/user/handlers/me.query.ts +1 -1
  86. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  87. package/src/user-data-rights/deletion-token.ts +2 -0
  88. package/src/user-data-rights/feature.ts +1 -1
  89. package/src/user-data-rights/run-export-jobs.ts +2 -0
  90. package/src/user-profile/handlers/change-email.write.ts +1 -0
@@ -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(qualifiedKey, keyDef, tenantId, userId, db);
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(qualifiedKey, keyDef, tenantId, userId, db): Promise<ConfigCascade> {
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
+ };
@@ -0,0 +1,3 @@
1
+ // @runtime client
2
+ export { type ConfigClientFeature, type ConfigClientOptions, configClient } from "./client-plugin";
3
+ export { defaultTranslations } from "./i18n";
@@ -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
+ });
@@ -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 currently not secured by design
160
-
161
- `marked` renders HTML tags 1:1, so a malicious tenant admin could in
162
- theory put `<script>` into the body.
163
-
164
- Currently accepted because:
165
- - only `roles: ["TenantAdmin"]` may set texts
166
- - multi-author setups don't exist yet
167
- - self-hosted tier without unknown tenant admins
168
-
169
- **Phase-2 hardening:** `DOMPurify` or `isomorphic-dompurify`
170
- sanitization step between `marked.parse()` and the response.
171
- Documented when a customer with a multi-author setup shows up.
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>` → `&lt;script&gt;`).
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