@cosmicdrift/kumiko-samples 0.341.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 (85) 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 +9 -0
  5. package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +1 -0
  6. package/packages/bundled-features/src/audit/feature.ts +47 -6
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +144 -0
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +18 -2
  9. package/packages/bundled-features/src/auth-email-password/changes.json +19 -0
  10. package/packages/bundled-features/src/auth-email-password/email-templates.ts +76 -16
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -1
  13. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -0
  14. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +4 -0
  15. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
  16. package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
  17. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
  18. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
  19. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
  20. package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
  21. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
  22. package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
  23. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
  24. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
  25. package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
  26. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
  27. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
  28. package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
  29. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  30. package/packages/bundled-features/src/channel-email/email-channel.ts +8 -1
  31. package/packages/bundled-features/src/channel-email/feature.ts +1 -0
  32. package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
  33. package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
  34. package/packages/bundled-features/src/channel-email/types.ts +4 -0
  35. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -0
  36. package/packages/bundled-features/src/channel-push/feature.ts +1 -0
  37. package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
  38. package/packages/bundled-features/src/channel-teams/changes.json +8 -0
  39. package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
  40. package/packages/bundled-features/src/channel-teams/teams-channel.ts +16 -0
  41. package/packages/bundled-features/src/channel-telegram/feature.ts +1 -0
  42. package/packages/bundled-features/src/delivery/changes.json +32 -0
  43. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +7 -2
  44. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +60 -14
  45. package/packages/bundled-features/src/delivery/constants.ts +4 -0
  46. package/packages/bundled-features/src/delivery/delivery-service.ts +70 -5
  47. package/packages/bundled-features/src/delivery/events.ts +15 -1
  48. package/packages/bundled-features/src/delivery/feature.ts +32 -4
  49. package/packages/bundled-features/src/delivery/handlers/log.query.ts +65 -20
  50. package/packages/bundled-features/src/delivery/i18n.ts +55 -0
  51. package/packages/bundled-features/src/delivery/index.ts +2 -0
  52. package/packages/bundled-features/src/delivery/jobs.ts +43 -20
  53. package/packages/bundled-features/src/delivery/notify-factory.ts +19 -0
  54. package/packages/bundled-features/src/delivery/public-names.ts +4 -0
  55. package/packages/bundled-features/src/delivery/tables.ts +3 -0
  56. package/packages/bundled-features/src/delivery/testing.ts +5 -9
  57. package/packages/bundled-features/src/delivery/types.ts +6 -2
  58. package/packages/bundled-features/src/delivery/web/client-plugin.tsx +16 -1
  59. package/packages/bundled-features/src/delivery/web/delivery-channel-cell.tsx +13 -0
  60. package/packages/bundled-features/src/delivery/web/delivery-error-cell.tsx +17 -0
  61. package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +10 -3
  62. package/packages/bundled-features/src/delivery/web/delivery-time-cell.tsx +8 -0
  63. package/packages/bundled-features/src/delivery/web/delivery-type-cell.tsx +20 -0
  64. package/packages/bundled-features/src/jobs/changes.json +6 -0
  65. package/packages/bundled-features/src/secrets/changes.json +6 -0
  66. package/packages/bundled-features/src/secrets/constants.ts +10 -0
  67. package/packages/bundled-features/src/secrets/feature.ts +4 -0
  68. package/packages/bundled-features/src/secrets/i18n.ts +5 -3
  69. package/packages/bundled-features/src/secrets/index.ts +8 -1
  70. package/packages/bundled-features/src/secrets/write-gate.ts +17 -5
  71. package/packages/bundled-features/src/shared/index.ts +6 -0
  72. package/packages/bundled-features/src/shared/redact.ts +27 -0
  73. package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -6
  74. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +0 -6
  75. package/packages/bundled-features/src/tenant/web/member-roles-cell.tsx +1 -1
  76. package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
  77. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +12 -2
  78. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -8
  79. package/packages/bundled-features/src/user/index.ts +1 -0
  80. package/packages/bundled-features/src/user/resolve-display-names.ts +35 -0
  81. package/packages/bundled-features/src/waitlist/changes.json +6 -0
  82. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +1 -1
  83. package/packages/bundled-features/src/waitlist/options.ts +3 -2
  84. package/samples/apps/use-all-bundled/feature-manifest.json +27 -2
  85. /package/packages/bundled-features/src/{tenant → shared}/web/translate-or-raw.ts +0 -0
