@cosmicdrift/kumiko-samples 0.338.0 → 0.339.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 (138) 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 +34 -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/tenant-destroy-hook.ts +4 -4
  38. package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
  39. package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
  40. package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
  41. package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
  42. package/packages/bundled-features/src/channel-discord/index.ts +2 -0
  43. package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
  44. package/packages/bundled-features/src/channel-slack/index.ts +2 -0
  45. package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
  46. package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
  47. package/packages/bundled-features/src/channel-teams/index.ts +2 -0
  48. package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
  49. package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
  50. package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
  51. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
  52. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
  53. package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
  54. package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
  55. package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  56. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
  57. package/packages/bundled-features/src/data-retention/feature.ts +2 -3
  58. package/packages/bundled-features/src/delivery/changes.json +7 -0
  59. package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
  60. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
  61. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
  62. package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
  63. package/packages/bundled-features/src/delivery/index.ts +15 -0
  64. package/packages/bundled-features/src/delivery/jobs.ts +1 -1
  65. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  66. package/packages/bundled-features/src/delivery/types.ts +8 -3
  67. package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
  68. package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
  69. package/packages/bundled-features/src/form-draft/feature.ts +1 -1
  70. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  71. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
  72. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
  74. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  75. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
  78. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
  79. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
  80. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
  81. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
  82. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
  83. package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
  84. package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
  85. package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
  86. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
  87. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  88. package/packages/bundled-features/src/sessions/feature.ts +1 -1
  89. package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
  90. package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
  91. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
  92. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
  93. package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  94. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  95. package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
  96. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
  97. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -6
  98. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  99. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
  100. package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
  101. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  102. package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
  103. package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
  104. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
  105. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
  106. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  107. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
  108. package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
  109. package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
  110. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
  111. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  112. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
  113. package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
  114. package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
  115. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
  116. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
  117. package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
  118. package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
  119. package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
  120. package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
  121. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
  122. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
  123. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
  124. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
  125. package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
  126. package/samples/apps/use-all-bundled/package.json +1 -0
  127. package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
  128. package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
  129. package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
  130. package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
  131. package/samples/apps/use-all-bundled/tsconfig.json +1 -0
  132. package/samples/recipes/chat-channels/README.md +19 -0
  133. package/samples/recipes/chat-channels/package.json +9 -0
  134. package/samples/recipes/chat-channels/src/feature.ts +40 -0
  135. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  136. package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
  137. package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
  138. package/samples/recipes/workflow-engine/src/workflow-runner.ts +12 -2
@@ -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
  },
