@cosmicdrift/kumiko-bundled-features 0.2.2 → 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 (162) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +22 -13
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  6. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  9. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  11. package/src/auth-email-password/handlers/login.write.ts +32 -2
  12. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  13. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  14. package/src/auth-email-password/i18n.ts +4 -0
  15. package/src/auth-email-password/web/auth-client.ts +1 -1
  16. package/src/billing-foundation/events.ts +1 -1
  17. package/src/billing-foundation/feature.ts +44 -47
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  19. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  20. package/src/billing-foundation/projection.ts +1 -1
  21. package/src/billing-foundation/webhook-handler.ts +1 -1
  22. package/src/cap-counter/constants.ts +1 -1
  23. package/src/cap-counter/enforce-cap.ts +1 -1
  24. package/src/cap-counter/feature.ts +3 -7
  25. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  26. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  27. package/src/cap-counter/handlers/increment.write.ts +3 -3
  28. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  29. package/src/channel-email/email-channel.ts +1 -1
  30. package/src/channel-email/types.ts +1 -1
  31. package/src/compliance-profiles/README.md +88 -0
  32. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  33. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  34. package/src/compliance-profiles/feature.ts +51 -0
  35. package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
  36. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  37. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  38. package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
  39. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
  42. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  43. package/src/compliance-profiles/seeding.ts +96 -0
  44. package/src/config/resolver.ts +1 -1
  45. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  46. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  47. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  48. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  49. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  50. package/src/data-retention/_internal/parse-override.ts +34 -0
  51. package/src/data-retention/feature.ts +57 -0
  52. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  53. package/src/data-retention/index.ts +18 -0
  54. package/src/data-retention/keep-for.ts +75 -0
  55. package/src/data-retention/override-schema.ts +37 -0
  56. package/src/data-retention/presets.ts +72 -0
  57. package/src/data-retention/resolve-for-tenant.ts +50 -0
  58. package/src/data-retention/resolver.ts +107 -0
  59. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  60. package/src/delivery/feature.ts +1 -1
  61. package/src/delivery/testing.ts +1 -2
  62. package/src/delivery/upsert-preference.ts +1 -1
  63. package/src/feature-toggles/feature.ts +1 -1
  64. package/src/feature-toggles/handlers/list.query.ts +1 -1
  65. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  66. package/src/feature-toggles/handlers/set.write.ts +3 -3
  67. package/src/file-foundation/feature.ts +44 -4
  68. package/src/file-foundation/index.ts +1 -0
  69. package/src/file-provider-inmemory/feature.ts +6 -3
  70. package/src/file-provider-s3/feature.ts +10 -12
  71. package/src/files/README.md +50 -0
  72. package/src/files/__tests__/files.integration.ts +157 -0
  73. package/src/files/feature.ts +34 -0
  74. package/src/files/index.ts +1 -0
  75. package/src/files/schema/file-ref.ts +58 -0
  76. package/src/files-provider-s3/s3-provider.ts +90 -1
  77. package/src/jobs/handlers/list.query.ts +3 -3
  78. package/src/jobs/handlers/trigger.write.ts +1 -1
  79. package/src/legal-pages/constants.ts +1 -0
  80. package/src/legal-pages/web/client-plugin.ts +42 -0
  81. package/src/legal-pages/web/index.ts +4 -0
  82. package/src/mail-foundation/feature.ts +1 -1
  83. package/src/mail-transport-smtp/feature.ts +2 -2
  84. package/src/renderer-simple/simple-renderer.ts +1 -1
  85. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  86. package/src/secrets/feature.ts +10 -6
  87. package/src/secrets/handlers/rotate.job.ts +2 -2
  88. package/src/sessions/constants.ts +4 -0
  89. package/src/sessions/feature.ts +3 -0
  90. package/src/sessions/handlers/cleanup.job.ts +2 -2
  91. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  92. package/src/step-dispatcher/feature.ts +62 -0
  93. package/src/step-dispatcher/index.ts +16 -0
  94. package/src/step-dispatcher/mail-runner.ts +32 -0
  95. package/src/step-dispatcher/webhook-runner.ts +67 -0
  96. package/src/subscription-mollie/plugin-methods.ts +1 -1
  97. package/src/subscription-mollie/verify-webhook.ts +9 -5
  98. package/src/subscription-stripe/verify-webhook.ts +3 -3
  99. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  100. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  101. package/src/tenant/handlers/remove-member.write.ts +1 -1
  102. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  103. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  104. package/src/text-content/constants.ts +2 -0
  105. package/src/text-content/feature.ts +20 -4
  106. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  107. package/src/text-content/handlers/set.write.ts +1 -1
  108. package/src/text-content/web/client-plugin.ts +113 -0
  109. package/src/text-content/web/index.ts +8 -0
  110. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  111. package/src/tier-engine/feature.ts +23 -13
  112. package/src/user/__tests__/user-status.test.ts +39 -0
  113. package/src/user/handlers/find-for-auth.query.ts +1 -1
  114. package/src/user/index.ts +11 -1
  115. package/src/user/schema/user.ts +76 -0
  116. package/src/user/seeding.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +182 -0
  118. package/src/user-data-rights/README.md +109 -0
  119. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  120. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  121. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  122. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  123. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  124. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  129. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  130. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  131. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  132. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  133. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  134. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  135. package/src/user-data-rights/audit-download.ts +125 -0
  136. package/src/user-data-rights/feature.ts +310 -0
  137. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  138. package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
  139. package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
  140. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  141. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  142. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  143. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  144. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  145. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  146. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  147. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  148. package/src/user-data-rights/i18n.ts +37 -0
  149. package/src/user-data-rights/index.ts +19 -0
  150. package/src/user-data-rights/run-export-jobs.ts +878 -0
  151. package/src/user-data-rights/run-forget-cleanup.ts +333 -0
  152. package/src/user-data-rights/run-user-export.ts +211 -0
  153. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  154. package/src/user-data-rights/schema/download-token.ts +111 -0
  155. package/src/user-data-rights/schema/export-job.ts +166 -0
  156. package/src/user-data-rights/token-helpers.ts +67 -0
  157. package/src/user-data-rights/zip-path.ts +94 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  159. package/src/user-data-rights-defaults/feature.ts +40 -0
  160. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  161. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  162. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -0,0 +1,81 @@