@@ -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.343.0",
4
+ "type": "improvement",
5
+ "title": "Recognise the invalid-secret-value error",
6
+ "detail": "`secrets` exports `isInvalidSecretValueError`, which recognises the validation error that `secrets:write:set` and `ctx.secrets.set` raise when a value fails the key's `valueSchema`. It also exports `SECRETS_ERROR_KEYS` (the i18n keys of the secrets write errors) and `INVALID_SECRET_VALUE_CODE` (the field error code), so callers can map the failure to their own message instead of matching strings."
7
+ },
2
8
  {
3
9
  "version": "0.341.0",
4
10
  "type": "improvement",
@@ -10,3 +10,13 @@ import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
10
10
  // design, not a per-tenant RBAC surface.
11
11
  export const DEFAULT_SECRETS_ROLES = ["TenantAdmin"] as const;
12
12
  export const DEFAULT_SECRETS_ACCESS: AccessRule = { roles: DEFAULT_SECRETS_ROLES };
13
+
14
+ // Error i18n keys of the secrets write path. Shared by the write gate, the
15
+ // bundled translations and callers that need to recognise a specific failure.
16
+ export const SECRETS_ERROR_KEYS = {
17
+ unknownKey: "secrets.errors.unknownKey",
18
+ writeDenied: "secrets.errors.writeDenied",
19
+ invalidValue: "secrets.errors.invalidValue",
20
+ } as const;
21
+
22
+ export const INVALID_SECRET_VALUE_CODE = "invalid_secret_value" as const;
@@ -32,6 +32,10 @@ const ROTATE_ESCAPE_HATCH_REASON = "re-encrypts every tenant's secrets with the
32
32
  */
33
33
  export const SECRETS_FEATURE_NAME = "secrets";
34
34
 
35
+ /** Env keys that carry the KEK keyring. Optional when the app supplies its own
36
+ * `masterKey` provider. */
37
+ export const SECRETS_MASTER_KEK_ENV_KEYS = ["KUMIKO_SECRETS_MASTER_KEY_V1"] as const;
38
+
35
39
  export const secretsEnvSchema = z.object({
36
40
  KUMIKO_SECRETS_MASTER_KEY_V1: z
37
41
  .string()
@@ -1,13 +1,15 @@
1
+ import { SECRETS_ERROR_KEYS } from "./constants.js";
2
+
1
3
  export const SECRETS_I18N = {
2
- "secrets.errors.unknownKey": {
4
+ [SECRETS_ERROR_KEYS.unknownKey]: {
3
5
  en: "Unknown secret key.",
4
6
  de: "Unbekannter Geheimnis-Schlüssel.",
5
7
  },
6
- "secrets.errors.writeDenied": {
8
+ [SECRETS_ERROR_KEYS.writeDenied]: {
7
9
  en: "You are not allowed to change this secret.",
8
10
  de: "Dieses Geheimnis darf nicht geändert werden.",
9
11
  },
10
- "secrets.errors.invalidValue": {
12
+ [SECRETS_ERROR_KEYS.invalidValue]: {
11
13
  en: "This value is not valid for this secret.",
12
14
  de: "Dieser Wert ist für dieses Geheimnis nicht gültig.",
13
15
  },
@@ -1,9 +1,15 @@
1
- export { DEFAULT_SECRETS_ACCESS, DEFAULT_SECRETS_ROLES } from "./constants.js";
1
+ export {
2
+ DEFAULT_SECRETS_ACCESS,
3
+ DEFAULT_SECRETS_ROLES,
4
+ INVALID_SECRET_VALUE_CODE,
5
+ SECRETS_ERROR_KEYS,
6
+ } from "./constants.js";
2
7
  export {
3
8
  createSecretsContext,
4
9
  createSecretsFeature,
5
10
  requireSecretsContext,
6
11
  SECRETS_FEATURE_NAME,
12
+ SECRETS_MASTER_KEK_ENV_KEYS,
7
13
  type SecretsContext,
8
14
  type SecretsContextOptions,
9
15
  type SecretsFeatureOptions,
@@ -21,3 +27,4 @@ export {
21
27
  rotateJob,
22
28
  } from "./handlers/rotate.job.js";
23
29
  export { createSetHandler } from "./handlers/set.write.js";
30
+ export { isInvalidSecretValueError } from "./write-gate.js";
@@ -5,11 +5,13 @@ import type {
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import {
7
7
  AccessDeniedError,
8
+ isKumikoError,
8
9
  NotFoundError,
9
10
  ValidationError,
10
11
  type WriteFailure,
11
12
  writeFailure,
12
13
  } from "@cosmicdrift/kumiko-framework/errors";
14
+ import { INVALID_SECRET_VALUE_CODE, SECRETS_ERROR_KEYS } from "./constants.js";
13
15
 
14
16
  // Fixed keys carry their definition (redact fn, writeRoles); namespace keys
15
17
  // only the namespace, which has no redact fn.
@@ -21,7 +23,7 @@ function unknownKeyFailure(key: string): SecretKeyWriteCheck {
21
23
  return {
22
24
  ok: false,
23
25
  failure: writeFailure(
24
- new NotFoundError("secretKey", key, { i18nKey: "secrets.errors.unknownKey" }),
26
+ new NotFoundError("secretKey", key, { i18nKey: SECRETS_ERROR_KEYS.unknownKey }),
25
27
  ),
26
28
  };
27
29
  }
@@ -53,7 +55,7 @@ export function checkSecretKeyWrite(
53
55
  failure: writeFailure(
54
56
  new AccessDeniedError({
55
57
  message: "secret write access denied",
56
- i18nKey: "secrets.errors.writeDenied",
58
+ i18nKey: SECRETS_ERROR_KEYS.writeDenied,
57
59
  details: { requiredRoles: writeRoles },
58
60
  }),
59
61
  ),
@@ -72,12 +74,22 @@ export function invalidSecretValueError(): ValidationError {
72
74
  fields: [
73
75
  {
74
76
  path: "value",
75
- code: "invalid_secret_value",
76
- i18nKey: "secrets.errors.invalidValue",
77
+ code: INVALID_SECRET_VALUE_CODE,
78
+ i18nKey: SECRETS_ERROR_KEYS.invalidValue,
77
79
  },
78
80
  ],
79
81
  },
80
- { i18nKey: "secrets.errors.invalidValue" },
82
+ { i18nKey: SECRETS_ERROR_KEYS.invalidValue },
83
+ );
84
+ }
85
+
86
+ // Matches on the i18n key and error code rather than instanceof, which is fragile
87
+ // when the error crosses a package boundary with a duplicated framework copy.
88
+ export function isInvalidSecretValueError(err: unknown): err is ValidationError {
89
+ return (
90
+ isKumikoError(err) &&
91
+ err.code === "validation_error" &&
92
+ err.i18nKey === SECRETS_ERROR_KEYS.invalidValue
81
93
  );
82
94
  }
83
95
 
@@ -25,6 +25,12 @@ export {
25
25
  parentRowIsVisible,
26
26
  } from "./parent-visibility.js";
27
27
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
28
+ export {
29
+ redactBotTokens,
30
+ redactEmailAddresses,
31
+ redactErrorText,
32
+ redactUrls,
33
+ } from "./redact.js";
28
34
  export {
29
35
  type RowBoundGrantResult,
30
36
  redeemRowBoundGrant,
@@ -0,0 +1,27 @@
1
+ // Adapter errors usually quote the rejected recipient ("550 <ops@example.com> rejected"); the
2
+ // address must not outlive the crypto-shredded dispatch payload in logs.
3
+ export function redactEmailAddresses(text: string): string {
4
+ return text.replace(/[^\s<>,;"']+@[^\s<>,;"']+/g, "[redacted-address]");
5
+ }
6
+
7
+ // Provider URLs carry secrets in the path (Slack hook, Telegram bot token), the query or
8
+ // the userinfo. The origin stays so the log still says which host failed. Any scheme
9
+ // counts: smtp://user:pass@host carries credentials just like https.
10
+ export function redactUrls(text: string): string {
11
+ return text.replace(
12
+ /([a-z][a-z0-9+.-]*:\/\/)([^\s/?#<>"']*)([^\s<>"']*)/gi,
13
+ (_match, scheme: string, authority: string, rest: string) => {
14
+ const host = authority.slice(authority.lastIndexOf("@") + 1);
15
+ return rest.length > 0 && rest !== "/" ? `${scheme}${host}/[redacted]` : `${scheme}${host}`;
16
+ },
17
+ );
18
+ }
19
+
20
+ // Telegram-style bot tokens outside a URL ("<digits>:<35 chars>"). Times like 12:30 are too short to match.
21
+ export function redactBotTokens(text: string): string {
22
+ return text.replace(/\b\d{6,}:[A-Za-z0-9_-]{30,}\b/g, "[redacted-token]");
23
+ }
24
+
25
+ export function redactErrorText(text: string): string {
26
+ return redactBotTokens(redactEmailAddresses(redactUrls(text)));
27
+ }
@@ -25,12 +25,8 @@ import {
25
25
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
26
26
  import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
27
27
  import * as z from "zod";
28
- import {
29
- type MailSpec,
30
- mailSpecSchema,
31
- performMailDispatch,
32
- redactEmailAddresses,
33
- } from "./mail-runner.js";
28
+ import { redactEmailAddresses } from "../shared/index.js";
29
+ import { type MailSpec, mailSpecSchema, performMailDispatch } from "./mail-runner.js";
34
30
  import {
35
31
  performWebhookDispatch,
36
32
  WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,