@cosmicdrift/kumiko-bundled-features 0.356.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 (69) hide show
  1. package/dist/auth-mfa/i18n.js +1 -0
  2. package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
  3. package/dist/auth-mfa/screens.js +8 -1
  4. package/dist/billing-foundation/entities.d.ts +8 -0
  5. package/dist/billing-foundation/projection.d.ts +8 -0
  6. package/dist/cap-counter/entity.d.ts +1 -0
  7. package/dist/cap-counter/feature.js +7 -1
  8. package/dist/cap-overview/screens.js +3 -1
  9. package/dist/config/table.d.ts +6 -0
  10. package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -3
  11. package/dist/custom-fields/entity.d.ts +4 -0
  12. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  13. package/dist/delivery/chat-webhook-sender.js +1 -1
  14. package/dist/delivery/constants.d.ts +1 -0
  15. package/dist/delivery/constants.js +4 -0
  16. package/dist/delivery/delivery-service.js +62 -25
  17. package/dist/delivery/feature.js +7 -1
  18. package/dist/delivery/jobs.js +25 -1
  19. package/dist/delivery/tables.d.ts +6 -0
  20. package/dist/delivery/testing.d.ts +1 -0
  21. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  22. package/dist/folders/entity.d.ts +8 -0
  23. package/dist/form-draft/entity.d.ts +2 -0
  24. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  25. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  26. package/dist/jobs/job-run-table.d.ts +6 -0
  27. package/dist/ledger/entity.d.ts +13 -0
  28. package/dist/managed-pages/table.d.ts +12 -0
  29. package/dist/notes-history/entity.d.ts +12 -0
  30. package/dist/notes-history/entity.js +7 -6
  31. package/dist/notes-history-user-data/hooks.d.ts +10 -1
  32. package/dist/notes-history-user-data/hooks.js +78 -61
  33. package/dist/notes-history-user-data/index.d.ts +1 -0
  34. package/dist/notes-history-user-data/index.js +2 -1
  35. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  36. package/dist/personal-access-tokens/screens.js +2 -0
  37. package/dist/secrets/table.d.ts +1 -0
  38. package/dist/sessions/schema/user-session.d.ts +4 -0
  39. package/dist/sessions/screens.js +2 -0
  40. package/dist/tags/entity.d.ts +9 -0
  41. package/dist/template-resolver/table.d.ts +10 -0
  42. package/dist/template-resolver/user-content-table.d.ts +10 -0
  43. package/dist/tenant/constants.d.ts +1 -0
  44. package/dist/tenant/constants.js +6 -3
  45. package/dist/tenant/feature.js +2 -0
  46. package/dist/tenant/handlers/member-role-availability.query.d.ts +7 -0
  47. package/dist/tenant/handlers/member-role-availability.query.js +18 -0
  48. package/dist/tenant/invitation-table.d.ts +6 -0
  49. package/dist/tenant/member-role-options.d.ts +2 -0
  50. package/dist/tenant/member-role-options.js +4 -0
  51. package/dist/tenant/membership-table.d.ts +4 -0
  52. package/dist/tenant/schema/tenant.d.ts +6 -0
  53. package/dist/tenant/screens.d.ts +4 -2
  54. package/dist/tenant/screens.js +10 -6
  55. package/dist/tier-engine/entity.d.ts +2 -0
  56. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  57. package/dist/user/schema/user.d.ts +12 -0
  58. package/dist/user/screens.js +8 -1
  59. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  60. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  61. package/dist/user-data-rights/schema/export-job.d.ts +6 -0
  62. package/dist/user-data-rights/screens.js +8 -1
  63. package/dist/waitlist/entity.d.ts +14 -0
  64. package/dist/waitlist/screens.js +8 -1
  65. package/package.json +9 -9
  66. package/src/auth-mfa/changes.json +6 -0
  67. package/src/crypto-shredding/changes.json +6 -0
  68. package/src/delivery/changes.json +6 -0
  69. package/src/tenant/changes.json +6 -0
