@cosmicdrift/kumiko-bundled-features 0.2.3 → 0.3.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 (92) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/package.json +17 -14
  3. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  4. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  6. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  7. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  8. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  9. package/src/auth-email-password/handlers/login.write.ts +1 -1
  10. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  11. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  12. package/src/auth-email-password/web/auth-client.ts +1 -1
  13. package/src/billing-foundation/events.ts +1 -1
  14. package/src/billing-foundation/feature.ts +44 -47
  15. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  16. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  17. package/src/billing-foundation/projection.ts +1 -1
  18. package/src/billing-foundation/webhook-handler.ts +1 -1
  19. package/src/cap-counter/constants.ts +1 -1
  20. package/src/cap-counter/enforce-cap.ts +1 -1
  21. package/src/cap-counter/feature.ts +3 -7
  22. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  23. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  24. package/src/cap-counter/handlers/increment.write.ts +3 -3
  25. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  26. package/src/channel-email/email-channel.ts +1 -1
  27. package/src/channel-email/types.ts +1 -1
  28. package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -6
  29. package/src/compliance-profiles/handlers/needs-profile.query.ts +1 -1
  30. package/src/compliance-profiles/handlers/set-profile.write.ts +6 -8
  31. package/src/compliance-profiles/resolve-for-tenant.ts +7 -5
  32. package/src/compliance-profiles/seeding.ts +1 -1
  33. package/src/config/resolver.ts +1 -1
  34. package/src/data-retention/_internal/parse-override.ts +3 -2
  35. package/src/data-retention/handlers/policy-for.query.ts +1 -1
  36. package/src/data-retention/keep-for.ts +1 -1
  37. package/src/data-retention/presets.ts +1 -1
  38. package/src/data-retention/resolve-for-tenant.ts +1 -1
  39. package/src/delivery/feature.ts +1 -1
  40. package/src/delivery/testing.ts +1 -2
  41. package/src/delivery/upsert-preference.ts +1 -1
  42. package/src/feature-toggles/feature.ts +1 -1
  43. package/src/feature-toggles/handlers/list.query.ts +1 -1
  44. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  45. package/src/feature-toggles/handlers/set.write.ts +3 -3
  46. package/src/file-foundation/feature.ts +1 -1
  47. package/src/file-provider-s3/feature.ts +2 -2
  48. package/src/files-provider-s3/s3-provider.ts +2 -2
  49. package/src/jobs/handlers/list.query.ts +3 -3
  50. package/src/jobs/handlers/trigger.write.ts +1 -1
  51. package/src/legal-pages/constants.ts +1 -0
  52. package/src/legal-pages/web/client-plugin.ts +42 -0
  53. package/src/legal-pages/web/index.ts +4 -0
  54. package/src/mail-foundation/feature.ts +1 -1
  55. package/src/mail-transport-smtp/feature.ts +2 -2
  56. package/src/renderer-simple/simple-renderer.ts +1 -1
  57. package/src/secrets/handlers/rotate.job.ts +2 -2
  58. package/src/sessions/handlers/cleanup.job.ts +2 -2
  59. package/src/step-dispatcher/feature.ts +62 -0
  60. package/src/step-dispatcher/index.ts +16 -0
  61. package/src/step-dispatcher/mail-runner.ts +32 -0
  62. package/src/step-dispatcher/webhook-runner.ts +67 -0
  63. package/src/subscription-mollie/plugin-methods.ts +1 -1
  64. package/src/subscription-mollie/verify-webhook.ts +9 -5
  65. package/src/subscription-stripe/verify-webhook.ts +3 -3
  66. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  67. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  68. package/src/tenant/handlers/remove-member.write.ts +1 -1
  69. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  70. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  71. package/src/text-content/constants.ts +2 -0
  72. package/src/text-content/feature.ts +20 -4
  73. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  74. package/src/text-content/handlers/set.write.ts +1 -1
  75. package/src/text-content/web/client-plugin.ts +113 -0
  76. package/src/text-content/web/index.ts +8 -0
  77. package/src/tier-engine/feature.ts +8 -8
  78. package/src/user/handlers/find-for-auth.query.ts +1 -1
  79. package/src/user/seeding.ts +2 -2
  80. package/src/user-data-rights/feature.ts +4 -3
  81. package/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  82. package/src/user-data-rights/handlers/download-by-job.query.ts +8 -11
  83. package/src/user-data-rights/handlers/download-by-token.query.ts +14 -16
  84. package/src/user-data-rights/handlers/export-status.query.ts +1 -1
  85. package/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  86. package/src/user-data-rights/handlers/request-export.write.ts +2 -2
  87. package/src/user-data-rights/run-export-jobs.ts +2 -2
  88. package/src/user-data-rights/run-forget-cleanup.ts +27 -28
  89. package/src/user-data-rights/run-user-export.ts +1 -1
  90. package/src/user-data-rights/token-helpers.ts +2 -2
  91. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +1 -1
  92. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +1 -1
