@cosmicdrift/kumiko-bundled-features 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 (153) hide show
  1. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  2. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -4
  3. package/dist/auth-email-password/handlers/invite-info.query.js +3 -1
  4. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +2 -4
  5. package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -1
  6. package/dist/auth-email-password/handlers/signup-request.write.js +1 -1
  7. package/dist/auth-email-password/web/auth-gate.d.ts +12 -2
  8. package/dist/auth-email-password/web/auth-gate.js +25 -1
  9. package/dist/auth-email-password/web/auth-redirect.d.ts +5 -0
  10. package/dist/auth-email-password/web/auth-redirect.js +58 -0
  11. package/dist/auth-email-password/web/client-plugin.d.ts +8 -1
  12. package/dist/auth-email-password/web/client-plugin.js +2 -0
  13. package/dist/auth-email-password/web/index.d.ts +2 -1
  14. package/dist/auth-email-password/web/index.js +1 -0
  15. package/dist/auth-email-password/web/session.d.ts +3 -1
  16. package/dist/auth-email-password/web/session.js +8 -2
  17. package/dist/auth-mfa/feature.js +1 -1
  18. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -1
  19. package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -1
  20. package/dist/auth-mfa/handlers/verify.write.js +1 -1
  21. package/dist/auth-mfa/mfa-code-verifier.js +1 -1
  22. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  23. package/dist/billing-foundation/constants.d.ts +7 -0
  24. package/dist/billing-foundation/constants.js +8 -0
  25. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +3 -3
  26. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +3 -3
  27. package/dist/billing-foundation/consumer-protection/consent-gate.js +10 -2
  28. package/dist/billing-foundation/consumer-protection/consent-text.js +2 -2
  29. package/dist/billing-foundation/consumer-protection/register-termination.d.ts +1 -1
  30. package/dist/billing-foundation/consumer-protection/register-termination.js +31 -3
  31. package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +3 -1
  32. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -1
  33. package/dist/billing-foundation/consumer-protection/termination-notify.d.ts +18 -0
  34. package/dist/billing-foundation/consumer-protection/termination-notify.js +21 -0
  35. package/dist/billing-foundation/consumer-protection/termination-pages.js +31 -2
  36. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +24 -2
  37. package/dist/billing-foundation/consumer-protection/termination-record.js +102 -34
  38. package/dist/billing-foundation/consumer-protection/termination-request.js +10 -42
  39. package/dist/billing-foundation/consumer-protection/termination-texts.js +20 -20
  40. package/dist/billing-foundation/events.d.ts +19 -0
  41. package/dist/billing-foundation/events.js +12 -0
  42. package/dist/billing-foundation/handlers/create-checkout-session.write.js +2 -2
  43. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +4 -2
  44. package/dist/billing-foundation/i18n.js +3 -0
  45. package/dist/billing-foundation/tenant-destroy-hook.js +4 -4
  46. package/dist/billing-foundation/types.d.ts +6 -1
  47. package/dist/billing-foundation/validate-options.js +4 -0
  48. package/dist/channel-discord/discord-channel.d.ts +6 -0
  49. package/dist/channel-discord/discord-channel.js +19 -0
  50. package/dist/channel-discord/feature.d.ts +3 -0
  51. package/dist/channel-discord/feature.js +30 -0
  52. package/dist/channel-discord/index.d.ts +2 -0
  53. package/dist/channel-discord/index.js +2 -0
  54. package/dist/channel-slack/feature.d.ts +3 -0
  55. package/dist/channel-slack/feature.js +30 -0
  56. package/dist/channel-slack/index.d.ts +2 -0
  57. package/dist/channel-slack/index.js +2 -0
  58. package/dist/channel-slack/slack-channel.d.ts +5 -0
  59. package/dist/channel-slack/slack-channel.js +16 -0
  60. package/dist/channel-teams/feature.d.ts +3 -0
  61. package/dist/channel-teams/feature.js +34 -0
  62. package/dist/channel-teams/index.d.ts +2 -0
  63. package/dist/channel-teams/index.js +2 -0
  64. package/dist/channel-teams/teams-channel.d.ts +5 -0
  65. package/dist/channel-teams/teams-channel.js +38 -0
  66. package/dist/channel-telegram/feature.d.ts +3 -0
  67. package/dist/channel-telegram/feature.js +28 -0
  68. package/dist/channel-telegram/index.d.ts +2 -0
  69. package/dist/channel-telegram/index.js +2 -0
  70. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  71. package/dist/channel-telegram/telegram-channel.js +54 -0
  72. package/dist/crypto-shredding/handlers/forget-subject.write.js +6 -6
  73. package/dist/custom-fields/handlers/clear-custom-field.write.js +1 -1
  74. package/dist/custom-fields/handlers/define-tenant-field.write.js +1 -1
  75. package/dist/custom-fields/handlers/set-custom-field.write.js +1 -1
  76. package/dist/custom-fields/wire-user-data-rights.js +1 -1
  77. package/dist/data-retention/feature.js +1 -1
  78. package/dist/delivery/channel-context.d.ts +2 -1
  79. package/dist/delivery/channel-context.js +2 -2
  80. package/dist/delivery/chat-webhook-channel.d.ts +19 -0
  81. package/dist/delivery/chat-webhook-channel.js +48 -0
  82. package/dist/delivery/chat-webhook-sender.d.ts +21 -0
  83. package/dist/delivery/chat-webhook-sender.js +102 -0
  84. package/dist/delivery/delivery-service.js +3 -0
  85. package/dist/delivery/index.d.ts +2 -0
  86. package/dist/delivery/index.js +2 -0
  87. package/dist/delivery/jobs.js +1 -1
  88. package/dist/delivery/testing.d.ts +2 -0
  89. package/dist/delivery/types.d.ts +3 -1
  90. package/dist/delivery/types.js +1 -2
  91. package/dist/file-derivatives/handlers/public-variant-by-file-ref.query.js +1 -1
  92. package/dist/files-tenant-data/index.js +1 -1
  93. package/dist/form-draft/feature.js +1 -1
  94. package/dist/form-draft/handlers/discard.write.js +1 -1
  95. package/dist/form-draft/handlers/save.write.js +1 -1
  96. package/dist/inbound-mail-foundation/connect-routes.d.ts +7 -0
  97. package/dist/inbound-mail-foundation/connect-routes.js +13 -8
  98. package/dist/inbound-mail-foundation/feature.js +1 -1
  99. package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -1
  100. package/dist/inbound-mail-foundation/index.d.ts +1 -0
  101. package/dist/inbound-mail-foundation/index.js +1 -0
  102. package/dist/inbound-mail-foundation/oauth-access-token.d.ts +28 -0
  103. package/dist/inbound-mail-foundation/oauth-access-token.js +131 -0
  104. package/dist/inbound-mail-foundation/tenant-destroy-hook.js +1 -1
  105. package/dist/inbound-mail-foundation/types.d.ts +18 -1
  106. package/dist/inbound-mail-foundation/watch-supervisor.d.ts +5 -0
  107. package/dist/inbound-mail-foundation/watch-supervisor.js +42 -14
  108. package/dist/managed-pages/handlers/set.write.js +1 -1
  109. package/dist/notes-history/handlers/add-note.write.js +1 -1
  110. package/dist/renderer-simple/feature.d.ts +8 -2
  111. package/dist/renderer-simple/feature.js +8 -5
  112. package/dist/renderer-simple/index.d.ts +2 -2
  113. package/dist/renderer-simple/index.js +1 -1
  114. package/dist/renderer-simple/simple-renderer.d.ts +15 -0
  115. package/dist/renderer-simple/simple-renderer.js +74 -27
  116. package/dist/secrets/feature.js +1 -1
  117. package/dist/sessions/feature.js +1 -1
  118. package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -1
  119. package/dist/tenant-handover/handlers/claim.write.js +1 -2
  120. package/dist/tenant-lifecycle/feature.js +1 -1
  121. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +3 -1
  122. package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -1
  123. package/dist/tier-engine/feature.js +1 -1
  124. package/dist/tier-engine/handlers/get-tenant-tier.query.js +1 -1
  125. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -1
  126. package/dist/user-data-rights/feature.js +2 -2
  127. package/dist/user-data-rights/handlers/cancel-deletion.write.js +1 -1
  128. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +1 -1
  129. package/dist/user-data-rights/handlers/download-by-job.query.js +1 -1
  130. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  131. package/dist/user-data-rights/handlers/export-status.query.js +1 -1
  132. package/dist/user-data-rights/handlers/lift-restriction.write.js +2 -4
  133. package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -1
  134. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  135. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -1
  136. package/dist/user-data-rights/handlers/request-export.write.js +1 -1
  137. package/dist/user-data-rights/handlers/restrict-account.write.js +2 -2
  138. package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -1
  139. package/dist/user-data-rights/lib/update-user-lifecycle.js +1 -1
  140. package/dist/workflow-runner/event-subscriber.js +6 -2
  141. package/dist/workflow-runner/event-trigger.js +5 -0
  142. package/dist/workflow-runner/handlers/resume-run.write.js +147 -59
  143. package/dist/workflow-runner/pending-projection.js +1 -0
  144. package/dist/workflow-runner/runner.d.ts +7 -1
  145. package/dist/workflow-runner/runner.js +1 -1
  146. package/dist/workflow-runner/tables.js +8 -1
  147. package/package.json +41 -9
  148. package/src/auth-email-password/changes.json +7 -0
  149. package/src/billing-foundation/changes.json +34 -0
  150. package/src/delivery/changes.json +7 -0
  151. package/src/inbound-mail-foundation/changes.json +7 -0
  152. package/src/renderer-simple/changes.json +9 -1
  153. package/src/workflow-runner/changes.json +7 -0
