@cosmicdrift/kumiko-bundled-features 0.394.0 → 0.395.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 (78) hide show
  1. package/dist/inbound-provider-imap/feature.js +2 -91
  2. package/dist/inbound-provider-imap/watch-mailbox.d.ts +5 -0
  3. package/dist/inbound-provider-imap/watch-mailbox.js +92 -0
  4. package/dist/managed-pages/constants.d.ts +1 -0
  5. package/dist/managed-pages/constants.js +1 -0
  6. package/dist/managed-pages/feature.js +5 -3
  7. package/dist/managed-pages/handlers/by-slug.query.d.ts +1 -0
  8. package/dist/managed-pages/handlers/by-slug.query.js +2 -0
  9. package/dist/managed-pages/handlers/page-body-guard.d.ts +2 -0
  10. package/dist/managed-pages/handlers/page-body-guard.js +46 -0
  11. package/dist/managed-pages/handlers/page-body-issues.d.ts +10 -0
  12. package/dist/managed-pages/handlers/page-body-issues.js +21 -0
  13. package/dist/managed-pages/handlers/page-crud.js +5 -4
  14. package/dist/managed-pages/handlers/set.write.d.ts +4 -0
  15. package/dist/managed-pages/handlers/set.write.js +16 -4
  16. package/dist/managed-pages/render-page-body.d.ts +10 -0
  17. package/dist/managed-pages/render-page-body.js +19 -0
  18. package/dist/managed-pages/screens/page-screens.js +6 -2
  19. package/dist/managed-pages/seeding.d.ts +2 -0
  20. package/dist/managed-pages/seeding.js +12 -2
  21. package/dist/managed-pages/table.d.ts +46 -0
  22. package/dist/managed-pages/table.js +5 -3
  23. package/dist/managed-pages/web/client-plugin.js +6 -4
  24. package/dist/managed-pages/web/constants.d.ts +5 -0
  25. package/dist/managed-pages/web/constants.js +9 -0
  26. package/dist/managed-pages/web/i18n.js +12 -0
  27. package/dist/managed-pages/web/lossy-markdown.d.ts +3 -0
  28. package/dist/managed-pages/web/lossy-markdown.js +59 -0
  29. package/dist/managed-pages/web/markdown-to-document.d.ts +1 -0
  30. package/dist/managed-pages/web/markdown-to-document.js +26 -0
  31. package/dist/managed-pages/web/page-body-section.d.ts +3 -0
  32. package/dist/managed-pages/web/page-body-section.js +41 -0
  33. package/dist/template-resolver/document-sanitizer.d.ts +6 -2
  34. package/dist/template-resolver/document-sanitizer.js +5 -4
  35. package/dist/template-resolver/feature.js +12 -1
  36. package/dist/template-resolver/handlers/collection-set.write.d.ts +1 -6
  37. package/dist/template-resolver/handlers/collection-set.write.js +21 -6
  38. package/dist/template-resolver/handlers/collection-shared.d.ts +2 -0
  39. package/dist/template-resolver/handlers/collection-shared.js +5 -0
  40. package/dist/template-resolver/handlers/set.write.js +1 -0
  41. package/dist/template-resolver/handlers/shared.js +3 -3
  42. package/dist/template-resolver/web/client-plugin.d.ts +0 -15
  43. package/dist/template-resolver/web/client-plugin.js +8 -363
  44. package/dist/template-resolver/web/collection-entry-create-form.d.ts +11 -0
  45. package/dist/template-resolver/web/collection-entry-create-form.js +31 -0
  46. package/dist/template-resolver/web/collection-lookup.d.ts +4 -0
  47. package/dist/template-resolver/web/collection-lookup.js +24 -0
  48. package/dist/template-resolver/web/constants.d.ts +2 -0
  49. package/dist/template-resolver/web/constants.js +2 -0
  50. package/dist/template-resolver/web/content-field.d.ts +9 -0
  51. package/dist/template-resolver/web/content-field.js +14 -0
  52. package/dist/template-resolver/web/content-tree-provider.d.ts +14 -0
  53. package/dist/template-resolver/web/content-tree-provider.js +197 -0
  54. package/dist/template-resolver/web/document-reference-name.d.ts +1 -0
  55. package/dist/template-resolver/web/document-reference-name.js +9 -0
  56. package/dist/template-resolver/web/document-schema.d.ts +2 -1
  57. package/dist/template-resolver/web/document-schema.js +2 -8
  58. package/dist/template-resolver/web/i18n.js +6 -0
  59. package/dist/template-resolver/web/legacy-variable-text.d.ts +1 -0
  60. package/dist/template-resolver/web/legacy-variable-text.js +23 -0
  61. package/dist/template-resolver/web/tenant-override.d.ts +3 -0
  62. package/dist/template-resolver/web/tenant-override.js +4 -0
  63. package/dist/template-resolver/web/text-block-editor.d.ts +6 -0
  64. package/dist/template-resolver/web/text-block-editor.js +22 -0
  65. package/dist/template-resolver/web/use-collection-editor-context.d.ts +14 -0
  66. package/dist/template-resolver/web/use-collection-editor-context.js +21 -0
  67. package/dist/template-resolver/web/use-collection-entry-create.d.ts +18 -0
  68. package/dist/template-resolver/web/use-collection-entry-create.js +78 -0
  69. package/dist/template-resolver/web/use-content-write.d.ts +17 -0
  70. package/dist/template-resolver/web/use-content-write.js +54 -0
  71. package/dist/template-resolver/web/use-text-block-editor.d.ts +19 -0
  72. package/dist/template-resolver/web/use-text-block-editor.js +85 -0
  73. package/dist/tier-engine/feature.js +9 -1
  74. package/package.json +11 -11
  75. package/src/inbound-provider-imap/changes.json +6 -0
  76. package/src/managed-pages/changes.json +7 -0
  77. package/src/template-resolver/changes.json +8 -0
  78. package/src/tier-engine/changes.json +6 -0
