@cosmicdrift/kumiko-bundled-features 0.340.0 → 0.342.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 (174) hide show
  1. package/dist/audit/constants.d.ts +3 -0
  2. package/dist/audit/constants.js +3 -0
  3. package/dist/audit/feature.js +34 -7
  4. package/dist/audit/run-escape-hatch-retention.d.ts +10 -0
  5. package/dist/audit/run-escape-hatch-retention.js +27 -0
  6. package/dist/auth-email-password/bootstrap.d.ts +80 -0
  7. package/dist/auth-email-password/bootstrap.js +170 -0
  8. package/dist/auth-email-password/constants.d.ts +2 -0
  9. package/dist/auth-email-password/constants.js +5 -0
  10. package/dist/auth-email-password/errors.d.ts +1 -0
  11. package/dist/auth-email-password/errors.js +6 -0
  12. package/dist/auth-email-password/feature.js +2 -1
  13. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
  14. package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
  15. package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
  16. package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
  17. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
  18. package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
  19. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  20. package/dist/auth-email-password/index.d.ts +2 -0
  21. package/dist/auth-email-password/index.js +1 -0
  22. package/dist/auth-email-password/invite-token-store.d.ts +3 -0
  23. package/dist/auth-email-password/invite-token-store.js +5 -0
  24. package/dist/auth-email-password/magic-link-mail.js +1 -0
  25. package/dist/auth-email-password/web/auth-form-primitives.d.ts +7 -1
  26. package/dist/auth-email-password/web/auth-form-primitives.js +5 -5
  27. package/dist/auth-email-password/web/confirm-account-unlock-screen.js +3 -3
  28. package/dist/auth-email-password/web/forgot-password-screen.js +2 -2
  29. package/dist/auth-email-password/web/invite-accept-screen.js +9 -9
  30. package/dist/auth-email-password/web/login-screen.js +8 -8
  31. package/dist/auth-email-password/web/request-account-unlock-screen.js +2 -2
  32. package/dist/auth-email-password/web/reset-password-screen.js +3 -3
  33. package/dist/auth-email-password/web/session-bootstrap-error.js +3 -3
  34. package/dist/auth-email-password/web/signup-complete-screen.js +3 -3
  35. package/dist/auth-email-password/web/signup-screen.js +2 -2
  36. package/dist/auth-email-password/web/verify-email-screen.js +3 -3
  37. package/dist/auth-mfa/config.d.ts +1 -1
  38. package/dist/auth-mfa/config.js +8 -3
  39. package/dist/auth-mfa/feature.d.ts +2 -0
  40. package/dist/auth-mfa/feature.js +2 -1
  41. package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
  42. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  43. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +5 -5
  44. package/dist/auth-mfa/web/mfa-verify-screen.js +3 -3
  45. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
  46. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
  47. package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
  48. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
  49. package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
  50. package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
  51. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
  52. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
  53. package/dist/channel-discord/discord-channel.d.ts +4 -0
  54. package/dist/channel-discord/discord-channel.js +4 -0
  55. package/dist/channel-discord/feature.js +7 -2
  56. package/dist/channel-discord/index.d.ts +1 -1
  57. package/dist/channel-discord/index.js +1 -1
  58. package/dist/channel-email/email-channel.js +8 -1
  59. package/dist/channel-email/pii-guard.js +6 -5
  60. package/dist/channel-email/smtp-transport.js +22 -1
  61. package/dist/channel-email/types.d.ts +1 -0
  62. package/dist/channel-slack/feature.js +4 -2
  63. package/dist/channel-slack/index.d.ts +1 -1
  64. package/dist/channel-slack/index.js +1 -1
  65. package/dist/channel-slack/slack-channel.d.ts +4 -0
  66. package/dist/channel-slack/slack-channel.js +4 -0
  67. package/dist/channel-teams/feature.js +4 -2
  68. package/dist/channel-teams/index.d.ts +1 -1
  69. package/dist/channel-teams/index.js +1 -1
  70. package/dist/channel-teams/teams-channel.d.ts +4 -0
  71. package/dist/channel-teams/teams-channel.js +4 -0
  72. package/dist/channel-telegram/feature.js +4 -2
  73. package/dist/channel-telegram/index.d.ts +1 -1
  74. package/dist/channel-telegram/index.js +1 -1
  75. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  76. package/dist/channel-telegram/telegram-channel.js +28 -12
  77. package/dist/delivery/chat-webhook-channel.d.ts +5 -1
  78. package/dist/delivery/chat-webhook-channel.js +10 -5
  79. package/dist/delivery/chat-webhook-sender.d.ts +6 -2
  80. package/dist/delivery/chat-webhook-sender.js +13 -8
  81. package/dist/delivery/delivery-service.d.ts +4 -3
  82. package/dist/delivery/delivery-service.js +53 -33
  83. package/dist/delivery/index.d.ts +2 -2
  84. package/dist/delivery/index.js +2 -2
  85. package/dist/delivery/jobs.js +3 -1
  86. package/dist/delivery/types.d.ts +5 -3
  87. package/dist/legal-pages/feature.d.ts +3 -15
  88. package/dist/legal-pages/feature.js +20 -50
  89. package/dist/legal-pages/lib/boot-check.d.ts +10 -0
  90. package/dist/legal-pages/lib/boot-check.js +33 -0
  91. package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
  92. package/dist/legal-pages/lib/validate-routes.js +17 -0
  93. package/dist/page-render/index.d.ts +2 -1
  94. package/dist/page-render/index.js +1 -0
  95. package/dist/page-render/layout.d.ts +7 -0
  96. package/dist/page-render/layout.js +5 -0
  97. package/dist/page-render/trailing-slash.d.ts +2 -0
  98. package/dist/page-render/trailing-slash.js +15 -0
  99. package/dist/renderer-simple/feature.js +1 -0
  100. package/dist/renderer-simple/index.d.ts +1 -1
  101. package/dist/renderer-simple/index.js +1 -1
  102. package/dist/renderer-simple/simple-renderer.d.ts +12 -5
  103. package/dist/renderer-simple/simple-renderer.js +66 -12
  104. package/dist/secrets/announce-secret-change.d.ts +8 -0
  105. package/dist/secrets/announce-secret-change.js +18 -0
  106. package/dist/secrets/feature.js +1 -1
  107. package/dist/secrets/handlers/delete.write.js +2 -0
  108. package/dist/secrets/handlers/set.write.js +6 -1
  109. package/dist/secrets/i18n.d.ts +4 -0
  110. package/dist/secrets/i18n.js +4 -0
  111. package/dist/secrets/secrets-context.d.ts +2 -0
  112. package/dist/secrets/secrets-context.js +3 -0
  113. package/dist/secrets/write-gate.d.ts +5 -1
  114. package/dist/secrets/write-gate.js +24 -1
  115. package/dist/tenant/constants.d.ts +1 -0
  116. package/dist/tenant/constants.js +1 -0
  117. package/dist/tenant/handlers/add-member.write.js +14 -3
  118. package/dist/tenant/handlers/invitations.query.d.ts +8 -0
  119. package/dist/tenant/handlers/invitations.query.js +15 -1
  120. package/dist/tenant/index.d.ts +1 -0
  121. package/dist/tenant/index.js +1 -0
  122. package/dist/tenant/invitation-table.d.ts +2 -0
  123. package/dist/tenant/invitation-table.js +10 -1
  124. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
  125. package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
  126. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  127. package/dist/tier-engine/feature.js +83 -14
  128. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
  129. package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
  130. package/dist/user/invited-global-roles.d.ts +14 -0
  131. package/dist/user/invited-global-roles.js +35 -0
  132. package/dist/user-data-rights/feature.js +3 -0
  133. package/dist/waitlist/constants.d.ts +33 -0
  134. package/dist/waitlist/constants.js +35 -0
  135. package/dist/waitlist/entity.d.ts +433 -0
  136. package/dist/waitlist/entity.js +64 -0
  137. package/dist/waitlist/feature.d.ts +3 -0
  138. package/dist/waitlist/feature.js +38 -0
  139. package/dist/waitlist/handlers/invite.write.d.ts +9 -0
  140. package/dist/waitlist/handlers/invite.write.js +119 -0
  141. package/dist/waitlist/handlers/list.query.d.ts +1 -0
  142. package/dist/waitlist/handlers/list.query.js +10 -0
  143. package/dist/waitlist/handlers/reject.write.d.ts +7 -0
  144. package/dist/waitlist/handlers/reject.write.js +80 -0
  145. package/dist/waitlist/handlers/submit.write.d.ts +13 -0
  146. package/dist/waitlist/handlers/submit.write.js +112 -0
  147. package/dist/waitlist/i18n.d.ts +6 -0
  148. package/dist/waitlist/i18n.js +31 -0
  149. package/dist/waitlist/index.d.ts +5 -0
  150. package/dist/waitlist/index.js +4 -0
  151. package/dist/waitlist/lib.d.ts +10 -0
  152. package/dist/waitlist/lib.js +24 -0
  153. package/dist/waitlist/mail.d.ts +15 -0
  154. package/dist/waitlist/mail.js +73 -0
  155. package/dist/waitlist/options.d.ts +30 -0
  156. package/dist/waitlist/options.js +8 -0
  157. package/dist/waitlist/payloads.d.ts +14 -0
  158. package/dist/waitlist/payloads.js +47 -0
  159. package/dist/waitlist/screens.d.ts +2 -0
  160. package/dist/waitlist/screens.js +42 -0
  161. package/dist/waitlist/user-data-hooks.d.ts +3 -0
  162. package/dist/waitlist/user-data-hooks.js +71 -0
  163. package/package.json +17 -9
  164. package/src/auth-email-password/changes.json +30 -0
  165. package/src/auth-mfa/changes.json +6 -0
  166. package/src/billing-foundation/changes.json +6 -0
  167. package/src/channel-email/changes.json +6 -0
  168. package/src/channel-telegram/changes.json +9 -0
  169. package/src/delivery/changes.json +7 -0
  170. package/src/files/changes.json +13 -1
  171. package/src/renderer-simple/changes.json +6 -0
  172. package/src/secrets/changes.json +12 -0
  173. package/src/tenant/changes.json +6 -0
  174. package/src/waitlist/changes.json +23 -0
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
3
  // SignupScreen — Magic-Link-Self-Signup, Step 1.
