@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
@@ -0,0 +1,107 @@
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
+ // Literal types (as const) keep the colors compile-time known, so they can go into style
3
+ // attributes without escaping.
4
+ const TONE_COLORS = {
5
+ neutral: { bg: "#f3f4f6", fg: "#374151" },
6
+ info: { bg: "#dbeafe", fg: "#1e40af" },
7
+ success: { bg: "#dcfce7", fg: "#166534" },
8
+ warning: { bg: "#fef3c7", fg: "#92400e" },
9
+ danger: { bg: "#fee2e2", fg: "#991b1b" },
10
+ };
11
+ const STAGE_STATES = ["done", "current", "upcoming"];
12
+ const STAGE_DONE_COLOR = "#16a34a";
13
+ const STAGE_UPCOMING_BG = "#e5e7eb";
14
+ const STAGE_UPCOMING_FG = "#9ca3af";
15
+ function isRecord(value) {
16
+ return typeof value === "object" && value !== null;
17
+ }
18
+ function isMailTone(value) {
19
+ return typeof value === "string" && Object.hasOwn(TONE_COLORS, value);
20
+ }
21
+ function toneColors(tone) {
22
+ return TONE_COLORS[isMailTone(tone) ? tone : "neutral"];
23
+ }
24
+ function isStageState(value) {
25
+ return STAGE_STATES.some((state) => state === value);
26
+ }
27
+ // Template data is runtime data (variables of a stored template): entries that do not match are
28
+ // skipped instead of failing the whole mail.
29
+ function isStage(value) {
30
+ return isRecord(value) && typeof value["label"] === "string" && isStageState(value["state"]);
31
+ }
32
+ function isChip(value) {
33
+ return isRecord(value) && typeof value["label"] === "string";
34
+ }
35
+ export function parseBadge(value) {
36
+ return isChip(value) && value.label !== "" ? value : undefined;
37
+ }
38
+ function validStages(value) {
39
+ return Array.isArray(value) ? value.filter(isStage) : [];
40
+ }
41
+ function validChips(value) {
42
+ return Array.isArray(value) ? value.filter(isChip) : [];
43
+ }
44
+ function pill(chip, margin) {
45
+ const { bg, fg } = toneColors(chip.tone);
46
+ return `<span style="display:inline-block;margin:${margin};padding:2px 10px;border-radius:999px;background:${bg};color:${fg};font-size:12px;font-weight:600;line-height:18px">${escapeHtml(chip.label)}</span>`;
47
+ }
48
+ export function renderBadge(badge) {
49
+ return `<p style="margin:0 0 12px">${pill(badge, "0")}</p>`;
50
+ }
51
+ export function renderChips(chips) {
52
+ const valid = validChips(chips);
53
+ if (valid.length === 0)
54
+ return "";
55
+ return `<p style="margin:0 0 16px">${valid.map((chip) => pill(chip, "0 6px 6px 0")).join("")}</p>`;
56
+ }
57
+ function stageMarker(stage, position, primaryColor) {
58
+ const base = "display:inline-block;width:24px;height:24px;line-height:24px;border-radius:12px;text-align:center;font-size:12px;font-weight:700";
59
+ const primary = escapeHtmlAttr(primaryColor);
60
+ if (stage.state === "done") {
61
+ return `<span style="${base};background:${STAGE_DONE_COLOR};color:#fff">&#10003;</span>`;
62
+ }
63
+ if (stage.state === "current") {
64
+ return `<span style="${base};background:#fff;border:2px solid ${primary};line-height:20px;width:20px;height:20px;color:${primary}">${position}</span>`;
65
+ }
66
+ return `<span style="${base};background:${STAGE_UPCOMING_BG};color:${STAGE_UPCOMING_FG}">${position}</span>`;
67
+ }
68
+ function stageCell(stage, position, primaryColor) {
69
+ const labelColor = stage.state === "upcoming" ? STAGE_UPCOMING_FG : "#111";
70
+ const weight = stage.state === "current" ? 700 : 400;
71
+ return `<td align="center" valign="top" style="padding:0 4px;font-family:sans-serif">${stageMarker(stage, position, primaryColor)}<div style="margin:4px 0 0;font-size:12px;font-weight:${weight};color:${labelColor}">${escapeHtml(stage.label)}</div></td>`;
72
+ }
73
+ function stageConnector(previous) {
74
+ const color = previous.state === "done" ? STAGE_DONE_COLOR : STAGE_UPCOMING_BG;
75
+ return `<td valign="top" style="padding:11px 0 0"><div style="width:24px;height:2px;line-height:2px;font-size:2px;background:${color}">&nbsp;</div></td>`;
76
+ }
77
+ export function renderStages(stages, primaryColor) {
78
+ const valid = validStages(stages);
79
+ if (valid.length === 0)
80
+ return "";
81
+ const cellsHtml = valid
82
+ .map((stage, index) => {
83
+ const previous = valid[index - 1];
84
+ const connector = previous ? stageConnector(previous) : "";
85
+ return `${connector}${stageCell(stage, index + 1, primaryColor)}`;
86
+ })
87
+ .join("");
88
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 16px"><tr>${cellsHtml}</tr></table>`;
89
+ }
90
+ const STAGE_TEXT_MARKER = {
91
+ done: "✓",
92
+ current: "▶",
93
+ upcoming: "○",
94
+ };
95
+ export function badgeText(badge) {
96
+ return `[${badge.label}]`;
97
+ }
98
+ export function stagesText(stages) {
99
+ return validStages(stages)
100
+ .map((stage) => `${STAGE_TEXT_MARKER[stage.state]} ${stage.label}`)
101
+ .join(" → ");
102
+ }
103
+ export function chipsText(chips) {
104
+ return validChips(chips)
105
+ .map((chip) => chip.label)
106
+ .join(" · ");
107
+ }
@@ -1,5 +1,6 @@
1
1
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
2
  import { renderSafeMarkdown } from "../page-render/index.js";
