@cosmicdrift/kumiko-samples 0.342.0 → 0.344.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 (84) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/constants.ts +5 -1
  5. package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +1 -0
  6. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +99 -8
  7. package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
  8. package/packages/bundled-features/src/auth-email-password/email-templates.ts +76 -16
  9. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -1
  10. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -1
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -0
  12. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +4 -0
  13. package/packages/bundled-features/src/billing-foundation/changes.json +7 -0
  14. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +35 -7
  15. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +36 -22
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +12 -2
  17. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-locale.ts +1 -1
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +34 -1
  19. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  20. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +14 -5
  21. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +61 -36
  22. package/packages/bundled-features/src/billing-foundation/index.ts +11 -0
  23. package/packages/bundled-features/src/billing-foundation/types.ts +25 -7
  24. package/packages/bundled-features/src/billing-foundation/validate-options.ts +36 -3
  25. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -1
  26. package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +1 -2
  27. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +16 -6
  28. package/packages/bundled-features/src/billing-foundation/web/index.ts +12 -0
  29. package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
  30. package/packages/bundled-features/src/channel-email/feature.ts +1 -0
  31. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -0
  32. package/packages/bundled-features/src/channel-push/feature.ts +1 -0
  33. package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
  34. package/packages/bundled-features/src/channel-teams/changes.json +8 -0
  35. package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
  36. package/packages/bundled-features/src/channel-teams/teams-channel.ts +16 -0
  37. package/packages/bundled-features/src/channel-telegram/feature.ts +1 -0
  38. package/packages/bundled-features/src/delivery/changes.json +32 -0
  39. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +7 -2
  40. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +60 -14
  41. package/packages/bundled-features/src/delivery/constants.ts +4 -0
  42. package/packages/bundled-features/src/delivery/delivery-service.ts +70 -5
  43. package/packages/bundled-features/src/delivery/events.ts +15 -1
  44. package/packages/bundled-features/src/delivery/feature.ts +32 -4
  45. package/packages/bundled-features/src/delivery/handlers/log.query.ts +65 -20
  46. package/packages/bundled-features/src/delivery/i18n.ts +55 -0
  47. package/packages/bundled-features/src/delivery/index.ts +2 -0
  48. package/packages/bundled-features/src/delivery/jobs.ts +43 -20
  49. package/packages/bundled-features/src/delivery/notify-factory.ts +19 -0
  50. package/packages/bundled-features/src/delivery/public-names.ts +4 -0
  51. package/packages/bundled-features/src/delivery/tables.ts +3 -0
  52. package/packages/bundled-features/src/delivery/testing.ts +5 -9
  53. package/packages/bundled-features/src/delivery/types.ts +6 -2
  54. package/packages/bundled-features/src/delivery/web/client-plugin.tsx +16 -1
  55. package/packages/bundled-features/src/delivery/web/delivery-channel-cell.tsx +13 -0
  56. package/packages/bundled-features/src/delivery/web/delivery-error-cell.tsx +17 -0
  57. package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +10 -3
  58. package/packages/bundled-features/src/delivery/web/delivery-time-cell.tsx +8 -0
  59. package/packages/bundled-features/src/delivery/web/delivery-type-cell.tsx +20 -0
  60. package/packages/bundled-features/src/jobs/changes.json +6 -0
  61. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  62. package/packages/bundled-features/src/legal-pages/constants.ts +2 -0
  63. package/packages/bundled-features/src/legal-pages/feature.ts +8 -3
  64. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  65. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +10 -0
  66. package/packages/bundled-features/src/secrets/changes.json +6 -0
  67. package/packages/bundled-features/src/secrets/constants.ts +10 -0
  68. package/packages/bundled-features/src/secrets/feature.ts +4 -0
  69. package/packages/bundled-features/src/secrets/i18n.ts +5 -3
  70. package/packages/bundled-features/src/secrets/index.ts +8 -1
  71. package/packages/bundled-features/src/secrets/write-gate.ts +17 -5
  72. package/packages/bundled-features/src/shared/index.ts +6 -0
  73. package/packages/bundled-features/src/shared/redact.ts +27 -0
  74. package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -6
  75. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +0 -6
  76. package/packages/bundled-features/src/tenant/web/member-roles-cell.tsx +1 -1
  77. package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
  78. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +12 -2
  79. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -8
  80. package/packages/bundled-features/src/user/index.ts +1 -0
  81. package/packages/bundled-features/src/user/resolve-display-names.ts +35 -0
  82. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +10 -11
  83. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  84. /package/packages/bundled-features/src/{tenant → shared}/web/translate-or-raw.ts +0 -0
