@cosmicdrift/kumiko-bundled-features 0.401.0 → 0.403.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 (39) hide show
  1. package/dist/agent-tools/agent-manifest.js +123 -46
  2. package/dist/agent-tools/tool-catalog.d.ts +6 -0
  3. package/dist/agent-tools/tool-catalog.js +10 -11
  4. package/dist/agent-tools/types.d.ts +11 -10
  5. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +2 -2
  6. package/dist/inbound-mail-foundation/entities.js +1 -0
  7. package/dist/inbound-mail-foundation/watch-lease.d.ts +1 -1
  8. package/dist/inbound-mail-foundation/watch-lease.js +2 -2
  9. package/dist/inbound-mail-foundation/watch-supervisor-types.d.ts +5 -5
  10. package/dist/inbound-mail-foundation/watch-supervisor.js +5 -7
  11. package/dist/managed-pages/feature.js +9 -1
  12. package/dist/managed-pages/render-page-body.d.ts +12 -1
  13. package/dist/managed-pages/render-page-body.js +51 -14
  14. package/dist/managed-pages/web/i18n.js +1 -2
  15. package/dist/managed-pages/web/lossy-markdown.d.ts +1 -1
  16. package/dist/managed-pages/web/lossy-markdown.js +2 -16
  17. package/dist/managed-pages/web/markdown-to-document.js +2 -2
  18. package/dist/managed-pages/web/page-body-section.js +0 -1
  19. package/dist/readiness/handlers/run-list.query.js +2 -1
  20. package/dist/readiness/run-view.d.ts +4 -7
  21. package/dist/template-resolver/compile-document-body.js +2 -2
  22. package/dist/template-resolver/document-sanitizer.js +3 -3
  23. package/dist/template-resolver/index.d.ts +1 -1
  24. package/dist/template-resolver/index.js +1 -1
  25. package/dist/template-resolver/web/document-schema.d.ts +1 -0
  26. package/dist/template-resolver/web/document-schema.js +16 -1
  27. package/dist/template-resolver/web/i18n.js +2 -2
  28. package/dist/template-resolver/web/use-collection-entry-create.js +4 -3
  29. package/dist/user-data-rights/boot-checks.js +38 -31
  30. package/dist/user-data-rights/boot-rules.d.ts +17 -0
  31. package/dist/user-data-rights/boot-rules.js +17 -0
  32. package/dist/user-data-rights/web/request-deletion-screen.js +3 -3
  33. package/package.json +11 -11
  34. package/src/agent-tools/changes.json +19 -0
  35. package/src/inbound-mail-foundation/changes.json +7 -0
  36. package/src/managed-pages/changes.json +6 -0
  37. package/src/readiness/changes.json +6 -0
  38. package/src/template-resolver/changes.json +12 -0
  39. package/src/user-data-rights/changes.json +12 -0
@@ -2,18 +2,55 @@ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
2
2
  import { renderSafeMarkdown } from "../page-render/index.js";
3
3
  import { sanitizeDocumentHtml } from "../template-resolver/index.js";
4
4
  const defaultLog = createFallbackLogger("managed-pages");