@@ -35,11 +35,12 @@ import { downloadBatch } from "./download-batch.js";
35
35
  import { fetchMessageContent } from "./fetch-content.js";
36
36
  import { INBOUND_PROVIDER_IMAP_I18N } from "./i18n.js";
37
37
  import { assertUidValidity, closeOnAbort, connectOnce, createImapClient, IMAP_MAILBOX, imapMailHostGuard, mapImapError, parseImapCursor, } from "./imap-client.js";
38
- import { buildProviderMessageId, coerceDate, parseOrReject } from "./parse-message.js";
38
+ import { buildProviderMessageId } from "./parse-message.js";
39
39
  import { createReconnectMailboxHandler } from "./reconnect-mailbox.js";
40
40
  import { createReconnectPrefillQuery } from "./reconnect-prefill.query.js";
41
41
  import { createImapScreens } from "./screens.js";
42
42
  import { sendFromImapAccount } from "./send-mail.js";
43
+ import { watchMailbox } from "./watch-mailbox.js";
43
44
  export { IMAP_PROVIDER_KEY } from "./constants.js";
44
45
  export { setImapMailHostLookup } from "./imap-client.js";
45
46
  // =============================================================================
@@ -102,96 +103,6 @@ async function fetchMessages(ctx, account, cursor, opts) {
102
103
  }
103
104
  }
104
105
  // =============================================================================
