@cosmicdrift/kumiko-bundled-features 0.377.0 → 0.378.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/cap-counter/stock-cap-guard.js +8 -0
  2. package/dist/config/web/i18n.js +1 -0
  3. package/dist/inbound-mail-foundation/handlers/list-accounts.query.d.ts +1 -1
  4. package/dist/inbound-mail-foundation/handlers/list-accounts.query.js +11 -3
  5. package/dist/mail-foundation/i18n.d.ts +5 -2
  6. package/dist/mail-foundation/i18n.js +3 -2
  7. package/dist/mail-transport-smtp/i18n.d.ts +1 -1
  8. package/dist/mail-transport-smtp/i18n.js +1 -1
  9. package/dist/template-resolver/compile-document-body.d.ts +3 -0
  10. package/dist/template-resolver/compile-document-body.js +50 -3
  11. package/dist/template-resolver/constants.d.ts +3 -0
  12. package/dist/template-resolver/constants.js +2 -0
  13. package/dist/template-resolver/document-blocks.d.ts +2 -2
  14. package/dist/template-resolver/document-blocks.js +1 -0
  15. package/dist/template-resolver/document-images.d.ts +2 -0
  16. package/dist/template-resolver/document-images.js +28 -0
  17. package/dist/template-resolver/document-sanitizer.d.ts +1 -1
  18. package/dist/template-resolver/document-sanitizer.js +20 -0
  19. package/dist/template-resolver/document-schema.d.ts +2 -1
  20. package/dist/template-resolver/document-schema.js +4 -0
  21. package/dist/template-resolver/feature.d.ts +9 -0
  22. package/dist/template-resolver/feature.js +58 -33
  23. package/dist/template-resolver/handlers/collection-item.query.js +2 -1
  24. package/dist/template-resolver/handlers/collection-list.query.js +2 -1
  25. package/dist/template-resolver/handlers/collection-set.write.js +2 -2
  26. package/dist/template-resolver/handlers/collection-shared.d.ts +1 -2
  27. package/dist/template-resolver/handlers/collection-shared.js +0 -4
  28. package/dist/template-resolver/handlers/copy-locale.write.d.ts +2 -1
  29. package/dist/template-resolver/handlers/copy-locale.write.js +67 -63
  30. package/dist/template-resolver/handlers/discard-draft.write.d.ts +3 -2
  31. package/dist/template-resolver/handlers/discard-draft.write.js +18 -6
  32. package/dist/template-resolver/handlers/import-bundle.write.d.ts +2 -1
  33. package/dist/template-resolver/handlers/import-bundle.write.js +30 -25
  34. package/dist/template-resolver/handlers/overview.query.d.ts +2 -1
  35. package/dist/template-resolver/handlers/overview.query.js +46 -42
  36. package/dist/template-resolver/handlers/patch-draft.write.d.ts +2 -1
  37. package/dist/template-resolver/handlers/patch-draft.write.js +92 -86
  38. package/dist/template-resolver/handlers/reset-to-system.write.d.ts +2 -1
  39. package/dist/template-resolver/handlers/reset-to-system.write.js +51 -44
  40. package/dist/template-resolver/handlers/resource-link-pdf.write.d.ts +2 -1
  41. package/dist/template-resolver/handlers/resource-link-pdf.write.js +34 -28
  42. package/dist/template-resolver/handlers/resource-remove.write.d.ts +2 -1
  43. package/dist/template-resolver/handlers/resource-remove.write.js +39 -33
  44. package/dist/template-resolver/handlers/resource-upload.write.d.ts +2 -1
  45. package/dist/template-resolver/handlers/resource-upload.write.js +104 -96
  46. package/dist/template-resolver/handlers/send-test-shared.d.ts +5 -0
  47. package/dist/template-resolver/handlers/send-test-shared.js +19 -0
  48. package/dist/template-resolver/handlers/send-test-status.query.d.ts +9 -0
  49. package/dist/template-resolver/handlers/send-test-status.query.js +18 -0
  50. package/dist/template-resolver/handlers/send-test.write.d.ts +2 -1
  51. package/dist/template-resolver/handlers/send-test.write.js +87 -89
  52. package/dist/template-resolver/handlers/set.write.d.ts +2 -1
  53. package/dist/template-resolver/handlers/set.write.js +94 -90
  54. package/dist/template-resolver/handlers/toggle-status.write.d.ts +6 -7
  55. package/dist/template-resolver/handlers/toggle-status.write.js +28 -10
  56. package/dist/template-resolver/handlers/upsert-tenant.write.d.ts +2 -1
  57. package/dist/template-resolver/handlers/upsert-tenant.write.js +30 -24
  58. package/dist/template-resolver/index.d.ts +4 -2
  59. package/dist/template-resolver/index.js +3 -1
  60. package/dist/template-resolver/qualified-names.d.ts +2 -0
  61. package/dist/template-resolver/qualified-names.js +2 -0
  62. package/dist/template-resolver/template-edit-policy.d.ts +22 -0
  63. package/dist/template-resolver/template-edit-policy.js +45 -0
  64. package/dist/template-resolver/template-preview.js +7 -2
  65. package/dist/template-resolver/template-validation.js +33 -13
  66. package/dist/template-resolver/web/document-editor-extensions.d.ts +1 -0
  67. package/dist/template-resolver/web/document-editor-extensions.js +21 -1
  68. package/dist/template-resolver/web/document-tiptap-editor.d.ts +1 -1
  69. package/dist/template-resolver/web/document-tiptap-editor.js +22 -6
  70. package/dist/template-resolver/web/document-toolbar-blocks.d.ts +2 -0
  71. package/dist/template-resolver/web/i18n.js +4 -0
  72. package/package.json +8 -8
  73. package/src/cap-counter/changes.json +12 -0
  74. package/src/inbound-mail-foundation/changes.json +6 -0
  75. package/src/mail-foundation/changes.json +6 -0
  76. package/src/template-resolver/changes.json +20 -0
