webcake-landing-mcp 1.0.62 → 1.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -80,7 +80,8 @@ MCP (Model Context Protocol) server that teaches AI agents how to build a comple
80
80
  It exposes the element catalog, per-element usage hints + `specials`, the full page JSON Schema,
81
81
  valid element/page skeletons, a page validator, and tools to create or edit pages on the backend.
82
82
  The AI agent produces the full `{ page, popup, settings, options, cartConfigs }` JSON; `create_page`
83
- persists it (source-only the page opens in the editor where re-saving renders it).
83
+ persists it and auto-publishes (build + `publish_html`) so the preview renders immediately (the edit
84
+ tools save source-only — re-publish via `publish_page` after edits).
84
85
 
85
86
  | Method | Best for | Auth |
86
87
  |--------|----------|------|
@@ -152,10 +153,12 @@ npx -y webcake-landing-mcp login # opens the browser once, saves the token to
152
153
 
153
154
  …or set `WEBCAKE_ENV` (`local` | `staging` | `prod` — fills in all base URLs) + `WEBCAKE_JWT`.
154
155
 
155
- For `publish_page` to produce a **rendered** (non-blank) page, a build host is needed:
156
- - `prod` preset auto-configures `https://build.webcake.io` no extra setup.
156
+ For `publish_page` to actually put a page **live**, a build host is needed (it renders the
157
+ `app`/`app_css` that the live `publish_html` route requires):
158
+ - `prod` preset auto-configures `https://build.webcake.io` — no extra setup (the preset applies when the env resolves to `prod`: `WEBCAKE_ENV=prod`, `--env prod`, or `x-webcake-env: prod`).
157
159
  - For staging/local, set `WEBCAKE_BUILD_BASE=<url>` or send the `x-webcake-build-base` header per request.
158
- - Without it, `publish_page` falls back to source-only with `rendered:false` + a warning.
160
+ - Without it, `publish_page` falls back to a legacy source-only save with `rendered:false, live:false` + a warning — nothing goes live.
161
+ - A page is only **permanently** live with a `custom_domain`; without one the returned `/preview/<page_id>` link expires ~10 minutes after the publish.
159
162
 
160
163
  Everything else — the full env-var table, environment presets, per-request headers for the hosted
161
164
  server, the `login` browser flow (+ backend contract), and how to grab a JWT by hand — lives in
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.0.63",
4
+ "d": "11/06/2026",
5
+ "type": "Added",
6
+ "en": "create_page now auto-publishes after a successful create: builds the rendered app via the build host and calls the editor's publish_html route so…",
7
+ "vi": "create_page nay tự động publish sau khi tạo thành công: build rendered app qua build host rồi gọi route publish_html của editor để preview trang mới…"
8
+ },
2
9
  {
3
10
  "v": "1.0.62",
4
11
  "d": "11/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Changed",
34
41
  "en": "html-box descriptor (get_element) has been rewritten to document COMPOSITE VISUALS as the primary use case: intricate non-interactive mockups such…",
35
42
  "vi": "Descriptor html-box (get_element) được viết lại để ghi lại COMPOSITE VISUALS là trường hợp sử dụng chính: các mockup phi tương tác phức tạp như…"
36
- },
37
- {
38
- "v": "1.0.57",
39
- "d": "11/06/2026",
40
- "type": "Added",
41
- "en": "New upload_images tool re-hosts up to 20 external image URLs or data: URIs as Webcake-hosted URLs (statics.pancake.vn) by downloading and uploading…",
42
- "vi": "Công cụ upload_images mới tải lại tối đa 20 URL ảnh ngoài hoặc data: URI thành URL do Webcake lưu trữ (statics.pancake.vn) bằng cách tải về và…"
43
43
  }
44
44
  ]
@@ -171,6 +171,42 @@ function normalizeBackgrounds(node) {
171
171
  normalizeBackgrounds(child);
172
172
  }
173
173
  }
174
+ // ---------------------------------------------------------------------------
175
+ // misplaced-animation normalization: models regularly emit the animation object
176
+ // directly under responsive.<bp> instead of responsive.<bp>.config.animation —
177
+ // the single most common "must NOT have additional properties" schema error,
178
+ // and one a patch op:'update' can never fix (update merges; it cannot delete
179
+ // the stray key). The intent is unambiguous, so move it where the editor reads
180
+ // it: into config.animation (the author's explicit non-'none' config.animation
181
+ // wins if both are set), then drop the stray key. Deterministic + idempotent,
182
+ // so the expand(compact(x)) == expand(x) invariant holds.
183
+ // ---------------------------------------------------------------------------
184
+ /** Walk a tree node and relocate responsive.<bp>.animation → config.animation in-place (mutates). */
185
+ function normalizeMisplacedAnimation(node) {
186
+ if (!node || typeof node !== "object")
187
+ return;
188
+ for (const bp of ["desktop", "mobile"]) {
189
+ const rbp = node.responsive?.[bp];
190
+ if (!rbp || typeof rbp !== "object")
191
+ continue;
192
+ const stray = rbp.animation;
193
+ if (stray !== undefined) {
194
+ if (stray && typeof stray === "object") {
195
+ rbp.config = rbp.config && typeof rbp.config === "object" ? rbp.config : {};
196
+ const existing = rbp.config.animation;
197
+ const existingWins = existing && typeof existing === "object" && typeof existing.name === "string" && existing.name !== "none";
198
+ if (!existingWins) {
199
+ rbp.config.animation = { name: "none", delay: 0, duration: 3, repeat: null, ...stray };
200
+ }
201
+ }
202
+ delete rbp.animation;
203
+ }
204
+ }
205
+ if (Array.isArray(node.children)) {
206
+ for (const child of node.children)
207
+ normalizeMisplacedAnimation(child);
208
+ }
209
+ }
174
210
  /** Apply all post-expand normalizations to every node in a page source. */
