@cosmicdrift/kumiko-samples 0.339.0 → 0.341.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 (115) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
  4. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  5. package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
  6. package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
  7. package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
  9. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  14. package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
  15. package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
  16. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
  17. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  18. package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
  19. package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
  20. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
  21. package/packages/bundled-features/src/billing-foundation/changes.json +13 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
  28. package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
  29. package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
  30. package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
  31. package/packages/bundled-features/src/channel-discord/index.ts +7 -1
  32. package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
  33. package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
  34. package/packages/bundled-features/src/channel-slack/index.ts +6 -1
  35. package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
  36. package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
  37. package/packages/bundled-features/src/channel-teams/index.ts +6 -1
  38. package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
  39. package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
  40. package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
  41. package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
  42. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
  43. package/packages/bundled-features/src/delivery/changes.json +7 -0
  44. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
  45. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
  46. package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
  47. package/packages/bundled-features/src/delivery/index.ts +4 -0
  48. package/packages/bundled-features/src/delivery/jobs.ts +3 -1
  49. package/packages/bundled-features/src/delivery/types.ts +10 -4
  50. package/packages/bundled-features/src/files/changes.json +13 -1
  51. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  52. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
  53. package/packages/bundled-features/src/legal-pages/README.md +26 -3
  54. package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
  55. package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
  56. package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
  57. package/packages/bundled-features/src/page-render/index.ts +2 -0
  58. package/packages/bundled-features/src/page-render/layout.ts +17 -0
  59. package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
  60. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  61. package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
  62. package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
  63. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
  64. package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
  65. package/packages/bundled-features/src/secrets/changes.json +12 -0
  66. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  67. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
  68. package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
  69. package/packages/bundled-features/src/secrets/i18n.ts +4 -0
  70. package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
  71. package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
  72. package/packages/bundled-features/src/tenant/changes.json +6 -0
  73. package/packages/bundled-features/src/tenant/constants.ts +1 -0
  74. package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
  75. package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
  76. package/packages/bundled-features/src/tenant/index.ts +1 -0
  77. package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
  78. package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
  79. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
  80. package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
  81. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
  82. package/packages/bundled-features/src/user/changes.json +13 -6
  83. package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
  84. package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
  85. package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
  86. package/packages/bundled-features/src/waitlist/changes.json +17 -0
  87. package/packages/bundled-features/src/waitlist/constants.ts +44 -0
  88. package/packages/bundled-features/src/waitlist/entity.ts +76 -0
  89. package/packages/bundled-features/src/waitlist/feature.ts +50 -0
  90. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
  91. package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
  92. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
  93. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
  94. package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
  95. package/packages/bundled-features/src/waitlist/index.ts +15 -0
  96. package/packages/bundled-features/src/waitlist/lib.ts +36 -0
  97. package/packages/bundled-features/src/waitlist/mail.ts +114 -0
  98. package/packages/bundled-features/src/waitlist/options.ts +41 -0
  99. package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
  100. package/packages/bundled-features/src/waitlist/screens.ts +43 -0
  101. package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
  102. package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
  103. package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
  104. package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
  105. package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
  106. package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
  107. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  108. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  109. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  110. package/samples/recipes/chat-channels/README.md +2 -0
  111. package/samples/recipes/managed-config/README.md +1 -1
  112. package/samples/recipes/rate-limiting/README.md +21 -0
  113. package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
  114. package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
  115. package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
@@ -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) =>
@@ -14,11 +14,14 @@
14
14
  // `drizzle/generate.ts` ergänzen (= via subscriptionsProjectionTable-
15
15
  // import). setupTestStack pusht sie automatisch via r.projection.table.
16
16
 
17
- import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
17
+ import {
18
+ insertOnConflictDoNothing,
19
+ requireEntityTableMeta,
20
+ upsertOnConflict,
21
+ } from "@cosmicdrift/kumiko-framework/bun-db";
22
+ import { buildEntityTable, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
18
23
  import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
19
24
  import { paymentRowId } from "./aggregate-id.js";
20
- import { insertPaymentProjectionRow } from "./db/queries/payment-projection.js";
21
- import { upsertSubscriptionProjectionRow } from "./db/queries/subscription-projection.js";
22
25
  import { paymentEntity, subscriptionEntity } from "./entities.js";
23
26
  import type { PaymentEventPayload, SubscriptionEventPayload } from "./events.js";
24
27
 
@@ -32,6 +35,17 @@ export const subscriptionsProjectionTable = buildEntityTable("subscription", sub
32
35
  // production must add read_payments to their own drizzle/generate.ts too.
33
36
  export const paymentsProjectionTable = buildEntityTable("payment", paymentEntity);
34
37
 
38
+ // Unbranded metas: the apply functions below ARE the executor of these
39
+ // projections, so they may write through the typed helpers.
40
+ const subscriptionsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
41
+ subscriptionsProjectionTable,
42
+ "subscription",
43
+ );
44
+ const paymentsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
45
+ paymentsProjectionTable,
46
+ "payment",
47
+ );
48
+
35
49
  // =============================================================================
36
50
  // Shared helpers
37
51
  // =============================================================================