1
+ // requireSecretsContext-Surface-Tests (S2.U3 Atom 3b.fix2).
2
+ //
3
+ // Pinst dass `requireSecretsContext` mit dem schmalen FileProviderContext-
4
+ // Surface funktioniert — nicht nur mit voller HandlerContext.
5
+ // Regression-Pin fuer den latenten Worker-Pfad-Bug:
6
+ // - Vor 3b.fix wurde `ctx as unknown as HandlerContext` durchgereicht
7
+ // - Im Worker-Pfad ist `ctx._userId` undefined (dispatcher setzt es nur
8
+ // im request-Pfad), also throw `_userId missing`
9
+ // - Fix: Worker-Wrap setzt explizit `_userId: SYSTEM_USER_ID`
10
+ //
11
+ // Der Test inszeniert beide Surfaces (HandlerContext-shape via type-assert
12
+ // + FileProviderContext-shape direkt) und prueft dass beide den happy-path
13
+ // + den fehlt-_userId-throw durchlaufen.
14
+
15
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
16
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
17
+ import { describe, expect, test, vi } from "vitest";
18
+ import { requireSecretsContext } from "../feature";
19
+
20
+ function makeRawSecretsContext(): SecretsContext {
21
+ return {
22
+ get: vi.fn(),
23
+ set: vi.fn(),
24
+ delete: vi.fn(),
25
+ };
26
+ }
27
+
28
+ describe("requireSecretsContext :: FileProviderContext surface", () => {
29
+ test("succeeds with secrets + _userId present (Worker-Pfad mit SYSTEM_USER_ID)", () => {
30
+ const fileProviderCtx = {
31
+ secrets: makeRawSecretsContext(),
32
+ _userId: SYSTEM_USER_ID,
33
+ };
34
+ expect(() => requireSecretsContext(fileProviderCtx, "test-handler")).not.toThrow();
35
+ });
36
+
37
+ test("throws when _userId is missing (latenter Worker-Bug pre-3b.fix)", () => {
38
+ // Pinst die Falle die der 3b.fix abfaengt: wenn ein Provider-Plugin
39
+ // `requireSecretsContext` ruft und der ctx kein _userId hat (z.B. weil
40
+ // ein r.job-Wrap das vergessen hat zu setzen), faellt es FRUEH mit
41
+ // einer klaren Fehlermeldung um — nicht silent broken.
42
+ const fileProviderCtx = {
43
+ secrets: makeRawSecretsContext(),
44
+ // _userId absichtlich undefined
45
+ };
46
+ expect(() => requireSecretsContext(fileProviderCtx, "test-handler")).toThrow(/_userId missing/);
47
+ });
48
+
49
+ test("throws when secrets is missing (boot-Misconfig)", () => {
50
+ const fileProviderCtx = {
51
+ _userId: SYSTEM_USER_ID,
52
+ // secrets absichtlich undefined
53
+ };
54
+ expect(() => requireSecretsContext(fileProviderCtx, "test-handler")).toThrow(
55
+ /ctx\.secrets missing/,
56
+ );
57
+ });
58
+
59
+ test("audit-userId reaches secrets.get when call is delegated", async () => {
60
+ // Pinst den Audit-Pfad: wenn ein Plugin secrets.get(...) aufruft, kommt
61
+ // _userId als audit.userId ohne Override durch. Das ist der Grund warum
62
+ // SYSTEM_USER_ID nicht durch einen ad-hoc-magic-string ersetzt werden
63
+ // darf — sonst wird der Audit-Trail inkonsistent.
64
+ const raw = makeRawSecretsContext();
65
+ const ctx = {
66
+ secrets: raw,
67
+ _userId: SYSTEM_USER_ID,
68
+ };
69
+ const wrapped = requireSecretsContext(ctx, "user-data-rights:run-export-jobs");
70
+ await wrapped.get(
71
+ "tenant-x" as Parameters<SecretsContext["get"]>[0],
72
+ "any-key" as unknown as Parameters<SecretsContext["get"]>[1],
73
+ );
74
+ // Erste-Aufruf-args: [tenantId, key, audit-Object]
75
+ const audit = vi.mocked(raw.get).mock.calls[0]?.[2];
76
+ expect(audit).toEqual({
77
+ userId: SYSTEM_USER_ID,
78
+ handlerName: "user-data-rights:run-export-jobs",
79
+ });
80
+ });
81
+ });
@@ -1,8 +1,4 @@
1
- import {
2
- defineFeature,
3
- type FeatureDefinition,
4
- type HandlerContext,
5
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
6
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
7
3
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
8
4
  import { deleteWrite } from "./handlers/delete.write";
@@ -24,7 +20,15 @@ export { type StoredEnvelope, type StoredMetadata, tenantSecretsTable } from "./
24
20
  // wraps that raw context so every `.get(...)` call auto-includes the
25
21
  // current user + handler as audit metadata — feature code can't forget
26
22
  // to log the read (silent bypass of audit was the v1 gap).
27
- export function requireSecretsContext(ctx: HandlerContext, handlerName: string): SecretsContext {
23
+ //
24
+ // Surface bewusst minimal: HandlerContext-Pfade (Set/Delete-Handler) +
25
+ // FileProviderContext-Pfade (S3-Plugin im Worker) liefern dieselben
26
+ // zwei Felder, also reicht die schmale ctx-shape — kein voller
27
+ // HandlerContext-Import noetig.
28
+ export function requireSecretsContext(
29
+ ctx: { readonly secrets?: SecretsContext; readonly _userId?: string | undefined },
30
+ handlerName: string,
31
+ ): SecretsContext {
28
32
  if (!ctx.secrets) {
29
33
  throw new InternalError({
30
34
  message:
@@ -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
@@ -5,6 +5,10 @@ export const SESSIONS_FEATURE = "sessions" as const;
5
5
  export const SessionHandlers = {
6
6
  revoke: "sessions:write:user-session:revoke",
7
7
  revokeAllOthers: "sessions:write:user-session:revoke-all-others",
8
+ /** Privileged: System-Caller (cross-feature) revokes ALL live sessions
9
+ * fuer einen User. Genutzt von user-data-rights:restrict-account
10
+ * (DSGVO Art. 18 Account-Freeze). */
11
+ revokeAllForUser: "sessions:write:user-session:revoke-all-for-user",
8
12
  } as const;
9
13
 
10
14
  export const SessionQueries = {
@@ -3,6 +3,7 @@ import { cleanupJob } from "./handlers/cleanup.job";
3
3
  import { listQuery } from "./handlers/list.query";
4
4
  import { mineQuery } from "./handlers/mine.query";
5
5
  import { revokeWrite } from "./handlers/revoke.write";
6
+ import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
6
7
  import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
7
8
  import { userSessionEntity } from "./schema/user-session";
8
9
  import type { SessionMassRevoker } from "./session-callbacks";
@@ -42,7 +43,9 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
42
43
  const handlers = {
43
44
  revoke: r.writeHandler(revokeWrite),
44
45
  revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
46
+ revokeAllForUser: r.writeHandler(revokeAllForUserWrite),
45
47
  };
48
+ r.exposesApi("sessions.revokeAllForUser");
46
49
 
47
50
  const queries = {
48
51
  mine: r.queryHandler(mineQuery),
@@ -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,42 @@
1
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { and, eq, isNull } from "drizzle-orm";
3
+ import { Temporal } from "temporal-polyfill";
4
+ import { z } from "zod";
5
+ import { userSessionTable } from "../schema/user-session";
6
+
7
+ // Mass-revoke ALL live sessions for a target user — privileged-only.
8
+ // Used by user-data-rights:restrict-account zur Account-Freeze
9
+ // (DSGVO Art. 18) sowie potenziell anderen ops-flows ("ban user",
10
+ // "compromised account"). Im Gegensatz zu revoke-all-others wird
11
+ // die ggf. aufrufende Session ebenfalls revoked — Caller ist System
12
+ // (cron/operator/cross-feature), nicht der Endnutzer selbst.
13
+ //
14
+ // Tenant-scope: das userSession-Schema persistiert tenantId pro Row
15
+ // (User kann mehrere Sessions in mehreren Tenants haben). Wir
16
+ // revoken cross-tenant, weil "Account-Restriction" eine globale
17
+ // User-Aussage ist (Forget-Pfad ist auch global, sieht User-Entity-
18
+ // special-Doc). UPDATE filtert nur auf userId.
19
+ export const revokeAllForUserWrite = defineWriteHandler({
20
+ name: "user-session:revoke-all-for-user",
21
+ schema: z.object({
22
+ userId: z.string().min(1),
23
+ }),
24
+ access: { roles: access.privileged },
25
+ handler: async (event, ctx) => {
26
+ const updated = await ctx.db.raw
27
+ .update(userSessionTable)
28
+ .set({ revokedAt: Temporal.Now.instant() })
29
+ .where(
30
+ and(
31
+ eq(userSessionTable["userId"], event.payload.userId),
32
+ isNull(userSessionTable["revokedAt"]),
33
+ ),
34
+ )
35
+ .returning();
36
+
37
+ return {
38
+ isSuccess: true as const,
39
+ data: { count: updated.length, userId: event.payload.userId },
40
+ };
41
+ },
42
+ });
@@ -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
+ }
@@ -0,0 +1,67 @@
1
+ // Webhook execution logic — separated from feature.ts so tests can stub
2
+ // the fetch without touching the MSP wiring.
3
+
4
+ import { z } from "zod";
5
+
6
+ export const webhookSpecSchema = z.object({
7
+ url: z.string(),
8
+ method: z.enum(["POST", "PUT", "PATCH"]),
9
+ headers: z.record(z.string(), z.string()),
10
+ body: z.unknown().optional(),
11
+ auth: z
12
+ .union([
13
+ z.object({ kind: z.literal("bearer"), secretRef: z.string() }),
14
+ z.object({ kind: z.literal("header"), name: z.string(), secretRef: z.string() }),
15
+ ])
16
+ .optional(),
17
+ });
18
+
19
+ export type WebhookSpec = z.infer<typeof webhookSpecSchema>;
20
+
21
+ export type WebhookDispatchResult =
22
+ | { readonly ok: true; readonly status: number }
23
+ | { readonly ok: false; readonly error: string };
24
+
25
+ // Resolves a secretRef via the test-injectable secret-store. Default
26
+ // implementation reads from process.env at the prefix WEBHOOK_SECRET_.
27
+ // Tests pass a custom resolver via setWebhookSecretResolver.
28
+ let secretResolver: (ref: string) => string | undefined = (ref) =>
29
+ process.env[`WEBHOOK_SECRET_${ref}`];
30
+
31
+ export function setWebhookSecretResolver(fn: (ref: string) => string | undefined): void {
32
+ secretResolver = fn;
33
+ }
34
+
35
+ let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis);
36
+
37
+ export function setWebhookFetch(fn: typeof fetch): void {
38
+ fetchImpl = fn;
39
+ }
40
+
41
+ export async function performWebhookDispatch(spec: WebhookSpec): Promise<WebhookDispatchResult> {
42
+ const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
43
+ if (spec.auth) {
44
+ const secret = secretResolver(spec.auth.secretRef);
45
+ if (!secret) {
46
+ return { ok: false, error: `secret "${spec.auth.secretRef}" not configured` };
47
+ }
48
+ if (spec.auth.kind === "bearer") {
49
+ headers["authorization"] = `Bearer ${secret}`;
50
+ } else {
51
+ headers[spec.auth.name] = secret;
52
+ }
53
+ }
54
+ try {
55
+ const res = await fetchImpl(spec.url, {
56
+ method: spec.method,
57
+ headers,
58
+ body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
59
+ });
60
+ if (!res.ok) {
61
+ return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
62
+ }
63
+ return { ok: true, status: res.status };
64
+ } catch (err) {
65
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
66
+ }
67
+ }
@@ -66,7 +66,7 @@ export function createMollieCheckoutSession(
66
66
  tenantId: options.tenantId,
67
67
  priceId: options.priceId,
68
68
  },
69
- }) as Promise<Payment>)) satisfies Payment;
69
+ }) as Promise<Payment>)) satisfies Payment; // @cast-boundary engine-bridge
70
70
 
