@cosmicdrift/kumiko-bundled-features 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 (45) hide show
  1. package/dist/audit/handlers/details.query.d.ts +1 -1
  2. package/dist/audit/handlers/details.query.js +2 -1
  3. package/dist/audit/handlers/list.query.d.ts +1 -1
  4. package/dist/audit/handlers/list.query.js +4 -5
  5. package/dist/auth-email-password/web/auth-client.d.ts +2 -0
  6. package/dist/auth-email-password/web/auth-client.js +3 -0
  7. package/dist/auth-email-password/web/session.js +19 -3
  8. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +1 -1
  9. package/dist/billing-foundation/consumer-protection/termination-pages.js +4 -3
  10. package/dist/cap-counter/index.d.ts +1 -0
  11. package/dist/cap-counter/index.js +1 -0
  12. package/dist/data-retention/run-retention-cleanup.js +9 -6
  13. package/dist/folders-user-data/hooks.js +4 -1
  14. package/dist/form-draft-user-data/hooks.js +4 -1
  15. package/dist/jobs/job-run-logger.js +14 -5
  16. package/dist/renderer-pdf/feature.d.ts +4 -0
  17. package/dist/renderer-pdf/feature.js +32 -0
  18. package/dist/renderer-pdf/index.d.ts +2 -0
  19. package/dist/renderer-pdf/index.js +2 -0
  20. package/dist/renderer-pdf/render-table-pdf.d.ts +54 -0
  21. package/dist/renderer-pdf/render-table-pdf.js +274 -0
  22. package/dist/shared/encrypt-for-direct-write.js +2 -2
  23. package/dist/shared/index.d.ts +1 -0
  24. package/dist/shared/index.js +1 -0
  25. package/dist/shared/redact-pii-ciphertext.d.ts +2 -0
  26. package/dist/shared/redact-pii-ciphertext.js +14 -0
  27. package/dist/tenant-lifecycle/run-tenant-destroy.js +3 -3
  28. package/dist/tenant-lifecycle/stages.js +2 -2
  29. package/dist/user/handlers/me.query.d.ts +3 -1
  30. package/dist/user/handlers/me.query.js +5 -1
  31. package/dist/user-data-rights/handlers/my-audit-log.query.d.ts +1 -1
  32. package/dist/user-data-rights/handlers/my-audit-log.query.js +2 -1
  33. package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +2 -1
  34. package/dist/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.js +3 -2
  35. package/dist/user-data-rights-defaults/hooks/user.userdata-hook.js +3 -3
  36. package/dist/user-profile/feature.d.ts +6 -1
  37. package/dist/user-profile/feature.js +15 -11
  38. package/dist/user-profile/index.d.ts +1 -1
  39. package/package.json +20 -10
  40. package/src/audit/changes.json +6 -0
  41. package/src/cap-counter/changes.json +6 -0
  42. package/src/jobs/changes.json +6 -0
  43. package/src/renderer-pdf/changes.json +8 -0
  44. package/src/user/changes.json +6 -0
  45. package/src/user-profile/changes.json +6 -0
