@cosmicdrift/kumiko-samples 0.363.0 → 0.364.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +7 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
- package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/changes.json +6 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
- package/packages/bundled-features/src/config/resolver.ts +15 -12
- package/packages/bundled-features/src/config/write-helpers.ts +23 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
- package/packages/bundled-features/src/delivery/changes.json +18 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +51 -14
- package/packages/bundled-features/src/delivery/types.ts +7 -1
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
- package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
- package/packages/bundled-features/src/jobs/changes.json +19 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
- package/packages/bundled-features/src/jobs/feature.ts +7 -4
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
- package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
- package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
- package/packages/bundled-features/src/ledger/changes.json +7 -0
- package/packages/bundled-features/src/ledger/entity.ts +3 -1
- package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
- package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
- package/packages/bundled-features/src/template-resolver/README.md +4 -2
- package/packages/bundled-features/src/template-resolver/api.ts +3 -2
- package/packages/bundled-features/src/template-resolver/changes.json +68 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
- package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
- package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
- package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
- package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
- package/packages/bundled-features/src/template-resolver/index.ts +12 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
- package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
- package/packages/bundled-features/src/template-resolver/table.ts +13 -1
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
- package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
- package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
- package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
- package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
- package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
- package/packages/bundled-features/src/user/seeding.ts +19 -5
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/waitlist/options.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
- package/samples/apps/ui-walkthrough/package.json +9 -0
- package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
|
@@ -1,52 +1,56 @@
|
|
|
1
1
|
import { PDFDocument, type PDFFont, type PDFPage, rgb, StandardFonts } from "pdf-lib";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { RendererError } from "../renderer-foundation/index.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
readonly
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
4
|
+
import type { PdfPageFormat } from "../template-resolver/index.js";
|
|
5
|
+
|
|
6
|
+
// Upper bounds keep a hostile or runaway payload from exhausting memory and CPU in layout.
|
|
7
|
+
const PDF_TABLE_MAX_ROWS = 10_000;
|
|
8
|
+
const PDF_TABLE_MAX_COLUMNS = 30;
|
|
9
|
+
const PDF_TABLE_MAX_CELL_LENGTH = 5_000;
|
|
10
|
+
|
|
11
|
+
const pdfTableColumnSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
header: z.string(),
|
|
14
|
+
weight: z.number().positive().optional(),
|
|
15
|
+
align: z.enum(["left", "right"]).optional(),
|
|
16
|
+
})
|
|
17
|
+
.readonly();
|
|
18
|
+
|
|
19
|
+
export const pdfTableDocumentSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
title: z.string(),
|
|
22
|
+
subtitle: z.string().optional(),
|
|
23
|
+
meta: z
|
|
24
|
+
.array(z.object({ label: z.string(), value: z.string() }).readonly())
|
|
25
|
+
.readonly()
|
|
26
|
+
.optional(),
|
|
27
|
+
columns: z.array(pdfTableColumnSchema).min(1).max(PDF_TABLE_MAX_COLUMNS).readonly(),
|
|
28
|
+
rows: z
|
|
29
|
+
.array(z.array(z.string().max(PDF_TABLE_MAX_CELL_LENGTH)).readonly())
|
|
30
|
+
.max(PDF_TABLE_MAX_ROWS)
|
|
31
|
+
.readonly(),
|
|
32
|
+
emptyText: z.string().optional(),
|
|
33
|
+
footerText: z.string().optional(),
|
|
34
|
+
// Template with `{page}` and `{pages}`; default `"{page} / {pages}"`.
|
|
35
|
+
pageLabel: z.string().optional(),
|
|
36
|
+
})
|
|
37
|
+
.readonly();
|
|
38
|
+
|
|
39
|
+
export type PdfTableColumn = z.infer<typeof pdfTableColumnSchema>;
|
|
40
|
+
export type PdfTableDocument = z.infer<typeof pdfTableDocumentSchema>;
|
|
41
|
+
|
|
42
|
+
const marginMmSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
top: z.number().nonnegative().optional(),
|
|
45
|
+
right: z.number().nonnegative().optional(),
|
|
46
|
+
bottom: z.number().nonnegative().optional(),
|
|
47
|
+
left: z.number().nonnegative().optional(),
|
|
48
|
+
})
|
|
49
|
+
.readonly();
|
|
41
50
|
|
|
42
51
|
export type PdfTableOptions = {
|
|
43
|
-
readonly format?:
|
|
44
|
-
readonly marginMm?:
|
|
45
|
-
readonly top?: number;
|
|
46
|
-
readonly right?: number;
|
|
47
|
-
readonly bottom?: number;
|
|
48
|
-
readonly left?: number;
|
|
49
|
-
};
|
|
52
|
+
readonly format?: PdfPageFormat;
|
|
53
|
+
readonly marginMm?: z.input<typeof marginMmSchema>;
|
|
50
54
|
};
|
|
51
55
|
|
|
52
56
|
export type PdfTableResult = { readonly pdfBytes: Uint8Array; readonly pageCount: number };
|
|
@@ -55,7 +59,7 @@ const POINTS_PER_MM = 72 / 25.4;
|
|
|
55
59
|
const PAGE_SIZE_POINTS = {
|
|
56
60
|
A4: { width: 595.28, height: 841.89 },
|
|
57
61
|
Letter: { width: 612, height: 792 },
|
|
58
|
-
} as const;
|
|
62
|
+
} as const satisfies Record<PdfPageFormat, { readonly width: number; readonly height: number }>;
|
|
59
63
|
const DEFAULT_MARGIN_MM = { top: 18, right: 15, bottom: 18, left: 15 } as const;
|
|
60
64
|
|
|
61
65
|
const TITLE_SIZE = 16;
|
|
@@ -66,6 +70,7 @@ const LINE_HEIGHT_FACTOR = 1.3;
|
|
|
66
70
|
const CELL_PADDING_X = 4;
|
|
67
71
|
const CELL_PADDING_Y = 3;
|
|
68
72
|
const FOOTER_RESERVE = 16;
|
|
73
|
+
const MIN_COLUMN_WIDTH = 20;
|
|
69
74
|
const META_LABEL_GAP = 10;
|
|
70
75
|
const BLOCK_GAP = 10;
|
|
71
76
|
const REPLACEMENT_CHAR = "?";
|
|
@@ -86,7 +91,7 @@ function createEncodableText(font: PDFFont): (text: string) => string {
|
|
|
86
91
|
const supported = new Set(font.getCharacterSet());
|
|
87
92
|
return (text) => {
|
|
88
93
|
let result = "";
|
|
89
|
-
for (const char of text.replace(/\t/g, " ")) {
|
|
94
|
+
for (const char of text.replace(/\r\n?/g, "\n").replace(/\t/g, " ")) {
|
|
90
95
|
const codePoint = char.codePointAt(0) ?? 0;
|
|
91
96
|
result += codePoint === 0x0a || supported.has(codePoint) ? char : REPLACEMENT_CHAR;
|
|
92
97
|
}
|
|
@@ -157,30 +162,43 @@ function assertRowShape(doc: PdfTableDocument): void {
|
|
|
157
162
|
});
|
|
158
163
|
}
|
|
159
164
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
assertRowShape(doc);
|
|
168
|
-
|
|
169
|
-
const pdf = await PDFDocument.create({ updateMetadata: false });
|
|
170
|
-
const fonts: Fonts = {
|
|
171
|
-
regular: await pdf.embedFont(StandardFonts.Helvetica),
|
|
172
|
-
bold: await pdf.embedFont(StandardFonts.HelveticaBold),
|
|
165
|
+
type TableLayout = {
|
|
166
|
+
readonly pageSize: { readonly width: number; readonly height: number };
|
|
167
|
+
readonly margin: {
|
|
168
|
+
readonly top: number;
|
|
169
|
+
readonly right: number;
|
|
170
|
+
readonly bottom: number;
|
|
171
|
+
readonly left: number;
|
|
173
172
|
};
|
|
174
|
-
|
|
173
|
+
readonly contentWidth: number;
|
|
174
|
+
readonly contentBottom: number;
|
|
175
|
+
readonly columnWidths: readonly number[];
|
|
176
|
+
readonly bodyLineHeight: number;
|
|
177
|
+
readonly headerLines: readonly (readonly string[])[];
|
|
178
|
+
readonly headerHeight: number;
|
|
179
|
+
};
|
|
175
180
|
|
|
181
|
+
function resolveTableLayout(
|
|
182
|
+
doc: PdfTableDocument,
|
|
183
|
+
options: PdfTableOptions | undefined,
|
|
184
|
+
fonts: Fonts,
|
|
185
|
+
clean: (text: string) => string,
|
|
186
|
+
): TableLayout {
|
|
187
|
+
const marginMm = marginMmSchema.safeParse(options?.marginMm ?? {});
|
|
188
|
+
if (!marginMm.success) {
|
|
189
|
+
throw new RendererError("margins must be non-negative numbers", "invalid_payload");
|
|
190
|
+
}
|
|
176
191
|
const pageSize = PAGE_SIZE_POINTS[options?.format ?? "A4"];
|
|
177
192
|
const margin = {
|
|
178
|
-
top: (
|
|
179
|
-
right: (
|
|
180
|
-
bottom: (
|
|
181
|
-
left: (
|
|
193
|
+
top: (marginMm.data.top ?? DEFAULT_MARGIN_MM.top) * POINTS_PER_MM,
|
|
194
|
+
right: (marginMm.data.right ?? DEFAULT_MARGIN_MM.right) * POINTS_PER_MM,
|
|
195
|
+
bottom: (marginMm.data.bottom ?? DEFAULT_MARGIN_MM.bottom) * POINTS_PER_MM,
|
|
196
|
+
left: (marginMm.data.left ?? DEFAULT_MARGIN_MM.left) * POINTS_PER_MM,
|
|
182
197
|
};
|
|
183
198
|
const contentWidth = pageSize.width - margin.left - margin.right;
|
|
199
|
+
if (contentWidth < MIN_COLUMN_WIDTH * doc.columns.length) {
|
|
200
|
+
throw new RendererError("margins leave no room for table columns", "invalid_payload");
|
|
201
|
+
}
|
|
184
202
|
const contentBottom = margin.bottom + FOOTER_RESERVE;
|
|
185
203
|
const columnWidths = resolveColumnWidths(doc.columns, contentWidth);
|
|
186
204
|
const bodyLineHeight = lineHeightFor(BODY_SIZE);
|
|
@@ -200,6 +218,48 @@ export async function renderTablePdf(
|
|
|
200
218
|
);
|
|
201
219
|
const headerHeight =
|
|
202
220
|
Math.max(...headerLines.map((lines) => lines.length)) * bodyLineHeight + 2 * CELL_PADDING_Y;
|
|
221
|
+
// A header this tall would leave every continuation page without room for a row.
|
|
222
|
+
if (rowSpaceOnEmptyPage - headerHeight < bodyLineHeight + 2 * CELL_PADDING_Y) {
|
|
223
|
+
throw new RendererError("table header leaves no room for rows", "invalid_payload");
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
pageSize,
|
|
227
|
+
margin,
|
|
228
|
+
contentWidth,
|
|
229
|
+
contentBottom,
|
|
230
|
+
columnWidths,
|
|
231
|
+
bodyLineHeight,
|
|
232
|
+
headerLines,
|
|
233
|
+
headerHeight,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function renderTablePdf(
|
|
238
|
+
doc: PdfTableDocument,
|
|
239
|
+
options?: PdfTableOptions,
|
|
240
|
+
): Promise<PdfTableResult> {
|
|
241
|
+
if (doc.columns.length === 0) {
|
|
242
|
+
throw new RendererError("table needs at least one column", "invalid_payload");
|
|
243
|
+
}
|
|
244
|
+
assertRowShape(doc);
|
|
245
|
+
|
|
246
|
+
const pdf = await PDFDocument.create({ updateMetadata: false });
|
|
247
|
+
const fonts: Fonts = {
|
|
248
|
+
regular: await pdf.embedFont(StandardFonts.Helvetica),
|
|
249
|
+
bold: await pdf.embedFont(StandardFonts.HelveticaBold),
|
|
250
|
+
};
|
|
251
|
+
const clean = createEncodableText(fonts.regular);
|
|
252
|
+
|
|
253
|
+
const {
|
|
254
|
+
pageSize,
|
|
255
|
+
margin,
|
|
256
|
+
contentWidth,
|
|
257
|
+
contentBottom,
|
|
258
|
+
columnWidths,
|
|
259
|
+
bodyLineHeight,
|
|
260
|
+
headerLines,
|
|
261
|
+
headerHeight,
|
|
262
|
+
} = resolveTableLayout(doc, options, fonts, clean);
|
|
203
263
|
|
|
204
264
|
let page: PDFPage = pdf.addPage([pageSize.width, pageSize.height]);
|
|
205
265
|
let cursorY = pageSize.height - margin.top;
|
|
@@ -227,29 +287,50 @@ export async function renderTablePdf(
|
|
|
227
287
|
});
|
|
228
288
|
}
|
|
229
289
|
|
|
290
|
+
function startBlankPage(): void {
|
|
291
|
+
page = pdf.addPage([pageSize.width, pageSize.height]);
|
|
292
|
+
cursorY = pageSize.height - margin.top;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function ensureRoom(height: number): void {
|
|
296
|
+
if (cursorY - height < contentBottom) startBlankPage();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Line by line, so a block taller than one page continues on the next.
|
|
300
|
+
function flowLines(
|
|
301
|
+
lines: readonly string[],
|
|
302
|
+
font: PDFFont,
|
|
303
|
+
size: number,
|
|
304
|
+
left: number,
|
|
305
|
+
width: number,
|
|
306
|
+
color = TEXT_COLOR,
|
|
307
|
+
): void {
|
|
308
|
+
const lineHeight = lineHeightFor(size);
|
|
309
|
+
for (const line of lines) {
|
|
310
|
+
ensureRoom(lineHeight);
|
|
311
|
+
drawLines([line], font, size, left, width, cursorY, "left", color);
|
|
312
|
+
cursorY -= lineHeight;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
230
316
|
function drawIntroBlock(): void {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
317
|
+
flowLines(
|
|
318
|
+
wrapText(clean(doc.title), fonts.bold, TITLE_SIZE, contentWidth),
|
|
319
|
+
fonts.bold,
|
|
320
|
+
TITLE_SIZE,
|
|
321
|
+
margin.left,
|
|
322
|
+
contentWidth,
|
|
323
|
+
);
|
|
234
324
|
|
|
235
325
|
if (doc.subtitle !== undefined) {
|
|
236
|
-
|
|
237
|
-
clean(doc.subtitle),
|
|
238
|
-
fonts.regular,
|
|
239
|
-
SUBTITLE_SIZE,
|
|
240
|
-
contentWidth,
|
|
241
|
-
);
|
|
242
|
-
drawLines(
|
|
243
|
-
subtitleLines,
|
|
326
|
+
flowLines(
|
|
327
|
+
wrapText(clean(doc.subtitle), fonts.regular, SUBTITLE_SIZE, contentWidth),
|
|
244
328
|
fonts.regular,
|
|
245
329
|
SUBTITLE_SIZE,
|
|
246
330
|
margin.left,
|
|
247
331
|
contentWidth,
|
|
248
|
-
cursorY,
|
|
249
|
-
"left",
|
|
250
332
|
MUTED_COLOR,
|
|
251
333
|
);
|
|
252
|
-
cursorY -= subtitleLines.length * lineHeightFor(SUBTITLE_SIZE);
|
|
253
334
|
}
|
|
254
335
|
|
|
255
336
|
const meta = doc.meta ?? [];
|
|
@@ -266,6 +347,7 @@ export async function renderTablePdf(
|
|
|
266
347
|
BODY_SIZE,
|
|
267
348
|
contentWidth - labelWidth,
|
|
268
349
|
);
|
|
350
|
+
ensureRoom(bodyLineHeight);
|
|
269
351
|
drawLines(
|
|
270
352
|
[labels[index] ?? ""],
|
|
271
353
|
fonts.bold,
|
|
@@ -275,16 +357,13 @@ export async function renderTablePdf(
|
|
|
275
357
|
cursorY,
|
|
276
358
|
"left",
|
|
277
359
|
);
|
|
278
|
-
|
|
360
|
+
flowLines(
|
|
279
361
|
valueLines,
|
|
280
362
|
fonts.regular,
|
|
281
363
|
BODY_SIZE,
|
|
282
364
|
margin.left + labelWidth,
|
|
283
365
|
contentWidth - labelWidth,
|
|
284
|
-
cursorY,
|
|
285
|
-
"left",
|
|
286
366
|
);
|
|
287
|
-
cursorY -= valueLines.length * bodyLineHeight;
|
|
288
367
|
});
|
|
289
368
|
}
|
|
290
369
|
cursorY -= BLOCK_GAP;
|
|
@@ -372,6 +451,7 @@ export async function renderTablePdf(
|
|
|
372
451
|
}
|
|
373
452
|
|
|
374
453
|
drawIntroBlock();
|
|
454
|
+
ensureRoom(headerHeight + bodyLineHeight + 2 * CELL_PADDING_Y);
|
|
375
455
|
drawTableHeader();
|
|
376
456
|
|
|
377
457
|
if (doc.rows.length === 0 && doc.emptyText !== undefined) {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "SecretsContext.withRunner(tx) runs secret access on a given transaction",
|
|
6
|
+
"detail": "The OAuth refresh now reads and writes the refresh token on its advisory-lock transaction, so a refresh holds one pool connection instead of two and parallel refreshes cannot starve the pool. Custom SecretsContext implementations need a `withRunner` method."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.343.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -83,21 +83,22 @@ export function requireSecretsContext(
|
|
|
83
83
|
"createSecretsContext({db, masterKeyProvider, registry}) via extraContext.secrets at boot.",
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
|
-
const raw = ctx.secrets;
|
|
87
86
|
const userId = ctx._userId;
|
|
88
87
|
if (!userId) {
|
|
89
88
|
throw new InternalError({
|
|
90
89
|
message: `[${handlerName}] ctx._userId missing — cannot audit secret reads without a caller identity.`,
|
|
91
90
|
});
|
|
92
91
|
}
|
|
93
|
-
|
|
92
|
+
const withAudit = (raw: SecretsContext): SecretsContext => ({
|
|
93
|
+
withRunner: (runner) => withAudit(raw.withRunner(runner)),
|
|
94
94
|
get: (tenantId, key, overrideAudit) =>
|
|
95
95
|
raw.get(tenantId, key, overrideAudit ?? { userId, handlerName }),
|
|
96
96
|
// No audit injection: has() is metadata-only and never logs a read.
|
|
97
97
|
has: raw.has.bind(raw),
|
|
98
98
|
set: raw.set.bind(raw),
|
|
99
99
|
delete: raw.delete.bind(raw),
|
|
100
|
-
};
|
|
100
|
+
});
|
|
101
|
+
return withAudit(ctx.secrets);
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
export type SecretsFeatureOptions = {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
createEventStoreExecutor,
|
|
18
18
|
createTenantDb,
|
|
19
19
|
type DbConnection,
|
|
20
|
+
type DbRunner,
|
|
20
21
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
21
22
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
23
|
import { InternalError, type WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -91,8 +92,20 @@ function resolveKey(keyOrHandle: string | { readonly name: string }): string {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
export function createSecretsContext(opts: SecretsContextOptions): SecretsContext {
|
|
94
|
-
const {
|
|
95
|
+
const { masterKeyProvider } = opts;
|
|
95
96
|
const provider = withDekCache(masterKeyProvider, opts.dekCache ?? createDekCache());
|
|
97
|
+
return buildSecretsContext(opts, provider, opts.db, false);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// `runnerIsTransaction`: the runner is a caller-owned transaction, so audited reads join it
|
|
101
|
+
// instead of opening a second connection (which would deadlock a small pool).
|
|
102
|
+
function buildSecretsContext(
|
|
103
|
+
opts: SecretsContextOptions,
|
|
104
|
+
provider: MasterKeyProvider,
|
|
105
|
+
db: DbRunner,
|
|
106
|
+
runnerIsTransaction: boolean,
|
|
107
|
+
): SecretsContext {
|
|
108
|
+
const { masterKeyProvider } = opts;
|
|
96
109
|
|
|
97
110
|
type SecretLookupRow = {
|
|
98
111
|
readonly id: string;
|
|
@@ -105,6 +118,10 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
105
118
|
}
|
|
106
119
|
|
|
107
120
|
return {
|
|
121
|
+
withRunner(runner) {
|
|
122
|
+
return buildSecretsContext(opts, provider, runner, true);
|
|
123
|
+
},
|
|
124
|
+
|
|
108
125
|
async get(tenantId, keyOrHandle, auditCtx) {
|
|
109
126
|
const key = resolveKey(keyOrHandle);
|
|
110
127
|
// Atomic audit + read: a decrypt that "escaped" the audit trail
|
|
@@ -120,7 +137,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
120
137
|
return createSecret(plaintext);
|
|
121
138
|
}
|
|
122
139
|
|
|
123
|
-
const
|
|
140
|
+
const readAndAudit = async (tx: DbRunner): Promise<string | undefined> => {
|
|
124
141
|
// Inline select inside the TX via raw client — fetchOne's connection
|
|
125
142
|
// type doesn't widen to the transaction object cleanly.
|
|
126
143
|
const envelope = await selectTenantSecretEnvelope(tx, tenantId, key);
|
|
@@ -154,7 +171,10 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
154
171
|
metadata,
|
|
155
172
|
});
|
|
156
173
|
return pt;
|
|
157
|
-
}
|
|
174
|
+
};
|
|
175
|
+
const plaintext = runnerIsTransaction
|
|
176
|
+
? await readAndAudit(db)
|
|
177
|
+
: await transaction(db, readAndAudit);
|
|
158
178
|
|
|
159
179
|
if (plaintext === undefined) return undefined;
|
|
160
180
|
// Brand the plaintext only after audit committed. The response
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Mail template body helpers exported as `./shared/mail-template-body`",
|
|
6
|
+
"detail": "Apps that register their own notification templates can build the body with the same helpers the framework uses: `mailParagraph`, `mailHeading`, `mailRepeatedRows`, `mailTextWithSlotsHtml`, plus the new `mailButton`, `mailParagraphWithLink` and `mailSubject`. The subpath only depends on kumiko-headless and is safe on the server. Apps that copied these helpers can import them instead."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.335.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -33,6 +33,9 @@ export type ChunkedMigrationOptions<Row> = {
|
|
|
33
33
|
readonly maxFailures: number;
|
|
34
34
|
// Epoch ms; Number.POSITIVE_INFINITY for no time bound.
|
|
35
35
|
readonly deadlineAt: number;
|
|
36
|
+
// Batches processed even when the deadline already passed. Callers whose setup before the
|
|
37
|
+
// loop can eat the whole budget set 1, so every run makes progress instead of timing out empty.
|
|
38
|
+
readonly minBatches?: number;
|
|
36
39
|
readonly signal?: AbortSignal | undefined;
|
|
37
40
|
readonly onRowError?: (row: Row, err: unknown) => void;
|
|
38
41
|
};
|
|
@@ -51,7 +54,7 @@ export async function runChunkedMigration<Row>(
|
|
|
51
54
|
stoppedReason = "signal";
|
|
52
55
|
break;
|
|
53
56
|
}
|
|
54
|
-
if (Date.now() >= opts.deadlineAt) {
|
|
57
|
+
if (batchesProcessed >= (opts.minBatches ?? 0) && Date.now() >= opts.deadlineAt) {
|
|
55
58
|
stoppedReason = "timeout";
|
|
56
59
|
break;
|
|
57
60
|
}
|
|
@@ -19,7 +19,24 @@ export function mailHeading(text: string): string {
|
|
|
19
19
|
return `<h2>${mailTextWithSlotsHtml(text)}</h2>`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export function mailButton(label: string, linkVariable: string): string {
|
|
23
|
+
return `<p><a data-button href="{{${escapeHtmlAttr(linkVariable)}}}">${escapeHtml(label)}</a></p>`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function mailParagraphWithLink(
|
|
27
|
+
text: string,
|
|
28
|
+
linkLabel: string,
|
|
29
|
+
linkVariable: string,
|
|
30
|
+
): string {
|
|
31
|
+
return `<p>${mailTextWithSlotsHtml(text)} <a href="{{${escapeHtmlAttr(linkVariable)}}}">${escapeHtml(linkLabel)}</a></p>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
// One table row per list entry; the cell shows the entry's `field`.
|
|
23
35
|
export function mailRepeatedRows(listName: string, field: string): string {
|
|
24
36
|
return `<table><tbody><tr data-each="${escapeHtmlAttr(listName)}"><td><p><span data-variable="${escapeHtmlAttr(field)}"></span></p></td></tr></tbody></table>`;
|
|
25
37
|
}
|
|
38
|
+
|
|
39
|
+
// Subjects are Handlebars text, so the same `{name}` slots become `{{name}}`.
|
|
40
|
+
export function mailSubject(text: string): string {
|
|
41
|
+
return text.replace(SLOT_PATTERN, "{{$1}}");
|
|
42
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "A stale Stripe customer id no longer turns the customer portal into a 500",
|
|
6
|
+
"detail": "createPortalSession now maps Stripe's \"No such customer\" rejection (account switch, customer deleted in the dashboard) to an UnprocessableError with the i18n key billing-foundation.errors.providerCustomerUnknown, translated in German and Spanish, instead of letting the raw Stripe error surface as a server error."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.338.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -109,12 +109,10 @@ export function mapAppLocaleToStripeLocale(
|
|
|
109
109
|
return knownLocales.find((known) => known === language) ?? "auto";
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function isUnknownCustomerStripeError(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
error.param === "customer"
|
|
117
|
-
);
|
|
112
|
+
function isUnknownCustomerStripeError(
|
|
113
|
+
error: unknown,
|
|
114
|
+
): error is Stripe.errors.StripeInvalidRequestError {
|
|
115
|
+
return isResourceMissingStripeError(error) && error.param === "customer";
|
|
118
116
|
}
|
|
119
117
|
|
|
120
118
|
export function createStripeCheckoutSession(
|
|
@@ -203,11 +201,21 @@ export type StripePortalOptions = Parameters<
|
|
|
203
201
|
export function createStripePortalSession(runtime: StripeCtxRuntime) {
|
|
204
202
|
return async (ctx: HandlerContext, options: StripePortalOptions): Promise<{ url: string }> => {
|
|
205
203
|
const stripe = await runtime.clientForCtx(ctx);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
204
|
+
try {
|
|
205
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
206
|
+
customer: options.providerCustomerId,
|
|
207
|
+
return_url: options.returnUrl,
|
|
208
|
+
});
|
|
209
|
+
return { url: session.url };
|
|
210
|
+
} catch (error) {
|
|
211
|
+
// A portal needs an existing customer, so unlike checkout there is no retry without one.
|
|
212
|
+
if (!isUnknownCustomerStripeError(error)) throw error;
|
|
213
|
+
throw new UnprocessableError("provider_customer_unknown", {
|
|
214
|
+
i18nKey: "billing-foundation.errors.providerCustomerUnknown",
|
|
215
|
+
message: `subscription-stripe: Stripe does not know customer "${options.providerCustomerId}" (account switch or customer deleted).`,
|
|
216
|
+
cause: error,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
211
219
|
};
|
|
212
220
|
}
|
|
213
221
|
|
|
@@ -54,7 +54,9 @@ import type { StripeWebhookRuntime } from "./runtime.js";
|
|
|
54
54
|
|
|
55
55
|
const log = createFallbackLogger("subscription-stripe");
|
|
56
56
|
|
|
57
|
-
export function isResourceMissingStripeError(
|
|
57
|
+
export function isResourceMissingStripeError(
|
|
58
|
+
error: unknown,
|
|
59
|
+
): error is Stripe.errors.StripeInvalidRequestError {
|
|
58
60
|
return (
|
|
59
61
|
error instanceof Stripe.errors.StripeInvalidRequestError && error.code === "resource_missing"
|
|
60
62
|
);
|
|
@@ -50,7 +50,7 @@ async function someHandler(ctx) {
|
|
|
50
50
|
|
|
51
51
|
| kind | contentFormat | meta |
|
|
52
52
|
|---|---|---|
|
|
53
|
-
| `mail` | `html` (Dokument-Schema, wird serverseitig neu aufgebaut) | Pflicht: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `
|
|
53
|
+
| `mail` | `html` (Dokument-Schema, wird serverseitig neu aufgebaut) | Pflicht: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `senderName`, `replyTo` |
|
|
54
54
|
| `mail-layout` | `mjml` | leer (`{}`) oder fehlend |
|
|
55
55
|
| `pdf-layout` | `html` | leer (`{}`) oder fehlend |
|
|
56
56
|
| `document-pdf` | `html` | fehlt |
|
|
@@ -137,10 +137,12 @@ upsertSystem ──┐
|
|
|
137
137
|
├──► status: "active" (System-Default sofort aktiv)
|
|
138
138
|
upsertTenant ──┴──► status: "draft" (Default) | "active" (explizit)
|
|
139
139
|
|
|
140
|
-
publish ───────► status: "active"
|
|
140
|
+
publish ───────► status: "active" (übernimmt einen offenen Entwurf)
|
|
141
141
|
archive ───────► status: "archived"
|
|
142
|
+
discardDraft ──► verwirft den Entwurf neben der aktiven Fassung
|
|
142
143
|
```
|
|
143
144
|
|
|
145
|
+
Ein Entwurf über einer aktiven Zeile (`upsertTenant` ohne `status`, `copy-locale`, `import-bundle`) liegt als JSON in `draft` neben der Zeile; die Hauptfelder bleiben die ausgelieferte Fassung. `find-by-id` und `layout-source` liefern die Arbeitsfassung (Entwurf, sonst gespeichert) mit `active { version, since }`.
|
|
144
146
|
Resolver returnt **nur** Templates mit `status: "active"`. draft/archived werden ignoriert.
|
|
145
147
|
|
|
146
148
|
## Consumer Conformance
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type TemplatePdfAttachmentLink,
|
|
19
19
|
} from "./linked-resources.js";
|
|
20
20
|
import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
|
|
21
|
+
import { activeVersionOf, servedVersionOf } from "./template-draft.js";
|
|
21
22
|
import { parseTemplateMeta, type TemplateMeta } from "./template-meta.js";
|
|
22
23
|
|
|
23
24
|
// Public TemplateResource: what consumers see. Hides DB-internal columns
|
|
@@ -161,7 +162,7 @@ function pickActiveLayoutRow(
|
|
|
161
162
|
): TemplateResourceRow | null {
|
|
162
163
|
const active = rows
|
|
163
164
|
.filter((row) => row.status === "active")
|
|
164
|
-
.sort((a, b) => b
|
|
165
|
+
.sort((a, b) => activeVersionOf(b) - activeVersionOf(a) || a.locale.localeCompare(b.locale));
|
|
165
166
|
return (
|
|
166
167
|
active.find((row) => row.locale === locale) ??
|
|
167
168
|
active.find((row) => row.locale === FALLBACK_LOCALE) ??
|
|
@@ -202,7 +203,7 @@ function toPublic(row: TemplateResourceRow): TemplateResource {
|
|
|
202
203
|
const linkedEntries = parseLinkedResourceEntries(row.linkedResources);
|
|
203
204
|
return {
|
|
204
205
|
id: String(row.id),
|
|
205
|
-
version: row
|
|
206
|
+
version: servedVersionOf(row),
|
|
206
207
|
tenantId: row.tenantId,
|
|
207
208
|
slug: row.slug,
|
|
208
209
|
kind,
|