@cosmicdrift/kumiko-samples 0.352.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 (68) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  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-overview/access.ts +2 -9
  15. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
  16. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
  17. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
  18. package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
  19. package/packages/bundled-features/src/delivery/public-names.ts +1 -1
  20. package/packages/bundled-features/src/files-tenant-data/hooks.ts +3 -2
  21. package/packages/bundled-features/src/folders/feature.ts +2 -12
  22. package/packages/bundled-features/src/form-draft/changes.json +7 -1
  23. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
  24. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
  25. package/packages/bundled-features/src/jobs/handlers/list.query.ts +44 -11
  26. package/packages/bundled-features/src/jobs/i18n.ts +1 -0
  27. package/packages/bundled-features/src/ledger/entity.ts +4 -0
  28. package/packages/bundled-features/src/ledger/schemas.ts +8 -2
  29. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  30. package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
  31. package/packages/bundled-features/src/notes-history/feature.ts +2 -12
  32. package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
  33. package/packages/bundled-features/src/seo/changes.json +5 -0
  34. package/packages/bundled-features/src/seo/feature.ts +23 -7
  35. package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
  36. package/packages/bundled-features/src/shared/index.ts +2 -1
  37. package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
  38. package/packages/bundled-features/src/tags/feature.ts +2 -12
  39. package/packages/bundled-features/src/tenant/changes.json +7 -0
  40. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
  41. package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
  42. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
  43. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
  44. package/packages/bundled-features/src/tenant/index.ts +1 -0
  45. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
  46. package/packages/bundled-features/src/tenant/screens.ts +1 -1
  47. package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
  48. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
  49. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
  50. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  51. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +23 -1
  52. package/packages/bundled-features/src/user/handlers/list.query.ts +5 -2
  53. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
  54. package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
  55. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
  56. package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
  57. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +11 -3
  58. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +5 -0
  59. package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
  60. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
  61. package/samples/apps/styleguide/src/features/widgets/feature.ts +48 -15
  62. package/samples/apps/styleguide/src/features/widgets/i18n.ts +28 -0
  63. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
  64. package/samples/recipes/public-share-token/README.md +4 -5
  65. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  66. package/samples/recipes/security-baseline/README.md +6 -0
  67. package/samples/recipes/tenant-settings/README.md +1 -1
  68. package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
@@ -1,11 +1,13 @@
1
1
  import { countWhere, selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
2
3
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
- import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
4
5
  import * as z from "zod";
5
6
  import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
6
7
  import { jobRunsTable } from "../job-run-table.js";
7
8
 
8
9
  const KMS_POOL_CONCURRENCY = 4;
10
+ const DEFAULT_PAGE_SIZE = 50;
9
11
 
10
12
  const jobRunStatusSchema = z.enum(["queued", "running", "completed", "failed"]);
11
13
 
@@ -23,6 +25,31 @@ async function decryptRunRow<T extends Record<string, unknown>>(row: T): Promise
23
25
  return result;
24
26
  }
25
27
 
