@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.
Files changed (147) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +21 -1
  3. package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
  4. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -6
  5. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +2 -6
  6. package/packages/bundled-features/src/auth-email-password/handlers/invite-info.query.ts +3 -5
  7. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -8
  8. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -1
  10. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +35 -3
  11. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +60 -0
  12. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +10 -0
  13. package/packages/bundled-features/src/auth-email-password/web/index.ts +8 -0
  14. package/packages/bundled-features/src/auth-email-password/web/session.tsx +15 -2
  15. package/packages/bundled-features/src/auth-mfa/feature.ts +1 -1
  16. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -7
  17. package/packages/bundled-features/src/auth-mfa/handlers/enable-start-preauth.write.ts +1 -5
  18. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -7
  19. package/packages/bundled-features/src/auth-mfa/mfa-code-verifier.ts +1 -5
  20. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +1 -7
  21. package/packages/bundled-features/src/billing-foundation/changes.json +41 -0
  22. package/packages/bundled-features/src/billing-foundation/constants.ts +11 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +3 -3
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +2 -2
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +48 -2
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +10 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +44 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +32 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +129 -33
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +13 -59
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +20 -21
  33. package/packages/bundled-features/src/billing-foundation/events.ts +18 -0
  34. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +4 -5
  35. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +6 -5
  36. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  37. package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
  38. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +4 -4
  39. package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
  40. package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
  41. package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
  42. package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
  43. package/packages/bundled-features/src/channel-discord/index.ts +2 -0
  44. package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
  45. package/packages/bundled-features/src/channel-slack/index.ts +2 -0
  46. package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
  47. package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
  48. package/packages/bundled-features/src/channel-teams/index.ts +2 -0
  49. package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
  50. package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
  51. package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
  52. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
  53. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
  54. package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
  55. package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
  56. package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  57. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
  58. package/packages/bundled-features/src/data-retention/feature.ts +2 -3
  59. package/packages/bundled-features/src/delivery/changes.json +7 -0
  60. package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
  61. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
  62. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
  63. package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
  64. package/packages/bundled-features/src/delivery/index.ts +15 -0
  65. package/packages/bundled-features/src/delivery/jobs.ts +1 -1
  66. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  67. package/packages/bundled-features/src/delivery/types.ts +8 -3
  68. package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
  69. package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
  70. package/packages/bundled-features/src/form-draft/feature.ts +1 -1
  71. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  72. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
  73. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +14 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
  75. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
  78. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
  80. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
  81. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
  82. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
  83. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
  84. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
  85. package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
  86. package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
  87. package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
  88. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
  89. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  90. package/packages/bundled-features/src/sessions/feature.ts +1 -1
  91. package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
  92. package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
  93. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
  94. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
  95. package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  96. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  97. package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
  98. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
  99. package/packages/bundled-features/src/user/changes.json +13 -6
  100. package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
  101. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -6
  102. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  103. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
  104. package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
  105. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  106. package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
  107. package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
  108. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
  109. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
  110. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  111. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
  112. package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
  113. package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
  114. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
  115. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  116. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
  117. package/packages/bundled-features/src/workflow-runner/changes.json +14 -0
  118. package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
  119. package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
  120. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
  121. package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
  122. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
  123. package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
  124. package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
  125. package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
  126. package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
  127. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
  128. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
  129. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
  130. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
  131. package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
  132. package/samples/apps/use-all-bundled/package.json +1 -0
  133. package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
  134. package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
  135. package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
  136. package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
  137. package/samples/apps/use-all-bundled/tsconfig.json +1 -0
  138. package/samples/recipes/chat-channels/README.md +19 -0
  139. package/samples/recipes/chat-channels/package.json +9 -0
  140. package/samples/recipes/chat-channels/src/feature.ts +40 -0
  141. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  142. package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
  143. package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
  144. package/samples/recipes/workflow-engine/src/workflow-runner.ts +12 -2
  145. package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
  146. package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
  147. 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;
