@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
@@ -64,6 +64,9 @@ export type DeliveryChannel = {
64
64
  // Absent for channels addressed only through `route` (tenant-owned chat
65
65
  // targets): deliverToUser skips them without writing a no_address row.
66
66
  resolve?(userId: string, ctx: ChannelContext): Promise<string | null>;
67
+ // "connection-name": the address is a tenant-chosen connection name (chat webhooks), not
68
+ // personal data, so the attempt log keeps it unmasked. Absent = personal, masked.
69
+ readonly addressKind?: "connection-name";
67
70
  render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
68
71
  send(
69
72
  address: string,
@@ -118,14 +121,21 @@ export type DeliveryService = {
118
121
  // payload — collectChannels derives it from the usage's entityName instead.
119
122
  export type DeliveryChannelPlugin = Omit<DeliveryChannel, "name">;
120
123
 
124
+ function hasOptionalHooks(o: object): boolean {
125
+ return (
126
+ (!("render" in o) || o.render === undefined || typeof o.render === "function") &&
127
+ (!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
128
+ (!("addressKind" in o) || o.addressKind === undefined || o.addressKind === "connection-name")
129
+ );
130
+ }
131
+
121
132
  export function isDeliveryChannelPlugin(o: unknown): o is DeliveryChannelPlugin {
122
133
  return (
123
134
  typeof o === "object" &&
124
135
  o !== null &&
125
136
  "mode" in o &&
126
137
  DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
127
- (!("render" in o) || o.render === undefined || typeof o.render === "function") &&
128
- (!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
138
+ hasOptionalHooks(o) &&
129
139
  "send" in o &&
130
140
  typeof o.send === "function"
131
141
  );
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "fix",
5
+ "title": "sharp is bumped to 0.35.5 to pick up the librsvg security fix",
6
+ "detail": "Bump sharp to 0.35.5 (librsvg advisory GHSA-WQ5F-XC86-PV6W)"
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -170,8 +170,9 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
170
170
  }
171
171
  // Provider resolved — a list()/delete() failure from here IS fail-closed:
172
172
  // the "files" stage throws, tenant-lifecycle's retry/abandon handling sees
173
- // it, and the next sweep tick retries (list+delete are idempotent, so this
174
- // converges rather than double-deleting or erroring on a missing key).
173
+ // it and retries up to the stage's attempt cap before abandoning the destroy
174
+ // (list+delete are idempotent, so a retry converges rather than
175
+ // double-deleting or erroring on a missing key).
175
176
  for (const prefix of tenantStoragePrefixes(tenantId)) {
176
177
  const keys = await provider.list(prefix);
177
178
  const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
@@ -25,7 +25,7 @@ import {
25
25
  type EntityDefinition,
26
26
  type FeatureRegistrar,
27
27
  } from "@cosmicdrift/kumiko-framework/engine";
28
- import { hasWhereRule } from "../shared/index.js";
28
+ import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
29
29
  import { DEFAULT_FOLDER_ACCESS, FOLDERS_FEATURE_NAME } from "./constants.js";
30
30
  import { createFolderAssignmentEntity, folderEntity } from "./entity.js";
31
31
  import { createClearFolderHandler } from "./handlers/clear-folder.write.js";
@@ -166,17 +166,7 @@ export function createFoldersFeature(opts: FoldersFeatureOptions = {}): typeof f
166
166
  ) {
167
167
  return foldersFeature;
168
168
  }
169
- if (hasWhereRule(opts.ownership?.write)) {
170
- throw new Error(
171
- "createFoldersFeature({ ownership }): ownership.write must not contain a " +
172
- '`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
173
- "layer (the read path, via buildOwnershipClause). Write paths that " +
174
- "consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
175
- "evaluate them, so such a rule can only ever deny — boot validation " +
176
- "rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
177
- "leave it unset.",
178
- );
179
- }
169
+ assertNoWhereRuleInOwnershipWrite("createFoldersFeature", opts.ownership?.write);
180
170
  if (opts.parents !== undefined && opts.parents.length === 0) {
181
171
  throw new Error(
182
172
  "createFoldersFeature({ parents }): parents must not be an empty array — " +
@@ -1 +1,7 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.353.0",
4
+ "type": "fix",
5
+ "title": "The stale-draft cleanup keeps sweeping when a whole batch was re-saved in the meantime"
6
+ }
7
+ ]
@@ -57,6 +57,7 @@ export async function selectStaleDraftsBatch(
57
57
  /** Re-check staleness after the select→release window so a freshly saved draft is not deleted. */
58
58
  export async function isDraftStillStale(
59
59
  db: DbConnection,
60
+ tenantId: TenantId,
60
61
  id: string,
61
62
  olderThanDays: number,
62
63
  ): Promise<boolean> {
@@ -64,9 +65,10 @@ export async function isDraftStillStale(
64
65
  db,
65
66
  `SELECT 1 FROM "read_form_drafts"
66
67
  WHERE "id" = $1
67
- AND COALESCE("modified_at", "inserted_at") < now() - ($2::int * interval '1 day')
68
+ AND "tenant_id" = $2
69
+ AND COALESCE("modified_at", "inserted_at") < now() - ($3::int * interval '1 day')
68
70
  LIMIT 1`,
69
- [id, olderThanDays],
71
+ [id, tenantId, olderThanDays],
70
72
  )) as readonly unknown[];
71
73
  return rows.length > 0;
72
74
  }
@@ -121,20 +121,24 @@ export function groupStaleDraftIdsByTenant(
121
121
  // resurrects, PII included. formDraftExecutor.delete() is the event-sourced
122
122
  // path; it needs a tenant-scoped db + a SessionUser, so rows are grouped by
123
123
  // tenant first (see groupStaleDraftIdsByTenant above).
124
- async function deleteStaleDraftsBatch(
124
+ export async function deleteStaleDraftsBatch(
125
125
  batch: readonly StaleDraftRow[],
126
126
  db: DbConnection,
127
127
  retentionDays: number,
128
128
  log: AppContext["log"],
129
- ): Promise<readonly StaleDraftRow[]> {
129
+ ): Promise<{ readonly deletedRows: readonly StaleDraftRow[]; readonly skippedCount: number }> {
130
130
  const deletedRows: StaleDraftRow[] = [];
131
+ let skippedCount = 0;
131
132
  const byId = new Map(batch.map((row) => [row.id, row]));
132
133
  for (const [tenantId, ids] of groupStaleDraftIdsByTenant(batch)) {
133
134
  const systemUser = createSystemUser(tenantId);
134
135
  const tenantDb = createTenantDb(db, tenantId, "system");
135
136
  for (const id of ids) {
136
137
  // Race: user may have saved between select and here — skip if no longer stale.
137
- if (!(await isDraftStillStale(db, id, retentionDays))) continue;
138
+ if (!(await isDraftStillStale(db, tenantId, id, retentionDays))) {
139
+ skippedCount++;
140
+ continue;
141
+ }
138
142
  const result = await formDraftExecutor.delete({ id }, systemUser, tenantDb);
139
143
  if (result.isSuccess) {
140
144
  const row = byId.get(id);
@@ -146,7 +150,7 @@ async function deleteStaleDraftsBatch(
146
150
  }
147
151
  }
148
152
  }
149
- return deletedRows;
153
+ return { deletedRows, skippedCount };
150
154
  }
151
155
 
152
156
  export async function cleanupDraftsJob(
@@ -189,12 +193,20 @@ export async function cleanupDraftsJob(
189
193
  // entirely for rows with no FileRefs in the blob, the common case.
190
194
  // Delete first (with staleness re-check), then release binaries from rows
191
195
  // that actually disappeared — never release a draft the user just saved.
192
- const deletedRows = await deleteStaleDraftsBatch(batch, db, retentionDays, ctx.log);
196
+ const { deletedRows, skippedCount } = await deleteStaleDraftsBatch(
197
+ batch,
198
+ db,
199
+ retentionDays,
200
+ ctx.log,
201
+ );
193
202
  for (const row of deletedRows) {
194
203
  await releaseRowFileRefs(row, db, fileProviderResolver, ctx.log);
195
204
  }
196
205
  deleted += deletedRows.length;
197
- if (deletedRows.length === 0 || batch.length < DEFAULT_BATCH_SIZE) break;
206
+ // A skipped row is no longer stale, so the next select drops it — a fully
207
+ // skipped batch still makes progress. Only a batch with neither deletes nor
208
+ // skips (all failed) would reselect the same rows forever.
209
+ if (deletedRows.length + skippedCount === 0 || batch.length < DEFAULT_BATCH_SIZE) break;
198
210
  }
199
211
  ctx.log?.info?.(`[form-draft:cleanup] deleted=${deleted} retentionDays=${retentionDays}`);
200
212
  }
@@ -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",
@@ -201,10 +201,10 @@ await seedTextBlock(db, {
201
201
  title: "Impressum",
202
202
  content: `## Angaben gemäß § 5 TMG
203
203
 
204
- **Marc Frost**
204
+ **Alex Morgan**
205
205
 
206
- Slevogtstr. 10
207
- 04159 Leipzig
206
+ Musterstraße 1
207
+ 12345 Musterstadt
208
208
 
209
209
  ## Kontakt
210
210
 
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "fix",
5
+ "title": "The legal-pages README imprint example uses a fictional demo identity",
6
+ "detail": "The legal-pages README seeds its imprint example with a fictional demo identity and address instead of a real person's details."
7
+ },
2
8
  {
3
9
  "version": "0.344.0",
4
10
  "type": "improvement",
@@ -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
  {
@@ -12,7 +12,7 @@ export type RenderRequest =
12
12
  | { kind: "image-snapshot"; payload: DocumentPayload; options?: ImageOptions };
13
13
 
14
14
  export type RenderResponse =
15
- | { kind: "notification"; html: string }
15
+ | { kind: "notification"; html: string; text?: string }
16
16
  | { kind: "mail-html"; html: string; text: string }
17
17
  | { kind: "document-pdf"; pdfBytes: Uint8Array; pageCount: number; sizeBytes: number }
18
18
  | {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "improvement",
5
+ "title": "Badge, stages track and chips for the simple mail renderer",
6
+ "detail": "Use badge, { stages } and { chips } in the template variables; the notification RenderResponse carries the matching plain text in text."
7
+ },
2
8
  {
3
9
  "version": "0.344.0",
4
10
  "type": "improvement",
@@ -31,12 +31,10 @@ export async function adaptToFoundation(
31
31
  );
32
32
  }
33
33
  const variables = await resolveNotificationVariables(req, ctx);
34
- const html = await renderer.render({
35
- template: req.payload.template ?? "",
36
- variables,
37
- locale: req.payload.locale,
38
- });
39
- return { kind: "notification", html };
34
+ const input = { template: req.payload.template ?? "", variables, locale: req.payload.locale };
35
+ const html = await renderer.render(input);
36
+ const text = renderer.renderText ? await renderer.renderText(input) : undefined;
37
+ return text === undefined ? { kind: "notification", html } : { kind: "notification", html, text };
40
38
  }
41
39
 
42
40
  export type RendererSimpleOptions = {
@@ -1,4 +1,5 @@
1
1
  export { createRendererSimpleFeature, type RendererSimpleOptions } from "./feature.js";
2
+ export type { MailBadge, MailChip, MailStage, MailStageState, MailTone } from "./mail-blocks.js";
2
3
  export {
3
4
  createSimpleRenderer,
4
5
  type LocalizedText,