4
4
  //
@@ -67,5 +67,5 @@ export function SignupScreen({ title, subtitle, loginHref = "/login", }) {
67
67
  }
68
68
  };
69
69
  const effectiveTitle = title ?? t("auth.signup.title");
70
- return (_jsx(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: done ? (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.signup.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.signup.successBody") })] }), _jsx(Button, { variant: "secondary", onClick: onResend, disabled: submitting, children: t("auth.signup.resend") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.signup.haveAccount") })] })) : (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signup.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "signup-email", label: t("auth.signup.email"), required: true, children: _jsx(Input, { kind: "email", id: "signup-email", name: "signup-email", value: email, onChange: setEmail, disabled: submitting, required: true, autoComplete: "email" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.signup.submitting") : t("auth.signup.submit") })] }), _jsx(Link, { href: loginHref, variant: "muted", className: "self-center", children: t("auth.signup.haveAccount") })] })) }));
70
+ return (_jsx(AuthCard, { title: effectiveTitle, subtitle: subtitle, children: done ? (_jsxs(_Fragment, { children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.signup.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.signup.successBody") })] }), _jsx(Button, { variant: "secondary", onClick: onResend, disabled: submitting, children: t("auth.signup.resend") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.signup.haveAccount") })] })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.signup.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "signup-email", label: t("auth.signup.email"), required: true, children: _jsx(Input, { kind: "email", id: "signup-email", name: "signup-email", value: email, onChange: setEmail, disabled: submitting, required: true, autoComplete: "email" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.signup.submitting") : t("auth.signup.submit") })] }), _jsx(Link, { href: loginHref, variant: "muted", className: "self-center", children: t("auth.signup.haveAccount") })] })) }));
71
71
  }
@@ -33,14 +33,14 @@ export function VerifyEmailScreen({ title, token: tokenProp, loginHref = "/login
33
33
  })();
34
34
  }, [status, token]);
35
35
  if (status === "missing-token") {
36
- return (_jsx(AuthCard, { title: title ?? t("auth.verifyEmail.errorTitle"), children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.verifyEmail.goToLogin") })] }) }));
36
+ return (_jsxs(AuthCard, { title: title ?? t("auth.verifyEmail.errorTitle"), children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.verifyEmail.goToLogin") })] }));
37
37
  }
38
38
  if (status === "verifying") {
39
39
  return (_jsx(AuthCard, { children: _jsx("div", { className: "p-6", children: _jsx("p", { className: "text-sm text-muted-foreground", role: "status", children: t("auth.verifyEmail.verifying") }) }) }));
40
40
  }
41
41
  if (status === "success") {
42
- return (_jsx(AuthCard, { title: title ?? t("auth.verifyEmail.successTitle"), children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.successBody") }), _jsx(Link, { href: loginHref, variant: "button", children: t("auth.verifyEmail.goToLogin") })] }) }));
42
+ return (_jsxs(AuthCard, { title: title ?? t("auth.verifyEmail.successTitle"), children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.successBody") }), _jsx(Link, { href: loginHref, variant: "button", children: t("auth.verifyEmail.goToLogin") })] }));
43
43
  }
44
44
  // status === "error"
45
- return (_jsx(AuthCard, { title: title ?? t("auth.verifyEmail.errorTitle"), children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.errorBody") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.verifyEmail.goToLogin") })] }) }));
45
+ return (_jsxs(AuthCard, { title: title ?? t("auth.verifyEmail.errorTitle"), children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.verifyEmail.errorBody") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.verifyEmail.goToLogin") })] }));
46
46
  }
@@ -2,4 +2,4 @@ import { type ConfigKeyHandle } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare const MFA_REQUIRED_POLICIES: readonly ["optional", "admins", "all"];
3
3
  export type MfaRequiredPolicy = (typeof MFA_REQUIRED_POLICIES)[number];
4
4
  export declare const mfaRequiredConfigHandle: ConfigKeyHandle<"select">;
5
- export declare function mfaRequiredConfigKey(): import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"select">;
5
+ export declare function mfaRequiredConfigKey(defaultPolicy?: MfaRequiredPolicy): import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"select">;
@@ -8,15 +8,20 @@
8
8
  // UI). Flipping this away from "optional" before PR3 locks out every
9
9
  // unenrolled matching user with no recovery path. Fine for a tenant that
10
10
  // enrolls its admins out-of-band first; a footgun otherwise.
11
- import { createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { access, createTenantConfig, } from "@cosmicdrift/kumiko-framework/engine";
12
12
  export const MFA_REQUIRED_POLICIES = ["optional", "admins", "all"];
13
13
  export const mfaRequiredConfigHandle = {
14
14
  name: "auth-mfa:config:required",
15
15
  type: "select",
16
16
  };
17
- export function mfaRequiredConfigKey() {
17
+ // Write access is machine-only: a TenantAdmin must not be able to relax the
18
+ // policy back to "optional". The app-wide default comes from
19
+ // `createAuthMfaFeature({ requiredPolicy })`, per-tenant overrides are set by
20
+ // system writes (runBootstrap `tenants[].config`, jobs, SystemAdmin tooling).
21
+ export function mfaRequiredConfigKey(defaultPolicy = "optional") {
18
22
  return createTenantConfig("select", {
19
- default: "optional",
23
+ default: defaultPolicy,
20
24
  options: [...MFA_REQUIRED_POLICIES],
25
+ write: access.system,
21
26
  });
22
27
  }
@@ -1,10 +1,12 @@
1
1
  import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type MfaRequiredPolicy } from "./config.js";
2
3
  import { type MfaCodeVerifier } from "./mfa-code-verifier.js";
3
4
  import { type MfaStatusChecker } from "./mfa-status-checker.js";
4
5
  export type AuthMfaFeatureOptions = {
5
6
  readonly setupTokenSecret: string;
6
7
  readonly issuer: string;
7
8
  readonly challengeTokenSecret: string;
9
+ readonly requiredPolicy?: MfaRequiredPolicy;
8
10
  };
9
11
  export type BindMfaRevokeAllOtherSessions = (revoker: (userId: string, currentSid: string | undefined) => Promise<number>) => void;
10
12
  export declare function bindMfaRevokeAllOtherSessionsFromFeature(feature: FeatureDefinition): BindMfaRevokeAllOtherSessions | undefined;
@@ -87,7 +87,7 @@ export function createAuthMfaFeature(opts) {
87
87
  // the app boots clean and the first MFA login dies at runtime on an
88
88
  // unknown handler instead of failing at mount time.
89
89
  r.requires("tenant");
90
- r.config("required", mfaRequiredConfigKey());
90
+ r.config("required", mfaRequiredConfigKey(opts.requiredPolicy));
91
91
  // Declarative secretMint screen — mint (no input) -> reveal QR/secret/
92
92
  // recovery-codes -> confirm with a code. App places it via r.nav in its
93
93
  // logged-in settings area (see personal-access-tokens/feature.ts for the
@@ -154,6 +154,7 @@ export function createAuthMfaFeature(opts) {
154
154
  // at app-boot time.
155
155
  const checkMfaStatus = createMfaStatusChecker({
156
156
  challengeTokenSecret: opts.challengeTokenSecret,
157
+ ...(opts.requiredPolicy && { requiredPolicy: opts.requiredPolicy }),
157
158
  });
158
159
  // No late-bind needed — same reasoning as checkMfaStatus: only needs the
159
160
  // HandlerContext the caller already has.
@@ -1,4 +1,5 @@
1
1
  import { type HandlerContext, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type MfaRequiredPolicy } from "./config.js";
2
3
  export type MfaStatusCheckResult = {
3
4
  readonly required: false;
4
5
  } | {
@@ -11,4 +12,5 @@ export type MfaStatusCheckResult = {
11
12
  export type MfaStatusChecker = (ctx: HandlerContext, userId: string, tenantId: TenantId, roles: readonly string[]) => Promise<MfaStatusCheckResult>;
12
13
  export declare function createMfaStatusChecker(opts: {
13
14
  readonly challengeTokenSecret: string;
15
+ readonly requiredPolicy?: MfaRequiredPolicy;
14
16
  }): MfaStatusChecker;
@@ -26,7 +26,7 @@ export function createMfaStatusChecker(opts) {
26
26
  // user's tenant needs.
27
27
  // @cast-boundary engine-payload — "select" config values are validated
28
28
  // against the declared `options` at write-time by the config feature.
29
- const policy = ((await ctx.configResolver?.get(mfaRequiredConfigHandle.name, mfaRequiredConfigKey(), tenantId, userId, scopedDb)) ?? "optional");
29
+ const policy = ((await ctx.configResolver?.get(mfaRequiredConfigHandle.name, mfaRequiredConfigKey(opts.requiredPolicy), tenantId, userId, scopedDb)) ?? "optional");
30
30
  if (policy === "optional")
31
31
  return { required: false };
32
32
  const buildSetupToken = () => signMfaPreauthSetupToken({ userId, tenantId }, MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES, opts.challengeTokenSecret).token;
@@ -127,9 +127,9 @@ export function MfaSetupPreauthScreen({ preauthSetupToken, accountLabel, title,
127
127
  e?.preventDefault();
128
128
  void confirmSetup();
129
129
  };
130
- return (_jsx(AuthCard, { title: title ?? t("auth.mfa.setup.title"), subtitle: subtitle ?? t("auth.mfa.setup.subtitle"), children: _jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [error !== null ? (_jsx(Banner, { variant: "error", children: error === "too_many_attempts" && retryAfterSeconds !== null
131
- ? t("auth.mfa.errors.tooManyAttemptsWithSeconds", { seconds: retryAfterSeconds })
132
- : t(reasonToKey(error)) })) : null, !setup && (_jsx(Section, { testId: "mfa-setup-preauth-intro", actions: _jsx(Button, { variant: "primary", onClick: () => void startSetup(), loading: busy, disabled: busy, children: t("auth.mfa.setup.start") }), children: _jsx("span", { className: "text-sm text-muted-foreground", children: t("auth.mfa.setup.intro") }) })), setup && (_jsx(Form, { onSubmit: onSubmit, children: _jsxs(Section, { testId: "mfa-setup-preauth-setup", actions: _jsx(Button, { type: "submit", variant: "primary", loading: busy, disabled: busy || !acknowledged || code.length !== 6, children: t("auth.mfa.setup.confirm") }), children: [_jsxs("div", { className: "flex flex-col items-center gap-2 text-center", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.scanTitle") }), _jsx("div", { className: "h-40 w-40",
133
- // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
134
- dangerouslySetInnerHTML: { __html: setup.qrSvg } }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.manualEntry") }), _jsx("code", { className: "inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.secretParam })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.recoveryTitle") }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.recoveryHint") }), _jsx("code", { className: "block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.recoveryCodes.join("\n") }), _jsx(Field, { id: "mfa-setup-preauth-ack", label: t("auth.mfa.enable.acknowledge"), children: _jsx(Input, { kind: "boolean", id: "mfa-setup-preauth-ack", name: "mfa-setup-preauth-ack", value: acknowledged, onChange: setAcknowledged }) })] }), _jsx(Field, { id: "mfa-setup-preauth-code", label: t("auth.mfa.enable.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-setup-preauth-code", name: "mfa-setup-preauth-code", value: code, onChange: setCode, disabled: busy || !acknowledged, autoComplete: "one-time-code" }) })] }) })), onCancel ? (_jsx("div", { className: "flex justify-center", children: _jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: busy, children: t("auth.mfa.verify.backToLogin") }) })) : null] }) }));
130
+ return (_jsxs(AuthCard, { title: title ?? t("auth.mfa.setup.title"), subtitle: subtitle ?? t("auth.mfa.setup.subtitle"), children: [error !== null ? (_jsx(Banner, { variant: "error", children: error === "too_many_attempts" && retryAfterSeconds !== null
131
+ ? t("auth.mfa.errors.tooManyAttemptsWithSeconds", { seconds: retryAfterSeconds })
132
+ : t(reasonToKey(error)) })) : null, !setup && (_jsx(Section, { testId: "mfa-setup-preauth-intro", actions: _jsx(Button, { variant: "primary", onClick: () => void startSetup(), loading: busy, disabled: busy, children: t("auth.mfa.setup.start") }), children: _jsx("span", { className: "text-sm text-muted-foreground", children: t("auth.mfa.setup.intro") }) })), setup && (_jsx(Form, { onSubmit: onSubmit, children: _jsxs(Section, { testId: "mfa-setup-preauth-setup", actions: _jsx(Button, { type: "submit", variant: "primary", loading: busy, disabled: busy || !acknowledged || code.length !== 6, children: t("auth.mfa.setup.confirm") }), children: [_jsxs("div", { className: "flex flex-col items-center gap-2 text-center", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.scanTitle") }), _jsx("div", { className: "h-40 w-40",
133
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
134
+ dangerouslySetInnerHTML: { __html: setup.qrSvg } }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.manualEntry") }), _jsx("code", { className: "inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.secretParam })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-sm font-semibold", children: t("auth.mfa.enable.recoveryTitle") }), _jsx("span", { className: "text-xs text-muted-foreground", children: t("auth.mfa.enable.recoveryHint") }), _jsx("code", { className: "block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: setup.recoveryCodes.join("\n") }), _jsx(Field, { id: "mfa-setup-preauth-ack", label: t("auth.mfa.enable.acknowledge"), children: _jsx(Input, { kind: "boolean", id: "mfa-setup-preauth-ack", name: "mfa-setup-preauth-ack", value: acknowledged, onChange: setAcknowledged }) })] }), _jsx(Field, { id: "mfa-setup-preauth-code", label: t("auth.mfa.enable.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-setup-preauth-code", name: "mfa-setup-preauth-code", value: code, onChange: setCode, disabled: busy || !acknowledged, autoComplete: "one-time-code" }) })] }) })), onCancel ? (_jsx("div", { className: "flex justify-center", children: _jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: busy, children: t("auth.mfa.verify.backToLogin") }) })) : null] }));
135
135
  }
@@ -52,7 +52,7 @@ export function MfaVerifyScreen({ challengeToken, title, subtitle, submitLabel,
52
52
  e?.preventDefault();
53
53
  void doSubmit();
54
54
  };
55
- return (_jsx(AuthCard, { title: title ?? t("auth.mfa.verify.title"), subtitle: subtitle ?? t("auth.mfa.verify.subtitle"), children: _jsx("div", { className: "p-6 pt-0 flex flex-col gap-4", children: _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "mfa-verify-code", label: t("auth.mfa.verify.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-verify-code", name: "mfa-verify-code", value: code, onChange: setCode, disabled: submitting, required: true, autoComplete: "one-time-code" }) }), error !== null ? _jsx(Banner, { variant: "error", children: t(reasonToKey(error)) }) : null, _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting
56
- ? t("auth.mfa.verify.submitting")
57
- : (submitLabel ?? t("auth.mfa.verify.submit")) }), onCancel ? (_jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: submitting, children: t("auth.mfa.verify.backToLogin") })) : null] }) }) }));
55
+ return (_jsx(AuthCard, { title: title ?? t("auth.mfa.verify.title"), subtitle: subtitle ?? t("auth.mfa.verify.subtitle"), children: _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "mfa-verify-code", label: t("auth.mfa.verify.code"), required: true, children: _jsx(Input, { kind: "text", id: "mfa-verify-code", name: "mfa-verify-code", value: code, onChange: setCode, disabled: submitting, required: true, autoComplete: "one-time-code" }) }), error !== null ? _jsx(Banner, { variant: "error", children: t(reasonToKey(error)) }) : null, _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting
56
+ ? t("auth.mfa.verify.submitting")
57
+ : (submitLabel ?? t("auth.mfa.verify.submit")) }), onCancel ? (_jsx(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: submitting, children: t("auth.mfa.verify.backToLogin") })) : null] }) }));
58
58
  }
