@cosmicdrift/kumiko-samples 0.355.0 → 0.357.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 (53) 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/feature.ts +7 -1
  7. package/packages/bundled-features/src/cap-overview/screens.ts +3 -1
  8. package/packages/bundled-features/src/compliance-profiles/handlers/set-profile.write.ts +18 -24
  9. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  10. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +16 -1
  11. package/packages/bundled-features/src/custom-fields/handlers/define-system-field.write.ts +10 -17
  12. package/packages/bundled-features/src/custom-fields/handlers/delete-system-field.write.ts +5 -2
  13. package/packages/bundled-features/src/custom-fields/lib/define-or-resurrect.ts +12 -3
  14. package/packages/bundled-features/src/delivery/changes.json +6 -0
  15. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +1 -1
  16. package/packages/bundled-features/src/delivery/constants.ts +5 -0
  17. package/packages/bundled-features/src/delivery/delivery-service.ts +118 -50
  18. package/packages/bundled-features/src/delivery/feature.ts +7 -1
  19. package/packages/bundled-features/src/delivery/jobs.ts +27 -1
  20. package/packages/bundled-features/src/delivery/testing.ts +1 -0
  21. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +8 -25
  22. package/packages/bundled-features/src/notes-history/entity.ts +7 -6
  23. package/packages/bundled-features/src/notes-history-user-data/hooks.ts +109 -62
  24. package/packages/bundled-features/src/notes-history-user-data/index.ts +3 -1
  25. package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
  26. package/packages/bundled-features/src/sessions/screens.ts +2 -0
  27. package/packages/bundled-features/src/shared/index.ts +1 -0
  28. package/packages/bundled-features/src/shared/require-for-tenant.ts +18 -0
  29. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +6 -4
  30. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +7 -7
  31. package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +7 -6
  32. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +7 -4
  33. package/packages/bundled-features/src/tenant/cancel-pending-invitation.ts +3 -1
  34. package/packages/bundled-features/src/tenant/changes.json +6 -0
  35. package/packages/bundled-features/src/tenant/constants.ts +6 -3
  36. package/packages/bundled-features/src/tenant/feature.ts +2 -0
  37. package/packages/bundled-features/src/tenant/handlers/member-role-availability.query.ts +25 -0
  38. package/packages/bundled-features/src/tenant/handlers/remove-member.write.ts +22 -8
  39. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +19 -11
  40. package/packages/bundled-features/src/tenant/member-role-options.ts +6 -0
  41. package/packages/bundled-features/src/tenant/screens.ts +9 -6
  42. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +9 -23
  43. package/packages/bundled-features/src/user/screens.ts +8 -1
  44. package/packages/bundled-features/src/user-data-rights/screens.ts +8 -1
  45. package/packages/bundled-features/src/waitlist/screens.ts +8 -1
  46. package/samples/apps/marketing-demo/src/features/assets/schema/asset.ts +4 -4
  47. package/samples/apps/marketing-demo/src/features/helpdesk/schema/ticket.ts +4 -4
  48. package/samples/apps/showcase/src/features/items/schema/item.ts +1 -1
  49. package/samples/apps/styleguide/src/features/demo/feature.ts +3 -3
  50. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
  51. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +6 -2
  52. package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +9 -2
  53. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.355.0",
3
+ "version": "0.357.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.355.0",
3
+ "version": "0.357.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.357.0",
4
+ "type": "improvement",
5
+ "title": "MFA enable asks to confirm saved recovery codes, code field autocompletes one-time codes",
6
+ "detail": "secretMint `reveal.acknowledge` (i18n key) renders a checkbox on the reveal card that must be ticked before the confirm step or the acknowledge button can be used; the tick never reaches the payload. Text fields accept an `autoComplete` token that reaches the input. The MFA enable screen uses both: a \"saved my recovery codes\" gate and `one-time-code` on the code field"
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "breaking",
@@ -28,6 +28,7 @@ export const AUTH_MFA_FEATURE_I18N: Readonly<Record<string, LocalizedString>> =
28
28
  "mfa.enable.reveal.qr": { en: "Scan this QR code" },
29
29
  "mfa.enable.reveal.secret": { en: "Or enter this code manually" },
30
30
  "mfa.enable.reveal.recoveryCodes": { en: "Recovery codes" },
