@cosmicdrift/kumiko-bundled-features 0.339.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 (166) hide show
  1. package/dist/auth-email-password/bootstrap.d.ts +74 -0
  2. package/dist/auth-email-password/bootstrap.js +165 -0
  3. package/dist/auth-email-password/constants.d.ts +2 -0
  4. package/dist/auth-email-password/constants.js +5 -0
  5. package/dist/auth-email-password/errors.d.ts +1 -0
  6. package/dist/auth-email-password/errors.js +6 -0
  7. package/dist/auth-email-password/feature.js +2 -1
  8. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
  9. package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
  10. package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
  11. package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
  12. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
  13. package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
  14. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  15. package/dist/auth-email-password/index.d.ts +2 -0
  16. package/dist/auth-email-password/index.js +1 -0
  17. package/dist/auth-email-password/invite-token-store.d.ts +3 -0
  18. package/dist/auth-email-password/invite-token-store.js +5 -0
  19. package/dist/auth-email-password/magic-link-mail.js +1 -0
  20. package/dist/auth-mfa/config.d.ts +1 -1
  21. package/dist/auth-mfa/config.js +8 -3
  22. package/dist/auth-mfa/feature.d.ts +2 -0
  23. package/dist/auth-mfa/feature.js +2 -1
  24. package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
  25. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  26. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
  27. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
  28. package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
  29. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
  30. package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
  31. package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
  32. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
  33. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
  34. package/dist/billing-foundation/projection.js +23 -40
  35. package/dist/channel-discord/discord-channel.d.ts +4 -0
  36. package/dist/channel-discord/discord-channel.js +4 -0
  37. package/dist/channel-discord/feature.js +7 -2
  38. package/dist/channel-discord/index.d.ts +1 -1
  39. package/dist/channel-discord/index.js +1 -1
  40. package/dist/channel-email/email-channel.js +1 -0
  41. package/dist/channel-slack/feature.js +4 -2
  42. package/dist/channel-slack/index.d.ts +1 -1
  43. package/dist/channel-slack/index.js +1 -1
  44. package/dist/channel-slack/slack-channel.d.ts +4 -0
  45. package/dist/channel-slack/slack-channel.js +4 -0
  46. package/dist/channel-teams/feature.js +4 -2
  47. package/dist/channel-teams/index.d.ts +1 -1
  48. package/dist/channel-teams/index.js +1 -1
  49. package/dist/channel-teams/teams-channel.d.ts +4 -0
  50. package/dist/channel-teams/teams-channel.js +4 -0
  51. package/dist/channel-telegram/feature.js +4 -2
  52. package/dist/channel-telegram/index.d.ts +1 -1
  53. package/dist/channel-telegram/index.js +1 -1
  54. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  55. package/dist/channel-telegram/telegram-channel.js +28 -12
  56. package/dist/delivery/chat-webhook-channel.d.ts +5 -1
  57. package/dist/delivery/chat-webhook-channel.js +10 -5
  58. package/dist/delivery/chat-webhook-sender.d.ts +6 -2
  59. package/dist/delivery/chat-webhook-sender.js +13 -8
  60. package/dist/delivery/delivery-service.d.ts +4 -3
  61. package/dist/delivery/delivery-service.js +53 -33
  62. package/dist/delivery/index.d.ts +2 -2
  63. package/dist/delivery/index.js +2 -2
  64. package/dist/delivery/jobs.js +3 -1
  65. package/dist/delivery/types.d.ts +5 -3
  66. package/dist/inbound-mail-foundation/projection.js +37 -45
  67. package/dist/legal-pages/feature.d.ts +3 -15
  68. package/dist/legal-pages/feature.js +20 -50
  69. package/dist/legal-pages/lib/boot-check.d.ts +10 -0
  70. package/dist/legal-pages/lib/boot-check.js +33 -0
  71. package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
  72. package/dist/legal-pages/lib/validate-routes.js +17 -0
  73. package/dist/page-render/index.d.ts +2 -1
  74. package/dist/page-render/index.js +1 -0
  75. package/dist/page-render/layout.d.ts +7 -0
  76. package/dist/page-render/layout.js +5 -0
  77. package/dist/page-render/trailing-slash.d.ts +2 -0
  78. package/dist/page-render/trailing-slash.js +15 -0
  79. package/dist/renderer-simple/feature.js +1 -0
  80. package/dist/renderer-simple/index.d.ts +1 -1
  81. package/dist/renderer-simple/index.js +1 -1
  82. package/dist/renderer-simple/simple-renderer.d.ts +12 -5
  83. package/dist/renderer-simple/simple-renderer.js +66 -12
  84. package/dist/secrets/announce-secret-change.d.ts +8 -0
  85. package/dist/secrets/announce-secret-change.js +18 -0
  86. package/dist/secrets/feature.js +1 -1
  87. package/dist/secrets/handlers/delete.write.js +2 -0
  88. package/dist/secrets/handlers/set.write.js +6 -1
  89. package/dist/secrets/i18n.d.ts +4 -0
  90. package/dist/secrets/i18n.js +4 -0
  91. package/dist/secrets/secrets-context.d.ts +2 -0
  92. package/dist/secrets/secrets-context.js +3 -0
  93. package/dist/secrets/write-gate.d.ts +5 -1
  94. package/dist/secrets/write-gate.js +24 -1
  95. package/dist/tenant/constants.d.ts +1 -0
  96. package/dist/tenant/constants.js +1 -0
  97. package/dist/tenant/handlers/add-member.write.js +14 -3
  98. package/dist/tenant/handlers/invitations.query.d.ts +8 -0
  99. package/dist/tenant/handlers/invitations.query.js +15 -1
  100. package/dist/tenant/index.d.ts +1 -0
  101. package/dist/tenant/index.js +1 -0
  102. package/dist/tenant/invitation-table.d.ts +2 -0
  103. package/dist/tenant/invitation-table.js +10 -1
  104. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
  105. package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
  106. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  107. package/dist/tier-engine/feature.js +83 -14
  108. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
  109. package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
  110. package/dist/user/handlers/update-roles.js +7 -18
  111. package/dist/user/invited-global-roles.d.ts +14 -0
  112. package/dist/user/invited-global-roles.js +35 -0
  113. package/dist/user-data-rights/feature.js +3 -0
  114. package/dist/waitlist/constants.d.ts +33 -0
  115. package/dist/waitlist/constants.js +35 -0
  116. package/dist/waitlist/entity.d.ts +433 -0
  117. package/dist/waitlist/entity.js +64 -0
  118. package/dist/waitlist/feature.d.ts +3 -0
  119. package/dist/waitlist/feature.js +38 -0
  120. package/dist/waitlist/handlers/invite.write.d.ts +9 -0
  121. package/dist/waitlist/handlers/invite.write.js +119 -0
  122. package/dist/waitlist/handlers/list.query.d.ts +1 -0
  123. package/dist/waitlist/handlers/list.query.js +10 -0
  124. package/dist/waitlist/handlers/reject.write.d.ts +7 -0
  125. package/dist/waitlist/handlers/reject.write.js +80 -0
  126. package/dist/waitlist/handlers/submit.write.d.ts +13 -0
  127. package/dist/waitlist/handlers/submit.write.js +112 -0
  128. package/dist/waitlist/i18n.d.ts +6 -0
  129. package/dist/waitlist/i18n.js +31 -0
  130. package/dist/waitlist/index.d.ts +5 -0
  131. package/dist/waitlist/index.js +4 -0
  132. package/dist/waitlist/lib.d.ts +10 -0
  133. package/dist/waitlist/lib.js +24 -0
  134. package/dist/waitlist/mail.d.ts +15 -0
  135. package/dist/waitlist/mail.js +73 -0
  136. package/dist/waitlist/options.d.ts +29 -0
  137. package/dist/waitlist/options.js +8 -0
  138. package/dist/waitlist/payloads.d.ts +14 -0
  139. package/dist/waitlist/payloads.js +47 -0
  140. package/dist/waitlist/screens.d.ts +2 -0
  141. package/dist/waitlist/screens.js +42 -0
  142. package/dist/waitlist/user-data-hooks.d.ts +3 -0
  143. package/dist/waitlist/user-data-hooks.js +71 -0
  144. package/dist/workflow-runner/db/queries/due-runs.d.ts +4 -5
  145. package/dist/workflow-runner/db/queries/due-runs.js +5 -4
  146. package/dist/workflow-runner/feature.js +4 -6
  147. package/package.json +17 -9
  148. package/src/auth-email-password/changes.json +18 -0
  149. package/src/auth-mfa/changes.json +6 -0
  150. package/src/billing-foundation/changes.json +13 -0
  151. package/src/channel-telegram/changes.json +9 -0
  152. package/src/delivery/changes.json +7 -0
  153. package/src/files/changes.json +13 -1
  154. package/src/inbound-mail-foundation/changes.json +7 -0
  155. package/src/renderer-simple/changes.json +6 -0
  156. package/src/secrets/changes.json +12 -0
  157. package/src/tenant/changes.json +6 -0
  158. package/src/user/changes.json +13 -6
  159. package/src/waitlist/changes.json +17 -0
  160. package/src/workflow-runner/changes.json +7 -0
  161. package/dist/billing-foundation/db/queries/payment-projection.d.ts +0 -2
  162. package/dist/billing-foundation/db/queries/payment-projection.js +0 -13
  163. package/dist/billing-foundation/db/queries/subscription-projection.d.ts +0 -2
  164. package/dist/billing-foundation/db/queries/subscription-projection.js +0 -7
  165. package/dist/inbound-mail-foundation/db/queries/inbound-projections.d.ts +0 -7
  166. package/dist/inbound-mail-foundation/db/queries/inbound-projections.js +0 -24
