@cosmicdrift/kumiko-bundled-features 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 (84) 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/book-cap-usage.d.ts +11 -5
  7. package/dist/cap-counter/book-cap-usage.js +54 -13
  8. package/dist/cap-counter/calendar-cap-usage-reservation.d.ts +17 -0
  9. package/dist/cap-counter/calendar-cap-usage-reservation.js +53 -0
  10. package/dist/cap-counter/cap-reservation.d.ts +15 -6
  11. package/dist/cap-counter/cap-reservation.js +80 -31
  12. package/dist/cap-counter/enforce-cap.d.ts +5 -5
  13. package/dist/cap-counter/enforce-cap.js +7 -20
  14. package/dist/cap-counter/entity.d.ts +1 -0
  15. package/dist/cap-counter/feature.js +7 -1
  16. package/dist/cap-counter/index.d.ts +1 -0
  17. package/dist/cap-counter/index.js +1 -0
  18. package/dist/cap-counter/with-cap-enforcement.js +4 -19
  19. package/dist/cap-overview/screens.js +3 -1
  20. package/dist/config/table.d.ts +6 -0
  21. package/dist/crypto-shredding/handlers/forget-subject.write.js +14 -3
  22. package/dist/custom-fields/entity.d.ts +4 -0
  23. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  24. package/dist/delivery/chat-webhook-sender.js +1 -1
  25. package/dist/delivery/constants.d.ts +1 -0
  26. package/dist/delivery/constants.js +4 -0
  27. package/dist/delivery/delivery-service.js +62 -25
  28. package/dist/delivery/feature.js +7 -1
  29. package/dist/delivery/jobs.js +25 -1
  30. package/dist/delivery/tables.d.ts +6 -0
  31. package/dist/delivery/testing.d.ts +1 -0
  32. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  33. package/dist/folders/entity.d.ts +8 -0
  34. package/dist/form-draft/entity.d.ts +2 -0
  35. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  36. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  37. package/dist/jobs/job-run-logger.js +21 -17
  38. package/dist/jobs/job-run-table.d.ts +6 -0
  39. package/dist/jobs/job-run-table.js +10 -3
  40. package/dist/ledger/entity.d.ts +13 -0
  41. package/dist/managed-pages/table.d.ts +12 -0
  42. package/dist/notes-history/entity.d.ts +12 -0
  43. package/dist/notes-history/entity.js +9 -7
  44. package/dist/notes-history-user-data/hooks.d.ts +8 -0
  45. package/dist/notes-history-user-data/hooks.js +56 -72
  46. package/dist/notes-history-user-data/index.d.ts +1 -0
  47. package/dist/notes-history-user-data/index.js +2 -1
  48. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  49. package/dist/personal-access-tokens/screens.js +2 -0
  50. package/dist/secrets/table.d.ts +1 -0
  51. package/dist/sessions/schema/user-session.d.ts +4 -0
  52. package/dist/sessions/screens.js +2 -0
  53. package/dist/tags/entity.d.ts +9 -0
  54. package/dist/template-resolver/table.d.ts +10 -0
  55. package/dist/template-resolver/user-content-table.d.ts +10 -0
  56. package/dist/tenant/constants.d.ts +1 -0
  57. package/dist/tenant/constants.js +6 -3
  58. package/dist/tenant/feature.js +2 -0
  59. package/dist/tenant/handlers/member-role-availability.query.d.ts +7 -0
  60. package/dist/tenant/handlers/member-role-availability.query.js +18 -0
  61. package/dist/tenant/invitation-table.d.ts +6 -0
  62. package/dist/tenant/member-role-options.d.ts +2 -0
  63. package/dist/tenant/member-role-options.js +4 -0
  64. package/dist/tenant/membership-table.d.ts +4 -0
  65. package/dist/tenant/schema/tenant.d.ts +6 -0
  66. package/dist/tenant/screens.d.ts +4 -2
  67. package/dist/tenant/screens.js +10 -6
  68. package/dist/tier-engine/entity.d.ts +2 -0
  69. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  70. package/dist/user/schema/user.d.ts +12 -0
  71. package/dist/user/screens.js +8 -1
  72. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  73. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  74. package/dist/user-data-rights/schema/export-job.d.ts +6 -0
  75. package/dist/user-data-rights/screens.js +8 -1
  76. package/dist/waitlist/entity.d.ts +14 -0
  77. package/dist/waitlist/screens.js +8 -1
  78. package/package.json +9 -9
  79. package/src/auth-mfa/changes.json +6 -0
  80. package/src/cap-counter/changes.json +7 -0
  81. package/src/crypto-shredding/changes.json +6 -0
  82. package/src/delivery/changes.json +6 -0
  83. package/src/notes-history/changes.json +6 -0
  84. package/src/tenant/changes.json +6 -0
