@cosmicdrift/kumiko-bundled-features 0.342.0 → 0.343.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 (92) hide show
  1. package/dist/audit/constants.d.ts +5 -1
  2. package/dist/audit/constants.js +5 -1
  3. package/dist/audit/escape-hatch-audit-sink.d.ts +1 -0
  4. package/dist/audit/escape-hatch-audit-sink.js +1 -0
  5. package/dist/audit/run-escape-hatch-retention.d.ts +3 -2
  6. package/dist/audit/run-escape-hatch-retention.js +71 -7
  7. package/dist/auth-email-password/email-templates.d.ts +4 -0
  8. package/dist/auth-email-password/email-templates.js +69 -16
  9. package/dist/auth-email-password/handlers/invite-create.write.js +4 -1
  10. package/dist/auth-email-password/handlers/signup-request.write.js +4 -1
  11. package/dist/auth-email-password/handlers/token-request-handler.js +3 -0
  12. package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
  13. package/dist/auth-email-password/magic-link-mail.js +2 -0
  14. package/dist/channel-discord/feature.js +1 -0
  15. package/dist/channel-email/feature.js +1 -0
  16. package/dist/channel-in-app/feature.js +1 -0
  17. package/dist/channel-push/feature.js +1 -0
  18. package/dist/channel-slack/feature.js +1 -0
  19. package/dist/channel-teams/feature.js +1 -0
  20. package/dist/channel-teams/teams-channel.d.ts +2 -1
  21. package/dist/channel-teams/teams-channel.js +12 -0
  22. package/dist/channel-telegram/feature.js +1 -0
  23. package/dist/delivery/chat-webhook-channel.d.ts +2 -1
  24. package/dist/delivery/chat-webhook-channel.js +5 -2
  25. package/dist/delivery/chat-webhook-sender.d.ts +8 -1
  26. package/dist/delivery/chat-webhook-sender.js +45 -4
  27. package/dist/delivery/constants.d.ts +1 -1
  28. package/dist/delivery/constants.js +1 -1
  29. package/dist/delivery/delivery-service.d.ts +2 -1
  30. package/dist/delivery/delivery-service.js +55 -4
  31. package/dist/delivery/events.d.ts +19 -1
  32. package/dist/delivery/events.js +11 -1
  33. package/dist/delivery/feature.js +29 -5
  34. package/dist/delivery/handlers/log.query.d.ts +4 -2
  35. package/dist/delivery/handlers/log.query.js +46 -18
  36. package/dist/delivery/i18n.js +36 -0
  37. package/dist/delivery/index.d.ts +2 -1
  38. package/dist/delivery/index.js +1 -0
  39. package/dist/delivery/jobs.js +24 -10
  40. package/dist/delivery/notify-factory.d.ts +5 -0
  41. package/dist/delivery/notify-factory.js +6 -0
  42. package/dist/delivery/public-names.d.ts +4 -0
  43. package/dist/delivery/public-names.js +4 -0
  44. package/dist/delivery/tables.js +3 -0
  45. package/dist/delivery/testing.d.ts +2 -0
  46. package/dist/delivery/testing.js +2 -1
  47. package/dist/delivery/types.d.ts +5 -3
  48. package/dist/delivery/web/client-plugin.js +9 -1
  49. package/dist/delivery/web/delivery-channel-cell.d.ts +3 -0
  50. package/dist/delivery/web/delivery-channel-cell.js +10 -0
  51. package/dist/delivery/web/delivery-error-cell.d.ts +3 -0
  52. package/dist/delivery/web/delivery-error-cell.js +16 -0
  53. package/dist/delivery/web/delivery-status-cell.d.ts +1 -1
  54. package/dist/delivery/web/delivery-status-cell.js +11 -1
  55. package/dist/delivery/web/delivery-time-cell.d.ts +3 -0
  56. package/dist/delivery/web/delivery-time-cell.js +6 -0
  57. package/dist/delivery/web/delivery-type-cell.d.ts +3 -0
  58. package/dist/delivery/web/delivery-type-cell.js +17 -0
  59. package/dist/secrets/constants.d.ts +6 -0
  60. package/dist/secrets/constants.js +8 -0
  61. package/dist/secrets/feature.d.ts +3 -0
  62. package/dist/secrets/feature.js +3 -0
  63. package/dist/secrets/i18n.js +4 -3
  64. package/dist/secrets/index.d.ts +3 -2
  65. package/dist/secrets/index.js +3 -2
  66. package/dist/secrets/write-gate.d.ts +1 -0
  67. package/dist/secrets/write-gate.js +14 -6
  68. package/dist/shared/index.d.ts +1 -0
  69. package/dist/shared/index.js +1 -0
  70. package/dist/shared/redact.d.ts +4 -0
  71. package/dist/shared/redact.js +21 -0
  72. package/dist/step-dispatcher/feature.js +2 -1
  73. package/dist/step-dispatcher/mail-runner.d.ts +0 -1
  74. package/dist/step-dispatcher/mail-runner.js +0 -5
  75. package/dist/tenant/web/member-roles-cell.js +1 -1
  76. package/dist/tenant/web/member-status-cell.js +1 -1
  77. package/dist/tenant-lifecycle/feature.js +8 -2
  78. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +2 -1
  79. package/dist/tenant-lifecycle/run-tenant-destroy.js +16 -7
  80. package/dist/user/index.d.ts +1 -0
  81. package/dist/user/index.js +1 -0
  82. package/dist/user/resolve-display-names.d.ts +2 -0
  83. package/dist/user/resolve-display-names.js +24 -0
  84. package/package.json +9 -9
  85. package/src/audit/changes.json +6 -0
  86. package/src/auth-email-password/changes.json +7 -0
  87. package/src/channel-teams/changes.json +8 -0
  88. package/src/delivery/changes.json +32 -0
  89. package/src/jobs/changes.json +6 -0
  90. package/src/secrets/changes.json +6 -0
  91. /package/dist/{tenant → shared}/web/translate-or-raw.d.ts +0 -0
  92. /package/dist/{tenant → shared}/web/translate-or-raw.js +0 -0
