@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.
- 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 +13 -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/billing-foundation/projection.ts +35 -41
- 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/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
- 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/changes.json +13 -6
- package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
- 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/packages/bundled-features/src/workflow-runner/changes.json +7 -0
- package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
- package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
- 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/db/queries/payment-projection.ts +0 -19
- package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
- package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
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) =>
|
|
@@ -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 {
|
|
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.
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
113
|
+
currentPeriodEnd: fullPayload.currentPeriodEndIso,
|
|
114
|
+
cancelAt: fullPayload.cancelAtIso ?? null,
|
|
115
|
+
modifiedAt: event.createdAt,
|
|
104
116
|
};
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
214
|
+
await insertOnConflictDoNothing(tx, paymentsProjectionMeta, {
|
|
221
215
|
id: paymentRowId(event.tenantId, providerName, providerEventId),
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 {
|
|
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);
|