@cosmicdrift/kumiko-samples 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 (49) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +2 -2
  3. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +95 -54
  4. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
  5. package/packages/bundled-features/src/cap-counter/changes.json +28 -0
  6. package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
  7. package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
  8. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
  9. package/packages/bundled-features/src/cap-counter/index.ts +9 -0
  10. package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
  11. package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
  12. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
  13. package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
  14. package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
  15. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
  16. package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
  17. package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
  18. package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
  19. package/packages/bundled-features/src/delivery/attempt-log-retention.ts +40 -0
  20. package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
  21. package/packages/bundled-features/src/delivery/changes.json +12 -0
  22. package/packages/bundled-features/src/delivery/constants.ts +1 -0
  23. package/packages/bundled-features/src/delivery/events.ts +3 -0
  24. package/packages/bundled-features/src/delivery/feature.ts +50 -2
  25. package/packages/bundled-features/src/delivery/index.ts +6 -0
  26. package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
  27. package/packages/bundled-features/src/delivery/public-names.ts +5 -0
  28. package/packages/bundled-features/src/delivery/types.ts +12 -2
  29. package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
  30. package/packages/bundled-features/src/legal-pages/README.md +3 -3
  31. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  32. package/packages/bundled-features/src/renderer-foundation/types.ts +1 -1
  33. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  34. package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
  35. package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
  36. package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
  37. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
  38. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  39. package/packages/bundled-features/src/tier-engine/index.ts +7 -0
  40. package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
  41. package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
  42. package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
  43. package/samples/apps/styleguide/src/app/shell.tsx +1 -1
  44. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
  45. package/samples/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
  46. package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
  47. package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
  48. package/samples/recipes/files-post-processing/package.json +1 -1
  49. package/samples/recipes/seed-migration/README.md +13 -1
@@ -0,0 +1,40 @@
1
+ import { isPiiCiphertext } from "@cosmicdrift/kumiko-framework/crypto";
2
+
3
+ const MASK = "***";
4
+ const VISIBLE_TAIL_LENGTH = 4;
5
+ const MIN_LENGTH_FOR_VISIBLE_TAIL = 8;
6
+
7
+ function maskedUrl(address: string): string | undefined {
8
+ if (!URL.canParse(address)) return undefined;
9
+ const url = new URL(address);
10
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
11
+ return `${url.origin}/${MASK}`;
12
+ }
13
+
14
+ function maskedEmail(address: string): string | undefined {
15
+ const parts = address.split("@");
16
+ const [local, domain] = parts;
17
+ if (parts.length !== 2 || !local || domain === undefined) return undefined;
18
+ return `${local.charAt(0)}${MASK}@${domain}`;
19
+ }
20
+
21
+ // The attempt log is an audit trail, not a contact list: it keeps enough of the address to
22
+ // recognise where a message went, never the full value. URLs go first because webhook URLs can
23
+ // contain an "@" and their path carries the secret.
24
+ export function maskRecipientAddress(address: string | null): string | null {
25
+ if (address === null) return null;
26
+ return maskPlainAddress(address);
27
+ }
28
+
29
+ function maskPlainAddress(address: string): string {
30
+ // isPiiCiphertext is a type predicate; its false branch would narrow `string` to `never`.
31
+ if (isPiiCiphertext(address)) return MASK;
32
+ return maskStructuredOrTail(address);
33
+ }
34
+
35
+ function maskStructuredOrTail(address: string): string {
36
+ const structured = maskedUrl(address) ?? maskedEmail(address);
37
+ if (structured !== undefined) return structured;
38
+ if (address.length < MIN_LENGTH_FOR_VISIBLE_TAIL) return MASK;
39
+ return `${MASK}${address.slice(-VISIBLE_TAIL_LENGTH)}`;
40
+ }
@@ -61,4 +61,9 @@ export const DELIVERY_ATTEMPT_EVENT = "delivery:event:attempt" as const;
61
61
  export const DeliveryJobNames = {
62
62
  render: "render",
63
63
  send: "send",
64
+ attemptLogRetention: "attempt-log-retention",
64
65
  } as const;