175
211
  function normalizeSource(source) {
176
212
  if (!source || typeof source !== "object")
@@ -178,6 +214,7 @@ function normalizeSource(source) {
178
214
  for (const arr of ["page", "popup", "dynamic_pages"]) {
179
215
  if (Array.isArray(source[arr])) {
180
216
  for (const node of source[arr]) {
217
+ normalizeMisplacedAnimation(node);
181
218
  normalizeImageBlocks(node);
182
219
  normalizeBorderRadius(node);
183
220
  normalizeBackgrounds(node);
@@ -21,7 +21,7 @@ RULES (follow for every request):
21
21
  · patch_page (live-page) timed out → response returns a draft_id (patched source cached). Retry patch_page({ draft_id, dry_run:false }) with no patches — the COMMIT-AS-IS path.
22
22
  · update_page validation failed → the page already has a page_id → patch_page({ page_id, patches:[…] }) the offending ids.
23
23
  COMMIT-AS-IS (retry path after timeout): patch_page({ draft_id, dry_run:false }) with NO patches — skips the apply step, re-validates, then saves. This is the universal retry for any timed-out write.
24
- A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). Drafts expire in ~30 min.
24
+ A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). Drafts expire in ~2 h.
25
25
  - DRY-RUN CACHE: create_page, update_page, and add_section dry_run=true all cache the validated payload and return a draft_id. Re-run the same tool with { draft_id, dry_run:false } — no need to re-send the source.
26
26
  - Organizations: call list_organizations before saving. If the account has exactly ONE org, create_page auto-selects it — no need to ask. If there are MULTIPLE orgs, show them and ask the user which to use (is_default is the suggested default); pass the chosen organization_id to create_page. Pass organization_id:"personal" only when the user explicitly wants no org. create_page enforces this itself (it refuses to guess between multiple orgs). Endpoints are owner-scoped (only the account's own pages).
27
27
  - REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements using role→element hints (hero → section with background image/overlay + heading + paragraph + button; features → group blocks with icon/title/text; stats bar → group with heading+text per stat; pricing → group with text list + button; footer → section with text + links). When the reference contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box (clone its HTML with all styles inlined), not as element soup. (2) HTML string → call ingest_html(html, detail:'full') to get the richer AST when cloning; use detail:'compact' (default) for a layout-only reference. (3) URL → call ingest_url(url, detail:'full') for the same richer AST. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts), CSS custom-property palette, background_images from stylesheets, and in full mode: per-section blocks (cards/tiles/steps with title/body/image/cta), li lists, and gradients — use it for LAYOUT + HIERARCHY, then generate FRESH content tailored to the user's brand (don't 1:1 copy text). IMAGES FROM THE REFERENCE ARE THE USER'S ASSETS — for BOTH intents (adapt AND clone), every real image URL found in the AST (images, background_images, og_image) MUST be re-hosted via upload_images and reused in the corresponding slot; do NOT replace them with search_images stock photos. Call search_images ONLY for slots that have no source image in the reference. intent='clone' only when the user explicitly asks to mirror the original; default intent='adapt' (adapt rewrites TEXT, not the imagery). The reference workflow PRESERVES craft rules above (centering, page margin, premium spacing, real images) — apply them on top of the reference layout, don't bypass them.
@@ -37,6 +37,6 @@ MODEL (essentials):
37
37
  - Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
38
38
  - IMAGES: include them (hero/product, feature icons, about photo). SOURCE PRIORITY: (1) images the user provided or that exist in the reference HTML/URL → re-host via upload_images and use those exact images; (2) only for slots with NO source image → call search_images with a short English subject (e.g. 'fresh coffee cup') and put a returned URL (src.large for a hero/banner, src.medium for a card/thumb) into the image-block specials.src; it works out of the box (a shared proxy supplies images). Only if search_images returns ok:false, FALL BACK to a PLACEHOLDER sized to the box: https://placehold.co/<width>x<height>. (gallery.media = array of OBJECTS {type:'image',link:'<url>',linkVideo:'',typeVideo:'youtube',imageCompression:true} — NOT plain strings, the gallery reads item.link; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
39
39
 
40
- - PREVIEW vs PUBLISH: the preview_url returned by create_page/update_page/add_section lives on the PREVIEW host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod NOT the builder subdomain) and renders the stored source immediatelyshare it as-is for review. When the user wants the page LIVE (public/published, optionally on their custom domain), call publish_page({ page_id, custom_domain?, custom_path?, dry_run:false }).
40
+ - PREVIEW vs PUBLISH: for review share the EDITOR url (the builder renders the raw source). The editor_url SIGNS THE BROWSER IN automatically (it routes through the builder's /transport with the account token), so it works even when the user isn't logged in — but for the same reason it must go to the PAGE OWNER ONLY, never into anything public. create_page AUTO-PUBLISHES on success (builds the rendered app + publish_html), so a fresh page's preview_url renders right away — but the preview host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod) only serves it for ~10 MINUTES after the last publish, then shows "Preview page is expired". The EDIT routes (update_page/add_section/patch_page) store source onlyafter finishing a round of edits, run publish_page({ page_id, dry_run:false }) to rebuild the rendered app (else the preview shows the STALE pre-edit build). ONLY a custom_domain (publish_page({ page_id, custom_domain, dry_run:false })) gives a permanent public URL; without one the page has just the ephemeral preview link — say so and suggest attaching a domain the user already points at Webcake.
41
41
 
42
42
  Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, upload_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
@@ -125,6 +125,57 @@ export function coercePage(input) {
125
125
  return JSON.parse(input);
126
126
  return input;
127
127
  }
128
+ /**
129
+ * Resolve an ajv instancePath (e.g. "/page/1/children/2/responsive/desktop") to
130
+ * the deepest ELEMENT (object with string id + type) along it, plus the final
131
+ * value the path lands on. The positional path alone is the #1 reason a model
132
+ * patches the WRONG element after a schema error — indices are easy to miscount;
133
+ * ids are not.
134
+ */
135
+ function describeInstancePath(page, instancePath) {
136
+ if (!instancePath || instancePath === "/")
137
+ return {};
138
+ let cur = page;
139
+ let el;
140
+ for (const rawSeg of instancePath.split("/").slice(1)) {
141
+ if (cur == null || typeof cur !== "object")
142
+ return { id: el?.id, type: el?.type };
143
+ const seg = rawSeg.replace(/~1/g, "/").replace(/~0/g, "~");
144
+ cur = Array.isArray(cur) ? cur[Number(seg)] : cur[seg];
145
+ if (cur && typeof cur === "object" && typeof cur.id === "string" && typeof cur.type === "string")
146
+ el = cur;
147
+ }
148
+ return { id: el?.id, type: el?.type, value: cur };
149
+ }
150
+ /**
151
+ * Format one ajv error as an ACTIONABLE message: positional path + ajv message,
152
+ * plus the offending property name (additionalProperties), the actual bad value
153
+ * (enum/type), and — crucially — the enclosing element's id/type so the fix can
154
+ * target the right element by id on the first try. For stray-key errors it also
155
+ * names the only op that can fix them: patch update MERGES, so deleting a key
156
+ * needs op:'replace' (or, on a rebuild, simply omitting the key).
157
+ */
158
+ function describeSchemaError(page, err) {
159
+ const path = err.instancePath || "/";
160
+ let msg = `schema ${path} ${err.message}`;
161
+ const at = describeInstancePath(page, path);
162
+ const extraKey = err.params?.additionalProperty;
163
+ if (extraKey)
164
+ msg += ` — offending key: "${extraKey}"`;
165
+ if ((err.keyword === "enum" || err.keyword === "type" || err.keyword === "const") &&
166
+ (typeof at.value === "string" || typeof at.value === "number" || typeof at.value === "boolean")) {
167
+ msg += ` — got: ${JSON.stringify(at.value)}`;
168
+ }
169
+ if (at.id)
170
+ msg += ` — element id="${at.id}"${at.type ? ` (type ${at.type})` : ""}`;
171
+ if (extraKey && at.id) {
172
+ msg +=
173
+ `. patch_page op:'update' MERGES and cannot delete this key — fix via ` +
174
+ `{op:'replace', id:'${at.id}', element:<the clean node without "${extraKey}">}` +
175
+ (extraKey === "animation" ? ` (animation belongs in responsive.<bp>.config.animation, not responsive.<bp>)` : "");
176
+ }
177
+ return msg;
178
+ }
128
179
  export function validatePage(input) {
129
180
  const errors = [];
130
181
  const warnings = [];
@@ -135,11 +186,13 @@ export function validatePage(input) {
135
186
  catch (e) {
136
187
  return { valid: false, errors: [`Invalid JSON: ${e.message}`], warnings: [], stats: { sections: 0, popups: 0, elements: 0, ids: 0 } };
137
188
  }
138
- // 1) Structural (JSON Schema)
189
+ // 1) Structural (JSON Schema) — each error names the enclosing ELEMENT (id +
190
+ // type) and the offending key/value, so a fix can target the right element
191
+ // by id instead of decoding positional indices.
139
192
  const ok = validateSchema(page);
140
193
  if (!ok && validateSchema.errors) {
141
194
  for (const err of validateSchema.errors) {
142
- errors.push(`schema ${err.instancePath || "/"} ${err.message}`);
195
+ errors.push(describeSchemaError(page, err));
143
196
  }
144
197
  }
145
198
  // 2) Semantic
@@ -29,7 +29,11 @@
29
29
  * CALLER's own creds, so a draft only ever yields a page in the caller's account.
30
30
  */
31
31
  import { randomUUID } from "node:crypto";
32
- const TTL_MS = 30 * 60 * 1000; // 30 minutes
32
+ /** Draft lifetime default 2 hours. Override via WEBCAKE_DRAFT_TTL_MS env. */
33
+ const TTL_MS = (() => {
34
+ const v = parseInt(process.env.WEBCAKE_DRAFT_TTL_MS ?? "", 10);
35
+ return Number.isFinite(v) && v > 0 ? v : 2 * 60 * 60 * 1000;
36
+ })();
33
37
  const MAX_ENTRIES = 50;
34
38
  const store = new Map();
35
39
  function sweep(now) {
@@ -27,13 +27,22 @@ const SEARCH_PAGES_ENDPOINT = "/api/v1/ai/search_pages";
27
27
  const PAGE_SOURCE_ENDPOINT = "/api/v1/ai/page_source";
28
28
  const UPDATE_ENDPOINT = "/api/v1/ai/update_page_source";
29
29
  const APPEND_ENDPOINT = "/api/v1/ai/append_section";
30
- // The editor's own publish route (NOT under /api/v1/ai): saves the source as a
31
- // new version and creates/updates the page_published record (+ optional custom
32
- // domain/path) so the page goes live. NOTE: this scope is host-constrained to
33
- // the BUILDER host (router scope `host: "builder."`), so the request goes to
34
- // config.builderBase, not the API base.
35
- const publishEndpoint = (pageId) => `/api/pages/${encodeURIComponent(pageId)}/edit/publish`;
36
- const publishUrl = (config, pageId) => `${(config.builderBase ?? config.base).replace(/\/+$/, "")}${publishEndpoint(pageId)}`;
30
+ // The editor's own publish routes (NOT under /api/v1/ai). Both scopes are
31
+ // host-constrained to the BUILDER host (router scope `host: "builder."`), so
32
+ // requests go to config.builderBase, not the API base.
33
+ //
34
+ // /edit/publish_html is what the editor's publish button calls and the ONLY
35
+ // route that creates/updates the PagePublishedV2 record — the record EVERY
36
+ // public serving path reads (render_custom_domain get_published_by_domain_path_v2
37
+ // → serves page_published_v2.app). It expects the rendered app/app_css in the body.
38
+ //
39
+ // /edit/publish is LEGACY: it saves the source (+app/app_css onto the page_source
40
+ // row, which only the ~10-minute /preview/:id window serves) and writes a
41
+ // PagePublished v1 record that no serving path reads. Kept only as the
42
+ // source-only fallback when no build host is available.
43
+ const publishHtmlEndpoint = (pageId) => `/api/pages/${encodeURIComponent(pageId)}/edit/publish_html`;
44
+ const legacyPublishEndpoint = (pageId) => `/api/pages/${encodeURIComponent(pageId)}/edit/publish`;
45
+ const publishUrl = (config, pageId, rendered) => `${(config.builderBase ?? config.base).replace(/\/+$/, "")}${rendered ? publishHtmlEndpoint(pageId) : legacyPublishEndpoint(pageId)}`;
37
46
  function authHeaders(config, orgId) {
38
47
  const headers = {
39
48
  "Content-Type": "application/json",
@@ -73,14 +82,36 @@ export function toEditorUrl(config, raw) {
73
82
  pathQuery = `/${pathQuery}`;
74
83
  return `${builder.replace(/\/+$/, "")}${pathQuery}`;
75
84
  }
85
+ /**
86
+ * Build a SELF-LOGGING-IN editor link. The bare `/editor/v2/<id>` route sits
87
+ * behind the backend's `:passport` pipeline (jwt COOKIE or Bearer header), so a
88
+ * plain editor URL 401s ("Token not found") in any browser that isn't already
89
+ * logged in to Webcake. The builder host exposes `GET /transport?token=&redirect_uri=`
90
+ * (public; AuthController.transport) which sets the `jwt` cookie and redirects —
91
+ * so we wrap the editor URL in it, carrying the SAME jwt the MCP call used
92
+ * (env / auth.json / per-request header). The token is the caller's own
93
+ * credential, but the link logs into their account — share it with the page
94
+ * owner only, never publish it. Preview links stay UNWRAPPED (they're public).
95
+ */
96
+ export function toEditorLoginUrl(config, raw) {
97
+ const editor = toEditorUrl(config, raw);
98
+ if (!editor || !config.jwt)
99
+ return editor;
100
+ const builder = config.builderBase ?? config.appBase;
101
+ if (!builder)
102
+ return editor;
103
+ return `${builder.replace(/\/+$/, "")}/transport?token=${encodeURIComponent(config.jwt)}&redirect_uri=${encodeURIComponent(editor)}`;
104
+ }
76
105
  /**
77
106
  * Resolve the public preview link (`/preview/<page_id>`) onto the PREVIEW host
78
107
  * (config.previewBase) — NOT the builder subdomain. The /preview/:id route only
79
108
  * exists on the root preview hosts (preview.localhost:5800 local /
80
- * staging.webcake.me staging / www.webcake.me prod); the v4 renderer there serves
81
- * the STORED `app`/`app_css` build columns an MCP-created page's preview is
82
- * blank until publish_page (with a build host) runs or the page is re-saved in
83
- * the Webcake editor.
109
+ * staging.webcake.me staging / www.webcake.me prod); the v4 renderer there
110
+ * serves the page_source row's STORED `app`/`app_css` build columns, and only
111
+ * for ~10 minutes after the last source save (then "Preview page is expired").
112
+ * An MCP-created page's preview is blank until a rendered publish_page runs or
113
+ * the page is re-saved in the Webcake editor — and even then the link is
114
+ * ephemeral; only a custom_domain publish gives a permanent URL.
84
115
  */
85
116
  export function toPreviewUrl(config, raw) {
86
117
  if (!raw)
@@ -191,7 +222,7 @@ export async function createPage(config, name, source, orgId) {
191
222
  ok: true,
192
223
  status: res.status,
193
224
  page_id: pageId,
194
- editor_url: toEditorUrl(config, editorPath),
225
+ editor_url: toEditorLoginUrl(config, editorPath),
195
226
  preview_url: toPreviewUrl(config, previewPath),
196
227
  organization_id: (orgId ?? config.orgId) ?? null,
197
228
  raw: data,
@@ -337,7 +368,7 @@ export async function appendSection(config, pageId, sections) {
337
368
  ok: true,
338
369
  status: res.status,
339
370
  page_id: pageIdOut,
340
- editor_url: toEditorUrl(config, data?.editor_url),
371
+ editor_url: toEditorLoginUrl(config, data?.editor_url),
341
372
  preview_url: toPreviewUrl(config, data?.preview_url),
342
373
  organization_id: data?.organization_id ?? null,
343
374
  section_count: data?.section_count,
@@ -383,7 +414,7 @@ export async function updatePageSource(config, pageId, source) {
383
414
  ok: true,
384
415
  status: res.status,
385
416
  page_id: pageIdOut,
386
- editor_url: toEditorUrl(config, data?.editor_url),
417
+ editor_url: toEditorLoginUrl(config, data?.editor_url),
387
418
  preview_url: toPreviewUrl(config, data?.preview_url),
388
419
  organization_id: data?.organization_id ?? null,
389
420
  raw: data,
@@ -467,39 +498,70 @@ export async function buildPageApp(buildBase, pageId, source) {
467
498
  }
468
499
  return { ok: true, app, app_css, status: res.status };
469
500
  }
470
- function publishBody(sourceString, opts = {}) {
471
- // The publish action expects `source` as a JSON STRING (it Jason.decode!s it),
472
- // plus optional custom_domain/custom_path. is_publish marks the save as a
473
- // publish in save_page_with_source. app/app_css are the rendered HTML produced
474
- // by the build host when present the page renders without the editor.
475
- const payload = {
476
- source: sourceString,
501
+ /**
502
+ * Body for the editor's /edit/publish_html route mirrors the payload the
503
+ * editor's PublishModal sends (see landing_page_backend assets/editor
504
+ * PublishModal.vue): the saved source rides as the `data_node` JSON STRING (the
505
+ * route Jason.decode!s it and stores it via save_page_with_source), there is NO
506
+ * `source` key, and `settings` (with `mobile_only` folded in from
507
+ * options.mobileOnly) is stored on the PagePublishedV2 record the public
508
+ * serving paths read. `render_type: "v4"` only when a domain is attached —
509
+ * exactly what the editor sends. `auto: false` so the publish creates a version.
510
+ */
511
+ function publishHtmlBody(source, opts = {}) {
512
+ const hasDomain = !!opts.customDomain;
513
+ return JSON.stringify({
514
+ custom_domain: opts.customDomain ?? "",
515
+ custom_path: opts.customPath ?? "",
516
+ selected_custom_domain: hasDomain,
517
+ data_node: JSON.stringify(source),
518
+ render_type: hasDomain ? "v4" : null,
519
+ app: opts.app,
520
+ app_css: opts.app_css ?? "",
521
+ settings: { ...(source?.settings ?? {}), mobile_only: source?.options?.mobileOnly ?? false },
522
+ type: 1,
523
+ auto: false,
524
+ });
525
+ }
526
+ /**
527
+ * Body for the LEGACY /edit/publish route (source-only fallback): `source` as a
528
+ * JSON STRING plus custom_domain/custom_path; is_publish marks the save as a
529
+ * publish in save_page_with_source. No PagePublishedV2 record is written, so
530
+ * nothing goes live — the page only renders in the editor / the short-lived
531
+ * /preview window after a build.
532
+ */
533
+ function legacyPublishBody(source, opts = {}) {
534
+ return JSON.stringify({
535
+ source: JSON.stringify(source),
477
536
  custom_domain: opts.customDomain ?? "",
478
537
  custom_path: opts.customPath ?? "",
479
538
  is_publish: true,
480
- };
481
- if (opts.app != null)
482
- payload["app"] = opts.app;
483
- if (opts.app_css != null)
484
- payload["app_css"] = opts.app_css;
485
- return JSON.stringify(payload);
539
+ });
540
+ }
541
+ function publishRequestBody(source, opts, rendered) {
542
+ return rendered ? publishHtmlBody(source, opts) : legacyPublishBody(source, opts);
486
543
  }
487
- /** Build (but do not send) the publish request with the token masked — for dry-run previews. */
488
- export function buildPublishRequestRedacted(config, pageId, sourceString, opts = {}) {
489
- // Build a preview body: replace actual app/app_css content with size hints so the
490
- // preview is readable while still showing whether rendered HTML is included.
544
+ /**
545
+ * Build (but do not send) the publish request with the token masked — for
546
+ * dry-run previews. `willRender` says whether the real run will call the build
547
+ * host and take the publish_html path (dry_run itself never builds, so the
548
+ * preview stands in a size hint / placeholder for app/app_css).
549
+ */
550
+ export function buildPublishRequestRedacted(config, pageId, source, opts = {}, willRender = opts.app != null) {
551
+ // Replace actual app/app_css content with size hints (or a placeholder when
552
+ // the build runs later) so the preview stays readable.
491
553
  const previewOpts = { ...opts };
492
- if (opts.app != null)
493
- previewOpts.app = `<${opts.app.length} bytes>`;
494
- if (opts.app_css != null)
495
- previewOpts.app_css = `<${opts.app_css.length} bytes>`;
496
- const body = publishBody(sourceString, previewOpts);
554
+ if (willRender) {
555
+ previewOpts.app = opts.app != null ? `<${opts.app.length} bytes>` : "<built by the build host on dry_run=false>";
556
+ previewOpts.app_css = opts.app_css != null ? `<${opts.app_css.length} bytes>` : "<built by the build host on dry_run=false>";
557
+ }
558
+ const body = publishRequestBody(source, previewOpts, willRender);
497
559
  return {
498
560
  method: "POST",
499
- url: publishUrl(config, pageId),
561
+ url: publishUrl(config, pageId, willRender),
500
562
  headers: { ...authHeaders(config), Authorization: "Bearer ***JWT***", Cookie: "jwt=***JWT***" },
501
563
  body: body.replace(config.jwt, "***JWT***").slice(0, 600) + (body.length > 600 ? `… (${body.length} bytes)` : ""),
502
- rendered: opts.app != null,
564
+ rendered: willRender,
503
565
  };
504
566
  }
505
567
  // ---------------------------------------------------------------------------
@@ -580,22 +642,27 @@ async function postToHost(url, headers, body) {
580
642
  });
581
643
  }
582
644
  /**
583
- * Publish a page: saves the source as a new version and creates/updates the
584
- * page_published record (live status + optional custom domain/path). When
585
- * opts.app/app_css are provided (built by buildPageApp) the page renders
586
- * immediately without needing the editor. Returns the resulting public URL
587
- * `https://<domain>/<path>` when a custom domain is attached, else the
588
- * preview-host link (`<previewBase>/preview/<page_id>`).
645
+ * Publish a page. With opts.app/app_css (built by buildPageApp) it POSTs the
646
+ * editor's /edit/publish_html route, which creates/updates the PagePublishedV2
647
+ * record the one the public serving paths read — so the page actually goes
648
+ * LIVE. Without them it falls back to the legacy /edit/publish route, which
649
+ * only saves the source as a new version (nothing goes live).
650
+ *
651
+ * Returns the resulting public URL — `https://<domain>/<path>` when a custom
652
+ * domain is attached, else the preview-host link
653
+ * (`<previewBase>/preview/<page_id>`), which the backend only serves for ~10
654
+ * minutes after the publish (then "Preview page is expired").
589
655
  */
590
- export async function publishPage(config, pageId, sourceString, opts = {}) {
591
- const url = publishUrl(config, pageId);
656
+ export async function publishPage(config, pageId, source, opts = {}) {
657
+ const rendered = opts.app != null;
658
+ const url = publishUrl(config, pageId, rendered);
592
659
  let status;
593
660
  let text;
594
661
  try {
595
662
  // The builder-host pipeline runs an `accepts ["html"]` plug (it serves the
596
663
  // editor SPA); a literal application/json Accept gets a 406, so send */*
597
664
  // like the browser does — the action still returns JSON.
598
- ({ status, text } = await postToHost(url, { ...authHeaders(config), Accept: "*/*" }, publishBody(sourceString, opts)));
665
+ ({ status, text } = await postToHost(url, { ...authHeaders(config), Accept: "*/*" }, publishRequestBody(source, opts, rendered)));
599
666
  }
600
667
  catch (e) {
601
668
  const e2 = timeoutOrNetworkError(url, e);
@@ -624,6 +691,20 @@ export async function publishPage(config, pageId, sourceString, opts = {}) {
624
691
  const path = data?.path ?? null;
625
692
  const previewUrl = toPreviewUrl(config, `/preview/${pageId}`);
626
693
  const publishedUrl = domain ? `https://${domain}${path ? `/${String(path).replace(/^\/+/, "")}` : ""}` : previewUrl;
694
+ // The publish_html response data is PagePublishedV2.json — it carries the full
695
+ // app/app_css/source/data_node columns. Keep only the small identifying fields.
696
+ const raw = data && typeof data === "object"
697
+ ? {
698
+ id: data.id,
699
+ page_id: data.page_id,
700
+ domain: data.domain,
701
+ path: data.path,
702
+ status: data.status,
703
+ version_id: data.version_id,
704
+ render_type: data.render_type,
705
+ type: data.type,
706
+ }
707
+ : data;
627
708
  return {
628
709
  ok: true,
629
710
  status,
@@ -632,7 +713,8 @@ export async function publishPage(config, pageId, sourceString, opts = {}) {
632
713
  preview_url: previewUrl,
633
714
  domain,
634
715
  path,
635
- rendered: opts.app != null,
636
- raw: data,
716
+ rendered,
717
+ live: rendered,
718
+ raw,
637
719
  };
638
720
  }
package/dist/smoke.js CHANGED
@@ -10,7 +10,7 @@ import { compactSource, deepEq, sparseTemplate } from "./core/compact.js";
10
10
  import { parseHtml } from "./persistence/html-ingest.js";
11
11
  import { warningsField } from "./mcp/response.js";
12
12
  import { readConfig, resolveEnv, ENV_NAMES, configFromHeaders } from "./persistence/config.js";
13
- import { toEditorUrl, toPreviewUrl, buildPublishRequestRedacted } from "./persistence/webcake-client.js";
13
+ import { toEditorUrl, toEditorLoginUrl, toPreviewUrl, buildPublishRequestRedacted } from "./persistence/webcake-client.js";
14
14
  import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsProxyBase, buildSearchQuery, PEXELS_PROXY_DEFAULT } from "./persistence/pexels-client.js";
15
15
  import { putDraft, getDraft, updateDraft, deleteDraft } from "./persistence/draft-cache.js";
16
16
  import { buildConnectUrl, parseCallback } from "./auth/login.js";
@@ -132,6 +132,45 @@ check("bad page detects duplicate id", r2.errors.some((e) => e.includes("Duplica
132
132
  check("bad page detects children-on-noncontainer", r2.errors.some((e) => e.includes("not a container")), r2.errors);
133
133
  check("bad page detects missing mobile", r2.errors.some((e) => e.toLowerCase().includes("mobile")), r2.errors);
134
134
  check("bad page warns missing field_name", r2.warnings.some((w) => w.includes("field_name")), r2.warnings);
135
+ console.log("== validate: schema errors name the element id + offending key ==");
136
+ {
137
+ // A stray key directly under responsive.<bp> is the classic schema error a
138
+ // model cannot act on from the positional path alone — the message must name
139
+ // the element id, the stray key, and the only op that can delete it (replace).
140
+ const strayed = structuredClone(good);
141
+ strayed.page[0].children[0].responsive.desktop.zoomy = 1;
142
+ const rs = validatePage(strayed);
143
+ check("stray key fails schema", !rs.valid, rs);
144
+ const schemaErr = rs.errors.find((e) => e.includes("additional properties")) ?? "";
145
+ check("schema error names the offending key", schemaErr.includes('offending key: "zoomy"'), schemaErr);
146
+ check("schema error names the element id", schemaErr.includes('element id="btn1"'), schemaErr);
147
+ check("schema error prescribes op:'replace'", schemaErr.includes("op:'replace'") && schemaErr.includes("cannot delete"), schemaErr);
148
+ // A bad enum value reports what was actually there.
149
+ const badType = structuredClone(good);
150
+ badType.page[0].children[0].type = "txt-block";
151
+ const rt = validatePage(badType);
152
+ const enumErr = rt.errors.find((e) => e.includes("got:")) ?? "";
153
+ check("enum error reports the bad value + element id", enumErr.includes('"txt-block"') && enumErr.includes('element id="btn1"'), rt.errors);
154
+ }
155
+ console.log("== expand: relocates misplaced responsive.<bp>.animation into config ==");
156
+ {
157
+ // Models regularly emit animation at the breakpoint level (schema error a
158
+ // patch update can never fix) — domain.expand moves it where the editor
159
+ // reads it, so the page validates on the FIRST create_page.
160
+ const misplaced = structuredClone(good);
161
+ misplaced.page[0].children[0].responsive.desktop.animation = { name: "fadeInUp", delay: 0, duration: 2, repeat: null };
162
+ const fixed = landingDomain.expand(misplaced);
163
+ const bp = fixed.page[0].children[0].responsive.desktop;
164
+ check("stray animation key removed", bp.animation === undefined, bp);
165
+ check("animation moved into config", bp.config?.animation?.name === "fadeInUp" && bp.config?.animation?.duration === 2, bp.config);
166
+ check("relocated page validates", validatePage(fixed).valid, validatePage(fixed).errors);
167
+ // An explicit non-'none' config.animation wins over the stray key.
168
+ const both = structuredClone(good);
169
+ both.page[0].children[0].responsive.desktop.animation = { name: "fadeInUp" };
170
+ both.page[0].children[0].responsive.desktop.config.animation = { name: "zoomIn", delay: 1, duration: 4, repeat: null };
171
+ const kept = landingDomain.expand(both).page[0].children[0].responsive.desktop;
172
+ check("explicit config.animation wins over the stray key", kept.config.animation.name === "zoomIn" && kept.animation === undefined, kept.config);
173
+ }
135
174
  console.log("== validate: accepts JSON string input ==");
136
175
  const r3 = validatePage(JSON.stringify(good));
137
176
  check("string input parsed & valid", r3.valid, r3.errors);
@@ -540,6 +579,15 @@ console.log("== config: named environment presets (local/staging/prod) ==");
540
579
  check("editor url from a path → builder host", toEditorUrl(localCfg, "/editor/v2/abc") === "http://builder.localhost:5800/editor/v2/abc");
541
580
  check("editor url from an absolute api url → builder host", toEditorUrl(localCfg, "http://localhost:5800/editor/v2/abc?x=1") === "http://builder.localhost:5800/editor/v2/abc?x=1");
542
581
  check("editor url passthrough when empty", toEditorUrl(localCfg, undefined) === undefined);
582
+ // The RETURNED editor link must sign the browser in: the /editor route sits
583
+ // behind the jwt-cookie passport, so the link is wrapped in the builder
584
+ // host's public /transport?token=&redirect_uri= cookie-setting redirect.
585
+ const loginUrl = toEditorLoginUrl(localCfg, "/editor/v2/abc");
586
+ check("editor login url goes through /transport on the builder host", loginUrl.startsWith("http://builder.localhost:5800/transport?token="), loginUrl);
587
+ check("editor login url carries the jwt as token", loginUrl.includes("token=t&"), loginUrl);
588
+ check("editor login url percent-encodes the redirect target", loginUrl.endsWith(`redirect_uri=${encodeURIComponent("http://builder.localhost:5800/editor/v2/abc")}`), loginUrl);
589
+ check("editor login url passthrough when empty", toEditorLoginUrl(localCfg, undefined) === undefined);
590
+ check("editor login url stays bare without a jwt", toEditorLoginUrl({ ...localCfg, jwt: "" }, "/editor/v2/abc") === "http://builder.localhost:5800/editor/v2/abc");
543
591
  // The PREVIEW link lives on its own root host (NOT the builder subdomain):
544
592
  // preview.localhost:5800 / staging.webcake.me / www.webcake.me.
545
593
  check("env presets carry preview bases", resolveEnv("local")?.previewBase === "http://preview.localhost:5800" && resolveEnv("staging")?.previewBase === "https://staging.webcake.me" && resolveEnv("prod")?.previewBase === "https://www.webcake.me");
@@ -552,11 +600,23 @@ console.log("== config: named environment presets (local/staging/prod) ==");
552
600
  check("preview url from an absolute api url → preview host", toPreviewUrl(localCfg, "http://localhost:5800/preview/abc?x=1") === "http://preview.localhost:5800/preview/abc?x=1");
553
601
  check("preview url passthrough when empty", toPreviewUrl(localCfg, undefined) === undefined);
554
602
  check("preview url falls back to builder when previewBase missing", toPreviewUrl({ ...localCfg, previewBase: undefined }, "/preview/abc") === "http://builder.localhost:5800/preview/abc");
555
- // publish request preview: JWT must be masked everywhere.
556
- const pub = buildPublishRequestRedacted({ ...localCfg, jwt: "SECRETJWT" }, "pg1", JSON.stringify({ page: [] }), { customDomain: "shop.example.com", customPath: "sale" });
557
- check("publish request hits the editor publish route on the BUILDER host", pub.url === "http://builder.localhost:5800/api/pages/pg1/edit/publish", pub.url);
558
- check("publish request masks the JWT", !JSON.stringify(pub).includes("SECRETJWT"), pub);
559
- check("publish request carries domain/path + source string", pub.body.includes("shop.example.com") && pub.body.includes("custom_path") && pub.body.includes("is_publish"), pub.body);
603
+ // publish request preview: JWT must be masked everywhere. Without a build
604
+ // (willRender=false) the LEGACY source-only route is used…
605
+ const pub = buildPublishRequestRedacted({ ...localCfg, jwt: "SECRETJWT" }, "pg1", { page: [] }, { customDomain: "shop.example.com", customPath: "sale" });
606
+ check("legacy publish preview hits /edit/publish on the BUILDER host", pub.url === "http://builder.localhost:5800/api/pages/pg1/edit/publish", pub.url);
607
+ check("legacy publish preview masks the JWT", !JSON.stringify(pub).includes("SECRETJWT"), pub);
608
+ check("legacy publish preview carries domain/path + source string", pub.body.includes("shop.example.com") && pub.body.includes("custom_path") && pub.body.includes("is_publish"), pub.body);
609
+ check("legacy publish preview is marked rendered:false", pub.rendered === false, pub);
610
+ // …with a build (willRender=true) the editor's publish_html route — the only
611
+ // one that writes the PagePublishedV2 record public serving reads — is used,
612
+ // with the editor-shaped body (data_node, no `source` key).
613
+ const pubHtml = buildPublishRequestRedacted({ ...localCfg, jwt: "SECRETJWT" }, "pg1", { page: [], options: { mobileOnly: true } }, { customDomain: "shop.example.com" }, true);
614
+ check("rendered publish preview hits /edit/publish_html on the BUILDER host", pubHtml.url === "http://builder.localhost:5800/api/pages/pg1/edit/publish_html", pubHtml.url);
615
+ check("rendered publish preview masks the JWT", !JSON.stringify(pubHtml).includes("SECRETJWT"), pubHtml);
616
+ check("rendered publish preview uses the editor body shape", pubHtml.body.includes("data_node") && pubHtml.body.includes("selected_custom_domain") && pubHtml.body.includes("render_type"), pubHtml.body);
617
+ check("rendered publish preview folds mobile_only into settings", pubHtml.body.includes('"mobile_only":true'), pubHtml.body);
618
+ check("rendered publish preview stands in a placeholder for the unbuilt app", pubHtml.body.includes("built by the build host"), pubHtml.body);
619
+ check("rendered publish preview is marked rendered:true", pubHtml.rendered === true, pubHtml);
560
620
  }
561
621
  console.log("== login: connect URL + loopback callback parsing (offline) ==");
562
622
  {
@@ -17,6 +17,46 @@ import { putDraft, getDraft, updateDraft, deleteDraft } from "../persistence/dra
17
17
  export function registerPersistenceTools(server, domain) {
18
18
  // Resolve config from THIS request's headers (remote per-user JWT) first, then env.
19
19
  const cfgFor = (extra) => readConfig(configFromHeaders(extra?.requestInfo?.headers));
20
+ // After a successful CREATE, build the rendered app and publish via the
21
+ // editor's publish_html route so the page renders immediately — without this
22
+ // a fresh page's preview is a blank shell until publish_page runs or the page
23
+ // is re-saved in the editor. Failures here never fail the create (the page
24
+ // exists either way); the result tells the caller how to retry. Skipped when
25
+ // no build host is configured (a source-only legacy publish renders nothing).
26
+ const autoPublish = async (config, pageId, source) => {
27
+ if (!config.buildBase) {
28
+ return {
29
+ published: false,
30
+ skipped: true,
31
+ note: "No build host configured (WEBCAKE_BUILD_BASE env / x-webcake-build-base header; prod preset auto-configures https://build.webcake.io) — created source-only; the preview stays blank until publish_page runs with a build host or the page is re-saved in the editor.",
32
+ };
33
+ }
34
+ console.error(`[create_page] auto-publish: building ${pageId} via ${config.buildBase}`);
35
+ const build = await buildPageApp(config.buildBase, pageId, source);
36
+ if (!build.ok) {
37
+ console.error(`[create_page] auto-publish build failed: ${build.error}`);
38
+ return {
39
+ published: false,
40
+ error: `Build host failed (${build.error ?? "unknown error"})`,
41
+ hint: `The page was CREATED fine — only the rendering publish failed. Retry via publish_page({ page_id: "${pageId}", dry_run: false }).`,
42
+ };
43
+ }
44
+ const outcome = await publishPage(config, pageId, source, { app: build.app, app_css: build.app_css });
45
+ if (!outcome.ok) {
46
+ console.error(`[create_page] auto-publish publish failed: ${outcome.error}`);
47
+ return {
48
+ published: false,
49
+ status: outcome.status,
50
+ error: outcome.error,
51
+ hint: `The page was CREATED fine — only the rendering publish failed. Retry via publish_page({ page_id: "${pageId}", dry_run: false }).`,
52
+ };
53
+ }
54
+ return {
55
+ published: true,
56
+ rendered: true,
57
+ note: "Auto-published (no domain): the preview link renders for ~10 minutes after each publish, then expires. For a permanent public URL attach a domain via publish_page({ page_id, custom_domain, dry_run:false }).",
58
+ };
59
+ };
20
60
  // 8) List organizations -----------------------------------------------------
21
61
  server.tool("list_organizations", "Returns the account's Webcake organizations (id, name, is_default). The default org (type===1, usually the personal workspace) is where pages normally go. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {}, { title: "List Webcake Organizations", readOnlyHint: true, openWorldHint: true }, async (_args, extra) => {
22
62
  const { config, missing } = cfgFor(extra);
@@ -31,7 +71,7 @@ export function registerPersistenceTools(server, domain) {
31
71
  return text(await listOrganizations(config));
32
72
  });
33
73
  // 9) Create page (persist) --------------------------------------------------
34
- server.tool("create_page", "Persists a page source to the configured Webcake backend: creates a NEW page and saves the source (source-only opens in the editor where re-saving renders it). Validates first. DEFAULTS to dry_run=true (validates, caches the source as draft_id, returns the HTTP request it WOULD send, token masked); dry_run=false to actually create. Accepts draft_id from a previous call (validation failure, dry_run, or a timed-out create) — re-runs from the cached source without re-sending the full JSON. Organization resolution on the real run (dry_run=false): (1) explicit organization_id wins; pass the string 'personal' to save without any org. (2) WEBCAKE_ORG_ID env / x-webcake-org-id header wins. (3) Otherwise list_organizations is called: 0 orgs or lookup fails → personal (no org); exactly 1 org → used automatically (result includes organization_auto_selected:true); 2+ orgs → returns ok:false with the org list and asks the caller to re-call with organization_id. Real writes need WEBCAKE_API_BASE + WEBCAKE_JWT.", {
74
+ server.tool("create_page", "Persists a page source to the configured Webcake backend: creates a NEW page, saves the source, then AUTO-PUBLISHES it (builds the rendered app on the build host + publishes via the editor's publish_html route) so the preview renders immediately — set publish:false to skip, and note the no-domain preview link still expires ~10 minutes after each publish (publish_page with custom_domain gives a permanent URL). A failed auto-publish never fails the create (result.publish says how to retry). Validates first. DEFAULTS to dry_run=true (validates, caches the source as draft_id, returns the HTTP request it WOULD send, token masked); dry_run=false to actually create. Accepts draft_id from a previous call (validation failure, dry_run, or a timed-out create) — re-runs from the cached source without re-sending the full JSON. Organization resolution on the real run (dry_run=false): (1) explicit organization_id wins; pass the string 'personal' to save without any org. (2) WEBCAKE_ORG_ID env / x-webcake-org-id header wins. (3) Otherwise list_organizations is called: 0 orgs or lookup fails → personal (no org); exactly 1 org → used automatically (result includes organization_auto_selected:true); 2+ orgs → returns ok:false with the org list and asks the caller to re-call with organization_id. Real writes need WEBCAKE_API_BASE + WEBCAKE_JWT.", {
35
75
  source: z
36
76
  .any()
37
77
  .optional()
@@ -49,7 +89,11 @@ export function registerPersistenceTools(server, domain) {
49
89
  .boolean()
50
90
  .optional()
51
91
  .describe("Default TRUE — validate, cache the source as draft_id, and preview the request without sending. Set false to actually create."),
52
- }, { title: "Create Webcake Page", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ source, draft_id, name, organization_id, dry_run }, extra) => {
92
+ publish: z
93
+ .boolean()
94
+ .optional()
95
+ .describe("Default TRUE — after a successful create, automatically build the rendered app and publish (publish_html) so the preview renders immediately. Set false to create source-only (blank preview until publish_page runs)."),
96
+ }, { title: "Create Webcake Page", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ source, draft_id, name, organization_id, dry_run, publish }, extra) => {
53
97
  const isDry = dry_run !== false; // default true (safe)
54
98
  // --- Resolve source: from cache (draft_id) or from the argument ---
55
99
  let expanded;
@@ -60,7 +104,7 @@ export function registerPersistenceTools(server, domain) {
60
104
  return text({
61
105
  created: false,
62
106
  reason: "draft_expired",
63
- hint: "The cached draft is gone (expired after ~30 min or evicted). Re-send the full source via create_page({ source:…, dry_run:false }).",
107
+ hint: "The cached draft is gone (expired after ~2 h or evicted). Re-send the full source via create_page({ source:…, dry_run:false }).",
64
108
  });
65
109
  }
66
110
  if (cached.kind != null && cached.kind !== "page") {
@@ -104,7 +148,7 @@ export function registerPersistenceTools(server, domain) {
104
148
  errors: result.errors,
105
149
  ...warningsField(result.warnings),
106
150
  draft_id: existingDraftId,
107
- hint: "Do NOT rebuild the whole source — it is cached as draft_id. Fix ONLY the listed elements with patch_page({ draft_id, patches:[…], dry_run:false }); it re-validates the merged tree and creates the page. A wrong element type → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements/get_element if unsure of the exact type name). The draft expires in ~30 min.",
151
+ hint: "Do NOT rebuild the whole source — it is cached as draft_id. Each error names the offending element id — fix ONLY those elements with patch_page({ draft_id, patches:[…], dry_run:false }); it re-validates the merged tree and creates the page. A wrong element type → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements/get_element if unsure). A stray/extra key ('must NOT have additional properties') → { op:'replace', id, element:<clean node> } — op:'update' MERGES and cannot delete a key. The draft expires in ~2 h.",
108
152
  });
109
153
  }
110
154
  const parsed = domain.coerce(expanded);
@@ -149,6 +193,11 @@ export function registerPersistenceTools(server, domain) {
149
193
  missing_env: missing,
150
194
  target_organization_id: orgId ?? config?.orgId ?? null,
151
195
  organization_note: organizationNote,
196
+ publish_step: publish === false
197
+ ? { would_run: false, note: "publish:false — the real run will create source-only (blank preview until publish_page)." }
198
+ : config?.buildBase
199
+ ? { would_run: true, build_host: config.buildBase, note: "After creating, the real run auto-builds the rendered app and publishes (publish_html) so the preview renders immediately." }
200
+ : { would_run: false, note: "No build host configured — the real run creates source-only (blank preview). Set WEBCAKE_BUILD_BASE env or x-webcake-build-base header (prod preset auto-configures) to enable auto-publish." },
152
201
  draft_id: existingDraftId,
153
202
  request: config
154
203
  ? buildRequestRedacted(config, pageName, parsed, orgId)
@@ -238,9 +287,15 @@ export function registerPersistenceTools(server, domain) {
238
287
  const outcome = await createPage(config, pageName, parsed, resolvedOrgId);
239
288
  if (outcome.ok) {
240
289
  deleteDraft(existingDraftId); // created — drop the draft
290
+ // Auto-publish (default): build + publish_html so the preview renders
291
+ // immediately. Never fails the create — result.publish carries the state.
292
+ const publishOutcome = publish === false
293
+ ? { published: false, skipped: true, note: "publish:false — created source-only; the preview stays blank until publish_page runs." }
294
+ : await autoPublish(config, outcome.page_id, parsed);
241
295
  return text({
242
296
  created: true,
243
297
  ...outcome,
298
+ publish: publishOutcome,
244
299
  ...warningsField(result.warnings),
245
300
  ...(organizationAutoSelected ? { organization_auto_selected: true } : {}),
246
301
  ...(organizationNote ? { note: organizationNote } : {}),
@@ -248,12 +303,21 @@ export function registerPersistenceTools(server, domain) {
248
303
  }
249
304
  // Failure (including timeout): keep the draft so the model can retry.
250
305
  updateDraft(existingDraftId, expanded);
306
+ // A backend 404/5xx on a route that normally works is usually a transient
307
+ // deploy/restart window — the fix is to RETRY THE SAME REQUEST, not to
308
+ // change parameters. (Observed failure mode: a transient 404 with an
309
+ // organization_id made the model "work around" it by dropping the org —
310
+ // the page then landed in personal instead of the requested workspace.)
311
+ const transient = outcome.status === 404 || (outcome.status ?? 0) >= 500 || outcome.status === 0;
251
312
  return text({
252
313
  created: false,
253
314
  ...outcome,
254
315
  ...warningsField(result.warnings),
255
316
  draft_id: existingDraftId,
256
- hint: `Create failed — source is cached. Retry via create_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ draft_id: "${existingDraftId}", patches:[…], dry_run:false }). The draft expires in ~30 min.`,
317
+ hint: `Create failed — source is cached. Retry via create_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ draft_id: "${existingDraftId}", patches:[…], dry_run:false }). The draft expires in ~2 h.` +
318
+ (transient
319
+ ? ` A ${outcome.status === 0 ? "network error" : outcome.status} from the backend is usually TRANSIENT (deploy/restart window) — retry the SAME draft with the SAME organization_id after a short pause. Do NOT drop or change organization_id to work around it: the page would land in the wrong workspace.`
320
+ : ""),
257
321
  });
258
322
  });
259
323
  // 10) List pages ------------------------------------------------------------
@@ -354,7 +418,7 @@ export function registerPersistenceTools(server, domain) {
354
418
  return text({
355
419
  updated: false,
356
420
  reason: "draft_expired",
357
- hint: "The cached draft is gone (expired after ~30 min or evicted). Re-send the full source via update_page({ page_id, source:…, dry_run:false }).",
421
+ hint: "The cached draft is gone (expired after ~2 h or evicted). Re-send the full source via update_page({ page_id, source:…, dry_run:false }).",
358
422
  });
359
423
  }
360
424
  if (cached.kind !== "update") {
@@ -441,7 +505,7 @@ export function registerPersistenceTools(server, domain) {
441
505
  ...outcome,
442
506
  ...warningsField(result.warnings),
443
507
  draft_id: existingDraftId,
444
- hint: `Update failed — source is cached. Retry via update_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ page_id: "${resolvedPageId}", patches:[…] }). The draft expires in ~30 min.`,
508
+ hint: `Update failed — source is cached. Retry via update_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ page_id: "${resolvedPageId}", patches:[…] }). The draft expires in ~2 h.`,
445
509
  });
446
510
  });
447
511
  // 13) Add section (incremental build) ---------------------------------------
@@ -514,7 +578,7 @@ export function registerPersistenceTools(server, domain) {
514
578
  return text({
515
579
  added: false,
516
580
  reason: "draft_expired",
517
- hint: "The cached section draft is gone (expired after ~30 min or evicted). Re-send the sections via add_section({ page_id, sections:[…] }).",
581
+ hint: "The cached section draft is gone (expired after ~2 h or evicted). Re-send the sections via add_section({ page_id, sections:[…] }).",
518
582
  });
519
583
  }
520
584
  if (cached.kind !== "sections") {
@@ -571,7 +635,7 @@ export function registerPersistenceTools(server, domain) {
571
635
  errors: result.errors,
572
636
  ...warningsField(result.warnings),
573
637
  draft_id: existingDraftId,
574
- hint: "Do NOT rebuild the section batch — it is cached as draft_id. Fix ONLY the listed elements with patch_page({ draft_id, patches:[…], dry_run:false }); it re-validates the merged shell and appends the sections. A wrong element type → { op:'update', id:'<element id>', type:'<allowed type>' }. The draft expires in ~30 min.",
638
+ hint: "Do NOT rebuild the section batch — it is cached as draft_id. Each error names the offending element id — fix ONLY those elements with patch_page({ draft_id, patches:[…], dry_run:false }); it re-validates the merged shell and appends the sections. A wrong element type → { op:'update', id:'<element id>', type:'<allowed type>' }. A stray/extra key ('must NOT have additional properties') → { op:'replace', id, element:<clean node> } — op:'update' MERGES and cannot delete a key. The draft expires in ~2 h.",
575
639
  });
576
640
  }
577
641
  const { config, missing } = cfgFor(extra);
@@ -756,13 +820,13 @@ export function registerPersistenceTools(server, domain) {
756
820
  }
757
821
  return touched;
758
822
  };
759
- server.tool("patch_page", "Edits a page by element id WITHOUT re-sending the whole source — the surgical-edit and fix-after-error path. Targets EITHER a live page (page_id) OR a cached draft source (draft_id). Draft sources come from: (a) create_page — failed validation or timed-out network call → patched/committed tree is CREATED as a new page once valid; (b) add_section dry_run or validation/network failure → patched/committed shell is APPENDED to the stored page once valid; (c) update_page or live-page patch_page — timed-out/failed network call → re-committed via updatePageSource. Send only a list of per-element ops; the MCP loads the source, applies them, validates the WHOLE merged tree (blocks on errors), and saves. Ops: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} (shallow-merges; op defaults to 'update'; `type` fixes a wrong element type), {op:'replace',id,element}, {op:'remove',id}, {op:'add',parent_id,element}. EMPTY/OMITTED patches with a draft_id = commit the cached draft as-is (skip apply, still validate, then honor dry_run) — this is the RETRY PATH after a timeout. Use this to fix the elements a failed create_page/update_page/add_section reported instead of rebuilding. DEFAULTS to dry_run=true (loads + merges + validates + previews, no write); dry_run=false to save. Needs WEBCAKE_API_BASE + WEBCAKE_JWT (a draft_id sections-patch only needs creds to actually append; a page_id patch reads the live page so needs creds even on dry_run).", {
823
+ server.tool("patch_page", "Edits a page by element id WITHOUT re-sending the whole source — the surgical-edit and fix-after-error path. Targets EITHER a live page (page_id) OR a cached draft source (draft_id). Draft sources come from: (a) create_page — failed validation or timed-out network call → patched/committed tree is CREATED as a new page once valid; (b) add_section dry_run or validation/network failure → patched/committed shell is APPENDED to the stored page once valid; (c) update_page or live-page patch_page — timed-out/failed network call → re-committed via updatePageSource. Send only a list of per-element ops; the MCP loads the source, applies them, validates the WHOLE merged tree (blocks on errors), and saves. Ops: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} (shallow-merges; op defaults to 'update'; `type` fixes a wrong element type; update CANNOT delete an existing/stray key — schema 'additional properties' errors need op:'replace'), {op:'replace',id,element}, {op:'remove',id}, {op:'add',parent_id,element}. EMPTY/OMITTED patches with a draft_id = commit the cached draft as-is (skip apply, still validate, then honor dry_run) — this is the RETRY PATH after a timeout. Use this to fix the elements a failed create_page/update_page/add_section reported instead of rebuilding. DEFAULTS to dry_run=true (loads + merges + validates + previews, no write); dry_run=false to save. Needs WEBCAKE_API_BASE + WEBCAKE_JWT (a draft_id sections-patch only needs creds to actually append; a page_id patch reads the live page so needs creds even on dry_run).", {
760
824
  page_id: z.string().optional().describe("Edit a LIVE page by id (from create_page, list_pages, or find_pages; must be owned by the account). Provide page_id OR draft_id. For a sections or update draft_id you may also pass page_id here to override the stored page target."),
761
825
  draft_id: z.string().optional().describe("Commit or fix a CACHED source: from create_page (failed/timed-out → new page created once valid), add_section (dry_run or failure → sections appended), or update_page/live-page patch (timed-out/failed → updatePageSource retried). The originating tool's error/dry_run response returns draft_id. Provide page_id OR draft_id. Empty/omitted patches = commit the cached draft as-is (the RETRY PATH after a timeout)."),
762
826
  patches: z
763
827
  .any()
764
828
  .optional()
765
- .describe("One op object or an array of them (object/array or JSON string). Each targets an element by id: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} merges fields into the element (op may be omitted; set `type` to fix a wrong element type); {op:'replace',id,element} swaps the node; {op:'remove',id} deletes it; {op:'add',parent_id,element} appends a child to a container. `element` may be a SPARSE node (id/type/styles/specials/events only) — the server hydrates omitted boilerplate from factory defaults. OMIT (or pass empty array) when draft_id is given and you just want to commit/retry the cached draft as-is."),
829
+ .describe("One op object or an array of them (object/array or JSON string). Each targets an element by id: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} merges fields into the element (op may be omitted; set `type` to fix a wrong element type; update MERGES — it cannot DELETE an existing/stray key, so 'must NOT have additional properties' errors need op:'replace' with a clean node); {op:'replace',id,element} swaps the node; {op:'remove',id} deletes it; {op:'add',parent_id,element} appends a child to a container. `element` may be a SPARSE node (id/type/styles/specials/events only) — the server hydrates omitted boilerplate from factory defaults. OMIT (or pass empty array) when draft_id is given and you just want to commit/retry the cached draft as-is."),
766
830
  dry_run: z
767
831
  .boolean()
768
832
  .optional()
@@ -787,7 +851,7 @@ export function registerPersistenceTools(server, domain) {
787
851
  return text({
788
852
  patched: false,
789
853
  reason: "draft_expired",
790
- hint: "The cached draft is gone (expired after ~30 min or evicted). Re-send the full source via create_page.",
854
+ hint: "The cached draft is gone (expired after ~2 h or evicted). Re-send the full source via create_page.",
791
855
  });
792
856
  }
793
857
  base = draft.source; // already an expanded full tree
@@ -931,7 +995,7 @@ export function registerPersistenceTools(server, domain) {
931
995
  ...warningsField(result.warnings),
932
996
  patches_applied: applied,
933
997
  draft_id,
934
- hint: "Still invalid — fix the remaining errors with another patch_page({ draft_id, patches:[…] }). Your applied fixes are kept in the draft.",
998
+ hint: "Still invalid — fix the remaining errors with another patch_page({ draft_id, patches:[…] }); your applied fixes are kept in the draft. Each error names the offending element id — target THAT id. A stray/extra key ('must NOT have additional properties') needs op:'replace' with a clean node (op:'update' merges; it cannot delete a key). Do NOT rebuild with create_page — the draft already holds everything.",
935
999
  });
936
1000
  }
937
1001
  const parsed = domain.coerce(expanded);
@@ -1071,6 +1135,9 @@ export function registerPersistenceTools(server, domain) {
1071
1135
  else {
1072
1136
  updateDraft(draft_id, base); // keep for retry
1073
1137
  }
1138
+ // A page created via the fix-after-error path gets the same auto-publish
1139
+ // as a direct create_page (build + publish_html so the preview renders).
1140
+ const publishOutcome = outcome.ok ? await autoPublish(config, outcome.page_id, parsed) : undefined;
1074
1141
  return text({
1075
1142
  patched: outcome.ok,
1076
1143
  created: outcome.ok,
@@ -1081,6 +1148,7 @@ export function registerPersistenceTools(server, domain) {
1081
1148
  preview_url: outcome.preview_url,
1082
1149
  status: outcome.status,
1083
1150
  error: outcome.error,
1151
+ ...(publishOutcome ? { publish: publishOutcome } : {}),
1084
1152
  ...warningsField(result.warnings),
1085
1153
  ...(outcome.ok ? {} : { draft_id, hint: `Create failed — fixes kept in draft. Retry patch_page({ draft_id: "${draft_id}", dry_run:false }) or resolve the error first.` }),
1086
1154
  });
@@ -1134,11 +1202,11 @@ export function registerPersistenceTools(server, domain) {
1134
1202
  error: outcome.error,
1135
1203
  ...warningsField(result.warnings),
1136
1204
  draft_id: liveDraftId,
1137
- hint: `Save failed — the patched source is cached. Retry via patch_page({ draft_id: "${liveDraftId}", dry_run: false }) with no patches. The draft expires in ~30 min.`,
1205
+ hint: `Save failed — the patched source is cached. Retry via patch_page({ draft_id: "${liveDraftId}", dry_run: false }) with no patches. The draft expires in ~2 h.`,
1138
1206
  });
1139
1207
  });
1140
1208
  // 15) Publish page (go live) -------------------------------------------------
1141
- server.tool("publish_page", "Publishes an EXISTING page: builds the rendered app via the Webcake build host (POST <buildBase>/render/build) when available — prod default https://build.webcake.io, override with WEBCAKE_BUILD_BASE env / x-webcake-build-base header so the published page and /preview/<page_id> render immediately without opening the editor. When no build host is configured or the build fails, falls back to source-only publish with a warning (page will appear blank until re-saved in the Webcake editor or a build host is configured). Saves the source as a new version and creates/updates the page_published record (live status), optionally attaching a custom domain/path. DEFAULTS to dry_run=true (network-free: does NOT call the build host on dry_run). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
1209
+ server.tool("publish_page", "Publishes an EXISTING page LIVE via the editor's publish_html route: builds the rendered app on the Webcake build host (POST <buildBase>/render/build; prod default https://build.webcake.io, override with WEBCAKE_BUILD_BASE env / x-webcake-build-base header), then creates/updates the PagePublishedV2 record the record ALL public serving reads. With custom_domain the page goes live at that domain (it must already point at Webcake). WITHOUT a domain there is NO permanent public URL: the returned preview link (<previewBase>/preview/<page_id>) only renders for ~10 minutes after the publish, then shows 'Preview page is expired' tell the user to attach a domain for a lasting URL. If no build host is configured or the build fails, falls back to the LEGACY source-only publish route with a warning (saves a version; nothing goes live; the page stays blank). DEFAULTS to dry_run=true (network-free: does NOT call the build host on dry_run). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
1142
1210
  page_id: z.string().describe("The page id to publish (must be owned by the account)."),
1143
1211
  custom_domain: z
1144
1212
  .string()
@@ -1152,15 +1220,18 @@ export function registerPersistenceTools(server, domain) {
1152
1220
  if (!config)
1153
1221
  return text({ published: false, reason: "missing_env", missing_env: missing });
1154
1222
  // Publish re-saves the page's CURRENT stored source (the publish endpoint
1155
- // requires the source in the request), so read it first — even on dry_run,
1156
- // to show the real payload.
1223
+ // requires it in the request), so read it first — even on dry_run, to show
1224
+ // the real payload.
1157
1225
  const res = await getPageSource(config, page_id);
1158
1226
  if (!res.ok || res.source == null) {
1159
1227
  return text({ published: false, reason: "page_not_found", status: res.status, error: res.error ?? "No source on this page." });
1160
1228
  }
1161
- const sourceString = JSON.stringify(res.source);
1162
1229
  const opts = { customDomain: custom_domain, customPath: custom_path };
1163
1230
  const buildBase = config.buildBase;
1231
+ // Only the preview window serves a domain-less publish, and only briefly.
1232
+ const previewExpiryNote = custom_domain
1233
+ ? undefined
1234
+ : "No custom_domain — the page has NO permanent public URL. The preview link only renders for ~10 minutes after the publish, then shows 'Preview page is expired'. Attach a custom_domain (already pointed at Webcake) for a lasting URL.";
1164
1235
  if (isDry) {
1165
1236
  // dry_run is network-free — do NOT call the build host.
1166
1237
  return text({
@@ -1171,13 +1242,16 @@ export function registerPersistenceTools(server, domain) {
1171
1242
  ? `https://${custom_domain}${custom_path ? `/${custom_path}` : ""}`
1172
1243
  : toPreviewUrl(config, `/preview/${page_id}`),
1173
1244
  build_step: buildBase
1174
- ? { would_run: true, build_host: buildBase, note: "Build host will be called on dry_run=false to produce rendered app/app_css." }
1175
- : { would_run: false, note: "No build host configured — publish will be source-only. Set WEBCAKE_BUILD_BASE env or x-webcake-build-base header (prod preset: https://build.webcake.io) to enable rendered output." },
1176
- request: buildPublishRequestRedacted(config, page_id, sourceString, opts),
1245
+ ? { would_run: true, build_host: buildBase, note: "Build host will be called on dry_run=false to produce rendered app/app_css, then the page is published live via the editor's publish_html route." }
1246
+ : { would_run: false, note: "No build host configured — publish will fall back to the LEGACY source-only route (nothing goes live). Set WEBCAKE_BUILD_BASE env or x-webcake-build-base header (prod preset: https://build.webcake.io) to publish for real." },
1247
+ ...(previewExpiryNote ? { note: previewExpiryNote } : {}),
1248
+ request: buildPublishRequestRedacted(config, page_id, res.source, opts, !!buildBase),
1177
1249
  hint: "Re-run with dry_run=false to actually publish.",
1178
1250
  });
1179
1251
  }
1180
- // Real publish: attempt to build app/app_css first when a build host is available.
1252
+ // Real publish: build app/app_css first required for the publish_html
1253
+ // (live) route. Without a successful build we fall back to the legacy
1254
+ // source-only route rather than publishing a blank PagePublishedV2 record.
1181
1255
  let app;
1182
1256
  let app_css;
1183
1257
  let rendered = false;
@@ -1192,17 +1266,20 @@ export function registerPersistenceTools(server, domain) {
1192
1266
  console.error(`[publish_page] build ok — app ${app?.length ?? 0}B css ${app_css?.length ?? 0}B`);
1193
1267
  }
1194
1268
  else {
1195
- buildWarning = `Build host failed (${buildResult.error ?? "unknown error"}) — publishing source-only. The page will appear blank until it is re-saved in the Webcake editor or a working build host is configured.`;
1269
+ buildWarning = `Build host failed (${buildResult.error ?? "unknown error"}) — fell back to the legacy source-only publish: a version was saved but NOTHING WENT LIVE (the live publish_html route needs the rendered app). Fix the build host and re-run publish_page.`;
1196
1270
  console.error(`[publish_page] build failed: ${buildResult.error}`);
1197
1271
  }
1198
1272
  }
1199
1273
  else {
1200
- buildWarning = "No build host configured (WEBCAKE_BUILD_BASE env / x-webcake-build-base header; prod preset has https://build.webcake.io automatically). Publishing source-only the page will appear blank until it is re-saved in the Webcake editor or a build host is configured.";
1274
+ buildWarning = "No build host configured (WEBCAKE_BUILD_BASE env / x-webcake-build-base header; prod preset has https://build.webcake.io automatically). Fell back to the legacy source-only publish: a version was saved but NOTHING WENT LIVE (the live publish_html route needs the rendered app).";
1201
1275
  }
1202
1276
  const publishOpts = { ...opts, app, app_css };
1203
- const outcome = await publishPage(config, page_id, sourceString, publishOpts);
1277
+ const outcome = await publishPage(config, page_id, res.source, publishOpts);
1204
1278
  return text({
1205
1279
  published: outcome.ok,
1280
+ // live = the PagePublishedV2 record public serving reads was written
1281
+ // (publish_html route). The legacy fallback only saves a version.
1282
+ live: outcome.ok && rendered,
1206
1283
  rendered,
1207
1284
  page_id,
1208
1285
  url: outcome.published_url,
@@ -1211,6 +1288,7 @@ export function registerPersistenceTools(server, domain) {
1211
1288
  path: outcome.path,
1212
1289
  status: outcome.status,
1213
1290
  error: outcome.error,
1291
+ ...(outcome.ok && previewExpiryNote ? { note: previewExpiryNote } : {}),
1214
1292
  ...(buildWarning ? { warning: buildWarning } : {}),
1215
1293
  });
1216
1294
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.62",
3
+ "version": "1.0.63",
4
4
  "description": "MCP server exposing Webcake landing-page element schemas + AI usage hints, and persisting LLM-generated page sources to a Webcake backend.",
5
5
  "mcpName": "io.github.vuluu2k/webcake-landing-mcp",
6
6
  "type": "module",