@@ -3,7 +3,7 @@ import { DELIVERY_FEATURE, DeliveryJobNames } from "./public-names.js";
3
3
  // Extension-point name for delivery-channel plugins (channel-email,
4
4
  // channel-in-app, channel-push, ...).
5
5
  export const DELIVERY_CHANNEL_EXTENSION = "deliveryChannel";
6
- export { DELIVERY_ATTEMPT_EVENT, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_RESUBSCRIBE_PATH, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobNames, DeliveryQueries, DeliveryStatus, } from "./public-names.js";
6
+ export { DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_CELL_COMPONENT, DELIVERY_ERROR_CELL_COMPONENT, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_RESUBSCRIBE_PATH, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_TIME_CELL_COMPONENT, DELIVERY_TYPE_CELL_COMPONENT, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobNames, DeliveryQueries, DeliveryStatus, } from "./public-names.js";
7
7
  // notify() priority → BullMQ job priority. Lower number = processed first; all
8
8
  // > 0 so prioritised delivery jobs never mix with BullMQ's "0 = unprioritised
9
9
  // FIFO" bucket. critical jobs jump ahead of normal/low in the worker queue.
@@ -1,10 +1,11 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { EscapeHatchAuditSink, NotifyJobDispatcher, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
- import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
4
+ import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
5
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
+ export declare function redactedMessageOf(err: unknown): string;
8
9
  export type RateLimitConfig = {
9
10
  readonly redis: Redis;
10
11
  readonly maxPerHour: number;
@@ -1,8 +1,10 @@
1
1
  import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
3
4
  import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
4
5
  import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
5
6
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
7
+ import { redactErrorText } from "../shared/index.js";
6
8
  import { hashUnsubscribeAddress } from "./address-opt-out.js";
7
9
  import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
8
10
  import { buildChannelContext } from "./channel-context.js";
@@ -10,6 +12,9 @@ import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "
10
12
  import { isAddressOptedOut } from "./db/queries/address-opt-outs.js";
11
13
  import { selectNotificationPreferences } from "./db/queries/preferences.js";
12
14
  import { isDeliveryChannelPlugin, } from "./types.js";
15
+ export function redactedMessageOf(err) {
16
+ return redactErrorText(err instanceof Error ? err.message : String(err));
17
+ }
13
18
  // Build channel list from registry extension usages
14
19
  export function collectChannels(registry) {
15
20
  const usages = registry.getExtensionUsages(DELIVERY_CHANNEL_EXTENSION);
@@ -27,6 +32,7 @@ export function collectChannels(registry) {
27
32
  export function createDeliveryService(options) {
28
33
  const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, escapeHatchAuditSink, log, } = options;
29
34
  const idemRedis = idempotencyRedis ?? rateLimit?.redis;
35
+ const logError = createFallbackLogger("delivery", log);
30
36
  // Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
31
37
  // Lua. Runs single-threaded in Redis, so two parallel clients can't both
32
38
  // observe `count <= max` and slip past. The prior non-atomic JS version
@@ -125,6 +131,7 @@ export function createDeliveryService(options) {
125
131
  recipientId: entry.recipientId,
126
132
  status: entry.status,
127
133
  error: entry.error,
134
+ ...(entry.confirmed === false && { confirmed: entry.confirmed }),
128
135
  deliveryAttemptId,
129
136
  });
130
137
  }
@@ -135,6 +142,23 @@ export function createDeliveryService(options) {
135
142
  const attemptId = await logAttempt(db, registry, entry);
136
143
  recordDelivery(run, entry, attemptId);
137
144
  }
145
+ // The stored/returned error is only the code; the redacted message goes to the log.
146
+ async function logInlineFailure(run, args, code, err) {
147
+ logError.error(`${args.channel.name} ${code}: ${redactedMessageOf(err)}`, {
148
+ notificationType: args.notificationType,
149
+ channel: args.channel.name,
150
+ });
151
+ await logDelivery(run, {
152
+ tenantId: args.tenantId,
153
+ notificationType: args.notificationType,
154
+ channel: args.channel.name,
155
+ recipientId: args.recipientId,
156
+ recipientAddress: args.address,
157
+ status: "failed",
158
+ error: code,
159
+ priority: args.priority,
160
+ });
161
+ }
138
162
  // Deliver one resolved (channel, address) pair. Inline channels (inApp) and
139
163
  // the no-job-runner fallback render + send synchronously and log the terminal
140
164
  // status. Queued channels (email/push) record a `queued` attempt up front and
@@ -172,8 +196,26 @@ export function createDeliveryService(options) {
172
196
  }
173
197
  else {
174
198
  // Inline (inApp) or no-job-runner fallback: render + send synchronously.
175
- const rendered = channel.render ? await channel.render(message, channelCtx) : undefined;
176
- const result = await channel.send(address, message, channelCtx, rendered);
199
+ let rendered;
200
+ if (channel.render) {
201
+ try {
202
+ rendered = await channel.render(message, channelCtx);
203
+ }
204
+ catch (err) {
205
+ await logInlineFailure(run, args, "render_failed", err);
206
+ // skip: logInlineFailure recorded and logged the failure
207
+ return;
208
+ }
209
+ }
210
+ let result;
211
+ try {
212
+ result = await channel.send(address, message, channelCtx, rendered);
213
+ }
214
+ catch (err) {
215
+ await logInlineFailure(run, args, "send_failed", err);
216
+ // skip: logInlineFailure recorded and logged the failure
217
+ return;
218
+ }
177
219
  await logDelivery(run, {
178
220
  tenantId,
179
221
  notificationType,
@@ -183,6 +225,7 @@ export function createDeliveryService(options) {
183
225
  status: result.status,
184
226
  error: result.error ?? null,
185
227
  priority,
228
+ ...(result.confirmed === false && { confirmed: result.confirmed }),
186
229
  });
187
230
  }
188
231
  }
@@ -340,6 +383,10 @@ export function createDeliveryService(options) {
340
383
  });
341
384
  }