@@ -13,11 +13,23 @@ export {
13
13
  type SubscriptionStatus,
14
14
  SubscriptionStatuses,
15
15
  } from "../constants.js";
16
+ export {
17
+ CONSENT_LOCALES,
18
+ type ConsentLocale,
19
+ resolveConsentLocale,
20
+ } from "../consumer-protection/consent-locale.js";
16
21
  export type {
17
22
  BillingPlanBenefit,
18
23
  BillingPlanCatalog,
19
24
  BillingPlansResult,
20
25
  BillingPlanView,
26
+ LegalLinkSet,
21
27
  } from "../types.js";
22
28
  export { BillingPlansPanel } from "./billing-plans-panel.js";
29
+ export {
30
+ CheckoutConsentDialog,
31
+ type CheckoutConsentDialogProps,
32
+ type CheckoutConsentPayload,
33
+ CONSENT_TEXT_OUTDATED_CODE,
34
+ } from "./checkout-consent-dialog.js";
23
35
  export { type BillingFoundationClientOptions, billingFoundationClient } from "./client-plugin.js";
@@ -47,6 +47,7 @@ export function createChannelDiscordFeature(
47
47
  });
48
48
 
49
49
  const channel = createDiscordChannel(options, webhooks.keyFor);
50
+ r.translations({ keys: { "delivery.channel.discord": { en: "Discord" } } });
50
51
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
51
52
  mode: channel.mode,
52
53
  send: channel.send,
@@ -16,6 +16,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
16
16
  });
17
17
  r.requires("delivery");
18
18
 
19
+ r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
19
20
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "email", {
20
21
  mode: channel.mode,
21
22
  resolve: channel.resolve,
@@ -24,6 +24,7 @@ export function createChannelInAppFeature(): FeatureDefinition {
24
24
  });
25
25
 
26
26
  // Register as delivery channel via extension system
27
+ r.translations({ keys: { "delivery.channel.inApp": { en: "In-app" } } });
27
28
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "inApp", {
28
29
  mode: inAppChannel.mode,
29
30
  resolve: inAppChannel.resolve,
@@ -16,6 +16,7 @@ export function createChannelPushFeature(options: PushChannelOptions): FeatureDe
16
16
  });
17
17
  r.requires("delivery");
18
18
 
19
+ r.translations({ keys: { "delivery.channel.push": { en: "Push" } } });
19
20
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
20
21
  mode: channel.mode,
21
22
  resolve: channel.resolve,
@@ -41,6 +41,7 @@ export function createChannelSlackFeature(options: SlackChannelOptions = {}): Fe
41
41
  });
42
42
 
43
43
  const channel = createSlackChannel(options, webhooks.keyFor);
44
+ r.translations({ keys: { "delivery.channel.slack": { en: "Slack" } } });
44
45
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
45
46
  mode: channel.mode,
46
47
  send: channel.send,
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.343.0",
4
+ "type": "fix",
5
+ "title": "Teams no longer reports any 2xx as delivered",
6
+ "detail": "`channel-teams` no longer reports every 2xx answer as sent. A 200 with the body `1` (classic Office connector) is a confirmed send. A 202 (Power Automate / Workflows endpoints) means the message was accepted but not confirmed: the attempt is `sent` and `NotifyResult.deliveries[].confirmed` is `false`. Any other 2xx, including an empty 200 as a made-up URL returns, fails with `unexpected_response`.\n`NotifyDelivery.confirmed` and `ChannelResult.confirmed` are new and only ever `false`. `createChatWebhookChannel` takes an optional `classifyResponse`, which `postChatWebhook` calls for 2xx answers with a status and a lazy `readBodyPrefix()` (at most 64 bytes; the body is always cancelled afterwards). `ChatSendResult` success now carries `confirmed`. `confirmed` is returned for inline delivery only; jobs do not carry it yet."
7
+ }
8
+ ]
@@ -45,6 +45,7 @@ export function createChannelTeamsFeature(options: TeamsChannelOptions = {}): Fe
45
45
  });
46
46
 
47
47
  const channel = createTeamsChannel(options, webhooks.keyFor);