105
- // watch — IMAP IDLE (imapflow idles automatically while the mailbox is open
106
- // and emits 'exists' for new messages).
107
- // =============================================================================
108
- async function watchMailbox(ctx, account, handlers) {
109
- const doc = await readCredentialDocument(ctx, account);
110
- const client = await createImapClient(doc, imapMailHostGuard());
111
- let stopped = false;
112
- try {
113
- await client.connect();
114
- const mailbox = (await client.mailboxOpen(IMAP_MAILBOX));
115
- const uidValidity = String(mailbox.uidValidity);
116
- // uidNext is the next UID to be assigned, so everything from here is
117
- // "new".
118
- let nextUid = Number(mailbox.uidNext ?? 1);
119
- const drainNew = async () => {
120
- const messages = [];
121
- const rejected = [];
122
- let maxSeen = nextUid - 1;
123
- for await (const msg of client.fetch(`${nextUid}:*`, { uid: true, internalDate: true, source: true }, { uid: true })) {
124
- // `N:*` gotcha (see fetch): filter out the previous last message.
125
- if (msg.uid < nextUid || !msg.source)
126
- continue;
127
- const parsed = await parseOrReject({
128
- source: msg.source,
129
- uid: msg.uid,
130
- uidValidity,
131
- internalDate: coerceDate(msg.internalDate ?? undefined),
132
- trustedAuthservId: account.trustedAuthservId ?? null,
133
- });
134
- if ("message" in parsed)
135
- messages.push(parsed.message);
136
- else
137
- rejected.push(parsed.rejected);
138
- if (msg.uid > maxSeen)
139
- maxSeen = msg.uid;
140
- }
141
- nextUid = maxSeen + 1;
142
- if (messages.length > 0 || rejected.length > 0)
143
- await handlers.onMessages(messages, rejected);
144
- };
145
- client.on("exists", () => {
146
- // skip: ignore delayed exists events after the watcher has stopped.
147
- if (stopped)
148
- return;
149
- void drainNew().catch((err) => {
150
- if (!stopped)
151
- handlers.onError(mapImapError(err, doc.host));
152
- });
153
- });
154
- const die = (err) => {
155
- // skip: already stopped or terminated; fire onError only once.
156
- if (stopped)
157
- return;
158
- stopped = true;
159
- handlers.onError(mapImapError(err, doc.host));
160
- };
161
- client.on("error", die);
162
- client.on("close", () => die(new Error("connection closed")));
163
- // IDLE loop: 'exists' fires ONLY while client.idle() is running
164
- // (verified empirically against greenmail). Each intervening command
165
- // (drainNew-fetch) ends idle(), so the loop re-enters idle() until
166
- // stop() closes the connection.
167
- void (async () => {
168
- while (!stopped) {
169
- try {
170
- await client.idle();
171
- }
172
- catch (err) {
173
- if (!stopped)
174
- die(err);
175
- // skip: the idle loop ends; die() has informed the supervisor (or stop()
176
- // was the cause).
177
- return;
178
- }
179
- }
180
- })();
181
- return async () => {
182
- stopped = true;
183
- client.removeAllListeners("exists");
184
- client.removeAllListeners("error");
185
- client.removeAllListeners("close");
186
- await client.logout().catch(() => { });
187
- };
188
- }
189
- catch (err) {
190
- await client.logout().catch(() => { });
191
- throw mapImapError(err, doc.host);
192
- }
193
- }
194
- // =============================================================================
195
106
  // Plugin + Feature-definition
196
107
  // =============================================================================
