@cosmicdrift/kumiko-bundled-features 0.351.0 → 0.352.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 (70) hide show
  1. package/dist/cap-counter/book-cap-usage.d.ts +5 -1
  2. package/dist/cap-counter/book-cap-usage.js +65 -44
  3. package/dist/cap-counter/cap-reservation.d.ts +12 -0
  4. package/dist/cap-counter/cap-reservation.js +135 -0
  5. package/dist/cap-counter/constants.d.ts +3 -0
  6. package/dist/cap-counter/constants.js +6 -0
  7. package/dist/cap-counter/feature.js +7 -2
  8. package/dist/cap-counter/handlers/increment-rolling.write.d.ts +5 -0
  9. package/dist/cap-counter/handlers/increment-rolling.write.js +4 -0
  10. package/dist/cap-counter/index.d.ts +4 -2
  11. package/dist/cap-counter/index.js +3 -1
  12. package/dist/cap-counter/payload-field.d.ts +1 -0
  13. package/dist/cap-counter/payload-field.js +11 -0
  14. package/dist/cap-counter/reservation-chain.d.ts +2 -0
  15. package/dist/cap-counter/reservation-chain.js +44 -0
  16. package/dist/cap-counter/stock-cap-guard.d.ts +3 -0
  17. package/dist/cap-counter/tables.d.ts +3 -0
  18. package/dist/cap-counter/tables.js +36 -0
  19. package/dist/cap-counter/value-cap-guard.d.ts +18 -0
  20. package/dist/cap-counter/value-cap-guard.js +33 -0
  21. package/dist/cap-counter/with-cap-enforcement.d.ts +5 -7
  22. package/dist/cap-counter/with-cap-enforcement.js +35 -51
  23. package/dist/channel-discord/feature.js +1 -0
  24. package/dist/channel-slack/feature.js +1 -0
  25. package/dist/channel-teams/feature.js +1 -0
  26. package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -0
  27. package/dist/delivery/attempt-log-retention.d.ts +9 -0
  28. package/dist/delivery/attempt-log-retention.js +23 -0
  29. package/dist/delivery/attempt-log.d.ts +1 -1
  30. package/dist/delivery/attempt-log.js +21 -2
  31. package/dist/delivery/constants.d.ts +1 -1
  32. package/dist/delivery/constants.js +1 -1
  33. package/dist/delivery/events.d.ts +1 -0
  34. package/dist/delivery/events.js +2 -0
  35. package/dist/delivery/feature.d.ts +1 -0
  36. package/dist/delivery/feature.js +39 -3
  37. package/dist/delivery/index.d.ts +2 -1
  38. package/dist/delivery/index.js +2 -1
  39. package/dist/delivery/mask-recipient-address.d.ts +1 -0
  40. package/dist/delivery/mask-recipient-address.js +41 -0
  41. package/dist/delivery/public-names.d.ts +2 -0
  42. package/dist/delivery/public-names.js +3 -0
  43. package/dist/delivery/types.d.ts +1 -0
  44. package/dist/delivery/types.js +6 -2
  45. package/dist/ledger/entity.d.ts +3 -0
  46. package/dist/renderer-foundation/types.d.ts +1 -0
  47. package/dist/renderer-simple/feature.js +4 -6
  48. package/dist/renderer-simple/index.d.ts +1 -0
  49. package/dist/renderer-simple/mail-blocks.d.ts +21 -0
  50. package/dist/renderer-simple/mail-blocks.js +107 -0
  51. package/dist/renderer-simple/simple-renderer.js +21 -3
  52. package/dist/template-resolver/table.d.ts +8 -0
  53. package/dist/template-resolver/user-content-table.d.ts +4 -0
  54. package/dist/tenant/invitation-table.d.ts +2 -0
  55. package/dist/tenant/schema/tenant.d.ts +2 -0
  56. package/dist/tier-engine/index.d.ts +1 -0
  57. package/dist/tier-engine/index.js +1 -0
  58. package/dist/tier-engine/tier-option-gate.d.ts +32 -0
  59. package/dist/tier-engine/tier-option-gate.js +62 -0
  60. package/dist/user/schema/user.d.ts +2 -0
  61. package/dist/user-data-rights/schema/export-job.d.ts +2 -0
  62. package/dist/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.js +25 -6
  63. package/dist/waitlist/entity.d.ts +2 -0
  64. package/package.json +10 -10
  65. package/src/cap-counter/changes.json +28 -0
  66. package/src/delivery/changes.json +12 -0
  67. package/src/derivatives-sharp/changes.json +6 -0
  68. package/src/legal-pages/changes.json +6 -0
  69. package/src/renderer-simple/changes.json +6 -0
  70. package/src/tier-engine/changes.json +6 -0