3
+ import { badgeText, chipsText, parseBadge, renderBadge, renderChips, renderStages, stagesText, } from "./mail-blocks.js";
3
4
  const DEFAULT_PRIMARY_COLOR = "#2563eb";
4
5
  const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
5
6
  function isHttpUrl(value) {
@@ -107,6 +108,7 @@ function templateContent(variables) {
107
108
  const data = variables; // @cast-boundary render-helper
108
109
  // Without structured fields, title + body become the header and a single text section.
109
110
  return {
111
+ badge: parseBadge(data.badge),
110
112
  header: data.header ?? data.title,
111
113
  sections: data.sections ?? (data.body ? [{ text: data.body }] : undefined),
112
114
  footer: data.footer,
@@ -122,6 +124,10 @@ function sectionText(section) {
122
124
  return section.markdown;
123
125
  if ("button" in section)
124
126
  return `${section.button.label}: ${section.button.url}`;
127
+ if ("stages" in section)
128
+ return stagesText(section.stages);
129
+ if ("chips" in section)
130
+ return chipsText(section.chips);
125
131
  return "";
126
132
  }
127
133
  function renderSection(section, primaryColor) {
@@ -138,6 +144,10 @@ function renderSection(section, primaryColor) {
138
144
  if ("button" in section) {
139
145
  return `<p style="margin:0 0 16px"><a href="${escapeHtml(section.button.url)}" style="display:inline-block;padding:10px 24px;background:${escapeHtmlAttr(primaryColor)};color:#fff;text-decoration:none;border-radius:4px;font-size:14px">${escapeHtml(section.button.label)}</a></p>`;
140
146
  }
147
+ if ("stages" in section)
148
+ return renderStages(section.stages, primaryColor);
149
+ if ("chips" in section)
150
+ return renderChips(section.chips);
141
151
  return "";
142
152
  }
143
153
  // Simple Renderer: turns structured email template data into HTML with inline CSS.
@@ -150,11 +160,13 @@ export function createSimpleRenderer(branding) {
150
160
  return {
151
161
  name: "simple",
152
162
  async render(input) {
153
- const { header, sections, footer } = templateContent(input.variables);
163
+ const { badge, header, sections, footer } = templateContent(input.variables);
154
164
  const parts = [];
155
165
  parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
156
166
  parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
157
167
  parts.push(renderBrandingHeader(branding, primaryColor, logoUrl));
168
+ if (badge)
169
+ parts.push(renderBadge(badge));
158
170
  if (header) {
159
171
  parts.push(`<h1 style="margin:0 0 24px;color:#111;font-size:20px;font-weight:600">${escapeHtml(header)}</h1>`);
160
172
  }
@@ -171,9 +183,15 @@ export function createSimpleRenderer(branding) {
171
183
  return parts.join("");
172
184
  },
173
185
  async renderText(input) {
174
- const { header, sections, footer } = templateContent(input.variables);
186
+ const { badge, header, sections, footer } = templateContent(input.variables);
175
187
  const brandingFooter = brandingFooterLines(branding, input.locale).join("\n");
176
- return [header, ...(sections ?? []).map(sectionText), footer, brandingFooter]
188
+ return [
189
+ badge ? badgeText(badge) : undefined,
190
+ header,
191
+ ...(sections ?? []).map(sectionText),
192
+ footer,
193
+ brandingFooter,
194
+ ]
177
195
  .filter((block) => block !== undefined && block !== "")
178
196
  .join("\n\n");
179
197
  },
@@ -42,6 +42,7 @@ export declare const templateResourceEntity: import("@cosmicdrift/kumiko-types/f
42
42
  } | undefined;
43
43
  readonly optionsQuery?: string;
44
44
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
45
+ readonly optionsAvailabilityQuery?: string;
45
46
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
46
47
  required: true;
47
48
  };
@@ -143,6 +144,7 @@ export declare const templateResourceEntity: import("@cosmicdrift/kumiko-types/f
143
144
  } | undefined;
144
145
  readonly optionsQuery?: string;
145
146
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
147
+ readonly optionsAvailabilityQuery?: string;
146
148
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
147
149
  required: true;
148
150
  };
@@ -195,6 +197,7 @@ export declare const templateResourceEntity: import("@cosmicdrift/kumiko-types/f
195
197
  } | undefined;
196
198
  readonly optionsQuery?: string;
197
199
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
200
+ readonly optionsAvailabilityQuery?: string;
198
201
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
199
202
  required: true;
200
203
  };
@@ -237,6 +240,7 @@ export declare const templateResourceEntity: import("@cosmicdrift/kumiko-types/f
237
240
  } | undefined;
238
241
  readonly optionsQuery?: string;
239
242
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
243
+ readonly optionsAvailabilityQuery?: string;
240
244
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
241
245
  required: true;
242
246
  };
@@ -284,6 +288,7 @@ export declare const templateResourcesTable: import("@cosmicdrift/kumiko-framewo
284
288
  } | undefined;
285
289
  readonly optionsQuery?: string;
286
290
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
291
+ readonly optionsAvailabilityQuery?: string;
287
292
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
288
293
  required: true;
289
294
  };
@@ -385,6 +390,7 @@ export declare const templateResourcesTable: import("@cosmicdrift/kumiko-framewo
385
390
  } | undefined;
386
391
  readonly optionsQuery?: string;
387
392
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
393
+ readonly optionsAvailabilityQuery?: string;
388
394
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
389
395
  required: true;
390
396
  };
@@ -437,6 +443,7 @@ export declare const templateResourcesTable: import("@cosmicdrift/kumiko-framewo
437
443
  } | undefined;
438
444
  readonly optionsQuery?: string;
439
445
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
446
+ readonly optionsAvailabilityQuery?: string;
440
447
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
441
448
  required: true;
442
449
  };