@@ -1,6 +1,6 @@
1
- import { type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
1
  import { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
+ import { type CapCounterContext } from "./book-cap-usage.js";
4
4
  /**
5
5
  * Cap-Toleranz-Profile — pro Cap-Typ asymmetrisch. Quelle: Memory
6
6
  * `project_pricing_byok_caps` §3 + `docs/plans/marketing/produkt/
@@ -79,7 +79,7 @@ export declare function assertBelowHardCap(valueSeenByLastUnit: number, cap: {
79
79
  * (apps-count) need stricter serialization at the create-handler
80
80
  * level (e.g. uniqueness-index on apps.tenantId+slot-number).
81
81
  */
82
- export declare function enforceCap(ctx: HandlerContext, options: {
82
+ export declare function enforceCap(ctx: CapCounterContext, options: {
83
83
  readonly capName: string;
84
84
  readonly periodStartIso: string;
85
85
  readonly limit: number;
@@ -110,7 +110,7 @@ export declare function enforceCap(ctx: HandlerContext, options: {
110
110
  * Projection mit pre-aggregierten daily-buckets. Heute nicht
111
111
  * vorgezogen.
112
112
  */
113
- export declare function enforceRollingCap(ctx: HandlerContext, options: {
113
+ export declare function enforceRollingCap(ctx: CapCounterContext, options: {
114
114
  readonly capName: string;
115
115
  readonly windowDays: number;
116
116
  readonly limit: number;
@@ -162,7 +162,7 @@ export type SoftHitNotifier = (info: {
162
162
  * verzweigen (z.B. UI-Toast bei soft-hit zusätzlich zur
163
163
  * Email-Notification).
164
164
  */
165
- export declare function enforceCapAndMaybeNotify(ctx: HandlerContext, options: {
165
+ export declare function enforceCapAndMaybeNotify(ctx: CapCounterContext, options: {
166
166
  readonly capName: string;
167
167
  readonly periodStartIso: string;
168
168
  readonly limit: number;
@@ -181,7 +181,7 @@ export declare function enforceCapAndMaybeNotify(ctx: HandlerContext, options: {
181
181
  * oder er ruft `enforceRollingCapAndMaybeNotify` nur einmal pro
182
182
  * Tag/Stunde auf (Hourly-Cron statt pro Request).
183
183
  */
184
- export declare function enforceRollingCapAndMaybeNotify(ctx: HandlerContext, options: {
184
+ export declare function enforceRollingCapAndMaybeNotify(ctx: CapCounterContext, options: {
185
185
  readonly capName: string;
186
186
  readonly windowDays: number;
187
187
  readonly limit: number;
@@ -1,7 +1,7 @@
1
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 { markCapSoftWarned, readRollingCapUsage, resolveCapCounterScope, } from "./book-cap-usage.js";
5
5
  import { capCounterEntity } from "./entity.js";
6
6
  const { table } = createEntityExecutor("cap-counter", capCounterEntity);
7
7
  export const CAP_TOLERANCES = {
@@ -45,16 +45,14 @@ export function assertBelowHardCap(valueSeenByLastUnit, cap) {
45
45
  * level (e.g. uniqueness-index on apps.tenantId+slot-number).
46
46
  */
47
47
  export async function enforceCap(ctx, options) {
48
- if (!ctx.db) {
49
- throw new Error("cap-counter.enforceCap: ctx.db missing — run inside a handler context");
50
- }
48
+ const { user, readDb } = resolveCapCounterScope(ctx, false);
51
49
  const amount = options.amount ?? 1;
52
50
  if (!Number.isInteger(amount) || amount < 1) {
53
51
  throw new Error(`cap-counter.enforceCap: amount must be a positive integer, got ${options.amount}`);
54
52
  }
55
53
  const tolerance = CAP_TOLERANCES[options.profile];
56
54
  const softThreshold = options.limit * tolerance.soft;
57
- const rows = await ctx.db.selectMany(table, { capName: options.capName, periodStart: options.periodStartIso }, { limit: 1 });
55
+ const rows = await readDb.selectMany(table, { tenantId: user.tenantId, capName: options.capName, periodStart: options.periodStartIso }, { limit: 1 });
58
56
  const row = rows[0];
59
57
  const storedValue = row ? row["value"] : 0; // @cast-boundary db-row
60
58
  // The last of `amount` units sees this value before its own increment.
@@ -94,16 +92,11 @@ export async function enforceCap(ctx, options) {
94
92
  * vorgezogen.
95
93
  */
96
94
  export async function enforceRollingCap(ctx, options) {
97
- if (!ctx.db) {
98
- throw new Error("cap-counter.enforceRollingCap: ctx.db missing — run inside a handler context");
99
- }
100
- if (!ctx.user?.tenantId) {
101
- throw new Error("cap-counter.enforceRollingCap: ctx.user.tenantId missing — required to compute aggregate-id");
102
- }
95
+ const { user, readDb } = resolveCapCounterScope(ctx, false);
103
96
  const tolerance = CAP_TOLERANCES[options.profile];
104
97
  const softThreshold = options.limit * tolerance.soft;
105
98
  const hardThreshold = options.limit * tolerance.hard;
106
- const value = await readRollingCapUsage(ctx.db, ctx.user.tenantId, {
99
+ const value = await readRollingCapUsage(readDb, user.tenantId, {
107
100
  capName: options.capName,
108
101
  windowDays: options.windowDays,
109
102
  });
@@ -184,14 +177,11 @@ export async function enforceCapAndMaybeNotify(ctx, options) {
184
177
  ...(options.amount !== undefined && { amount: options.amount }),
185
178
  });
186
179
  if (result.state === "soft-hit" && result.crossed) {
187
- if (!ctx.user?.tenantId) {
188
- throw new Error("cap-counter.enforceCapAndMaybeNotify: ctx.user.tenantId missing — required for notification");
189
- }
190
180
  await options.notify({
191
181
  capName: options.capName,
192
182
  value: result.value,
193
183
  limit: options.limit,
194
- tenantId: ctx.user.tenantId,
184
+ tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
195
185
  });
196
186
  // Flip the soft-warned flag so the same period doesn't re-notify.
197
187
  const marked = await markCapSoftWarned(ctx, {
@@ -222,14 +212,11 @@ export async function enforceRollingCapAndMaybeNotify(ctx, options) {
222
212
  profile: options.profile,
223
213
  });
224
214
  if (result.state === "soft-hit") {
225
- if (!ctx.user?.tenantId) {
226
- throw new Error("cap-counter.enforceRollingCapAndMaybeNotify: ctx.user.tenantId missing — required for notification");
227
- }
228
215
  await options.notify({
229
216
  capName: options.capName,
230
217
  value: result.value,
231
218
  limit: options.limit,
232
- tenantId: ctx.user.tenantId,
219
+ tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
233
220
  });
234
221
  }
235
222
  return result;
@@ -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.
@@ -1,5 +1,6 @@
1
1
  export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
2
2
  export { type BookCapUsageOptions, bookCapUsage, type MarkCapSoftWarnedOptions, markCapSoftWarned, type ReadRollingCapUsageOptions, readRollingCapUsage, } from "./book-cap-usage.js";
3
+ export { type CalendarCapUsageReservation, type CalendarCapUsageReservationSpec, reserveCalendarCapUsage, } from "./calendar-cap-usage-reservation.js";
3
4
  export { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, ROLLING_RELEASED_EVENT_QN, ROLLING_RELEASED_EVENT_SHORT, } from "./constants.js";
4
5
  export { CAP_TOLERANCES, CapExceededError, type CapToleranceProfile, type CapToleranceProfileName, currentCalendarMonthStartIso, type EnforceCapResult, enforceCap, enforceCapAndMaybeNotify, enforceRollingCap, enforceRollingCapAndMaybeNotify, enforceStockCap, type SoftHitNotifier, type StockCapResult, } from "./enforce-cap.js";
5
6
  export { capCounterEntity } from "./entity.js";
@@ -1,6 +1,7 @@
1
1
  // Public API of the cap-counter bundled-feature.
2
2
  export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
3
3
  export { bookCapUsage, markCapSoftWarned, readRollingCapUsage, } from "./book-cap-usage.js";
4
+ export { reserveCalendarCapUsage, } from "./calendar-cap-usage-reservation.js";
4
5
  export { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, ROLLING_RELEASED_EVENT_QN, ROLLING_RELEASED_EVENT_SHORT, } from "./constants.js";
5
6
  export { CAP_TOLERANCES, CapExceededError, currentCalendarMonthStartIso, enforceCap, enforceCapAndMaybeNotify, enforceRollingCap, enforceRollingCapAndMaybeNotify, enforceStockCap, } from "./enforce-cap.js";
6
7
  export { capCounterEntity } from "./entity.js";
@@ -23,8 +23,9 @@
23
23
  //
24
24
  // No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
25
25
  // (enforce-cap.ts).
26
- import { releaseExpiredCapReservations, reserveCalendarCap, reserveRollingCap, } from "./cap-reservation.js";
27
- import { assertBelowHardCap, enforceCapAndMaybeNotify, enforceRollingCapAndMaybeNotify, } from "./enforce-cap.js";
26
+ import { reserveEnforcedCalendarCap } from "./calendar-cap-usage-reservation.js";
27
+ import { releaseExpiredCapReservations, reserveRollingCap } from "./cap-reservation.js";
28
+ import { assertBelowHardCap, enforceRollingCapAndMaybeNotify, } from "./enforce-cap.js";
28
29
  import { chainReservations } from "./reservation-chain.js";
29
30
  /**
30
31
  * Wrap a write-handler with calendar-period cap-enforcement.
@@ -41,23 +42,7 @@ export function withCapEnforcement(handler, capResolver) {
41
42
  ...handler,
42
43
  reserveBeforeTransaction: chainReservations(handler.reserveBeforeTransaction, async (event, ctx) => {
43
44
  const cap = await capResolver(event, ctx);
44
- await releaseExpiredCapReservations(ctx, cap.capName);
45
- await enforceCapAndMaybeNotify(ctx, {
46
- capName: cap.capName,
47
- periodStartIso: cap.periodStartIso,
48
- limit: cap.limit,
49
- profile: cap.profile,
50
- notify: cap.notify,
51
- markSoftWarnedOutsideTransaction: true,
52
- ...(cap.amount !== undefined && { amount: cap.amount }),
53
- });
54
- const amount = cap.amount ?? 1;
55
- return reserveCalendarCap(ctx, {
56
- capName: cap.capName,
57
- amount,
58
- periodStartIso: cap.periodStartIso,
59
- guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
60
- });
45
+ return reserveEnforcedCalendarCap(ctx, { ...cap, amount: cap.amount ?? 1 });
61
46
  }),
62
47
  };
63
48
  }
@@ -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,15 @@ 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
+ subject,
233
+ erasedBy: audit.erasedBy,
234
+ requestId: audit.requestId,
235
+ });
236
+ }
227
237
  });
228
238
  return undefined;
229
239
  }
@@ -335,12 +345,13 @@ export const forgetSubjectWrite = defineWriteHandler({
335
345
  // but no event exists — a retry is a no-op erase plus the event. The
336
346
  // reverse order could leave an audit trail claiming a shred that never
337
347
  // happened.
348
+ const requestId = requestContext.get()?.requestId ?? "crypto-shredding:forget-subject";
338
349
  await kms.eraseKey(subject, {
339
- requestId: requestContext.get()?.requestId ?? "crypto-shredding:forget-subject",
350
+ requestId,
340
351
  userId: event.user.id,
341
352
  eraseReason: event.payload.reason,
342
353
  });
343
- const cleanupError = await sweepDerivedSubjectData(ctx, subject, subjectKey, ctx.db.unsafeRaw());
354
+ const cleanupError = await sweepDerivedSubjectData(ctx, subject, subjectKey, ctx.db.unsafeRaw(), { requestId, erasedBy: event.user.id });
344
355
  // User subject: close the login door. DEK-erase makes the passwordHash
345
356
  // ciphertext unreadable, but status + PATs are standalone credentials —
346
357
  // 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