@cosmicdrift/kumiko-samples 0.338.0 → 0.340.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 +21 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -6
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +2 -6
- package/packages/bundled-features/src/auth-email-password/handlers/invite-info.query.ts +3 -5
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -8
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +35 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +60 -0
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +10 -0
- package/packages/bundled-features/src/auth-email-password/web/index.ts +8 -0
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +15 -2
- package/packages/bundled-features/src/auth-mfa/feature.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -7
- package/packages/bundled-features/src/auth-mfa/handlers/enable-start-preauth.write.ts +1 -5
- package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -7
- package/packages/bundled-features/src/auth-mfa/mfa-code-verifier.ts +1 -5
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +1 -7
- package/packages/bundled-features/src/billing-foundation/changes.json +41 -0
- package/packages/bundled-features/src/billing-foundation/constants.ts +11 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +3 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +48 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +10 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +44 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +32 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +129 -33
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +13 -59
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +20 -21
- package/packages/bundled-features/src/billing-foundation/events.ts +18 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +4 -5
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +6 -5
- package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
- package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +4 -4
- package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
- package/packages/bundled-features/src/channel-discord/index.ts +2 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
- package/packages/bundled-features/src/channel-slack/index.ts +2 -0
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
- package/packages/bundled-features/src/channel-teams/index.ts +2 -0
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
- package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
- package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
- package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
- package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
- package/packages/bundled-features/src/data-retention/feature.ts +2 -3
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
- package/packages/bundled-features/src/delivery/index.ts +15 -0
- package/packages/bundled-features/src/delivery/jobs.ts +1 -1
- package/packages/bundled-features/src/delivery/testing.ts +2 -0
- package/packages/bundled-features/src/delivery/types.ts +8 -3
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
- package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
- package/packages/bundled-features/src/form-draft/feature.ts +1 -1
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +14 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
- package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
- package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
- package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
- package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/sessions/feature.ts +1 -1
- package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
- package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
- package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
- 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-data-rights/feature.ts +4 -6
- package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
- package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
- package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +14 -0
- package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
- package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
- package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
- package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
- package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
- package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
- package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
- package/samples/apps/use-all-bundled/tsconfig.json +1 -0
- package/samples/recipes/chat-channels/README.md +19 -0
- package/samples/recipes/chat-channels/package.json +9 -0
- package/samples/recipes/chat-channels/src/feature.ts +40 -0
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
- package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
- package/samples/recipes/workflow-engine/src/workflow-runner.ts +12 -2
- 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
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import * as z from "zod";
|
|
14
14
|
import { BILLING_FOUNDATION_FEATURE, SubscriptionStatuses } from "./constants.js";
|
|
15
|
+
import { CONSENT_LOCALES } from "./consumer-protection/consent-locale.js";
|
|
15
16
|
|
|
16
17
|
// Aggregate-type für den event-store. Eine subscription pro Tenant ist
|
|
17
18
|
// ein stream; der subscriptionAggregateId-helper liefert die stream-id.
|
|
@@ -196,6 +197,23 @@ export type ContractTerminationRequestedPayload = z.infer<
|
|
|
196
197
|
typeof contractTerminationRequestedPayloadSchema
|
|
197
198
|
>;
|
|
198
199
|
|
|
200
|
+
// A public declaration matched to this tenant. The provider cancel runs in a
|
|
201
|
+
// job triggered by this event, so the request itself only looks up and appends.
|
|
202
|
+
export const CONTRACT_TERMINATION_DECLARED_EVENT_SHORT = "contract-termination-declared" as const;
|
|
203
|
+
export const CONTRACT_TERMINATION_DECLARED_EVENT_QN =
|
|
204
|
+
`${BILLING_FOUNDATION_FEATURE}:event:${CONTRACT_TERMINATION_DECLARED_EVENT_SHORT}` as const;
|
|
205
|
+
|
|
206
|
+
export const contractTerminationDeclaredPayloadSchema = z.object({
|
|
207
|
+
requestId: z.string().min(1).max(100),
|
|
208
|
+
declarationType: z.enum(CONTRACT_TERMINATION_DECLARATION_TYPES),
|
|
209
|
+
terminationKind: z.enum(CONTRACT_TERMINATION_KINDS),
|
|
210
|
+
receivedAtIso: z.string().min(1),
|
|
211
|
+
locale: z.enum(CONSENT_LOCALES),
|
|
212
|
+
});
|
|
213
|
+
export type ContractTerminationDeclaredPayload = z.infer<
|
|
214
|
+
typeof contractTerminationDeclaredPayloadSchema
|
|
215
|
+
>;
|
|
216
|
+
|
|
199
217
|
export const CONTRACT_TERMINATION_UNMATCHED_AGGREGATE_TYPE =
|
|
200
218
|
"contract-termination-unmatched" as const;
|
|
201
219
|
export const CONTRACT_TERMINATION_UNMATCHED_EVENT_SHORT = "contract-termination-unmatched" as const;
|
package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts
CHANGED
|
@@ -64,7 +64,7 @@ export function createCheckoutSessionHandler(
|
|
|
64
64
|
): WriteHandlerDef {
|
|
65
65
|
const consumerProtection = options.consumerProtection;
|
|
66
66
|
const schema = consumerProtection
|
|
67
|
-
? createCheckoutSessionSchema.extend({ consent: consentPayloadSchema })
|
|
67
|
+
? createCheckoutSessionSchema.extend({ consent: consentPayloadSchema.optional() })
|
|
68
68
|
: createCheckoutSessionSchema;
|
|
69
69
|
return {
|
|
70
70
|
name: "create-checkout-session",
|
|
@@ -79,10 +79,9 @@ export function createCheckoutSessionHandler(
|
|
|
79
79
|
const payload = event.payload as CreateCheckoutSessionPayload;
|
|
80
80
|
|
|
81
81
|
const mode = payload.mode ?? "subscription";
|
|
82
|
-
const consent =
|
|
83
|
-
consumerProtection
|
|
84
|
-
|
|
85
|
-
: undefined;
|
|
82
|
+
const consent = consumerProtection
|
|
83
|
+
? await prepareConsent(ctx, consumerProtection, payload.consent, mode)
|
|
84
|
+
: undefined;
|
|
86
85
|
|
|
87
86
|
const result = await openCheckout(
|
|
88
87
|
ctx,
|
package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts
CHANGED
|
@@ -35,7 +35,9 @@ export function createStartPlanCheckoutHandler(
|
|
|
35
35
|
const consumerProtection = options.consumerProtection;
|
|
36
36
|
const tierSchema = z.object({ tier: z.enum(plans) });
|
|
37
37
|
const schema = (
|
|
38
|
-
consumerProtection
|
|
38
|
+
consumerProtection
|
|
39
|
+
? tierSchema.extend({ consent: consentPayloadSchema.optional() })
|
|
40
|
+
: tierSchema
|
|
39
41
|
).strict();
|
|
40
42
|
|
|
41
43
|
return {
|
|
@@ -65,10 +67,9 @@ export function createStartPlanCheckoutHandler(
|
|
|
65
67
|
});
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
const consent =
|
|
69
|
-
consumerProtection
|
|
70
|
-
|
|
71
|
-
: undefined;
|
|
70
|
+
const consent = consumerProtection
|
|
71
|
+
? await prepareConsent(ctx, consumerProtection, payload.consent, "subscription")
|
|
72
|
+
: undefined;
|
|
72
73
|
|
|
73
74
|
const baseUrl = options.baseUrl ?? "";
|
|
74
75
|
const result = await openCheckout(
|
|
@@ -124,6 +124,9 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
|
|
|
124
124
|
"billing-foundation.errors.priceUnavailable": {
|
|
125
125
|
en: "This plan's price is temporarily unavailable.",
|
|
126
126
|
},
|
|
127
|
+
"billing-foundation.errors.consentRequired": {
|
|
128
|
+
en: "Confirm both consent boxes before you continue.",
|
|
129
|
+
},
|
|
127
130
|
"billing-foundation.errors.consentTextOutdated": {
|
|
128
131
|
en: "The consent text has changed. Reload the page and confirm again.",
|
|
129
132
|
},
|
|
@@ -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
|
});
|
|
@@ -26,7 +26,7 @@ export const PAYMENT_TENANT_DESTROY_ARCHIVE_REASON =
|
|
|
26
26
|
export async function subscriptionTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
|
|
27
27
|
declareEscapeHatch({ reason: SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON });
|
|
28
28
|
const { profile } = await resolveProfileForTenant({
|
|
29
|
-
db: ctx.db.unsafeRaw(
|
|
29
|
+
db: ctx.db.unsafeRaw(),
|
|
30
30
|
tenantId: ctx.tenantId,
|
|
31
31
|
});
|
|
32
32
|
const aggregateId = subscriptionAggregateId(ctx.tenantId);
|
|
@@ -39,7 +39,7 @@ export async function subscriptionTenantDestroyHook(ctx: TenantDataHookCtx): Pro
|
|
|
39
39
|
} else {
|
|
40
40
|
await ctx.db.deleteMany(subscriptionsProjectionTable as EntityTableMeta, { id: aggregateId });
|
|
41
41
|
}
|
|
42
|
-
await archiveStream(ctx.db.unsafeRaw(
|
|
42
|
+
await archiveStream(ctx.db.unsafeRaw(), {
|
|
43
43
|
tenantId: ctx.tenantId,
|
|
44
44
|
aggregateId,
|
|
45
45
|
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
@@ -56,7 +56,7 @@ export async function subscriptionTenantDestroyHook(ctx: TenantDataHookCtx): Pro
|
|
|
56
56
|
export async function paymentTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
|
|
57
57
|
declareEscapeHatch({ reason: PAYMENT_TENANT_DESTROY_ARCHIVE_REASON });
|
|
58
58
|
const { profile } = await resolveProfileForTenant({
|
|
59
|
-
db: ctx.db.unsafeRaw(
|
|
59
|
+
db: ctx.db.unsafeRaw(),
|
|
60
60
|
tenantId: ctx.tenantId,
|
|
61
61
|
});
|
|
62
62
|
const aggregateId = paymentAggregateId(ctx.tenantId);
|
|
@@ -71,7 +71,7 @@ export async function paymentTenantDestroyHook(ctx: TenantDataHookCtx): Promise<
|
|
|
71
71
|
tenantId: ctx.tenantId,
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
|
-
await archiveStream(ctx.db.unsafeRaw(
|
|
74
|
+
await archiveStream(ctx.db.unsafeRaw(), {
|
|
75
75
|
tenantId: ctx.tenantId,
|
|
76
76
|
aggregateId,
|
|
77
77
|
aggregateType: PAYMENT_AGGREGATE_TYPE,
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type SubscriptionCancelTiming,
|
|
36
36
|
type SubscriptionEventType,
|
|
37
37
|
type SubscriptionStatus,
|
|
38
|
+
type TerminationScope,
|
|
38
39
|
} from "./constants.js";
|
|
39
40
|
|
|
40
41
|
// =============================================================================
|
|
@@ -389,6 +390,11 @@ export type ConsumerProtectionOptions = {
|
|
|
389
390
|
/** Per-locale VAT note; must carry at least `de` and `en`. */
|
|
390
391
|
readonly vatNote: Readonly<Record<string, string>>;
|
|
391
392
|
readonly operatorEmail: string;
|
|
393
|
+
/** Where the public § 312k declaration is reachable. `"tenant-host"`
|
|
394
|
+
* (default): on a host that resolves a tenant. `"platform"`: also on a
|
|
395
|
+
* host that resolves none (the platform apex) — the declaration then runs
|
|
396
|
+
* without a request tenant and finds the contract by the declarant's email. */
|
|
397
|
+
readonly terminationScope?: TerminationScope;
|
|
392
398
|
/** Root-relative paths or absolute https URLs. */
|
|
393
399
|
readonly legalLinks: {
|
|
394
400
|
readonly terms: string;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// re-exported from index.ts.
|
|
5
5
|
|
|
6
6
|
import * as z from "zod";
|
|
7
|
+
import { isTerminationScope, TERMINATION_SCOPES } from "./constants.js";
|
|
7
8
|
import type { BillingFoundationOptions, ConsumerProtectionOptions } from "./types.js";
|
|
8
9
|
|
|
9
10
|
function isRootRelativePath(path: string): boolean {
|
|
@@ -35,6 +36,11 @@ function validateConsumerProtection(cp: ConsumerProtectionOptions): void {
|
|
|
35
36
|
`createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`,
|
|
36
37
|
);
|
|
37
38
|
}
|
|
39
|
+
if (cp.terminationScope !== undefined && !isTerminationScope(cp.terminationScope)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`createBillingFoundationFeature: consumerProtection.terminationScope "${String(cp.terminationScope)}" must be one of ${TERMINATION_SCOPES.map((scope) => `"${scope}"`).join(", ")}.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
38
44
|
for (const [key, link] of Object.entries(cp.legalLinks)) {
|
|
39
45
|
if (!isRootRelativeOrHttpsUrl(link)) {
|
|
40
46
|
throw new Error(
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChatWebhookChannelOptions,
|
|
3
|
+
chatMessageText,
|
|
4
|
+
createChatWebhookChannel,
|
|
5
|
+
type DeliveryChannel,
|
|
6
|
+
truncateChars,
|
|
7
|
+
} from "../delivery/index.js";
|
|
8
|
+
|
|
9
|
+
export type DiscordChannelOptions = ChatWebhookChannelOptions;
|
|
10
|
+
|
|
11
|
+
export const DISCORD_DEFAULT_ALLOWED_HOSTS = ["discord.com", "discordapp.com"] as const;
|
|
12
|
+
export const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
|
|
13
|
+
export const DISCORD_CONTENT_MAX_CHARS = 2000;
|
|
14
|
+
|
|
15
|
+
export function createDiscordChannel(
|
|
16
|
+
options: DiscordChannelOptions,
|
|
17
|
+
keyFor: (connection: string) => string,
|
|
18
|
+
): DeliveryChannel {
|
|
19
|
+
return createChatWebhookChannel({
|
|
20
|
+
name: "discord",
|
|
21
|
+
featureName: "channel-discord",
|
|
22
|
+
defaultAllowedHosts: DISCORD_DEFAULT_ALLOWED_HOSTS,
|
|
23
|
+
requiredPathPrefix: DISCORD_WEBHOOK_PATH_PREFIX,
|
|
24
|
+
keyFor,
|
|
25
|
+
// parse: [] keeps @everyone / @here / role and user mentions in the text inert.
|
|
26
|
+
buildBody: (message) => ({
|
|
27
|
+
content: truncateChars(chatMessageText(message), DISCORD_CONTENT_MAX_CHARS),
|
|
28
|
+
allowed_mentions: { parse: [] },
|
|
29
|
+
}),
|
|
30
|
+
options,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chatConnectionNameSchema,
|
|
3
|
+
DELIVERY_CHANNEL_EXTENSION,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
5
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { createDiscordChannel, type DiscordChannelOptions } from "./discord-channel.js";
|
|
7
|
+
|
|
8
|
+
export function createChannelDiscordFeature(
|
|
9
|
+
options: DiscordChannelOptions = {},
|
|
10
|
+
): FeatureDefinition {
|
|
11
|
+
return defineFeature("channel-discord", (r) => {
|
|
12
|
+
r.describe(
|
|
13
|
+
'Posts delivery notifications to a Discord webhook, registered as the `discord` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { discord: "<connection name>" } })`. The webhook URL is a tenant secret under `channel-discord:webhooks.<connection name>`; only the connection name is logged. Mentions are disabled and content is cut to 2000 characters. Requires `delivery` and `secrets`. Host allowlist (discord.com, discordapp.com, path /api/webhooks/), https-only and the request timeout are app options of `createChannelDiscordFeature(opts)`, never tenant config.',
|
|
14
|
+
);
|
|
15
|
+
r.uiHints({
|
|
16
|
+
displayLabel: "Discord Channel",
|
|
17
|
+
category: "notifications",
|
|
18
|
+
recommended: false,
|
|
19
|
+
});
|
|
20
|
+
r.requires("delivery");
|
|
21
|
+
r.requires("secrets");
|
|
22
|
+
|
|
23
|
+
const webhooks = r.secretNamespace("webhooks", {
|
|
24
|
+
label: { en: "Discord webhooks", de: "Discord-Webhooks", es: "Webhooks de Discord" },
|
|
25
|
+
hint: {
|
|
26
|
+
en: 'One secret per connection name; the value is the Discord webhook URL. Address it with route: { discord: "<connection name>" }.',
|
|
27
|
+
de: 'Ein Secret pro Verbindungsname; der Wert ist die Discord-Webhook-URL. Adressierung mit route: { discord: "<Verbindungsname>" }.',
|
|
28
|
+
es: 'Un secreto por nombre de conexión; el valor es la URL del webhook de Discord. Se usa con route: { discord: "<nombre de conexión>" }.',
|
|
29
|
+
},
|
|
30
|
+
scope: "tenant",
|
|
31
|
+
nameSchema: chatConnectionNameSchema,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const channel = createDiscordChannel(options, webhooks.keyFor);
|
|
35
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
|
|
36
|
+
mode: channel.mode,
|
|
37
|
+
send: channel.send,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chatConnectionNameSchema,
|
|
3
|
+
DELIVERY_CHANNEL_EXTENSION,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
5
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { createSlackChannel, type SlackChannelOptions } from "./slack-channel.js";
|
|
7
|
+
|
|
8
|
+
export function createChannelSlackFeature(options: SlackChannelOptions = {}): FeatureDefinition {
|
|
9
|
+
return defineFeature("channel-slack", (r) => {
|
|
10
|
+
r.describe(
|
|
11
|
+
'Posts delivery notifications to a Slack incoming webhook, registered as the `slack` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { slack: "<connection name>" } })`. The webhook URL is a tenant secret under `channel-slack:webhooks.<connection name>`; only the connection name is logged. Requires `delivery` and `secrets`. Host allowlist (hooks.slack.com), https-only and the request timeout are app options of `createChannelSlackFeature(opts)`, never tenant config.',
|
|
12
|
+
);
|
|
13
|
+
r.uiHints({
|
|
14
|
+
displayLabel: "Slack Channel",
|
|
15
|
+
category: "notifications",
|
|
16
|
+
recommended: false,
|
|
17
|
+
});
|
|
18
|
+
r.requires("delivery");
|
|
19
|
+
r.requires("secrets");
|
|
20
|
+
|
|
21
|
+
const webhooks = r.secretNamespace("webhooks", {
|
|
22
|
+
label: { en: "Slack webhooks", de: "Slack-Webhooks", es: "Webhooks de Slack" },
|
|
23
|
+
hint: {
|
|
24
|
+
en: 'One secret per connection name; the value is the Slack webhook URL. Address it with route: { slack: "<connection name>" }.',
|
|
25
|
+
de: 'Ein Secret pro Verbindungsname; der Wert ist die Slack-Webhook-URL. Adressierung mit route: { slack: "<Verbindungsname>" }.',
|
|
26
|
+
es: 'Un secreto por nombre de conexión; el valor es la URL del webhook de Slack. Se usa con route: { slack: "<nombre de conexión>" }.',
|
|
27
|
+
},
|
|
28
|
+
scope: "tenant",
|
|
29
|
+
nameSchema: chatConnectionNameSchema,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const channel = createSlackChannel(options, webhooks.keyFor);
|
|
33
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
|
|
34
|
+
mode: channel.mode,
|
|
35
|
+
send: channel.send,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChatWebhookChannelOptions,
|
|
3
|
+
chatMessageText,
|
|
4
|
+
createChatWebhookChannel,
|
|
5
|
+
type DeliveryChannel,
|
|
6
|
+
} from "../delivery/index.js";
|
|
7
|
+
|
|
8
|
+
export type SlackChannelOptions = ChatWebhookChannelOptions;
|
|
9
|
+
|
|
10
|
+
export const SLACK_DEFAULT_ALLOWED_HOSTS = ["hooks.slack.com"] as const;
|
|
11
|
+
|
|
12
|
+
// Slack mrkdwn treats & < > as control characters (links, @-mentions via <!channel>).
|
|
13
|
+
export function escapeSlackText(text: string): string {
|
|
14
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createSlackChannel(
|
|
18
|
+
options: SlackChannelOptions,
|
|
19
|
+
keyFor: (connection: string) => string,
|
|
20
|
+
): DeliveryChannel {
|
|
21
|
+
return createChatWebhookChannel({
|
|
22
|
+
name: "slack",
|
|
23
|
+
featureName: "channel-slack",
|
|
24
|
+
defaultAllowedHosts: SLACK_DEFAULT_ALLOWED_HOSTS,
|
|
25
|
+
keyFor,
|
|
26
|
+
buildBody: (message) => ({ text: escapeSlackText(chatMessageText(message)) }),
|
|
27
|
+
options,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chatConnectionNameSchema,
|
|
3
|
+
DELIVERY_CHANNEL_EXTENSION,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
5
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { createTeamsChannel, type TeamsChannelOptions } from "./teams-channel.js";
|
|
7
|
+
|
|
8
|
+
export function createChannelTeamsFeature(options: TeamsChannelOptions = {}): FeatureDefinition {
|
|
9
|
+
return defineFeature("channel-teams", (r) => {
|
|
10
|
+
r.describe(
|
|
11
|
+
'Posts delivery notifications to a Microsoft Teams webhook (Workflows or connector) as an Adaptive Card, registered as the `teams` channel in the delivery system. Tenant-owned chat target: reach it only via `ctx.notify(type, { route: { teams: "<connection name>" } })`. The webhook URL is a tenant secret under `channel-teams:webhooks.<connection name>`; only the connection name is logged. Requires `delivery` and `secrets`. Host allowlist (.webhook.office.com, .logic.azure.com, .powerplatform.com), https-only and the request timeout are app options of `createChannelTeamsFeature(opts)`, never tenant config.',
|
|
12
|
+
);
|
|
13
|
+
r.uiHints({
|
|
14
|
+
displayLabel: "Microsoft Teams Channel",
|
|
15
|
+
category: "notifications",
|
|
16
|
+
recommended: false,
|
|
17
|
+
});
|
|
18
|
+
r.requires("delivery");
|
|
19
|
+
r.requires("secrets");
|
|
20
|
+
|
|
21
|
+
const webhooks = r.secretNamespace("webhooks", {
|
|
22
|
+
label: {
|
|
23
|
+
en: "Microsoft Teams webhooks",
|
|
24
|
+
de: "Microsoft Teams-Webhooks",
|
|
25
|
+
es: "Webhooks de Microsoft Teams",
|
|
26
|
+
},
|
|
27
|
+
hint: {
|
|
28
|
+
en: 'One secret per connection name; the value is the Microsoft Teams webhook URL. Address it with route: { teams: "<connection name>" }.',
|
|
29
|
+
de: 'Ein Secret pro Verbindungsname; der Wert ist die Microsoft Teams-Webhook-URL. Adressierung mit route: { teams: "<Verbindungsname>" }.',
|
|
30
|
+
es: 'Un secreto por nombre de conexión; el valor es la URL del webhook de Microsoft Teams. Se usa con route: { teams: "<nombre de conexión>" }.',
|
|
31
|
+
},
|
|
32
|
+
scope: "tenant",
|
|
33
|
+
nameSchema: chatConnectionNameSchema,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const channel = createTeamsChannel(options, webhooks.keyFor);
|
|
37
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
|
|
38
|
+
mode: channel.mode,
|
|
39
|
+
send: channel.send,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChatWebhookChannelOptions,
|
|
3
|
+
createChatWebhookChannel,
|
|
4
|
+
type DeliveryChannel,
|
|
5
|
+
} from "../delivery/index.js";
|
|
6
|
+
|
|
7
|
+
export type TeamsChannelOptions = ChatWebhookChannelOptions;
|
|
8
|
+
|
|
9
|
+
export const TEAMS_DEFAULT_ALLOWED_HOSTS = [
|
|
10
|
+
".webhook.office.com",
|
|
11
|
+
".logic.azure.com",
|
|
12
|
+
".powerplatform.com",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
// Text only ever goes into TextBlocks (no markup interpretation of titles/bodies
|
|
16
|
+
// beyond Adaptive Card's own text rendering).
|
|
17
|
+
export function buildTeamsAdaptiveCard(title: string, body: string | undefined): unknown {
|
|
18
|
+
return {
|
|
19
|
+
type: "message",
|
|
20
|
+
attachments: [
|
|
21
|
+
{
|
|
22
|
+
contentType: "application/vnd.microsoft.card.adaptive",
|
|
23
|
+
contentUrl: null,
|
|
24
|
+
content: {
|
|
25
|
+
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
|
26
|
+
type: "AdaptiveCard",
|
|
27
|
+
version: "1.4",
|
|
28
|
+
body: [
|
|
29
|
+
{ type: "TextBlock", text: title, weight: "Bolder", wrap: true },
|
|
30
|
+
...(body ? [{ type: "TextBlock", text: body, wrap: true }] : []),
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createTeamsChannel(
|
|
39
|
+
options: TeamsChannelOptions,
|
|
40
|
+
keyFor: (connection: string) => string,
|
|
41
|
+
): DeliveryChannel {
|
|
42
|
+
return createChatWebhookChannel({
|
|
43
|
+
name: "teams",
|
|
44
|
+
featureName: "channel-teams",
|
|
45
|
+
defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS,
|
|
46
|
+
keyFor,
|
|
47
|
+
buildBody: (message) => buildTeamsAdaptiveCard(message.title, message.body),
|
|
48
|
+
options,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
2
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createTelegramChannel, type TelegramChannelOptions } from "./telegram-channel.js";
|
|
4
|
+
|
|
5
|
+
export function createChannelTelegramFeature(
|
|
6
|
+
options: TelegramChannelOptions = {},
|
|
7
|
+
): FeatureDefinition {
|
|
8
|
+
return defineFeature("channel-telegram", (r) => {
|
|
9
|
+
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.',
|
|
11
|
+
);
|
|
12
|
+
r.uiHints({
|
|
13
|
+
displayLabel: "Telegram Channel",
|
|
14
|
+
category: "notifications",
|
|
15
|
+
recommended: false,
|
|
16
|
+
});
|
|
17
|
+
r.requires("delivery");
|
|
18
|
+
r.requires("secrets");
|
|
19
|
+
|
|
20
|
+
const botToken = r.secret("botToken", {
|
|
21
|
+
label: { en: "Telegram bot token" },
|
|
22
|
+
hint: {
|
|
23
|
+
en: 'Token from @BotFather. Used for every Telegram chat the tenant addresses via route: { telegram: "<chat id>" }.',
|
|
24
|
+
},
|
|
25
|
+
redact: () => "••••••••",
|
|
26
|
+
scope: "tenant",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const channel = createTelegramChannel(options, botToken.name);
|
|
30
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "telegram", {
|
|
31
|
+
mode: channel.mode,
|
|
32
|
+
send: channel.send,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
2
|
+
import {
|
|
3
|
+
chatMessageText,
|
|
4
|
+
DEFAULT_CHAT_TIMEOUT_MS,
|
|
5
|
+
type DeliveryChannel,
|
|
6
|
+
postChatWebhook,
|
|
7
|
+
toChannelResult,
|
|
8
|
+
truncateChars,
|
|
9
|
+
} from "../delivery/index.js";
|
|
10
|
+
|
|
11
|
+
export type TelegramChannelOptions = {
|
|
12
|
+
// App-level override (tests, self-hosted Bot API server); never tenant config.
|
|
13
|
+
readonly apiBaseUrl?: string;
|
|
14
|
+
readonly timeoutMs?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const TELEGRAM_DEFAULT_API_BASE_URL = "https://api.telegram.org";
|
|
18
|
+
export const TELEGRAM_TEXT_MAX_CHARS = 4096;
|
|
19
|
+
|
|
20
|
+
// Numeric chat/group id (groups and channels are negative) or a public @channelname.
|
|
21
|
+
const TELEGRAM_CHAT_ID_PATTERN = /^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{3,31})$/;
|
|
22
|
+
// `<bot id>:<secret>`; the token is spliced into the URL path, so only the real shape passes.
|
|
23
|
+
const TELEGRAM_BOT_TOKEN_PATTERN = /^\d{1,20}:[A-Za-z0-9_-]{10,}$/;
|
|
24
|
+
|
|
25
|
+
export function createTelegramChannel(
|
|
26
|
+
options: TelegramChannelOptions,
|
|
27
|
+
botTokenKey: string,
|
|
28
|
+
): DeliveryChannel {
|
|
29
|
+
const apiBaseUrl = options.apiBaseUrl ?? TELEGRAM_DEFAULT_API_BASE_URL;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name: "telegram",
|
|
33
|
+
mode: "queued",
|
|
34
|
+
|
|
35
|
+
async send(address, message, ctx) {
|
|
36
|
+
if (!TELEGRAM_CHAT_ID_PATTERN.test(address)) {
|
|
37
|
+
return toChannelResult(address, { ok: false, code: "invalid_address" });
|
|
38
|
+
}
|
|
39
|
+
let token: string | undefined;
|
|
40
|
+
try {
|
|
41
|
+
const revealed = await ctx.secrets?.get(ctx.tenantId, botTokenKey, {
|
|
42
|
+
userId: SYSTEM_USER_ID,
|
|
43
|
+
handlerName: "channel-telegram:send",
|
|
44
|
+
});
|
|
45
|
+
token = revealed?.reveal();
|
|
46
|
+
} catch {
|
|
47
|
+
token = undefined;
|
|
48
|
+
}
|
|
49
|
+
if (!token || !TELEGRAM_BOT_TOKEN_PATTERN.test(token)) {
|
|
50
|
+
return toChannelResult(address, { ok: false, code: "missing_credentials" });
|
|
51
|
+
}
|
|
52
|
+
|
|
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
|
+
const result = await postChatWebhook({
|
|
61
|
+
url: `${apiBaseUrl.replace(/\/+$/, "")}/bot${token}/sendMessage`,
|
|
62
|
+
allowedHosts: [baseHost],
|
|
63
|
+
requireHttps: apiBaseUrl.startsWith("https:"),
|
|
64
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
|
|
65
|
+
// No parse_mode: Telegram renders the text literally.
|
|
66
|
+
body: {
|
|
67
|
+
chat_id: address,
|
|
68
|
+
text: truncateChars(chatMessageText(message), TELEGRAM_TEXT_MAX_CHARS),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
return toChannelResult(address, result);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|