@@ -4,8 +4,12 @@
4
4
  // tenant, so both are now factory parameters.
5
5
  import { UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import { enforceStockCap } from "./enforce-cap.js";
7
+ // pg_advisory_xact_lock namespace (int4): 'scap' as ASCII, disjoint from the
8
+ // framework's other fixed advisory-lock keys.
9
+ const STOCK_CAP_LOCK_NAMESPACE = 0x73636170;
7
10
  export function createStockCapGuard(resolveTierCaps) {
8
11
  async function checkStockCap(db, spec, context = {}) {
12
+ // Lock-free on its own: a direct call outside withStockCap is not atomic.
9
13
  const caps = await resolveTierCaps(db, context);
10
14
  const current = await db.count(spec.table, { ...spec.where, tenantId: db.tenantId });
11
15
  const { state, limit } = enforceStockCap({
@@ -24,6 +28,10 @@ export function createStockCapGuard(resolveTierCaps) {
24
28
  return {
25
29
  ...handler,
26
30
  handler: async (event, ctx) => {
31
+ // ctx.db is the handler's transaction, so the xact lock releases with its commit or
32
+ // rollback. A handler wrapped in several caps must list them in the same order
33
+ // everywhere; Postgres aborts one side of an inverse order with a deadlock error.
34
+ await ctx.db.advisoryXactLock(STOCK_CAP_LOCK_NAMESPACE, spec.code);
27
35
  const failure = await checkStockCap(ctx.db, spec, { config: ctx.config });
28
36
  return failure ?? handler.handler(event, ctx);
29
37
  },
@@ -29,6 +29,7 @@ export const defaultTranslations = {
29
29
  "config.errors.unknownExtensionPlugin": "This provider is not available.",
30
30
  "config.settings.extensionSelectorHint": "Choose a provider. Its settings appear below and are saved together with it.",
31
31
  "config.settings.provider": "Provider",
32
+ "config.settings.provider.platformDefault": "Platform default",
32
33
  "config.settings.audience.system": "Applies to every tenant unless a tenant sets its own value.",
33
34
  "config.settings.audience.tenant": "Applies to everyone in this tenant. Platform defaults are shown where nothing is set here.",
34
35
  "config.settings.audience.user": "Only applies to you.",
@@ -1,2 +1,2 @@
1
- import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare const listAccountsQuery: QueryHandlerDef;
@@ -8,21 +8,29 @@
8
8
  // in-memory nach dem tenant-scoped select — Volumen ist die Anzahl
9
9
  // verbundener Postfächer eines Tenants (einstellig bis zweistellig).
10
10
  import { configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/kumiko-framework/crypto";
11
+ import { MAX_LIST_LIMIT } from "@cosmicdrift/kumiko-framework/engine";
11
12
  import * as z from "zod";
12
13
  import { MAIL_ACCOUNT_PII_FIELDS } from "../entities.js";
13
14
  import { mailAccountsProjectionTable } from "../projection.js";
14
15
  import { isVisibleToCaller } from "./scope-visibility.js";
15
- const listAccountsSchema = z.object({}).strict();
16
+ // The projectionList screen always sends `limit`; unknown keys stay rejected.
17
+ const listAccountsSchema = z
18
+ .object({ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT) })
19
+ .strict();
16
20
  export const listAccountsQuery = {
17
21
  name: "account:list",
18
22
  schema: listAccountsSchema,
19
23
  access: { roles: ["SystemAdmin", "TenantAdmin", "User"] },
20
24
  description: "Lists the connected mailboxes of the caller's tenant with the mailbox address decrypted, hiding personal mailboxes the caller neither owns nor administers; use it for the mail connect and settings views.",
21
- handler: async (_query, ctx) => {
25
+ handler: async (query, ctx) => {
26
+ const { limit } = listAccountsSchema.parse(query.payload);
22
27
  const allRows = await ctx.db.selectMany(mailAccountsProjectionTable, {
23
28
  tenantId: ctx.user.tenantId,
24
29
  });
25
- const rows = allRows.filter((row) => isVisibleToCaller(row, ctx.user));
30
+ const rows = allRows
31
+ .filter((row) => isVisibleToCaller(row, ctx.user))
32
+ .sort((a, b) => String(a["id"]).localeCompare(String(b["id"])))
33
+ .slice(0, limit);
26
34
  const piiKms = configuredPiiSubjectKms();
27
35
  if (!piiKms)
28
36
  return { rows };
@@ -6,9 +6,12 @@ export declare const MAIL_FOUNDATION_I18N: {
6
6
  readonly en: "Email sending";
7
7
  };
8
8
  readonly "mail-foundation.provider": {
9
- readonly en: "Own mail server";
9
+ readonly en: "Send via";
10
10
  };
11
11
  readonly "mail-foundation.provider.description": {
12
- readonly en: "Without one, the platform sends the mails. Reset the choice to go back to that.";
12
+ readonly en: "Choose who sends your emails. The platform default needs no settings of your own.";
13
+ };
14
+ readonly "mail-foundation.provider.platformDefault": {
15
+ readonly en: "Platform sending (default)";
13
16
  };
14
17
  };
@@ -1,8 +1,9 @@
1
1
  export const MAIL_FOUNDATION_I18N = {
2
2
  "screen:mail-foundation-tenant.title": { en: "Email sending" },
3
3
  "mail-foundation.settings": { en: "Email sending" },
4
- "mail-foundation.provider": { en: "Own mail server" },
4
+ "mail-foundation.provider": { en: "Send via" },
5
5
  "mail-foundation.provider.description": {
6
- en: "Without one, the platform sends the mails. Reset the choice to go back to that.",
6
+ en: "Choose who sends your emails. The platform default needs no settings of your own.",
7
7
  },
8
+ "mail-foundation.provider.platformDefault": { en: "Platform sending (default)" },
8
9
  };
@@ -1,6 +1,6 @@
1
1
  export declare const MAIL_TRANSPORT_SMTP_I18N: {
2
2
  readonly "mail-transport-smtp.settings": {
3
- readonly en: "SMTP server";
3
+ readonly en: "Own SMTP server";
4
4
  };
5
5
  readonly "mail-transport-smtp.settings.secrets": {
6
6
  readonly en: "SMTP sign-in";
@@ -1,6 +1,6 @@
1
1
  export const MAIL_TRANSPORT_SMTP_I18N = {
2
2
  // Also the option label of mail-foundation's provider select.
3
- "mail-transport-smtp.settings": { en: "SMTP server" },
3
+ "mail-transport-smtp.settings": { en: "Own SMTP server" },
4
4
  "mail-transport-smtp.settings.secrets": { en: "SMTP sign-in" },
5
5
  "mail-transport-smtp.settings.description": {
6
6
  en: "The connection details come from your mail provider, for example Brevo, Postmark, Amazon SES or your own server.",
@@ -9,7 +9,10 @@ export type CompileDocumentBodyOptions = {
9
9
  readonly buttonInlineStyle?: string;
10
10
  readonly headingAsInlineStyle?: boolean;
11
11
  readonly stepsColor?: string;
12
+ readonly recordImagesSrc?: (variable: string) => readonly string[];
13
+ readonly recordImagesPlaceholder?: boolean;
12
14
  };
13
15
  export declare function collectDocumentResourceKeys(html: string): readonly string[];
16
+ export declare function collectDocumentImageVariables(html: string): readonly string[];
14
17
  export declare function toResourceDataUri(contentType: string, bytes: Uint8Array): string;
15
18
  export declare function compileDocumentBodyToHandlebars(html: string, variables: TemplateVariables, options?: CompileDocumentBodyOptions): string;
@@ -48,9 +48,13 @@ function valueExpression(path, def, isWholeBlock) {
48
48
  }
49
49
  return `{{${path}}}`;
50
50
  }
51
+ // Never interpolated as text: its value is a list of file ids.
52
+ function isImagesVariable(def) {
53
+ return def?.type === "images";
54
+ }
51
55
  function rewriteVariable(element, state) {
52
56
  const name = element.getAttribute("data-variable") ?? "";
53
- const replacement = element.ownerDocument.createTextNode(isDocumentReferenceName(name)
57
+ const replacement = element.ownerDocument.createTextNode(isDocumentReferenceName(name) && !isImagesVariable(pathAndDef(name, element, state)[1])
54
58
  ? marker(state, valueExpression(...pathAndDef(name, element, state), state.blockRichTextSpans.has(element)))
55
59
  : "");
56
60
  element.replaceWith(replacement);
@@ -82,6 +86,9 @@ function wrapVariableOnlyBlock(block, state) {
82
86
  if (name === null)
83
87
  return;
84
88
  const [path, def] = pathAndDef(name, block, state);
89
+ // skip: images blocks are rewritten by rewriteImagesBlock
90
+ if (isImagesVariable(def))
91
+ return;
85
92
  const host = def?.type === "richText" && block.tagName === "P" ? richTextBlockHost(block, state) : block;
86
93
  const document = host.ownerDocument;
87
94
  host.before(document.createTextNode(marker(state, `{{#if ${path} includeZero=true}}`)));
@@ -106,8 +113,8 @@ function rewriteLink(element, state) {
106
113
  if (isDocumentPlaceholderHref(href)) {
107
114
  const name = href.slice(2, -2);
108
115
  if (isDocumentReferenceName(name)) {
109
- const [path] = pathAndDef(name, element, state);
110
- element.setAttribute("href", marker(state, `{{link ${path}}}`));
116
+ const [path, def] = pathAndDef(name, element, state);
117
+ element.setAttribute("href", isImagesVariable(def) ? "#" : marker(state, `{{link ${path}}}`));
111
118
  }
112
119
  else {
113
120
  element.setAttribute("href", "#");
@@ -152,6 +159,7 @@ function stripActiveContent(element) {
152
159
  function neutralizeBraces(html) {
153
160
  return html.replace(/[{}]/g, (brace) => (brace === "{" ? "{" : "}"));
154
161
  }
162
+ const IMAGES_PLACEHOLDER_STYLE = "height:96px;margin:8px 0;border-radius:4px;background:#e5e7eb";
155
163
  const MAIL_BUTTON_STYLE = "display:inline-block;padding:10px 20px;border-radius:6px;background-color:#1f5468;color:#ffffff;font-weight:600;text-decoration:none";
156
164
  const MAIL_TABLE_STYLE = "width:100%;border-collapse:collapse";
157
165
  const MAIL_TABLE_CELL_STYLE = "padding:6px 12px;vertical-align:top;border-bottom:1px solid #e5e5e5";
@@ -179,6 +187,42 @@ export function collectDocumentResourceKeys(html) {
179
187
  return [...keys];
180
188
  });
181
189
  }
190
+ // Unique valid names of the images blocks in document order.
191
+ export function collectDocumentImageVariables(html) {
192
+ return withParsedDocument(html, (root) => {
193
+ const names = new Set();
194
+ for (const element of Array.from(root.querySelectorAll("div[data-images]"))) {
195
+ const name = element.getAttribute("data-images") ?? "";
196
+ if (isDocumentReferenceName(name))
197
+ names.add(name);
198
+ }
199
+ return [...names];
200
+ });
201
+ }
202
+ function rewriteImagesBlock(element, state, options) {
203
+ const name = element.getAttribute("data-images") ?? "";
204
+ const declared = isDocumentReferenceName(name) && isImagesVariable(ownVariable(state.variables, name));
205
+ if (!declared) {
206
+ element.remove();
207
+ // skip: an images block bound to anything but a declared images variable renders nothing
208
+ return;
209
+ }
210
+ const document = element.ownerDocument;
211
+ element.removeAttribute("data-images");
212
+ if (options.recordImagesPlaceholder === true) {
213
+ element.setAttribute("class", "kumiko-doc-image-placeholder");
214
+ element.setAttribute("style", IMAGES_PLACEHOLDER_STYLE);
215
+ // skip: preview and drafts never resolve record images
216
+ return;
217
+ }
218
+ element.setAttribute("class", "kumiko-doc-images");
219
+ for (const src of options.recordImagesSrc?.(name) ?? []) {
220
+ const image = document.createElement("img");
221
+ image.setAttribute("src", src);
222
+ image.setAttribute("alt", "");
223
+ element.append(image);
224
+ }
225
+ }
182
226
  export function toResourceDataUri(contentType, bytes) {
183
227
  if (!isResourceImageContentType(contentType)) {
184
228
  throw new Error(`resource content type "${contentType}" cannot be embedded as an image`);
@@ -273,6 +317,9 @@ export function compileDocumentBodyToHandlebars(html, variables, options = {}) {
273
317
  color: options.stepsColor,
274
318
  });
275
319
  }
320
+ for (const element of Array.from(root.querySelectorAll("div[data-images]"))) {
321
+ rewriteImagesBlock(element, state, options);
322
+ }
276
323
  for (const block of Array.from(root.querySelectorAll(`[${DOCUMENT_CONDITION_ATTRIBUTE}]`))) {
277
324
  rewriteConditionalBlock(block, state);
278
325
  }
@@ -26,3 +26,6 @@ export declare const MAIL_PREVIEW_MAX_BYTES: number;
26
26
  export declare const TEST_MAIL_SUBJECT_PREFIX = "[TEST] ";
27
27
  export { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
28
28
  export declare const FALLBACK_LOCALE = "de";
29
+ export declare const DEFAULT_COLLECTION_ACCESS: {
30
+ readonly roles: readonly ["SystemAdmin"];
31
+ };
@@ -66,3 +66,5 @@ export { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
66
66
  // Default-Locale wenn Tenant keinen eigenen Default konfiguriert. Resolver
67
67
  // fällt darauf zurück wenn requested locale + tenant-default fehlen.
68
68
  export const FALLBACK_LOCALE = "de";
69
+ // A collection mounted without `access` is maintained by the platform only; TenantAdmin access is an explicit mount decision.
70
+ export const DEFAULT_COLLECTION_ACCESS = { roles: ["SystemAdmin"] };
@@ -1,5 +1,5 @@
1
- export type DocumentToolbarBlock = "image" | "table" | "button" | "divider" | "repeat" | "pageBreak" | "align" | "steps";
1
+ export type DocumentToolbarBlock = "image" | "table" | "button" | "divider" | "repeat" | "pageBreak" | "align" | "steps" | "images";
2
2
  export declare const MAIL_DOCUMENT_BLOCKS: readonly ["image", "table", "button", "divider", "repeat", "steps"];
3
- export declare const PDF_DOCUMENT_BLOCKS: readonly ["align", "image", "table", "divider", "repeat", "steps", "pageBreak"];
3
+ export declare const PDF_DOCUMENT_BLOCKS: readonly ["align", "image", "table", "divider", "repeat", "steps", "images", "pageBreak"];
4
4
  /** Block set for a template-resource kind; every kind except "document-pdf" gets the mail set. */
5
5
  export declare function documentBlocksForKind(kind: string | undefined): readonly DocumentToolbarBlock[];
@@ -13,6 +13,7 @@ export const PDF_DOCUMENT_BLOCKS = [
13
13
  "divider",
14
14
  "repeat",
15
15
  "steps",
16
+ "images",
16
17
  "pageBreak",
17
18
  ];
18
19
  /** Block set for a template-resource kind; every kind except "document-pdf" gets the mail set. */
@@ -0,0 +1,2 @@
1
+ import { Node } from "@tiptap/core";
2
+ export declare function createDocumentImagesNode(isReferenceName: (value: string) => boolean): Node<any, any>;
@@ -0,0 +1,28 @@
1
+ // @runtime client
2
+ // The record's photos: a block bound to an `images` variable whose FileRef ids the
3
+ // app passes at render time. Universal like document-schema.ts.
4
+ import { Node } from "@tiptap/core";
5
+ export function createDocumentImagesNode(isReferenceName) {
6
+ return Node.create({
7
+ name: "images",
8
+ group: "block",
9
+ atom: true,
10
+ addAttributes() {
11
+ return { variable: { default: null } };
12
+ },
13
+ parseHTML() {
14
+ return [
15
+ {
16
+ tag: "div[data-images]",
17
+ getAttrs: (element) => {
18
+ const variable = element.getAttribute("data-images");
19
+ return variable !== null && isReferenceName(variable) ? { variable } : false;
20
+ },
21
+ },
22
+ ];
23
+ },
24
+ renderHTML({ node }) {
25
+ return ["div", { "data-images": node.attrs["variable"] }];
26
+ },
27
+ });
28
+ }
@@ -2,7 +2,7 @@ import type { FieldIssue } from "@cosmicdrift/kumiko-framework/errors";
2
2
  import type { JSONContent } from "@tiptap/core";
3
3
  import { type Element as ScanElement } from "happy-dom";
4
4
  import type { ContentFormat } from "./constants.ts";
5
- export type DocumentViolationCode = "script_element" | "event_handler_attribute" | "disallowed_link_href" | "external_image_source" | "image_without_resource" | "invalid_reference_name" | "embedded_content" | "unsafe_attribute_value" | "button_href_not_placeholder" | "document_requires_html_format" | "layout_requires_mjml_format" | "layout_invalid_structure" | "layout_missing_content_slot" | "layout_source_system_only";
5
+ export type DocumentViolationCode = "script_element" | "event_handler_attribute" | "disallowed_link_href" | "external_image_source" | "image_without_resource" | "invalid_reference_name" | "embedded_content" | "unsafe_attribute_value" | "images_block_pdf_only" | "button_href_not_placeholder" | "document_requires_html_format" | "layout_requires_mjml_format" | "layout_invalid_structure" | "layout_missing_content_slot" | "layout_source_system_only";
6
6
  export type DocumentViolation = {
7
7
  readonly code: DocumentViolationCode;
8
8
  readonly detail: string;
@@ -59,6 +59,7 @@ const REFERENCE_ATTRIBUTES = [
59
59
  [DOCUMENT_CONDITION_ATTRIBUTE, isDocumentReferenceName],
60
60
  ["data-resource", isDocumentResourceKey],
61
61
  ["data-steps", isDocumentReferenceName],
62
+ ["data-images", isDocumentReferenceName],
62
63
  ["data-step", isDocumentStepKey],
63
64
  ];
64
65
  function scanReferences(tag, element, out) {
@@ -182,10 +183,28 @@ function requiredFormatViolation(kind, contentFormat) {
182
183
  }
183
184
  return null;
184
185
  }
186
+ const IMAGES_BLOCK_KIND = "document-pdf";
187
+ // The node is part of the shared document model, so a mail body would keep it after the
188
+ // rebuild; only a PDF resolves the record's images.
189
+ function imagesBlockViolation(input) {
190
+ if (input.kind === IMAGES_BLOCK_KIND ||
191
+ !isSanitizedHtmlKind(input.kind) ||
192
+ input.contentFormat !== "html" ||
193
+ input.content === null) {
194
+ return null;
195
+ }
196
+ const hasImagesBlock = withParsedDocument(input.content, (body) => body.querySelector("div[data-images]") !== null);
197
+ return hasImagesBlock
198
+ ? violation("images_block_pdf_only", input.kind, `Record images blocks are only allowed in templates of kind ${IMAGES_BLOCK_KIND}`)
199
+ : null;
200
+ }
185
201
  export function sanitizeTemplateBody(input) {
186
202
  const formatViolation = requiredFormatViolation(input.kind, input.contentFormat);
187
203
  if (formatViolation !== null)
188
204
  return { ok: false, violations: [formatViolation] };
205
+ const imagesViolation = imagesBlockViolation(input);
206
+ if (imagesViolation !== null)
207
+ return { ok: false, violations: [imagesViolation] };
189
208
  if (!isSanitizedHtmlKind(input.kind) ||
190
209
  input.contentFormat !== "html" ||
191
210
  input.content === null) {
@@ -203,6 +222,7 @@ const VIOLATION_I18N_KEYS = {
203
222
  invalid_reference_name: "templateResolver.errors.document.invalidReferenceName",
204
223
  embedded_content: "templateResolver.errors.document.embeddedContent",
205
224
  unsafe_attribute_value: "templateResolver.errors.document.unsafeAttributeValue",
225
+ images_block_pdf_only: "templateResolver.errors.document.imagesBlockPdfOnly",
206
226
  button_href_not_placeholder: "templateResolver.errors.document.buttonHrefNotPlaceholder",
207
227
  document_requires_html_format: "templateResolver.errors.document.documentRequiresHtmlFormat",
208
228
  layout_requires_mjml_format: "templateResolver.errors.document.layoutRequiresMjmlFormat",
@@ -3,13 +3,14 @@ export declare function isDocumentPlaceholderHref(href: string): boolean;
3
3
  export declare function isAllowedDocumentLinkHref(href: string): boolean;
4
4
  export declare function isDocumentReferenceName(value: string): boolean;
5
5
  export declare function isDocumentResourceKey(value: string): boolean;
6
- export declare const DOCUMENT_NODE_NAMES: readonly ["variable", "resource", "button", "divider", "pageBreak", "steps"];
6
+ export declare const DOCUMENT_NODE_NAMES: readonly ["variable", "resource", "button", "divider", "pageBreak", "steps", "images"];
7
7
  export declare const DocumentVariable: Node<any, any>;
8
8
  export declare const DocumentResource: Node<any, any>;
9
9
  export declare const DocumentButton: Node<any, any>;
10
10
  export declare const DocumentDivider: Node<any, any>;
11
11
  export declare const DocumentPageBreak: Node<any, any>;
12
12
  export declare const DocumentSteps: Node<any, any>;
13
+ export declare const DocumentImages: Node<any, any>;
13
14
  export declare const DocumentTableRow: Node<import("@tiptap/extension-table").TableRowOptions, any>;
14
15
  export declare const DocumentTable: Node<import("@tiptap/extension-table").TableOptions, any>;
15
16
  export declare const DocumentTableCell: Node<import("@tiptap/extension-table").TableCellOptions, any>;
@@ -8,6 +8,7 @@ import { Extension, Node } from "@tiptap/core";
8
8
  import { Link } from "@tiptap/extension-link";
9
9
  import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
10
10
  import StarterKit from "@tiptap/starter-kit";
11
+ import { createDocumentImagesNode } from "./document-images.js";
11
12
  import { createDocumentStepsNode } from "./document-steps.js";
12
13
  const PLACEHOLDER_HREF = /^\{\{(.+)\}\}$/;
13
14
  const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
@@ -65,6 +66,7 @@ export const DOCUMENT_NODE_NAMES = [
65
66
  "divider",
66
67
  "pageBreak",
67
68
  "steps",
69
+ "images",
68
70
  ];
69
71
  export const DocumentVariable = Node.create({
70
72
  name: "variable",
@@ -170,6 +172,7 @@ export const DocumentPageBreak = Node.create({
170
172
  },
171
173
  });
172
174
  export const DocumentSteps = createDocumentStepsNode(isDocumentReferenceName);
175
+ export const DocumentImages = createDocumentImagesNode(isDocumentReferenceName);
173
176
  // A repeat row is a table row carrying `each`: prosemirror-tables requires
174
177
  // the tableRow node type, so a separate node type is not an option.
175
178
  export const DocumentTableRow = TableRow.extend({
@@ -346,6 +349,7 @@ export const documentExtensions = [
346
349
  DocumentDivider,
347
350
  DocumentPageBreak,
348
351
  DocumentSteps,
352
+ DocumentImages,
349
353
  DocumentTextAlign,
350
354
  DocumentTextSize,
351
355
  DocumentCondition,
@@ -1,4 +1,5 @@
1
1
  import { type ContentCollectionDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { TenantEditableTemplate } from "./template-edit-policy.ts";
2
3
  export type TemplateResolverOptions = {
3
4
  /** Content collections this app mounts. Declared here rather than inside the
4
5
  * feature because `access` needs the host's role vocabulary — a bundled
@@ -10,6 +11,13 @@ export type TemplateResolverOptions = {
10
11
  * access rule, so the dispatcher enforces the separation instead of a
11
12
  * branch in a shared handler. */
12
13
  readonly collections?: readonly ContentCollectionDefinition[];
14
+ /** Templates a TenantAdmin may edit for the own tenant (override, draft, publish, archive,
15
+ * reset, copy-locale, import, resources). Without an entry only a SystemAdmin edits.
16
+ * A tenant-edited mail goes out under the platform's name, so the app opts in per
17
+ * template instead of the framework guessing which ones a tenant may reword.
18
+ * `access` is required: the host decides its role vocabulary here, like for
19
+ * `collections`. SystemAdmin always passes. */
20
+ readonly tenantEditable?: readonly TenantEditableTemplate[];
13
21
  };
14
22
  export declare function createTemplateResolverFeature(opts?: TemplateResolverOptions): import("@cosmicdrift/kumiko-types/feature").FeatureDefinition & {
15
23
  readonly exports: {
@@ -47,6 +55,7 @@ export declare function createTemplateResolverFeature(opts?: TemplateResolverOpt
47
55
  preview: import("@cosmicdrift/kumiko-types/handlers").HandlerRef;
48
56
  documentBlocks: import("@cosmicdrift/kumiko-types/handlers").HandlerRef;
49
57
  exportBundle: import("@cosmicdrift/kumiko-types/handlers").HandlerRef;
58
+ sendTestStatus: import("@cosmicdrift/kumiko-types/handlers").HandlerRef;
50
59
  };
51
60
  treeHandle: import("@cosmicdrift/kumiko-types/tree-node").TreeActionsHandle<"template-resolver", {
52
61
  readonly edit: {
@@ -1,33 +1,34 @@
1
1
  import { createSystemConfig, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
2
- import { isCollectionRefusedKind } from "./constants.js";
2
+ import { DEFAULT_COLLECTION_ACCESS, isCollectionRefusedKind } from "./constants.js";
3
3
  import { bySlugQuery } from "./handlers/by-slug.query.js";
4
4
  import { byTenantQuery } from "./handlers/by-tenant.query.js";
5
5
  import { makeCollectionItemQuery } from "./handlers/collection-item.query.js";
6
6
  import { makeCollectionListQuery } from "./handlers/collection-list.query.js";
7
7
  import { makeCollectionSetWrite } from "./handlers/collection-set.write.js";
8
- import { copyLocaleWrite } from "./handlers/copy-locale.write.js";
8
+ import { makeCopyLocaleWrite } from "./handlers/copy-locale.write.js";
9
9
  import { diffToSystemQuery } from "./handlers/diff-to-system.query.js";
10
- import { discardDraftSystemWrite, discardDraftWrite } from "./handlers/discard-draft.write.js";
10
+ import { makeDiscardDraftSystemWrite, makeDiscardDraftWrite, } from "./handlers/discard-draft.write.js";
11
11
  import { documentBlocksQuery } from "./handlers/document-blocks.query.js";
12
12
  import { exportBundleQuery } from "./handlers/export-bundle.query.js";
13
13
  import { findByIdQuery } from "./handlers/find-by-id.query.js";
14
- import { importBundleWrite } from "./handlers/import-bundle.write.js";
14
+ import { makeImportBundleWrite } from "./handlers/import-bundle.write.js";
15
15
  import { importBundleSystemWrite } from "./handlers/import-bundle-system.write.js";
16
16
  import { layoutSourceQuery } from "./handlers/layout-source.query.js";
17
17
  import { listQuery } from "./handlers/list.query.js";
18
- import { overviewQuery } from "./handlers/overview.query.js";
19
- import { patchDraftWrite } from "./handlers/patch-draft.write.js";
18
+ import { makeOverviewQuery } from "./handlers/overview.query.js";
19
+ import { makePatchDraftWrite } from "./handlers/patch-draft.write.js";
20
20
  import { previewQuery } from "./handlers/preview.query.js";
21
- import { resetToSystemWrite } from "./handlers/reset-to-system.write.js";
22
- import { resourceLinkPdfWrite } from "./handlers/resource-link-pdf.write.js";
23
- import { resourceRemoveWrite } from "./handlers/resource-remove.write.js";
24
- import { resourceUploadWrite } from "./handlers/resource-upload.write.js";
25
- import { sendTestWrite } from "./handlers/send-test.write.js";
26
- import { setWrite } from "./handlers/set.write.js";
21
+ import { makeResetToSystemWrite } from "./handlers/reset-to-system.write.js";
22
+ import { makeResourceLinkPdfWrite } from "./handlers/resource-link-pdf.write.js";
23
+ import { makeResourceRemoveWrite } from "./handlers/resource-remove.write.js";
24
+ import { makeResourceUploadWrite } from "./handlers/resource-upload.write.js";
25
+ import { makeSendTestWrite } from "./handlers/send-test.write.js";
26
+ import { sendTestStatusQuery } from "./handlers/send-test-status.query.js";
27
+ import { makeSetWrite } from "./handlers/set.write.js";
27
28
  import { tenantOverridesQuery } from "./handlers/tenant-overrides.query.js";
28
- import { archiveSystemWrite, archiveWrite, publishSystemWrite, publishWrite, } from "./handlers/toggle-status.write.js";
29
+ import { makeArchiveSystemWrite, makeArchiveWrite, makePublishSystemWrite, makePublishWrite, } from "./handlers/toggle-status.write.js";
29
30
  import { upsertSystemWrite } from "./handlers/upsert-system.write.js";
30
- import { upsertTenantWrite } from "./handlers/upsert-tenant.write.js";
31
+ import { makeUpsertTenantWrite } from "./handlers/upsert-tenant.write.js";
31
32
  import { usageQuery } from "./handlers/usage.query.js";
32
33
  import { validateQuery } from "./handlers/validate.query.js";
33
34
  import { templateResourceEntity } from "./table.js";
@@ -39,19 +40,39 @@ function assertCollectionKindsAllowed(collections) {
39
40
  }
40
41
  }
41
42
  }
43
+ function assertTenantEditableValid(entries) {
44
+ const seen = new Set();
45
+ for (const entry of entries) {
46
+ const key = `${entry.kind}/${entry.slug}`;
47
+ if (seen.has(key)) {
48
+ throw new Error(`template-resolver: tenantEditable lists "${key}" twice`);
49
+ }
50
+ seen.add(key);
51
+ if (entry.slug.trim() === "") {
52
+ throw new Error(`template-resolver: tenantEditable entry of kind "${entry.kind}" has an empty slug`);
53
+ }
54
+ if (entry.access.roles.length === 0) {
55
+ throw new Error(`template-resolver: tenantEditable entry "${entry.kind}/${entry.slug}" has no roles in access`);
56
+ }
57
+ }
58
+ }
42
59
  export function createTemplateResolverFeature(opts = {}) {
43
60
  const collections = opts.collections ?? [];
44
61
  assertCollectionKindsAllowed(collections);
62
+ const tenantEditable = opts.tenantEditable ?? [];
63
+ assertTenantEditableValid(tenantEditable);
64
+ const policy = { tenantEditable };
45
65
  const hasUserOwned = collections.some((c) => c.ownership === "user");
46
66
  return defineFeature("template-resolver", (r) => {
47
67
  r.describe([
48
68
  "Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`).",
49
69
  "Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away.",
70
+ 'Editing templates is for SystemAdmin only. A TenantAdmin edits only the templates the app unlocks at the mount site, `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug: "status-update", access: { roles: ["TenantAdmin"] } }] })`, and every write handler enforces that on the server (override, draft, publish, archive, reset, copy-locale, import, resources, `set`). Each entry carries the `access` roles allowed to edit it, because a bundled feature cannot know the host\'s roles. Reading and delivery do not depend on it: a tenant override that exists is served. A collection mounted without `access` is SystemAdmin only.',
50
71
  "Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation.",
51
72
  "How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning).",
52
73
  'A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side.',
53
74
  "`validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only.",
54
- "`preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted.",
75
+ "`preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. `send-test-status` tells an editor beforehand whether that send can work for the caller (`canSend`, else `reason` `no_channel` or `no_address`), never returning the address.",
55
76
  'Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail\'s own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10.',
56
77
  "Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.",
57
78
  ].join("\n\n"));
@@ -74,23 +95,23 @@ export function createTemplateResolverFeature(opts = {}) {
74
95
  r.entity({ name: "user-content-entry", ...userContentEntryEntity });
75
96
  const handlers = {
76
97
  upsertSystem: r.writeHandler(upsertSystemWrite),
77
- upsertTenant: r.writeHandler(upsertTenantWrite),
78
- publish: r.writeHandler(publishWrite),
79
- archive: r.writeHandler(archiveWrite),
80
- publishSystem: r.writeHandler(publishSystemWrite),
81
- archiveSystem: r.writeHandler(archiveSystemWrite),
82
- discardDraft: r.writeHandler(discardDraftWrite),
83
- discardDraftSystem: r.writeHandler(discardDraftSystemWrite),
84
- resetToSystem: r.writeHandler(resetToSystemWrite),
85
- set: r.writeHandler(setWrite),
86
- importBundle: r.writeHandler(importBundleWrite),
98
+ upsertTenant: r.writeHandler(makeUpsertTenantWrite(policy)),
99
+ publish: r.writeHandler(makePublishWrite(policy)),
100
+ archive: r.writeHandler(makeArchiveWrite(policy)),
101
+ publishSystem: r.writeHandler(makePublishSystemWrite(policy)),
102
+ archiveSystem: r.writeHandler(makeArchiveSystemWrite(policy)),
103
+ discardDraft: r.writeHandler(makeDiscardDraftWrite(policy)),
104
+ discardDraftSystem: r.writeHandler(makeDiscardDraftSystemWrite(policy)),
105
+ resetToSystem: r.writeHandler(makeResetToSystemWrite(policy)),
106
+ set: r.writeHandler(makeSetWrite(policy)),
107
+ importBundle: r.writeHandler(makeImportBundleWrite(policy)),
87
108
  importBundleSystem: r.writeHandler(importBundleSystemWrite),
88
- copyLocale: r.writeHandler(copyLocaleWrite),
89
- resourceUpload: r.writeHandler(resourceUploadWrite),
90
- resourceRemove: r.writeHandler(resourceRemoveWrite),
91
- resourceLinkPdf: r.writeHandler(resourceLinkPdfWrite),
92
- sendTest: r.writeHandler(sendTestWrite),
93
- patchDraft: r.writeHandler(patchDraftWrite),
109
+ copyLocale: r.writeHandler(makeCopyLocaleWrite(policy)),
110
+ resourceUpload: r.writeHandler(makeResourceUploadWrite(policy)),
111
+ resourceRemove: r.writeHandler(makeResourceRemoveWrite(policy)),
112
+ resourceLinkPdf: r.writeHandler(makeResourceLinkPdfWrite(policy)),
113
+ sendTest: r.writeHandler(makeSendTestWrite(policy)),
114
+ patchDraft: r.writeHandler(makePatchDraftWrite(policy)),
94
115
  };
95
116
  const queries = {
96
117
  findById: r.queryHandler(findByIdQuery),
@@ -98,7 +119,7 @@ export function createTemplateResolverFeature(opts = {}) {
98
119
  bySlug: r.queryHandler(bySlugQuery),
99
120
  byTenant: r.queryHandler(byTenantQuery),
100
121
  usage: r.queryHandler(usageQuery),
101
- overview: r.queryHandler(overviewQuery),
122
+ overview: r.queryHandler(makeOverviewQuery(policy)),
102
123
  layoutSource: r.queryHandler(layoutSourceQuery),
103
124
  tenantOverrides: r.queryHandler(tenantOverridesQuery),
104
125
  diffToSystem: r.queryHandler(diffToSystemQuery),
@@ -106,9 +127,13 @@ export function createTemplateResolverFeature(opts = {}) {
106
127
  preview: r.queryHandler(previewQuery),
107
128
  documentBlocks: r.queryHandler(documentBlocksQuery),
108
129
  exportBundle: r.queryHandler(exportBundleQuery),
130
+ sendTestStatus: r.queryHandler(sendTestStatusQuery),
109
131
  };
110
132
  for (const collection of collections) {
111
- r.contentCollection(collection);
133
+ r.contentCollection({
134
+ ...collection,
135
+ access: collection.access ?? DEFAULT_COLLECTION_ACCESS,
136
+ });
112
137
  r.queryHandler(makeCollectionListQuery(collection));
113
138
  r.queryHandler(makeCollectionItemQuery(collection));
114
139
  r.writeHandler(makeCollectionSetWrite(collection));
@@ -1,7 +1,8 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
- import { collectionStore, DEFAULT_COLLECTION_ACCESS, toCollectionEntry, } from "./collection-shared.js";
4
+ import { DEFAULT_COLLECTION_ACCESS } from "../constants.js";
5
+ import { collectionStore, toCollectionEntry, } from "./collection-shared.js";
5
6
  // Single entry of one collection, for the editor behind a tree node. Same
6
7
  // per-collection construction as collection-list — see there for why.
7
8
  export function makeCollectionItemQuery(collection) {
@@ -1,7 +1,8 @@
1
1
  import { castTenantRows, selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
- import { collectionStore, DEFAULT_COLLECTION_ACCESS, toCollectionEntry, } from "./collection-shared.js";
4
+ import { DEFAULT_COLLECTION_ACCESS } from "../constants.js";
5
+ import { collectionStore, toCollectionEntry, } from "./collection-shared.js";
5
6
  // One list handler per declared collection: `kind`, `ownership` and `access`
6
7
  // come from the declaration, never from the payload.
7
8
  //