@@ -2,7 +2,7 @@
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
5
- import { type BillingEventKinds, type BillingPlanAction, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, type SubscriptionEventType, type SubscriptionStatus } from "./constants.js";
5
+ import { type BillingEventKinds, type BillingPlanAction, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, type SubscriptionEventType, type SubscriptionStatus, type TerminationScope } from "./constants.js";
6
6
  export type SubscriptionEvent = {
7
7
  /** Discriminator against PaymentEvent. Optional (instead of required) so
8
8
  * existing plugins like subscription-mollie, which don't set this field,
@@ -289,6 +289,11 @@ export type ConsumerProtectionOptions = {
289
289
  /** Per-locale VAT note; must carry at least `de` and `en`. */
290
290
  readonly vatNote: Readonly<Record<string, string>>;
291
291
  readonly operatorEmail: string;
292
+ /** Where the public § 312k declaration is reachable. `"tenant-host"`
293
+ * (default): on a host that resolves a tenant. `"platform"`: also on a
294
+ * host that resolves none (the platform apex) — the declaration then runs
295
+ * without a request tenant and finds the contract by the declarant's email. */
296
+ readonly terminationScope?: TerminationScope;
292
297
  /** Root-relative paths or absolute https URLs. */
293
298
  readonly legalLinks: {
294
299
  readonly terms: string;
@@ -3,6 +3,7 @@
3
3
  // concerns (app-feature-structure guard's 300-line budget). Internal — not
4
4
  // re-exported from index.ts.
5
5
  import * as z from "zod";
6
+ import { isTerminationScope, TERMINATION_SCOPES } from "./constants.js";
6
7
  function isRootRelativePath(path) {
7
8
  return path.startsWith("/") && !path.startsWith("//");
8
9
  }
@@ -28,6 +29,9 @@ function validateConsumerProtection(cp) {
28
29
  if (!z.email().safeParse(cp.operatorEmail).success) {
29
30
  throw new Error(`createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`);
30
31
  }
32
+ if (cp.terminationScope !== undefined && !isTerminationScope(cp.terminationScope)) {
33
+ throw new Error(`createBillingFoundationFeature: consumerProtection.terminationScope "${String(cp.terminationScope)}" must be one of ${TERMINATION_SCOPES.map((scope) => `"${scope}"`).join(", ")}.`);
34
+ }
31
35
  for (const [key, link] of Object.entries(cp.legalLinks)) {
32
36
  if (!isRootRelativeOrHttpsUrl(link)) {
33
37
  throw new Error(`createBillingFoundationFeature: consumerProtection.legalLinks.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`);
@@ -0,0 +1,6 @@
1
+ import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
2
+ export type DiscordChannelOptions = ChatWebhookChannelOptions;
3
+ export declare const DISCORD_DEFAULT_ALLOWED_HOSTS: readonly ["discord.com", "discordapp.com"];
4
+ export declare const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
5
+ export declare const DISCORD_CONTENT_MAX_CHARS = 2000;
6
+ export declare function createDiscordChannel(options: DiscordChannelOptions, keyFor: (connection: string) => string): DeliveryChannel;
@@ -0,0 +1,19 @@
1
+ import { chatMessageText, createChatWebhookChannel, truncateChars, } from "../delivery/index.js";
2
+ export const DISCORD_DEFAULT_ALLOWED_HOSTS = ["discord.com", "discordapp.com"];
3
+ export const DISCORD_WEBHOOK_PATH_PREFIX = "/api/webhooks/";
4
+ export const DISCORD_CONTENT_MAX_CHARS = 2000;
5
+ export function createDiscordChannel(options, keyFor) {
6
+ return createChatWebhookChannel({
7
+ name: "discord",
8
+ featureName: "channel-discord",
9
+ defaultAllowedHosts: DISCORD_DEFAULT_ALLOWED_HOSTS,
10
+ requiredPathPrefix: DISCORD_WEBHOOK_PATH_PREFIX,
11
+ keyFor,
12
+ // parse: [] keeps @everyone / @here / role and user mentions in the text inert.
13
+ buildBody: (message) => ({
14
+ content: truncateChars(chatMessageText(message), DISCORD_CONTENT_MAX_CHARS),
15
+ allowed_mentions: { parse: [] },
16
+ }),
17
+ options,
18
+ });
19
+ }
@@ -0,0 +1,3 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type DiscordChannelOptions } from "./discord-channel.js";
3
+ export declare function createChannelDiscordFeature(options?: DiscordChannelOptions): FeatureDefinition;
@@ -0,0 +1,30 @@
1
+ import { chatConnectionNameSchema, DELIVERY_CHANNEL_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createDiscordChannel } from "./discord-channel.js";
4
+ export function createChannelDiscordFeature(options = {}) {
5
+ return defineFeature("channel-discord", (r) => {
6
+ r.describe('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.');
7
+ r.uiHints({
8
+ displayLabel: "Discord Channel",
9
+ category: "notifications",
10
+ recommended: false,
11
+ });
12
+ r.requires("delivery");
13
+ r.requires("secrets");
14
+ const webhooks = r.secretNamespace("webhooks", {
15
+ label: { en: "Discord webhooks", de: "Discord-Webhooks", es: "Webhooks de Discord" },
16
+ hint: {
17
+ en: 'One secret per connection name; the value is the Discord webhook URL. Address it with route: { discord: "<connection name>" }.',
18
+ de: 'Ein Secret pro Verbindungsname; der Wert ist die Discord-Webhook-URL. Adressierung mit route: { discord: "<Verbindungsname>" }.',
19
+ 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>" }.',
20
+ },
21
+ scope: "tenant",
22
+ nameSchema: chatConnectionNameSchema,
23
+ });
24
+ const channel = createDiscordChannel(options, webhooks.keyFor);
25
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
26
+ mode: channel.mode,
27
+ send: channel.send,
28
+ });
29
+ });
30
+ }
@@ -0,0 +1,2 @@
1
+ export { createDiscordChannel, type DiscordChannelOptions } from "./discord-channel.js";
2
+ export { createChannelDiscordFeature } from "./feature.js";
@@ -0,0 +1,2 @@
1
+ export { createDiscordChannel } from "./discord-channel.js";
2
+ export { createChannelDiscordFeature } from "./feature.js";
@@ -0,0 +1,3 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type SlackChannelOptions } from "./slack-channel.js";
3
+ export declare function createChannelSlackFeature(options?: SlackChannelOptions): FeatureDefinition;
@@ -0,0 +1,30 @@
1
+ import { chatConnectionNameSchema, DELIVERY_CHANNEL_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createSlackChannel } from "./slack-channel.js";
4
+ export function createChannelSlackFeature(options = {}) {
5
+ return defineFeature("channel-slack", (r) => {
6
+ r.describe('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.');
7
+ r.uiHints({
8
+ displayLabel: "Slack Channel",
9
+ category: "notifications",
10
+ recommended: false,
11
+ });
12
+ r.requires("delivery");
13
+ r.requires("secrets");
14
+ const webhooks = r.secretNamespace("webhooks", {
15
+ label: { en: "Slack webhooks", de: "Slack-Webhooks", es: "Webhooks de Slack" },
16
+ hint: {
17
+ en: 'One secret per connection name; the value is the Slack webhook URL. Address it with route: { slack: "<connection name>" }.',
18
+ de: 'Ein Secret pro Verbindungsname; der Wert ist die Slack-Webhook-URL. Adressierung mit route: { slack: "<Verbindungsname>" }.',
19
+ 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>" }.',
20
+ },
21
+ scope: "tenant",
22
+ nameSchema: chatConnectionNameSchema,
23
+ });
24
+ const channel = createSlackChannel(options, webhooks.keyFor);
25
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
26
+ mode: channel.mode,
27
+ send: channel.send,
28
+ });
29
+ });
30
+ }
@@ -0,0 +1,2 @@
1
+ export { createChannelSlackFeature } from "./feature.js";
2
+ export { createSlackChannel, type SlackChannelOptions } from "./slack-channel.js";
@@ -0,0 +1,2 @@
1
+ export { createChannelSlackFeature } from "./feature.js";
2
+ export { createSlackChannel } from "./slack-channel.js";
@@ -0,0 +1,5 @@
1
+ import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
2
+ export type SlackChannelOptions = ChatWebhookChannelOptions;
3
+ export declare const SLACK_DEFAULT_ALLOWED_HOSTS: readonly ["hooks.slack.com"];
4
+ export declare function escapeSlackText(text: string): string;
5
+ export declare function createSlackChannel(options: SlackChannelOptions, keyFor: (connection: string) => string): DeliveryChannel;
@@ -0,0 +1,16 @@
1
+ import { chatMessageText, createChatWebhookChannel, } from "../delivery/index.js";
2
+ export const SLACK_DEFAULT_ALLOWED_HOSTS = ["hooks.slack.com"];
3
+ // Slack mrkdwn treats & < > as control characters (links, @-mentions via <!channel>).
4
+ export function escapeSlackText(text) {
5
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
6
+ }
7
+ export function createSlackChannel(options, keyFor) {
8
+ return createChatWebhookChannel({
9
+ name: "slack",
10
+ featureName: "channel-slack",
11
+ defaultAllowedHosts: SLACK_DEFAULT_ALLOWED_HOSTS,
12
+ keyFor,
13
+ buildBody: (message) => ({ text: escapeSlackText(chatMessageText(message)) }),
14
+ options,
15
+ });
16
+ }
@@ -0,0 +1,3 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type TeamsChannelOptions } from "./teams-channel.js";
3
+ export declare function createChannelTeamsFeature(options?: TeamsChannelOptions): FeatureDefinition;
@@ -0,0 +1,34 @@
1
+ import { chatConnectionNameSchema, DELIVERY_CHANNEL_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createTeamsChannel } from "./teams-channel.js";
4
+ export function createChannelTeamsFeature(options = {}) {
5
+ return defineFeature("channel-teams", (r) => {
6
+ r.describe('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.');
7
+ r.uiHints({
8
+ displayLabel: "Microsoft Teams Channel",
9
+ category: "notifications",
10
+ recommended: false,
11
+ });
12
+ r.requires("delivery");
13
+ r.requires("secrets");
14
+ const webhooks = r.secretNamespace("webhooks", {
15
+ label: {
16
+ en: "Microsoft Teams webhooks",
17
+ de: "Microsoft Teams-Webhooks",
18
+ es: "Webhooks de Microsoft Teams",
19
+ },
20
+ hint: {
21
+ en: 'One secret per connection name; the value is the Microsoft Teams webhook URL. Address it with route: { teams: "<connection name>" }.',
22
+ de: 'Ein Secret pro Verbindungsname; der Wert ist die Microsoft Teams-Webhook-URL. Adressierung mit route: { teams: "<Verbindungsname>" }.',
23
+ 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>" }.',
24
+ },
25
+ scope: "tenant",
26
+ nameSchema: chatConnectionNameSchema,
27
+ });
28
+ const channel = createTeamsChannel(options, webhooks.keyFor);
29
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
30
+ mode: channel.mode,
31
+ send: channel.send,
32
+ });
33
+ });
34
+ }
@@ -0,0 +1,2 @@
1
+ export { createChannelTeamsFeature } from "./feature.js";
2
+ export { createTeamsChannel, type TeamsChannelOptions } from "./teams-channel.js";
@@ -0,0 +1,2 @@
1
+ export { createChannelTeamsFeature } from "./feature.js";
2
+ export { createTeamsChannel } from "./teams-channel.js";
@@ -0,0 +1,5 @@
1
+ import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
2
+ export type TeamsChannelOptions = ChatWebhookChannelOptions;
3
+ export declare const TEAMS_DEFAULT_ALLOWED_HOSTS: readonly [".webhook.office.com", ".logic.azure.com", ".powerplatform.com"];
4
+ export declare function buildTeamsAdaptiveCard(title: string, body: string | undefined): unknown;
5
+ export declare function createTeamsChannel(options: TeamsChannelOptions, keyFor: (connection: string) => string): DeliveryChannel;
@@ -0,0 +1,38 @@
1
+ import { createChatWebhookChannel, } from "../delivery/index.js";
2
+ export const TEAMS_DEFAULT_ALLOWED_HOSTS = [
3
+ ".webhook.office.com",
4
+ ".logic.azure.com",
5
+ ".powerplatform.com",
6
+ ];
7
+ // Text only ever goes into TextBlocks (no markup interpretation of titles/bodies
8
+ // beyond Adaptive Card's own text rendering).
9
+ export function buildTeamsAdaptiveCard(title, body) {
10
+ return {
11
+ type: "message",
12
+ attachments: [
13
+ {
14
+ contentType: "application/vnd.microsoft.card.adaptive",
15
+ contentUrl: null,
16
+ content: {
17
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
18
+ type: "AdaptiveCard",
19
+ version: "1.4",
20
+ body: [
21
+ { type: "TextBlock", text: title, weight: "Bolder", wrap: true },
22
+ ...(body ? [{ type: "TextBlock", text: body, wrap: true }] : []),
23
+ ],
24
+ },
25
+ },
26
+ ],
27
+ };
28
+ }
29
+ export function createTeamsChannel(options, keyFor) {
30
+ return createChatWebhookChannel({
31
+ name: "teams",
32
+ featureName: "channel-teams",
33
+ defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS,
34
+ keyFor,
35
+ buildBody: (message) => buildTeamsAdaptiveCard(message.title, message.body),
36
+ options,
37
+ });
38
+ }
@@ -0,0 +1,3 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type TelegramChannelOptions } from "./telegram-channel.js";
3
+ export declare function createChannelTelegramFeature(options?: TelegramChannelOptions): FeatureDefinition;
@@ -0,0 +1,28 @@
1
+ import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createTelegramChannel } from "./telegram-channel.js";
4
+ export function createChannelTelegramFeature(options = {}) {
5
+ return defineFeature("channel-telegram", (r) => {
6
+ r.describe('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.');
7
+ r.uiHints({
8
+ displayLabel: "Telegram Channel",
9
+ category: "notifications",
10
+ recommended: false,
11
+ });
12
+ r.requires("delivery");
13
+ r.requires("secrets");
14
+ const botToken = r.secret("botToken", {
15
+ label: { en: "Telegram bot token" },
16
+ hint: {
17
+ en: 'Token from @BotFather. Used for every Telegram chat the tenant addresses via route: { telegram: "<chat id>" }.',
18
+ },
19
+ redact: () => "••••••••",
20
+ scope: "tenant",
21
+ });
22
+ const channel = createTelegramChannel(options, botToken.name);
23
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, "telegram", {
24
+ mode: channel.mode,
25
+ send: channel.send,
26
+ });
27
+ });
28
+ }
@@ -0,0 +1,2 @@
1
+ export { createChannelTelegramFeature } from "./feature.js";
2
+ export { createTelegramChannel, type TelegramChannelOptions } from "./telegram-channel.js";
@@ -0,0 +1,2 @@
1
+ export { createChannelTelegramFeature } from "./feature.js";
2
+ export { createTelegramChannel } from "./telegram-channel.js";
@@ -0,0 +1,8 @@
1
+ import { type DeliveryChannel } from "../delivery/index.js";
2
+ export type TelegramChannelOptions = {
3
+ readonly apiBaseUrl?: string;
4
+ readonly timeoutMs?: number;
5
+ };
6
+ export declare const TELEGRAM_DEFAULT_API_BASE_URL = "https://api.telegram.org";
7
+ export declare const TELEGRAM_TEXT_MAX_CHARS = 4096;
8
+ export declare function createTelegramChannel(options: TelegramChannelOptions, botTokenKey: string): DeliveryChannel;
@@ -0,0 +1,54 @@
1
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
2
+ import { chatMessageText, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, toChannelResult, truncateChars, } from "../delivery/index.js";
3
+ export const TELEGRAM_DEFAULT_API_BASE_URL = "https://api.telegram.org";
4
+ export const TELEGRAM_TEXT_MAX_CHARS = 4096;
5
+ // Numeric chat/group id (groups and channels are negative) or a public @channelname.
6
+ const TELEGRAM_CHAT_ID_PATTERN = /^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{3,31})$/;
7
+ // `<bot id>:<secret>`; the token is spliced into the URL path, so only the real shape passes.
8
+ const TELEGRAM_BOT_TOKEN_PATTERN = /^\d{1,20}:[A-Za-z0-9_-]{10,}$/;
9
+ export function createTelegramChannel(options, botTokenKey) {
10
+ const apiBaseUrl = options.apiBaseUrl ?? TELEGRAM_DEFAULT_API_BASE_URL;
11
+ return {
12
+ name: "telegram",
13
+ mode: "queued",
14
+ async send(address, message, ctx) {
15
+ if (!TELEGRAM_CHAT_ID_PATTERN.test(address)) {
16
+ return toChannelResult(address, { ok: false, code: "invalid_address" });
17
+ }
18
+ let token;
19
+ try {
20
+ const revealed = await ctx.secrets?.get(ctx.tenantId, botTokenKey, {
21
+ userId: SYSTEM_USER_ID,
22
+ handlerName: "channel-telegram:send",
23
+ });
24
+ token = revealed?.reveal();
25
+ }
26
+ catch {
27
+ token = undefined;
28
+ }
29
+ if (!token || !TELEGRAM_BOT_TOKEN_PATTERN.test(token)) {
30
+ return toChannelResult(address, { ok: false, code: "missing_credentials" });
31
+ }
32
+ // The base URL is the allowlist: only its own host is reachable.
33
+ let baseHost;
34
+ try {
35
+ baseHost = new URL(apiBaseUrl).hostname;
36
+ }
37
+ catch {
38
+ return toChannelResult(address, { ok: false, code: "host_not_allowed" });
39
+ }
40
+ const result = await postChatWebhook({
41
+ url: `${apiBaseUrl.replace(/\/+$/, "")}/bot${token}/sendMessage`,
42
+ allowedHosts: [baseHost],
43
+ requireHttps: apiBaseUrl.startsWith("https:"),
44
+ timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
45
+ // No parse_mode: Telegram renders the text literally.
46
+ body: {
47
+ chat_id: address,
48
+ text: truncateChars(chatMessageText(message), TELEGRAM_TEXT_MAX_CHARS),
49
+ },
50
+ });
51
+ return toChannelResult(address, result);
52
+ },
53
+ };
54
+ }
@@ -248,17 +248,17 @@ export const forgetSubjectWrite = defineWriteHandler({
248
248
  ? { kind: "tenant", tenantId: raw.tenantId }
249
249
  : { kind: "record", entity: raw.entity, id: raw.id };
250
250
  const subjectKey = subjectIdToKey(subject);
251
- const tenantScopeDenial = await resolveTenantScopeDenial(ctx.db.unsafeRaw("tenant-scope check runs against the subject's tenant, not necessarily the caller's"), ctx.registry.features, event.user, raw);
251
+ const tenantScopeDenial = await resolveTenantScopeDenial(ctx.db.unsafeRaw(), ctx.registry.features, event.user, raw);
252
252
  if (tenantScopeDenial) {
253
- const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, denialReasonOf(tenantScopeDenial), ctx.dbOutsideTransaction?.unsafeRaw("denial audit append: names the prober's own tenant stream on the outside-transaction db"));
253
+ const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, denialReasonOf(tenantScopeDenial), ctx.dbOutsideTransaction?.unsafeRaw());
254
254
  return auditFailure ?? tenantScopeDenial;
255
255
  }