48
+ r.translations({ keys: { "delivery.channel.teams": { en: "Microsoft Teams" } } });
48
49
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
49
50
  mode: channel.mode,
50
51
  send: channel.send,
@@ -1,5 +1,7 @@
1
1
  import {
2
+ type ChatSendResult,
2
3
  type ChatWebhookChannelOptions,
4
+ type ChatWebhookResponse,
3
5
  createChatWebhookChannel,
4
6
  type DeliveryChannel,
5
7
  } from "../delivery/index.js";
@@ -40,6 +42,19 @@ export function buildTeamsAdaptiveCard(title: string, body: string | undefined):
40
42
  };
41
43
  }
42
44
 
45
+ // Classic Office connectors answer 200 with the body "1". Workflow endpoints answer
46
+ // 202 and never say whether the card reached the channel. Any other 2xx (an empty
47
+ // 200 is what a made-up URL returns) is not a Teams success.
48
+ export async function classifyTeamsResponse(
49
+ response: ChatWebhookResponse,
50
+ ): Promise<ChatSendResult> {
51
+ if (response.status === 202) return { ok: true, confirmed: false };
52
+ if (response.status === 200 && (await response.readBodyPrefix()).trim() === "1") {
53
+ return { ok: true, confirmed: true };
54
+ }
55
+ return { ok: false, code: "unexpected_response" };
56
+ }
57
+
43
58
  export function createTeamsChannel(
44
59
  options: TeamsChannelOptions,
45
60
  keyFor: (connection: string) => string,
@@ -49,6 +64,7 @@ export function createTeamsChannel(
49
64
  featureName: "channel-teams",
50
65
  defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS,
51
66
  keyFor,
67
+ classifyResponse: classifyTeamsResponse,
52
68
  buildBody: (message) => buildTeamsAdaptiveCard(message.title, message.body),
53
69
  options,
54
70
  });
@@ -33,6 +33,7 @@ export function createChannelTelegramFeature(
33
33
  });
34
34
 
35
35
  const channel = createTelegramChannel(options, botToken.name);
