@cosmicdrift/kumiko-samples 0.373.0 → 0.377.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 (207) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -4
  3. package/packages/bundled-features/src/account-security/changes.json +6 -0
  4. package/packages/bundled-features/src/account-security/constants.ts +1 -0
  5. package/packages/bundled-features/src/account-security/index.ts +1 -0
  6. package/packages/bundled-features/src/admin-shell/web/client-plugin.tsx +1 -1
  7. package/packages/bundled-features/src/audit/web/client-plugin.tsx +1 -1
  8. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  9. package/packages/bundled-features/src/auth-email-password/i18n.ts +1 -0
  10. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +1 -1
  11. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +1 -1
  12. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +1 -1
  13. package/packages/bundled-features/src/auth-email-password/web/client-actions.ts +36 -0
  14. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +7 -4
  15. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +1 -1
  16. package/packages/bundled-features/src/auth-email-password/web/index.ts +1 -1
  17. package/packages/bundled-features/src/auth-email-password/web/session.tsx +1 -1
  18. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +1 -1
  19. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +1 -1
  20. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  21. package/packages/bundled-features/src/auth-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/auth-foundation/env.ts +27 -0
  23. package/packages/bundled-features/src/auth-foundation/index.ts +1 -0
  24. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +1 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -1
  27. package/packages/bundled-features/src/billing-foundation/contact-card.ts +14 -0
  28. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +1 -1
  29. package/packages/bundled-features/src/billing-foundation/index.ts +1 -0
  30. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  31. package/packages/bundled-features/src/billing-foundation/types.ts +12 -0
  32. package/packages/bundled-features/src/billing-foundation/validate-options.ts +16 -0
  33. package/packages/bundled-features/src/billing-foundation/web/billing-contact-plan.tsx +32 -0
  34. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +5 -15
  35. package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +2 -2
  36. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +1 -1
  37. package/packages/bundled-features/src/billing-foundation/web/client-plugin.tsx +1 -1
  38. package/packages/bundled-features/src/billing-foundation/web/localized-params.ts +14 -0
  39. package/packages/bundled-features/src/cap-overview/changes.json +7 -0
  40. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +15 -76
  41. package/packages/bundled-features/src/cap-overview/i18n.ts +0 -2
  42. package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +3 -3
  43. package/packages/bundled-features/src/cap-overview/web/client-plugin.tsx +1 -1
  44. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  45. package/packages/bundled-features/src/channel-email/email-channel.ts +2 -1
  46. package/packages/bundled-features/src/compliance-profiles/web/client-plugin.tsx +1 -1
  47. package/packages/bundled-features/src/config/feature.ts +2 -2
  48. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  49. package/packages/bundled-features/src/custom-fields/web/client-plugin.tsx +1 -1
  50. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +1 -1
  51. package/packages/bundled-features/src/delivery/web/client-plugin.tsx +1 -1
  52. package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +1 -1
  53. package/packages/bundled-features/src/feature-toggles/web/client-plugin.tsx +1 -1
  54. package/packages/bundled-features/src/file-provider-s3/changes.json +6 -0
  55. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +45 -0
  56. package/packages/bundled-features/src/file-provider-s3/env-schema.ts +20 -13
  57. package/packages/bundled-features/src/file-provider-s3/index.ts +4 -0
  58. package/packages/bundled-features/src/folders/web/client-plugin.tsx +1 -1
  59. package/packages/bundled-features/src/folders/web/folder-section.tsx +3 -4
  60. package/packages/bundled-features/src/host-routing/README.md +7 -0
  61. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  62. package/packages/bundled-features/src/host-routing/constants.ts +4 -0
  63. package/packages/bundled-features/src/host-routing/dispatch.ts +34 -26
  64. package/packages/bundled-features/src/host-routing/feature.ts +122 -41
  65. package/packages/bundled-features/src/host-routing/host.ts +28 -1
  66. package/packages/bundled-features/src/host-routing/index.ts +3 -0
  67. package/packages/bundled-features/src/host-routing/options.ts +45 -2
  68. package/packages/bundled-features/src/host-routing/redirects.ts +33 -0
  69. package/packages/bundled-features/src/host-routing/tenant-lookup.ts +65 -0
  70. package/packages/bundled-features/src/host-routing/validate-options.ts +48 -1
  71. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +8 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +42 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +4 -0
  75. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +100 -0
  76. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +2 -2
  77. package/packages/bundled-features/src/jobs/web/client-plugin.tsx +1 -1
  78. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  79. package/packages/bundled-features/src/legal-pages/feature.ts +14 -1
  80. package/packages/bundled-features/src/legal-pages/layout.ts +6 -6
  81. package/packages/bundled-features/src/legal-pages/web/client-plugin.ts +1 -1
  82. package/packages/bundled-features/src/mail-foundation/feature.ts +21 -22
  83. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +1 -1
  84. package/packages/bundled-features/src/notes-history/web/client-plugin.tsx +1 -1
  85. package/packages/bundled-features/src/page-render/branding.ts +1 -1
  86. package/packages/bundled-features/src/page-render/layout.ts +2 -2
  87. package/packages/bundled-features/src/page-render/markdown.ts +1 -1
  88. package/packages/bundled-features/src/personal-access-tokens/i18n.ts +1 -1
  89. package/packages/bundled-features/src/personal-access-tokens/screens.ts +1 -1
  90. package/packages/bundled-features/src/secrets/feature.ts +4 -4
  91. package/packages/bundled-features/src/seo/sitemap.ts +1 -1
  92. package/packages/bundled-features/src/shared/mail-template-body.ts +1 -1
  93. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +127 -2
  94. package/packages/bundled-features/src/tags/web/client-plugin.tsx +1 -1
  95. package/packages/bundled-features/src/template-resolver/changes.json +18 -0
  96. package/packages/bundled-features/src/template-resolver/document-blocks.ts +34 -0
  97. package/packages/bundled-features/src/template-resolver/document-ops.ts +515 -0
  98. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +9 -0
  99. package/packages/bundled-features/src/template-resolver/feature.ts +4 -0
  100. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +1 -0
  101. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +86 -0
  102. package/packages/bundled-features/src/template-resolver/handlers/document-blocks.query.ts +48 -0
  103. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +11 -3
  104. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +125 -0
  105. package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +2 -1
  106. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +2 -1
  107. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -1
  108. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +24 -9
  109. package/packages/bundled-features/src/template-resolver/{web/placeholder-text.ts → placeholder-text.ts} +1 -1
  110. package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
  111. package/packages/bundled-features/src/template-resolver/seeding.ts +3 -1
  112. package/packages/bundled-features/src/template-resolver/template-bundle.ts +6 -1
  113. package/packages/bundled-features/src/template-resolver/template-preview.ts +24 -8
  114. package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -1
  115. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +3 -3
  116. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +1 -1
  117. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +7 -33
  118. package/packages/bundled-features/src/template-resolver/web/i18n.ts +24 -0
  119. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
  120. package/packages/bundled-features/src/tenant/web/client-plugin.tsx +1 -1
  121. package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
  122. package/packages/bundled-features/src/tier-engine/web/client-plugin.tsx +1 -1
  123. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  124. package/packages/bundled-features/src/user-data-rights/env.ts +15 -0
  125. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -2
  126. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  127. package/packages/bundled-features/src/user-data-rights/lib/mail-transport-resolver.ts +1 -1
  128. package/packages/bundled-features/src/user-data-rights/lib/storage-provider-resolver.ts +1 -1
  129. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +1 -1
  130. package/packages/bundled-features/src/user-data-rights/web/client-plugin.tsx +1 -1
  131. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -2
  132. package/packages/bundled-features/src/waitlist/changes.json +6 -0
  133. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +51 -2
  134. package/samples/apps/admin-console/package.json +0 -3
  135. package/samples/apps/admin-console/src/app/client.tsx +1 -1
  136. package/samples/apps/admin-console/src/app/shell.tsx +1 -1
  137. package/samples/apps/config-demo/package.json +1 -3
  138. package/samples/apps/config-demo/src/app/client.tsx +1 -1
  139. package/samples/apps/config-demo/src/app/shell.tsx +2 -2
  140. package/samples/apps/config-demo/src/features/demo/web/index.ts +1 -1
  141. package/samples/apps/config-demo/tsconfig.json +0 -12
  142. package/samples/apps/marketing-demo/package.json +1 -3
  143. package/samples/apps/marketing-demo/src/app/client.tsx +1 -1
  144. package/samples/apps/marketing-demo/src/app/shell.tsx +1 -1
  145. package/samples/apps/marketing-demo/src/features/assets/web/index.ts +1 -1
  146. package/samples/apps/marketing-demo/src/features/helpdesk/web/index.ts +1 -1
  147. package/samples/apps/showcase/package.json +1 -3
  148. package/samples/apps/showcase/src/app/client.tsx +1 -1
  149. package/samples/apps/showcase/src/app/shell.tsx +1 -1
  150. package/samples/apps/showcase/src/features/demos/web/index.ts +1 -1
  151. package/samples/apps/showcase/src/features/demos/web/pages/sidebar-panel.tsx +1 -1
  152. package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +1 -1
  153. package/samples/apps/showcase/src/features/items/web/index.ts +1 -1
  154. package/samples/apps/styleguide/package.json +1 -3
  155. package/samples/apps/styleguide/src/app/client.tsx +1 -1
  156. package/samples/apps/styleguide/src/app/shell.tsx +2 -2
  157. package/samples/apps/styleguide/src/features/ai-settings/web.ts +1 -1
  158. package/samples/apps/styleguide/src/features/content/web.tsx +2 -2
  159. package/samples/apps/styleguide/src/features/demo/web.ts +1 -1
  160. package/samples/apps/styleguide/src/features/examples/web.ts +1 -1
  161. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
  162. package/samples/apps/styleguide/src/features/gallery/web/index.ts +1 -1
  163. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +1 -1
  164. package/samples/apps/styleguide/src/features/widgets/feature.ts +17 -0
  165. package/samples/apps/styleguide/src/features/widgets/i18n.ts +8 -0
  166. package/samples/apps/styleguide/src/features/widgets/lib/format.ts +1 -1
  167. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +1 -1
  168. package/samples/apps/styleguide/src/features/widgets/web/FormExamples.tsx +1 -1
  169. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +1 -1
  170. package/samples/apps/styleguide/src/features/widgets/web/index.ts +1 -1
  171. package/samples/apps/styleguide/tsconfig.json +0 -12
  172. package/samples/apps/ui-walkthrough/package.json +1 -3
  173. package/samples/apps/ui-walkthrough/src/app/client.tsx +1 -1
  174. package/samples/apps/ui-walkthrough/src/app/shell.tsx +1 -1
  175. package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +1 -1
  176. package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +1 -1
  177. package/samples/apps/ui-walkthrough/src/features/tasks/web/index.ts +1 -1
  178. package/samples/apps/ui-walkthrough/src/features/vehicles/web/index.ts +1 -1
  179. package/samples/apps/use-all-bundled/bin/main.ts +3 -2
  180. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +12 -0
  181. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +1 -1
  182. package/samples/apps/use-all-bundled/feature-manifest.json +1 -0
  183. package/samples/apps/use-all-bundled/package.json +1 -3
  184. package/samples/apps/use-all-bundled/src/app/client.tsx +1 -1
  185. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +5 -4
  186. package/samples/apps/use-all-bundled/src/app/shell.tsx +1 -1
  187. package/samples/apps/use-all-bundled/src/run-config.ts +10 -1
  188. package/samples/apps/workspaces/package.json +1 -3
  189. package/samples/apps/workspaces/src/app/client.tsx +1 -1
  190. package/samples/apps/workspaces/src/app/shell.tsx +1 -1
  191. package/samples/apps/workspaces/src/features/demo/web/index.ts +1 -1
  192. package/samples/apps/workspaces/src/features/demo-driver/web/index.ts +1 -1
  193. package/samples/recipes/apex-landing/package.json +1 -1
  194. package/samples/recipes/apex-landing/src/feature.ts +1 -1
  195. package/samples/recipes/apex-surface-auth/README.md +1 -1
  196. package/samples/recipes/apex-surface-auth/src/feature.ts +2 -2
  197. package/samples/recipes/compose-stacks/README.md +7 -4
  198. package/samples/recipes/embedded-entity-form/package.json +0 -1
  199. package/samples/recipes/embedded-entity-form/src/web/accept-suggestion-drawer.tsx +1 -1
  200. package/samples/recipes/notes-history-basic/README.md +1 -1
  201. package/samples/recipes/record-detail-layout/package.json +0 -1
  202. package/samples/recipes/tags-basic/README.md +1 -1
  203. package/samples/recipes/user-profile/README.md +1 -1
  204. package/samples/recipes/wizard-form/package.json +0 -1
  205. package/samples/recipes/wizard-form/src/web/index.ts +2 -2
  206. package/samples/recipes/wizard-form/src/web/listing-review-section.tsx +2 -2
  207. package/samples/recipes/writeform-section/package.json +0 -1