@@ -19,6 +19,7 @@ export const AUTH_MFA_FEATURE_I18N = {
19
19
  "mfa.enable.reveal.qr": { en: "Scan this QR code" },
20
20
  "mfa.enable.reveal.secret": { en: "Or enter this code manually" },
21
21
  "mfa.enable.reveal.recoveryCodes": { en: "Recovery codes" },
22
+ "mfa.enable.reveal.savedCodes": { en: "I have saved my recovery codes." },
22
23
  "mfa.enable.confirm.submit": { en: "Enable" },
23
24
  "mfa.enable.confirm.done": { en: "Two-factor authentication is now enabled." },
24
25
  "screen:auth-mfa-disable.title": { en: "Disable two-factor authentication" },
@@ -4,6 +4,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const userMfaEntity: import("@cosmicdrift/kumiko-types/fields").E
48
50
  readonly description?: string;
49
51
  readonly maxLength?: number;
50
52
  readonly minLength?: number;
53
+ readonly autoComplete?: string;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -102,6 +105,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
102
105
  readonly description?: string;
103
106
  readonly maxLength?: number;
104
107
  readonly minLength?: number;
108
+ readonly autoComplete?: string;
105
109
  readonly required?: boolean;
106
110
  readonly searchable?: boolean;
107
111
  readonly sortable?: boolean;
@@ -124,6 +128,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
124
128
  readonly description?: string;
125
129
  readonly maxLength?: number;
126
130
  readonly minLength?: number;
131
+ readonly autoComplete?: string;
127
132
  readonly required?: boolean;
128
133
  readonly searchable?: boolean;
129
134
  readonly sortable?: boolean;
@@ -146,6 +151,7 @@ export declare const userMfaTable: import("@cosmicdrift/kumiko-framework/db").En
146
151
  readonly description?: string;
147
152
  readonly maxLength?: number;
148
153
  readonly minLength?: number;
154
+ readonly autoComplete?: string;
149
155
  readonly required?: boolean;
150
156
  readonly searchable?: boolean;
151
157
  readonly sortable?: boolean;
@@ -23,6 +23,7 @@ export const mfaEnableScreen = {
23
23
  reveal: {
24
24
  title: i18nKey("mfa.enable.reveal.title"),
25
25
  warning: i18nKey("mfa.enable.reveal.warning"),
26
+ acknowledge: i18nKey("mfa.enable.reveal.savedCodes"),
26
27
  fields: [
27
28
  {
28
29
  field: "otpauthUri",
@@ -47,7 +48,13 @@ export const mfaEnableScreen = {
47
48
  confirm: {
48
49
  handler: AuthMfaHandlers.enableConfirm,
49
50
  fields: {
50
- code: { type: "text", required: true, minLength: 6, maxLength: 6 },
51
+ code: {
52
+ type: "text",
53
+ required: true,
54
+ minLength: 6,
55
+ maxLength: 6,
56
+ autoComplete: "one-time-code",
57
+ },
51
58
  },
52
59
  layout: { sections: [{ fields: ["code"] }] },
53
60
  carry: ["setupToken"],
@@ -4,6 +4,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
48
50
  readonly description?: string;
49
51
  readonly maxLength?: number;
50
52
  readonly minLength?: number;
53
+ readonly autoComplete?: string;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -70,6 +73,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
70
73
  readonly description?: string;
71
74
  readonly maxLength?: number;
72
75
  readonly minLength?: number;
76
+ readonly autoComplete?: string;
73
77
  readonly required?: boolean;
74
78
  readonly searchable?: boolean;
75
79
  readonly sortable?: boolean;
@@ -92,6 +96,7 @@ export declare const subscriptionEntity: import("@cosmicdrift/kumiko-types/field
92
96
  readonly description?: string;
93
97
  readonly maxLength?: number;
94
98
  readonly minLength?: number;
99
+ readonly autoComplete?: string;
95
100
  readonly required?: boolean;
96
101
  readonly searchable?: boolean;
97
102
  readonly sortable?: boolean;
@@ -147,6 +152,7 @@ export declare const paymentEntity: import("@cosmicdrift/kumiko-types/fields").E
147
152
  readonly description?: string;
148
153
  readonly maxLength?: number;
149
154
  readonly minLength?: number;
155
+ readonly autoComplete?: string;
150
156
  readonly required?: boolean;
151
157
  readonly searchable?: boolean;
152
158
  readonly sortable?: boolean;
@@ -169,6 +175,7 @@ export declare const paymentEntity: import("@cosmicdrift/kumiko-types/fields").E
169
175
  readonly description?: string;
170
176
  readonly maxLength?: number;
171
177
  readonly minLength?: number;
178
+ readonly autoComplete?: string;
172
179
  readonly required?: boolean;
173
180
  readonly searchable?: boolean;
174
181
  readonly sortable?: boolean;
@@ -191,6 +198,7 @@ export declare const paymentEntity: import("@cosmicdrift/kumiko-types/fields").E
191
198
  readonly description?: string;
192
199
  readonly maxLength?: number;
193
200
  readonly minLength?: number;
201
+ readonly autoComplete?: string;
194
202
  readonly required?: boolean;
195
203
  readonly searchable?: boolean;
196
204
  readonly sortable?: boolean;
@@ -4,6 +4,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
48
50
  readonly description?: string;
49
51
  readonly maxLength?: number;
50
52
  readonly minLength?: number;
53
+ readonly autoComplete?: string;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -70,6 +73,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
70
73
  readonly description?: string;
71
74
  readonly maxLength?: number;
72
75
  readonly minLength?: number;
76
+ readonly autoComplete?: string;
73
77
  readonly required?: boolean;
74
78
  readonly searchable?: boolean;
75
79
  readonly sortable?: boolean;
@@ -92,6 +96,7 @@ export declare const subscriptionsProjectionTable: import("@cosmicdrift/kumiko-f
92
96
  readonly description?: string;
93
97
  readonly maxLength?: number;
94
98
  readonly minLength?: number;
99
+ readonly autoComplete?: string;
95
100
  readonly required?: boolean;
96
101
  readonly searchable?: boolean;
97
102
  readonly sortable?: boolean;
@@ -146,6 +151,7 @@ export declare const paymentsProjectionTable: import("@cosmicdrift/kumiko-framew
146
151
  readonly description?: string;
147
152
  readonly maxLength?: number;
148
153
  readonly minLength?: number;
154
+ readonly autoComplete?: string;
149
155
  readonly required?: boolean;
150
156
  readonly searchable?: boolean;
151
157
  readonly sortable?: boolean;
@@ -168,6 +174,7 @@ export declare const paymentsProjectionTable: import("@cosmicdrift/kumiko-framew
168
174
  readonly description?: string;
169
175
  readonly maxLength?: number;
170
176
  readonly minLength?: number;
177
+ readonly autoComplete?: string;
171
178
  readonly required?: boolean;
172
179
  readonly searchable?: boolean;
173
180
  readonly sortable?: boolean;
@@ -190,6 +197,7 @@ export declare const paymentsProjectionTable: import("@cosmicdrift/kumiko-framew
190
197
  readonly description?: string;
191
198
  readonly maxLength?: number;
192
199
  readonly minLength?: number;
200
+ readonly autoComplete?: string;
193
201
  readonly required?: boolean;
194
202
  readonly searchable?: boolean;
195
203
  readonly sortable?: boolean;
@@ -4,6 +4,7 @@ export declare const capCounterEntity: import("@cosmicdrift/kumiko-types/fields"
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -94,7 +94,13 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
94
94
  id: CAP_COUNTER_LIST_SCREEN_ID,
95
95
  type: "entityList",
96
96
  entity: "cap-counter",
97
- columns: ["tenantId", "capName", "value", "periodStart", "lastSoftWarnedAt"],
97
+ columns: [
98
+ "tenantId",
99
+ "capName",
100
+ "value",
101
+ "periodStart",
102
+ { field: "lastSoftWarnedAt", hideOnNarrow: true },
103
+ ],
98
104
  defaultSort: { field: "capName", dir: "asc" },
99
105
  // Search resolves against the caller's own tenant stream, so on this
100
106
  // cross-tenant operator list it would only ever hit the SystemAdmin's tenant.
@@ -15,10 +15,12 @@ export function createTenantCapListScreen(caps, listCaps, tiers) {
15
15
  { field: "name", label: "cap-overview.list.col.name" },
16
16
  { field: "tier", label: "cap-overview.list.col.tier" },
17
17
  { field: "billing", label: "cap-overview.list.col.billing" },
18
- ...listedCaps.map((cap) => ({
18
+ // Name is the title; tier, billing and the first cap fill the card's three meta rows.
19
+ ...listedCaps.map((cap, index) => ({
19
20
  field: capFieldName(cap.id),
20
21
  label: cap.label,
21
22
  renderer: { react: { __component: CAP_USAGE_CELL_COMPONENT } },
23
+ ...(index > 0 && { hideOnNarrow: true }),
22
24
  })),
23
25
  ],
24
26
  searchable: true,
@@ -4,6 +4,7 @@ export declare const configValueEntity: import("@cosmicdrift/kumiko-types/fields
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const configValueEntity: import("@cosmicdrift/kumiko-types/fields
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const configValueEntity: import("@cosmicdrift/kumiko-types/fields
48
50
  readonly description?: string;
49
51
  readonly maxLength?: number;
50
52
  readonly minLength?: number;
53
+ readonly autoComplete?: string;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -72,6 +75,7 @@ export declare const configValuesTable: import("@cosmicdrift/kumiko-framework/db
72
75
  readonly description?: string;
73
76
  readonly maxLength?: number;
74
77
  readonly minLength?: number;
78
+ readonly autoComplete?: string;
75
79
  readonly required?: boolean;
76
80
  readonly searchable?: boolean;
77
81
  readonly sortable?: boolean;
@@ -94,6 +98,7 @@ export declare const configValuesTable: import("@cosmicdrift/kumiko-framework/db
94
98
  readonly description?: string;
95
99
  readonly maxLength?: number;
96
100
  readonly minLength?: number;
101
+ readonly autoComplete?: string;
97
102
  readonly required?: boolean;
98
103
  readonly searchable?: boolean;
99
104
  readonly sortable?: boolean;
@@ -116,6 +121,7 @@ export declare const configValuesTable: import("@cosmicdrift/kumiko-framework/db
116
121
  readonly description?: string;
117
122
  readonly maxLength?: number;
118
123
  readonly minLength?: number;
124
+ readonly autoComplete?: string;
119
125
  readonly required?: boolean;
120
126
  readonly searchable?: boolean;
121
127
  readonly sortable?: boolean;
@@ -10,6 +10,7 @@ import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/se
10
10
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
11
11
  import * as z from "zod";
12
12
  import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
13
+ import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.js";
13
14
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
14
15
  import { USER_STATUS } from "../../user/index.js";
15
16
  import { denyIfTargetOutsideAdminTenant, isSystemAdminActor, updateUserLifecycle, } from "../../user-data-rights/index.js";
@@ -213,7 +214,7 @@ function sweepErrorLabel(err) {
213
214
  // The key is already erased when this runs, so a failing sweep must not skip the
214
215
  // audit event: the failure is returned as a label for the event instead of thrown.
215
216
  // The savepoint keeps a database error from aborting the handler transaction.
216
- async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunner) {
217
+ async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunner, audit) {
217
218
  try {
218
219
  await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
219
220
  // Blind-index sweep (#818): nulls bidx columns now so the deterministic
@@ -224,6 +225,16 @@ async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunn
224
225
  if (ctx.searchAdapter) {
225
226
  await purgeSearchDocumentsForSubject(crossTenantSubjectRunner, ctx.registry.features, ctx.searchAdapter, subjectKey, subject);
226
227
  }
228
+ // Note bodies are keyed per note, so the subject's own key never reaches them.
229
+ if (ctx.registry.features.has("notes-history")) {
230
+ await eraseNoteBodiesReachingSubject({
231
+ db: crossTenantSubjectRunner,
232
+ registry: ctx.registry,
233
+ subject,
234
+ erasedBy: audit.erasedBy,
235
+ requestId: audit.requestId,
236
+ });
237
+ }
227
238
  });
228
239
  return undefined;
229
240
  }
@@ -335,12 +346,13 @@ export const forgetSubjectWrite = defineWriteHandler({
335
346
  // but no event exists — a retry is a no-op erase plus the event. The
336
347
  // reverse order could leave an audit trail claiming a shred that never
337
348
  // happened.
349
+ const requestId = requestContext.get()?.requestId ?? "crypto-shredding:forget-subject";
338
350
  await kms.eraseKey(subject, {
339
- requestId: requestContext.get()?.requestId ?? "crypto-shredding:forget-subject",
351
+ requestId,
340
352
  userId: event.user.id,
341
353
  eraseReason: event.payload.reason,
342
354
  });
343
- const cleanupError = await sweepDerivedSubjectData(ctx, subject, subjectKey, ctx.db.unsafeRaw());
355
+ const cleanupError = await sweepDerivedSubjectData(ctx, subject, subjectKey, ctx.db.unsafeRaw(), { requestId, erasedBy: event.user.id });
344
356
  // User subject: close the login door. DEK-erase makes the passwordHash
345
357
  // ciphertext unreadable, but status + PATs are standalone credentials —
346
358
  // the PAT resolver only checks revokedAt/expiresAt, NOT user.status
@@ -4,6 +4,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
48
50
  readonly description?: string;
49
51
  readonly maxLength?: number;
50
52
  readonly minLength?: number;
53
+ readonly autoComplete?: string;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -95,6 +98,7 @@ export declare const fieldDefinitionEntity: import("@cosmicdrift/kumiko-types/fi
95
98
  readonly description?: string;
96
99
  readonly maxLength?: number;
97
100
  readonly minLength?: number;
101
+ readonly autoComplete?: string;
98
102
  readonly required?: boolean;
99
103
  readonly searchable?: boolean;
100
104
  readonly sortable?: boolean;
@@ -4,6 +4,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -42,6 +43,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
42
43
  readonly description?: string;
43
44
  readonly maxLength?: number;
44
45
  readonly minLength?: number;
46
+ readonly autoComplete?: string;
45
47
  readonly required?: boolean;
46
48
  readonly searchable?: boolean;
47
49
  readonly sortable?: boolean;
@@ -66,6 +68,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
66
68
  readonly description?: string;
67
69
  readonly maxLength?: number;
68
70
  readonly minLength?: number;
71
+ readonly autoComplete?: string;
69
72
  readonly required?: boolean;
70
73
  readonly searchable?: boolean;
71
74
  readonly sortable?: boolean;
@@ -104,6 +107,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
104
107
  readonly description?: string;
105
108
  readonly maxLength?: number;
106
109
  readonly minLength?: number;
110
+ readonly autoComplete?: string;
107
111
  readonly required?: boolean;
108
112
  readonly searchable?: boolean;
109
113
  readonly sortable?: boolean;
@@ -17,7 +17,7 @@ export const chatConnectionNameSchema = z
17
17
  function policyFor(request, target) {
18
18
  return request.requireHttps
19
19
  ? { kind: "external" }
20
- : { kind: "internal", allowHosts: [target.hostname] };
20
+ : { kind: "internal", allowOrigins: [target.origin] };
21
21
  }
22
22
  function hostMatches(hostname, allowedHost) {
23
23
  const allowed = allowedHost.toLowerCase();
@@ -1,6 +1,7 @@
1
1
  import type { NotifyPriority } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare const DELIVERY_CHANNEL_EXTENSION: "deliveryChannel";
3
3
  export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_CELL_COMPONENT, DELIVERY_ERROR_CELL_COMPONENT, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_RESUBSCRIBE_PATH, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_TIME_CELL_COMPONENT, DELIVERY_TYPE_CELL_COMPONENT, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobNames, DeliveryQueries, DeliveryStatus, type DeliveryStatusValue, } from "./public-names.js";
4
+ export declare function deliveryIdempotencyRedisKey(tenantId: string, key: string): string;
4
5
  export declare const deliveryPriorityRank: Record<NotifyPriority, number>;
5
6
  export declare const DeliveryJobs: {
6
7
  readonly render: string;
@@ -4,6 +4,10 @@ import { DELIVERY_FEATURE, DeliveryJobNames } from "./public-names.js";
4
4
  // channel-in-app, channel-push, ...).
5
5
  export const DELIVERY_CHANNEL_EXTENSION = "deliveryChannel";
6
6
  export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_CELL_COMPONENT, DELIVERY_ERROR_CELL_COMPONENT, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_RESUBSCRIBE_PATH, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_TIME_CELL_COMPONENT, DELIVERY_TYPE_CELL_COMPONENT, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobNames, DeliveryQueries, DeliveryStatus, } from "./public-names.js";
7
+ // Single source for claim (delivery-service) and terminal-failure release (jobs).
8
+ export function deliveryIdempotencyRedisKey(tenantId, key) {
9
+ return `delivery:idem:${tenantId}:${key}`;
10
+ }
7
11
  // notify() priority → BullMQ job priority. Lower number = processed first; all
8
12
  // > 0 so prioritised delivery jobs never mix with BullMQ's "0 = unprioritised
9
13
  // FIFO" bucket. critical jobs jump ahead of normal/low in the worker queue.
@@ -8,7 +8,7 @@ import { redactErrorText } from "../shared/index.js";
8
8
  import { hashUnsubscribeAddress } from "./address-opt-out.js";
9
9
  import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
10
10
  import { buildChannelContext } from "./channel-context.js";
11
- import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants.js";
11
+ import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank, } from "./constants.js";
12
12
  import { addressOptOutKey, selectOptedOutAddresses, } from "./db/queries/address-opt-outs.js";
13
13
  import { selectNotificationPreferences } from "./db/queries/preferences.js";
14
14
  import { isDeliveryChannelPlugin, } from "./types.js";
@@ -74,10 +74,28 @@ export function createDeliveryService(options) {
74
74
  if (!idemRedis) {
75
75
  throw new Error("Delivery idempotencyKey requires options.idempotencyRedis (or rateLimit.redis) to be configured");
76
76
  }
77
- const k = `delivery:idem:${tenantId}:${key}`;
78
- const res = await idemRedis.set(k, "1", "EX", ttlSec, "NX");
77
+ const res = await idemRedis.set(deliveryIdempotencyRedisKey(tenantId, key), "1", "EX", ttlSec, "NX");
79
78
  return res === "OK";
80
79
  }
80
+ function isDeliveredOrQueued(delivery) {
81
+ return delivery.status === "sent" || delivery.status === "queued";
82
+ }
83
+ // A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
84
+ // Partial success keeps it: a retry would re-send to those who got it.
85
+ async function releaseIdempotencyIfNothingDelivered(tenantId, key, run) {
86
+ // skip: no key claimed, or a recipient already got the notification
87
+ if (!key || run.deliveries.some(isDeliveredOrQueued))
88
+ return;
89
+ await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
90
+ }
91
+ // Only a single-channel route lets the queued job free the key on terminal failure
92
+ // without risking a duplicate send on another channel.
93
+ function jobIdempotencyKeyFor(idempotencyKey, route) {
94
+ if (!idempotencyKey || !route)
95
+ return undefined;
96
+ const routedChannelCount = channels.filter((channel) => route[channel.name]).length;
97
+ return routedChannelCount === 1 ? idempotencyKey : undefined;
98
+ }
81
99
  async function resolveUserIdsForTenant(tenantId) {
82
100
  if (!tenantUserIdsQuery) {
83
101
  throw new Error("Tenant broadcast requires tenantUserIdsQuery in DeliveryServiceOptions");
@@ -191,6 +209,7 @@ export function createDeliveryService(options) {
191
209
  deliveryAttemptId,
192
210
  priority,
193
211
  message,
212
+ ...(run.jobIdempotencyKey !== undefined && { idempotencyKey: run.jobIdempotencyKey }),
194
213
  }, { priority: deliveryPriorityRank[priority] });
195
214
  recordDelivery(run, queuedEntry, deliveryAttemptId);
196
215
  }
@@ -517,14 +536,42 @@ export function createDeliveryService(options) {
517
536
  }
518
537
  }
519
538
  }
539
+ async function dispatchNotification(notificationType, options, tenantId, priority, run) {
540
+ const { to, route, data } = options;
541
+ if (route) {
542
+ await deliverDirect(route, notificationType, data, tenantId, priority, options.recipientId ?? null, run);
543
+ // skip: explicit route is delivered directly, no recipient resolution needed
544
+ return;
545
+ }
546
+ if (to !== undefined) {
547
+ let userIds;
548
+ if (typeof to === "string") {
549
+ userIds = [to];
550
+ }
551
+ else if ("tenant" in to) {
552
+ userIds = await resolveUserIdsForTenant(to.tenant);
553
+ }
554
+ else {
555
+ userIds = to;
556
+ }
557
+ const prefetchedAddresses = priority === "critical"
558
+ ? new Map()
559
+ : await prefetchUserAddresses(userIds, tenantId, notificationType);
560
+ const suppressed = await loadSuppressedAddresses(tenantId, notificationType, priority, [...prefetchedAddresses.values()].filter((address) => !!address));
561
+ for (const userId of userIds) {
562
+ await deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetchedAddresses, suppressed);
563
+ }
564
+ }
565
+ }
520
566
  return {
521
567
  async notify(notificationType, options, _user, tenantId, jobDispatcher) {
522
- const { to, route, data, idempotencyKey } = options;
568
+ const { route, idempotencyKey } = options;
523
569
  const priority = options.priority ?? "normal";
524
570
  const run = {
525
571
  deliveries: [],
526
572
  jobDispatcher: options.immediate ? undefined : (jobDispatcher ?? jobRunner),
527
573
  locale: options.locale,
574
+ jobIdempotencyKey: jobIdempotencyKeyFor(idempotencyKey, route),
528
575
  };
529
576
  if (idempotencyKey) {
530
577
  const first = await claimIdempotency(tenantId, idempotencyKey);
@@ -542,29 +589,19 @@ export function createDeliveryService(options) {
542
589
  return { deliveries: run.deliveries };
543
590
  }
544
591
  }
545
- if (route) {
546
- await deliverDirect(route, notificationType, data, tenantId, priority, options.recipientId ?? null, run);
547
- return { deliveries: run.deliveries };
592
+ try {
593
+ await dispatchNotification(notificationType, options, tenantId, priority, run);
548
594
  }
549
- if (to !== undefined) {
550
- let userIds;
551
- if (typeof to === "string") {
552
- userIds = [to];
553
- }
554
- else if ("tenant" in to) {
555
- userIds = await resolveUserIdsForTenant(to.tenant);
556
- }
557
- else {
558
- userIds = to;
559
- }
560
- const prefetchedAddresses = priority === "critical"
561
- ? new Map()
562
- : await prefetchUserAddresses(userIds, tenantId, notificationType);
563
- const suppressed = await loadSuppressedAddresses(tenantId, notificationType, priority, [...prefetchedAddresses.values()].filter((address) => !!address));
564
- for (const userId of userIds) {
565
- await deliverToUser(userId, notificationType, data, tenantId, priority, run, prefetchedAddresses, suppressed);
566
- }
595
+ catch (err) {
596
+ // A failing release must not replace the dispatch error the caller retries on.
597
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch((releaseErr) => {
598
+ logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
599
+ notificationType,
600
+ });
601
+ });
602
+ throw err;
567
603
  }
604
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
568
605
  return { deliveries: run.deliveries };
569
606
  },
570
607
  };
@@ -179,15 +179,21 @@ export function createDeliveryFeature(options) {
179
179
  label: i18nKey("delivery.log.col.channel"),
180
180
  renderer: { react: { __component: DELIVERY_CHANNEL_CELL_COMPONENT } },
181
181
  },
182
- { field: "recipientLabel", label: i18nKey("delivery.log.col.recipient") },
182
+ {
183
+ field: "recipientLabel",
184
+ label: i18nKey("delivery.log.col.recipient"),
185
+ hideOnNarrow: true,
186
+ },
183
187
  {
184
188
  field: "status",
185
189
  label: i18nKey("delivery.log.col.status"),
190
+ hideOnNarrow: true,
186
191
  renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
187
192
  },
188
193
  {
189
194
  field: "error",
190
195
  label: i18nKey("delivery.log.col.error"),
196
+ hideOnNarrow: true,
191
197
  renderer: { react: { __component: DELIVERY_ERROR_CELL_COMPONENT } },
192
198
  },
193
199
  ],
@@ -8,7 +8,7 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import * as z from "zod";
9
9
  import { appendAttemptEvent } from "./attempt-log.js";
10
10
  import { buildChannelContext } from "./channel-context.js";
11
- import { DeliveryJobs, deliveryPriorityRank } from "./constants.js";
11
+ import { DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank } from "./constants.js";
12
12
  import { collectChannels, redactedMessageOf } from "./delivery-service.js";
13
13
  const channelMessageSchema = z.object({
14
14
  notificationType: z.string(),
@@ -26,6 +26,7 @@ const renderJobPayloadSchema = z.object({
26
26
  deliveryAttemptId: z.string(),
27
27
  priority: z.enum(["critical", "normal", "low"]),
28
28
  message: channelMessageSchema,
29
+ idempotencyKey: z.string().optional(),
29
30
  });
30
31
  const sendJobPayloadSchema = renderJobPayloadSchema.extend({
31
32
  rendered: z
@@ -75,6 +76,28 @@ function entryFor(p, status, error, address, confirmed) {
75
76
  ...(confirmed === false && { confirmed }),
76
77
  };
77
78
  }
79
+ // Only the final retry frees the key: earlier failures are retried by the runner and
80
+ // must still dedup. Assumes ctx.redis is the handle the service claimed on; runProdApp
81
+ // wires the same instance into both.
82
+ async function releaseIdempotencyOnTerminalFailure(ctx, p) {
83
+ // skip: nothing to release before the last attempt or without an idempotency key
84
+ if (!ctx.finalAttempt || !p.idempotencyKey)
85
+ return;
86
+ if (!ctx.redis) {
87
+ ctx.log.warn("delivery: idempotency key not released, job context has no redis", {
88
+ notificationType: p.notificationType,
89
+ });
90
+ return;
91
+ }
92
+ try {
93
+ await ctx.redis.del(deliveryIdempotencyRedisKey(p.tenantId, p.idempotencyKey));
94
+ }
95
+ catch (err) {
96
+ ctx.log.warn(`delivery: idempotency key release failed: ${redactedMessageOf(err)}`, {
97
+ notificationType: p.notificationType,
98
+ });
99
+ }
100
+ }
78
101
  // The job runner stores err.message in the run row, and BullMQ keeps failedReason and
79
102
  // the stack trace in Redis in plain text, so the rethrown error carries only the code.
80
103
  // The redacted message goes to the log.
@@ -85,6 +108,7 @@ async function failAttempt(ctx, db, registry, p, code, err) {
85
108
  channel: p.channelName,
86
109
  });
87
110
  await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, "failed", code, p.address));
111
+ await releaseIdempotencyOnTerminalFailure(ctx, p);
88
112
  throw new Error(code);
89
113
  }
90
114
  // Render the message and hand off to delivery.send. On failure: record the