@@ -479,6 +486,7 @@ export declare const templateResourcesTable: import("@cosmicdrift/kumiko-framewo
479
486
  } | undefined;
480
487
  readonly optionsQuery?: string;
481
488
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
489
+ readonly optionsAvailabilityQuery?: string;
482
490
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
483
491
  required: true;
484
492
  };
@@ -63,6 +63,7 @@ export declare const userContentEntryEntity: import("@cosmicdrift/kumiko-types/f
63
63
  } | undefined;
64
64
  readonly optionsQuery?: string;
65
65
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
66
+ readonly optionsAvailabilityQuery?: string;
66
67
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
67
68
  required: true;
68
69
  };
@@ -164,6 +165,7 @@ export declare const userContentEntryEntity: import("@cosmicdrift/kumiko-types/f
164
165
  } | undefined;
165
166
  readonly optionsQuery?: string;
166
167
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
168
+ readonly optionsAvailabilityQuery?: string;
167
169
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
168
170
  required: true;
169
171
  };
@@ -232,6 +234,7 @@ export declare const userContentEntriesTable: import("@cosmicdrift/kumiko-framew
232
234
  } | undefined;
233
235
  readonly optionsQuery?: string;
234
236
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
237
+ readonly optionsAvailabilityQuery?: string;
235
238
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
236
239
  required: true;