36
+ r.translations({ keys: { "delivery.channel.telegram": { en: "Telegram" } } });
36
37
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "telegram", {
37
38
  mode: channel.mode,
38
39
  send: channel.send,
@@ -1,4 +1,36 @@
1
1
  [
2
+ {
3
+ "version": "0.343.0",
4
+ "type": "improvement",
5
+ "title": "Delivery log marks sends the provider did not confirm",
6
+ "detail": "The delivery log now records when a provider accepted a send without confirming delivery (for example a Teams webhook answering 202). `store_delivery_attempts` gets a nullable boolean column `confirmed`: `false` means accepted but unconfirmed, `null` means confirmed or not applicable. The `deliveryAttempt` event carries `confirmed: false` on both the inline and the job path, `delivery:query:log` returns `confirmed` per row, and the status cell shows \"Sent (unconfirmed)\" for such rows.",
7
+ "migration": "New nullable column store_delivery_attempts.confirmed. Consumers generate the migration with the generator (kumiko-schema generate) and apply it; existing rows stay null."
8
+ },
9
+ {
10
+ "version": "0.343.0",
11
+ "type": "breaking",
12
+ "title": "Delivery errors are fixed codes instead of raw messages",
13
+ "detail": "Delivery stores and returns only fixed error codes, never raw error messages. `delivery_attempts.error`, the `deliveryAttempt` event, `NotifyResult.deliveries[].error` and the job failure now hold one of `DELIVERY_FAILURE_CODES` (`timeout`, `network_error`, `redirect_blocked`, `host_not_allowed`, `missing_credentials`, `invalid_address`, `unexpected_response`, `render_failed`, `send_failed`, `channel_error`), one of `DELIVERY_SKIP_REASONS`, or `http_<status>`. A throwing channel ends as `send_failed` or `render_failed`; a failure around resolve or dispatch ends as `channel_error`. The full error goes to the log with URLs and email addresses redacted. `redactUrls` and `redactErrorText` are new next to `redactEmailAddresses`, which moved out of the step dispatcher.\n`@cosmicdrift/kumiko-framework/engine` exports `DELIVERY_FAILURE_CODES`, `DELIVERY_SKIP_REASONS`, `isDeliveryErrorCode` and the types `DeliveryErrorCode`, `DeliveryFailureCode`, `DeliverySkipReason` and `ChatSendFailureCode`. `NotifyDelivery.error`, `ChannelResult.error` and `DeliveryLogEntry.error` are now `DeliveryErrorCode`. `delivery:query:log` returns `channel_error` for stored rows that still hold free text.",
14
+ "migration": "NotifyDelivery.error is now a DeliveryErrorCode. Code that matches on error text must match the code instead (for example send_failed). Custom channels must return a DeliveryErrorCode in ChannelResult.error. Existing attempt events keep their free text; reading them through delivery:query:log masks it as channel_error."
15
+ },
16
+ {
17
+ "version": "0.343.0",
18
+ "type": "improvement",
19
+ "title": "Delivery log shows time and error and translates its values",
20
+ "detail": "The delivery log screen shows the time and the error of each attempt and translates type, channel, status and error. Error codes map to `delivery.error.<code>` (`http_<status>` to `delivery.error.http` with a `status` parameter), statuses to `delivery.status.<status>`, channels to `delivery.channel.<name>`. Every bundled channel feature registers its own channel label. Notification types are shown through `<scope>.notification.<name>` for a type `<scope>:notify:<name>`; apps register those labels, and the short name is shown when none exists. English, German and Spanish texts are included for the bundled keys.\n`translateOrRaw` moved from the tenant web folder to `shared/web`; it is internal."
21
+ },
22
+ {
23
+ "version": "0.343.0",
24
+ "type": "improvement",
25
+ "title": "Delivery log shows the recipient name",
26
+ "detail": "`delivery:query:log` returns a `recipientLabel` per row and the delivery log screen shows it in the recipient column: the user's display name, else the decrypted address, else the recipient id. Names are resolved with one batched lookup per page, and only when the user feature is mounted. The new `resolveUserDisplayNames(db, userIds)` is exported from the user feature. The recipient column is not sortable."
27
+ },
28
+ {
29
+ "version": "0.343.0",
30
+ "type": "improvement",
31
+ "title": "createDeliveryTestContext matches production notify",
32
+ "detail": "`createDeliveryTestContext` now behaves like the production `ctx.notify`. It takes a `secrets` option for inline-delivered chat channels and passes the calling context's job dispatcher to `notify()` on every call. Queued channels now go through the delivery jobs when the stack has a job consumer (`jobs: { consumerLane: \"worker\" }`); call `stack.drainJobs()` before asserting on the result. With `jobs: {}` and no `consumerLane` the attempts stay queued and `drainJobs()` never finishes. Without `jobs` they still deliver inline.\nNew export `createDeliveryNotifyFactory(deliveryService, { deliverQueuedInline? })` builds this `NotifyFactory` for custom setups."
33
+ },
2
34
  {
3
35
  "version": "0.341.0",
4
36
  "type": "breaking",
@@ -1,6 +1,7 @@
1
1
  import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
2
2
  import {
3
3
  type ChatSendResult,
4
+ type ChatWebhookRequest,
4
5
  type ChatWebhookTarget,
5
6
  chatConnectionNameSchema,
6
7
  DEFAULT_CHAT_TIMEOUT_MS,
@@ -17,9 +18,10 @@ export type ChatWebhookChannelOptions = {
17
18
  };
18
19
 
19
20
  export function toChannelResult(address: string, result: ChatSendResult): ChannelResult {
20
- return result.ok
21
+ if (!result.ok) return { status: "failed", error: result.code, address };
22
+ return result.confirmed
21
23
  ? { status: "sent", address }
22
- : { status: "failed", error: result.code, address };
24
+ : { status: "sent", confirmed: false, address };
23
25
  }
24
26
 
25
27
  export function chatMessageText(message: ChannelMessage): string {
@@ -50,6 +52,8 @@ export type ChatWebhookChannelSpec = {
50
52
  // Resolves the secret key for a validated connection name.
51
53
  readonly keyFor: (connection: string) => string;
52
54
  readonly buildBody: (message: ChannelMessage) => unknown;
55
+ // Providers whose 2xx does not by itself mean "delivered" check the answer here.
56
+ readonly classifyResponse?: ChatWebhookRequest["classifyResponse"];
53
57
  readonly options: ChatWebhookChannelOptions;
54
58
  };
55
59
 
@@ -82,6 +86,7 @@ export function createChatWebhookChannel(spec: ChatWebhookChannelSpec): Delivery
82
86
  ...resolveChatWebhookTarget(spec, options),
83
87
  timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
84
88
  body: spec.buildBody(message),
89
+ ...(spec.classifyResponse && { classifyResponse: spec.classifyResponse }),
85
90
  });
86
91
  return toChannelResult(address, result);
87
92
  },
@@ -1,22 +1,27 @@
1
+ import type { ChatSendFailureCode } from "@cosmicdrift/kumiko-framework/engine";
1
2
  import { BlockedHostError, type EgressPolicy, egress } from "@cosmicdrift/kumiko-framework/http";
2
3
  import * as z from "zod";
3
4
 
4
5
  // Closed vocabulary: these codes (and nothing else) land in delivery_attempts.error.
5
6
  // Never err.message and never a provider response body — a fetch error can carry
6
7
  // the request URL, and the Telegram URL embeds the bot token.
7
- export type ChatSendFailureCode =
8
- | `http_${number}`
9
- | "timeout"
10
- | "network_error"
11
- | "redirect_blocked"
12
- | "host_not_allowed"
13
- | "missing_credentials"
14
- | "invalid_address";
8
+ export type { ChatSendFailureCode };
15
9
 
10
+ // confirmed=false: the provider accepted the request but does not say whether the
11
+ // message arrived (e.g. a 202 from a workflow endpoint).
16
12
  export type ChatSendResult =
17
- | { readonly ok: true }
13
+ | { readonly ok: true; readonly confirmed: boolean }
18
14
  | { readonly ok: false; readonly code: ChatSendFailureCode };
19
15
 
16
+ // What a classifier may look at on a 2xx answer. The body is never stored or logged.
17
+ export type ChatWebhookResponse = {
18
+ readonly status: number;
19
+ // At most 64 bytes, UTF-8 decoded.
20
+ readBodyPrefix(): Promise<string>;
21
+ };
22
+
23
+ const MAX_BODY_PREFIX_BYTES = 64;
24
+
20
25
  export const DEFAULT_CHAT_TIMEOUT_MS = 10_000;
21
26
 
22
27
  // Tenant-chosen connection name (the route address for webhook channels). Also
@@ -41,6 +46,8 @@ export type ChatWebhookRequest = ChatWebhookTarget & {
41
46
  readonly body: unknown;
42
47
  // Test seam; production always uses the policy-bound egress().
43
48
  readonly send?: (url: string, init: RequestInit) => Promise<Response>;
49
+ // Called for 2xx answers only. Default: every 2xx is a confirmed send.
50
+ readonly classifyResponse?: (response: ChatWebhookResponse) => Promise<ChatSendResult>;
44
51
  };
45
52
 
46
53
  // https targets are provider endpoints on public hosts: egress "external" adds
@@ -129,15 +136,54 @@ export async function postChatWebhook(request: ChatWebhookRequest): Promise<Chat
129
136
  return { ok: false, code: classifyFailure(err) };
130
137
  }
131
138
 
132
- // Free the connection; the body is never read or stored.
133
- await response.body?.cancel().catch(() => undefined);
134
-
135
139
  const { status } = response;
136
- if (status >= 300 && status < 400) return { ok: false, code: "redirect_blocked" };
137
- if (status >= 200 && status < 300) return { ok: true };
140
+ if (status >= 300 && status < 400) {
141
+ await response.body?.cancel().catch(() => undefined);
142
+ return { ok: false, code: "redirect_blocked" };
143
+ }
144
+ if (status >= 200 && status < 300) return classifySuccess(request, response);
145
+ await response.body?.cancel().catch(() => undefined);
138
146
  return { ok: false, code: `http_${status}` };
139
147
  }