28
+ type JobRunStatus = z.infer<typeof jobRunStatusSchema>;
29
+
30
+ function buildListWhere(payload: {
31
+ readonly jobName?: string | undefined;
32
+ readonly status?: JobRunStatus | undefined;
33
+ readonly filters?: readonly { readonly value: readonly JobRunStatus[] }[] | undefined;
34
+ }): WhereObject {
35
+ const where: WhereObject = {};
36
+ if (payload.jobName) where["jobName"] = payload.jobName;
37
+ const statuses = payload.filters?.[0]?.value ?? (payload.status ? [payload.status] : []);
38
+ if (statuses.length === 1) where["status"] = statuses[0];
39
+ else if (statuses.length > 1) where["status"] = { in: statuses };
40
+ return where;
41
+ }
42
+
43
+ function parseOffsetCursor(cursor: string | undefined): number {
44
+ const offset = cursor ? Number(decodeCursor(cursor)) : 0;
45
+ if (!Number.isInteger(offset) || offset < 0) {
46
+ throw new ValidationError({
47
+ fields: [{ path: "cursor", code: "invalid_cursor", i18nKey: "jobs.errors.invalidCursor" }],
48
+ });
49
+ }
50
+ return offset;
51
+ }
52
+
26
53
  export const listQuery = defineQueryHandler({
27
54
  name: "list",
28
55
  description:
@@ -42,6 +69,7 @@ export const listQuery = defineQueryHandler({
42
69
  sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
43
70
  sortDirection: z.enum(["asc", "desc"]).optional(),
44
71
  limit: z.number().optional(),
72
+ cursor: z.string().optional(),
45
73
  totalCount: z.boolean().optional(),
46
74
  }),
47
75
  access: { roles: ["SystemAdmin"] },
@@ -54,17 +82,22 @@ export const listQuery = defineQueryHandler({
54
82
  const db = ctx.systemDb.unsafeRaw(
55
83
  "cross-tenant job monitoring: SystemAdmin lists job runs of every tenant",
56
84
  );
57
- const where: WhereObject = {};
58
- if (query.payload.jobName) where["jobName"] = query.payload.jobName;
59
- const statusFilter = query.payload.filters?.find((filter) => filter.field === "status");
60
- const statuses = statusFilter?.value ?? (query.payload.status ? [query.payload.status] : []);
61
- if (statuses.length === 1) where["status"] = statuses[0];
62
- else if (statuses.length > 1) where["status"] = { in: statuses };
85
+ const where = buildListWhere(query.payload);
63
86
  const sortColumn = query.payload.sort ?? "startedAt";
64
- const rows = await selectMany(db, jobRunsTable, where, {
65
- orderBy: { col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
66
- limit: query.payload.limit ?? 50,
87
+ const limit = query.payload.limit ?? DEFAULT_PAGE_SIZE;
88
+ const offset = parseOffsetCursor(query.payload.cursor);
89
+ const direction = query.payload.sortDirection ?? "desc";
90
+ // selectMany has no OFFSET: read through the end of the page plus one probe
91
+ // row, then slice. `id` as tiebreaker keeps pages stable for non-unique sorts.
92
+ const fetched = await selectMany(db, jobRunsTable, where, {
93
+ orderBy: [
94
+ { col: sortColumn, direction },
95
+ { col: "id", direction },
96
+ ],
97
+ limit: offset + limit + 1,
67
98
  });
99
+ const hasMore = fetched.length > offset + limit;
100
+ const rows = fetched.slice(offset, offset + limit);
68
101
  // countWhere reruns the SAME `where` without the limit — `rows.length` is
69
102
  // capped at the page size and would silently undercount the total.
70
103
  const total =
@@ -72,7 +105,7 @@ export const listQuery = defineQueryHandler({
72
105
  // payload/error are stored encrypted under the triggering user's DEK (#799, #2307).
73
106
  return {
74
107
  rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, decryptRunRow),
75
- nextCursor: null,
108
+ nextCursor: hasMore ? encodeCursor(String(offset + limit)) : null,
76
109
  ...(total !== undefined && { total }),
77
110
  };
78
111
  },
@@ -36,6 +36,7 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
36
36
  "jobs.detail.logs": { en: "Logs" },
37
37
  "jobs.detail.retry": { en: "Retry" },
38
38
  "jobs.trigger.title": { en: "Run a job" },
39
+ "jobs.errors.invalidCursor": { en: "The pagination cursor is invalid." },
39
40
  "jobs.trigger.submit": { en: "Run" },
40
41
  "jobs.errors.unknownJob": { en: "Unknown job." },
41
42
  "jobs.errors.notManual": {
@@ -94,12 +94,14 @@ export const transactionEntity = createEntity({
94
94
  // Optional business-object reference (e.g. a lease contract) so entries can
95
95
  // be filtered by what they're about, not just grepped out of `reference`.
96
96
  subjectType: createTextField({
97
+ minLength: 1,
97
98
  maxLength: 64,
98
99
  personal: false,
99
100
  reason: "technical_reference",
100
101
  filterable: true,
101
102
  }),
102
103
  subjectId: createTextField({
104
+ minLength: 1,
103
105
  maxLength: 128,
104
106
  personal: false,
105
107
  reason: "technical_reference",
@@ -147,12 +149,14 @@ export const scheduleEntity = createEntity({
147
149
  // Confirmed periods inherit this from the schedule (see
148
150
  // confirm-schedule-period.write.ts) rather than repeating it per period.
149
151
  subjectType: createTextField({
152
+ minLength: 1,
150
153
  maxLength: 64,
151
154
  personal: false,
152
155
  reason: "technical_reference",
153
156
  filterable: true,
154
157
  }),
155
158
  subjectId: createTextField({
159
+ minLength: 1,
156
160
  maxLength: 128,
157
161
  personal: false,
158
162
  reason: "technical_reference",
@@ -28,8 +28,14 @@ export const createTransactionPayloadSchema = z
28
28
  status: z.enum(TRANSACTION_STATUS).optional(),
29
29
  lines: z.array(postingSchema).min(2),
30
30
  // Business-object reference (e.g. a lease contract) this entry is about.
31
- subjectType: z.string().max(64).optional(),
32
- subjectId: z.string().max(128).optional(),
31
+ subjectType: z.string().min(1).max(64).optional(),
32
+ subjectId: z.string().min(1).max(128).optional(),
33
+ })
34
+ // Ids are only unique within their type, so a lone subjectId would match
35
+ // bookings of a different object type.
36
+ .refine((p) => (p.subjectType === undefined) === (p.subjectId === undefined), {
37
+ message: "subjectType and subjectId must be set together",
38
+ path: ["subjectId"],
33
39
  })
34
40
  .refine((p) => sumIsZero(p.lines), {
35
41
  message: "Transaction must balance: Σ of posting amounts must equal 0",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.353.0",
4
+ "type": "fix",
5
+ "title": "A rate-limited page request answers 429 with Retry-After instead of 503 or an unbranded page",
6
+ "detail": "Managed pages and sitemap answer 429 when rate limited, agent catalog checks the deny list, draft cleanup continues past skipped rows\nA rate-limited managed-page request now answers 429 with `Retry-After` instead of 503 or an unbranded page, and `sitemap.xml` and `llms.txt` answer 429 instead of silently dropping the managed-page entries. `buildToolCatalog` throws when its manifest was built with `denyQns` that the catalog options lack. The form-draft cleanup no longer stops when a whole batch was re-saved by users in the meantime."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -1,5 +1,6 @@
1
1
  import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import {
4
5
  type BrandingTokens,
5
6
  cachedSecurePageResponse,
@@ -7,7 +8,7 @@ import {
7
8
  renderSafeMarkdown,
8
9
  wrapInLayout,
9
10
  } 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 { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding.js";
12
13
  import { createBrandingQuery } from "./handlers/branding.query.js";
13
14
  import { bySlugQuery } from "./handlers/by-slug.query.js";
@@ -51,16 +52,18 @@ function brandingRevisionSeed(branding: BrandingTokens): string {
51
52
  ]);
52
53
  }
53
54
 
54
- // Never throws: a query failure or malformed result degrades to the
55
- // unbranded default (branding is decoration, not a hard dependency of the
56
- // page render).
55
+ // A query failure or malformed result degrades to the unbranded default
56
+ // (branding is decoration, not a hard dependency of the page render). A rate
57
+ // limit is rethrown so the route answers 429 instead of serving (and caching)
58
+ // an unbranded page.
57
59
  async function readBrandingViaSystemQuery(
58
60
  systemQuery: SystemQueryFn,
59
61
  tenantId: string,
60
62
  ): Promise<BrandingTokens> {
61
63
  try {
62
64
  return coerceBranding(await systemQuery(BRANDING_QUERY_QN, {}, tenantId));
63
- } catch {
65
+ } catch (error) {
66
+ if (error instanceof RateLimitError) throw error;
64
67
  return EMPTY_BRANDING;
65
68
  }
66
69
  }
@@ -207,12 +210,22 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
207
210
  // von resolverTrust: "authoritative" zurecht abgelehnt; siehe
208
211
  // systemQuery's Doc in http-route.ts). Branding ist Deko → ein
209
212
  // Fehlschlag degradiert zum unbranded Default, blockt nie die Page.
210
- const [pageSettled, brandingResult] = await Promise.all([
211
- systemQuery(BY_SLUG_QN, { slug, lang }, tenantId)
212
- .then((data) => ({ ok: true as const, data: data as BySlugQueryResult }))
213
- .catch(() => ({ ok: false as const, data: null as BySlugQueryResult })),
214
- readBrandingViaSystemQuery(systemQuery, tenantId),
215
- ]);
213
+ let pageSettled: { ok: true; data: BySlugQueryResult } | { ok: false };
214
+ let brandingResult: BrandingTokens;
215
+ try {
216
+ [pageSettled, brandingResult] = await Promise.all([
217
+ systemQuery(BY_SLUG_QN, { slug, lang }, tenantId)
218
+ .then((data) => ({ ok: true as const, data: data as BySlugQueryResult }))
219
+ .catch((error: unknown) => {
220
+ if (error instanceof RateLimitError) throw error;
221
+ return { ok: false as const };
222
+ }),
223
+ readBrandingViaSystemQuery(systemQuery, tenantId),
224
+ ]);
225
+ } catch (error) {
226
+ if (error instanceof RateLimitError) return rateLimitedTextResponse(error);
227
+ throw error;
228
+ }
216
229
  if (!pageSettled.ok) return c.text("page unavailable", 503);
217
230
 
218
231
  const data = pageSettled.data;
@@ -20,7 +20,7 @@ import {
20
20
  type EntityDefinition,
21
21
  type FeatureRegistrar,
22
22
  } from "@cosmicdrift/kumiko-framework/engine";
23
- import { hasWhereRule } from "../shared/index.js";
23
+ import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
24
24
  import { DEFAULT_NOTES_HISTORY_ACCESS, NOTES_HISTORY_FEATURE_NAME } from "./constants.js";
25
25
  import { createNoteEntryEntity, noteMentionEntity } from "./entity.js";
26
26
  import { createNoteEntryExecutor } from "./executor.js";
@@ -123,17 +123,7 @@ export function createNotesHistoryFeature(
123
123
  ) {
124
124
  return notesHistoryFeature;
125
125
  }
126
- if (hasWhereRule(opts.ownership?.write)) {
127
- throw new Error(
128
- "createNotesHistoryFeature({ ownership }): ownership.write must not contain a " +
129
- '`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
130
- "layer (the read path, via buildOwnershipClause). Write paths that " +
131
- "consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
132
- "evaluate them, so such a rule can only ever deny — boot validation " +
133
- "rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
134
- "leave it unset.",
135
- );
136
- }
126
+ assertNoWhereRuleInOwnershipWrite("createNotesHistoryFeature", opts.ownership?.write);
137
127
  // A mount that accepts no parents at all can never take a note write —
138
128
  // that's a config mistake, not a valid allowlist. Omit `parents` instead
139
129
  // of passing an empty array.
@@ -4,10 +4,25 @@ import {
4
4
  type ProjectionListScreenDefinition,
5
5
  type SecretMintScreenDefinition,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { PAT_MINT_SCREEN_ID, PAT_SCREEN_ID, PatHandlers, PatQueries } from "./constants.js";
7
+ import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
8
+ import { fieldOptionLabelKeyPrefix } from "@cosmicdrift/kumiko-headless";
9
+ import {
10
+ PAT_FEATURE,
11
+ PAT_MINT_SCREEN_ID,
12
+ PAT_SCREEN_ID,
13
+ PatHandlers,
14
+ PatQueries,
15
+ } from "./constants.js";
8
16
  import type { PatScopeConfig } from "./scopes.js";
9
17
 
10
18
  const PAT_STATUS_OPTION_KEY_PREFIX = "pat.list.status.";
19
+ // Same keys the mint form's `scopes` multiSelect options resolve through
20
+ // (see patScopeOptionTranslations), so list and form show identical labels.
21
+ const PAT_SCOPE_OPTION_KEY_PREFIX = fieldOptionLabelKeyPrefix(
22
+ PAT_FEATURE,
23
+ ACTION_FORM_ENTITY,
24
+ "scopes",
25
+ );
11
26
 
12
27
  // Both screens render only for tenants whose tier includes the feature; the
13
28
  // availability query is rejected by the feature gate otherwise.
@@ -29,11 +44,17 @@ export const patListScreen: ProjectionListScreenDefinition = {
29
44
  defaultSort: { field: "createdAt", dir: "desc" },
30
45
  columns: [
31
46
  { field: "name", label: i18nKey("pat.list.col.name") },
32
- { field: "prefix", label: i18nKey("pat.list.col.prefix") },
33
- { field: "scopes", label: i18nKey("pat.list.col.scopes") },
47
+ { field: "prefix", label: i18nKey("pat.list.col.prefix"), sortable: false },
48
+ {
49
+ field: "scopes",
50
+ label: i18nKey("pat.list.col.scopes"),
51
+ sortable: false,
52
+ renderer: { format: "enumOption", keyPrefix: PAT_SCOPE_OPTION_KEY_PREFIX },
53
+ },
34
54
  {
35
55
  field: "status",
36
56
  label: i18nKey("pat.list.col.status"),
57
+ sortable: false,
37
58
  renderer: { format: "enumOption", keyPrefix: PAT_STATUS_OPTION_KEY_PREFIX },
38
59
  },
39
60
  {
@@ -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",