@cosmicdrift/kumiko-samples 0.351.0 → 0.353.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/package.json +1 -1
- package/packages/bundled-features/package.json +2 -2
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +15 -2
- package/packages/bundled-features/src/agent-tools/changes.json +12 -0
- package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +2 -3
- package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +3 -4
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +95 -54
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
- package/packages/bundled-features/src/cap-counter/changes.json +28 -0
- package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
- package/packages/bundled-features/src/cap-counter/index.ts +9 -0
- package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
- package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
- package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
- package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
- package/packages/bundled-features/src/cap-overview/access.ts +2 -9
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
- package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
- package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
- package/packages/bundled-features/src/delivery/attempt-log-retention.ts +40 -0
- package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/constants.ts +1 -0
- package/packages/bundled-features/src/delivery/events.ts +3 -0
- package/packages/bundled-features/src/delivery/feature.ts +50 -2
- package/packages/bundled-features/src/delivery/index.ts +6 -0
- package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
- package/packages/bundled-features/src/delivery/public-names.ts +6 -1
- package/packages/bundled-features/src/delivery/types.ts +12 -2
- package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +3 -2
- package/packages/bundled-features/src/folders/feature.ts +2 -12
- package/packages/bundled-features/src/form-draft/changes.json +7 -1
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
- package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +44 -11
- package/packages/bundled-features/src/jobs/i18n.ts +1 -0
- package/packages/bundled-features/src/ledger/entity.ts +4 -0
- package/packages/bundled-features/src/ledger/schemas.ts +8 -2
- package/packages/bundled-features/src/legal-pages/README.md +3 -3
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
- package/packages/bundled-features/src/notes-history/feature.ts +2 -12
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
- package/packages/bundled-features/src/renderer-foundation/types.ts +1 -1
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
- package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
- package/packages/bundled-features/src/seo/changes.json +5 -0
- package/packages/bundled-features/src/seo/feature.ts +23 -7
- package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
- package/packages/bundled-features/src/shared/index.ts +2 -1
- package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
- package/packages/bundled-features/src/tags/feature.ts +2 -12
- package/packages/bundled-features/src/tenant/changes.json +7 -0
- package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
- package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
- package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
- package/packages/bundled-features/src/tenant/screens.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +23 -1
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/index.ts +7 -0
- package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
- package/packages/bundled-features/src/user/handlers/list.query.ts +5 -2
- package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
- package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
- package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +11 -3
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +5 -0
- package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
- package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
- package/samples/apps/styleguide/src/app/shell.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
- package/samples/apps/styleguide/src/features/widgets/feature.ts +48 -15
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +28 -0
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
- package/samples/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/recipes/files-post-processing/package.json +1 -1
- package/samples/recipes/public-share-token/README.md +4 -5
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/security-baseline/README.md +6 -0
- package/samples/recipes/seed-migration/README.md +13 -1
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
|
@@ -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">✓</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}"> </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 [
|
|
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
|
},
|
|
@@ -5,9 +5,10 @@ import {
|
|
|
5
5
|
type FeatureDefinition,
|
|
6
6
|
SYSTEM_TENANT_ID,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
9
|
import { LEGAL_ROUTES } from "../legal-pages/index.js";
|
|
9
10
|
import { cachedSecurePageResponse } from "../page-render/index.js";
|
|
10
|
-
import type
|
|
11
|
+
import { rateLimitedTextResponse, type SystemQueryFn } from "../shared/index.js";
|
|
11
12
|
import { SEO_CONFIG_KEYS, SEO_DEFAULT_PATHS } from "./constants.js";
|
|
12
13
|
import { seoConfigQuery } from "./handlers/seo-config.query.js";
|
|
13
14
|
import { buildLlmsTxt } from "./llms-txt.js";
|
|
@@ -125,7 +126,9 @@ async function gatherEntries(
|
|
|
125
126
|
lastmod: page.updatedAt,
|
|
126
127
|
});
|
|
127
128
|
}
|
|
128
|
-
} catch {
|
|
129
|
+
} catch (error) {
|
|
130
|
+
// A rate limit must not look like "pages removed" to a crawler.
|
|
131
|
+
if (error instanceof RateLimitError) throw error;
|
|
129
132
|
// managed-pages unreachable/not mounted — degrade to callback-only entries.
|
|
130
133
|
}
|
|
131
134
|
}
|
|
@@ -188,7 +191,13 @@ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
|
|
|
188
191
|
anonymous: true,
|
|
189
192
|
handler: async (c, { systemQuery }) => {
|
|
190
193
|
const { origin, host } = requestHost(c);
|
|
191
|
-
|
|
194
|
+
let entries: SitemapEntry[];
|
|
195
|
+
try {
|
|
196
|
+
entries = await gatherEntries(opts, systemQuery, origin, host);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof RateLimitError) return rateLimitedTextResponse(error);
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
192
201
|
const xml = buildSitemapXml(entries);
|
|
193
202
|
const etag = computeRevisionEtag([host, xml]);
|
|
194
203
|
return cachedSecurePageResponse(c.req.raw, {
|
|
@@ -209,10 +218,17 @@ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
|
|
|
209
218
|
const tenantId = opts.managedPages
|
|
210
219
|
? ((await opts.managedPages.resolveApexTenant(host)) ?? SYSTEM_TENANT_ID)
|
|
211
220
|
: SYSTEM_TENANT_ID;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
let entries: SitemapEntry[];
|
|
222
|
+
let seoConfig: SeoConfigValues;
|
|
223
|
+
try {
|
|
224
|
+
[entries, seoConfig] = await Promise.all([
|
|
225
|
+
gatherEntries(opts, systemQuery, origin, host),
|
|
226
|
+
readSeoConfig(systemQuery, tenantId),
|
|
227
|
+
]);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error instanceof RateLimitError) return rateLimitedTextResponse(error);
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
216
232
|
const sections =
|
|
217
233
|
entries.length > 0
|
|
218
234
|
? [
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { OwnershipMap } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
// Boot validation rejects `{ kind: "where" }` in access.write too (fw#2626);
|
|
4
|
+
// failing at factory time names the option instead of an entity scope.
|
|
5
|
+
export function assertNoWhereRuleInOwnershipWrite(
|
|
6
|
+
factoryName: string,
|
|
7
|
+
writeOwnership: OwnershipMap | undefined,
|
|
8
|
+
): void {
|
|
9
|
+
// skip: no write ownership declared, nothing to validate
|
|
10
|
+
if (!writeOwnership) return;
|
|
11
|
+
const hasWhereRule = Object.values(writeOwnership).some(
|
|
12
|
+
(rule) => rule !== "all" && rule.kind === "where",
|
|
13
|
+
);
|
|
14
|
+
// skip: no where-rule present, the option is valid
|
|
15
|
+
if (!hasWhereRule) return;
|
|
16
|
+
throw new Error(
|
|
17
|
+
`${factoryName}({ ownership }): ownership.write must not contain a ` +
|
|
18
|
+
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
19
|
+
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
20
|
+
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
21
|
+
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
22
|
+
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
23
|
+
"leave it unset.",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { collectErasureFailure, throwIfErasureFailed } from "./assert-erased.js";
|
|
2
|
+
export { assertNoWhereRuleInOwnershipWrite } from "./assert-no-where-rule-in-ownership-write.js";
|
|
2
3
|
export {
|
|
3
4
|
type ChunkedMigrationOptions,
|
|
4
5
|
type ChunkedMigrationResult,
|
|
@@ -14,7 +15,6 @@ export { decryptStoredPii } from "./decrypt-stored-pii.js";
|
|
|
14
15
|
export { encryptForDirectWrite } from "./encrypt-for-direct-write.js";
|
|
15
16
|
export { entitiesOf } from "./entities-of.js";
|
|
16
17
|
export { isWithinGracePeriod } from "./grace-period.js";
|
|
17
|
-
export { hasWhereRule } from "./has-where-rule.js";
|
|
18
18
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash.js";
|
|
19
19
|
export { isTenantDb } from "./is-tenant-db.js";
|
|
20
20
|
export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter.js";
|
|
@@ -25,6 +25,7 @@ export {
|
|
|
25
25
|
parentRowIsVisible,
|
|
26
26
|
} from "./parent-visibility.js";
|
|
27
27
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
|
|
28
|
+
export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
|
|
28
29
|
export {
|
|
29
30
|
redactBotTokens,
|
|
30
31
|
redactEmailAddresses,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
+
|
|
3
|
+
export function rateLimitedTextResponse(error: RateLimitError): Response {
|
|
4
|
+
return new Response("rate limited", {
|
|
5
|
+
status: 429,
|
|
6
|
+
headers: {
|
|
7
|
+
"content-type": "text/plain; charset=utf-8",
|
|
8
|
+
"retry-after": String(error.details.retryAfterSeconds),
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
type EntityDefinition,
|
|
28
28
|
type FeatureRegistrar,
|
|
29
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
30
|
-
import {
|
|
30
|
+
import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
|
|
31
31
|
import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants.js";
|
|
32
32
|
import { createTagAssignmentEntity, tagEntity } from "./entity.js";
|
|
33
33
|
import { createTagAssignmentExecutor } from "./executor.js";
|
|
@@ -201,17 +201,7 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFea
|
|
|
201
201
|
"admissible as a host instead.",
|
|
202
202
|
);
|
|
203
203
|
}
|
|
204
|
-
|
|
205
|
-
throw new Error(
|
|
206
|
-
"createTagsFeature({ ownership }): ownership.write must not contain a " +
|
|
207
|
-
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
208
|
-
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
209
|
-
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
210
|
-
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
211
|
-
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
212
|
-
"leave it unset.",
|
|
213
|
-
);
|
|
214
|
-
}
|
|
204
|
+
assertNoWhereRuleInOwnershipWrite("createTagsFeature", opts.ownership?.write);
|
|
215
205
|
const access = resolveAccess(opts);
|
|
216
206
|
return defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
217
207
|
registerTags(r, access, opts.toggleable, opts.ownership, opts.parents),
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.353.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Tenant names are encrypted under the tenant record and crypto-shredded when the tenant is destroyed",
|
|
6
|
+
"detail": "Tenant names are PII encrypted under the tenant record and crypto-shredded on tenant destroy\n`tenant.name` is now `personal: { of: \"id\" }`, so it is stored as `kumiko-pii:` ciphertext in events and in `read_tenants`. Signup no longer writes the email into the tenant name; it uses the generated tenant key. The tenant destroy job erases the tenant record key and purges the tenant's search documents. The tenant queries (`me`, memberships, tenant-directory, user list, cap-overview, tenants-missing-profile) decrypt the name, and the tenant list sorts by `key` because encrypted names are not sortable.",
|
|
7
|
+
"migration": "After the bump run the PII backfill (backfillEventPiiEncryption), then rebuild the read_tenants projection; the backfill only encrypts event payloads. Raw reads of read_tenants.name in apps now return ciphertext and must go through decryptStoredPii or the tenant query handlers. Tenants that were already destroyed (read_tenants.destroyed_at set) get the erased sentinel instead of a fresh record key."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.341.0",
|
|
4
11
|
"type": "improvement",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { decryptStoredPii, mapWithConcurrency } from "../shared/index.js";
|
|
2
|
+
|
|
3
|
+
// Bounded like tenant:query:members: each decrypt hits the KMS adapter's small pool.
|
|
4
|
+
const KMS_POOL_CONCURRENCY = 4;
|
|
5
|
+
|
|
6
|
+
// Raw reads of read_tenants return the stored column, which is ciphertext
|
|
7
|
+
// under an active KMS; executor reads (crud.list/detail) decrypt on their own.
|
|
8
|
+
export async function decryptTenantNames<T extends { readonly name?: unknown }>(
|
|
9
|
+
rows: readonly T[],
|
|
10
|
+
fallbackRequestId: string,
|
|
11
|
+
): Promise<T[]> {
|
|
12
|
+
return mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, async (row) =>
|
|
13
|
+
typeof row.name === "string"
|
|
14
|
+
? { ...row, name: await decryptStoredPii(row.name, "name", fallbackRequestId) }
|
|
15
|
+
: row,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -2,6 +2,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import * as z from "zod";
|
|
5
|
+
import { decryptTenantNames } from "../decrypt-tenant-names.js";
|
|
5
6
|
import { tenantTable } from "../schema/tenant.js";
|
|
6
7
|
|
|
7
8
|
// Direct query — query handlers don't have a tenant-crud handle. A direct
|
|
@@ -33,6 +34,8 @@ export const meQuery = defineQueryHandler({
|
|
|
33
34
|
}
|
|
34
35
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
35
36
|
const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
|
|
36
|
-
|
|
37
|
+
if (row === undefined) return null;
|
|
38
|
+
const [decrypted] = await decryptTenantNames([row], "tenant:me");
|
|
39
|
+
return decrypted ?? null;
|
|
37
40
|
},
|
|
38
41
|
});
|
|
@@ -3,6 +3,7 @@ import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/e
|
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
5
|
import * as z from "zod";
|
|
6
|
+
import { decryptTenantNames } from "../decrypt-tenant-names.js";
|
|
6
7
|
import { tenantMembershipsTable } from "../membership-table.js";
|
|
7
8
|
import { tenantTable } from "../schema/tenant.js";
|
|
8
9
|
|
|
@@ -32,9 +33,12 @@ export const membershipsQuery = defineQueryHandler({
|
|
|
32
33
|
// prefix would be indistinguishable). A single IN-batch over all
|
|
33
34
|
// tenantIds instead of fetchOne per membership (#324).
|
|
34
35
|
type TenantRow = { id: unknown; name?: unknown; key?: unknown; isEnabled?: unknown };
|
|
35
|
-
const tenants = await
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const tenants = await decryptTenantNames(
|
|
37
|
+
await selectMany<TenantRow>(db, tenantTable, {
|
|
38
|
+
id: rows.map((row) => row["tenantId"]),
|
|
39
|
+
}),
|
|
40
|
+
"tenant:memberships",
|
|
41
|
+
);
|
|
38
42
|
const tenantById = new Map<unknown, TenantRow>(tenants.map((t) => [t.id, t]));
|
|
39
43
|
|
|
40
44
|
return rows
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
8
|
import * as z from "zod";
|
|
9
|
+
import { decryptTenantNames } from "../decrypt-tenant-names.js";
|
|
9
10
|
import { tenantTable } from "../schema/tenant.js";
|
|
10
11
|
import { isSystemAdmin } from "./is-system-admin.js";
|
|
11
12
|
|
|
@@ -48,7 +49,7 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
48
49
|
tenants = await selectMany(db, tenantTable, undefined, {
|
|
49
50
|
limit: scanLimit,
|
|
50
51
|
orderBy: [
|
|
51
|
-
{ col: "
|
|
52
|
+
{ col: "key", direction: "asc" },
|
|
52
53
|
{ col: "id", direction: "asc" },
|
|
53
54
|
],
|
|
54
55
|
});
|
|
@@ -57,10 +58,15 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
57
58
|
const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
|
|
58
59
|
tenants = row ? [row] : [];
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
// name is ciphertext at rest, so the SQL page is keyed by the plaintext
|
|
62
|
+
// `key`; label order and search happen in memory after decrypting.
|
|
63
|
+
const decrypted = await decryptTenantNames(tenants, "tenant:tenant-directory");
|
|
64
|
+
const labeled = decrypted
|
|
65
|
+
.map((tenant) => {
|
|
66
|
+
const id = String(tenant.id);
|
|
67
|
+
return { id, label: typeof tenant.name === "string" ? tenant.name : id };
|
|
68
|
+
})
|
|
69
|
+
.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
|
64
70
|
const needle = search?.toLowerCase();
|
|
65
71
|
const rows =
|
|
66
72
|
needle === undefined
|
|
@@ -7,6 +7,7 @@ export {
|
|
|
7
7
|
TenantHandlers,
|
|
8
8
|
TenantQueries,
|
|
9
9
|
} from "./constants.js";
|
|
10
|
+
export { decryptTenantNames } from "./decrypt-tenant-names.js";
|
|
10
11
|
export { collectAssignableAppRoles, createTenantFeature } from "./feature.js";
|
|
11
12
|
export type { InvitationStatus } from "./invitation-table.js";
|
|
12
13
|
export {
|
|
@@ -28,19 +28,17 @@ export const tenantEntity = createEntity({
|
|
|
28
28
|
key: createTextField({
|
|
29
29
|
required: true,
|
|
30
30
|
maxLength: 50,
|
|
31
|
+
sortable: true,
|
|
31
32
|
personal: false,
|
|
32
33
|
reason: "technical_reference",
|
|
33
34
|
}),
|
|
34
|
-
//
|
|
35
|
-
// same class as the `company.legalName` example in the boot-validator's
|
|
36
|
-
// own PII-heuristic doc-comment.
|
|
35
|
+
// Subject is the tenant record itself: tenant events live in the writer's (system) tenant stream, so `personal: "tenant"` would not resolve to this tenant.
|
|
37
36
|
name: createTextField({
|
|
38
37
|
required: true,
|
|
39
38
|
maxLength: 200,
|
|
40
39
|
searchable: true,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
reason: "is_business_data",
|
|
40
|
+
personal: { of: "id" },
|
|
41
|
+
find: "none",
|
|
44
42
|
}),
|
|
45
43
|
isEnabled: createBooleanField({ default: true, filterable: true }),
|
|
46
44
|
// Tenant-destroy lifecycle (tenant-lifecycle feature). Defaults keep
|
|
@@ -43,7 +43,7 @@ export const tenantListScreen: EntityListScreenDefinition = {
|
|
|
43
43
|
entityId: "id",
|
|
44
44
|
},
|
|
45
45
|
],
|
|
46
|
-
defaultSort: { field: "
|
|
46
|
+
defaultSort: { field: "key", dir: "asc" },
|
|
47
47
|
searchable: true,
|
|
48
48
|
access: { roles: ["SystemAdmin"] },
|
|
49
49
|
// Inert until an app navs it (see file header) — no nav area to resolve
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.353.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Tenant handover claims fail with transfer_graph_too_deep only when rows exist beyond the last allowed hop",
|
|
6
|
+
"detail": "Tenant handover only fails with `transfer_graph_too_deep` when rows really hang below the fifth hop, and failed workflow runs report the suspended step\nA claim used to fail whenever the last round's entity type had outgoing edges, even with no rows behind them, so a chain of exactly five hops with an unrestricted `parentRef` entity registered was rejected. `workflow.run-failed` carries the real `stepIndex` for the unsupported-suspension case instead of always 0."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.321.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -268,7 +268,7 @@ export async function moveTransferGraph(args: {
|
|
|
268
268
|
{ entityName: rootEntityName, rowIds: [rootRowId] },
|
|
269
269
|
];
|
|
270
270
|
|
|
271
|
-
for (let round = 0; round
|
|
271
|
+
for (let round = 0; round <= MAX_TRANSFER_DEPTH && pending.length > 0; round++) {
|
|
272
272
|
// Merged per type so an edge leaving a type runs once per round, not once
|
|
273
273
|
// per edge that reached it.
|
|
274
274
|
const discovered = new Map<string, string[]>();
|
|
@@ -330,25 +330,18 @@ export async function moveTransferGraph(args: {
|
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
-
|
|
334
|
-
|
|
333
|
+
// The extra round past the limit is a probe: it only exists to find out
|
|
334
|
+
// whether rows still hang below the last allowed hop. Its moves are rolled
|
|
335
|
+
// back with the transaction when this throws.
|
|
336
|
+
const [strandedEntityName] = discovered.keys();
|
|
337
|
+
if (round === MAX_TRANSFER_DEPTH && strandedEntityName !== undefined) {
|
|
338
|
+
throw new UnprocessableError("transfer_graph_too_deep", {
|
|
339
|
+
i18nKey: "errors.tenantHandover.transferGraphTooDeep",
|
|
340
|
+
details: { entityName: strandedEntityName },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
335
343
|
|
|
336
|
-
|
|
337
|
-
// rounds ran out while rows remain whose type still leads somewhere, the
|
|
338
|
-
// declaration reaches further than the mover does, and returning quietly
|
|
339
|
-
// would leave exactly the orphaned rows #3088 is about.
|
|
340
|
-
//
|
|
341
|
-
// Rounds count hops of both edge kinds, while the boot validator measures
|
|
342
|
-
// reference chains between transferable entities only. A graph that fills the
|
|
343
|
-
// limit with reference hops and then adds a parentRef child therefore boots
|
|
344
|
-
// clean and fails here instead — the residual the validator cannot see, and
|
|
345
|
-
// the reason this check has to exist rather than trusting boot alone.
|
|
346
|
-
const stranded = pending.find(({ entityName }) => (adjacency.get(entityName) ?? []).length > 0);
|
|
347
|
-
if (stranded !== undefined) {
|
|
348
|
-
throw new UnprocessableError("transfer_graph_too_deep", {
|
|
349
|
-
i18nKey: "errors.tenantHandover.transferGraphTooDeep",
|
|
350
|
-
details: { entityName: stranded.entityName },
|
|
351
|
-
});
|
|
344
|
+
pending = [...discovered].map(([entityName, rowIds]) => ({ entityName, rowIds }));
|
|
352
345
|
}
|
|
353
346
|
|
|
354
347
|
return movedCounts;
|
|
@@ -141,6 +141,7 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
|
|
|
141
141
|
now,
|
|
142
142
|
log: (message) => ctx.log?.warn(message),
|
|
143
143
|
fileProviderResolver: ctx._fileProviderResolver,
|
|
144
|
+
searchAdapter: ctx.searchAdapter,
|
|
144
145
|
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
145
146
|
actor: ctx.systemUser.id,
|
|
146
147
|
});
|