@cosmicdrift/kumiko-samples 0.338.0 → 0.340.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 (147) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +21 -1
  3. package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
  4. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -6
  5. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +2 -6
  6. package/packages/bundled-features/src/auth-email-password/handlers/invite-info.query.ts +3 -5
  7. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -8
  8. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -1
  10. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +35 -3
  11. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +60 -0
  12. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +10 -0
  13. package/packages/bundled-features/src/auth-email-password/web/index.ts +8 -0
  14. package/packages/bundled-features/src/auth-email-password/web/session.tsx +15 -2
  15. package/packages/bundled-features/src/auth-mfa/feature.ts +1 -1
  16. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -7
  17. package/packages/bundled-features/src/auth-mfa/handlers/enable-start-preauth.write.ts +1 -5
  18. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -7
  19. package/packages/bundled-features/src/auth-mfa/mfa-code-verifier.ts +1 -5
  20. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +1 -7
  21. package/packages/bundled-features/src/billing-foundation/changes.json +41 -0
  22. package/packages/bundled-features/src/billing-foundation/constants.ts +11 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +3 -3
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +2 -2
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +48 -2
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +10 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +44 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +32 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +129 -33
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +13 -59
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +20 -21
  33. package/packages/bundled-features/src/billing-foundation/events.ts +18 -0
  34. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +4 -5
  35. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +6 -5
  36. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  37. package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
  38. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +4 -4
  39. package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
  40. package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
  41. package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
  42. package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
  43. package/packages/bundled-features/src/channel-discord/index.ts +2 -0
  44. package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
  45. package/packages/bundled-features/src/channel-slack/index.ts +2 -0
  46. package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
  47. package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
  48. package/packages/bundled-features/src/channel-teams/index.ts +2 -0
  49. package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
  50. package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
  51. package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
  52. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
  53. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
  54. package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
  55. package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
  56. package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  57. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
  58. package/packages/bundled-features/src/data-retention/feature.ts +2 -3
  59. package/packages/bundled-features/src/delivery/changes.json +7 -0
  60. package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
  61. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
  62. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
  63. package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
  64. package/packages/bundled-features/src/delivery/index.ts +15 -0
  65. package/packages/bundled-features/src/delivery/jobs.ts +1 -1
  66. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  67. package/packages/bundled-features/src/delivery/types.ts +8 -3
  68. package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
  69. package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
  70. package/packages/bundled-features/src/form-draft/feature.ts +1 -1
  71. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  72. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
  73. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +14 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
  75. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
  78. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
  80. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
  81. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
  82. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
  83. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
  84. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
  85. package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
  86. package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
  87. package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
  88. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
  89. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  90. package/packages/bundled-features/src/sessions/feature.ts +1 -1
  91. package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
  92. package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
  93. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
  94. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
  95. package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  96. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  97. package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
  98. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
  99. package/packages/bundled-features/src/user/changes.json +13 -6
  100. package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
  101. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -6
  102. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  103. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
  104. package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
  105. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  106. package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
  107. package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
  108. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
  109. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
  110. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  111. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
  112. package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
  113. package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
  114. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
  115. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  116. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
  117. package/packages/bundled-features/src/workflow-runner/changes.json +14 -0
  118. package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
  119. package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
  120. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
  121. package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
  122. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
  123. package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
  124. package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
  125. package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
  126. package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
  127. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
  128. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
  129. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
  130. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
  131. package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
  132. package/samples/apps/use-all-bundled/package.json +1 -0
  133. package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
  134. package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
  135. package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
  136. package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
  137. package/samples/apps/use-all-bundled/tsconfig.json +1 -0
  138. package/samples/recipes/chat-channels/README.md +19 -0
  139. package/samples/recipes/chat-channels/package.json +9 -0
  140. package/samples/recipes/chat-channels/src/feature.ts +40 -0
  141. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  142. package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
  143. package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
  144. package/samples/recipes/workflow-engine/src/workflow-runner.ts +12 -2
  145. package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
  146. package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
  147. package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