71
71
  const checkoutHref = payment.getCheckoutUrl();
72
72
  if (!checkoutHref) {
@@ -102,7 +102,7 @@ export function verifyAndParseMollieWebhook(
102
102
  return null;
103
103
  }
104
104
 
105
- const metadata = (subscription.metadata as Record<string, string> | null) ?? {};
105
+ const metadata = (subscription.metadata as Record<string, string> | null) ?? {}; // @cast-boundary engine-bridge
106
106
  const tenantId = metadata["tenantId"];
107
107
  if (!tenantId || tenantId.length === 0) return null;
108
108
  const priceId = metadata["priceId"];
@@ -153,7 +153,7 @@ async function ensureSubscriptionForMandate(
153
153
  ): Promise<MollieSubscription | null> {
154
154
  const customerId = payment.customerId;
155
155
  if (!customerId) return null;
156
- const paymentMetadata = (payment.metadata as Record<string, string> | null) ?? {};
156
+ const paymentMetadata = (payment.metadata as Record<string, string> | null) ?? {}; // @cast-boundary engine-bridge
157
157
  const tenantId = paymentMetadata["tenantId"];
158
158
  const priceId = paymentMetadata["priceId"];
159
159
  if (!tenantId || !priceId) return null;
@@ -163,7 +163,7 @@ async function ensureSubscriptionForMandate(
163
163
  const existing = await client.customerSubscriptions.list(customerId);
164
164
  const matchingExisting = existing.find(
165
165
  (sub) =>
166
- (sub.metadata as Record<string, string> | null)?.["priceId"] === priceId &&
166
+ (sub.metadata as Record<string, string> | null)?.["priceId"] === priceId && // @cast-boundary engine-bridge
167
167
  (sub.status === "active" || sub.status === "pending"),
168
168
  );
169
169
  if (matchingExisting) return matchingExisting;
@@ -185,8 +185,12 @@ export function extractMollieId(rawBody: string, headers: Record<string, string>
185
185
  const contentType = headers["content-type"] ?? "";
186
186
  if (contentType.includes("application/json")) {
187
187
  try {
188
- const parsed = JSON.parse(rawBody) as { id?: unknown };
189
- return typeof parsed.id === "string" ? parsed.id : null;
188
+ const parsed: unknown = JSON.parse(rawBody);
189
+ const id =
190
+ typeof parsed === "object" && parsed !== null && "id" in parsed
191
+ ? (parsed as Record<string, unknown>)["id"] // @cast-boundary engine-payload
192
+ : undefined;
193
+ return typeof id === "string" ? id : null;
190
194
  } catch {
191
195
  return null;
192
196
  }
@@ -203,15 +203,15 @@ async function extractSubscriptionFromEvent(
203
203
  case StripeEventTypes.customerSubscriptionCreated:
204
204
  case StripeEventTypes.customerSubscriptionUpdated:
205
205
  case StripeEventTypes.customerSubscriptionDeleted:
206
- return event.data.object as Stripe.Subscription;
206
+ return event.data.object as Stripe.Subscription; // @cast-boundary engine-bridge
207
207
  case StripeEventTypes.invoicePaid:
208
208
  case StripeEventTypes.invoicePaymentFailed: {
209
209
  // Lazy-fetch der subscription. invoice.subscription ist eine
210
210
  // string-id (Stripe-Webhooks expanden nicht auto). Wir holen das
211
211
  // full subscription-Object damit der downstream-mapping
212
212
  // (status, tier via priceId, period-end) konsistent funktioniert.
213
- const invoice = event.data.object as Stripe.Invoice;
214
- const subRef = (invoice as { subscription?: string | Stripe.Subscription | null })
213
+ const invoice = event.data.object as Stripe.Invoice; // @cast-boundary engine-bridge
214
+ const subRef = (invoice as { subscription?: string | Stripe.Subscription | null }) // @cast-boundary engine-payload
215
215
  .subscription;
216
216
  if (!subRef) {
217
217
  // Invoice ohne subscription-reference (= one-shot-invoice, nicht
@@ -14,6 +14,6 @@ export const activeTenantIdsQuery = defineQueryHandler({
14
14
  .from(tenantTable)
15
15
  .where(eq(tenantTable["isEnabled"], true));
16
16
 
17
- return rows.map((row) => (row as DbRow)["id"] as number);
17
+ return rows.map((row) => (row as DbRow)["id"] as number); // @cast-boundary db-row
18
18
  },
19
19
  });
@@ -61,7 +61,7 @@ export const cancelInvitationWrite = defineWriteHandler({
61
61
  const updateResult = await executor.update(
62
62
  {
63
63
  id: event.payload.invitationId,
64
- version: invitation["version"] as number,
64
+ version: invitation["version"] as number, // @cast-boundary db-row
65
65
  changes: { status: INVITATION_STATUS.cancelled },
66
66
  },
67
67
  event.user,
@@ -31,7 +31,7 @@ export const removeMemberWrite = defineWriteHandler({
31
31
  }
32
32
 
33
33
  const result = await executor.delete(
34
- { id: (existing as DbRow)["id"] as string },
34
+ { id: (existing as DbRow)["id"] as string }, // @cast-boundary db-row
35
35
  event.user,
36
36
  db,
37
37
  );
@@ -27,7 +27,7 @@ export const resolveUserIdsQuery = defineQueryHandler({
27
27
  .select({ userId: tenantMembershipsTable.userId })
28
28
  .from(tenantMembershipsTable)
29
29
  .where(eq(tenantMembershipsTable.tenantId, tenantId));
30
- return rows.map((r) => r["userId"] as number);
30
+ return rows.map((r) => r["userId"] as number); // @cast-boundary db-row
31
31
  }
32
32
 
33
33
  if (userId !== undefined) {
@@ -39,11 +39,11 @@ export const updateMemberRolesWrite = defineWriteHandler({
39
39
  // between this read and append) surfaces as version_conflict rather than
40
40
  // silent overwrite. Per-membership parallelism is rare; if it happens,
41
41
  // the client retries on the error.
42
- const row = existing as DbRow;
42
+ const row = existing as DbRow; // @cast-boundary generic-record
43
43
  const result = await executor.update(
44
44
  {
45
- id: row["id"] as string,
46
- version: row["version"] as number,
45
+ id: row["id"] as string, // @cast-boundary db-row
46
+ version: row["version"] as number, // @cast-boundary db-row
47
47
  changes: { roles: JSON.stringify(event.payload.roles) },
48
48
  },
49
49
  event.user,
@@ -1,3 +1,4 @@
1
+ // @runtime client
1
2
  // Feature name
2
3
  export const TEXT_CONTENT_FEATURE = "text-content" as const;
3
4
 
@@ -9,6 +10,7 @@ export const TextContentHandlers = {
9
10
  // Qualified query handler names (QN format: scope:type:name)
10
11
  export const TextContentQueries = {
11
12
  bySlug: "text-content:query:by-slug",
13
+ byTenant: "text-content:query:by-tenant",
12
14
  } as const;
13
15
 
14
16
  // Error codes
@@ -1,5 +1,6 @@
1
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { bySlugQuery } from "./handlers/by-slug.query";
3
+ import { byTenantQuery } from "./handlers/by-tenant.query";
3
4
  import { setWrite } from "./handlers/set.write";
4
5
  import { textBlockEntity } from "./table";
5
6
 
@@ -11,8 +12,16 @@ import { textBlockEntity } from "./table";
11
12
  //
12
13
  // Opt-in: wer keine statischen Texte braucht (interne Tools), aktiviert
13
14
  // das Feature gar nicht. Wer es aktiviert, hat sofort CRUD + by-slug-
14
- // query — Routes/Render kommen pro Use-Case (legal-pages, etc.).
15
- export function createTextContentFeature(): FeatureDefinition {
15
+ // query + by-tenant-list-query — Routes/Render kommen pro Use-Case
16
+ // (legal-pages, Visual-Tree, etc.).
17
+ //
18
+ // **Visual-Tree-Integration (V.1.2)**: r.treeActions deklariert die
19
+ // Edit-Actions für Cross-Feature-Linking via buildTarget. Der Handle
20
+ // wird via setup-export propagiert (Memory `[EventDef-Exports-Pattern]`),
21
+ // sodass andere Features compile-time-typed Cross-Feature-Edits triggern
22
+ // können — siehe legal-pages's TreeProvider der text-content:edit als
23
+ // Target nutzt. Der Client-side TreeProvider lebt in `web/client-plugin.ts`.
24
+ export function createTextContentFeature() {
16
25
  return defineFeature("text-content", (r) => {
17
26
  r.entity("text-block", textBlockEntity);
18
27
 
@@ -22,8 +31,15 @@ export function createTextContentFeature(): FeatureDefinition {
22
31
 
23
32
  const queries = {
24
33
  bySlug: r.queryHandler(bySlugQuery),
34
+ byTenant: r.queryHandler(byTenantQuery),
25
35
  };
26
36
 
27
- return { handlers, queries };
37
+ const treeHandle = r.treeActions({
38
+ edit: { args: { slug: "" as string, lang: "" as string } },
39
+ list: {},
40
+ create: { args: { folder: "" as string } },
41
+ });
42
+
43
+ return { handlers, queries, treeHandle };
28
44
  });
29
45
  }
@@ -0,0 +1,56 @@
1
+ import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { eq } from "drizzle-orm";
5
+ import { z } from "zod";
6
+ import { type TextBlockRow, textBlocksTable } from "../table";
7
+
8
+ // Public-Read aller Text-Blocks für einen Tenant. Use-Case: Visual-Tree-
9
+ // Provider lädt die Slug-Liste zur Sidebar-Render. Anonymous: explizit
10
+ // in roles damit no-JWT-Visitors auch lesen können (Marketing-Sidebar
11
+ // auf Public-Pages). Tenant-Scope kommt aus query.user.tenantId; optional
12
+ // `tenantIdOverride` (SystemAdmin-only) — symmetrisch zu by-slug.query.
13
+ //
14
+ // **Listing statt single-row**: anders als by-slug returnt das hier
15
+ // `{ blocks: [...] }` mit allen Slugs des Tenants. Pro Slug nur die
16
+ // Summary-Felder (kein full body — den lädt der Editor on-demand via
17
+ // by-slug). Hält die Sidebar-Payload klein bei vielen Slugs.
18
+ export type TextBlockSummary = {
19
+ readonly slug: string;
20
+ readonly lang: string;
21
+ readonly title: string;
22
+ readonly body: string | null;
23
+ readonly updatedAt: Date;
24
+ };
25
+
26
+ export const byTenantQuery = defineQueryHandler({
27
+ name: "by-tenant",
28
+ schema: z.object({
29
+ /** Optional cross-tenant read — nur für SystemAdmin. Symmetrisch
30
+ * zur by-slug.query und set.write Override-Logik. */
31
+ tenantIdOverride: z.string().min(1).optional(),
32
+ }),
33
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
34
+ handler: async (query, ctx) => {
35
+ const override = query.payload.tenantIdOverride;
36
+ if (override !== undefined && !query.user.roles.includes("SystemAdmin")) {
37
+ throw new AccessDeniedError({
38
+ i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
39
+ details: { reason: "tenant_override_requires_system_admin" },
40
+ });
41
+ }
42
+ const tenantId = override ?? query.user.tenantId;
43
+ const rows = castTenantRows<TextBlockRow>(
44
+ await ctx.db.select().from(textBlocksTable).where(eq(textBlocksTable["tenantId"], tenantId)),
45
+ );
46
+ return {
47
+ blocks: rows.map((row) => ({
48
+ slug: row.slug,
49
+ lang: row.lang,
50
+ title: row.title,
51
+ body: row.body,
52
+ updatedAt: row.updatedAt,
53
+ })),
54
+ };
55
+ },
56
+ });
@@ -68,7 +68,7 @@ export const setWrite = defineWriteHandler({
68
68
  // Symmetrisch zu seedTextBlock, das TestUsers.systemAdmin (tenantId =
69
69
  // SYSTEM_TENANT) als by verwendet.
70
70
  const executorUser =
71
- override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user;
71
+ override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
72
72
 
73
73
  const existing = await fetchOne<TextBlockRow>(
74
74
  db,