@cosmicdrift/kumiko-samples 0.340.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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
- package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
- package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
- package/packages/bundled-features/src/channel-discord/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
- package/packages/bundled-features/src/channel-slack/index.ts +6 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
- package/packages/bundled-features/src/channel-teams/index.ts +6 -1
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
- package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
- package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
- package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
- package/packages/bundled-features/src/delivery/index.ts +4 -0
- package/packages/bundled-features/src/delivery/jobs.ts +3 -1
- package/packages/bundled-features/src/delivery/types.ts +10 -4
- package/packages/bundled-features/src/files/changes.json +13 -1
- package/packages/bundled-features/src/legal-pages/README.md +26 -3
- package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
- package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
- package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
- package/packages/bundled-features/src/page-render/index.ts +2 -0
- package/packages/bundled-features/src/page-render/layout.ts +17 -0
- package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
- package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
- package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
- package/packages/bundled-features/src/secrets/i18n.ts +4 -0
- package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
- package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
- package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
- package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
- package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
- package/packages/bundled-features/src/waitlist/changes.json +17 -0
- package/packages/bundled-features/src/waitlist/constants.ts +44 -0
- package/packages/bundled-features/src/waitlist/entity.ts +76 -0
- package/packages/bundled-features/src/waitlist/feature.ts +50 -0
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
- package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
- package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
- package/packages/bundled-features/src/waitlist/index.ts +15 -0
- package/packages/bundled-features/src/waitlist/lib.ts +36 -0
- package/packages/bundled-features/src/waitlist/mail.ts +114 -0
- package/packages/bundled-features/src/waitlist/options.ts +41 -0
- package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
- package/packages/bundled-features/src/waitlist/screens.ts +43 -0
- package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
- package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/chat-channels/README.md +2 -0
- package/samples/recipes/managed-config/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +21 -0
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
83
|
+
ctx: PageContext,
|
|
63
84
|
title: string,
|
|
64
85
|
bodyHtml: string,
|
|
65
|
-
status: 200 | 400 | 429
|
|
86
|
+
status: 200 | 400 | 429,
|
|
66
87
|
): Response {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
259
|
+
return htmlResponse(ctx, page.reviewTitle, body, 200);
|
|
231
260
|
}
|
|
232
261
|
|
|
233
|
-
function resultPage(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
380
|
+
if (step === "edit") return formPage(ctx, values, [], 200);
|
|
338
381
|
const messages = validationMessages(values, locale);
|
|
339
|
-
if (messages.length > 0) return formPage(
|
|
340
|
-
if (step !== "confirm") return reviewPage(
|
|
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(
|
|
388
|
+
return resultPage(ctx, outcome.receivedAtIso, outcome.requestId);
|
|
346
389
|
}
|
|
347
390
|
if (outcome.kind === "rate-limited") {
|
|
348
391
|
return htmlResponse(
|
|
349
|
-
|
|
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(
|
|
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,
|
|
416
|
+
routesFor({ locale, path: alternates[locale], wrapLayout, alternates }),
|
|
368
417
|
);
|
|
369
418
|
}
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts
CHANGED
|
@@ -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:
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts
CHANGED
|
@@ -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
|
-
"
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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);
|
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
export { createChannelTelegramFeature } from "./feature.js";
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
createTelegramChannel,
|
|
4
|
+
isTelegramBotToken,
|
|
5
|
+
isTelegramChatId,
|
|
6
|
+
TELEGRAM_DEFAULT_API_BASE_URL,
|
|
7
|
+
TELEGRAM_SECRET_KEYS,
|
|
8
|
+
type TelegramChannelOptions,
|
|
9
|
+
} from "./telegram-channel.js";
|
|
@@ -10,30 +10,65 @@ import {
|
|
|
10
10
|
|
|
11
11
|
export type TelegramChannelOptions = {
|
|
12
12
|
// App-level override (tests, self-hosted Bot API server); never tenant config.
|
|
13
|
+
// Its host must be listed in `allowedHosts`, otherwise boot fails.
|
|
13
14
|
readonly apiBaseUrl?: string;
|
|
15
|
+
// Replaces the default allowlist (`api.telegram.org`); required for a self-hosted Bot API server.
|
|
16
|
+
readonly allowedHosts?: readonly string[];
|
|
17
|
+
// Default true; `false` only for local http test servers.
|
|
18
|
+
readonly requireHttps?: boolean;
|
|
14
19
|
readonly timeoutMs?: number;
|
|
15
20
|
};
|
|
16
21
|
|
|
17
22
|
export const TELEGRAM_DEFAULT_API_BASE_URL = "https://api.telegram.org";
|
|
23
|
+
export const TELEGRAM_DEFAULT_ALLOWED_HOSTS: readonly string[] = ["api.telegram.org"];
|
|
18
24
|
export const TELEGRAM_TEXT_MAX_CHARS = 4096;
|
|
25
|
+
export const TELEGRAM_SECRET_KEYS = { botToken: "channel-telegram:secret:bot-token" } as const;
|
|
19
26
|
|
|
20
27
|
// Numeric chat/group id (groups and channels are negative) or a public @channelname.
|
|
21
28
|
const TELEGRAM_CHAT_ID_PATTERN = /^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{3,31})$/;
|
|
22
29
|
// `<bot id>:<secret>`; the token is spliced into the URL path, so only the real shape passes.
|
|
23
30
|
const TELEGRAM_BOT_TOKEN_PATTERN = /^\d{1,20}:[A-Za-z0-9_-]{10,}$/;
|
|
24
31
|
|
|
32
|
+
export function isTelegramChatId(address: string): boolean {
|
|
33
|
+
return TELEGRAM_CHAT_ID_PATTERN.test(address);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isTelegramBotToken(token: string): boolean {
|
|
37
|
+
return TELEGRAM_BOT_TOKEN_PATTERN.test(token);
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
export function createTelegramChannel(
|
|
26
41
|
options: TelegramChannelOptions,
|
|
27
42
|
botTokenKey: string,
|
|
28
43
|
): DeliveryChannel {
|
|
29
44
|
const apiBaseUrl = options.apiBaseUrl ?? TELEGRAM_DEFAULT_API_BASE_URL;
|
|
45
|
+
const allowedHosts = options.allowedHosts ?? TELEGRAM_DEFAULT_ALLOWED_HOSTS;
|
|
46
|
+
const requireHttps = options.requireHttps ?? true;
|
|
47
|
+
|
|
48
|
+
// Fail at boot: a misconfigured base URL must not surface as per-send failures.
|
|
49
|
+
let parsedBase: URL;
|
|
50
|
+
try {
|
|
51
|
+
parsedBase = new URL(apiBaseUrl);
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error("channel-telegram: apiBaseUrl is not a valid URL");
|
|
54
|
+
}
|
|
55
|
+
if (!allowedHosts.includes(parsedBase.hostname)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`channel-telegram: apiBaseUrl host "${parsedBase.hostname}" is not in allowedHosts`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (requireHttps && parsedBase.protocol !== "https:") {
|
|
61
|
+
throw new Error(
|
|
62
|
+
"channel-telegram: apiBaseUrl must use https (set requireHttps: false to opt out)",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
30
65
|
|
|
31
66
|
return {
|
|
32
67
|
name: "telegram",
|
|
33
68
|
mode: "queued",
|
|
34
69
|
|
|
35
70
|
async send(address, message, ctx) {
|
|
36
|
-
if (!
|
|
71
|
+
if (!isTelegramChatId(address)) {
|
|
37
72
|
return toChannelResult(address, { ok: false, code: "invalid_address" });
|
|
38
73
|
}
|
|
39
74
|
let token: string | undefined;
|
|
@@ -46,21 +81,14 @@ export function createTelegramChannel(
|
|
|
46
81
|
} catch {
|
|
47
82
|
token = undefined;
|
|
48
83
|
}
|
|
49
|
-
if (!token || !
|
|
84
|
+
if (!token || !isTelegramBotToken(token)) {
|
|
50
85
|
return toChannelResult(address, { ok: false, code: "missing_credentials" });
|
|
51
86
|
}
|
|
52
87
|
|
|
53
|
-
// The base URL is the allowlist: only its own host is reachable.
|
|
54
|
-
let baseHost: string;
|
|
55
|
-
try {
|
|
56
|
-
baseHost = new URL(apiBaseUrl).hostname;
|
|
57
|
-
} catch {
|
|
58
|
-
return toChannelResult(address, { ok: false, code: "host_not_allowed" });
|
|
59
|
-
}
|
|
60
88
|
const result = await postChatWebhook({
|
|
61
89
|
url: `${apiBaseUrl.replace(/\/+$/, "")}/bot${token}/sendMessage`,
|
|
62
|
-
allowedHosts
|
|
63
|
-
requireHttps
|
|
90
|
+
allowedHosts,
|
|
91
|
+
requireHttps,
|
|
64
92
|
timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
|
|
65
93
|
// No parse_mode: Telegram renders the text literally.
|
|
66
94
|
body: {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.341.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Chat channels deliver in production boot, ctx.notify returns a NotifyResult",
|
|
6
|
+
"detail": "Chat channels (slack, discord, teams, telegram) now deliver in production boot. `runProdApp`, `runDevApp` and `runWorkerApp` pass the tenant secrets to the delivery service and hand queued channels to the `delivery.render`/`delivery.send` jobs of the calling context's job runner. `delivery.render` now receives `ctx.secrets` as well. Without a job runner, queued channels still deliver inline, now with secrets. `runBootstrap` is a one-shot process whose queue nobody drains after it exits, so it keeps delivering queued channels inline (the SystemAdmin invitation goes out before the process ends).\n`NotifyFn` returns a `NotifyResult` (`{ deliveries }` with channel, recipientId, status `queued | sent | failed | skipped`, error and `deliveryAttemptId` per delivery) instead of `void`. `NotifyOptions.immediate` delivers queued channels inline for one call and bypasses job retry, for \"send test message\" handlers. `NotifyFactory` takes an optional job dispatcher as third argument, and `DeliveryService.notify` an optional per-call dispatcher.\nConsumers: `NotifyFn` implementations in tests and mocks must now return a `NotifyResult`, e.g. `async () => ({ deliveries: [] })`. When running the API without a worker (`runSingleInstance: false`), a dedicated worker must process the delivery jobs.",
|
|
7
|
+
"migration": "NotifyFn implementations in tests and mocks must return a NotifyResult, e.g. async () => ({ deliveries: [] }). Queued channels now run through the delivery jobs in production; an API-only deployment (runSingleInstance: false) needs a worker that mounts delivery and the channel features."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.339.0",
|
|
4
11
|
"type": "improvement",
|