140
148
 
149
+ // The body is cancelled afterwards in every case, even when the classifier throws,
150
+ // so the connection is freed and a large body is never read to the end.
151
+ async function classifySuccess(
152
+ request: ChatWebhookRequest,
153
+ response: Response,
154
+ ): Promise<ChatSendResult> {
155
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
156
+ try {
157
+ if (!request.classifyResponse) return { ok: true, confirmed: true };
158
+ return await request.classifyResponse({
159
+ status: response.status,
160
+ async readBodyPrefix() {
161
+ reader ??= response.body?.getReader();
162
+ if (!reader) return "";
163
+ const chunks: Uint8Array[] = [];
164
+ let total = 0;
165
+ while (total < MAX_BODY_PREFIX_BYTES) {
166
+ const { done, value } = await reader.read();
167
+ if (done) break;
168
+ chunks.push(value);
169
+ total += value.length;
170
+ }
171
+ const joined = new Uint8Array(total);
172
+ let offset = 0;
173
+ for (const chunk of chunks) {
174
+ joined.set(chunk, offset);
175
+ offset += chunk.length;
176
+ }
177
+ return new TextDecoder().decode(joined.subarray(0, MAX_BODY_PREFIX_BYTES));
178
+ },
179
+ });
180
+ } catch (err) {
181
+ return { ok: false, code: classifyFailure(err) };
182
+ } finally {
183
+ await (reader ? reader.cancel() : response.body?.cancel())?.catch(() => undefined);
184
+ }
185
+ }
186
+
141
187
  export function truncateChars(text: string, maxChars: number): string {
142
188
  const chars = Array.from(text);
143
189
  return chars.length <= maxChars ? text : chars.slice(0, maxChars).join("");
@@ -8,10 +8,14 @@ export const DELIVERY_CHANNEL_EXTENSION = "deliveryChannel" as const;
8
8
 
9
9
  export {
10
10
  DELIVERY_ATTEMPT_EVENT,
11
+ DELIVERY_CHANNEL_CELL_COMPONENT,
12
+ DELIVERY_ERROR_CELL_COMPONENT,
11
13
  DELIVERY_FEATURE,
12
14
  DELIVERY_LOG_SCREEN_ID,
13
15
  DELIVERY_RESUBSCRIBE_PATH,
14
16
  DELIVERY_STATUS_CELL_COMPONENT,
17
+ DELIVERY_TIME_CELL_COMPONENT,
18
+ DELIVERY_TYPE_CELL_COMPONENT,
15
19
  DELIVERY_UNSUBSCRIBE_PATH,
16
20
  DeliveryErrors,
17
21
  DeliveryHandlers,
@@ -2,6 +2,7 @@ import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
4
  import type {
5
+ DeliveryErrorCode,
5
6
  EscapeHatchAuditSink,
6
7
  NotifyDelivery,
7
8
  NotifyJobDispatcher,
@@ -10,12 +11,13 @@ import type {
10
11
  TenantId,
11
12
  } from "@cosmicdrift/kumiko-framework/engine";
12
13
  import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
13
- import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
14
+ import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
14
15
  import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
15
16
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
16
17
  import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
17
18
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
18
19
  import type { Redis } from "ioredis";
20
+ import { redactErrorText } from "../shared/index.js";
19
21
  import { hashUnsubscribeAddress } from "./address-opt-out.js";
20
22
  import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
21
23
  import { buildChannelContext } from "./channel-context.js";
@@ -25,12 +27,18 @@ import { selectNotificationPreferences } from "./db/queries/preferences.js";
25
27
  import {
26
28
  type ChannelContext,
27
29
  type ChannelMessage,
30
+ type ChannelResult,
28
31
  type DeliveryChannel,
29
32
  type DeliveryLogEntry,
30
33
  type DeliveryService,
31
34
  isDeliveryChannelPlugin,
35
+ type RenderedMessage,
32
36
  } from "./types.js";
33
37
 
38
+ export function redactedMessageOf(err: unknown): string {
39
+ return redactErrorText(err instanceof Error ? err.message : String(err));
40
+ }
41
+
34
42
  export type RateLimitConfig = {
35
43
  readonly redis: Redis;
36
44
  readonly maxPerHour: number; // per channel per tenant
@@ -99,6 +107,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
99
107
  log,
100
108
  } = options;
101
109
  const idemRedis = idempotencyRedis ?? rateLimit?.redis;
110
+ const logError = createFallbackLogger("delivery", log);
102
111
 
103
112
  // Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
104
113
  // Lua. Runs single-threaded in Redis, so two parallel clients can't both
@@ -232,6 +241,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
232
241
  recipientId: entry.recipientId,
233
242
  status: entry.status,
234
243
  error: entry.error,
244
+ ...(entry.confirmed === false && { confirmed: entry.confirmed }),
235
245
  deliveryAttemptId,
236
246
  });
237
247
  }
