@cosmicdrift/kumiko-samples 0.359.0 → 0.361.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 (51) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +9 -2
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/details.query.ts +2 -1
  5. package/packages/bundled-features/src/audit/handlers/list.query.ts +4 -5
  6. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +6 -0
  7. package/packages/bundled-features/src/auth-email-password/web/session.tsx +39 -3
  8. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +4 -4
  9. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  10. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  11. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +11 -6
  12. package/packages/bundled-features/src/folders-user-data/hooks.ts +4 -2
  13. package/packages/bundled-features/src/form-draft-user-data/hooks.ts +4 -1
  14. package/packages/bundled-features/src/jobs/changes.json +6 -0
  15. package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -10
  16. package/packages/bundled-features/src/renderer-pdf/README.md +27 -0
  17. package/packages/bundled-features/src/renderer-pdf/changes.json +8 -0
  18. package/packages/bundled-features/src/renderer-pdf/feature.ts +48 -0
  19. package/packages/bundled-features/src/renderer-pdf/index.ts +9 -0
  20. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +433 -0
  21. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +2 -2
  22. package/packages/bundled-features/src/shared/index.ts +1 -0
  23. package/packages/bundled-features/src/shared/redact-pii-ciphertext.ts +16 -0
  24. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +7 -2
  25. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +2 -1
  26. package/packages/bundled-features/src/user/changes.json +6 -0
  27. package/packages/bundled-features/src/user/handlers/me.query.ts +5 -1
  28. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +2 -1
  29. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +6 -1
  30. package/packages/bundled-features/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +3 -2
  31. package/packages/bundled-features/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +3 -3
  32. package/packages/bundled-features/src/user-profile/changes.json +6 -0
  33. package/packages/bundled-features/src/user-profile/feature.ts +21 -11
  34. package/packages/bundled-features/src/user-profile/index.ts +1 -1
  35. package/samples/apps/styleguide/src/features/examples/feature.ts +52 -3
  36. package/samples/apps/styleguide/src/features/examples/i18n.ts +3 -0
  37. package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +3 -2
  38. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +11 -2
  39. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +17 -5
  40. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +12 -1
  41. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +46 -1
  42. package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +2 -0
  43. package/samples/apps/ui-walkthrough/src/features/vehicles/i18n.ts +3 -1
  44. package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +18 -0
  45. package/samples/apps/use-all-bundled/feature-manifest.json +28 -2
  46. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  47. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  48. package/samples/recipes/files-post-processing/src/feature.ts +11 -1
  49. package/samples/recipes/pdf-audit-export/README.md +22 -0
  50. package/samples/recipes/pdf-audit-export/package.json +12 -0
  51. package/samples/recipes/pdf-audit-export/src/feature.ts +114 -0