@@ -26,7 +26,7 @@ export const forTenantQuery = defineQueryHandler({
26
26
  ctx.db,
27
27
  tenantComplianceProfileTable,
28
28
  eq(tenantComplianceProfileTable["tenantId"], query.user.tenantId),
29
- )) as { profileKey: string; override: string | null } | null;
29
+ )) as { profileKey: string; override: string | null } | null; // @cast-boundary db-runner
30
30
 
31
31
  if (!row) {
32
32
  return resolveComplianceProfile({});
@@ -34,7 +34,7 @@ export const forTenantQuery = defineQueryHandler({
34
34
 
35
35
  const override = parseOverride(row.override, query.user.tenantId);
36
36
  return resolveComplianceProfile({
37
- selection: row.profileKey as ComplianceProfileKey,
37
+ selection: row.profileKey as ComplianceProfileKey, // @cast-boundary engine-payload
38
38
  override,
39
39
  });
40
40
  },
@@ -46,9 +46,10 @@ function parseOverride(
46
46
  ): ComplianceProfileOverride | undefined {
47
47
  if (!raw || raw.trim() === "") return undefined;
48
48
  try {
49
- const parsed = JSON.parse(raw) as ComplianceProfileOverride;
50
- return parsed;
51
- } catch (e) {
49
+ const parsed: unknown = JSON.parse(raw);
50
+ return parsed as ComplianceProfileOverride; // @cast-boundary engine-payload
51
+ } catch (e: unknown) {
52
+ const reason = e instanceof Error ? e.message : String(e);
52
53
  // Defensiv: ungültiges JSON wird als "kein Override" behandelt. Der
53
54
  // set-profile-Handler validiert Zod das Override schon — invalides
54
55
  // JSON in der DB ist also nur möglich bei manueller DB-Manipulation
@@ -56,7 +57,7 @@ function parseOverride(
56
57
  // Operator-Sichtbarkeit via console.warn — Telemetry-Hook spaeter.
57
58
  // biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
58
59
  console.warn(
59
- `[compliance-profiles:for-tenant] tenant ${tenantId}: stored override is not valid JSON, falling back to base profile. Reason: ${(e as Error).message}`,
60
+ `[compliance-profiles:for-tenant] tenant ${tenantId}: stored override is not valid JSON, falling back to base profile. Reason: ${reason}`,
60
61
  );
61
62
  return undefined;
62
63
  }
@@ -27,7 +27,7 @@ export const needsProfileQuery = defineQueryHandler({
27
27
  ctx.db,
28
28
  tenantComplianceProfileTable,
29
29
  eq(tenantComplianceProfileTable["tenantId"], query.user.tenantId),
30
- )) as { profileKey: ComplianceProfileKey } | null;
30
+ )) as { profileKey: ComplianceProfileKey } | null; // @cast-boundary db-runner
31
31
 
32
32
  if (!row) {
33
33
  return {
@@ -1,6 +1,5 @@
1
1
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
2
  import {
3
- type ComplianceProfileKey,
4
3
  complianceProfileOverrideSchema,
5
4
  SELECTABLE_PROFILE_KEYS,
6
5
  } from "@cosmicdrift/kumiko-framework/compliance";
@@ -27,9 +26,7 @@ const crud = createEventStoreExecutor(tenantComplianceProfileTable, tenantCompli
27
26
  // X1) — minimal-no-region ist Default-Fallback fuer "noch keine Wahl",
28
27
  // nicht eine waehlbare Production-Option. Symmetrisch zu
29
28
  // SELECTABLE_PROFILE_KEYS aus der framework/compliance-Liste.
30
- const profileKeySchema = z.enum(
31
- SELECTABLE_PROFILE_KEYS as readonly [ComplianceProfileKey, ...ComplianceProfileKey[]],
32
- );
29
+ const profileKeySchema = z.enum(SELECTABLE_PROFILE_KEYS);
33
30
 
34
31
  // Tenant-Admin setzt Profile-Key + optional Override-JSON.
35
32
  //
@@ -69,7 +66,7 @@ export const setProfileWrite = defineWriteHandler({
69
66
  }),
70
67
  );
71
68
  }
72
- const tenantId = (tenantOverride ?? event.user.tenantId) as TenantId;
69
+ const tenantId = (tenantOverride ?? event.user.tenantId) as TenantId; // @cast-boundary engine-payload
73
70
  const executorUser = tenantOverride !== undefined ? { ...event.user, tenantId } : event.user;
74
71
 
75
72
  // Override-Validation: muss parseables JSON-Object sein UND dem
@@ -85,12 +82,13 @@ export const setProfileWrite = defineWriteHandler({
85
82
  let parsed: unknown;
86
83
  try {
87
84
  parsed = JSON.parse(event.payload.override);
88
- } catch (e) {
85
+ } catch (e: unknown) {
86
+ const parseError = e instanceof Error ? e.message : String(e);
89
87
  return writeFailure(
90
88
  new UnprocessableError("compliance_override_invalid_json", {
91
89
  details: {
92
90
  reason: "compliance_override_invalid_json",
93
- parseError: (e as Error).message,
91
+ parseError,
94
92
  },
95
93
  }),
96
94
  );
@@ -106,7 +104,7 @@ export const setProfileWrite = defineWriteHandler({
106
104
  ctx.db,
107
105
  tenantComplianceProfileTable,
108
106
  eq(tenantComplianceProfileTable["tenantId"], tenantId),
109
- )) as { id: string; version: number } | null;
107
+ )) as { id: string; version: number } | null; // @cast-boundary db-runner
110
108
 
111
109
  if (existing) {
112
110
  const result = await crud.update(
@@ -31,7 +31,7 @@ export async function resolveProfileForTenant(
31
31
  args.db,
32
32
  tenantComplianceProfileTable,
33
33
  eq(tenantComplianceProfileTable["tenantId"], args.tenantId),
34
- )) as { profileKey: string; override: string | null } | null;
34
+ )) as { profileKey: string; override: string | null } | null; // @cast-boundary db-runner
35
35
 
36
36
  if (!row) {
37
37
  return resolveComplianceProfile({});
@@ -39,7 +39,7 @@ export async function resolveProfileForTenant(
39
39
 
40
40
  const override = parseOverride(row.override, args.tenantId);
41
41
  return resolveComplianceProfile({
42
- selection: row.profileKey as ComplianceProfileKey,
42
+ selection: row.profileKey as ComplianceProfileKey, // @cast-boundary engine-payload
43
43
  override,
44
44
  });
45
45
  }
@@ -50,11 +50,13 @@ function parseOverride(
50
50
  ): ComplianceProfileOverride | undefined {
51
51
  if (!raw || raw.trim() === "") return undefined;
52
52
  try {
53
- return JSON.parse(raw) as ComplianceProfileOverride;
54
- } catch (e) {
53
+ const parsed: unknown = JSON.parse(raw);
54
+ return parsed as ComplianceProfileOverride; // @cast-boundary engine-payload
55
+ } catch (e: unknown) {
56
+ const reason = e instanceof Error ? e.message : String(e);
55
57
  // biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
56
58
  console.warn(
57
- `[compliance-profiles:resolve-for-tenant] tenant ${tenantId}: stored override is not valid JSON, ignoring. Reason: ${(e as Error).message}`,
59
+ `[compliance-profiles:resolve-for-tenant] tenant ${tenantId}: stored override is not valid JSON, ignoring. Reason: ${reason}`,
58
60
  );
59
61
  return undefined;
60
62
  }
@@ -56,7 +56,7 @@ export async function seedComplianceProfile(
56
56
  db,
57
57
  tenantComplianceProfileTable,
58
58
  eq(tenantComplianceProfileTable["tenantId"], opts.tenantId),
59
- )) as { id: string; version: number } | null;
59
+ )) as { id: string; version: number } | null; // @cast-boundary db-runner
60
60
 
61
61
  if (existing) {
62
62
  const result = await executor.update(
@@ -179,7 +179,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
179
179
 
180
180
  const result = new Map<string, ConfigRow>();
181
181
  for (const row of rows) {
182
- const r = row as ConfigRow;
182
+ const r = row as ConfigRow; // @cast-boundary db-row
183
183
  // Higher specificity wins: user > tenant > system. Under the ES
184
184
  // schema system rows carry SYSTEM_TENANT_ID instead of NULL, so the
185
185
  // "tenant set" check compares against the sentinel rather than null.
@@ -14,10 +14,11 @@ export function parseRetentionOverrideOrNull(
14
14
  let parsed: unknown;
15
15
  try {
16
16
  parsed = JSON.parse(raw);
17
- } catch (e) {
17
+ } catch (e: unknown) {
18
+ const reason = e instanceof Error ? e.message : String(e);
18
19
  // biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
19
20
  console.warn(
20
- `[${callerLabel}] tenant ${tenantId}: stored override is not valid JSON, ignoring. Reason: ${(e as Error).message}`,
21
+ `[${callerLabel}] tenant ${tenantId}: stored override is not valid JSON, ignoring. Reason: ${reason}`,
21
22
  );
22
23
  return null;
23
24
  }
@@ -33,7 +33,7 @@ export const policyForQuery = defineQueryHandler({
33
33
  tenantRetentionOverrideTable,
34
34
  eq(tenantRetentionOverrideTable["tenantId"], query.user.tenantId),
35
35
  eq(tenantRetentionOverrideTable["entityName"], entityName),
36
- )) as { config: string | null } | null;
36
+ )) as { config: string | null } | null; // @cast-boundary db-runner
37
37
 
38
38
  const tenantOverride = parseRetentionOverrideOrNull(
39
39
  overrideRow?.config ?? null,
@@ -19,7 +19,7 @@ const UNIT_TO_DAYS: Readonly<Record<string, number>> = {
19
19
  w: 7,
20
20
  m: 30,
21
21
  y: 365,
22
- };
22
+ } satisfies Readonly<Record<string, number>>;
23
23
 
24
24
  export class InvalidKeepForError extends Error {
25
25
  constructor(spec: string) {
@@ -58,7 +58,7 @@ export const RETENTION_PRESETS: Readonly<Record<RetentionPresetKey, RetentionPre
58
58
  session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
59
59
  invoice: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
60
60
  },
61
- };
61
+ } satisfies Readonly<Record<RetentionPresetKey, RetentionPreset>>;
62
62
 
63
63
  /**
64
64
  * Auswählbare Presets für den Onboarding-Banner. "default" ist Migration-
@@ -28,7 +28,7 @@ export async function resolveRetentionPolicyForTenant(
28
28
  tenantRetentionOverrideTable,
29
29
  eq(tenantRetentionOverrideTable["tenantId"], args.tenantId),
30
30
  eq(tenantRetentionOverrideTable["entityName"], args.entityName),
31
- )) as { config: string | null } | null;
31
+ )) as { config: string | null } | null; // @cast-boundary db-runner
32
32
 
33
33
  const tenantOverride = parseRetentionOverrideOrNull(
34
34
  overrideRow?.config ?? null,
@@ -31,7 +31,7 @@ export function createDeliveryFeature(): FeatureDefinition {
31
31
  table: deliveryAttemptsTable,
32
32
  apply: {
33
33
  [DELIVERY_ATTEMPT_EVENT]: async (event, tx) => {
34
- const p = event.payload as z.infer<typeof deliveryAttemptSchema>;
34
+ const p = event.payload as z.infer<typeof deliveryAttemptSchema>; // @cast-boundary engine-payload
35
35
  // PK = aggregateId — replaying the same event twice conflicts on
36
36
  // the PK rather than silently duplicating the log row.
37
37
  await tx.insert(deliveryAttemptsTable).values({
@@ -41,7 +41,6 @@ export function createDeliveryTestContext(
41
41
  _notifyFactory:
42
42
  (user: { id: number; tenantId: TenantId }, tenantId: TenantId) =>
43
43
  (notificationType: string, notifyOptions: Record<string, unknown>) =>
44
- // @cast-boundary engine-bridge — generic test-helper → typed entity-specific notify()
45
- deliveryService.notify(notificationType, notifyOptions as never, user as never, tenantId),
44
+ deliveryService.notify(notificationType, notifyOptions as never, user as never, tenantId), // @cast-boundary engine-bridge
46
45
  };
47
46
  }
@@ -137,7 +137,7 @@ export async function upsertPreference(
137
137
  // minor driver-version shifts without drifting wide.
138
138
  function isUniqueViolation(err: unknown): boolean {
139
139
  if (typeof err !== "object" || err === null) return false;
140
- const e = err as { code?: unknown; cause?: { code?: unknown }; message?: unknown };
140
+ const e = err as { code?: unknown; cause?: { code?: unknown }; message?: unknown }; // @cast-boundary error-details
141
141
  if (e.code === "23505") return true;
142
142
  if (e.cause && typeof e.cause === "object" && e.cause.code === "23505") return true;
143
143
  if (typeof e.message === "string" && e.message.includes("23505")) return true;
@@ -78,7 +78,7 @@ export function createFeatureTogglesFeature(options: FeatureTogglesOptions): Fea
78
78
  // (validated on append). Shallow-cast to a typed shape rather
79
79
  // than re-parsing — the payload round-trips through JSON and is
80
80
  // fixed at the source.
81
- const payload = event.payload as { featureName: string; enabled: boolean };
81
+ const payload = event.payload as { featureName: string; enabled: boolean }; // @cast-boundary engine-payload
82
82
  options.getRuntime().apply(payload.featureName, payload.enabled);
83
83
  },
84
84
  },
@@ -12,7 +12,7 @@ export const listQuery = defineQueryHandler({
12
12
  access: { roles: ["SystemAdmin", "Admin"] },
13
13
  handler: async (_event, ctx) => {
14
14
  type Row = typeof globalFeatureStateTable.$inferSelect;
15
- const rows = (await ctx.db.select().from(globalFeatureStateTable)) as Row[];
15
+ const rows = (await ctx.db.select().from(globalFeatureStateTable)) as Row[]; // @cast-boundary db-row
16
16
  return {
17
17
  items: rows.map((r) => ({
18
18
  featureName: r.featureName,
@@ -21,7 +21,7 @@ export const registeredQuery = defineQueryHandler({
21
21
  featureName: globalFeatureStateTable.featureName,
22
22
  enabled: globalFeatureStateTable.enabled,
23
23
  })
24
- .from(globalFeatureStateTable)) as OverrideRow[];
24
+ .from(globalFeatureStateTable)) as OverrideRow[]; // @cast-boundary db-row
25
25
  const overrides = new Map(overrideRows.map((r) => [r.featureName, r.enabled]));
26
26
 
27
27
  // SystemAdmin operator-tooling: das listing soll die PLATTFORM-truth
@@ -31,7 +31,14 @@ export const registeredQuery = defineQueryHandler({
31
31
  // dokumentiert in DispatcherOptions.effectiveFeatures.
32
32
  const effective = ctx.effectiveFeatures?.(SYSTEM_TENANT_ID);
33
33
 
34
- const items = [];
34
+ const items: Array<{
35
+ name: string;
36
+ toggleable: boolean;
37
+ default: boolean | null;
38
+ override: boolean | null;
39
+ requires: readonly string[];
40
+ effective: boolean | null;
41
+ }> = [];
35
42
  for (const feature of ctx.registry.features.values()) {
36
43
  const toggleable = feature.toggleableDefault !== undefined;
37
44
  const override = overrides.get(feature.name);
@@ -71,7 +71,7 @@ export function createSetWriteHandler(getRuntime: () => GlobalFeatureToggleRunti
71
71
  .select()
72
72
  .from(globalFeatureStateTable)
73
73
  .where(eq(globalFeatureStateTable.featureName, featureName))
74
- .limit(1)) as StateRow[];
74
+ .limit(1)) as StateRow[]; // @cast-boundary db-row
75
75
 
76
76
  const previousEnabled = existing?.enabled ?? null;
77
77
 
@@ -123,11 +123,11 @@ export function createSetWriteHandler(getRuntime: () => GlobalFeatureToggleRunti
123
123
  // and filtering by payload.featureName is trivial at query time.
124
124
  // This mirrors how `config` handles the same constraint for
125
125
  // its config-changed events.
126
- // appendEventUnsafe — bundled-features ohne lokalen Wrapper. Apps
126
+ // unsafeAppendEvent — bundled-features ohne lokalen Wrapper. Apps
127
127
  // mit `yarn kumiko codegen` kriegen `.kumiko/define.ts` als strict-
128
128
  // path; bundled-features bleibt bei der unsafe-Variante. Schema-
129
129
  // Validation läuft trotzdem via r.defineEvent("toggle-set", ...).
130
- await ctx.appendEventUnsafe({
130
+ await ctx.unsafeAppendEvent({
131
131
  aggregateId: SYSTEM_TENANT_ID,
132
132
  aggregateType: FEATURE_TOGGLE_AGGREGATE_TYPE,
133
133
  type: FEATURE_TOGGLE_SET_EVENT_NAME,
@@ -137,7 +137,7 @@ export async function createFileProviderForTenant(
137
137
  await ctxConfig(fileFoundationFeature.exports.configKeys.provider),
138
138
  FEATURE_NAME,
139
139
  "provider",
140
- ) as string;
140
+ ) as string; // @cast-boundary engine-payload
141
141
  if (provider.length === 0) {
142
142
  const usages = ctx.registry.getExtensionUsages("fileProvider");
143
143
  const known = usages.map((u) => u.entityName).join(", ") || "<none>";
@@ -129,13 +129,13 @@ async function buildS3Provider(
129
129
  await ctxConfig(fileProviderS3Feature.exports.configKeys.endpoint),
130
130
  FEATURE_NAME,
131
131
  "endpoint",
132
- ) as string;
132
+ ) as string; // @cast-boundary engine-payload
133
133
  const endpoint = endpointRaw.length > 0 ? endpointRaw : undefined;
134
134
  const forcePathStyle = requireDefined(
135
135
  await ctxConfig(fileProviderS3Feature.exports.configKeys.forcePathStyle),
136
136
  FEATURE_NAME,
137
137
  "forcePathStyle",
138
- ) as boolean;
138
+ ) as boolean; // @cast-boundary engine-payload
139
139
  const accessKeyId = requireNonEmpty(
140
140
  await ctxConfig(fileProviderS3Feature.exports.configKeys.accessKeyId),
141
141
  FEATURE_NAME,
@@ -154,7 +154,7 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
154
154
  }
155
155
  // SdkStream is AsyncIterable<Buffer> on node. Buffer extends
156
156
  // Uint8Array; cast sichert die Surface ohne neue runtime-deps.
157
- const body = response.Body as AsyncIterable<Uint8Array>;
157
+ const body = response.Body as AsyncIterable<Uint8Array>; // @cast-boundary engine-bridge
158
158
  for await (const chunk of body) {
159
159
  yield chunk;
160
160
  }
@@ -174,7 +174,7 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
174
174
  // S3 SDK throws either NotFound or a generic 404. Check both the
175
175
  // `.name` property (newer SDKs) and the `$metadata.httpStatusCode`
176
176
  // (what the SDK guarantees on every error).
177
- const err = error as { name?: string; $metadata?: { httpStatusCode?: number } };
177
+ const err = error as { name?: string; $metadata?: { httpStatusCode?: number } }; // @cast-boundary error-details
178
178
  if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
179
179
  return false;
180
180
  }
@@ -1,5 +1,5 @@
1
1
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
- import { and, desc, eq } from "drizzle-orm";
2
+ import { and, desc, eq, type SQL } from "drizzle-orm";
3
3
  import { z } from "zod";
4
4
  import { type JobRunStatus, jobRunsTable } from "../job-run-table";
5
5
 
@@ -13,13 +13,13 @@ export const listQuery = defineQueryHandler({
13
13
  access: { roles: ["SystemAdmin"] },
14
14
  handler: async (query, ctx) => {
15
15
  const db = ctx.db;
16
- const conditions = [];
16
+ const conditions: SQL[] = [];
17
17
 
18
18
  if (query.payload.jobName) {
19
19
  conditions.push(eq(jobRunsTable.jobName, query.payload.jobName));
20
20
  }
21
21
  if (query.payload.status) {
22
- conditions.push(eq(jobRunsTable.status, query.payload.status as JobRunStatus));
22
+ conditions.push(eq(jobRunsTable.status, query.payload.status as JobRunStatus)); // @cast-boundary engine-payload
23
23
  }
24
24
 
25
25
  const limit = query.payload.limit ?? 50;
@@ -14,7 +14,7 @@ export const triggerWrite = defineWriteHandler({
14
14
  handler: async (event, ctx) => {
15
15
  const registry = ctx.registry;
16
16
  // `jobRunner` is a dynamic context extension — not a core HandlerContext field.
17
- const jobRunner = ctx["jobRunner"] as JobRunner;
17
+ const jobRunner = ctx["jobRunner"] as JobRunner; // @cast-boundary dynamic-key
18
18
 
19
19
  const jobDef = registry.getJob(event.payload.jobName);
20
20
  if (!jobDef) {
@@ -1,3 +1,4 @@
1
+ // @runtime client
1
2
  // Feature name
2
3
  export const LEGAL_PAGES_FEATURE = "legal-pages" as const;
3
4
 
@@ -0,0 +1,42 @@
1
+ // @runtime client
2
+ // Client-Feature-Factory für legal-pages Visual-Tree. Liefert statische
3
+ // Tree-Knoten für die DACH-Compliance-Blöcke (imprint, privacy in de/en).
4
+ // Jeder Knoten linkt auf text-content's edit-Action — reines Cross-Feature-
5
+ // Linking, kein eigener State oder Fetch nötig.
6
+ //
7
+ // **Static statt fetch**: legal-pages weiß out-of-the-box welche Blocks
8
+ // existieren (LEGAL_REQUIRED_BLOCKS + LEGAL_OPTIONAL_BLOCKS aus constants).
9
+ // Anders als text-content's Provider (der alle Slugs des Tenants holt)
10
+ // ist diese Liste bekannt zur Build-Zeit — kein /api/query-Round-trip nötig.
11
+ //
12
+ // **Content-State unbekannt**: V.1.2 setzt keine state-Markierung; alle
13
+ // Knoten erscheinen "filled" (default). V.1.3+ könnte via by-slug-Query
14
+ // ermitteln ob ein Block tatsächlich body hat und „stub" markieren wenn
15
+ // leer (Provider-Author-Hinweis dass Block existiert aber befüllt werden
16
+ // muss). Aktuell ist legal-pages's Boot-Check der primäre Wächter für
17
+ // fehlende Pflicht-Blocks.
18
+
19
+ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
20
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
21
+ import { LEGAL_OPTIONAL_BLOCKS, LEGAL_REQUIRED_BLOCKS } from "../constants";
22
+
23
+ const treeProvider: TreeChildrenSubscribe = (_ctx) => (emit) => {
24
+ const allBlocks = [...LEGAL_REQUIRED_BLOCKS, ...LEGAL_OPTIONAL_BLOCKS];
25
+ const nodes: readonly TreeNode[] = allBlocks.map((b) => ({
26
+ label: `${b.slug} (${b.lang})`,
27
+ target: {
28
+ featureId: "text-content",
29
+ action: "edit",
30
+ args: { slug: b.slug, lang: b.lang },
31
+ },
32
+ }));
33
+ emit(nodes);
34
+ return () => {};
35
+ };
36
+
37
+ export function legalPagesClient(): ClientFeatureDefinition {
38
+ return {
39
+ name: "legal-pages",
40
+ treeProvider,
41
+ };
42
+ }
@@ -0,0 +1,4 @@
1
+ // @runtime client
2
+ // Public exports für die Browser-Seite des legal-pages Features.
3
+
4
+ export { legalPagesClient } from "./client-plugin";
@@ -134,7 +134,7 @@ export async function createTransportForTenant(
134
134
  await ctxConfig(mailFoundationFeature.exports.configKeys.provider),
135
135
  FEATURE_NAME,
136
136
  "provider",
137
- ) as string;
137
+ ) as string; // @cast-boundary engine-payload
138
138
  if (provider.length === 0) {
139
139
  const usages = ctx.registry.getExtensionUsages("mailTransport");
140
140
  const known = usages.map((u) => u.entityName).join(", ") || "<none>";
@@ -140,12 +140,12 @@ async function buildSmtpTransport(ctx: HandlerContext, tenantId: string): Promis
140
140
  await ctxConfig(mailTransportSmtpFeature.exports.configKeys.port),
141
141
  FEATURE_NAME,
142
142
  "port",
143
- ) as number;
143
+ ) as number; // @cast-boundary engine-payload
144
144
  const secure = requireDefined(
145
145
  await ctxConfig(mailTransportSmtpFeature.exports.configKeys.secure),
146
146
  FEATURE_NAME,
147
147
  "secure",
148
- ) as boolean;
148
+ ) as boolean; // @cast-boundary engine-payload
149
149
  const from = requireNonEmpty(
150
150
  await ctxConfig(mailTransportSmtpFeature.exports.configKeys.from),
151
151
  FEATURE_NAME,
@@ -38,7 +38,7 @@ export const simpleRenderer: NotificationRenderer = {
38
38
  name: "simple",
39
39
 
40
40
  async render(input) {
41
- const data = input.variables as EmailTemplateData;
41
+ const data = input.variables as EmailTemplateData; // @cast-boundary render-helper
42
42
 
43
43
  // Fallback: if no structured fields, use title + body as header + single text section
44
44
  const header = data.header ?? data.title;
@@ -51,7 +51,7 @@ export type RotateJobResult = {
51
51
  };
52
52
 
53
53
  export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
54
- const payload = rawPayload as RotateJobPayload;
54
+ const payload = rawPayload as RotateJobPayload; // @cast-boundary engine-payload
55
55
  if (!ctx.masterKeyProvider) {
56
56
  throw new InternalError({
57
57
  message:
@@ -64,7 +64,7 @@ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =>
64
64
  message: "[secrets:rotate] ctx.db missing — job context requires a database connection.",
65
65
  });
66
66
  }
67
- const db = ctx.db as DbConnection;
67
+ const db = ctx.db as DbConnection; // @cast-boundary db-operator
68
68
  const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
69
69
  const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
70
70
  const deadline = payload.maxDurationMs
@@ -39,13 +39,13 @@ export type SessionCleanupResult = {
39
39
  };
40
40
 
41
41
  export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
42
- const payload = rawPayload as SessionCleanupPayload;
42
+ const payload = rawPayload as SessionCleanupPayload; // @cast-boundary engine-payload
43
43
  if (!ctx.db) {
44
44
  throw new InternalError({
45
45
  message: "[sessions:cleanup] ctx.db missing — job context requires a database connection.",
46
46
  });
47
47
  }
48
- const db = ctx.db as DbConnection;
48
+ const db = ctx.db as DbConnection; // @cast-boundary db-operator
49
49
 
50
50
  // Coerce-and-validate: BullMQ payloads arrive as opaque JSON, so TS types
51
51
  // don't survive. Guard before the value is interpolated into SQL.
@@ -0,0 +1,62 @@
1
+ // step-dispatcher — bundled-feature that drains deferred Tier-2 step
2
+ // requests (webhook.send, mail.send, ...) after their TX commits.
3
+ //
4
+ // Listens on the `kumiko:system:step.dispatch-requested` system event
5
+ // (registry-bypassed, see append-event-core.ts SYSTEM_EVENT_PREFIX).
6
+ // Performs the side-effect and emits `kumiko:system:step.dispatched`
7
+ // or `kumiko:system:step.dispatch-failed` back onto the same stream so
8
+ // the audit trail lives in the event log only — no separate status table.
9
+
10
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { type MailSpec, performMailDispatch } from "./mail-runner";
12
+ import { performWebhookDispatch, type WebhookSpec } from "./webhook-runner";
13
+
14
+ export const STEP_DISPATCH_AGGREGATE_TYPE = "step-dispatch";
15
+ export const STEP_DISPATCH_REQUESTED_TYPE = "kumiko:system:step.dispatch-requested";
16
+ export const STEP_DISPATCHED_TYPE = "kumiko:system:step.dispatched";
17
+ export const STEP_DISPATCH_FAILED_TYPE = "kumiko:system:step.dispatch-failed";
18
+
19
+ type DispatchRequestedPayload =
20
+ | {
21
+ readonly stepKind: "webhook.send";
22
+ readonly spec: WebhookSpec;
23
+ readonly retry?: { readonly times: number; readonly backoff: "exponential" | "linear" };
24
+ }
25
+ | {
26
+ readonly stepKind: "mail.send";
27
+ readonly spec: MailSpec;
28
+ };
29
+
30
+ export function createStepDispatcherFeature(): FeatureDefinition {
31
+ return defineFeature("step-dispatcher", (r) => {
32
+ r.systemScope();
33
+
34
+ r.multiStreamProjection({
35
+ name: "step-dispatcher",
36
+ apply: {
37
+ [STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
38
+ const payload = event.payload as DispatchRequestedPayload;
39
+ const result =
40
+ payload.stepKind === "webhook.send"
41
+ ? await performWebhookDispatch(payload.spec)
42
+ : await performMailDispatch(payload.spec);
43
+ if (result.ok) {
44
+ await ctx.unsafeAppendEvent({
45
+ aggregateId: event.aggregateId,
46
+ aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
47
+ type: STEP_DISPATCHED_TYPE,
48
+ payload: { stepKind: payload.stepKind, status: result.status },
49
+ });
50
+ } else {
51
+ await ctx.unsafeAppendEvent({
52
+ aggregateId: event.aggregateId,
53
+ aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
54
+ type: STEP_DISPATCH_FAILED_TYPE,
55
+ payload: { stepKind: payload.stepKind, error: result.error, attempt: 1 },
56
+ });
57
+ }
58
+ },
59
+ },
60
+ });
61
+ });
62
+ }
@@ -0,0 +1,16 @@
1
+ export { createStepDispatcherFeature, STEP_DISPATCH_AGGREGATE_TYPE } from "./feature";
2
+ export {
3
+ type MailDispatchResult,
4
+ type MailSpec,
5
+ mailSpecSchema,
6
+ performMailDispatch,
7
+ setMailRunner,
8
+ } from "./mail-runner";
9
+ export {
10
+ performWebhookDispatch,
11
+ setWebhookFetch,
12
+ setWebhookSecretResolver,
13
+ type WebhookDispatchResult,
14
+ type WebhookSpec,
15
+ webhookSpecSchema,
16
+ } from "./webhook-runner";
@@ -0,0 +1,32 @@
1
+ // Mail execution logic — separated from feature.ts and tests-injectable.
2
+ // Production wiring (mail-foundation transport) is a follow-up; the
3
+ // default impl throws so a missing setMailRunner is loud, not silent.
4
+
5
+ import { z } from "zod";
6
+
7
+ export const mailSpecSchema = z.object({
8
+ to: z.union([z.string(), z.array(z.string())]),
9
+ subject: z.string(),
10
+ body: z.string(),
11
+ from: z.string().optional(),
12
+ });
13
+
14
+ export type MailSpec = z.infer<typeof mailSpecSchema>;
15
+
16
+ export type MailDispatchResult =
17
+ | { readonly ok: true; readonly status: number }
18
+ | { readonly ok: false; readonly error: string };
19
+
20
+ let mailRunner: (spec: MailSpec) => Promise<MailDispatchResult> = async () => ({
21
+ ok: false,
22
+ error:
23
+ "no mail-runner configured — call setMailRunner() with a mail-foundation transport adapter",
24
+ });
25
+
26
+ export function setMailRunner(fn: (spec: MailSpec) => Promise<MailDispatchResult>): void {
27
+ mailRunner = fn;
28
+ }
29
+
30
+ export async function performMailDispatch(spec: MailSpec): Promise<MailDispatchResult> {
31
+ return mailRunner(spec);
32
+ }