237
240
  };
@@ -333,6 +336,7 @@ export declare const userContentEntriesTable: import("@cosmicdrift/kumiko-framew
333
336
  } | undefined;
334
337
  readonly optionsQuery?: string;
335
338
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
339
+ readonly optionsAvailabilityQuery?: string;
336
340
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
337
341
  required: true;
338
342
  };
@@ -69,6 +69,7 @@ export declare const tenantInvitationEntity: import("@cosmicdrift/kumiko-types/f
69
69
  } | undefined;
70
70
  readonly optionsQuery?: string;
71
71
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
72
+ readonly optionsAvailabilityQuery?: string;
72
73
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
73
74
  required: true;
74
75
  };
@@ -172,6 +173,7 @@ export declare const tenantInvitationsTable: import("@cosmicdrift/kumiko-framewo
172
173
  } | undefined;
173
174
  readonly optionsQuery?: string;
174
175
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
176
+ readonly optionsAvailabilityQuery?: string;
175
177
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
176
178
  required: true;
177
179
  };
@@ -66,6 +66,7 @@ export declare const tenantEntity: import("@cosmicdrift/kumiko-types/fields").En
66
66
  } | undefined;
67
67
  readonly optionsQuery?: string;
68
68
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
69
+ readonly optionsAvailabilityQuery?: string;
69
70
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
70
71
  required: true;
71
72
  };
@@ -217,6 +218,7 @@ export declare const tenantTable: import("@cosmicdrift/kumiko-framework/db").Ent
217
218
  } | undefined;
218
219
  readonly optionsQuery?: string;
219
220
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
221
+ readonly optionsAvailabilityQuery?: string;
220
222
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
221
223
  required: true;
222
224
  };
