@cosmicdrift/kumiko-samples 0.363.0 → 0.364.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 (140) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +7 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  5. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
  6. package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
  7. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  9. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  10. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  12. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  13. package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
  14. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  15. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  17. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  24. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  26. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  27. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
  28. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  29. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  30. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  31. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  32. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  33. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  34. package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
  35. package/packages/bundled-features/src/config/changes.json +6 -0
  36. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  37. package/packages/bundled-features/src/config/resolver.ts +15 -12
  38. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  39. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  40. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
  41. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  42. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  43. package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
  44. package/packages/bundled-features/src/delivery/changes.json +18 -0
  45. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  46. package/packages/bundled-features/src/delivery/index.ts +2 -0
  47. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  48. package/packages/bundled-features/src/delivery/types.ts +7 -1
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  50. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  51. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  52. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  56. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  57. package/packages/bundled-features/src/jobs/changes.json +19 -0
  58. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  59. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  60. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  61. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  62. package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
  63. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  64. package/packages/bundled-features/src/ledger/changes.json +7 -0
  65. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  66. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  67. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  68. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  69. package/packages/bundled-features/src/secrets/changes.json +6 -0
  70. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  71. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  72. package/packages/bundled-features/src/shared/changes.json +6 -0
  73. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  74. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  75. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  76. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  77. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  78. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  79. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  80. package/packages/bundled-features/src/template-resolver/changes.json +68 -0
  81. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
  82. package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
  83. package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
  84. package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
  85. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
  86. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
  87. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  88. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  89. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  91. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  92. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  93. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
  94. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
  95. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  96. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  97. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  98. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  99. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  100. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  101. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  102. package/packages/bundled-features/src/template-resolver/index.ts +12 -1
  103. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
  104. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  105. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  106. package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
  107. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
  108. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  109. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  110. package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
  111. package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
  112. package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
  113. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  114. package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
  115. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  116. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
  117. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
  118. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
  119. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
  120. package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
  121. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  122. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
  123. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  124. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  125. package/packages/bundled-features/src/user/changes.json +6 -0
  126. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  127. package/packages/bundled-features/src/user/seeding.ts +19 -5
  128. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  129. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  130. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  131. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  132. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  133. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  134. package/samples/apps/ui-walkthrough/package.json +9 -0
  135. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  136. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  137. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  138. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  139. package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
  140. package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
@@ -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,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "improvement",
5
+ "title": "SecretsContext.withRunner(tx) runs secret access on a given transaction",
6
+ "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."
7
+ },
2
8
  {
3
9
  "version": "0.343.0",
4
10
  "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 = {
@@ -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
@@ -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
  }
