@cosmicdrift/kumiko-samples 0.359.0 → 0.360.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.359.0",
3
+ "version": "0.360.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.359.0",
3
+ "version": "0.360.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -116,6 +116,7 @@
116
116
  "./channel-teams": "./src/channel-teams/index.ts",
117
117
  "./channel-telegram": "./src/channel-telegram/index.ts",
118
118
  "./renderer-simple": "./src/renderer-simple/index.ts",
119
+ "./renderer-pdf": "./src/renderer-pdf/index.ts",
119
120
  "./files-provider-s3": "./src/files-provider-s3/index.ts",
120
121
  "./rate-limiting": "./src/rate-limiting/index.ts",
121
122
  "./presets": "./src/presets/index.ts",
@@ -166,6 +167,7 @@
166
167
  "mailparser": "^3.9.32",
167
168
  "marked": "^18.0.3",
168
169
  "nodemailer": "^10.0.13",
170
+ "pdf-lib": "^1.17.1",
169
171
  "qrcode": "^1.5.4",
170
172
  "react": "^19.2.6",
171
173
  "sharp": "^0.35.5",
@@ -555,6 +557,10 @@
555
557
  "types": "./dist/renderer-simple/index.d.ts",
556
558
  "default": "./dist/renderer-simple/index.js"
557
559
  },
560
+ "./renderer-pdf": {
561
+ "types": "./dist/renderer-pdf/index.d.ts",
562
+ "default": "./dist/renderer-pdf/index.js"
563
+ },
558
564
  "./files-provider-s3": {
559
565
  "types": "./dist/files-provider-s3/index.d.ts",
560
566
  "default": "./dist/files-provider-s3/index.js"
@@ -683,6 +689,7 @@
683
689
  "@testing-library/user-event": "^14.6.1",
684
690
  "@cosmicdrift/kumiko-locale-de": "workspace:*",
685
691
  "@cosmicdrift/kumiko-locale-es": "workspace:*",
686
- "jsqr": "^1.4.0"
692
+ "jsqr": "^1.4.0",
693
+ "unpdf": "^1.8.1"
687
694
  }
688
695
  }
@@ -1,7 +1,7 @@
1
1
  // Public § 312k pages (/legal/kuendigen, /legal/cancel): form → review → confirm,
2
2
  // no JavaScript. Every echoed value goes through escapeHtml/escapeHtmlAttr.
3
3
 
4
- import type { AnonymousExtraRoute } from "@cosmicdrift/kumiko-framework/api";
4
+ import { type AnonymousExtraRoute, getSessionCookies } from "@cosmicdrift/kumiko-framework/api";
5
5
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
6
6
  import type { Context } from "hono";
7
7
  import { getCookie } from "hono/cookie";