@@ -244,6 +254,36 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
244
254
  recordDelivery(run, entry, attemptId);
245
255
  }
246
256
 
257
+ // The stored/returned error is only the code; the redacted message goes to the log.
258
+ async function logInlineFailure(
259
+ run: NotifyRun,
260
+ args: {
261
+ readonly channel: DeliveryChannel;
262
+ readonly address: string;
263
+ readonly tenantId: TenantId;
264
+ readonly recipientId: string | null;
265
+ readonly notificationType: string;
266
+ readonly priority: NotifyPriority;
267
+ },
268
+ code: DeliveryErrorCode,
269
+ err: unknown,
270
+ ): Promise<void> {
271
+ logError.error(`${args.channel.name} ${code}: ${redactedMessageOf(err)}`, {
272
+ notificationType: args.notificationType,
273
+ channel: args.channel.name,
274
+ });
275
+ await logDelivery(run, {
276
+ tenantId: args.tenantId,
277
+ notificationType: args.notificationType,
278
+ channel: args.channel.name,
279
+ recipientId: args.recipientId,
280
+ recipientAddress: args.address,
281
+ status: "failed",
282
+ error: code,
283
+ priority: args.priority,
284
+ });
285
+ }
286
+
247
287
  // Deliver one resolved (channel, address) pair. Inline channels (inApp) and
