@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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 (237) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +7 -1
  4. package/packages/bundled-features/src/audit/changes.json +18 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
  9. package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
  10. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  15. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  20. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  21. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  22. package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  34. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  35. package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
  36. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
  37. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  38. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  39. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  40. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  41. package/packages/bundled-features/src/cap-counter/changes.json +19 -0
  42. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  43. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  44. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  45. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  46. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  48. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  49. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  50. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  51. package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
  52. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  53. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  54. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  55. package/packages/bundled-features/src/config/changes.json +6 -0
  56. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  57. package/packages/bundled-features/src/config/resolver.ts +15 -12
  58. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  59. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  60. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
  61. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  62. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  63. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  64. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  65. package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
  66. package/packages/bundled-features/src/delivery/changes.json +30 -0
  67. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  68. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  69. package/packages/bundled-features/src/delivery/index.ts +2 -0
  70. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  71. package/packages/bundled-features/src/delivery/types.ts +7 -1
  72. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  73. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  74. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  75. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  76. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  78. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  79. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  80. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  82. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  83. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  85. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  86. package/packages/bundled-features/src/jobs/changes.json +31 -0
  87. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  88. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  89. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  90. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
  92. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  93. package/packages/bundled-features/src/ledger/changes.json +7 -0
  94. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  95. package/packages/bundled-features/src/metrics/changes.json +6 -0
  96. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  97. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  98. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  99. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  100. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  101. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  102. package/packages/bundled-features/src/secrets/changes.json +12 -0
  103. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  104. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  105. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  106. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  107. package/packages/bundled-features/src/shared/changes.json +6 -0
  108. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  109. package/packages/bundled-features/src/shared/index.ts +8 -0
  110. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  111. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  112. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  113. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  114. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  115. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  116. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  117. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  118. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  119. package/packages/bundled-features/src/template-resolver/changes.json +149 -0
  120. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
  121. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  122. package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
  123. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  124. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
  125. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  126. package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
  127. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  128. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  129. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
  130. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
  131. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  132. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  133. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  134. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  135. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  136. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  137. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  138. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
  140. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
  141. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  142. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  143. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  144. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  145. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  146. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  147. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  148. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  149. package/packages/bundled-features/src/template-resolver/index.ts +18 -2
  150. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
  151. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  152. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  153. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  154. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  155. package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
  156. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  157. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  158. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  159. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  160. package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
  161. package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
  162. package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
  163. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  164. package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
  165. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  166. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
  167. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
  168. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
  169. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
  170. package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
  171. package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
  172. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
  173. package/packages/bundled-features/src/tenant/changes.json +6 -0
  174. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  175. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  176. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  177. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  178. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  179. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  180. package/packages/bundled-features/src/tier-engine/changes.json +18 -0
  181. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  182. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  183. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  184. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  185. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  186. package/packages/bundled-features/src/user/changes.json +6 -0
  187. package/packages/bundled-features/src/user/constants.ts +1 -0
  188. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  189. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  190. package/packages/bundled-features/src/user/seeding.ts +19 -5
  191. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  192. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  193. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  194. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  195. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  196. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  197. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  198. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  199. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  200. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  201. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  202. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  203. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  204. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  205. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  206. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  207. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  208. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  209. package/samples/apps/ui-walkthrough/package.json +9 -0
  210. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  211. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  212. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  213. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  214. package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
  215. package/samples/apps/use-all-bundled/README.md +3 -13
  216. package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
  217. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  218. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  219. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  220. package/samples/recipes/auth-mfa/README.md +2 -1
  221. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  222. package/samples/recipes/custom-handlers/README.md +4 -3
  223. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  224. package/samples/recipes/dashboard-stats/README.md +38 -0
  225. package/samples/recipes/dashboard-stats/package.json +8 -0
  226. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  227. package/samples/recipes/legal-pages/README.md +2 -2
  228. package/samples/recipes/managed-pages/README.md +2 -2
  229. package/samples/recipes/pdf-audit-export/README.md +2 -1
  230. package/samples/recipes/pdf-audit-export/package.json +1 -1
  231. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  232. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  233. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  234. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  235. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  237. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -51,7 +51,15 @@ export const TEXT_BLOCK_KIND = "text-block" as const;
