@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
@@ -1,10 +1,13 @@
1
- import { transaction, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import {
2
+ runnerCanBeginTransaction,
3
+ transaction,
4
+ updateMany,
5
+ } from "@cosmicdrift/kumiko-framework/bun-db";
2
6
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
7
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
8
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
5
9
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
6
10
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
7
- import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
8
11
  import {
9
12
  PAT_REVOKED_AGGREGATE_TYPE,
10
13
  PAT_REVOKED_EVENT_QN,
@@ -12,11 +15,6 @@ import {
12
15
  } from "./pat-revoked-event.js";
13
16
  import { apiTokenTable } from "./schema/api-token.js";
14
17
 
15
- // A pool has begin(); a tx/savepoint handle does not and is already atomic.
16
- function canBeginTransaction(db: DbRunner): boolean {
17
- return typeof propertyOf(db, "begin") === "function";
18
- }
19
-
20
18
  // Cross-tenant revoke: password-change and MFA-enable/disable are account-
21
19
  // level security events, not scoped to one tenant. Mirrors sessions'
22
20
  // sessionMassRevoker (session-callbacks.ts) which passes the boot-time
@@ -24,7 +22,8 @@ function canBeginTransaction(db: DbRunner): boolean {
24
22
  export async function revokeAllPatTokensForUser(db: DbRunner, userId: string): Promise<number> {
25
23
  // Update and event commit together: otherwise a failed append leaves the
26
24
  // tokens revoked with no event, and a retry matches no live row.
27
- return canBeginTransaction(db)
25
+ // A pool has begin(); a tx/savepoint handle does not and is already atomic.
26
+ return runnerCanBeginTransaction(db)
28
27
  ? transaction(db, (tx) => revokeAndAppend(tx, userId))
29
28
  : revokeAndAppend(db, userId);
30
29
  }
@@ -62,6 +62,8 @@ export type RenderResponse = (
62
62
  html: string;
63
63
  text: string;
64
64
  previewText?: string;
65
+ fromName?: string;
66
+ replyTo?: string;
65
67
  attachments?: readonly MailAttachmentRef[];
66
68
  }
67
69
  | {
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "renderTablePdf rejects tables above 10000 rows, 30 columns or 5000 characters per cell",
6
+ "detail": "The table document schema now caps rows at 10000, columns at 30 and each cell string at 5000 characters. Larger input fails with invalid_payload instead of exhausting memory during layout."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "fix",
11
+ "title": "render-table-pdf validates input and paginates its intro",
12
+ "detail": "Table documents reject negative margins and unusable column widths, normalize CRLF and paginate the intro text and the first header row."
13
+ },
2
14
  {
3
15
  "version": "0.360.0",
4
16
  "type": "improvement",
@@ -1,52 +1,56 @@
1
1
  import { PDFDocument, type PDFFont, type PDFPage, rgb, StandardFonts } from "pdf-lib";
2
2
  import * as z from "zod";
3
3
  import { RendererError } from "../renderer-foundation/index.js";
4
-
5
- export const pdfTableDocumentSchema = z.object({
6
- title: z.string(),
7
- subtitle: z.string().optional(),
8
- meta: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
9
- columns: z
10
- .array(
11
- z.object({
12
- header: z.string(),
13
- weight: z.number().positive().optional(),
14
- align: z.enum(["left", "right"]).optional(),
15
- }),
16
- )
17
- .min(1),
18
- rows: z.array(z.array(z.string())),
19
- emptyText: z.string().optional(),
20
- footerText: z.string().optional(),
21
- pageLabel: z.string().optional(),
22
- });
23
-
24
- export type PdfTableColumn = {
25
- readonly header: string;
26
- readonly weight?: number;
27
- readonly align?: "left" | "right";
28
- };
29
-
30
- export type PdfTableDocument = {
31
- readonly title: string;
32
- readonly subtitle?: string;
33
- readonly meta?: readonly { readonly label: string; readonly value: string }[];
34
- readonly columns: readonly PdfTableColumn[];
35
- readonly rows: readonly (readonly string[])[];
36
- readonly emptyText?: string;
37
- readonly footerText?: string;
38
- /** Template with `{page}` and `{pages}`; default `"{page} / {pages}"`. */
39
- readonly pageLabel?: string;
40
- };
4
+ import type { PdfPageFormat } from "../template-resolver/index.js";
5
+
6
+ // Upper bounds keep a hostile or runaway payload from exhausting memory and CPU in layout.
7
+ const PDF_TABLE_MAX_ROWS = 10_000;
8
+ const PDF_TABLE_MAX_COLUMNS = 30;
9
+ const PDF_TABLE_MAX_CELL_LENGTH = 5_000;
10
+
11
+ const pdfTableColumnSchema = z
12
+ .object({
13
+ header: z.string(),
14
+ weight: z.number().positive().optional(),
15
+ align: z.enum(["left", "right"]).optional(),
16
+ })
17
+ .readonly();
18
+
19
+ export const pdfTableDocumentSchema = z
20
+ .object({
21
+ title: z.string(),
22
+ subtitle: z.string().optional(),
23
+ meta: z
24
+ .array(z.object({ label: z.string(), value: z.string() }).readonly())
25
+ .readonly()
26
+ .optional(),
27
+ columns: z.array(pdfTableColumnSchema).min(1).max(PDF_TABLE_MAX_COLUMNS).readonly(),
28
+ rows: z
29
+ .array(z.array(z.string().max(PDF_TABLE_MAX_CELL_LENGTH)).readonly())
30
+ .max(PDF_TABLE_MAX_ROWS)
31
+ .readonly(),
32
+ emptyText: z.string().optional(),
33
+ footerText: z.string().optional(),
34
+ // Template with `{page}` and `{pages}`; default `"{page} / {pages}"`.
35
+ pageLabel: z.string().optional(),
36
+ })
37
+ .readonly();
38
+
39
+ export type PdfTableColumn = z.infer<typeof pdfTableColumnSchema>;
40
+ export type PdfTableDocument = z.infer<typeof pdfTableDocumentSchema>;
41
+
42
+ const marginMmSchema = z
43
+ .object({
44
+ top: z.number().nonnegative().optional(),
45
+ right: z.number().nonnegative().optional(),
46
+ bottom: z.number().nonnegative().optional(),
47
+ left: z.number().nonnegative().optional(),
48
+ })
49
+ .readonly();
41
50
 
42
51
  export type PdfTableOptions = {
43
- readonly format?: "A4" | "Letter";
44
- readonly marginMm?: {
45
- readonly top?: number;
46
- readonly right?: number;
47
- readonly bottom?: number;
48
- readonly left?: number;
49
- };
52
+ readonly format?: PdfPageFormat;
53
+ readonly marginMm?: z.input<typeof marginMmSchema>;
50
54
  };
51
55
 
52
56
  export type PdfTableResult = { readonly pdfBytes: Uint8Array; readonly pageCount: number };
@@ -55,7 +59,7 @@ const POINTS_PER_MM = 72 / 25.4;
55
59
  const PAGE_SIZE_POINTS = {
56
60
  A4: { width: 595.28, height: 841.89 },
57
61
  Letter: { width: 612, height: 792 },
58
- } as const;
62
+ } as const satisfies Record<PdfPageFormat, { readonly width: number; readonly height: number }>;
59
63
  const DEFAULT_MARGIN_MM = { top: 18, right: 15, bottom: 18, left: 15 } as const;
60
64
 
61
65
  const TITLE_SIZE = 16;
@@ -66,6 +70,7 @@ const LINE_HEIGHT_FACTOR = 1.3;
66
70
  const CELL_PADDING_X = 4;
67
71
  const CELL_PADDING_Y = 3;
68
72
  const FOOTER_RESERVE = 16;
73
+ const MIN_COLUMN_WIDTH = 20;
69
74
  const META_LABEL_GAP = 10;
70
75
  const BLOCK_GAP = 10;
71
76
  const REPLACEMENT_CHAR = "?";
@@ -86,7 +91,7 @@ function createEncodableText(font: PDFFont): (text: string) => string {
86
91
  const supported = new Set(font.getCharacterSet());
87
92
  return (text) => {
88
93
  let result = "";
89
- for (const char of text.replace(/\t/g, " ")) {
94
+ for (const char of text.replace(/\r\n?/g, "\n").replace(/\t/g, " ")) {
90
95
  const codePoint = char.codePointAt(0) ?? 0;
91
96
  result += codePoint === 0x0a || supported.has(codePoint) ? char : REPLACEMENT_CHAR;
92
97
  }
@@ -157,30 +162,43 @@ function assertRowShape(doc: PdfTableDocument): void {
157
162
  });
158
163
  }
159
164
 
160
- export async function renderTablePdf(
161
- doc: PdfTableDocument,
162
- options?: PdfTableOptions,
163
- ): Promise<PdfTableResult> {
164
- if (doc.columns.length === 0) {
165
- throw new RendererError("table needs at least one column", "invalid_payload");
166
- }
167
- assertRowShape(doc);
168
-
169
- const pdf = await PDFDocument.create({ updateMetadata: false });
170
- const fonts: Fonts = {
171
- regular: await pdf.embedFont(StandardFonts.Helvetica),
172
- bold: await pdf.embedFont(StandardFonts.HelveticaBold),
165
+ type TableLayout = {
166
+ readonly pageSize: { readonly width: number; readonly height: number };
167
+ readonly margin: {
168
+ readonly top: number;
169
+ readonly right: number;
170
+ readonly bottom: number;
171
+ readonly left: number;
173
172
  };
174
- const clean = createEncodableText(fonts.regular);
173
+ readonly contentWidth: number;
174
+ readonly contentBottom: number;
175
+ readonly columnWidths: readonly number[];
176
+ readonly bodyLineHeight: number;
177
+ readonly headerLines: readonly (readonly string[])[];
178
+ readonly headerHeight: number;
179
+ };
175
180
 
181
+ function resolveTableLayout(
182
+ doc: PdfTableDocument,
183
+ options: PdfTableOptions | undefined,
184
+ fonts: Fonts,
185
+ clean: (text: string) => string,
186
+ ): TableLayout {
187
+ const marginMm = marginMmSchema.safeParse(options?.marginMm ?? {});
188
+ if (!marginMm.success) {
189
+ throw new RendererError("margins must be non-negative numbers", "invalid_payload");
190
+ }
176
191
  const pageSize = PAGE_SIZE_POINTS[options?.format ?? "A4"];
177
192
  const margin = {
178
- top: (options?.marginMm?.top ?? DEFAULT_MARGIN_MM.top) * POINTS_PER_MM,
179
- right: (options?.marginMm?.right ?? DEFAULT_MARGIN_MM.right) * POINTS_PER_MM,
180
- bottom: (options?.marginMm?.bottom ?? DEFAULT_MARGIN_MM.bottom) * POINTS_PER_MM,
181
- left: (options?.marginMm?.left ?? DEFAULT_MARGIN_MM.left) * POINTS_PER_MM,
193
+ top: (marginMm.data.top ?? DEFAULT_MARGIN_MM.top) * POINTS_PER_MM,
194
+ right: (marginMm.data.right ?? DEFAULT_MARGIN_MM.right) * POINTS_PER_MM,
195
+ bottom: (marginMm.data.bottom ?? DEFAULT_MARGIN_MM.bottom) * POINTS_PER_MM,
196
+ left: (marginMm.data.left ?? DEFAULT_MARGIN_MM.left) * POINTS_PER_MM,
182
197
  };
183
198
  const contentWidth = pageSize.width - margin.left - margin.right;
199
+ if (contentWidth < MIN_COLUMN_WIDTH * doc.columns.length) {
200
+ throw new RendererError("margins leave no room for table columns", "invalid_payload");
201
+ }
184
202
  const contentBottom = margin.bottom + FOOTER_RESERVE;
185
203
  const columnWidths = resolveColumnWidths(doc.columns, contentWidth);
186
204
  const bodyLineHeight = lineHeightFor(BODY_SIZE);
@@ -200,6 +218,48 @@ export async function renderTablePdf(
200
218
  );
201
219
  const headerHeight =
202
220
  Math.max(...headerLines.map((lines) => lines.length)) * bodyLineHeight + 2 * CELL_PADDING_Y;
221
+ // A header this tall would leave every continuation page without room for a row.
222
+ if (rowSpaceOnEmptyPage - headerHeight < bodyLineHeight + 2 * CELL_PADDING_Y) {
223
+ throw new RendererError("table header leaves no room for rows", "invalid_payload");
224
+ }
225
+ return {
226
+ pageSize,
227
+ margin,
228
+ contentWidth,
229
+ contentBottom,
230
+ columnWidths,
231
+ bodyLineHeight,
232
+ headerLines,
233
+ headerHeight,
234
+ };
235
+ }
236
+
237
+ export async function renderTablePdf(
238
+ doc: PdfTableDocument,
239
+ options?: PdfTableOptions,
240
+ ): Promise<PdfTableResult> {
241
+ if (doc.columns.length === 0) {
242
+ throw new RendererError("table needs at least one column", "invalid_payload");
243
+ }
244
+ assertRowShape(doc);
245
+
246
+ const pdf = await PDFDocument.create({ updateMetadata: false });
247
+ const fonts: Fonts = {
248
+ regular: await pdf.embedFont(StandardFonts.Helvetica),
249
+ bold: await pdf.embedFont(StandardFonts.HelveticaBold),
250
+ };
251
+ const clean = createEncodableText(fonts.regular);
252
+
253
+ const {
254
+ pageSize,
255
+ margin,
256
+ contentWidth,
257
+ contentBottom,
258
+ columnWidths,
259
+ bodyLineHeight,
260
+ headerLines,
261
+ headerHeight,
262
+ } = resolveTableLayout(doc, options, fonts, clean);
203
263
 
204
264
  let page: PDFPage = pdf.addPage([pageSize.width, pageSize.height]);
205
265
  let cursorY = pageSize.height - margin.top;
@@ -227,29 +287,50 @@ export async function renderTablePdf(
227
287
  });
228
288
  }
229
289
 
290
+ function startBlankPage(): void {
291
+ page = pdf.addPage([pageSize.width, pageSize.height]);
292
+ cursorY = pageSize.height - margin.top;
293
+ }
294
+
295
+ function ensureRoom(height: number): void {
296
+ if (cursorY - height < contentBottom) startBlankPage();
297
+ }
298
+
299
+ // Line by line, so a block taller than one page continues on the next.
300
+ function flowLines(
301
+ lines: readonly string[],
302
+ font: PDFFont,
303
+ size: number,
304
+ left: number,
305
+ width: number,
306
+ color = TEXT_COLOR,
307
+ ): void {
308
+ const lineHeight = lineHeightFor(size);
309
+ for (const line of lines) {
310
+ ensureRoom(lineHeight);
311
+ drawLines([line], font, size, left, width, cursorY, "left", color);
312
+ cursorY -= lineHeight;
313
+ }
314
+ }
315
+
230
316
  function drawIntroBlock(): void {
231
- const titleLines = wrapText(clean(doc.title), fonts.bold, TITLE_SIZE, contentWidth);
232
- drawLines(titleLines, fonts.bold, TITLE_SIZE, margin.left, contentWidth, cursorY, "left");
233
- cursorY -= titleLines.length * lineHeightFor(TITLE_SIZE);
317
+ flowLines(
318
+ wrapText(clean(doc.title), fonts.bold, TITLE_SIZE, contentWidth),
319
+ fonts.bold,
320
+ TITLE_SIZE,
321
+ margin.left,
322
+ contentWidth,
323
+ );
234
324
 
235
325
  if (doc.subtitle !== undefined) {
236
- const subtitleLines = wrapText(
237
- clean(doc.subtitle),
238
- fonts.regular,
239
- SUBTITLE_SIZE,
240
- contentWidth,
241
- );
242
- drawLines(
243
- subtitleLines,
326
+ flowLines(
327
+ wrapText(clean(doc.subtitle), fonts.regular, SUBTITLE_SIZE, contentWidth),
244
328
  fonts.regular,
245
329
  SUBTITLE_SIZE,
246
330
  margin.left,
247
331
  contentWidth,
248
- cursorY,
249
- "left",
250
332
  MUTED_COLOR,
251
333
  );
252
- cursorY -= subtitleLines.length * lineHeightFor(SUBTITLE_SIZE);
253
334
  }
254
335
 
255
336
  const meta = doc.meta ?? [];
@@ -266,6 +347,7 @@ export async function renderTablePdf(
266
347
  BODY_SIZE,
267
348
  contentWidth - labelWidth,
268
349
  );
350
+ ensureRoom(bodyLineHeight);
269
351
  drawLines(
270
352
  [labels[index] ?? ""],
271
353
  fonts.bold,
@@ -275,16 +357,13 @@ export async function renderTablePdf(
275
357
  cursorY,
276
358
  "left",
277
359
  );
278
- drawLines(
360
+ flowLines(
279
361
  valueLines,
280
362
  fonts.regular,
281
363
  BODY_SIZE,
282
364
  margin.left + labelWidth,
283
365
  contentWidth - labelWidth,
284
- cursorY,
285
- "left",
286
366
  );
287
- cursorY -= valueLines.length * bodyLineHeight;
288
367
  });
289
368
  }
290
369
  cursorY -= BLOCK_GAP;
@@ -372,6 +451,7 @@ export async function renderTablePdf(
372
451
  }
373
452
 
374
453
  drawIntroBlock();
454
+ ensureRoom(headerHeight + bodyLineHeight + 2 * CELL_PADDING_Y);
375
455
  drawTableHeader();
376
456
 
377
457
  if (doc.rows.length === 0 && doc.emptyText !== undefined) {
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "secrets delete no longer requires a declared key",
6
+ "detail": "secrets:write:delete used to answer 404 unknownKey for a key that no feature declares, so a row stored before the declaration gate existed could never be removed. Delete now skips the declaration check; writeRoles of a declared key or namespace still apply, and set keeps the gate."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "improvement",
11
+ "title": "SecretsContext.withRunner(tx) runs secret access on a given transaction",
12
+ "detail": "The OAuth refresh now reads and writes the refresh token on its advisory-lock transaction, so a refresh holds one pool connection instead of two and parallel refreshes cannot starve the pool. Custom SecretsContext implementations need a `withRunner` method."
13
+ },
2
14
  {
3
15
  "version": "0.343.0",
4
16
  "type": "improvement",
@@ -83,21 +83,22 @@ export function requireSecretsContext(
83
83
  "createSecretsContext({db, masterKeyProvider, registry}) via extraContext.secrets at boot.",
84
84
  });
85
85
  }
86
- const raw = ctx.secrets;
87
86
  const userId = ctx._userId;
88
87
  if (!userId) {
89
88
  throw new InternalError({
90
89
  message: `[${handlerName}] ctx._userId missing — cannot audit secret reads without a caller identity.`,
91
90
  });
92
91
  }
93
- return {
92
+ const withAudit = (raw: SecretsContext): SecretsContext => ({
93
+ withRunner: (runner) => withAudit(raw.withRunner(runner)),
94
94
  get: (tenantId, key, overrideAudit) =>
95
95
  raw.get(tenantId, key, overrideAudit ?? { userId, handlerName }),
96
96
  // No audit injection: has() is metadata-only and never logs a read.
97
97
  has: raw.has.bind(raw),
98
98
  set: raw.set.bind(raw),
99
99
  delete: raw.delete.bind(raw),
100
- };
100
+ });
101
+ return withAudit(ctx.secrets);
101
102
  }
102
103
 
103
104
  export type SecretsFeatureOptions = {
@@ -18,7 +18,9 @@ export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS)
18
18
  access,
19
19
  handler: async (event, ctx) => {
20
20
  const secrets = requireSecretsContext(ctx, "secrets:write:delete");
21
- const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key);
21
+ const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key, {
22
+ allowUndeclared: true,
23
+ });
22
24
  if (!gate.ok) return gate.failure;
23
25
  const removed = await secrets.delete(event.user.tenantId, event.payload.key, {
24
26
  deletedBy: event.user.id,
@@ -17,6 +17,7 @@ import {
17
17
  createEventStoreExecutor,
18
18
  createTenantDb,
19
19
  type DbConnection,
20
+ type DbRunner,
20
21
  } from "@cosmicdrift/kumiko-framework/db";
21
22
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
22
23
  import { InternalError, type WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
@@ -91,8 +92,20 @@ function resolveKey(keyOrHandle: string | { readonly name: string }): string {
91
92
  }
92
93
 
93
94
  export function createSecretsContext(opts: SecretsContextOptions): SecretsContext {
94
- const { db, masterKeyProvider } = opts;
95
+ const { masterKeyProvider } = opts;
95
96
  const provider = withDekCache(masterKeyProvider, opts.dekCache ?? createDekCache());
97
+ return buildSecretsContext(opts, provider, opts.db, false);
98
+ }
99
+
100
+ // `runnerIsTransaction`: the runner is a caller-owned transaction, so audited reads join it
101
+ // instead of opening a second connection (which would deadlock a small pool).
102
+ function buildSecretsContext(
103
+ opts: SecretsContextOptions,
104
+ provider: MasterKeyProvider,
105
+ db: DbRunner,
106
+ runnerIsTransaction: boolean,
107
+ ): SecretsContext {
108
+ const { masterKeyProvider } = opts;
96
109
 
97
110
  type SecretLookupRow = {
98
111
  readonly id: string;
@@ -105,6 +118,10 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
105
118
  }
106
119
 
107
120
  return {
121
+ withRunner(runner) {
122
+ return buildSecretsContext(opts, provider, runner, true);
123
+ },
124
+
108
125
  async get(tenantId, keyOrHandle, auditCtx) {
109
126
  const key = resolveKey(keyOrHandle);
110
127
  // Atomic audit + read: a decrypt that "escaped" the audit trail
@@ -120,7 +137,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
120
137
  return createSecret(plaintext);
121
138
  }
122
139
 
123
- const plaintext = await transaction(db, async (tx) => {
140
+ const readAndAudit = async (tx: DbRunner): Promise<string | undefined> => {
124
141
  // Inline select inside the TX via raw client — fetchOne's connection
125
142
  // type doesn't widen to the transaction object cleanly.
126
143
  const envelope = await selectTenantSecretEnvelope(tx, tenantId, key);
@@ -154,7 +171,10 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
154
171
  metadata,
155
172
  });
156
173
  return pt;
157
- });
174
+ };
175
+ const plaintext = runnerIsTransaction
176
+ ? await readAndAudit(db)
177
+ : await transaction(db, readAndAudit);
158
178
 
159
179
  if (plaintext === undefined) return undefined;
160
180
  // Brand the plaintext only after audit committed. The response
@@ -37,15 +37,21 @@ function namespaceAcceptsKey(namespace: SecretNamespaceDefinition, key: string):
37
37
  // Only keys declared via r.secret() or under a declared r.secretNamespace()
38
38
  // are writable through the API, so a tenant admin cannot plant arbitrary rows
39
39
  // next to the feature-owned ones. writeRoles narrows the handler access
40
- // further for a key or a whole namespace.
40
+ // further for a key or a whole namespace. Delete passes allowUndeclared: it
41
+ // creates nothing, and legacy rows under since-undeclared keys must stay removable.
41
42
  export function checkSecretKeyWrite(
42
43
  registry: Registry,
43
44
  userRoles: readonly string[],
44
45
  key: string,
46
+ options: { readonly allowUndeclared?: boolean } = {},
45
47
  ): SecretKeyWriteCheck {
46
48
  const keyDef = registry.getSecretKey(key);
47
49
  const namespace = keyDef ? undefined : registry.findSecretNamespace(key);
48
- if (!keyDef && !(namespace && namespaceAcceptsKey(namespace, key))) {
50
+ if (
51
+ !keyDef &&
52
+ !(namespace && namespaceAcceptsKey(namespace, key)) &&
53
+ options.allowUndeclared !== true
54
+ ) {
49
55
  return unknownKeyFailure(key);
50
56
  }
51
57
  const writeRoles = keyDef?.writeRoles ?? namespace?.writeRoles;
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "improvement",
5
+ "title": "Mail template body helpers exported as `./shared/mail-template-body`",
6
+ "detail": "Apps that register their own notification templates can build the body with the same helpers the framework uses: `mailParagraph`, `mailHeading`, `mailRepeatedRows`, `mailTextWithSlotsHtml`, plus the new `mailButton`, `mailParagraphWithLink` and `mailSubject`. The subpath only depends on kumiko-headless and is safe on the server. Apps that copied these helpers can import them instead."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -33,6 +33,9 @@ export type ChunkedMigrationOptions<Row> = {
33
33
  readonly maxFailures: number;
34
34
  // Epoch ms; Number.POSITIVE_INFINITY for no time bound.
35
35
  readonly deadlineAt: number;
36
+ // Batches processed even when the deadline already passed. Callers whose setup before the
37
+ // loop can eat the whole budget set 1, so every run makes progress instead of timing out empty.
38
+ readonly minBatches?: number;
36
39
  readonly signal?: AbortSignal | undefined;
37
40
  readonly onRowError?: (row: Row, err: unknown) => void;
38
41
  };
@@ -51,7 +54,7 @@ export async function runChunkedMigration<Row>(
51
54
  stoppedReason = "signal";
52
55
  break;
53
56
  }
54
- if (Date.now() >= opts.deadlineAt) {
57
+ if (batchesProcessed >= (opts.minBatches ?? 0) && Date.now() >= opts.deadlineAt) {
55
58
  stoppedReason = "timeout";
56
59
  break;
57
60
  }
@@ -40,6 +40,14 @@ export {
40
40
  } from "./redact.js";
41
41
  export { REDACTED_PII_PLACEHOLDER, redactPiiCiphertext } from "./redact-pii-ciphertext.js";
42
42
  export { requireForTenant } from "./require-for-tenant.js";
43
+ export {
44
+ extensionForResourceContentType,
45
+ isResourceImageContentType,
46
+ isResourceImageFormat,
47
+ RESOURCE_IMAGE_FORMATS,
48
+ type ResourceImageContentType,
49
+ type ResourceImageFormat,
50
+ } from "./resource-image-formats.js";
43
51
  export {
44
52
  type RowBoundGrantResult,
45
53
  redeemRowBoundGrant,
@@ -19,7 +19,24 @@ export function mailHeading(text: string): string {
19
19
  return `<h2>${mailTextWithSlotsHtml(text)}</h2>`;
20
20
  }
21
21
 
22
+ export function mailButton(label: string, linkVariable: string): string {
23
+ return `<p><a data-button href="{{${escapeHtmlAttr(linkVariable)}}}">${escapeHtml(label)}</a></p>`;
24
+ }
25
+
26
+ export function mailParagraphWithLink(
27
+ text: string,
28
+ linkLabel: string,
29
+ linkVariable: string,
30
+ ): string {
31
+ return `<p>${mailTextWithSlotsHtml(text)} <a href="{{${escapeHtmlAttr(linkVariable)}}}">${escapeHtml(linkLabel)}</a></p>`;
32
+ }
33
+
22
34
  // One table row per list entry; the cell shows the entry's `field`.
23
35
  export function mailRepeatedRows(listName: string, field: string): string {
24
36
  return `<table><tbody><tr data-each="${escapeHtmlAttr(listName)}"><td><p><span data-variable="${escapeHtmlAttr(field)}"></span></p></td></tr></tbody></table>`;
25
37
  }
38
+
39
+ // Subjects are Handlebars text, so the same `{name}` slots become `{{name}}`.
40
+ export function mailSubject(text: string): string {
41
+ return text.replace(SLOT_PATTERN, "{{$1}}");
42
+ }
@@ -0,0 +1,31 @@
1
+ // Single source for the raster formats a template resource image may have: the normalizer, the
2
+ // stored file extension, the data-URI embedding and the inline mail attachment name all derive
3
+ // from it, so a new format cannot be accepted in one place and dropped in another.
4
+ export const RESOURCE_IMAGE_FORMATS = {
5
+ png: { contentType: "image/png", extension: "png" },
6
+ jpeg: { contentType: "image/jpeg", extension: "jpg" },
7
+ gif: { contentType: "image/gif", extension: "gif" },
8
+ webp: { contentType: "image/webp", extension: "webp" },
9
+ } as const;
10
+
11
+ export type ResourceImageFormat = keyof typeof RESOURCE_IMAGE_FORMATS;
12
+ export type ResourceImageContentType =
13
+ (typeof RESOURCE_IMAGE_FORMATS)[ResourceImageFormat]["contentType"];
14
+
15
+ export function isResourceImageFormat(format: string | undefined): format is ResourceImageFormat {
16
+ return format !== undefined && Object.hasOwn(RESOURCE_IMAGE_FORMATS, format);
17
+ }
18
+
19
+ function formatForContentType(contentType: string) {
20
+ return Object.values(RESOURCE_IMAGE_FORMATS).find((entry) => entry.contentType === contentType);
21
+ }
22
+
23
+ export function isResourceImageContentType(
24
+ contentType: string,
25
+ ): contentType is ResourceImageContentType {
26
+ return formatForContentType(contentType) !== undefined;
27
+ }
28
+
29
+ export function extensionForResourceContentType(contentType: string): string | undefined {
30
+ return formatForContentType(contentType)?.extension;
31
+ }