@cosmicdrift/kumiko-samples 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 (124) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/audit/constants.ts +5 -0
  4. package/packages/bundled-features/src/audit/feature.ts +47 -6
  5. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +53 -0
  6. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +325 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +30 -0
  8. package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
  9. package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
  10. package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
  15. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
  16. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  17. package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
  18. package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
  21. package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
  22. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
  23. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
  24. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
  25. package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
  26. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
  27. package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
  28. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
  29. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
  30. package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
  31. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  32. package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
  33. package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
  34. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
  35. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
  36. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
  37. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  38. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
  39. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
  40. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
  44. package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
  45. package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
  46. package/packages/bundled-features/src/channel-discord/index.ts +7 -1
  47. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  48. package/packages/bundled-features/src/channel-email/email-channel.ts +9 -1
  49. package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
  50. package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
  51. package/packages/bundled-features/src/channel-email/types.ts +4 -0
  52. package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
  53. package/packages/bundled-features/src/channel-slack/index.ts +6 -1
  54. package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
  55. package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
  56. package/packages/bundled-features/src/channel-teams/index.ts +6 -1
  57. package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
  58. package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
  59. package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
  60. package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
  61. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
  62. package/packages/bundled-features/src/delivery/changes.json +7 -0
  63. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
  64. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
  65. package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
  66. package/packages/bundled-features/src/delivery/index.ts +4 -0
  67. package/packages/bundled-features/src/delivery/jobs.ts +3 -1
  68. package/packages/bundled-features/src/delivery/types.ts +10 -4
  69. package/packages/bundled-features/src/files/changes.json +13 -1
  70. package/packages/bundled-features/src/legal-pages/README.md +26 -3
  71. package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
  72. package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
  73. package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
  74. package/packages/bundled-features/src/page-render/index.ts +2 -0
  75. package/packages/bundled-features/src/page-render/layout.ts +17 -0
  76. package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
  77. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  78. package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
  79. package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
  80. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
  81. package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
  82. package/packages/bundled-features/src/secrets/changes.json +12 -0
  83. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  84. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
  85. package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
  86. package/packages/bundled-features/src/secrets/i18n.ts +4 -0
  87. package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
  88. package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
  89. package/packages/bundled-features/src/tenant/changes.json +6 -0
  90. package/packages/bundled-features/src/tenant/constants.ts +1 -0
  91. package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
  92. package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
  93. package/packages/bundled-features/src/tenant/index.ts +1 -0
  94. package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
  95. package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
  96. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
  97. package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
  98. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
  99. package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
  100. package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
  101. package/packages/bundled-features/src/waitlist/changes.json +23 -0
  102. package/packages/bundled-features/src/waitlist/constants.ts +44 -0
  103. package/packages/bundled-features/src/waitlist/entity.ts +76 -0
  104. package/packages/bundled-features/src/waitlist/feature.ts +50 -0
  105. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
  106. package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
  107. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
  108. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
  109. package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
  110. package/packages/bundled-features/src/waitlist/index.ts +15 -0
  111. package/packages/bundled-features/src/waitlist/lib.ts +36 -0
  112. package/packages/bundled-features/src/waitlist/mail.ts +114 -0
  113. package/packages/bundled-features/src/waitlist/options.ts +42 -0
  114. package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
  115. package/packages/bundled-features/src/waitlist/screens.ts +43 -0
  116. package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
  117. package/samples/apps/use-all-bundled/feature-manifest.json +63 -7
  118. package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
  119. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  120. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  121. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  122. package/samples/recipes/chat-channels/README.md +2 -0
  123. package/samples/recipes/managed-config/README.md +1 -1
  124. package/samples/recipes/rate-limiting/README.md +21 -0
@@ -5,7 +5,13 @@ import type { AnonymousExtraRoute } from "@cosmicdrift/kumiko-framework/api";
5
5
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
6
6
  import type { Context } from "hono";
7
7
  import { getCookie } from "hono/cookie";
8
- import { securePageHeaders, wrapInLayout } from "../../page-render/index.js";
8
+ import {
9
+ type PublicPageWrapLayout,
10
+ redirectToCanonicalPath,
11
+ securePageHeaders,
12
+ slashVariantOf,
13
+ wrapInLayout,
14
+ } from "../../page-render/index.js";
9
15
  import { SubscriptionFoundationHandlers } from "../constants.js";
10
16
  import { CONTRACT_TERMINATION_DECLARATION_TYPES, CONTRACT_TERMINATION_KINDS } from "../events.js";
11
17
  import type { ConsentLocale } from "./consent-text.js";
@@ -15,6 +21,21 @@ import { type PageTexts, TERMINATION_TEXTS } from "./termination-texts.js";
15
21
 
