@cosmicdrift/kumiko-bundled-features 0.340.0 → 0.341.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/dist/auth-email-password/bootstrap.d.ts +74 -0
  2. package/dist/auth-email-password/bootstrap.js +165 -0
  3. package/dist/auth-email-password/constants.d.ts +2 -0
  4. package/dist/auth-email-password/constants.js +5 -0
  5. package/dist/auth-email-password/errors.d.ts +1 -0
  6. package/dist/auth-email-password/errors.js +6 -0
  7. package/dist/auth-email-password/feature.js +2 -1
  8. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
  9. package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
  10. package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
  11. package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
  12. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
  13. package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
  14. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  15. package/dist/auth-email-password/index.d.ts +2 -0
  16. package/dist/auth-email-password/index.js +1 -0
  17. package/dist/auth-email-password/invite-token-store.d.ts +3 -0
  18. package/dist/auth-email-password/invite-token-store.js +5 -0
  19. package/dist/auth-email-password/magic-link-mail.js +1 -0
  20. package/dist/auth-mfa/config.d.ts +1 -1
  21. package/dist/auth-mfa/config.js +8 -3
  22. package/dist/auth-mfa/feature.d.ts +2 -0
  23. package/dist/auth-mfa/feature.js +2 -1
  24. package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
  25. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  26. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
  27. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
  28. package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
  29. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
  30. package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
  31. package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
  32. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
  33. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
  34. package/dist/channel-discord/discord-channel.d.ts +4 -0
  35. package/dist/channel-discord/discord-channel.js +4 -0
  36. package/dist/channel-discord/feature.js +7 -2
  37. package/dist/channel-discord/index.d.ts +1 -1
  38. package/dist/channel-discord/index.js +1 -1
  39. package/dist/channel-email/email-channel.js +1 -0
  40. package/dist/channel-slack/feature.js +4 -2
  41. package/dist/channel-slack/index.d.ts +1 -1
  42. package/dist/channel-slack/index.js +1 -1
  43. package/dist/channel-slack/slack-channel.d.ts +4 -0
  44. package/dist/channel-slack/slack-channel.js +4 -0
  45. package/dist/channel-teams/feature.js +4 -2
  46. package/dist/channel-teams/index.d.ts +1 -1
  47. package/dist/channel-teams/index.js +1 -1
  48. package/dist/channel-teams/teams-channel.d.ts +4 -0
  49. package/dist/channel-teams/teams-channel.js +4 -0
  50. package/dist/channel-telegram/feature.js +4 -2
  51. package/dist/channel-telegram/index.d.ts +1 -1
  52. package/dist/channel-telegram/index.js +1 -1
  53. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  54. package/dist/channel-telegram/telegram-channel.js +28 -12
  55. package/dist/delivery/chat-webhook-channel.d.ts +5 -1
  56. package/dist/delivery/chat-webhook-channel.js +10 -5
  57. package/dist/delivery/chat-webhook-sender.d.ts +6 -2
  58. package/dist/delivery/chat-webhook-sender.js +13 -8
  59. package/dist/delivery/delivery-service.d.ts +4 -3
  60. package/dist/delivery/delivery-service.js +53 -33
  61. package/dist/delivery/index.d.ts +2 -2
  62. package/dist/delivery/index.js +2 -2
  63. package/dist/delivery/jobs.js +3 -1
  64. package/dist/delivery/types.d.ts +5 -3
  65. package/dist/legal-pages/feature.d.ts +3 -15
  66. package/dist/legal-pages/feature.js +20 -50
  67. package/dist/legal-pages/lib/boot-check.d.ts +10 -0
  68. package/dist/legal-pages/lib/boot-check.js +33 -0
  69. package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
  70. package/dist/legal-pages/lib/validate-routes.js +17 -0
  71. package/dist/page-render/index.d.ts +2 -1
  72. package/dist/page-render/index.js +1 -0
  73. package/dist/page-render/layout.d.ts +7 -0
  74. package/dist/page-render/layout.js +5 -0
  75. package/dist/page-render/trailing-slash.d.ts +2 -0
  76. package/dist/page-render/trailing-slash.js +15 -0
  77. package/dist/renderer-simple/feature.js +1 -0
  78. package/dist/renderer-simple/index.d.ts +1 -1
  79. package/dist/renderer-simple/index.js +1 -1
  80. package/dist/renderer-simple/simple-renderer.d.ts +12 -5
  81. package/dist/renderer-simple/simple-renderer.js +66 -12
  82. package/dist/secrets/announce-secret-change.d.ts +8 -0
  83. package/dist/secrets/announce-secret-change.js +18 -0
  84. package/dist/secrets/feature.js +1 -1
  85. package/dist/secrets/handlers/delete.write.js +2 -0
  86. package/dist/secrets/handlers/set.write.js +6 -1
  87. package/dist/secrets/i18n.d.ts +4 -0
  88. package/dist/secrets/i18n.js +4 -0
  89. package/dist/secrets/secrets-context.d.ts +2 -0
  90. package/dist/secrets/secrets-context.js +3 -0
  91. package/dist/secrets/write-gate.d.ts +5 -1
  92. package/dist/secrets/write-gate.js +24 -1
  93. package/dist/tenant/constants.d.ts +1 -0
  94. package/dist/tenant/constants.js +1 -0
  95. package/dist/tenant/handlers/add-member.write.js +14 -3
  96. package/dist/tenant/handlers/invitations.query.d.ts +8 -0
  97. package/dist/tenant/handlers/invitations.query.js +15 -1
  98. package/dist/tenant/index.d.ts +1 -0
  99. package/dist/tenant/index.js +1 -0
  100. package/dist/tenant/invitation-table.d.ts +2 -0
  101. package/dist/tenant/invitation-table.js +10 -1
  102. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
  103. package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
  104. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  105. package/dist/tier-engine/feature.js +83 -14
  106. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
  107. package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
  108. package/dist/user/invited-global-roles.d.ts +14 -0
  109. package/dist/user/invited-global-roles.js +35 -0
  110. package/dist/user-data-rights/feature.js +3 -0
  111. package/dist/waitlist/constants.d.ts +33 -0
  112. package/dist/waitlist/constants.js +35 -0
  113. package/dist/waitlist/entity.d.ts +433 -0
  114. package/dist/waitlist/entity.js +64 -0
  115. package/dist/waitlist/feature.d.ts +3 -0
  116. package/dist/waitlist/feature.js +38 -0
  117. package/dist/waitlist/handlers/invite.write.d.ts +9 -0
  118. package/dist/waitlist/handlers/invite.write.js +119 -0
  119. package/dist/waitlist/handlers/list.query.d.ts +1 -0
  120. package/dist/waitlist/handlers/list.query.js +10 -0
  121. package/dist/waitlist/handlers/reject.write.d.ts +7 -0
  122. package/dist/waitlist/handlers/reject.write.js +80 -0
  123. package/dist/waitlist/handlers/submit.write.d.ts +13 -0
  124. package/dist/waitlist/handlers/submit.write.js +112 -0
  125. package/dist/waitlist/i18n.d.ts +6 -0
  126. package/dist/waitlist/i18n.js +31 -0
  127. package/dist/waitlist/index.d.ts +5 -0
  128. package/dist/waitlist/index.js +4 -0
  129. package/dist/waitlist/lib.d.ts +10 -0
  130. package/dist/waitlist/lib.js +24 -0
  131. package/dist/waitlist/mail.d.ts +15 -0
  132. package/dist/waitlist/mail.js +73 -0
  133. package/dist/waitlist/options.d.ts +29 -0
  134. package/dist/waitlist/options.js +8 -0
  135. package/dist/waitlist/payloads.d.ts +14 -0
  136. package/dist/waitlist/payloads.js +47 -0
  137. package/dist/waitlist/screens.d.ts +2 -0
  138. package/dist/waitlist/screens.js +42 -0
  139. package/dist/waitlist/user-data-hooks.d.ts +3 -0
  140. package/dist/waitlist/user-data-hooks.js +71 -0
  141. package/package.json +17 -9
  142. package/src/auth-email-password/changes.json +18 -0
  143. package/src/auth-mfa/changes.json +6 -0
  144. package/src/billing-foundation/changes.json +6 -0
  145. package/src/channel-telegram/changes.json +9 -0
  146. package/src/delivery/changes.json +7 -0
  147. package/src/files/changes.json +13 -1
  148. package/src/renderer-simple/changes.json +6 -0
  149. package/src/secrets/changes.json +12 -0
  150. package/src/tenant/changes.json +6 -0
  151. package/src/waitlist/changes.json +17 -0
