@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.
- package/dist/cap-counter/book-cap-usage.d.ts +5 -1
- package/dist/cap-counter/book-cap-usage.js +65 -44
- package/dist/cap-counter/cap-reservation.d.ts +12 -0
- package/dist/cap-counter/cap-reservation.js +135 -0
- package/dist/cap-counter/constants.d.ts +3 -0
- package/dist/cap-counter/constants.js +6 -0
- package/dist/cap-counter/feature.js +7 -2
- package/dist/cap-counter/handlers/increment-rolling.write.d.ts +5 -0
- package/dist/cap-counter/handlers/increment-rolling.write.js +4 -0
- package/dist/cap-counter/index.d.ts +4 -2
- package/dist/cap-counter/index.js +3 -1
- package/dist/cap-counter/payload-field.d.ts +1 -0
- package/dist/cap-counter/payload-field.js +11 -0
- package/dist/cap-counter/reservation-chain.d.ts +2 -0
- package/dist/cap-counter/reservation-chain.js +44 -0
- package/dist/cap-counter/stock-cap-guard.d.ts +3 -0
- package/dist/cap-counter/tables.d.ts +3 -0
- package/dist/cap-counter/tables.js +36 -0
- package/dist/cap-counter/value-cap-guard.d.ts +18 -0
- package/dist/cap-counter/value-cap-guard.js +33 -0
- package/dist/cap-counter/with-cap-enforcement.d.ts +5 -7
- package/dist/cap-counter/with-cap-enforcement.js +35 -51
- package/dist/channel-discord/feature.js +1 -0
- package/dist/channel-slack/feature.js +1 -0
- package/dist/channel-teams/feature.js +1 -0
- package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -0
- package/dist/delivery/attempt-log-retention.d.ts +9 -0
- package/dist/delivery/attempt-log-retention.js +23 -0
- package/dist/delivery/attempt-log.d.ts +1 -1
- package/dist/delivery/attempt-log.js +21 -2
- package/dist/delivery/constants.d.ts +1 -1
- package/dist/delivery/constants.js +1 -1
- package/dist/delivery/events.d.ts +1 -0
- package/dist/delivery/events.js +2 -0
- package/dist/delivery/feature.d.ts +1 -0
- package/dist/delivery/feature.js +39 -3
- package/dist/delivery/index.d.ts +2 -1
- package/dist/delivery/index.js +2 -1
- package/dist/delivery/mask-recipient-address.d.ts +1 -0
- package/dist/delivery/mask-recipient-address.js +41 -0
- package/dist/delivery/public-names.d.ts +2 -0
- package/dist/delivery/public-names.js +3 -0
- package/dist/delivery/types.d.ts +1 -0
- package/dist/delivery/types.js +6 -2
- package/dist/ledger/entity.d.ts +3 -0
- package/dist/renderer-foundation/types.d.ts +1 -0
- package/dist/renderer-simple/feature.js +4 -6
- package/dist/renderer-simple/index.d.ts +1 -0
- package/dist/renderer-simple/mail-blocks.d.ts +21 -0
- package/dist/renderer-simple/mail-blocks.js +107 -0
- package/dist/renderer-simple/simple-renderer.js +21 -3
- package/dist/template-resolver/table.d.ts +8 -0
- package/dist/template-resolver/user-content-table.d.ts +4 -0
- package/dist/tenant/invitation-table.d.ts +2 -0
- package/dist/tenant/schema/tenant.d.ts +2 -0
- package/dist/tier-engine/index.d.ts +1 -0
- package/dist/tier-engine/index.js +1 -0
- package/dist/tier-engine/tier-option-gate.d.ts +32 -0
- package/dist/tier-engine/tier-option-gate.js +62 -0
- package/dist/user/schema/user.d.ts +2 -0
- package/dist/user-data-rights/schema/export-job.d.ts +2 -0
- package/dist/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.js +25 -6
- package/dist/waitlist/entity.d.ts +2 -0
- package/package.json +10 -10
- package/src/cap-counter/changes.json +28 -0
- package/src/delivery/changes.json +12 -0
- package/src/derivatives-sharp/changes.json +6 -0
- package/src/legal-pages/changes.json +6 -0
- package/src/renderer-simple/changes.json +6 -0
- 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">✓</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}"> </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 [
|
|
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 {
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
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.
|
|
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.
|
|
507
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
508
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
509
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
510
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
511
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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.
|
|
1029
|
-
"@cosmicdrift/kumiko-locale-es": "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": "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",
|