16
22
  export type ContractTerminationRoutesOptions = {
17
23
  readonly paths?: Readonly<Partial<Record<ConsentLocale, string>>>;
24
+ /** Layout around every page (form, review, result, 429, error). Default:
25
+ * the minimal `wrapInLayout` skeleton. The page headers (CSP with
26
+ * `script-src 'none'`, framing, no-store) stay with the framework, so the
27
+ * layout must work without JavaScript and only returns the HTML string. */
28
+ readonly wrapLayout?: PublicPageWrapLayout;
29
+ };
30
+
31
+ const PAGE_SLUG = "contract-termination";
32
+
33
+ // Everything a page render needs besides its own content.
34
+ type PageContext = {
35
+ readonly locale: ConsentLocale;
36
+ readonly path: string;
37
+ readonly wrapLayout: PublicPageWrapLayout;
38
+ readonly alternates: Readonly<Record<ConsentLocale, string>>;
18
39
  };
19
40
 
20
41
  const DEFAULT_PATHS: Readonly<Record<ConsentLocale, string>> = {
@@ -59,15 +80,22 @@ function pageHeaders(): Record<string, string> {
59
80
  }
60
81
 
61
82
  function htmlResponse(
62
- locale: ConsentLocale,
83
+ ctx: PageContext,
63
84
  title: string,
64
85
  bodyHtml: string,
65
- status: 200 | 400 | 429 | 500,
86
+ status: 200 | 400 | 429,
66
87
  ): Response {
67
- return new Response(wrapInLayout({ title, bodyHtml, lang: locale }), {
68
- status,
69
- headers: pageHeaders(),
70
- });
88
+ const root = `<div data-kumiko-page="${PAGE_SLUG}">\n${bodyHtml}\n</div>`;
89
+ return new Response(
90
+ ctx.wrapLayout({
91
+ title,
92
+ bodyHtml: root,
93
+ lang: ctx.locale,
94
+ slug: PAGE_SLUG,
95
+ alternates: ctx.alternates,
96
+ }),
97
+ { status, headers: pageHeaders() },
98
+ );
71
99
  }
72
100
 
73
101
  function isOneOf<T extends string>(options: readonly T[], value: string): value is T {
@@ -152,32 +180,32 @@ function textField(
152
180
  value: string,
153
181
  attributes: { readonly type?: string; readonly required?: boolean; readonly maxlength: number },
154
182
  ): string {
155
- return `<p><label>${escapeHtml(label)}<br><input type="${escapeHtmlAttr(attributes.type ?? "text")}" name="${name}" value="${escapeHtmlAttr(value)}" maxlength="${attributes.maxlength}"${
183
+ 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}"${
156
184
  attributes.required ? " required" : ""
157
185
  }></label></p>`;
158
186
  }
159
187
 
160
188
  function formPage(
161
- locale: ConsentLocale,
162
- path: string,
189
+ ctx: PageContext,
163
190
  values: FormValues,
164
191
  messages: readonly string[],
165
192
  status: 200 | 400,
166
193
  ): Response {
194
+ const { locale, path } = ctx;
167
195
  const texts = TERMINATION_TEXTS[locale];
168
196
  const { page } = texts;
169
197
  const body = `<h1>${escapeHtml(page.title)}</h1>
170
198
  <p>${escapeHtml(page.lead)}</p>
171
199
  ${errorsHtml(messages, page)}
172
- <form method="post" action="${escapeHtmlAttr(path)}">
200
+ <form method="post" action="${escapeHtmlAttr(path)}" data-kumiko-form="contract-termination">
173
201
  <input type="hidden" name="step" value="review">
174
- <fieldset><legend>${escapeHtml(page.declarationTypeHeading)}</legend>${radioGroup(
202
+ <fieldset data-kumiko-field-group="declarationType"><legend>${escapeHtml(page.declarationTypeHeading)}</legend>${radioGroup(
175
203
  "declarationType",
176
204
  CONTRACT_TERMINATION_DECLARATION_TYPES,
177
205
  texts.declarationTypeLabel,
178
206
  values.declarationType,
179
207
  )}</fieldset>
180
- <fieldset><legend>${escapeHtml(page.kindHeading)}</legend>${radioGroup(
208
+ <fieldset data-kumiko-field-group="terminationKind"><legend>${escapeHtml(page.kindHeading)}</legend>${radioGroup(
181
209
  "terminationKind",
182
210
  CONTRACT_TERMINATION_KINDS,
183
211
  texts.terminationKindLabel,
@@ -186,10 +214,10 @@ ${errorsHtml(messages, page)}
186
214
  ${textField("name", page.nameLabel, values.name, { required: true, maxlength: 200 })}
187
215
  ${textField("email", page.emailLabel, values.email, { type: "email", required: true, maxlength: 254 })}
188
216
  ${textField("customerReference", page.customerReferenceLabel, values.customerReference, { maxlength: 200 })}
189
- <p><label>${escapeHtml(page.reasonLabel)} (${escapeHtml(page.reasonHint)})<br><textarea name="reason" rows="5" maxlength="2000">${escapeHtml(values.reason)}</textarea></label></p>
190
- <p><button type="submit">${escapeHtml(page.reviewButton)}</button></p>
217
+ <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>
218
+ <p><button type="submit" data-kumiko-button="review">${escapeHtml(page.reviewButton)}</button></p>
191
219
  </form>`;
192
- return htmlResponse(locale, page.title, body, status);
220
+ return htmlResponse(ctx, page.title, body, status);
193
221
  }
194
222
 
195
223
  function hiddenInputs(values: FormValues): string {
@@ -198,7 +226,8 @@ function hiddenInputs(values: FormValues): string {
198
226
  ).join("\n");
199
227
  }
200
228
 
201
- function reviewPage(locale: ConsentLocale, path: string, values: FormValues): Response {
229
+ function reviewPage(ctx: PageContext, values: FormValues): Response {
230
+ const { locale, path } = ctx;
202
231
  const texts = TERMINATION_TEXTS[locale];
203
232
  const { page } = texts;
204
233
  // validationMessages already proved the enum values.
@@ -214,7 +243,7 @@ function reviewPage(locale: ConsentLocale, path: string, values: FormValues): Re
214
243
  : `<tr><th scope="row">${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`;
215
244
  const body = `<h1>${escapeHtml(page.reviewTitle)}</h1>
216
245
  <p>${escapeHtml(page.reviewLead)}</p>
217
- <table>
246
+ <table data-kumiko-review-table>
218
247
  ${row(texts.declarationType, texts.declarationTypeLabel[declarationType])}
219
248
  ${row(texts.terminationKind, texts.terminationKindLabel[terminationKind])}
220
249
  ${row(texts.name, values.name)}
@@ -222,22 +251,23 @@ ${row(texts.email, values.email)}
222
251
  ${row(texts.customerReference, values.customerReference)}
223
252
  ${row(texts.reasonFieldLabel, values.reason)}
224
253
  </table>
225
- <form method="post" action="${escapeHtmlAttr(path)}">
254
+ <form method="post" action="${escapeHtmlAttr(path)}" data-kumiko-form="contract-termination-review">
226
255
  ${hiddenInputs(values)}
227
- <button type="submit" name="step" value="confirm">${escapeHtml(page.confirmButton[declarationType])}</button>
228
- <button type="submit" name="step" value="edit">${escapeHtml(page.backButton)}</button>
256
+ <button type="submit" name="step" value="confirm" data-kumiko-button="confirm">${escapeHtml(page.confirmButton[declarationType])}</button>
257
+ <button type="submit" name="step" value="edit" data-kumiko-button="back">${escapeHtml(page.backButton)}</button>
229
258
  </form>`;
230
- return htmlResponse(locale, page.reviewTitle, body, 200);
259
+ return htmlResponse(ctx, page.reviewTitle, body, 200);
231
260
  }
232
261
 
233
- function resultPage(locale: ConsentLocale, receivedAtIso: string, requestId: string): Response {
262
+ function resultPage(ctx: PageContext, receivedAtIso: string, requestId: string): Response {
263
+ const { locale } = ctx;
234
264
  const { page } = TERMINATION_TEXTS[locale];
235
265
  const body = `<h1>${escapeHtml(page.resultTitle)}</h1>
236
266
  <p>${escapeHtml(page.resultLead)}</p>
237
267
  <p>${escapeHtml(page.resultReceivedAt)}: <strong>${escapeHtml(formatReceivedAt(receivedAtIso, locale))}</strong></p>
238
268
  <p>${escapeHtml(page.resultRequestId)}: <code>${escapeHtml(requestId)}</code></p>
239
269
  <p>${escapeHtml(page.resultMailNote)}</p>`;
240
- return htmlResponse(locale, page.resultTitle, body, 200);
270
+ return htmlResponse(ctx, page.resultTitle, body, 200);
241
271
  }
242
272
 
243
273
  type WriteResponseBody = {
@@ -320,50 +350,69 @@ async function submitDeclaration(
320
350
  return { kind: "failed" };
321
351
  }
322
352
 
323
- function routesFor(locale: ConsentLocale, path: string): AnonymousExtraRoute[] {
353
+ function routesFor(ctx: PageContext): AnonymousExtraRoute[] {
354
+ const { locale, path } = ctx;
355
+ const slashVariant = slashVariantOf(path);
324
356
  return [
325
357
  {
326
358
  method: "GET",
327
359
  path,
328
360
  entry: "anonymous",
329
- handler: () => formPage(locale, path, EMPTY_FORM, [], 200),
361
+ handler: () => formPage(ctx, EMPTY_FORM, [], 200),
330
362
  },
363
+ // GET also serves HEAD; POST gets no alias (a redirect would drop the body).
364
+ ...(slashVariant === null
365
+ ? []
366
+ : [
367
+ {
368
+ method: "GET" as const,
369
+ path: slashVariant,
370
+ entry: "anonymous" as const,
371
+ handler: (c: Context) => redirectToCanonicalPath(c.req.url, path),
372
+ },
373
+ ]),
331
374
  {
332
375
  method: "POST",
333
376
  path,
334
377
  entry: "anonymous",
335
378
  handler: async (c, deps) => {
336
379
  const { step, values } = await readForm(c);
337
- if (step === "edit") return formPage(locale, path, values, [], 200);
380
+ if (step === "edit") return formPage(ctx, values, [], 200);
338
381
  const messages = validationMessages(values, locale);
339
- if (messages.length > 0) return formPage(locale, path, values, messages, 400);
340
- if (step !== "confirm") return reviewPage(locale, path, values);
382
+ if (messages.length > 0) return formPage(ctx, values, messages, 400);
383
+ if (step !== "confirm") return reviewPage(ctx, values);
341
384
 
342
385
  const outcome = await submitDeclaration(c, deps.app, deps.clientIp, values, locale);
343
386
  const { page } = TERMINATION_TEXTS[locale];
344
387
  if (outcome.kind === "received") {
345
- return resultPage(locale, outcome.receivedAtIso, outcome.requestId);
388
+ return resultPage(ctx, outcome.receivedAtIso, outcome.requestId);
346
389
  }
347
390
  if (outcome.kind === "rate-limited") {
348
391
  return htmlResponse(
349
- locale,
392
+ ctx,
350
393
  page.rateLimitedTitle,
351
394
  `<h1>${escapeHtml(page.rateLimitedTitle)}</h1><p>${escapeHtml(page.rateLimitedBody)}</p>`,
352
395
  429,
353
396
  );
354
397
  }
355
- return formPage(locale, path, values, [page.errorGeneric], 400);
398
+ return formPage(ctx, values, [page.errorGeneric], 400);
356
399
  },
357
400
  },
358
401
  ];
359
402
  }
360
403
 
361
404
  /** GET + POST pages for de (`/legal/kuendigen`) and en (`/legal/cancel`);
362
- * mount via the app's `extraRoutes`. */
405
+ * mount via the app's `extraRoutes`. The other trailing-slash form of each
406
+ * path 301-redirects (GET/HEAD) to the configured path. */
363
407
  export function createContractTerminationRoutes(
364
408
  options: ContractTerminationRoutesOptions = {},
365
409
  ): AnonymousExtraRoute[] {
410
+ const alternates: Readonly<Record<ConsentLocale, string>> = {
411
+ de: options.paths?.de ?? DEFAULT_PATHS.de,
412
+ en: options.paths?.en ?? DEFAULT_PATHS.en,
413
+ };
414
+ const wrapLayout = options.wrapLayout ?? wrapInLayout;
366
415
  return (["de", "en"] as const).flatMap((locale) =>
367
- routesFor(locale, options.paths?.[locale] ?? DEFAULT_PATHS[locale]),
416
+ routesFor({ locale, path: alternates[locale], wrapLayout, alternates }),
368
417
  );
369
418
  }
@@ -118,6 +118,7 @@ async function sendTerminationMails(
118
118
  declaration: plan.declaration,
119
119
  operatorEmail: consumerProtection.operatorEmail,
120
120
  }),
121
+ locale: plan.locale,
121
122
  priority: "critical",
122
123
  });
123
124
  // skip: no operator notice needed for this declaration
@@ -179,6 +180,7 @@ export function createRequestContractTerminationHandler(
179
180
  schema: requestContractTerminationSchema,
180
181
  access: { roles: ["anonymous"] },
181
182
  rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 600 },
183
+ additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
182
184
  ...(consumerProtection.terminationScope === "platform" && { tenantlessAnonymous: true }),
183
185
  escapeHatch: {
184
186
  reason:
@@ -15,6 +15,7 @@ export type TerminationTexts = {
15
15
  readonly customerReference: string;
16
16
  readonly reasonFieldLabel: string;
17
17
  readonly receiptSubject: Readonly<Record<ContractTerminationDeclarationType, string>>;
18
+ readonly receiptGreeting: string;
18
19
  readonly receiptIntro: string;
19
20
  readonly receiptNextSteps: string;
20
21
  readonly receiptRevoke: (operatorEmail: string) => string;
@@ -93,6 +94,7 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
93
94
  termination: "Eingangsbestätigung deiner Kündigung",
94
95
  withdrawal: "Eingangsbestätigung deines Widerrufs",
95
96
  },
97
+ receiptGreeting: "Hallo,",
96
98
  receiptIntro:
97
99
  "wir bestätigen den Eingang deiner Erklärung mit folgendem Inhalt. Diese E-Mail ist eine Eingangsbestätigung, keine Bestätigung der Wirksamkeit.",
98
100
  receiptNextSteps:
@@ -175,8 +177,9 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
175
177
  termination: "Receipt for your termination",
176
178
  withdrawal: "Receipt for your withdrawal",
177
179
  },
180
+ receiptGreeting: "Hello,",
178
181
  receiptIntro:
179
- "we confirm receipt of your declaration with the following content. This email is a receipt, not a confirmation that the declaration is effective.",
182
+ "We confirm receipt of your declaration with the following content. This email is a receipt, not a confirmation that the declaration is effective.",
180
183
  receiptNextSteps:
181
184
  "We will review your declaration and get back to you once it is processed or if we have questions.",
182
185
  receiptRevoke: (operatorEmail) =>
@@ -8,6 +8,11 @@ import {
8
8
 
9
9
  export type DiscordChannelOptions = ChatWebhookChannelOptions;
10
10
 
11
+ export const DISCORD_SECRET_KEYS = {
12
+ webhookPrefix: "channel-discord:webhooks.",
13
+ webhookKeyFor: (connection: string) => `channel-discord:webhooks.${connection}`,
14
+ } as const;
15
+
11
16
  export const DISCORD_DEFAULT_ALLOWED_HOSTS = ["discord.com", "discordapp.com"] as const;
12
17
  export const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
13
18
  export const DISCORD_CONTENT_MAX_CHARS = 2000;
@@ -1,9 +1,16 @@
1
1
  import {
2
2
  chatConnectionNameSchema,
3
+ chatWebhookUrlSchema,
3
4
  DELIVERY_CHANNEL_EXTENSION,
5
+ resolveChatWebhookTarget,
4
6
  } from "@cosmicdrift/kumiko-bundled-features/delivery";
5
7
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
6
- import { createDiscordChannel, type DiscordChannelOptions } from "./discord-channel.js";
8
+ import {
9
+ createDiscordChannel,
10
+ DISCORD_DEFAULT_ALLOWED_HOSTS,
11
+ DISCORD_WEBHOOK_PATH_PREFIX,
12
+ type DiscordChannelOptions,
13
+ } from "./discord-channel.js";
7
14
 
8
15
  export function createChannelDiscordFeature(
9
16
  options: DiscordChannelOptions = {},
@@ -20,6 +27,13 @@ export function createChannelDiscordFeature(
20
27
  r.requires("delivery");
21
28
  r.requires("secrets");
22
29
 
30
+ const webhookTarget = resolveChatWebhookTarget(
31
+ {
32
+ defaultAllowedHosts: DISCORD_DEFAULT_ALLOWED_HOSTS,
33
+ requiredPathPrefix: DISCORD_WEBHOOK_PATH_PREFIX,
34
+ },
35
+ options,
36
+ );
23
37
  const webhooks = r.secretNamespace("webhooks", {
24
38
  label: { en: "Discord webhooks", de: "Discord-Webhooks", es: "Webhooks de Discord" },
25
39
  hint: {
@@ -29,6 +43,7 @@ export function createChannelDiscordFeature(
29
43
  },
30
44
  scope: "tenant",
31
45
  nameSchema: chatConnectionNameSchema,
46
+ valueSchema: chatWebhookUrlSchema(webhookTarget),
32
47
  });
33
48
 
34
49
  const channel = createDiscordChannel(options, webhooks.keyFor);
@@ -1,2 +1,8 @@
1
- export { createDiscordChannel, type DiscordChannelOptions } from "./discord-channel.js";
1
+ export {
2
+ createDiscordChannel,
3
+ DISCORD_DEFAULT_ALLOWED_HOSTS,
4
+ DISCORD_SECRET_KEYS,
5
+ DISCORD_WEBHOOK_PATH_PREFIX,
6
+ type DiscordChannelOptions,
7
+ } from "./discord-channel.js";
2
8
  export { createChannelDiscordFeature } from "./feature.js";
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.342.0",
4
+ "type": "improvement",
5
+ "title": "EmailMessage.fromName sets the From display name",
6
+ "detail": "channel-email: fromName sets the display name of the From header\n`EmailMessage` gets an optional `fromName`, and the email channel passes `data.fromName` through like `replyTo`. The SMTP transport combines it with the address of `message.from` or the transport default, so `from: \"App <noreply@x>\"` plus `fromName: \"Tenant via veridom\"` sends from `noreply@x` under the new name. Control characters in the name are replaced by spaces before sending, and an empty name behaves like no name. The PII ciphertext guard refuses a ciphertext `fromName`."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -53,11 +53,13 @@ function listUnsubscribeHeaders(
53
53
 
54
54
  function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined): {
55
55
  from?: string;
56
+ fromName?: string;
56
57
  replyTo?: string;
57
58
  headers?: Readonly<Record<string, string>>;
58
59
  } {
59
60
  if (!data) return {};
60
61
  const from = typeof data["from"] === "string" ? data["from"] : undefined;
62
+ const fromName = typeof data["fromName"] === "string" ? data["fromName"] : undefined;
61
63
  const replyTo = typeof data["replyTo"] === "string" ? data["replyTo"] : undefined;
62
64
  const autoHeaders = listUnsubscribeHeaders(data);
63
65
  const explicitHeaders = stringHeaders(data["headers"]);
@@ -71,7 +73,12 @@ function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined):
71
73
  (!overridesUnsubscribe && autoHeaders) || explicitHeaders
72
74
  ? { ...(overridesUnsubscribe ? undefined : autoHeaders), ...explicitHeaders }
73
75
  : undefined;
74
- return { ...(from && { from }), ...(replyTo && { replyTo }), ...(headers && { headers }) };
76
+ return {
77
+ ...(from && { from }),
78
+ ...(fromName && { fromName }),
79
+ ...(replyTo && { replyTo }),
80
+ ...(headers && { headers }),
81
+ };
75
82
  }
76
83
 
77
84
  export type EmailChannelOptions = {
@@ -100,6 +107,7 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
100
107
  const html = await renderer.render({
101
108
  template: message.notificationType,
102
109
  variables,
110
+ locale: message.locale,
103
111
  });
104
112
  const subject = (variables["subject"] as string) ?? message.title; // @cast-boundary dynamic-key
105
113
  return { html, subject };
@@ -12,16 +12,17 @@ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
12
12
  // blob); ciphertext in subject/body fails loud in dev and is redacted+logged
13
13
  // in prod.
14
14
  export function guardEmailMessage(message: EmailMessage): EmailMessage {
15
- // Every envelope address, not just `to`: a ciphertext From or Reply-To is
15
+ // Every envelope address, not just `to`: a ciphertext From, From-Name or Reply-To is
16
16
  // the same garbage as a ciphertext recipient — refuse rather than mail it.
17
17
  for (const [label, address] of [
18
- ["recipient", message.to],
19
- ["from", message.from],
20
- ["reply-to", message.replyTo],
18
+ ["recipient address", message.to],
19
+ ["from address", message.from],
20
+ ["from name", message.fromName],
21
+ ["reply-to address", message.replyTo],
21
22
  ] as const) {
22
23
  if (address?.includes(CIPHERTEXT_MARKER)) {
23
24
  throw new Error(
24
- `[channel-email] refusing to send: ${label} address is a PII ciphertext ` +
25
+ `[channel-email] refusing to send: ${label} is a PII ciphertext ` +
25
26
  `("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
26
27
  );
27
28
  }
@@ -17,6 +17,7 @@
17
17
  // dedizierter Queue (BullMQ + retry) drüberlegen.
18
18
 
19
19
  import { createTransport, type Transporter } from "nodemailer";
20
+ import addressparser from "nodemailer/lib/addressparser";
20
21
  import type { EmailMessage, EmailTransport } from "./types.js";
21
22
 
22
23
  export type SmtpTransportOptions = {
@@ -48,6 +49,31 @@ export type SmtpTransportOptions = {
48
49
  readonly servername?: string;
49
50
  };
50
51
 
52
+ function isControlCharacter(char: string): boolean {
53
+ const code = char.charCodeAt(0);
54
+ return code <= 0x1f || code === 0x7f;
55
+ }
56
+
57
+ // Control characters in a display name would let a caller inject headers.
58
+ function sanitizedDisplayName(name: string | undefined): string | undefined {
59
+ const cleaned = name
60
+ ?.split("")
61
+ .map((char) => (isControlCharacter(char) ? " " : char))
62
+ .join("")
63
+ .trim();
64
+ return cleaned === undefined || cleaned === "" ? undefined : cleaned;
65
+ }
66
+
67
+ function fromHeader(
68
+ address: string,
69
+ fromName: string | undefined,
70
+ ): string | { readonly name: string; readonly address: string } {
71
+ const name = sanitizedDisplayName(fromName);
72
+ if (name === undefined) return address;
73
+ const [parsed] = addressparser(address, { flatten: true });
74
+ return parsed?.address ? { name, address: parsed.address } : address;
75
+ }
76
+
51
77
  export function createSmtpTransport(options: SmtpTransportOptions): EmailTransport {
52
78
  const transporter: Transporter = createTransport({
53
79
  host: options.host,
@@ -62,7 +88,7 @@ export function createSmtpTransport(options: SmtpTransportOptions): EmailTranspo
62
88
  return {
63
89
  async send(message: EmailMessage): Promise<void> {
64
90
  await transporter.sendMail({
65
- from: message.from ?? options.from,
91
+ from: fromHeader(message.from ?? options.from, message.fromName),
66
92
  to: message.to,
67
93
  subject: message.subject,
68
94
  html: message.html,
@@ -7,6 +7,10 @@ export type EmailMessage = {
7
7
  // app-wide default). Set when one send must originate from a specific
8
8
  // mailbox — a reply from the address the original mail reached, not noreply@.
9
9
  readonly from?: string;
10
+ // Display name for the From header. Combined with the address of `from`
11
+ // (or the transport default), so a per-tenant sender name needs no
12
+ // hand-built "Name <addr>" string.
13
+ readonly fromName?: string;
10
14
  // Reply-To header: where a recipient's answer goes when it differs from From.
11
15
  readonly replyTo?: string;
12
16
  // Extra RFC-5322 headers — In-Reply-To / References thread a reply into the
@@ -1,9 +1,15 @@
1
1
  import {
2
2
  chatConnectionNameSchema,
3
+ chatWebhookUrlSchema,
3
4
  DELIVERY_CHANNEL_EXTENSION,
5
+ resolveChatWebhookTarget,
4
6
  } from "@cosmicdrift/kumiko-bundled-features/delivery";
5
7
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
6
- import { createSlackChannel, type SlackChannelOptions } from "./slack-channel.js";
8
+ import {
9
+ createSlackChannel,
10
+ SLACK_DEFAULT_ALLOWED_HOSTS,
11
+ type SlackChannelOptions,
12
+ } from "./slack-channel.js";
7
13
 
8
14
  export function createChannelSlackFeature(options: SlackChannelOptions = {}): FeatureDefinition {
9
15
  return defineFeature("channel-slack", (r) => {
@@ -18,6 +24,10 @@ export function createChannelSlackFeature(options: SlackChannelOptions = {}): Fe
18
24
  r.requires("delivery");
19
25
  r.requires("secrets");
20
26
 
27
+ const webhookTarget = resolveChatWebhookTarget(
28
+ { defaultAllowedHosts: SLACK_DEFAULT_ALLOWED_HOSTS },
29
+ options,
30
+ );
21
31
  const webhooks = r.secretNamespace("webhooks", {
22
32
  label: { en: "Slack webhooks", de: "Slack-Webhooks", es: "Webhooks de Slack" },
23
33
  hint: {
@@ -27,6 +37,7 @@ export function createChannelSlackFeature(options: SlackChannelOptions = {}): Fe
27
37
  },
28
38
  scope: "tenant",
29
39
  nameSchema: chatConnectionNameSchema,
40
+ valueSchema: chatWebhookUrlSchema(webhookTarget),
30
41
  });
31
42
 
32
43
  const channel = createSlackChannel(options, webhooks.keyFor);
@@ -1,2 +1,7 @@
1
1
  export { createChannelSlackFeature } from "./feature.js";
2
- export { createSlackChannel, type SlackChannelOptions } from "./slack-channel.js";
2
+ export {
3
+ createSlackChannel,
4
+ SLACK_DEFAULT_ALLOWED_HOSTS,
5
+ SLACK_SECRET_KEYS,
6
+ type SlackChannelOptions,
7
+ } from "./slack-channel.js";
@@ -7,6 +7,11 @@ import {
7
7
 
8
8
  export type SlackChannelOptions = ChatWebhookChannelOptions;
9
9
 
10
+ export const SLACK_SECRET_KEYS = {
11
+ webhookPrefix: "channel-slack:webhooks.",
12
+ webhookKeyFor: (connection: string) => `channel-slack:webhooks.${connection}`,
13
+ } as const;
14
+
10
15
  export const SLACK_DEFAULT_ALLOWED_HOSTS = ["hooks.slack.com"] as const;
11
16
 
12
17
  // Slack mrkdwn treats & < > as control characters (links, @-mentions via <!channel>).
@@ -1,9 +1,15 @@
1
1
  import {
2
2
  chatConnectionNameSchema,
3
+ chatWebhookUrlSchema,
3
4
  DELIVERY_CHANNEL_EXTENSION,
5
+ resolveChatWebhookTarget,
4
6
  } from "@cosmicdrift/kumiko-bundled-features/delivery";
5
7
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
6
- import { createTeamsChannel, type TeamsChannelOptions } from "./teams-channel.js";
8
+ import {
9
+ createTeamsChannel,
10
+ TEAMS_DEFAULT_ALLOWED_HOSTS,
11
+ type TeamsChannelOptions,
12
+ } from "./teams-channel.js";
7
13
 
8
14
  export function createChannelTeamsFeature(options: TeamsChannelOptions = {}): FeatureDefinition {
9
15
  return defineFeature("channel-teams", (r) => {
@@ -18,6 +24,10 @@ export function createChannelTeamsFeature(options: TeamsChannelOptions = {}): Fe
18
24
  r.requires("delivery");
19
25
  r.requires("secrets");
20
26
 
27
+ const webhookTarget = resolveChatWebhookTarget(
28
+ { defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS },
29
+ options,
30
+ );
21
31
  const webhooks = r.secretNamespace("webhooks", {
22
32
  label: {
23
33
  en: "Microsoft Teams webhooks",
@@ -31,6 +41,7 @@ export function createChannelTeamsFeature(options: TeamsChannelOptions = {}): Fe
31
41
  },
32
42
  scope: "tenant",
33
43
  nameSchema: chatConnectionNameSchema,
44
+ valueSchema: chatWebhookUrlSchema(webhookTarget),
34
45
  });
35
46
 
36
47
  const channel = createTeamsChannel(options, webhooks.keyFor);
@@ -1,2 +1,7 @@
1
1
  export { createChannelTeamsFeature } from "./feature.js";
2
- export { createTeamsChannel, type TeamsChannelOptions } from "./teams-channel.js";
2
+ export {
3
+ createTeamsChannel,
4
+ TEAMS_DEFAULT_ALLOWED_HOSTS,
5
+ TEAMS_SECRET_KEYS,
6
+ type TeamsChannelOptions,
7
+ } from "./teams-channel.js";
@@ -6,6 +6,11 @@ import {
6
6
 
7
7
  export type TeamsChannelOptions = ChatWebhookChannelOptions;
8
8
 
9
+ export const TEAMS_SECRET_KEYS = {
10
+ webhookPrefix: "channel-teams:webhooks.",
11
+ webhookKeyFor: (connection: string) => `channel-teams:webhooks.${connection}`,
12
+ } as const;
13
+
9
14
  export const TEAMS_DEFAULT_ALLOWED_HOSTS = [
10
15
  ".webhook.office.com",
11
16
  ".logic.azure.com",
@@ -0,0 +1,9 @@
1
+ [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "breaking",
5
+ "title": "Telegram apiBaseUrl must match allowedHosts and use https",
6
+ "detail": "`ctx.secrets.set` now runs the same `valueSchema` check as `secrets:write:set` and throws the same validation error (`secrets.errors.invalidValue`, never carrying the value) before anything is encrypted or stored. `createSecretsContext` takes a required `registry` option that supplies the declared schemas; `runProdApp`, `runDevApp` and `setupTestStack` setups pass the booted registry.\n`createChannelTelegramFeature` takes `allowedHosts` (default `[\"api.telegram.org\"]`) and `requireHttps` (default `true`), like the other chat channels. Boot fails when the host of `apiBaseUrl` is not in `allowedHosts` or the URL is not https while `requireHttps` is on. Before, the host of `apiBaseUrl` was its own allowlist and https was only required when the URL already started with `https:`.",
7
+ "migration": "An app that sets a custom apiBaseUrl (self-hosted Bot API server, local test server) must also pass allowedHosts: [\"<host>\"], plus requireHttps: false for plain http. Without it the app fails at boot."
8
+ }
9
+ ]
@@ -1,13 +1,18 @@
1
1
  import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
2
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
- import { createTelegramChannel, type TelegramChannelOptions } from "./telegram-channel.js";
3
+ import * as z from "zod";
4
+ import {
5
+ createTelegramChannel,
6
+ isTelegramBotToken,
7
+ type TelegramChannelOptions,
8
+ } from "./telegram-channel.js";
4
9
 
5
10
  export function createChannelTelegramFeature(
6
11
  options: TelegramChannelOptions = {},
7
12
  ): FeatureDefinition {
8
13
  return defineFeature("channel-telegram", (r) => {
9
14
  r.describe(
10
- 'Sends delivery notifications through the Telegram Bot API (`sendMessage`, plain text), registered as the `telegram` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { telegram: "<chat id or @channel>" } })`. The bot token is the tenant secret `channel-telegram:secret:bot-token`; the token never reaches logs or delivery attempts. Requires `delivery` and `secrets`. The API base URL and request timeout are app options of `createChannelTelegramFeature(opts)`, never tenant config.',
15
+ 'Sends delivery notifications through the Telegram Bot API (`sendMessage`, plain text), registered as the `telegram` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { telegram: "<chat id or @channel>" } })`. The bot token is the tenant secret `channel-telegram:secret:bot-token`; the token never reaches logs or delivery attempts. Requires `delivery` and `secrets`. The API base URL, its host allowlist (`allowedHosts`, default `api.telegram.org`), `requireHttps` (default true; boot fails on a mismatch) and the request timeout are app options of `createChannelTelegramFeature(opts)`, never tenant config.',
11
16
  );
12
17
  r.uiHints({
13
18
  displayLabel: "Telegram Channel",
@@ -24,6 +29,7 @@ export function createChannelTelegramFeature(
24
29
  },
25
30
  redact: () => "••••••••",
26
31
  scope: "tenant",
32
+ valueSchema: z.string().refine(isTelegramBotToken, "invalid bot token"),
27
33
  });
28
34
 
29
35
  const channel = createTelegramChannel(options, botToken.name);