@@ -44,13 +44,11 @@ export type RollingCapResolver = (event: WriteEvent, ctx: HandlerContext) => Pro
44
44
  /**
45
45
  * Wrap a write-handler with rolling-window cap-enforcement.
46
46
  *
47
- * Same flow as `withCapEnforcement` but uses
48
- * `enforceRollingCapAndMaybeNotify` + dispatches
49
- * `cap-counter:write:increment-rolling` post-success.
47
+ * Same flow as `withCapEnforcement` but uses `enforceRollingCapAndMaybeNotify` and reserves by
48
+ * appending a `rolling-incremented` event before the handler transaction; a release appends
49
+ * `rolling-released`.
50
50
  *
51
- * **Notification-Storm-Caveat:** rolling-counter trackt KEIN
52
- * lastSoftWarnedAt — der Notifier feuert bei JEDEM Call solange
53
- * der counter im soft-Bereich ist. Caller sollte einen TTL-Cache
54
- * (`Map<capName, lastNotifiedAt>`) im notify-callback einbauen.
51
+ * Rolling counters track no lastSoftWarnedAt, so the notifier fires on every call while usage
52
+ * sits in the soft range; throttle inside the notify callback (e.g. a per-capName TTL cache).
55
53
  */
56
54
  export declare function withRollingCapEnforcement(handler: WriteHandlerDef, capResolver: RollingCapResolver): WriteHandlerDef;
@@ -6,26 +6,26 @@
6
6
  // the wrapper keeps the pattern explicit and co-located.
7
7
  //
8
8
  // **Calendar reservation:** the wrapper only declares the cap; the dispatcher runs the
9
- // reservation (reserveBeforeTransaction) before the handler transaction opens, in its own short
10
- // committed write, and releases it after that transaction ended without committing (rollback,
11
- // failure result, failed COMMIT). No connection is held across the handler, so capped requests
12
- // cannot exhaust the pool, and the counter stream is not locked while the handler runs. If a
13
- // COMMIT fails with an unknown outcome the release can under-count; a wrapped handler must not
14
- // book the same counter itself. Reached through a nested ctx.write the cap is not reserved, so
15
- // the dispatcher rejects that call unless the top-level batch reserved the same handler.
9
+ // reservation (reserveBeforeTransaction) after all pre-handler gates and before the handler
10
+ // transaction opens, in its own short committed write (counter increment plus a
11
+ // store_cap_reservations row). The handler transaction deletes the row on its own commit; a release
12
+ // after a rollback, failure result or failed COMMIT only gives back what is still booked, so an
13
+ // unknown COMMIT outcome cannot under-count. No connection is held across the handler. Rows a
14
+ // crashed process left behind expire after CAP_RESERVATION_TTL_MINUTES and are given back before the
15
+ // next reserve of the same cap. Reached through a nested ctx.write the cap is not reserved, so the
16
+ // dispatcher rejects that call unless the top-level batch reserved the same handler.
16
17
  //
17
- // Rolling booking still dispatches the SystemAdmin-only increment-rolling
18
- // handler and has no reservation: it would need a compensating event type,
19
- // a changed readRollingCapUsage and a version-guarded append behind the
20
- // SystemAdmin dispatch. Rolling callers need a SystemAdmin identity until
21
- // cap-counter declares an explicit foreign-booking opt-in.
18
+ // The wrapper spreads the wrapped handler and chains its reserveBeforeTransaction: inner reserves
19
+ // first, release runs in reverse, and an outer failure gives the inner reservation back.
20
+ //
21
+ // Rolling caps reserve the same way: a version-guarded `rolling-incremented` append plus a
22
+ // reservation row, undone by a `rolling-released` event. The window sum is incremented minus released.
22
23
  //
23
24
  // No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
24
25
  // (enforce-cap.ts).
25
- import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
26
- import { bookCapUsage, releaseCapUsage } from "./book-cap-usage.js";
27
- import { CapCounterHandlers } from "./constants.js";
26
+ import { releaseExpiredCapReservations, reserveCalendarCap, reserveRollingCap, } from "./cap-reservation.js";
28
27
  import { assertBelowHardCap, enforceCapAndMaybeNotify, enforceRollingCapAndMaybeNotify, } from "./enforce-cap.js";