@@ -25,7 +25,7 @@ export function collectChannels(registry) {
25
25
  // nicht aus Extension-Usages). Multi-Kind-Plugin-Pool lebt jetzt im
26
26
  // `renderer-foundation`-Bundle via `collectRendererPlugins`.
27
27
  export function createDeliveryService(options) {
28
- const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, escapeHatchAuditSink, log, } = options;
28
+ const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, escapeHatchAuditSink, log, } = options;
29
29
  const idemRedis = idempotencyRedis ?? rateLimit?.redis;
30
30
  // Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
31
31
  // Lua. Runs single-threaded in Redis, so two parallel clients can't both
@@ -119,11 +119,21 @@ export function createDeliveryService(options) {
119
119
  ...bridgeStub(),
120
120
  }));
121
121
  }
122
+ function recordDelivery(run, entry, deliveryAttemptId) {
123
+ run.deliveries.push({
124
+ channel: entry.channel,
125
+ recipientId: entry.recipientId,
126
+ status: entry.status,
127
+ error: entry.error,
128
+ deliveryAttemptId,
129
+ });
130
+ }
122
131
  // Single-shot terminal log (inline channels, skips, idempotency dups). Async
123
132
  // attempts instead append a queued event up front and a terminal event from
124
133
  // the send job — see deliverViaChannel + jobs.ts.