@@ -11,7 +11,7 @@ export declare const detailsQuery: import("@cosmicdrift/kumiko-types/define-hand
11
11
  aggregateType: string;
12
12
  version: number;
13
13
  type: string;
14
- payload: Record<string, unknown>;
14
+ payload: unknown;
15
15
  metadata: Record<string, unknown>;
16
16
  createdAt: unknown;
17
17
  createdBy: string;
@@ -6,6 +6,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
6
6
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
8
8
  import * as z from "zod";
9
+ import { redactPiiCiphertext } from "../../shared/index.js";
9
10
  import { AUDIT_SCOPE_VALUES } from "../constants.js";
10
11
  import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
11
12
  export const detailsQuery = defineQueryHandler({
@@ -34,7 +35,7 @@ export const detailsQuery = defineQueryHandler({
34
35
  aggregateType: row.aggregateType,
35
36
  version: row.version,
36
37
  type: row.type,
37
- payload: row.payload,
38
+ payload: redactPiiCiphertext(row.payload),
38
39
  metadata: row.metadata,
39
40
  createdAt: row.createdAt,
40
41
  createdBy: row.createdBy,
@@ -29,7 +29,7 @@ export declare const listQuery: import("@cosmicdrift/kumiko-types/define-handler
29
29
  aggregateType: string;
30
30
  version: number;
31
31
  type: string;
32
- payload: Record<string, unknown>;
32
+ payload: unknown;
33
33
  metadata: Record<string, unknown>;
34
34
  createdAt: unknown;
35
35
  createdBy: string;
@@ -9,15 +9,14 @@
9
9
  // A SystemAdmin can pass scope "system" to read the app-instance system events
10
10
  // (e.g. app.started); no other cross-tenant read exists.
11
11
  //
12
- // Sensitive field-values are ciphertext inside the event payload (the log
13
- // carries them encrypted); stripSensitive only strips the event echo. This
14
- // query returns payloads as stored, so a sensitive value surfaces as ciphertext,
15
- // never as plaintext.
12
+ // Event payloads hold PII as subject-KMS ciphertext; it is replaced by
13
+ // REDACTED_PII_PLACEHOLDER before returning, since the API leak guard refuses ciphertext.
16
14
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
17
15
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
18
16
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
19
17
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
20
18
  import * as z from "zod";
19
+ import { redactPiiCiphertext } from "../../shared/index.js";
21
20
  import { AUDIT_SCOPE_VALUES } from "../constants.js";
22
21
  import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
23
22
  const MAX_LIMIT = 100;
@@ -102,7 +101,7 @@ export const listQuery = defineQueryHandler({
102
101
  aggregateType: r.aggregateType,
103
102
  version: r.version,
104
103
  type: r.type,
105
- payload: r.payload,
104
+ payload: redactPiiCiphertext(r.payload),
106
105
  metadata: r.metadata,
107
106
  createdAt: r.createdAt,
108
107
  createdBy: r.createdBy,
@@ -135,6 +135,8 @@ export type CurrentUserProfile = {
135
135
  readonly email: string;
136
136
  readonly displayName: string;
137
137
  readonly locale?: string;
138
+ /** Effective IANA zone (user override, else tenant, else UTC). */
139
+ readonly effectiveTimeZone?: string;
138
140
  readonly globalRoles: readonly string[];
139
141
  };
140
142
  export declare function fetchCurrentUser(): Promise<CurrentUserProfile | null>;
@@ -295,6 +295,9 @@ export async function fetchCurrentUser() {
295
295
  email: body.data.email,
296
296
  displayName: body.data.displayName,
297
297
  ...(body.data.locale !== undefined && { locale: body.data.locale }),
298
+ ...(body.data.effectiveTimeZone !== undefined && {
299
+ effectiveTimeZone: body.data.effectiveTimeZone,
300
+ }),
298
301
  globalRoles: parseRoles(body.data.roles),
299
302
  };
300
303
  }
@@ -10,8 +10,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
10
10
  // unter `<SessionProvider>`; der `useSession()`-Hook liefert den State
11
11
  // und die Transitions.
12
12
  import { readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
13
- import { useSessionEndedSignal } from "@cosmicdrift/kumiko-renderer";
14
- import { createContext, useCallback, useContext, useEffect, useState } from "react";
13
+ import { CurrentUserProvider, useSessionEndedSignal, } from "@cosmicdrift/kumiko-renderer";
14
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
15
15
  import { AuthRequestError, fetchCurrentUser, fetchTenants, login as loginApi, logout as logoutApi, switchTenant as switchTenantApi, } from "./auth-client.js";
16
16
  // Exported so screens that render on BOTH authenticated and anonymous public
17
17
  // routes (e.g. InviteAcceptScreen) can read `.status` without requiring a
@@ -154,7 +154,23 @@ export function SessionProvider({ children, postLogoutUrl, }) {
154
154
  }
155
155
  }, []);
156
156
  const api = { ...state, login, logout, switchTenant, refresh: doRefresh };
157
- return _jsx(SessionContext.Provider, { value: api, children: children });
157
+ // Keyed on the primitives, not the profile object: every refresh (tenant
158
+ // switch, session bootstrap retry) builds a new profile object, and a new
159
+ // CurrentUser reference would re-seed a create form and drop typed input.
160
+ const profile = state.user;
161
+ const userId = profile?.id;
162
+ const userEmail = profile?.email;
163
+ const userDisplayName = profile?.displayName;
164
+ const userTimeZone = profile?.effectiveTimeZone;
165
+ const currentUser = useMemo(() => userId === undefined || userEmail === undefined || userDisplayName === undefined
166
+ ? undefined
167
+ : {
168
+ id: userId,
169
+ email: userEmail,
170
+ displayName: userDisplayName,
171
+ ...(userTimeZone !== undefined && { timeZone: userTimeZone }),
172
+ }, [userId, userEmail, userDisplayName, userTimeZone]);
173
+ return (_jsx(SessionContext.Provider, { value: api, children: _jsx(CurrentUserProvider, { user: currentUser, children: children }) }));
158
174
  }
159
175
  export function useSession() {
160
176
  const ctx = useContext(SessionContext);
@@ -1,4 +1,4 @@
1
- import type { AnonymousExtraRoute } from "@cosmicdrift/kumiko-framework/api";
1
+ import { type AnonymousExtraRoute } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { type PublicPageWrapLayout } from "../../page-render/index.js";
3
3
  import type { ConsentLocale } from "./consent-text.js";
4
4
  export type ContractTerminationRoutesOptions = {
@@ -1,5 +1,6 @@
1
1
  // Public § 312k pages (/legal/kuendigen, /legal/cancel): form → review → confirm,
2
2
  // no JavaScript. Every echoed value goes through escapeHtml/escapeHtmlAttr.
3
+ import { getSessionCookies } from "@cosmicdrift/kumiko-framework/api";
3
4
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
4
5
  import { getCookie } from "hono/cookie";
5
6
  import { redirectToCanonicalPath, securePageHeaders, slashVariantOf, wrapInLayout, } from "../../page-render/index.js";
@@ -181,7 +182,6 @@ function resultPage(ctx, receivedAtIso, requestId) {
181
182
  }
182
183
  const RATE_LIMITED_CODE = "rate_limited";
183
184
  const TENANT_HEADER = "x-tenant";
184
- const TENANT_COOKIE = "kumiko_tenant";
185
185
  // Exactly what the anonymous tenant resolution reads. The resolver and its
186
186
  // trust rules still decide; the re-entry only sees what a direct /api/write
187
187
  // from this client would see. Never the session cookie or Authorization (the
@@ -200,9 +200,10 @@ function reentryHeaders(c) {
200
200
  if (value !== undefined)
201
201
  headers.set(name, value);
202
202
  }
203
- const tenantCookie = getCookie(c, TENANT_COOKIE);
203
+ const tenantCookieName = getSessionCookies(c).names.tenant;
204
+ const tenantCookie = getCookie(c, tenantCookieName);
204
205
  if (tenantCookie !== undefined) {
205
- headers.set("cookie", `${TENANT_COOKIE}=${encodeURIComponent(tenantCookie)}`);
206
+ headers.set("cookie", `${tenantCookieName}=${encodeURIComponent(tenantCookie)}`);
206
207
  }
207
208
  return headers;
208
209
  }
@@ -7,5 +7,6 @@ export { capCounterEntity } from "./entity.js";
7
7
  export { capCounterFeature } from "./feature.js";
8
8
  export { readPayloadField } from "./payload-field.js";
9
9
  export { type CapGuardContext, type CapLimitContext, createStockCapGuard, type StockCapGuard, type StockCapSpec, } from "./stock-cap-guard.js";
10
+ export { capReservationsTable, capReservationsTableMeta } from "./tables.js";
10
11
  export { createValueCapGuard, type ValueCapGuard, type ValueCapSpec, } from "./value-cap-guard.js";
11
12
  export { type CalendarCapDef, type CalendarCapResolver, type RollingCapDef, type RollingCapResolver, withCapEnforcement, withRollingCapEnforcement, } from "./with-cap-enforcement.js";
@@ -8,5 +8,6 @@ export { capCounterEntity } from "./entity.js";
8
8
  export { capCounterFeature } from "./feature.js";
9
9
  export { readPayloadField } from "./payload-field.js";
10
10
  export { createStockCapGuard, } from "./stock-cap-guard.js";
11
+ export { capReservationsTable, capReservationsTableMeta } from "./tables.js";
11
12
  export { createValueCapGuard, } from "./value-cap-guard.js";
12
13
  export { withCapEnforcement, withRollingCapEnforcement, } from "./with-cap-enforcement.js";
@@ -39,7 +39,7 @@
39
39
  // einzelnen Page — sonst verstopfen erledigte Rows das batchLimit-Fenster.
40
40
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
41
41
  import { collectPiiSubjectFields, configuredPiiSubjectKms, isSelfPiiField, resolveSubjectForField, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
42
- import { createEventStoreExecutor, createTenantDb, nullBlindIndexesForSubject, } from "@cosmicdrift/kumiko-framework/db";
42
+ import { createEventStoreExecutor, createTenantDb, nullBlindIndexesForSubject, WritePurposes, } from "@cosmicdrift/kumiko-framework/db";
43
43
  import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
44
44
  import { createSystemUser, entityHasAnonymizableSubjectField, } from "@cosmicdrift/kumiko-framework/engine";
45
45
  import { fileRefEntity, fileRefsTable, } from "@cosmicdrift/kumiko-framework/files";
@@ -149,7 +149,7 @@ async function forgetRowAndEraseKeys(args) {
149
149
  }
150
150
  const NO_FILE_FIELDS = { singleFields: [], multiFields: [] };
151
151
  // file/image = single-column fields (fileRef-id lives directly in the entity
152
- // row); files/images = plural, no column, resolved via file_refs. Same set
152
+ // row); files/images = plural, resolved via file_refs. Same set
153
153
  // as boot-validator/entity-handler.ts FILE_FIELD_TYPES and engine's
154
154
  // isFileField — inlined here rather than importing across the package
155
155
  // barrel since this module already needs the single/multi split, not just
@@ -169,8 +169,8 @@ function planFileFields(fields) {
169
169
  : NO_FILE_FIELDS;
170
170
  }
171
171
  // Every fileRef-id a row's file/image/files/images-fields reference: single
172
- // fields hold the id in their own column, plural fields have no column and
173
- // resolve via file_refs (tenantId, entityType, entityId, fieldName).
172
+ // fields hold the id in their own column, plural fields resolve
173
+ // via file_refs (tenantId, entityType, entityId, fieldName).
174
174
  async function collectFileRefIds(db, tenantId, entityName, row, plan) {
175
175
  const ids = new Set();
176
176
  for (const field of plan.singleFields) {
@@ -536,7 +536,7 @@ export async function runRetentionCleanup(args) {
536
536
  break;
537
537
  }
538
538
  const executor = createEventStoreExecutor(proj.table, entity, { entityName });
539
- softDeleted += await purgeMatchingRows(db, proj.table, { ...where, isDeleted: false }, batchLimit, (id) => executor.delete({ id }, systemUser, tdb));
539
+ softDeleted += await purgeMatchingRows(db, proj.table, { ...where, isDeleted: false }, batchLimit, (id) => executor.delete({ id }, systemUser, tdb, { writePurpose: WritePurposes.retention }));
540
540
  break;
541
541
  }
542
542
  // blockDelete = Legal-Hold: rows stay untouched during keepFor (the
@@ -560,7 +560,10 @@ export async function runRetentionCleanup(args) {
560
560
  break;
561
561
  }
562
562
  const executor = createEventStoreExecutor(proj.table, entity, { entityName });
563
- anonymized += await anonymizeMatchingRows(db, proj.table, where, batchLimit, targets, (id, changes) => executor.update({ id, changes }, systemUser, tdb, { skipOptimisticLock: true }));
563
+ anonymized += await anonymizeMatchingRows(db, proj.table, where, batchLimit, targets, (id, changes) => executor.update({ id, changes }, systemUser, tdb, {
564
+ skipOptimisticLock: true,
565
+ writePurpose: WritePurposes.retention,
566
+ }));
564
567
  break;
565
568
  }
566
569
  }
@@ -3,6 +3,7 @@
3
3
  // consumers without the user-data-rights pipeline don't pull a hard dependency.
4
4
  // Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
5
5
  // (a folder name is tenant data, not per-user PII).
6
+ import { WritePurposes } from "@cosmicdrift/kumiko-framework/db";
6
7
  import { createEntityExecutor, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
7
8
  import { folderAssignmentEntity, folderEntity } from "../folders/index.js";
8
9
  const { table: folderTable, executor: folderExecutor } = createEntityExecutor("folder", folderEntity);
@@ -48,7 +49,9 @@ function tenantScopedDelete(table, executor) {
48
49
  const systemUser = createSystemUser(ctx.tenantId);
49
50
  const rows = await ctx.db.selectMany(table, { tenantId: ctx.tenantId });
50
51
  for (const row of rows) {
51
- await executor.delete({ id: row.id }, systemUser, ctx.db);
52
+ await executor.delete({ id: row.id }, systemUser, ctx.db, {
53
+ writePurpose: WritePurposes.erasure,
54
+ });
52
55
  }
53
56
  };
54
57
  }
@@ -2,6 +2,7 @@
2
2
  // (GDPR Art. 20 export / Art. 17 erasure). Lives apart from form-draft so
3
3
  // consumers without the user-data-rights pipeline don't pull a hard
4
4
  // dependency.
5
+ import { WritePurposes } from "@cosmicdrift/kumiko-framework/db";
5
6
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import { formDraftExecutor, formDraftTable } from "../form-draft/index.js";
7
8
  export const formDraftExportHook = async (ctx) => {
@@ -36,7 +37,9 @@ export const formDraftDeleteHook = async (ctx) => {
36
37
  const systemUser = createSystemUser(ctx.tenantId);
37
38
  const failures = [];
38
39
  for (const row of rows) {
39
- const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db);
40
+ const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db, {
41
+ writePurpose: WritePurposes.erasure,
42
+ });
40
43
  if (!result.isSuccess) {
41
44
  failures.push(`${row.id}: ${result.error.message}`);
42
45
  }
@@ -101,6 +101,8 @@ async function encryptStartedPayload(payload, triggeredById) {
101
101
  // pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
102
102
  // framework's other fixed advisory-lock keys.
103
103
  const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
104
+ // 'tjrl': serialises the finished-row write + prune per tenant run key.
105
+ const TENANT_JOB_RUN_LOCK_NAMESPACE = 0x746a726c;
104
106
  // fw#3079 — which row the tenant-visible failure record lives in: one per
105
107
  // (tenant, job, subject), so the next outcome of the same work replaces or
106
108
  // clears it. `subject` is null for a job that declares no subjectFields.
@@ -188,11 +190,18 @@ async function recordTenantRunOutcome(db, jobName, bullJobId, outcome, result) {
188
190
  // skip: retry pending, the row stays active until the final attempt
189
191
  return;
190
192
  }
191
- await upsertOnConflict(db, tenantJobRunsTable, { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now }, { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } });
192
- await deleteMany(db, tenantJobRunsTable, {
193
- ...key,
194
- status: { in: ["completed", "failed"] },
195
- bullJobId: { ne: bullJobId },
193
+ // One transaction: a reader never sees the new finished row next to the
194
+ // one it replaces. The per-key lock keeps two runs of the same subject that
195
+ // finish together from each missing the other's uncommitted row and leaving
196
+ // both behind.
197
+ await transaction(db, async (tx) => {
198
+ await acquireNamespacedAdvisoryLock(tx, TENANT_JOB_RUN_LOCK_NAMESPACE, JSON.stringify([key.tenantId, key.jobName, key.subject]));
199
+ await upsertOnConflict(tx, tenantJobRunsTable, { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now }, { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } });
200
+ await deleteMany(tx, tenantJobRunsTable, {
201
+ ...key,
202
+ status: { in: ["completed", "failed"] },
203
+ bullJobId: { ne: bullJobId },
204
+ });
196
205
  });
197
206
  }
198
207
  async function dropTenantRunQueued(db, bullJobId) {
@@ -0,0 +1,4 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type RenderRequest, type RenderResponse } from "../renderer-foundation/index.js";
3
+ export declare function renderTablePdfRequest(req: RenderRequest): Promise<RenderResponse>;
4
+ export declare function createRendererPdfFeature(): FeatureDefinition;
@@ -0,0 +1,32 @@
1
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { RENDERER_EXTENSION, RendererError, } from "../renderer-foundation/index.js";
3
+ import { pdfTableDocumentSchema, renderTablePdf } from "./render-table-pdf.js";
4
+ export async function renderTablePdfRequest(req) {
5
+ if (req.kind !== "document-pdf") {
6
+ throw new RendererError(`renderer-pdf supports only kind="document-pdf", got "${req.kind}"`, "invalid_payload");
7
+ }
8
+ const parsed = pdfTableDocumentSchema.safeParse(req.payload.variables);
9
+ if (!parsed.success) {
10
+ throw new RendererError(`renderer-pdf: payload.variables is not a table document: ${parsed.error.message}`, "invalid_payload");
11
+ }
12
+ const { pdfBytes, pageCount } = await renderTablePdf(parsed.data, {
13
+ ...(req.options?.format && { format: req.options.format }),
14
+ ...(req.options?.marginMm && { marginMm: req.options.marginMm }),
15
+ });
16
+ return { kind: "document-pdf", pdfBytes, pageCount, sizeBytes: pdfBytes.byteLength };
17
+ }
18
+ export function createRendererPdfFeature() {
19
+ return defineFeature("renderer-pdf", (r) => {
20
+ r.describe('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.');
21
+ r.uiHints({
22
+ displayLabel: "Renderer · PDF tables",
23
+ category: "notifications",
24
+ recommended: false,
25
+ });
26
+ r.requires("renderer-foundation");
27
+ r.useExtension(RENDERER_EXTENSION, "pdf", {
28
+ kinds: ["document-pdf"],
29
+ render: (req) => renderTablePdfRequest(req),
30
+ });
31
+ });
32
+ }
@@ -0,0 +1,2 @@
1
+ export { createRendererPdfFeature } from "./feature.js";
2
+ export { type PdfTableColumn, type PdfTableDocument, type PdfTableOptions, type PdfTableResult, pdfTableDocumentSchema, renderTablePdf, } from "./render-table-pdf.js";
@@ -0,0 +1,2 @@
1
+ export { createRendererPdfFeature } from "./feature.js";
2
+ export { pdfTableDocumentSchema, renderTablePdf, } from "./render-table-pdf.js";
@@ -0,0 +1,54 @@
1
+ import * as z from "zod";
2
+ export declare const pdfTableDocumentSchema: z.ZodObject<{
3
+ title: z.ZodString;
4
+ subtitle: z.ZodOptional<z.ZodString>;
5
+ meta: z.ZodOptional<z.ZodArray<z.ZodObject<{
6
+ label: z.ZodString;
7
+ value: z.ZodString;
8
+ }, z.core.$strip>>>;
9
+ columns: z.ZodArray<z.ZodObject<{
10
+ header: z.ZodString;
11
+ weight: z.ZodOptional<z.ZodNumber>;
12
+ align: z.ZodOptional<z.ZodEnum<{
13
+ left: "left";
14
+ right: "right";
15
+ }>>;
16
+ }, z.core.$strip>>;
17
+ rows: z.ZodArray<z.ZodArray<z.ZodString>>;
18
+ emptyText: z.ZodOptional<z.ZodString>;
19
+ footerText: z.ZodOptional<z.ZodString>;
20
+ pageLabel: z.ZodOptional<z.ZodString>;
21
+ }, z.core.$strip>;
22
+ export type PdfTableColumn = {
23
+ readonly header: string;
24
+ readonly weight?: number;
25
+ readonly align?: "left" | "right";
26
+ };
27
+ export type PdfTableDocument = {
28
+ readonly title: string;
29
+ readonly subtitle?: string;
30
+ readonly meta?: readonly {
31
+ readonly label: string;
32
+ readonly value: string;
33
+ }[];
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
+ export type PdfTableOptions = {
42
+ readonly format?: "A4" | "Letter";
43
+ readonly marginMm?: {
44
+ readonly top?: number;
45
+ readonly right?: number;
46
+ readonly bottom?: number;
47
+ readonly left?: number;
48
+ };
49
+ };
50
+ export type PdfTableResult = {
51
+ readonly pdfBytes: Uint8Array;
52
+ readonly pageCount: number;
53
+ };
54
+ export declare function renderTablePdf(doc: PdfTableDocument, options?: PdfTableOptions): Promise<PdfTableResult>;
@@ -0,0 +1,274 @@
1
+ import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
2
+ import * as z from "zod";
3
+ import { RendererError } from "../renderer-foundation/index.js";
4
+ export const pdfTableDocumentSchema = z.object({
5
+ title: z.string(),
6
+ subtitle: z.string().optional(),
7
+ meta: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
8
+ columns: z
9
+ .array(z.object({
10
+ header: z.string(),
11
+ weight: z.number().positive().optional(),
12
+ align: z.enum(["left", "right"]).optional(),
13
+ }))
14
+ .min(1),
15
+ rows: z.array(z.array(z.string())),
16
+ emptyText: z.string().optional(),
17
+ footerText: z.string().optional(),
18
+ pageLabel: z.string().optional(),
19
+ });
20
+ const POINTS_PER_MM = 72 / 25.4;
21
+ const PAGE_SIZE_POINTS = {
22
+ A4: { width: 595.28, height: 841.89 },
23
+ Letter: { width: 612, height: 792 },
24
+ };
25
+ const DEFAULT_MARGIN_MM = { top: 18, right: 15, bottom: 18, left: 15 };
26
+ const TITLE_SIZE = 16;
27
+ const SUBTITLE_SIZE = 11;
28
+ const BODY_SIZE = 9;
29
+ const FOOTER_SIZE = 8;
30
+ const LINE_HEIGHT_FACTOR = 1.3;
31
+ const CELL_PADDING_X = 4;
32
+ const CELL_PADDING_Y = 3;
33
+ const FOOTER_RESERVE = 16;
34
+ const META_LABEL_GAP = 10;
35
+ const BLOCK_GAP = 10;
36
+ const REPLACEMENT_CHAR = "?";
37
+ const ELLIPSIS = "...";
38
+ const TEXT_COLOR = rgb(0.1, 0.1, 0.1);
39
+ const MUTED_COLOR = rgb(0.4, 0.4, 0.4);
40
+ const HEADER_FILL = rgb(0.92, 0.93, 0.95);
41
+ const RULE_COLOR = rgb(0.8, 0.8, 0.82);
42
+ function lineHeightFor(size) {
43
+ return size * LINE_HEIGHT_FACTOR;
44
+ }
45
+ function createEncodableText(font) {
46
+ const supported = new Set(font.getCharacterSet());
47
+ return (text) => {
48
+ let result = "";
49
+ for (const char of text.replace(/\t/g, " ")) {
50
+ const codePoint = char.codePointAt(0) ?? 0;
51
+ result += codePoint === 0x0a || supported.has(codePoint) ? char : REPLACEMENT_CHAR;
52
+ }
53
+ return result;
54
+ };
55
+ }
56
+ function breakLongWord(word, font, size, maxWidth) {
57
+ const pieces = [];
58
+ let current = "";
59
+ for (const char of word) {
60
+ if (current !== "" && font.widthOfTextAtSize(current + char, size) > maxWidth) {
61
+ pieces.push(current);
62
+ current = char;
63
+ }
64
+ else {
65
+ current += char;
66
+ }
67
+ }
68
+ if (current !== "")
69
+ pieces.push(current);
70
+ return pieces;
71
+ }
72
+ function wrapText(text, font, size, maxWidth) {
73
+ const lines = [];
74
+ for (const paragraph of text.split(/\r\n|\n|\r/)) {
75
+ let current = "";
76
+ for (const word of paragraph.split(" ").filter((w) => w !== "")) {
77
+ const candidate = current === "" ? word : `${current} ${word}`;
78
+ if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
79
+ current = candidate;
80
+ continue;
81
+ }
82
+ if (current !== "")
83
+ lines.push(current);
84
+ const pieces = font.widthOfTextAtSize(word, size) <= maxWidth
85
+ ? [word]
86
+ : breakLongWord(word, font, size, maxWidth);
87
+ lines.push(...pieces.slice(0, -1));
88
+ current = pieces[pieces.length - 1] ?? "";
89
+ }
90
+ lines.push(current);
91
+ }
92
+ return lines;
93
+ }
94
+ function fitSingleLine(text, font, size, maxWidth) {
95
+ if (font.widthOfTextAtSize(text, size) <= maxWidth)
96
+ return text;
97
+ let fitted = text;
98
+ while (fitted !== "" && font.widthOfTextAtSize(fitted + ELLIPSIS, size) > maxWidth) {
99
+ fitted = fitted.slice(0, -1);
100
+ }
101
+ return fitted + ELLIPSIS;
102
+ }
103
+ function resolveColumnWidths(columns, totalWidth) {
104
+ const totalWeight = columns.reduce((sum, column) => sum + (column.weight ?? 1), 0);
105
+ return columns.map((column) => (totalWidth * (column.weight ?? 1)) / totalWeight);
106
+ }
107
+ function assertRowShape(doc) {
108
+ doc.rows.forEach((row, index) => {
109
+ if (row.length !== doc.columns.length) {
110
+ throw new RendererError(`row ${index} has ${row.length} cells but the table has ${doc.columns.length} columns`, "invalid_payload");
111
+ }
112
+ });
113
+ }
114
+ export async function renderTablePdf(doc, options) {
115
+ if (doc.columns.length === 0) {
116
+ throw new RendererError("table needs at least one column", "invalid_payload");
117
+ }
118
+ assertRowShape(doc);
119
+ const pdf = await PDFDocument.create({ updateMetadata: false });
120
+ const fonts = {
121
+ regular: await pdf.embedFont(StandardFonts.Helvetica),
122
+ bold: await pdf.embedFont(StandardFonts.HelveticaBold),
123
+ };
124
+ const clean = createEncodableText(fonts.regular);
125
+ const pageSize = PAGE_SIZE_POINTS[options?.format ?? "A4"];
126
+ const margin = {
127
+ top: (options?.marginMm?.top ?? DEFAULT_MARGIN_MM.top) * POINTS_PER_MM,
128
+ right: (options?.marginMm?.right ?? DEFAULT_MARGIN_MM.right) * POINTS_PER_MM,
129
+ bottom: (options?.marginMm?.bottom ?? DEFAULT_MARGIN_MM.bottom) * POINTS_PER_MM,
130
+ left: (options?.marginMm?.left ?? DEFAULT_MARGIN_MM.left) * POINTS_PER_MM,
131
+ };
132
+ const contentWidth = pageSize.width - margin.left - margin.right;
133
+ const contentBottom = margin.bottom + FOOTER_RESERVE;
134
+ const columnWidths = resolveColumnWidths(doc.columns, contentWidth);
135
+ const bodyLineHeight = lineHeightFor(BODY_SIZE);
136
+ const rowSpaceOnEmptyPage = pageSize.height - margin.top - contentBottom;
137
+ if (rowSpaceOnEmptyPage < 4 * bodyLineHeight) {
138
+ throw new RendererError("margins leave no room for table rows", "invalid_payload");
139
+ }
140
+ const headerLines = doc.columns.map((column, index) => wrapText(clean(column.header), fonts.bold, BODY_SIZE, (columnWidths[index] ?? contentWidth) - 2 * CELL_PADDING_X));
141
+ const headerHeight = Math.max(...headerLines.map((lines) => lines.length)) * bodyLineHeight + 2 * CELL_PADDING_Y;
142
+ let page = pdf.addPage([pageSize.width, pageSize.height]);
143
+ let cursorY = pageSize.height - margin.top;
144
+ function drawLines(lines, font, size, left, width, topY, align, color = TEXT_COLOR) {
145
+ lines.forEach((line, index) => {
146
+ const lineWidth = font.widthOfTextAtSize(line, size);
147
+ const x = align === "right" ? left + width - lineWidth : left;
148
+ page.drawText(line, {
149
+ x,
150
+ y: topY - size - index * lineHeightFor(size),
151
+ size,
152
+ font,
153
+ color,
154
+ });
155
+ });
156
+ }
157
+ function drawIntroBlock() {
158
+ const titleLines = wrapText(clean(doc.title), fonts.bold, TITLE_SIZE, contentWidth);
159
+ drawLines(titleLines, fonts.bold, TITLE_SIZE, margin.left, contentWidth, cursorY, "left");
160
+ cursorY -= titleLines.length * lineHeightFor(TITLE_SIZE);
161
+ if (doc.subtitle !== undefined) {
162
+ const subtitleLines = wrapText(clean(doc.subtitle), fonts.regular, SUBTITLE_SIZE, contentWidth);
163
+ drawLines(subtitleLines, fonts.regular, SUBTITLE_SIZE, margin.left, contentWidth, cursorY, "left", MUTED_COLOR);
164
+ cursorY -= subtitleLines.length * lineHeightFor(SUBTITLE_SIZE);
165
+ }
166
+ const meta = doc.meta ?? [];
167
+ if (meta.length > 0) {
168
+ cursorY -= BLOCK_GAP / 2;
169
+ const labels = meta.map((entry) => clean(entry.label));
170
+ const labelWidth = Math.max(...labels.map((label) => fonts.bold.widthOfTextAtSize(label, BODY_SIZE))) +
171
+ META_LABEL_GAP;
172
+ meta.forEach((entry, index) => {
173
+ const valueLines = wrapText(clean(entry.value), fonts.regular, BODY_SIZE, contentWidth - labelWidth);
174
+ drawLines([labels[index] ?? ""], fonts.bold, BODY_SIZE, margin.left, labelWidth, cursorY, "left");
175
+ drawLines(valueLines, fonts.regular, BODY_SIZE, margin.left + labelWidth, contentWidth - labelWidth, cursorY, "left");
176
+ cursorY -= valueLines.length * bodyLineHeight;
177
+ });
178
+ }
179
+ cursorY -= BLOCK_GAP;
180
+ }
181
+ function drawTableHeader() {
182
+ page.drawRectangle({
183
+ x: margin.left,
184
+ y: cursorY - headerHeight,
185
+ width: contentWidth,
186
+ height: headerHeight,
187
+ color: HEADER_FILL,
188
+ });
189
+ let x = margin.left;
190
+ doc.columns.forEach((column, index) => {
191
+ const width = columnWidths[index] ?? 0;
192
+ drawLines(headerLines[index] ?? [], fonts.bold, BODY_SIZE, x + CELL_PADDING_X, width - 2 * CELL_PADDING_X, cursorY - CELL_PADDING_Y, column.align ?? "left");
193
+ x += width;
194
+ });
195
+ cursorY -= headerHeight;
196
+ }
197
+ function startContinuationPage() {
198
+ page = pdf.addPage([pageSize.width, pageSize.height]);
199
+ cursorY = pageSize.height - margin.top;
200
+ drawTableHeader();
201
+ }
202
+ function drawRowSlice(cellLines, from, count) {
203
+ const height = count * bodyLineHeight + 2 * CELL_PADDING_Y;
204
+ let x = margin.left;
205
+ doc.columns.forEach((column, index) => {
206
+ const width = columnWidths[index] ?? 0;
207
+ drawLines((cellLines[index] ?? []).slice(from, from + count), fonts.regular, BODY_SIZE, x + CELL_PADDING_X, width - 2 * CELL_PADDING_X, cursorY - CELL_PADDING_Y, column.align ?? "left");
208
+ x += width;
209
+ });
210
+ page.drawLine({
211
+ start: { x: margin.left, y: cursorY - height },
212
+ end: { x: margin.left + contentWidth, y: cursorY - height },
213
+ thickness: 0.5,
214
+ color: RULE_COLOR,
215
+ });
216
+ cursorY -= height;
217
+ }
218
+ function drawRow(cellLines) {
219
+ const lineCount = Math.max(...cellLines.map((lines) => lines.length));
220
+ const rowHeight = lineCount * bodyLineHeight + 2 * CELL_PADDING_Y;
221
+ const emptyPageSpace = pageSize.height - margin.top - headerHeight - contentBottom;
222
+ if (rowHeight > cursorY - contentBottom && rowHeight <= emptyPageSpace) {
223
+ startContinuationPage();
224
+ }
225
+ let drawn = 0;
226
+ while (drawn < lineCount) {
227
+ const fitting = Math.floor((cursorY - contentBottom - 2 * CELL_PADDING_Y) / bodyLineHeight);
228
+ if (fitting < 1) {
229
+ startContinuationPage();
230
+ continue;
231
+ }
232
+ const count = Math.min(fitting, lineCount - drawn);
233
+ drawRowSlice(cellLines, drawn, count);
234
+ drawn += count;
235
+ }
236
+ }
237
+ drawIntroBlock();
238
+ drawTableHeader();
239
+ if (doc.rows.length === 0 && doc.emptyText !== undefined) {
240
+ drawRow([
241
+ wrapText(clean(doc.emptyText), fonts.regular, BODY_SIZE, contentWidth - 2 * CELL_PADDING_X),
242
+ ]);
243
+ }
244
+ for (const row of doc.rows) {
245
+ drawRow(row.map((cell, index) => wrapText(clean(cell), fonts.regular, BODY_SIZE, (columnWidths[index] ?? contentWidth) - 2 * CELL_PADDING_X)));
246
+ }
247
+ const pages = pdf.getPages();
248
+ const pageLabelTemplate = doc.pageLabel ?? "{page} / {pages}";
249
+ const footerBaseline = margin.bottom;
250
+ pages.forEach((footerPage, index) => {
251
+ const label = clean(pageLabelTemplate
252
+ .replaceAll("{page}", String(index + 1))
253
+ .replaceAll("{pages}", String(pages.length)));
254
+ const labelWidth = fonts.regular.widthOfTextAtSize(label, FOOTER_SIZE);
255
+ if (doc.footerText !== undefined) {
256
+ footerPage.drawText(fitSingleLine(clean(doc.footerText.replace(/\s+/g, " ")), fonts.regular, FOOTER_SIZE, contentWidth - labelWidth - 2 * CELL_PADDING_X), {
257
+ x: margin.left,
258
+ y: footerBaseline,
259
+ size: FOOTER_SIZE,
260
+ font: fonts.regular,
261
+ color: MUTED_COLOR,
262
+ });
263
+ }
264
+ footerPage.drawText(label, {
265
+ x: margin.left + contentWidth - labelWidth,
266
+ y: footerBaseline,
267
+ size: FOOTER_SIZE,
268
+ font: fonts.regular,
269
+ color: MUTED_COLOR,
270
+ });
271
+ });
272
+ const pdfBytes = await pdf.save();
273
+ return { pdfBytes, pageCount: pages.length };
274
+ }
@@ -1,5 +1,5 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
- import { collectPiiSubjectFields, configuredPiiSubjectKms, encryptPiiFieldValues, KeyErasedError, } from "@cosmicdrift/kumiko-framework/crypto";
2
+ import { collectPiiEncryptedFields, configuredPiiSubjectKms, encryptPiiFieldValues, KeyErasedError, } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { subjectErasedConflict } from "@cosmicdrift/kumiko-framework/errors";
4
4
  // Unmanaged direct-write stores (r.unmanagedTable) skip the executor, so its
5
5
  // PII encryption never runs — every insert of subject-annotated fields must
@@ -10,7 +10,7 @@ export async function encryptForDirectWrite(entity, entityName, row, fallbackReq
10
10
  if (!kms)
11
11
  return row;
12
12
  try {
13
- return await encryptPiiFieldValues(row, entity, collectPiiSubjectFields(entity), kms, { requestId: requestContext.get()?.requestId ?? fallbackRequestId }, { entityName });
13
+ return await encryptPiiFieldValues(row, entity, collectPiiEncryptedFields(entity), kms, { requestId: requestContext.get()?.requestId ?? fallbackRequestId }, { entityName });
14
14
  }
15
15
  catch (e) {
16
16
  if (e instanceof KeyErasedError)
@@ -14,6 +14,7 @@ export { denyUnlessJoinRowParentVisible, hostRowIsGone, joinRowParentIsVisible,
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
15
  export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
16
16
  export { redactBotTokens, redactEmailAddresses, redactErrorText, redactUrls, } from "./redact.js";
17
+ export { REDACTED_PII_PLACEHOLDER, redactPiiCiphertext } from "./redact-pii-ciphertext.js";
17
18
  export { requireForTenant } from "./require-for-tenant.js";
18
19
  export { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
19
20
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
@@ -14,6 +14,7 @@ export { denyUnlessJoinRowParentVisible, hostRowIsGone, joinRowParentIsVisible,
14
14
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
15
15
  export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
16
16
  export { redactBotTokens, redactEmailAddresses, redactErrorText, redactUrls, } from "./redact.js";
17
+ export { REDACTED_PII_PLACEHOLDER, redactPiiCiphertext } from "./redact-pii-ciphertext.js";
17
18
  export { requireForTenant } from "./require-for-tenant.js";
18
19
  export { redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
19
20
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
@@ -0,0 +1,2 @@
1
+ export declare const REDACTED_PII_PLACEHOLDER: "[[personal data]]";
2
+ export declare function redactPiiCiphertext(value: unknown): unknown;
@@ -0,0 +1,14 @@
1
+ import { isPiiCiphertext } from "@cosmicdrift/kumiko-framework/crypto";
2
+ export const REDACTED_PII_PLACEHOLDER = "[[personal data]]";
3
+ // Event payloads store PII fields as subject-KMS ciphertext; the API leak guard
4
+ // rejects any response carrying it, so payload reads swap it for a placeholder.
5
+ export function redactPiiCiphertext(value) {
6
+ if (isPiiCiphertext(value))
7
+ return REDACTED_PII_PLACEHOLDER;
8
+ if (Array.isArray(value))
9
+ return value.map(redactPiiCiphertext);
10
+ if (typeof value === "object" && value !== null) {
11
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, redactPiiCiphertext(inner)]));
12
+ }
13
+ return value;
14
+ }
@@ -1,6 +1,6 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
2
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createEventStoreExecutor, createTenantDb, WritePurposes, } from "@cosmicdrift/kumiko-framework/db";
4
4
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { append, loadAggregate, VersionConflictError, } from "@cosmicdrift/kumiko-framework/event-store";
6
6
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
@@ -95,7 +95,7 @@ async function markTenantDestroyFailed(db, tenantId) {
95
95
  await tenantCrud.update({
96
96
  id: tenantId,
97
97
  changes: { status: "destroyFailed" },
98
- }, user, scopedDb, { skipOptimisticLock: true });
98
+ }, user, scopedDb, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
99
99
  invalidateTenantLifecycleGate(tenantId);
100
100
  }
101
101
  async function haltPipelineOnAbandon(args) {
@@ -240,7 +240,7 @@ export async function runTenantDestructionSweep(args) {
240
240
  // tenant stream, so an unguarded overwrite here would silently revert the
241
241
  // cancellation. A version conflict means we lost that race — skip, the
242
242
  // tenant is no longer due and the next tick re-evaluates it.
243
- const result = await tenantCrud.update({ id: tenantId, version, changes: { status: "destroying", destroyStartedAt: now } }, user, scopedDb);
243
+ const result = await tenantCrud.update({ id: tenantId, version, changes: { status: "destroying", destroyStartedAt: now } }, user, scopedDb, { writePurpose: WritePurposes.erasure });
244
244
  if (!result.isSuccess)
245
245
  continue;
246
246
  invalidateTenantLifecycleGate(tenantId);
@@ -1,6 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { configuredPiiSubjectKms, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
3
- import { createEventStoreExecutor, createTenantDb, } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createEventStoreExecutor, createTenantDb, WritePurposes, } from "@cosmicdrift/kumiko-framework/db";
4
4
  import { createSystemUser, EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, extensionUsageEscapeHatchReason, isTenantDataExtensionHooks, isTenantResourceExtensionHooks, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
6
6
  import { createEscapeHatchReporter, UNATTRIBUTED_ACTOR, } from "@cosmicdrift/kumiko-framework/pipeline";
@@ -132,7 +132,7 @@ async function tombstoneTenantRow(ctx) {
132
132
  destroyedAt: now,
133
133
  isEnabled: false,
134
134
  },
135
- }, user, db, { skipOptimisticLock: true });
135
+ }, user, db, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
136
136
  invalidateTenantLifecycleGate(ctx.tenantId);
137
137
  return STAGE_DONE;
138
138
  }
@@ -1,2 +1,4 @@
1
1
  import * as z from "zod";
2
- export declare const meQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"user:me", z.ZodObject<{}, z.core.$strip>, Record<string, unknown> | null, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
2
+ export declare const meQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"user:me", z.ZodObject<{}, z.core.$strip>, {
3
+ effectiveTimeZone: string;
4
+ } | null, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -6,6 +6,7 @@ import { userEntity, userTable } from "../schema/user.js";
6
6
  const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
7
7
  // Returns the currently signed-in user's profile. Field-level read access
8
8
  // strips out the passwordHash automatically (configured on the entity).
9
+ // `effectiveTimeZone` is the effective IANA zone of the caller.
9
10
  export const meQuery = defineQueryHandler({
10
11
  name: "user:me",
11
12
  schema: z.object({}),
@@ -26,6 +27,9 @@ export const meQuery = defineQueryHandler({
26
27
  throw new InternalError({ message: "user:me requires r.systemScope()" });
27
28
  }
28
29
  const db = ctx.systemDb.acknowledgeCrossTenant("user rows are tenant-agnostic identity records; self-lookup by id needs no tenant filter");
29
- return crud.detail({ id: query.user.id }, query.user, db); // @wrapper-known semantic-alias
30
+ const row = await crud.detail({ id: query.user.id }, query.user, db);
31
+ // `timezone` on the row is only the stored override (often unset); clients
32
+ // need the effective zone (override, else tenant, else UTC) to compute "today".
33
+ return row === null ? row : { ...row, effectiveTimeZone: ctx.tz.user };
30
34
  },
31
35
  });
@@ -13,7 +13,7 @@ export declare const myAuditLogQuery: import("@cosmicdrift/kumiko-types/define-h
13
13
  aggregateType: string;
14
14
  version: number;
15
15
  type: string;
16
- payload: Record<string, unknown>;
16
+ payload: unknown;
17
17
  createdAt: unknown;
18
18
  }[];
19
19
  nextBefore: string | null;
@@ -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
  // DSGVO Art. 15 Selbstauskunft — User reads HIS OWN audit-log.
7
8
  // WHERE createdBy = ctx.user.id ist hard-coded (kein userId-Param,
8
9
  // anti-cross-user-Snooping). KEIN tenantId-Filter: User hat Anspruch
@@ -63,7 +64,7 @@ export const myAuditLogQuery = defineQueryHandler({
63
64
  aggregateType: r["aggregate_type"],
64
65
  version: r["version"],
65
66
  type: r["type"],
66
- payload: r["payload"],
67
+ payload: redactPiiCiphertext(r["payload"]),
67
68
  createdAt: r["created_at"],
68
69
  }));
69
70
  const last = serialised[serialised.length - 1];
@@ -1,4 +1,4 @@
1
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
1
+ import { createEventStoreExecutor, WritePurposes, } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
3
3
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
@@ -184,6 +184,7 @@ async function severPersonLink(tdb, systemUser, rows) {
184
184
  continue;
185
185
  await crud.update({ id, changes: { insertedById: null } }, systemUser, tdb, {
186
186
  skipOptimisticLock: true,
187
+ writePurpose: WritePurposes.erasure,
187
188
  });
188
189
  }
189
190
  }
@@ -1,4 +1,4 @@
1
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
1
+ import { createEventStoreExecutor, WritePurposes } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { collectErasureFailure, decryptStoredPii, mapWithConcurrency, throwIfErasureFailed, } from "../../shared/index.js";
4
4
  import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/index.js";
@@ -95,7 +95,7 @@ export const tenantInvitationDeleteHook = async (ctx, strategy) => {
95
95
  else {
96
96
  // Row-id in the pseudonym keeps the (tenantId, email) unique index
97
97
  // collision-free when a user has invitations in several states.
98
- await crud.update({ id, changes: { email: `forgotten-${id}@anonymized.invalid` } }, systemUser, ctx.db, { skipOptimisticLock: true });
98
+ await crud.update({ id, changes: { email: `forgotten-${id}@anonymized.invalid` } }, systemUser, ctx.db, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
99
99
  }
100
100
  }
101
101
  }
@@ -115,6 +115,7 @@ export const tenantInvitationDeleteHook = async (ctx, strategy) => {
115
115
  continue;
116
116
  await crud.update({ id, changes: { invitedBy: INVITED_BY_ANONYMIZED } }, systemUser, ctx.db, {
117
117
  skipOptimisticLock: true,
118
+ writePurpose: WritePurposes.erasure,
118
119
  });
119
120
  }
120
121
  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 { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { USER_ANONYMIZED_DISPLAY_NAME, USER_ANONYMIZED_EMAIL_DOMAIN, USER_ANONYMIZED_EMAIL_PREFIX, USER_DELETED_DISPLAY_NAME, USER_DELETED_EMAIL_PREFIX, USER_STATUS, userEntity, userTable, } from "../../user/index.js";
4
4
  // Forget writes go through the executor (events), not a raw UPDATE: a
@@ -72,7 +72,7 @@ export const userDeleteHook = async (ctx, strategy) => {
72
72
  // Frozen into the event payload, so a rebuild replays the same value.
73
73
  deletedAt: Temporal.Now.instant(),
74
74
  },
75
- }, systemUser, ctx.db, { skipOptimisticLock: true });
75
+ }, systemUser, ctx.db, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
76
76
  }
77
77
  else {
78
78
  // anonymize: PII raus, aber Row bleibt active (damit FK-References
@@ -86,6 +86,6 @@ export const userDeleteHook = async (ctx, strategy) => {
86
86
  email: `${USER_ANONYMIZED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
87
87
  displayName: USER_ANONYMIZED_DISPLAY_NAME,
88
88
  },
89
- }, systemUser, ctx.db, { skipOptimisticLock: true });
89
+ }, systemUser, ctx.db, { skipOptimisticLock: true, writePurpose: WritePurposes.erasure });
90
90
  }
91
91
  };
@@ -1,2 +1,7 @@
1
1
  import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- export declare function createUserProfileFeature(): FeatureDefinition;
2
+ export type UserProfileFeatureOptions = {
3
+ /** Default true. false drops the change-email handler and the email
4
+ * section — for apps whose login email is managed elsewhere (SSO, admin-set). */
5
+ readonly allowEmailChange?: boolean;
6
+ };
7
+ export declare function createUserProfileFeature(options?: UserProfileFeatureOptions): FeatureDefinition;
@@ -1,12 +1,14 @@
1
1
  import { defineFeature, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { CHANGE_EMAIL_SECTION_EXTENSION_NAME, CHANGE_PASSWORD_SECTION_EXTENSION_NAME, PROFILE_SCREEN_ID, UserDataRightsHandlers, UserProfileQueries, } from "./constants.js";
3
3
  import { changeEmailWrite } from "./handlers/change-email.write.js";
4
- export function createUserProfileFeature() {
4
+ export function createUserProfileFeature(options) {
5
+ const allowEmailChange = options?.allowEmailChange ?? true;
5
6
  return defineFeature("user-profile", (r) => {
6
7
  r.describe("Self-service account page (fw#2312: declarative `projectionDetail` bound to " +
7
8
  "the signed-in user's own `me` row) plus its `change-email` write handler " +
8
9
  "(re-auth via current password, uniqueness check, resets emailVerified and " +
9
- "expects the app to trigger the verification flow). Change-password and " +
10
+ "expects the app to trigger the verification flow; opt out with " +
11
+ "`allowEmailChange: false`, which drops handler and section). Change-password and " +
10
12
  "change-email stay custom `EditExtensionSection` components (re-auth flows a " +
11
13
  "declarative action can't express); account deletion (user-data-rights " +
12
14
  "request/cancel with grace period) is declarative fields + actions. Apps " +
@@ -24,9 +26,7 @@ export function createUserProfileFeature() {
24
26
  r.requires("auth-email-password");
25
27
  r.requires("user-data-rights");
26
28
  r.requires("user-data-rights-defaults");
27
- const handlers = {
28
- changeEmail: r.writeHandler(changeEmailWrite),
29
- };
29
+ const handlers = allowEmailChange ? { changeEmail: r.writeHandler(changeEmailWrite) } : {};
30
30
  // Boot-validator requires a "screen:<id>.title" key per r.screen(), plus
31
31
  // one entry per i18nKey()-marked dot-form label below — kept inline (not
32
32
  // imported from ./i18n) so feature.ts, part of the server barrel, stays
@@ -83,12 +83,16 @@ export function createUserProfileFeature() {
83
83
  },
84
84
  layout: {
85
85
  sections: [
86
- {
87
- kind: "extension",
88
- title: i18nKey("profile.email.title"),
89
- component: { react: { __component: CHANGE_EMAIL_SECTION_EXTENSION_NAME } },
90
- entityName: "user",
91
- },
86
+ ...(allowEmailChange
87
+ ? [
88
+ {
89
+ kind: "extension",
90
+ title: i18nKey("profile.email.title"),
91
+ component: { react: { __component: CHANGE_EMAIL_SECTION_EXTENSION_NAME } },
92
+ entityName: "user",
93
+ },
94
+ ]
95
+ : []),
92
96
  {
93
97
  kind: "extension",
94
98
  title: i18nKey("profile.password.title"),
@@ -1,2 +1,2 @@
1
1
  export { USER_PROFILE_FEATURE, UserDataRightsHandlers, UserProfileErrors, UserProfileHandlers, UserProfileQueries, } from "./constants.js";
2
- export { createUserProfileFeature } from "./feature.js";
2
+ export { createUserProfileFeature, type UserProfileFeatureOptions } from "./feature.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.359.0",
3
+ "version": "0.361.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>",
@@ -398,6 +398,10 @@
398
398
  "types": "./dist/renderer-simple/index.d.ts",
399
399
  "default": "./dist/renderer-simple/index.js"
400
400
  },
401
+ "./renderer-pdf": {
402
+ "types": "./dist/renderer-pdf/index.d.ts",
403
+ "default": "./dist/renderer-pdf/index.js"
404
+ },
401
405
  "./files-provider-s3": {
402
406
  "types": "./dist/files-provider-s3/index.d.ts",
403
407
  "default": "./dist/files-provider-s3/index.js"
@@ -516,12 +520,12 @@
516
520
  }
517
521
  },
518
522
  "dependencies": {
519
- "@cosmicdrift/kumiko-dispatcher-live": "0.359.0",
520
- "@cosmicdrift/kumiko-framework": "0.359.0",
521
- "@cosmicdrift/kumiko-headless": "0.359.0",
522
- "@cosmicdrift/kumiko-renderer": "0.359.0",
523
- "@cosmicdrift/kumiko-renderer-web": "0.359.0",
524
- "@cosmicdrift/kumiko-types": "0.359.0",
523
+ "@cosmicdrift/kumiko-dispatcher-live": "0.361.0",
524
+ "@cosmicdrift/kumiko-framework": "0.361.0",
525
+ "@cosmicdrift/kumiko-headless": "0.361.0",
526
+ "@cosmicdrift/kumiko-renderer": "0.361.0",
527
+ "@cosmicdrift/kumiko-renderer-web": "0.361.0",
528
+ "@cosmicdrift/kumiko-types": "0.361.0",
525
529
  "@mollie/api-client": "^4.5.0",
526
530
  "@node-rs/argon2": "^2.0.2",
527
531
  "@types/mailparser": "^3.4.6",
@@ -535,6 +539,7 @@
535
539
  "mailparser": "^3.9.32",
536
540
  "marked": "^18.0.3",
537
541
  "nodemailer": "^10.0.13",
542
+ "pdf-lib": "^1.17.1",
538
543
  "qrcode": "^1.5.4",
539
544
  "react": "^19.2.6",
540
545
  "sharp": "^0.35.5",
@@ -924,6 +929,10 @@
924
929
  "types": "./dist/renderer-simple/index.d.ts",
925
930
  "default": "./dist/renderer-simple/index.js"
926
931
  },
932
+ "./renderer-pdf": {
933
+ "types": "./dist/renderer-pdf/index.d.ts",
934
+ "default": "./dist/renderer-pdf/index.js"
935
+ },
927
936
  "./files-provider-s3": {
928
937
  "types": "./dist/files-provider-s3/index.d.ts",
929
938
  "default": "./dist/files-provider-s3/index.js"
@@ -1050,8 +1059,9 @@
1050
1059
  ],
1051
1060
  "devDependencies": {
1052
1061
  "@testing-library/user-event": "^14.6.1",
1053
- "@cosmicdrift/kumiko-locale-de": "0.359.0",
1054
- "@cosmicdrift/kumiko-locale-es": "0.359.0",
1055
- "jsqr": "^1.4.0"
1062
+ "@cosmicdrift/kumiko-locale-de": "0.361.0",
1063
+ "@cosmicdrift/kumiko-locale-es": "0.361.0",
1064
+ "jsqr": "^1.4.0",
1065
+ "unpdf": "^1.8.1"
1056
1066
  }
1057
1067
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "fix",
5
+ "title": "Audit log works under an active subject KMS",
6
+ "detail": "audit list, audit details and user-data-rights my-audit-log returned event payloads as stored. With a subject KMS configured the payload carried kumiko-pii ciphertext and the API leak guard answered 500 pii_ciphertext_leak. Every PII ciphertext string in the payload is now replaced by REDACTED_PII_PLACEHOLDER (\"[[personal data]]\"); the audit log still never shows plaintext PII."
7
+ },
2
8
  {
3
9
  "version": "0.353.0",
4
10
  "type": "fix",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "improvement",
5
+ "title": "cap-counter exports capReservationsTable and capReservationsTableMeta",
6
+ "detail": "The reservation table store_cap_reservations was only reachable through internal imports, so kumiko-enterprise redeclared it in a test. The cap-counter entry now exports capReservationsTable and capReservationsTableMeta, following the feature-toggles pattern."
7
+ },
2
8
  {
3
9
  "version": "0.358.0",
4
10
  "type": "improvement",
@@ -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",
@@ -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
+ ]
@@ -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",
@@ -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",