28
+ import { chainReservations } from "./reservation-chain.js";
29
29
  /**
30
30
  * Wrap a write-handler with calendar-period cap-enforcement.
31
31
  *
@@ -39,8 +39,9 @@ import { assertBelowHardCap, enforceCapAndMaybeNotify, enforceRollingCapAndMaybe
39
39
  export function withCapEnforcement(handler, capResolver) {
40
40
  return {
41
41
  ...handler,
42
- reserveBeforeTransaction: async (event, ctx) => {
42
+ reserveBeforeTransaction: chainReservations(handler.reserveBeforeTransaction, async (event, ctx) => {
43
43
  const cap = await capResolver(event, ctx);
44
+ await releaseExpiredCapReservations(ctx, cap.capName);
44
45
  await enforceCapAndMaybeNotify(ctx, {
45
46
  capName: cap.capName,
46
47
  periodStartIso: cap.periodStartIso,
@@ -51,47 +52,31 @@ export function withCapEnforcement(handler, capResolver) {
51
52
  ...(cap.amount !== undefined && { amount: cap.amount }),
52
53
  });
53
54
  const amount = cap.amount ?? 1;
54
- const reserved = await bookCapUsage(ctx, {
55
+ return reserveCalendarCap(ctx, {
55
56
  capName: cap.capName,
56
57
  amount,
57
58
  periodStartIso: cap.periodStartIso,
58
- outsideTransaction: true,
59
59
  guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
60
60
  });
61
- if (!reserved.isSuccess)
62
- throw reraiseAsKumikoError(reserved.error);
63
- return async () => {
64
- const released = await releaseCapUsage(ctx, {
65
- capName: cap.capName,
66
- amount,
67
- periodStartIso: cap.periodStartIso,
68
- outsideTransaction: true,
69
- });
70
- if (!released.isSuccess)
71
- throw reraiseAsKumikoError(released.error);
72
- };
73
- },
61
+ }),
74
62
  };
75
63
  }
76
64
  /**
77
65
  * Wrap a write-handler with rolling-window cap-enforcement.
78
66
  *
79
- * Same flow as `withCapEnforcement` but uses
80
- * `enforceRollingCapAndMaybeNotify` + dispatches
81
- * `cap-counter:write:increment-rolling` post-success.
67
+ * Same flow as `withCapEnforcement` but uses `enforceRollingCapAndMaybeNotify` and reserves by
68
+ * appending a `rolling-incremented` event before the handler transaction; a release appends
69
+ * `rolling-released`.
82
70
  *
83
- * **Notification-Storm-Caveat:** rolling-counter trackt KEIN
84
- * lastSoftWarnedAt — der Notifier feuert bei JEDEM Call solange
85
- * der counter im soft-Bereich ist. Caller sollte einen TTL-Cache
86
- * (`Map<capName, lastNotifiedAt>`) im notify-callback einbauen.
71
+ * Rolling counters track no lastSoftWarnedAt, so the notifier fires on every call while usage
72
+ * sits in the soft range; throttle inside the notify callback (e.g. a per-capName TTL cache).
87
73
  */
88
74
  export function withRollingCapEnforcement(handler, capResolver) {
89
75
  return {
90
- name: handler.name,
91
- schema: handler.schema,
92
- access: handler.access,
93
- handler: async (event, ctx) => {
76
+ ...handler,
77
+ reserveBeforeTransaction: chainReservations(handler.reserveBeforeTransaction, async (event, ctx) => {
94
78
  const cap = await capResolver(event, ctx);
79
+ await releaseExpiredCapReservations(ctx, cap.capName);
95
80
  await enforceRollingCapAndMaybeNotify(ctx, {
96
81
  capName: cap.capName,
97
82
  windowDays: cap.windowDays,
@@ -99,14 +84,13 @@ export function withRollingCapEnforcement(handler, capResolver) {
99
84
  profile: cap.profile,
100
85
  notify: cap.notify,
101
86
  });
102
- const result = await handler.handler(event, ctx);
103
- if (result.isSuccess) {
104
- await ctx.write(CapCounterHandlers.incrementRolling, {
105
- capName: cap.capName,
106
- amount: cap.amount ?? 1,
107
- });
108
- }
109
- return result;
110
- },
87
+ const amount = cap.amount ?? 1;
88
+ return reserveRollingCap(ctx, {
89
+ capName: cap.capName,
90
+ windowDays: cap.windowDays,
91
+ amount,
92
+ guardCurrentValue: (usage) => assertBelowHardCap(usage + amount - 1, cap),
93
+ });
94
+ }),
111
95
  };
112
96
  }
@@ -30,6 +30,7 @@ export function createChannelDiscordFeature(options = {}) {
30
30
  r.translations({ keys: { "delivery.channel.discord": { en: "Discord" } } });
31
31
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
32
32
  mode: channel.mode,
33
+ addressKind: "connection-name",
33
34
  send: channel.send,
34
35
  });
35
36
  });
