@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.
- package/dist/audit/handlers/details.query.d.ts +1 -1
- package/dist/audit/handlers/details.query.js +2 -1
- package/dist/audit/handlers/list.query.d.ts +1 -1
- package/dist/audit/handlers/list.query.js +4 -5
- package/dist/auth-email-password/web/auth-client.d.ts +2 -0
- package/dist/auth-email-password/web/auth-client.js +3 -0
- package/dist/auth-email-password/web/session.js +19 -3
- package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +1 -1
- package/dist/billing-foundation/consumer-protection/termination-pages.js +4 -3
- package/dist/cap-counter/index.d.ts +1 -0
- package/dist/cap-counter/index.js +1 -0
- package/dist/data-retention/run-retention-cleanup.js +9 -6
- package/dist/folders-user-data/hooks.js +4 -1
- package/dist/form-draft-user-data/hooks.js +4 -1
- package/dist/jobs/job-run-logger.js +14 -5
- package/dist/renderer-pdf/feature.d.ts +4 -0
- package/dist/renderer-pdf/feature.js +32 -0
- package/dist/renderer-pdf/index.d.ts +2 -0
- package/dist/renderer-pdf/index.js +2 -0
- package/dist/renderer-pdf/render-table-pdf.d.ts +54 -0
- package/dist/renderer-pdf/render-table-pdf.js +274 -0
- package/dist/shared/encrypt-for-direct-write.js +2 -2
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.js +1 -0
- package/dist/shared/redact-pii-ciphertext.d.ts +2 -0
- package/dist/shared/redact-pii-ciphertext.js +14 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +3 -3
- package/dist/tenant-lifecycle/stages.js +2 -2
- package/dist/user/handlers/me.query.d.ts +3 -1
- package/dist/user/handlers/me.query.js +5 -1
- package/dist/user-data-rights/handlers/my-audit-log.query.d.ts +1 -1
- package/dist/user-data-rights/handlers/my-audit-log.query.js +2 -1
- package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +2 -1
- package/dist/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.js +3 -2
- package/dist/user-data-rights-defaults/hooks/user.userdata-hook.js +3 -3
- package/dist/user-profile/feature.d.ts +6 -1
- package/dist/user-profile/feature.js +15 -11
- package/dist/user-profile/index.d.ts +1 -1
- package/package.json +20 -10
- package/src/audit/changes.json +6 -0
- package/src/cap-counter/changes.json +6 -0
- package/src/jobs/changes.json +6 -0
- package/src/renderer-pdf/changes.json +8 -0
- package/src/user/changes.json +6 -0
- 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:
|
|
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:
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
203
|
+
const tenantCookieName = getSessionCookies(c).names.tenant;
|
|
204
|
+
const tenantCookie = getCookie(c, tenantCookieName);
|
|
204
205
|
if (tenantCookie !== undefined) {
|
|
205
|
-
headers.set("cookie", `${
|
|
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,
|
|
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
|
|
173
|
-
//
|
|
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, {
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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,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 {
|
|
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,
|
|
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)
|
package/dist/shared/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/shared/index.js
CHANGED
|
@@ -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,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>,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
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.
|
|
520
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
521
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
522
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
523
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
524
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
1054
|
-
"@cosmicdrift/kumiko-locale-es": "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
|
}
|
package/src/audit/changes.json
CHANGED
|
@@ -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",
|
package/src/jobs/changes.json
CHANGED
|
@@ -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
|
+
]
|
package/src/user/changes.json
CHANGED
|
@@ -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",
|