@@ -3,5 +3,6 @@ export { type AddOnDefinition, type AddOnMap, type ComposeAppInput, type Compose
3
3
  export { TIER_ENGINE_FEATURE, TierAssignmentSources, TierEngineHandlers, TierEngineQueries, } from "./constants.js";
4
4
  export { tierAssignmentEntity } from "./entity.js";
5
5
  export { type CreateTierEngineOptions, createTierEngineFeature, tierEngineFeature, } from "./feature.js";
6
+ export { createTierOptionGate, type TierOptionAvailability, type TierOptionGate, type TierOptionGateDeps, type TierOptionSpec, } from "./tier-option-gate.js";
6
7
  export { createTierResolver, type TierResolver, type TierResolverDeps, } from "./tier-resolver.js";
7
8
  export { isTrialActive, type TrialPolicy } from "./trial.js";
@@ -11,5 +11,6 @@ export { composeApp, } from "./compose-app.js";
11
11
  export { TIER_ENGINE_FEATURE, TierAssignmentSources, TierEngineHandlers, TierEngineQueries, } from "./constants.js";
12
12
  export { tierAssignmentEntity } from "./entity.js";
13
13
  export { createTierEngineFeature, tierEngineFeature, } from "./feature.js";
14
+ export { createTierOptionGate, } from "./tier-option-gate.js";
14
15
  export { createTierResolver, } from "./tier-resolver.js";
15
16
  export { isTrialActive } from "./trial.js";
@@ -0,0 +1,32 @@
1
+ import type { EntityTableMeta, SchemaTable, TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { type CapGuardContext, type CapLimitContext } from "../cap-counter/index.js";
5
+ export type TierOptionSpec<TCaps> = {
6
+ readonly field: string;
7
+ readonly options: readonly string[];
8
+ readonly isOptionAllowed: (option: string, caps: TCaps) => boolean;
9
+ readonly code: string;
10
+ readonly i18nKey: string;
11
+ /** Lets an update that resends the unchanged stored value of a no longer allowed
12
+ * option pass (edit forms submit the whole row). Without it the stored value is not looked up. */
13
+ readonly table?: SchemaTable | EntityTableMeta;
14
+ };
15
+ export type TierOptionAvailability = {
16
+ readonly value: string;
17
+ readonly disabled: boolean;
18
+ readonly hint?: string;
19
+ };
20
+ export type TierOptionGateDeps<TTier extends string, TCaps> = {
21
+ /** Ascending: the first tier that allows an option is the one named as required. */
22
+ readonly tierOrder: readonly TTier[];
23
+ readonly capsForTier: (tier: TTier, context: CapLimitContext) => TCaps | Promise<TCaps>;
24
+ readonly resolveTier: (db: TenantDb) => Promise<TTier>;
25
+ };
26
+ export type TierOptionGate<TTier extends string, TCaps> = {
27
+ readonly optionAvailability: (db: TenantDb, context: CapLimitContext, spec: Pick<TierOptionSpec<TCaps>, "options" | "isOptionAllowed">, hintFor: (requiredTier: TTier) => string) => Promise<readonly TierOptionAvailability[]>;
28
+ /** `recordId` is the id of the edited row; with `spec.table` it enables the unchanged-value pass. */
29
+ readonly checkTierOption: (ctx: CapGuardContext, value: unknown, spec: TierOptionSpec<TCaps>, recordId?: string) => Promise<WriteFailure | null>;
30
+ readonly withTierOptionGate: (handler: WriteHandlerDef, spec: TierOptionSpec<TCaps>) => WriteHandlerDef;
31
+ };
32
+ export declare function createTierOptionGate<TTier extends string, TCaps>(deps: TierOptionGateDeps<TTier, TCaps>): TierOptionGate<TTier, TCaps>;
@@ -0,0 +1,62 @@
1
+ // Tier-gated options of a select field: one spec drives the availability query the
2
+ // renderer shows (disabled option + "from <tier>" hint) and the write-path check that
3
+ // actually enforces it, so the two cannot drift apart.
4
+ import { UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
5
+ import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
6
+ import { readPayloadField, } from "../cap-counter/index.js";
7
+ function limitContextOf(ctx) {
8
+ return ctx.config ? { config: ctx.config } : {};
9
+ }
10
+ function recordIdOf(payload) {
11
+ return isPlainObject(payload) && typeof payload["id"] === "string" ? payload["id"] : undefined;
12
+ }
13
+ export function createTierOptionGate(deps) {
14
+ async function capsPerTier(context) {
15
+ return Promise.all(deps.tierOrder.map(async (tier) => ({ tier, caps: await deps.capsForTier(tier, context) })));
16
+ }
17
+ async function optionAvailability(db, context, spec, hintFor) {
18
+ const currentCaps = await deps.capsForTier(await deps.resolveTier(db), context);
19
+ const tiers = await capsPerTier(context);
20
+ return spec.options.map((value) => {
21
+ if (spec.isOptionAllowed(value, currentCaps))
22
+ return { value, disabled: false };
23
+ const required = tiers.find(({ caps }) => spec.isOptionAllowed(value, caps));
24
+ return required === undefined
25
+ ? { value, disabled: true }
26
+ : { value, disabled: true, hint: hintFor(required.tier) };
27
+ });
28
+ }
29
+ async function storedValueMatches(ctx, spec, value, recordId) {
30
+ if (spec.table === undefined || recordId === undefined)
31
+ return false;
32
+ const stored = await ctx.db.fetchOne(spec.table, { id: recordId });
33
+ return stored?.[spec.field] === value;
34
+ }
35
+ async function checkTierOption(ctx, value, spec, recordId) {
36
+ // skip: an absent or non-string value is the schema's business, not a tier violation.
37
+ if (typeof value !== "string")
38
+ return null;
39
+ const context = limitContextOf(ctx);
40
+ const currentCaps = await deps.capsForTier(await deps.resolveTier(ctx.db), context);
41
+ if (spec.isOptionAllowed(value, currentCaps))
42
+ return null;
43
+ if (await storedValueMatches(ctx, spec, value, recordId))
44
+ return null;
45
+ const required = (await capsPerTier(context)).find(({ caps }) => spec.isOptionAllowed(value, caps));
46
+ return writeFailure(new UnprocessableError(spec.code, {
47
+ i18nKey: spec.i18nKey,
48
+ details: { field: spec.field, value, requiredTier: required?.tier },
49
+ }));
50
+ }
51
+ function withTierOptionGate(handler, spec) {
52
+ return {
53
+ ...handler,
54
+ handler: async (event, ctx) => {
55
+ const value = readPayloadField(event.payload, spec.field);
56
+ const failure = await checkTierOption(ctx, value, spec, recordIdOf(event.payload));
57
+ return failure ?? handler.handler(event, ctx);
58
+ },
59
+ };
60
+ }
61
+ return { optionAvailability, checkTierOption, withTierOptionGate };
62
+ }
@@ -165,6 +165,7 @@ export declare const userEntity: import("@cosmicdrift/kumiko-types/fields").Enti
165
165
  } | undefined;
166
166
  readonly optionsQuery?: string;
167
167
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
168
+ readonly optionsAvailabilityQuery?: string;
168
169
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
169
170
  required: true;
170
171
  };
@@ -343,6 +344,7 @@ export declare const userTable: import("@cosmicdrift/kumiko-framework/db").Entit
343
344
  } | undefined;