@@ -69,9 +83,7 @@ function cancelAtSetFromPayload(p: SubscriptionEventPayload): {
69
83
  * on every apply including the initial create — a projection rebuild at a
70
84
  * different wall-clock time must materialize the exact same row, and
71
85
  * `getSubscriptionForTenant`'s `lastChangedAt` (staleness check for
72
- * incomplete subscriptions) depends on that determinism. Raw `.unsafe()`
73
- * bypasses the typed query-builder's Temporal.Instant-to-string coercion,
74
- * hence the explicit `.toString()`. */
86
+ * incomplete subscriptions) depends on that determinism. */
75
87
  async function upsert(
76
88
  tx: Parameters<Parameters<typeof defineApply<SubscriptionEventPayload>>[0]>[1],
77
89
  event: { aggregateId: string; tenantId: string; createdAt: Temporal.Instant },
@@ -92,38 +104,21 @@ async function upsert(
92
104
  // INSERT-Pfad und nur den teil-`set` für ON CONFLICT.
93
105
  const insertCols = {
94
106
  id: event.aggregateId,
95
- tenant_id: event.tenantId,
96
- provider_name: fullPayload.providerName,
97
- provider_customer_id: fullPayload.providerCustomerId,
98
- provider_subscription_id: fullPayload.providerSubscriptionId,
107
+ tenantId: event.tenantId,
108
+ providerName: fullPayload.providerName,
109
+ providerCustomerId: fullPayload.providerCustomerId,
110
+ providerSubscriptionId: fullPayload.providerSubscriptionId,
99
111
  status: fullPayload.status,
100
112
  tier: fullPayload.tier,
101
- current_period_end: fullPayload.currentPeriodEndIso,
102
- cancel_at: fullPayload.cancelAtIso ?? null,
103
- modified_at: event.createdAt.toString(),
113
+ currentPeriodEnd: fullPayload.currentPeriodEndIso,
114
+ cancelAt: fullPayload.cancelAtIso ?? null,
115
+ modifiedAt: event.createdAt,
104
116
  };
105
- // Map camelCase set-keys to snake_case DB columns.
106
- const setMap: Record<keyof typeof set, string> = {
107
- providerName: "provider_name",
108
- providerCustomerId: "provider_customer_id",
109
- providerSubscriptionId: "provider_subscription_id",
110
- status: "status",
111
- tier: "tier",
112
- currentPeriodEnd: "current_period_end",
113
- cancelAt: "cancel_at",
114
- };
115
- const insertParams = Object.values(insertCols);
116
- const setEntries = Object.entries(set).filter(([, v]) => v !== undefined);
117
- // modified_at is already bound as the last insertParams placeholder —
118
- // reused (not re-pushed) for the ON CONFLICT SET clause below.
119
- const setClauses: string[] = [`"modified_at" = $${insertParams.length}`];
120
- const allParams: unknown[] = [...insertParams];
121
- for (const [k, v] of setEntries) {
122
- allParams.push(v);
123
- setClauses.push(`"${setMap[k as keyof typeof set]}" = $${allParams.length}`);
124
- }
125
- const tableName = (subscriptionsProjectionTable as { tableName: string }).tableName;
126
- await upsertSubscriptionProjectionRow(tx, tableName, insertCols, setClauses, allParams);
117
+ const definedSet = Object.fromEntries(Object.entries(set).filter(([, v]) => v !== undefined));
118
+ await upsertOnConflict(tx, subscriptionsProjectionMeta, insertCols, {
119
+ conflictKeys: ["id"],
120
+ update: { modifiedAt: event.createdAt, ...definedSet },
121
+ });
127
122
  }
128
123
 
129
124
  // =============================================================================
@@ -209,7 +204,6 @@ export const applyInvoicePaymentFailed = defineApply<SubscriptionEventPayload>(
209
204
  * the second tenant's row. ON CONFLICT DO NOTHING also makes a projection
210
205
  * rebuild idempotent without mutating an existing row. */
211
206
  export const applyPaymentReceived = defineApply<PaymentEventPayload>(async (event, tx) => {
212
- const tableName = (paymentsProjectionTable as { tableName: string }).tableName;
213
207
  const providerEventId = event.metadata.headers?.["providerEventId"];
214
208
  const providerName = event.metadata.headers?.["providerName"];
215
209
  if (typeof providerEventId !== "string" || typeof providerName !== "string") {
@@ -217,11 +211,11 @@ export const applyPaymentReceived = defineApply<PaymentEventPayload>(async (even
217
211
  "applyPaymentReceived: event.metadata.headers is missing providerEventId/providerName — process-payment-event.write.ts always sets both",
218
212
  );
219
213
  }
220
- await insertPaymentProjectionRow(tx, tableName, {
214
+ await insertOnConflictDoNothing(tx, paymentsProjectionMeta, {
221
215
  id: paymentRowId(event.tenantId, providerName, providerEventId),
222
- tenant_id: event.tenantId,
223
- provider_name: event.payload.providerName,
224
- provider_customer_id: event.payload.providerCustomerId,
225
- price_id: event.payload.priceId,
216
+ tenantId: event.tenantId,
217
+ providerName: event.payload.providerName,
218
+ providerCustomerId: event.payload.providerCustomerId,
219
+ priceId: event.payload.priceId,
226
220
  });
227
221
  });
@@ -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";
@@ -100,6 +100,7 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
100
100
  const html = await renderer.render({
101
101
  template: message.notificationType,
102
102
  variables,
103
+ locale: message.locale,
103
104
  });
104
105
  const subject = (variables["subject"] as string) ?? message.title; // @cast-boundary dynamic-key
105
106
  return { html, subject };
@@ -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);