@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.
Files changed (114) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +2 -2
  3. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +15 -2
  4. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  5. package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
  6. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
  7. package/packages/bundled-features/src/audit/changes.json +6 -0
  8. package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
  9. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +2 -3
  10. package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
  11. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +3 -4
  12. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
  13. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
  14. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +95 -54
  15. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
  16. package/packages/bundled-features/src/cap-counter/changes.json +28 -0
  17. package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
  18. package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
  19. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
  20. package/packages/bundled-features/src/cap-counter/index.ts +9 -0
  21. package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
  22. package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
  23. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
  24. package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
  25. package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
  26. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
  27. package/packages/bundled-features/src/cap-overview/access.ts +2 -9
  28. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
  29. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
  30. package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
  31. package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
  32. package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
  33. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
  34. package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
  35. package/packages/bundled-features/src/delivery/attempt-log-retention.ts +40 -0
  36. package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
  37. package/packages/bundled-features/src/delivery/changes.json +12 -0
  38. package/packages/bundled-features/src/delivery/constants.ts +1 -0
  39. package/packages/bundled-features/src/delivery/events.ts +3 -0
  40. package/packages/bundled-features/src/delivery/feature.ts +50 -2
  41. package/packages/bundled-features/src/delivery/index.ts +6 -0
  42. package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
  43. package/packages/bundled-features/src/delivery/public-names.ts +6 -1
  44. package/packages/bundled-features/src/delivery/types.ts +12 -2
  45. package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
  46. package/packages/bundled-features/src/files-tenant-data/hooks.ts +3 -2
  47. package/packages/bundled-features/src/folders/feature.ts +2 -12
  48. package/packages/bundled-features/src/form-draft/changes.json +7 -1
  49. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
  50. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
  51. package/packages/bundled-features/src/jobs/handlers/list.query.ts +44 -11
  52. package/packages/bundled-features/src/jobs/i18n.ts +1 -0
  53. package/packages/bundled-features/src/ledger/entity.ts +4 -0
  54. package/packages/bundled-features/src/ledger/schemas.ts +8 -2
  55. package/packages/bundled-features/src/legal-pages/README.md +3 -3
  56. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  57. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  58. package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
  59. package/packages/bundled-features/src/notes-history/feature.ts +2 -12
  60. package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
  61. package/packages/bundled-features/src/renderer-foundation/types.ts +1 -1
  62. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  63. package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
  64. package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
  65. package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
  66. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
  67. package/packages/bundled-features/src/seo/changes.json +5 -0
  68. package/packages/bundled-features/src/seo/feature.ts +23 -7
  69. package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
  70. package/packages/bundled-features/src/shared/index.ts +2 -1
  71. package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
  72. package/packages/bundled-features/src/tags/feature.ts +2 -12
  73. package/packages/bundled-features/src/tenant/changes.json +7 -0
  74. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
  75. package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
  76. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
  77. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
  78. package/packages/bundled-features/src/tenant/index.ts +1 -0
  79. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
  80. package/packages/bundled-features/src/tenant/screens.ts +1 -1
  81. package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
  82. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
  83. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
  84. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  85. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +23 -1
  86. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  87. package/packages/bundled-features/src/tier-engine/index.ts +7 -0
  88. package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
  89. package/packages/bundled-features/src/user/handlers/list.query.ts +5 -2
  90. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
  91. package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
  92. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
  93. package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
  94. package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
  95. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +11 -3
  96. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +5 -0
  97. package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
  98. package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
  99. package/samples/apps/styleguide/src/app/shell.tsx +1 -1
  100. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
  101. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
  102. package/samples/apps/styleguide/src/features/widgets/feature.ts +48 -15
  103. package/samples/apps/styleguide/src/features/widgets/i18n.ts +28 -0
  104. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
  105. package/samples/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
  106. package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
  107. package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
  108. package/samples/recipes/files-post-processing/package.json +1 -1
  109. package/samples/recipes/public-share-token/README.md +4 -5
  110. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  111. package/samples/recipes/security-baseline/README.md +6 -0
  112. package/samples/recipes/seed-migration/README.md +13 -1
  113. package/samples/recipes/tenant-settings/README.md +1 -1
  114. 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">&#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,9 @@
1
1
  [
2
+ {
3
+ "version": "0.353.0",
4
+ "type": "fix",
5
+ "title": "sitemap.xml and llms.txt answer 429 when the managed-pages read is rate limited instead of omitting the pages"
6
+ },
2
7
  {
3
8
  "version": "0.147.3",
4
9
  "type": "fix",
@@ -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 { SystemQueryFn } from "../shared/index.js";
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
- const entries = await gatherEntries(opts, systemQuery, origin, host);
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
- const [entries, seoConfig] = await Promise.all([
213
- gatherEntries(opts, systemQuery, origin, host),
214
- readSeoConfig(systemQuery, tenantId),
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 { hasWhereRule } from "../shared/index.js";
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
- if (hasWhereRule(opts.ownership?.write)) {
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
- return row ?? null;
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 selectMany<TenantRow>(db, tenantTable, {
36
- id: rows.map((row) => row["tenantId"]),
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: "name", direction: "asc" },
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
- const labeled = tenants.map((tenant) => {
61
- const id = String(tenant.id);
62
- return { id, label: typeof tenant.name === "string" ? tenant.name : id };
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
- // Workspace/company display name, not an individual's identity —
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
- sortable: true,
42
- personal: false,
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: "name", dir: "asc" },
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 < MAX_TRANSFER_DEPTH && pending.length > 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
- pending = [...discovered].map(([entityName, rowIds]) => ({ entityName, rowIds }));
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
- // The depth limit is a safety net, not a licence to move part of a graph: if
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
  });