@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
@@ -1,15 +1,25 @@
1
1
  import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { decodeKeysetCursor, encodeKeysetCursor } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ decodeKeysetCursor,
4
+ encodeKeysetCursor,
5
+ type TenantDb,
6
+ } from "@cosmicdrift/kumiko-framework/db";
3
7
  import {
4
8
  access,
9
+ type DeliveryErrorCode,
5
10
  definePagedQueryHandler,
11
+ isDeliveryErrorCode,
12
+ isUuid,
6
13
  MAX_LIST_LIMIT,
7
14
  type NotifyPriority,
15
+ type Registry,
16
+ SYSTEM_USER_ID,
8
17
  } from "@cosmicdrift/kumiko-framework/engine";
9
18
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
19
  import { Temporal } from "temporal-polyfill";
11
20
  import * as z from "zod";
12
21
  import { decryptStoredPii } from "../../shared/index.js";
22
+ import { resolveUserDisplayNames, USER_FEATURE } from "../../user/index.js";
13
23
  import type { DeliveryStatusValue } from "../constants.js";
14
24
  import { deliveryAttemptsTable } from "../tables.js";
15
25
 
@@ -21,17 +31,25 @@ type DeliveryLogRow = {
21
31
  recipientId: string | null;
22
32
  recipientAddress: string | null;
23
33
  status: DeliveryStatusValue;
34
+ // Legacy rows may hold free text; the handler masks those on the way out.
24
35
  error: string | null;
25
36
  priority: NotifyPriority;
37
+ confirmed: boolean | null;
26
38
  createdAt: Temporal.Instant;
27
39
  };
28
40
 
41
+ function toClientError(stored: string | null): DeliveryErrorCode | null {
42
+ if (stored === null) return null;
43
+ return isDeliveryErrorCode(stored) ? stored : "channel_error";
44
+ }
45
+
29
46
  // Sort whitelist keyed by the DISPLAY field the client actually sends: the
30
47
  // declarative projectionList renderer marks every listed column uniformly
31
48
  // sortable and a header click sends `sort=<screen.columns[].field>`, not the
32
49
  // DB column name (see projection-list-shim.ts synthesizeProjectionEntity).
33
- // "recipient" is deliberately absent — it's PII decrypted per-row in
34
- // application code, so there is no column to ORDER BY in SQL for it.
50
+ // "recipient" is deliberately absent — it's PII (name or decrypted address)
51
+ // resolved in application code, so there is no column to ORDER BY in SQL for it.
52
+ // A click on such a header sends an unlisted sort value and falls back to createdAt.
35
53
  const DELIVERY_LOG_SORT_COLUMNS = {
36
54
  createdAt: "createdAt",
37
55
  tenantId: "tenantId",
@@ -67,6 +85,48 @@ function decodeSortCursor(column: DeliveryLogSortColumn, cursor: string): Decode
67
85
  return { sortValue, lastId: decoded.sortValue === undefined ? undefined : decoded.id };
68
86
  }
69
87
 