66
+
67
+ // Clears recipientAddress on an attempt row after its recipient was forgotten.
68
+ export const DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT =
69
+ "delivery:event:attempt-address-erased" as const;
@@ -64,6 +64,9 @@ export type DeliveryChannel = {
64
64
  // Absent for channels addressed only through `route` (tenant-owned chat
65
65
  // targets): deliverToUser skips them without writing a no_address row.
66
66
  resolve?(userId: string, ctx: ChannelContext): Promise<string | null>;
67
+ // "connection-name": the address is a tenant-chosen connection name (chat webhooks), not
68
+ // personal data, so the attempt log keeps it unmasked. Absent = personal, masked.
69
+ readonly addressKind?: "connection-name";
67
70
  render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
68
71
  send(
69
72
  address: string,
@@ -118,14 +121,21 @@ export type DeliveryService = {
118
121
  // payload — collectChannels derives it from the usage's entityName instead.
119
122
  export type DeliveryChannelPlugin = Omit<DeliveryChannel, "name">;
120
123
 
124
+ function hasOptionalHooks(o: object): boolean {
125
+ return (
126
+ (!("render" in o) || o.render === undefined || typeof o.render === "function") &&
127
+ (!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
128
+ (!("addressKind" in o) || o.addressKind === undefined || o.addressKind === "connection-name")
129
+ );
130
+ }
131
+
121
132
  export function isDeliveryChannelPlugin(o: unknown): o is DeliveryChannelPlugin {
122
133
  return (
123
134
  typeof o === "object" &&
124
135
  o !== null &&
125
136
  "mode" in o &&
126
137
  DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
127
- (!("render" in o) || o.render === undefined || typeof o.render === "function") &&
128
- (!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
138
+ hasOptionalHooks(o) &&
129
139
  "send" in o &&
130
140
  typeof o.send === "function"
131
141
  );
@@ -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",
@@ -201,10 +201,10 @@ await seedTextBlock(db, {
201
201
  title: "Impressum",
202
202
  content: `## Angaben gemäß § 5 TMG
203
203
 
204
- **Marc Frost**
204
+ **Alex Morgan**
205
205
 
206
- Slevogtstr. 10
207
- 04159 Leipzig
206
+ Musterstraße 1
207
+ 12345 Musterstadt
208
208
 
209
209
  ## Kontakt
210
210
 
@@ -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",
@@ -12,7 +12,7 @@ export type RenderRequest =
12
12
  | { kind: "image-snapshot"; payload: DocumentPayload; options?: ImageOptions };
13
13
 
14
14
  export type RenderResponse =
15
- | { kind: "notification"; html: string }
15
+ | { kind: "notification"; html: string; text?: string }
16
16
  | { kind: "mail-html"; html: string; text: string }
17
17
  | { kind: "document-pdf"; pdfBytes: Uint8Array; pageCount: number; sizeBytes: number }
18
18
  | {
@@ -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",
@@ -31,12 +31,10 @@ export async function adaptToFoundation(
31
31
  );
32
32
  }
33
33
  const variables = await resolveNotificationVariables(req, ctx);
34
- const html = await renderer.render({
35
- template: req.payload.template ?? "",
36
- variables,
37
- locale: req.payload.locale,
38
- });
39
- return { kind: "notification", html };
34
+ const input = { template: req.payload.template ?? "", variables, locale: req.payload.locale };
35
+ const html = await renderer.render(input);
36
+ const text = renderer.renderText ? await renderer.renderText(input) : undefined;
37
+ return text === undefined ? { kind: "notification", html } : { kind: "notification", html, text };
40
38
  }
41
39
 
42
40
  export type RendererSimpleOptions = {
@@ -1,4 +1,5 @@
1
1
  export { createRendererSimpleFeature, type RendererSimpleOptions } from "./feature.js";
2
+ export type { MailBadge, MailChip, MailStage, MailStageState, MailTone } from "./mail-blocks.js";
2
3
  export {
3
4
  createSimpleRenderer,
4
5
  type LocalizedText,
@@ -0,0 +1,138 @@
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
+
3
+ export type MailTone = "neutral" | "info" | "success" | "warning" | "danger";
4
+ export type MailStageState = "done" | "current" | "upcoming";
5
+
6
+ export type MailBadge = { readonly label: string; readonly tone?: MailTone };
7
+ export type MailStage = { readonly label: string; readonly state: MailStageState };
8
+ export type MailChip = { readonly label: string; readonly tone?: MailTone };
9
+
10
+ // Literal types (as const) keep the colors compile-time known, so they can go into style
11
+ // attributes without escaping.
12
+ const TONE_COLORS = {
13
+ neutral: { bg: "#f3f4f6", fg: "#374151" },
14
+ info: { bg: "#dbeafe", fg: "#1e40af" },
15
+ success: { bg: "#dcfce7", fg: "#166534" },
16
+ warning: { bg: "#fef3c7", fg: "#92400e" },
17
+ danger: { bg: "#fee2e2", fg: "#991b1b" },
18
+ } as const satisfies Readonly<Record<MailTone, { readonly bg: string; readonly fg: string }>>;
19
+
20
+ type ToneColors = (typeof TONE_COLORS)[MailTone];
21
+ type PillMargin = "0" | "0 6px 6px 0";
22
+
23
+ const STAGE_STATES: readonly MailStageState[] = ["done", "current", "upcoming"];
24
+ const STAGE_DONE_COLOR = "#16a34a";
25
+ const STAGE_UPCOMING_BG = "#e5e7eb";
26
+ const STAGE_UPCOMING_FG = "#9ca3af";
27
+
28
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
29
+ return typeof value === "object" && value !== null;
30
+ }
31
+
32
+ function isMailTone(value: unknown): value is MailTone {
33
+ return typeof value === "string" && Object.hasOwn(TONE_COLORS, value);
34
+ }
35
+
36
+ function toneColors(tone: unknown): ToneColors {
37
+ return TONE_COLORS[isMailTone(tone) ? tone : "neutral"];
38
+ }
39
+
40
+ function isStageState(value: unknown): value is MailStageState {
41
+ return STAGE_STATES.some((state) => state === value);
42
+ }
43
+
44
+ // Template data is runtime data (variables of a stored template): entries that do not match are
45
+ // skipped instead of failing the whole mail.
46
+ function isStage(value: unknown): value is MailStage {
47
+ return isRecord(value) && typeof value["label"] === "string" && isStageState(value["state"]);
48
+ }
49
+
50
+ function isChip(value: unknown): value is MailChip {
51
+ return isRecord(value) && typeof value["label"] === "string";
52
+ }
53
+
54
+ export function parseBadge(value: unknown): MailBadge | undefined {
55
+ return isChip(value) && value.label !== "" ? value : undefined;
56
+ }
57
+
58
+ function validStages(value: unknown): readonly MailStage[] {
59
+ return Array.isArray(value) ? value.filter(isStage) : [];
60
+ }
61
+
62
+ function validChips(value: unknown): readonly MailChip[] {
63
+ return Array.isArray(value) ? value.filter(isChip) : [];
64
+ }
65
+
66
+ function pill(chip: MailChip, margin: PillMargin): string {
67
+ const { bg, fg } = toneColors(chip.tone);
68
+ 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>`;
69
+ }
70
+
71
+ export function renderBadge(badge: MailBadge): string {
72
+ return `<p style="margin:0 0 12px">${pill(badge, "0")}</p>`;
73
+ }
74
+
75
+ export function renderChips(chips: unknown): string {
76
+ const valid = validChips(chips);
77
+ if (valid.length === 0) return "";
78
+ return `<p style="margin:0 0 16px">${valid.map((chip) => pill(chip, "0 6px 6px 0")).join("")}</p>`;
79
+ }
80
+
81
+ function stageMarker(stage: MailStage, position: number, primaryColor: string): string {
82
+ const base =
83
+ "display:inline-block;width:24px;height:24px;line-height:24px;border-radius:12px;text-align:center;font-size:12px;font-weight:700";
84
+ const primary = escapeHtmlAttr(primaryColor);
85
+ if (stage.state === "done") {
86
+ return `<span style="${base};background:${STAGE_DONE_COLOR};color:#fff">&#10003;</span>`;
87
+ }
88
+ if (stage.state === "current") {
89
+ return `<span style="${base};background:#fff;border:2px solid ${primary};line-height:20px;width:20px;height:20px;color:${primary}">${position}</span>`;
90
+ }
91
+ return `<span style="${base};background:${STAGE_UPCOMING_BG};color:${STAGE_UPCOMING_FG}">${position}</span>`;
92
+ }
93
+
94
+ function stageCell(stage: MailStage, position: number, primaryColor: string): string {
95
+ const labelColor = stage.state === "upcoming" ? STAGE_UPCOMING_FG : "#111";
96
+ const weight = stage.state === "current" ? 700 : 400;
97
+ 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>`;
98
+ }
99
+
100
+ function stageConnector(previous: MailStage): string {
101
+ const color = previous.state === "done" ? STAGE_DONE_COLOR : STAGE_UPCOMING_BG;
102
+ 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>`;
103
+ }
104
+
105
+ export function renderStages(stages: unknown, primaryColor: string): string {
106
+ const valid = validStages(stages);
107
+ if (valid.length === 0) return "";
108
+ const cellsHtml = valid
109
+ .map((stage, index) => {
110
+ const previous = valid[index - 1];
111
+ const connector = previous ? stageConnector(previous) : "";
112
+ return `${connector}${stageCell(stage, index + 1, primaryColor)}`;
113
+ })
114
+ .join("");
115
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 16px"><tr>${cellsHtml}</tr></table>`;
116
+ }
117
+
118
+ const STAGE_TEXT_MARKER: Readonly<Record<MailStageState, string>> = {
119
+ done: "✓",
120
+ current: "▶",
121
+ upcoming: "○",
122
+ };
123
+
124
+ export function badgeText(badge: MailBadge): string {
125
+ return `[${badge.label}]`;
126
+ }
127
+
128
+ export function stagesText(stages: unknown): string {
129
+ return validStages(stages)
130
+ .map((stage) => `${STAGE_TEXT_MARKER[stage.state]} ${stage.label}`)
131
+ .join(" → ");
132
+ }
133
+
134
+ export function chipsText(chips: unknown): string {
135
+ return validChips(chips)
136
+ .map((chip) => chip.label)
137
+ .join(" · ");
138
+ }
@@ -1,6 +1,18 @@
1
1
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
2
  import type { NotificationRenderer } from "../delivery/index.js";
3
3
  import { renderSafeMarkdown } from "../page-render/index.js";
4
+ import {
5
+ badgeText,
6
+ chipsText,
7
+ type MailBadge,
8
+ type MailChip,
9
+ type MailStage,
10
+ parseBadge,
11
+ renderBadge,
12
+ renderChips,
13
+ renderStages,
14
+ stagesText,
15
+ } from "./mail-blocks.js";
4
16
 
5
17
  export type MailBranding = {
6
18
  /** Shown as text when there is no logo, and as the logo alt text. */
@@ -170,10 +182,13 @@ type Section =
170
182
  | { readonly text: string }
171
183
  | { readonly heading: string }
172
184
  | { readonly markdown: string }
173
- | { readonly button: { readonly label: string; readonly url: string } };
185
+ | { readonly button: { readonly label: string; readonly url: string } }
186
+ | { readonly stages: readonly MailStage[] }
187
+ | { readonly chips: readonly MailChip[] };
174
188
 
175
189
  type EmailTemplateData = {
176
190
  // Preferred: structured email data
191
+ readonly badge?: MailBadge;
177
192
  readonly header?: string;
178
193
  readonly sections?: readonly Section[];
179
194
  readonly footer?: string;
@@ -183,6 +198,7 @@ type EmailTemplateData = {
183
198
  };
184
199
 
185
200
  function templateContent(variables: Readonly<Record<string, unknown>>): {
201
+ readonly badge: MailBadge | undefined;
186
202
  readonly header: string | undefined;
187
203
  readonly sections: readonly Section[] | undefined;
188
204
  readonly footer: string | undefined;
@@ -190,6 +206,7 @@ function templateContent(variables: Readonly<Record<string, unknown>>): {
190
206
  const data = variables as EmailTemplateData; // @cast-boundary render-helper
191
207
  // Without structured fields, title + body become the header and a single text section.
192
208
  return {
209
+ badge: parseBadge(data.badge),
193
210
  header: data.header ?? data.title,
194
211
  sections: data.sections ?? (data.body ? [{ text: data.body }] : undefined),
195
212
  footer: data.footer,
@@ -202,6 +219,8 @@ function sectionText(section: Section): string {
202
219
  if ("heading" in section) return section.heading;
203
220
  if ("markdown" in section) return section.markdown;
204
221
  if ("button" in section) return `${section.button.label}: ${section.button.url}`;
222
+ if ("stages" in section) return stagesText(section.stages);
223
+ if ("chips" in section) return chipsText(section.chips);
205
224
  return "";
206
225
  }
207
226
 
@@ -219,6 +238,8 @@ function renderSection(section: Section, primaryColor: string): string {
219
238
  if ("button" in section) {
220
239
  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>`;
221
240
  }
241
+ if ("stages" in section) return renderStages(section.stages, primaryColor);
242
+ if ("chips" in section) return renderChips(section.chips);
222
243
  return "";
223
244
  }
224
245
 
@@ -232,13 +253,15 @@ export function createSimpleRenderer(branding?: MailBranding): NotificationRende
232
253
  name: "simple",
233
254
 
234
255
  async render(input) {
235
- const { header, sections, footer } = templateContent(input.variables);
256
+ const { badge, header, sections, footer } = templateContent(input.variables);
236
257
 
237
258
  const parts: string[] = [];
238
259
  parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
239
260
  parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
240
261
  parts.push(renderBrandingHeader(branding, primaryColor, logoUrl));
241
262
 
263
+ if (badge) parts.push(renderBadge(badge));
264
+
242
265
  if (header) {
243
266
  parts.push(
244
267
  `<h1 style="margin:0 0 24px;color:#111;font-size:20px;font-weight:600">${escapeHtml(header)}</h1>`,
@@ -263,9 +286,15 @@ export function createSimpleRenderer(branding?: MailBranding): NotificationRende
263
286
  },
264
287
 
265
288
  async renderText(input) {
266
- const { header, sections, footer } = templateContent(input.variables);
289
+ const { badge, header, sections, footer } = templateContent(input.variables);
267
290
  const brandingFooter = brandingFooterLines(branding, input.locale).join("\n");
268
- return [header, ...(sections ?? []).map(sectionText), footer, brandingFooter]
291
+ return [
292
+ badge ? badgeText(badge) : undefined,
293
+ header,
294
+ ...(sections ?? []).map(sectionText),
295
+ footer,
296
+ brandingFooter,
297
+ ]
269
298
  .filter((block): block is string => block !== undefined && block !== "")
270
299
  .join("\n\n");
271
300
  },
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "improvement",
5
+ "title": "createTierOptionGate builds the availability rows and gates select options by tier on write",
6
+ "detail": "withTierOptionGate rejects a disallowed option with the lowest tier that allows it and lets an unchanged stored value pass on update."
7
+ },
2
8
  {
3
9
  "version": "0.278.0",
4
10
  "type": "breaking",
@@ -29,6 +29,13 @@ export {
29
29
  createTierEngineFeature,
30
30
  tierEngineFeature,
31
31
  } from "./feature.js";
32
+ export {
33
+ createTierOptionGate,
34
+ type TierOptionAvailability,
35
+ type TierOptionGate,
36
+ type TierOptionGateDeps,
37
+ type TierOptionSpec,
38
+ } from "./tier-option-gate.js";
32
39
  export {
33
40
  createTierResolver,
34
41
  type TierResolver,
@@ -0,0 +1,148 @@
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
+
5
+ import type { EntityTableMeta, SchemaTable, TenantDb } from "@cosmicdrift/kumiko-framework/db";
6
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
7
+ import {
8
+ UnprocessableError,
9
+ type WriteFailure,
10
+ writeFailure,
11
+ } from "@cosmicdrift/kumiko-framework/errors";
12
+ import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
13
+ import {
14
+ type CapGuardContext,
15
+ type CapLimitContext,
16
+ readPayloadField,
17
+ } from "../cap-counter/index.js";
18
+
19
+ export type TierOptionSpec<TCaps> = {
20
+ readonly field: string;
21
+ readonly options: readonly string[];
22
+ readonly isOptionAllowed: (option: string, caps: TCaps) => boolean;
23
+ readonly code: string;
24
+ readonly i18nKey: string;
25
+ /** Lets an update that resends the unchanged stored value of a no longer allowed
26
+ * option pass (edit forms submit the whole row). Without it the stored value is not looked up. */
27
+ readonly table?: SchemaTable | EntityTableMeta;
28
+ };
29
+
30
+ export type TierOptionAvailability = {
31
+ readonly value: string;
32
+ readonly disabled: boolean;
33
+ readonly hint?: string;
34
+ };
35
+
36
+ export type TierOptionGateDeps<TTier extends string, TCaps> = {
37
+ /** Ascending: the first tier that allows an option is the one named as required. */
38
+ readonly tierOrder: readonly TTier[];
39
+ readonly capsForTier: (tier: TTier, context: CapLimitContext) => TCaps | Promise<TCaps>;
40
+ readonly resolveTier: (db: TenantDb) => Promise<TTier>;
41
+ };
42
+
43
+ export type TierOptionGate<TTier extends string, TCaps> = {
44
+ readonly optionAvailability: (
45
+ db: TenantDb,
46
+ context: CapLimitContext,
47
+ spec: Pick<TierOptionSpec<TCaps>, "options" | "isOptionAllowed">,
48
+ hintFor: (requiredTier: TTier) => string,
49
+ ) => Promise<readonly TierOptionAvailability[]>;
50
+ /** `recordId` is the id of the edited row; with `spec.table` it enables the unchanged-value pass. */
51
+ readonly checkTierOption: (
52
+ ctx: CapGuardContext,
53
+ value: unknown,
54
+ spec: TierOptionSpec<TCaps>,
55
+ recordId?: string,
56
+ ) => Promise<WriteFailure | null>;
57
+ readonly withTierOptionGate: (
58
+ handler: WriteHandlerDef,
59
+ spec: TierOptionSpec<TCaps>,
60
+ ) => WriteHandlerDef;
61
+ };
62
+
63
+ function limitContextOf(ctx: CapGuardContext): CapLimitContext {
64
+ return ctx.config ? { config: ctx.config } : {};
65
+ }
66
+
67
+ function recordIdOf(payload: unknown): string | undefined {
68
+ return isPlainObject(payload) && typeof payload["id"] === "string" ? payload["id"] : undefined;
69
+ }
70
+
71
+ export function createTierOptionGate<TTier extends string, TCaps>(
72
+ deps: TierOptionGateDeps<TTier, TCaps>,
73
+ ): TierOptionGate<TTier, TCaps> {
74
+ async function capsPerTier(
75
+ context: CapLimitContext,
76
+ ): Promise<readonly { readonly tier: TTier; readonly caps: TCaps }[]> {
77
+ return Promise.all(
78
+ deps.tierOrder.map(async (tier) => ({ tier, caps: await deps.capsForTier(tier, context) })),
79
+ );
80
+ }
81
+
82
+ async function optionAvailability(
83
+ db: TenantDb,
84
+ context: CapLimitContext,
85
+ spec: Pick<TierOptionSpec<TCaps>, "options" | "isOptionAllowed">,
86
+ hintFor: (requiredTier: TTier) => string,
87
+ ): Promise<readonly TierOptionAvailability[]> {
88
+ const currentCaps = await deps.capsForTier(await deps.resolveTier(db), context);
89
+ const tiers = await capsPerTier(context);
90
+ return spec.options.map((value) => {
91
+ if (spec.isOptionAllowed(value, currentCaps)) return { value, disabled: false };
92
+ const required = tiers.find(({ caps }) => spec.isOptionAllowed(value, caps));
93
+ return required === undefined
94
+ ? { value, disabled: true }
95
+ : { value, disabled: true, hint: hintFor(required.tier) };
96
+ });
97
+ }
98
+
99
+ async function storedValueMatches(
100
+ ctx: CapGuardContext,
101
+ spec: TierOptionSpec<TCaps>,
102
+ value: string,
103
+ recordId: string | undefined,
104
+ ): Promise<boolean> {
105
+ if (spec.table === undefined || recordId === undefined) return false;
106
+ const stored = await ctx.db.fetchOne<Record<string, unknown>>(spec.table, { id: recordId });
107
+ return stored?.[spec.field] === value;
108
+ }
109
+
110
+ async function checkTierOption(
111
+ ctx: CapGuardContext,
112
+ value: unknown,
113
+ spec: TierOptionSpec<TCaps>,
114
+ recordId?: string,
115
+ ): Promise<WriteFailure | null> {
116
+ // skip: an absent or non-string value is the schema's business, not a tier violation.
117
+ if (typeof value !== "string") return null;
118
+ const context = limitContextOf(ctx);
119
+ const currentCaps = await deps.capsForTier(await deps.resolveTier(ctx.db), context);
120
+ if (spec.isOptionAllowed(value, currentCaps)) return null;
121
+ if (await storedValueMatches(ctx, spec, value, recordId)) return null;
122
+ const required = (await capsPerTier(context)).find(({ caps }) =>
123
+ spec.isOptionAllowed(value, caps),
124
+ );
125
+ return writeFailure(
126
+ new UnprocessableError(spec.code, {
127
+ i18nKey: spec.i18nKey,
128
+ details: { field: spec.field, value, requiredTier: required?.tier },
129
+ }),
130
+ );
131
+ }
132
+
133
+ function withTierOptionGate(
134
+ handler: WriteHandlerDef,
135
+ spec: TierOptionSpec<TCaps>,
136
+ ): WriteHandlerDef {
137
+ return {
138
+ ...handler,
139
+ handler: async (event, ctx) => {
140
+ const value = readPayloadField(event.payload, spec.field);
141
+ const failure = await checkTierOption(ctx, value, spec, recordIdOf(event.payload));
142
+ return failure ?? handler.handler(event, ctx);
143
+ },
144
+ };
145
+ }
146
+
147
+ return { optionAvailability, checkTierOption, withTierOptionGate };
148
+ }
@@ -1,5 +1,12 @@
1
1
  import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
2
- import { deliveryAttemptsTable } from "../../delivery/index.js";
2
+ import {
3
+ appendEventInTenantDb,
4
+ getStreamVersionInTenantDb,
5
+ } from "@cosmicdrift/kumiko-framework/event-store";
6
+ import {
7
+ DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
8
+ deliveryAttemptsTable,
9
+ } from "../../delivery/index.js";
3
10
  import { featureMounted } from "./feature-mounted.js";
4
11
 
5
12
  // userData-Hooks for delivery's attempt log (deferred from #797, closed by
@@ -28,9 +35,32 @@ export const deliveryAttemptExportHook: UserDataExportHook = async (ctx) => {
28
35
  };
29
36
  };
30
37
 
31
- export const deliveryAttemptDeleteHook: UserDataDeleteHook = async () => {
32
- // Deliberate no-op: erasure runs via crypto-shredding — the forget pipeline
33
- // erases the recipient's DEK, which makes recipientAddress unreadable in
34
- // BOTH the append-only events and the projected rows (#799). A read-side
35
- // UPDATE here would be wiped on the next projection rebuild anyway.
38
+ // Both strategies erase the address. The DEK shredding of a forget covers KMS deployments; the
39
+ // erase event also covers plaintext mode, and unlike a read-side UPDATE it survives a projection
40
+ // rebuild. Rows without an address are skipped, which makes a second run a no-op.
41
+ export const deliveryAttemptDeleteHook: UserDataDeleteHook = async (ctx) => {
42
+ // skip: delivery not mounted — its table doesn't exist, nothing to erase.
43
+ if (!featureMounted(ctx, "delivery")) return;
44
+ const rows = await ctx.db.selectMany<{ id: string; recipientAddress: string | null }>(
45
+ deliveryAttemptsTable,
46
+ {
47
+ tenantId: ctx.tenantId,
48
+ recipientId: ctx.userId,
49
+ },
50
+ );
51
+ for (const row of rows) {
52
+ if (row.recipientAddress === null) continue;
53
+ await appendEventInTenantDb(
54
+ ctx.db,
55
+ {
56
+ aggregateType: "deliveryAttempt",
57
+ aggregateId: row.id,
58
+ expectedVersion: await getStreamVersionInTenantDb(ctx.db, row.id),
59
+ type: DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
60
+ payload: {},
61
+ metadata: { userId: "system" },
62
+ },
63
+ { registry: ctx.registry },
64
+ );
65
+ }
36
66
  };
@@ -306,7 +306,7 @@ export const ASSET_TEMPLATES: ReadonlyArray<AssetTemplate> = [
306
306
  // treffen ohne Sonderfall. Wird nur für status==="lent" konsultiert.
307
307
  export const OWNERS: ReadonlyArray<string> = [
308
308
  "",
309
- "Marc Frost",
309
+ "Alex Morgan",
310
310
  "Anna Weber",
311
311
  "Lars Bergmann",
312
312
  "Sina Klein",
@@ -435,7 +435,7 @@ export const REPORTERS: ReadonlyArray<string> = [
435
435
  // unzugewiesen damit die Liste nicht zu uniform aussieht.
436
436
  export const ASSIGNEES: ReadonlyArray<string> = [
437
437
  "",
438
- "Marc Frost",
438
+ "Alex Morgan",
439
439
  "IT-Team",
440
440
  "Sysadmin",
441
441
  "Helpdesk-L1",
@@ -58,7 +58,7 @@ export function AppShell({
58
58
  ],
59
59
  ])
60
60
  }
61
- sidebarFooter={<SidebarUser name="Marc Frost" email="marc@cosmicdrift.dev" />}
61
+ sidebarFooter={<SidebarUser name="Alex Morgan" email="alex@example.com" />}
62
62
  >
63
63
  {activeTarget !== undefined ? <EditorPanel resolvers={resolvers} /> : children}
64
64
  </DefaultAppShell>