51
51
  // Kinds a content collection must not write: a tenant row of these kinds would
52
52
  // shadow the system notification template or layout without the meta and
53
53
  // placeholder gates that upsertTenant applies.
54
- export const COLLECTION_REFUSED_KINDS: readonly string[] = ["mail", "mail-layout", "pdf-layout"];
54
+ export const COLLECTION_REFUSED_KINDS = [
55
+ "mail",
56
+ "mail-layout",
57
+ "pdf-layout",
58
+ ] as const satisfies readonly TemplateKind[];
59
+
60
+ export function isCollectionRefusedKind(kind: string): boolean {
61
+ return COLLECTION_REFUSED_KINDS.some((refused) => refused === kind);
62
+ }
55
63
 
56
64
  export const CONTENT_FORMATS = ["markdown", "mjml", "html", "plain"] as const;
57
65
  export type ContentFormat = (typeof CONTENT_FORMATS)[number];
@@ -61,11 +69,23 @@ export type TemplateScope = (typeof TEMPLATE_SCOPES)[number];
61
69
 
62
70
  export const TEMPLATE_STATUSES = ["draft", "active", "archived"] as const;
63
71
  export type TemplateStatus = (typeof TEMPLATE_STATUSES)[number];
72
+ export const ACTIVE_TEMPLATE_STATUS = "active" satisfies TemplateStatus;
73
+
74
+ export {
75
+ PDF_DEFAULT_MARGIN_MM,
76
+ PDF_FOOTER_MARGIN_MM,
77
+ PDF_HEADER_MARGIN_MM,
78
+ PDF_ORIENTATIONS,
79
+ PDF_PAGE_FORMATS,
80
+ type PdfOrientation,
81
+ type PdfPageFormat,
82
+ } from "./pdf-constants.js";
64
83
 
65
- export const PDF_PAGE_FORMATS = ["A4", "Letter"] as const;
66
- export type PdfPageFormat = (typeof PDF_PAGE_FORMATS)[number];
84
+ // Colors end up inside MJML attributes, so only a strict hex is ever written there.
85
+ export const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
67
86
 
68
87
  export const DEFAULT_MAIL_LAYOUT_SLUG = "default";
88
+ export const DEFAULT_PDF_LAYOUT_SLUG = "default";
69
89
 
70
90
  // The mail transport enforces no size limit of its own; 10 MiB is a common provider ceiling.
71
91
  export const MAIL_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;
@@ -13,6 +13,7 @@ import {
13
13
  isDocumentReferenceName,
14
14
  isDocumentResourceKey,
15
15
  } from "./document-schema.js";
16
+ import { isDocumentStepKey } from "./document-steps.js";
16
17
 
17
18
  export type DocumentViolationCode =
18
19
  | "script_element"
@@ -138,6 +139,8 @@ const REFERENCE_ATTRIBUTES: ReadonlyArray<readonly [string, (value: string) => b
138
139
  ["data-variable", isDocumentReferenceName],
139
140
  ["data-each", isDocumentReferenceName],
140
141
  ["data-resource", isDocumentResourceKey],
142
+ ["data-steps", isDocumentReferenceName],
143
+ ["data-step", isDocumentStepKey],
141
144
  ];
142
145
 