342
385
  catch (err) {
386
+ logError.error(`${channel.name} channel_error: ${redactedMessageOf(err)}`, {
387
+ notificationType,
388
+ channel: channel.name,
389
+ });
343
390
  await logDelivery(run, {
344
391
  tenantId,
345
392
  notificationType,
@@ -347,7 +394,7 @@ export function createDeliveryService(options) {
347
394
  recipientId: userId,
348
395
  recipientAddress: null,
349
396
  status: "failed",
350
- error: err instanceof Error ? err.message : String(err),
397
+ error: "channel_error",
351
398
  priority,
352
399
  });
353
400
  }
@@ -409,6 +456,10 @@ export function createDeliveryService(options) {
409
456
  });
410
457
  }
411
458
  catch (err) {
459
+ logError.error(`${channel.name} channel_error: ${redactedMessageOf(err)}`, {
460
+ notificationType,
461
+ channel: channel.name,
462
+ });
412
463
  await logDelivery(run, {
413
464
  tenantId,
414
465
  notificationType,
@@ -416,7 +467,7 @@ export function createDeliveryService(options) {
416
467
  recipientId,
417
468
  recipientAddress: address,
418
469
  status: "failed",
419
- error: err instanceof Error ? err.message : String(err),
470
+ error: "channel_error",
420
471
  priority,
421
472
  });
422
473
  }
@@ -10,11 +10,29 @@ export declare const deliveryAttemptSchema: z.ZodObject<{
10
10
  sent: "sent";
11
11
  skipped: "skipped";
12
12
  }>;
13
- error: z.ZodNullable<z.ZodString>;
13
+ error: z.ZodNullable<z.ZodUnion<readonly [z.ZodEnum<{
14
+ channel_disabled: "channel_disabled";
15
+ channel_error: "channel_error";
16
+ duplicate_idempotency_key: "duplicate_idempotency_key";
17
+ host_not_allowed: "host_not_allowed";
18
+ invalid_address: "invalid_address";
19
+ missing_credentials: "missing_credentials";
20
+ network_error: "network_error";
21
+ no_address: "no_address";
22
+ preference_disabled: "preference_disabled";
23
+ rate_limited: "rate_limited";
24
+ redirect_blocked: "redirect_blocked";
25
+ render_failed: "render_failed";
26
+ send_failed: "send_failed";
27
+ timeout: "timeout";
28
+ unexpected_response: "unexpected_response";
29
+ unsubscribed: "unsubscribed";
30
+ }>, z.ZodString]>>;
14
31
  priority: z.ZodEnum<{
15
32
  critical: "critical";
16
33
  low: "low";
17
34
  normal: "normal";
18
35
  }>;
36
+ confirmed: z.ZodOptional<z.ZodLiteral<false>>;
19
37
  }, z.core.$strip>;