5
- // A document body is re-sanitized at render time even though writes validate it:
6
- // rows can predate the validation or arrive through other writers. A rejected
7
- // document renders as an empty body (never the raw HTML) and is logged.
8
- export function renderPageBody(page, log = defaultLog) {
9
- if (page.bodyFormat === "markdown")
10
- return renderSafeMarkdown(page.body);
11
- const result = sanitizeDocumentHtml(page.body);
12
- if (result.ok)
13
- return result.html;
14
- log.warn("managed page document body rejected at render, serving an empty body", {
15
- slug: page.slug,
16
- violations: result.violations.map((violation) => violation.code),
17
- });
18
- return "";
5
+ const MAX_CACHED_BODIES = 500;
6
+ // Bounds the cache memory: 500 entries of arbitrary size could otherwise pin
7
+ // hundreds of MB. A bigger body is still served, just re-rendered per request.
8
+ const MAX_CACHED_BODY_LENGTH = 256 * 1024;
9
+ // Every write bumps the version, so a (page, version) key never serves stale
10
+ // HTML. A Map keeps insertion order: delete+reinsert on a hit makes the first
11
+ // key the least recently used one.
12
+ function cacheKeyOf(page) {
13
+ return [page.tenantId, page.slug, page.lang, page.version, page.updatedAt, page.bodyFormat].join("\u0000");
19
14
  }
15
+ // Writes store the rebuilt, sanitized HTML, but rows can predate that validation
16
+ // or arrive through other writers, so a render still sanitizes — once per page
17
+ // version instead of once per request. A rejected document renders as an empty
18
+ // body (never the raw HTML), is logged on the miss, and the empty result is cached.
19
+ export function createPageBodyRenderer(deps = {}) {
20
+ const sanitizeDocument = deps.sanitizeDocument ?? sanitizeDocumentHtml;
21
+ const renderMarkdown = deps.renderMarkdown ?? renderSafeMarkdown;
22
+ const maxEntries = deps.maxEntries ?? MAX_CACHED_BODIES;
23
+ const cache = new Map();
24
+ function renderUncached(page, log) {
25
+ if (page.bodyFormat === "markdown")
26
+ return renderMarkdown(page.body);
27
+ const result = sanitizeDocument(page.body);
28
+ if (result.ok)
29
+ return result.html;
30
+ log.warn("managed page document body rejected at render, serving an empty body", {
31
+ slug: page.slug,
32
+ violations: result.violations.map((violation) => violation.code),
33
+ });
34
+ return "";
35
+ }
36
+ return (page, log = defaultLog) => {
37
+ const key = cacheKeyOf(page);
38
+ const cached = cache.get(key);
39
+ if (cached !== undefined) {
40
+ cache.delete(key);
41
+ cache.set(key, cached);
42
+ return cached;
43
+ }
44
+ const html = renderUncached(page, log);
45
+ if (html.length > MAX_CACHED_BODY_LENGTH)
46
+ return html;
47
+ cache.set(key, html);
48
+ if (cache.size > maxEntries) {
49
+ const oldest = cache.keys().next();
50
+ if (!oldest.done)
51
+ cache.delete(oldest.value);
52
+ }
53
+ return html;
54
+ };
55
+ }
56
+ export const renderPageBody = createPageBodyRenderer();
@@ -50,11 +50,10 @@ export const MANAGED_PAGES_I18N = {
50
50
  "managed-pages:body.lossy.warning": {
51
51
  en: "This page was written in Markdown. Converting it to the editor drops: {features}. Check the result before saving.",
52
52
  },
53
- "managed-pages:body.lossy.image": { en: "images" },
53
+ "managed-pages:body.lossy.image": { en: "external images" },
54
54
  "managed-pages:body.lossy.blockquote": { en: "block quotes" },
55
55
  "managed-pages:body.lossy.code": { en: "code" },
56
56
  "managed-pages:body.lossy.deepHeading": { en: "headings below level 3" },
57
- "managed-pages:body.lossy.relativeLink": { en: "relative links" },
58
57
  // Row-Actions + Confirm (pageListScreen)
59
58
  "managed-pages:actions.edit": { en: "Edit" },
60
59
  "managed-pages:actions.delete": { en: "Delete" },
@@ -1,3 +1,3 @@
1
- export declare const LOSSY_MARKDOWN_FEATURES: readonly ["image", "blockquote", "code", "deepHeading", "relativeLink"];
1
+ export declare const LOSSY_MARKDOWN_FEATURES: readonly ["image", "blockquote", "code", "deepHeading"];
2
2
  export type LossyMarkdownFeature = (typeof LOSSY_MARKDOWN_FEATURES)[number];
3
3
  export declare function detectLossyMarkdown(markdown: string): readonly LossyMarkdownFeature[];
@@ -1,15 +1,6 @@
1
- import { isAllowedDocumentLinkHref } from "@cosmicdrift/kumiko-bundled-features/template-resolver/document-schema";
2
1
  import { Lexer } from "marked";
3
- export const LOSSY_MARKDOWN_FEATURES = [
4
- "image",
5
- "blockquote",
6
- "code",
7
- "deepHeading",
8
- "relativeLink",
9
- ];
10
- // The document editor has no image, quote or code nodes, only three heading
11
- // levels, and links must pass the document link policy (absolute http(s),
12
- // mailto, tel, placeholders).
2
+ export const LOSSY_MARKDOWN_FEATURES = ["image", "blockquote", "code", "deepHeading"];
3
+ // The document editor has no image, quote or code nodes and only three heading levels.
13
4
  const MAX_DOCUMENT_HEADING_DEPTH = 3;
14
5
  function tokenList(value) {
15
6
  return Array.isArray(value) ? value : [];
@@ -23,9 +14,6 @@ function childTokens(token) {
23
14
  function isDeepHeading(token) {
24
15
  return ("depth" in token && typeof token.depth === "number" && token.depth > MAX_DOCUMENT_HEADING_DEPTH);
25
16
  }
26
- function isRelativeLink(token) {
27
- return ("href" in token && typeof token.href === "string" && !isAllowedDocumentLinkHref(token.href));
28
- }
29
17
  function featuresOf(token) {
30
18
  switch (token.type) {
31
19
  case "image":
@@ -37,8 +25,6 @@ function featuresOf(token) {
37
25
  return ["code"];
38
26
  case "heading":
39
27
  return isDeepHeading(token) ? ["deepHeading"] : [];
40
- case "link":
41
- return isRelativeLink(token) ? ["relativeLink"] : [];
42
28
  default:
43
29
  return [];
44
30
  }
@@ -2,8 +2,8 @@ import { isAllowedDocumentLinkHref } from "@cosmicdrift/kumiko-bundled-features/
2
2
  import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
3
3
  import { Marked } from "marked";
4
4
  // The server sanitizer rejects what it cannot keep, so the conversion drops it up front and the
5
- // lossy banner stays true: a link outside the document link policy keeps its text, an image
6
- // (no uploaded resource to point at) is dropped, and raw HTML is shown as text.
5
+ // lossy banner stays true: a link outside the document link policy keeps its text, an external
6
+ // image (no uploaded resource to point at) is dropped, and raw HTML is shown as text.
7
7
  const converter = new Marked({ gfm: false, breaks: false });
8
8
  converter.use({
9
9
  renderer: {
@@ -10,7 +10,6 @@ const LOSSY_LABEL_KEYS = {
10
10
  blockquote: "managed-pages:body.lossy.blockquote",
11
11
  code: "managed-pages:body.lossy.code",
12
12
  deepHeading: "managed-pages:body.lossy.deepHeading",
13
- relativeLink: "managed-pages:body.lossy.relativeLink",
14
13
  };
15
14
  const BODY_EDITOR_ID = "managed-pages-page-body";
16
15
  function stringValue(value) {
@@ -18,7 +18,8 @@ export const runListQuery = defineQueryHandler({
18
18
  const { counts, outcome } = viewRun(run, query.user);
19
19
  return {
20
20
  id: run.id,
21
- requestedAt: run.requestedAt.toString(),
21
+ // Fixed width: pageRows sorts this as a string, and a trimmed fraction ("…20Z" vs "…20.001Z") sorts wrong.
22
+ requestedAt: run.requestedAt.toString({ fractionalSecondDigits: 6 }),
22
23
  triggeredByUserId: run.triggeredByUserId,
23
24
  triggeredByName: starterNames.get(run.triggeredByUserId) ?? run.triggeredByUserId,
24
25
  status: run.status,
@@ -1,15 +1,13 @@
1
1
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
4
  import { type ResultCounts, type RunOutcome, type VisibleProbeResult } from "./visible-results.ts";
4
- type InstantLike = {
5
- readonly toString: () => string;
6
- };
7
5
  export type RunRow = {
8
6
  readonly id: string;
9
7
  readonly status: string;
10
- readonly requestedAt: InstantLike;
11
- readonly startedAt: InstantLike | null;
12
- readonly finishedAt: InstantLike | null;
8
+ readonly requestedAt: Temporal.Instant;
9
+ readonly startedAt: Temporal.Instant | null;
10
+ readonly finishedAt: Temporal.Instant | null;
13
11
  readonly triggeredByUserId: string;
14
12
  readonly durationMs: number | null;
15
13
  readonly results: unknown;
@@ -22,4 +20,3 @@ export type RunView = {
22
20
  export declare const HISTORY_LIMIT = 100;
23
21
  export declare function fetchLatestRun(db: TenantDb): Promise<RunRow | undefined>;
24
22
  export declare function viewRun(run: RunRow, user: SessionUser): RunView;
25
- export {};
@@ -2,7 +2,7 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
2
2
  import { isResourceImageContentType } from "../shared/index.js";
3
3
  import { rewriteStepsBlock } from "./compile-steps-block.js";
4
4
  import { conditionScopeElement, withParsedDocument } from "./document-sanitizer.js";
5
- import { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_TEXT_ALIGNMENTS, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.js";
5
+ import { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_TEXT_ALIGNMENTS, isAllowedDocumentUrlHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.js";
6
6
  // Private-use characters mark where a Handlebars expression goes. Author text
7
7
  // is scrubbed of them first and every `{`/`}` is entity-encoded before the
8
8
  // markers are swapped for real expressions, so the only Handlebars syntax in
@@ -120,7 +120,7 @@ function rewriteLink(element, state) {
120
120
  element.setAttribute("href", "#");
121
121
  }
122
122
  }
123
- else if (!isAllowedDocumentLinkHref(href)) {
123
+ else if (!isAllowedDocumentUrlHref(href)) {
124
124
  element.setAttribute("href", "#");
125
125
  }
126
126
  }
@@ -2,7 +2,7 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
2
2
  import { generateHTML, generateJSON } from "@tiptap/html/server";
3
3
  import { Window } from "happy-dom";
4
4
  import { DOCUMENT_HTML_KINDS } from "./web/constants.js";
5
- import { DOCUMENT_CONDITION_ATTRIBUTE, documentExtensions, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.js";
5
+ import { DOCUMENT_CONDITION_ATTRIBUTE, documentExtensions, isAllowedDocumentLinkHref, isAllowedDocumentUrlHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.js";
6
6
  import { isDocumentStepKey } from "./web/document-steps.js";
7
7
  export function violation(code, detail, message) {
8
8
  return { code, detail, message };
@@ -38,7 +38,7 @@ function scanScriptAndHandlers(tag, element, out) {
38
38
  function scanAnchor(element, out) {
39
39
  const href = element.getAttribute("href");
40
40
  if (href !== null && !isAllowedDocumentLinkHref(href)) {
41
- out.push(violation("disallowed_link_href", href, `Link target "${href}" is not allowed; use https, http, mailto, tel or a {{placeholder}}`));
41
+ out.push(violation("disallowed_link_href", href, `Link target "${href}" is not allowed; use https, http, mailto, tel, a relative path or a {{placeholder}}`));
42
42
  }
43
43
  if (element.hasAttribute("data-button") && !isDocumentPlaceholderHref(href ?? "")) {
44
44
  out.push(violation("button_href_not_placeholder", href ?? "", `Button target "${href ?? ""}" must be a {{placeholder}}`));
@@ -96,7 +96,7 @@ function scanUrlAttributes(tag, element, out) {
96
96
  if (SVG_IMAGE_TAGS.has(tag) && name.endsWith("href")) {
97
97
  out.push(violation("external_image_source", value, `Images must reference an uploaded resource via data-resource; image source "${value}" is not allowed`));
98
98
  }
99
- else if (tag === "base" || !isAllowedDocumentLinkHref(value)) {
99
+ else if (tag === "base" || !isAllowedDocumentUrlHref(value)) {
100
100
  out.push(violation("disallowed_link_href", value, `Link target "${value}" is not allowed; use https, http, mailto, tel or a {{placeholder}}`));
101
101
  }
102
102
  }
@@ -24,5 +24,5 @@ export { collectTemplateUsage, type TemplateUsageEntry } from "./template-usage.
24
24
  export { hasPlaceholderProblems, resolveTemplateVariables, SYSTEM_PLACEHOLDERS, systemPlaceholdersFor, type TemplateValidation, validateTemplatePlaceholders, } from "./template-validation.ts";
25
25
  export { type UserContentEntryRow, userContentEntriesTable, userContentEntryEntity, } from "./user-content-table.ts";
26
26
  export { CONTENT_FORMATS, type ContentFormat, DEFAULT_MAIL_LAYOUT_SLUG, DEFAULT_PDF_LAYOUT_SLUG, DOCUMENT_HTML_KINDS, type DocumentHtmlKind, FALLBACK_LOCALE, NON_RENDER_KINDS, type NonRenderKind, PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, PDF_ORIENTATIONS, PDF_PAGE_FORMATS, type PdfOrientation, type PdfPageFormat, RENDER_KINDS, type RenderKind, SYSTEM_TENANT_ID, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES, TEXT_BLOCK_KIND, type TemplateKind, type TemplateScope, type TemplateStatus, UPSERT_KINDS, type UpsertKind, } from "./web/constants.ts";
27
- export { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentCondition, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, type DocumentTextAlignment, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.ts";
27
+ export { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentCondition, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, type DocumentTextAlignment, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isAllowedDocumentUrlHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.ts";
28
28
  export { TEMPLATE_RESOLVER_FEATURE, TemplateResolverErrors, TemplateResolverHandlers, TemplateResolverQueries, } from "./web/qualified-names.ts";
@@ -21,5 +21,5 @@ export { collectTemplateUsage } from "./template-usage.js";
21
21
  export { hasPlaceholderProblems, resolveTemplateVariables, SYSTEM_PLACEHOLDERS, systemPlaceholdersFor, validateTemplatePlaceholders, } from "./template-validation.js";
22
22
  export { userContentEntriesTable, userContentEntryEntity, } from "./user-content-table.js";
23
23
  export { CONTENT_FORMATS, DEFAULT_MAIL_LAYOUT_SLUG, DEFAULT_PDF_LAYOUT_SLUG, DOCUMENT_HTML_KINDS, FALLBACK_LOCALE, NON_RENDER_KINDS, PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, PDF_ORIENTATIONS, PDF_PAGE_FORMATS, RENDER_KINDS, SYSTEM_TENANT_ID, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES, TEXT_BLOCK_KIND, UPSERT_KINDS, } from "./web/constants.js";
24
- export { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentCondition, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.js";
24
+ export { DOCUMENT_CONDITION_ATTRIBUTE, DOCUMENT_NODE_NAMES, DOCUMENT_TEXT_ALIGNMENTS, DocumentButton, DocumentCondition, DocumentDivider, DocumentLink, DocumentPageBreak, DocumentResource, DocumentTable, DocumentTableCell, DocumentTableHeader, DocumentTableRow, DocumentTextAlign, DocumentVariable, documentExtensions, isAllowedDocumentLinkHref, isAllowedDocumentUrlHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "./web/document-schema.js";
25
25
  export { TEMPLATE_RESOLVER_FEATURE, TemplateResolverErrors, TemplateResolverHandlers, TemplateResolverQueries, } from "./web/qualified-names.js";
@@ -2,6 +2,7 @@ import { Extension, type Extensions, Node } from "@tiptap/core";
2
2
  import { isDocumentReferenceName } from "./document-reference-name.ts";
3
3
  export { isDocumentReferenceName };
4
4
  export declare function isDocumentPlaceholderHref(href: string): boolean;
5
+ export declare function isAllowedDocumentUrlHref(href: string): boolean;
5
6
  export declare function isAllowedDocumentLinkHref(href: string): boolean;
6
7
  export declare function isDocumentResourceKey(value: string): boolean;
7
8
  export declare const DOCUMENT_NODE_NAMES: readonly ["variable", "resource", "button", "divider", "pageBreak", "steps", "images"];
@@ -40,15 +40,30 @@ function hasAllowedLinkScheme(href) {
40
40
  return isParsableWebUrl(href, "http:");
41
41
  return ALLOWED_LINK_SCHEMES.some((scheme) => href.startsWith(scheme));
42
42
  }
43
+ // Relative references are matched by their literal leading character so a
44
+ // scheme can never hide in them: a value starting with a letter ("x:y",
45
+ // "javascript:") is not relative. Protocol-relative "//host" and any backslash
46
+ // (browsers read "/\\host" as "//host") would leave the site.
47
+ const RELATIVE_HREF_PREFIXES = ["/", "./", "../", "#", "?"];
48
+ function isRelativeHref(href) {
49
+ if (href.includes("\\") || href.startsWith("//"))
50
+ return false;
51
+ return RELATIVE_HREF_PREFIXES.some((prefix) => href.startsWith(prefix));
52
+ }
43
53
  // Exact lowercase prefix on the unchanged value: no trim, no case folding, so
44
54
  // "JaVaScRiPt:", "HTTPS://" or a leading tab can never slip through as a
45
55
  // scheme variant. Markup and control characters are refused outright because
46
56
  // downstream parsers (MJML, PDF renderer) re-read the serialized attribute.
47
- export function isAllowedDocumentLinkHref(href) {
57
+ export function isAllowedDocumentUrlHref(href) {
48
58
  if (hasUnsafeUrlCharacter(href))
49
59
  return false;
50
60
  return hasAllowedLinkScheme(href) || isDocumentPlaceholderHref(href);
51
61
  }
62
+ // Anchors only: other URL attributes (form action, svg href) resolve against the
63
+ // page and must stay on explicit schemes or placeholders.
64
+ export function isAllowedDocumentLinkHref(href) {
65
+ return isAllowedDocumentUrlHref(href) || (!hasUnsafeUrlCharacter(href) && isRelativeHref(href));
66
+ }
52
67
  export function isDocumentResourceKey(value) {
53
68
  return RESOURCE_KEY.test(value);
54
69
  }
@@ -18,7 +18,7 @@ export const defaultTranslations = {
18
18
  "template-resolver.documentEditor.insert": "Insert",
19
19
  "template-resolver.documentEditor.insertPlaceholder": "Placeholder",
20
20
  "template-resolver.documentEditor.invalidImageAlt": "The alt text must not contain < or >.",
21
- "template-resolver.documentEditor.invalidLink": "This link target is not allowed. Use https, http, mailto, tel or a placeholder.",
21
+ "template-resolver.documentEditor.invalidLink": "This link target is not allowed. Use https, http, mailto, tel, a relative path or a placeholder.",
22
22
  "template-resolver.documentEditor.invalidResourceKey": "This resource key is not valid.",
23
23
  "template-resolver.documentEditor.link": "Link",
24
24
  "template-resolver.documentEditor.linkHref": "Link target",
@@ -55,7 +55,7 @@ export const defaultTranslations = {
55
55
  "template-resolver.editor.saved": "Saved.",
56
56
  "templateResolver.errors.document.imagesBlockPdfOnly": "Photo blocks are only allowed in PDF documents.",
57
57
  "templateResolver.errors.document.buttonHrefNotPlaceholder": "The button target “{detail}” must be a placeholder.",
58
- "templateResolver.errors.document.disallowedLinkHref": "Link target “{detail}” is not allowed; use https, http, mailto, tel or a placeholder.",
58
+ "templateResolver.errors.document.disallowedLinkHref": "Link target “{detail}” is not allowed; use https, http, mailto, tel, a relative path or a placeholder.",
59
59
  "templateResolver.errors.document.documentRequiresHtmlFormat": "This template kind requires the html format, got “{detail}”.",
60
60
  "templateResolver.errors.document.layoutRequiresMjmlFormat": "Mail layouts require the mjml format, got “{detail}”.",
61
61
  "templateResolver.errors.document.layoutInvalidStructure": "The layout may only contain the header, content, signature and footer regions: {detail}.",
@@ -26,9 +26,10 @@ export function useCollectionEntryCreate(target, tenantId) {
26
26
  const [content, setContent] = useState("");
27
27
  const slugValid = slug.length <= SLUG_MAX_LENGTH && SLUG_PATTERN.test(slug);
28
28
  const disabled = submitting || !canWrite || collectionId === undefined;
29
- const headerTitle = t("template-resolver.editor.newEntryTitle", {
30
- label: collection !== undefined ? t(collection.nav.label) : "",
31
- });
29
+ const entryLabel = collection?.nav.entryLabel;
30
+ const headerTitle = entryLabel !== undefined
31
+ ? t("template-resolver.editor.newEntryTitle", { label: t(entryLabel) })
32
+ : t("template-resolver.editor.newEntry");
32
33
  const create = async () => {
33
34
  // skip: nothing to write to, or the slug is invalid (the field already shows the issue)
34
35
  if (collectionId === undefined || !slugValid)
@@ -1,5 +1,6 @@
1
- import { EXT_USER_DATA, } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { bootRuleError, collectBootRuleViolations, EXT_USER_DATA, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { entitiesOf } from "../shared/index.js";
3
+ import { USER_DATA_RIGHTS_BOOT_RULES } from "./boot-rules.js";
3
4
  // V2: export-without-erase gate. A feature that registers an EXT_USER_DATA
4
5
  // export hook without a matching delete hook exports data under Art.20 but
5
6
  // never erases it on forget — an Art.17 violation. Hard boot failure: no app
@@ -14,22 +15,25 @@ import { entitiesOf } from "../shared/index.js";
14
15
  // fails otherwise); running this from user-data-rights' own bootCheck is
15
16
  // equivalent to the framework-internal call it replaces.
16
17
  export function validateGdprHookCompleteness(features) {
17
- for (const feature of features) {
18
- for (const usage of feature.extensionUsages) {
19
- if (usage.extensionName !== EXT_USER_DATA)
20
- continue;
21
- const hasExport = typeof usage.options?.["export"] === "function";
22
- const hasDelete = typeof usage.options?.["delete"] === "function";
23
- // The compile-time "at least one hook" type is bypassed by string-typed
24
- // extension names and casts; without this an empty bag registers nothing.
25
- if (!hasExport && !hasDelete) {
26
- throw new Error(`[kumiko:boot] Feature "${feature.name}" registers EXT_USER_DATA for entity "${usage.entityName}" with neither an export nor a delete hook — the entity silently stays out of the Art.15/17/20 pipeline. Register at least one hook.`);
27
- }
28
- if (hasExport && !hasDelete) {
29
- throw new Error(`[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`);
30
- }
18
+ const usages = features.flatMap((feature) => feature.extensionUsages
19
+ .filter((usage) => usage.extensionName === EXT_USER_DATA)
20
+ .map((usage) => ({ feature, usage })));
21
+ collectBootRuleViolations(usages, ({ feature, usage }) => {
22
+ const hasExport = typeof usage.options?.["export"] === "function";
23
+ const hasDelete = typeof usage.options?.["delete"] === "function";
24
+ // The compile-time "at least one hook" type is bypassed by string-typed
25
+ // extension names and casts; without this an empty bag registers nothing.
26
+ if (!hasExport && !hasDelete) {
27
+ throw bootRuleError(USER_DATA_RIGHTS_BOOT_RULES.emptyHook, {
28
+ message: `[kumiko:boot] Feature "${feature.name}" registers EXT_USER_DATA for entity "${usage.entityName}" with neither an export nor a delete hook — the entity silently stays out of the Art.15/17/20 pipeline. Register at least one hook.`,
29
+ });
31
30
  }
32
- }
31
+ if (hasExport && !hasDelete) {
32
+ throw bootRuleError(USER_DATA_RIGHTS_BOOT_RULES.exportWithoutDelete, {
33
+ message: `[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
34
+ });
35
+ }
36
+ });
33
37
  }
34
38
  // V3: PII-entity-without-hook gate. V2 checks registered hooks for
35
39
  // completeness; V3 catches the entity nobody registered at all — fields
@@ -51,19 +55,22 @@ export function validateGdprPiiHookCoverage(features) {
51
55
  hookedEntities.add(usage.entityName);
52
56
  }
53
57
  }
54
- for (const feature of features) {
55
- for (const [entityName, entity] of entitiesOf(feature)) {
56
- if (hookedEntities.has(entityName))
57
- continue;
58
- const subjectFields = Object.entries(entity.fields)
59
- .filter(([, field]) => {
60
- const annot = field; // @cast-boundary schema-walk
61
- return Boolean(annot.pii) || Boolean(annot.userOwned) || Boolean(annot.subjectRef);
62
- })
63
- .map(([name]) => name);
64
- if (subjectFields.length === 0)
65
- continue;
66
- throw new Error(`[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has user-subject fields (${subjectFields.join(", ")}) but no feature registers an EXT_USER_DATA hook for it — the data never appears in Art.15/20 exports and is never erased on forget (Art.17 gap). Register r.useExtension(EXT_USER_DATA, "${entityName}", { export, delete }) in the owning feature or a defaults feature. If this entity is intentionally out of the pipeline (e.g. crypto-shredding key-erase covers it), register a no-op hook { export: async () => null, delete: async () => {} } with a comment explaining why.`);
67
- }
68
- }
58
+ const entities = features.flatMap((feature) => [...entitiesOf(feature)].map(([entityName, entity]) => ({ feature, entityName, entity })));
59
+ collectBootRuleViolations(entities, ({ feature, entityName, entity }) => {
60
+ // skip: an entity with a registered hook is covered
61
+ if (hookedEntities.has(entityName))
62
+ return;
63
+ const subjectFields = Object.entries(entity.fields)
64
+ .filter(([, field]) => {
65
+ const annot = field; // @cast-boundary schema-walk
66
+ return Boolean(annot.pii) || Boolean(annot.userOwned) || Boolean(annot.subjectRef);
67
+ })
68
+ .map(([name]) => name);
69
+ // skip: an entity without user-subject fields stays out of the pipeline by design
70
+ if (subjectFields.length === 0)
71
+ return;
72
+ throw bootRuleError(USER_DATA_RIGHTS_BOOT_RULES.missingHook, {
73
+ message: `[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has user-subject fields (${subjectFields.join(", ")}) but no feature registers an EXT_USER_DATA hook for it — the data never appears in Art.15/20 exports and is never erased on forget (Art.17 gap). Register r.useExtension(EXT_USER_DATA, "${entityName}", { export, delete }) in the owning feature or a defaults feature. If this entity is intentionally out of the pipeline (e.g. crypto-shredding key-erase covers it), register a no-op hook { export: async () => null, delete: async () => {} } with a comment explaining why.`,
74
+ });
75
+ });
69
76
  }
@@ -0,0 +1,17 @@
1
+ export declare const USER_DATA_RIGHTS_BOOT_RULES: {
2
+ readonly emptyHook: {
3
+ readonly id: "user-data-rights/empty-hook";
4
+ readonly summary: "An EXT_USER_DATA registration declares at least one of an export or a delete hook.";
5
+ readonly fix: "Register at least one hook.";
6
+ };
7
+ readonly exportWithoutDelete: {
8
+ readonly id: "user-data-rights/export-without-delete";
9
+ readonly summary: "An entity exported via EXT_USER_DATA also registers a delete hook.";
10
+ readonly fix: "Add a delete hook, or a documented no-op delete if erasure is handled elsewhere.";
11
+ };
12
+ readonly missingHook: {
13
+ readonly id: "user-data-rights/missing-hook";
14
+ readonly summary: "An entity with user-subject fields registers an EXT_USER_DATA hook.";
15
+ readonly fix: "Register r.useExtension(EXT_USER_DATA, ...) for the entity, or a documented no-op hook if it is covered elsewhere.";
16
+ };
17
+ };
@@ -0,0 +1,17 @@
1
+ export const USER_DATA_RIGHTS_BOOT_RULES = {
2
+ emptyHook: {
3
+ id: "user-data-rights/empty-hook",
4
+ summary: "An EXT_USER_DATA registration declares at least one of an export or a delete hook.",
5
+ fix: "Register at least one hook.",
6
+ },
7
+ exportWithoutDelete: {
8
+ id: "user-data-rights/export-without-delete",
9
+ summary: "An entity exported via EXT_USER_DATA also registers a delete hook.",
10
+ fix: "Add a delete hook, or a documented no-op delete if erasure is handled elsewhere.",
11
+ },
12
+ missingHook: {
13
+ id: "user-data-rights/missing-hook",
14
+ summary: "An entity with user-subject fields registers an EXT_USER_DATA hook.",
15
+ fix: "Register r.useExtension(EXT_USER_DATA, ...) for the entity, or a documented no-op hook if it is covered elsewhere.",
16
+ },
17
+ };
@@ -46,7 +46,7 @@ export function RequestAccountDeletionScreen({ title, }) {
46
46
  // kumiko-lint-ignore primitives-discipline narrow centered confirmation card, not a screen container
47
47
  className: "w-full max-w-sm mx-auto", options: { padded: false }, slots: {
48
48
  header: (_jsx("div", { className: "flex flex-col space-y-1.5 p-6 pb-4", children: _jsx("h1", { className: "text-xl font-semibold tracking-tight", children: title ?? t("userDataRights.deletion.request.title") }) })),
49
- }, children: done ? (_jsx("div", { className: "p-6 pt-0", children: _jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("userDataRights.deletion.request.successTitle") }), _jsx("p", { className: "mt-1", children: t("userDataRights.deletion.request.successBody") })] }) })) : (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("userDataRights.deletion.request.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "delete-email", label: t("userDataRights.deletion.request.email"), required: true, children: _jsx(Input, { kind: "text", id: "delete-email", name: "delete-email", value: email, onChange: setEmail, disabled: submitting, required: true }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting
50
- ? t("userDataRights.deletion.request.submitting")
51
- : t("userDataRights.deletion.request.submit") })] })] })) }));
49
+ }, children: done ? (_jsx("div", { className: "p-6 pt-0", children: _jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("userDataRights.deletion.request.successTitle") }), _jsx("p", { className: "mt-1", children: t("userDataRights.deletion.request.successBody") })] }) })) : (_jsxs("div", { className: "p-6 pt-0 flex flex-col gap-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("userDataRights.deletion.request.intro") }), _jsx(Form, { chromeless: true, onSubmit: onSubmit, children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Field, { id: "delete-email", label: t("userDataRights.deletion.request.email"), required: true, children: _jsx(Input, { kind: "text", id: "delete-email", name: "delete-email", value: email, onChange: setEmail, disabled: submitting, required: true }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting
50
+ ? t("userDataRights.deletion.request.submitting")
51
+ : t("userDataRights.deletion.request.submit") })] }) })] })) }));
52
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.401.0",
3
+ "version": "0.403.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -570,9 +570,9 @@
570
570
  }
571
571
  },
572
572
  "dependencies": {
573
- "@cosmicdrift/kumiko-framework": "0.401.0",
574
- "@cosmicdrift/kumiko-renderer": "0.401.0",
575
- "@cosmicdrift/kumiko-types": "0.401.0",
573
+ "@cosmicdrift/kumiko-framework": "0.403.0",
574
+ "@cosmicdrift/kumiko-renderer": "0.403.0",
575
+ "@cosmicdrift/kumiko-types": "0.403.0",
576
576
  "@mollie/api-client": "^4.5.0",
577
577
  "@node-rs/argon2": "^2.0.2",
578
578
  "@tiptap/core": "^3.31.3",
@@ -1166,17 +1166,17 @@
1166
1166
  ],
1167
1167
  "devDependencies": {
1168
1168
  "@testing-library/user-event": "^14.6.8",
1169
- "@cosmicdrift/kumiko-locale-de": "0.401.0",
1170
- "@cosmicdrift/kumiko-locale-es": "0.401.0",
1171
- "@cosmicdrift/kumiko-locale-fr": "0.401.0",
1172
- "@cosmicdrift/kumiko-locale-it": "0.401.0",
1173
- "@cosmicdrift/kumiko-renderer-mail-html": "0.401.0",
1169
+ "@cosmicdrift/kumiko-locale-de": "0.403.0",
1170
+ "@cosmicdrift/kumiko-locale-es": "0.403.0",
1171
+ "@cosmicdrift/kumiko-locale-fr": "0.403.0",
1172
+ "@cosmicdrift/kumiko-locale-it": "0.403.0",
1173
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.403.0",
1174
1174
  "jsqr": "^1.4.0",
1175
1175
  "unpdf": "^1.8.1",
1176
- "@cosmicdrift/kumiko-testing": "0.401.0"
1176
+ "@cosmicdrift/kumiko-testing": "0.403.0"
1177
1177
  },
1178
1178
  "peerDependencies": {
1179
- "@cosmicdrift/kumiko-testing": "0.401.0"
1179
+ "@cosmicdrift/kumiko-testing": "0.403.0"
1180
1180
  },
1181
1181
  "peerDependenciesMeta": {
1182
1182
  "@cosmicdrift/kumiko-testing": {
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "0.402.0",
4
+ "type": "breaking",
5
+ "title": "Agent manifest labels are one string in the requested locale",
6
+ "detail": "`buildAgentManifest` used to emit every label in every locale (`labels: { de, en, es }`, often `{}`), although the caller names one `locale`. Entities, fields, navs and workspaces now carry `label?: string`, screens `title?: string`, picked in the requested locale, then English, then the first other locale, and left out when untranslated. Empty `params` and `workspaces` on screens, empty `workspaces` on navs and an empty handler `description` are left out as well (`description`, `params` and `workspaces` are optional in the types). `buildToolCatalog` reads the new fields.",
7
+ "migration": "Read `entity.label`, `field.label`, `nav.label`, `workspace.label` and `screen.title` instead of the `labels`/`titles` maps. Code that only passes the manifest to `buildToolCatalog` or serialises it needs no change."
8
+ },
9
+ {
10
+ "version": "0.402.0",
11
+ "type": "improvement",
12
+ "title": "Agent manifest shows only the first sentence of a feature description",
13
+ "detail": "`buildAgentManifest` emits the first sentence of `FeatureDefinition.description` (a `. ` before a capital letter ends it, so \"e.g.\" and \"Art. 15\" stay whole), at most 300 characters. The full text stays on the definition for the docs generators. In an app with 77 features the descriptions drop from about 46.6k to 12.2k characters."
14
+ },
15
+ {
16
+ "version": "0.402.0",
17
+ "type": "improvement",
18
+ "title": "Agent manifest features and entities follow the role",
19
+ "detail": "`buildAgentManifest` now leaves out a feature when none of its handlers, screens or navs remain in the manifest for the role, and an entity when no remaining handler or screen refers to it, no `get_`/`list_` tool of the role reads it and no reachable entity points at it through a reference field (one hop). Before, `features` and `entities` were identical for every role, so a restricted role paid for schemas it could not use in every system prompt. A translation-only feature such as `locale-de` no longer appears in `features`."
20
+ },
2
21
  {
3
22
  "version": "0.389.0",
4
23
  "type": "improvement",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.402.0",
4
+ "type": "breaking",
5
+ "title": "The inbound mail supervisor requires a lock and the dedup anchor is unique",
6
+ "detail": "`createInboundMailSupervisor` takes a required `lock` (a DistributedLock) instead of an optional one, so only one worker holds the IMAP watch per account; the single-process path without a lock is gone. The unmanaged table store_mail_seen_messages gets a unique index on (account_id, provider_message_id), which the ingest comment always promised; ingest-message already serialized concurrent ingests of one mail with a per-message advisory lock, so the second one reports duplicate.",
7
+ "migration": "Pass `lock: createDistributedLock(redis, \"kumiko:lock:inbound-mail:watch:\")` to createInboundMailSupervisor. Run `kumiko-schema generate <name>` in each app that mounts inbound-mail-foundation to get the migration for the unique index on store_mail_seen_messages; existing rows must hold no duplicate (account_id, provider_message_id)."
8
+ },
2
9
  {
3
10
  "version": "0.394.0",
4
11
  "type": "improvement",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.402.0",
4
+ "type": "improvement",
5
+ "title": "The public render caches a sanitized document body per page version",
6
+ "detail": "The render cache holds up to 500 entries keyed on tenant, slug, language, version and format. Bodies larger than 256 KB are sanitized on every render and not cached."
7
+ },
2
8
  {
3
9
  "version": "0.395.0",
4
10
  "type": "breaking",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.402.0",
4
+ "type": "fix",
5
+ "title": "System-check history keeps runs of the same second in order",
6
+ "detail": "`probe-run:list` returned `requestedAt` as `Instant.toString()`, which drops trailing zeros in the fraction, and `pageRows` sorts that field as a string. Two runs in the same second could then come out reversed (`…:20Z` sorted after `…:20.001Z`), so the history showed the older run on top and a click opened it. `requestedAt` now always carries six fractional digits, so string order matches time order."
7
+ },
2
8
  {
3
9
  "version": "0.392.0",
4
10
  "type": "improvement",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.402.0",
4
+ "type": "improvement",
5
+ "title": "Document anchors accept relative links",
6
+ "detail": "An anchor href in a document body may be relative (/path, ./, ../, #, ?); never // or a backslash. Other URL attributes (image src and similar) and mail links still accept only explicit schemes or placeholders. The sanitizer, the editor schema, compileDocumentBody and the confirmation mail use the same split."
7
+ },
8
+ {
9
+ "version": "0.402.0",
10
+ "type": "improvement",
11
+ "title": "Collection nav takes entryLabel for the create title",
12
+ "detail": "A collection's nav takes an optional entryLabel (singular i18n key, new in the nav type and in the required surface keys of all locale packages). The create screen title reads \"New {label}\", or \"New entry\" without it."
13
+ },
2
14
  {
3
15
  "version": "0.395.0",
4
16
  "type": "breaking",