88
+ // One batched name lookup for the whole page. Only when the user feature is
89
+ // mounted: delivery does not require it, and a direct-address send has no user.
90
+ async function loadDisplayNames(
91
+ db: TenantDb,
92
+ registry: Registry,
93
+ rows: readonly DeliveryLogRow[],
94
+ ): Promise<ReadonlyMap<string, string>> {
95
+ const recipientIds = [
96
+ ...new Set(
97
+ rows
98
+ .map((row) => row.recipientId)
99
+ .filter((id): id is string => id !== null && id !== SYSTEM_USER_ID && isUuid(id)),
100
+ ),
101
+ ];
102
+ if (recipientIds.length === 0 || registry.getFeature(USER_FEATURE) === undefined)
103
+ return new Map();
104
+ return resolveUserDisplayNames(db, recipientIds);
105
+ }
106
+
107
+ // recipientLabel: display name, else the decrypted address, else the id.
108
+ async function toClientRow(row: DeliveryLogRow, displayNames: ReadonlyMap<string, string>) {
109
+ const address =
110
+ row.recipientAddress !== null
111
+ ? await decryptStoredPii(row.recipientAddress, "recipientAddress", "delivery-log")
112
+ : null;
113
+ const name = row.recipientId !== null ? displayNames.get(row.recipientId) : undefined;
114
+ return {
115
+ id: row.id,
116
+ tenantId: row.tenantId,
117
+ recipientId: row.recipientId,
118
+ type: row.notificationType,
119
+ channel: row.channel,
120
+ recipient: address,
121
+ recipientLabel: name ?? address ?? row.recipientId,
122
+ status: row.status,
123
+ error: toClientError(row.error),
124
+ priority: row.priority,
125
+ confirmed: row.confirmed,
126
+ createdAt: row.createdAt,
127
+ };
128
+ }
129
+
70
130
  export const logQuery = definePagedQueryHandler({
71
131
  name: "log",
72
132
  schema: z.object({
@@ -153,24 +213,9 @@ export const logQuery = definePagedQueryHandler({
153
213
  // here, not in the client: a projectionList screen has no entity to
154
214
  // derive a field-mapping from, so the row shape the query returns is the
155
215
  // shape the declarative columns read directly.
216
+ const displayNames = await loadDisplayNames(db, ctx.registry, rows);
156
217
  return {
157
- rows: await Promise.all(
158
- rows.map(async (row) => ({
159
- id: row.id,
160
- tenantId: row.tenantId,
161
- recipientId: row.recipientId,
162
- type: row.notificationType,
163
- channel: row.channel,
164
- recipient:
165
- row.recipientAddress !== null
166
- ? await decryptStoredPii(row.recipientAddress, "recipientAddress", "delivery-log")
167
- : row.recipientAddress,
168
- status: row.status,
169
- error: row.error,
170
- priority: row.priority,
171
- createdAt: row.createdAt,
172
- })),
173
- ),
218
+ rows: await Promise.all(rows.map((row) => toClientRow(row, displayNames))),
174
219
  nextCursor,
175
220
  };
176
221
  },
@@ -1,11 +1,66 @@
1
+ import {
2
+ DELIVERY_FAILURE_CODES,
3
+ DELIVERY_SKIP_REASONS,
4
+ type DeliveryFailureCode,
5
+ type DeliverySkipReason,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { DeliveryStatus, type DeliveryStatusValue } from "./public-names.js";
8
+
1
9
  type LocalizedString = { readonly en: string };
2
10
 
11
+ // Typed over the code vocabularies, so a new code without a label fails to compile.
12
+ const ERROR_LABELS = {
13
+ timeout: "Timed out",
14
+ network_error: "Network error",
15
+ redirect_blocked: "Redirect blocked",
16
+ host_not_allowed: "Host not allowed",
17
+ missing_credentials: "Missing credentials",
18
+ invalid_address: "Invalid address",
19
+ unexpected_response: "Unexpected provider response",
20
+ render_failed: "Rendering failed",
21
+ send_failed: "Sending failed",
22
+ channel_error: "Channel error",
23
+ channel_disabled: "Channel disabled",
24
+ preference_disabled: "Disabled in preferences",
25
+ rate_limited: "Rate limited",
26
+ no_address: "No address",
27
+ unsubscribed: "Unsubscribed",
28
+ duplicate_idempotency_key: "Duplicate request",
29
+ } as const satisfies Record<DeliveryFailureCode | DeliverySkipReason, string>;
30
+
31
+ const STATUS_LABELS = {
32
+ queued: "Queued",
33
+ sent: "Sent",
34
+ failed: "Failed",
35
+ skipped: "Skipped",
36
+ } as const satisfies Record<DeliveryStatusValue, string>;
37
+
38
+ function labelKeys(
39
+ prefix: string,
40
+ codes: readonly string[],
41
+ labels: Readonly<Record<string, string>>,
42
+ ): Record<string, LocalizedString> {
43
+ return Object.fromEntries(
44
+ codes.map((code) => [`${prefix}.${code}`, { en: labels[code] ?? code }]),
45
+ );
46
+ }
47
+
3
48
  export const DELIVERY_I18N: Readonly<Record<string, LocalizedString>> = {
4
49
  "screen:delivery-log.title": { en: "Delivery log" },
5
50
  "delivery:nav.deliveryLog": { en: "Delivery" },
51
+ "delivery.log.col.createdAt": { en: "Time" },
6
52
  "delivery.log.col.tenantId": { en: "Tenant" },
7
53
  "delivery.log.col.type": { en: "Type" },
8
54
  "delivery.log.col.channel": { en: "Channel" },
9
55
  "delivery.log.col.recipient": { en: "Recipient" },
10
56
  "delivery.log.col.status": { en: "Status" },
57
+ "delivery.log.col.error": { en: "Error" },
58
+ ...labelKeys("delivery.status", Object.values(DeliveryStatus), STATUS_LABELS),
59
+ ...labelKeys(
60
+ "delivery.error",
61
+ [...DELIVERY_FAILURE_CODES, ...DELIVERY_SKIP_REASONS],
62
+ ERROR_LABELS,
63
+ ),
64
+ "delivery.status.sentUnconfirmed": { en: "Sent (unconfirmed)" },
65
+ "delivery.error.http": { en: "HTTP {status}" },
11
66
  };
@@ -10,6 +10,7 @@ export {
10
10
  type ChatSendFailureCode,
11
11
  type ChatSendResult,
12
12
  type ChatWebhookRequest,
13
+ type ChatWebhookResponse,
13
14
  type ChatWebhookTarget,
14
15
  chatConnectionNameSchema,
15
16
  chatWebhookUrlSchema,
@@ -38,6 +39,7 @@ export {
38
39
  type RateLimitConfig,
39
40
  } from "./delivery-service.js";
40
41
  export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.js";
42
+ export { createDeliveryNotifyFactory } from "./notify-factory.js";
41
43
  export {
42
44
  deliveryAttemptsTable,
43
45
  notificationAddressOptOutEntity,
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
9
  import type {
10
+ DeliveryErrorCode,
10
11
  JobContext,
11
12
  JobHandlerFn,
12
13
  Registry,
@@ -18,7 +19,7 @@ import * as z from "zod";
18
19
  import { appendAttemptEvent } from "./attempt-log.js";
19
20
  import { buildChannelContext } from "./channel-context.js";
20
21
  import { DeliveryJobs, deliveryPriorityRank } from "./constants.js";
21
- import { collectChannels } from "./delivery-service.js";
22
+ import { collectChannels, redactedMessageOf } from "./delivery-service.js";
22
23
  import type {
23
24
  ChannelMessage,
24
25
  DeliveryChannel,
@@ -90,8 +91,9 @@ function toMessage(p: RenderJobPayload): ChannelMessage {
90
91
  function entryFor(
91
92
  p: RenderJobPayload,
92
93
  status: DeliveryLogEntry["status"],
93
- error: string | null,
94
+ error: DeliveryErrorCode | null,
94
95
  address: string | null,
96
+ confirmed?: false,
95
97
  ): DeliveryLogEntry {
96
98
  return {
97
99
  tenantId: p.tenantId as TenantId, // @cast-boundary engine-payload — job payload string is the stream tenant
@@ -102,11 +104,33 @@ function entryFor(
102
104
  status,
103
105
  error,
104
106
  priority: p.priority,
107
+ ...(confirmed === false && { confirmed }),
105
108
  };
106
109
  }
107
110
 
108
- function messageOf(err: unknown): string {
109
- return err instanceof Error ? err.message : String(err);
111
+ // The job runner stores err.message in the run row, and BullMQ keeps failedReason and
112
+ // the stack trace in Redis in plain text, so the rethrown error carries only the code.
113
+ // The redacted message goes to the log.
114
+ async function failAttempt(
115
+ ctx: JobContext,
116
+ db: DbConnection,
117
+ registry: Registry,
118
+ p: RenderJobPayload,
119
+ code: DeliveryErrorCode,
120
+ err: unknown,
121
+ ): Promise<never> {
122
+ const message = redactedMessageOf(err);
123
+ ctx.log.error(`delivery.${p.channelName} ${code}: ${message}`, {
124
+ notificationType: p.notificationType,
125
+ channel: p.channelName,
126
+ });
127
+ await appendAttemptEvent(
128
+ db,
129
+ registry,
130
+ p.deliveryAttemptId,
131
+ entryFor(p, "failed", code, p.address),
132
+ );
133
+ throw new Error(code);
110
134
  }
111
135
 
112
136
  // Render the message and hand off to delivery.send. On failure: record the
@@ -119,11 +143,16 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
119
143
  const channel = resolveChannel(registry, p.channelName);
120
144
  const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
121
145
 
146
+ let rendered: RenderedMessage;
122
147
  try {
123
148
  if (!channel.render) {
124
149
  throw new Error(`delivery.render: channel "${p.channelName}" has no render step`);
125
150
  }
126
- const rendered: RenderedMessage = await channel.render(toMessage(p), channelCtx);
151
+ rendered = await channel.render(toMessage(p), channelCtx);
152
+ } catch (err) {
153
+ return failAttempt(ctx, db, registry, p, "render_failed", err);
154
+ }
155
+ try {
127
156
  const jobRunner = ctx["jobRunner"] as JobRunner; // @cast-boundary dynamic-key — dispatch lives on the concrete runner
128
157
  await jobRunner.dispatch(
129
158
  DeliveryJobs.send,
@@ -131,13 +160,7 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
131
160
  { priority: deliveryPriorityRank[p.priority] },
132
161
  );
133
162
  } catch (err) {
134
- await appendAttemptEvent(
135
- db,
136
- registry,
137
- p.deliveryAttemptId,
138
- entryFor(p, "failed", `render: ${messageOf(err)}`, p.address),
139
- );
140
- throw err;
163
+ return failAttempt(ctx, db, registry, p, "channel_error", err);
141
164
  }
142
165
  };
143
166
 
@@ -157,15 +180,15 @@ export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
157
180
  db,
158
181
  registry,
159
182
  p.deliveryAttemptId,
160
- entryFor(p, result.status, result.error ?? null, result.address ?? p.address),
183
+ entryFor(
184
+ p,
185
+ result.status,
186
+ result.error ?? null,
187
+ result.address ?? p.address,
188
+ result.confirmed,
189
+ ),
161
190
  );
162
191
  } catch (err) {
163
- await appendAttemptEvent(
164
- db,
165
- registry,
166
- p.deliveryAttemptId,
167
- entryFor(p, "failed", `send: ${messageOf(err)}`, p.address),
168
- );
169
- throw err;
192
+ return failAttempt(ctx, db, registry, p, "send_failed", err);
170
193
  }
171
194
  };
@@ -0,0 +1,19 @@
1
+ import type { NotifyFactory } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { DeliveryService } from "./types.js";
3
+
4
+ // The same binding runProdApp wires into the app context: the calling context's
5
+ // job dispatcher is handed to notify() per call, so queued channels go through
6
+ // the delivery jobs. deliverQueuedInline drops the dispatcher and sends inline.
7
+ export function createDeliveryNotifyFactory(
8
+ deliveryService: Pick<DeliveryService, "notify">,
9
+ options: { readonly deliverQueuedInline?: boolean } = {},
10
+ ): NotifyFactory {
11
+ return (user, tenantId, jobDispatcher) => (notificationType, notifyOptions) =>
12
+ deliveryService.notify(
13
+ notificationType,
14
+ notifyOptions,
15
+ user,
16
+ tenantId,
17
+ options.deliverQueuedInline === true ? undefined : jobDispatcher,
18
+ );
19
+ }
@@ -33,6 +33,10 @@ export const DELIVERY_LOG_SCREEN_ID = "delivery-log" as const;
33
33
  // column — see `screen.columns[].renderer.react.__component` in feature.ts
34
34
  // and `deliveryClient()`'s `columnRenderers` map.
35
35
  export const DELIVERY_STATUS_CELL_COMPONENT = "DeliveryStatusCell" as const;
36
+ export const DELIVERY_TYPE_CELL_COMPONENT = "DeliveryTypeCell" as const;
37
+ export const DELIVERY_CHANNEL_CELL_COMPONENT = "DeliveryChannelCell" as const;
38
+ export const DELIVERY_TIME_CELL_COMPONENT = "DeliveryTimeCell" as const;
39
+ export const DELIVERY_ERROR_CELL_COMPONENT = "DeliveryErrorCell" as const;
36
40
 
37
41
  export const DeliveryErrors = {
38
42
  noRecipient: "delivery_no_recipient",
@@ -43,6 +43,8 @@ export const deliveryAttemptsTable = pgTable(
43
43
  recipientAddress: text("recipient_address"),
44
44
  status: text("status").notNull().$type<"queued" | "sent" | "failed" | "skipped">(),
45
45
  error: text("error"),
46
+ // false = provider accepted but did not confirm; null = confirmed or not applicable.
47
+ confirmed: boolean("confirmed"),
46
48
  // Default covers rows that predate the column; new rows always carry the
47
49
  // notify() priority from the event payload.
48
50
  priority: text("priority").notNull().default("normal").$type<"critical" | "normal" | "low">(),
@@ -71,6 +73,7 @@ export const deliveryAttemptsTableMeta: EntityTableMeta = defineUnmanagedTable({
71
73
  { name: "recipient_address", pgType: "text", notNull: false },
72
74
  { name: "status", pgType: "text", notNull: true },
73
75
  { name: "error", pgType: "text", notNull: false },
76
+ { name: "confirmed", pgType: "boolean", notNull: false },
74
77
  { name: "priority", pgType: "text", notNull: true, defaultSql: "'normal'" },
75
78
  { name: "created_at", pgType: "timestamptz", notNull: true, defaultSql: "now()" },
76
79
  ],
@@ -1,15 +1,13 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
- import type {
4
- EscapeHatchAuditSink,
5
- Registry,
6
- TenantId,
7
- } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { EscapeHatchAuditSink, Registry } from "@cosmicdrift/kumiko-framework/engine";
8
4
  import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
9
5
  import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
6
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
10
7
  import type { Redis } from "ioredis";
11
8
  import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js";
12
9
  import { collectChannels, createDeliveryService } from "./delivery-service.js";
10
+ import { createDeliveryNotifyFactory } from "./notify-factory.js";
13
11
  import type { DeliveryService } from "./types.js";
14
12
 
15
13
  export type CreateDeliveryTestContextOptions = {
@@ -19,6 +17,7 @@ export type CreateDeliveryTestContextOptions = {
19
17
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
20
18
  readonly log?: Logger;
21
19
  readonly jobRunner?: JobRunner;
20
+ readonly secrets?: SecretsContext;
22
21
  };
23
22
 
24
23
  /**
@@ -47,9 +46,6 @@ export function createDeliveryTestContext(
47
46
 
48
47
  return {
49
48
  deliveryService, // exposed so tests can inspect/call directly if needed
50
- _notifyFactory:
51
- (user: { id: number; tenantId: TenantId }, tenantId: TenantId) =>
52
- (notificationType: string, notifyOptions: Record<string, unknown>) =>
53
- deliveryService.notify(notificationType, notifyOptions as never, user as never, tenantId), // @cast-boundary engine-bridge
49
+ _notifyFactory: createDeliveryNotifyFactory(deliveryService),
54
50
  };
55
51
  }
@@ -1,6 +1,7 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type {
4
+ DeliveryErrorCode,
4
5
  NotifyDeliveryStatus,
5
6
  NotifyJobDispatcher,
6
7
  NotifyOptions,
@@ -35,7 +36,9 @@ export type ChannelMessage = {
35
36
 
36
37
  export type ChannelResult = {
37
38
  readonly status: "sent" | "failed" | "skipped";
38
- readonly error?: string;
39
+ readonly error?: DeliveryErrorCode;
40
+ // Only ever set to false: the provider accepted the message but did not confirm delivery.
41
+ readonly confirmed?: false;
39
42
  readonly address?: string;
40
43
  };
41
44
 
@@ -91,8 +94,9 @@ export type DeliveryLogEntry = {
91
94
  readonly recipientId: string | null;
92
95
  readonly recipientAddress: string | null;
93
96
  readonly status: NotifyDeliveryStatus;
94
- readonly error: string | null;
97
+ readonly error: DeliveryErrorCode | null;
95
98
  readonly priority: NotifyPriority;
99
+ readonly confirmed?: false;
96
100
  };
97
101
 
98
102
  // --- Delivery Service ---
@@ -1,8 +1,19 @@
1
1
  // @runtime client
2
2
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
3
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
- import { DELIVERY_FEATURE, DELIVERY_STATUS_CELL_COMPONENT } from "../public-names.js";
4
+ import {
5
+ DELIVERY_CHANNEL_CELL_COMPONENT,
6
+ DELIVERY_ERROR_CELL_COMPONENT,
7
+ DELIVERY_FEATURE,
8
+ DELIVERY_STATUS_CELL_COMPONENT,
9
+ DELIVERY_TIME_CELL_COMPONENT,
10
+ DELIVERY_TYPE_CELL_COMPONENT,
11
+ } from "../public-names.js";
12
+ import { DeliveryChannelCell } from "./delivery-channel-cell.js";
13
+ import { DeliveryErrorCell } from "./delivery-error-cell.js";
5
14
  import { DeliveryStatusCell } from "./delivery-status-cell.js";
15
+ import { DeliveryTimeCell } from "./delivery-time-cell.js";
16
+ import { DeliveryTypeCell } from "./delivery-type-cell.js";
6
17
 
7
18
  export type DeliveryClientOptions = {
8
19
  readonly translations?: TranslationsByLocale;
@@ -13,6 +24,10 @@ export function deliveryClient(options?: DeliveryClientOptions): ClientFeatureDe
13
24
  name: DELIVERY_FEATURE,
14
25
  columnRenderers: {
15
26
  [DELIVERY_STATUS_CELL_COMPONENT]: DeliveryStatusCell,
27
+ [DELIVERY_TYPE_CELL_COMPONENT]: DeliveryTypeCell,
28
+ [DELIVERY_CHANNEL_CELL_COMPONENT]: DeliveryChannelCell,
29
+ [DELIVERY_TIME_CELL_COMPONENT]: DeliveryTimeCell,
30
+ [DELIVERY_ERROR_CELL_COMPONENT]: DeliveryErrorCell,
16
31
  },
17
32
  ...(options?.translations !== undefined && { translations: options.translations }),
18
33
  };
@@ -0,0 +1,13 @@
1
+ // @runtime client
2
+ // Channel column: the label comes from `delivery.channel.<name>`, registered by each
3
+ // channel feature; unknown channels show their raw name.
4
+
5
+ import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
6
+ import type { ReactNode } from "react";
7
+ import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
8
+
9
+ export function DeliveryChannelCell({ row }: ColumnRendererProps): ReactNode {
10
+ const t = useTranslation();
11
+ const channel = typeof row["channel"] === "string" ? row["channel"] : "";
12
+ return translateOrRaw(t, `delivery.channel.${channel}`, channel);
13
+ }
@@ -0,0 +1,17 @@
1
+ // @runtime client
2
+ // Error column. The log only carries fixed codes (`DeliveryErrorCode`); `http_<status>`
3
+ // takes the status as a parameter, anything unknown shows as the raw value.
4
+
5
+ import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
6
+ import { HTTP_ERROR_CODE_PATTERN } from "@cosmicdrift/kumiko-types/handlers";
7
+ import type { ReactNode } from "react";
8
+ import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
9
+
10
+ export function DeliveryErrorCell({ row }: ColumnRendererProps): ReactNode {
11
+ const t = useTranslation();
12
+ const error = row["error"];
13
+ if (typeof error !== "string" || error === "") return null;
14
+ const http = HTTP_ERROR_CODE_PATTERN.exec(error);
15
+ if (http) return t("delivery.error.http", { status: http[1] });
16
+ return translateOrRaw(t, `delivery.error.${error}`, error);
17
+ }
@@ -4,9 +4,10 @@
4
4
  // in feature.ts). The list frame itself is the generic declarative
5
5
  // projectionList renderer — only this one cell stays TSX.
6
6
 
7
- import type { ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
7
+ import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
8
8
  import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer-web";
9
9
  import type { ReactNode } from "react";
10
+ import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
10
11
  import { DeliveryStatus } from "../public-names.js";
11
12
 
12
13
  const STATUS_TONE: Readonly<Record<string, StatusTone>> = {
@@ -17,10 +18,16 @@ const STATUS_TONE: Readonly<Record<string, StatusTone>> = {
17
18
  };
18
19
 
19
20
  export function DeliveryStatusCell({ row }: ColumnRendererProps): ReactNode {
21
+ const t = useTranslation();
20
22
  const status = typeof row["status"] === "string" ? row["status"] : "";
23
+ const unconfirmed = status === DeliveryStatus.sent && row["confirmed"] === false;
24
+ const labelKey = unconfirmed ? "delivery.status.sentUnconfirmed" : `delivery.status.${status}`;
21
25
  return (
22
- <StatusBadge tone={STATUS_TONE[status] ?? "muted"} testId="delivery-status-cell">
23
- {status}
26
+ <StatusBadge
27
+ tone={unconfirmed ? "muted" : (STATUS_TONE[status] ?? "muted")}
28
+ testId="delivery-status-cell"
29
+ >
30
+ {translateOrRaw(t, labelKey, status)}
24
31
  </StatusBadge>
25
32
  );
26
33
  }
@@ -0,0 +1,8 @@
1
+ // @runtime client
2
+ import { type ColumnRendererProps, formatWhen } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ReactNode } from "react";
4
+
5
+ export function DeliveryTimeCell({ row }: ColumnRendererProps): ReactNode {
6
+ const createdAt = row["createdAt"];
7
+ return typeof createdAt === "string" ? formatWhen(createdAt) : "";
8
+ }
@@ -0,0 +1,20 @@
1
+ // @runtime client
2
+ // Notification type column. Types are QNs like `<scope>:notify:<name>`; the
3
+ // translation key is `<scope>.notification.<name>`, registered by the app that
4
+ // owns the notification (bundled features register none). Falls back to the short
5
+ // name, or to the raw value for any other format.
6
+
7
+ import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
8
+ import type { ReactNode } from "react";
9
+ import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
10
+
11
+ const NOTIFICATION_QN_PATTERN = /^([^:]+):notify:(.+)$/;
12
+
13
+ export function DeliveryTypeCell({ row }: ColumnRendererProps): ReactNode {
14
+ const t = useTranslation();
15
+ const type = typeof row["type"] === "string" ? row["type"] : "";
16
+ const match = NOTIFICATION_QN_PATTERN.exec(type);
17
+ if (!match) return type;
18
+ const [, scope, name] = match;
19
+ return translateOrRaw(t, `${scope}.notification.${name}`, name ?? type);
20
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.343.0",
4
+ "type": "improvement",
5
+ "title": "ctx.crossTenantReads for read-only cross-tenant job reads",
6
+ "detail": "Jobs whose `escapeHatch` grants `unsafeRaw` get `ctx.crossTenantReads` with `selectMany`, `fetchOne` and `count` across all tenants, read-only. Each call is reported as the new escape-hatch kind `cross-tenant-read`; system crons audit it once per process and the metric `kumiko_escape_hatch_uses_total` counts every call. `EscapeHatchKind` and the `escape-hatch-used` audit schema gain `cross-tenant-read`.\n`tenant-lifecycle:job:run-tenant-destruction` checks through `ctx.crossTenantReads` whether a tenant is due and only then calls `ctx.db.unsafeRaw()`, so idle minutes no longer write an `unsafe-raw` audit event."
7
+ },
2
8
  {
3
9
  "version": "0.336.0",
4
10
  "type": "improvement",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.344.0",
4
+ "type": "improvement",
5
+ "title": "Optional title heading on legal pages",
6
+ "detail": "`LegalPageRoute` has an optional `titleHeading` flag. When `true`, the page body starts with an `<h1>` holding the escaped page title. The default (`false`) leaves the body unchanged."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -10,6 +10,8 @@ export type LegalPageRoute = {
10
10
  readonly slug: string;
11
11
  readonly lang: string;
12
12
  readonly titleFallback: string;
13
+ /** Prepend the page title as `<h1>` to the rendered body. Default false. */
14
+ readonly titleHeading?: boolean;
13
15
  };
14
16
 
15
17
  // Shape of one boot-check-required block — the type behind
@@ -4,6 +4,7 @@ import {
4
4
  type FeatureDefinition,
5
5
  SYSTEM_TENANT_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { escapeHtml } from "@cosmicdrift/kumiko-headless";
7
8
  import {
8
9
  cachedSecurePageResponse,
9
10
  type PublicPageWrapLayout,
@@ -87,7 +88,7 @@ export type LegalPagesOptions = {
87
88
 
88
89
  export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
89
90
  const wrapLayout = opts.wrapLayout ?? wrapInLayout;
90
- const routes = opts.routes ?? LEGAL_ROUTES;
91
+ const routes: readonly LegalPageRoute[] = opts.routes ?? LEGAL_ROUTES;
91
92
  const requiredBlocks = opts.requiredBlocks ?? LEGAL_REQUIRED_BLOCKS;
92
93
  validateRoutes(routes);
93
94
  if (opts.routes !== undefined && opts.routes.length > 0 && opts.requiredBlocks === undefined) {
@@ -172,9 +173,13 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
172
173
  });
173
174
  }
174
175
 
176
+ const title = data.title || route.titleFallback;
177
+ const renderedBody = renderMarkdownToHtml(data.content);
175
178
  const html = wrapLayout({
176
- title: data.title || route.titleFallback,
177
- bodyHtml: renderMarkdownToHtml(data.content),
179
+ title,
180
+ bodyHtml: route.titleHeading
181
+ ? `<h1>${escapeHtml(title)}</h1>${renderedBody}`
182
+ : renderedBody,
178
183
  lang: route.lang,
179
184
  slug: route.slug,
180
185
  });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.344.0",
4
+ "type": "improvement",
5
+ "title": "Heading and Markdown sections in the simple mail renderer",
6
+ "detail": "`createSimpleRenderer` accepts two more section kinds: `{ heading: string }` (rendered as an escaped `<h2>`) and `{ markdown: string }` (rendered with the hardened Markdown renderer; raw HTML is escaped and unsafe link targets are replaced by `#`). Existing section kinds render as before."
7
+ },
2
8
  {
3
9
  "version": "0.341.0",
4
10
  "type": "improvement",