125
- async function logDelivery(entry) {
126
- await logAttempt(db, registry, entry);
134
+ async function logDelivery(run, entry) {
135
+ const attemptId = await logAttempt(db, registry, entry);
136
+ recordDelivery(run, entry, attemptId);
127
137
  }
128
138
  // Deliver one resolved (channel, address) pair. Inline channels (inApp) and
129
139
  // the no-job-runner fallback render + send synchronously and log the terminal
@@ -131,13 +141,13 @@ export function createDeliveryService(options) {
131
141
  // hand off to the delivery.render (or delivery.send) job, which appends the
132
142
  // terminal event on the same attempt stream.
133
143
  async function deliverViaChannel(args) {
134
- const { channel, address, message, channelCtx, tenantId, recipientId, notificationType, priority, } = args;
135
- if (channel.mode === "queued" && jobRunner) {
144
+ const { run, channel, address, message, channelCtx, tenantId, recipientId, notificationType, priority, } = args;
145
+ if (channel.mode === "queued" && run.jobDispatcher) {
136
146
  // Async hand-off: record the attempt as queued, then dispatch the
137
147
  // render/send job — the terminal sent/failed event is appended by the
138
148
  // job, not here.
139
149
  const deliveryAttemptId = generateId();
140
- await appendAttemptEvent(db, registry, deliveryAttemptId, {
150
+ const queuedEntry = {
141
151
  tenantId,
142
152
  notificationType,
143
153
  channel: channel.name,
@@ -146,8 +156,9 @@ export function createDeliveryService(options) {
146
156
  status: "queued",
147
157
  error: null,
148
158
  priority,
149
- });
150
- await jobRunner.dispatch(channel.render ? DeliveryJobs.render : DeliveryJobs.send, {
159
+ };
160
+ await appendAttemptEvent(db, registry, deliveryAttemptId, queuedEntry);
161
+ await run.jobDispatcher.dispatch(channel.render ? DeliveryJobs.render : DeliveryJobs.send, {
151
162
  channelName: channel.name,
152
163
  address,
153
164
  tenantId,
@@ -157,12 +168,13 @@ export function createDeliveryService(options) {
157
168
  priority,
158
169
  message,
159
170
  }, { priority: deliveryPriorityRank[priority] });
171
+ recordDelivery(run, queuedEntry, deliveryAttemptId);
160
172
  }
161
173
  else {
162
174
  // Inline (inApp) or no-job-runner fallback: render + send synchronously.
163
175
  const rendered = channel.render ? await channel.render(message, channelCtx) : undefined;
164
176
  const result = await channel.send(address, message, channelCtx, rendered);
165
- await logDelivery({
177
+ await logDelivery(run, {
166
178
  tenantId,
167
179
  notificationType,
168
180
  channel: channel.name,
@@ -174,7 +186,7 @@ export function createDeliveryService(options) {
174
186
  });
175
187
  }
176
188
  }
177
- function buildMessage(notificationType, data, channelName) {
189
+ function buildMessage(notificationType, data, channelName, locale) {
178
190
  // Look up per-channel template from notification definition
179
191
  const notifDef = registry.getAllNotifications().get(notificationType);
180
192
  const templateFn = notifDef?.templates?.[channelName];
@@ -186,6 +198,7 @@ export function createDeliveryService(options) {
186
198
  title: channelData["title"] ?? data["title"] ?? notificationType,
187
199
  body: channelData["body"],
188
200
  data: channelData,
201
+ locale,
189
202
  };
190
203
  }
191
204
  // @cast-boundary engine-payload — generic notification.data shape
@@ -194,6 +207,7 @@ export function createDeliveryService(options) {
194
207
  title: data?.["title"] ?? notificationType,
195
208
  body: data?.["body"],
196
209
  data,
210
+ locale,
197
211
  };
198
212
  }
199
213
  // Shared by deliverToUser (resolved account address) and deliverDirect (a
@@ -226,18 +240,18 @@ export function createDeliveryService(options) {
226
240
  // and conservative — DB ordering no longer decides the outcome).
227
241
  return !prefs.some((p) => p.enabled === false);
228
242
  }
229
- async function deliverToUser(userId, notificationType, data, tenantId, priority) {
230
- const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
243
+ async function deliverToUser(userId, notificationType, data, tenantId, priority, run) {
244
+ const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
231
245
  for (const channel of channels) {
232
246
  // Route-only channel (no per-user address): not a user-notification target.
233
247
  if (!channel.resolve)
234
248
  continue;
235
- const message = buildMessage(notificationType, data, channel.name);
249
+ const message = buildMessage(notificationType, data, channel.name, run.locale);
236
250
  // Kill switch: tenant admin disabled this channel entirely
237
251
  if (isChannelKilled) {
238
252
  const killed = await isChannelKilled(tenantId, channel.name);
239
253
  if (killed) {
240
- await logDelivery({
254
+ await logDelivery(run, {
241
255
  tenantId,
242
256
  notificationType,
243
257
  channel: channel.name,
@@ -254,7 +268,7 @@ export function createDeliveryService(options) {
254
268
  if (priority !== "critical") {
255
269
  const enabled = await isChannelEnabled(userId, tenantId, notificationType, channel.name);
256
270
  if (!enabled) {
257
- await logDelivery({
271
+ await logDelivery(run, {
258
272
  tenantId,
259
273
  notificationType,
260
274
  channel: channel.name,
@@ -271,7 +285,7 @@ export function createDeliveryService(options) {
271
285
  if (rateLimit) {
272
286
  const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
273
287
  if (!allowed) {
274
- await logDelivery({
288
+ await logDelivery(run, {
275
289
  tenantId,
276
290
  notificationType,
277
291
  channel: channel.name,
@@ -287,7 +301,7 @@ export function createDeliveryService(options) {
287
301
  try {
288
302
  const address = await channel.resolve(userId, channelCtx);
289
303
  if (!address) {
290
- await logDelivery({
304
+ await logDelivery(run, {
291
305
  tenantId,
292
306
  notificationType,
293
307
  channel: channel.name,
@@ -301,7 +315,7 @@ export function createDeliveryService(options) {
301
315
  }
302
316
  if (priority !== "critical" &&
303
317
  (await isAddressSuppressed(address, tenantId, notificationType, channel.name))) {
304
- await logDelivery({
318
+ await logDelivery(run, {
305
319
  tenantId,
306
320
  notificationType,
307
321
  channel: channel.name,
@@ -314,6 +328,7 @@ export function createDeliveryService(options) {
314
328
  continue;
315
329
  }
316
330
  await deliverViaChannel({
331
+ run,
317
332
  channel,
318
333
  address,
319
334
  message,
@@ -325,7 +340,7 @@ export function createDeliveryService(options) {
325
340
  });
326
341
  }
327
342
  catch (err) {
328
- await logDelivery({
343
+ await logDelivery(run, {
329
344
  tenantId,
330
345
  notificationType,
331
346
  channel: channel.name,
@@ -338,20 +353,20 @@ export function createDeliveryService(options) {
338
353
  }
339
354
  }
340
355
  }
341
- async function deliverDirect(route, notificationType, data, tenantId, priority, recipientId) {
342
- const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
356
+ async function deliverDirect(route, notificationType, data, tenantId, priority, recipientId, run) {
357
+ const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
343
358
  // Direct routing skips preferences (no user account) but NOT rate limit
344
359
  // — direct sends can still be abused (webhook replays, test harnesses).
345
360
  for (const channel of channels) {
346
361
  const address = route[channel.name];
347
- const message = buildMessage(notificationType, data, channel.name);
362
+ const message = buildMessage(notificationType, data, channel.name, run.locale);
348
363
  if (!address)
349
364
  continue;
350
365
  // Address opt-out (critical priority skips it, same rule as user
351
366
  // preferences).
352
367
  if (priority !== "critical" &&
353
368
  (await isAddressSuppressed(address, tenantId, notificationType, channel.name))) {
354
- await logDelivery({
369
+ await logDelivery(run, {
355
370
  tenantId,
356
371
  notificationType,
357
372
  channel: channel.name,
@@ -367,7 +382,7 @@ export function createDeliveryService(options) {
367
382
  if (rateLimit) {
368
383
  const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
369
384
  if (!allowed) {
370
- await logDelivery({
385
+ await logDelivery(run, {
371
386
  tenantId,
372
387
  notificationType,
373
388
  channel: channel.name,
@@ -382,6 +397,7 @@ export function createDeliveryService(options) {
382
397
  }
383
398
  try {
384
399
  await deliverViaChannel({
400
+ run,
385
401
  channel,
386
402
  address,
387
403
  message,
@@ -393,7 +409,7 @@ export function createDeliveryService(options) {
393
409
  });
394
410
  }
395
411
  catch (err) {
396
- await logDelivery({
412
+ await logDelivery(run, {
397
413
  tenantId,
398
414
  notificationType,
399
415
  channel: channel.name,
@@ -407,13 +423,18 @@ export function createDeliveryService(options) {
407
423
  }
408
424
  }
409
425
  return {
410
- async notify(notificationType, options, _user, tenantId) {
426
+ async notify(notificationType, options, _user, tenantId, jobDispatcher) {
411
427
  const { to, route, data, idempotencyKey } = options;
412
428
  const priority = options.priority ?? "normal";
429
+ const run = {
430
+ deliveries: [],
431
+ jobDispatcher: options.immediate ? undefined : (jobDispatcher ?? jobRunner),
432
+ locale: options.locale,
433
+ };
413
434
  if (idempotencyKey) {
414
435
  const first = await claimIdempotency(tenantId, idempotencyKey);
415
436
  if (!first) {
416
- await logDelivery({
437
+ await logDelivery(run, {
417
438
  tenantId,
418
439
  notificationType,
419
440
  channel: "*",
@@ -423,14 +444,12 @@ export function createDeliveryService(options) {
423
444
  error: "duplicate_idempotency_key",
424
445
  priority,
425
446
  });
426
- // skip: duplicate send deduped via idempotency key, logged above
427
- return;
447
+ return { deliveries: run.deliveries };
428
448
  }
429
449
  }
430
450
  if (route) {
431
- await deliverDirect(route, notificationType, data, tenantId, priority, options.recipientId ?? null);
432
- // skip: direct route delivered, no recipient resolution needed
433
- return;
451
+ await deliverDirect(route, notificationType, data, tenantId, priority, options.recipientId ?? null, run);
452
+ return { deliveries: run.deliveries };
434
453
  }
435
454
  if (to !== undefined) {
436
455
  let userIds;
@@ -444,9 +463,10 @@ export function createDeliveryService(options) {
444
463
  userIds = to;
445
464
  }
446
465
  for (const userId of userIds) {
447
- await deliverToUser(userId, notificationType, data, tenantId, priority);
466
+ await deliverToUser(userId, notificationType, data, tenantId, priority, run);
448
467
  }
449
468
  }
469
+ return { deliveries: run.deliveries };
450
470
  },
451
471
  };
452
472
  }
@@ -1,6 +1,6 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
- export { type ChatWebhookChannelOptions, chatMessageText, createChatWebhookChannel, toChannelResult, } from "./chat-webhook-channel.js";
3
- export { type ChatSendFailureCode, type ChatSendResult, type ChatWebhookRequest, chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
2
+ export { type ChatWebhookChannelOptions, chatMessageText, createChatWebhookChannel, resolveChatWebhookTarget, toChannelResult, } from "./chat-webhook-channel.js";
3
+ export { type ChatSendFailureCode, type ChatSendResult, type ChatWebhookRequest, type ChatWebhookTarget, chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
4
4
  export type { DeliveryStatusValue } from "./constants.js";
5
5
  export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
6
6
  export { collectChannels, createDeliveryService, type DeliveryServiceOptions, type KillSwitchResolver, type RateLimitConfig, } from "./delivery-service.js";
@@ -1,6 +1,6 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
- export { chatMessageText, createChatWebhookChannel, toChannelResult, } from "./chat-webhook-channel.js";
3
- export { chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
2
+ export { chatMessageText, createChatWebhookChannel, resolveChatWebhookTarget, toChannelResult, } from "./chat-webhook-channel.js";
3
+ export { chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
4
4
  export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
5
5
  export { collectChannels, createDeliveryService, } from "./delivery-service.js";
6
6
  export { createDeliveryFeature } from "./feature.js";
@@ -15,6 +15,7 @@ const channelMessageSchema = z.object({
15
15
  title: z.string(),
16
16
  body: z.string().optional(),
17
17
  data: z.record(z.string(), z.unknown()).optional(),
18
+ locale: z.string().optional(),
18
19
  });
19
20
  const renderJobPayloadSchema = z.object({
20
21
  channelName: z.string(),
@@ -56,6 +57,7 @@ function toMessage(p) {
56
57
  title: p.message.title,
57
58
  body: p.message.body,
58
59
  data: p.message.data,
60
+ locale: p.message.locale,
59
61
  };
60
62
  }
61
63
  function entryFor(p, status, error, address) {
@@ -81,7 +83,7 @@ export const deliveryRenderJob = async (payload, ctx) => {
81
83
  const tenantId = p.tenantId; // @cast-boundary engine-payload — stream tenant
82
84
  const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
83
85
  const channel = resolveChannel(registry, p.channelName);
84
- const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
86
+ const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
85
87
  try {
86
88
  if (!channel.render) {
87
89
  throw new Error(`delivery.render: channel "${p.channelName}" has no render step`);
@@ -1,6 +1,6 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
- import type { NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { NotifyDeliveryStatus, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
5
5
  import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
6
6
  export type ChannelContext = {
@@ -15,6 +15,7 @@ export type ChannelMessage = {
15
15
  readonly title: string;
16
16
  readonly body: string | undefined;
17
17
  readonly data: Readonly<Record<string, unknown>> | undefined;
18
+ readonly locale?: string | undefined;
18
19
  };
19
20
  export type ChannelResult = {
20
21
  readonly status: "sent" | "failed" | "skipped";
@@ -37,6 +38,7 @@ export type DeliveryChannel = {
37
38
  export type RendererInput = {
38
39
  readonly template: string;
39
40
  readonly variables: Readonly<Record<string, unknown>>;
41
+ readonly locale?: string | undefined;
40
42
  };
41
43
  export type NotificationRenderer = {
42
44
  readonly name: string;
@@ -48,12 +50,12 @@ export type DeliveryLogEntry = {
48
50
  readonly channel: string;
49
51
  readonly recipientId: string | null;
50
52
  readonly recipientAddress: string | null;
51
- readonly status: "queued" | "sent" | "failed" | "skipped";
53
+ readonly status: NotifyDeliveryStatus;
52
54
  readonly error: string | null;
53
55
  readonly priority: NotifyPriority;
54
56
  };
55
57
  export type DeliveryService = {
56
- notify(notificationType: string, options: NotifyOptions, user: SessionUser, tenantId: TenantId): Promise<void>;
58
+ notify(notificationType: string, options: NotifyOptions, user: SessionUser, tenantId: TenantId, jobDispatcher?: NotifyJobDispatcher): Promise<NotifyResult>;
57
59
  };
58
60
  export type DeliveryChannelPlugin = Omit<DeliveryChannel, "name">;
59
61
  export declare function isDeliveryChannelPlugin(o: unknown): o is DeliveryChannelPlugin;
@@ -16,51 +16,45 @@
16
16
  // kommen bereits als Ciphertext an — encrypted VOR dem append im
17
17
  // write-handler. Die applies kopieren nur durch; decrypted wird erst
18
18
  // in den list-queries.
19
+ import { insertOnConflictDoNothing, requireEntityTableMeta, upsertOnConflict, } from "@cosmicdrift/kumiko-framework/bun-db";
19
20
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
20
21
  import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
21
- import { insertIgnoreProjectionRow, upsertProjectionRow, } from "./db/queries/inbound-projections.js";
22
22
  import { inboundMessageEntity, mailAccountEntity, mailThreadEntity } from "./entities.js";
23
23
  // Drizzle-table-instances aus den entity-shapes — geteilt zwischen
24
24
  // projection-applies und list-queries (ein column-namespace).
25
25
  export const mailAccountsProjectionTable = buildEntityTable("mail-account", mailAccountEntity);
26
26
  export const inboundMessagesProjectionTable = buildEntityTable("inbound-message", inboundMessageEntity);
27
27
  export const mailThreadsProjectionTable = buildEntityTable("mail-thread", mailThreadEntity);
28
- function tableNameOf(table) {
29
- return table.tableName;
30
- }
28
+ // Unbranded metas: the apply functions below ARE the executor of these
29
+ // projections, so they may write through the typed helpers.
30
+ const mailAccountsProjectionMeta = requireEntityTableMeta(mailAccountsProjectionTable, "mail-account");
31
+ const inboundMessagesProjectionMeta = requireEntityTableMeta(inboundMessagesProjectionTable, "inbound-message");
32
+ const mailThreadsProjectionMeta = requireEntityTableMeta(mailThreadsProjectionTable, "mail-thread");
31
33
  // =============================================================================
32
34
  // mail-account — connected/updated/disconnected teilen den payload-shape,
33
35
  // der event-type taggt was passiert ist. Alle drei UPSERT-full für
34
36
  // defensive consistency (rebuild-aus-dem-Nichts, out-of-order).
35
37
  // =============================================================================
36
- const ACCOUNT_SET_CLAUSES = [
37
- `"provider" = EXCLUDED."provider"`,
38
- `"auth_method" = EXCLUDED."auth_method"`,
39
- `"owner_user_id" = EXCLUDED."owner_user_id"`,
40
- `"display_name" = EXCLUDED."display_name"`,
41
- `"address" = EXCLUDED."address"`,
42
- `"status" = EXCLUDED."status"`,
43
- `"watch_state" = EXCLUDED."watch_state"`,
44
- // connected_at bewusst NICHT im SET: der Erst-Connect-Zeitpunkt
45
- // bleibt stehen, updated/disconnected überschreiben ihn nicht.
46
- ];
47
38
  const applyMailAccountUpsert = defineApply(async (event, tx) => {
48
39
  const p = event.payload;
49
- const insertCols = {
50
- id: event.aggregateId,
51
- tenant_id: event.tenantId,
40
+ const mutable = {
52
41
  provider: p.provider,
53
- auth_method: p.authMethod,
54
- owner_user_id: p.ownerUserId,
55
- display_name: p.displayName,
42
+ authMethod: p.authMethod,
43
+ ownerUserId: p.ownerUserId,
44
+ displayName: p.displayName,
56
45
  address: p.address,
57
46
  status: p.status,
58
- watch_state: p.watchState,
59
- // Insert-Pfad = Erst-Connect (bzw. Rebuild: createdAt des ersten
60
- // events des Streams — fachlich derselbe Zeitpunkt).
61
- connected_at: event.createdAt.toString(),
47
+ watchState: p.watchState,
62
48
  };
63
- await upsertProjectionRow(tx, tableNameOf(mailAccountsProjectionTable), insertCols, ACCOUNT_SET_CLAUSES, Object.values(insertCols));
49
+ await upsertOnConflict(tx, mailAccountsProjectionMeta, {
50
+ id: event.aggregateId,
51
+ tenantId: event.tenantId,
52
+ ...mutable,
53
+ // Only the insert path is the first connect (on rebuild: createdAt of the
54
+ // stream's first event, the same moment). Left out of `update` so the
55
+ // first-connect time survives updated/disconnected events.
56
+ connectedAt: event.createdAt,
57
+ }, { conflictKeys: ["id"], update: mutable });
64
58
  });
65
59
  /** mail-account-connected → UPSERT full. */
66
60
  export const applyMailAccountConnected = applyMailAccountUpsert;
@@ -77,20 +71,20 @@ export const applyMailAccountDisconnected = applyMailAccountUpsert;
77
71
  /** inbound-message-received → INSERT ... ON CONFLICT DO NOTHING. */
78
72
  export const applyInboundMessageReceived = defineApply(async (event, tx) => {
79
73
  const p = event.payload;
80
- await insertIgnoreProjectionRow(tx, tableNameOf(inboundMessagesProjectionTable), {
74
+ await insertOnConflictDoNothing(tx, inboundMessagesProjectionMeta, {
81
75
  id: event.aggregateId,
82
- tenant_id: event.tenantId,
83
- account_id: p.accountId,
84
- owner_user_id: p.ownerUserId,
85
- message_id_header: p.messageIdHeader,
86
- thread_key: p.threadKey,
76
+ tenantId: event.tenantId,
77
+ accountId: p.accountId,
78
+ ownerUserId: p.ownerUserId,
79
+ messageIdHeader: p.messageIdHeader,
80
+ threadKey: p.threadKey,
87
81
  from: p.from,
88
82
  to: p.to,
89
83
  cc: p.cc,
90
84
  subject: p.subject,
91
85
  snippet: p.snippet,
92
- received_at: p.receivedAtIso,
93
- body_ref: p.bodyRef,
86
+ receivedAt: p.receivedAtIso,
87
+ bodyRef: p.bodyRef,
94
88
  scope: p.scope,
95
89
  });
96
90
  });
@@ -103,17 +97,15 @@ export const applyInboundMessageReceived = defineApply(async (event, tx) => {
103
97
  /** mail-thread-updated → UPSERT full mit dem Payload-Snapshot. */
104
98
  export const applyMailThreadUpdated = defineApply(async (event, tx) => {
105
99
  const p = event.payload;
106
- const insertCols = {
107
- id: event.aggregateId,
108
- tenant_id: event.tenantId,
109
- thread_key: p.threadKey,
100
+ const mutable = {
110
101
  subject: p.subject,
111
- last_message_at: p.lastMessageAtIso,
112
- message_count: p.messageCount,
102
+ lastMessageAt: p.lastMessageAtIso,
103
+ messageCount: p.messageCount,
113
104
  };
114
- await upsertProjectionRow(tx, tableNameOf(mailThreadsProjectionTable), insertCols, [
115
- `"subject" = EXCLUDED."subject"`,
116
- `"last_message_at" = EXCLUDED."last_message_at"`,
117
- `"message_count" = EXCLUDED."message_count"`,
118
- ], Object.values(insertCols));
105
+ await upsertOnConflict(tx, mailThreadsProjectionMeta, {
106
+ id: event.aggregateId,
107
+ tenantId: event.tenantId,
108
+ threadKey: p.threadKey,
109
+ ...mutable,
110
+ }, { conflictKeys: ["id"], update: mutable });
119
111
  });
@@ -1,12 +1,8 @@
1
- import { type TemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
1
  import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type PublicPageWrapLayout } from "../page-render/index.js";
3
3
  import { type LegalPageRoute, type LegalRequiredBlock } from "./constants.js";
4
- export type LegalPagesWrapLayout = (opts: {
5
- readonly title: string;
6
- readonly bodyHtml: string;
7
- readonly lang: string;
8
- readonly slug?: string;
9
- }) => string;
4
+ export { type LegalPagesBootCheckCtx, runLegalPagesBootCheck } from "./lib/boot-check.js";
5
+ export type LegalPagesWrapLayout = PublicPageWrapLayout;
10
6
  export type LegalPagesOptions = {
11
7
  /** Custom Layout-Wrapper für die /legal/*-Routes. Default: minimaler
12
8
  * HTML-Skeleton aus markdown.ts (`wrapInLayout`). Apps die ihr eigenes
@@ -27,11 +23,3 @@ export type LegalPagesOptions = {
27
23
  readonly requiredBlocks?: readonly LegalRequiredBlock[];
28
24
  };
29
25
  export declare function createLegalPagesFeature(opts?: LegalPagesOptions): FeatureDefinition;
30
- export type LegalPagesBootCheckCtx = {
31
- readonly templateResolver?: TemplateResolverApi;
32
- readonly log?: {
33
- readonly info?: (msg: string) => void;
34
- readonly warn?: (msg: string) => void;
35
- };
36
- };
37
- export declare function runLegalPagesBootCheck(ctx: LegalPagesBootCheckCtx, requiredBlocks?: readonly LegalRequiredBlock[]): Promise<void>;
@@ -1,9 +1,11 @@
1
- import { requireTemplateResolver, TEXT_BLOCK_KIND, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
1
  import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
3
2
  import { defineFeature, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
4
- import { cachedSecurePageResponse } from "../page-render/index.js";
3
+ import { cachedSecurePageResponse, redirectToCanonicalPath, slashVariantOf, } from "../page-render/index.js";
5
4
  import { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES, } from "./constants.js";
5
+ import { runLegalPagesBootCheck } from "./lib/boot-check.js";
6
+ import { validateRoutes } from "./lib/validate-routes.js";
6
7
  import { renderMarkdownToHtml, wrapInLayout } from "./markdown.js";
8
+ export { runLegalPagesBootCheck } from "./lib/boot-check.js";
7
9
  // QN-Konstante als dokumentierter Public-Contract des template-resolver-
8
10
  // Features. Ein magic-string statt eines Code-Imports ist hier explizit
9
11
  // gewollt: Cross-Feature-Calls gehen nur über stable Public-API
@@ -14,23 +16,6 @@ import { renderMarkdownToHtml, wrapInLayout } from "./markdown.js";
14
16
  const TEXT_BLOCK_BY_SLUG_QN = "template-resolver:query:by-slug";
15
17
  // 60s-shared-cache saves the origin-revalidate roundtrip; legal-content edits are live within 60s.
16
18
  const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 };
17
- // Validated once at feature-build time (not per-request) so a config typo
18
- // fails app startup instead of surfacing as a confusing 404 later.
19
- function validateRoutes(routes) {
20
- const seenPaths = new Set();
21
- for (const route of routes) {
22
- if (!route.path || !route.slug || !route.lang) {
23
- throw new Error(`legal-pages: route is missing a required field (path/slug/lang): ${JSON.stringify(route)}`);
24
- }
25
- if (!route.path.startsWith("/")) {
26
- throw new Error(`legal-pages: route path must start with "/", got "${route.path}"`);
27
- }
28
- if (seenPaths.has(route.path)) {
29
- throw new Error(`legal-pages: duplicate route path "${route.path}"`);
30
- }
31
- seenPaths.add(route.path);
32
- }
33
- }
34
19
  export function createLegalPagesFeature(opts = {}) {
35
20
  const wrapLayout = opts.wrapLayout ?? wrapInLayout;
36
21
  const routes = opts.routes ?? LEGAL_ROUTES;
@@ -56,6 +41,8 @@ export function createLegalPagesFeature(opts = {}) {
56
41
  method: "GET",
57
42
  path: route.path,
58
43
  anonymous: true,
44
+ // Public pages backed by a DB query: bound the per-IP read rate.
45
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
59
46
  handler: async (c, { systemQuery }) => {
60
47
  // Architektur: 1 App = X Tenants = 1 Impressum. Egal welche
61
48
  // Subdomain der Visitor besucht (apex, admin.*, tenant-x.*) —
@@ -113,6 +100,20 @@ export function createLegalPagesFeature(opts = {}) {
113
100
  },
114
101
  });
115
102
  }
103
+ // Hono matches strictly, so "/legal/x/" would 404 for sites that link with
104
+ // a trailing slash. GET also serves HEAD.
105
+ const configuredPaths = new Set(routes.map((route) => route.path));
106
+ for (const route of routes) {
107
+ const variant = slashVariantOf(route.path);
108
+ if (variant === null || configuredPaths.has(variant))
109
+ continue;
110
+ r.httpRoute({
111
+ method: "GET",
112
+ path: variant,
113
+ anonymous: true,
114
+ handler: (c) => redirectToCanonicalPath(c.req.url, route.path),
115
+ });
116
+ }
116
117
  // Boot-Check via ctx.templateResolver (extraContext-Pattern, symmetrisch
117
118
  // zu requireConfigResolver in config). App-Bootstrap muss templateResolver
118
119
  // wired haben — der Helper gibt einen klaren Wiring-Hinweis wenn nicht.
@@ -132,34 +133,3 @@ export function createLegalPagesFeature(opts = {}) {
132
133
  return {};
133
134
  });
134
135
  }
135
- // Exportiert für direkte Tests. Wirft InternalError wenn ctx.templateResolver
136
- // nicht gewired ist (Hinweis auf fehlenden extraContext). Wirft Error
137
- // in NODE_ENV=production wenn Pflicht-Blocks fehlen, sonst log.warn.
138
- // Logged log.info wenn alles vorhanden ist (kein silent-skip).
139
- export async function runLegalPagesBootCheck(ctx, requiredBlocks = LEGAL_REQUIRED_BLOCKS) {
140
- const templateResolver = requireTemplateResolver(ctx, "legal-pages-boot-check");
141
- const missing = [];
142
- for (const required of requiredBlocks) {
143
- const block = await templateResolver.findExact({
144
- tenantId: SYSTEM_TENANT_ID,
145
- slug: required.slug,
146
- kind: TEXT_BLOCK_KIND,
147
- locale: required.lang,
148
- });
149
- if (!block?.content) {
150
- missing.push({ slug: required.slug, lang: required.lang });
151
- }
152
- }
153
- if (missing.length === 0) {
154
- ctx.log?.info?.("legal-pages boot-check: alle Pflicht-Blocks vorhanden");
155
- }
156
- else {
157
- const message = `legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
158
- missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
159
- ". Seed via template-resolver:write:set or the seedTextBlock helper.";
160
- if (process.env["NODE_ENV"] === "production") {
161
- throw new Error(`Boot-Validation failed: ${message}`);
162
- }
163
- ctx.log?.warn?.(message);
164
- }
165
- }
@@ -0,0 +1,10 @@
1
+ import { type TemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
+ import { type LegalRequiredBlock } from "../constants.js";
3
+ export type LegalPagesBootCheckCtx = {
4
+ readonly templateResolver?: TemplateResolverApi;
5
+ readonly log?: {
6
+ readonly info?: (msg: string) => void;
7
+ readonly warn?: (msg: string) => void;
8
+ };
9
+ };
10
+ export declare function runLegalPagesBootCheck(ctx: LegalPagesBootCheckCtx, requiredBlocks?: readonly LegalRequiredBlock[]): Promise<void>;