@cosmicdrift/kumiko-samples 0.340.0 → 0.341.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
  4. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  5. package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
  6. package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
  7. package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
  9. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  14. package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
  15. package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
  16. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
  17. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  18. package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
  19. package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
  20. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
  21. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
  28. package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
  29. package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
  30. package/packages/bundled-features/src/channel-discord/index.ts +7 -1
  31. package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
  32. package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
  33. package/packages/bundled-features/src/channel-slack/index.ts +6 -1
  34. package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
  35. package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
  36. package/packages/bundled-features/src/channel-teams/index.ts +6 -1
  37. package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
  38. package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
  39. package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
  40. package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
  41. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
  42. package/packages/bundled-features/src/delivery/changes.json +7 -0
  43. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
  44. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
  45. package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
  46. package/packages/bundled-features/src/delivery/index.ts +4 -0
  47. package/packages/bundled-features/src/delivery/jobs.ts +3 -1
  48. package/packages/bundled-features/src/delivery/types.ts +10 -4
  49. package/packages/bundled-features/src/files/changes.json +13 -1
  50. package/packages/bundled-features/src/legal-pages/README.md +26 -3
  51. package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
  52. package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
  53. package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
  54. package/packages/bundled-features/src/page-render/index.ts +2 -0
  55. package/packages/bundled-features/src/page-render/layout.ts +17 -0
  56. package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
  57. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  58. package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
  59. package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
  60. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
  61. package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
  62. package/packages/bundled-features/src/secrets/changes.json +12 -0
  63. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  64. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
  65. package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
  66. package/packages/bundled-features/src/secrets/i18n.ts +4 -0
  67. package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
  68. package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
  69. package/packages/bundled-features/src/tenant/changes.json +6 -0
  70. package/packages/bundled-features/src/tenant/constants.ts +1 -0
  71. package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
  72. package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
  73. package/packages/bundled-features/src/tenant/index.ts +1 -0
  74. package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
  75. package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
  76. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
  77. package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
  78. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
  79. package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
  80. package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
  81. package/packages/bundled-features/src/waitlist/changes.json +17 -0
  82. package/packages/bundled-features/src/waitlist/constants.ts +44 -0
  83. package/packages/bundled-features/src/waitlist/entity.ts +76 -0
  84. package/packages/bundled-features/src/waitlist/feature.ts +50 -0
  85. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
  86. package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
  87. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
  88. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
  89. package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
  90. package/packages/bundled-features/src/waitlist/index.ts +15 -0
  91. package/packages/bundled-features/src/waitlist/lib.ts +36 -0
  92. package/packages/bundled-features/src/waitlist/mail.ts +114 -0
  93. package/packages/bundled-features/src/waitlist/options.ts +41 -0
  94. package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
  95. package/packages/bundled-features/src/waitlist/screens.ts +43 -0
  96. package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
  97. package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
  98. package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
  99. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  100. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  101. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  102. package/samples/recipes/chat-channels/README.md +2 -0
  103. package/samples/recipes/managed-config/README.md +1 -1
  104. package/samples/recipes/rate-limiting/README.md +21 -0
@@ -1,6 +1,7 @@
1
1
  import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
