webcake-landing-mcp 1.6.0 → 1.6.2

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.
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.6.2",
4
+ "d": "27/10/2026",
5
+ "type": "Fixed",
6
+ "en": "expand now syncs a field element's properties.field_default with its specials.field_name, turning it on for the editor's system columns…",
7
+ "vi": "expand nay đồng bộ properties.field_default của field element theo specials.field_name: bật cho các cột hệ thống của editor (phone_number,…"
8
+ },
9
+ {
10
+ "v": "1.6.1",
11
+ "d": "21/10/2026",
12
+ "type": "Fixed",
13
+ "en": "publish_page now looks up the account's verified domains (GET /api/v1/domains/all) and publishes to the domain exactly as the account stores it: an…",
14
+ "vi": "publish_page nay tra cứu danh sách domain đã xác minh của account (GET /api/v1/domains/all) và publish đúng vào domain theo đúng dạng account đã…"
15
+ },
2
16
  {
3
17
  "v": "1.6.0",
4
18
  "d": "14/10/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Fixed",
27
41
  "en": "The HTTP server now answers a request carrying an unknown mcp-session-id with 404 instead of 400, so clients re-initialize instead of staying stuck…",
28
42
  "vi": "HTTP server nay trả về 404 (thay vì 400) khi nhận request với mcp-session-id không xác định, giúp client tự re-initialize thay vì bị kẹt ở trạng…"
29
- },
30
- {
31
- "v": "1.5.8",
32
- "d": "13/10/2026",
33
- "type": "Fixed",
34
- "en": "The OAuth authorize endpoint now accepts http://localhost/127.0.0.1/[::1] redirect URIs on any port as long as the scheme, host, path, and query…",
35
- "vi": "Endpoint OAuth authorize nay chấp nhận redirect URI http://localhost/127.0.0.1/[::1] ở bất kỳ port nào miễn là scheme, host, path, và query khớp với…"
36
- },
37
- {
38
- "v": "1.5.7",
39
- "d": "13/10/2026",
40
- "type": "Changed",
41
- "en": "The ingest_url and render_preview tool descriptions now link to the API docs (docs/tools.md for both, plus the Microlink API reference for…",
42
- "vi": "Mô tả của tool ingest_url và render_preview nay có thêm link tới tài liệu API (docs/tools.md cho cả hai, cùng với link tham chiếu API của Microlink…"
43
43
  }
44
44
  ]
@@ -58,7 +58,7 @@ export const FORM = [
58
58
  summary: "Single-line input. specials.field_name is the submitted data column (REQUIRED & unique).",
59
59
  useWhen: "Name/email/phone fields. Set field_type to text/email/phone/number. Must be a DIRECT child of the form to submit.",
60
60
  keySpecials: {
61
- field_name: "REQUIRED unique data key. Special names coerce the input type: 'phone_number'/'recheck_phone_number' → tel, 'email' → email. Phone pattern validation only applies when field_name=='phone_number' AND validate=true.",
61
+ field_name: "REQUIRED unique data key. Use the EXACT system names for standard columns — full_name, first_name, last_name, phone_number, recheck_phone_number, email, address, quantity, coupon, postal_code (never 'phone'/'name'/'sdt'/'ho_ten'): the editor locks those IDs and refuses them when typed, so a field saved as 'phone' can never be renamed to 'phone_number' there. Special names coerce the input type: 'phone_number'/'recheck_phone_number' → tel, 'email' → email. Phone pattern validation only applies when field_name=='phone_number' AND validate=true.",
62
62
  field_placeholder: "placeholder text.",
63
63
  field_type: "text | email | phone | number | postal_code | date. 'postal_code' enables postcode-detect helper and uses condition/pattern (country_default|limit_5|limit_6|custom). 'date' renders a date input.",
64
64
  required: "boolean.",
@@ -99,7 +99,7 @@ export const FORM = [
99
99
  desktop: { styles: { top: 60, left: 20, width: 360, height: 40 } },
100
100
  mobile: { styles: { top: 60, left: 20, width: 360, height: 40 } },
101
101
  },
102
- specials: { field_name: "phone", field_placeholder: "Số điện thoại", field_type: "phone", required: true },
102
+ specials: { field_name: "phone_number", field_placeholder: "Số điện thoại", field_type: "phone", required: true },
103
103
  },
104
104
  },
105
105
  {
@@ -389,3 +389,37 @@ export const FORM = [
389
389
  },
390
390
  },
391
391
  ];
