@cosmicdrift/kumiko-samples 0.356.0 → 0.358.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 (49) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  4. package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
  5. package/packages/bundled-features/src/auth-mfa/screens.ts +8 -1
  6. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +71 -21
  7. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +99 -0
  8. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +143 -43
  9. package/packages/bundled-features/src/cap-counter/changes.json +7 -0
  10. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +18 -32
  11. package/packages/bundled-features/src/cap-counter/feature.ts +7 -1
  12. package/packages/bundled-features/src/cap-counter/index.ts +5 -0
  13. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -25
  14. package/packages/bundled-features/src/cap-overview/screens.ts +3 -1
  15. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  16. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +15 -1
  17. package/packages/bundled-features/src/delivery/changes.json +6 -0
  18. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +1 -1
  19. package/packages/bundled-features/src/delivery/constants.ts +5 -0
  20. package/packages/bundled-features/src/delivery/delivery-service.ts +118 -50
  21. package/packages/bundled-features/src/delivery/feature.ts +7 -1
  22. package/packages/bundled-features/src/delivery/jobs.ts +27 -1
  23. package/packages/bundled-features/src/delivery/testing.ts +1 -0
  24. package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -15
  25. package/packages/bundled-features/src/jobs/job-run-table.ts +10 -3
  26. package/packages/bundled-features/src/notes-history/changes.json +6 -0
  27. package/packages/bundled-features/src/notes-history/entity.ts +9 -7
  28. package/packages/bundled-features/src/notes-history-user-data/hooks.ts +84 -76
  29. package/packages/bundled-features/src/notes-history-user-data/index.ts +3 -1
  30. package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
  31. package/packages/bundled-features/src/sessions/screens.ts +2 -0
  32. package/packages/bundled-features/src/tenant/changes.json +6 -0
  33. package/packages/bundled-features/src/tenant/constants.ts +6 -3
  34. package/packages/bundled-features/src/tenant/feature.ts +2 -0
  35. package/packages/bundled-features/src/tenant/handlers/member-role-availability.query.ts +25 -0
  36. package/packages/bundled-features/src/tenant/member-role-options.ts +6 -0
  37. package/packages/bundled-features/src/tenant/screens.ts +9 -6
  38. package/packages/bundled-features/src/user/screens.ts +8 -1
  39. package/packages/bundled-features/src/user-data-rights/screens.ts +8 -1
  40. package/packages/bundled-features/src/waitlist/screens.ts +8 -1
  41. package/samples/apps/marketing-demo/src/features/assets/schema/asset.ts +4 -4
  42. package/samples/apps/marketing-demo/src/features/helpdesk/schema/ticket.ts +4 -4
  43. package/samples/apps/showcase/src/features/items/schema/item.ts +1 -1
  44. package/samples/apps/styleguide/src/features/demo/feature.ts +3 -3
  45. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
  46. package/samples/apps/styleguide/src/features/widgets/feature.ts +1 -0
  47. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +6 -2
  48. package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +9 -2
  49. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