197
108
  /** Exported for the live test (imap-live.integration.test.ts) —
@@ -0,0 +1,5 @@
1
+ import type { InboundMailContext, MailAccountRecord, RawInboundMessage, RejectedInboundMessage } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
2
+ export declare function watchMailbox(ctx: InboundMailContext, account: MailAccountRecord, handlers: {
3
+ readonly onMessages: (msgs: readonly RawInboundMessage[], rejected?: readonly RejectedInboundMessage[]) => Promise<void>;
4
+ readonly onError: (err: unknown) => void;
5
+ }): Promise<() => Promise<void>>;
@@ -0,0 +1,92 @@
1
+ import { readCredentialDocument } from "./credential-document.js";
2
+ import { createImapClient, IMAP_MAILBOX, imapMailHostGuard, mapImapError } from "./imap-client.js";
3
+ import { coerceDate, parseOrReject } from "./parse-message.js";
4
+ // IMAP IDLE (imapflow idles automatically while the mailbox is open
5
+ // and emits 'exists' for new messages).
6
+ export async function watchMailbox(ctx, account, handlers) {
7
+ const doc = await readCredentialDocument(ctx, account);
8
+ const client = await createImapClient(doc, imapMailHostGuard());
9
+ let stopped = false;
10
+ try {
11
+ await client.connect();
12
+ const mailbox = await client.mailboxOpen(IMAP_MAILBOX);
13
+ const uidValidity = String(mailbox.uidValidity);
14
+ // uidNext is the next UID to be assigned, so everything from here is
15
+ // "new".
16
+ let nextUid = Number(mailbox.uidNext ?? 1);
17
+ const drainNew = async () => {
18
+ const messages = [];
19
+ const rejected = [];
20
+ let maxSeen = nextUid - 1;
21
+ for await (const msg of client.fetch(`${nextUid}:*`, { uid: true, internalDate: true, source: true, envelope: true }, { uid: true })) {
22
+ // `N:*` gotcha (see fetch): filter out the previous last message.
23
+ if (msg.uid < nextUid || !msg.source)
24
+ continue;
25
+ const parsed = await parseOrReject({
26
+ source: msg.source,
27
+ envelope: msg.envelope,
28
+ uid: msg.uid,
29
+ uidValidity,
30
+ internalDate: coerceDate(msg.internalDate ?? undefined),
31
+ trustedAuthservId: account.trustedAuthservId ?? null,
32
+ });
33
+ if ("message" in parsed)
34
+ messages.push(parsed.message);
35
+ else
36
+ rejected.push(parsed.rejected);
37
+ if (msg.uid > maxSeen)
38
+ maxSeen = msg.uid;
39
+ }
40
+ nextUid = maxSeen + 1;
41
+ if (messages.length > 0 || rejected.length > 0)
42
+ await handlers.onMessages(messages, rejected);
43
+ };
44
+ client.on("exists", () => {
45
+ // skip: ignore delayed exists events after the watcher has stopped.
46
+ if (stopped)
47
+ return;
48
+ void drainNew().catch((err) => {
49
+ if (!stopped)
50
+ handlers.onError(mapImapError(err, doc.host));
51
+ });
52
+ });
53
+ const die = (err) => {
54
+ // skip: already stopped or terminated; fire onError only once.
55
+ if (stopped)
56
+ return;
57
+ stopped = true;
58
+ handlers.onError(mapImapError(err, doc.host));
59
+ };
60
+ client.on("error", die);
61
+ client.on("close", () => die(new Error("connection closed")));
62
+ // IDLE loop: 'exists' fires ONLY while client.idle() is running
63
+ // (verified empirically against greenmail). Each intervening command
64
+ // (drainNew-fetch) ends idle(), so the loop re-enters idle() until
65
+ // stop() closes the connection.
66
+ void (async () => {
67
+ while (!stopped) {
68
+ try {
69
+ await client.idle();
70
+ }
71
+ catch (err) {
72
+ if (!stopped)
73
+ die(err);
74
+ // skip: the idle loop ends; die() has informed the supervisor (or stop()
75
+ // was the cause).
76
+ return;
77
+ }
78
+ }
79
+ })();
80
+ return async () => {
81
+ stopped = true;
82
+ client.removeAllListeners("exists");
83
+ client.removeAllListeners("error");
84
+ client.removeAllListeners("close");
85
+ await client.logout().catch(() => { });
86
+ };
87
+ }
88
+ catch (err) {
89
+ await client.logout().catch(() => { });
90
+ throw mapImapError(err, doc.host);
91
+ }
92
+ }
@@ -1 +1,2 @@
1
1
  export declare const MANAGED_PAGES_FEATURE: "managed-pages";
2
+ export { BODY_FORMATS, type BodyFormat, DEFAULT_BODY_FORMAT, effectiveBodyFormat, } from "./web/constants.ts";
@@ -1 +1,2 @@
1
1
  export const MANAGED_PAGES_FEATURE = "managed-pages";
2
+ export { BODY_FORMATS, DEFAULT_BODY_FORMAT, effectiveBodyFormat, } from "./web/constants.js";
@@ -1,7 +1,7 @@
1
1
  import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
4
- import { cachedSecurePageResponse, EMPTY_BRANDING, renderSafeMarkdown, wrapInLayout, } from "../page-render/index.js";
4
+ import { cachedSecurePageResponse, EMPTY_BRANDING, wrapInLayout, } from "../page-render/index.js";
5
5
  import { rateLimitedTextResponse } from "../shared/index.js";
6
6
  import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding.js";
7
7
  import { MANAGED_PAGES_FEATURE } from "./constants.js";
@@ -10,6 +10,7 @@ import { bySlugQuery } from "./handlers/by-slug.query.js";
10
10
  import { byTenantPublishedQuery } from "./handlers/by-tenant-published.query.js";
11
11
  import { pageCrudQueries, pageCrudWrites } from "./handlers/page-crud.js";
12
12
  import { setWrite } from "./handlers/set.write.js";
13
+ import { renderPageBody } from "./render-page-body.js";
13
14
  import { createBrandingSettingsScreen } from "./screens/branding-screen.js";
14
15
  import { pageEditScreen, pageListScreen } from "./screens/page-screens.js";
15
16
  import { pageEntity } from "./table.js";
@@ -82,7 +83,7 @@ export function createManagedPagesFeature(opts) {
82
83
  const allowCustomCss = opts.allowCustomCss ?? false;
83
84
  return defineFeature(MANAGED_PAGES_FEATURE, (r) => {
84
85
  r.adoptsBundledLists();
85
- r.describe("Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.");
86
+ r.describe("Tenant-editable, server-rendered public pages with per-tenant branding. Stores one `page` (Markdown or editor-document HTML body, per `bodyFormat`) per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core and document bodies through the template-resolver document sanitizer, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.");
86
87
  r.uiHints({
87
88
  displayLabel: "Managed Pages · Public CMS",
88
89
  category: "content",
@@ -163,6 +164,7 @@ export function createManagedPagesFeature(opts) {
163
164
  slug,
164
165
  lang,
165
166
  String(data.version),
167
+ data.bodyFormat,
166
168
  data.updatedAt,
167
169
  brandingRevisionSeed(branding),
168
170
  ]);
@@ -182,7 +184,7 @@ export function createManagedPagesFeature(opts) {
182
184
  }
183
185
  const html = wrapLayout({
184
186
  title: data.title,
185
- bodyHtml: renderSafeMarkdown(data.body),
187
+ bodyHtml: renderPageBody({ slug, body: data.body, bodyFormat: data.bodyFormat }),
186
188
  lang: data.lang,
187
189
  slug,
188
190
  description: data.description,
@@ -7,6 +7,7 @@ export declare const bySlugQuery: import("@cosmicdrift/kumiko-types/define-handl
7
7
  lang: string;
8
8
  title: string;
9
9
  body: string;
10
+ bodyFormat: "document" | "markdown";
10
11
  description: string | null;
11
12
  ogImage: string | null;
12
13
  version: number;
@@ -1,6 +1,7 @@
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 { effectiveBodyFormat } from "../constants.js";
4
5
  import { pagesTable } from "../table.js";
5
6
  // Public read by (tenantId, slug, lang), including anonymous access for landing and
6
7
  // marketing pages. The tenant comes from query.user.tenantId, set on the render path
@@ -29,6 +30,7 @@ export const bySlugQuery = defineQueryHandler({
29
30
  lang: row.lang,
30
31
  title: row.title,
31
32
  body: row.body,
33
+ bodyFormat: effectiveBodyFormat(row.bodyFormat),
32
34
  description: row.description,
33
35
  ogImage: row.ogImage,
34
36
  version: row.version,
@@ -0,0 +1,2 @@
1
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare function withBodyValidation(def: WriteHandlerDef, verb: "create" | "update"): WriteHandlerDef;
@@ -0,0 +1,46 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import { ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
+ import { pagesTable } from "../table.js";
5
+ import { sanitizePageBody } from "./page-body-issues.js";
6
+ function stringOrNull(value) {
7
+ return typeof value === "string" ? value : null;
8
+ }
9
+ // preSave hooks cannot report field errors (the executor maps every throw to
10
+ // presave_hook_failed), so the convention handlers are checked before they run.
11
+ // An update may touch only body or only bodyFormat, so the pair is completed
12
+ // from the stored row. A document body is handed on as the rebuilt html.
13
+ async function guardUpdate(payload, ctx) {
14
+ if (!isPlainObject(payload) || !isPlainObject(payload["changes"]))
15
+ return { ok: true, payload };
16
+ const changes = payload["changes"];
17
+ if (!("body" in changes) && !("bodyFormat" in changes))
18
+ return { ok: true, payload };
19
+ const id = payload["id"];
20
+ const stored = typeof id === "string" ? await fetchOne(ctx.db, pagesTable, { id }) : null;
21
+ const body = "body" in changes ? stringOrNull(changes["body"]) : (stored?.body ?? null);
22
+ const format = "bodyFormat" in changes ? changes["bodyFormat"] : stored?.bodyFormat;
23
+ const result = sanitizePageBody(body, format);
24
+ if (!result.ok)
25
+ return result;
26
+ return { ok: true, payload: { ...payload, changes: { ...changes, body: result.body } } };
27
+ }
28
+ function guardCreate(payload) {
29
+ if (!isPlainObject(payload))
30
+ return { ok: true, payload };
31
+ const result = sanitizePageBody(stringOrNull(payload["body"]), payload["bodyFormat"]);
32
+ if (!result.ok)
33
+ return result;
34
+ return { ok: true, payload: { ...payload, body: result.body } };
35
+ }
36
+ export function withBodyValidation(def, verb) {
37
+ return {
38
+ ...def,
39
+ handler: async (event, ctx) => {
40
+ const guarded = verb === "create" ? guardCreate(event.payload) : await guardUpdate(event.payload, ctx);
41
+ if (!guarded.ok)
42
+ return writeFailure(new ValidationError({ fields: guarded.issues }));
43
+ return def.handler({ ...event, payload: guarded.payload }, ctx);
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,10 @@
1
+ import type { FieldIssue } from "@cosmicdrift/kumiko-framework/errors";
2
+ export declare const BODY_REJECTED_I18N_KEY = "managed-pages.errors.document.rejected";
3
+ export type PageBodyResult = {
4
+ readonly ok: true;
5
+ readonly body: string | null;
6
+ } | {
7
+ readonly ok: false;
8
+ readonly issues: FieldIssue[];
9
+ };
10
+ export declare function sanitizePageBody(body: string | null | undefined, format: unknown): PageBodyResult;
@@ -0,0 +1,21 @@
1
+ import { sanitizeDocumentHtml } from "../../template-resolver/index.js";
2
+ import { effectiveBodyFormat } from "../constants.js";
3
+ export const BODY_REJECTED_I18N_KEY = "managed-pages.errors.document.rejected";
4
+ // The single write-time gate for document bodies (create, update, set, seed): a
5
+ // document body is stored as the sanitizer's rebuilt html, never as the raw input.
6
+ export function sanitizePageBody(body, format) {
7
+ if (effectiveBodyFormat(format) !== "document" || !body)
8
+ return { ok: true, body: body ?? null };
9
+ const result = sanitizeDocumentHtml(body);
10
+ if (result.ok)
11
+ return { ok: true, body: result.html };
12
+ return {
13
+ ok: false,
14
+ issues: result.violations.map((violation) => ({
15
+ path: "body",
16
+ code: violation.code,
17
+ i18nKey: BODY_REJECTED_I18N_KEY,
18
+ params: { detail: violation.detail },
19
+ })),
20
+ };
21
+ }
@@ -1,5 +1,6 @@
1
1
  import { defineEntityCreateHandler, defineEntityDeleteHandler, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { pageEntity } from "../table.js";
3
+ import { withBodyValidation } from "./page-body-guard.js";
3
4
  // Admin authoring runs as TenantAdmin (self-service) or SystemAdmin (app-wide
4
5
  // pages). Mirrors set.write's ACL — an app with its own role alias
5
6
  // (publicstatus = "Admin") has to grant or map TenantAdmin.
@@ -9,14 +10,14 @@ const ADMIN_ACCESS = { roles: ["TenantAdmin", "SystemAdmin"] };
9
10
  // `managed-pages:query:page:{list,detail}` by convention. The `set` write
10
11
  // handler in feature.ts is NOT used by them and stays the provisioning API.
11
12
  export const pageCrudWrites = [
12
- defineEntityCreateHandler("page", pageEntity, {
13
+ withBodyValidation(defineEntityCreateHandler("page", pageEntity, {
13
14
  access: ADMIN_ACCESS,
14
15
  description: "Creates a managed page from the admin form's field values, failing if that slug and language already exist; use it from the page catalog screen, whereas managed-pages:write:set upserts a page addressed by slug and language.",
15
- }),
16
- defineEntityUpdateHandler("page", pageEntity, {
16
+ }), "create"),
17
+ withBodyValidation(defineEntityUpdateHandler("page", pageEntity, {
17
18
  access: ADMIN_ACCESS,
18
19
  description: "Updates one managed page addressed by row id from the admin form's `{ id, version, changes }` envelope; use it from the page edit screen, whereas managed-pages:write:set addresses a page by slug and language instead.",
19
- }),
20
+ }), "update"),
20
21
  defineEntityDeleteHandler("page", pageEntity, {
21
22
  access: ADMIN_ACCESS,
22
23
  description: "Deletes one managed page by row id so its URL stops resolving entirely; use it to retire a page for good, not to take it offline temporarily — that is a published:false write through managed-pages:write:set.",
@@ -4,6 +4,10 @@ export declare const setWrite: import("@cosmicdrift/kumiko-types/define-handler"
4
4
  lang: z.ZodString;
5
5
  title: z.ZodString;
6
6
  body: z.ZodNullable<z.ZodString>;
7
+ bodyFormat: z.ZodOptional<z.ZodEnum<{
8
+ document: "document";
9
+ markdown: "markdown";
10
+ }>>;
7
11
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
8
12
  ogImage: z.ZodOptional<z.ZodNullable<z.ZodString>>;
9
13
  published: z.ZodOptional<z.ZodBoolean>;
@@ -1,8 +1,11 @@
1
1
  import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { ValidationError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import * as z from "zod";
4
5
  import { requireForTenant } from "../../shared/index.js";
6
+ import { BODY_FORMATS, DEFAULT_BODY_FORMAT } from "../constants.js";
5
7
  import { pageEntity, pagesTable } from "../table.js";
8
+ import { sanitizePageBody } from "./page-body-issues.js";
6
9
  const slugSchema = z
7
10
  .string()
8
11
  .min(1)
@@ -17,7 +20,8 @@ const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName:
17
20
  // Upserts one page per (tenantId, slug, lang). The tenant scope defaults to event.user;
18
21
  // SystemAdmin can write to another tenant via `tenantIdOverride` (typically SYSTEM_TENANT_ID
19
22
  // for app-wide pages). On update, omitted published/description/ogImage values are preserved
20
- // so a publish toggle does not overwrite the body, and vice versa.
23
+ // so a publish toggle does not overwrite the body, and vice versa. bodyFormat is the
24
+ // exception: omitted means markdown, so provisioning callers that only know markdown stay valid.
21
25
  export const setWrite = defineWriteHandler({
22
26
  name: "set",
23
27
  schema: z.object({
@@ -25,6 +29,7 @@ export const setWrite = defineWriteHandler({
25
29
  lang: langSchema,
26
30
  title: z.string().min(1).max(200),
27
31
  body: z.string().max(100_000).nullable(),
32
+ bodyFormat: z.enum(BODY_FORMATS).optional(),
28
33
  description: z.string().max(500).nullable().optional(),
29
34
  ogImage: z.string().max(2000).nullable().optional(),
30
35
  published: z.boolean().optional(),
@@ -32,10 +37,15 @@ export const setWrite = defineWriteHandler({
32
37
  tenantIdOverride: z.string().min(1).optional(),
33
38
  }),
34
39
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
35
- description: "Creates or overwrites one managed page addressed by slug and language, keeping the existing published flag, description and OG image when the payload omits them; use it for content edits and publish toggles, and as SystemAdmin to write another tenant's pages.",
40
+ description: "Creates or overwrites one managed page addressed by slug and language, keeping the existing published flag, description and OG image when the payload omits them and resetting bodyFormat to markdown (the default) when it is omitted; use it for content edits and publish toggles, and as SystemAdmin to write another tenant's pages.",
36
41
  handler: async (event, ctx) => {
37
42
  const override = event.payload.tenantIdOverride;
38
43
  const tenantId = (override ?? event.user.tenantId); // @cast-boundary engine-bridge
44
+ const bodyFormat = event.payload.bodyFormat ?? DEFAULT_BODY_FORMAT;
45
+ const sanitized = sanitizePageBody(event.payload.body, bodyFormat);
46
+ if (!sanitized.ok)
47
+ return writeFailure(new ValidationError({ fields: sanitized.issues }));
48
+ const { body } = sanitized;
39
49
  // ctx.db is scoped to the executing user's tenant, wrong for a cross-tenant override
40
50
  // on both the existing-check and the executor's stream reads; re-scope to the target.
41
51
  const { db: scopedDb, streamTenantId } = requireForTenant(ctx, tenantId);
@@ -50,7 +60,8 @@ export const setWrite = defineWriteHandler({
50
60
  version: existing.version,
51
61
  changes: {
52
62
  title: event.payload.title,
53
- body: event.payload.body,
63
+ body,
64
+ bodyFormat,
54
65
  description: event.payload.description !== undefined
55
66
  ? event.payload.description
56
67
  : existing.description,
@@ -69,7 +80,8 @@ export const setWrite = defineWriteHandler({
69
80
  slug: event.payload.slug,
70
81
  lang: event.payload.lang,
71
82
  title: event.payload.title,
72
- body: event.payload.body,
83
+ body,
84
+ bodyFormat,
73
85
  description: event.payload.description ?? null,
74
86
  ogImage: event.payload.ogImage ?? null,
75
87
  published: event.payload.published ?? false,
@@ -0,0 +1,10 @@
1
+ import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
2
+ import type { BodyFormat } from "./constants.ts";
3
+ type WarnLogger = Pick<Logger, "warn">;
4
+ export type RenderablePageBody = {
5
+ readonly slug: string;
6
+ readonly body: string;
7
+ readonly bodyFormat: BodyFormat;
8
+ };
9
+ export declare function renderPageBody(page: RenderablePageBody, log?: WarnLogger): string;
10
+ export {};
@@ -0,0 +1,19 @@
1
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
2
+ import { renderSafeMarkdown } from "../page-render/index.js";
3
+ import { sanitizeDocumentHtml } from "../template-resolver/index.js";
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 "";
19
+ }
@@ -1,3 +1,4 @@
1
+ import { PAGE_BODY_EXTENSION_NAME } from "../web/constants.js";
1
2
  // Admin authoring screens for the `page` entity. These are JSON-safe data only —
2
3
  // the generic DataTable/form renderer mounts them, so no React component is
3
4
  // needed. Both MUST be registered in the managed-pages feature (not in the
@@ -70,10 +71,13 @@ export const pageEditScreen = {
70
71
  { field: "published", span: 1 },
71
72
  ],
72
73
  },
74
+ // Not a plain field: the body is edited as a document and a Markdown
75
+ // body is converted on open, so the section also patches `bodyFormat`.
73
76
  {
77
+ kind: "extension",
74
78
  title: "managed-pages:section.body",
75
- columns: 1,
76
- fields: [{ field: "body", span: 1 }],
79
+ component: { react: { __component: PAGE_BODY_EXTENSION_NAME } },
80
+ entityName: "page",
77
81
  },
78
82
  ],
79
83
  },
@@ -1,12 +1,14 @@
1
1
  import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { type SessionUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
4
+ import { type BodyFormat } from "./constants.ts";
4
5
  export type SeedPageOptions = {
5
6
  readonly tenantId: TenantId;
6
7
  readonly slug: string;
7
8
  readonly lang: string;
8
9
  readonly title: string;
9
10
  readonly body?: string | null;
11
+ readonly bodyFormat?: BodyFormat;
10
12
  readonly description?: string | null;
11
13
  readonly ogImage?: string | null;
12
14
  readonly published?: boolean;
@@ -4,6 +4,8 @@
4
4
  import { createEventStoreExecutor, createTenantDb, fetchOne, } from "@cosmicdrift/kumiko-framework/db";
5
5
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { runEventStoreSeed } from "@cosmicdrift/kumiko-framework/seeding";
7
+ import { DEFAULT_BODY_FORMAT } from "./constants.js";
8
+ import { sanitizePageBody } from "./handlers/page-body-issues.js";
7
9
  import { pageEntity, pagesTable } from "./table.js";
8
10
  const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
9
11
  export async function seedPage(db, opts) {
@@ -25,6 +27,12 @@ export async function seedPage(db, opts) {
25
27
  const description = opts.description ?? null;
26
28
  const ogImage = opts.ogImage ?? null;
27
29
  const published = opts.published ?? false;
30
+ const bodyFormat = opts.bodyFormat ?? DEFAULT_BODY_FORMAT;
31
+ const sanitized = sanitizePageBody(opts.body, bodyFormat);
32
+ if (!sanitized.ok) {
33
+ throw new Error(`seedPage rejected the body: ${JSON.stringify(sanitized.issues)}`);
34
+ }
35
+ const { body } = sanitized;
28
36
  return runEventStoreSeed({
29
37
  existing,
30
38
  ifExists: opts.ifExists,
@@ -33,7 +41,8 @@ export async function seedPage(db, opts) {
33
41
  slug: opts.slug,
34
42
  lang: opts.lang,
35
43
  title: opts.title,
36
- body: opts.body ?? null,
44
+ body,
45
+ bodyFormat,
37
46
  description,
38
47
  ogImage,
39
48
  published,
@@ -56,7 +65,8 @@ export async function seedPage(db, opts) {
56
65
  version: row.version,
57
66
  changes: {
58
67
  title: opts.title,
59
- body: opts.body ?? null,
68
+ body,
69
+ bodyFormat,
60
70
  description,
61
71
  ogImage,
62
72
  published,