@cosmicdrift/kumiko-samples 0.394.0 → 0.396.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 (53) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  4. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +3 -104
  5. package/packages/bundled-features/src/inbound-provider-imap/watch-mailbox.ts +109 -0
  6. package/packages/bundled-features/src/managed-pages/changes.json +7 -0
  7. package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
  8. package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
  9. package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
  10. package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
  11. package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
  12. package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
  13. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
  14. package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
  15. package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
  16. package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
  17. package/packages/bundled-features/src/managed-pages/table.ts +6 -2
  18. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
  19. package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
  20. package/packages/bundled-features/src/managed-pages/web/i18n.ts +13 -0
  21. package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
  22. package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
  23. package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -0
  24. package/packages/bundled-features/src/template-resolver/README.md +2 -2
  25. package/packages/bundled-features/src/template-resolver/changes.json +8 -0
  26. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
  27. package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
  28. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +24 -6
  29. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +7 -0
  30. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
  31. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
  32. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +8 -576
  33. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
  34. package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
  35. package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
  36. package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
  37. package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
  38. package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
  39. package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
  40. package/packages/bundled-features/src/template-resolver/web/i18n.ts +8 -0
  41. package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
  42. package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
  43. package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
  44. package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
  45. package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
  46. package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
  47. package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
  48. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  49. package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
  50. package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
  51. package/samples/apps/use-all-bundled/src/app/client.tsx +6 -1
  52. package/samples/apps/use-all-bundled/src/app/seed.ts +13 -1
  53. package/samples/apps/use-all-bundled/src/run-config.ts +12 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.394.0",