@@ -27,6 +27,7 @@ export function createChannelSlackFeature(options = {}) {
27
27
  r.translations({ keys: { "delivery.channel.slack": { en: "Slack" } } });
28
28
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
29
29
  mode: channel.mode,
30
+ addressKind: "connection-name",
30
31
  send: channel.send,
31
32
  });
32
33
  });
@@ -31,6 +31,7 @@ export function createChannelTeamsFeature(options = {}) {
31
31
  r.translations({ keys: { "delivery.channel.teams": { en: "Microsoft Teams" } } });
32
32
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
33
33
  mode: channel.mode,
34
+ addressKind: "connection-name",
34
35
  send: channel.send,
35
36
  });
36
37
  });
@@ -18,6 +18,7 @@ export declare const tenantComplianceProfileEntity: import("@cosmicdrift/kumiko-
18
18
  } | undefined;
19
19
  readonly optionsQuery?: string;
20
20
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
21
+ readonly optionsAvailabilityQuery?: string;
21
22
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
22
23
  required: true;
23
24
  };
@@ -58,6 +59,7 @@ export declare const tenantComplianceProfileTable: import("@cosmicdrift/kumiko-f
58
59
  } | undefined;
59
60
  readonly optionsQuery?: string;
60
61
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
62
+ readonly optionsAvailabilityQuery?: string;
61
63
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
62
64
  required: true;
63
65
  };
@@ -0,0 +1,9 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import { type PruneEventsOptions } from "@cosmicdrift/kumiko-framework/pipeline";
3
+ export declare const DEFAULT_ATTEMPT_LOG_RETENTION_DAYS = 90;
4
+ export type AttemptLogRetentionOptions = Pick<PruneEventsOptions, "olderThan" | "olderThanDays">;
5
+ export declare function resolveAttemptLogRetentionDays(option: number | false | undefined): number | undefined;
6
+ export declare function runAttemptLogRetention(db: DbConnection, options: AttemptLogRetentionOptions): Promise<{
7
+ readonly deletedEvents: number;
8
+ readonly deletedRows: number;
9
+ }>;
@@ -0,0 +1,23 @@
1
+ import { deleteManyBatched } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { pruneEvents } from "@cosmicdrift/kumiko-framework/pipeline";
3
+ import { deliveryAttemptsTable } from "./tables.js";
4
+ export const DEFAULT_ATTEMPT_LOG_RETENTION_DAYS = 90;
5
+ const ROW_DELETE_BATCH_SIZE = 1000;
6
+ export function resolveAttemptLogRetentionDays(option) {
7
+ if (option === false)
8
+ return undefined;
9
+ const days = option ?? DEFAULT_ATTEMPT_LOG_RETENTION_DAYS;
10
+ if (!Number.isInteger(days) || days <= 0) {
11
+ throw new Error(`delivery: attemptLogRetentionDays must be a positive integer or false, got ${String(option)}`);
12
+ }
13
+ return days;
14
+ }
15
+ // Prunes the attempt events first and then the projection rows older than the same cutoff: a
16
+ // rebuild would not recreate rows whose events are gone, so they would otherwise stay forever.
17
+ // A lagging event consumer makes pruneEvents throw ConsumerLagError before anything is deleted;
18
+ // the caller retries on its next run.
19
+ export async function runAttemptLogRetention(db, options) {
20
+ const pruned = await pruneEvents(db, { ...options, aggregateTypes: ["deliveryAttempt"] });
21
+ const rows = await deleteManyBatched(db, deliveryAttemptsTable, { createdAt: { lt: pruned.cutoff } }, { limit: ROW_DELETE_BATCH_SIZE });
22
+ return { deletedEvents: pruned.deletedCount, deletedRows: rows.deleted };
23
+ }
@@ -1,5 +1,5 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
3
- import type { DeliveryLogEntry } from "./types.js";
3
+ import { type DeliveryLogEntry } from "./types.js";
4
4
  export declare function appendAttemptEvent(db: DbConnection, registry: Registry, attemptId: string, entry: DeliveryLogEntry): Promise<void>;
5
5
  export declare function logAttempt(db: DbConnection, registry: Registry, entry: DeliveryLogEntry): Promise<string>;
@@ -5,16 +5,35 @@
5
5
  import { append, getStreamVersion } from "@cosmicdrift/kumiko-framework/event-store";
6
6
  import { runProjectionsForEvent } from "@cosmicdrift/kumiko-framework/pipeline";