248
288
  // the no-job-runner fallback render + send synchronously and log the terminal
249
289
  // status. Queued channels (email/push) record a `queued` attempt up front and
@@ -305,8 +345,24 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
305
345
  recordDelivery(run, queuedEntry, deliveryAttemptId);
306
346
  } else {
307
347
  // Inline (inApp) or no-job-runner fallback: render + send synchronously.
308
- const rendered = channel.render ? await channel.render(message, channelCtx) : undefined;
309
- const result = await channel.send(address, message, channelCtx, rendered);
348
+ let rendered: RenderedMessage | undefined;
349
+ if (channel.render) {
350
+ try {
351
+ rendered = await channel.render(message, channelCtx);
352
+ } catch (err) {
353
+ await logInlineFailure(run, args, "render_failed", err);
354
+ // skip: logInlineFailure recorded and logged the failure
355
+ return;
356
+ }
357
+ }
358
+ let result: ChannelResult;
359
+ try {
360
+ result = await channel.send(address, message, channelCtx, rendered);
361
+ } catch (err) {
362
+ await logInlineFailure(run, args, "send_failed", err);
363
+ // skip: logInlineFailure recorded and logged the failure
364
+ return;
365
+ }
310
366
  await logDelivery(run, {
311
367
  tenantId,
312
368
  notificationType,
@@ -316,6 +372,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
316
372
  status: result.status,
317
373
  error: result.error ?? null,
318
374
  priority,
375
+ ...(result.confirmed === false && { confirmed: result.confirmed }),
319
376
  });
320
377
  }
321
378
  }
@@ -517,6 +574,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
517
574
  priority,
518
575
  });
519
576
  } catch (err) {
577
+ logError.error(`${channel.name} channel_error: ${redactedMessageOf(err)}`, {
578
+ notificationType,
579
+ channel: channel.name,
580
+ });
520
581
  await logDelivery(run, {
521
582
  tenantId,
522
583
  notificationType,
@@ -524,7 +585,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
524
585
  recipientId: userId,
525
586
  recipientAddress: null,
526
587
  status: "failed",
527
- error: err instanceof Error ? err.message : String(err),
588
+ error: "channel_error",
528
589
  priority,
529
590
  });
530
591
  }
@@ -599,6 +660,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
599
660
  priority,
600
661
  });
601
662
  } catch (err) {
663
+ logError.error(`${channel.name} channel_error: ${redactedMessageOf(err)}`, {
664
+ notificationType,
665
+ channel: channel.name,
666
+ });
602
667
  await logDelivery(run, {
603
668
  tenantId,
604
669
  notificationType,
@@ -606,7 +671,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
606
671
  recipientId,
607
672
  recipientAddress: address,
608
673
  status: "failed",
609
- error: err instanceof Error ? err.message : String(err),
674
+ error: "channel_error",
610
675
  priority,
611
676
  });
612
677
  }
@@ -3,6 +3,11 @@
3
3
  // delivery-service.ts (validates payloads before the low-level append()
4
4
  // — out-of-dispatcher writes otherwise skip schema enforcement).
5
5
 
6
+ import {
7
+ DELIVERY_FAILURE_CODES,
8
+ DELIVERY_SKIP_REASONS,
9
+ HTTP_ERROR_CODE_PATTERN,
10
+ } from "@cosmicdrift/kumiko-framework/engine";
6
11
  import * as z from "zod";