344
345
  readonly optionsQuery?: string;
345
346
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
347
+ readonly optionsAvailabilityQuery?: string;
346
348
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
347
349
  required: true;
348
350
  };
@@ -74,6 +74,7 @@ export declare const exportJobEntity: import("@cosmicdrift/kumiko-types/fields")
74
74
  } | undefined;
75
75
  readonly optionsQuery?: string;
76
76
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
77
+ readonly optionsAvailabilityQuery?: string;
77
78
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
78
79
  required: true;
79
80
  };
@@ -249,6 +250,7 @@ export declare const exportJobsTable: import("@cosmicdrift/kumiko-framework/db")
249
250
  } | undefined;
250
251
  readonly optionsQuery?: string;
251
252
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
253
+ readonly optionsAvailabilityQuery?: string;
252
254
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
253
255
  required: true;
254
256
  };
@@ -1,4 +1,5 @@
1
- import { deliveryAttemptsTable } from "../../delivery/index.js";
1
+ import { appendEventInTenantDb, getStreamVersionInTenantDb, } from "@cosmicdrift/kumiko-framework/event-store";
2
+ import { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, deliveryAttemptsTable, } from "../../delivery/index.js";
2
3
  import { featureMounted } from "./feature-mounted.js";
3
4
  // userData-Hooks for delivery's attempt log (deferred from #797, closed by
4
5
  // #799). deliveryAttempt is an events-only aggregate — the export reads the
@@ -26,9 +27,27 @@ export const deliveryAttemptExportHook = async (ctx) => {
26
27
  })),
27
28
  };
28
29
  };