@@ -279,7 +279,6 @@ type WriteResponseBody = {
279
279
  const RATE_LIMITED_CODE = "rate_limited";
280
280
 
281
281
  const TENANT_HEADER = "x-tenant";
282
- const TENANT_COOKIE = "kumiko_tenant";
283
282
  // Exactly what the anonymous tenant resolution reads. The resolver and its
284
283
  // trust rules still decide; the re-entry only sees what a direct /api/write
285
284
  // from this client would see. Never the session cookie or Authorization (the
@@ -298,9 +297,10 @@ function reentryHeaders(c: Context): Headers {
298
297
  const value = c.req.header(name);
299
298
  if (value !== undefined) headers.set(name, value);
300
299
  }
301
- const tenantCookie = getCookie(c, TENANT_COOKIE);
300
+ const tenantCookieName = getSessionCookies(c).names.tenant;
301
+ const tenantCookie = getCookie(c, tenantCookieName);
302
302
  if (tenantCookie !== undefined) {
303
- headers.set("cookie", `${TENANT_COOKIE}=${encodeURIComponent(tenantCookie)}`);
303
+ headers.set("cookie", `${tenantCookieName}=${encodeURIComponent(tenantCookie)}`);
304
304
  }
305
305
  return headers;
306
306
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.360.0",
4
+ "type": "fix",
5
+ "title": "Finished tenant job run replaces the older one atomically",
6
+ "detail": "recordTenantRunOutcome wrote the new completed or failed row and pruned older finished rows of the same tenant, job and subject in two separate statements, so the store briefly held two finished rows per subject, and two runs of one subject finishing together could each miss the other's row and leave both. Both writes now run in one transaction under a per-key advisory lock (namespace tjrl). jobs:query:tenant-runs already showed only the newest finished row, so its result does not change."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "breaking",
@@ -167,6 +167,8 @@ async function encryptStartedPayload(
167
167
  // pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
168
168
  // framework's other fixed advisory-lock keys.
169
169
  const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
170
+ // 'tjrl': serialises the finished-row write + prune per tenant run key.
171
+ const TENANT_JOB_RUN_LOCK_NAMESPACE = 0x746a726c;
170
172
 
171
173
  // fw#3079 — which row the tenant-visible failure record lives in: one per
172
174
  // (tenant, job, subject), so the next outcome of the same work replaces or
@@ -311,16 +313,27 @@ async function recordTenantRunOutcome(
311
313
  // skip: retry pending, the row stays active until the final attempt
312
314
  return;
313
315
  }
314
- await upsertOnConflict(
315
- db,
316
- tenantJobRunsTable,
317
- { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now },
318
- { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } },
319
- );
320
- await deleteMany(db, tenantJobRunsTable, {
321
- ...key,
322
- status: { in: ["completed", "failed"] },
323
- bullJobId: { ne: bullJobId },
316
+ // One transaction: a reader never sees the new finished row next to the
317
+ // one it replaces. The per-key lock keeps two runs of the same subject that
318
+ // finish together from each missing the other's uncommitted row and leaving
319
+ // both behind.
320
+ await transaction(db, async (tx) => {
321
+ await acquireNamespacedAdvisoryLock(
322
+ tx,
323
+ TENANT_JOB_RUN_LOCK_NAMESPACE,
324
+ JSON.stringify([key.tenantId, key.jobName, key.subject]),
325
+ );
326
+ await upsertOnConflict(
327
+ tx,
328
+ tenantJobRunsTable,
329
+ { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now },
330
+ { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } },
331
+ );
332
+ await deleteMany(tx, tenantJobRunsTable, {
333
+ ...key,
334
+ status: { in: ["completed", "failed"] },
335
+ bullJobId: { ne: bullJobId },
336
+ });
324
337
  });
325
338
  }
326
339
 
@@ -0,0 +1,27 @@
1
+ # renderer-pdf
2
+
3
+ Public `document-pdf` renderer plugin: draws a table document as PDF in pure JavaScript (`pdf-lib`, standard Helvetica fonts), no browser and no Puppeteer service. Same input gives byte-identical output.
4
+
5
+ ## Pure function
6
+
7
+ ```typescript
8
+ import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
9
+
10
+ const { pdfBytes, pageCount } = await renderTablePdf(
11
+ {
12
+ title: "Prüfprotokoll",
13
+ meta: [{ label: "Anlage", value: "Kessel 3" }],
14
+ columns: [{ header: "Datum" }, { header: "Prüfer", weight: 2 }, { header: "Kosten", align: "right" }],
15
+ rows: [["03.02.2026", "Müller", "120,00 €"]],
16
+ footerText: "Kumiko GmbH",
17
+ pageLabel: "Seite {page} von {pages}",
18
+ },
19
+ { format: "A4" },
20
+ );
21
+ ```
22
+
23
+ The caller passes ready-formatted strings (dates, numbers); the renderer never reads the clock. Characters Helvetica (WinAnsi) cannot encode are drawn as `?`; umlauts, `ß` and `€` are kept.
24
+
25
+ ## Plugin
26
+
27
+ Mount `createRendererPdfFeature()` next to `renderer-foundation`. It serves `kind: "document-pdf"` with a `PdfTableDocument` in `payload.variables`; `options.format` and `options.marginMm` apply. Invalid variables raise `RendererError("invalid_payload")`. A Puppeteer plugin keeps precedence (`DEFAULT_PLUGIN_BY_KIND`) when mounted.
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.360.0",
4
+ "type": "improvement",
5
+ "title": "Pure-JS PDF renderer for table documents",
6
+ "detail": "renderTablePdf(doc, options?) renders a PdfTableDocument (title, subtitle, meta lines, weighted columns, wrapped cells, header row repeated per page, footerText and a pageLabel template with {page} and {pages}) to PDF bytes with pdf-lib. Characters outside WinAnsi are replaced by \"?\". Identical input yields identical bytes. createRendererPdfFeature() registers it as plugin \"pdf\" for kind \"document-pdf\"; payload.variables is validated against pdfTableDocumentSchema, anything else fails with RendererError invalid_payload."
7
+ }
8
+ ]
@@ -0,0 +1,48 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ RENDERER_EXTENSION,
4
+ RendererError,
5
+ type RenderRequest,
6
+ type RenderResponse,
7
+ } from "../renderer-foundation/index.js";
8
+ import { pdfTableDocumentSchema, renderTablePdf } from "./render-table-pdf.js";
9
+
10
+ export async function renderTablePdfRequest(req: RenderRequest): Promise<RenderResponse> {
11
+ if (req.kind !== "document-pdf") {
12
+ throw new RendererError(
13
+ `renderer-pdf supports only kind="document-pdf", got "${req.kind}"`,
14
+ "invalid_payload",
15
+ );
16
+ }
17
+ const parsed = pdfTableDocumentSchema.safeParse(req.payload.variables);
18
+ if (!parsed.success) {
19
+ throw new RendererError(
20
+ `renderer-pdf: payload.variables is not a table document: ${parsed.error.message}`,
21
+ "invalid_payload",
22
+ );
23
+ }
24
+ const { pdfBytes, pageCount } = await renderTablePdf(parsed.data, {
25
+ ...(req.options?.format && { format: req.options.format }),
26
+ ...(req.options?.marginMm && { marginMm: req.options.marginMm }),
27
+ });
28
+ return { kind: "document-pdf", pdfBytes, pageCount, sizeBytes: pdfBytes.byteLength };
29
+ }
30
+
31
+ export function createRendererPdfFeature(): FeatureDefinition {
32
+ return defineFeature("renderer-pdf", (r) => {
33
+ r.describe(
34
+ 'Renderer plugin for `kind="document-pdf"` that draws a table document (title, meta lines, weighted columns, repeating header, page footer) as a PDF in pure JavaScript, without a browser or Puppeteer service. `payload.variables` must be a `PdfTableDocument`. Requires `renderer-foundation`; mount a Puppeteer-based plugin instead when you need HTML/CSS layouts.',
35
+ );
36
+ r.uiHints({
37
+ displayLabel: "Renderer · PDF tables",
38
+ category: "notifications",
39
+ recommended: false,
40
+ });
41
+ r.requires("renderer-foundation");
42
+
43
+ r.useExtension(RENDERER_EXTENSION, "pdf", {
44
+ kinds: ["document-pdf"] as const,
45
+ render: (req) => renderTablePdfRequest(req),
46
+ });
47
+ });
48
+ }
@@ -0,0 +1,9 @@
1
+ export { createRendererPdfFeature } from "./feature.js";
2
+ export {
3
+ type PdfTableColumn,
4
+ type PdfTableDocument,
5
+ type PdfTableOptions,
6
+ type PdfTableResult,
7
+ pdfTableDocumentSchema,
8
+ renderTablePdf,
9
+ } from "./render-table-pdf.js";
@@ -0,0 +1,433 @@
1
+ import { PDFDocument, type PDFFont, type PDFPage, rgb, StandardFonts } from "pdf-lib";
2
+ import * as z from "zod";
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
+ };
41
+
42
+ 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
+ };
50
+ };
51
+
52
+ export type PdfTableResult = { readonly pdfBytes: Uint8Array; readonly pageCount: number };
53
+
54
+ const POINTS_PER_MM = 72 / 25.4;
55
+ const PAGE_SIZE_POINTS = {
56
+ A4: { width: 595.28, height: 841.89 },
57
+ Letter: { width: 612, height: 792 },
58
+ } as const;
59
+ const DEFAULT_MARGIN_MM = { top: 18, right: 15, bottom: 18, left: 15 } as const;
60
+
61
+ const TITLE_SIZE = 16;
62
+ const SUBTITLE_SIZE = 11;
63
+ const BODY_SIZE = 9;
64
+ const FOOTER_SIZE = 8;
65
+ const LINE_HEIGHT_FACTOR = 1.3;
66
+ const CELL_PADDING_X = 4;
67
+ const CELL_PADDING_Y = 3;
68
+ const FOOTER_RESERVE = 16;
69
+ const META_LABEL_GAP = 10;
70
+ const BLOCK_GAP = 10;
71
+ const REPLACEMENT_CHAR = "?";
72
+ const ELLIPSIS = "...";
73
+
74
+ const TEXT_COLOR = rgb(0.1, 0.1, 0.1);
75
+ const MUTED_COLOR = rgb(0.4, 0.4, 0.4);
76
+ const HEADER_FILL = rgb(0.92, 0.93, 0.95);
77
+ const RULE_COLOR = rgb(0.8, 0.8, 0.82);
78
+
79
+ type Fonts = { readonly regular: PDFFont; readonly bold: PDFFont };
80
+
81
+ function lineHeightFor(size: number): number {
82
+ return size * LINE_HEIGHT_FACTOR;
83
+ }
84
+
85
+ function createEncodableText(font: PDFFont): (text: string) => string {
86
+ const supported = new Set(font.getCharacterSet());
87
+ return (text) => {
88
+ let result = "";
89
+ for (const char of text.replace(/\t/g, " ")) {
90
+ const codePoint = char.codePointAt(0) ?? 0;
91
+ result += codePoint === 0x0a || supported.has(codePoint) ? char : REPLACEMENT_CHAR;
92
+ }
93
+ return result;
94
+ };
95
+ }
96
+
97
+ function breakLongWord(word: string, font: PDFFont, size: number, maxWidth: number): string[] {
98
+ const pieces: string[] = [];
99
+ let current = "";
100
+ for (const char of word) {
101
+ if (current !== "" && font.widthOfTextAtSize(current + char, size) > maxWidth) {
102
+ pieces.push(current);
103
+ current = char;
104
+ } else {
105
+ current += char;
106
+ }
107
+ }
108
+ if (current !== "") pieces.push(current);
109
+ return pieces;
110
+ }
111
+
112
+ function wrapText(text: string, font: PDFFont, size: number, maxWidth: number): string[] {
113
+ const lines: string[] = [];
114
+ for (const paragraph of text.split(/\r\n|\n|\r/)) {
115
+ let current = "";
116
+ for (const word of paragraph.split(" ").filter((w) => w !== "")) {
117
+ const candidate = current === "" ? word : `${current} ${word}`;
118
+ if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
119
+ current = candidate;
120
+ continue;
121
+ }
122
+ if (current !== "") lines.push(current);
123
+ const pieces =
124
+ font.widthOfTextAtSize(word, size) <= maxWidth
125
+ ? [word]
126
+ : breakLongWord(word, font, size, maxWidth);
127
+ lines.push(...pieces.slice(0, -1));
128
+ current = pieces[pieces.length - 1] ?? "";
129
+ }
130
+ lines.push(current);
131
+ }
132
+ return lines;
133
+ }
134
+
135
+ function fitSingleLine(text: string, font: PDFFont, size: number, maxWidth: number): string {
136
+ if (font.widthOfTextAtSize(text, size) <= maxWidth) return text;
137
+ let fitted = text;
138
+ while (fitted !== "" && font.widthOfTextAtSize(fitted + ELLIPSIS, size) > maxWidth) {
139
+ fitted = fitted.slice(0, -1);
140
+ }
141
+ return fitted + ELLIPSIS;
142
+ }
143
+
144
+ function resolveColumnWidths(columns: readonly PdfTableColumn[], totalWidth: number): number[] {
145
+ const totalWeight = columns.reduce((sum, column) => sum + (column.weight ?? 1), 0);
146
+ return columns.map((column) => (totalWidth * (column.weight ?? 1)) / totalWeight);
147
+ }
148
+
149
+ function assertRowShape(doc: PdfTableDocument): void {
150
+ doc.rows.forEach((row, index) => {
151
+ if (row.length !== doc.columns.length) {
152
+ throw new RendererError(
153
+ `row ${index} has ${row.length} cells but the table has ${doc.columns.length} columns`,
154
+ "invalid_payload",
155
+ );
156
+ }
157
+ });
158
+ }
159
+
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),
173
+ };
174
+ const clean = createEncodableText(fonts.regular);
175
+
176
+ const pageSize = PAGE_SIZE_POINTS[options?.format ?? "A4"];
177
+ 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,
182
+ };
183
+ const contentWidth = pageSize.width - margin.left - margin.right;
184
+ const contentBottom = margin.bottom + FOOTER_RESERVE;
185
+ const columnWidths = resolveColumnWidths(doc.columns, contentWidth);
186
+ const bodyLineHeight = lineHeightFor(BODY_SIZE);
187
+
188
+ const rowSpaceOnEmptyPage = pageSize.height - margin.top - contentBottom;
189
+ if (rowSpaceOnEmptyPage < 4 * bodyLineHeight) {
190
+ throw new RendererError("margins leave no room for table rows", "invalid_payload");
191
+ }
192
+
193
+ const headerLines = doc.columns.map((column, index) =>
194
+ wrapText(
195
+ clean(column.header),
196
+ fonts.bold,
197
+ BODY_SIZE,
198
+ (columnWidths[index] ?? contentWidth) - 2 * CELL_PADDING_X,
199
+ ),
200
+ );
201
+ const headerHeight =
202
+ Math.max(...headerLines.map((lines) => lines.length)) * bodyLineHeight + 2 * CELL_PADDING_Y;
203
+
204
+ let page: PDFPage = pdf.addPage([pageSize.width, pageSize.height]);
205
+ let cursorY = pageSize.height - margin.top;
206
+
207
+ function drawLines(
208
+ lines: readonly string[],
209
+ font: PDFFont,
210
+ size: number,
211
+ left: number,
212
+ width: number,
213
+ topY: number,
214
+ align: "left" | "right",
215
+ color = TEXT_COLOR,
216
+ ): void {
217
+ lines.forEach((line, index) => {
218
+ const lineWidth = font.widthOfTextAtSize(line, size);
219
+ const x = align === "right" ? left + width - lineWidth : left;
220
+ page.drawText(line, {
221
+ x,
222
+ y: topY - size - index * lineHeightFor(size),
223
+ size,
224
+ font,
225
+ color,
226
+ });
227
+ });
228
+ }
229
+
230
+ 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);
234
+
235
+ 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,
244
+ fonts.regular,
245
+ SUBTITLE_SIZE,
246
+ margin.left,
247
+ contentWidth,
248
+ cursorY,
249
+ "left",
250
+ MUTED_COLOR,
251
+ );
252
+ cursorY -= subtitleLines.length * lineHeightFor(SUBTITLE_SIZE);
253
+ }
254
+
255
+ const meta = doc.meta ?? [];
256
+ if (meta.length > 0) {
257
+ cursorY -= BLOCK_GAP / 2;
258
+ const labels = meta.map((entry) => clean(entry.label));
259
+ const labelWidth =
260
+ Math.max(...labels.map((label) => fonts.bold.widthOfTextAtSize(label, BODY_SIZE))) +
261
+ META_LABEL_GAP;
262
+ meta.forEach((entry, index) => {
263
+ const valueLines = wrapText(
264
+ clean(entry.value),
265
+ fonts.regular,
266
+ BODY_SIZE,
267
+ contentWidth - labelWidth,
268
+ );
269
+ drawLines(
270
+ [labels[index] ?? ""],
271
+ fonts.bold,
272
+ BODY_SIZE,
273
+ margin.left,
274
+ labelWidth,
275
+ cursorY,
276
+ "left",
277
+ );
278
+ drawLines(
279
+ valueLines,
280
+ fonts.regular,
281
+ BODY_SIZE,
282
+ margin.left + labelWidth,
283
+ contentWidth - labelWidth,
284
+ cursorY,
285
+ "left",
286
+ );
287
+ cursorY -= valueLines.length * bodyLineHeight;
288
+ });
289
+ }
290
+ cursorY -= BLOCK_GAP;
291
+ }
292
+
293
+ function drawTableHeader(): void {
294
+ page.drawRectangle({
295
+ x: margin.left,
296
+ y: cursorY - headerHeight,
297
+ width: contentWidth,
298
+ height: headerHeight,
299
+ color: HEADER_FILL,
300
+ });
301
+ let x = margin.left;
302
+ doc.columns.forEach((column, index) => {
303
+ const width = columnWidths[index] ?? 0;
304
+ drawLines(
305
+ headerLines[index] ?? [],
306
+ fonts.bold,
307
+ BODY_SIZE,
308
+ x + CELL_PADDING_X,
309
+ width - 2 * CELL_PADDING_X,
310
+ cursorY - CELL_PADDING_Y,
311
+ column.align ?? "left",
312
+ );
313
+ x += width;
314
+ });
315
+ cursorY -= headerHeight;
316
+ }
317
+
318
+ function startContinuationPage(): void {
319
+ page = pdf.addPage([pageSize.width, pageSize.height]);
320
+ cursorY = pageSize.height - margin.top;
321
+ drawTableHeader();
322
+ }
323
+
324
+ function drawRowSlice(
325
+ cellLines: readonly (readonly string[])[],
326
+ from: number,
327
+ count: number,
328
+ ): void {
329
+ const height = count * bodyLineHeight + 2 * CELL_PADDING_Y;
330
+ let x = margin.left;
331
+ doc.columns.forEach((column, index) => {
332
+ const width = columnWidths[index] ?? 0;
333
+ drawLines(
334
+ (cellLines[index] ?? []).slice(from, from + count),
335
+ fonts.regular,
336
+ BODY_SIZE,
337
+ x + CELL_PADDING_X,
338
+ width - 2 * CELL_PADDING_X,
339
+ cursorY - CELL_PADDING_Y,
340
+ column.align ?? "left",
341
+ );
342
+ x += width;
343
+ });
344
+ page.drawLine({
345
+ start: { x: margin.left, y: cursorY - height },
346
+ end: { x: margin.left + contentWidth, y: cursorY - height },
347
+ thickness: 0.5,
348
+ color: RULE_COLOR,
349
+ });
350
+ cursorY -= height;
351
+ }
352
+
353
+ function drawRow(cellLines: readonly (readonly string[])[]): void {
354
+ const lineCount = Math.max(...cellLines.map((lines) => lines.length));
355
+ const rowHeight = lineCount * bodyLineHeight + 2 * CELL_PADDING_Y;
356
+ const emptyPageSpace = pageSize.height - margin.top - headerHeight - contentBottom;
357
+
358
+ if (rowHeight > cursorY - contentBottom && rowHeight <= emptyPageSpace) {
359
+ startContinuationPage();
360
+ }
361
+ let drawn = 0;
362
+ while (drawn < lineCount) {
363
+ const fitting = Math.floor((cursorY - contentBottom - 2 * CELL_PADDING_Y) / bodyLineHeight);
364
+ if (fitting < 1) {
365
+ startContinuationPage();
366
+ continue;
367
+ }
368
+ const count = Math.min(fitting, lineCount - drawn);
369
+ drawRowSlice(cellLines, drawn, count);
370
+ drawn += count;
371
+ }
372
+ }
373
+
374
+ drawIntroBlock();
375
+ drawTableHeader();
376
+
377
+ if (doc.rows.length === 0 && doc.emptyText !== undefined) {
378
+ drawRow([
379
+ wrapText(clean(doc.emptyText), fonts.regular, BODY_SIZE, contentWidth - 2 * CELL_PADDING_X),
380
+ ]);
381
+ }
382
+ for (const row of doc.rows) {
383
+ drawRow(
384
+ row.map((cell, index) =>
385
+ wrapText(
386
+ clean(cell),
387
+ fonts.regular,
388
+ BODY_SIZE,
389
+ (columnWidths[index] ?? contentWidth) - 2 * CELL_PADDING_X,
390
+ ),
391
+ ),
392
+ );
393
+ }
394
+
395
+ const pages = pdf.getPages();
396
+ const pageLabelTemplate = doc.pageLabel ?? "{page} / {pages}";
397
+ const footerBaseline = margin.bottom;
398
+ pages.forEach((footerPage, index) => {
399
+ const label = clean(
400
+ pageLabelTemplate
401
+ .replaceAll("{page}", String(index + 1))
402
+ .replaceAll("{pages}", String(pages.length)),
403
+ );
404
+ const labelWidth = fonts.regular.widthOfTextAtSize(label, FOOTER_SIZE);
405
+ if (doc.footerText !== undefined) {
406
+ footerPage.drawText(
407
+ fitSingleLine(
408
+ clean(doc.footerText.replace(/\s+/g, " ")),
409
+ fonts.regular,
410
+ FOOTER_SIZE,
411
+ contentWidth - labelWidth - 2 * CELL_PADDING_X,
412
+ ),
413
+ {
414
+ x: margin.left,
415
+ y: footerBaseline,
416
+ size: FOOTER_SIZE,
417
+ font: fonts.regular,
418
+ color: MUTED_COLOR,
419
+ },
420
+ );
421
+ }
422
+ footerPage.drawText(label, {
423
+ x: margin.left + contentWidth - labelWidth,
424
+ y: footerBaseline,
425
+ size: FOOTER_SIZE,
426
+ font: fonts.regular,
427
+ color: MUTED_COLOR,
428
+ });
429
+ });
430
+
431
+ const pdfBytes = await pdf.save();
432
+ return { pdfBytes, pageCount: pages.length };
433
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.360.0",
4
+ "type": "improvement",
5
+ "title": "change-email can be switched off",
6
+ "detail": "createUserProfileFeature({ allowEmailChange: false }) neither registers the change-email write handler nor shows the email section on the profile screen. Without the option nothing changes."
7
+ },
2
8
  {
3
9
  "version": "0.243.1",
4
10
  "type": "fix",
@@ -12,13 +12,21 @@ import {
12
12
  } from "./constants.js";
13
13
  import { changeEmailWrite } from "./handlers/change-email.write.js";
14
14
 
15
- export function createUserProfileFeature(): FeatureDefinition {
15
+ export type UserProfileFeatureOptions = {
16
+ /** Default true. false drops the change-email handler and the email
17
+ * section — for apps whose login email is managed elsewhere (SSO, admin-set). */
18
+ readonly allowEmailChange?: boolean;
19
+ };
20
+
21
+ export function createUserProfileFeature(options?: UserProfileFeatureOptions): FeatureDefinition {
22
+ const allowEmailChange = options?.allowEmailChange ?? true;
16
23
  return defineFeature("user-profile", (r) => {
17
24
  r.describe(
18
25
  "Self-service account page (fw#2312: declarative `projectionDetail` bound to " +
19
26
  "the signed-in user's own `me` row) plus its `change-email` write handler " +
20
27
  "(re-auth via current password, uniqueness check, resets emailVerified and " +
21
- "expects the app to trigger the verification flow). Change-password and " +
28
+ "expects the app to trigger the verification flow; opt out with " +
29
+ "`allowEmailChange: false`, which drops handler and section). Change-password and " +
22
30
  "change-email stay custom `EditExtensionSection` components (re-auth flows a " +
23
31
  "declarative action can't express); account deletion (user-data-rights " +
24
32
  "request/cancel with grace period) is declarative fields + actions. Apps " +
@@ -38,9 +46,7 @@ export function createUserProfileFeature(): FeatureDefinition {
38
46
  r.requires("user-data-rights");
39
47
  r.requires("user-data-rights-defaults");
40
48
 
41
- const handlers = {
42
- changeEmail: r.writeHandler(changeEmailWrite),
43
- };
49
+ const handlers = allowEmailChange ? { changeEmail: r.writeHandler(changeEmailWrite) } : {};
44
50
 
45
51
  // Boot-validator requires a "screen:<id>.title" key per r.screen(), plus
46
52
  // one entry per i18nKey()-marked dot-form label below — kept inline (not
@@ -100,12 +106,16 @@ export function createUserProfileFeature(): FeatureDefinition {
100
106
  },
101
107
  layout: {
102
108
  sections: [
103
- {
104
- kind: "extension",
105
- title: i18nKey("profile.email.title"),
106
- component: { react: { __component: CHANGE_EMAIL_SECTION_EXTENSION_NAME } },
107
- entityName: "user",
108
- },
109
+ ...(allowEmailChange
110
+ ? [
111
+ {
112
+ kind: "extension" as const,
113
+ title: i18nKey("profile.email.title"),
114
+ component: { react: { __component: CHANGE_EMAIL_SECTION_EXTENSION_NAME } },
115
+ entityName: "user",
116
+ },
117
+ ]
118
+ : []),
109
119
  {
110
120
  kind: "extension",
111
121
  title: i18nKey("profile.password.title"),
@@ -8,4 +8,4 @@ export {
8
8
  UserProfileHandlers,
9
9
  UserProfileQueries,
10
10
  } from "./constants.js";
11
- export { createUserProfileFeature } from "./feature.js";
11
+ export { createUserProfileFeature, type UserProfileFeatureOptions } from "./feature.js";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "samples/apps/use-all-bundled APP_FEATURES (composeFeatures includeBundled + signup)",
3
- "featureCount": 81,
3
+ "featureCount": 82,
4
4
  "features": [
5
5
  {
6
6
  "name": "admin-shell",
@@ -2072,6 +2072,32 @@
2072
2072
  "recommended": false
2073
2073
  }
2074
2074
  },
2075
+ {
2076
+ "name": "renderer-pdf",
2077
+ "description": "Renderer plugin for `kind=\"document-pdf\"` that draws a table document (title, meta lines, weighted columns, repeating header, page footer) as a PDF in pure JavaScript, without a browser or Puppeteer service. `payload.variables` must be a `PdfTableDocument`. Requires `renderer-foundation`; mount a Puppeteer-based plugin instead when you need HTML/CSS layouts.",
2078
+ "toggleableDefault": null,
2079
+ "requires": [
2080
+ "renderer-foundation"
2081
+ ],
2082
+ "optionalRequires": [],
2083
+ "configReads": [],
2084
+ "exposesApis": [],
2085
+ "usesApis": [],
2086
+ "extensionsUsed": [
2087
+ {
2088
+ "extensionName": "renderer",
2089
+ "entityName": "pdf"
2090
+ }
2091
+ ],
2092
+ "configKeys": [],
2093
+ "secrets": [],
2094
+ "writeHandlers": [],
2095
+ "uiHints": {
2096
+ "displayLabel": "Renderer · PDF tables",
2097
+ "category": "notifications",
2098
+ "recommended": false
2099
+ }
2100
+ },
2075
2101
  {
2076
2102
  "name": "renderer-simple",
2077
2103
  "description": "Default renderer plugin for `kind=\"notification\"`: takes a structured `EmailTemplateData` variable map (with `header`, `sections[]` of text/button objects, and optional `footer`; falls back to `title`/`body` if no structured fields are present) and returns rendered HTML with inline CSS. Requires `renderer-foundation`; sufficient for plain notification emails — swap it for `renderer-mail-html` if you need MJML/Markdown layouts.",
@@ -3054,7 +3080,7 @@
3054
3080
  },
3055
3081
  {
3056
3082
  "name": "user-profile",
3057
- "description": "Self-service account page (fw#2312: declarative `projectionDetail` bound to the signed-in user's own `me` row) plus its `change-email` write handler (re-auth via current password, uniqueness check, resets emailVerified and expects the app to trigger the verification flow). Change-password and change-email stay custom `EditExtensionSection` components (re-auth flows a declarative action can't express); account deletion (user-data-rights request/cancel with grace period) is declarative fields + actions. Apps place the screen (id \"profile\") in their logged-in area via r.nav — no `type: \"custom\"` / `__component` registration needed on the app side anymore. Requires `user`, `auth-email-password`, `user-data-rights`, and `user-data-rights-defaults` (so the GDPR boot-validator finds export/delete hooks for `user`'s PII fields once user-data-rights is mounted).",
3083
+ "description": "Self-service account page (fw#2312: declarative `projectionDetail` bound to the signed-in user's own `me` row) plus its `change-email` write handler (re-auth via current password, uniqueness check, resets emailVerified and expects the app to trigger the verification flow; opt out with `allowEmailChange: false`, which drops handler and section). Change-password and change-email stay custom `EditExtensionSection` components (re-auth flows a declarative action can't express); account deletion (user-data-rights request/cancel with grace period) is declarative fields + actions. Apps place the screen (id \"profile\") in their logged-in area via r.nav — no `type: \"custom\"` / `__component` registration needed on the app side anymore. Requires `user`, `auth-email-password`, `user-data-rights`, and `user-data-rights-defaults` (so the GDPR boot-validator finds export/delete hooks for `user`'s PII fields once user-data-rights is mounted).",
3058
3084
  "toggleableDefault": null,
3059
3085
  "requires": [
3060
3086
  "user",
@@ -322,6 +322,11 @@ const FEATURE_IMPORT_REGISTRY: Record<string, FeatureImport> = {
322
322
  path: "@cosmicdrift/kumiko-bundled-features/renderer-simple",
323
323
  factory: "createRendererSimpleFeature",
324
324
  },
325
+ "renderer-pdf": {
326
+ kind: "factory",
327
+ path: "@cosmicdrift/kumiko-bundled-features/renderer-pdf",
328
+ factory: "createRendererPdfFeature",
329
+ },
325
330
  "rate-limiting": {
326
331
  kind: "factory",
327
332
  path: "@cosmicdrift/kumiko-bundled-features/rate-limiting",
@@ -76,6 +76,7 @@ import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-f
76
76
  import { securityBaselineFeatures } from "@cosmicdrift/kumiko-bundled-features/presets";
77
77
  import { readinessFeature } from "@cosmicdrift/kumiko-bundled-features/readiness";
78
78
  import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
79
+ import { createRendererPdfFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
79
80
  import { createRendererSimpleFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
80
81
  import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
81
82
  import { createSeoFeature } from "@cosmicdrift/kumiko-bundled-features/seo";
@@ -336,6 +337,7 @@ export const APP_FEATURES = [
336
337
  collectionLabelsFeature,
337
338
  createRendererFoundationFeature(),
338
339
  createRendererSimpleFeature(),
340
+ createRendererPdfFeature(),
339
341
 
340
342
  // managed-pages: requires config (auto-bundled). Smoke resolver never serves
341
343
  // (boot-only). allowCustomCss:true boot-validates the CSS code path + emits
@@ -0,0 +1,22 @@
1
+ # PDF Audit Export
2
+
3
+ A write handler exports tenant data as a PDF and keeps it as a FileRef.
4
+
5
+ ## What it shows
6
+
7
+ - `renderTablePdf` (`@cosmicdrift/kumiko-bundled-features/renderer-pdf`) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.
8
+ - `createFileRefFromBytes` (`@cosmicdrift/kumiko-framework/files`) stores the bytes like an upload (validation, tenant-prefixed storage key, `fileRef.created` event); the tenant comes from the caller.
9
+ - The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
10
+
11
+ ## Source
12
+
13
+ Feature entry point: `src/feature.ts`. The export reads at most 200 inspections per call.
14
+
15
+ ## Tests
16
+
17
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
18
+
19
+ ```bash
20
+ cd samples/recipes/pdf-audit-export
21
+ bun test
22
+ ```
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@cosmicdrift/kumiko-sample-pdf-audit-export",
3
+ "description": "Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and stores the bytes as a FileRef via createFileRefFromBytes.",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@cosmicdrift/kumiko-bundled-features": "workspace:*",
7
+ "@cosmicdrift/kumiko-framework": "workspace:*"
8
+ },
9
+ "devDependencies": {
10
+ "unpdf": "^1.8.1"
11
+ }
12
+ }
@@ -0,0 +1,114 @@
1
+ // PDF Audit Export Sample
2
+ //
3
+ // A write handler turns tenant data into a PDF and keeps it as a regular
4
+ // FileRef: renderTablePdf draws the table (pure JS, no browser), then
5
+ // createFileRefFromBytes stores the bytes through the same validation,
6
+ // storage layout and fileRef.created event as an upload.
7
+
8
+ import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
9
+ import {
10
+ createDateField,
11
+ createEntity,
12
+ createEntityExecutor,
13
+ createSelectField,
14
+ createTextField,
15
+ defineFeature,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { createFileRefFromBytes } from "@cosmicdrift/kumiko-framework/files";
18
+ import * as z from "zod";
19
+
20
+ export const INSPECTION_RESULTS = ["ok", "defect"] as const;
21
+
22
+ export const inspectionEntity = createEntity({
23
+ table: "read_sample_inspections",
24
+ fields: {
25
+ assetName: createTextField({ personal: false, reason: "is_business_data", required: true }),
26
+ inspectedOn: createDateField({ required: true }),
27
+ result: createSelectField({ options: INSPECTION_RESULTS, required: true }),
28
+ inspector: createTextField({ personal: "ref" }),
29
+ },
30
+ });
31
+
32
+ const RESULT_LABELS: Readonly<Record<(typeof INSPECTION_RESULTS)[number], string>> = {
33
+ ok: "In Ordnung",
34
+ defect: "Mangel",
35
+ };
36
+
37
+ // The renderer takes ready-formatted strings, so date formatting stays with the caller.
38
+ function formatGermanDate(isoDate: string): string {
39
+ const [year, month, day] = isoDate.split("-");
40
+ return `${day}.${month}.${year}`;
41
+ }
42
+
43
+ function toFileNameSegment(text: string): string {
44
+ return text.replaceAll(/[^A-Za-z0-9_-]+/g, "-");
45
+ }
46
+
47
+ const AUDIT_ROW_LIMIT = 200;
48
+
49
+ const access = { access: { roles: ["Admin", "User"] } } as const;
50
+
51
+ export const pdfAuditExportFeature = defineFeature("pdf-audit-export", (r) => {
52
+ r.entity("inspection", inspectionEntity);
53
+ const { executor: inspectionExecutor } = createEntityExecutor("inspection", inspectionEntity);
54
+
55
+ r.writeHandler(
56
+ "inspection:create",
57
+ z.object({
58
+ assetName: z.string().min(1),
59
+ inspectedOn: z.iso.date(),
60
+ result: z.enum(INSPECTION_RESULTS),
61
+ inspector: z.string().optional(),
62
+ }),
63
+ async (event, ctx) => inspectionExecutor.create(event.payload, event.user, ctx.db),
64
+ access,
65
+ );
66
+
67
+ r.writeHandler(
68
+ "export-audit",
69
+ z.object({ assetName: z.string().min(1), from: z.iso.date(), to: z.iso.date() }),
70
+ async (event, ctx) => {
71
+ const { assetName, from, to } = event.payload;
72
+ const inspections = await inspectionExecutor.list(
73
+ {
74
+ filters: [
75
+ { field: "assetName", op: "eq", value: assetName },
76
+ { field: "inspectedOn", op: "gte", value: from },
77
+ { field: "inspectedOn", op: "lte", value: to },
78
+ ],
79
+ sort: "inspectedOn",
80
+ sortDirection: "asc",
81
+ limit: AUDIT_ROW_LIMIT,
82
+ },
83
+ event.user,
84
+ ctx.db,
85
+ );
86
+
87
+ const { pdfBytes } = await renderTablePdf({
88
+ title: `Prüfprotokoll ${assetName}`,
89
+ meta: [
90
+ { label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` },
91
+ { label: "Prüfungen", value: String(inspections.rows.length) },
92
+ ],
93
+ columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }],
94
+ rows: inspections.rows.map((row) => [
95
+ formatGermanDate(String(row["inspectedOn"])),
96
+ RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])],
97
+ String(row["inspector"] ?? ""),
98
+ ]),
99
+ emptyText: "Keine Prüfungen im Zeitraum",
100
+ footerText: `Prüfbericht ${assetName}`,
101
+ pageLabel: "Seite {page} von {pages}",
102
+ });
103
+
104
+ const created = await createFileRefFromBytes(ctx, {
105
+ fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
106
+ mimeType: "application/pdf",
107
+ data: pdfBytes,
108
+ });
109
+ if (!created.isSuccess) return created;
110
+ return { isSuccess: true as const, data: { fileRefId: created.data.id } };
111
+ },
112
+ access,
113
+ );
114
+ });