@@ -1,4 +1,5 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { NotificationRenderer } from "../delivery/index.js";
2
3
  import {
3
4
  RENDERER_EXTENSION,
4
5
  type RendererContext,
@@ -7,7 +8,7 @@ import {
7
8
  type RenderResponse,
8
9
  } from "../renderer-foundation/index.js";
9
10
  import { resolveNotificationVariables } from "./resolve-variables.js";
10
- import { simpleRenderer } from "./simple-renderer.js";
11
+ import { createSimpleRenderer, type MailBranding, simpleRenderer } from "./simple-renderer.js";
11
12
 
12
13
  // Adapter: simpleRenderer.render hat `Promise<string>`-Signatur (Legacy
13
14
  // NotificationRenderer-Contract), renderer-foundation erwartet
@@ -19,6 +20,7 @@ import { simpleRenderer } from "./simple-renderer.js";
19
20
  export async function adaptToFoundation(
20
21
  req: RenderRequest,
21
22
  ctx: RendererContext,
23
+ renderer: NotificationRenderer = simpleRenderer,
22
24
  ): Promise<RenderResponse> {
23
25
  if (req.kind !== "notification") {
24
26
  // Defensiver Guard — Foundation wählt Plugins nur für matching kinds,
@@ -29,14 +31,22 @@ export async function adaptToFoundation(
29
31
  );
30
32
  }
31
33
  const variables = await resolveNotificationVariables(req, ctx);
32
- const html = await simpleRenderer.render({
34
+ const html = await renderer.render({
33
35
  template: req.payload.template ?? "",
34
36
  variables,
35
37
  });
36
38
  return { kind: "notification", html };
37
39
  }
38
40
 
39
- export function createRendererSimpleFeature(): FeatureDefinition {
41
+ export type RendererSimpleOptions = {
42
+ /** Branding applied to every notification mail, including the framework auth mails. */
43
+ readonly mailBranding?: MailBranding;
44
+ };
45
+
46
+ export function createRendererSimpleFeature(options?: RendererSimpleOptions): FeatureDefinition {
47
+ const renderer = options?.mailBranding
48
+ ? createSimpleRenderer(options.mailBranding)
49
+ : simpleRenderer;
40
50
  return defineFeature("renderer-simple", (r) => {
41
51
  r.describe(
42
52
  'Default renderer plugin for `kind="notification"`: takes a structured `EmailTemplateData` variable map (with `header`, `sections[]` of text/button objects, and optional `footer`; falls back to `title`/`body` if no structured fields are present) and returns rendered HTML with inline CSS. Requires `renderer-foundation`; sufficient for plain notification emails \u2014 swap it for `renderer-mail-html` if you need MJML/Markdown layouts.',
@@ -51,7 +61,7 @@ export function createRendererSimpleFeature(): FeatureDefinition {
51
61
 
52
62
  r.useExtension(RENDERER_EXTENSION, "simple", {
53
63
  kinds: ["notification"] as const,
54
- render: adaptToFoundation,
64
+ render: (req, ctx) => adaptToFoundation(req, ctx, renderer),
55
65
  });
56
66
  });
57
67
  }
@@ -1,2 +1,2 @@
1
- export { createRendererSimpleFeature } from "./feature.js";
2
- export { simpleRenderer } from "./simple-renderer.js";
1
+ export { createRendererSimpleFeature, type RendererSimpleOptions } from "./feature.js";
2
+ export { createSimpleRenderer, type MailBranding, simpleRenderer } from "./simple-renderer.js";
@@ -1,6 +1,62 @@
1
- import { escapeHtml } from "@cosmicdrift/kumiko-headless";
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
2
  import type { NotificationRenderer } from "../delivery/index.js";
3
3
 
4
+ export type MailBranding = {
5
+ /** Shown as text when there is no logo, and as the logo alt text. */
6
+ readonly productName?: string;
7
+ /** Hex color (#rgb, #rrggbb, #rrggbbaa); used for buttons and the header accent. */
8
+ readonly primaryColor?: string;
9
+ /** Absolute http(s) URL; anything else is dropped. */
10
+ readonly logoUrl?: string;
11
+ readonly footerText?: string;
12
+ /** Absolute http(s) URLs only; other entries are dropped. */
13
+ readonly footerLinks?: readonly { readonly label: string; readonly url: string }[];
14
+ };
15
+
16
+ const DEFAULT_PRIMARY_COLOR = "#2563eb";
17
+ const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
18
+
19
+ function isHttpUrl(value: string): boolean {
20
+ try {
21
+ const { protocol } = new URL(value);
22
+ return protocol === "http:" || protocol === "https:";
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function resolvePrimaryColor(branding: MailBranding | undefined): string {
29
+ const color = branding?.primaryColor;
30
+ return color && HEX_COLOR_PATTERN.test(color) ? color : DEFAULT_PRIMARY_COLOR;
31
+ }
32
+
33
+ function renderBrandingHeader(branding: MailBranding | undefined, primaryColor: string): string {
34
+ if (!branding) return "";
35
+ const logoUrl = branding.logoUrl && isHttpUrl(branding.logoUrl) ? branding.logoUrl : undefined;
36
+ if (!logoUrl && !branding.productName) return "";
37
+ const logo = logoUrl
38
+ ? `<img src="${escapeHtmlAttr(logoUrl)}" alt="${escapeHtmlAttr(branding.productName ?? "")}" style="display:block;max-height:40px;max-width:200px;border:0" />`
39
+ : `<span style="font-size:18px;font-weight:700;color:${escapeHtmlAttr(primaryColor)}">${escapeHtml(branding.productName ?? "")}</span>`;
40
+ return `<div style="margin:0 0 24px;padding:0 0 16px;border-bottom:3px solid ${escapeHtmlAttr(primaryColor)}">${logo}</div>`;
41
+ }
42
+
43
+ function renderBrandingFooter(branding: MailBranding | undefined): string {
44
+ if (!branding) return "";
45
+ const links = (branding.footerLinks ?? [])
46
+ .filter((link) => isHttpUrl(link.url))
47
+ .map(
48
+ (link) =>
49
+ `<a href="${escapeHtmlAttr(link.url)}" style="color:#999">${escapeHtml(link.label)}</a>`,
50
+ );
51
+ const footerPartsHtml = [
52
+ branding.footerText ? escapeHtml(branding.footerText) : "",
53
+ links.join(" · "),
54
+ ].filter((part) => part !== "");
55
+ if (footerPartsHtml.length === 0) return "";
56
+ const footerHtml = footerPartsHtml.join("<br />");
57
+ return `<p style="margin:16px 0 0;color:#999;font-size:12px">${footerHtml}</p>`;
58
+ }
59
+
4
60
  type Section =
5
61
  | { readonly text: string }
6
62
  | { readonly button: { readonly label: string; readonly url: string } };
@@ -15,51 +71,58 @@ type EmailTemplateData = {
15
71
  readonly body?: string;
16
72
  };
17
73
 
18
- function renderSection(section: Section): string {
74
+ function renderSection(section: Section, primaryColor: string): string {
19
75
  if ("text" in section) {
20
76
  return `<p style="margin:0 0 16px;color:#333;font-size:14px;line-height:1.5">${escapeHtml(section.text)}</p>`;
21
77
  }
22
78
  if ("button" in section) {
23
- return `<p style="margin:0 0 16px"><a href="${escapeHtml(section.button.url)}" style="display:inline-block;padding:10px 24px;background:#2563eb;color:#fff;text-decoration:none;border-radius:4px;font-size:14px">${escapeHtml(section.button.label)}</a></p>`;
79
+ return `<p style="margin:0 0 16px"><a href="${escapeHtml(section.button.url)}" style="display:inline-block;padding:10px 24px;background:${escapeHtmlAttr(primaryColor)};color:#fff;text-decoration:none;border-radius:4px;font-size:14px">${escapeHtml(section.button.label)}</a></p>`;
24
80
  }
25
81
  return "";
26
82
  }
27
83
 
28
84
  // Simple Renderer: turns structured email template data into HTML with inline CSS.
29
85
  // No external dependencies, no template engine — just string concatenation.
30
- export const simpleRenderer: NotificationRenderer = {
31
- name: "simple",
86
+ export function createSimpleRenderer(branding?: MailBranding): NotificationRenderer {
87
+ const primaryColor = resolvePrimaryColor(branding);
88
+ return {
89
+ name: "simple",
90
+
91
+ async render(input) {
92
+ const data = input.variables as EmailTemplateData; // @cast-boundary render-helper
32
93
 
33
- async render(input) {
34
- const data = input.variables as EmailTemplateData; // @cast-boundary render-helper
94
+ // Fallback: if no structured fields, use title + body as header + single text section
95
+ const header = data.header ?? data.title;
96
+ const sections = data.sections ?? (data.body ? [{ text: data.body }] : undefined);
35
97
 
36
- // Fallback: if no structured fields, use title + body as header + single text section
37
- const header = data.header ?? data.title;
38
- const sections = data.sections ?? (data.body ? [{ text: data.body }] : undefined);
98
+ const parts: string[] = [];
99
+ parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
100
+ parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
101
+ parts.push(renderBrandingHeader(branding, primaryColor));
39
102
 
40
- const parts: string[] = [];
41
- parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
42
- parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
103
+ if (header) {
104
+ parts.push(
105
+ `<h1 style="margin:0 0 24px;color:#111;font-size:20px;font-weight:600">${escapeHtml(header)}</h1>`,
106
+ );
107
+ }
43
108
 
44
- if (header) {
45
- parts.push(
46
- `<h1 style="margin:0 0 24px;color:#111;font-size:20px;font-weight:600">${escapeHtml(header)}</h1>`,
47
- );
48
- }
109
+ if (sections) {
110
+ for (const section of sections) {
111
+ parts.push(renderSection(section, primaryColor));
112
+ }
113
+ }
49
114
 
50
- if (sections) {
51
- for (const section of sections) {
52
- parts.push(renderSection(section));
115
+ if (data.footer) {
116
+ parts.push(
117
+ `<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(data.footer)}</p>`,
118
+ );
53
119
  }
54
- }
55
120
 
56
- if (data.footer) {
57
- parts.push(
58
- `<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(data.footer)}</p>`,
59
- );
60
- }
121
+ parts.push(renderBrandingFooter(branding));
122
+ parts.push("</div></body></html>");
123
+ return parts.join("");
124
+ },
125
+ };
126
+ }
61
127
 
62
- parts.push("</div></body></html>");
63
- return parts.join("");
64
- },
65
- };
128
+ export const simpleRenderer: NotificationRenderer = createSimpleRenderer();
@@ -183,7 +183,7 @@ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureD
183
183
  rotateJob(
184
184
  payload,
185
185
  ctx,
186
- ctx.db.unsafeRaw(ROTATE_ESCAPE_HATCH_REASON) as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
186
+ ctx.db.unsafeRaw() as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
187
187
  ),
188
188
  });
189
189
 
@@ -177,7 +177,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
177
177
  cleanupJob(
178
178
  payload,
179
179
  ctx,
180
- ctx.db.unsafeRaw(CLEANUP_ESCAPE_HATCH_REASON) as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
180
+ ctx.db.unsafeRaw() as DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
181
181
  ),
182
182
  });
183
183
 
@@ -49,7 +49,7 @@ export const revokeAllForUserWrite = defineWriteHandler({
49
49
  reason: REVOKE_ALL_SESSIONS_REASON,
50
50
  },
51
51
  handler: async (event, ctx) => {
52
- const runner = ctx.db.unsafeRaw(REVOKE_ALL_SESSIONS_REASON);
52
+ const runner = ctx.db.unsafeRaw();
53
53
  const updated = await updateMany<{ id: string }>(
54
54
  runner,
55
55
  userSessionTable,
@@ -51,10 +51,7 @@ export function createClaimTenantHandoverHandler(opts: ClaimTenantHandoverOption
51
51
  agent: { expose: false },
52
52
  rateLimit: { per: "user", limit: 10, windowSeconds: 60 },
53
53
  handler: async (event, ctx) => {
54
- const db = ctx.db.unsafeRaw(
55
- "tenant-handover claim: cross-tenant read + ownership write, legitimized by the " +
56
- "row-bound grant redeemed below",
57
- );
54
+ const db = ctx.db.unsafeRaw();
58
55
  const rootLocation = resolveRootAnchorLocation(ctx.registry, db, event.payload.entityType);
59
56
  if (!rootLocation) return notTransferable(event.payload.entityType);
60
57
  const { rootTableName, rootIdCol, rootTenantCol, loadAnchor } = rootLocation;
@@ -126,9 +126,7 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
126
126
  }
127
127
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
128
128
  await runTenantDestructionSweep({
129
- db: ctx.db.unsafeRaw(
130
- RUN_TENANT_DESTRUCTION_REASON,
131
- ) as import("@cosmicdrift/kumiko-framework/db").DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
129
+ db: ctx.db.unsafeRaw() as import("@cosmicdrift/kumiko-framework/db").DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
132
130
  registry: ctx.registry,
133
131
  now: T.Now.instant(),
134
132
  log: (message) => ctx.log?.warn(message),
@@ -30,11 +30,9 @@ export const cancelDestructionWrite = defineWriteHandler({
30
30
  },
31
31
  handler: async (event, ctx) => {
32
32
  const tenantId = event.user.tenantId;
33
- const row = await fetchOne<TenantLifecycleRow>(
34
- ctx.db.unsafeRaw(CANCEL_DESTRUCTION_TENANT_ROW_REASON),
35
- tenantTable,
36
- { id: tenantId },
37
- );
33
+ const row = await fetchOne<TenantLifecycleRow>(ctx.db.unsafeRaw(), tenantTable, {
34
+ id: tenantId,
35
+ });
38
36
  if (!row) {
39
37
  return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
40
38
  }
@@ -34,7 +34,7 @@ export const requestDestructionWrite = defineWriteHandler({
34
34
  },
35
35
  handler: async (event, ctx) => {
36
36
  const tenantId = event.user.tenantId;
37
- const runner = ctx.db.unsafeRaw(REQUEST_DESTRUCTION_TENANT_ROW_REASON);
37
+ const runner = ctx.db.unsafeRaw();
38
38
  const row = await fetchOne<TenantLifecycleRow>(runner, tenantTable, { id: tenantId });
39
39
  if (!row) {
40
40
  return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
@@ -399,7 +399,7 @@ export function createTierEngineFeature<
399
399
  const rawDb = ctx.systemDb
400
400
  ? ctx.systemDb.unsafeRaw(autoDefaultTierHookReason)
401
401
  : ctx.db && "unsafeRaw" in ctx.db
402
- ? ctx.db.unsafeRaw(autoDefaultTierHookReason)
402
+ ? ctx.db.unsafeRaw()
403
403
  : undefined;
404
404
  // skip: defensive — inTransaction phase always sets db, but AppContext's
405
405
  // type makes it optional. Throwing would be overreach (lifecycle
@@ -25,7 +25,7 @@ export const getTenantTierQuery = defineQueryHandler({
25
25
  },
26
26
  handler: async (query, ctx) => {
27
27
  const tenantId = query.payload.tenantId as TenantId; // @cast-boundary engine-bridge
28
- const tdb = createTenantDb(ctx.db.unsafeRaw(GET_TENANT_TIER_REASON), tenantId, "system");
28
+ const tdb = createTenantDb(ctx.db.unsafeRaw(), tenantId, "system");
29
29
  const row = await fetchOne<TierAssignmentRow>(tdb, tierAssignmentTable, { tenantId });
30
30
  return row ?? null;
31
31
  },
@@ -73,7 +73,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
73
73
  },
74
74
  handler: async (event, ctx) => {
75
75
  const tenantId = event.payload.tenantId as TenantId; // @cast-boundary engine-bridge
76
- const rawDb = ctx.db.unsafeRaw(SET_TENANT_TIER_REASON);
76
+ const rawDb = ctx.db.unsafeRaw();
77
77
  const tdb = createTenantDb(rawDb, tenantId, "system");
78
78
  const systemUser = { ...event.user, tenantId };
79
79
  const tier = event.payload.tier;
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.340.0",
4
+ "type": "improvement",
5
+ "title": "The last-SystemAdmin check uses jsonb containment through selectMany",
6
+ "detail": "Removing the SystemAdmin role still refuses to drop the last active SystemAdmin; the count now comes from `selectMany` with a roles containment filter instead of raw SQL.",
7
+ "migration": "keine"
8
+ },
2
9
  {
3
10
  "version": "0.271.0",
4
11
  "type": "improvement",
@@ -8,7 +15,7 @@
8
15
  {
9
16
  "version": "0.218.1",
10
17
  "type": "improvement",
11
- "title": "user.email searchable \u2014 reindex required after upgrade",
18
+ "title": "user.email searchable — reindex required after upgrade",
12
19
  "detail": "email gained searchable for roster lookup. buildSearchDocument only runs on write; existing rows need reindexEntity(\"user\") (or the reindex job) plus Meili configure for the new attribute.",
13
20
  "migration": "After deploying, run reindexEntity(\"user\") (or the existing reindex job) so pre-upgrade user rows appear in roster search."
14
21
  },
@@ -16,28 +23,28 @@
16
23
  "version": "0.218.0",
17
24
  "type": "fix",
18
25
  "title": "user-list: system-scope search; membership roles on tenants; emailVerified sort",
19
- "detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant \u2192 Meili miss/500). Tenants column shows membership roles (Offlot Demo (TenantAdmin)). emailVerified sortable; email also searchable for roster lookup. Global roles stay SystemAdmin-only \u2014 TenantAdmin/Admin live on membership (Team \u2192 Members)."
26
+ "detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant → Meili miss/500). Tenants column shows membership roles (Offlot Demo (TenantAdmin)). emailVerified sortable; email also searchable for roster lookup. Global roles stay SystemAdmin-only — TenantAdmin/Admin live on membership (Team → Members)."
20
27
  },
21
28
  {
22
29
  "version": "0.217.0",
23
30
  "type": "improvement",
24
31
  "title": "user-list: roles multiSelect + tenants column; roles editable as SystemAdmin (fw#2384 follow-up)",
25
- "detail": "user.roles is now createMultiSelectField (jsonb string[], options=[SystemAdmin]) so entityEdit shows a real multi-select instead of a raw JSON text box \u2014 SystemAdmins can promote another SystemAdmin from the UI. user-list columns gain roles + tenants; tenants is a derived field filled by the list handler from memberships (platform roster was unusable without tenant context). switch-tenant + auth-client parseRoles accept jsonb arrays (string-only deserialize dropped SystemAdmin on tenant switch). Soft-deleted users stay hidden by default.",
26
- "migration": "Run `kumiko-schema generate` and apply \u2014 migrates read_users.roles from text to jsonb USING roles::jsonb. Existing JSON array strings (`[]`, `[\"SystemAdmin\"]`) convert cleanly. Consumers that wrote roles as a JSON-encoded string via user:create/update should pass string[] instead (parseRoles still accepts both on read)."
32
+ "detail": "user.roles is now createMultiSelectField (jsonb string[], options=[SystemAdmin]) so entityEdit shows a real multi-select instead of a raw JSON text box — SystemAdmins can promote another SystemAdmin from the UI. user-list columns gain roles + tenants; tenants is a derived field filled by the list handler from memberships (platform roster was unusable without tenant context). switch-tenant + auth-client parseRoles accept jsonb arrays (string-only deserialize dropped SystemAdmin on tenant switch). Soft-deleted users stay hidden by default.",
33
+ "migration": "Run `kumiko-schema generate` and apply — migrates read_users.roles from text to jsonb USING roles::jsonb. Existing JSON array strings (`[]`, `[\"SystemAdmin\"]`) convert cleanly. Consumers that wrote roles as a JSON-encoded string via user:create/update should pass string[] instead (parseRoles still accepts both on read)."
27
34
  },
28
35
  {
29
36
  "version": "0.167.1",
30
37
  "type": "breaking",
31
38
  "title": "user.locale no longer defaults to \"de\" (fw#1637).",
32
39
  "detail": "The entity-level default contradicted tenant-settings' own \"en\" default and silently overrode any app or tenant locale configuration for every new user. locale now stays unset until the client or a resolution chain provides one. Consumers that already fall back with `user.locale ?? \"en\"` are unaffected in shape but now see null instead of \"de\".",
33
- "migration": "Run `kumiko-schema generate` and apply \u2014 the migration emits `ALTER TABLE read_users ALTER COLUMN locale DROP DEFAULT`. Note that DROP DEFAULT only affects future inserts: existing rows keep the \"de\" the old default wrote, which no user ever chose. Your app therefore splits into pre-bump users on \"de\" and post-bump users on null, and each group resolves to a different language. Decide deliberately: either keep the old values (and state that pre-bump users stay German), or run `UPDATE read_users SET locale = NULL WHERE locale = 'de'` once so every user follows the same chain \u2014 the latter only if no UI ever let users edit the field, otherwise it erases real choices. If your app relied on the implicit German default, set an explicit fallback instead (fw#1653)."
40
+ "migration": "Run `kumiko-schema generate` and apply — the migration emits `ALTER TABLE read_users ALTER COLUMN locale DROP DEFAULT`. Note that DROP DEFAULT only affects future inserts: existing rows keep the \"de\" the old default wrote, which no user ever chose. Your app therefore splits into pre-bump users on \"de\" and post-bump users on null, and each group resolves to a different language. Decide deliberately: either keep the old values (and state that pre-bump users stay German), or run `UPDATE read_users SET locale = NULL WHERE locale = 'de'` once so every user follows the same chain — the latter only if no UI ever let users edit the field, otherwise it erases real choices. If your app relied on the implicit German default, set an explicit fallback instead (fw#1653)."
34
41
  },
35
42
  {
36
43
  "version": "0.167.1",
37
44
  "type": "improvement",
38
45
  "title": "user.timezone field, wired into ctx.tz (fw#1636).",
39
46
  "detail": "The user entity gains a `timezone` field (createTzField, no default). buildHandlerContext passes it to ctx.tz.user; the fallback chain stays tenant.timezone, then \"UTC\". Before this, ctx.tz.tenant and ctx.tz.user both hardcoded \"UTC\" while the plumbing already accepted per-request values.",
40
- "migration": "Run `kumiko-schema generate` and apply \u2014 the migration adds `read_users.timezone`. No backfill: an unset timezone falls through to the tenant's, exactly as before the field existed."
47
+ "migration": "Run `kumiko-schema generate` and apply — the migration adds `read_users.timezone`. No backfill: an unset timezone falls through to the tenant's, exactly as before the field existed."
41
48
  },
42
49
  {
43
50
  "version": "0.165.3",
@@ -1,4 +1,4 @@
1
- import { asEntityTableMeta, asRawClient, fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
3
  acquireNamespacedAdvisoryLock,
4
4
  createEventStoreExecutor,
@@ -40,10 +40,6 @@ type UserRolesRow = {
40
40
  isDeleted?: boolean;
41
41
  };
42
42
 
43
- function quoteIdent(name: string): string {
44
- return `"${name.replace(/"/g, '""')}"`;
45
- }
46
-
47
43
  function isActiveUserRow(row: UserRolesRow): boolean {
48
44
  if (row.isDeleted === true) return false;
49
45
  return row.status === USER_STATUS.Active || !row.status;
@@ -54,21 +50,11 @@ function isActiveSystemAdminRow(row: UserRolesRow): boolean {
54
50
  }
55
51
 
56
52
  async function countOtherActiveSystemAdmins(db: DbRunner, excludeUserId: string): Promise<number> {
57
- const tableName = asEntityTableMeta(userTable)?.tableName;
58
- if (!tableName) {
59
- throw new InternalError({ message: "user read table meta missing" });
60
- }
61
- // kumiko-lint-ignore raw-sql jsonb @> prefilter for SystemAdmin roster under advisory lock
62
- const rows = (await asRawClient(db).unsafe(
63
- `SELECT id, roles, status, is_deleted AS "isDeleted"
64
- FROM ${quoteIdent(tableName)}
65
- WHERE is_deleted = false
66
- AND (status = $1 OR status IS NULL)
67
- AND roles @> $2::jsonb`,
68
- // Pass a JS array — JSON.stringify + $n::jsonb double-encodes to a jsonb string
69
- // (postgres.js), so `@>` never matches a roles array.
70
- [USER_STATUS.Active, ["SystemAdmin"]],
71
- )) as UserRolesRow[];
53
+ // `roles` is a jsonb multiSelect column: a scalar filter value means `@>` containment.
54
+ const rows = await selectMany<UserRolesRow>(db, userTable, {
55
+ isDeleted: false,
56
+ roles: "SystemAdmin",
57
+ });
72
58
  return rows.filter((u) => u.id !== excludeUserId && isActiveSystemAdminRow(u)).length;
73
59
  }
74
60
 
@@ -138,9 +124,7 @@ export async function applyUserRolesUpdate(
138
124
  // observe otherActiveSystemAdmins >= 1 and leave zero active SystemAdmins.
139
125
  await acquireNamespacedAdvisoryLock(lockRunner, LAST_SYSTEM_ADMIN_LOCK_NAMESPACE, "global");
140
126
  const otherActiveSystemAdmins = await countOtherActiveSystemAdmins(
141
- ctx.systemDb.unsafeRaw(
142
- "jsonb @> prefilter over the global users table to count remaining active SystemAdmins",
143
- ),
127
+ ctx.systemDb.unsafeRaw("count remaining active SystemAdmins across the global users table"),
144
128
  event.payload.id,
145
129
  );
146
130
  if (otherActiveSystemAdmins === 0) {
@@ -588,9 +588,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
588
588
  // Discriminator wird via handlerName="user-data-rights:run-export-
589
589
  // jobs" im Secret-Read-Audit erfasst.
590
590
  const exportUserId = ctx._userId ?? SYSTEM_USER_ID;
591
- const exportDb = ctx.db.unsafeRaw(
592
- RUN_EXPORT_JOBS_REASON,
593
- ) as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
591
+ const exportDb =
592
+ ctx.db.unsafeRaw() as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
594
593
  const exportRegistry = ctx.registry;
595
594
  // JobContext carries ctx.meter (raw), not ctx.metrics — that bound
596
595
  // handle only exists on HandlerContext (built per write/query call
@@ -684,9 +683,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
684
683
  }
685
684
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
686
685
  const forgetUserId = ctx._userId ?? SYSTEM_USER_ID;
687
- const forgetDb = ctx.db.unsafeRaw(
688
- RUN_FORGET_CLEANUP_REASON,
689
- ) as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
686
+ const forgetDb =
687
+ ctx.db.unsafeRaw() as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
690
688
  const forgetRegistry = ctx.registry;
691
689
  const tenantModel = await resolveAppTenantModel({
692
690
  registry: forgetRegistry,
@@ -61,7 +61,7 @@ export const cancelDeletionWrite = defineWriteHandler({
61
61
  return writeFailure(new UnprocessableError("grace_period_expired"));
62
62
  }
63
63
 
64
- await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), event.user.id, {
64
+ await updateUserLifecycle(ctx.db.unsafeRaw(), event.user.id, {
65
65
  status: USER_STATUS.Active,
66
66
  gracePeriodEnd: null,
67
67
  // #354/1: closes the replay-after-cancel window — a still-TTL-valid email
@@ -83,9 +83,7 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
83
83
  ctx,
84
84
  userId,
85
85
  () => resolveGracePeriod(ctx, event.user.tenantId),
86
- ctx.db.unsafeRaw(
87
- "appends the user lifecycle event on the SYSTEM_TENANT_ID user stream",
88
- ),
86
+ ctx.db.unsafeRaw(),
89
87
  { pendingDeletionRequestId: requestId },
90
88
  );
91
89
  if (!res.ok) return false;
@@ -87,7 +87,7 @@ export const downloadByJobQuery = defineQueryHandler({
87
87
  // Wert (603/2).
88
88
  const auditIp = requestContext.get()?.ip ?? null;
89
89
  const auditUa = requestContext.get()?.userAgent ?? null;
90
- const runner = ctx.db.unsafeRaw(DOWNLOAD_BY_JOB_REASON);
90
+ const runner = ctx.db.unsafeRaw();
91
91
 
92
92
  // Step 1-2: job-lookup + cross-user-isolation
93
93
  const jobRow = await fetchOne<JobRow>(runner, exportJobsTable, { id: jobId });
@@ -92,7 +92,7 @@ export const downloadByTokenQuery = defineQueryHandler({
92
92
  handler: async (query, ctx) => {
93
93
  const T = getTemporal();
94
94
  const now = T.Now.instant();
95
- const runner = ctx.db.unsafeRaw(DOWNLOAD_BY_TOKEN_REASON);
95
+ const runner = ctx.db.unsafeRaw();
96
96
 
97
97
  // Step 1: hash + lookup
98
98
  const hash = await hashDownloadToken(query.payload.token);
@@ -50,7 +50,7 @@ export const exportStatusQuery = defineQueryHandler({
50
50
  },
51
51
  handler: async (query, ctx) => {
52
52
  const rows = await selectMany<ExportJobRow>(
53
- ctx.db.unsafeRaw(EXPORT_STATUS_REASON),
53
+ ctx.db.unsafeRaw(),
54
54
  exportJobsTable,
55
55
  { userId: query.user.id },
56
56
  { limit: 1, orderBy: { col: "requestedAt", direction: "desc" } },
@@ -7,10 +7,6 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle.js";
7
7
 
8
8
  const LIFT_RESTRICTION_ESCAPE_HATCH_REASON =
9
9
  "checks the target user's membership in the admin's tenant and appends the user lifecycle status change on the SYSTEM_TENANT_ID user stream, both via DbRunner helpers outside the admin's own tenant scope.";
10
- const CHECK_TARGET_MEMBERSHIP_REASON =
11
- "checks the target user's membership in the admin's tenant via the DbRunner helper";
12
- const APPEND_LIFECYCLE_EVENT_REASON =
13
- "appends the user lifecycle event on the SYSTEM_TENANT_ID user stream";
14
10
 
15
11
  // POST /api/user/lift-restriction (S2.U6) — DSGVO Art. 18 Reverse.
16
12
  //
@@ -46,7 +42,7 @@ export const liftRestrictionWrite = defineWriteHandler({
46
42
  const targetUserId = event.payload.userId;
47
43
 
48
44
  const outside = await denyIfTargetOutsideAdminTenant(
49
- ctx.db.unsafeRaw(CHECK_TARGET_MEMBERSHIP_REASON),
45
+ ctx.db.unsafeRaw(),
50
46
  event.user,
51
47
  targetUserId,
52
48
  );
@@ -73,7 +69,7 @@ export const liftRestrictionWrite = defineWriteHandler({
73
69
  );
74
70
  }
75
71
 
76
- await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), targetUserId, {
72
+ await updateUserLifecycle(ctx.db.unsafeRaw(), targetUserId, {
77
73
  status: USER_STATUS.Active,
78
74
  });
79
75
 
@@ -64,7 +64,7 @@ export const myAuditLogQuery = defineQueryHandler({
64
64
  type: string;
65
65
  payload: Record<string, unknown>;
66
66
  created_at: unknown;
67
- }>(ctx.db.unsafeRaw(MY_AUDIT_LOG_REASON), eventsTable, where, {
67
+ }>(ctx.db.unsafeRaw(), eventsTable, where, {
68
68
  orderBy: { col: "id", direction: "desc" },
69
69
  limit: p.limit,
70
70
  });
@@ -92,7 +92,7 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
92
92
  // user-Row landet und in die Token-HMAC-Purpose gefaltet wird. cancel
93
93
  // nullt sie → ein nach Cancel nachgespieltes Token verifiziert nicht mehr.
94
94
  const requestId = crypto.randomUUID();
95
- await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), userRow["id"], {
95
+ await updateUserLifecycle(ctx.db.unsafeRaw(), userRow["id"], {
96
96
  pendingDeletionRequestId: requestId,
97
97
  });
98
98
 
@@ -65,7 +65,7 @@ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {})
65
65
  ctx,
66
66
  event.user.id,
67
67
  () => resolveGracePeriod(ctx, event.user.tenantId),
68
- ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
68
+ ctx.db.unsafeRaw(),
69
69
  );
70
70
  if (!res.ok) return writeFailure(res.error);
71
71
  const { gracePeriodEnd, userEmail, userLocale } = res;
@@ -77,7 +77,7 @@ export const requestExportWrite = defineWriteHandler({
77
77
  const userId = event.user.id;
78
78
  const T = getTemporal();
79
79
  const now = T.Now.instant();
80
- const exportJobRunner = ctx.db.unsafeRaw(REQUEST_EXPORT_REASON);
80
+ const exportJobRunner = ctx.db.unsafeRaw();
81
81
 
82
82
  // Pre-Check: exportJobRunner bypasses the TenantDb-Filter — the
83
83
  // TenantDb-Wrapper would hide cross-tenant jobs.
@@ -70,9 +70,7 @@ export const restrictAccountWrite = defineWriteHandler({
70
70
  );
71
71
  }
72
72
  const outside = await denyIfTargetOutsideAdminTenant(
73
- ctx.db.unsafeRaw(
74
- "checks the target user's membership in the admin's tenant via the DbRunner helper",
75
- ),
73
+ ctx.db.unsafeRaw(),
76
74
  event.user,
77
75
  targetUserId,
78
76
  );
@@ -107,11 +105,7 @@ export const restrictAccountWrite = defineWriteHandler({
107
105
  );
108
106
  }
109
107
 
110
- await updateUserLifecycle(
111
- ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
112
- targetUserId,
113
- { status: USER_STATUS.Restricted },
114
- );
108
+ await updateUserLifecycle(ctx.db.unsafeRaw(), targetUserId, { status: USER_STATUS.Restricted });
115
109
 
116
110
  // Cross-Feature: alle live sessions revoken — sonst koennte der User
117
111
  // mit existierendem JWT bis zur Token-Expiry weiter schreiben.
@@ -48,7 +48,7 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
48
48
  }
49
49
 
50
50
  const T = getTemporal();
51
- const runner = ctx.db.unsafeRaw(RUN_FORGET_CLEANUP_REASON);
51
+ const runner = ctx.db.unsafeRaw();
52
52
  // Operator-triggered forget must also erase binaries, not just rows —
53
53
  // it flips users to Deleted, after which the cron never re-processes
54
54
  // them, so a row-only delete here would permanently leak the binaries.