@@ -1,7 +1,12 @@
1
- import { createEntityExecutor, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { KumikoError, reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
- import { markCapSoftWarned, readRollingCapUsage } from "./book-cap-usage.js";
4
+ import {
5
+ type CapCounterContext,
6
+ markCapSoftWarned,
7
+ readRollingCapUsage,
8
+ resolveCapCounterScope,
9
+ } from "./book-cap-usage.js";
5
10
  import { capCounterEntity } from "./entity.js";
6
11
 
7
12
  const { table } = createEntityExecutor("cap-counter", capCounterEntity);
@@ -95,7 +100,7 @@ export function assertBelowHardCap(
95
100
  * level (e.g. uniqueness-index on apps.tenantId+slot-number).
96
101
  */
97
102
  export async function enforceCap(
98
- ctx: HandlerContext,
103
+ ctx: CapCounterContext,
99
104
  options: {
100
105
  readonly capName: string;
101
106
  readonly periodStartIso: string;
@@ -106,9 +111,7 @@ export async function enforceCap(
106
111
  readonly amount?: number;
107
112
  },
108
113
  ): Promise<EnforceCapResult> {
109
- if (!ctx.db) {
110
- throw new Error("cap-counter.enforceCap: ctx.db missing — run inside a handler context");
111
- }
114
+ const { user, readDb } = resolveCapCounterScope(ctx, false);
112
115
 
113
116
  const amount = options.amount ?? 1;
114
117
  if (!Number.isInteger(amount) || amount < 1) {
@@ -120,9 +123,9 @@ export async function enforceCap(
120
123
  const tolerance = CAP_TOLERANCES[options.profile];
121
124
  const softThreshold = options.limit * tolerance.soft;
122
125
 
123
- const rows = await ctx.db.selectMany(
126
+ const rows = await readDb.selectMany(
124
127
  table,
125
- { capName: options.capName, periodStart: options.periodStartIso },
128
+ { tenantId: user.tenantId, capName: options.capName, periodStart: options.periodStartIso },
126
129
  { limit: 1 },
127
130
  );
128
131
 
@@ -170,7 +173,7 @@ export async function enforceCap(
170
173
  * vorgezogen.
171
174
  */
172
175
  export async function enforceRollingCap(
173
- ctx: HandlerContext,
176
+ ctx: CapCounterContext,
174
177
  options: {
175
178
  readonly capName: string;
176
179
  readonly windowDays: number;
@@ -178,20 +181,13 @@ export async function enforceRollingCap(
178
181
  readonly profile: CapToleranceProfileName;
179
182
  },
180
183
  ): Promise<EnforceCapResult> {
181
- if (!ctx.db) {
182
- throw new Error("cap-counter.enforceRollingCap: ctx.db missing — run inside a handler context");
183
- }
184
- if (!ctx.user?.tenantId) {
185
- throw new Error(
186
- "cap-counter.enforceRollingCap: ctx.user.tenantId missing — required to compute aggregate-id",
187
- );
188
- }
184
+ const { user, readDb } = resolveCapCounterScope(ctx, false);
189
185
 
190
186
  const tolerance = CAP_TOLERANCES[options.profile];
191
187
  const softThreshold = options.limit * tolerance.soft;
192
188
  const hardThreshold = options.limit * tolerance.hard;
193
189
 
194
- const value = await readRollingCapUsage(ctx.db, ctx.user.tenantId, {
190
+ const value = await readRollingCapUsage(readDb, user.tenantId, {
195
191
  capName: options.capName,
196
192
  windowDays: options.windowDays,
197
193
  });
@@ -295,7 +291,7 @@ export type SoftHitNotifier = (info: {
295
291
  * Email-Notification).
296
292
  */
297
293
  export async function enforceCapAndMaybeNotify(
298
- ctx: HandlerContext,
294
+ ctx: CapCounterContext,
299
295
  options: {
300
296
  readonly capName: string;
301
297
  readonly periodStartIso: string;
@@ -315,16 +311,11 @@ export async function enforceCapAndMaybeNotify(
315
311
  });
316
312
 
317
313
  if (result.state === "soft-hit" && result.crossed) {
318
- if (!ctx.user?.tenantId) {
319
- throw new Error(
320
- "cap-counter.enforceCapAndMaybeNotify: ctx.user.tenantId missing — required for notification",
321
- );
322
- }
323
314
  await options.notify({
324
315
  capName: options.capName,
325
316
  value: result.value,
326
317
  limit: options.limit,
327
- tenantId: ctx.user.tenantId,
318
+ tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
328
319
  });
329
320
  // Flip the soft-warned flag so the same period doesn't re-notify.
330
321
  const marked = await markCapSoftWarned(ctx, {
@@ -349,7 +340,7 @@ export async function enforceCapAndMaybeNotify(
349
340
  * Tag/Stunde auf (Hourly-Cron statt pro Request).
350
341
  */
351
342
  export async function enforceRollingCapAndMaybeNotify(
352
- ctx: HandlerContext,
343
+ ctx: CapCounterContext,
353
344
  options: {
354
345
  readonly capName: string;
355
346
  readonly windowDays: number;
@@ -366,16 +357,11 @@ export async function enforceRollingCapAndMaybeNotify(
366
357
  });
367
358
 
368
359
  if (result.state === "soft-hit") {
369
- if (!ctx.user?.tenantId) {
370
- throw new Error(
371
- "cap-counter.enforceRollingCapAndMaybeNotify: ctx.user.tenantId missing — required for notification",
372
- );
373
- }
374
360
  await options.notify({
375
361
  capName: options.capName,
376
362
  value: result.value,
377
363
  limit: options.limit,
378
- tenantId: ctx.user.tenantId,
364
+ tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
379
365
  });
380
366
  }
381
367
 
@@ -119,7 +119,13 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
119
119
  id: CAP_COUNTER_LIST_SCREEN_ID,
120
120
  type: "entityList",
121
121
  entity: "cap-counter",
122
- columns: ["tenantId", "capName", "value", "periodStart", "lastSoftWarnedAt"],
122
+ columns: [
123
+ "tenantId",
124
+ "capName",
125
+ "value",
126
+ "periodStart",
127
+ { field: "lastSoftWarnedAt", hideOnNarrow: true },
128
+ ],
123
129
  defaultSort: { field: "capName", dir: "asc" },
124
130
  // Search resolves against the caller's own tenant stream, so on this
125
131
  // cross-tenant operator list it would only ever hit the SystemAdmin's tenant.
@@ -9,6 +9,11 @@ export {
9
9
  type ReadRollingCapUsageOptions,
10
10
  readRollingCapUsage,
11
11
  } from "./book-cap-usage.js";
12
+ export {
13
+ type CalendarCapUsageReservation,
14
+ type CalendarCapUsageReservationSpec,
15
+ reserveCalendarCapUsage,
16
+ } from "./calendar-cap-usage-reservation.js";
12
17
  export {
13
18
  CAP_COUNTER_FEATURE,
14
19
  CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
@@ -29,15 +29,11 @@ import type {
29
29
  WriteEvent,
30
30
  WriteHandlerDef,
31
31
  } from "@cosmicdrift/kumiko-framework/engine";
32
- import {
33
- releaseExpiredCapReservations,
34
- reserveCalendarCap,
35
- reserveRollingCap,
36
- } from "./cap-reservation.js";
32
+ import { reserveEnforcedCalendarCap } from "./calendar-cap-usage-reservation.js";
33
+ import { releaseExpiredCapReservations, reserveRollingCap } from "./cap-reservation.js";
37
34
  import {
38
35
  assertBelowHardCap,
39
36
  type CapToleranceProfileName,
40
- enforceCapAndMaybeNotify,
41
37
  enforceRollingCapAndMaybeNotify,
42
38
  type SoftHitNotifier,
43
39
  } from "./enforce-cap.js";
@@ -93,25 +89,7 @@ export function withCapEnforcement(
93
89
  handler.reserveBeforeTransaction,
94
90
  async (event, ctx) => {
95
91
  const cap = await capResolver(event, ctx);
96
-
97
- await releaseExpiredCapReservations(ctx, cap.capName);
98
- await enforceCapAndMaybeNotify(ctx, {
99
- capName: cap.capName,
100
- periodStartIso: cap.periodStartIso,
101
- limit: cap.limit,
102
- profile: cap.profile,
103
- notify: cap.notify,
104
- markSoftWarnedOutsideTransaction: true,
105
- ...(cap.amount !== undefined && { amount: cap.amount }),
106
- });
107
-
108
- const amount = cap.amount ?? 1;
109
- return reserveCalendarCap(ctx, {
110
- capName: cap.capName,
111
- amount,
112
- periodStartIso: cap.periodStartIso,
113
- guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
114
- });
92
+ return reserveEnforcedCalendarCap(ctx, { ...cap, amount: cap.amount ?? 1 });
115
93
  },
116
94
  ),
117
95
  };
@@ -31,10 +31,12 @@ export function createTenantCapListScreen(
31
31
  { field: "name", label: "cap-overview.list.col.name" },
32
32
  { field: "tier", label: "cap-overview.list.col.tier" },
33
33
  { field: "billing", label: "cap-overview.list.col.billing" },
34
- ...listedCaps.map((cap) => ({
34
+ // Name is the title; tier, billing and the first cap fill the card's three meta rows.
35
+ ...listedCaps.map((cap, index) => ({
35
36
  field: capFieldName(cap.id),
36
37
  label: cap.label,
37
38
  renderer: { react: { __component: CAP_USAGE_CELL_COMPONENT } },
39
+ ...(index > 0 && { hideOnNarrow: true }),
38
40
  })),
39
41
  ],
40
42
  searchable: true,
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.357.0",
4
+ "type": "improvement",
5
+ "title": "forget-subject shreds note bodies attached to the subject and notes that mention it",
6
+ "detail": "`forget-subject` on a user or record subject now also erases the per-note keys of every `note-entry` that hangs on the subject as host entity (entityId, plus entityType for record subjects) or @-mentions it, unless the host entity's retention strategy is anonymize/blockDelete. The automated Art. 17 delete hook reaches notes on the user as host too, and the export lists them. Before, `forget-subject(contact)` left the note bodies readable. Consumer tests that expect readable note bodies after `forget-subject` on a contact (e.g. solon contact-notes-forget) now see the erased sentinel. Forgetting only the author still leaves the bodies readable."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "fix",
@@ -40,6 +40,7 @@ import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/se
40
40
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
41
41
  import * as z from "zod";
42
42
  import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
43
+ import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.js";
43
44
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
44
45
  import { USER_STATUS } from "../../user/index.js";
45
46
  import {
@@ -300,6 +301,7 @@ async function sweepDerivedSubjectData(
300
301
  subject: SubjectId,
301
302
  subjectKey: string,
302
303
  crossTenantRunner: DbRunner,
304
+ audit: { readonly requestId: string; readonly erasedBy: string },
303
305
  ): Promise<string | undefined> {
304
306
  try {
305
307
  await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
@@ -318,6 +320,16 @@ async function sweepDerivedSubjectData(
318
320
  subject,
319
321
  );
320
322
  }
323
+
324
+ // Note bodies are keyed per note, so the subject's own key never reaches them.
325
+ if (ctx.registry.features.has("notes-history")) {
326
+ await eraseNoteBodiesReachingSubject({
327
+ db: crossTenantSubjectRunner,
328
+ subject,
329
+ erasedBy: audit.erasedBy,
330
+ requestId: audit.requestId,
331
+ });
332
+ }
321
333
  });
322
334
  return undefined;
323
335
  } catch (err) {
@@ -471,8 +483,9 @@ export const forgetSubjectWrite = defineWriteHandler({
471
483
  // but no event exists — a retry is a no-op erase plus the event. The
472
484
  // reverse order could leave an audit trail claiming a shred that never
473
485
  // happened.
486
+ const requestId = requestContext.get()?.requestId ?? "crypto-shredding:forget-subject";
474
487
  await kms.eraseKey(subject, {
475
- requestId: requestContext.get()?.requestId ?? "crypto-shredding:forget-subject",
488
+ requestId,
476
489
  userId: event.user.id,
477
490
  eraseReason: event.payload.reason,
478
491
  });
@@ -482,6 +495,7 @@ export const forgetSubjectWrite = defineWriteHandler({
482
495
  subject,
483
496
  subjectKey,
484
497
  ctx.db.unsafeRaw(),
498
+ { requestId, erasedBy: event.user.id },
485
499
  );
486
500
 
487
501
  // User subject: close the login door. DEK-erase makes the passwordHash
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.357.0",
4
+ "type": "improvement",
5
+ "title": "Delivery idempotencyKey is released when nothing was delivered",
6
+ "detail": "A retry of a failed notify() with the same idempotencyKey is no longer skipped as a duplicate. The key is deleted when no recipient got a `sent` or `queued` delivery, also when notify() throws; one successful recipient keeps it, even if notify() throws afterwards (a retry would re-send to them). For queued channels the send/render job deletes the key after its final failed attempt, not per retry. That reference is only attached when a `route` call has exactly one channel address; `to` calls (user lists) and multi-channel routes keep the key after queueing. The job payload gains an optional `idempotencyKey`, backward compatible. `createDeliveryTestContext` accepts `idempotencyRedis`."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "breaking",
@@ -57,7 +57,7 @@ export type ChatWebhookRequest = ChatWebhookTarget & {
57
57
  function policyFor(request: ChatWebhookRequest, target: URL): EgressPolicy {
58
58
  return request.requireHttps
59
59
  ? { kind: "external" }
60
- : { kind: "internal", allowHosts: [target.hostname] };
60
+ : { kind: "internal", allowOrigins: [target.origin] };
61
61
  }
62
62
 
63
63
  function hostMatches(hostname: string, allowedHost: string): boolean {
@@ -26,6 +26,11 @@ export {
26
26
  type DeliveryStatusValue,
27
27
  } from "./public-names.js";
28
28
 
29
+ // Single source for claim (delivery-service) and terminal-failure release (jobs).
30
+ export function deliveryIdempotencyRedisKey(tenantId: string, key: string): string {
31
+ return `delivery:idem:${tenantId}:${key}`;
32
+ }
33
+
29
34
  // notify() priority → BullMQ job priority. Lower number = processed first; all
30
35
  // > 0 so prioritised delivery jobs never mix with BullMQ's "0 = unprioritised
31
36
  // FIFO" bucket. critical jobs jump ahead of normal/low in the worker queue.
@@ -6,6 +6,7 @@ import type {
6
6
  EscapeHatchAuditSink,
7
7
  NotifyDelivery,
8
8
  NotifyJobDispatcher,
9
+ NotifyOptions,
9
10
  NotifyPriority,
10
11
  Registry,
11
12
  TenantId,
@@ -21,7 +22,12 @@ import { redactErrorText } from "../shared/index.js";
21
22
  import { hashUnsubscribeAddress } from "./address-opt-out.js";
22
23
  import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
23
24
  import { buildChannelContext } from "./channel-context.js";
24
- import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants.js";
25
+ import {
26
+ DELIVERY_CHANNEL_EXTENSION,
27
+ DeliveryJobs,
28
+ deliveryIdempotencyRedisKey,
29
+ deliveryPriorityRank,
30
+ } from "./constants.js";
25
31
  import {
26
32
  type AddressOptOutKey,
27
33
  addressOptOutKey,
@@ -171,11 +177,43 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
171
177
  "Delivery idempotencyKey requires options.idempotencyRedis (or rateLimit.redis) to be configured",
172
178
  );
173
179
  }
174
- const k = `delivery:idem:${tenantId}:${key}`;
175
- const res = await idemRedis.set(k, "1", "EX", ttlSec, "NX");
180
+ const res = await idemRedis.set(
181
+ deliveryIdempotencyRedisKey(tenantId, key),
182
+ "1",
183
+ "EX",
184
+ ttlSec,
185
+ "NX",
186
+ );
176
187
  return res === "OK";
177
188
  }
178
189
 
190
+ function isDeliveredOrQueued(delivery: NotifyDelivery): boolean {
191
+ return delivery.status === "sent" || delivery.status === "queued";
192
+ }
193
+
194
+ // A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
195
+ // Partial success keeps it: a retry would re-send to those who got it.
196
+ async function releaseIdempotencyIfNothingDelivered(
197
+ tenantId: TenantId,
198
+ key: string | undefined,
199
+ run: NotifyRun,
200
+ ): Promise<void> {
201
+ // skip: no key claimed, or a recipient already got the notification
202
+ if (!key || run.deliveries.some(isDeliveredOrQueued)) return;
203
+ await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
204
+ }
205
+
206
+ // Only a single-channel route lets the queued job free the key on terminal failure
207
+ // without risking a duplicate send on another channel.
208
+ function jobIdempotencyKeyFor(
209
+ idempotencyKey: string | undefined,
210
+ route: NotifyOptions["route"],
211
+ ): string | undefined {
212
+ if (!idempotencyKey || !route) return undefined;
213
+ const routedChannelCount = channels.filter((channel) => route[channel.name]).length;
214
+ return routedChannelCount === 1 ? idempotencyKey : undefined;
215
+ }
216
+
179
217
  async function resolveUserIdsForTenant(tenantId: TenantId): Promise<readonly string[]> {
180
218
  if (!tenantUserIdsQuery) {
181
219
  throw new Error("Tenant broadcast requires tenantUserIdsQuery in DeliveryServiceOptions");
@@ -233,6 +271,9 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
233
271
  readonly deliveries: NotifyDelivery[];
234
272
  readonly jobDispatcher: NotifyJobDispatcher | undefined;
235
273
  readonly locale: string | undefined;
274
+ // Set only when exactly one channel carries the call: the queued job may then free
275
+ // the key on terminal failure without risking a duplicate send on another channel.
276
+ readonly jobIdempotencyKey: string | undefined;
236
277
  };
237
278
 
238
279
  function recordDelivery(
@@ -343,6 +384,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
343
384
  deliveryAttemptId,
344
385
  priority,
345
386
  message,
387
+ ...(run.jobIdempotencyKey !== undefined && { idempotencyKey: run.jobIdempotencyKey }),
346
388
  },
347
389
  { priority: deliveryPriorityRank[priority] },
348
390
  );
@@ -745,14 +787,74 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
745
787
  }
746
788
  }
747
789
 
790
+ async function dispatchNotification(
791
+ notificationType: string,
792
+ options: NotifyOptions,
793
+ tenantId: TenantId,
794
+ priority: NotifyPriority,
795
+ run: NotifyRun,
796
+ ): Promise<void> {
797
+ const { to, route, data } = options;
798
+ if (route) {
799
+ await deliverDirect(
800
+ route,
801
+ notificationType,
802
+ data,
803
+ tenantId,
804
+ priority,
805
+ options.recipientId ?? null,
806
+ run,
807
+ );
808
+ // skip: explicit route is delivered directly, no recipient resolution needed
809
+ return;
810
+ }
811
+
812
+ if (to !== undefined) {
813
+ let userIds: readonly string[];
814
+
815
+ if (typeof to === "string") {
816
+ userIds = [to];
817
+ } else if ("tenant" in to) {
818
+ userIds = await resolveUserIdsForTenant(to.tenant);
819
+ } else {
820
+ userIds = to;
821
+ }
822
+
823
+ const prefetchedAddresses =
824
+ priority === "critical"
825
+ ? new Map<string, string | null>()
826
+ : await prefetchUserAddresses(userIds, tenantId, notificationType);
827
+ const suppressed = await loadSuppressedAddresses(
828
+ tenantId,
829
+ notificationType,
830
+ priority,
831
+ [...prefetchedAddresses.values()].filter((address): address is string => !!address),
832
+ );
833
+
834
+ for (const userId of userIds) {
835
+ await deliverToUser(
836
+ userId,
837
+ notificationType,
838
+ data,
839
+ tenantId,
840
+ priority,
841
+ run,
842
+ prefetchedAddresses,
843
+ suppressed,
844
+ );
845
+ }
846
+ }
847
+ }
848
+
748
849
  return {
749
850
  async notify(notificationType, options, _user, tenantId, jobDispatcher) {
750
- const { to, route, data, idempotencyKey } = options;
851
+ const { route, idempotencyKey } = options;
751
852
  const priority: NotifyPriority = options.priority ?? "normal";
752
853
  const run: NotifyRun = {
753
854
  deliveries: [],
754
855
  jobDispatcher: options.immediate ? undefined : (jobDispatcher ?? jobRunner),
755
856
  locale: options.locale,
857
+ jobIdempotencyKey: jobIdempotencyKeyFor(idempotencyKey, route),
756
858
  };
757
859
 
758
860
  if (idempotencyKey) {
@@ -772,54 +874,20 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
772
874
  }
773
875
  }
774
876
 
775
- if (route) {
776
- await deliverDirect(
777
- route,
778
- notificationType,
779
- data,
780
- tenantId,
781
- priority,
782
- options.recipientId ?? null,
783
- run,
784
- );
785
- return { deliveries: run.deliveries };
786
- }
787
-
788
- if (to !== undefined) {
789
- let userIds: readonly string[];
790
-
791
- if (typeof to === "string") {
792
- userIds = [to];
793
- } else if ("tenant" in to) {
794
- userIds = await resolveUserIdsForTenant(to.tenant);
795
- } else {
796
- userIds = to;
797
- }
798
-
799
- const prefetchedAddresses =
800
- priority === "critical"
801
- ? new Map<string, string | null>()
802
- : await prefetchUserAddresses(userIds, tenantId, notificationType);
803
- const suppressed = await loadSuppressedAddresses(
804
- tenantId,
805
- notificationType,
806
- priority,
807
- [...prefetchedAddresses.values()].filter((address): address is string => !!address),
877
+ try {
878
+ await dispatchNotification(notificationType, options, tenantId, priority, run);
879
+ } catch (err) {
880
+ // A failing release must not replace the dispatch error the caller retries on.
881
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch(
882
+ (releaseErr: unknown) => {
883
+ logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
884
+ notificationType,
885
+ });
886
+ },
808
887
  );
809
-
810
- for (const userId of userIds) {
811
- await deliverToUser(
812
- userId,
813
- notificationType,
814
- data,
815
- tenantId,
816
- priority,
817
- run,
818
- prefetchedAddresses,
819
- suppressed,
820
- );
821
- }
888
+ throw err;
822
889
  }
890
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
823
891
  return { deliveries: run.deliveries };
824
892
  },
825
893
  };
@@ -247,15 +247,21 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
247
247
  label: i18nKey("delivery.log.col.channel"),
248
248
  renderer: { react: { __component: DELIVERY_CHANNEL_CELL_COMPONENT } },
249
249
  },
250
- { field: "recipientLabel", label: i18nKey("delivery.log.col.recipient") },
250
+ {
251
+ field: "recipientLabel",
252
+ label: i18nKey("delivery.log.col.recipient"),
253
+ hideOnNarrow: true,
254
+ },
251
255
  {
252
256
  field: "status",
253
257
  label: i18nKey("delivery.log.col.status"),
258
+ hideOnNarrow: true,
254
259
  renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
255
260
  },
256
261
  {
257
262
  field: "error",
258
263
  label: i18nKey("delivery.log.col.error"),
264
+ hideOnNarrow: true,
259
265
  renderer: { react: { __component: DELIVERY_ERROR_CELL_COMPONENT } },
260
266
  },
261
267
  ],
@@ -18,7 +18,7 @@ import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
18
18
  import * as z from "zod";
19
19
  import { appendAttemptEvent } from "./attempt-log.js";
20
20
  import { buildChannelContext } from "./channel-context.js";
21
- import { DeliveryJobs, deliveryPriorityRank } from "./constants.js";
21
+ import { DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank } from "./constants.js";
22
22
  import { collectChannels, redactedMessageOf } from "./delivery-service.js";
23
23
  import type {
24
24
  ChannelMessage,
@@ -44,6 +44,7 @@ const renderJobPayloadSchema = z.object({
44
44
  deliveryAttemptId: z.string(),
45
45
  priority: z.enum(["critical", "normal", "low"]),
46
46
  message: channelMessageSchema,
47
+ idempotencyKey: z.string().optional(),
47
48
  });
48
49
 
49
50
  const sendJobPayloadSchema = renderJobPayloadSchema.extend({
@@ -110,6 +111,30 @@ function entryFor(
110
111
  };
111
112
  }
112
113
 
114
+ // Only the final retry frees the key: earlier failures are retried by the runner and
115
+ // must still dedup. Assumes ctx.redis is the handle the service claimed on; runProdApp
116
+ // wires the same instance into both.
117
+ async function releaseIdempotencyOnTerminalFailure(
118
+ ctx: JobContext,
119
+ p: RenderJobPayload,
120
+ ): Promise<void> {
121
+ // skip: nothing to release before the last attempt or without an idempotency key
122
+ if (!ctx.finalAttempt || !p.idempotencyKey) return;
123
+ if (!ctx.redis) {
124
+ ctx.log.warn("delivery: idempotency key not released, job context has no redis", {
125
+ notificationType: p.notificationType,
126
+ });
127
+ return;
128
+ }
129
+ try {
130
+ await ctx.redis.del(deliveryIdempotencyRedisKey(p.tenantId, p.idempotencyKey));
131
+ } catch (err) {
132
+ ctx.log.warn(`delivery: idempotency key release failed: ${redactedMessageOf(err)}`, {
133
+ notificationType: p.notificationType,
134
+ });
135
+ }
136
+ }
137
+
113
138
  // The job runner stores err.message in the run row, and BullMQ keeps failedReason and
114
139
  // the stack trace in Redis in plain text, so the rethrown error carries only the code.
115
140
  // The redacted message goes to the log.
@@ -132,6 +157,7 @@ async function failAttempt(
132
157
  p.deliveryAttemptId,
133
158
  entryFor(p, "failed", code, p.address),
134
159
  );
160
+ await releaseIdempotencyOnTerminalFailure(ctx, p);
135
161
  throw new Error(code);
136
162
  }
137
163
 
@@ -18,6 +18,7 @@ export type CreateDeliveryTestContextOptions = {
18
18
  readonly log?: Logger;
19
19
  readonly jobRunner?: JobRunner;
20
20
  readonly secrets?: SecretsContext;
21
+ readonly idempotencyRedis?: Redis;
21
22
  };
22
23
 
23
24
  /**