31
+ "mfa.enable.reveal.savedCodes": { en: "I have saved my recovery codes." },
31
32
 
32
33
  "mfa.enable.confirm.submit": { en: "Enable" },
33
34
  "mfa.enable.confirm.done": { en: "Two-factor authentication is now enabled." },
@@ -34,6 +34,7 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
34
34
  reveal: {
35
35
  title: i18nKey("mfa.enable.reveal.title"),
36
36
  warning: i18nKey("mfa.enable.reveal.warning"),
37
+ acknowledge: i18nKey("mfa.enable.reveal.savedCodes"),
37
38
  fields: [
38
39
  {
39
40
  field: "otpauthUri",
@@ -58,7 +59,13 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
58
59
  confirm: {
59
60
  handler: AuthMfaHandlers.enableConfirm,
60
61
  fields: {
61
- code: { type: "text", required: true, minLength: 6, maxLength: 6 },
62
+ code: {
63
+ type: "text",
64
+ required: true,
65
+ minLength: 6,
66
+ maxLength: 6,
67
+ autoComplete: "one-time-code",
68
+ },
62
69
  },
63
70
  layout: { sections: [{ fields: ["code"] }] },
64
71
  carry: ["setupToken"],
@@ -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.
@@ -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,
@@ -11,6 +11,7 @@ import {
11
11
  writeFailure,
12
12
  } from "@cosmicdrift/kumiko-framework/errors";
13
13
  import * as z from "zod";
14
+ import { requireForTenant } from "../../shared/index.js";
14
15
  import {
15
16
  tenantComplianceProfileEntity,
16
17
  tenantComplianceProfileTable,
@@ -26,24 +27,16 @@ const crud = createEventStoreExecutor(tenantComplianceProfileTable, tenantCompli
26
27
  // SELECTABLE_PROFILE_KEYS aus der framework/compliance-Liste.
27
28
  const profileKeySchema = z.enum(SELECTABLE_PROFILE_KEYS);
28
29
 
29
- // Tenant-Admin setzt Profile-Key + optional Override-JSON.
30
+ // Upsert: the first call inserts, later calls update, so repeating the same values
31
+ // is idempotent apart from the extra audit events.
30
32
  //
31
- // Upsert-Verhalten: erste Wahl insert, weitere update. Idempotent —
32
- // wer mit gleichen Werten zweimal aufruft, kriegt das gleiche Ergebnis
33
- // (modulo aktualisierte Audit-Events im Event-Store).
33
+ // A SystemAdmin may target another tenant via `tenantIdOverride` (operator setup,
34
+ // onboarding migrations); the dispatcher rejects the override for anyone else. The
35
+ // executor then writes into the target tenant's stream via streamTenantId while
36
+ // the operator stays the recorded actor.
34
37
  //
35
- // Cross-Tenant-Pfad: SystemAdmin kann via `tenantIdOverride` fuer einen
36
- // anderen Tenant schreiben (Plattform-Operator-Setup, Customer-
37
- // Onboarding-Migrationen). TenantAdmin's Override-Versuch → 403.
38
- // executorUser.tenantId muss = ziel-tenant sein damit der event-store-
39
- // Stream-Lookup nicht miss → version_conflict gibt (Memory:
40
- // feedback_event_store_tenant_consistency).
41
- //
42
- // Validation:
43
- // - profileKey muss in SELECTABLE_PROFILE_KEYS sein (Zod-checked)
44
- // - override (optional) muss valides JSON-Object sein
45
- // - override Top-Level-Keys muessen in ALLOWED_OVERRIDE_KEYS sein
46
- // — verhindert Tippfehler die deepMerge stillschweigend ignoriert
38
+ // Override top-level keys are restricted to ALLOWED_OVERRIDE_KEYS because deepMerge
39
+ // would silently ignore a typo.
47
40
  export const setProfileWrite = defineWriteHandler({
48
41
  name: "set-profile",
49
42
  schema: z.object({
@@ -57,9 +50,8 @@ export const setProfileWrite = defineWriteHandler({
57
50
  description:
58
51
  "Sets or replaces a tenant's compliance profile key plus an optional JSON override, for onboarding or a later region change; a system admin may target a different tenant through tenantIdOverride.",
59
52
  handler: async (event, ctx) => {
60
- const tenantOverride = event.payload.tenantIdOverride;
61
- const tenantId = (tenantOverride ?? event.user.tenantId) as TenantId; // @cast-boundary engine-payload
62
- const executorUser = tenantOverride !== undefined ? { ...event.user, tenantId } : event.user;
53
+ const tenantId = (event.payload.tenantIdOverride ?? event.user.tenantId) as TenantId; // @cast-boundary engine-payload
54
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
63
55
 
64
56
  // Override-Validation: muss parseables JSON-Object sein UND dem
65
57
  // ComplianceProfileOverride-Schema entsprechen (S1.9 Z3 — strict-Zod
@@ -89,7 +81,7 @@ export const setProfileWrite = defineWriteHandler({
89
81
  }
90
82
 
91
83
  // Upsert: existierenden Eintrag suchen
92
- const existing = (await fetchOne(ctx.db, tenantComplianceProfileTable, {
84
+ const existing = (await fetchOne(db, tenantComplianceProfileTable, {
93
85
  tenantId: tenantId,
94
86
  })) as { id: string; version: number } | null; // @cast-boundary db-runner
95
87
 
@@ -103,8 +95,9 @@ export const setProfileWrite = defineWriteHandler({
103
95
  override: event.payload.override ?? null,
104
96
  },
105
97
  },
106
- executorUser,
107
- ctx.db,
98
+ event.user,
99
+ db,
100
+ { streamTenantId },
108
101
  );
109
102
  if (!result.isSuccess) return result;
110
103
  return {
@@ -119,8 +112,9 @@ export const setProfileWrite = defineWriteHandler({
119
112
  override: event.payload.override ?? null,
120
113
  tenantId,
121
114
  },
122
- executorUser,
123
- ctx.db,
115
+ event.user,
116
+ db,
117
+ { streamTenantId },
124
118
  );
125
119
  if (!result.isSuccess) return result;
126
120
  return {
@@ -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,17 @@ 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
+ registry: ctx.registry,
329
+ subject,
330
+ erasedBy: audit.erasedBy,
331
+ requestId: audit.requestId,
332
+ });
333
+ }
321
334
  });
322
335
  return undefined;
323
336
  } catch (err) {
@@ -471,8 +484,9 @@ export const forgetSubjectWrite = defineWriteHandler({
471
484
  // but no event exists — a retry is a no-op erase plus the event. The
472
485
  // reverse order could leave an audit trail claiming a shred that never
473
486
  // happened.
487
+ const requestId = requestContext.get()?.requestId ?? "crypto-shredding:forget-subject";
474
488
  await kms.eraseKey(subject, {
475
- requestId: requestContext.get()?.requestId ?? "crypto-shredding:forget-subject",
489
+ requestId,
476
490
  userId: event.user.id,
477
491
  eraseReason: event.payload.reason,
478
492
  });
@@ -482,6 +496,7 @@ export const forgetSubjectWrite = defineWriteHandler({
482
496
  subject,
483
497
  subjectKey,
484
498
  ctx.db.unsafeRaw(),
499
+ { requestId, erasedBy: event.user.id },
485
500
  );
486
501
 
487
502
  // User subject: close the login door. DEK-erase makes the passwordHash
@@ -1,20 +1,15 @@
1
1
  import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { requireForTenant } from "../../shared/index.js";
2
3
  import { fieldDefinitionAggregateId } from "../aggregate-id.js";
3
4
  import { defineOrResurrectFieldDefinition } from "../lib/define-or-resurrect.js";
4
5
  import { buildFieldDefinitionColumns } from "../lib/field-definition-row.js";
5
6
  import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas.js";
6
7
 
7
- // define-system-field — SystemAdmin definiert eine system-weite Custom-Field-
8
- // Definition die für ALLE Tenants gilt. tenantId wird auf SYSTEM_TENANT_ID
9
- // gesetzt (NICHT vom Caller — SystemAdmin's event.user.tenantId würde sonst
10
- // auf den admin's eigenen platform-tenant zeigen).
11
- //
12
- // **Use-Case:** Vendor (cdgs) sagt "alle Hausverwaltungs-customers haben ab
13
- // heute ein `internalNumber`-Field". Tenant kann den Wert pro customer
14
- // setzen, aber die Definition nicht ändern oder löschen.
15
- //
16
- // **Same-scope-conflict** wie bei define-tenant-field via aggregate-version-
17
- // conflict (deterministische ID mit tenantId=SYSTEM_TENANT_ID).
8
+ // A system-wide field definition applies to every tenant. tenantId is forced to
9
+ // SYSTEM_TENANT_ID (never taken from the caller) and addressed via streamTenantId,
10
+ // so the SystemAdmin stays the recorded actor. Tenants can set values for the field
11
+ // but cannot change or delete the definition. A same-scope duplicate surfaces as an
12
+ // aggregate version conflict, because the ID is derived from SYSTEM_TENANT_ID.
18
13
  export const defineSystemFieldHandler: WriteHandlerDef = {
19
14
  name: "define-system-field",
20
15
  schema: defineFieldPayloadSchema,
@@ -30,16 +25,14 @@ export const defineSystemFieldHandler: WriteHandlerDef = {
30
25
  payload.fieldKey,
31
26
  );
32
27
 
33
- // Override event.user.tenantId to SYSTEM_TENANT_ID for the system-scope
34
- // write. The framework's CrudExecutor writes the row with this tenantId
35
- // — the row lives in the system-scope-stream.
36
- const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
28
+ const { db, streamTenantId } = requireForTenant(ctx, SYSTEM_TENANT_ID);
37
29
 
38
30
  return defineOrResurrectFieldDefinition(
39
31
  aggregateId,
40
32
  buildFieldDefinitionColumns(payload),
41
- systemUser,
42
- ctx.db,
33
+ event.user,
34
+ db,
35
+ streamTenantId,
43
36
  );
44
37
  },
45
38
  };
@@ -1,4 +1,5 @@
1
1
  import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { requireForTenant } from "../../shared/index.js";
2
3
  import { fieldDefinitionAggregateId } from "../aggregate-id.js";
3
4
  import { FIELD_DEFINITION_AGGREGATE_TYPE } from "../constants.js";
4
5
  import { fieldDefinitionExecutor } from "../executor.js";
@@ -26,8 +27,10 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
26
27
  payload.fieldKey,
27
28
  );
28
29
 
29
- const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
30
- const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, systemUser, ctx.db);
30
+ const { db, streamTenantId } = requireForTenant(ctx, SYSTEM_TENANT_ID);
31
+ const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, event.user, db, {
32
+ streamTenantId,
33
+ });
31
34
 
32
35
  // Cascade-cleanup-Event — host-entity-MSPs entfernen orphan values aus
33
36
  // ihrer customFields jsonb. Im selben TX = atomic.
@@ -1,3 +1,4 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
1
2
  import { fieldDefinitionExecutor } from "../executor.js";
2
3
  import type { FieldDefinitionColumns } from "./field-definition-row.js";
3
4
 
@@ -27,13 +28,18 @@ export async function defineOrResurrectFieldDefinition(
27
28
  columns: FieldDefinitionColumns,
28
29
  user: DefineUser,
29
30
  db: DefineDb,
31
+ streamTenantId?: TenantId,
30
32
  ): Promise<WriteResult> {
31
33
  const active = await fieldDefinitionExecutor.detail({ id: aggregateId }, user, db);
32
34
  if (active) {
33
- return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db);
35
+ return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db, {
36
+ streamTenantId,
37
+ });
34
38
  }
35
39
 
36
- const restored = await fieldDefinitionExecutor.restore({ id: aggregateId }, user, db);
40
+ const restored = await fieldDefinitionExecutor.restore({ id: aggregateId }, user, db, {
41
+ streamTenantId,
42
+ });
37
43
  if (restored.isSuccess) {
38
44
  // restore() just un-deleted the row in this same tx; no concurrent writer
39
45
  // exists, so skip the optimistic-lock version match (we'd otherwise have to
@@ -43,10 +49,13 @@ export async function defineOrResurrectFieldDefinition(
43
49
  // readonly FieldDefinitionColumns isn't assignable to it.
44
50
  return fieldDefinitionExecutor.update({ id: aggregateId, changes: { ...columns } }, user, db, {
45
51
  skipOptimisticLock: true,
52
+ streamTenantId,
46
53
  });
47
54
  }
48
55
  if (restored.error.code === "not_found") {
49
- return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db);
56
+ return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db, {
57
+ streamTenantId,
58
+ });
50
59
  }
51
60
  return restored;
52
61
  }
@@ -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
  ],