webcake-landing-mcp 1.5.0 → 1.5.1
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/dist/changelog.json +7 -7
- package/dist/domains/landing/guide.js +1 -1
- package/dist/domains/landing/index.js +112 -1
- package/dist/domains/landing/instructions.js +1 -1
- package/dist/domains/landing/page-schema.json +24 -24
- package/dist/domains/landing/validate.js +25 -0
- package/dist/persistence/page-version.js +198 -0
- package/dist/persistence/webcake-client.js +24 -0
- package/dist/smoke.js +160 -0
- package/dist/tools/persistence.js +178 -14
- package/package.json +1 -1
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.5.1",
|
|
4
|
+
"d": "24/08/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "update_page now detects when the live page was edited outside this session (e.g. in the Webcake editor) since it was last read, and rejects the…",
|
|
7
|
+
"vi": "update_page nay phát hiện khi trang đang sống đã bị chỉnh sửa bên ngoài phiên làm việc này (ví dụ trong editor Webcake) kể từ lần đọc gần nhất, và…"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"v": "1.5.0",
|
|
4
11
|
"d": "23/07/2026",
|
|
@@ -33,12 +40,5 @@
|
|
|
33
40
|
"type": "Added",
|
|
34
41
|
"en": "New layout tool computes exact top/left/width/height coordinates for both breakpoints so the model never hand-calculates left/top (the number-one…",
|
|
35
42
|
"vi": "Tool mới layout tính toán chính xác tọa độ top/left/width/height cho cả hai breakpoint để model không cần tự tính left/top bằng tay (nguyên nhân…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.1.0",
|
|
39
|
-
"d": "20/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "create_page, add_section, and validate_page now apply a deterministic layout auto-fix before validating or saving: off-canvas children are pulled…",
|
|
42
|
-
"vi": "create_page, add_section và validate_page nay tự động áp dụng một lần auto-fix layout xác định trước khi validate hoặc lưu: các phần tử con nằm…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -113,7 +113,7 @@ SECTION BUILD HINTS (apply to whichever sections the chosen archetype uses)
|
|
|
113
113
|
|
|
114
114
|
RULES
|
|
115
115
|
- Visible content goes in "specials" (text-block.specials.text, image-block.specials.src…), NEVER in "styles".
|
|
116
|
-
- Colors as rgba(r,g,b,a). fontSize/borderWidth/top/left/width/height are NUMBERS (px). borderRadius is a STRING with CSS units ("8px", "50%", "16px 16px 0 0") — a bare number or unit-less string is auto-coerced to px by the server, but write the unit explicitly to avoid surprises.
|
|
116
|
+
- Colors as rgba(r,g,b,a). fontSize/borderWidth/top/left/width/height are NUMBERS (px) — always EMIT numbers. (Reading an existing page you may still meet strings: the editor saves fontSize as "59"/"13px", height as "600", opacity as "50%". The server normalizes those back to numbers on both get_page and save, so a hand-edited page is never locked out of the MCP; only a value with a unit the renderer cannot use — e.g. width:"100%" — is reported, as a warning.) borderRadius is a STRING with CSS units ("8px", "50%", "16px 16px 0 0") — a bare number or unit-less string is auto-coerced to px by the server, but write the unit explicitly to avoid surprises.
|
|
117
117
|
- IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). The save AUTO-HOSTS external image URLs: any real http(s) URL you put in specials.src / a url(...) background / gallery item.link / video poster gets downloaded and re-hosted to the Webcake CDN automatically on create_page/update_page/add_section/patch_page (the response's rehost field reports the count) — so reference and web image URLs do NOT need a pre-call to upload_images. SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL (ingest AST images/background_images/og_image, canvas src/background) → put the REAL source URL straight into the slot and let the save host it; use those EXACT images, never swap them for stock photos, and carry the URL through un-altered (never drop it, never placeholder a slot that had a real source image). The ONE case you must call upload_images yourself FIRST: LOCAL FILE PATHS from the user's computer (pass the path directly in upload_images 'urls' — the save can't read local files; NEVER relay the user's local file through a third-party host like catbox or imgur), 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', 'modern office team') and put a returned URL into image-block specials.src. MATCH THE VARIANT TO THE SLOT SIZE — search_images returns a per-photo \`sizes\` map (delivered WxH per variant) + a \`size_guide\`; pick the variant whose width ≈ the element's RENDERED width (×1–2 for retina), then stop: full-bleed hero/banner (~900–1200px) → src.large (~940px) or src.large2x (~1880px); feature card/thumb (~260–360px) → src.medium (~350px); avatar/logo (~64–120px) → src.tiny (280px). NEVER stretch a small variant across a big slot — it pixelates ('vỡ ảnh'); NEVER put src.original (full-res, often several MB) or an oversized variant in a small card — it makes the page slow/heavy ('nặng trang'). If a photo's NATIVE width (photo.width) is smaller than the slot, pick a higher-res photo instead of upscaling. (avg_color helps pick a matching section background); (3) if search_images returns ok:false, is unreachable, or has NO photo that fits the slot → find a real image YOURSELF using whatever web search/fetch capability you have (the brand's own site, the product page, a free-to-use source), then re-host it via upload_images and use the returned URL; (4) a PLACEHOLDER is the LAST resort, ONLY after (2) AND (3) both failed — sized to the box: "https://placehold.co/<width>x<height>". Never jump straight from a failed search to a placeholder without trying (3). NEVER leave src empty — it renders blank on the live page. The server automatically derives styles.background from specials.src on every expand (create/update/validate) using the editor's exact format: 'center center/ cover no-repeat scroll content-box url(<src>) border-box' — you do NOT need to set styles.background manually; if you do hand-write it, it must contain url(...). A SECTION background may layer a gradient overlay over an image: 'linear-gradient(...), center center/ cover no-repeat scroll content-box url(<src>) border-box' — the server canonicalises any url() layer into that exact editor shorthand on expand (other url() formats survive the first save but get mangled to 'undefined/ undefined/ …' the moment the page is edited in the Webcake editor). gallery.media = array of OBJECTS {type:'image', link:'<real-or-placeholder-url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} (NOT plain URL strings — the gallery reads item.link); video.specials.img = a poster image (real photo, else placeholder). Do NOT set a flat (no url()) styles.background on a video element — it suppresses the poster image.
|
|
118
118
|
- CONTRAST (check EVERY text element against the band it sits on, especially SATURATED / mid-tone bands like yellow, orange, teal, pink — there "light vs dark text" is not obvious, so decide by the band's luminance): light bands → near-black text (e.g. rgba(20,30,25,1)); dark bands → near-white text; a saturated/mid-tone band → whichever of near-black or near-white actually reads (for a bright yellow/amber band that means DARK text, not white/grey). NEVER use muted-grey, low-alpha (alpha < ~0.85), or near-white text on a colored band — that is exactly what makes labels look faded/sunken. Muted-grey is ONLY for secondary text on a white/very-light band. Icons and their captions follow the SAME rule as the text beside them.
|
|
119
119
|
- movable:false for section/slide/grid-item/popup; otherwise true. runtime is always {}.
|
|
@@ -226,6 +226,104 @@ function normalizeMisplacedAnimation(node) {
|
|
|
226
226
|
normalizeMisplacedAnimation(child);
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// editor-value normalization: the Webcake EDITOR does not store every numeric
|
|
231
|
+
// style as a number. Confirmed write paths in landing_page_backend/assets/editor:
|
|
232
|
+
// • fontSize → "59" (main/panels/EditTextPanel.vue: updateStyle('fontSize', e.target.value))
|
|
233
|
+
// "13px" / "11px" / "70" (main/pickers/*.vue seed values)
|
|
234
|
+
// • height → "600" (main/traits/SizeAndPosition.vue handleBlur: e.target.value)
|
|
235
|
+
// • borderWidth→ "1" / "0px"
|
|
236
|
+
// • opacity → "50%" (main/traits/Layer.vue: updateStyle('opacity', val + '%'))
|
|
237
|
+
// • top/left → "unset"
|
|
238
|
+
// The published renderer copes (landing_page_build/render/build/exportCss.js
|
|
239
|
+
// parseInt()s the KEY_PREFIX_PIXEL group and string-concatenates '+ "px"' for
|
|
240
|
+
// top/left/width/height), so these pages are perfectly valid — but the MCP
|
|
241
|
+
// schema demanded numbers, so ONE manual tweak in the editor locked the page out
|
|
242
|
+
// of every MCP edit tool. That is the bug this pass removes.
|
|
243
|
+
//
|
|
244
|
+
// Fix: after every expand pass, coerce a unit-less or px-suffixed numeric string
|
|
245
|
+
// back to a number for the keys the renderer treats as bare px numbers, and turn
|
|
246
|
+
// an editor percent opacity into the 0–1 fraction. Values the coercion cannot
|
|
247
|
+
// read (`"unset"`, `"auto"`, `"100%"`) are left alone — the schema accepts them
|
|
248
|
+
// and validate.ts warns where the renderer would choke. Deterministic +
|
|
249
|
+
// idempotent, so the expand(compact(x)) == expand(x) invariant holds.
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
/** Style keys the renderer emits as bare px numbers (or parseInt()s). */
|
|
252
|
+
const NUMERIC_STYLE_KEYS = new Set(["top", "left", "width", "height", "zIndex", "fontSize", "borderWidth"]);
|
|
253
|
+
/** Per-breakpoint config keys the renderer appends "px" to / uses as numbers. */
|
|
254
|
+
const NUMERIC_CONFIG_KEYS = new Set([
|
|
255
|
+
"virtualHeight", "column", "row", "slideWidth", "iconSize", "iconTop", "linePaddingLeft",
|
|
256
|
+
"topBgImage", "leftBgImage", "widthBgImage", "heightBgImage",
|
|
257
|
+
"stickyTop", "stickyLeft", "stickyRight", "stickyBottom",
|
|
258
|
+
]);
|
|
259
|
+
/** A number written as a string, with an optional "px" suffix: "59", " 12.5 ", "13px". */
|
|
260
|
+
const NUMERIC_PX_STRING = /^\s*-?\d+(?:\.\d+)?\s*(?:px)?\s*$/i;
|
|
261
|
+
/** "59" | "13px" -> 59 | 13. Anything else (including numbers) passes through. */
|
|
262
|
+
function numFromEditorString(v) {
|
|
263
|
+
if (typeof v !== "string" || !NUMERIC_PX_STRING.test(v))
|
|
264
|
+
return v;
|
|
265
|
+
const n = parseFloat(v);
|
|
266
|
+
return Number.isFinite(n) ? n : v;
|
|
267
|
+
}
|
|
268
|
+
/** "50%" -> 0.5 (the editor's Layer trait writes percent); else numeric-string coercion. */
|
|
269
|
+
function opacityFromEditorString(v) {
|
|
270
|
+
if (typeof v !== "string")
|
|
271
|
+
return v;
|
|
272
|
+
const pct = v.trim().match(/^(-?\d+(?:\.\d+)?)\s*%$/);
|
|
273
|
+
if (pct) {
|
|
274
|
+
const n = parseFloat(pct[1]) / 100;
|
|
275
|
+
return Number.isFinite(n) ? n : v;
|
|
276
|
+
}
|
|
277
|
+
return numFromEditorString(v);
|
|
278
|
+
}
|
|
279
|
+
/** Walk a tree node and coerce editor-written numeric strings in-place (mutates). */
|
|
280
|
+
function normalizeEditorValues(node) {
|
|
281
|
+
if (!node || typeof node !== "object")
|
|
282
|
+
return;
|
|
283
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
284
|
+
const rbp = node.responsive?.[bp];
|
|
285
|
+
if (!rbp || typeof rbp !== "object")
|
|
286
|
+
continue;
|
|
287
|
+
const styles = rbp.styles;
|
|
288
|
+
if (styles && typeof styles === "object") {
|
|
289
|
+
for (const key of NUMERIC_STYLE_KEYS) {
|
|
290
|
+
if (styles[key] !== undefined)
|
|
291
|
+
styles[key] = numFromEditorString(styles[key]);
|
|
292
|
+
}
|
|
293
|
+
if (styles.opacity !== undefined && styles.opacity !== null) {
|
|
294
|
+
styles.opacity = opacityFromEditorString(styles.opacity);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const config = rbp.config;
|
|
298
|
+
if (config && typeof config === "object") {
|
|
299
|
+
for (const key of NUMERIC_CONFIG_KEYS) {
|
|
300
|
+
if (config[key] !== undefined)
|
|
301
|
+
config[key] = numFromEditorString(config[key]);
|
|
302
|
+
}
|
|
303
|
+
const anim = config.animation;
|
|
304
|
+
if (anim && typeof anim === "object") {
|
|
305
|
+
for (const key of ["delay", "duration"]) {
|
|
306
|
+
if (anim[key] !== undefined && anim[key] !== null)
|
|
307
|
+
anim[key] = numFromEditorString(anim[key]);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (Array.isArray(node.children)) {
|
|
313
|
+
for (const child of node.children)
|
|
314
|
+
normalizeEditorValues(child);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** settings.width_section carries the canvas width the coords live in — keep it numeric. */
|
|
318
|
+
function normalizeSettings(source) {
|
|
319
|
+
const ws = source?.settings?.width_section;
|
|
320
|
+
if (!ws || typeof ws !== "object")
|
|
321
|
+
return;
|
|
322
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
323
|
+
if (ws[bp] !== undefined)
|
|
324
|
+
ws[bp] = numFromEditorString(ws[bp]);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
229
327
|
/** Apply all post-expand normalizations to every node in a page source. */
|
|
230
328
|
function normalizeSource(source) {
|
|
231
329
|
if (!source || typeof source !== "object")
|
|
@@ -233,6 +331,7 @@ function normalizeSource(source) {
|
|
|
233
331
|
for (const arr of ["page", "popup", "dynamic_pages"]) {
|
|
234
332
|
if (Array.isArray(source[arr])) {
|
|
235
333
|
for (const node of source[arr]) {
|
|
334
|
+
normalizeEditorValues(node);
|
|
236
335
|
normalizeMisplacedAnimation(node);
|
|
237
336
|
normalizeImageBlocks(node);
|
|
238
337
|
normalizeBorderRadius(node);
|
|
@@ -240,6 +339,7 @@ function normalizeSource(source) {
|
|
|
240
339
|
}
|
|
241
340
|
}
|
|
242
341
|
}
|
|
342
|
+
normalizeSettings(source);
|
|
243
343
|
return source;
|
|
244
344
|
}
|
|
245
345
|
export const landingDomain = {
|
|
@@ -264,7 +364,18 @@ export const landingDomain = {
|
|
|
264
364
|
},
|
|
265
365
|
compact: (input) => {
|
|
266
366
|
try {
|
|
267
|
-
|
|
367
|
+
// Normalize FIRST (on a clone, so a caller's fetched tree is never mutated):
|
|
368
|
+
// a page hand-tweaked in the editor stores fontSize as "59" and height as
|
|
369
|
+
// "600", and the model should READ the canonical number it is asked to
|
|
370
|
+
// WRITE. Normalizing before compaction also lets a value that merely
|
|
371
|
+
// differs from the seed by its type ("20" vs 20) compact away.
|
|
372
|
+
const normalized = structuredClone(coercePage(input));
|
|
373
|
+
for (const arr of ["page", "popup", "dynamic_pages"]) {
|
|
374
|
+
if (Array.isArray(normalized?.[arr]))
|
|
375
|
+
normalized[arr].forEach(normalizeEditorValues);
|
|
376
|
+
}
|
|
377
|
+
normalizeSettings(normalized);
|
|
378
|
+
return compactSource(normalized, createElement);
|
|
268
379
|
}
|
|
269
380
|
catch {
|
|
270
381
|
return input; // bad JSON — return as-is
|
|
@@ -15,7 +15,7 @@ RULES (follow for every request):
|
|
|
15
15
|
- BUILD THE SOURCE IN ONE PASS — gather everything you need BEFORE assembling the source, then build the FULL tree once. BATCH the reads: when a section needs several element types (section + text-block + image-block + button + form + input), call get_element({types:[…]}) ONCE instead of one call per type — same for images, call search_images({queries:[…]}) ONCE with one query per image slot (it dedups + parallelizes and returns one best photo per query). Across MULTIPLE pages in one conversation: reusing skeletons you already fetched is correct, but call get_element for any type you have NOT already fetched in this conversation — NEVER author an unfamiliar type from memory; and if the earlier skeletons are no longer in your context (long conversation, compacted history), re-fetch before building. Do NOT interleave get_element calls between create_page previews and rebuild. create_page/update_page take the entire source as input, so each call re-ships the whole page — re-previewing repeatedly wastes the request.
|
|
16
16
|
- create_page and update_page DEFAULT to dry_run=true (a safety net for ambiguous requests). When the user's intent is clear AND validate_page already passed (no errors, warnings fixed), SKIP the dry-run and call with dry_run=false directly — saves one round-trip. Use dry_run=true only when (a) the request is ambiguous about target/content, (b) the user explicitly asks to "preview" or "xem trước", (c) this is an update_page that overwrites significant existing content, or (d) you genuinely need to inspect the redacted payload. Never loop dry-runs to "check" the source — validate_page is the validator. Do not run dry-run then dry-run again before the real write.
|
|
17
17
|
- LARGE PAGES (4+ sections) — build INCREMENTALLY to avoid the giant single create_page payload that can drop the connection: create_page with a SMALL skeleton (empty/near-empty page) to get a page_id, then call add_section once per section (each call ships ONLY that section; the backend appends it server-side and rejects duplicate ids — no whole-source get+put). Small pages can still go in one create_page pass.
|
|
18
|
-
- EDIT existing pages surgically: find_pages (locate the page by name/domain/id when you don't already have a page_id) → get_page → change ONLY what was asked → keep every other element, its id, and coordinates. For a SMALL edit, PREFER patch_page over update_page: send only the changed elements by id (ops: update/replace/remove/add) instead of re-shipping the whole tree — the MCP fetches the live source, merges, validates and saves server-side. Use update_page only when you're rewriting most of the page. Never regenerate the whole tree for a small change.
|
|
18
|
+
- EDIT existing pages surgically: find_pages (locate the page by name/domain/id when you don't already have a page_id) → get_page → change ONLY what was asked → keep every other element, its id, and coordinates. For a SMALL edit, PREFER patch_page over update_page: send only the changed elements by id (ops: update/replace/remove/add) instead of re-shipping the whole tree — the MCP fetches the live source, merges, validates and saves server-side. Use update_page only when you're rewriting most of the page. Never regenerate the whole tree for a small change. CONCURRENCY — the user may be editing the SAME page in the Webcake editor while you work: update_page replaces the whole stored tree, so a source read a while ago (or a cached draft_id, which can be hours old) would silently revert their changes. The server now checks this for you: get_page returns source_version, and update_page re-reads the page and REJECTS the save with reason:"page_changed_externally" (naming the element ids it would have deleted) when the live tree moved. When you see that, do NOT pass force:true on your own — either redo the edit with patch_page({ page_id, patches }), which merges into the live tree and can never revert anyone, or call get_page again, re-apply the change to the fresh tree, and save. Only use force:true after the user has explicitly said to discard the newer edits.
|
|
19
19
|
- FIX-AFTER-ERROR / RETRY-AFTER-TIMEOUT (don't rebuild — this is the #1 time-waster): every mutating tool (create_page, update_page, add_section, patch_page) writes the payload to a draft cache BEFORE the network call and returns a draft_id. On validation failure, timeout, or any network error, the draft is kept — retry or fix without re-sending the full JSON:
|
|
20
20
|
· create_page failed validation OR timed out → its error returns a draft_id (full source cached). Call patch_page({ draft_id, patches:[…], dry_run:false }) to fix ONLY the listed elements, or retry create_page({ draft_id, dry_run:false }) as-is after a timeout.
|
|
21
21
|
· add_section failed validation, timed out, or returned dry_run=true → response returns a draft_id (section batch cached). Call patch_page({ draft_id, patches:[…], dry_run:false }) to fix elements and append, or retry patch_page({ draft_id, dry_run:false }) (no patches) to commit as-is.
|
|
@@ -97,16 +97,16 @@
|
|
|
97
97
|
"description": "Per-breakpoint CSS. Positioning is absolute (px numbers). additionalProperties allowed for any camelCase CSS property.",
|
|
98
98
|
"additionalProperties": true,
|
|
99
99
|
"properties": {
|
|
100
|
-
"top": { "type": "number", "description": "px, absolute within container. Omit on section." },
|
|
101
|
-
"left": { "type": "number", "description": "px, absolute within container. Omit on section." },
|
|
102
|
-
"width": { "type": "number", "description": "px." },
|
|
103
|
-
"height": { "type": "number", "description": "px. Section uses this as canvas height." },
|
|
100
|
+
"top": { "type": ["number", "string", "null"], "description": "px, absolute within container. EMIT A NUMBER. Read-tolerance: the editor can save a numeric string (\"120\") or the keyword \"unset\" — numeric/px strings are normalized back to numbers before validation. Omit on section." },
|
|
101
|
+
"left": { "type": ["number", "string", "null"], "description": "px, absolute within container. EMIT A NUMBER. Read-tolerance: same as top (numeric string or \"unset\"). Omit on section." },
|
|
102
|
+
"width": { "type": ["number", "string", "null"], "description": "px. EMIT A NUMBER — the renderer appends \"px\" verbatim, so a value that already carries a unit would render as \"120pxpx\". Read-tolerance: unit-less/px strings the editor saves are normalized to numbers." },
|
|
103
|
+
"height": { "type": ["number", "string", "null"], "description": "px. Section uses this as canvas height. EMIT A NUMBER (the editor saves the string \"600\" when the height box loses focus — normalized on read)." },
|
|
104
104
|
"position": { "type": "string", "enum": ["absolute", "relative", "fixed", "static"] },
|
|
105
|
-
"zIndex": { "type": "number" },
|
|
105
|
+
"zIndex": { "type": ["number", "string", "null"], "description": "Stacking order. EMIT A NUMBER; numeric strings are normalized." },
|
|
106
106
|
"background": { "type": "string", "description": "rgba(...) or gradient." },
|
|
107
107
|
"backgroundImage": { "type": "string", "description": "url(...)." },
|
|
108
108
|
"color": { "type": "string", "description": "rgba(r,g,b,a)." },
|
|
109
|
-
"fontSize": { "type": "number", "description": "px." },
|
|
109
|
+
"fontSize": { "type": ["number", "string"], "description": "px. EMIT A NUMBER. Read-tolerance: the editor saves \"59\" (inline text-resize box) and \"13px\" (element pickers); the renderer parseInt()s it, and the MCP normalizes both back to a number." },
|
|
110
110
|
"fontFamily": { "type": "string" },
|
|
111
111
|
"fontWeight": { "type": ["string", "number"] },
|
|
112
112
|
"fontStyle": { "type": "string" },
|
|
@@ -115,12 +115,12 @@
|
|
|
115
115
|
"letterSpacing": { "type": ["number", "string"] },
|
|
116
116
|
"textTransform": { "type": "string" },
|
|
117
117
|
"textDecoration": { "type": "string" },
|
|
118
|
-
"borderStyle": { "type": "string", "enum": ["solid", "dashed", "dotted", "double", "
|
|
119
|
-
"borderWidth": { "type": "number", "description": "px." },
|
|
118
|
+
"borderStyle": { "type": "string", "enum": ["none", "solid", "dashed", "dotted", "double", "groove", "ridge", "inset", "outset", "hidden"], "description": "The full set the editor's border-style dropdown offers (Border.vue)." },
|
|
119
|
+
"borderWidth": { "type": ["number", "string"], "description": "px. EMIT A NUMBER. Read-tolerance: the editor saves \"1\" / \"0px\"; parseInt()ed by the renderer and normalized here." },
|
|
120
120
|
"borderColor": { "type": "string" },
|
|
121
121
|
"borderRadius": { "type": ["string", "number"] },
|
|
122
122
|
"boxShadow": { "type": "string" },
|
|
123
|
-
"opacity": { "type": "number", "
|
|
123
|
+
"opacity": { "type": ["number", "string"], "description": "0–1. EMIT A NUMBER. Read-tolerance: the editor Layer trait saves a percent string (\"50%\") — normalized to the 0–1 fraction (0.5) before validation." },
|
|
124
124
|
"mixBlendMode": { "type": "string" },
|
|
125
125
|
"transform": { "type": "string" },
|
|
126
126
|
"padding": { "type": ["number", "string"] },
|
|
@@ -139,26 +139,26 @@
|
|
|
139
139
|
"additionalProperties": true,
|
|
140
140
|
"properties": {
|
|
141
141
|
"name": { "type": "string", "description": "animate.css name, or \"none\"." },
|
|
142
|
-
"delay": { "type": "number" },
|
|
143
|
-
"duration": { "type": "number" },
|
|
142
|
+
"delay": { "type": ["number", "string"] },
|
|
143
|
+
"duration": { "type": ["number", "string"] },
|
|
144
144
|
"repeat": { "type": ["number", "null"] }
|
|
145
145
|
}
|
|
146
146
|
},
|
|
147
|
-
"virtualHeight": { "type": "number" },
|
|
147
|
+
"virtualHeight": { "type": ["number", "string", "null"] },
|
|
148
148
|
"overlay": { "type": "string", "description": "Overlay color rgba(...)." },
|
|
149
149
|
"hiddenEffect": { "type": "object", "additionalProperties": true },
|
|
150
150
|
"svgMask": { "type": "string" },
|
|
151
151
|
"lock": { "type": "boolean" },
|
|
152
|
-
"column": { "type": "number" },
|
|
153
|
-
"row": { "type": "number" },
|
|
154
|
-
"slideWidth": { "type": "number" },
|
|
155
|
-
"iconSize": { "type": "number" },
|
|
156
|
-
"iconTop": { "type": "number" },
|
|
157
|
-
"linePaddingLeft": { "type": "number" },
|
|
158
|
-
"topBgImage": { "type": "number" },
|
|
159
|
-
"leftBgImage": { "type": "number" },
|
|
160
|
-
"widthBgImage": { "type": "number" },
|
|
161
|
-
"heightBgImage": { "type": "number" }
|
|
152
|
+
"column": { "type": ["number", "string", "null"] },
|
|
153
|
+
"row": { "type": ["number", "string", "null"] },
|
|
154
|
+
"slideWidth": { "type": ["number", "string", "null"] },
|
|
155
|
+
"iconSize": { "type": ["number", "string", "null"] },
|
|
156
|
+
"iconTop": { "type": ["number", "string", "null"] },
|
|
157
|
+
"linePaddingLeft": { "type": ["number", "string", "null"] },
|
|
158
|
+
"topBgImage": { "type": ["number", "string", "null"] },
|
|
159
|
+
"leftBgImage": { "type": ["number", "string", "null"] },
|
|
160
|
+
"widthBgImage": { "type": ["number", "string", "null"] },
|
|
161
|
+
"heightBgImage": { "type": ["number", "string", "null"] }
|
|
162
162
|
}
|
|
163
163
|
},
|
|
164
164
|
"responsive": {
|
|
@@ -203,8 +203,8 @@
|
|
|
203
203
|
"id": { "type": "string", "description": "Event id (any string; ~8 chars in real data)." },
|
|
204
204
|
"type": {
|
|
205
205
|
"type": "string",
|
|
206
|
-
"enum": ["click", "hover", "success", "unset"],
|
|
207
|
-
"description": "Trigger."
|
|
206
|
+
"enum": ["click", "hover", "success", "error", "delay", "unset"],
|
|
207
|
+
"description": "Trigger. Matches the editor's own dropdown: click/hover on any element, success/error on a form, delay on anything (plus the legacy \"unset\")."
|
|
208
208
|
},
|
|
209
209
|
"action": {
|
|
210
210
|
"type": "string",
|
|
@@ -352,6 +352,31 @@ export function validatePage(input) {
|
|
|
352
352
|
`Valid examples: fadeInUp, slideInLeft, zoomIn, bounceIn, backInDown, flipInX, lightSpeedInLeft, rotateIn, rollIn, jackInTheBox.`);
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
|
+
// top/left/width/height are the ONE style group the renderer does NOT emit
|
|
356
|
+
// verbatim: exportCss.js strips them from the generic loop (PROPERTY_NOT_SHOW)
|
|
357
|
+
// and re-emits them as `${getStyle(el, mode, key)}px`. A value that already
|
|
358
|
+
// carries a unit therefore renders as "100%px"/"autopx" — invalid CSS the
|
|
359
|
+
// browser drops, so the element collapses or jumps to 0. Plain numbers and
|
|
360
|
+
// numeric strings are fine (expand() normalizes the latter to numbers first).
|
|
361
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
362
|
+
const styles = node.responsive?.[bp]?.styles;
|
|
363
|
+
if (!styles || typeof styles !== "object")
|
|
364
|
+
continue;
|
|
365
|
+
// A sticky element takes exportCss.js handleStyleSticky(), which positions
|
|
366
|
+
// it from config.sticky* and never reads styles.top/left — the editor
|
|
367
|
+
// deliberately parks "unset" there, so those two are not a problem here.
|
|
368
|
+
const isSticky = !!node.responsive?.[bp]?.config?.sticky;
|
|
369
|
+
for (const key of ["top", "left", "width", "height"]) {
|
|
370
|
+
if (isSticky && (key === "top" || key === "left"))
|
|
371
|
+
continue;
|
|
372
|
+
const raw = styles[key];
|
|
373
|
+
if (typeof raw !== "string" || raw.trim() === "")
|
|
374
|
+
continue;
|
|
375
|
+
warnings.push(`${path} (${type ?? "?"}) [${bp}]: styles.${key}="${raw}" is not a plain px number — ` +
|
|
376
|
+
`the renderer emits this property as \`${key}:${raw}px\`, which the browser drops. ` +
|
|
377
|
+
`Set it to a NUMBER: patch_page({op:'update',id:'${node.id ?? "?"}',styles:{${bp}:{${key}:<px>}}}).`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
355
380
|
// styles.opacity < 1 renders the element permanently faded (exportCss.js emits opacity:<v>)
|
|
356
381
|
for (const bp of ["desktop", "mobile"]) {
|
|
357
382
|
const styles = node.responsive?.[bp]?.styles;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optimistic-concurrency guard for whole-source overwrites.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM. `update_page` (and a committed `kind:'update'` draft) REPLACES a
|
|
5
|
+
* page's stored source with a tree the model read earlier — from `get_page`, or
|
|
6
|
+
* from a draft the MCP cached minutes/hours ago. If the user opens the Webcake
|
|
7
|
+
* editor in between and moves an element, retypes a headline or adds a section,
|
|
8
|
+
* that overwrite silently reverts their work: the snapshot wins and the UI edit
|
|
9
|
+
* is gone. The backend cannot help — `/api/v1/ai/update_page_source` is a blind
|
|
10
|
+
* `update_source(record, %{source: …})` with no version column and no ETag
|
|
11
|
+
* (LandingPageWeb.V1.AiController.update_page_source), so last-write-wins is the
|
|
12
|
+
* only semantics it offers.
|
|
13
|
+
*
|
|
14
|
+
* THE FIX. Fingerprint the STORED source and remember it per page:
|
|
15
|
+
* - every READ we do (`get_page`, the live read inside `patch_page`) records a
|
|
16
|
+
* TRUSTED baseline — the exact bytes the backend just handed us;
|
|
17
|
+
* - every WRITE records an UNTRUSTED baseline — our prediction of what the page
|
|
18
|
+
* now holds. It should match a later read, but the backend re-encodes the JSON
|
|
19
|
+
* on the way in and out, so a mismatch here must never be read as "someone
|
|
20
|
+
* else edited the page";
|
|
21
|
+
* - a server-side append (`add_section`) FORGETS it — the backend rewrote the
|
|
22
|
+
* tree behind our back, so nothing we hold is a baseline any more.
|
|
23
|
+
* Before a blind overwrite we re-read the page and compare (see decideOverwrite).
|
|
24
|
+
*
|
|
25
|
+
* The fingerprint is content-based (canonical key-sorted JSON → sha256), so it
|
|
26
|
+
* survives the backend's decode→re-encode round-trip and needs no schema change.
|
|
27
|
+
*
|
|
28
|
+
* The store is per-process and bounded. Losing it (restart, a second `serve`
|
|
29
|
+
* instance) is safe: with no baseline the caller falls back to the element-id
|
|
30
|
+
* loss check, which still catches the destructive case.
|
|
31
|
+
*/
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
/** Canonical JSON: object keys sorted recursively so two equal trees hash equal. */
|
|
34
|
+
function canonical(v) {
|
|
35
|
+
if (Array.isArray(v))
|
|
36
|
+
return v.map(canonical);
|
|
37
|
+
if (v && typeof v === "object") {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const k of Object.keys(v).sort())
|
|
40
|
+
out[k] = canonical(v[k]);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
/** Short content fingerprint of a page source (object or JSON string). */
|
|
46
|
+
export function sourceFingerprint(source) {
|
|
47
|
+
let v = source;
|
|
48
|
+
if (typeof v === "string") {
|
|
49
|
+
try {
|
|
50
|
+
v = JSON.parse(v);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* not JSON — hash the raw string */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return createHash("sha256").update(JSON.stringify(canonical(v) ?? null)).digest("hex").slice(0, 16);
|
|
57
|
+
}
|
|
58
|
+
/** Every element id in a source tree (page + popup + dynamic_pages, recursively). */
|
|
59
|
+
export function collectElementIds(source) {
|
|
60
|
+
const ids = new Set();
|
|
61
|
+
const walk = (node) => {
|
|
62
|
+
if (!node || typeof node !== "object")
|
|
63
|
+
return;
|
|
64
|
+
if (typeof node.id === "string" && typeof node.type === "string")
|
|
65
|
+
ids.add(node.id);
|
|
66
|
+
if (Array.isArray(node.children))
|
|
67
|
+
for (const c of node.children)
|
|
68
|
+
walk(c);
|
|
69
|
+
};
|
|
70
|
+
let v = source;
|
|
71
|
+
if (typeof v === "string") {
|
|
72
|
+
try {
|
|
73
|
+
v = JSON.parse(v);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return ids;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const arr of ["page", "popup", "dynamic_pages"]) {
|
|
80
|
+
if (Array.isArray(v?.[arr]))
|
|
81
|
+
for (const n of v[arr])
|
|
82
|
+
walk(n);
|
|
83
|
+
}
|
|
84
|
+
return ids;
|
|
85
|
+
}
|
|
86
|
+
const MAX_PAGES = 100;
|
|
87
|
+
const versions = new Map();
|
|
88
|
+
/** Record what the page holds now, and whether we KNOW it (read) or predict it (write). */
|
|
89
|
+
export function rememberPageVersion(pageId, fp, origin) {
|
|
90
|
+
if (!pageId)
|
|
91
|
+
return;
|
|
92
|
+
versions.delete(pageId); // re-insert so Map iteration order = LRU
|
|
93
|
+
versions.set(pageId, { fp, origin, at: Date.now() });
|
|
94
|
+
while (versions.size > MAX_PAGES) {
|
|
95
|
+
const oldest = versions.keys().next().value;
|
|
96
|
+
if (oldest === undefined)
|
|
97
|
+
break;
|
|
98
|
+
versions.delete(oldest);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Drop the baseline — the stored tree changed in a way we cannot predict. */
|
|
102
|
+
export function forgetPageVersion(pageId) {
|
|
103
|
+
if (pageId)
|
|
104
|
+
versions.delete(pageId);
|
|
105
|
+
}
|
|
106
|
+
/** The baseline we hold for this page, or undefined if we hold none. */
|
|
107
|
+
export function lastKnownPageVersion(pageId) {
|
|
108
|
+
if (!pageId)
|
|
109
|
+
return undefined;
|
|
110
|
+
const v = versions.get(pageId);
|
|
111
|
+
return v ? { fp: v.fp, origin: v.origin } : undefined;
|
|
112
|
+
}
|
|
113
|
+
/** Test hook — clears the baseline store. */
|
|
114
|
+
export function resetPageVersions() {
|
|
115
|
+
versions.clear();
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Pure verdict for "may this whole-source overwrite proceed?" — the network read
|
|
119
|
+
* lives in the caller so this stays testable.
|
|
120
|
+
*
|
|
121
|
+
* trusted baseline matches live → allow (any deletions are deliberate)
|
|
122
|
+
* trusted baseline differs → refuse: edited outside this session
|
|
123
|
+
* no/untrusted baseline, live ids dropped → refuse: provable destruction
|
|
124
|
+
* no/untrusted baseline, nothing dropped → allow, flagged unverified
|
|
125
|
+
*/
|
|
126
|
+
export function decideOverwrite(liveSource, incoming, baseline) {
|
|
127
|
+
const liveVersion = sourceFingerprint(liveSource);
|
|
128
|
+
if (baseline && baseline.fp === liveVersion)
|
|
129
|
+
return { allow: true, liveVersion };
|
|
130
|
+
const liveIds = collectElementIds(liveSource);
|
|
131
|
+
const incomingIds = collectElementIds(incoming);
|
|
132
|
+
const dropped = [...liveIds].filter((id) => !incomingIds.has(id));
|
|
133
|
+
// A stale READ baseline is proof the page moved under us — refuse even when the
|
|
134
|
+
// overwrite drops nothing, because it would still revert the edits it does touch.
|
|
135
|
+
if (baseline?.origin === "read") {
|
|
136
|
+
return { allow: false, reason: "page_changed_externally", liveVersion, baseVersion: baseline.fp, dropped };
|
|
137
|
+
}
|
|
138
|
+
// No baseline, or one we only PREDICTED after a write: a mismatch proves nothing,
|
|
139
|
+
// so fall back to the check that is always sound — does this save destroy content?
|
|
140
|
+
if (dropped.length === 0)
|
|
141
|
+
return { allow: true, liveVersion, unverified: true };
|
|
142
|
+
return { allow: false, reason: "unverified_overwrite", liveVersion, baseVersion: baseline?.fp, dropped };
|
|
143
|
+
}
|
|
144
|
+
// ---- version-keyed source cache -------------------------------------------
|
|
145
|
+
//
|
|
146
|
+
// The concurrency check must never be served from a cache — a cached answer
|
|
147
|
+
// cannot see an edit made after it was written, which is the whole bug. What IS
|
|
148
|
+
// cacheable is the SOURCE, validated by the backend's own version token: ask
|
|
149
|
+
// `GET /api/v1/ai/page_version` for `updated_at` (a few hundred bytes), and only
|
|
150
|
+
// re-download the tree when it moved. Unchanged page ⇒ no download at all; the
|
|
151
|
+
// freshness guarantee is identical because the token comes from the server.
|
|
152
|
+
//
|
|
153
|
+
// Entries are keyed by credentials as well as page id, so a hosted `serve`
|
|
154
|
+
// instance can never hand one caller a tree fetched with another caller's JWT.
|
|
155
|
+
// Bounded to a handful of pages — a source can be megabytes.
|
|
156
|
+
const MAX_CACHED_SOURCES = 6;
|
|
157
|
+
const sources = new Map();
|
|
158
|
+
/** Cache key: the page plus a short digest of the credentials it was fetched with. */
|
|
159
|
+
export function sourceCacheKey(pageId, jwt) {
|
|
160
|
+
const who = createHash("sha256").update(jwt ?? "").digest("hex").slice(0, 12);
|
|
161
|
+
return `${who}:${pageId}`;
|
|
162
|
+
}
|
|
163
|
+
/** Store a full getPageSource outcome under the backend version it was read at. */
|
|
164
|
+
export function cachePageSource(key, version, result) {
|
|
165
|
+
if (!version)
|
|
166
|
+
return; // no server token ⇒ nothing to validate a hit against
|
|
167
|
+
sources.delete(key);
|
|
168
|
+
sources.set(key, { version, result, at: Date.now() });
|
|
169
|
+
while (sources.size > MAX_CACHED_SOURCES) {
|
|
170
|
+
const oldest = sources.keys().next().value;
|
|
171
|
+
if (oldest === undefined)
|
|
172
|
+
break;
|
|
173
|
+
sources.delete(oldest);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** The version a cached entry was read at, or undefined when nothing is cached. */
|
|
177
|
+
export function cachedPageSourceVersion(key) {
|
|
178
|
+
return sources.get(key)?.version;
|
|
179
|
+
}
|
|
180
|
+
/** The cached outcome IF it was read at exactly this version, else undefined. */
|
|
181
|
+
export function cachedPageSource(key, version) {
|
|
182
|
+
if (!version)
|
|
183
|
+
return undefined;
|
|
184
|
+
const hit = sources.get(key);
|
|
185
|
+
if (!hit || hit.version !== version)
|
|
186
|
+
return undefined;
|
|
187
|
+
sources.delete(key); // refresh LRU position
|
|
188
|
+
sources.set(key, { ...hit, at: Date.now() });
|
|
189
|
+
return hit.result;
|
|
190
|
+
}
|
|
191
|
+
/** Drop a page's cached source (e.g. after our own write changed it). */
|
|
192
|
+
export function dropCachedPageSource(key) {
|
|
193
|
+
sources.delete(key);
|
|
194
|
+
}
|
|
195
|
+
/** Test hook — clears the source cache. */
|
|
196
|
+
export function resetPageSourceCache() {
|
|
197
|
+
sources.clear();
|
|
198
|
+
}
|
|
@@ -59,6 +59,7 @@ const ORGS_ENDPOINT = "/api/v1/org/organizations";
|
|
|
59
59
|
const PAGES_ENDPOINT = "/api/v1/ai/pages";
|
|
60
60
|
const SEARCH_PAGES_ENDPOINT = "/api/v1/ai/search_pages";
|
|
61
61
|
const PAGE_SOURCE_ENDPOINT = "/api/v1/ai/page_source";
|
|
62
|
+
const PAGE_VERSION_ENDPOINT = "/api/v1/ai/page_version";
|
|
62
63
|
const UPDATE_ENDPOINT = "/api/v1/ai/update_page_source";
|
|
63
64
|
const APPEND_ENDPOINT = "/api/v1/ai/append_section";
|
|
64
65
|
// The editor's own publish routes (NOT under /api/v1/ai). Both scopes are
|
|
@@ -326,6 +327,27 @@ export async function searchPages(config, filters) {
|
|
|
326
327
|
return { ok: true, status: r.status, pages };
|
|
327
328
|
}
|
|
328
329
|
/** Read a page's decoded source tree (must be owned by the account). */
|
|
330
|
+
/**
|
|
331
|
+
* The page source's VERSION only (`updated_at`), a few hundred bytes instead of the
|
|
332
|
+
* whole tree. Backed by `GET /api/v1/ai/page_version` (LandingPageWeb.V1.AiController.
|
|
333
|
+
* get_page_version), which selects the timestamp WITHOUT the `source` column.
|
|
334
|
+
*
|
|
335
|
+
* `updated_at` moves on every save through `Pages.update_source/2` — the path the
|
|
336
|
+
* editor and `update_page_source` both take — so it answers "did anyone touch this
|
|
337
|
+
* page since I last read it?" without a download.
|
|
338
|
+
*
|
|
339
|
+
* A backend that predates the route replies 404: `endpoint_missing` says so, and the
|
|
340
|
+
* caller reads the full source instead (same shape as the append_section fallback).
|
|
341
|
+
*/
|
|
342
|
+
export async function getPageVersion(config, pageId) {
|
|
343
|
+
const url = `${config.base}${PAGE_VERSION_ENDPOINT}?page_id=${encodeURIComponent(pageId)}`;
|
|
344
|
+
const r = await getJson(url, config);
|
|
345
|
+
if (!r.ok)
|
|
346
|
+
return { ok: false, status: r.status, endpoint_missing: r.status === 404, error: r.error };
|
|
347
|
+
const d = r.json?.data ?? r.json ?? {};
|
|
348
|
+
const version = d.updated_at == null ? undefined : String(d.updated_at);
|
|
349
|
+
return { ok: version !== undefined, status: r.status, version };
|
|
350
|
+
}
|
|
329
351
|
export async function getPageSource(config, pageId) {
|
|
330
352
|
const url = `${config.base}${PAGE_SOURCE_ENDPOINT}?page_id=${encodeURIComponent(pageId)}`;
|
|
331
353
|
const r = await getJson(url, config);
|
|
@@ -342,6 +364,8 @@ export async function getPageSource(config, pageId) {
|
|
|
342
364
|
// publish_page reuse it instead of dropping to a domain-less preview.
|
|
343
365
|
custom_domain: d.custom_domain ?? null,
|
|
344
366
|
custom_path: d.custom_path ?? null,
|
|
367
|
+
// The same token GET /page_version returns — absent on a backend that predates it.
|
|
368
|
+
updated_at: d.updated_at == null ? undefined : String(d.updated_at),
|
|
345
369
|
source: d.source,
|
|
346
370
|
};
|
|
347
371
|
}
|
package/dist/smoke.js
CHANGED
|
@@ -22,6 +22,7 @@ import { buildMicrolinkUrl } from "./persistence/screenshot-client.js";
|
|
|
22
22
|
import { isPrivateHost, isAllowedScreenshotUrl } from "./persistence/screenshot-playwright.js";
|
|
23
23
|
import { iconifyCandidates } from "./persistence/icon-client.js";
|
|
24
24
|
import { collectExternalImageUrls, rewriteImageUrls, isRehostableImageUrl } from "./persistence/rehost.js";
|
|
25
|
+
import { sourceFingerprint, collectElementIds, decideOverwrite, rememberPageVersion, forgetPageVersion, lastKnownPageVersion, resetPageVersions, sourceCacheKey, cachePageSource, cachedPageSource, cachedPageSourceVersion, dropCachedPageSource, resetPageSourceCache, } from "./persistence/page-version.js";
|
|
25
26
|
let failures = 0;
|
|
26
27
|
const check = (name, cond, extra) => {
|
|
27
28
|
if (cond) {
|
|
@@ -330,6 +331,66 @@ check("expand preserves provided styles", eTxt.responsive.desktop.styles.fontSiz
|
|
|
330
331
|
check("expand keeps id/type/specials", eTxt.id === "t_h1" && eTxt.type === "text-block" && eTxt.specials.text === "Sparse hero", eTxt);
|
|
331
332
|
check("expanded sparse page validates", validatePage(exp).valid, validatePage(exp).errors);
|
|
332
333
|
check("expand(full good page) still valid", validatePage(expandSource(good, createElement)).valid);
|
|
334
|
+
console.log("== editor tolerance: a page hand-tweaked in the Webcake editor still validates ==");
|
|
335
|
+
{
|
|
336
|
+
// Every value shape below is one the EDITOR actually writes (verified against
|
|
337
|
+
// landing_page_backend/assets/editor): fontSize "59" (EditTextPanel inline box),
|
|
338
|
+
// "32px" (element pickers), height "520" (SizeAndPosition blur), borderWidth
|
|
339
|
+
// "1"/"0px", opacity "50%" (Layer trait), width_section as strings, and the
|
|
340
|
+
// "delay"/"error" event triggers the EventTrait dropdown offers.
|
|
341
|
+
const edited = {
|
|
342
|
+
page: [{
|
|
343
|
+
id: "sec_edit", type: "section",
|
|
344
|
+
properties: { name: "Section", movable: false, sync: true },
|
|
345
|
+
specials: {}, runtime: {}, events: [],
|
|
346
|
+
responsive: {
|
|
347
|
+
desktop: { config: {}, styles: { height: "600", background: "rgba(255,255,255,1)" } },
|
|
348
|
+
mobile: { config: {}, styles: { height: "520", background: "rgba(255,255,255,1)" } },
|
|
349
|
+
},
|
|
350
|
+
children: [{
|
|
351
|
+
id: "txt_edit", type: "text-block",
|
|
352
|
+
properties: { name: "Text", movable: true, sync: true },
|
|
353
|
+
specials: { text: "Hand-edited headline", tag: "h1" }, runtime: {},
|
|
354
|
+
events: [{ id: "EVENT1", type: "delay", action: "none", target: null }],
|
|
355
|
+
responsive: {
|
|
356
|
+
desktop: { config: { animation: { name: "none", delay: "0", duration: "3", repeat: null } },
|
|
357
|
+
styles: { top: 60, left: 80, width: 500, height: 70, fontSize: "59", borderWidth: "1", opacity: "50%", color: "rgba(20,20,20,1)" } },
|
|
358
|
+
mobile: { config: {},
|
|
359
|
+
styles: { top: "50", left: 20, width: 380, height: 60, fontSize: "32px", borderWidth: "0px", color: "rgba(20,20,20,1)" } },
|
|
360
|
+
},
|
|
361
|
+
}],
|
|
362
|
+
}],
|
|
363
|
+
popup: [],
|
|
364
|
+
settings: { title: "Edited", description: "d", keywords: "a", width_section: { desktop: "960", mobile: "420" } },
|
|
365
|
+
options: { mobileOnly: false, versionID: null },
|
|
366
|
+
cartConfigs: {},
|
|
367
|
+
};
|
|
368
|
+
const ex = landingDomain.expand(edited);
|
|
369
|
+
const v = landingDomain.validate(ex);
|
|
370
|
+
check("editor tolerance: hand-edited page has NO schema errors", v.valid, v.errors);
|
|
371
|
+
const dTxt = ex.page[0].children[0].responsive.desktop;
|
|
372
|
+
const mTxt = ex.page[0].children[0].responsive.mobile;
|
|
373
|
+
check('editor tolerance: fontSize "59" -> 59', dTxt.styles.fontSize === 59, dTxt.styles.fontSize);
|
|
374
|
+
check('editor tolerance: fontSize "32px" -> 32', mTxt.styles.fontSize === 32, mTxt.styles.fontSize);
|
|
375
|
+
check('editor tolerance: borderWidth "1"/"0px" -> 1/0', dTxt.styles.borderWidth === 1 && mTxt.styles.borderWidth === 0, [dTxt.styles.borderWidth, mTxt.styles.borderWidth]);
|
|
376
|
+
check('editor tolerance: opacity "50%" -> 0.5', dTxt.styles.opacity === 0.5, dTxt.styles.opacity);
|
|
377
|
+
check('editor tolerance: height "600"/"520" -> numbers', ex.page[0].responsive.desktop.styles.height === 600 && ex.page[0].responsive.mobile.styles.height === 520, ex.page[0].responsive);
|
|
378
|
+
check('editor tolerance: top "50" -> 50', mTxt.styles.top === 50, mTxt.styles.top);
|
|
379
|
+
check('editor tolerance: animation delay/duration strings -> numbers', dTxt.config.animation.delay === 0 && dTxt.config.animation.duration === 3, dTxt.config.animation);
|
|
380
|
+
check('editor tolerance: width_section strings -> numbers', ex.settings.width_section.desktop === 960 && ex.settings.width_section.mobile === 420, ex.settings.width_section);
|
|
381
|
+
check('editor tolerance: "delay" event trigger accepted', !v.errors.some((e) => /type/.test(e) && /delay/.test(e)), v.errors);
|
|
382
|
+
check("editor tolerance: normalization is idempotent", JSON.stringify(landingDomain.expand(ex)) === JSON.stringify(ex));
|
|
383
|
+
// get_page COMPACTS the stored tree — the model must read the canonical number it is asked to write.
|
|
384
|
+
const readBack = landingDomain.compact(edited);
|
|
385
|
+
check("editor tolerance: compact (the get_page read path) normalizes too", readBack.page[0].children[0].responsive.desktop.styles.fontSize === 59, readBack.page[0].children[0].responsive.desktop.styles);
|
|
386
|
+
check("editor tolerance: compact does not mutate the caller’s tree", edited.page[0].children[0].responsive.desktop.styles.fontSize === "59", edited.page[0].children[0].responsive.desktop.styles.fontSize);
|
|
387
|
+
// A unit the renderer cannot use (it appends "px" itself) stays a string and is WARNED, not blocked.
|
|
388
|
+
const unit = JSON.parse(JSON.stringify(edited));
|
|
389
|
+
unit.page[0].children[0].responsive.desktop.styles.width = "100%";
|
|
390
|
+
const uv = landingDomain.validate(landingDomain.expand(unit));
|
|
391
|
+
check("editor tolerance: non-px dimension unit is a warning, not an error", uv.valid, uv.errors);
|
|
392
|
+
check("editor tolerance: non-px dimension unit IS warned", uv.warnings.some((w) => /styles\.width="100%"/.test(w)), uv.warnings);
|
|
393
|
+
}
|
|
333
394
|
console.log("== compact: the inverse of expand (round-trip persists the same tree) ==");
|
|
334
395
|
{
|
|
335
396
|
const cGood = compactSource(good, createElement);
|
|
@@ -1963,6 +2024,105 @@ console.log("== layout: exact centering/row/grid/stack coordinates (both breakpo
|
|
|
1963
2024
|
const lastRight = rightWide.desktop[1].left + rightWide.desktop[1].width;
|
|
1964
2025
|
check("layout: align right ends at canvas − margin (1200 − 80 = 1120)", lastRight === 1120, { lastRight, desktop: rightWide.desktop });
|
|
1965
2026
|
}
|
|
2027
|
+
console.log("== stale-overwrite guard: a UI edit is never silently reverted ==");
|
|
2028
|
+
{
|
|
2029
|
+
const el = (id, extra = {}) => ({
|
|
2030
|
+
id, type: "text-block",
|
|
2031
|
+
properties: { name: id, movable: true, sync: true },
|
|
2032
|
+
specials: { text: id }, runtime: {}, events: [],
|
|
2033
|
+
responsive: {
|
|
2034
|
+
desktop: { config: {}, styles: { top: 0, left: 0, width: 100, height: 20, ...extra } },
|
|
2035
|
+
mobile: { config: {}, styles: { top: 0, left: 0, width: 100, height: 20, ...extra } },
|
|
2036
|
+
},
|
|
2037
|
+
});
|
|
2038
|
+
const pageWith = (children) => ({
|
|
2039
|
+
page: [{
|
|
2040
|
+
id: "sec1", type: "section",
|
|
2041
|
+
properties: { name: "Section", movable: false, sync: true },
|
|
2042
|
+
specials: {}, runtime: {}, events: [],
|
|
2043
|
+
responsive: { desktop: { config: {}, styles: { height: 600 } }, mobile: { config: {}, styles: { height: 500 } } },
|
|
2044
|
+
children,
|
|
2045
|
+
}],
|
|
2046
|
+
popup: [], settings: { title: "t" }, options: {}, cartConfigs: {},
|
|
2047
|
+
});
|
|
2048
|
+
// --- fingerprint --------------------------------------------------------
|
|
2049
|
+
const a = { page: [], settings: { title: "x", description: "d" } };
|
|
2050
|
+
const b = { settings: { description: "d", title: "x" }, page: [] };
|
|
2051
|
+
check("page-version: fingerprint ignores object key order", sourceFingerprint(a) === sourceFingerprint(b), [sourceFingerprint(a), sourceFingerprint(b)]);
|
|
2052
|
+
check("page-version: fingerprint survives the JSON string round-trip", sourceFingerprint(JSON.stringify(a)) === sourceFingerprint(a));
|
|
2053
|
+
check("page-version: fingerprint changes when content changes", sourceFingerprint(a) !== sourceFingerprint({ ...a, settings: { title: "y" } }));
|
|
2054
|
+
check("page-version: array order still matters", sourceFingerprint({ page: [1, 2] }) !== sourceFingerprint({ page: [2, 1] }));
|
|
2055
|
+
// --- element ids --------------------------------------------------------
|
|
2056
|
+
const ids = collectElementIds(pageWith([el("t1"), el("t2")]));
|
|
2057
|
+
check("page-version: collectElementIds walks nested children", ids.has("sec1") && ids.has("t1") && ids.has("t2") && ids.size === 3, [...ids]);
|
|
2058
|
+
// --- the decision -------------------------------------------------------
|
|
2059
|
+
const live = pageWith([el("t1"), el("t2")]);
|
|
2060
|
+
const liveFp = sourceFingerprint(live);
|
|
2061
|
+
const sameContent = pageWith([el("t1"), el("t2", { fontSize: 30 })]); // model edited t2
|
|
2062
|
+
const fresh = decideOverwrite(live, sameContent, { fp: liveFp, origin: "read" });
|
|
2063
|
+
check("guard: baseline matches live → allowed", fresh.allow === true, fresh);
|
|
2064
|
+
// The model deliberately deletes t2 while holding a current baseline.
|
|
2065
|
+
const deleting = decideOverwrite(live, pageWith([el("t1")]), { fp: liveFp, origin: "read" });
|
|
2066
|
+
check("guard: deliberate delete on a current baseline → allowed", deleting.allow === true, deleting);
|
|
2067
|
+
// THE BUG: user edited in the editor after the model read the page.
|
|
2068
|
+
const uiEdited = pageWith([el("t1"), el("t2"), el("t3_added_in_editor")]);
|
|
2069
|
+
const stale = decideOverwrite(uiEdited, sameContent, { fp: liveFp, origin: "read" });
|
|
2070
|
+
check("guard: page changed in the editor → REFUSED", stale.allow === false && stale.reason === "page_changed_externally", stale);
|
|
2071
|
+
check("guard: refusal names the elements the overwrite would delete", stale.allow === false && stale.dropped.includes("t3_added_in_editor"), stale);
|
|
2072
|
+
// Even a change that drops nothing is refused on a trusted baseline: the
|
|
2073
|
+
// overwrite would still revert the user's tweak to the elements it does touch.
|
|
2074
|
+
const tweaked = pageWith([el("t1"), el("t2", { fontSize: 99 })]);
|
|
2075
|
+
const noDrop = decideOverwrite(tweaked, sameContent, { fp: liveFp, origin: "read" });
|
|
2076
|
+
check("guard: editor tweak with no id loss → still REFUSED on a read baseline", noDrop.allow === false && noDrop.reason === "page_changed_externally", noDrop);
|
|
2077
|
+
// No baseline at all (fresh process / no get_page): fall back to id loss.
|
|
2078
|
+
const blind = decideOverwrite(uiEdited, sameContent, undefined);
|
|
2079
|
+
check("guard: no baseline + would delete live ids → REFUSED", blind.allow === false && blind.reason === "unverified_overwrite", blind);
|
|
2080
|
+
const blindSafe = decideOverwrite(live, sameContent, undefined);
|
|
2081
|
+
check("guard: no baseline + nothing dropped → allowed as unverified", blindSafe.allow === true && blindSafe.unverified === true, blindSafe);
|
|
2082
|
+
// A baseline we only PREDICTED after a write must never produce a false
|
|
2083
|
+
// "someone edited it" — the backend re-encodes JSON, so a mismatch proves nothing.
|
|
2084
|
+
const predicted = decideOverwrite(live, sameContent, { fp: "deadbeefdeadbeef", origin: "write" });
|
|
2085
|
+
check("guard: stale WRITE baseline + nothing dropped → allowed (no false conflict)", predicted.allow === true, predicted);
|
|
2086
|
+
const predictedDrop = decideOverwrite(uiEdited, sameContent, { fp: "deadbeefdeadbeef", origin: "write" });
|
|
2087
|
+
check("guard: stale WRITE baseline + id loss → REFUSED", predictedDrop.allow === false && predictedDrop.reason === "unverified_overwrite", predictedDrop);
|
|
2088
|
+
// --- the baseline store -------------------------------------------------
|
|
2089
|
+
resetPageVersions();
|
|
2090
|
+
check("page-version: unknown page has no baseline", lastKnownPageVersion("p1") === undefined);
|
|
2091
|
+
rememberPageVersion("p1", liveFp, "read");
|
|
2092
|
+
check("page-version: remember/lastKnown round-trip", lastKnownPageVersion("p1")?.fp === liveFp && lastKnownPageVersion("p1")?.origin === "read");
|
|
2093
|
+
rememberPageVersion("p1", "other", "write");
|
|
2094
|
+
check("page-version: a later write downgrades the origin", lastKnownPageVersion("p1")?.origin === "write");
|
|
2095
|
+
forgetPageVersion("p1");
|
|
2096
|
+
check("page-version: add_section-style forget clears it", lastKnownPageVersion("p1") === undefined);
|
|
2097
|
+
// --- version-keyed source cache ----------------------------------------
|
|
2098
|
+
resetPageSourceCache();
|
|
2099
|
+
const k1 = sourceCacheKey("pageA", "jwt-user-1");
|
|
2100
|
+
const k2 = sourceCacheKey("pageA", "jwt-user-2");
|
|
2101
|
+
check("source cache: key is scoped to the credentials, not just the page", k1 !== k2, [k1, k2]);
|
|
2102
|
+
check("source cache: same page + same creds → same key", sourceCacheKey("pageA", "jwt-user-1") === k1);
|
|
2103
|
+
cachePageSource(k1, "2026-08-24T10:00:00", { ok: true, source: live });
|
|
2104
|
+
check("source cache: stores the version it was read at", cachedPageSourceVersion(k1) === "2026-08-24T10:00:00");
|
|
2105
|
+
check("source cache: hit when the backend reports the SAME version", cachedPageSource(k1, "2026-08-24T10:00:00")?.source === live);
|
|
2106
|
+
check("source cache: MISS when the page moved (the download is unavoidable)", cachedPageSource(k1, "2026-08-24T10:05:00") === undefined);
|
|
2107
|
+
check("source cache: another caller's key never hits", cachedPageSource(k2, "2026-08-24T10:00:00") === undefined);
|
|
2108
|
+
check("source cache: no version probe → never a hit", cachedPageSource(k1, undefined) === undefined);
|
|
2109
|
+
// An older backend has no /page_version and no updated_at: nothing caches, so
|
|
2110
|
+
// every read stays a single full fetch exactly as before.
|
|
2111
|
+
resetPageSourceCache();
|
|
2112
|
+
cachePageSource(k1, undefined, { ok: true, source: live });
|
|
2113
|
+
check("source cache: a backend without updated_at caches nothing", cachedPageSourceVersion(k1) === undefined);
|
|
2114
|
+
cachePageSource(k1, "v1", { ok: true, source: live });
|
|
2115
|
+
dropCachedPageSource(k1);
|
|
2116
|
+
check("source cache: our own write drops the entry", cachedPageSourceVersion(k1) === undefined);
|
|
2117
|
+
for (let i = 0; i < 10; i++)
|
|
2118
|
+
cachePageSource(sourceCacheKey("big" + i, "j"), "v", { ok: true, source: live });
|
|
2119
|
+
check("source cache: bounded (a source can be megabytes)", cachedPageSourceVersion(sourceCacheKey("big0", "j")) === undefined && cachedPageSourceVersion(sourceCacheKey("big9", "j")) === "v");
|
|
2120
|
+
resetPageSourceCache();
|
|
2121
|
+
for (let i = 0; i < 120; i++)
|
|
2122
|
+
rememberPageVersion("bulk" + i, "fp" + i, "read");
|
|
2123
|
+
check("page-version: store is bounded (LRU evicts the oldest)", lastKnownPageVersion("bulk0") === undefined && lastKnownPageVersion("bulk119")?.fp === "fp119");
|
|
2124
|
+
resetPageVersions();
|
|
2125
|
+
}
|
|
1966
2126
|
console.log("== server: MCP serverInfo.version follows package.json (no hardcoded constant) ==");
|
|
1967
2127
|
{
|
|
1968
2128
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
@@ -12,11 +12,102 @@
|
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { text, warningsField, autoFixedField } from "../mcp/response.js";
|
|
14
14
|
import { readConfig, configFromHeaders } from "../persistence/config.js";
|
|
15
|
-
import { buildRequestRedacted, buildUpdateRequestRedacted, buildAppendRequestRedacted, buildPublishRequestRedacted, buildPageApp, createPage, listOrganizations, listPages, searchPages, getPageSource, updatePageSource, appendSection, publishPage, toPreviewUrl, } from "../persistence/webcake-client.js";
|
|
15
|
+
import { buildRequestRedacted, buildUpdateRequestRedacted, buildAppendRequestRedacted, buildPublishRequestRedacted, buildPageApp, createPage, listOrganizations, listPages, searchPages, getPageSource, getPageVersion, updatePageSource, appendSection, publishPage, toPreviewUrl, } from "../persistence/webcake-client.js";
|
|
16
16
|
import { putDraft, getDraft, updateDraft, deleteDraft } from "../persistence/draft-cache.js";
|
|
17
|
+
import { sourceFingerprint, decideOverwrite, rememberPageVersion, forgetPageVersion, lastKnownPageVersion, sourceCacheKey, cachePageSource, cachedPageSource, cachedPageSourceVersion, dropCachedPageSource, } from "../persistence/page-version.js";
|
|
17
18
|
export function registerPersistenceTools(server, domain) {
|
|
18
19
|
// Resolve config from THIS request's headers (remote per-user JWT) first, then env.
|
|
19
20
|
const cfgFor = (extra) => readConfig(configFromHeaders(extra?.requestInfo?.headers));
|
|
21
|
+
// ---- live source read (version-validated cache) ---------------------------
|
|
22
|
+
// Every live read used to download the whole tree, and the concurrency guard
|
|
23
|
+
// added one more. It cannot be served from a plain cache — a cached answer
|
|
24
|
+
// cannot see an edit made after it was written, which is the bug the guard
|
|
25
|
+
// exists to catch. So the SERVER decides: GET /api/v1/ai/page_version returns
|
|
26
|
+
// page_source.updated_at (a few hundred bytes; bumped by every save, editor and
|
|
27
|
+
// MCP alike), and we reuse the cached tree only when that token is unchanged.
|
|
28
|
+
// Freshness is identical to a full read because the token comes from the backend.
|
|
29
|
+
//
|
|
30
|
+
// We probe only when something IS cached, so a cold read costs exactly one
|
|
31
|
+
// request as before; a warm one costs a tiny probe and no download. A backend
|
|
32
|
+
// without the route answers 404 → nothing ever caches → one full read, as before.
|
|
33
|
+
const readLiveSource = async (config, pageId) => {
|
|
34
|
+
const key = sourceCacheKey(pageId, config.jwt);
|
|
35
|
+
if (cachedPageSourceVersion(key)) {
|
|
36
|
+
const probe = await getPageVersion(config, pageId);
|
|
37
|
+
if (probe.ok && probe.version) {
|
|
38
|
+
const hit = cachedPageSource(key, probe.version);
|
|
39
|
+
if (hit)
|
|
40
|
+
return { ...hit, from_cache: true };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const full = await getPageSource(config, pageId);
|
|
44
|
+
if (full.ok && full.source != null)
|
|
45
|
+
cachePageSource(key, full.updated_at, full);
|
|
46
|
+
return { ...full, from_cache: false };
|
|
47
|
+
};
|
|
48
|
+
/** Our own write changed the stored tree — the cached copy is spent. */
|
|
49
|
+
const invalidateSource = (config, pageId) => {
|
|
50
|
+
if (pageId)
|
|
51
|
+
dropCachedPageSource(sourceCacheKey(pageId, config.jwt));
|
|
52
|
+
};
|
|
53
|
+
// ---- stale-overwrite guard ------------------------------------------------
|
|
54
|
+
// update_page (and a committed 'update' draft) REPLACES the stored source with a
|
|
55
|
+
// tree the model read earlier. If the user edited the page in the Webcake editor
|
|
56
|
+
// in between, that overwrite silently reverts their work — the exact failure the
|
|
57
|
+
// draft cache makes easy to hit, since a draft can sit for ~2 h. The backend has
|
|
58
|
+
// no version column, so we do optimistic concurrency ourselves: re-read the page
|
|
59
|
+
// and compare its fingerprint with the baseline we recorded when the model last
|
|
60
|
+
// read/wrote it (or with an explicit base_version the caller passes back).
|
|
61
|
+
//
|
|
62
|
+
// baseline matches live → the model is current; overwrite (deletes are intentional)
|
|
63
|
+
// baseline differs → someone else edited the page → REFUSE
|
|
64
|
+
// no baseline + the overwrite would DROP live element ids → REFUSE
|
|
65
|
+
// no baseline + nothing is dropped → allow, but say the check could not be made
|
|
66
|
+
//
|
|
67
|
+
// force:true skips the check (an explicit "yes, discard the editor's changes").
|
|
68
|
+
const guardOverwrite = async (config, pageId, incoming, opts) => {
|
|
69
|
+
if (opts.force)
|
|
70
|
+
return { ok: true, notice: "force:true — the staleness check was skipped and any newer editor changes were overwritten." };
|
|
71
|
+
const live = await readLiveSource(config, pageId);
|
|
72
|
+
// Cannot read the live page (offline/404): don't block the write on our own check.
|
|
73
|
+
if (!live.ok || live.source == null)
|
|
74
|
+
return { ok: true };
|
|
75
|
+
// An explicit base_version comes from get_page's source_version — a real read.
|
|
76
|
+
const baseline = opts.baseVersion
|
|
77
|
+
? { fp: opts.baseVersion, origin: "read" }
|
|
78
|
+
: lastKnownPageVersion(pageId);
|
|
79
|
+
const verdict = decideOverwrite(live.source, incoming, baseline);
|
|
80
|
+
if (verdict.allow) {
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
liveVersion: verdict.liveVersion,
|
|
84
|
+
...(verdict.unverified
|
|
85
|
+
? {
|
|
86
|
+
notice: "No baseline for this page in this session, so the overwrite could not be verified against the live tree — " +
|
|
87
|
+
"no live element ids are lost, so it was allowed. Call get_page first next time to make the check exact.",
|
|
88
|
+
}
|
|
89
|
+
: {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const { reason, liveVersion, baseVersion, dropped } = verdict;
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
payload: {
|
|
96
|
+
reason,
|
|
97
|
+
page_id: pageId,
|
|
98
|
+
live_version: liveVersion,
|
|
99
|
+
base_version: baseVersion,
|
|
100
|
+
would_delete_element_ids: dropped.slice(0, 30),
|
|
101
|
+
would_delete_count: dropped.length,
|
|
102
|
+
hint: (baseVersion
|
|
103
|
+
? "The page was edited OUTSIDE this session (the Webcake editor, another agent) after this source was read, so saving it would revert those changes. "
|
|
104
|
+
: "This source was not read from the live page in this session and the overwrite would delete element ids that exist on the page right now. ") +
|
|
105
|
+
"Do ONE of these: (a) patch_page({ page_id, patches:[…] }) — it always merges into the LIVE tree, so it never reverts anything; " +
|
|
106
|
+
"(b) get_page({ page_id }) again, re-apply your edit to the fresh tree, then save; " +
|
|
107
|
+
`(c) if discarding the newer changes is intended, re-run ${opts.retryHint} with force:true.`,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
};
|
|
20
111
|
// After a successful CREATE, build the rendered app and publish via the
|
|
21
112
|
// editor's publish_html route so the page renders immediately — without this
|
|
22
113
|
// a fresh page's preview is a blank shell until publish_page runs or the page
|
|
@@ -294,6 +385,8 @@ export function registerPersistenceTools(server, domain) {
|
|
|
294
385
|
const outcome = await createPage(config, pageName, parsed, resolvedOrgId);
|
|
295
386
|
if (outcome.ok) {
|
|
296
387
|
await deleteDraft(existingDraftId); // created — drop the draft
|
|
388
|
+
rememberPageVersion(outcome.page_id, sourceFingerprint(outcome.rehosted_source ?? parsed), "write");
|
|
389
|
+
invalidateSource(config, outcome.page_id);
|
|
297
390
|
// Auto-publish (default): build + publish_html so the preview renders
|
|
298
391
|
// immediately. Never fails the create — result.publish carries the state.
|
|
299
392
|
const publishOutcome = publish === false
|
|
@@ -392,14 +485,22 @@ export function registerPersistenceTools(server, domain) {
|
|
|
392
485
|
const { config, missing } = cfgFor(extra);
|
|
393
486
|
if (!config)
|
|
394
487
|
return text({ ok: false, reason: "missing_env", missing_env: missing });
|
|
395
|
-
const res = await
|
|
396
|
-
if (!res.ok ||
|
|
488
|
+
const res = await readLiveSource(config, page_id);
|
|
489
|
+
if (!res.ok || res.source == null)
|
|
397
490
|
return text(res);
|
|
491
|
+
// Fingerprint what the page holds RIGHT NOW: this read is the baseline a
|
|
492
|
+
// later update_page is checked against, so an edit made in the Webcake
|
|
493
|
+
// editor after this point cannot be silently overwritten.
|
|
494
|
+
const source_version = sourceFingerprint(res.source);
|
|
495
|
+
rememberPageVersion(page_id, source_version, "read");
|
|
496
|
+
if (compact === false)
|
|
497
|
+
return text({ ...res, source_version });
|
|
398
498
|
return text({
|
|
399
499
|
...res,
|
|
400
500
|
source: domain.compact(res.source),
|
|
401
501
|
compacted: true,
|
|
402
|
-
|
|
502
|
+
source_version,
|
|
503
|
+
note: "Source is COMPACTED (factory-default boilerplate stripped). Edit elements in this same sparse shape — keep ids — and send the edited tree back to update_page (or use patch_page for small edits); the server re-hydrates omitted boilerplate. Pass source_version back as update_page({ base_version }) so a save is rejected instead of reverting an edit someone made in the editor meanwhile.",
|
|
403
504
|
});
|
|
404
505
|
});
|
|
405
506
|
// 12) Update page (edit existing) -------------------------------------------
|
|
@@ -413,13 +514,24 @@ export function registerPersistenceTools(server, domain) {
|
|
|
413
514
|
.string()
|
|
414
515
|
.optional()
|
|
415
516
|
.describe("A draft_id from a previous update_page call (dry_run=true or a timed-out/failed update). Loads the cached source — no need to re-send the full JSON. Use for dry-run → real call transitions and retrying after a timeout."),
|
|
517
|
+
base_version: z
|
|
518
|
+
.string()
|
|
519
|
+
.optional()
|
|
520
|
+
.describe("The source_version get_page returned for this page. When it no longer matches the live page the save is REJECTED instead of reverting whatever was edited in the Webcake editor meanwhile. Optional — omit it and the server uses the version it recorded on your last read/write of this page."),
|
|
521
|
+
force: z
|
|
522
|
+
.boolean()
|
|
523
|
+
.optional()
|
|
524
|
+
.describe("Default false. true = save even when the page changed outside this session, discarding those changes. Only pass it after the user has said to."),
|
|
416
525
|
dry_run: z.boolean().optional().describe("Default TRUE — validate, cache the source as draft_id, and preview without sending. Set false to actually save."),
|
|
417
|
-
}, { title: "Update Webcake Page (Overwrite)", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, source, draft_id, dry_run }, extra) => {
|
|
526
|
+
}, { title: "Update Webcake Page (Overwrite)", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, source, draft_id, base_version, force, dry_run }, extra) => {
|
|
418
527
|
const isDry = dry_run !== false;
|
|
419
528
|
// --- Resolve source: from cache (draft_id) or from the argument ---
|
|
420
529
|
let expanded;
|
|
421
530
|
let existingDraftId = draft_id;
|
|
422
531
|
let resolvedPageId = page_id;
|
|
532
|
+
// The version of the live page this source was built from. Explicit wins;
|
|
533
|
+
// a draft carries the one captured when it was first cached.
|
|
534
|
+
let resolvedBaseVersion = base_version;
|
|
423
535
|
if (draft_id) {
|
|
424
536
|
const cached = await getDraft(draft_id);
|
|
425
537
|
if (!cached) {
|
|
@@ -438,6 +550,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
438
550
|
}
|
|
439
551
|
expanded = cached.source;
|
|
440
552
|
resolvedPageId = page_id ?? cached.page_id;
|
|
553
|
+
resolvedBaseVersion = base_version ?? cached.base_version;
|
|
441
554
|
}
|
|
442
555
|
else {
|
|
443
556
|
if (source == null) {
|
|
@@ -458,7 +571,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
458
571
|
await updateDraft(existingDraftId, expanded);
|
|
459
572
|
}
|
|
460
573
|
else {
|
|
461
|
-
existingDraftId = await putDraft({ source: expanded, kind: "update", page_id: resolvedPageId });
|
|
574
|
+
existingDraftId = await putDraft({ source: expanded, kind: "update", page_id: resolvedPageId, base_version: resolvedBaseVersion });
|
|
462
575
|
}
|
|
463
576
|
return text({
|
|
464
577
|
updated: false,
|
|
@@ -478,7 +591,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
478
591
|
await updateDraft(existingDraftId, expanded);
|
|
479
592
|
}
|
|
480
593
|
else {
|
|
481
|
-
existingDraftId = await putDraft({ source: expanded, kind: "update", page_id: resolvedPageId });
|
|
594
|
+
existingDraftId = await putDraft({ source: expanded, kind: "update", page_id: resolvedPageId, base_version: resolvedBaseVersion });
|
|
482
595
|
}
|
|
483
596
|
return text({
|
|
484
597
|
dry_run: true,
|
|
@@ -500,12 +613,23 @@ export function registerPersistenceTools(server, domain) {
|
|
|
500
613
|
await updateDraft(existingDraftId, expanded);
|
|
501
614
|
}
|
|
502
615
|
else {
|
|
503
|
-
existingDraftId = await putDraft({ source: expanded, kind: "update", page_id: resolvedPageId });
|
|
616
|
+
existingDraftId = await putDraft({ source: expanded, kind: "update", page_id: resolvedPageId, base_version: resolvedBaseVersion });
|
|
617
|
+
}
|
|
618
|
+
const guard = await guardOverwrite(config, resolvedPageId, parsed, {
|
|
619
|
+
baseVersion: resolvedBaseVersion,
|
|
620
|
+
force,
|
|
621
|
+
retryHint: `update_page({ draft_id: "${existingDraftId}", dry_run:false })`,
|
|
622
|
+
});
|
|
623
|
+
if (!guard.ok) {
|
|
624
|
+
return text({ updated: false, ...guard.payload, draft_id: existingDraftId });
|
|
504
625
|
}
|
|
505
626
|
const outcome = await updatePageSource(config, resolvedPageId, parsed);
|
|
506
627
|
if (outcome.ok) {
|
|
507
628
|
await deleteDraft(existingDraftId);
|
|
508
|
-
|
|
629
|
+
// The page now holds exactly what we sent — that becomes the new baseline.
|
|
630
|
+
rememberPageVersion(resolvedPageId, sourceFingerprint(outcome.rehosted_source ?? parsed), "write");
|
|
631
|
+
invalidateSource(config, resolvedPageId);
|
|
632
|
+
return text({ updated: true, ...outcome, ...warningsField(result.warnings), ...(guard.notice ? { stale_check: guard.notice } : {}) });
|
|
509
633
|
}
|
|
510
634
|
await updateDraft(existingDraftId, expanded);
|
|
511
635
|
return text({
|
|
@@ -688,6 +812,11 @@ export function registerPersistenceTools(server, domain) {
|
|
|
688
812
|
// Success: drop the draft now that sections are persisted.
|
|
689
813
|
if (existingDraftId)
|
|
690
814
|
await deleteDraft(existingDraftId);
|
|
815
|
+
// The BACKEND appended server-side, so the stored tree is no longer any
|
|
816
|
+
// source we hold — drop the baseline so the next whole-source overwrite
|
|
817
|
+
// has to prove itself against a fresh read.
|
|
818
|
+
forgetPageVersion(page_id);
|
|
819
|
+
invalidateSource(config, page_id);
|
|
691
820
|
}
|
|
692
821
|
else {
|
|
693
822
|
// Server-side failure (duplicate id vs live tree, etc.): keep the draft so
|
|
@@ -720,7 +849,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
720
849
|
}
|
|
721
850
|
// Fallback for an older backend WITHOUT /append_section (404): the original
|
|
722
851
|
// heavier path — get the live source → merge → validate the WHOLE tree → put.
|
|
723
|
-
const current = await
|
|
852
|
+
const current = await readLiveSource(config, page_id);
|
|
724
853
|
if (!current.ok || current.source == null) {
|
|
725
854
|
return text({
|
|
726
855
|
added: false,
|
|
@@ -757,6 +886,12 @@ export function registerPersistenceTools(server, domain) {
|
|
|
757
886
|
const fbOutcome = await updatePageSource(config, page_id, parsed);
|
|
758
887
|
if (fbOutcome.ok && existingDraftId)
|
|
759
888
|
await deleteDraft(existingDraftId);
|
|
889
|
+
// The fallback merged into the tree it JUST read, so no staleness guard is
|
|
890
|
+
// needed here — but the save makes a new baseline worth recording.
|
|
891
|
+
if (fbOutcome.ok)
|
|
892
|
+
rememberPageVersion(page_id, sourceFingerprint(fbOutcome.rehosted_source ?? parsed), "write");
|
|
893
|
+
if (fbOutcome.ok)
|
|
894
|
+
invalidateSource(config, page_id);
|
|
760
895
|
return text({
|
|
761
896
|
added: fbOutcome.ok,
|
|
762
897
|
sections_added: newSections.length,
|
|
@@ -842,11 +977,15 @@ export function registerPersistenceTools(server, domain) {
|
|
|
842
977
|
.any()
|
|
843
978
|
.optional()
|
|
844
979
|
.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."),
|
|
980
|
+
force: z
|
|
981
|
+
.boolean()
|
|
982
|
+
.optional()
|
|
983
|
+
.describe("Default false. Only meaningful when committing an 'update' draft: true saves the cached snapshot even though the page changed outside this session, discarding those changes. A page_id patch never needs it — it merges into the tree it just read."),
|
|
845
984
|
dry_run: z
|
|
846
985
|
.boolean()
|
|
847
986
|
.optional()
|
|
848
987
|
.describe("Default TRUE — load, merge, validate and preview the resulting save WITHOUT writing. Set false to actually save."),
|
|
849
|
-
}, { title: "Patch Webcake Page (by element id)", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, draft_id, patches, dry_run }, extra) => {
|
|
988
|
+
}, { title: "Patch Webcake Page (by element id)", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, draft_id, patches, force, dry_run }, extra) => {
|
|
850
989
|
const isDry = dry_run !== false; // default true (safe)
|
|
851
990
|
const ops = asArray(patches).filter((p) => p != null && typeof p === "object");
|
|
852
991
|
// Empty ops are only valid when a draft_id is supplied (commit-as-is / retry path).
|
|
@@ -880,7 +1019,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
880
1019
|
hint: "Configure WEBCAKE_API_BASE and WEBCAKE_JWT (env), or send the x-webcake-jwt header (remote), then retry.",
|
|
881
1020
|
});
|
|
882
1021
|
}
|
|
883
|
-
const current = await
|
|
1022
|
+
const current = await readLiveSource(config, page_id);
|
|
884
1023
|
if (!current.ok || current.source == null) {
|
|
885
1024
|
return text({
|
|
886
1025
|
patched: false,
|
|
@@ -890,6 +1029,9 @@ export function registerPersistenceTools(server, domain) {
|
|
|
890
1029
|
hint: "Check the page_id (find_pages/list_pages) and that the account owns it.",
|
|
891
1030
|
});
|
|
892
1031
|
}
|
|
1032
|
+
// This read is by definition current, so it becomes the baseline: a later
|
|
1033
|
+
// update_page that still matches it is provably not reverting anything.
|
|
1034
|
+
rememberPageVersion(page_id, sourceFingerprint(current.source), "read");
|
|
893
1035
|
base = current.source;
|
|
894
1036
|
if (typeof base === "string") {
|
|
895
1037
|
try {
|
|
@@ -1050,6 +1192,8 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1050
1192
|
const outcome = await appendSection(config, targetPageId, sectionsToAppend);
|
|
1051
1193
|
if (outcome.ok) {
|
|
1052
1194
|
await deleteDraft(draft_id);
|
|
1195
|
+
forgetPageVersion(targetPageId);
|
|
1196
|
+
invalidateSource(config, targetPageId);
|
|
1053
1197
|
}
|
|
1054
1198
|
else {
|
|
1055
1199
|
await updateDraft(draft_id, base);
|
|
@@ -1100,9 +1244,20 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1100
1244
|
await updateDraft(draft_id, base);
|
|
1101
1245
|
return text({ patched: false, reason: "missing_env", missing_env: missing, hint: `Add WEBCAKE_API_BASE + WEBCAKE_JWT, then retry patch_page({ draft_id: "${draft_id}", dry_run:false }).` });
|
|
1102
1246
|
}
|
|
1247
|
+
const guard = await guardOverwrite(config, targetPageId, parsed, {
|
|
1248
|
+
baseVersion: draft.base_version,
|
|
1249
|
+
force,
|
|
1250
|
+
retryHint: `patch_page({ draft_id: "${draft_id}", dry_run:false })`,
|
|
1251
|
+
});
|
|
1252
|
+
if (!guard.ok) {
|
|
1253
|
+
await updateDraft(draft_id, base);
|
|
1254
|
+
return text({ patched: false, updated: false, ...guard.payload, draft_id, patches_applied: applied });
|
|
1255
|
+
}
|
|
1103
1256
|
const outcome = await updatePageSource(config, targetPageId, parsed);
|
|
1104
1257
|
if (outcome.ok) {
|
|
1105
1258
|
await deleteDraft(draft_id);
|
|
1259
|
+
rememberPageVersion(targetPageId, sourceFingerprint(outcome.rehosted_source ?? parsed), "write");
|
|
1260
|
+
invalidateSource(config, targetPageId);
|
|
1106
1261
|
}
|
|
1107
1262
|
else {
|
|
1108
1263
|
await updateDraft(draft_id, base);
|
|
@@ -1146,6 +1301,8 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1146
1301
|
const outcome = await createPage(config, draft.name ?? "AI Page", parsed, draft.organization_id);
|
|
1147
1302
|
if (outcome.ok) {
|
|
1148
1303
|
await deleteDraft(draft_id);
|
|
1304
|
+
rememberPageVersion(outcome.page_id, sourceFingerprint(outcome.rehosted_source ?? parsed), "write");
|
|
1305
|
+
invalidateSource(config, outcome.page_id);
|
|
1149
1306
|
}
|
|
1150
1307
|
else {
|
|
1151
1308
|
await updateDraft(draft_id, base); // keep for retry
|
|
@@ -1182,7 +1339,12 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1182
1339
|
const parsed = domain.coerce(expanded);
|
|
1183
1340
|
// CACHE-FIRST: write an 'update' draft BEFORE the network call so a timeout or
|
|
1184
1341
|
// failure is recoverable via patch_page({ draft_id, dry_run:false }) with no patches.
|
|
1185
|
-
const liveDraftId = await putDraft({
|
|
1342
|
+
const liveDraftId = await putDraft({
|
|
1343
|
+
source: expanded,
|
|
1344
|
+
kind: "update",
|
|
1345
|
+
page_id: page_id,
|
|
1346
|
+
base_version: lastKnownPageVersion(page_id)?.fp,
|
|
1347
|
+
});
|
|
1186
1348
|
if (isDry) {
|
|
1187
1349
|
return text({
|
|
1188
1350
|
dry_run: true,
|
|
@@ -1197,6 +1359,8 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1197
1359
|
const outcome = await updatePageSource(config, page_id, parsed);
|
|
1198
1360
|
if (outcome.ok) {
|
|
1199
1361
|
await deleteDraft(liveDraftId);
|
|
1362
|
+
rememberPageVersion(page_id, sourceFingerprint(outcome.rehosted_source ?? parsed), "write");
|
|
1363
|
+
invalidateSource(config, page_id);
|
|
1200
1364
|
return text({
|
|
1201
1365
|
patched: true,
|
|
1202
1366
|
patches_applied: applied,
|
|
@@ -1237,7 +1401,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1237
1401
|
// Publish re-saves the page's CURRENT stored source (the publish endpoint
|
|
1238
1402
|
// requires it in the request), so read it first — even on dry_run, to show
|
|
1239
1403
|
// the real payload.
|
|
1240
|
-
const res = await
|
|
1404
|
+
const res = await readLiveSource(config, page_id);
|
|
1241
1405
|
if (!res.ok || res.source == null) {
|
|
1242
1406
|
return text({ published: false, reason: "page_not_found", status: res.status, error: res.error ?? "No source on this page." });
|
|
1243
1407
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-landing-mcp",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
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",
|