@@ -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
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "A stale Stripe customer id no longer turns the customer portal into a 500",
6
+ "detail": "createPortalSession now maps Stripe's \"No such customer\" rejection (account switch, customer deleted in the dashboard) to an UnprocessableError with the i18n key billing-foundation.errors.providerCustomerUnknown, translated in German and Spanish, instead of letting the raw Stripe error surface as a server error."
7
+ },
2
8
  {
3
9
  "version": "0.338.0",
4
10
  "type": "improvement",
@@ -109,12 +109,10 @@ export function mapAppLocaleToStripeLocale(
109
109
  return knownLocales.find((known) => known === language) ?? "auto";
110
110
  }
111
111
 
112
- function isUnknownCustomerStripeError(error: unknown): boolean {
113
- return (
114
- isResourceMissingStripeError(error) &&
115
- error instanceof Stripe.errors.StripeInvalidRequestError &&
116
- error.param === "customer"
117
- );
112
+ function isUnknownCustomerStripeError(
113
+ error: unknown,
114
+ ): error is Stripe.errors.StripeInvalidRequestError {
115
+ return isResourceMissingStripeError(error) && error.param === "customer";
118
116
  }
119
117
 
120
118
  export function createStripeCheckoutSession(
@@ -203,11 +201,21 @@ export type StripePortalOptions = Parameters<
203
201
  export function createStripePortalSession(runtime: StripeCtxRuntime) {
204
202
  return async (ctx: HandlerContext, options: StripePortalOptions): Promise<{ url: string }> => {
205
203
  const stripe = await runtime.clientForCtx(ctx);
206
- const session = await stripe.billingPortal.sessions.create({
207
- customer: options.providerCustomerId,
208
- return_url: options.returnUrl,
209
- });
210
- return { url: session.url };
204
+ try {
205
+ const session = await stripe.billingPortal.sessions.create({
206
+ customer: options.providerCustomerId,
207
+ return_url: options.returnUrl,
208
+ });
209
+ return { url: session.url };
210
+ } catch (error) {
211
+ // A portal needs an existing customer, so unlike checkout there is no retry without one.
212
+ if (!isUnknownCustomerStripeError(error)) throw error;
213
+ throw new UnprocessableError("provider_customer_unknown", {
214
+ i18nKey: "billing-foundation.errors.providerCustomerUnknown",
215
+ message: `subscription-stripe: Stripe does not know customer "${options.providerCustomerId}" (account switch or customer deleted).`,
216
+ cause: error,
217
+ });
218
+ }
211
219
  };
212
220
  }
213
221
 
@@ -54,7 +54,9 @@ import type { StripeWebhookRuntime } from "./runtime.js";
54
54
 
55
55
  const log = createFallbackLogger("subscription-stripe");
56
56
 
57
- export function isResourceMissingStripeError(error: unknown): boolean {
57
+ export function isResourceMissingStripeError(
58
+ error: unknown,
59
+ ): error is Stripe.errors.StripeInvalidRequestError {
58
60
  return (
59
61
  error instanceof Stripe.errors.StripeInvalidRequestError && error.code === "resource_missing"
60
62
  );
@@ -50,7 +50,7 @@ async function someHandler(ctx) {
50
50
 
51
51
  | kind | contentFormat | meta |
52
52
  |---|---|---|
53
- | `mail` | `html` (Dokument-Schema, wird serverseitig neu aufgebaut) | Pflicht: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `sender` |
53
+ | `mail` | `html` (Dokument-Schema, wird serverseitig neu aufgebaut) | Pflicht: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `senderName`, `replyTo` |
54
54
  | `mail-layout` | `mjml` | leer (`{}`) oder fehlend |
55
55
  | `pdf-layout` | `html` | leer (`{}`) oder fehlend |
56
56
  | `document-pdf` | `html` | fehlt |
@@ -137,10 +137,12 @@ upsertSystem ──┐
137
137
  ├──► status: "active" (System-Default sofort aktiv)
138
138
  upsertTenant ──┴──► status: "draft" (Default) | "active" (explizit)
139
139
 
140
- publish ───────► status: "active"
140
+ publish ───────► status: "active" (übernimmt einen offenen Entwurf)
141
141
  archive ───────► status: "archived"
142
+ discardDraft ──► verwirft den Entwurf neben der aktiven Fassung
142
143
  ```
143
144
 
145
+ Ein Entwurf über einer aktiven Zeile (`upsertTenant` ohne `status`, `copy-locale`, `import-bundle`) liegt als JSON in `draft` neben der Zeile; die Hauptfelder bleiben die ausgelieferte Fassung. `find-by-id` und `layout-source` liefern die Arbeitsfassung (Entwurf, sonst gespeichert) mit `active { version, since }`.
144
146
  Resolver returnt **nur** Templates mit `status: "active"`. draft/archived werden ignoriert.
145
147
 
146
148
  ## Consumer Conformance
@@ -18,6 +18,7 @@ import {
18
18
  type TemplatePdfAttachmentLink,
19
19
  } from "./linked-resources.js";
20
20
  import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
21
+ import { activeVersionOf, servedVersionOf } from "./template-draft.js";
21
22
  import { parseTemplateMeta, type TemplateMeta } from "./template-meta.js";
22
23
 
23
24
  // Public TemplateResource: what consumers see. Hides DB-internal columns
@@ -161,7 +162,7 @@ function pickActiveLayoutRow(
161
162
  ): TemplateResourceRow | null {
162
163
  const active = rows
163
164
  .filter((row) => row.status === "active")
164
- .sort((a, b) => b.version - a.version || a.locale.localeCompare(b.locale));
165
+ .sort((a, b) => activeVersionOf(b) - activeVersionOf(a) || a.locale.localeCompare(b.locale));
165
166
  return (
166
167
  active.find((row) => row.locale === locale) ??
167
168
  active.find((row) => row.locale === FALLBACK_LOCALE) ??
@@ -202,7 +203,7 @@ function toPublic(row: TemplateResourceRow): TemplateResource {
202
203
  const linkedEntries = parseLinkedResourceEntries(row.linkedResources);
203
204
  return {
204
205
  id: String(row.id),
205
- version: row.version,
206
+ version: servedVersionOf(row),
206
207
  tenantId: row.tenantId,
207
208
  slug: row.slug,
208
209
  kind,