29
- export const deliveryAttemptDeleteHook = async () => {
30
- // Deliberate no-op: erasure runs via crypto-shredding — the forget pipeline
31
- // erases the recipient's DEK, which makes recipientAddress unreadable in
32
- // BOTH the append-only events and the projected rows (#799). A read-side
33
- // UPDATE here would be wiped on the next projection rebuild anyway.
30
+ // Both strategies erase the address. The DEK shredding of a forget covers KMS deployments; the
31
+ // erase event also covers plaintext mode, and unlike a read-side UPDATE it survives a projection
32
+ // rebuild. Rows without an address are skipped, which makes a second run a no-op.
33
+ export const deliveryAttemptDeleteHook = async (ctx) => {
34
+ // skip: delivery not mounted — its table doesn't exist, nothing to erase.
35
+ if (!featureMounted(ctx, "delivery"))
36
+ return;
37
+ const rows = await ctx.db.selectMany(deliveryAttemptsTable, {
38
+ tenantId: ctx.tenantId,
39
+ recipientId: ctx.userId,
40
+ });
41
+ for (const row of rows) {
42
+ if (row.recipientAddress === null)
43
+ continue;
44
+ await appendEventInTenantDb(ctx.db, {
45
+ aggregateType: "deliveryAttempt",
46
+ aggregateId: row.id,
47
+ expectedVersion: await getStreamVersionInTenantDb(ctx.db, row.id),
48
+ type: DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
49
+ payload: {},
50
+ metadata: { userId: "system" },
51
+ }, { registry: ctx.registry });
52
+ }
34
53
  };
@@ -139,6 +139,7 @@ export declare const waitlistEntryEntity: import("@cosmicdrift/kumiko-types/fiel
139
139
  } | undefined;
140
140
  readonly optionsQuery?: string;
141
141
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
142
+ readonly optionsAvailabilityQuery?: string;
142
143
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
143
144
  required: true;
144
145
  };
@@ -355,6 +356,7 @@ export declare const waitlistEntryTable: import("@cosmicdrift/kumiko-framework/d
355
356
  } | undefined;
356
357
  readonly optionsQuery?: string;
357
358
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
359
+ readonly optionsAvailabilityQuery?: string;
358
360
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
359
361
  required: true;
360
362
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.351.0",
3
+ "version": "0.352.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -503,12 +503,12 @@
503
503
  }
504
504
  },
505
505
  "dependencies": {
506
- "@cosmicdrift/kumiko-dispatcher-live": "0.351.0",
507
- "@cosmicdrift/kumiko-framework": "0.351.0",
508
- "@cosmicdrift/kumiko-headless": "0.351.0",
509
- "@cosmicdrift/kumiko-renderer": "0.351.0",
510
- "@cosmicdrift/kumiko-renderer-web": "0.351.0",
511
- "@cosmicdrift/kumiko-types": "0.351.0",
506
+ "@cosmicdrift/kumiko-dispatcher-live": "0.352.0",
507
+ "@cosmicdrift/kumiko-framework": "0.352.0",
508
+ "@cosmicdrift/kumiko-headless": "0.352.0",
509
+ "@cosmicdrift/kumiko-renderer": "0.352.0",
510
+ "@cosmicdrift/kumiko-renderer-web": "0.352.0",
511
+ "@cosmicdrift/kumiko-types": "0.352.0",
512
512
  "@mollie/api-client": "^4.5.0",
513
513
  "@node-rs/argon2": "^2.0.2",
514
514
  "@types/mailparser": "^3.4.6",
@@ -524,7 +524,7 @@
524
524
  "nodemailer": "^10.0.13",
525
525
  "qrcode": "^1.5.4",
526
526
  "react": "^19.2.6",
527
- "sharp": "^0.35.4",
527
+ "sharp": "^0.35.5",
528
528
  "stripe": "^22.6.2",
529
529
  "tailwind-merge": "^3.6.0",
530
530
  "temporal-polyfill": "^0.3.2",
@@ -1025,8 +1025,8 @@
1025
1025
  ],
1026
1026
  "devDependencies": {
1027
1027
  "@testing-library/user-event": "^14.6.1",
1028
- "@cosmicdrift/kumiko-locale-de": "0.351.0",
1029
- "@cosmicdrift/kumiko-locale-es": "0.351.0",
1028
+ "@cosmicdrift/kumiko-locale-de": "0.352.0",
1029
+ "@cosmicdrift/kumiko-locale-es": "0.352.0",
1030
1030
  "jsqr": "^1.4.0"
1031
1031
  }