20
38
  export type DeliveryAttemptPayload = z.infer<typeof deliveryAttemptSchema>;
@@ -2,6 +2,7 @@
2
2
  // delivery-feature.ts (registers it via r.defineEvent) and
3
3
  // delivery-service.ts (validates payloads before the low-level append()
4
4
  // — out-of-dispatcher writes otherwise skip schema enforcement).
5
+ import { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import * as z from "zod";
6
7
  import { DeliveryStatus } from "./constants.js";
7
8
  export const deliveryAttemptSchema = z.object({
@@ -15,6 +16,15 @@ export const deliveryAttemptSchema = z.object({
15
16
  DeliveryStatus.failed,
16
17
  DeliveryStatus.skipped,
17
18
  ]),
18
- error: z.string().nullable(),
19
+ // Closed code set; z.custom would not serialize to JSON-Schema. Events written
20
+ // before the code vocabulary may hold free text, they are not re-validated on replay.
21
+ error: z
22
+ .union([
23
+ z.enum([...DELIVERY_FAILURE_CODES, ...DELIVERY_SKIP_REASONS]),
24
+ z.string().regex(HTTP_ERROR_CODE_PATTERN),
25
+ ])
26
+ .nullable(),
19
27
  priority: z.enum(["critical", "normal", "low"]),
28
+ // Provider accepted the request but does not confirm delivery (e.g. Teams 202).
29
+ confirmed: z.literal(false).optional(),
20
30
  });
@@ -1,6 +1,6 @@
1
1
  import { upsertByPk } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { access, defineFeature, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
3
- import { DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_LOG_SCREEN_ID, DELIVERY_STATUS_CELL_COMPONENT, DeliveryJobNames, DeliveryQueries, } from "./constants.js";
3
+ import { DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_CELL_COMPONENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_ERROR_CELL_COMPONENT, DELIVERY_LOG_SCREEN_ID, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_TIME_CELL_COMPONENT, DELIVERY_TYPE_CELL_COMPONENT, DeliveryJobNames, DeliveryQueries, } from "./constants.js";
4
4
  import { deliveryAttemptSchema } from "./events.js";
5
5
  import { logQuery } from "./handlers/log.query.js";
6
6
  import { preferencesQuery } from "./handlers/preferences.query.js";
@@ -81,7 +81,13 @@ export function createDeliveryFeature(options) {
81
81
  status: p.status,
82
82
  error: p.error,
83
83
  priority: p.priority,
84
- }, { status: p.status, error: p.error, recipientAddress: p.recipientAddress });
84
+ confirmed: p.confirmed ?? null,
85
+ }, {
86
+ status: p.status,
87
+ error: p.error,
88
+ recipientAddress: p.recipientAddress,
89
+ confirmed: p.confirmed ?? null,
90
+ });
85
91
  },
86
92
  },
87
93
  });
@@ -116,20 +122,38 @@ export function createDeliveryFeature(options) {
116
122
  type: "projectionList",
117
123
  query: DeliveryQueries.log,
118
124
  columns: [
125
+ {
126
+ field: "createdAt",
127
+ label: i18nKey("delivery.log.col.createdAt"),
128
+ renderer: { react: { __component: DELIVERY_TIME_CELL_COMPONENT } },
129
+ },
119
130
  {
120
131
  field: "tenantId",
121
132
  label: i18nKey("delivery.log.col.tenantId"),
122
133
  refEntity: "tenant:tenant",
123
134
  refLabelField: "name",
124
135
  },
125
- { field: "type", label: i18nKey("delivery.log.col.type") },
126
- { field: "channel", label: i18nKey("delivery.log.col.channel") },
127
- { field: "recipient", label: i18nKey("delivery.log.col.recipient") },
136
+ {
137
+ field: "type",
138
+ label: i18nKey("delivery.log.col.type"),
139
+ renderer: { react: { __component: DELIVERY_TYPE_CELL_COMPONENT } },
140
+ },
141
+ {
142
+ field: "channel",
143
+ label: i18nKey("delivery.log.col.channel"),
144
+ renderer: { react: { __component: DELIVERY_CHANNEL_CELL_COMPONENT } },
145
+ },
146
+ { field: "recipientLabel", label: i18nKey("delivery.log.col.recipient") },
128
147
  {
129
148
  field: "status",
130
149
  label: i18nKey("delivery.log.col.status"),
131
150
  renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
132
151
  },
152
+ {
153
+ field: "error",
154
+ label: i18nKey("delivery.log.col.error"),
155
+ renderer: { react: { __component: DELIVERY_ERROR_CELL_COMPONENT } },
156
+ },
133
157
  ],
134
158
  // Cursor-based paging: deliveryAttemptsTable's id is an event-stream
135
159
  // aggregate id (not a sequential PK), so the "pages" default (offset +
@@ -1,4 +1,4 @@
1
- import { type NotifyPriority } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type DeliveryErrorCode, type NotifyPriority } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { Temporal } from "temporal-polyfill";
3
3
  import * as z from "zod";