392
+ /**
393
+ * The editor's SYSTEM form columns — the `default: true` fields of its "add
394
+ * field" panel (landing_page_backend assets/editor/main/panels/FormPanel.vue).
395
+ * The editor creates them with `properties.field_default = true`, which LOCKS
396
+ * the ID box (FieldTrait.vue `disabled`), and it refuses these names when typed
397
+ * (FieldTrait.vue `defaultFieldName` appends a random suffix). So a field must
398
+ * be BORN with the canonical name: a field saved as "phone" can never become
399
+ * "phone_number" in the editor, and only the canonical names get the editor's
400
+ * extra behaviour (phone validation, address autocomplete, quantity, coupon…).
401
+ */
402
+ export const SYSTEM_FIELD_NAMES = new Set([
403
+ "full_name", "first_name", "last_name", "phone_number", "recheck_phone_number",
404
+ "email", "quantity", "coupon", "address", "postal_code", "country_select",
405
+ "verify_code", "access_key", "province_id/district_id/commune_id",
406
+ ]);
407
+ /** Common hand-made spellings of a system column (slugged: lowercase, no accents, `_`). */
408
+ const SYSTEM_FIELD_ALIASES = {
409
+ phone: "phone_number", phonenumber: "phone_number", phone_no: "phone_number", tel: "phone_number",
410
+ telephone: "phone_number", mobile: "phone_number", sdt: "phone_number", so_dt: "phone_number",
411
+ so_dien_thoai: "phone_number", sodienthoai: "phone_number", dien_thoai: "phone_number", dienthoai: "phone_number",
412
+ name: "full_name", fullname: "full_name", your_name: "full_name", customer_name: "full_name",
413
+ ho_ten: "full_name", hoten: "full_name", ho_va_ten: "full_name", ten: "full_name",
414
+ firstname: "first_name", lastname: "last_name",
415
+ mail: "email", e_mail: "email", email_address: "email",
416
+ dia_chi: "address", diachi: "address",
417
+ so_luong: "quantity", soluong: "quantity", qty: "quantity",
418
+ ma_giam_gia: "coupon", voucher: "coupon",
419
+ };
420
+ /** The system column a non-canonical field_name was clearly meant to be, else undefined. */
421
+ export function systemFieldAlias(fieldName) {
422
+ const slug = fieldName.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/đ/gi, "d")
423
+ .toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
424
+ return SYSTEM_FIELD_ALIASES[slug];
425
+ }
@@ -2,6 +2,7 @@ import { CANVAS, EVENT_TRIGGERS, CLICK_ACTIONS, HOVER_ACTIONS, SUCCESS_ACTIONS,
2
2
  import { GENERATION_GUIDE } from "./guide.js";
3
3
  import { INSTRUCTIONS } from "./instructions.js";
4
4
  import { LIBRARY, ELEMENT_TYPES, CONTAINER_TYPES, FIELD_TYPES, createElement } from "./elements/index.js";
5
+ import { SYSTEM_FIELD_NAMES } from "./elements/form.js";
5
6
  import { createPageSource } from "./page.js";
6
7
  import { canvasToPageSource } from "./canvas-to-source.js";
7
8
  import { validatePage, coercePage, pageSchema } from "./validate.js";
@@ -389,6 +390,32 @@ function normalizeSettings(source) {
389
390
  ws.desktop ??= CANVAS.desktopWidth;
390
391
  ws.mobile ??= CANVAS.mobileWidth;
391
392
  }
393
+ // ---------------------------------------------------------------------------
394
+ // form-field lock normalization: the editor locks a field's ID box when
395
+ // `properties.field_default` is true (FieldTrait.vue `disabled`) and only ever
396
+ // sets it on its SYSTEM columns (phone_number, full_name, email…). A model that
397
+ // copies `field_default:true` onto a custom field ("message", "note") leaves
398
+ // the user an ID they can NEVER change; a system column without it is editable
399
+ // in a way the editor itself never allows. So the flag follows the name:
400
+ // on for system names, off for everything else. Deterministic + idempotent,
401
+ // so the expand(compact(x)) == expand(x) invariant holds.
402
+ // ---------------------------------------------------------------------------
403
+ /** Walk a tree node and sync properties.field_default with the field_name in-place (mutates). */
404
+ function normalizeFormFields(node) {
405
+ if (!node || typeof node !== "object")
406
+ return;
407
+ const fn = node.specials?.field_name;
408
+ if (FIELD_TYPES.has(node.type) && typeof fn === "string" && node.properties && typeof node.properties === "object") {
409
+ if (SYSTEM_FIELD_NAMES.has(fn))
410
+ node.properties.field_default = true;
411
+ else
412
+ delete node.properties.field_default;
413
+ }
414
+ if (Array.isArray(node.children)) {
415
+ for (const child of node.children)
416
+ normalizeFormFields(child);
417
+ }
418
+ }
392
419
  /** Apply all post-expand normalizations to every node in a page source. */
393
420
  function normalizeSource(source) {
394
421
  if (!source || typeof source !== "object")
@@ -405,6 +432,7 @@ function normalizeSource(source) {
405
432
  // url() layer in the same value.
406
433
  normalizeColors(node);
407
434
  normalizeBackgrounds(node);
435
+ normalizeFormFields(node);
408
436
  }
409
437
  }
410
438
  }