@@ -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,6 +1,6 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import {
3
- collectPiiSubjectFields,
3
+ collectPiiEncryptedFields,
4
4
  configuredPiiSubjectKms,
5
5
  encryptPiiFieldValues,
6
6
  KeyErasedError,
@@ -24,7 +24,7 @@ export async function encryptForDirectWrite(
24
24
  return await encryptPiiFieldValues(
25
25
  row,
26
26
  entity,
27
- collectPiiSubjectFields(entity),
27
+ collectPiiEncryptedFields(entity),
28
28
  kms,
29
29
  { requestId: requestContext.get()?.requestId ?? fallbackRequestId },
30
30
  { entityName },
@@ -33,6 +33,7 @@ export {
33
33
  redactErrorText,
34
34
  redactUrls,
35
35
  } from "./redact.js";
36
+ export { REDACTED_PII_PLACEHOLDER, redactPiiCiphertext } from "./redact-pii-ciphertext.js";
36
37
  export { requireForTenant } from "./require-for-tenant.js";
37
38
  export {
38
39
  type RowBoundGrantResult,
@@ -0,0 +1,16 @@
1
+ import { isPiiCiphertext } from "@cosmicdrift/kumiko-framework/crypto";
2
+
3
+ export const REDACTED_PII_PLACEHOLDER = "[[personal data]]" as const;
4
+
5
+ // Event payloads store PII fields as subject-KMS ciphertext; the API leak guard
6
+ // rejects any response carrying it, so payload reads swap it for a placeholder.
7
+ export function redactPiiCiphertext(value: unknown): unknown {
8
+ if (isPiiCiphertext(value)) return REDACTED_PII_PLACEHOLDER;
9
+ if (Array.isArray(value)) return value.map(redactPiiCiphertext);
10
+ if (typeof value === "object" && value !== null) {
11
+ return Object.fromEntries(
12
+ Object.entries(value).map(([key, inner]) => [key, redactPiiCiphertext(inner)]),
13
+ );
14
+ }
15
+ return value;
16
+ }
@@ -1,7 +1,11 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
2
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import type { DbRunner, TenantDb, WhereObject } from "@cosmicdrift/kumiko-framework/db";
4
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
+ import {
5
+ createEventStoreExecutor,
6
+ createTenantDb,
7
+ WritePurposes,
8
+ } from "@cosmicdrift/kumiko-framework/db";
5
9
  import {
6
10
  createSystemUser,
7
11
  type EscapeHatchAuditSink,
@@ -177,7 +181,7 @@ async function markTenantDestroyFailed(db: DbRunner, tenantId: TenantId): Promis
177
181
  },
178
182
  user,
179
183
  scopedDb,
180
- { skipOptimisticLock: true },
184
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
181
185
  );
182
186
  invalidateTenantLifecycleGate(tenantId);
183
187
  }
@@ -436,6 +440,7 @@ export async function runTenantDestructionSweep(args: {
436
440
  { id: tenantId, version, changes: { status: "destroying", destroyStartedAt: now } },
437
441
  user,
438
442
  scopedDb,
443
+ { writePurpose: WritePurposes.erasure },
439
444
  );
440
445
  if (!result.isSuccess) continue;
441
446
  invalidateTenantLifecycleGate(tenantId);
@@ -8,6 +8,7 @@ import {
8
8
  createEventStoreExecutor,
9
9
  createTenantDb,
10
10
  type DbRunner,
11
+ WritePurposes,
11
12
  } from "@cosmicdrift/kumiko-framework/db";
12
13
  import {
13
14
  createSystemUser,
@@ -233,7 +234,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<StageRunOut
233
234
  },
234
235
  user,
235
236
  db,
236
- { skipOptimisticLock: true },
237
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
237
238
  );
238
239
  invalidateTenantLifecycleGate(ctx.tenantId);
239
240
  return STAGE_DONE;
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "improvement",
5
+ "title": "user:me returns effectiveTimeZone, SessionProvider provides the current user",
6
+ "detail": "The me query adds effectiveTimeZone (ctx.tz.user), CurrentUserProfile.effectiveTimeZone is parsed by fetchCurrentUser and SessionProvider wraps its children in CurrentUserProvider."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "fix",
@@ -8,6 +8,7 @@ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user
8
8
 
9
9
  // Returns the currently signed-in user's profile. Field-level read access
10
10
  // strips out the passwordHash automatically (configured on the entity).
11
+ // `effectiveTimeZone` is the effective IANA zone of the caller.
11
12
  export const meQuery = defineQueryHandler({
12
13
  name: "user:me",
13
14
  schema: z.object({}),
@@ -33,6 +34,9 @@ export const meQuery = defineQueryHandler({
33
34
  const db = ctx.systemDb.acknowledgeCrossTenant(
34
35
  "user rows are tenant-agnostic identity records; self-lookup by id needs no tenant filter",
35
36
  );
36
- return crud.detail({ id: query.user.id }, query.user, db); // @wrapper-known semantic-alias
37
+ const row = await crud.detail({ id: query.user.id }, query.user, db);
38
+ // `timezone` on the row is only the stored override (often unset); clients
39
+ // need the effective zone (override, else tenant, else UTC) to compute "today".
40
+ return row === null ? row : { ...row, effectiveTimeZone: ctx.tz.user };
37
41
  },
38
42
  });
@@ -3,6 +3,7 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
5
5
  import * as z from "zod";
6
+ import { redactPiiCiphertext } from "../../shared/index.js";
6
7
 
7
8
  // DSGVO Art. 15 Selbstauskunft — User reads HIS OWN audit-log.
8
9
  // WHERE createdBy = ctx.user.id ist hard-coded (kein userId-Param,
@@ -75,7 +76,7 @@ export const myAuditLogQuery = defineQueryHandler({
75
76
  aggregateType: r["aggregate_type"],
76
77
  version: r["version"],
77
78
  type: r["type"],
78
- payload: r["payload"],
79
+ payload: redactPiiCiphertext(r["payload"]),
79
80
  createdAt: r["created_at"],
80
81
  }));
81
82
  const last = serialised[serialised.length - 1];
@@ -1,4 +1,8 @@
1
- import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
+ import {
2
+ createEventStoreExecutor,
3
+ type TenantDb,
4
+ WritePurposes,
5
+ } from "@cosmicdrift/kumiko-framework/db";
2
6
  import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
3
7
  import {
4
8
  createSystemUser,
@@ -206,6 +210,7 @@ async function severPersonLink(
206
210
  if (typeof id !== "string") continue;
207
211
  await crud.update({ id, changes: { insertedById: null } }, systemUser, tdb, {
208
212
  skipOptimisticLock: true,
213
+ writePurpose: WritePurposes.erasure,
209
214
  });
210
215
  }
211
216
  }
@@ -1,4 +1,4 @@
1
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
1
+ import { createEventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  createSystemUser,
4
4
  type UserDataDeleteHook,
@@ -117,7 +117,7 @@ export const tenantInvitationDeleteHook: UserDataDeleteHook = async (ctx, strate
117
117
  { id, changes: { email: `forgotten-${id}@anonymized.invalid` } },
118
118
  systemUser,
119
119
  ctx.db,
120
- { skipOptimisticLock: true },
120
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
121
121
  );
122
122
  }
123
123
  }
@@ -138,6 +138,7 @@ export const tenantInvitationDeleteHook: UserDataDeleteHook = async (ctx, strate
138
138
  if (typeof id !== "string") continue;
139
139
  await crud.update({ id, changes: { invitedBy: INVITED_BY_ANONYMIZED } }, systemUser, ctx.db, {
140
140
  skipOptimisticLock: true,
141
+ writePurpose: WritePurposes.erasure,
141
142
  });
142
143
  }
143
144
  throwIfErasureFailed(failures);
@@ -1,4 +1,4 @@
1
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
1
+ import { createEventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  createSystemUser,
4
4
  type UserDataDeleteHook,
@@ -102,7 +102,7 @@ export const userDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
102
102
  },
103
103
  systemUser,
104
104
  ctx.db,
105
- { skipOptimisticLock: true },
105
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
106
106
  );
107
107
  } else {
108
108
  // anonymize: PII raus, aber Row bleibt active (damit FK-References
@@ -120,7 +120,7 @@ export const userDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
120
120
  },
121
121
  systemUser,
122
122
  ctx.db,
123
- { skipOptimisticLock: true },
123
+ { skipOptimisticLock: true, writePurpose: WritePurposes.erasure },
124
124
  );
125
125
  }
126
126
  };
@@ -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",