@@ -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
+ }
@@ -334,9 +334,7 @@ export const forgetSubjectWrite = defineWriteHandler({
334
334
  const subjectKey = subjectIdToKey(subject);
335
335
 
336
336
  const tenantScopeDenial = await resolveTenantScopeDenial(
337
- ctx.db.unsafeRaw(
338
- "tenant-scope check runs against the subject's tenant, not necessarily the caller's",
339
- ),
337
+ ctx.db.unsafeRaw(),
340
338
  ctx.registry.features,
341
339
  event.user,
342
340
  raw,
@@ -348,9 +346,7 @@ export const forgetSubjectWrite = defineWriteHandler({
348
346
  subjectKey,
349
347
  raw.kind,
350
348
  denialReasonOf(tenantScopeDenial),
351
- ctx.dbOutsideTransaction?.unsafeRaw(
352
- "denial audit append: names the prober's own tenant stream on the outside-transaction db",
353
- ),
349
+ ctx.dbOutsideTransaction?.unsafeRaw(),
354
350
  );
355
351
  return auditFailure ?? tenantScopeDenial;
356
352
  }
@@ -358,9 +354,7 @@ export const forgetSubjectWrite = defineWriteHandler({
358
354
  // Runs after the tenant gate but before any key is touched: a retention
359
355
  // check ahead of the tenant gate would leak a foreign entity's retention
360
356
  // posture to a cross-tenant prober.
361
- const retentionDenial = await resolveRetentionDenial(ctx, raw, () =>
362
- ctx.db.unsafeRaw("retention check reads the record row's owning tenant, not the caller's"),
363
- );
357
+ const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw());
364
358
  if (retentionDenial) {
365
359
  const auditFailure = await appendDenialAuditEvent(
366
360
  ctx,
@@ -368,9 +362,7 @@ export const forgetSubjectWrite = defineWriteHandler({
368
362
  subjectKey,
369
363
  raw.kind,
370
364
  TARGET_RECORD_RETENTION_BLOCK_DELETE,
371
- ctx.dbOutsideTransaction?.unsafeRaw(
372
- "denial audit append: names the prober's own tenant stream on the outside-transaction db",
373
- ),
365
+ ctx.dbOutsideTransaction?.unsafeRaw(),
374
366
  );
375
367
  return auditFailure ?? retentionDenial;
376
368
  }
@@ -387,9 +379,7 @@ export const forgetSubjectWrite = defineWriteHandler({
387
379
 
388
380
  // Blind-index sweep (#818): nulls bidx columns now so the deterministic
389
381
  // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
390
- const crossTenantSubjectRunner = ctx.db.unsafeRaw(
391
- "blind-index sweep and search purge address the subject across tenants",
392
- );
382
+ const crossTenantSubjectRunner = ctx.db.unsafeRaw();
393
383
  await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
394
384
 
395
385
  // Derived search index still holds plaintext (#1610) — purge next to the
@@ -425,9 +415,7 @@ export const forgetSubjectWrite = defineWriteHandler({
425
415
  // update is best-effort for real users.
426
416
  if (raw.kind === "user" && ctx.registry.features.has("user")) {
427
417
  try {
428
- const userLifecycleRunner = ctx.db.unsafeRaw(
429
- "user lifecycle update and PAT revoke run on the SYSTEM user stream",
430
- );
418
+ const userLifecycleRunner = ctx.db.unsafeRaw();
431
419
  await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
432
420
  if (ctx.registry.features.has("personal-access-tokens")) {
433
421
  await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
@@ -40,7 +40,7 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
40
40
  const payload = event.payload as ClearCustomFieldPayload; // @cast-boundary engine-payload
41
41
 
42
42
  const accessCheck = await checkFieldAccessForWrite(
43
- ctx.db.unsafeRaw(CLEAR_CUSTOM_FIELD_REASON),
43
+ ctx.db.unsafeRaw(),
44
44
  event.user.tenantId,
45
45
  payload.entityName,
46
46
  payload.fieldKey,
@@ -74,10 +74,7 @@ export function createDefineTenantFieldHandler(
74
74
  }
75
75
 
76
76
  if (limit !== undefined) {
77
- const current = await countTenantFieldDefinitions(
78
- ctx.db.unsafeRaw(DEFINE_TENANT_FIELD_REASON),
79
- tenantId,
80
- );
77
+ const current = await countTenantFieldDefinitions(ctx.db.unsafeRaw(), tenantId);
81
78
  if (current >= limit) {
82
79
  return failUnprocessable("cap_exceeded", {
83
80
  capName: "customFields.fieldDefinition.count",
@@ -63,7 +63,7 @@ export const setCustomFieldHandler: WriteHandlerDef = {
63
63
  const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
64
64
 
65
65
  const loaded = await loadFieldDefinition(
66
- ctx.db.unsafeRaw(SET_CUSTOM_FIELD_REASON),
66
+ ctx.db.unsafeRaw(),
67
67
  event.user.tenantId,
68
68
  payload.entityName,
69
69
  payload.fieldKey,
@@ -73,7 +73,7 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
73
73
  // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
74
74
  r.useExtension(EXT_USER_DATA, opts.entityName, {
75
75
  export: async (ctx: UserDataHookCtx) =>
76
- exportCustomFields(ctx.db.unsafeRaw(CUSTOM_FIELDS_EXPORT_REASON), ctx, tableName, opts),
76
+ exportCustomFields(ctx.db.unsafeRaw(), ctx, tableName, opts),
77
77
  escapeHatch: { reason: CUSTOM_FIELDS_EXPORT_REASON },
78
78
  });
79
79
  }