@@ -138,6 +138,7 @@ export function createIssueContractConfirmationHandler(options, consumerProtecti
138
138
  await ctx.notify(CONTRACT_CONFIRMATION_NOTIFICATION_TYPE, {
139
139
  route: { email: recipient.data.email },
140
140
  data: content,
141
+ locale,
141
142
  priority: "critical",
142
143
  });
143
144
  return {
@@ -41,6 +41,7 @@ export function renderTerminationReceipt(args) {
41
41
  subject,
42
42
  header: subject,
43
43
  sections: [
44
+ { text: texts.receiptGreeting },
44
45
  { text: texts.receiptIntro },
45
46
  { text: `${texts.receivedAt}: ${formatReceivedAt(args.receivedAtIso, args.locale)}` },
46
47
  { text: `${texts.requestId}: ${args.requestId}` },
@@ -16,6 +16,7 @@ export async function notifyOperator(ctx, consumerProtection, plan) {
16
16
  ...plan,
17
17
  operatorEmail: consumerProtection.operatorEmail,
18
18
  }),
19
+ locale: plan.locale,
19
20
  priority: "critical",
20
21
  });
21
22
  }
@@ -1,8 +1,15 @@
1
1
  import type { AnonymousExtraRoute } from "@cosmicdrift/kumiko-framework/api";