7
7
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
8
- import { DELIVERY_ATTEMPT_EVENT } from "./constants.js";
8
+ import { DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
9
9
  import { deliveryAttemptSchema } from "./events.js";
10
+ import { maskRecipientAddress } from "./mask-recipient-address.js";
11
+ import { isDeliveryChannelPlugin } from "./types.js";
12
+ // Unknown channels count as personal: masking is the fail-closed default.
13
+ function addressIsConnectionName(registry, channelName) {
14
+ return registry
15
+ .getExtensionUsages(DELIVERY_CHANNEL_EXTENSION)
16
+ .some((usage) => usage.entityName === channelName &&
17
+ isDeliveryChannelPlugin(usage.options) &&
18
+ usage.options.addressKind === "connection-name");
19
+ }
20
+ function loggedAddress(registry, entry) {
21
+ return addressIsConnectionName(registry, entry.channel)
22
+ ? entry.recipientAddress
23
+ : maskRecipientAddress(entry.recipientAddress);
24
+ }
10
25
  // Shared append + inline-projection write (low-level append() does not
11
26
  // auto-fire projections — only the dispatcher/executor paths do).
12
27
  async function writeAttemptEvent(db, registry, attemptId, expectedVersion, entry) {
13
28
  const { tenantId, ...rest } = entry;
29
+ // Masked here, the only place attempt events are written, so no event or projection row ever holds the full address.
14
30
  // Schema-parse to match ctx.appendEvent's guarantee: a payload drift between
15
31
  // service/job + feature-registration fails loudly here instead of landing on
16
32
  // the events-table and crashing a consumer later.
17
- const payload = deliveryAttemptSchema.parse(rest);
33
+ const payload = deliveryAttemptSchema.parse({
34
+ ...rest,
35
+ recipientAddress: loggedAddress(registry, entry),
36
+ });
18
37
  const stored = await append(db, {
19
38
  aggregateId: attemptId,
20
39
  aggregateType: "deliveryAttempt",
@@ -1,6 +1,6 @@
1
1
  import type { NotifyPriority } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare const DELIVERY_CHANNEL_EXTENSION: "deliveryChannel";
3
- export { 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";
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
4
  export declare const deliveryPriorityRank: Record<NotifyPriority, number>;
5
5
  export declare const DeliveryJobs: {
6
6
  readonly render: string;
@@ -3,7 +3,7 @@ import { DELIVERY_FEATURE, DeliveryJobNames } from "./public-names.js";
3
3
  // Extension-point name for delivery-channel plugins (channel-email,
4
4
  // channel-in-app, channel-push, ...).
5
5
  export const DELIVERY_CHANNEL_EXTENSION = "deliveryChannel";
6
- export { 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";
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
7
  // notify() priority → BullMQ job priority. Lower number = processed first; all
8
8
  // > 0 so prioritised delivery jobs never mix with BullMQ's "0 = unprioritised
9
9
  // FIFO" bucket. critical jobs jump ahead of normal/low in the worker queue.
@@ -36,3 +36,4 @@ export declare const deliveryAttemptSchema: z.ZodObject<{
36
36
  confirmed: z.ZodOptional<z.ZodLiteral<false>>;
37
37
  }, z.core.$strip>;
38
38
  export type DeliveryAttemptPayload = z.infer<typeof deliveryAttemptSchema>;
39
+ export declare const deliveryAttemptAddressErasedSchema: z.ZodObject<{}, z.core.$strip>;
@@ -28,3 +28,5 @@ export const deliveryAttemptSchema = z.object({
28
28
  // Provider accepted the request but does not confirm delivery (e.g. Teams 202).
29
29
  confirmed: z.literal(false).optional(),
30
30
  });
31
+ // No payload: the event only marks the attempt row's address as erased.
32
+ export const deliveryAttemptAddressErasedSchema = z.object({});
@@ -1,5 +1,6 @@
1
1
  import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export type DeliveryFeatureOptions = {
3
3
  readonly access?: readonly string[];
4
+ readonly attemptLogRetentionDays?: number | false;
4
5
  };
5
6
  export declare function createDeliveryFeature(options?: DeliveryFeatureOptions): FeatureDefinition;
@@ -1,7 +1,9 @@
1
- import { upsertByPk } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { updateMany, upsertByPk } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { access, defineFeature, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
3
- import { DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_CELL_COMPONENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_ERROR_CELL_COMPONENT, DELIVERY_LOG_SCREEN_ID, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_TIME_CELL_COMPONENT, DELIVERY_TYPE_CELL_COMPONENT, DeliveryJobNames, DeliveryQueries, } from "./constants.js";
4
- import { deliveryAttemptSchema } from "./events.js";
3
+ import { ConsumerLagError } from "@cosmicdrift/kumiko-framework/pipeline";
4
+ import { resolveAttemptLogRetentionDays, runAttemptLogRetention } from "./attempt-log-retention.js";
5
+ import { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_CELL_COMPONENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_ERROR_CELL_COMPONENT, DELIVERY_LOG_SCREEN_ID, DELIVERY_STATUS_CELL_COMPONENT, DELIVERY_TIME_CELL_COMPONENT, DELIVERY_TYPE_CELL_COMPONENT, DeliveryJobNames, DeliveryQueries, } from "./constants.js";
6
+ import { deliveryAttemptAddressErasedSchema, deliveryAttemptSchema } from "./events.js";
5
7
  import { logQuery } from "./handlers/log.query.js";
6
8
  import { preferencesQuery } from "./handlers/preferences.query.js";
7
9
  import { resubscribeAddressWrite } from "./handlers/resubscribe-address.write.js";
@@ -12,8 +14,10 @@ import { unsubscribeUserWrite } from "./handlers/unsubscribe-user.write.js";
12
14
  import { DELIVERY_I18N } from "./i18n.js";
13
15
  import { deliveryRenderJob, deliverySendJob } from "./jobs.js";
14
16
  import { deliveryAttemptsTable, deliveryAttemptsTableMeta, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
17
+ const ATTEMPT_LOG_RETENTION_ESCAPE_HATCH_REASON = "prunes delivery attempt events and log rows of every tenant";
15
18
  export function createDeliveryFeature(options) {
16
19
  const resolvedAccess = options?.access ?? access.admin;
20
+ const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
17
21
  return defineFeature("delivery", (r) => {
18
22
  r.describe("The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route.");
19
23
  r.uiHints({
@@ -54,6 +58,11 @@ export function createDeliveryFeature(options) {
54
58
  recipientAddress: { personal: { of: "recipientId", whenAbsent: "tenant" } },
55
59
  },
56
60
  });
61
+ // Written when the recipient is forgotten: clears the address on the projection row and, being
62
+ // an event, survives a projection rebuild (a plain UPDATE would be undone by the replay).
63
+ r.defineEvent("attempt-address-erased", deliveryAttemptAddressErasedSchema, {
64
+ piiFields: "none",
65
+ });
57
66
  // Inline projection that materialises every delivery attempt into
58
67
  // deliveryAttemptsTable. Runs in the SAME transaction as the low-level
59
68
  // append(), so callers see their write immediately — no dispatcher
@@ -89,6 +98,9 @@ export function createDeliveryFeature(options) {
89
98
  confirmed: p.confirmed ?? null,
90
99
  });
91
100
  },
101
+ [DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT]: async (event, tx) => {
102
+ await updateMany(tx, deliveryAttemptsTable, { recipientAddress: null }, { id: event.aggregateId });
103
+ },
92
104
  },
93
105
  });
94
106
  // Extension point: delivery-channels (email/in-app/push). Renderer-
@@ -106,6 +118,30 @@ export function createDeliveryFeature(options) {
106
118
  // from the delivery-service, hence manual trigger.
107
119
  r.job(DeliveryJobNames.render, { trigger: { manual: true }, retries: 3, backoff: "exponential" }, deliveryRenderJob);
108
120
  r.job(DeliveryJobNames.send, { trigger: { manual: true }, retries: 3, backoff: "exponential" }, deliverySendJob);
121
+ if (attemptLogRetentionDays !== undefined) {
122
+ r.job({
123
+ name: DeliveryJobNames.attemptLogRetention,
124
+ trigger: { cron: "15 3 * * *" },
125
+ concurrency: "skip",
126
+ escapeHatch: { reason: ATTEMPT_LOG_RETENTION_ESCAPE_HATCH_REASON },
127
+ handler: async (_payload, ctx) => {
128
+ const db = ctx.db.unsafeRaw(); // @cast-boundary db-operator — jobs never run inside a DbTx
129
+ try {
130
+ const pruned = await runAttemptLogRetention(db, {
131
+ olderThanDays: attemptLogRetentionDays,
132
+ });
133
+ ctx.log.info("delivery attempt log pruned", pruned);
134
+ }
135
+ catch (error) {
136
+ if (!(error instanceof ConsumerLagError))
137
+ throw error;
138
+ ctx.log.warn("delivery attempt log retention postponed: an event consumer lags", {
139
+ consumer: error.laggingConsumer,
140
+ });
141
+ }
142
+ },
143
+ });
144
+ }
109
145
  const handlers = {
110
146
  setPreference: r.writeHandler(setPreferenceWrite),
111
147
  unsubscribeAddress: r.writeHandler(unsubscribeAddressWrite),
@@ -1,8 +1,9 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
+ export { type AttemptLogRetentionOptions, DEFAULT_ATTEMPT_LOG_RETENTION_DAYS, runAttemptLogRetention, } from "./attempt-log-retention.js";
2
3
  export { type ChatWebhookChannelOptions, chatMessageText, createChatWebhookChannel, resolveChatWebhookTarget, toChannelResult, } from "./chat-webhook-channel.js";
3
4
  export { type ChatSendFailureCode, type ChatSendResult, type ChatWebhookRequest, type ChatWebhookResponse, type ChatWebhookTarget, chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
4
5
  export type { DeliveryStatusValue } from "./constants.js";
5
- export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
6
+ export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
6
7
  export { collectChannels, createDeliveryService, type DeliveryServiceOptions, type KillSwitchResolver, type RateLimitConfig, } from "./delivery-service.js";
7
8
  export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.js";
8
9
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
@@ -1,7 +1,8 @@
1
1
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
2
+ export { DEFAULT_ATTEMPT_LOG_RETENTION_DAYS, runAttemptLogRetention, } from "./attempt-log-retention.js";
2
3
  export { chatMessageText, createChatWebhookChannel, resolveChatWebhookTarget, toChannelResult, } from "./chat-webhook-channel.js";
3
4
  export { chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
4
- export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
5
+ export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
5
6
  export { collectChannels, createDeliveryService, } from "./delivery-service.js";
6
7
  export { createDeliveryFeature } from "./feature.js";
7
8
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
@@ -0,0 +1 @@
1
+ export declare function maskRecipientAddress(address: string | null): string | null;
@@ -0,0 +1,41 @@
1
+ import { isPiiCiphertext } from "@cosmicdrift/kumiko-framework/crypto";
2
+ const MASK = "***";
3
+ const VISIBLE_TAIL_LENGTH = 4;
4
+ const MIN_LENGTH_FOR_VISIBLE_TAIL = 8;
5
+ function maskedUrl(address) {
6
+ if (!URL.canParse(address))
7
+ return undefined;
8
+ const url = new URL(address);
9
+ if (url.protocol !== "http:" && url.protocol !== "https:")
10
+ return undefined;
11
+ return `${url.origin}/${MASK}`;
12
+ }
13
+ function maskedEmail(address) {
14
+ const parts = address.split("@");
15
+ const [local, domain] = parts;
16
+ if (parts.length !== 2 || !local || domain === undefined)
17
+ return undefined;
18
+ return `${local.charAt(0)}${MASK}@${domain}`;
19
+ }
20
+ // The attempt log is an audit trail, not a contact list: it keeps enough of the address to
21
+ // recognise where a message went, never the full value. URLs go first because webhook URLs can
22
+ // contain an "@" and their path carries the secret.
23
+ export function maskRecipientAddress(address) {
24
+ if (address === null)
25
+ return null;
26
+ return maskPlainAddress(address);
27
+ }
28
+ function maskPlainAddress(address) {
29
+ // isPiiCiphertext is a type predicate; its false branch would narrow `string` to `never`.
30
+ if (isPiiCiphertext(address))
31
+ return MASK;
32
+ return maskStructuredOrTail(address);
33
+ }
34
+ function maskStructuredOrTail(address) {
35
+ const structured = maskedUrl(address) ?? maskedEmail(address);
36
+ if (structured !== undefined)
37
+ return structured;
38
+ if (address.length < MIN_LENGTH_FOR_VISIBLE_TAIL)
39
+ return MASK;
40
+ return `${MASK}${address.slice(-VISIBLE_TAIL_LENGTH)}`;
41
+ }
@@ -34,4 +34,6 @@ export declare const DELIVERY_ATTEMPT_EVENT: "delivery:event:attempt";
34
34
  export declare const DeliveryJobNames: {
35
35
  readonly render: "render";
36
36
  readonly send: "send";
37
+ readonly attemptLogRetention: "attempt-log-retention";
37
38
  };
39
+ export declare const DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT: "delivery:event:attempt-address-erased";
@@ -48,4 +48,7 @@ export const DELIVERY_ATTEMPT_EVENT = "delivery:event:attempt";
48
48
  export const DeliveryJobNames = {
49
49
  render: "render",
50
50
  send: "send",
51
+ attemptLogRetention: "attempt-log-retention",
51
52
  };
53
+ // Clears recipientAddress on an attempt row after its recipient was forgotten.
54
+ export const DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT = "delivery:event:attempt-address-erased";
@@ -34,6 +34,7 @@ export type DeliveryChannel = {
34
34
  readonly name: string;
35
35
  readonly mode: DeliveryChannelMode;
36
36
  resolve?(userId: string, ctx: ChannelContext): Promise<string | null>;
37
+ readonly addressKind?: "connection-name";
37
38
  render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
38
39
  send(address: string, message: ChannelMessage, ctx: ChannelContext, rendered?: RenderedMessage): Promise<ChannelResult>;
39
40
  };
@@ -4,13 +4,17 @@ import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
4
4
  // queued — sent asynchronously via the delivery.send job; channels with a
5
5
  // render() additionally run through delivery.render first.
6
6
  export const DELIVERY_CHANNEL_MODES = ["inline", "queued"];
7
+ function hasOptionalHooks(o) {
8
+ return ((!("render" in o) || o.render === undefined || typeof o.render === "function") &&
9
+ (!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
10
+ (!("addressKind" in o) || o.addressKind === undefined || o.addressKind === "connection-name"));
11
+ }
7
12
  export function isDeliveryChannelPlugin(o) {
8
13
  return (typeof o === "object" &&
9
14
  o !== null &&
10
15
  "mode" in o &&
11
16
  DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
12
- (!("render" in o) || o.render === undefined || typeof o.render === "function") &&
13
- (!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
17
+ hasOptionalHooks(o) &&
14
18
  "send" in o &&
15
19
  typeof o.send === "function");
16
20
  }
@@ -40,6 +40,7 @@ export declare const accountEntity: import("@cosmicdrift/kumiko-types/fields").E
40
40
  } | undefined;
41
41
  readonly optionsQuery?: string;
42
42
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
43
+ readonly optionsAvailabilityQuery?: string;
43
44
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
44
45
  required: true;
45
46
  };
@@ -160,6 +161,7 @@ export declare const transactionEntity: import("@cosmicdrift/kumiko-types/fields
160
161
  } | undefined;
161
162
  readonly optionsQuery?: string;
162
163
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
164
+ readonly optionsAvailabilityQuery?: string;
163
165
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
164
166
  required: true;
165
167
  };
@@ -288,6 +290,7 @@ export declare const scheduleEntity: import("@cosmicdrift/kumiko-types/fields").
288
290
  } | undefined;
289
291
  readonly optionsQuery?: string;
290
292
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
293
+ readonly optionsAvailabilityQuery?: string;
291
294
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
292
295
  required: true;
293
296
  };
@@ -19,6 +19,7 @@ export type RenderRequest = {
19
19
  export type RenderResponse = {
20
20
  kind: "notification";
21
21
  html: string;
22
+ text?: string;
22
23
  } | {
23
24
  kind: "mail-html";
24
25
  html: string;
@@ -16,12 +16,10 @@ export async function adaptToFoundation(req, ctx, renderer = simpleRenderer) {
16
16
  throw new RendererError(`renderer-simple supports only kind="notification", got "${req.kind}"`, "invalid_payload");
17
17
  }
18
18
  const variables = await resolveNotificationVariables(req, ctx);
19
- const html = await renderer.render({
20
- template: req.payload.template ?? "",
21
- variables,
22
- locale: req.payload.locale,
23
- });
24
- return { kind: "notification", html };
19
+ const input = { template: req.payload.template ?? "", variables, locale: req.payload.locale };
20
+ const html = await renderer.render(input);
21
+ const text = renderer.renderText ? await renderer.renderText(input) : undefined;
22
+ return text === undefined ? { kind: "notification", html } : { kind: "notification", html, text };
25
23
  }
26
24
  export function createRendererSimpleFeature(options) {
27
25
  const renderer = options?.mailBranding
@@ -1,2 +1,3 @@
1
1
  export { createRendererSimpleFeature, type RendererSimpleOptions } from "./feature.js";
2
+ export type { MailBadge, MailChip, MailStage, MailStageState, MailTone } from "./mail-blocks.js";
2
3
  export { createSimpleRenderer, type LocalizedText, type MailBranding, simpleRenderer, } from "./simple-renderer.js";
@@ -0,0 +1,21 @@
1
+ export type MailTone = "neutral" | "info" | "success" | "warning" | "danger";
2
+ export type MailStageState = "done" | "current" | "upcoming";
3
+ export type MailBadge = {
4
+ readonly label: string;
5
+ readonly tone?: MailTone;
6
+ };
7
+ export type MailStage = {
8
+ readonly label: string;
9
+ readonly state: MailStageState;
10
+ };
11
+ export type MailChip = {
12
+ readonly label: string;
13
+ readonly tone?: MailTone;
14
+ };
15
+ export declare function parseBadge(value: unknown): MailBadge | undefined;
16
+ export declare function renderBadge(badge: MailBadge): string;
17
+ export declare function renderChips(chips: unknown): string;
18
+ export declare function renderStages(stages: unknown, primaryColor: string): string;
19
+ export declare function badgeText(badge: MailBadge): string;
20
+ export declare function stagesText(stages: unknown): string;
21
+ export declare function chipsText(chips: unknown): string;