1032
1032
  }
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "breaking",
5
+ "title": "Calendar cap reservations are stored in the new store_cap_reservations table",
6
+ "detail": "Capped writes now insert and delete rows in store_cap_reservations, so they fail until the table exists.",
7
+ "migration": "Generate and apply a migration with `kumiko-schema generate` — capped writes fail without the new store_cap_reservations table"
8
+ },
9
+ {
10
+ "version": "0.352.0",
11
+ "type": "improvement",
12
+ "title": "Reservations expire after 60 minutes and are swept before the next reserve of the same cap"
13
+ },
14
+ {
15
+ "version": "0.352.0",
16
+ "type": "fix",
17
+ "title": "withCapEnforcement keeps the wrapped handler's settings (rateLimit, additionalRateLimits) and composes with an inner reserveBeforeTransaction"
18
+ },
19
+ {
20
+ "version": "0.352.0",
21
+ "type": "improvement",
22
+ "title": "withRollingCapEnforcement reserves before the handler transaction and releases through the new rolling-released event, so rejected or failed writes no longer consume rolling cap"
23
+ },
24
+ {
25
+ "version": "0.352.0",
26
+ "type": "improvement",
27
+ "title": "createValueCapGuard rejects a written number outside the min and max of the tenant's tier",
28
+ "detail": "A number written by a handler can be bounded per tier\n`createValueCapGuard(resolveTierCaps)` returns `checkValueCap` and `withValueCap`. The spec names the payload field, optional `min` and `max` functions over the tier caps (`undefined` means unbounded), an error code and an i18n key. The value is read from the payload or, for entity updates, from `changes`; an absent or non-numeric value passes. A value below the minimum or above the maximum is rejected with `UnprocessableError` and `details: { field, value, min, max }`. `withValueCap` spreads the wrapped handler, so it composes with `withCapEnforcement`."
29
+ },
2
30
  {
3
31
  "version": "0.348.0",
4
32
  "type": "fix",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "breaking",
5
+ "title": "Delivery attempt log stores masked recipient addresses; attempts older than 90 days are pruned",
6
+ "detail": "The log query, the export and the stored events show masked addresses, and the feature registers the daily attempt-log-retention job.",
7
+ "migration": "Read recipients from your own data, not from the attempt log, because it only shows masked addresses now. Set `attemptLogRetentionDays` on createDeliveryFeature to another number of days, or to `false` to keep the log, if 90 days does not fit. Schedule nothing extra, the feature registers the cron job itself. Existing full-address entries are removed by the retention job once they are older than the window, or erased when their user is forgotten."
8
+ },
9
+ {
10
+ "version": "0.352.0",
11
+ "type": "improvement",
12
+ "title": "Forgetting a user erases the recipient address of their delivery attempts in plaintext mode too, through an event that survives projection rebuilds"
13
+ },
2
14
  {
3
15
  "version": "0.343.0",
4
16
  "type": "improvement",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "fix",
5
+ "title": "sharp is bumped to 0.35.5 to pick up the librsvg security fix",
6
+ "detail": "Bump sharp to 0.35.5 (librsvg advisory GHSA-WQ5F-XC86-PV6W)"
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "fix",
5
+ "title": "The legal-pages README imprint example uses a fictional demo identity",
6
+ "detail": "The legal-pages README seeds its imprint example with a fictional demo identity and address instead of a real person's details."
7
+ },
2
8
  {
3
9
  "version": "0.344.0",
4
10
  "type": "improvement",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "improvement",
5
+ "title": "Badge, stages track and chips for the simple mail renderer",
6
+ "detail": "Use badge, { stages } and { chips } in the template variables; the notification RenderResponse carries the matching plain text in text."
7
+ },
2
8
  {
3
9
  "version": "0.344.0",
4
10
  "type": "improvement",