256
256
  // Runs after the tenant gate but before any key is touched: a retention
257
257
  // check ahead of the tenant gate would leak a foreign entity's retention
258
258
  // posture to a cross-tenant prober.
259
- const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw("retention check reads the record row's owning tenant, not the caller's"));
259
+ const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw());
260
260
  if (retentionDenial) {
261
- const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, TARGET_RECORD_RETENTION_BLOCK_DELETE, ctx.dbOutsideTransaction?.unsafeRaw("denial audit append: names the prober's own tenant stream on the outside-transaction db"));
261
+ const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, TARGET_RECORD_RETENTION_BLOCK_DELETE, ctx.dbOutsideTransaction?.unsafeRaw());
262
262
  return auditFailure ?? retentionDenial;
263
263
  }
264
264
  // Erase BEFORE the audit append: if the append throws, the key is gone
@@ -272,7 +272,7 @@ export const forgetSubjectWrite = defineWriteHandler({
272
272
  });
273
273
  // Blind-index sweep (#818): nulls bidx columns now so the deterministic
274
274
  // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
275
- const crossTenantSubjectRunner = ctx.db.unsafeRaw("blind-index sweep and search purge address the subject across tenants");
275
+ const crossTenantSubjectRunner = ctx.db.unsafeRaw();
276
276
  await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