2
+ import { type PublicPageWrapLayout } from "../../page-render/index.js";
2
3
  import type { ConsentLocale } from "./consent-text.js";
3
4
  export type ContractTerminationRoutesOptions = {
4
5
  readonly paths?: Readonly<Partial<Record<ConsentLocale, string>>>;
6
+ /** Layout around every page (form, review, result, 429, error). Default:
7
+ * the minimal `wrapInLayout` skeleton. The page headers (CSP with
8
+ * `script-src 'none'`, framing, no-store) stay with the framework, so the
9
+ * layout must work without JavaScript and only returns the HTML string. */
10
+ readonly wrapLayout?: PublicPageWrapLayout;
5
11
  };
6
12
  /** GET + POST pages for de (`/legal/kuendigen`) and en (`/legal/cancel`);
7
- * mount via the app's `extraRoutes`. */
13
+ * mount via the app's `extraRoutes`. The other trailing-slash form of each
14
+ * path 301-redirects (GET/HEAD) to the configured path. */
8
15
  export declare function createContractTerminationRoutes(options?: ContractTerminationRoutesOptions): AnonymousExtraRoute[];
@@ -2,12 +2,13 @@
2
2
  // no JavaScript. Every echoed value goes through escapeHtml/escapeHtmlAttr.
3
3
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
4
4
  import { getCookie } from "hono/cookie";