@@ -41,7 +41,7 @@ MODEL (essentials):
41
41
  - IMAGES: include them (hero/product, feature icons, about photo). The server AUTO-HOSTS external image URLs on every save: any real http(s) image URL you put in specials.src / a url(...) background / gallery item.link / video poster is downloaded and re-hosted automatically by create_page/update_page/add_section/patch_page (the result's rehost field reports how many) — so you do NOT have to pre-call upload_images for reference or web images. WHERE THEY LAND: with credentials AND the settled org, every image is filed into THAT ORG's MEDIA COLLECTION (bộ sưu tập) — the same library the editor's media picker reads — so the user can re-pick it later; rehost.collection:true (and images[x].collection:true from upload_images) confirms it. This is why the org must be settled before any image work (see ORGANIZATION FIRST). With no credentials the image goes to the public CDN instead — the URL works and the page renders, but it is absent from every collection (collection:false); that is a server-setup issue to report, not something to fix by hand-uploading elsewhere. SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL → put the REAL source URL straight into specials.src and let the save host it (use those exact images, never swap them for stock photos; the original URL must reach the save un-altered — NEVER replace a real source image URL with a placeholder). The ONE case you MUST still call upload_images yourself: LOCAL FILE PATHS from the user's computer (pass the path directly in upload_images urls — the save can't read local files; NEVER upload a user's local file to a third-party host like catbox or imgur first), then use the returned URL. (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); (3) if search_images returns ok:false / is unreachable / has no fitting photo → find a real image YOURSELF using whatever web search/fetch capability you have (brand site, product page, free-to-use source) and re-host it via upload_images; (4) a PLACEHOLDER sized to the box (https://placehold.co/<width>x<height>) is the LAST resort, only after (2) AND (3) both failed. (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). DON'T AUTHOR BASE64 IMAGES. A 'data:image/...;base64,...' URI never reaches the stored source — the save DECODES it and uploads it to the CDN like any other image (it counts in the rehost report), and validate_page only WARNS, so a base64 URI that came in from a reference/user paste needs no manual fix: just save. What you must not do is EMIT base64 yourself: the whole image travels through this connection inside your payload, and megabytes there can drop it. Put a real http(s) URL in the slot, or call upload_images (it accepts data: URIs) and use the returned statics.pancake.vn URL. Ensure text contrasts with its section background.
42
42
 
43
43
  - BEYOND ELEMENT CAPABILITY: when a reference effect can't be expressed with an element's built-in specials (hover scale/lift/zoom & transitions, gradients, glassmorphism/backdrop-blur, custom shadows, gradient/clipped text, keyframe animations outside the 9 entrance types) DON'T drop it — use the escape hatches so the page is as complete as possible: element specials.custom_css (extra DECLARATIONS in #w-<id>{…}; declarations-only) + specials.custom_class (both need specials.customAdvance:true) for per-element styling, and page settings.extra_css (a full raw stylesheet in <head> — where :hover/@keyframes/media-queries live, target #w-<element id>) + settings.extra_script (raw JS) + settings.bhet/bbet (raw HTML blocks at end of <head>/<body> — for webfont <link>s, <meta>/verification, analytics/pixels, chat widgets, third-party embeds). get_generation_guide has the recipes. SAFETY (custom is raw + global → sloppy custom BREAKS the whole UI): SCOPE every extra_css rule to "#w-<id>" or a specials.custom_class — NEVER a bare tag / "*" / a Webcake-internal class (.section-container/.rectangle-css/.text-block-css/.group-*…); keep custom_css to VISUAL props only (no position/top/left/width/height/display/float); close every bhet/bbet tag; don't restructure "#w-…" elements in extra_script. validate_page flags unscoped selectors, layout props in custom_css, unbalanced braces/tags, missing customAdvance, and CSS/JS in the wrong field — fix every such warning.
44
- - 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 only — after 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.
44
+ - 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 only — after 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. The custom_domain must be one the user ALREADY ADDED in their account, written EXACTLY as listed there — www.example.com and example.com are DIFFERENT domains (if unsure which form they added, ask; never add or drop "www" yourself). publish_page corrects a www/non-www mismatch to the stored form (see domain_note) and refuses a domain the account doesn't have (reason domain_not_in_account) — then ask the user which listed domain to use.
45
45
  - VISUAL CHECK — a screenshot costs tokens + a round-trip, so by DEFAULT do NOT screenshot. validate_page (errors + warnings) and the save-time auto-fix already catch the mechanical defects (off-canvas, wrapped-text overlap, contrast, missing data) token-free — trust them. Screenshot ONLY when it adds something validation can't: (a) you're CLONING a reference and need to compare pixel fidelity, (b) the USER asks to see/verify the look, or (c) you have a specific AESTHETIC doubt (color/imagery/composition) text-validation can't judge. When you do: SCREENSHOT WITH YOUR OWN CAPABILITY FIRST — a browser/screenshot MCP (e.g. chrome-devtools) or any URL-capture tool (fresh + unlimited); only if you have none, call render_preview(page_id) (PNG; on a quota/429 it returns ok:false → skip that round, never fail the build). Take ONE shot, patch_page only the clear mismatches by id, re-publish (publish_page), and re-shoot ONLY if you changed something material — do NOT reflexively loop 2–3 times. For a clone, compare to the reference (screenshot the reference URL too when you only have HTML) — section order, colors, spacing, image placement, sizing, text. The no-domain preview only renders ~10 min after the last publish, so shoot promptly and re-publish before re-checking.
46
46
 
47
47
  Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, layout, search_images, get_icon_svg, upload_images, render_preview, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
@@ -9,6 +9,7 @@
9
9
  import { readFileSync } from "node:fs";
10
10
  import Ajv2020Module from "ajv/dist/2020.js";
11
11
  import { CONTAINER_TYPES, FIELD_TYPES } from "./elements/index.js";
12
+ import { systemFieldAlias } from "./elements/form.js";
12
13
  import { ANIMATABLE_TYPES, ANIMATION_NAMES } from "./vocab.js";
13
14
  import { estTextHeightPx, measureTextBlock } from "./text-metrics.js";
14
15
  import { isColorKey, isUnparseableByEditor } from "./color.js";
@@ -316,6 +317,12 @@ export function validatePage(input) {
316
317
  if (!fn || typeof fn !== "string" || fn.trim() === "") {
317
318
  warnings.push(`${path} (${type}): form input should have a unique specials.field_name.`);
318
319
  }
320
+ else {
321
+ const canonical = systemFieldAlias(fn);
322
+ if (canonical && canonical !== fn) {
323
+ warnings.push(`${path} (${type}): field_name "${fn}" should be the system column "${canonical}" — the Webcake editor reserves system names (typing "${canonical}" there gets a random suffix), so the user can NEVER rename this field to "${canonical}" later, and only "${canonical}" gets the editor's built-in behaviour. Rename it now (also update any {{${fn}}} placeholder). On an EXISTING page this changes the submitted data column — ask the user first.`);
324
+ }
325
+ }
319
326
  // `field_placeholder` is the ONLY placeholder key the renderer reads. A stray
320
327
  // `placeholder` renders blank; a select with no field_placeholder crashes the
321
328
  // published renderer (unescapeHTML(undefined)).
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Match a publish domain against the domains the account actually added (PURE — no network,
3
+ * smoke-tested). The editor's publish modal only lets the user PICK from that list, so its
4
+ * publishes always carry the stored form; publish_page used to take `custom_domain` verbatim,
5
+ * so an AI that wrote `xsummit.vn` published a page the customer had added as `www.xsummit.vn`
6
+ * — the page lived on a domain record that doesn't exist, and Dynamic campaigns on it failed.
7
+ *
8
+ * www and non-www are DIFFERENT domain records, so neither form is "the right one" in general:
9
+ * the stored form wins. Exact match → keep; the www-toggled form is stored → use that;
10
+ * neither → not in the account (the caller refuses rather than publishing to a phantom domain).
11
+ */
12
+ /** Lowercase host only: strips scheme, path/query, port, trailing dot and whitespace. */
13
+ export function normalizeDomain(d) {
14
+ return String(d ?? "")
15
+ .trim()
16
+ .toLowerCase()
17
+ .replace(/^[a-z]+:\/\//, "")
18
+ .replace(/[/?#].*$/, "")
19
+ .replace(/:\d+$/, "")
20
+ .replace(/\.$/, "");
21
+ }
22
+ export function matchAccountDomain(requested, accountDomains) {
23
+ const want = normalizeDomain(requested);
24
+ const stored = new Map();
25
+ for (const d of accountDomains) {
26
+ const n = normalizeDomain(d);
27
+ if (n && !stored.has(n))
28
+ stored.set(n, n);
29
+ }
30
+ if (want && stored.has(want))
31
+ return { ok: true, domain: want, adjusted: requested.trim() !== want };
32
+ const toggled = want.startsWith("www.") ? want.slice(4) : `www.${want}`;
33
+ if (want && stored.has(toggled))
34
+ return { ok: true, domain: toggled, adjusted: true };
35
+ return { ok: false, candidates: [...stored.keys()].slice(0, 20) };
36
+ }
@@ -74,6 +74,7 @@ const PAGES_ENDPOINT = "/api/v1/ai/pages";
74
74
  const SEARCH_PAGES_ENDPOINT = "/api/v1/ai/search_pages";
75
75
  const PAGE_SOURCE_ENDPOINT = "/api/v1/ai/page_source";
76
76
  const PAGE_VERSION_ENDPOINT = "/api/v1/ai/page_version";
77
+ const DOMAINS_ENDPOINT = "/api/v1/domains/all";
77
78
  const UPDATE_ENDPOINT = "/api/v1/ai/update_page_source";
78
79
  const APPEND_ENDPOINT = "/api/v1/ai/append_section";
79
80
  // The editor's own publish routes (NOT under /api/v1/ai). Both scopes are
@@ -290,10 +291,10 @@ export async function createPage(config, name, source, orgId) {
290
291
  // ---------------------------------------------------------------------------
291
292
  // Read / list / edit existing pages
292
293
  // ---------------------------------------------------------------------------
293
- async function getJson(url, config) {
294
+ async function getJson(url, config, orgId) {
294
295
  let res;
295
296
  try {
296
- res = await fetch(url, { method: "GET", headers: authHeaders(config), signal: timeoutSignal(HTTP_TIMEOUT_MS) });
297
+ res = await fetch(url, { method: "GET", headers: authHeaders(config, orgId), signal: timeoutSignal(HTTP_TIMEOUT_MS) });
297
298
  }
298
299
  catch (e) {
299
300
  const e2 = timeoutOrNetworkError(url, e);
@@ -343,6 +344,27 @@ export async function searchPages(config, filters) {
343
344
  const pages = r.json?.data?.pages ?? r.json?.pages ?? [];
344
345
  return { ok: true, status: r.status, pages };
345
346
  }
347
+ /**
348
+ * The VERIFIED domains the account can publish to, via the dashboard's own domain list
349
+ * (`GET /api/v1/domains/all` → LandingPageWeb.V1.DomainController.get_all_domains). The list is
350
+ * per ORGANIZATION (`x-org-id`; a page in an org can only use that org's domains), else the
351
+ * account's personal domains. `term` is a case-insensitive substring filter server-side — pass the
352
+ * bare host (no www) so both the www and non-www records come back. publish_page uses this to
353
+ * publish to the domain EXACTLY as stored (see persistence/domain-match.ts).
354
+ */
355
+ export async function listVerifiedDomains(config, opts = {}) {
356
+ const qs = new URLSearchParams({ is_verified: "true", limit: "50" });
357
+ if (opts.term)
358
+ qs.set("term", opts.term);
359
+ const org = opts.orgId == null || `${opts.orgId}` === "" ? undefined : `${opts.orgId}`;
360
+ const r = await getJson(`${config.base}${DOMAINS_ENDPOINT}?${qs}`, config, org);
361
+ if (!r.ok)
362
+ return { ok: false, status: r.status, endpoint_missing: r.status === 404, error: r.error };
363
+ const rows = r.json?.domains?.data ?? r.json?.data?.data ?? r.json?.data ?? [];
364
+ if (!Array.isArray(rows))
365
+ return { ok: false, status: r.status, error: "Unexpected domains response shape" };
366
+ return { ok: true, status: r.status, domains: rows.map((d) => String(d?.domain ?? "")).filter(Boolean) };
367
+ }
346
368
  /** Read a page's decoded source tree (must be owned by the account). */
347
369
  /**
348
370
  * The page source's VERSION only (`updated_at`), a few hundred bytes instead of the
package/dist/smoke.js CHANGED
@@ -15,6 +15,7 @@ import { registerFontGroups } from "./persistence/webcake-client.js";
15
15
  import { warningsField } from "./mcp/response.js";
16
16
  import { readConfig, resolveEnv, ENV_NAMES, configFromHeaders } from "./persistence/config.js";
17
17
  import { toEditorUrl, toEditorLoginUrl, toPreviewUrl, buildPublishRequestRedacted } from "./persistence/webcake-client.js";
18
+ import { matchAccountDomain, normalizeDomain } from "./persistence/domain-match.js";
18
19
  import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsProxyBase, buildSearchQuery, PEXELS_PROXY_DEFAULT, variantPixels, annotateVariantSizes } from "./persistence/pexels-client.js";
19
20
  import { putDraft, getDraft, updateDraft, deleteDraft } from "./persistence/draft-cache.js";
20
21
  import { buildConnectUrl, parseCallback } from "./auth/login.js";
@@ -186,6 +187,36 @@ console.log("== expand: relocates misplaced responsive.<bp>.animation into confi
186
187
  const kept = landingDomain.expand(both).page[0].children[0].responsive.desktop;
187
188
  check("explicit config.animation wins over the stray key", kept.config.animation.name === "zoomIn" && kept.animation === undefined, kept.config);
188
189
  }
190
+ console.log("== form fields: field_default follows the system field_name; alias names warn ==");
191
+ {
192
+ // The editor locks a field's ID when properties.field_default is true and
193
+ // refuses system names when typed — so the lock must match the name, and a
194
+ // hand-made "phone" must be flagged before it is saved un-renameable.
195
+ const mk = (fields) => ({
196
+ page: [{ id: "s1", type: "section", responsive: { desktop: { styles: { height: 400 } }, mobile: { styles: { height: 400 } } },
197
+ children: [{ id: "f1", type: "form",
198
+ responsive: { desktop: { styles: { top: 10, left: 10, width: 400, height: 300 } }, mobile: { styles: { top: 10, left: 10, width: 400, height: 300 } } },
199
+ children: fields.map((f, i) => ({ id: `i${i}`, type: f.type ?? "input", properties: f.properties,
200
+ responsive: { desktop: { styles: { top: i * 50, left: 0, width: 300, height: 40 } }, mobile: { styles: { top: i * 50, left: 0, width: 300, height: 40 } } },
201
+ specials: { field_name: f.name, field_placeholder: "x" } })) }] }],
202
+ popup: [], settings: {},
203
+ });
204
+ const ex = landingDomain.expand(mk([
205
+ { name: "phone_number" },
206
+ { name: "message", properties: { field_default: true } },
207
+ { name: "phone" },
208
+ ]));
209
+ const kids = ex.page[0].children[0].children;
210
+ check("system field gets field_default:true", kids[0].properties.field_default === true, kids[0].properties);
211
+ check("custom field never locked", kids[1].properties.field_default === undefined, kids[1].properties);
212
+ check("field_default normalization idempotent", JSON.stringify(landingDomain.expand(ex)) === JSON.stringify(ex));
213
+ const w = validatePage(ex).warnings;
214
+ check("alias 'phone' warns → phone_number", w.some((m) => m.includes('"phone"') && m.includes('"phone_number"')), w);
215
+ check("canonical/custom names don't warn", !w.some((m) => m.includes('"message" should') || m.includes('"phone_number" should')), w);
216
+ const vi = landingDomain.expand(mk([{ name: "Số điện thoại" }, { name: "Họ tên" }]));
217
+ const wv = validatePage(vi).warnings;
218
+ check("Vietnamese aliases warn", wv.some((m) => m.includes('"phone_number"')) && wv.some((m) => m.includes('"full_name"')), wv);
219
+ }
189
220
  console.log("== validate: accepts JSON string input ==");
190
221
  const r3 = validatePage(JSON.stringify(good));
191
222
  check("string input parsed & valid", r3.valid, r3.errors);
@@ -2502,6 +2533,22 @@ console.log("== stale-overwrite guard: a UI edit is never silently reverted ==")
2502
2533
  check("page-version: store is bounded (LRU evicts the oldest)", lastKnownPageVersion("bulk0") === undefined && lastKnownPageVersion("bulk119")?.fp === "fp119");
2503
2534
  resetPageVersions();
2504
2535
  }
2536
+ console.log("== publish_page: custom_domain matches the account's stored domain (www vs non-www) ==");
2537
+ {
2538
+ // The xsummit case: customer added www.xsummit.vn, the AI passed xsummit.vn verbatim.
2539
+ const m1 = matchAccountDomain("xsummit.vn", ["www.xsummit.vn"]);
2540
+ check("domain: non-www request → the stored www form", m1.ok && m1.domain === "www.xsummit.vn" && m1.adjusted, m1);
2541
+ const m2 = matchAccountDomain("www.shop.vn", ["shop.vn"]);
2542
+ check("domain: www request → the stored non-www form (never force www)", m2.ok && m2.domain === "shop.vn" && m2.adjusted, m2);
2543
+ const m3 = matchAccountDomain("shop.vn", ["shop.vn", "www.shop.vn"]);
2544
+ check("domain: both forms stored → exact match wins", m3.ok && m3.domain === "shop.vn" && !m3.adjusted, m3);
2545
+ const m4 = matchAccountDomain("https://WWW.Shop.vn/sale?x=1", ["www.shop.vn"]);
2546
+ check("domain: scheme/case/path stripped before matching", m4.ok && m4.domain === "www.shop.vn", m4);
2547
+ const m5 = matchAccountDomain("other.vn", ["www.shop.vn"]);
2548
+ check("domain: not in the account → no match, lists candidates", !m5.ok && m5.candidates.includes("www.shop.vn"), m5);
2549
+ check("domain: empty account list → no match", !matchAccountDomain("shop.vn", []).ok);
2550
+ check("domain: normalizeDomain drops port + trailing dot", normalizeDomain(" Shop.VN.:8080/a ") === "shop.vn", normalizeDomain(" Shop.VN.:8080/a "));
2551
+ }
2505
2552
  console.log("== server: MCP serverInfo.version follows package.json (no hardcoded constant) ==");
2506
2553
  {
2507
2554
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -13,7 +13,8 @@ import { z } from "zod";
13
13
  import { text, warningsField, autoFixedField } from "../mcp/response.js";
14
14
  import { objectOrJson, objectsOrJson } from "../mcp/params.js";
15
15
  import { readConfig, configFromHeaders } from "../persistence/config.js";
16
- import { buildRequestRedacted, buildUpdateRequestRedacted, buildAppendRequestRedacted, buildPublishRequestRedacted, buildPageApp, createPage, listOrganizations, listPages, searchPages, getPageSource, getPageVersion, updatePageSource, appendSection, publishPage, toPreviewUrl, } from "../persistence/webcake-client.js";
16
+ import { buildRequestRedacted, buildUpdateRequestRedacted, buildAppendRequestRedacted, buildPublishRequestRedacted, buildPageApp, createPage, listOrganizations, listPages, searchPages, listVerifiedDomains, getPageSource, getPageVersion, updatePageSource, appendSection, publishPage, toPreviewUrl, } from "../persistence/webcake-client.js";
17
+ import { matchAccountDomain, normalizeDomain } from "../persistence/domain-match.js";
17
18
  import { putDraft, getDraft, updateDraft, deleteDraft } from "../persistence/draft-cache.js";
18
19
  import { sourceFingerprint, decideOverwrite, rememberPageVersion, forgetPageVersion, lastKnownPageVersion, sourceCacheKey, cachePageSource, cachedPageSource, cachedPageSourceVersion, dropCachedPageSource, } from "../persistence/page-version.js";
19
20
  /**
@@ -1405,7 +1406,7 @@ export function registerPersistenceTools(server, domain) {
1405
1406
  custom_domain: z
1406
1407
  .string()
1407
1408
  .optional()
1408
- .describe("Optional custom domain to serve the page at (e.g. 'shop.example.com' — must already point at Webcake). OMIT to reuse the page's CURRENTLY-ATTACHED domain (so republishing keeps it live at the same URL, like the editor's publish modal); if the page has none it publishes domain-less (preview-host URL). Pass an empty string '' to force a domain-less publish even when the page has a domain."),
1409
+ .describe("Optional custom domain to serve the page at (e.g. 'shop.example.com' — must already point at Webcake). Use it EXACTLY as the account lists it: 'www.example.com' and 'example.com' are DIFFERENT domain records — a www/non-www mismatch is corrected to the stored form, and a domain the account never added is refused (nothing published). OMIT to reuse the page's CURRENTLY-ATTACHED domain (so republishing keeps it live at the same URL, like the editor's publish modal); if the page has none it publishes domain-less (preview-host URL). Pass an empty string '' to force a domain-less publish even when the page has a domain."),
1409
1410
  custom_path: z.string().optional().describe("Optional path under the custom domain (e.g. 'sale')."),
1410
1411
  dry_run: z.boolean().optional().describe("Default TRUE — preview the request without sending. Does NOT call the build host. Set false to actually publish (build + publish)."),
1411
1412
  }, { title: "Publish Webcake Page", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, custom_domain, custom_path, dry_run }, extra) => {
@@ -1444,6 +1445,41 @@ export function registerPersistenceTools(server, domain) {
1444
1445
  // Normalize an empty-string domain to "no domain" so downstream !! checks agree.
1445
1446
  if (!effectiveDomain)
1446
1447
  effectiveDomain = undefined;
1448
+ // Publish to the domain EXACTLY as the account stores it (the editor's modal can only pick
1449
+ // from that list). A verbatim custom_domain once published `xsummit.vn` for a customer who had
1450
+ // added `www.xsummit.vn`: the page lived on a domain record that doesn't exist and Dynamic
1451
+ // campaigns on it failed. Explicit domain not in the account → refuse. An INHERITED domain that
1452
+ // isn't listed (shared from another member, older record) or a lookup failure → publish as
1453
+ // before, with a note — never block a republish on the lookup itself.
1454
+ let domainNote;
1455
+ if (effectiveDomain) {
1456
+ const bare = normalizeDomain(effectiveDomain).replace(/^www\./, "");
1457
+ const listed = await listVerifiedDomains(config, { term: bare, orgId: res.organization_id });
1458
+ if (listed.ok) {
1459
+ const m = matchAccountDomain(effectiveDomain, listed.domains ?? []);
1460
+ if (m.ok) {
1461
+ if (m.domain !== effectiveDomain)
1462
+ domainNote = `'${effectiveDomain}' is stored in the account as '${m.domain}' — publishing to '${m.domain}' (www and non-www are different domains; campaigns and tracking key on the stored one).`;
1463
+ effectiveDomain = m.domain;
1464
+ }
1465
+ else if (!domainInherited) {
1466
+ return text({
1467
+ published: false,
1468
+ reason: "domain_not_in_account",
1469
+ page_id,
1470
+ requested_domain: effectiveDomain,
1471
+ account_domains_matching: m.candidates,
1472
+ hint: "The domain must be added and verified in the account (Domains) first, and passed EXACTLY as listed — www.example.com and example.com are different. Pick one of account_domains_matching, ask the user which domain to use, or pass custom_domain:'' to publish without a domain.",
1473
+ });
1474
+ }
1475
+ else {
1476
+ domainNote = `The page's attached domain '${effectiveDomain}' is not among the account's verified domains — republished to it unchanged.`;
1477
+ }
1478
+ }
1479
+ else {
1480
+ domainNote = `Could not check '${effectiveDomain}' against the account's domain list (${listed.error ?? `HTTP ${listed.status}`}) — publishing to it as given.`;
1481
+ }
1482
+ }
1447
1483
  const opts = { customDomain: effectiveDomain, customPath: effectivePath };
1448
1484
  const buildBase = config.buildBase;
1449
1485
  // Only the preview window serves a domain-less publish, and only briefly.
@@ -1465,6 +1501,7 @@ export function registerPersistenceTools(server, domain) {
1465
1501
  ? { 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." }
1466
1502
  : { 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." },
1467
1503
  ...(previewExpiryNote ? { note: previewExpiryNote } : {}),
1504
+ ...(domainNote ? { domain_note: domainNote } : {}),
1468
1505
  request: buildPublishRequestRedacted(config, page_id, res.source, opts, !!buildBase),
1469
1506
  hint: "Re-run with dry_run=false to actually publish.",
1470
1507
  });
@@ -1509,6 +1546,7 @@ export function registerPersistenceTools(server, domain) {
1509
1546
  status: outcome.status,
1510
1547
  error: outcome.error,
1511
1548
  ...(outcome.ok && previewExpiryNote ? { note: previewExpiryNote } : {}),
1549
+ ...(domainNote ? { domain_note: domainNote } : {}),
1512
1550
  ...(buildWarning ? { warning: buildWarning } : {}),
1513
1551
  });
1514
1552
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
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",