@@ -0,0 +1,100 @@
1
+ import {
2
+ i18nKey,
3
+ MAX_LIST_LIMIT,
4
+ type ProjectionListScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
7
+ import {
8
+ InboundMailAccountStatuses,
9
+ InboundMailFoundationHandlers,
10
+ InboundMailFoundationQueries,
11
+ } from "./constants.ts";
12
+
13
+ export const MAIL_ACCOUNT_LIST_SCREEN_ID = "mail-accounts";
14
+ export const MAIL_ACCOUNT_EDIT_SCREEN_ID = "mail-account-edit";
15
+
16
+ export const MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX = "inboundMail.accounts.status.";
17
+
18
+ // Same roles as the update/disconnect handlers: a screen rule wider than the
19
+ // handlers would only render actions that 403.
20
+ const MAIL_ACCOUNT_ADMIN_ACCESS = { roles: ["SystemAdmin", "TenantAdmin"] } as const;
21
+
22
+ // Several mailboxes per tenant, one row each. The bound query already filters
23
+ // personal mailboxes the caller neither owns nor administers and returns no
24
+ // credential: the secret lives in the secrets feature, never in the row.
25
+ // dormant: the consuming app places the list in its own settings nav.
26
+ export const mailAccountListScreen: ProjectionListScreenDefinition = {
27
+ id: MAIL_ACCOUNT_LIST_SCREEN_ID,
28
+ type: "projectionList",
29
+ agent: { expose: false },
30
+ query: InboundMailFoundationQueries.listAccounts,
31
+ pagination: false,
32
+ pageSize: MAX_LIST_LIMIT,
33
+ columns: [
34
+ { field: "displayName", label: i18nKey("inboundMail.accounts.col.name"), sortable: false },
35
+ { field: "address", label: i18nKey("inboundMail.accounts.col.address"), sortable: false },
36
+ {
37
+ field: "provider",
38
+ label: i18nKey("inboundMail.accounts.col.provider"),
39
+ sortable: false,
40
+ hideOnNarrow: true,
41
+ },
42
+ {
43
+ field: "status",
44
+ label: i18nKey("inboundMail.accounts.col.status"),
45
+ sortable: false,
46
+ renderer: { format: "enumOption", keyPrefix: MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX },
47
+ },
48
+ {
49
+ field: "connectedAt",
50
+ label: i18nKey("inboundMail.accounts.col.connected"),
51
+ sortable: false,
52
+ hideOnNarrow: true,
53
+ renderer: { format: "timestamp" },
54
+ },
55
+ ],
56
+ rowActions: [
57
+ {
58
+ kind: "navigate",
59
+ id: "edit",
60
+ label: i18nKey("inboundMail.accounts.edit"),
61
+ screen: MAIL_ACCOUNT_EDIT_SCREEN_ID,
62
+ params: { map: { accountId: "id", displayName: "displayName" } },
63
+ visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
64
+ },
65
+ {
66
+ kind: "writeHandler",
67
+ id: "disconnect",
68
+ label: i18nKey("inboundMail.accounts.disconnect"),
69
+ handler: InboundMailFoundationHandlers.disconnectAccount,
70
+ payload: { map: { accountId: "id" } },
71
+ style: "danger",
72
+ confirm: i18nKey("inboundMail.accounts.disconnect.confirm"),
73
+ visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
74
+ },
75
+ ],
76
+ access: MAIL_ACCOUNT_ADMIN_ACCESS,
77
+ dormant: true,
78
+ };
79
+
80
+ // The reason is the audit label update-account requires; it is not in the
81
+ // layout, so the default is what gets written.
82
+ export const mailAccountEditScreen: ActionFormScreenDefinition = {
83
+ id: MAIL_ACCOUNT_EDIT_SCREEN_ID,
84
+ type: "actionForm",
85
+ agent: { expose: false },
86
+ handler: InboundMailFoundationHandlers.updateAccount,
87
+ fields: {
88
+ accountId: { type: "text", required: true },
89
+ displayName: { type: "text", required: true, maxLength: 200 },
90
+ reason: { type: "text", default: "tenant_admin" },
91
+ },
92
+ layout: {
93
+ sections: [{ fields: [{ field: "accountId", readOnly: true }, "displayName"] }],
94
+ },
95
+ submitLabel: i18nKey("inboundMail.accounts.edit.submit"),
96
+ redirect: MAIL_ACCOUNT_LIST_SCREEN_ID,
97
+ cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_ID,
98
+ access: MAIL_ACCOUNT_ADMIN_ACCESS,
99
+ dormant: true,
100
+ };
@@ -21,7 +21,7 @@ import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "
21
21
  * config — tenant config reads (host/port/... of the plugins)
22
22
  * registry — extension lookup in the factory (not plugin-internal)
23
23
  * secrets — tenant secret reads (IMAP password, OAuth refresh token)
24
- * _userId — audit identity for secret reads. Handler path: the dispatcher
24
+ * auditUserId — audit identity for secret reads. Handler path: the dispatcher
25
25
  * sets the caller user ID; worker path: system identity.
26
26
  * metricsFor — metrics handle of a feature by name, same behavior as
27
27
  * HandlerContext.metricsFor: unregistered metrics are a silent
@@ -31,7 +31,7 @@ export type InboundMailContext = {
31
31
  readonly config?: ConfigAccessor;
32
32
  readonly registry?: Registry;
33
33
  readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
34
- readonly _userId?: string | undefined;
34
+ readonly auditUserId?: string | undefined;
35
35
  readonly metricsFor?: (featureName: string) => MetricsHandle;
36
36
  };
37
37
 
@@ -1,6 +1,6 @@
1
1
  // @runtime client
2
2
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
3
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
4
4
  import { JOBS_FEATURE } from "../constants.ts";
5
5
  import { defaultTranslations } from "./i18n.ts";
6
6
 
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.376.0",
4
+ "type": "improvement",
5
+ "title": "Legal layout receives alternates",
6
+ "detail": "createLegalPagesLayout passes alternates as second argument to headerHtml and footerHtml, and legal-pages routes provide the other-language paths of the same slug, so a layout can render a language switch."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "improvement",
@@ -4,7 +4,7 @@ import {
4
4
  type FeatureDefinition,
5
5
  SYSTEM_TENANT_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { escapeHtml } from "@cosmicdrift/kumiko-headless";
7
+ import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
8
8
  import {
9
9
  cachedSecurePageResponse,
10
10
  type PublicPageWrapLayout,
@@ -90,6 +90,17 @@ export type LegalPagesOptions = {
90
90
  readonly terms?: boolean;
91
91
  };
92
92
 
93
+ // Only slugs served in more than one language get alternates.
94
+ function alternatesBySlugOf(
95
+ routes: readonly LegalPageRoute[],
96
+ ): ReadonlyMap<string, Readonly<Record<string, string>>> {
97
+ const bySlug = new Map<string, Record<string, string>>();
98
+ for (const route of routes) {
99
+ bySlug.set(route.slug, { ...bySlug.get(route.slug), [route.lang]: route.path });
100
+ }
101
+ return new Map([...bySlug].filter(([, byLang]) => Object.keys(byLang).length > 1));
102
+ }
103
+
93
104
  export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
94
105
  const wrapLayout = opts.wrapLayout ?? wrapInLayout;
95
106
  const routes: readonly LegalPageRoute[] = [
@@ -108,6 +119,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
108
119
  );
109
120
  }
110
121
  }
122
+ const alternatesBySlug = alternatesBySlugOf(routes);
111
123
  return defineFeature("legal-pages", (r) => {
112
124
  r.describe(
113
125
  "Opt-in wrapper around `template-resolver` text-blocks that registers public HTML routes (default: `/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`, `/legal/privacy`) with Markdown-to-HTML rendering and a boot-time job that hard-fails in production when the required blocks (default: `imprint/de`, `privacy/de`) are not seeded in `SYSTEM_TENANT`. Both are configurable via `routes`/`requiredBlocks` for apps with a different default language or additional pages. Requires `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` and `extraContext.templateResolver` to be wired at app bootstrap; for per-tenant imprints or a custom layout call `template-resolver:query:by-slug` directly.",
@@ -189,6 +201,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
189
201
  : renderedBody,
190
202
  lang: route.lang,
191
203
  slug: route.slug,
204
+ alternates: alternatesBySlug.get(route.slug),
192
205
  });
193
206
 
194
207
  return cachedSecurePageResponse(c.req.raw, {
@@ -1,4 +1,4 @@
1
- import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
2
2
  import { type PublicPageWrapLayout, TENANT_CONTENT_ATTR } from "../page-render/index.ts";
3
3
 
4
4
  export type LegalPagesLayoutOptions = {
@@ -8,8 +8,8 @@ export type LegalPagesLayoutOptions = {
8
8
  readonly css?: string;
9
9
  readonly stylesheetHrefs?: readonly string[];
10
10
  // Trusted app chrome per language; the feature passes the route's `lang`.
11
- readonly headerHtml?: (lang: string) => string;
12
- readonly footerHtml?: (lang: string) => string;
11
+ readonly headerHtml?: (lang: string, alternates?: Readonly<Record<string, string>>) => string;
12
+ readonly footerHtml?: (lang: string, alternates?: Readonly<Record<string, string>>) => string;
13
13
  readonly contentClass?: string;
14
14
  };
15
15
 
@@ -22,9 +22,9 @@ export function createLegalPagesLayout(options: LegalPagesLayoutOptions): Public
22
22
  const contentClass = options.contentClass
23
23
  ? ` class="${escapeHtmlAttr(options.contentClass)}"`
24
24
  : "";
25
- return ({ title, bodyHtml, lang }) => {
26
- const headerHtml = options.headerHtml?.(lang) ?? "";
27
- const footerHtml = options.footerHtml?.(lang) ?? "";
25
+ return ({ title, bodyHtml, lang, alternates }) => {
26
+ const headerHtml = options.headerHtml?.(lang, alternates) ?? "";
27
+ const footerHtml = options.footerHtml?.(lang, alternates) ?? "";
28
28
  return `<!doctype html>
29
29
  <html lang="${escapeHtmlAttr(lang)}">
30
30
  <head>
@@ -17,7 +17,7 @@
17
17
  // fehlende Pflicht-Blocks.
18
18
 
19
19
  import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
20
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
20
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
21
21
  import { LEGAL_OPTIONAL_BLOCKS, LEGAL_REQUIRED_BLOCKS } from "../constants.ts";
22
22
 
23
23
  const treeProvider: TreeChildrenSubscribe = () => (emit) => {
@@ -58,34 +58,33 @@ export const MAIL_TRANSPORT_EXTENSION = "mailTransport" as const;
58
58
  // =============================================================================
59
59
 
60
60
  /**
61
- * Schmaler Surface-Type fuer Transport-Plugins — gespiegelt von
62
- * file-foundation's FileProviderContext. HandlerContext ist zu fett
63
- * (haelt tx, actor, signal etc.); Plugins beschraenken sich auf die
64
- * read-Felder die fuer Tenant-Config + Secret-Lookup noetig sind.
61
+ * Narrow surface type for transport plugins, mirroring file-foundation's
62
+ * FileProviderContext. HandlerContext carries tx, actor, signal and more;
63
+ * plugins only get the read fields needed for tenant config and secret lookup.
65
64
  *
66
- * **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job-getriggerte
67
- * Transport-Builds, z.B. der user-data-rights forget/export-Cron) gibt es
68
- * keinen per-request `config`/`tx`/`actor` — der Wrapper baut den per-Tenant-
69
- * ConfigAccessor aus `ctx.configResolver`. Ein Plugin das `ctx.tx` oder
70
- * andere request-only-Felder liest, wuerde den Worker-Pfad zur Runtime
71
- * brechen — und das fiele NUR mit echtem SMTP und nur in production auf.
72
- * Cast `ctx as unknown as HandlerContext` macht den Compiler happy, fliegt
73
- * aber zur Runtime im Worker. Plugin das mehr braucht: MailTransportContext
74
- * explizit erweitern (sichtbarer breaking change) statt ctx-cast.
65
+ * **Why not the full HandlerContext?** On the worker path (transport builds
66
+ * triggered by r.job, such as the user-data-rights forget/export cron) there
67
+ * is no per-request `config`/`tx`/`actor`; the wrapper builds the per-tenant
68
+ * ConfigAccessor from `ctx.configResolver`. A plugin reading `ctx.tx` or other
69
+ * request-only fields would break the worker path at runtime, and that would
70
+ * only show up with real SMTP in production. Casting
71
+ * `ctx as unknown as HandlerContext` satisfies the compiler but fails at
72
+ * runtime in the worker. A plugin that needs more extends MailTransportContext
73
+ * explicitly (a visible breaking change) instead of casting ctx.
75
74
  *
76
- * **Felder:**
77
- * config — tenant-config-reads (host/port/from/... der Plugins)
78
- * registry — extension-Lookup in der Factory (nicht plugin-intern)
79
- * secrets — tenant-secret-reads (smtp.password)
80
- * _userId — Audit-Identity fuer secret-reads. Handler-Pfad: dispatcher
81
- * setzt Caller-User-ID; Worker-Pfad: r.job-Wrap setzt eine
82
- * System-Identity.
75
+ * **Fields:**
76
+ * config — tenant config reads (the plugin's host/port/from/...)
77
+ * registry — extension lookup in the factory (not plugin-internal)
78
+ * secrets — tenant secret reads (smtp.password)
79
+ * auditUserId — audit identity for secret reads. Handler path: the
80
+ * dispatcher sets the caller's user id; worker path: the
81
+ * r.job wrap sets a system identity.
83
82
  */
84
83
  export type MailTransportContext = {
85
84
  readonly config?: ConfigAccessor;
86
85
  readonly registry?: Registry;
87
86
  readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
88
- readonly _userId?: string | undefined;
87
+ readonly auditUserId?: string | undefined;
89
88
  };
90
89
 
91
90
  /**
@@ -283,7 +282,7 @@ export function tenantMailTransport(
283
282
  config: scope.config,
284
283
  registry: scope.registry,
285
284
  secrets: scope.secrets,
286
- _userId: createSystemUser(scope.tenantId).id,
285
+ auditUserId: createSystemUser(scope.tenantId).id,
287
286
  },
288
287
  scope.tenantId,
289
288
  "channel-email",
@@ -6,7 +6,7 @@
6
6
  // [managedPagesClient()] }), damit die Admin-Labels übersetzt rendern.
7
7
 
8
8
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
9
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
9
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
10
10
  import { defaultTranslations } from "./i18n.ts";
11
11
 
12
12
  export type ManagedPagesClientOptions = {
@@ -1,6 +1,6 @@
1
1
  // @runtime client
2
2
 
3
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
3
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
4
4
  import { NOTES_HISTORY_FEATURE_NAME, NOTES_SECTION_EXTENSION_NAME } from "../constants.ts";
5
5
  import { defaultTranslations } from "./i18n.ts";
6
6
  import { NotesSection } from "./notes-section.tsx";
@@ -1,4 +1,4 @@
1
- import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
2
2
 
3
3
  // Tenant-branding tokens resolved at render time (managed-pages reads them
4
4
  // from config — see managed-pages/branding.ts). Empty string = "unset, use
@@ -1,5 +1,5 @@
1
- import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
- import { type ApexHead, renderApexHeadTags } from "@cosmicdrift/kumiko-headless/apex";
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
2
+ import { type ApexHead, renderApexHeadTags } from "@cosmicdrift/kumiko-renderer/headless/apex";
3
3
  import { type BrandingTokens, brandingHeaderHtml, brandingStyleBlock } from "./branding.ts";
4
4
  import { sanitizeTenantCss } from "./css-sanitize.ts";
5
5
 
@@ -1,4 +1,4 @@
1
- import { escapeHtml, isSafeHref } from "@cosmicdrift/kumiko-headless";
1
+ import { escapeHtml, isSafeHref } from "@cosmicdrift/kumiko-renderer/headless";
2
2
  import { Marked } from "marked";
3
3
 
4
4
  // Geteilter, gehärteter Markdown→HTML-Kern für server-gerenderte Public-
@@ -1,5 +1,5 @@
1
1
  import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
2
- import { fieldLabelKey, fieldOptionLabelKey } from "@cosmicdrift/kumiko-headless";
2
+ import { fieldLabelKey, fieldOptionLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
3
3
  import { PAT_FEATURE } from "./constants.ts";
4
4
  import type { PatScopeConfig } from "./scopes.ts";
5
5
 
@@ -5,7 +5,7 @@ import {
5
5
  type SecretMintScreenDefinition,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
8
- import { fieldOptionLabelKeyPrefix } from "@cosmicdrift/kumiko-headless";
8
+ import { fieldOptionLabelKeyPrefix } from "@cosmicdrift/kumiko-renderer/headless";
9
9
  import {
10
10
  PAT_FEATURE,
11
11
  PAT_MINT_SCREEN_ID,
@@ -79,11 +79,11 @@ export const JOIN_REQUEST_TRANSACTION_REASON =
79
79
  // FileProviderContext-Pfade (S3-Plugin im Worker) liefern dieselben
80
80
  // zwei Felder, also reicht die schmale ctx-shape — kein voller
81
81
  // HandlerContext-Import noetig.
82
- // Handler contexts carry the caller as ctx.user, provider contexts set _userId (takes precedence).
82
+ // Handler contexts carry the caller as ctx.user, provider contexts set auditUserId (takes precedence).
83
83
  export function requireSecretsContext(
84
84
  ctx: {
85
85
  readonly secrets?: SecretsContext;
86
- readonly _userId?: string | undefined;
86
+ readonly auditUserId?: string | undefined;
87
87
  readonly user?: { readonly id: string };
88
88
  },
89
89
  handlerName: string,
@@ -95,10 +95,10 @@ export function requireSecretsContext(
95
95
  "createSecretsContext({db, masterKeyProvider, registry}) via extraContext.secrets at boot.",
96
96
  });
97
97
  }
98
- const userId = ctx._userId ?? ctx.user?.id;
98
+ const userId = ctx.auditUserId ?? ctx.user?.id;
99
99
  if (!userId) {
100
100
  throw new InternalError({
101
- message: `[${handlerName}] ctx._userId missing (and no ctx.user) — cannot audit secret reads without a caller identity.`,
101
+ message: `[${handlerName}] ctx.auditUserId missing (and no ctx.user) — cannot audit secret reads without a caller identity.`,
102
102
  });
103
103
  }
104
104
  const withAudit = (raw: SecretsContext): SecretsContext => ({
@@ -1,4 +1,4 @@
1
- import { escapeXml } from "@cosmicdrift/kumiko-headless";
1
+ import { escapeXml } from "@cosmicdrift/kumiko-renderer/headless";
2
2
 
3
3
  export type SitemapEntry = {
4
4
  readonly loc: string;
@@ -1,4 +1,4 @@
1
- import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
2
2
 
3
3
  const SLOT_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
4
4
 
@@ -16,9 +16,14 @@ import {
16
16
  type EntityDefinition,
17
17
  parseTenantId,
18
18
  type Registry,
19
+ SYSTEM_USER_ID,
19
20
  type TenantId,
20
21
  } from "@cosmicdrift/kumiko-framework/engine";
21
- import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
22
+ import {
23
+ FILE_REF_ENTITY_NAME,
24
+ fileRefEntity,
25
+ fileRefsTable,
26
+ } from "@cosmicdrift/kumiko-framework/files";
22
27
  import {
23
28
  collectSingleFileHolders,
24
29
  type FileRefPurgeOutcome,
@@ -155,7 +160,8 @@ export async function purgeFilesReachingSubject(args: {
155
160
  const storageByTenant = new Map<TenantId, Promise<DeleteFileBytes | undefined>>();
156
161
  let firstFailure: FileSweepError | undefined;
157
162
 
158
- for (const fileEntity of collectFileEntities(registry)) {
163
+ const fileEntities = collectFileEntities(registry);
164
+ for (const fileEntity of fileEntities) {
159
165
  const seenRowIds = new Set<string>();
160
166
  for (const where of candidateWheres(subject, recordTenantId, fileEntity)) {
161
167
  const rows = await selectMany<Record<string, unknown>>(db, fileEntity.table, where);
@@ -202,5 +208,124 @@ export async function purgeFilesReachingSubject(args: {
202
208
  }
203
209
  }
204
210
  }
211
+ // Runs even after an entity-sweep failure: one failed row must not leave the pending uploads.
212
+ const pendingFailure = await purgePendingUploadsOfUser({
213
+ db,
214
+ registry,
215
+ subject,
216
+ fileFieldKeys: pendingFieldKeys(fileEntities),
217
+ fileHolders,
218
+ storageByTenant,
219
+ resolveDeleteBytes,
220
+ missingStorage,
221
+ });
222
+ firstFailure ??= pendingFailure;
205
223
  if (firstFailure) throw firstFailure;
206
224
  }
225
+
226
+ const pendingFieldKey = (entityType: unknown, fieldName: unknown) =>
227
+ `${entityType}\u0000${fieldName}`;
228
+
229
+ function pendingFieldKeys(fileEntities: readonly FileEntity[]): ReadonlySet<string> {
230
+ const keys = new Set<string>();
231
+ for (const { entityName, fileFields } of fileEntities) {
232
+ for (const fieldName of fileFields) keys.add(pendingFieldKey(entityName, fieldName));
233
+ }
234
+ return keys;
235
+ }
236
+
237
+ async function isFileRefHeldByRecord(
238
+ db: DbRunner,
239
+ holders: readonly SingleFileHolder[],
240
+ tenantId: TenantId,
241
+ fileRefId: string,
242
+ ): Promise<boolean> {
243
+ for (const holder of holders) {
244
+ for (const field of holder.singleFields) {
245
+ const where: WhereObject = { [field]: fileRefId };
246
+ if (holder.tableHasTenantId) where["tenantId"] = tenantId;
247
+ if ((await selectMany(db, holder.table, where, { limit: 1 })).length > 0) return true;
248
+ }
249
+ }
250
+ return false;
251
+ }
252
+
253
+ // An upload the user made but never bound to a record has no entity row to find it through, so
254
+ // the uploader id is the only link. Only refs that originate from a registered entity file
255
+ // field count as pending uploads: config saves (a tenant logo) and server-made files never
256
+ // bind and must outlive the uploader. A legacy unbound ref a record's single file column still
257
+ // names is not pending but in use and stays with that record.
258
+ async function purgePendingUploadsOfUser(args: {
259
+ readonly db: DbRunner;
260
+ readonly registry: Registry;
261
+ readonly subject: SubjectId;
262
+ readonly fileFieldKeys: ReadonlySet<string>;
263
+ readonly fileHolders: readonly SingleFileHolder[];
264
+ readonly storageByTenant: Map<TenantId, Promise<DeleteFileBytes | undefined>>;
265
+ readonly resolveDeleteBytes: (tenantId: TenantId) => Promise<DeleteFileBytes | undefined>;
266
+ readonly missingStorage: MissingStoragePolicy;
267
+ }): Promise<FileSweepError | undefined> {
268
+ const { db, registry, subject, fileFieldKeys } = args;
269
+ // skip: only a user uploads files without binding them to a record
270
+ if (subject.kind !== "user") return undefined;
271
+ // skip: the system user is not a person; its server-made files are not pending uploads
272
+ if (subject.userId === SYSTEM_USER_ID) return undefined;
273
+ // skip: without the files feature there is no file_refs table to sweep
274
+ if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined) return undefined;
275
+ const pending = await selectMany<Record<string, unknown>>(db, fileRefsTable, {
276
+ insertedById: subject.userId,
277
+ entityId: null,
278
+ });
279
+ let firstFailure: FileSweepError | undefined;
280
+ for (const fileRef of pending) {
281
+ // skip: not uploaded for a registered entity file field (config, server-made, unknown origin)
282
+ if (!fileFieldKeys.has(pendingFieldKey(fileRef["entityType"], fileRef["fieldName"]))) continue;
283
+ firstFailure ??= await purgePendingFileRef(args, fileRef);
284
+ }
285
+ return firstFailure;
286
+ }
287
+
288
+ async function purgePendingFileRef(
289
+ args: {
290
+ readonly db: DbRunner;
291
+ readonly fileHolders: readonly SingleFileHolder[];
292
+ readonly storageByTenant: Map<TenantId, Promise<DeleteFileBytes | undefined>>;
293
+ readonly resolveDeleteBytes: (tenantId: TenantId) => Promise<DeleteFileBytes | undefined>;
294
+ readonly missingStorage: MissingStoragePolicy;
295
+ },
296
+ fileRef: Record<string, unknown>,
297
+ ): Promise<FileSweepError | undefined> {
298
+ const { db, fileHolders, storageByTenant, resolveDeleteBytes, missingStorage } = args;
299
+ const tenantId = parseTenantId(fileRef["tenantId"]);
300
+ const fileRefId = String(fileRef["id"]);
301
+ const storageKey = fileRef["storageKey"];
302
+ // skip: a ref without tenant or key has no bytes this sweep could address
303
+ if (tenantId === null || typeof storageKey !== "string" || storageKey === "") return undefined;
304
+ // skip: a record still uses this file
305
+ if (await isFileRefHeldByRecord(db, fileHolders, tenantId, fileRefId)) return undefined;
306
+
307
+ let storagePromise = storageByTenant.get(tenantId);
308
+ if (storagePromise === undefined) {
309
+ storagePromise = resolveDeleteBytes(tenantId);
310
+ storageByTenant.set(tenantId, storagePromise);
311
+ }
312
+ const deleteBytes = await storagePromise;
313
+ if (deleteBytes === undefined) {
314
+ if (missingStorage.mode === "skip") {
315
+ missingStorage.warn(tenantId);
316
+ return undefined;
317
+ }
318
+ return new FileSweepError("missing_file_storage", FILE_REF_ENTITY_NAME);
319
+ }
320
+ if ((await deleteBytesFailingClosed(deleteBytes, storageKey)).length > 0) {
321
+ return new FileSweepError("file_delete_failed", FILE_REF_ENTITY_NAME);
322
+ }
323
+ const forgotten = await fileRefExecutor.forget(
324
+ { id: fileRefId },
325
+ createSystemUser(tenantId),
326
+ createTenantDb({ db, tenantId, mode: "system" }),
327
+ );
328
+ return forgotten.isSuccess
329
+ ? undefined
330
+ : new FileSweepError("file_delete_failed", FILE_REF_ENTITY_NAME);
331
+ }
@@ -1,6 +1,6 @@
1
1
  // @runtime client
2
2
 
3
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
3
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
4
4
  import {
5
5
  TAGS_COLUMN_RENDERER_NAME,
6
6
  TAGS_FEATURE_NAME,
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.377.0",
4
+ "type": "improvement",
5
+ "title": "seedSystemTemplateBundle sets a title per template",
6
+ "detail": "Bundle locale entries accept an optional title, written to the template resource and exported again by the bundle export. SeedSystemTemplateOptions gets title. The template overview shows it instead of the slug."
7
+ },
8
+ {
9
+ "version": "0.375.0",
10
+ "type": "improvement",
11
+ "title": "Preview and test mail render with the values of a picked record",
12
+ "detail": "`template-resolver:query:preview` and `template-resolver:write:send-test` accept an optional `variables` object that replaces the declared example values for the named variables. The values are checked against the template's variable schema like a real send (unknown names and wrong types are rejected as `variables.<name>` validation errors); variables not passed keep their example. The mail editor now passes the values picked through the preview record picker into the preview and the test mail instead of only listing them."
13
+ },
14
+ {
15
+ "version": "0.375.0",
16
+ "type": "improvement",
17
+ "title": "Read a mail or PDF template as blocks and change it with structured operations",
18
+ "detail": "The query `template-resolver:query:document-blocks` returns a document template as blocks with stable ids (b0, b1, ...), its meta, variables, allowed block types and uploaded image keys, plus a `base` (scope and version). The write `template-resolver:write:patch-draft` applies up to 50 operations (insertBlock, replaceText, removeBlock, setResource, setMeta, setVariable) to that state and stores the result as the caller's tenant draft; an active version keeps being served until publish. Block ids refer to the state that was read. If the template changed since `base` was read, the call fails with `version_conflict` and writes nothing; a patch that removes a required placeholder or sets variables a notification declares is refused. `writeTemplateRow` accepts an optional `expectedVersion`. The universal helpers `placeholder-text` and the toolbar block sets moved out of `web/` without changing the web exports."
19
+ },
2
20
  {
3
21
  "version": "0.372.0",
4
22
  "type": "improvement",
@@ -0,0 +1,34 @@
1
+ // @runtime client
2
+ export type DocumentToolbarBlock =
3
+ | "image"
4
+ | "table"
5
+ | "button"
6
+ | "divider"
7
+ | "repeat"
8
+ | "pageBreak"
9
+ | "align"
10
+ | "steps";
11
+
12
+ export const MAIL_DOCUMENT_BLOCKS = [
13
+ "image",
14
+ "table",
15
+ "button",
16
+ "divider",
17
+ "repeat",
18
+ "steps",
19
+ ] as const satisfies readonly DocumentToolbarBlock[];
20
+
21
+ export const PDF_DOCUMENT_BLOCKS = [
22
+ "align",
23
+ "image",
24
+ "table",
25
+ "divider",
26
+ "repeat",
27
+ "steps",
28
+ "pageBreak",
29
+ ] as const satisfies readonly DocumentToolbarBlock[];
30
+
31
+ /** Block set for a template-resource kind; every kind except "document-pdf" gets the mail set. */
32
+ export function documentBlocksForKind(kind: string | undefined): readonly DocumentToolbarBlock[] {
33
+ return kind === "document-pdf" ? PDF_DOCUMENT_BLOCKS : MAIL_DOCUMENT_BLOCKS;
34
+ }