3
+ "version": "0.396.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.394.0",
3
+ "version": "0.396.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>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.395.0",
4
+ "type": "fix",
5
+ "title": "A mail the IMAP watch quarantines keeps its sender and subject",
6
+ "detail": "The live watch fetched new mails without the IMAP envelope, so an unparseable mail it quarantined showed no sender and no subject in the quarantine list. The watch now fetches the envelope like the poll does."
7
+ },
2
8
  {
3
9
  "version": "0.394.0",
4
10
  "type": "improvement",
@@ -32,15 +32,13 @@ import {
32
32
  type InboundMailProviderPlugin,
33
33
  type InboundMailRoleOptions,
34
34
  type MailAccountRecord,
35
- type RawInboundMessage,
36
- type RejectedInboundMessage,
37
35
  resolveInboundMailRoles,
38
36
  roleDedupeOptions,
39
37
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
40
38
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
41
39
  import { instantToLegacyDate } from "@cosmicdrift/kumiko-framework/time";
42
40
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
43
- import type { ImapFlow, MailboxObject } from "imapflow";
41
+ import type { MailboxObject } from "imapflow";
44
42
  import { createConnectMailboxHandler } from "./connect-mailbox.ts";
45
43
  import { createConnectMyMailboxHandler } from "./connect-my-mailbox.ts";
46
44
  import { FEATURE_NAME, IMAP_PROVIDER_KEY } from "./constants.ts";
@@ -58,11 +56,12 @@ import {
58
56
  mapImapError,
59
57
  parseImapCursor,
60
58
  } from "./imap-client.ts";
61
- import { buildProviderMessageId, coerceDate, parseOrReject } from "./parse-message.ts";
59
+ import { buildProviderMessageId } from "./parse-message.ts";
62
60
  import { createReconnectMailboxHandler } from "./reconnect-mailbox.ts";
63
61
  import { createReconnectPrefillQuery } from "./reconnect-prefill.query.ts";
64
62
  import { createImapScreens } from "./screens.ts";
65
63
  import { sendFromImapAccount } from "./send-mail.ts";
64
+ import { watchMailbox } from "./watch-mailbox.ts";
66
65
 
67
66
  export { IMAP_PROVIDER_KEY } from "./constants.ts";
68
67
 
@@ -151,106 +150,6 @@ async function fetchMessages(
151
150
  }
152
151
  }
153
152
 
154
- // =============================================================================
155
- // watch — IMAP IDLE (imapflow idles automatically while the mailbox is open
156
- // and emits 'exists' for new messages).
157
- // =============================================================================
158
-
159
- async function watchMailbox(
160
- ctx: InboundMailContext,
161
- account: MailAccountRecord,
162
- handlers: {
163
- readonly onMessages: (
164
- msgs: readonly RawInboundMessage[],
165
- rejected?: readonly RejectedInboundMessage[],
166
- ) => Promise<void>;
167
- readonly onError: (err: unknown) => void;
168
- },
169
- ): Promise<() => Promise<void>> {
170
- const doc = await readCredentialDocument(ctx, account);
171
- const client: ImapFlow = await createImapClient(doc, imapMailHostGuard());
172
- let stopped = false;
173
-
174
- try {
175
- await client.connect();
176
- const mailbox = (await client.mailboxOpen(IMAP_MAILBOX)) as MailboxObject;
177
- const uidValidity = String(mailbox.uidValidity);
178
- // uidNext is the next UID to be assigned, so everything from here is
179
- // "new".
180
- let nextUid = Number(mailbox.uidNext ?? 1);
181
-
182
- const drainNew = async () => {
183
- const messages: RawInboundMessage[] = [];
184
- const rejected: RejectedInboundMessage[] = [];
185
- let maxSeen = nextUid - 1;
186
- for await (const msg of client.fetch(
187
- `${nextUid}:*`,
188
- { uid: true, internalDate: true, source: true },
189
- { uid: true },
190
- )) {
191
- // `N:*` gotcha (see fetch): filter out the previous last message.
192
- if (msg.uid < nextUid || !msg.source) continue;
193
- const parsed = await parseOrReject({
194
- source: msg.source,
195
- uid: msg.uid,
196
- uidValidity,
197
- internalDate: coerceDate(msg.internalDate ?? undefined),
198
- trustedAuthservId: account.trustedAuthservId ?? null,
199
- });
200
- if ("message" in parsed) messages.push(parsed.message);
201
- else rejected.push(parsed.rejected);
202
- if (msg.uid > maxSeen) maxSeen = msg.uid;
203
- }
204
- nextUid = maxSeen + 1;
205
- if (messages.length > 0 || rejected.length > 0) await handlers.onMessages(messages, rejected);
206
- };
207
-
208
- client.on("exists", () => {
209
- // skip: ignore delayed exists events after the watcher has stopped.
210
- if (stopped) return;
211
- void drainNew().catch((err) => {
212
- if (!stopped) handlers.onError(mapImapError(err, doc.host));
213
- });
214
- });
215
- const die = (err: unknown) => {
216
- // skip: already stopped or terminated; fire onError only once.
217
- if (stopped) return;
218
- stopped = true;
219
- handlers.onError(mapImapError(err, doc.host));
220
- };
221
- client.on("error", die);
222
- client.on("close", () => die(new Error("connection closed")));
223
-
224
- // IDLE loop: 'exists' fires ONLY while client.idle() is running
225
- // (verified empirically against greenmail). Each intervening command
226
- // (drainNew-fetch) ends idle(), so the loop re-enters idle() until
227
- // stop() closes the connection.
228
- void (async () => {
229
- while (!stopped) {
230
- try {
231
- await client.idle();
232
- } catch (err) {
233
- if (!stopped) die(err);
234
- // skip: the idle loop ends; die() has informed the supervisor (or stop()
235
- // was the cause).
236
- return;
237
- }
238
- }
239
- })();
240
-
241
- return async () => {
242
- stopped = true;
243
- client.removeAllListeners("exists");
244
- client.removeAllListeners("error");
245
- client.removeAllListeners("close");
246
- await client.logout().catch(() => {});
247
- };
248
- } catch (err) {
249
- await client.logout().catch(() => {});
250
- throw mapImapError(err, doc.host);
251
- }
252
- }
253
-
254
153
  // =============================================================================
255
154
  // Plugin + Feature-definition
256
155
  // =============================================================================
@@ -0,0 +1,109 @@
1
+ import type {
2
+ InboundMailContext,
3
+ MailAccountRecord,
4
+ RawInboundMessage,
5
+ RejectedInboundMessage,
6
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
7
+ import type { ImapFlow } from "imapflow";
8
+ import { readCredentialDocument } from "./credential-document.ts";
9
+ import { createImapClient, IMAP_MAILBOX, imapMailHostGuard, mapImapError } from "./imap-client.ts";
10
+ import { coerceDate, parseOrReject } from "./parse-message.ts";
11
+
12
+ // IMAP IDLE (imapflow idles automatically while the mailbox is open
13
+ // and emits 'exists' for new messages).
14
+
15
+ export async function watchMailbox(
16
+ ctx: InboundMailContext,
17
+ account: MailAccountRecord,
18
+ handlers: {
19
+ readonly onMessages: (
20
+ msgs: readonly RawInboundMessage[],
21
+ rejected?: readonly RejectedInboundMessage[],
22
+ ) => Promise<void>;
23
+ readonly onError: (err: unknown) => void;
24
+ },
25
+ ): Promise<() => Promise<void>> {
26
+ const doc = await readCredentialDocument(ctx, account);
27
+ const client: ImapFlow = await createImapClient(doc, imapMailHostGuard());
28
+ let stopped = false;
29
+
30
+ try {
31
+ await client.connect();
32
+ const mailbox = await client.mailboxOpen(IMAP_MAILBOX);
33
+ const uidValidity = String(mailbox.uidValidity);
34
+ // uidNext is the next UID to be assigned, so everything from here is
35
+ // "new".
36
+ let nextUid = Number(mailbox.uidNext ?? 1);
37
+
38
+ const drainNew = async () => {
39
+ const messages: RawInboundMessage[] = [];
40
+ const rejected: RejectedInboundMessage[] = [];
41
+ let maxSeen = nextUid - 1;
42
+ for await (const msg of client.fetch(
43
+ `${nextUid}:*`,
44
+ { uid: true, internalDate: true, source: true, envelope: true },
45
+ { uid: true },
46
+ )) {
47
+ // `N:*` gotcha (see fetch): filter out the previous last message.
48
+ if (msg.uid < nextUid || !msg.source) continue;
49
+ const parsed = await parseOrReject({
50
+ source: msg.source,
51
+ envelope: msg.envelope,
52
+ uid: msg.uid,
53
+ uidValidity,
54
+ internalDate: coerceDate(msg.internalDate ?? undefined),
55
+ trustedAuthservId: account.trustedAuthservId ?? null,
56
+ });
57
+ if ("message" in parsed) messages.push(parsed.message);
58
+ else rejected.push(parsed.rejected);
59
+ if (msg.uid > maxSeen) maxSeen = msg.uid;
60
+ }
61
+ nextUid = maxSeen + 1;
62
+ if (messages.length > 0 || rejected.length > 0) await handlers.onMessages(messages, rejected);
63
+ };
64
+
65
+ client.on("exists", () => {
66
+ // skip: ignore delayed exists events after the watcher has stopped.
67
+ if (stopped) return;
68
+ void drainNew().catch((err) => {
69
+ if (!stopped) handlers.onError(mapImapError(err, doc.host));
70
+ });
71
+ });
72
+ const die = (err: unknown) => {
73
+ // skip: already stopped or terminated; fire onError only once.
74
+ if (stopped) return;
75
+ stopped = true;
76
+ handlers.onError(mapImapError(err, doc.host));
77
+ };
78
+ client.on("error", die);
79
+ client.on("close", () => die(new Error("connection closed")));
80
+
81
+ // IDLE loop: 'exists' fires ONLY while client.idle() is running
82
+ // (verified empirically against greenmail). Each intervening command
83
+ // (drainNew-fetch) ends idle(), so the loop re-enters idle() until
84
+ // stop() closes the connection.
85
+ void (async () => {
86
+ while (!stopped) {
87
+ try {
88
+ await client.idle();
89
+ } catch (err) {
90
+ if (!stopped) die(err);
91
+ // skip: the idle loop ends; die() has informed the supervisor (or stop()
92
+ // was the cause).
93
+ return;
94
+ }
95
+ }
96
+ })();
97
+
98
+ return async () => {
99
+ stopped = true;
100
+ client.removeAllListeners("exists");
101
+ client.removeAllListeners("error");
102
+ client.removeAllListeners("close");
103
+ await client.logout().catch(() => {});
104
+ };
105
+ } catch (err) {
106
+ await client.logout().catch(() => {});
107
+ throw mapImapError(err, doc.host);
108
+ }
109
+ }
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.395.0",
4
+ "type": "breaking",
5
+ "title": "A managed page body is Markdown or an editor document, edited in the document editor",
6
+ "detail": "The page entity has a new field bodyFormat (\"markdown\" or \"document\", default markdown). Pages and events from before the field read as markdown and render unchanged. A document body is HTML from the template-resolver document editor; page create, update and managed-pages:write:set check it with sanitizeDocumentHtml and store the rebuilt HTML and answer a rejected body with field errors on body (i18n key managed-pages.errors.document.rejected, new in all locale packages). The public render sanitizes a document body again and serves an empty body, with a logged warning, when a stored one fails. managed-pages:write:set takes an optional bodyFormat; omitting it sets markdown, it does not preserve the stored format. The page-edit screen no longer has a body textarea: its body section is the extension section managed-pages:page-body (PAGE_BODY_EXTENSION_NAME), which opens a Markdown body converted to HTML, shows a warning for what the conversion drops (images, block quotes, code, headings below level 3, relative links) and writes body and bodyFormat \"document\" only after the first edit. The ETag of the public page includes bodyFormat. seedPage takes an optional bodyFormat and throws on a rejected document body.",
7
+ "migration": "Run `kumiko-schema generate` and apply the migration; read_pages gets a bodyFormat column with default 'markdown', no rebuild is needed. Mount `managedPagesClient()` from `@cosmicdrift/kumiko-bundled-features/managed-pages/web` in createKumikoApp({ clientFeatures }); without it the body section of page-edit shows the missing-component placeholder. Apps that replaced page-edit with their own screen keep the textarea and send no bodyFormat, so their pages stay markdown."
8
+ },
2
9
  {
3
10
  "version": "0.389.0",
4
11
  "type": "improvement",
@@ -1 +1,8 @@
1
1
  export const MANAGED_PAGES_FEATURE = "managed-pages" as const;
2
+
3
+ export {
4
+ BODY_FORMATS,
5
+ type BodyFormat,
6
+ DEFAULT_BODY_FORMAT,
7
+ effectiveBodyFormat,
8
+ } from "./web/constants.ts";
@@ -5,17 +5,17 @@ import {
5
5
  type BrandingTokens,
6
6
  cachedSecurePageResponse,
7
7
  EMPTY_BRANDING,
8
- renderSafeMarkdown,
9
8
  wrapInLayout,
10
9
  } from "../page-render/index.ts";
11
10
  import { rateLimitedTextResponse, type SystemQueryFn } from "../shared/index.ts";
12
11
  import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding.ts";
13
- import { MANAGED_PAGES_FEATURE } from "./constants.ts";
12
+ import { type BodyFormat, MANAGED_PAGES_FEATURE } from "./constants.ts";
14
13
  import { createBrandingQuery } from "./handlers/branding.query.ts";
15
14
  import { bySlugQuery } from "./handlers/by-slug.query.ts";
16
15
  import { byTenantPublishedQuery } from "./handlers/by-tenant-published.query.ts";
17
16
  import { pageCrudQueries, pageCrudWrites } from "./handlers/page-crud.ts";
18
17
  import { setWrite } from "./handlers/set.write.ts";
18
+ import { renderPageBody } from "./render-page-body.ts";
19
19
  import { createBrandingSettingsScreen } from "./screens/branding-screen.ts";
20
20
  import { pageEditScreen, pageListScreen } from "./screens/page-screens.ts";
21
21
  import { pageEntity } from "./table.ts";
@@ -34,6 +34,7 @@ const BY_SLUG_QN = "managed-pages:query:by-slug";
34
34
  type BySlugQueryResult = {
35
35
  title: string;
36
36
  body: string;
37
+ bodyFormat: BodyFormat;
37
38
  lang: string;
38
39
  description: string | null;
39
40
  ogImage: string | null;
@@ -157,7 +158,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
157
158
  return defineFeature(MANAGED_PAGES_FEATURE, (r) => {
158
159
  r.adoptsBundledLists();
159
160
  r.describe(
160
- "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.",
161
+ "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.",
161
162
  );
162
163
  r.uiHints({
163
164
  displayLabel: "Managed Pages · Public CMS",
@@ -241,6 +242,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
241
242
  slug,
242
243
  lang,
243
244
  String(data.version),
245
+ data.bodyFormat,
244
246
  data.updatedAt,
245
247
  brandingRevisionSeed(branding),
246
248
  ]);
@@ -261,7 +263,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
261
263
 
262
264
  const html = wrapLayout({
263
265
  title: data.title,
264
- bodyHtml: renderSafeMarkdown(data.body),
266
+ bodyHtml: renderPageBody({ slug, body: data.body, bodyFormat: data.bodyFormat }),
265
267
  lang: data.lang,
266
268
  slug,
267
269
  description: data.description,
@@ -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.ts";
4
5
  import { type PageRow, pagesTable } from "../table.ts";
5
6
 
6
7
  // Public read by (tenantId, slug, lang), including anonymous access for landing and
@@ -30,6 +31,7 @@ export const bySlugQuery = defineQueryHandler({
30
31
  lang: row.lang,
31
32
  title: row.title,
32
33
  body: row.body,
34
+ bodyFormat: effectiveBodyFormat(row.bodyFormat),
33
35
  description: row.description,
34
36
  ogImage: row.ogImage,
35
37
  version: row.version,
@@ -0,0 +1,61 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
+ import {
4
+ type FieldIssue,
5
+ ValidationError,
6
+ writeFailure,
7
+ } from "@cosmicdrift/kumiko-framework/errors";
8
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
9
+ import { type PageRow, pagesTable } from "../table.ts";
10
+ import { sanitizePageBody } from "./page-body-issues.ts";
11
+
12
+ type PageWriteHandler = WriteHandlerDef["handler"];
13
+ type PageWriteContext = Parameters<PageWriteHandler>[1];
14
+
15
+ type Guarded =
16
+ | { readonly ok: true; readonly payload: unknown }
17
+ | { readonly ok: false; readonly issues: FieldIssue[] };
18
+
19
+ function stringOrNull(value: unknown): string | null {
20
+ return typeof value === "string" ? value : null;
21
+ }
22
+
23
+ // preSave hooks cannot report field errors (the executor maps every throw to
24
+ // presave_hook_failed), so the convention handlers are checked before they run.
25
+ // An update may touch only body or only bodyFormat, so the pair is completed
26
+ // from the stored row. A document body is handed on as the rebuilt html.
27
+ async function guardUpdate(payload: unknown, ctx: PageWriteContext): Promise<Guarded> {
28
+ if (!isPlainObject(payload) || !isPlainObject(payload["changes"])) return { ok: true, payload };
29
+ const changes = payload["changes"];
30
+ if (!("body" in changes) && !("bodyFormat" in changes)) return { ok: true, payload };
31
+ const id = payload["id"];
32
+ const stored =
33
+ typeof id === "string" ? await fetchOne<PageRow>(ctx.db, pagesTable, { id }) : null;
34
+ const body = "body" in changes ? stringOrNull(changes["body"]) : (stored?.body ?? null);
35
+ const format = "bodyFormat" in changes ? changes["bodyFormat"] : stored?.bodyFormat;
36
+ const result = sanitizePageBody(body, format);
37
+ if (!result.ok) return result;
38
+ return { ok: true, payload: { ...payload, changes: { ...changes, body: result.body } } };
39
+ }
40
+
41
+ function guardCreate(payload: unknown): Guarded {
42
+ if (!isPlainObject(payload)) return { ok: true, payload };
43
+ const result = sanitizePageBody(stringOrNull(payload["body"]), payload["bodyFormat"]);
44
+ if (!result.ok) return result;
45
+ return { ok: true, payload: { ...payload, body: result.body } };
46
+ }
47
+
48
+ export function withBodyValidation(
49
+ def: WriteHandlerDef,
50
+ verb: "create" | "update",
51
+ ): WriteHandlerDef {
52
+ return {
53
+ ...def,
54
+ handler: async (event, ctx) => {
55
+ const guarded =
56
+ verb === "create" ? guardCreate(event.payload) : await guardUpdate(event.payload, ctx);
57
+ if (!guarded.ok) return writeFailure(new ValidationError({ fields: guarded.issues }));
58
+ return def.handler({ ...event, payload: guarded.payload }, ctx);
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,26 @@
1
+ import type { FieldIssue } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { sanitizeDocumentHtml } from "../../template-resolver/index.ts";
3
+ import { effectiveBodyFormat } from "../constants.ts";
4
+
5
+ export const BODY_REJECTED_I18N_KEY = "managed-pages.errors.document.rejected";
6
+
7
+ export type PageBodyResult =
8
+ | { readonly ok: true; readonly body: string | null }
9
+ | { readonly ok: false; readonly issues: FieldIssue[] };
10
+
11
+ // The single write-time gate for document bodies (create, update, set, seed): a
12
+ // document body is stored as the sanitizer's rebuilt html, never as the raw input.
13
+ export function sanitizePageBody(body: string | null | undefined, format: unknown): PageBodyResult {
14
+ if (effectiveBodyFormat(format) !== "document" || !body) return { ok: true, body: body ?? null };
15
+ const result = sanitizeDocumentHtml(body);
16
+ if (result.ok) return { ok: true, body: result.html };
17
+ return {
18
+ ok: false,
19
+ issues: result.violations.map((violation) => ({
20
+ path: "body",
21
+ code: violation.code,
22
+ i18nKey: BODY_REJECTED_I18N_KEY,
23
+ params: { detail: violation.detail },
24
+ })),
25
+ };
26
+ }
@@ -6,6 +6,7 @@ import {
6
6
  defineEntityUpdateHandler,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { pageEntity } from "../table.ts";
9
+ import { withBodyValidation } from "./page-body-guard.ts";
9
10
 
10
11
  // Admin authoring runs as TenantAdmin (self-service) or SystemAdmin (app-wide
11
12
  // pages). Mirrors set.write's ACL — an app with its own role alias
@@ -17,16 +18,22 @@ const ADMIN_ACCESS = { roles: ["TenantAdmin", "SystemAdmin"] } as const;
17
18
  // `managed-pages:query:page:{list,detail}` by convention. The `set` write
18
19
  // handler in feature.ts is NOT used by them and stays the provisioning API.
19
20
  export const pageCrudWrites = [
20
- defineEntityCreateHandler("page", pageEntity, {
21
- access: ADMIN_ACCESS,
22
- description:
23
- "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.",
24
- }),
25
- defineEntityUpdateHandler("page", pageEntity, {
26
- access: ADMIN_ACCESS,
27
- description:
28
- "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.",
29
- }),
21
+ withBodyValidation(
22
+ defineEntityCreateHandler("page", pageEntity, {
23
+ access: ADMIN_ACCESS,
24
+ description:
25
+ "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.",
26
+ }),
27
+ "create",
28
+ ),
29
+ withBodyValidation(
30
+ defineEntityUpdateHandler("page", pageEntity, {
31
+ access: ADMIN_ACCESS,
32
+ description:
33
+ "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.",
34
+ }),
35
+ "update",
36
+ ),
30
37
  defineEntityDeleteHandler("page", pageEntity, {
31
38
  access: ADMIN_ACCESS,
32
39
  description:
@@ -1,8 +1,11 @@
1
1
  import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineWriteHandler, type TenantId } 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.ts";
6
+ import { BODY_FORMATS, DEFAULT_BODY_FORMAT } from "../constants.ts";
5
7
  import { type PageRow, pageEntity, pagesTable } from "../table.ts";
8
+ import { sanitizePageBody } from "./page-body-issues.ts";
6
9
 
7
10
  const slugSchema = z
8
11
  .string()
@@ -21,7 +24,8 @@ const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName:
21
24
  // Upserts one page per (tenantId, slug, lang). The tenant scope defaults to event.user;
22
25
  // SystemAdmin can write to another tenant via `tenantIdOverride` (typically SYSTEM_TENANT_ID
23
26
  // for app-wide pages). On update, omitted published/description/ogImage values are preserved
24
- // so a publish toggle does not overwrite the body, and vice versa.
27
+ // so a publish toggle does not overwrite the body, and vice versa. bodyFormat is the
28
+ // exception: omitted means markdown, so provisioning callers that only know markdown stay valid.
25
29
  export const setWrite = defineWriteHandler({
26
30
  name: "set",
27
31
  schema: z.object({
@@ -29,6 +33,7 @@ export const setWrite = defineWriteHandler({
29
33
  lang: langSchema,
30
34
  title: z.string().min(1).max(200),
31
35
  body: z.string().max(100_000).nullable(),
36
+ bodyFormat: z.enum(BODY_FORMATS).optional(),
32
37
  description: z.string().max(500).nullable().optional(),
33
38
  ogImage: z.string().max(2000).nullable().optional(),
34
39
  published: z.boolean().optional(),
@@ -37,10 +42,14 @@ export const setWrite = defineWriteHandler({
37
42
  }),
38
43
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
39
44
  description:
40
- "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.",
45
+ "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.",
41
46
  handler: async (event, ctx) => {
42
47
  const override = event.payload.tenantIdOverride;
43
48
  const tenantId = (override ?? event.user.tenantId) as TenantId; // @cast-boundary engine-bridge
49
+ const bodyFormat = event.payload.bodyFormat ?? DEFAULT_BODY_FORMAT;
50
+ const sanitized = sanitizePageBody(event.payload.body, bodyFormat);
51
+ if (!sanitized.ok) return writeFailure(new ValidationError({ fields: sanitized.issues }));
52
+ const { body } = sanitized;
44
53
  // ctx.db is scoped to the executing user's tenant, wrong for a cross-tenant override
45
54
  // on both the existing-check and the executor's stream reads; re-scope to the target.
46
55
  const { db: scopedDb, streamTenantId } = requireForTenant(ctx, tenantId);
@@ -57,7 +66,8 @@ export const setWrite = defineWriteHandler({
57
66
  version: existing.version,
58
67
  changes: {
59
68
  title: event.payload.title,
60
- body: event.payload.body,
69
+ body,
70
+ bodyFormat,
61
71
  description:
62
72
  event.payload.description !== undefined
63
73
  ? event.payload.description
@@ -83,7 +93,8 @@ export const setWrite = defineWriteHandler({
83
93
  slug: event.payload.slug,
84
94
  lang: event.payload.lang,
85
95
  title: event.payload.title,
86
- body: event.payload.body,
96
+ body,
97
+ bodyFormat,
87
98
  description: event.payload.description ?? null,
88
99
  ogImage: event.payload.ogImage ?? null,
89
100
  published: event.payload.published ?? false,
@@ -0,0 +1,28 @@
1
+ import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
2
+ import { renderSafeMarkdown } from "../page-render/index.ts";
3
+ import { sanitizeDocumentHtml } from "../template-resolver/index.ts";
4
+ import type { BodyFormat } from "./constants.ts";
5
+
6
+ type WarnLogger = Pick<Logger, "warn">;
7
+
8
+ const defaultLog = createFallbackLogger("managed-pages");
9
+
10
+ export type RenderablePageBody = {
11
+ readonly slug: string;
12
+ readonly body: string;
13
+ readonly bodyFormat: BodyFormat;
14
+ };
15
+
16
+ // A document body is re-sanitized at render time even though writes validate it:
17
+ // rows can predate the validation or arrive through other writers. A rejected
18
+ // document renders as an empty body (never the raw HTML) and is logged.
19
+ export function renderPageBody(page: RenderablePageBody, log: WarnLogger = defaultLog): string {
20
+ if (page.bodyFormat === "markdown") return renderSafeMarkdown(page.body);
21
+ const result = sanitizeDocumentHtml(page.body);
22
+ if (result.ok) return result.html;
23
+ log.warn("managed page document body rejected at render, serving an empty body", {
24
+ slug: page.slug,
25
+ violations: result.violations.map((violation) => violation.code),
26
+ });
27
+ return "";
28
+ }
@@ -2,6 +2,7 @@ import type {
2
2
  EntityEditScreenDefinition,
3
3
  EntityListScreenDefinition,
4
4
  } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { PAGE_BODY_EXTENSION_NAME } from "../web/constants.ts";
5
6
 
6
7
  // Admin authoring screens for the `page` entity. These are JSON-safe data only —
7
8
  // the generic DataTable/form renderer mounts them, so no React component is
@@ -78,10 +79,13 @@ export const pageEditScreen: EntityEditScreenDefinition = {
78
79
  { field: "published", span: 1 },
79
80
  ],
80
81
  },
82
+ // Not a plain field: the body is edited as a document and a Markdown
83
+ // body is converted on open, so the section also patches `bodyFormat`.
81
84
  {
85
+ kind: "extension",
82
86
  title: "managed-pages:section.body",
83
- columns: 1,
84
- fields: [{ field: "body", span: 1 }],
87
+ component: { react: { __component: PAGE_BODY_EXTENSION_NAME } },
88
+ entityName: "page",
85
89
  },
86
90
  ],
87
91
  },