@@ -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 && payload.consent
84
- ? await prepareConsent(ctx, consumerProtection, payload.consent, mode)
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,
@@ -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 ? tierSchema.extend({ consent: consentPayloadSchema }) : tierSchema
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 && payload.consent
70
- ? await prepareConsent(ctx, consumerProtection, payload.consent, "subscription")
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 { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
17
+ import {
18
+ insertOnConflictDoNothing,
19
+ requireEntityTableMeta,
20
+ upsertOnConflict,
21
+ } from "@cosmicdrift/kumiko-framework/bun-db";
22
+ import { buildEntityTable, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
18
23
  import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
19
24
  import { paymentRowId } from "./aggregate-id.js";
20
- import { insertPaymentProjectionRow } from "./db/queries/payment-projection.js";
21
- import { upsertSubscriptionProjectionRow } from "./db/queries/subscription-projection.js";
22
25
  import { paymentEntity, subscriptionEntity } from "./entities.js";
23
26
  import type { PaymentEventPayload, SubscriptionEventPayload } from "./events.js";
24
27
 
@@ -32,6 +35,17 @@ export const subscriptionsProjectionTable = buildEntityTable("subscription", sub
32
35
  // production must add read_payments to their own drizzle/generate.ts too.
33
36
  export const paymentsProjectionTable = buildEntityTable("payment", paymentEntity);
34
37
 
38
+ // Unbranded metas: the apply functions below ARE the executor of these
39
+ // projections, so they may write through the typed helpers.
40
+ const subscriptionsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
41
+ subscriptionsProjectionTable,
42
+ "subscription",
43
+ );
44
+ const paymentsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
45
+ paymentsProjectionTable,
46
+ "payment",
47
+ );
48
+
35
49
  // =============================================================================
36
50
  // Shared helpers
37
51
  // =============================================================================
@@ -69,9 +83,7 @@ function cancelAtSetFromPayload(p: SubscriptionEventPayload): {
69
83
  * on every apply including the initial create — a projection rebuild at a
70
84
  * different wall-clock time must materialize the exact same row, and
71
85
  * `getSubscriptionForTenant`'s `lastChangedAt` (staleness check for
72
- * incomplete subscriptions) depends on that determinism. Raw `.unsafe()`
73
- * bypasses the typed query-builder's Temporal.Instant-to-string coercion,
74
- * hence the explicit `.toString()`. */
86
+ * incomplete subscriptions) depends on that determinism. */
75
87
  async function upsert(
76
88
  tx: Parameters<Parameters<typeof defineApply<SubscriptionEventPayload>>[0]>[1],
77
89
  event: { aggregateId: string; tenantId: string; createdAt: Temporal.Instant },
@@ -92,38 +104,21 @@ async function upsert(
92
104
  // INSERT-Pfad und nur den teil-`set` für ON CONFLICT.
93
105
  const insertCols = {
94
106
  id: event.aggregateId,
95
- tenant_id: event.tenantId,
96
- provider_name: fullPayload.providerName,
97
- provider_customer_id: fullPayload.providerCustomerId,
98
- provider_subscription_id: fullPayload.providerSubscriptionId,
107
+ tenantId: event.tenantId,
108
+ providerName: fullPayload.providerName,
109
+ providerCustomerId: fullPayload.providerCustomerId,
110
+ providerSubscriptionId: fullPayload.providerSubscriptionId,
99
111
  status: fullPayload.status,
100
112
  tier: fullPayload.tier,
101
- current_period_end: fullPayload.currentPeriodEndIso,
102
- cancel_at: fullPayload.cancelAtIso ?? null,
103
- modified_at: event.createdAt.toString(),
113
+ currentPeriodEnd: fullPayload.currentPeriodEndIso,
114
+ cancelAt: fullPayload.cancelAtIso ?? null,
115
+ modifiedAt: event.createdAt,
104
116
  };
105
- // Map camelCase set-keys to snake_case DB columns.
106
- const setMap: Record<keyof typeof set, string> = {
107
- providerName: "provider_name",
108
- providerCustomerId: "provider_customer_id",
109
- providerSubscriptionId: "provider_subscription_id",
110
- status: "status",
111
- tier: "tier",
112
- currentPeriodEnd: "current_period_end",
113
- cancelAt: "cancel_at",
114
- };
115
- const insertParams = Object.values(insertCols);
116
- const setEntries = Object.entries(set).filter(([, v]) => v !== undefined);
117
- // modified_at is already bound as the last insertParams placeholder —
118
- // reused (not re-pushed) for the ON CONFLICT SET clause below.
119
- const setClauses: string[] = [`"modified_at" = $${insertParams.length}`];
120
- const allParams: unknown[] = [...insertParams];
121
- for (const [k, v] of setEntries) {
122
- allParams.push(v);
123
- setClauses.push(`"${setMap[k as keyof typeof set]}" = $${allParams.length}`);
124
- }
125
- const tableName = (subscriptionsProjectionTable as { tableName: string }).tableName;
126
- await upsertSubscriptionProjectionRow(tx, tableName, insertCols, setClauses, allParams);
117
+ const definedSet = Object.fromEntries(Object.entries(set).filter(([, v]) => v !== undefined));
118
+ await upsertOnConflict(tx, subscriptionsProjectionMeta, insertCols, {
119
+ conflictKeys: ["id"],
120
+ update: { modifiedAt: event.createdAt, ...definedSet },
121
+ });
127
122
  }
128
123
 
129
124
  // =============================================================================
@@ -209,7 +204,6 @@ export const applyInvoicePaymentFailed = defineApply<SubscriptionEventPayload>(
209
204
  * the second tenant's row. ON CONFLICT DO NOTHING also makes a projection
210
205
  * rebuild idempotent without mutating an existing row. */
211
206
  export const applyPaymentReceived = defineApply<PaymentEventPayload>(async (event, tx) => {
212
- const tableName = (paymentsProjectionTable as { tableName: string }).tableName;
213
207
  const providerEventId = event.metadata.headers?.["providerEventId"];
214
208
  const providerName = event.metadata.headers?.["providerName"];
215
209
  if (typeof providerEventId !== "string" || typeof providerName !== "string") {
@@ -217,11 +211,11 @@ export const applyPaymentReceived = defineApply<PaymentEventPayload>(async (even
217
211
  "applyPaymentReceived: event.metadata.headers is missing providerEventId/providerName — process-payment-event.write.ts always sets both",
218
212
  );
219
213
  }
220
- await insertPaymentProjectionRow(tx, tableName, {
214
+ await insertOnConflictDoNothing(tx, paymentsProjectionMeta, {
221
215
  id: paymentRowId(event.tenantId, providerName, providerEventId),
222
- tenant_id: event.tenantId,
223
- provider_name: event.payload.providerName,
224
- provider_customer_id: event.payload.providerCustomerId,
225
- price_id: event.payload.priceId,
216
+ tenantId: event.tenantId,
217
+ providerName: event.payload.providerName,
218
+ providerCustomerId: event.payload.providerCustomerId,
219
+ priceId: event.payload.priceId,
226
220
  });
227
221
  });
@@ -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(SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON),
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(SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON), {
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(PAYMENT_TENANT_DESTROY_ARCHIVE_REASON),
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(PAYMENT_TENANT_DESTROY_ARCHIVE_REASON), {
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,2 @@
1
+ export { createDiscordChannel, type DiscordChannelOptions } from "./discord-channel.js";
2
+ export { createChannelDiscordFeature } from "./feature.js";
@@ -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,2 @@
1
+ export { createChannelSlackFeature } from "./feature.js";
2
+ export { createSlackChannel, type SlackChannelOptions } from "./slack-channel.js";
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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,2 @@
1
+ export { createChannelTeamsFeature } from "./feature.js";
2
+ export { createTeamsChannel, type TeamsChannelOptions } from "./teams-channel.js";
@@ -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,2 @@
1
+ export { createChannelTelegramFeature } from "./feature.js";
2
+ export { createTelegramChannel, type TelegramChannelOptions } from "./telegram-channel.js";
@@ -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
+ }