143
146
  function scanReferences(tag: string, element: ScanElement, out: DocumentViolation[]): void {
@@ -8,14 +8,16 @@ import { Extension, type Extensions, Node } from "@tiptap/core";
8
8
  import { Link } from "@tiptap/extension-link";
9
9
  import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
10
10
  import StarterKit from "@tiptap/starter-kit";
11
+ import { createDocumentStepsNode } from "./document-steps.js";
11
12
 
12
- const PLACEHOLDER_HREF = /^\{\{[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*\}\}$/;
13
+ const PLACEHOLDER_HREF = /^\{\{(.+)\}\}$/;
13
14
  const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
14
15
  const RESOURCE_KEY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
15
16
  const ALLOWED_LINK_SCHEMES = ["https://", "http://", "mailto:", "tel:"] as const;
16
17
 
17
18
  export function isDocumentPlaceholderHref(href: string): boolean {
18
- return PLACEHOLDER_HREF.test(href);
19
+ const name = PLACEHOLDER_HREF.exec(href)?.[1];
20
+ return name !== undefined && isDocumentReferenceName(name);
19
21
  }
20
22
 
21
23
  const UNSAFE_URL_CHARACTERS = new Set(["<", ">", '"']);
@@ -70,6 +72,7 @@ export const DOCUMENT_NODE_NAMES = [
70
72
  "button",
71
73
  "divider",
72
74
  "pageBreak",
75
+ "steps",
73
76
  ] as const;
74
77
 
75
78
  export const DocumentVariable = Node.create({
@@ -130,7 +133,8 @@ export const DocumentResource = Node.create({
130
133
  export const DocumentButton = Node.create({
131
134
  name: "button",
132
135
  group: "block",
133
- content: "text*",
136
+ // A placeholder in the label ("View {{pageName}}") must survive the round trip.
137
+ content: "(text | variable)*",
134
138
  marks: "",
135
139
  defining: true,
136
140
  addAttributes() {
@@ -178,6 +182,8 @@ export const DocumentPageBreak = Node.create({
178
182
  },
179
183
  });
180
184
 
185
+ export const DocumentSteps = createDocumentStepsNode(isDocumentReferenceName);
186
+
181
187
  // A repeat row is a table row carrying `each`: prosemirror-tables requires
182
188
  // the tableRow node type, so a separate node type is not an option.
183
189
  export const DocumentTableRow = TableRow.extend({
@@ -204,8 +210,6 @@ export const DocumentTableRow = TableRow.extend({
204
210
  },
205
211
  });
206
212
 
207
- export const DocumentRepeatRow = DocumentTableRow;
208
-
209
213
  export const DocumentTable = Table.extend({
210
214
  renderHTML() {
211
215
  return ["table", ["tbody", 0]];
@@ -288,6 +292,28 @@ export const DocumentTextAlign = Extension.create({
288
292
  },
289
293
  });
290
294
 
295
+ // Only "small" exists: the sender line above an address block. Serialized as data-size for the
296
+ // same reason as data-align.
297
+ export const DocumentTextSize = Extension.create({
298
+ name: "documentTextSize",
299
+ addGlobalAttributes() {
300
+ return [
301
+ {
302
+ types: ["paragraph"],
303
+ attributes: {
304
+ textSize: {
305
+ default: null,
306
+ parseHTML: (element) =>
307
+ element.getAttribute("data-size") === "small" ? "small" : null,
308
+ renderHTML: (attributes) =>
309
+ attributes["textSize"] === "small" ? { "data-size": "small" } : {},
310
+ },
311
+ },
312
+ },
313
+ ];
314
+ },
315
+ });
316
+
291
317
  export const documentExtensions: Extensions = [
292
318
  StarterKit.configure({
293
319
  blockquote: false,
@@ -319,5 +345,7 @@ export const documentExtensions: Extensions = [
319
345
  DocumentButton,
320
346
  DocumentDivider,
321
347
  DocumentPageBreak,
348
+ DocumentSteps,
322
349
  DocumentTextAlign,
350
+ DocumentTextSize,
323
351
  ];
@@ -0,0 +1,114 @@
1
+ // @runtime client
2
+ // A progress row: fixed stages stored in the template, the reached stage read
3
+ // from a variable at render time. Universal like document-schema.ts.
4
+ import { Node } from "@tiptap/core";
5
+
6
+ // Keys end up as Handlebars string literals in the compiled body, so they are
7
+ // restricted to characters that cannot close or escape a literal.
8
+ const STEP_KEY = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/;
9
+ export const DOCUMENT_STEPS_MAX = 8;
10
+ const STEP_LABEL_MAX_LENGTH = 40;
11
+
12
+ export type DocumentStep = { readonly key: string; readonly label: string };
13
+
14
+ export function isDocumentStepKey(value: string): boolean {
15
+ return STEP_KEY.test(value);
16
+ }
17
+
18
+ export function documentStepKeyOf(label: string): string {
19
+ return label
20
+ .normalize("NFKD")
21
+ .replace(/\p{M}/gu, "")
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, "-")
24
+ .replace(/^-+|-+$/g, "")
25
+ .slice(0, 40);
26
+ }
27
+
28
+ // Editor input: "value=Label" entries separated by commas; a bare label derives its value.
29
+ export function parseDocumentStepsInput(text: string): readonly DocumentStep[] | null {
30
+ const steps = text
31
+ .split(",")
32
+ .map((entry) => entry.trim())
33
+ .filter((entry) => entry !== "")
34
+ .map((entry): DocumentStep => {
35
+ const separator = entry.indexOf("=");
36
+ const label = (separator < 0 ? entry : entry.slice(separator + 1)).trim();
37
+ const key = separator < 0 ? documentStepKeyOf(entry) : entry.slice(0, separator).trim();
38
+ return { key, label: label.slice(0, STEP_LABEL_MAX_LENGTH) };
39
+ });
40
+ return areValidSteps(steps) ? steps : null;
41
+ }
42
+
43
+ function areValidSteps(steps: readonly DocumentStep[]): boolean {
44
+ return (
45
+ steps.length > 0 &&
46
+ steps.length <= DOCUMENT_STEPS_MAX &&
47
+ new Set(steps.map((step) => step.key)).size === steps.length &&
48
+ steps.every((step) => isDocumentStepKey(step.key) && step.label !== "")
49
+ );
50
+ }
51
+
52
+ // Duplicate or invalid keys would make the reached stage ambiguous, so the whole
53
+ // list is refused rather than repaired.
54
+ export function readDocumentSteps(element: {
55
+ querySelectorAll(selector: string): ArrayLike<{
56
+ getAttribute(name: string): string | null;
57
+ textContent: string | null;
58
+ }>;
59
+ }): readonly DocumentStep[] | null {
60
+ const steps = Array.from(element.querySelectorAll("span[data-step]")).map((span) => ({
61
+ key: span.getAttribute("data-step") ?? "",
62
+ label: (span.textContent ?? "").trim().slice(0, STEP_LABEL_MAX_LENGTH),
63
+ }));
64
+ return areValidSteps(steps) ? steps : null;
65
+ }
66
+
67
+ function isDocumentStep(step: unknown): step is DocumentStep {
68
+ return (
69
+ typeof step === "object" &&
70
+ step !== null &&
71
+ "key" in step &&
72
+ "label" in step &&
73
+ typeof step.key === "string" &&
74
+ typeof step.label === "string"
75
+ );
76
+ }
77
+
78
+ // Node attrs are untyped JSON; anything that is not a step entry is dropped.
79
+ export function readDocumentStepsAttribute(value: unknown): readonly DocumentStep[] {
80
+ return Array.isArray(value) ? value.filter(isDocumentStep) : [];
81
+ }
82
+
83
+ export function createDocumentStepsNode(isReferenceName: (value: string) => boolean) {
84
+ return Node.create({
85
+ name: "steps",
86
+ group: "block",
87
+ atom: true,
88
+ addAttributes() {
89
+ return { variable: { default: null }, steps: { default: [] } };
90
+ },
91
+ parseHTML() {
92
+ return [
93
+ {
94
+ tag: "div[data-steps]",
95
+ getAttrs: (element) => {
96
+ const variable = element.getAttribute("data-steps");
97
+ const steps = readDocumentSteps(element);
98
+ if (variable === null || !isReferenceName(variable) || steps === null) return false;
99
+ return { variable, steps };
100
+ },
101
+ },
102
+ ];
103
+ },
104
+ renderHTML({ node }) {
105
+ return [
106
+ "div",
107
+ { "data-steps": node.attrs["variable"] },
108
+ ...readDocumentStepsAttribute(node.attrs["steps"]).map(
109
+ (step) => ["span", { "data-step": step.key }, step.label] as const,
110
+ ),
111
+ ];
112
+ },
113
+ });
114
+ }
@@ -3,6 +3,7 @@ import {
3
3
  createSystemConfig,
4
4
  defineFeature,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { isCollectionRefusedKind } from "./constants.js";
6
7
  import { bySlugQuery } from "./handlers/by-slug.query.js";
7
8
  import { byTenantQuery } from "./handlers/by-tenant.query.js";
8
9
  import { makeCollectionItemQuery } from "./handlers/collection-item.query.js";
@@ -10,6 +11,7 @@ import { makeCollectionListQuery } from "./handlers/collection-list.query.js";
10
11
  import { makeCollectionSetWrite } from "./handlers/collection-set.write.js";
11
12
  import { copyLocaleWrite } from "./handlers/copy-locale.write.js";
12
13
  import { diffToSystemQuery } from "./handlers/diff-to-system.query.js";
14
+ import { discardDraftSystemWrite, discardDraftWrite } from "./handlers/discard-draft.write.js";
13
15
  import { exportBundleQuery } from "./handlers/export-bundle.query.js";
14
16
  import { findByIdQuery } from "./handlers/find-by-id.query.js";
15
17
  import { importBundleWrite } from "./handlers/import-bundle.write.js";
@@ -59,14 +61,25 @@ export type TemplateResolverOptions = {
59
61
  readonly collections?: readonly ContentCollectionDefinition[];
60
62
  };
61
63
 
64
+ function assertCollectionKindsAllowed(collections: readonly ContentCollectionDefinition[]): void {
65
+ for (const collection of collections) {
66
+ if (isCollectionRefusedKind(collection.kind)) {
67
+ throw new Error(
68
+ `template-resolver: collection "${collection.id}" uses kind "${collection.kind}", which collections may not write`,
69
+ );
70
+ }
71
+ }
72
+ }
73
+
62
74
  export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}) {
63
75
  const collections = opts.collections ?? [];
76
+ assertCollectionKindsAllowed(collections);
64
77
  const hasUserOwned = collections.some((c) => c.ownership === "user");
65
78
  return defineFeature("template-resolver", (r) => {
66
79
  r.describe(
67
80
  [
68
81
  "Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`).",
69
- "Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`.",
82
+ "Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away.",
70
83
  "Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation.",
71
84
  "How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning).",
72
85
  'A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side.',
@@ -100,6 +113,8 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
100
113
  archive: r.writeHandler(archiveWrite),
101
114
  publishSystem: r.writeHandler(publishSystemWrite),
102
115
  archiveSystem: r.writeHandler(archiveSystemWrite),
116
+ discardDraft: r.writeHandler(discardDraftWrite),
117
+ discardDraftSystem: r.writeHandler(discardDraftSystemWrite),
103
118
  resetToSystem: r.writeHandler(resetToSystemWrite),
104
119
  set: r.writeHandler(setWrite),
105
120
  importBundle: r.writeHandler(importBundleWrite),
@@ -1,5 +1,6 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type {
3
+ BeforeTransactionResult,
3
4
  HandlerContext,
4
5
  SessionUser,
5
6
  TenantId,
@@ -16,9 +17,13 @@ import type { LinkedResourceEntry } from "../linked-resources.js";
16
17
  import {
17
18
  attachmentLimitFailure,
18
19
  attachmentNeedsMailTemplate,
20
+ createPreparedBundleResourceEntry,
21
+ type PreparedTemplateFile,
22
+ prepareTemplateAttachmentBeforeTransaction,
23
+ prepareTemplateResourceBeforeTransaction,
24
+ type Release,
19
25
  readTemplateResourceMaxBytes,
20
- storeBundleAttachment,
21
- storeTemplateResource,
26
+ releaseAll,
22
27
  } from "../store-template-resource.js";
23
28
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
24
29
  import type { TemplateBundle } from "../template-bundle.js";
@@ -92,64 +97,101 @@ async function collectBundleIssues(
92
97
  return issues;
93
98
  }
94
99
 
95
- async function storeBundleResources(
96
- args: BundleImport,
97
- ): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
98
- const linked: Record<string, LinkedResourceEntry> = {};
99
- const pdfLinks = Object.entries(args.bundle.pdfAttachments);
100
+ export type PreparedBundleResources = ReadonlyMap<string, PreparedTemplateFile>;
101
+
102
+ function checkBundleAttachments(bundle: TemplateBundle): WriteResult<null> {
100
103
  const attachmentCount =
101
- Object.values(args.bundle.resources).filter((resource) => resource.purpose === "attachment")
102
- .length + pdfLinks.length;
103
- if (attachmentCount > 0 && args.bundle.kind !== "mail") return attachmentNeedsMailTemplate();
104
+ Object.values(bundle.resources).filter((resource) => resource.purpose === "attachment").length +
105
+ Object.keys(bundle.pdfAttachments).length;
106
+ if (attachmentCount > 0 && bundle.kind !== "mail") return attachmentNeedsMailTemplate();
104
107
  if (attachmentCount > MAX_FILE_ATTACHMENTS_PER_TEMPLATE) return attachmentLimitFailure();
105
- // The document template is not looked up: a bundle may carry it later than the mail.
106
- for (const [key, link] of pdfLinks) linked[key] = { kind: "rendered-pdf", ...link };
108
+ return { isSuccess: true, data: null };
109
+ }
110
+
111
+ // beforeTransaction half: validates the bundle and uploads every resource file to the target
112
+ // tenant's storage. A failed or throwing upload releases the ones before it.
113
+ export async function prepareTemplateBundleBeforeTransaction(
114
+ args: BundleImport & { readonly status: BundleImportStatus },
115
+ ): Promise<BeforeTransactionResult<PreparedBundleResources>> {
116
+ const refused = refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId);
117
+ if (refused !== null) return refused;
118
+ const issues = await collectBundleIssues(args, args.status);
119
+ if (issues.length > 0) return writeFailure(new ValidationError({ fields: [...issues] }));
120
+ const attachmentCheck = checkBundleAttachments(args.bundle);
121
+ if (!attachmentCheck.isSuccess) return attachmentCheck;
122
+
107
123
  const maxBytes = await readTemplateResourceMaxBytes(args.ctx.config, args.ctx.registry);
108
- // A bundle is untrusted input like an upload: the same re-encoding decides the stored
109
- // content type, so a declared image/png can't smuggle SVG or HTML into a mail or PDF.
110
- for (const [key, resource] of Object.entries(args.bundle.resources)) {
111
- const bytes = Uint8Array.from(Buffer.from(resource.base64, "base64"));
112
- if (resource.purpose === "attachment") {
113
- const attachment = await storeBundleAttachment({
124
+ const prepared = new Map<string, PreparedTemplateFile>();
125
+ const releases: Release[] = [];
126
+ try {
127
+ // A bundle is untrusted input like an upload: the same re-encoding decides the stored
128
+ // content type, so a declared image/png can't smuggle SVG or HTML into a mail or PDF.
129
+ for (const [key, resource] of Object.entries(args.bundle.resources)) {
130
+ const bytes = Uint8Array.from(Buffer.from(resource.base64, "base64"));
131
+ const common = {
114
132
  ctx: args.ctx,
115
133
  user: args.user,
116
134
  tenantId: args.tenantId,
117
135
  bytes,
118
136
  maxBytes,
119
137
  key,
120
- fileName: resource.fileName,
121
- });
122
- if (!attachment.isSuccess) return attachment;
123
- linked[key] = attachment.data;
124
- continue;
138
+ };
139
+ const result =
140
+ resource.purpose === "attachment"
141
+ ? await prepareTemplateAttachmentBeforeTransaction({
142
+ ...common,
143
+ fileName: resource.fileName,
144
+ })
145
+ : await prepareTemplateResourceBeforeTransaction(common);
146
+ if (!result.isSuccess) {
147
+ await releaseAll(releases);
148
+ return result;
149
+ }
150
+ if (result.release) releases.push(result.release);
151
+ prepared.set(key, result.data);
125
152
  }
126
- const stored = await storeTemplateResource({
127
- ctx: args.ctx,
128
- user: args.user,
129
- tenantId: args.tenantId,
130
- bytes,
131
- maxBytes,
132
- key,
133
- });
134
- if (!stored.isSuccess) return stored;
135
- linked[key] = stored.data.fileRefId;
153
+ } catch (cause) {
154
+ await releaseAll(releases);
155
+ throw cause;
156
+ }
157
+ return { isSuccess: true, data: prepared, release: () => releaseAll(releases) };
158
+ }
159
+
160
+ async function linkPreparedBundleResources(
161
+ args: BundleImport,
162
+ prepared: PreparedBundleResources,
163
+ ): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
164
+ const linked: Record<string, LinkedResourceEntry> = {};
165
+ // The document template is not looked up: a bundle may carry it later than the mail.
166
+ for (const [key, link] of Object.entries(args.bundle.pdfAttachments)) {
167
+ linked[key] = { kind: "rendered-pdf", ...link };
168
+ }
169
+ for (const [key, file] of prepared) {
170
+ const entry = await createPreparedBundleResourceEntry(
171
+ { ctx: args.ctx, user: args.user, tenantId: args.tenantId },
172
+ file,
173
+ );
174
+ if (!entry.isSuccess) return entry;
175
+ linked[key] = entry.data;
136
176
  }
137
177
  return { isSuccess: true, data: linked };
138
178
  }
139
179
 
140
- // Validates every locale first, stores the resource files, then writes one row
141
- // per locale, so a bad locale never leaves files or half a bundle behind. An image
142
- // that fails re-encoding stops the import before any row is written; files stored
143
- // for the resources before it stay as unlinked FileRefs, like a replaced upload.
180
+ // Validates every locale first, so a bad locale is refused before anything is written.
181
+ // The resource files are uploaded in the beforeTransaction phase; a rolled-back transaction
182
+ // releases them there. A crash between the upload and the commit leaves the binaries unreferenced.
144
183
  export async function importTemplateBundle(
145
- args: BundleImport & { readonly status: BundleImportStatus },
184
+ args: BundleImport & {
185
+ readonly status: BundleImportStatus;
186
+ readonly prepared: PreparedBundleResources;
187
+ },
146
188
  ): Promise<WriteResult<{ ids: Record<string, string> }>> {
147
189
  const refused = refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId);
148
190
  if (refused !== null) return refused;
149
191
  const issues = await collectBundleIssues(args, args.status);
150
192
  if (issues.length > 0) return writeFailure(new ValidationError({ fields: [...issues] }));
151
193
 
152
- const linked = await storeBundleResources(args);
194
+ const linked = await linkPreparedBundleResources(args, args.prepared);
153
195
  if (!linked.isSuccess) return linked;
154
196
 
155
197
  const variableSchema = args.bundle.variableSchema ?? {};
@@ -7,7 +7,7 @@ import {
7
7
  import { ValidationError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import * as z from "zod";
9
9
  import { requireForTenant } from "../../shared/index.js";
10
- import { COLLECTION_REFUSED_KINDS } from "../constants.js";
10
+ import { isCollectionRefusedKind } from "../constants.js";
11
11
  import { documentViolationsToFieldIssues, sanitizeTemplateBody } from "../document-sanitizer.js";
12
12
  import {
13
13
  type CollectionEntryRow,
@@ -43,12 +43,13 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
43
43
  ? `Creates or overwrites the caller's own "${collection.id}" entry at a slug and locale, live on save with no draft stage; it always writes the acting user's entry, never another user's.`
44
44
  : `Creates or overwrites the tenant-wide "${collection.id}" entry at a slug and locale, live on save with no draft stage; the change is visible to everyone who may reach this shared collection.`,
45
45
  handler: async (event, ctx) => {
46
- if (COLLECTION_REFUSED_KINDS.includes(collection.kind)) {
46
+ // Defense in depth: createTemplateResolverFeature already refuses these kinds at mount.
47
+ if (isCollectionRefusedKind(collection.kind)) {
47
48
  return writeFailure(
48
49
  new ValidationError({
49
50
  fields: [
50
51
  {
51
- path: "kind",
52
+ path: "",
52
53
  code: "kind_not_allowed",
53
54
  i18nKey: "templateResolver.errors.collection.kindNotAllowed",
54
55
  params: { detail: collection.kind },