7
12
  import { DeliveryStatus } from "./constants.js";
8
13
 
@@ -17,8 +22,17 @@ export const deliveryAttemptSchema = z.object({
17
22
  DeliveryStatus.failed,
18
23
  DeliveryStatus.skipped,
19
24
  ]),
20
- error: z.string().nullable(),
25
+ // Closed code set; z.custom would not serialize to JSON-Schema. Events written
26
+ // before the code vocabulary may hold free text, they are not re-validated on replay.
27
+ error: z
28
+ .union([
29
+ z.enum([...DELIVERY_FAILURE_CODES, ...DELIVERY_SKIP_REASONS]),
30
+ z.string().regex(HTTP_ERROR_CODE_PATTERN),
31
+ ])
32
+ .nullable(),
21
33
  priority: z.enum(["critical", "normal", "low"]),
34
+ // Provider accepted the request but does not confirm delivery (e.g. Teams 202).
35
+ confirmed: z.literal(false).optional(),
22
36
  });
23
37
 
24
38
  export type DeliveryAttemptPayload = z.infer<typeof deliveryAttemptSchema>;
@@ -8,9 +8,13 @@ import {
8
8
  import type * as z from "zod";
9
9
  import {
10
10
  DELIVERY_ATTEMPT_EVENT,
11
+ DELIVERY_CHANNEL_CELL_COMPONENT,
11
12
  DELIVERY_CHANNEL_EXTENSION,
13
+ DELIVERY_ERROR_CELL_COMPONENT,
12
14
  DELIVERY_LOG_SCREEN_ID,
13
15
  DELIVERY_STATUS_CELL_COMPONENT,
16
+ DELIVERY_TIME_CELL_COMPONENT,
17
+ DELIVERY_TYPE_CELL_COMPONENT,
14
18
  DeliveryJobNames,
15
19
  DeliveryQueries,
16
20
  } from "./constants.js";
@@ -118,8 +122,14 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
118
122
  status: p.status,
119
123
  error: p.error,
120
124
  priority: p.priority,
125
+ confirmed: p.confirmed ?? null,
126
+ },
127
+ {
128
+ status: p.status,
129
+ error: p.error,
130
+ recipientAddress: p.recipientAddress,
131
+ confirmed: p.confirmed ?? null,
121
132
  },
122
- { status: p.status, error: p.error, recipientAddress: p.recipientAddress },
123
133
  );
124
134
  },
125
135
  },
@@ -168,20 +178,38 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
168
178
  type: "projectionList",
169
179
  query: DeliveryQueries.log,
170
180
  columns: [
181
+ {
182
+ field: "createdAt",
183
+ label: i18nKey("delivery.log.col.createdAt"),
184
+ renderer: { react: { __component: DELIVERY_TIME_CELL_COMPONENT } },
185
+ },
171
186
  {
172
187
  field: "tenantId",
173
188
  label: i18nKey("delivery.log.col.tenantId"),
174
189
  refEntity: "tenant:tenant",
175
190
  refLabelField: "name",
176
191
  },
177
- { field: "type", label: i18nKey("delivery.log.col.type") },
178
- { field: "channel", label: i18nKey("delivery.log.col.channel") },
179
- { field: "recipient", label: i18nKey("delivery.log.col.recipient") },
192
+ {
193
+ field: "type",
194
+ label: i18nKey("delivery.log.col.type"),
195
+ renderer: { react: { __component: DELIVERY_TYPE_CELL_COMPONENT } },
196
+ },
197
+ {
198
+ field: "channel",
199
+ label: i18nKey("delivery.log.col.channel"),
200
+ renderer: { react: { __component: DELIVERY_CHANNEL_CELL_COMPONENT } },
201
+ },
202
+ { field: "recipientLabel", label: i18nKey("delivery.log.col.recipient") },
180
203
  {
181
204
  field: "status",
182
205
  label: i18nKey("delivery.log.col.status"),
183
206
  renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
184
207
  },
208
+ {
209
+ field: "error",
210
+ label: i18nKey("delivery.log.col.error"),
211
+ renderer: { react: { __component: DELIVERY_ERROR_CELL_COMPONENT } },
212
+ },
185
213
  ],
186
214
  // Cursor-based paging: deliveryAttemptsTable's id is an event-stream
187
215
  // aggregate id (not a sequential PK), so the "pages" default (offset +