5
- import { securePageHeaders, wrapInLayout } from "../../page-render/index.js";
5
+ import { redirectToCanonicalPath, securePageHeaders, slashVariantOf, wrapInLayout, } from "../../page-render/index.js";
6
6
  import { SubscriptionFoundationHandlers } from "../constants.js";
7
7
  import { CONTRACT_TERMINATION_DECLARATION_TYPES, CONTRACT_TERMINATION_KINDS } from "../events.js";
8
8
  import { formatReceivedAt } from "./termination-mail.js";
9
9
  import { requestContractTerminationSchema } from "./termination-request.js";
10
10
  import { TERMINATION_TEXTS } from "./termination-texts.js";
11
+ const PAGE_SLUG = "contract-termination";
11
12
  const DEFAULT_PATHS = {
12
13
  de: "/legal/kuendigen",
13
14
  en: "/legal/cancel",
@@ -42,11 +43,15 @@ function pageHeaders() {
42
43
  "x-frame-options": "DENY",
43
44
  };
44
45
  }
45
- function htmlResponse(locale, title, bodyHtml, status) {
46
- return new Response(wrapInLayout({ title, bodyHtml, lang: locale }), {
47
- status,
48
- headers: pageHeaders(),
49
- });
46
+ function htmlResponse(ctx, title, bodyHtml, status) {
47
+ const root = `<div data-kumiko-page="${PAGE_SLUG}">\n${bodyHtml}\n</div>`;
48
+ return new Response(ctx.wrapLayout({
49
+ title,
50
+ bodyHtml: root,
51
+ lang: ctx.locale,
52
+ slug: PAGE_SLUG,
53
+ alternates: ctx.alternates,
54
+ }), { status, headers: pageHeaders() });
50
55
  }
51
56
  function isOneOf(options, value) {
52
57
  return options.includes(value);
@@ -109,30 +114,32 @@ function radioGroup(name, options, labels, selected) {
109
114
  .join("");
110
115
  }
111
116
  function textField(name, label, value, attributes) {
112
- return `<p><label>${escapeHtml(label)}<br><input type="${escapeHtmlAttr(attributes.type ?? "text")}" name="${name}" value="${escapeHtmlAttr(value)}" maxlength="${attributes.maxlength}"${attributes.required ? " required" : ""}></label></p>`;
117
+ return `<p data-kumiko-field-group="${name}"><label>${escapeHtml(label)}<br><input type="${escapeHtmlAttr(attributes.type ?? "text")}" name="${name}" value="${escapeHtmlAttr(value)}" maxlength="${attributes.maxlength}"${attributes.required ? " required" : ""}></label></p>`;
113
118
  }
114
- function formPage(locale, path, values, messages, status) {
119
+ function formPage(ctx, values, messages, status) {
120
+ const { locale, path } = ctx;
115
121
  const texts = TERMINATION_TEXTS[locale];
116
122
  const { page } = texts;
117
123
  const body = `<h1>${escapeHtml(page.title)}</h1>
118
124
  <p>${escapeHtml(page.lead)}</p>
119
125
  ${errorsHtml(messages, page)}
120
- <form method="post" action="${escapeHtmlAttr(path)}">
126
+ <form method="post" action="${escapeHtmlAttr(path)}" data-kumiko-form="contract-termination">
121
127
  <input type="hidden" name="step" value="review">
122
- <fieldset><legend>${escapeHtml(page.declarationTypeHeading)}</legend>${radioGroup("declarationType", CONTRACT_TERMINATION_DECLARATION_TYPES, texts.declarationTypeLabel, values.declarationType)}</fieldset>
123
- <fieldset><legend>${escapeHtml(page.kindHeading)}</legend>${radioGroup("terminationKind", CONTRACT_TERMINATION_KINDS, texts.terminationKindLabel, values.terminationKind)}<p>${escapeHtml(page.earliestDate)}</p></fieldset>
128
+ <fieldset data-kumiko-field-group="declarationType"><legend>${escapeHtml(page.declarationTypeHeading)}</legend>${radioGroup("declarationType", CONTRACT_TERMINATION_DECLARATION_TYPES, texts.declarationTypeLabel, values.declarationType)}</fieldset>
129
+ <fieldset data-kumiko-field-group="terminationKind"><legend>${escapeHtml(page.kindHeading)}</legend>${radioGroup("terminationKind", CONTRACT_TERMINATION_KINDS, texts.terminationKindLabel, values.terminationKind)}<p>${escapeHtml(page.earliestDate)}</p></fieldset>
124
130
  ${textField("name", page.nameLabel, values.name, { required: true, maxlength: 200 })}
125
131
  ${textField("email", page.emailLabel, values.email, { type: "email", required: true, maxlength: 254 })}
126
132
  ${textField("customerReference", page.customerReferenceLabel, values.customerReference, { maxlength: 200 })}
127
- <p><label>${escapeHtml(page.reasonLabel)} (${escapeHtml(page.reasonHint)})<br><textarea name="reason" rows="5" maxlength="2000">${escapeHtml(values.reason)}</textarea></label></p>
128
- <p><button type="submit">${escapeHtml(page.reviewButton)}</button></p>
133
+ <p data-kumiko-field-group="reason"><label>${escapeHtml(page.reasonLabel)} (${escapeHtml(page.reasonHint)})<br><textarea name="reason" rows="5" maxlength="2000">${escapeHtml(values.reason)}</textarea></label></p>
134
+ <p><button type="submit" data-kumiko-button="review">${escapeHtml(page.reviewButton)}</button></p>
129
135
  </form>`;
130
- return htmlResponse(locale, page.title, body, status);
136
+ return htmlResponse(ctx, page.title, body, status);
131
137
  }
132
138
  function hiddenInputs(values) {
133
139
  return FORM_FIELDS.map((field) => `<input type="hidden" name="${field}" value="${escapeHtmlAttr(values[field])}">`).join("\n");
134
140
  }
135
- function reviewPage(locale, path, values) {
141
+ function reviewPage(ctx, values) {
142
+ const { locale, path } = ctx;
136
143
  const texts = TERMINATION_TEXTS[locale];
137
144
  const { page } = texts;
138
145
  // validationMessages already proved the enum values.
@@ -147,7 +154,7 @@ function reviewPage(locale, path, values) {
147
154
  : `<tr><th scope="row">${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`;
148
155
  const body = `<h1>${escapeHtml(page.reviewTitle)}</h1>
149
156
  <p>${escapeHtml(page.reviewLead)}</p>
150
- <table>
157
+ <table data-kumiko-review-table>
151
158
  ${row(texts.declarationType, texts.declarationTypeLabel[declarationType])}
152
159
  ${row(texts.terminationKind, texts.terminationKindLabel[terminationKind])}
153
160
  ${row(texts.name, values.name)}
@@ -155,21 +162,22 @@ ${row(texts.email, values.email)}
155
162
  ${row(texts.customerReference, values.customerReference)}
156
163
  ${row(texts.reasonFieldLabel, values.reason)}
157
164
  </table>
158
- <form method="post" action="${escapeHtmlAttr(path)}">
165
+ <form method="post" action="${escapeHtmlAttr(path)}" data-kumiko-form="contract-termination-review">
159
166
  ${hiddenInputs(values)}
160
- <button type="submit" name="step" value="confirm">${escapeHtml(page.confirmButton[declarationType])}</button>
161
- <button type="submit" name="step" value="edit">${escapeHtml(page.backButton)}</button>
167
+ <button type="submit" name="step" value="confirm" data-kumiko-button="confirm">${escapeHtml(page.confirmButton[declarationType])}</button>
168
+ <button type="submit" name="step" value="edit" data-kumiko-button="back">${escapeHtml(page.backButton)}</button>
162
169
  </form>`;
163
- return htmlResponse(locale, page.reviewTitle, body, 200);
170
+ return htmlResponse(ctx, page.reviewTitle, body, 200);
164
171
  }
165
- function resultPage(locale, receivedAtIso, requestId) {
172
+ function resultPage(ctx, receivedAtIso, requestId) {
173
+ const { locale } = ctx;
166
174
  const { page } = TERMINATION_TEXTS[locale];
167
175
  const body = `<h1>${escapeHtml(page.resultTitle)}</h1>
168
176
  <p>${escapeHtml(page.resultLead)}</p>
169
177
  <p>${escapeHtml(page.resultReceivedAt)}: <strong>${escapeHtml(formatReceivedAt(receivedAtIso, locale))}</strong></p>
170
178
  <p>${escapeHtml(page.resultRequestId)}: <code>${escapeHtml(requestId)}</code></p>
171
179
  <p>${escapeHtml(page.resultMailNote)}</p>`;
172
- return htmlResponse(locale, page.resultTitle, body, 200);
180
+ return htmlResponse(ctx, page.resultTitle, body, 200);
173
181
  }
174
182
  const RATE_LIMITED_CODE = "rate_limited";
175
183
  const TENANT_HEADER = "x-tenant";
@@ -227,14 +235,27 @@ async function submitDeclaration(c, app, clientIp, values, locale) {
227
235
  }
228
236
  return { kind: "failed" };
229
237
  }
230
- function routesFor(locale, path) {
238
+ function routesFor(ctx) {
239
+ const { locale, path } = ctx;
240
+ const slashVariant = slashVariantOf(path);
231
241
  return [
232
242
  {
233
243
  method: "GET",
234
244
  path,
235
245
  entry: "anonymous",
236
- handler: () => formPage(locale, path, EMPTY_FORM, [], 200),
246
+ handler: () => formPage(ctx, EMPTY_FORM, [], 200),
237
247
  },
248
+ // GET also serves HEAD; POST gets no alias (a redirect would drop the body).
249
+ ...(slashVariant === null
250
+ ? []
251
+ : [
252
+ {
253
+ method: "GET",
254
+ path: slashVariant,
255
+ entry: "anonymous",
256
+ handler: (c) => redirectToCanonicalPath(c.req.url, path),
257
+ },
258
+ ]),
238
259
  {
239
260
  method: "POST",
240
261
  path,
@@ -242,27 +263,33 @@ function routesFor(locale, path) {
242
263
  handler: async (c, deps) => {
243
264
  const { step, values } = await readForm(c);
244
265
  if (step === "edit")
245
- return formPage(locale, path, values, [], 200);
266
+ return formPage(ctx, values, [], 200);
246
267
  const messages = validationMessages(values, locale);
247
268
  if (messages.length > 0)
248
- return formPage(locale, path, values, messages, 400);
269
+ return formPage(ctx, values, messages, 400);
249
270
  if (step !== "confirm")
250
- return reviewPage(locale, path, values);
271
+ return reviewPage(ctx, values);
251
272
  const outcome = await submitDeclaration(c, deps.app, deps.clientIp, values, locale);
252
273
  const { page } = TERMINATION_TEXTS[locale];
253
274
  if (outcome.kind === "received") {
254
- return resultPage(locale, outcome.receivedAtIso, outcome.requestId);
275
+ return resultPage(ctx, outcome.receivedAtIso, outcome.requestId);
255
276
  }
256
277
  if (outcome.kind === "rate-limited") {
257
- return htmlResponse(locale, page.rateLimitedTitle, `<h1>${escapeHtml(page.rateLimitedTitle)}</h1><p>${escapeHtml(page.rateLimitedBody)}</p>`, 429);
278
+ return htmlResponse(ctx, page.rateLimitedTitle, `<h1>${escapeHtml(page.rateLimitedTitle)}</h1><p>${escapeHtml(page.rateLimitedBody)}</p>`, 429);
258
279
  }
259
- return formPage(locale, path, values, [page.errorGeneric], 400);
280
+ return formPage(ctx, values, [page.errorGeneric], 400);
260
281
  },
261
282
  },
262
283
  ];
263
284
  }
264
285
  /** GET + POST pages for de (`/legal/kuendigen`) and en (`/legal/cancel`);
265
- * mount via the app's `extraRoutes`. */
286
+ * mount via the app's `extraRoutes`. The other trailing-slash form of each
287
+ * path 301-redirects (GET/HEAD) to the configured path. */
266
288
  export function createContractTerminationRoutes(options = {}) {
267
- return ["de", "en"].flatMap((locale) => routesFor(locale, options.paths?.[locale] ?? DEFAULT_PATHS[locale]));
289
+ const alternates = {
290
+ de: options.paths?.de ?? DEFAULT_PATHS.de,
291
+ en: options.paths?.en ?? DEFAULT_PATHS.en,
292
+ };
293
+ const wrapLayout = options.wrapLayout ?? wrapInLayout;
294
+ return ["de", "en"].flatMap((locale) => routesFor({ locale, path: alternates[locale], wrapLayout, alternates }));
268
295
  }
@@ -62,6 +62,7 @@ async function sendTerminationMails(ctx, consumerProtection, plan) {
62
62
  declaration: plan.declaration,
63
63
  operatorEmail: consumerProtection.operatorEmail,
64
64
  }),
65
+ locale: plan.locale,
65
66
  priority: "critical",
66
67
  });
67
68
  // skip: no operator notice needed for this declaration
@@ -104,6 +105,7 @@ export function createRequestContractTerminationHandler(options, consumerProtect
104
105
  schema: requestContractTerminationSchema,
105
106
  access: { roles: ["anonymous"] },
106
107
  rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 600 },
108
+ additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
107
109
  ...(consumerProtection.terminationScope === "platform" && { tenantlessAnonymous: true }),
108
110
  escapeHatch: {
109
111
  reason: "Anonymous declarant has no session — finds the contract by email via ctx.queryAs(system, user:find-for-auth) and tenant:memberships; the tenant written to comes only from those lookups.",
@@ -12,6 +12,7 @@ export type TerminationTexts = {
12
12
  readonly customerReference: string;
13
13
  readonly reasonFieldLabel: string;
14
14
  readonly receiptSubject: Readonly<Record<ContractTerminationDeclarationType, string>>;
15
+ readonly receiptGreeting: string;
15
16
  readonly receiptIntro: string;
16
17
  readonly receiptNextSteps: string;
17
18
  readonly receiptRevoke: (operatorEmail: string) => string;
@@ -27,6 +27,7 @@ export const TERMINATION_TEXTS = {
27
27
  termination: "Eingangsbestätigung deiner Kündigung",
28
28
  withdrawal: "Eingangsbestätigung deines Widerrufs",
29
29
  },
30
+ receiptGreeting: "Hallo,",
30
31
  receiptIntro: "wir bestätigen den Eingang deiner Erklärung mit folgendem Inhalt. Diese E-Mail ist eine Eingangsbestätigung, keine Bestätigung der Wirksamkeit.",
31
32
  receiptNextSteps: "Wir prüfen deine Erklärung und melden uns, sobald sie bearbeitet ist oder wir Rückfragen haben.",
32
33
  receiptRevoke: (operatorEmail) => `Hast du diese Erklärung nicht abgegeben oder möchtest du sie zurücknehmen? Schreib uns bitte umgehend an ${operatorEmail}.`,
@@ -105,7 +106,8 @@ export const TERMINATION_TEXTS = {
105
106
  termination: "Receipt for your termination",
106
107
  withdrawal: "Receipt for your withdrawal",
107
108
  },
108
- receiptIntro: "we confirm receipt of your declaration with the following content. This email is a receipt, not a confirmation that the declaration is effective.",
109
+ receiptGreeting: "Hello,",
110
+ receiptIntro: "We confirm receipt of your declaration with the following content. This email is a receipt, not a confirmation that the declaration is effective.",
109
111
  receiptNextSteps: "We will review your declaration and get back to you once it is processed or if we have questions.",
110
112
  receiptRevoke: (operatorEmail) => `If you did not make this declaration or want to take it back, please write to ${operatorEmail} right away.`,
111
113
  contact: "Questions go to",
@@ -1,5 +1,9 @@
1
1
  import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
2
2
  export type DiscordChannelOptions = ChatWebhookChannelOptions;
3
+ export declare const DISCORD_SECRET_KEYS: {
4
+ readonly webhookPrefix: "channel-discord:webhooks.";
5
+ readonly webhookKeyFor: (connection: string) => string;
6
+ };
3
7
  export declare const DISCORD_DEFAULT_ALLOWED_HOSTS: readonly ["discord.com", "discordapp.com"];
4
8
  export declare const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
5
9
  export declare const DISCORD_CONTENT_MAX_CHARS = 2000;
@@ -1,4 +1,8 @@
1
1
  import { chatMessageText, createChatWebhookChannel, truncateChars, } from "../delivery/index.js";
2
+ export const DISCORD_SECRET_KEYS = {
3
+ webhookPrefix: "channel-discord:webhooks.",
4
+ webhookKeyFor: (connection) => `channel-discord:webhooks.${connection}`,
5
+ };
2
6
  export const DISCORD_DEFAULT_ALLOWED_HOSTS = ["discord.com", "discordapp.com"];
3
7
  export const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
4
8
  export const DISCORD_CONTENT_MAX_CHARS = 2000;
@@ -1,6 +1,6 @@
1
- import { chatConnectionNameSchema, DELIVERY_CHANNEL_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/delivery";
1
+ import { chatConnectionNameSchema, chatWebhookUrlSchema, DELIVERY_CHANNEL_EXTENSION, resolveChatWebhookTarget, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
2
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
- import { createDiscordChannel } from "./discord-channel.js";
3
+ import { createDiscordChannel, DISCORD_DEFAULT_ALLOWED_HOSTS, DISCORD_WEBHOOK_PATH_PREFIX, } from "./discord-channel.js";
4
4
  export function createChannelDiscordFeature(options = {}) {
5
5
  return defineFeature("channel-discord", (r) => {
6
6
  r.describe('Posts delivery notifications to a Discord webhook, registered as the `discord` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { discord: "<connection name>" } })`. The webhook URL is a tenant secret under `channel-discord:webhooks.<connection name>`; only the connection name is logged. Mentions are disabled and content is cut to 2000 characters. Requires `delivery` and `secrets`. Host allowlist (discord.com, discordapp.com, path /api/webhooks/), https-only and the request timeout are app options of `createChannelDiscordFeature(opts)`, never tenant config.');
@@ -11,6 +11,10 @@ export function createChannelDiscordFeature(options = {}) {
11
11
  });
12
12
  r.requires("delivery");
13
13
  r.requires("secrets");
14
+ const webhookTarget = resolveChatWebhookTarget({
15
+ defaultAllowedHosts: DISCORD_DEFAULT_ALLOWED_HOSTS,
16
+ requiredPathPrefix: DISCORD_WEBHOOK_PATH_PREFIX,
17
+ }, options);
14
18
  const webhooks = r.secretNamespace("webhooks", {
15
19
  label: { en: "Discord webhooks", de: "Discord-Webhooks", es: "Webhooks de Discord" },
16
20
  hint: {
@@ -20,6 +24,7 @@ export function createChannelDiscordFeature(options = {}) {
20
24
  },
21
25
  scope: "tenant",
22
26
  nameSchema: chatConnectionNameSchema,
27
+ valueSchema: chatWebhookUrlSchema(webhookTarget),
23
28
  });
24
29
  const channel = createDiscordChannel(options, webhooks.keyFor);
25
30
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
@@ -1,2 +1,2 @@
1
- export { createDiscordChannel, type DiscordChannelOptions } from "./discord-channel.js";
1
+ export { createDiscordChannel, DISCORD_DEFAULT_ALLOWED_HOSTS, DISCORD_SECRET_KEYS, DISCORD_WEBHOOK_PATH_PREFIX, type DiscordChannelOptions, } from "./discord-channel.js";
2
2
  export { createChannelDiscordFeature } from "./feature.js";
@@ -1,2 +1,2 @@
1
- export { createDiscordChannel } from "./discord-channel.js";
1
+ export { createDiscordChannel, DISCORD_DEFAULT_ALLOWED_HOSTS, DISCORD_SECRET_KEYS, DISCORD_WEBHOOK_PATH_PREFIX, } from "./discord-channel.js";
2
2
  export { createChannelDiscordFeature } from "./feature.js";
@@ -44,6 +44,7 @@ function emailEnvelopeFrom(data) {
44
44
  if (!data)
45
45
  return {};
46
46
  const from = typeof data["from"] === "string" ? data["from"] : undefined;
47
+ const fromName = typeof data["fromName"] === "string" ? data["fromName"] : undefined;
47
48
  const replyTo = typeof data["replyTo"] === "string" ? data["replyTo"] : undefined;
48
49
  const autoHeaders = listUnsubscribeHeaders(data);
49
50
  const explicitHeaders = stringHeaders(data["headers"]);
@@ -55,7 +56,12 @@ function emailEnvelopeFrom(data) {
55
56
  const headers = (!overridesUnsubscribe && autoHeaders) || explicitHeaders
56
57
  ? { ...(overridesUnsubscribe ? undefined : autoHeaders), ...explicitHeaders }
57
58
  : undefined;
58
- return { ...(from && { from }), ...(replyTo && { replyTo }), ...(headers && { headers }) };
59
+ return {
60
+ ...(from && { from }),
61
+ ...(fromName && { fromName }),
62
+ ...(replyTo && { replyTo }),
63
+ ...(headers && { headers }),
64
+ };
59
65
  }
60
66
  export function createEmailChannel(options) {
61
67
  const { transport, renderer, resolveEmail } = options;
@@ -73,6 +79,7 @@ export function createEmailChannel(options) {
73
79
  const html = await renderer.render({
74
80
  template: message.notificationType,
75
81
  variables,
82
+ locale: message.locale,
76
83
  });
77
84
  const subject = variables["subject"] ?? message.title; // @cast-boundary dynamic-key
78
85
  return { html, subject };
@@ -9,15 +9,16 @@ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
9
9
  // blob); ciphertext in subject/body fails loud in dev and is redacted+logged
10
10
  // in prod.
11
11
  export function guardEmailMessage(message) {
12
- // Every envelope address, not just `to`: a ciphertext From or Reply-To is
12
+ // Every envelope address, not just `to`: a ciphertext From, From-Name or Reply-To is
13
13
  // the same garbage as a ciphertext recipient — refuse rather than mail it.
14
14
  for (const [label, address] of [
15
- ["recipient", message.to],
16
- ["from", message.from],
17
- ["reply-to", message.replyTo],
15
+ ["recipient address", message.to],
16
+ ["from address", message.from],
17
+ ["from name", message.fromName],
18
+ ["reply-to address", message.replyTo],
18
19
  ]) {
19
20
  if (address?.includes(CIPHERTEXT_MARKER)) {
20
- throw new Error(`[channel-email] refusing to send: ${label} address is a PII ciphertext ` +
21
+ throw new Error(`[channel-email] refusing to send: ${label} is a PII ciphertext ` +
21
22
  `("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`);
22
23
  }
23
24
  }
@@ -16,6 +16,27 @@
16
16
  // Volume-Apps muss der Caller eine eigene Implementation mit
17
17
  // dedizierter Queue (BullMQ + retry) drüberlegen.
18
18
  import { createTransport } from "nodemailer";
19
+ import addressparser from "nodemailer/lib/addressparser";
20
+ function isControlCharacter(char) {
21
+ const code = char.charCodeAt(0);
22
+ return code <= 0x1f || code === 0x7f;
23
+ }
24
+ // Control characters in a display name would let a caller inject headers.
25
+ function sanitizedDisplayName(name) {
26
+ const cleaned = name
27
+ ?.split("")
28
+ .map((char) => (isControlCharacter(char) ? " " : char))
29
+ .join("")
30
+ .trim();
31
+ return cleaned === undefined || cleaned === "" ? undefined : cleaned;
32
+ }
33
+ function fromHeader(address, fromName) {
34
+ const name = sanitizedDisplayName(fromName);
35
+ if (name === undefined)
36
+ return address;
37
+ const [parsed] = addressparser(address, { flatten: true });
38
+ return parsed?.address ? { name, address: parsed.address } : address;
39
+ }
19
40
  export function createSmtpTransport(options) {
20
41
  const transporter = createTransport({
21
42
  host: options.host,
@@ -29,7 +50,7 @@ export function createSmtpTransport(options) {
29
50
  return {
30
51
  async send(message) {
31
52
  await transporter.sendMail({
32
- from: message.from ?? options.from,
53
+ from: fromHeader(message.from ?? options.from, message.fromName),
33
54
  to: message.to,
34
55
  subject: message.subject,
35
56
  html: message.html,
@@ -3,6 +3,7 @@ export type EmailMessage = {
3
3
  readonly subject: string;
4
4
  readonly html: string;
5
5
  readonly from?: string;
6
+ readonly fromName?: string;
6
7
  readonly replyTo?: string;
7
8
  readonly headers?: Readonly<Record<string, string>>;
8
9
  };