277
277
  // Derived search index still holds plaintext (#1610) — purge next to the
278
278
  // blind-index sweep. No adapter → no-op (apps without search).
@@ -300,7 +300,7 @@ export const forgetSubjectWrite = defineWriteHandler({
300
300
  // update is best-effort for real users.
301
301
  if (raw.kind === "user" && ctx.registry.features.has("user")) {
302
302
  try {
303
- const userLifecycleRunner = ctx.db.unsafeRaw("user lifecycle update and PAT revoke run on the SYSTEM user stream");
303
+ const userLifecycleRunner = ctx.db.unsafeRaw();
304
304
  await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
305
305
  if (ctx.registry.features.has("personal-access-tokens")) {
306
306
  await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
@@ -31,7 +31,7 @@ export const clearCustomFieldHandler = {
31
31
  },
32
32
  handler: async (event, ctx) => {
33
33
  const payload = event.payload; // @cast-boundary engine-payload
34
- const accessCheck = await checkFieldAccessForWrite(ctx.db.unsafeRaw(CLEAR_CUSTOM_FIELD_REASON), event.user.tenantId, payload.entityName, payload.fieldKey, event.user.roles);
34
+ const accessCheck = await checkFieldAccessForWrite(ctx.db.unsafeRaw(), event.user.tenantId, payload.entityName, payload.fieldKey, event.user.roles);
35
35
  if (!accessCheck.ok) {
36
36
  if (accessCheck.reason === "field_definition_not_found") {
37
37
  return failNotFound("fieldDefinition", payload.fieldKey);
@@ -26,7 +26,7 @@ export function createDefineTenantFieldHandler(opts = {}) {
26
26
  throw new Error("define-tenant-field: tenantId is SYSTEM_TENANT_ID — use define-system-field for system-scope definitions");
27
27
  }
28
28
  if (limit !== undefined) {
29
- const current = await countTenantFieldDefinitions(ctx.db.unsafeRaw(DEFINE_TENANT_FIELD_REASON), tenantId);
29
+ const current = await countTenantFieldDefinitions(ctx.db.unsafeRaw(), tenantId);
30
30
  if (current >= limit) {
31
31
  return failUnprocessable("cap_exceeded", {
32
32
  capName: "customFields.fieldDefinition.count",
@@ -54,7 +54,7 @@ export const setCustomFieldHandler = {
54
54
  },
55
55
  handler: async (event, ctx) => {
56
56
  const payload = event.payload; // @cast-boundary engine-payload
57
- const loaded = await loadFieldDefinition(ctx.db.unsafeRaw(SET_CUSTOM_FIELD_REASON), event.user.tenantId, payload.entityName, payload.fieldKey);
57
+ const loaded = await loadFieldDefinition(ctx.db.unsafeRaw(), event.user.tenantId, payload.entityName, payload.fieldKey);
58
58
  if (!loaded.found) {
59
59
  return failNotFound("fieldDefinition", payload.fieldKey);
60
60
  }
@@ -46,7 +46,7 @@ export function wireCustomFieldsUserDataRightsFor(r, opts) {
46
46
  const tableName = extractTableName(opts.entityTable, "custom-fields/wire-user-data-rights");
47
47
  // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
48
48
  r.useExtension(EXT_USER_DATA, opts.entityName, {
49
- export: async (ctx) => exportCustomFields(ctx.db.unsafeRaw(CUSTOM_FIELDS_EXPORT_REASON), ctx, tableName, opts),
49
+ export: async (ctx) => exportCustomFields(ctx.db.unsafeRaw(), ctx, tableName, opts),
50
50
  escapeHatch: { reason: CUSTOM_FIELDS_EXPORT_REASON },
51
51
  });
52
52
  }
@@ -64,7 +64,7 @@ export function createDataRetentionFeature() {
64
64
  return;
65
65
  }
66
66
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
67
- const cleanupDb = ctx.db.unsafeRaw("retention cleanup runs raw deletes and preset lookups; every statement filters by this run's tenantId"); // @cast-boundary db-operator — jobs never run inside a DbTx
67
+ const cleanupDb = ctx.db.unsafeRaw(); // @cast-boundary db-operator — jobs never run inside a DbTx
68
68
  const tenantPreset = await resolveTenantRetentionPreset({
69
69
  db: cleanupDb,
70
70
  registry: ctx.registry,
@@ -1,5 +1,6 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
4
5
  import type { ChannelContext } from "./types.js";
5
- export declare function buildChannelContext(db: DbConnection, registry: Registry, sseBroker: SseBroker | undefined, tenantId: TenantId): ChannelContext;
6
+ export declare function buildChannelContext(db: DbConnection, registry: Registry, sseBroker: SseBroker | undefined, tenantId: TenantId, secrets?: SecretsContext): ChannelContext;
@@ -3,6 +3,6 @@ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  // Shared by the synchronous delivery-service path and the async job handlers
4
4
  // (which pass sseBroker=undefined — only the inline inApp channel uses SSE,
5
5
  // and it never runs through a job).
6
- export function buildChannelContext(db, registry, sseBroker, tenantId) {
7
- return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId };
6
+ export function buildChannelContext(db, registry, sseBroker, tenantId, secrets) {
7
+ return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId, secrets };
8
8
  }
@@ -0,0 +1,19 @@
1
+ import { type ChatSendResult } from "./chat-webhook-sender.js";
2
+ import type { ChannelMessage, ChannelResult, DeliveryChannel } from "./types.js";
3
+ export type ChatWebhookChannelOptions = {
4
+ readonly allowedHosts?: readonly string[];
5
+ readonly requireHttps?: boolean;
6
+ readonly timeoutMs?: number;
7
+ };
8
+ export declare function toChannelResult(address: string, result: ChatSendResult): ChannelResult;
9
+ export declare function chatMessageText(message: ChannelMessage): string;
10
+ export type ChatWebhookChannelSpec = {
11
+ readonly name: string;
12
+ readonly featureName: string;
13
+ readonly defaultAllowedHosts: readonly string[];
14
+ readonly requiredPathPrefix?: string;
15
+ readonly keyFor: (connection: string) => string;
16
+ readonly buildBody: (message: ChannelMessage) => unknown;
17
+ readonly options: ChatWebhookChannelOptions;
18
+ };
19
+ export declare function createChatWebhookChannel(spec: ChatWebhookChannelSpec): DeliveryChannel;
@@ -0,0 +1,48 @@
1
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
2
+ import { chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, } from "./chat-webhook-sender.js";
3
+ export function toChannelResult(address, result) {
4
+ return result.ok
5
+ ? { status: "sent", address }
6
+ : { status: "failed", error: result.code, address };
7
+ }
8
+ export function chatMessageText(message) {
9
+ return message.body ? `${message.title}\n${message.body}` : message.title;
10
+ }
11
+ // Slack/Discord/Teams: the route address is a tenant-chosen connection name, the
12
+ // webhook URL lives in the secrets store under that name. Route-only (no resolve).
13
+ export function createChatWebhookChannel(spec) {
14
+ const { options } = spec;
15
+ return {
16
+ name: spec.name,
17
+ mode: "queued",
18
+ async send(address, message, ctx) {
19
+ if (!chatConnectionNameSchema.safeParse(address).success) {
20
+ return toChannelResult(address, { ok: false, code: "invalid_address" });
21
+ }
22
+ let url;
23
+ try {
24
+ const revealed = await ctx.secrets?.get(ctx.tenantId, spec.keyFor(address), {
25
+ userId: SYSTEM_USER_ID,
26
+ handlerName: `${spec.featureName}:send`,
27
+ });
28
+ url = revealed?.reveal();
29
+ }
30
+ catch {
31
+ url = undefined;
32
+ }
33
+ if (!url)
34
+ return toChannelResult(address, { ok: false, code: "missing_credentials" });
35
+ const result = await postChatWebhook({
36
+ url,
37
+ allowedHosts: options.allowedHosts ?? spec.defaultAllowedHosts,
38
+ requireHttps: options.requireHttps ?? true,
39
+ ...(spec.requiredPathPrefix !== undefined && {
40
+ requiredPathPrefix: spec.requiredPathPrefix,
41
+ }),
42
+ timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
43
+ body: spec.buildBody(message),
44
+ });
45
+ return toChannelResult(address, result);
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,21 @@
1
+ import * as z from "zod";
2
+ export type ChatSendFailureCode = `http_${number}` | "timeout" | "network_error" | "redirect_blocked" | "host_not_allowed" | "missing_credentials" | "invalid_address";
3
+ export type ChatSendResult = {
4
+ readonly ok: true;
5
+ } | {
6
+ readonly ok: false;
7
+ readonly code: ChatSendFailureCode;
8
+ };
9
+ export declare const DEFAULT_CHAT_TIMEOUT_MS = 10000;
10
+ export declare const chatConnectionNameSchema: z.ZodString;
11
+ export type ChatWebhookRequest = {
12
+ readonly url: string;
13
+ readonly allowedHosts: readonly string[];
14
+ readonly requireHttps: boolean;
15
+ readonly requiredPathPrefix?: string;
16
+ readonly timeoutMs: number;
17
+ readonly body: unknown;
18
+ readonly send?: (url: string, init: RequestInit) => Promise<Response>;
19
+ };
20
+ export declare function postChatWebhook(request: ChatWebhookRequest): Promise<ChatSendResult>;
21
+ export declare function truncateChars(text: string, maxChars: number): string;