@@ -1,6 +1,6 @@
1
- import { chatConnectionNameSchema, DELIVERY_CHANNEL_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/delivery";
1
+ import { chatConnectionNameSchema, chatWebhookUrlSchema, DELIVERY_CHANNEL_EXTENSION, resolveChatWebhookTarget, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
2
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
- import { createTeamsChannel } from "./teams-channel.js";
3
+ import { createTeamsChannel, TEAMS_DEFAULT_ALLOWED_HOSTS, } from "./teams-channel.js";
4
4
  export function createChannelTeamsFeature(options = {}) {
5
5
  return defineFeature("channel-teams", (r) => {
6
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.');
@@ -11,6 +11,7 @@ export function createChannelTeamsFeature(options = {}) {
11
11
  });
12
12
  r.requires("delivery");
13
13
  r.requires("secrets");
14
+ const webhookTarget = resolveChatWebhookTarget({ defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS }, options);
14
15
  const webhooks = r.secretNamespace("webhooks", {
15
16
  label: {
16
17
  en: "Microsoft Teams webhooks",
@@ -24,6 +25,7 @@ export function createChannelTeamsFeature(options = {}) {
24
25
  },
25
26
  scope: "tenant",
26
27
  nameSchema: chatConnectionNameSchema,
28
+ valueSchema: chatWebhookUrlSchema(webhookTarget),
27
29
  });
28
30
  const channel = createTeamsChannel(options, webhooks.keyFor);
29
31
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
@@ -1,2 +1,2 @@
1
1
  export { createChannelTeamsFeature } from "./feature.js";
2
- export { createTeamsChannel, type TeamsChannelOptions } from "./teams-channel.js";
2
+ export { createTeamsChannel, TEAMS_DEFAULT_ALLOWED_HOSTS, TEAMS_SECRET_KEYS, type TeamsChannelOptions, } from "./teams-channel.js";
@@ -1,2 +1,2 @@
1
1
  export { createChannelTeamsFeature } from "./feature.js";
2
- export { createTeamsChannel } from "./teams-channel.js";
2
+ export { createTeamsChannel, TEAMS_DEFAULT_ALLOWED_HOSTS, TEAMS_SECRET_KEYS, } from "./teams-channel.js";
@@ -1,5 +1,9 @@
1
1
  import { type ChatWebhookChannelOptions, type DeliveryChannel } from "../delivery/index.js";
2
2
  export type TeamsChannelOptions = ChatWebhookChannelOptions;
3
+ export declare const TEAMS_SECRET_KEYS: {
4
+ readonly webhookPrefix: "channel-teams:webhooks.";
5
+ readonly webhookKeyFor: (connection: string) => string;
6
+ };
3
7
  export declare const TEAMS_DEFAULT_ALLOWED_HOSTS: readonly [".webhook.office.com", ".logic.azure.com", ".powerplatform.com"];
4
8
  export declare function buildTeamsAdaptiveCard(title: string, body: string | undefined): unknown;
5
9
  export declare function createTeamsChannel(options: TeamsChannelOptions, keyFor: (connection: string) => string): DeliveryChannel;
@@ -1,4 +1,8 @@
1
1
  import { createChatWebhookChannel, } from "../delivery/index.js";
2
+ export const TEAMS_SECRET_KEYS = {
3
+ webhookPrefix: "channel-teams:webhooks.",
4
+ webhookKeyFor: (connection) => `channel-teams:webhooks.${connection}`,
5
+ };
2
6
  export const TEAMS_DEFAULT_ALLOWED_HOSTS = [
3
7
  ".webhook.office.com",
4
8
  ".logic.azure.com",
@@ -1,9 +1,10 @@
1
1
  import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
2
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
- import { createTelegramChannel } from "./telegram-channel.js";
3
+ import * as z from "zod";
4
+ import { createTelegramChannel, isTelegramBotToken, } from "./telegram-channel.js";
4
5
  export function createChannelTelegramFeature(options = {}) {
5
6
  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.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, its host allowlist (`allowedHosts`, default `api.telegram.org`), `requireHttps` (default true; boot fails on a mismatch) and the request timeout are app options of `createChannelTelegramFeature(opts)`, never tenant config.');
7
8
  r.uiHints({
8
9
  displayLabel: "Telegram Channel",
9
10
  category: "notifications",
@@ -18,6 +19,7 @@ export function createChannelTelegramFeature(options = {}) {
18
19
  },
19
20
  redact: () => "••••••••",
20
21
  scope: "tenant",
22
+ valueSchema: z.string().refine(isTelegramBotToken, "invalid bot token"),
21
23
  });
22
24
  const channel = createTelegramChannel(options, botToken.name);
23
25
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "telegram", {
@@ -1,2 +1,2 @@
1
1
  export { createChannelTelegramFeature } from "./feature.js";
2
- export { createTelegramChannel, type TelegramChannelOptions } from "./telegram-channel.js";
2
+ export { createTelegramChannel, isTelegramBotToken, isTelegramChatId, TELEGRAM_DEFAULT_API_BASE_URL, TELEGRAM_SECRET_KEYS, type TelegramChannelOptions, } from "./telegram-channel.js";
@@ -1,2 +1,2 @@
1
1
  export { createChannelTelegramFeature } from "./feature.js";
2
- export { createTelegramChannel } from "./telegram-channel.js";
2
+ export { createTelegramChannel, isTelegramBotToken, isTelegramChatId, TELEGRAM_DEFAULT_API_BASE_URL, TELEGRAM_SECRET_KEYS, } from "./telegram-channel.js";
@@ -1,8 +1,16 @@
1
1
  import { type DeliveryChannel } from "../delivery/index.js";
2
2
  export type TelegramChannelOptions = {
3
3
  readonly apiBaseUrl?: string;
4
+ readonly allowedHosts?: readonly string[];
5
+ readonly requireHttps?: boolean;
4
6
  readonly timeoutMs?: number;
5
7
  };
6
8
  export declare const TELEGRAM_DEFAULT_API_BASE_URL = "https://api.telegram.org";
9
+ export declare const TELEGRAM_DEFAULT_ALLOWED_HOSTS: readonly string[];
7
10
  export declare const TELEGRAM_TEXT_MAX_CHARS = 4096;
11
+ export declare const TELEGRAM_SECRET_KEYS: {
12
+ readonly botToken: "channel-telegram:secret:bot-token";
13
+ };
14
+ export declare function isTelegramChatId(address: string): boolean;
15
+ export declare function isTelegramBotToken(token: string): boolean;
8
16
  export declare function createTelegramChannel(options: TelegramChannelOptions, botTokenKey: string): DeliveryChannel;
@@ -1,18 +1,42 @@
1
1
  import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
2
2
  import { chatMessageText, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, toChannelResult, truncateChars, } from "../delivery/index.js";
3
3
  export const TELEGRAM_DEFAULT_API_BASE_URL = "https://api.telegram.org";
4
+ export const TELEGRAM_DEFAULT_ALLOWED_HOSTS = ["api.telegram.org"];
4
5
  export const TELEGRAM_TEXT_MAX_CHARS = 4096;
6
+ export const TELEGRAM_SECRET_KEYS = { botToken: "channel-telegram:secret:bot-token" };
5
7
  // Numeric chat/group id (groups and channels are negative) or a public @channelname.
6
8
  const TELEGRAM_CHAT_ID_PATTERN = /^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{3,31})$/;
7
9
  // `<bot id>:<secret>`; the token is spliced into the URL path, so only the real shape passes.
8
10
  const TELEGRAM_BOT_TOKEN_PATTERN = /^\d{1,20}:[A-Za-z0-9_-]{10,}$/;
11
+ export function isTelegramChatId(address) {
12
+ return TELEGRAM_CHAT_ID_PATTERN.test(address);
13
+ }
14
+ export function isTelegramBotToken(token) {
15
+ return TELEGRAM_BOT_TOKEN_PATTERN.test(token);
16
+ }
9
17
  export function createTelegramChannel(options, botTokenKey) {
10
18
  const apiBaseUrl = options.apiBaseUrl ?? TELEGRAM_DEFAULT_API_BASE_URL;
19
+ const allowedHosts = options.allowedHosts ?? TELEGRAM_DEFAULT_ALLOWED_HOSTS;
20
+ const requireHttps = options.requireHttps ?? true;
21
+ // Fail at boot: a misconfigured base URL must not surface as per-send failures.
22
+ let parsedBase;
23
+ try {
24
+ parsedBase = new URL(apiBaseUrl);
25
+ }
26
+ catch {
27
+ throw new Error("channel-telegram: apiBaseUrl is not a valid URL");
28
+ }
29
+ if (!allowedHosts.includes(parsedBase.hostname)) {
30
+ throw new Error(`channel-telegram: apiBaseUrl host "${parsedBase.hostname}" is not in allowedHosts`);
31
+ }
32
+ if (requireHttps && parsedBase.protocol !== "https:") {
33
+ throw new Error("channel-telegram: apiBaseUrl must use https (set requireHttps: false to opt out)");
34
+ }
11
35
  return {
12
36
  name: "telegram",
13
37
  mode: "queued",
14
38
  async send(address, message, ctx) {
15
- if (!TELEGRAM_CHAT_ID_PATTERN.test(address)) {
39
+ if (!isTelegramChatId(address)) {
16
40
  return toChannelResult(address, { ok: false, code: "invalid_address" });
17
41
  }
18
42
  let token;
@@ -26,21 +50,13 @@ export function createTelegramChannel(options, botTokenKey) {
26
50
  catch {
27
51
  token = undefined;
28
52
  }
29
- if (!token || !TELEGRAM_BOT_TOKEN_PATTERN.test(token)) {
53
+ if (!token || !isTelegramBotToken(token)) {
30
54
  return toChannelResult(address, { ok: false, code: "missing_credentials" });
31
55
  }
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
56
  const result = await postChatWebhook({
41
57
  url: `${apiBaseUrl.replace(/\/+$/, "")}/bot${token}/sendMessage`,
42
- allowedHosts: [baseHost],
43
- requireHttps: apiBaseUrl.startsWith("https:"),
58
+ allowedHosts,
59
+ requireHttps,
44
60
  timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
45
61
  // No parse_mode: Telegram renders the text literally.
46
62
  body: {
@@ -1,4 +1,4 @@
1
- import { type ChatSendResult } from "./chat-webhook-sender.js";
1
+ import { type ChatSendResult, type ChatWebhookTarget } from "./chat-webhook-sender.js";
2
2
  import type { ChannelMessage, ChannelResult, DeliveryChannel } from "./types.js";
3
3
  export type ChatWebhookChannelOptions = {
4
4
  readonly allowedHosts?: readonly string[];
@@ -7,6 +7,10 @@ export type ChatWebhookChannelOptions = {
7
7
  };
8
8
  export declare function toChannelResult(address: string, result: ChatSendResult): ChannelResult;
9
9
  export declare function chatMessageText(message: ChannelMessage): string;
10
+ export declare function resolveChatWebhookTarget(provider: {
11
+ readonly defaultAllowedHosts: readonly string[];
12
+ readonly requiredPathPrefix?: string;
13
+ }, options: ChatWebhookChannelOptions): ChatWebhookTarget;
10
14
  export type ChatWebhookChannelSpec = {
11
15
  readonly name: string;
12
16
  readonly featureName: string;
@@ -8,6 +8,15 @@ export function toChannelResult(address, result) {
8
8
  export function chatMessageText(message) {
9
9
  return message.body ? `${message.title}\n${message.body}` : message.title;
10
10
  }
11
+ export function resolveChatWebhookTarget(provider, options) {
12
+ return {
13
+ allowedHosts: options.allowedHosts ?? provider.defaultAllowedHosts,
14
+ requireHttps: options.requireHttps ?? true,
15
+ ...(provider.requiredPathPrefix !== undefined && {
16
+ requiredPathPrefix: provider.requiredPathPrefix,
17
+ }),
18
+ };
19
+ }
11
20
  // Slack/Discord/Teams: the route address is a tenant-chosen connection name, the
12
21
  // webhook URL lives in the secrets store under that name. Route-only (no resolve).
13
22
  export function createChatWebhookChannel(spec) {
@@ -34,11 +43,7 @@ export function createChatWebhookChannel(spec) {
34
43
  return toChannelResult(address, { ok: false, code: "missing_credentials" });
35
44
  const result = await postChatWebhook({
36
45
  url,
37
- allowedHosts: options.allowedHosts ?? spec.defaultAllowedHosts,
38
- requireHttps: options.requireHttps ?? true,
39
- ...(spec.requiredPathPrefix !== undefined && {
40
- requiredPathPrefix: spec.requiredPathPrefix,
41
- }),
46
+ ...resolveChatWebhookTarget(spec, options),
42
47
  timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
43
48
  body: spec.buildBody(message),
44
49
  });
@@ -8,14 +8,18 @@ export type ChatSendResult = {
8
8
  };
9
9
  export declare const DEFAULT_CHAT_TIMEOUT_MS = 10000;
10
10
  export declare const chatConnectionNameSchema: z.ZodString;
11
- export type ChatWebhookRequest = {
12
- readonly url: string;
11
+ export type ChatWebhookTarget = {
13
12
  readonly allowedHosts: readonly string[];
14
13
  readonly requireHttps: boolean;
15
14
  readonly requiredPathPrefix?: string;
15
+ };
16
+ export type ChatWebhookRequest = ChatWebhookTarget & {
17
+ readonly url: string;
16
18
  readonly timeoutMs: number;
17
19
  readonly body: unknown;
18
20
  readonly send?: (url: string, init: RequestInit) => Promise<Response>;
19
21
  };
22
+ export declare function checkChatWebhookTarget(target: ChatWebhookTarget, url: string): URL | undefined;
23
+ export declare function chatWebhookUrlSchema(target: ChatWebhookTarget): z.ZodType<string>;
20
24
  export declare function postChatWebhook(request: ChatWebhookRequest): Promise<ChatSendResult>;
21
25
  export declare function truncateChars(text: string, maxChars: number): string;
@@ -27,31 +27,36 @@ function hostMatches(hostname, allowedHost) {
27
27
  }
28
28
  // Allowlist runs on the parsed URL, never on the raw string: userinfo
29
29
  // (`https://hooks.slack.com@evil.com`) and look-alike suffixes must not pass.
30
- function checkTarget(request) {
30
+ export function checkChatWebhookTarget(target, url) {
31
31
  let parsed;
32
32
  try {
33
- parsed = new URL(request.url);
33
+ parsed = new URL(url);
34
34
  }
35
35
  catch {
36
36
  return undefined;
37
37
  }
38
- const protocolAllowed = parsed.protocol === "https:" || (!request.requireHttps && parsed.protocol === "http:");
38
+ const protocolAllowed = parsed.protocol === "https:" || (!target.requireHttps && parsed.protocol === "http:");
39
39
  if (!protocolAllowed)
40
40
  return undefined;
41
41
  // egress() has no port rule; a provider webhook over https never needs a custom port.
42
- if (request.requireHttps && parsed.port !== "")
42
+ if (target.requireHttps && parsed.port !== "")
43
43
  return undefined;
44
44
  if (parsed.username !== "" || parsed.password !== "")
45
45
  return undefined;
46
46
  const hostname = parsed.hostname.toLowerCase();
47
- if (!request.allowedHosts.some((allowed) => hostMatches(hostname, allowed)))
47
+ if (!target.allowedHosts.some((allowed) => hostMatches(hostname, allowed)))
48
48
  return undefined;
49
- if (request.requiredPathPrefix !== undefined &&
50
- !parsed.pathname.startsWith(request.requiredPathPrefix)) {
49
+ if (target.requiredPathPrefix !== undefined &&
50
+ !parsed.pathname.startsWith(target.requiredPathPrefix)) {
51
51
  return undefined;
52
52
  }
53
53
  return parsed;
54
54
  }
55
+ export function chatWebhookUrlSchema(target) {
56
+ return z
57
+ .string()
58
+ .refine((url) => checkChatWebhookTarget(target, url) !== undefined, "invalid webhook url");
59
+ }
55
60
  function isTimeout(err) {
56
61
  return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
57
62
  }
@@ -71,7 +76,7 @@ function classifyFailure(err) {
71
76
  // Never throws and never surfaces the URL: egress/Bun errors can carry it, so
72
77
  // every failure is reduced to a code here.
73
78
  export async function postChatWebhook(request) {
74
- const target = checkTarget(request);
79
+ const target = checkChatWebhookTarget(request, request.url);
75
80
  if (!target)
76
81
  return { ok: false, code: "host_not_allowed" };
77
82
  let response;
@@ -1,8 +1,8 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
- import type { EscapeHatchAuditSink, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
- import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
3
+ import type { EscapeHatchAuditSink, NotifyJobDispatcher, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
4
  import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
5
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
6
6
  import type { Redis } from "ioredis";
7
7
  import { type DeliveryChannel, type DeliveryService } from "./types.js";
8
8
  export type RateLimitConfig = {
@@ -20,7 +20,8 @@ export type DeliveryServiceOptions = {
20
20
  readonly rateLimit?: RateLimitConfig;
21
21
  readonly isChannelKilled?: KillSwitchResolver;
22
22
  readonly idempotencyRedis?: Redis;
23
- readonly jobRunner?: JobRunner;
23
+ readonly jobRunner?: NotifyJobDispatcher;
24
+ readonly secrets?: SecretsContext;
24
25
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
25
26
  readonly log?: Logger;
26
27
  };
@@ -25,7 +25,7 @@ export function collectChannels(registry) {
25
25
  // nicht aus Extension-Usages). Multi-Kind-Plugin-Pool lebt jetzt im
26
26
  // `renderer-foundation`-Bundle via `collectRendererPlugins`.
27
27
  export function createDeliveryService(options) {
28
- const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, escapeHatchAuditSink, log, } = options;
28
+ const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, escapeHatchAuditSink, log, } = options;
29
29
  const idemRedis = idempotencyRedis ?? rateLimit?.redis;
30
30
  // Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
31
31
  // Lua. Runs single-threaded in Redis, so two parallel clients can't both
@@ -119,11 +119,21 @@ export function createDeliveryService(options) {
119
119
  ...bridgeStub(),
120
120
  }));
121
121
  }
122
+ function recordDelivery(run, entry, deliveryAttemptId) {
123
+ run.deliveries.push({
124
+ channel: entry.channel,
125
+ recipientId: entry.recipientId,
126
+ status: entry.status,
127
+ error: entry.error,
128
+ deliveryAttemptId,
129
+ });
130
+ }
122
131
  // Single-shot terminal log (inline channels, skips, idempotency dups). Async
123
132
  // attempts instead append a queued event up front and a terminal event from
124
133
  // the send job — see deliverViaChannel + jobs.ts.
125
- async function logDelivery(entry) {
126
- await logAttempt(db, registry, entry);
134
+ async function logDelivery(run, entry) {
135
+ const attemptId = await logAttempt(db, registry, entry);
136
+ recordDelivery(run, entry, attemptId);
127
137
  }
128
138
  // Deliver one resolved (channel, address) pair. Inline channels (inApp) and
129
139
  // the no-job-runner fallback render + send synchronously and log the terminal
@@ -131,13 +141,13 @@ export function createDeliveryService(options) {
131
141
  // hand off to the delivery.render (or delivery.send) job, which appends the
132
142
  // terminal event on the same attempt stream.
133
143
  async function deliverViaChannel(args) {
134
- const { channel, address, message, channelCtx, tenantId, recipientId, notificationType, priority, } = args;
135
- if (channel.mode === "queued" && jobRunner) {
144
+ const { run, channel, address, message, channelCtx, tenantId, recipientId, notificationType, priority, } = args;
145
+ if (channel.mode === "queued" && run.jobDispatcher) {
136
146
  // Async hand-off: record the attempt as queued, then dispatch the
137
147
  // render/send job — the terminal sent/failed event is appended by the
138
148
  // job, not here.
139
149
  const deliveryAttemptId = generateId();
140
- await appendAttemptEvent(db, registry, deliveryAttemptId, {
150
+ const queuedEntry = {
141
151
  tenantId,
142
152
  notificationType,
143
153
  channel: channel.name,
@@ -146,8 +156,9 @@ export function createDeliveryService(options) {
146
156
  status: "queued",
147
157
  error: null,
148
158
  priority,
149
- });
150
- await jobRunner.dispatch(channel.render ? DeliveryJobs.render : DeliveryJobs.send, {
159
+ };
160
+ await appendAttemptEvent(db, registry, deliveryAttemptId, queuedEntry);
161
+ await run.jobDispatcher.dispatch(channel.render ? DeliveryJobs.render : DeliveryJobs.send, {
151
162
  channelName: channel.name,
152
163
  address,
153
164
  tenantId,
@@ -157,12 +168,13 @@ export function createDeliveryService(options) {
157
168
  priority,
158
169
  message,
159
170
  }, { priority: deliveryPriorityRank[priority] });
171
+ recordDelivery(run, queuedEntry, deliveryAttemptId);
160
172
  }
161
173
  else {
162
174
  // Inline (inApp) or no-job-runner fallback: render + send synchronously.
163
175
  const rendered = channel.render ? await channel.render(message, channelCtx) : undefined;
164
176
  const result = await channel.send(address, message, channelCtx, rendered);
165
- await logDelivery({
177
+ await logDelivery(run, {
166
178
  tenantId,
167
179
  notificationType,
168
180
  channel: channel.name,
@@ -174,7 +186,7 @@ export function createDeliveryService(options) {
174
186
  });
175
187
  }
176
188
  }
177
- function buildMessage(notificationType, data, channelName) {
189
+ function buildMessage(notificationType, data, channelName, locale) {
178
190
  // Look up per-channel template from notification definition
179
191
  const notifDef = registry.getAllNotifications().get(notificationType);
180
192
  const templateFn = notifDef?.templates?.[channelName];
@@ -186,6 +198,7 @@ export function createDeliveryService(options) {
186
198
  title: channelData["title"] ?? data["title"] ?? notificationType,
187
199
  body: channelData["body"],
188
200
  data: channelData,
201
+ locale,
189
202
  };
190
203
  }
191
204
  // @cast-boundary engine-payload — generic notification.data shape
@@ -194,6 +207,7 @@ export function createDeliveryService(options) {
194
207
  title: data?.["title"] ?? notificationType,
195
208
  body: data?.["body"],
196
209
  data,
210
+ locale,
197
211
  };
198
212
  }
199
213
  // Shared by deliverToUser (resolved account address) and deliverDirect (a
@@ -226,18 +240,18 @@ export function createDeliveryService(options) {
226
240
  // and conservative — DB ordering no longer decides the outcome).
227
241
  return !prefs.some((p) => p.enabled === false);
228
242
  }
229
- async function deliverToUser(userId, notificationType, data, tenantId, priority) {
230
- const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
243
+ async function deliverToUser(userId, notificationType, data, tenantId, priority, run) {
244
+ const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
231
245
  for (const channel of channels) {
232
246
  // Route-only channel (no per-user address): not a user-notification target.
233
247
  if (!channel.resolve)
234
248
  continue;
235
- const message = buildMessage(notificationType, data, channel.name);
249
+ const message = buildMessage(notificationType, data, channel.name, run.locale);
236
250
  // Kill switch: tenant admin disabled this channel entirely
237
251
  if (isChannelKilled) {
238
252
  const killed = await isChannelKilled(tenantId, channel.name);
239
253
  if (killed) {
240
- await logDelivery({
254
+ await logDelivery(run, {
241
255
  tenantId,
242
256
  notificationType,
243
257
  channel: channel.name,
@@ -254,7 +268,7 @@ export function createDeliveryService(options) {
254
268
  if (priority !== "critical") {
255
269
  const enabled = await isChannelEnabled(userId, tenantId, notificationType, channel.name);
256
270
  if (!enabled) {
257
- await logDelivery({
271
+ await logDelivery(run, {
258
272
  tenantId,
259
273
  notificationType,
260
274
  channel: channel.name,
@@ -271,7 +285,7 @@ export function createDeliveryService(options) {
271
285
  if (rateLimit) {
272
286
  const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
273
287
  if (!allowed) {
274
- await logDelivery({
288
+ await logDelivery(run, {
275
289
  tenantId,
276
290
  notificationType,
277
291
  channel: channel.name,
@@ -287,7 +301,7 @@ export function createDeliveryService(options) {
287
301
  try {
288
302
  const address = await channel.resolve(userId, channelCtx);
289
303
  if (!address) {
290
- await logDelivery({
304
+ await logDelivery(run, {
291
305
  tenantId,
292
306
  notificationType,
293
307
  channel: channel.name,
@@ -301,7 +315,7 @@ export function createDeliveryService(options) {
301
315
  }
302
316
  if (priority !== "critical" &&
303
317
  (await isAddressSuppressed(address, tenantId, notificationType, channel.name))) {
304
- await logDelivery({
318
+ await logDelivery(run, {
305
319
  tenantId,
306
320
  notificationType,
307
321
  channel: channel.name,
@@ -314,6 +328,7 @@ export function createDeliveryService(options) {
314
328
  continue;
315
329
  }
316
330
  await deliverViaChannel({
331
+ run,
317
332
  channel,
318
333
  address,
319
334
  message,
@@ -325,7 +340,7 @@ export function createDeliveryService(options) {
325
340
  });
326
341
  }
327
342
  catch (err) {
328
- await logDelivery({
343
+ await logDelivery(run, {
329
344
  tenantId,
330
345
  notificationType,
331
346
  channel: channel.name,
@@ -338,20 +353,20 @@ export function createDeliveryService(options) {
338
353
  }
339
354
  }
340
355
  }
341
- async function deliverDirect(route, notificationType, data, tenantId, priority, recipientId) {
342
- const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
356
+ async function deliverDirect(route, notificationType, data, tenantId, priority, recipientId, run) {
357
+ const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
343
358
  // Direct routing skips preferences (no user account) but NOT rate limit
344
359
  // — direct sends can still be abused (webhook replays, test harnesses).
345
360
  for (const channel of channels) {
346
361
  const address = route[channel.name];
347
- const message = buildMessage(notificationType, data, channel.name);
362
+ const message = buildMessage(notificationType, data, channel.name, run.locale);
348
363
  if (!address)
349
364
  continue;
350
365
  // Address opt-out (critical priority skips it, same rule as user
351
366
  // preferences).
352
367
  if (priority !== "critical" &&
353
368
  (await isAddressSuppressed(address, tenantId, notificationType, channel.name))) {
354
- await logDelivery({
369
+ await logDelivery(run, {
355
370
  tenantId,
356
371
  notificationType,
357
372
  channel: channel.name,
@@ -367,7 +382,7 @@ export function createDeliveryService(options) {
367
382
  if (rateLimit) {
368
383
  const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
369
384
  if (!allowed) {
370
- await logDelivery({
385
+ await logDelivery(run, {
371
386
  tenantId,
372
387
  notificationType,
373
388
  channel: channel.name,
@@ -382,6 +397,7 @@ export function createDeliveryService(options) {
382
397
  }
383
398
  try {
384
399
  await deliverViaChannel({
400
+ run,
385
401
  channel,
386
402
  address,
387
403
  message,
@@ -393,7 +409,7 @@ export function createDeliveryService(options) {
393
409
  });
394
410
  }
395
411
  catch (err) {
396
- await logDelivery({
412
+ await logDelivery(run, {
397
413
  tenantId,
398
414
  notificationType,
399
415
  channel: channel.name,
@@ -407,13 +423,18 @@ export function createDeliveryService(options) {
407
423
  }
408
424
  }
409
425
  return {
410
- async notify(notificationType, options, _user, tenantId) {
426
+ async notify(notificationType, options, _user, tenantId, jobDispatcher) {
411
427
  const { to, route, data, idempotencyKey } = options;
412
428
  const priority = options.priority ?? "normal";
429
+ const run = {
430
+ deliveries: [],
431
+ jobDispatcher: options.immediate ? undefined : (jobDispatcher ?? jobRunner),
432
+ locale: options.locale,
433
+ };
413
434
  if (idempotencyKey) {
414
435
  const first = await claimIdempotency(tenantId, idempotencyKey);
415
436
  if (!first) {
416
- await logDelivery({
437
+ await logDelivery(run, {
417
438
  tenantId,
418
439
  notificationType,
419
440
  channel: "*",
@@ -423,14 +444,12 @@ export function createDeliveryService(options) {
423
444
  error: "duplicate_idempotency_key",
424
445
  priority,
425
446
  });
426
- // skip: duplicate send deduped via idempotency key, logged above
427
- return;
447
+ return { deliveries: run.deliveries };
428
448
  }
429
449
  }
430
450
  if (route) {
431
- await deliverDirect(route, notificationType, data, tenantId, priority, options.recipientId ?? null);
432
- // skip: direct route delivered, no recipient resolution needed
433
- return;
451
+ await deliverDirect(route, notificationType, data, tenantId, priority, options.recipientId ?? null, run);
452
+ return { deliveries: run.deliveries };
434
453
  }
435
454
  if (to !== undefined) {
436
455
  let userIds;
@@ -444,9 +463,10 @@ export function createDeliveryService(options) {
444
463
  userIds = to;
445
464
  }
446
465
  for (const userId of userIds) {
447
- await deliverToUser(userId, notificationType, data, tenantId, priority);
466
+ await deliverToUser(userId, notificationType, data, tenantId, priority, run);
448
467
  }
449
468
  }
469
+ return { deliveries: run.deliveries };
450
470
  },
451
471
  };
452
472
  }
@@ -1,6 +1,6 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
- export { type ChatWebhookChannelOptions, chatMessageText, createChatWebhookChannel, toChannelResult, } from "./chat-webhook-channel.js";
3
- export { type ChatSendFailureCode, type ChatSendResult, type ChatWebhookRequest, chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
2
+ export { type ChatWebhookChannelOptions, chatMessageText, createChatWebhookChannel, resolveChatWebhookTarget, toChannelResult, } from "./chat-webhook-channel.js";
3
+ export { type ChatSendFailureCode, type ChatSendResult, type ChatWebhookRequest, type ChatWebhookTarget, chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
4
4
  export type { DeliveryStatusValue } from "./constants.js";
5
5
  export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
6
6
  export { collectChannels, createDeliveryService, type DeliveryServiceOptions, type KillSwitchResolver, type RateLimitConfig, } from "./delivery-service.js";
@@ -1,6 +1,6 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
- export { chatMessageText, createChatWebhookChannel, toChannelResult, } from "./chat-webhook-channel.js";
3
- export { chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
2
+ export { chatMessageText, createChatWebhookChannel, resolveChatWebhookTarget, toChannelResult, } from "./chat-webhook-channel.js";
3
+ export { chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
4
4
  export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
5
5
  export { collectChannels, createDeliveryService, } from "./delivery-service.js";
6
6
  export { createDeliveryFeature } from "./feature.js";
@@ -15,6 +15,7 @@ const channelMessageSchema = z.object({
15
15
  title: z.string(),
16
16
  body: z.string().optional(),
17
17
  data: z.record(z.string(), z.unknown()).optional(),
18
+ locale: z.string().optional(),
18
19
  });
19
20
  const renderJobPayloadSchema = z.object({
20
21
  channelName: z.string(),
@@ -56,6 +57,7 @@ function toMessage(p) {
56
57
  title: p.message.title,
57
58
  body: p.message.body,
58
59
  data: p.message.data,
60
+ locale: p.message.locale,
59
61
  };
60
62
  }
61
63
  function entryFor(p, status, error, address) {
@@ -81,7 +83,7 @@ export const deliveryRenderJob = async (payload, ctx) => {
81
83
  const tenantId = p.tenantId; // @cast-boundary engine-payload — stream tenant
82
84
  const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
83
85
  const channel = resolveChannel(registry, p.channelName);
84
- const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
86
+ const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
85
87
  try {
86
88
  if (!channel.render) {
87
89
  throw new Error(`delivery.render: channel "${p.channelName}" has no render step`);