4
4
  import type { DeliveryStatusValue } from "../constants.js";
@@ -17,8 +17,10 @@ export declare const logQuery: import("@cosmicdrift/kumiko-framework/engine").Pa
17
17
  type: string;
18
18
  channel: string;
19
19
  recipient: string | null;
20
+ recipientLabel: string | null;
20
21
  status: DeliveryStatusValue;
21
- error: string | null;
22
+ error: DeliveryErrorCode | null;
22
23
  priority: NotifyPriority;
24
+ confirmed: boolean | null;
23
25
  createdAt: Temporal.Instant;
24
26
  }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -1,17 +1,24 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { decodeKeysetCursor, encodeKeysetCursor } from "@cosmicdrift/kumiko-framework/db";
3
- import { access, definePagedQueryHandler, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { decodeKeysetCursor, encodeKeysetCursor, } from "@cosmicdrift/kumiko-framework/db";
3
+ import { access, definePagedQueryHandler, isDeliveryErrorCode, isUuid, MAX_LIST_LIMIT, SYSTEM_USER_ID, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { Temporal } from "temporal-polyfill";
6
6
  import * as z from "zod";
7
7
  import { decryptStoredPii } from "../../shared/index.js";
8
+ import { resolveUserDisplayNames, USER_FEATURE } from "../../user/index.js";
8
9
  import { deliveryAttemptsTable } from "../tables.js";
10
+ function toClientError(stored) {
11
+ if (stored === null)
12
+ return null;
13
+ return isDeliveryErrorCode(stored) ? stored : "channel_error";
14
+ }
9
15
  // Sort whitelist keyed by the DISPLAY field the client actually sends: the
10
16
  // declarative projectionList renderer marks every listed column uniformly
11
17
  // sortable and a header click sends `sort=<screen.columns[].field>`, not the
12
18
  // DB column name (see projection-list-shim.ts synthesizeProjectionEntity).
13
- // "recipient" is deliberately absent — it's PII decrypted per-row in
14
- // application code, so there is no column to ORDER BY in SQL for it.
19
+ // "recipient" is deliberately absent — it's PII (name or decrypted address)
20
+ // resolved in application code, so there is no column to ORDER BY in SQL for it.
21
+ // A click on such a header sends an unlisted sort value and falls back to createdAt.
15
22
  const DELIVERY_LOG_SORT_COLUMNS = {
16
23
  createdAt: "createdAt",
17
24
  tenantId: "tenantId",
@@ -38,6 +45,39 @@ function decodeSortCursor(column, cursor) {
38
45
  const sortValue = column === "createdAt" ? Temporal.Instant.from(raw) : raw;
39
46
  return { sortValue, lastId: decoded.sortValue === undefined ? undefined : decoded.id };
40
47
  }
48
+ // One batched name lookup for the whole page. Only when the user feature is
49
+ // mounted: delivery does not require it, and a direct-address send has no user.
50
+ async function loadDisplayNames(db, registry, rows) {
51
+ const recipientIds = [
52
+ ...new Set(rows
53
+ .map((row) => row.recipientId)
54
+ .filter((id) => id !== null && id !== SYSTEM_USER_ID && isUuid(id))),
55
+ ];
56
+ if (recipientIds.length === 0 || registry.getFeature(USER_FEATURE) === undefined)
57
+ return new Map();
58
+ return resolveUserDisplayNames(db, recipientIds);
59
+ }
60
+ // recipientLabel: display name, else the decrypted address, else the id.
61
+ async function toClientRow(row, displayNames) {
62
+ const address = row.recipientAddress !== null
63
+ ? await decryptStoredPii(row.recipientAddress, "recipientAddress", "delivery-log")
64
+ : null;
65
+ const name = row.recipientId !== null ? displayNames.get(row.recipientId) : undefined;
66
+ return {
67
+ id: row.id,
68
+ tenantId: row.tenantId,
69
+ recipientId: row.recipientId,
70
+ type: row.notificationType,
71
+ channel: row.channel,
72
+ recipient: address,
73
+ recipientLabel: name ?? address ?? row.recipientId,
74
+ status: row.status,
75
+ error: toClientError(row.error),
76
+ priority: row.priority,
77
+ confirmed: row.confirmed,
78
+ createdAt: row.createdAt,
79
+ };
80
+ }
41
81
  export const logQuery = definePagedQueryHandler({
42
82
  name: "log",
43
83
  schema: z.object({
@@ -102,21 +142,9 @@ export const logQuery = definePagedQueryHandler({
102
142
  // here, not in the client: a projectionList screen has no entity to
103
143
  // derive a field-mapping from, so the row shape the query returns is the
104
144
  // shape the declarative columns read directly.
145
+ const displayNames = await loadDisplayNames(db, ctx.registry, rows);
105
146
  return {
106
- rows: await Promise.all(rows.map(async (row) => ({
107
- id: row.id,
108
- tenantId: row.tenantId,
109
- recipientId: row.recipientId,
110
- type: row.notificationType,
111
- channel: row.channel,
112
- recipient: row.recipientAddress !== null
113
- ? await decryptStoredPii(row.recipientAddress, "recipientAddress", "delivery-log")
114
- : row.recipientAddress,
115
- status: row.status,
116
- error: row.error,
117
- priority: row.priority,
118
- createdAt: row.createdAt,
119
- }))),
147
+ rows: await Promise.all(rows.map((row) => toClientRow(row, displayNames))),
120
148
  nextCursor,
121
149
  };
122
150
  },
@@ -1,9 +1,45 @@
1
+ import { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { DeliveryStatus } from "./public-names.js";
3
+ // Typed over the code vocabularies, so a new code without a label fails to compile.
4
+ const ERROR_LABELS = {
5
+ timeout: "Timed out",
6
+ network_error: "Network error",
7
+ redirect_blocked: "Redirect blocked",
8
+ host_not_allowed: "Host not allowed",
9
+ missing_credentials: "Missing credentials",
10
+ invalid_address: "Invalid address",
11
+ unexpected_response: "Unexpected provider response",
12
+ render_failed: "Rendering failed",
13
+ send_failed: "Sending failed",
14
+ channel_error: "Channel error",
15
+ channel_disabled: "Channel disabled",
16
+ preference_disabled: "Disabled in preferences",
17
+ rate_limited: "Rate limited",
18
+ no_address: "No address",
19
+ unsubscribed: "Unsubscribed",
20
+ duplicate_idempotency_key: "Duplicate request",
21
+ };
22
+ const STATUS_LABELS = {
23
+ queued: "Queued",
24
+ sent: "Sent",
25
+ failed: "Failed",
26
+ skipped: "Skipped",
27
+ };
28
+ function labelKeys(prefix, codes, labels) {
29
+ return Object.fromEntries(codes.map((code) => [`${prefix}.${code}`, { en: labels[code] ?? code }]));
30
+ }
1
31
  export const DELIVERY_I18N = {
2
32
  "screen:delivery-log.title": { en: "Delivery log" },
3
33
  "delivery:nav.deliveryLog": { en: "Delivery" },
34
+ "delivery.log.col.createdAt": { en: "Time" },
4
35
  "delivery.log.col.tenantId": { en: "Tenant" },
5
36
  "delivery.log.col.type": { en: "Type" },
6
37
  "delivery.log.col.channel": { en: "Channel" },
7
38
  "delivery.log.col.recipient": { en: "Recipient" },
8
39
  "delivery.log.col.status": { en: "Status" },
40
+ "delivery.log.col.error": { en: "Error" },
41
+ ...labelKeys("delivery.status", Object.values(DeliveryStatus), STATUS_LABELS),
42
+ ...labelKeys("delivery.error", [...DELIVERY_FAILURE_CODES, ...DELIVERY_SKIP_REASONS], ERROR_LABELS),
43
+ "delivery.status.sentUnconfirmed": { en: "Sent (unconfirmed)" },
44
+ "delivery.error.http": { en: "HTTP {status}" },
9
45
  };
@@ -1,10 +1,11 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
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";
3
+ export { type ChatSendFailureCode, type ChatSendResult, type ChatWebhookRequest, type ChatWebhookResponse, 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";
7
7
  export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.js";
8
+ export { createDeliveryNotifyFactory } from "./notify-factory.js";
8
9
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
9
10
  export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing.js";
10
11
  export { type ChannelContext, type ChannelMessage, type ChannelResult, type DeliveryChannel, type DeliveryChannelMode, type DeliveryChannelPlugin, type DeliveryLogEntry, type DeliveryService, isDeliveryChannelPlugin, type NotificationRenderer, type RenderedMessage, type RendererInput, } from "./types.js";
@@ -4,6 +4,7 @@ export { chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget,
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";
7
+ export { createDeliveryNotifyFactory } from "./notify-factory.js";
7
8
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
8
9
  export { createDeliveryTestContext } from "./testing.js";
9
10
  export { isDeliveryChannelPlugin, } from "./types.js";
@@ -9,7 +9,7 @@ import * as z from "zod";
9
9
  import { appendAttemptEvent } from "./attempt-log.js";
10
10
  import { buildChannelContext } from "./channel-context.js";
11
11
  import { DeliveryJobs, deliveryPriorityRank } from "./constants.js";
12
- import { collectChannels } from "./delivery-service.js";
12
+ import { collectChannels, redactedMessageOf } from "./delivery-service.js";
13
13
  const channelMessageSchema = z.object({
14
14
  notificationType: z.string(),
15
15
  title: z.string(),
@@ -60,7 +60,7 @@ function toMessage(p) {
60
60
  locale: p.message.locale,
61
61
  };
62
62
  }
63
- function entryFor(p, status, error, address) {
63
+ function entryFor(p, status, error, address, confirmed) {
64
64
  return {
65
65
  tenantId: p.tenantId, // @cast-boundary engine-payload — job payload string is the stream tenant
66
66
  notificationType: p.notificationType,
@@ -70,10 +70,20 @@ function entryFor(p, status, error, address) {
70
70
  status,
71
71
  error,
72
72
  priority: p.priority,
73
+ ...(confirmed === false && { confirmed }),
73
74
  };
74
75
  }
75
- function messageOf(err) {
76
- return err instanceof Error ? err.message : String(err);
76
+ // The job runner stores err.message in the run row, and BullMQ keeps failedReason and
77
+ // the stack trace in Redis in plain text, so the rethrown error carries only the code.
78
+ // The redacted message goes to the log.
79
+ async function failAttempt(ctx, db, registry, p, code, err) {
80
+ const message = redactedMessageOf(err);
81
+ ctx.log.error(`delivery.${p.channelName} ${code}: ${message}`, {
82
+ notificationType: p.notificationType,
83
+ channel: p.channelName,
84
+ });
85
+ await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, "failed", code, p.address));
86
+ throw new Error(code);
77
87
  }
78
88
  // Render the message and hand off to delivery.send. On failure: record the
79
89
  // terminal failed event and rethrow so BullMQ retries this step (re-render);
@@ -84,17 +94,22 @@ export const deliveryRenderJob = async (payload, ctx) => {
84
94
  const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
85
95
  const channel = resolveChannel(registry, p.channelName);
86
96
  const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
97
+ let rendered;
87
98
  try {
88
99
  if (!channel.render) {
89
100
  throw new Error(`delivery.render: channel "${p.channelName}" has no render step`);
90
101
  }
91
- const rendered = await channel.render(toMessage(p), channelCtx);
102
+ rendered = await channel.render(toMessage(p), channelCtx);
103
+ }
104
+ catch (err) {
105
+ return failAttempt(ctx, db, registry, p, "render_failed", err);
106
+ }
107
+ try {
92
108
  const jobRunner = ctx["jobRunner"]; // @cast-boundary dynamic-key — dispatch lives on the concrete runner
93
109
  await jobRunner.dispatch(DeliveryJobs.send, { ...p, rendered }, { priority: deliveryPriorityRank[p.priority] });
94
110
  }
95
111
  catch (err) {
96
- await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, "failed", `render: ${messageOf(err)}`, p.address));
97
- throw err;
112
+ return failAttempt(ctx, db, registry, p, "channel_error", err);
98
113
  }
99
114
  };
100
115
  // Deliver via the channel using the rendered payload (if any). On failure:
@@ -108,10 +123,9 @@ export const deliverySendJob = async (payload, ctx) => {
108
123
  const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
109
124
  try {
110
125
  const result = await channel.send(p.address, toMessage(p), channelCtx, p.rendered);
111
- await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, result.status, result.error ?? null, result.address ?? p.address));
126
+ await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, result.status, result.error ?? null, result.address ?? p.address, result.confirmed));
112
127
  }
113
128
  catch (err) {
114
- await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, "failed", `send: ${messageOf(err)}`, p.address));
115
- throw err;
129
+ return failAttempt(ctx, db, registry, p, "send_failed", err);
116
130
  }
117
131
  };
@@ -0,0 +1,5 @@
1
+ import type { NotifyFactory } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { DeliveryService } from "./types.js";
3
+ export declare function createDeliveryNotifyFactory(deliveryService: Pick<DeliveryService, "notify">, options?: {
4
+ readonly deliverQueuedInline?: boolean;
5
+ }): NotifyFactory;
@@ -0,0 +1,6 @@
1
+ // The same binding runProdApp wires into the app context: the calling context's
2
+ // job dispatcher is handed to notify() per call, so queued channels go through
3
+ // the delivery jobs. deliverQueuedInline drops the dispatcher and sends inline.
4
+ export function createDeliveryNotifyFactory(deliveryService, options = {}) {
5
+ return (user, tenantId, jobDispatcher) => (notificationType, notifyOptions) => deliveryService.notify(notificationType, notifyOptions, user, tenantId, options.deliverQueuedInline === true ? undefined : jobDispatcher);
6
+ }
@@ -14,6 +14,10 @@ export declare const DeliveryQueries: {
14
14
  };
15
15
  export declare const DELIVERY_LOG_SCREEN_ID: "delivery-log";
16
16
  export declare const DELIVERY_STATUS_CELL_COMPONENT: "DeliveryStatusCell";
17
+ export declare const DELIVERY_TYPE_CELL_COMPONENT: "DeliveryTypeCell";
18
+ export declare const DELIVERY_CHANNEL_CELL_COMPONENT: "DeliveryChannelCell";
19
+ export declare const DELIVERY_TIME_CELL_COMPONENT: "DeliveryTimeCell";
20
+ export declare const DELIVERY_ERROR_CELL_COMPONENT: "DeliveryErrorCell";
17
21
  export declare const DeliveryErrors: {
18
22
  readonly noRecipient: "delivery_no_recipient";
19
23
  readonly channelFailed: "delivery_channel_failed";
@@ -26,6 +26,10 @@ export const DELIVERY_LOG_SCREEN_ID = "delivery-log";
26
26
  // column — see `screen.columns[].renderer.react.__component` in feature.ts
27
27
  // and `deliveryClient()`'s `columnRenderers` map.
28
28
  export const DELIVERY_STATUS_CELL_COMPONENT = "DeliveryStatusCell";
29
+ export const DELIVERY_TYPE_CELL_COMPONENT = "DeliveryTypeCell";
30
+ export const DELIVERY_CHANNEL_CELL_COMPONENT = "DeliveryChannelCell";
31
+ export const DELIVERY_TIME_CELL_COMPONENT = "DeliveryTimeCell";
32
+ export const DELIVERY_ERROR_CELL_COMPONENT = "DeliveryErrorCell";
29
33
  export const DeliveryErrors = {
30
34
  noRecipient: "delivery_no_recipient",
31
35
  channelFailed: "delivery_channel_failed",
@@ -23,6 +23,8 @@ export const deliveryAttemptsTable = pgTable("store_delivery_attempts", {
23
23
  recipientAddress: text("recipient_address"),
24
24
  status: text("status").notNull().$type(),
25
25
  error: text("error"),
26
+ // false = provider accepted but did not confirm; null = confirmed or not applicable.
27
+ confirmed: boolean("confirmed"),
26
28
  // Default covers rows that predate the column; new rows always carry the
27
29
  // notify() priority from the event payload.
28
30
  priority: text("priority").notNull().default("normal").$type(),
@@ -48,6 +50,7 @@ export const deliveryAttemptsTableMeta = defineUnmanagedTable({
48
50
  { name: "recipient_address", pgType: "text", notNull: false },
49
51
  { name: "status", pgType: "text", notNull: true },
50
52
  { name: "error", pgType: "text", notNull: false },
53
+ { name: "confirmed", pgType: "boolean", notNull: false },
51
54
  { name: "priority", pgType: "text", notNull: true, defaultSql: "'normal'" },
52
55
  { name: "created_at", pgType: "timestamptz", notNull: true, defaultSql: "now()" },
53
56
  ],
@@ -3,6 +3,7 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { EscapeHatchAuditSink, Registry } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
5
5
  import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
6
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
6
7
  import type { Redis } from "ioredis";
7
8
  import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js";
8
9
  import type { DeliveryService } from "./types.js";
@@ -13,6 +14,7 @@ export type CreateDeliveryTestContextOptions = {
13
14
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
14
15
  readonly log?: Logger;
15
16
  readonly jobRunner?: JobRunner;
17
+ readonly secrets?: SecretsContext;
16
18
  };
17
19
  /**
18
20
  * Helper for setupTestStack: creates a DeliveryService + _notifyFactory for the test context.
@@ -1,4 +1,5 @@
1
1
  import { collectChannels, createDeliveryService } from "./delivery-service.js";
2
+ import { createDeliveryNotifyFactory } from "./notify-factory.js";
2
3
  /**
3
4
  * Helper for setupTestStack: creates a DeliveryService + _notifyFactory for the test context.
4
5
  * Abstracts the boilerplate that every Delivery-using test needs.
@@ -21,6 +22,6 @@ export function createDeliveryTestContext(deps, options = {}) {
21
22
  });
22
23
  return {
23
24
  deliveryService, // exposed so tests can inspect/call directly if needed
24
- _notifyFactory: (user, tenantId) => (notificationType, notifyOptions) => deliveryService.notify(notificationType, notifyOptions, user, tenantId), // @cast-boundary engine-bridge
25
+ _notifyFactory: createDeliveryNotifyFactory(deliveryService),
25
26
  };
26
27
  }