2
2
  import {
3
3
  type ChatSendResult,
4
+ type ChatWebhookTarget,
4
5
  chatConnectionNameSchema,
5
6
  DEFAULT_CHAT_TIMEOUT_MS,
6
7
  postChatWebhook,
@@ -25,6 +26,22 @@ export function chatMessageText(message: ChannelMessage): string {
25
26
  return message.body ? `${message.title}\n${message.body}` : message.title;
26
27
  }
27
28
 
29
+ export function resolveChatWebhookTarget(
30
+ provider: {
31
+ readonly defaultAllowedHosts: readonly string[];
32
+ readonly requiredPathPrefix?: string;
33
+ },
34
+ options: ChatWebhookChannelOptions,
35
+ ): ChatWebhookTarget {
36
+ return {
37
+ allowedHosts: options.allowedHosts ?? provider.defaultAllowedHosts,
38
+ requireHttps: options.requireHttps ?? true,
39
+ ...(provider.requiredPathPrefix !== undefined && {
40
+ requiredPathPrefix: provider.requiredPathPrefix,
41
+ }),
42
+ };
43
+ }
44
+
28
45
  export type ChatWebhookChannelSpec = {
29
46
  readonly name: string;
30
47
  readonly featureName: string;
@@ -62,11 +79,7 @@ export function createChatWebhookChannel(spec: ChatWebhookChannelSpec): Delivery
62
79
 
63
80
  const result = await postChatWebhook({
64
81
  url,
65
- allowedHosts: options.allowedHosts ?? spec.defaultAllowedHosts,
66
- requireHttps: options.requireHttps ?? true,
67
- ...(spec.requiredPathPrefix !== undefined && {
68
- requiredPathPrefix: spec.requiredPathPrefix,
69
- }),
82
+ ...resolveChatWebhookTarget(spec, options),
70
83
  timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
71
84
  body: spec.buildBody(message),
72
85
  });
@@ -28,12 +28,15 @@ export const chatConnectionNameSchema = z
28
28
  .max(64)
29
29
  .regex(/^[a-z0-9][a-z0-9-]*$/);
30
30
 
31
- export type ChatWebhookRequest = {
32
- readonly url: string;
31
+ export type ChatWebhookTarget = {
33
32
  // Exact hostnames, or suffixes with a leading dot (".webhook.office.com").
34
33
  readonly allowedHosts: readonly string[];
35
34
  readonly requireHttps: boolean;
36
35
  readonly requiredPathPrefix?: string;
36
+ };
37
+
38
+ export type ChatWebhookRequest = ChatWebhookTarget & {
39
+ readonly url: string;
37
40
  readonly timeoutMs: number;
38
41
  readonly body: unknown;
39
42
  // Test seam; production always uses the policy-bound egress().
@@ -60,30 +63,36 @@ function hostMatches(hostname: string, allowedHost: string): boolean {
60
63
 
61
64
  // Allowlist runs on the parsed URL, never on the raw string: userinfo
62
65
  // (`https://hooks.slack.com@evil.com`) and look-alike suffixes must not pass.
63
- function checkTarget(request: ChatWebhookRequest): URL | undefined {
66
+ export function checkChatWebhookTarget(target: ChatWebhookTarget, url: string): URL | undefined {
64
67
  let parsed: URL;
65
68
  try {
66
- parsed = new URL(request.url);
69
+ parsed = new URL(url);
67
70
  } catch {
68
71
  return undefined;
69
72
  }
70
73
  const protocolAllowed =
71
- parsed.protocol === "https:" || (!request.requireHttps && parsed.protocol === "http:");
74
+ parsed.protocol === "https:" || (!target.requireHttps && parsed.protocol === "http:");
72
75
  if (!protocolAllowed) return undefined;
73
76
  // egress() has no port rule; a provider webhook over https never needs a custom port.
74
- if (request.requireHttps && parsed.port !== "") return undefined;
77
+ if (target.requireHttps && parsed.port !== "") return undefined;
75
78
  if (parsed.username !== "" || parsed.password !== "") return undefined;
76
79
  const hostname = parsed.hostname.toLowerCase();
77
- if (!request.allowedHosts.some((allowed) => hostMatches(hostname, allowed))) return undefined;
80
+ if (!target.allowedHosts.some((allowed) => hostMatches(hostname, allowed))) return undefined;
78
81
  if (
79
- request.requiredPathPrefix !== undefined &&
80
- !parsed.pathname.startsWith(request.requiredPathPrefix)
82
+ target.requiredPathPrefix !== undefined &&
83
+ !parsed.pathname.startsWith(target.requiredPathPrefix)
81
84
  ) {
82
85
  return undefined;
83
86
  }
84
87
  return parsed;
85
88
  }
86
89
 
90
+ export function chatWebhookUrlSchema(target: ChatWebhookTarget): z.ZodType<string> {
91
+ return z
92
+ .string()
93
+ .refine((url) => checkChatWebhookTarget(target, url) !== undefined, "invalid webhook url");
94
+ }
95
+
87
96
  function isTimeout(err: unknown): boolean {
88
97
  return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
89
98
  }
@@ -104,7 +113,7 @@ function classifyFailure(err: unknown): ChatSendFailureCode {
104
113
  // Never throws and never surfaces the URL: egress/Bun errors can carry it, so
105
114
  // every failure is reduced to a code here.
106
115
  export async function postChatWebhook(request: ChatWebhookRequest): Promise<ChatSendResult> {
107
- const target = checkTarget(request);
116
+ const target = checkChatWebhookTarget(request, request.url);
108
117
  if (!target) return { ok: false, code: "host_not_allowed" };
109
118
 
110
119
  let response: Response;
@@ -3,14 +3,16 @@ import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
4
  import type {
5
5
  EscapeHatchAuditSink,
6
+ NotifyDelivery,
7
+ NotifyJobDispatcher,
6
8
  NotifyPriority,
7
9
  Registry,
8
10
  TenantId,
9
11
  } from "@cosmicdrift/kumiko-framework/engine";
10
12
  import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
11
- import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
12
13
  import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
13
14
  import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
15
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
14
16
  import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
15
17
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
16
18
  import type { Redis } from "ioredis";
@@ -49,10 +51,14 @@ export type DeliveryServiceOptions = {
49
51
  // Must be present whenever callers rely on idempotencyKey, otherwise notify()
50
52
  // throws at the callsite (silent no-op would be a correctness bug).
51
53
  readonly idempotencyRedis?: Redis;
52
- // Job runner for async (queued-mode) channels: email/push render+send run in
53
- // the delivery.render → delivery.send jobs. When absent, queued channels fall
54
- // back to synchronous inline delivery (job-less setups, unit tests).
55
- readonly jobRunner?: JobRunner;
54
+ // Job dispatcher for async (queued-mode) channels: email/push/chat render+send
55
+ // run in the delivery.render → delivery.send jobs. notify() may override it per
56
+ // call. When neither is present, queued channels fall back to synchronous
57
+ // inline delivery (job-less setups, unit tests).
58
+ readonly jobRunner?: NotifyJobDispatcher;
59
+ // Credentials source for inline-delivered chat channels (webhook URL / bot
60
+ // token). Jobs get ctx.secrets from the job runner instead.
61
+ readonly secrets?: SecretsContext;
56
62
  // Attributed escape-hatch audit for resolveUserIdsForTenant's ctx.systemDb —
57
63
  // absent means an unattributed warn log (see fallbackEscapeHatchReporter).
58
64
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
@@ -88,6 +94,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
88
94
  isChannelKilled,
89
95
  idempotencyRedis,
90
96
  jobRunner,
97
+ secrets,
91
98
  escapeHatchAuditSink,
92
99
  log,
93
100
  } = options;
@@ -207,11 +214,34 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
207
214
  )) as readonly string[];
208
215
  }
209
216
 
217
+ // State of one notify() call: collects the per-channel outcome returned to
218
+ // the caller. Passed explicitly — the service itself is shared across calls.
219
+ type NotifyRun = {
220
+ readonly deliveries: NotifyDelivery[];
221
+ readonly jobDispatcher: NotifyJobDispatcher | undefined;
222
+ readonly locale: string | undefined;
223
+ };
224
+
225
+ function recordDelivery(
226
+ run: NotifyRun,
227
+ entry: DeliveryLogEntry,
228
+ deliveryAttemptId: string,
229
+ ): void {
230
+ run.deliveries.push({
231
+ channel: entry.channel,
232
+ recipientId: entry.recipientId,
233
+ status: entry.status,
234
+ error: entry.error,
235
+ deliveryAttemptId,
236
+ });
237
+ }
238
+
210
239
  // Single-shot terminal log (inline channels, skips, idempotency dups). Async
211
240
  // attempts instead append a queued event up front and a terminal event from
212
241
  // the send job — see deliverViaChannel + jobs.ts.
213
- async function logDelivery(entry: DeliveryLogEntry): Promise<void> {
214
- await logAttempt(db, registry, entry);
242
+ async function logDelivery(run: NotifyRun, entry: DeliveryLogEntry): Promise<void> {
243
+ const attemptId = await logAttempt(db, registry, entry);
244
+ recordDelivery(run, entry, attemptId);
215
245
  }
216
246
 
217
247
  // Deliver one resolved (channel, address) pair. Inline channels (inApp) and
@@ -220,6 +250,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
220
250
  // hand off to the delivery.render (or delivery.send) job, which appends the
221
251
  // terminal event on the same attempt stream.
222
252
  async function deliverViaChannel(args: {
253
+ run: NotifyRun;
223
254
  channel: DeliveryChannel;
224
255
  address: string;
225
256
  message: ChannelMessage;
@@ -230,6 +261,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
230
261
  priority: NotifyPriority;
231
262
  }): Promise<void> {
232
263
  const {
264
+ run,
233
265
  channel,
234
266
  address,
235
267
  message,
@@ -240,12 +272,12 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
240
272
  priority,
241
273
  } = args;
242
274
 
243
- if (channel.mode === "queued" && jobRunner) {
275
+ if (channel.mode === "queued" && run.jobDispatcher) {
244
276
  // Async hand-off: record the attempt as queued, then dispatch the
245
277
  // render/send job — the terminal sent/failed event is appended by the
246
278
  // job, not here.
247
279
  const deliveryAttemptId = generateId();
248
- await appendAttemptEvent(db, registry, deliveryAttemptId, {
280
+ const queuedEntry: DeliveryLogEntry = {
249
281
  tenantId,
250
282
  notificationType,
251
283
  channel: channel.name,
@@ -254,8 +286,9 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
254
286
  status: "queued",
255
287
  error: null,
256
288
  priority,
257
- });
258
- await jobRunner.dispatch(
289
+ };
290
+ await appendAttemptEvent(db, registry, deliveryAttemptId, queuedEntry);
291
+ await run.jobDispatcher.dispatch(
259
292
  channel.render ? DeliveryJobs.render : DeliveryJobs.send,
260
293
  {
261
294
  channelName: channel.name,
@@ -269,11 +302,12 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
269
302
  },
270
303
  { priority: deliveryPriorityRank[priority] },
271
304
  );
305
+ recordDelivery(run, queuedEntry, deliveryAttemptId);
272
306
  } else {
273
307
  // Inline (inApp) or no-job-runner fallback: render + send synchronously.
274
308
  const rendered = channel.render ? await channel.render(message, channelCtx) : undefined;
275
309
  const result = await channel.send(address, message, channelCtx, rendered);
276
- await logDelivery({
310
+ await logDelivery(run, {
277
311
  tenantId,
278
312
  notificationType,
279
313
  channel: channel.name,
@@ -290,6 +324,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
290
324
  notificationType: string,
291
325
  data: Readonly<Record<string, unknown>> | undefined,
292
326
  channelName: string,
327
+ locale: string | undefined,
293
328
  ): ChannelMessage {
294
329
  // Look up per-channel template from notification definition
295
330
  const notifDef = registry.getAllNotifications().get(notificationType);
@@ -303,6 +338,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
303
338
  title: (channelData["title"] as string) ?? (data["title"] as string) ?? notificationType,
304
339
  body: channelData["body"] as string | undefined,
305
340
  data: channelData,
341
+ locale,
306
342
  };
307
343
  }
308
344
 
@@ -312,6 +348,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
312
348
  title: (data?.["title"] as string) ?? notificationType,
313
349
  body: data?.["body"] as string | undefined,
314
350
  data,
351
+ locale,
315
352
  };
316
353
  }
317
354
 
@@ -371,20 +408,21 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
371
408
  data: Readonly<Record<string, unknown>> | undefined,
372
409
  tenantId: TenantId,
373
410
  priority: NotifyPriority,
411
+ run: NotifyRun,
374
412
  ): Promise<void> {
375
- const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
413
+ const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
376
414
 
377
415
  for (const channel of channels) {
378
416
  // Route-only channel (no per-user address): not a user-notification target.
379
417
  if (!channel.resolve) continue;
380
418
 
381
- const message = buildMessage(notificationType, data, channel.name);
419
+ const message = buildMessage(notificationType, data, channel.name, run.locale);
382
420
 
383
421
  // Kill switch: tenant admin disabled this channel entirely
384
422
  if (isChannelKilled) {
385
423
  const killed = await isChannelKilled(tenantId, channel.name);
386
424
  if (killed) {
387
- await logDelivery({
425
+ await logDelivery(run, {
388
426
  tenantId,
389
427
  notificationType,
390
428
  channel: channel.name,
@@ -402,7 +440,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
402
440
  if (priority !== "critical") {
403
441
  const enabled = await isChannelEnabled(userId, tenantId, notificationType, channel.name);
404
442
  if (!enabled) {
405
- await logDelivery({
443
+ await logDelivery(run, {
406
444
  tenantId,
407
445
  notificationType,
408
446
  channel: channel.name,
@@ -420,7 +458,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
420
458
  if (rateLimit) {
421
459
  const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
422
460
  if (!allowed) {
423
- await logDelivery({
461
+ await logDelivery(run, {
424
462
  tenantId,
425
463
  notificationType,
426
464
  channel: channel.name,
@@ -437,7 +475,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
437
475
  try {
438
476
  const address = await channel.resolve(userId, channelCtx);
439
477
  if (!address) {
440
- await logDelivery({
478
+ await logDelivery(run, {
441
479
  tenantId,
442
480
  notificationType,
443
481
  channel: channel.name,
@@ -454,7 +492,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
454
492
  priority !== "critical" &&
455
493
  (await isAddressSuppressed(address, tenantId, notificationType, channel.name))
456
494
  ) {
457
- await logDelivery({
495
+ await logDelivery(run, {
458
496
  tenantId,
459
497
  notificationType,
460
498
  channel: channel.name,
@@ -468,6 +506,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
468
506
  }
469
507
 
470
508
  await deliverViaChannel({
509
+ run,
471
510
  channel,
472
511
  address,
473
512
  message,
@@ -478,7 +517,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
478
517
  priority,
479
518
  });
480
519
  } catch (err) {
481
- await logDelivery({
520
+ await logDelivery(run, {
482
521
  tenantId,
483
522
  notificationType,
484
523
  channel: channel.name,
@@ -499,14 +538,15 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
499
538
  tenantId: TenantId,
500
539
  priority: NotifyPriority,
501
540
  recipientId: string | null,
541
+ run: NotifyRun,
502
542
  ): Promise<void> {
503
- const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
543
+ const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
504
544
 
505
545
  // Direct routing skips preferences (no user account) but NOT rate limit
506
546
  // — direct sends can still be abused (webhook replays, test harnesses).
507
547
  for (const channel of channels) {
508
548
  const address = route[channel.name];
509
- const message = buildMessage(notificationType, data, channel.name);
549
+ const message = buildMessage(notificationType, data, channel.name, run.locale);
510
550
  if (!address) continue;
511
551
 
512
552
  // Address opt-out (critical priority skips it, same rule as user
@@ -515,7 +555,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
515
555
  priority !== "critical" &&
516
556
  (await isAddressSuppressed(address, tenantId, notificationType, channel.name))
517
557
  ) {
518
- await logDelivery({
558
+ await logDelivery(run, {
519
559
  tenantId,
520
560
  notificationType,
521
561
  channel: channel.name,
@@ -532,7 +572,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
532
572
  if (rateLimit) {
533
573
  const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
534
574
  if (!allowed) {
535
- await logDelivery({
575
+ await logDelivery(run, {
536
576
  tenantId,
537
577
  notificationType,
538
578
  channel: channel.name,
@@ -548,6 +588,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
548
588
 
549
589
  try {
550
590
  await deliverViaChannel({
591
+ run,
551
592
  channel,
552
593
  address,
553
594
  message,
@@ -558,7 +599,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
558
599
  priority,
559
600
  });
560
601
  } catch (err) {
561
- await logDelivery({
602
+ await logDelivery(run, {
562
603
  tenantId,
563
604
  notificationType,
564
605
  channel: channel.name,
@@ -573,14 +614,19 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
573
614
  }
574
615
 
575
616
  return {
576
- async notify(notificationType, options, _user, tenantId) {
617
+ async notify(notificationType, options, _user, tenantId, jobDispatcher) {
577
618
  const { to, route, data, idempotencyKey } = options;
578
619
  const priority: NotifyPriority = options.priority ?? "normal";
620
+ const run: NotifyRun = {
621
+ deliveries: [],
622
+ jobDispatcher: options.immediate ? undefined : (jobDispatcher ?? jobRunner),
623
+ locale: options.locale,
624
+ };
579
625
 
580
626
  if (idempotencyKey) {
581
627
  const first = await claimIdempotency(tenantId, idempotencyKey);
582
628
  if (!first) {
583
- await logDelivery({
629
+ await logDelivery(run, {
584
630
  tenantId,
585
631
  notificationType,
586
632
  channel: "*",
@@ -590,8 +636,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
590
636
  error: "duplicate_idempotency_key",
591
637
  priority,
592
638
  });
593
- // skip: duplicate send deduped via idempotency key, logged above
594
- return;
639
+ return { deliveries: run.deliveries };
595
640
  }
596
641
  }
597
642
 
@@ -603,9 +648,9 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
603
648
  tenantId,
604
649
  priority,
605
650
  options.recipientId ?? null,
651
+ run,
606
652
  );
607
- // skip: direct route delivered, no recipient resolution needed
608
- return;
653
+ return { deliveries: run.deliveries };
609
654
  }
610
655
 
611
656
  if (to !== undefined) {
@@ -620,9 +665,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
620
665
  }
621
666
 
622
667
  for (const userId of userIds) {
623
- await deliverToUser(userId, notificationType, data, tenantId, priority);
668
+ await deliverToUser(userId, notificationType, data, tenantId, priority, run);
624
669
  }
625
670
  }
671
+ return { deliveries: run.deliveries };
626
672
  },
627
673
  };
628
674
  }
@@ -3,13 +3,17 @@ export {
3
3
  type ChatWebhookChannelOptions,
4
4
  chatMessageText,
5
5
  createChatWebhookChannel,
6
+ resolveChatWebhookTarget,
6
7
  toChannelResult,
7
8
  } from "./chat-webhook-channel.js";
8
9
  export {
9
10
  type ChatSendFailureCode,
10
11
  type ChatSendResult,
11
12
  type ChatWebhookRequest,
13
+ type ChatWebhookTarget,
12
14
  chatConnectionNameSchema,
15
+ chatWebhookUrlSchema,
16
+ checkChatWebhookTarget,
13
17
  DEFAULT_CHAT_TIMEOUT_MS,
14
18
  postChatWebhook,
15
19
  truncateChars,
@@ -31,6 +31,7 @@ const channelMessageSchema = z.object({
31
31
  title: z.string(),
32
32
  body: z.string().optional(),
33
33
  data: z.record(z.string(), z.unknown()).optional(),
34
+ locale: z.string().optional(),
34
35
  });
35
36
 
36
37
  const renderJobPayloadSchema = z.object({
@@ -82,6 +83,7 @@ function toMessage(p: RenderJobPayload): ChannelMessage {
82
83
  title: p.message.title,
83
84
  body: p.message.body,
84
85
  data: p.message.data,
86
+ locale: p.message.locale,
85
87
  };
86
88
  }
87
89
 
@@ -115,7 +117,7 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
115
117
  const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
116
118
  const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
117
119
  const channel = resolveChannel(registry, p.channelName);
118
- const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
120
+ const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
119
121
 
120
122
  try {
121
123
  if (!channel.render) {
@@ -1,8 +1,11 @@
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
+ NotifyDeliveryStatus,
5
+ NotifyJobDispatcher,
4
6
  NotifyOptions,
5
7
  NotifyPriority,
8
+ NotifyResult,
6
9
  Registry,
7
10
  SessionUser,
8
11
  TenantId,
@@ -17,8 +20,8 @@ export type ChannelContext = {
17
20
  readonly registry: Registry;
18
21
  readonly sseBroker: SseBroker | undefined;
19
22
  readonly tenantId: TenantId;
20
- // Only wired on the delivery.send job path. Chat channels read their
21
- // per-tenant webhook URL / bot token from it; inline sends have none.
23
+ // Chat channels read their per-tenant webhook URL / bot token from it.
24
+ // Absent when no secrets feature/KEK is wired.
22
25
  readonly secrets?: SecretsContext | undefined;
23
26
  };
24
27
 
@@ -27,6 +30,7 @@ export type ChannelMessage = {
27
30
  readonly title: string;
28
31
  readonly body: string | undefined;
29
32
  readonly data: Readonly<Record<string, unknown>> | undefined;
33
+ readonly locale?: string | undefined;
30
34
  };
31
35
 
32
36
  export type ChannelResult = {
@@ -70,6 +74,7 @@ export type DeliveryChannel = {
70
74
  export type RendererInput = {
71
75
  readonly template: string;
72
76
  readonly variables: Readonly<Record<string, unknown>>;
77
+ readonly locale?: string | undefined;
73
78
  };
74
79
 
75
80
  export type NotificationRenderer = {
@@ -85,7 +90,7 @@ export type DeliveryLogEntry = {
85
90
  readonly channel: string;
86
91
  readonly recipientId: string | null;
87
92
  readonly recipientAddress: string | null;
88
- readonly status: "queued" | "sent" | "failed" | "skipped";
93
+ readonly status: NotifyDeliveryStatus;
89
94
  readonly error: string | null;
90
95
  readonly priority: NotifyPriority;
91
96
  };
@@ -98,7 +103,8 @@ export type DeliveryService = {
98
103
  options: NotifyOptions,
99
104
  user: SessionUser,
100
105
  tenantId: TenantId,
101
- ): Promise<void>;
106
+ jobDispatcher?: NotifyJobDispatcher,
107
+ ): Promise<NotifyResult>;
102
108
  };
103
109
 
104
110
  // r.useExtension options-shape: `name` is NOT part of the registration
@@ -1 +1,13 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "improvement",
5
+ "title": "Uploads resolve a generic content type from the file content",
6
+ "detail": "POST /api/files now resolves application/octet-stream or a missing type from the bytes (PDF, XML, PNG, JPEG, GIF, WebP, ZIP) and falls back to the file extension. A concrete declared type is never overridden. The shared helper resolveContentType is exported from the files module."
7
+ },
8
+ {
9
+ "version": "0.341.0",
10
+ "type": "fix",
11
+ "title": "Uploads without entityId no longer produce an empty storage key segment"
12
+ }
13
+ ]
@@ -71,9 +71,32 @@ Response:
71
71
  - `404 text/plain` — block missing. Hint: "Tenant admin must set this text block".
72
72
  - `503 text/plain` — `app.fetch` to `/api/query` failed (anonymousAccess missing?).
73
73
 
74
- Layout: a minimal HTML5 skeleton with inline CSS — apps that want to
75
- integrate into their own layout use `template-resolver:query:by-slug`
76
- directly and render themselves.
74
+ Layout: a minimal HTML5 skeleton with inline CSS. Apps that want their own
75
+ header, footer and theme pass `createLegalPagesFeature({ wrapLayout })`, a
76
+ `PublicPageWrapLayout` (`@cosmicdrift/kumiko-bundled-features/page-render`):
77
+ `({ title, bodyHtml, lang, slug?, alternates? }) => string`. The framework
78
+ keeps setting the security headers; the layout only returns the HTML.
79
+
80
+ The page CSP is `script-src 'none'`, so the layout must not need JavaScript
81
+ (a language switch has to be plain links).
82
+
83
+ Trailing slash: every route also answers its other slash form
84
+ (`/legal/impressum/`) with a `301` to the configured path, for `GET` and `HEAD`,
85
+ query string kept. A path configured with a trailing slash redirects the form
86
+ without it.
87
+
88
+ ### Contract termination pages
89
+
90
+ `createContractTerminationRoutes({ paths?, wrapLayout? })` (billing-foundation,
91
+ consumer protection) serves `/legal/kuendigen` and `/legal/cancel`. It takes the
92
+ same `wrapLayout` and wraps every page with it: form, review, result, 429 and
93
+ error. `alternates` maps `de`/`en` to the configured paths (custom `paths`
94
+ included), so the layout can link to the other language. The body sits in
95
+ `<div data-kumiko-page="contract-termination">` with `data-kumiko-*` hooks on
96
+ the forms, field groups, buttons and review table for app CSS. The headers (CSP
97
+ with `script-src 'none'`, `frame-ancestors 'none'`, `X-Frame-Options: DENY`,
98
+ `no-store`) are the same with and without `wrapLayout`, so a layout that loads
99
+ scripts fails. The slash redirect applies here too; `POST` has no alias.
77
100
 
78
101
  ---
79
102