webcake-landing-mcp 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -187,7 +187,7 @@ server, the `login` browser flow (+ backend contract), and how to grab a JWT by
187
187
  |-------|-------|-------|
188
188
  | **Reference** | `get_generation_guide` · `list_elements` · `get_element` · `get_page_schema` | nothing |
189
189
  | **Generation** | `new_element` · `new_page_skeleton` · `validate_page` · `layout` (exact centering/row/grid/stack coordinates, both breakpoints) | nothing |
190
- | **Media** | `search_images` (real Pexels stock photos) · `get_icon_svg` (Material Symbols / Font Awesome icon names → inline SVG via Iconify) · `upload_images` (re-host external images, data: URIs, or local file paths from the user's machine) · `render_preview` (screenshot a page/URL so the model can see + compare it) | nothing |
190
+ | **Media** | `search_images` (real Pexels stock photos) · `get_icon_svg` (Material Symbols / Font Awesome icon names → inline SVG via Iconify) · `upload_images` (re-host external images, data: URIs, or local file paths from the user's machine — files into your media collection when Webcake creds **and** an org are available; the org is required and must match the page's, so settle it up front — 1 org auto-selects, 2+ with none chosen returns `organization_required` + the list; no JWT → the public CDN) · `render_preview` (screenshot a page/URL so the model can see + compare it) | nothing |
191
191
  | **Ingest** | `ingest_html` · `ingest_url` (recreate an existing page) | nothing |
192
192
  | **Persistence** | `list_organizations` · `create_page` · `list_pages` · `find_pages` · `get_page` · `update_page` · `add_section` · `patch_page` · `publish_page` | `WEBCAKE_API_BASE` + `WEBCAKE_JWT` |
193
193
 
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.4.0",
4
+ "d": "23/07/2026",
5
+ "type": "Changed",
6
+ "en": "publish_page now reuses the page's currently-attached domain when custom_domain is omitted — mirroring the editor's publish modal, a plain…",
7
+ "vi": "publish_page nay tự động dùng lại domain đang gắn với trang khi bỏ trống custom_domain — giống cách modal publish của editor hoạt động, gọi…"
8
+ },
9
+ {
10
+ "v": "1.3.0",
11
+ "d": "17/07/2026",
12
+ "type": "Added",
13
+ "en": "upload_images and the automatic image re-hosting on create_page, update_page, add_section, and patch_page now file uploaded images into the…",
14
+ "vi": "upload_images và bước tự động re-host ảnh khi lưu trong create_page, update_page, add_section, patch_page nay lưu ảnh đã upload vào bộ sưu tập…"
15
+ },
2
16
  {
3
17
  "v": "1.2.1",
4
18
  "d": "21/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "create_page now sends a by_ai field in the create request body (default value \"mcp\") so the backend can stamp each MCP-generated page in its by_ai…",
28
42
  "vi": "create_page nay gửi thêm trường by_ai trong body của request tạo trang (giá trị mặc định \"mcp\") để backend có thể đánh dấu từng trang được tạo qua…"
29
- },
30
- {
31
- "v": "1.0.83",
32
- "d": "16/06/2026",
33
- "type": "Added",
34
- "en": "The server web guide (GET /) now includes a bilingual video guides section with per-app installation walkthrough cards (Claude, Codex, Antigravity);…",
35
- "vi": "Web guide của server (GET /) nay có thêm mục video hướng dẫn cài đặt song ngữ với các card theo từng ứng dụng (Claude, Codex, Antigravity); card có…"
36
- },
37
- {
38
- "v": "1.0.82",
39
- "d": "16/06/2026",
40
- "type": "Added",
41
- "en": "render_preview now accepts a tiles parameter: pass tiles:true to receive the page as a stack of top-to-bottom horizontal band images instead of one…",
42
- "vi": "render_preview nay nhận tham số tiles: truyền tiles:true để nhận trang dưới dạng stack các ảnh dải ngang từ trên xuống dưới thay vì một ảnh…"
43
43
  }
44
44
  ]
@@ -7,7 +7,8 @@ import { ELEMENT_TYPES, CATALOG_SUMMARY } from "./elements/index.js";
7
7
  export const INSTRUCTIONS = `webcake-landing builds and edits Webcake landing pages (the editor "page_source" JSON). Act as a PROFESSIONAL landing-page designer doing client work: consult to understand the brief, LOCK a design system (exact palette + type scale + spacing grid + button/card specs derived from the customer's primary color), then build EVERY element from those tokens so the page looks studio-made and consistent — never invent a color/size/radius per element. The customer is an ORDINARY person, not a designer: talk to them only in plain everyday words and visual outcomes ("tông cam ấm, chữ to dễ đọc, ảnh lớn ở đầu trang"), NEVER jargon (hero/CTA/palette/type scale/rgba/8px grid) — read the intent behind how they phrase it and translate it into the design yourself; keep all technical terms internal.
8
8
 
9
9
  RULES (follow for every request):
10
- - INTAKE FIRST — do this EVERY time, even for a "quick"/"test" page. Do NOT jump straight to new_page_skeleton/create_page on the same turn as the request: ask the essentials, restate an outline, get a "yes", THEN build. Ask ONE short batch (3–6, with sensible defaults so the user answers fast) enough to understand the page's PURPOSE, name, look and layout: page purpose/goal, brand/page name, what they sell + price (sales/ads pages), primary color + logo/branding, sections & layout in order, primary CTA + destination, desktop+mobile or mobile-only, which organization. CONSULT, don't interrogate: SUGGEST so the user reacts to something concrete — propose a section flow (pick the archetype matching the page type) + a look (hero treatment + color/tone), and when the user is vague offer 2–3 directions to choose from; proactively suggest sections that fit their goal (social-proof, FAQ, countdown), but ask, don't silently add. Then restate the proposed design (section flow + CTA + color/tone) and WAIT for the user's confirmation, iterating until it matches their intent, before generating. Never assume or silently placeholder the page name, product, price, or colors — ask; only placeholder a core fact when the user explicitly declines to give it.
10
+ - APPROVE THE TOOLS ONCE, UP FRONT — building one page fires MANY tool calls (get_element, search_images, validate_page, create_page, add_section, patch_page, publish_page…), and a per-call permission prompt stops the build dead each time. So in the SAME message as your intake questions, ask the user ONCE to allow this server's tools for the session — in plain words, e.g. "Để mình dựng trang liên tục không bị ngắt quãng, bạn bấm cho phép (Allow / Always allow) cho bộ công cụ webcake-landing một lần nhé — mình sẽ cần gọi khá nhiều bước." Say it once, then get on with the intake; do NOT re-ask, and never treat it as a blocker: if the user prefers approving each call, just build anyway. Mutating steps still follow the dry-run rules below — blanket tool approval is about not being interrupted, NOT permission to skip confirmation of the design before you save.
11
+ - INTAKE FIRST — do this EVERY time, even for a "quick"/"test" page. Do NOT jump straight to new_page_skeleton/create_page on the same turn as the request: ask the essentials, restate an outline, get a "yes", THEN build. Ask ONE short batch (3–6, with sensible defaults so the user answers fast) enough to understand the page's PURPOSE, name, look and layout: page purpose/goal, brand/page name, what they sell + price (sales/ads pages), primary color + logo/branding, sections & layout in order, primary CTA + destination, desktop+mobile or mobile-only, which organization (call list_organizations FIRST — only ask when the account has 2+ orgs; the org must be settled here, before any image or build work). CONSULT, don't interrogate: SUGGEST so the user reacts to something concrete — propose a section flow (pick the archetype matching the page type) + a look (hero treatment + color/tone), and when the user is vague offer 2–3 directions to choose from; proactively suggest sections that fit their goal (social-proof, FAQ, countdown), but ask, don't silently add. Then restate the proposed design (section flow + CTA + color/tone) and WAIT for the user's confirmation, iterating until it matches their intent, before generating. Never assume or silently placeholder the page name, product, price, or colors — ask; only placeholder a core fact when the user explicitly declines to give it.
11
12
  - ASK for any real data the page will display — never invent it, and don't silently placeholder it. This includes: phone/hotline/Zalo, price (+ original price), address, shop/brand name, links/URLs, email, opening hours, and exact stats/social-proof numbers. If a value the page needs is missing, ASK the user for it (in intake, or pause and ask before generating). Use a clearly-labelled placeholder ONLY when the user explicitly says to skip it — then tell them exactly what to fill in.
12
13
  - LANGUAGE: write ALL page copy in the SAME language the user is chatting in, with FULL, CORRECT diacritics/accents. For Vietnamese, every word MUST carry its proper dấu (e.g. "Trân Trọng Kính Mời", "Ngày 15 Tháng 08 Năm 2025") — NEVER emit accent-stripped "không dấu" text. Never romanize or drop accent marks from any language.
13
14
  - ALWAYS call validate_page and fix every error before create_page / update_page. WARNINGS ARE A FIX LIST, NOT NOISE: each warning is a visible defect the customer will see (text spilling onto the element below, off-canvas boxes, empty bands, missing field_name, dead event targets). Fix every warning too — before the first save when building new, or via patch_page right after saving an edit — and re-validate until the list is empty. Only a warning you can demonstrate is a false positive may remain (name it to the user and say why). Never tell the user the page is done while warnings stand.
@@ -24,7 +25,7 @@ RULES (follow for every request):
24
25
  COMMIT-AS-IS (retry path after timeout): patch_page({ draft_id, dry_run:false }) with NO patches — skips the apply step, re-validates, then saves. This is the universal retry for any timed-out write.
25
26
  A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). Drafts expire in ~2 h.
26
27
  - DRY-RUN CACHE: create_page, update_page, and add_section dry_run=true all cache the validated payload and return a draft_id. Re-run the same tool with { draft_id, dry_run:false } — no need to re-send the source.
27
- - Organizations: call list_organizations before saving. If the account has exactly ONE org, create_page auto-selects it — no need to ask. If there are MULTIPLE orgs, show them and ask the user which to use (is_default is the suggested default); pass the chosen organization_id to create_page. Pass organization_id:"personal" only when the user explicitly wants no org. create_page enforces this itself (it refuses to guess between multiple orgs). Endpoints are owner-scoped (only the account's own pages).
28
+ - ORGANIZATION FIRST — the org is REQUIRED for the API and must be settled in INTAKE, before any image or build work. Call list_organizations at the START (part of the same first batch as your intake questions). Exactly ONE org → it is auto-selected everywhere, no need to ask. MULTIPLE orgs → show them and ask the user which to use (is_default is the suggested default) as one of your intake questions, then pass that SAME organization_id to create_page AND to upload_images. Both refuse to guess: they return ok:false with reason:"organization_required" and the org list. Pass organization_id:"personal" only when the user explicitly wants no org. Endpoints are owner-scoped (only the account's own pages). WHY IT MUST COME FIRST: the org decides WHICH media collection the page's images are filed into, so an image uploaded before the org is settled has no collection to go into — do NOT call search_images/upload_images or start building until the org is known. One org, chosen once, used for the page and every image on it.
28
29
  - REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements using role→element hints (hero → section with background image/overlay + heading + paragraph + button; features → group blocks with icon/title/text; stats bar → group with heading+text per stat; pricing → group with text list + button; footer → section with text + links). When the reference contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box (clone its HTML with all styles inlined), not as element soup. (2) HTML string → call ingest_html(html, detail:'full') to get the richer AST when cloning; use detail:'compact' (default) for a layout-only reference. (3) URL → call ingest_url(url, detail:'full') for the same richer AST. (4) GOOGLE STITCH design (when a Stitch MCP is connected and the user points at a Stitch screen/project) → bridge the two MCPs: call the Stitch tool get_screen, take the returned htmlCode.downloadUrl, and pass THAT url to ingest_url(detail:'full') (or fetch the HTML and use ingest_html) — Stitch output is a Tailwind-CDN page, so the AST comes back with a token-named palette + design_tokens (the real spacing/radii/type-scale) + gradients (Tailwind gradient utilities reconstructed as linear-gradient(...) with resolved color stops — apply them as the element's background, e.g. on CTA/hero) + per-section hover_effects (the interactions Stitch packs in: scale/image-zoom/lift/underline/color-change — REPRODUCE them via Webcake hover events: change_color (change_color_type text/background/border) for color/underline, the button's hovered* specials or animation_hover for scale/lift/zoom — don't ship a static page); LOCK the design system from those tokens, reuse the page's image URLs (they auto-host on save — Stitch's googleusercontent images included), then create_page. ABSOLUTE-CANVAS builder exports (LadiPage-family / Webcake-published HTML) are AUTO-DETECTED by ingest_html/ingest_url and converted DETERMINISTICALLY into a ready-to-save source (folded into the response as source + clone_notes + clone_notice, the per-element geometry summarized to canvas_summary) — a faithful 1:1 clone on the matching 420/960 canvas: pass that source STRAIGHT to create_page (dry_run:true first to validate, then dry_run:false) instead of hand-rebuilding; its images auto-host on save (no upload_images), and clone_notes lists the few lossy approximations (fixed/floating elements, svg-less shapes, skipped social-proof toasts) to patch_page afterward. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts), a token-named palette + design_tokens (for Tailwind-CDN pages like Google Stitch, lifted from the page's tailwind.config: palette names every color token e.g. primary→#a43b38, and design_tokens carries the resolved spacing grid + corner radii + TYPE SCALE e.g. display-lg→48px — rebuild sizing/spacing/color FROM these tokens, not by guessing), background_images from stylesheets, gradients (incl. reconstructed Tailwind gradient backgrounds), per-section hover_effects (reproduce them — via hover events, or the custom-CSS escape hatch below), and in full mode: per-section blocks (cards/tiles/steps with title/body/image/cta) and li lists — use it for LAYOUT + HIERARCHY, then generate FRESH content tailored to the user's brand (don't 1:1 copy text). IMAGES FROM THE REFERENCE ARE THE USER'S ASSETS — for BOTH intents (adapt AND clone), put every real image URL found in the AST (images, background_images, og_image, canvas src/background) straight into the corresponding slot's specials.src / url(...) background / gallery link; the save AUTO-HOSTS it to the Webcake CDN, so you do NOT need to call upload_images first — just carry the URL through un-altered (never drop it, never swap it for a search_images stock photo or a placeholder). Call search_images ONLY for slots that have no source image in the reference. intent='clone' only when the user explicitly asks to mirror the original; default intent='adapt' (adapt rewrites TEXT, not the imagery). The reference workflow PRESERVES craft rules above (centering, page margin, premium spacing, real images) — apply them on top of the reference layout, don't bypass them.
29
30
 
30
31
  MODEL (essentials):
@@ -37,7 +38,7 @@ MODEL (essentials):
37
38
  - PREMIUM CRAFT (read "sang"): generous whitespace (don't cram; ~48–72px above each band's first element, ≥16–24px between elements); clear type scale (H1 40–56 / body 16–18, big jump); ONE accent used sparingly + neutrals; snap spacing to an 8px grid; reuse the same content width / margin / card+button radius across sections.
38
39
  - STICKY HEADER: a sticky/fixed header (config.sticky) OVERLAYS the page — it does NOT push sections below it down. Offset the first section's top content DOWN by the header height (~60–72px) so nothing hides behind it, and do NOT duplicate the shop name in both the header and the top of the hero. A non-sticky header stacks normally and needs no offset.
39
40
  - Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
40
- - 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 to the Webcake CDN 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. 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). Ensure text contrasts with its section background.
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). Ensure text contrasts with its section background.
41
42
 
42
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.
43
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.
@@ -16,30 +16,38 @@ const REDIS_PREFIX = "wcl:rehost:";
16
16
  // Hosted URLs are effectively permanent on the CDN; cap Redis growth with a long TTL.
17
17
  const TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
18
18
  const memory = new Map();
19
- /** Look up the hosted URL previously stored for `url`, or null on a miss. */
20
- export async function rehostGet(url) {
19
+ /**
20
+ * Entries are namespaced by SCOPE — `org:<id>` when the upload files an Asset
21
+ * into that org's media collection, else `public`. The hosted URL is
22
+ * content-addressed and would be byte-identical across orgs, but the Asset row
23
+ * is per-org: sharing one key would let a second org hit the cache and skip the
24
+ * upload, leaving the image absent from ITS collection.
25
+ */
26
+ const key = (url, scope) => `${REDIS_PREFIX}${scope}:${url}`;
27
+ /** Look up the hosted URL previously stored for `url` in `scope`, or null on a miss. */
28
+ export async function rehostGet(url, scope = "public") {
21
29
  const redis = getRedis();
22
30
  if (redis) {
23
31
  try {
24
- return await redis.get(REDIS_PREFIX + url);
32
+ return await redis.get(key(url, scope));
25
33
  }
26
34
  catch (e) {
27
35
  console.error("[rehost-cache] redis get failed, using memory:", e?.message ?? e);
28
36
  }
29
37
  }
30
- return memory.get(url) ?? null;
38
+ return memory.get(key(url, scope)) ?? null;
31
39
  }
32
- /** Remember that `url` is now hosted at `hosted`. */
33
- export async function rehostSet(url, hosted) {
40
+ /** Remember that `url` is now hosted at `hosted` within `scope`. */
41
+ export async function rehostSet(url, hosted, scope = "public") {
34
42
  const redis = getRedis();
35
43
  if (redis) {
36
44
  try {
37
- await redis.set(REDIS_PREFIX + url, hosted, "PX", TTL_MS);
45
+ await redis.set(key(url, scope), hosted, "PX", TTL_MS);
38
46
  return;
39
47
  }
40
48
  catch (e) {
41
49
  console.error("[rehost-cache] redis set failed, using memory:", e?.message ?? e);
42
50
  }
43
51
  }
44
- memory.set(url, hosted);
52
+ memory.set(key(url, scope), hosted);
45
53
  }
@@ -21,6 +21,30 @@ function timeoutOrNetworkError(url, e) {
21
21
  return { ok: false, status: 0, error: `Network error calling ${url}: ${e?.message ?? e}` };
22
22
  }
23
23
  const UPLOAD_FILE_ENDPOINT = "/external/upload_file";
24
+ /**
25
+ * The builder's OWN media-library upload (AssetsController.upload, router scope
26
+ * `host: "builder."` → `/api/persona/upload`, piped through :org_check). This is
27
+ * the "bộ sưu tập" route the editor's media picker calls. Unlike the public
28
+ * /external/upload_file — which only PUTs bytes to S3 and hands back a CDN URL —
29
+ * this one also creates the `Image` row AND the `Asset` row in the org's folder,
30
+ * so the image shows up in the user's collection and can be re-picked later.
31
+ * Requires a JWT and an `x-org-id` (the controller drops the asset in the org
32
+ * folder resolved by :org_check; with no org it cannot file the asset).
33
+ */
34
+ const ASSET_UPLOAD_ENDPOINT = "/api/persona/upload";
35
+ /**
36
+ * The org's folder listing (AssetsController.get_all_folders_organization_by_type,
37
+ * builder host, behind :org_check). Needed because `AssetsController.upload`
38
+ * defaults `folder_id` to the account's PERSONA folder — sending only `x-org-id`
39
+ * would stamp the asset `organization_id: <org>` while filing it in the personal
40
+ * folder, and the collection listings query by `folder_id`, so the image would
41
+ * surface in neither. The editor avoids this by resolving the org's ROOT folder
42
+ * (`type == -1`) and posting it as `in_folder` (builderx_spa: landingLibrary.js
43
+ * `folders.find(f => f.type == -1)` → personalApi.upload). We mirror that.
44
+ */
45
+ const ORG_FOLDERS_ENDPOINT = "/api/organization/folders/all";
46
+ /** The org root folder's `type` discriminator in the folder listing. */
47
+ const ORG_ROOT_FOLDER_TYPE = -1;
24
48
  const BUILD_ENDPOINT = "/render/build";
25
49
  const CREATE_ENDPOINT = "/api/v1/ai/create_page_from_source";
26
50
  /**
@@ -199,7 +223,9 @@ export async function listOrganizations(config) {
199
223
  export async function createPage(config, name, source, orgId) {
200
224
  // Host external image URLs to the Webcake CDN before storing, so a clone never
201
225
  // keeps hotlinked/expiring source URLs. Failures keep the original URL.
202
- const { source: hostedSource, report: rehost } = await rehostSourceImages(config.base, source);
226
+ // `orgId` is the org resolved for THIS create and outranks config.orgId, so the
227
+ // images are filed into the same collection the page itself lands in.
228
+ const { source: hostedSource, report: rehost } = await rehostSourceImages(orgId ? { ...config, orgId } : config, source);
203
229
  const req = buildRequest(config, name, hostedSource, orgId);
204
230
  let res;
205
231
  try {
@@ -312,6 +338,10 @@ export async function getPageSource(config, pageId) {
312
338
  page_id: d.page_id,
313
339
  name: d.name,
314
340
  organization_id: d.organization_id ?? null,
341
+ // The page's currently-attached domain, when the record carries it — lets
342
+ // publish_page reuse it instead of dropping to a domain-less preview.
343
+ custom_domain: d.custom_domain ?? null,
344
+ custom_path: d.custom_path ?? null,
315
345
  source: d.source,
316
346
  };
317
347
  }
@@ -344,7 +374,7 @@ export function buildAppendRequestRedacted(config, pageId, sections) {
344
374
  */
345
375
  export async function appendSection(config, pageId, sections) {
346
376
  const url = `${config.base}${APPEND_ENDPOINT}`;
347
- const { source: hostedSections, report: rehost } = await rehostSourceImages(config.base, sections);
377
+ const { source: hostedSections, report: rehost } = await rehostSourceImages(config, sections);
348
378
  let res;
349
379
  try {
350
380
  res = await fetch(url, {
@@ -396,7 +426,7 @@ export async function appendSection(config, pageId, sections) {
396
426
  /** Overwrite an existing page's source (source-only). */
397
427
  export async function updatePageSource(config, pageId, source) {
398
428
  const url = `${config.base}${UPDATE_ENDPOINT}`;
399
- const { source: hostedSource, report: rehost } = await rehostSourceImages(config.base, source);
429
+ const { source: hostedSource, report: rehost } = await rehostSourceImages(config, source);
400
430
  let res;
401
431
  try {
402
432
  res = await fetch(url, {
@@ -682,6 +712,187 @@ export async function uploadImageMultipart(base, bytes, filename, contentType) {
682
712
  }
683
713
  return { ok: true, url: hostedUrl, status: res.status };
684
714
  }
715
+ /**
716
+ * Upload an image into the account's MEDIA COLLECTION (bộ sưu tập) via the
717
+ * builder's own media route — the same one the editor's media picker uses.
718
+ * Creates the Image + Asset rows in the org folder, so the image is re-pickable
719
+ * in the editor instead of being a loose CDN URL.
720
+ *
721
+ * Needs `config.jwt` AND an org — the org is REQUIRED, by project policy: an
722
+ * image must land in the collection of the org the page belongs to, so the org
723
+ * is settled up front rather than guessed at upload time.
724
+ *
725
+ * The org must reach the backend TWO ways, and both matter:
726
+ * 1. `x-org-id` — :org_check assigns the organization, stamping the Asset's
727
+ * `organization_id`.
728
+ * 2. `in_folder` — the org's ROOT folder. Without it the controller defaults
729
+ * `folder_id` to the persona folder, and since the library lists by
730
+ * `folder_id` the asset would appear in NO collection. Passing only the
731
+ * header is the subtle failure this guards against.
732
+ *
733
+ * `folderId` overrides (1) with an explicit sub-folder. Callers without a
734
+ * JWT/org should use `uploadImageMultipart` (public, no collection entry).
735
+ */
736
+ export async function uploadImageToCollection(config, bytes, filename, contentType, opts = {}) {
737
+ const builder = config.builderBase ?? config.base;
738
+ const org = opts.orgId ?? config.orgId;
739
+ if (!config.jwt)
740
+ return { ok: false, status: 0, error: "no JWT — cannot upload to the collection" };
741
+ if (org == null || `${org}` === "") {
742
+ return { ok: false, status: 0, error: "no org id — an organization must be chosen before uploading to the collection" };
743
+ }
744
+ // File into the org's own library. An explicit folderId wins; otherwise resolve
745
+ // the org root — never let the backend default to the persona folder, which
746
+ // would strand the asset outside every collection listing.
747
+ const folderId = opts.folderId ?? (await resolveOrgRootFolderId(config, `${org}`));
748
+ if (!folderId) {
749
+ return {
750
+ ok: false,
751
+ status: 0,
752
+ error: `could not resolve the root collection folder for org ${org} — uploading without it would file the image outside the org's collection`,
753
+ };
754
+ }
755
+ const url = `${builder.replace(/\/+$/, "")}${ASSET_UPLOAD_ENDPOINT}`;
756
+ const form = new FormData();
757
+ form.append("file", new Blob([bytes], { type: contentType }), filename);
758
+ form.append("in_folder", `${folderId}`);
759
+ let res;
760
+ try {
761
+ res = await fetch(url, {
762
+ method: "POST",
763
+ // No Content-Type — fetch sets the multipart boundary from the FormData.
764
+ // Auth mirrors authHeaders() but without its JSON Content-Type.
765
+ headers: {
766
+ Accept: "application/json",
767
+ Authorization: `Bearer ${config.jwt}`,
768
+ Cookie: `jwt=${config.jwt}`,
769
+ "x-org-id": `${org}`,
770
+ },
771
+ body: form,
772
+ signal: timeoutSignal(UPLOAD_MULTIPART_TIMEOUT_MS),
773
+ });
774
+ }
775
+ catch (e) {
776
+ const e2 = timeoutOrNetworkError(url, e);
777
+ return { ok: false, status: e2.status, error: e2.error };
778
+ }
779
+ const rawText = await res.text();
780
+ let json = null;
781
+ try {
782
+ json = JSON.parse(rawText);
783
+ }
784
+ catch {
785
+ /* non-JSON */
786
+ }
787
+ if (!res.ok || json?.success === false) {
788
+ const reason = json?.reason ?? json?.message ?? (json ? undefined : rawText.slice(0, 200));
789
+ return { ok: false, status: res.status, error: `Collection upload returned ${res.status}${reason ? `: ${reason}` : ""}` };
790
+ }
791
+ // AssetsController returns {success:true, asset: Asset.json(asset+image)} —
792
+ // the CDN link is the asset's txtdata, with the joined image row as a backup.
793
+ const asset = json?.asset ?? json?.data?.asset ?? json?.data;
794
+ const hostedUrl = (typeof asset?.txtdata === "string" ? asset.txtdata : undefined) ??
795
+ (typeof asset?.image?.link === "string" ? asset.image.link : undefined);
796
+ if (!hostedUrl) {
797
+ return { ok: false, status: res.status, error: "Collection upload succeeded but returned no URL" };
798
+ }
799
+ return { ok: true, url: hostedUrl, asset_id: asset?.id, status: res.status };
800
+ }
801
+ /**
802
+ * Memo of {jwt+base → resolved org id} so a multi-image save resolves the org
803
+ * once instead of calling list_organizations per image. Keyed by JWT so a
804
+ * multi-user `serve` process never leaks one caller's org to another.
805
+ */
806
+ const uploadOrgMemo = new Map();
807
+ /** Memo of {jwt+builder+org → org root folder id}, same rationale as uploadOrgMemo. */
808
+ const orgFolderMemo = new Map();
809
+ /**
810
+ * Resolve an org's ROOT collection folder id (`type === -1`) — the folder the
811
+ * editor's media picker treats as that org's library. Uploads must carry it as
812
+ * `in_folder`, otherwise the backend files the asset in the account's persona
813
+ * folder and it shows up in neither library (see ORG_FOLDERS_ENDPOINT).
814
+ *
815
+ * Returns undefined when the listing fails or has no root folder; the caller
816
+ * then has no verified org folder to file into.
817
+ */
818
+ export async function resolveOrgRootFolderId(config, orgId) {
819
+ const builder = config.builderBase ?? config.base;
820
+ const memoKey = `${builder}|${config.jwt}|${orgId}`;
821
+ if (orgFolderMemo.has(memoKey))
822
+ return orgFolderMemo.get(memoKey);
823
+ // `type` is required by the controller; organization-image is the media library.
824
+ const url = `${builder.replace(/\/+$/, "")}${ORG_FOLDERS_ENDPOINT}?type=organization-image`;
825
+ let resolved;
826
+ try {
827
+ const res = await fetch(url, {
828
+ method: "GET",
829
+ headers: { ...authHeaders(config, orgId) },
830
+ signal: timeoutSignal(HTTP_TIMEOUT_MS),
831
+ });
832
+ if (res.ok) {
833
+ const json = await res.json().catch(() => null);
834
+ const folders = Array.isArray(json?.folders) ? json.folders : [];
835
+ const root = folders.find((f) => Number(f?.type) === ORG_ROOT_FOLDER_TYPE);
836
+ if (root?.id != null)
837
+ resolved = `${root.id}`;
838
+ }
839
+ }
840
+ catch {
841
+ /* leave undefined — the caller decides how to proceed */
842
+ }
843
+ orgFolderMemo.set(memoKey, resolved);
844
+ return resolved;
845
+ }
846
+ /**
847
+ * Resolve the org to file uploaded assets into — the collection route cannot
848
+ * file an asset without one (:org_check assigns the organization only when
849
+ * `x-org-id` is present).
850
+ *
851
+ * Mirrors create_page's org policy: an explicit `config.orgId` wins; otherwise
852
+ * an account with exactly ONE org has it auto-selected. With 0 orgs, or 2+ (too
853
+ * ambiguous to guess), this returns undefined and the caller uploads to the
854
+ * public CDN endpoint instead — an image never blocks on an org prompt.
855
+ */
856
+ export async function resolveCollectionOrgId(config) {
857
+ if (config.orgId != null && `${config.orgId}` !== "")
858
+ return `${config.orgId}`;
859
+ if (!config.jwt)
860
+ return undefined;
861
+ const memoKey = `${config.base}|${config.jwt}`;
862
+ if (uploadOrgMemo.has(memoKey))
863
+ return uploadOrgMemo.get(memoKey);
864
+ const res = await listOrganizations(config);
865
+ let resolved;
866
+ if (res.ok && res.organizations && res.organizations.length === 1) {
867
+ resolved = `${res.organizations[0].id}`;
868
+ }
869
+ uploadOrgMemo.set(memoKey, resolved);
870
+ return resolved;
871
+ }
872
+ /**
873
+ * Upload an image, PREFERRING the media collection and falling back to the
874
+ * public CDN endpoint. This is the single entry point every upload path uses so
875
+ * that images land in the user's bộ sưu tập whenever we have the credentials to
876
+ * file them, while a credential-less run (`npx`, no env) still works exactly as
877
+ * before. A collection failure is never fatal — it degrades to the public upload
878
+ * and reports which route produced the URL.
879
+ *
880
+ * Requires a JWT AND an org: by policy the org is settled up front and the image
881
+ * belongs in THAT org's collection. Without either, the public endpoint is used
882
+ * (the URL works but no Asset row exists) — callers that must not silently skip
883
+ * the collection should check the org themselves and refuse first.
884
+ */
885
+ export async function uploadImagePreferCollection(config, bytes, filename, contentType, opts = {}) {
886
+ const org = opts.orgId ?? config.orgId;
887
+ if (config.jwt && org != null && `${org}` !== "") {
888
+ const viaCollection = await uploadImageToCollection(config, bytes, filename, contentType, opts);
889
+ if (viaCollection.ok)
890
+ return { ...viaCollection, collection: true };
891
+ console.error(`[upload] collection upload failed (${viaCollection.error}) — falling back to the public CDN endpoint`);
892
+ }
893
+ const viaPublic = await uploadImageMultipart(config.base, bytes, filename, contentType);
894
+ return { ...viaPublic, collection: false };
895
+ }
685
896
  // ---------------------------------------------------------------------------
686
897
  // Server-side image re-host (runs on every real create/update/append save)
687
898
  // ---------------------------------------------------------------------------
@@ -696,8 +907,12 @@ function rehostExtFromContentType(ct) {
696
907
  };
697
908
  return map[sub] ?? (sub.replace("image/", "") || "jpg");
698
909
  }
699
- /** Download one remote image and re-upload it to the Webcake CDN; returns the hosted URL or null. */
700
- async function fetchAndHostOne(apiBase, src) {
910
+ /**
911
+ * Download one remote image and re-upload it to Webcake; returns the hosted URL
912
+ * or null. Prefers the media collection (so auto-hosted images join the user's
913
+ * bộ sưu tập) and degrades to the public CDN endpoint without creds/org.
914
+ */
915
+ async function fetchAndHostOne(config, src) {
701
916
  let res;
702
917
  try {
703
918
  res = await fetch(src, {
@@ -738,9 +953,25 @@ async function fetchAndHostOne(apiBase, src) {
738
953
  if (buf.byteLength > REHOST_MAX_BYTES)
739
954
  return null;
740
955
  const ext = rehostExtFromContentType(contentType);
741
- const up = await uploadImageMultipart(apiBase, Buffer.from(buf), `rehost.${ext}`, contentType);
956
+ // Name the asset from the source URL so the collection shows something
957
+ // recognisable instead of a wall of "rehost.jpg" rows.
958
+ const filename = rehostFilename(src, ext);
959
+ const up = await uploadImagePreferCollection(config, Buffer.from(buf), filename, contentType);
742
960
  return up.ok && up.url ? up.url : null;
743
961
  }
962
+ /** Derive a human-readable collection filename from the source URL. */
963
+ function rehostFilename(src, ext) {
964
+ let stem = "";
965
+ try {
966
+ const p = new URL(src).pathname;
967
+ stem = decodeURIComponent(p.slice(p.lastIndexOf("/") + 1)).replace(/\.[^.]*$/, "");
968
+ }
969
+ catch {
970
+ /* fall through to the generic name */
971
+ }
972
+ stem = stem.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
973
+ return `${stem || "rehost"}.${ext}`;
974
+ }
744
975
  /**
745
976
  * Upload every external image URL in `source` to the Webcake CDN and return the
746
977
  * source with those URLs rewritten in place (specials.src, `url(...)`
@@ -749,25 +980,41 @@ async function fetchAndHostOne(apiBase, src) {
749
980
  * from the rehost cache (Redis-or-memory, ./rehost-cache.ts). A per-URL failure leaves the original URL untouched and
750
981
  * never throws — the save proceeds either way. Returns `{ source }` unchanged
751
982
  * with no report when the source has no external images.
983
+ *
984
+ * Uploads go to the account's media collection when the config carries a JWT and
985
+ * an org, else to the public CDN endpoint. The cache is therefore keyed PER ORG:
986
+ * the hosted URL is content-addressed and identical across orgs, but the Asset
987
+ * row is per-org, so a shared key would let org B reuse org A's URL and never get
988
+ * the image filed into its own collection.
752
989
  */
753
- export async function rehostSourceImages(apiBase, source) {
990
+ export async function rehostSourceImages(config, source) {
754
991
  const candidates = collectExternalImageUrls(source);
755
992
  if (candidates.length === 0)
756
993
  return { source };
757
994
  const capped = candidates.slice(0, MAX_REHOST_PER_SAVE);
758
995
  const skipped = candidates.length - capped.length;
996
+ // Resolve the collection org ONCE for the whole save, then pin it onto the
997
+ // config every upload sees — otherwise each image would re-resolve it.
998
+ // No org resolvable → the images take the public endpoint rather than the
999
+ // collection. This never blocks the save: a page's images are not worth
1000
+ // failing a write over, and create_page already refuses to save at all when
1001
+ // the org is ambiguous, so by this point an org is normally settled.
1002
+ const orgId = config.jwt ? await resolveCollectionOrgId(config) : undefined;
1003
+ if (orgId)
1004
+ config = { ...config, orgId };
1005
+ const scope = config.jwt && orgId ? `org:${orgId}` : "public";
759
1006
  // Resolve from cache first; upload the rest with a small concurrency pool.
760
- const cacheHits = await Promise.all(capped.map((u) => rehostGet(u)));
1007
+ const cacheHits = await Promise.all(capped.map((u) => rehostGet(u, scope)));
761
1008
  const toUpload = capped.filter((_, i) => !cacheHits[i]);
762
1009
  for (let i = 0; i < toUpload.length; i += REHOST_CONCURRENCY) {
763
1010
  const batch = toUpload.slice(i, i + REHOST_CONCURRENCY);
764
- const hosted = await Promise.all(batch.map((u) => fetchAndHostOne(apiBase, u)));
765
- await Promise.all(batch.map((u, j) => (hosted[j] ? rehostSet(u, hosted[j]) : Promise.resolve())));
1011
+ const hosted = await Promise.all(batch.map((u) => fetchAndHostOne(config, u)));
1012
+ await Promise.all(batch.map((u, j) => (hosted[j] ? rehostSet(u, hosted[j], scope) : Promise.resolve())));
766
1013
  }
767
1014
  const map = new Map();
768
1015
  const failed = [];
769
1016
  for (const u of capped) {
770
- const h = await rehostGet(u);
1017
+ const h = await rehostGet(u, scope);
771
1018
  if (h)
772
1019
  map.set(u, h);
773
1020
  else
@@ -779,6 +1026,8 @@ export async function rehostSourceImages(apiBase, source) {
779
1026
  rehosted: map.size,
780
1027
  failed: failed.length,
781
1028
  skipped,
1029
+ collection: Boolean(config.jwt && orgId),
1030
+ ...(orgId ? { collection_org_id: orgId } : {}),
782
1031
  ...(failed.length ? { failed_urls: failed.slice(0, 8) } : {}),
783
1032
  };
784
1033
  return { source: rewritten, report };
@@ -29,9 +29,27 @@ import { fileURLToPath } from "node:url";
29
29
  import { text, image, images } from "../mcp/response.js";
30
30
  import { searchPexels, searchImagesViaProxy, resolvePexelsKey, resolvePexelsProxyBase, pexelsKeyFromHeaders, } from "../persistence/pexels-client.js";
31
31
  import { captureScreenshot, captureScreenshotTiles, resolveScreenshotProxyBase, screenshotProxyBaseFromHeaders, resolveMicrolinkKey, microlinkKeyFromHeaders, } from "../persistence/screenshot-client.js";
32
- import { uploadImageMultipart } from "../persistence/webcake-client.js";
32
+ import { uploadImageMultipart, uploadImagePreferCollection, resolveCollectionOrgId, listOrganizations, } from "../persistence/webcake-client.js";
33
33
  import { resolveIconSvg } from "../persistence/icon-client.js";
34
- import { configFromHeaders, ENVIRONMENTS, stripTrailingSlash } from "../persistence/config.js";
34
+ import { configFromHeaders, ENVIRONMENTS, readConfig, stripTrailingSlash } from "../persistence/config.js";
35
+ /**
36
+ * Name the uploaded asset after its source file so the collection lists
37
+ * something recognisable rather than a wall of identical "upload.jpg" rows.
38
+ */
39
+ function uploadFilename(entry, ext) {
40
+ let stem = "";
41
+ if (!entry.startsWith("data:")) {
42
+ try {
43
+ const path = /^https?:\/\//i.test(entry) ? new URL(entry).pathname : entry;
44
+ stem = decodeURIComponent(path.slice(path.lastIndexOf("/") + 1)).replace(/\.[^.]*$/, "");
45
+ }
46
+ catch {
47
+ /* fall through to the generic name */
48
+ }
49
+ }
50
+ stem = stem.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
51
+ return `${stem || "upload"}.${ext}`;
52
+ }
35
53
  /** Resolve just the API base (no JWT required) from per-request headers → env → WEBCAKE_ENV preset → prod default. */
36
54
  function resolveApiBase(headers) {
37
55
  const overrides = configFromHeaders(headers);
@@ -325,7 +343,7 @@ export function registerMediaTools(server, allowLocalFiles = true) {
325
343
  return image(r.dataBase64, r.mimeType ?? "image/png", `Rendered ${target} (${r.bytes} bytes, via ${r.via}). Compare this to the reference: check section order, colors, spacing, image placement, and text. For each mismatch, patch_page the offending element by id, re-publish, then render_preview again until it matches.`);
326
344
  });
327
345
  // 14) Upload images to Webcake -----------------------------------------------
328
- server.tool("upload_images", "Converts external image URLs (typically collected from ingest_html/ingest_url results), data: URIs, or LOCAL FILE PATHS from the user's computer into Webcake-hosted URLs (statics.pancake.vn) by reading/downloading each image and re-uploading it to the Webcake backend via multipart upload (200 MB backend limit). Use this whenever the page is built from a reference HTML/URL (BOTH intents — adapt AND clone), the user supplies their own image URLs, OR the user provides local image files from their machine — pass the path directly in `urls`; NEVER upload a user's local file to a third-party host (catbox, imgur, transfer.sh…) to obtain a URL first. The returned URLs go directly into specials.src — same as search_images results. Processes up to 20 entries per call in parallel, with a 200 MB per-image cap. No Webcake credentials required (the upload endpoint is public). UPLOADS BY DEFAULT (dry_run defaults to FALSE — unlike the page-persistence tools, this touches no account data, so the default is the real upload): the call downloads/reads each entry, uploads it, and returns the images map (original URL → hosted URL); WAIT for that map before assembling the page and never fall back to a placeholder for a slot whose upload succeeded. Pass dry_run:true only to preview what would be processed without any network/filesystem activity. Use search_images instead when you need stock photos. Local file paths are only permitted when the MCP server runs locally (stdio mode); on the remote HTTP transport they are rejected per-entry.", {
346
+ server.tool("upload_images", "Converts external image URLs (typically collected from ingest_html/ingest_url results), data: URIs, or LOCAL FILE PATHS from the user's computer into Webcake-hosted URLs (statics.pancake.vn) by reading/downloading each image and re-uploading it to the Webcake backend via multipart upload (200 MB backend limit). Use this whenever the page is built from a reference HTML/URL (BOTH intents — adapt AND clone), the user supplies their own image URLs, OR the user provides local image files from their machine — pass the path directly in `urls`; NEVER upload a user's local file to a third-party host (catbox, imgur, transfer.sh…) to obtain a URL first. The returned URLs go directly into specials.src — same as search_images results. Processes up to 20 entries per call in parallel, with a 200 MB per-image cap. UPLOAD TARGET: with Webcake credentials (WEBCAKE_JWT) AND an organization, each 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; the response marks those entries collection:true with their asset_id. THE ORG IS REQUIRED and must be the same one the page is created in: pass organization_id, or set WEBCAKE_ORG_ID / x-webcake-org-id; it is auto-selected only when the account has exactly ONE org. With 2+ orgs and none chosen this returns ok:false + reason:'organization_required' and the org list — settle the org (ask the user) and re-call, exactly as with create_page. WITHOUT credentials it falls back to the public CDN endpoint: the URLs still work and the page still renders, but the images do NOT appear in any collection (collection:false). UPLOADS BY DEFAULT (dry_run defaults to FALSE — unlike the page-persistence tools, this touches no page data, so the default is the real upload): the call downloads/reads each entry, uploads it, and returns the images map (original URL → hosted URL); WAIT for that map before assembling the page and never fall back to a placeholder for a slot whose upload succeeded. Pass dry_run:true only to preview what would be processed without any network/filesystem activity. Use search_images instead when you need stock photos. Local file paths are only permitted when the MCP server runs locally (stdio mode); on the remote HTTP transport they are rejected per-entry.", {
329
347
  urls: z
330
348
  .array(z.string())
331
349
  .min(1)
@@ -335,13 +353,47 @@ export function registerMediaTools(server, allowLocalFiles = true) {
335
353
  "• data:image/...;base64,... URIs (inline image data)\n" +
336
354
  "• Local file paths from the user's machine: absolute POSIX paths (/home/user/photo.jpg), home-dir paths (~/Pictures/logo.png), file:// URIs, or Windows drive paths (C:\\Users\\…). Local paths are only allowed when the server runs in stdio mode (the user's own machine); they are rejected on the remote HTTP transport.\n" +
337
355
  "Up to 200 MB per image (the backend multipart limit)."),
356
+ in_folder: z
357
+ .string()
358
+ .optional()
359
+ .describe("Collection folder id to file the uploaded assets into (the media library's folder). Omit to use the account's root folder. Ignored when the upload falls back to the public CDN endpoint (no credentials)."),
360
+ organization_id: z
361
+ .string()
362
+ .optional()
363
+ .describe("Organization whose media collection the images are filed into — REQUIRED when the account has 2+ orgs, and it must match the org the page is created in. Omit only to use WEBCAKE_ORG_ID / x-webcake-org-id, or to auto-select when the account has exactly ONE org; with 2+ orgs and none given the call returns reason:'organization_required' with the org list. Ignored when there are no credentials (public CDN fallback)."),
338
364
  dry_run: z
339
365
  .boolean()
340
366
  .optional()
341
367
  .describe("Default FALSE — the call actually reads/downloads and uploads, returning hosted URLs. Set true to only preview the endpoint and entries that WOULD be processed, without any network or filesystem activity (local paths: reports whether the file exists and its size)."),
342
- }, { title: "Upload Images to Webcake", readOnlyHint: false, openWorldHint: true }, async ({ urls, dry_run }, extra) => {
368
+ }, { title: "Upload Images to Webcake", readOnlyHint: false, openWorldHint: true }, async ({ urls, in_folder, organization_id, dry_run }, extra) => {
343
369
  const isDry = dry_run === true;
344
- const base = resolveApiBase(extra?.requestInfo?.headers);
370
+ const headers = extra?.requestInfo?.headers;
371
+ const base = resolveApiBase(headers);
372
+ // Full creds (JWT + org) let the upload file each image into the account's
373
+ // media collection. Missing/partial creds are NOT an error here — this tool
374
+ // has always worked without them, so it degrades to the public endpoint.
375
+ const { config: credConfig } = readConfig({
376
+ ...configFromHeaders(headers),
377
+ ...(organization_id ? { orgId: organization_id } : {}),
378
+ });
379
+ // The org must be settled BEFORE any image work: an image belongs in the
380
+ // collection of the org its page lives in, and that cannot be guessed.
381
+ // With creds but an ambiguous org (2+ and none chosen) we refuse rather
382
+ // than quietly dumping the images outside every collection — mirroring
383
+ // create_page, which refuses to save for the same reason.
384
+ const uploadOrgId = credConfig ? await resolveCollectionOrgId(credConfig) : undefined;
385
+ if (credConfig && !uploadOrgId) {
386
+ const orgs = await listOrganizations(credConfig);
387
+ if (orgs.ok && orgs.organizations && orgs.organizations.length > 1) {
388
+ return text({
389
+ ok: false,
390
+ reason: "organization_required",
391
+ organizations: orgs.organizations.map((o) => ({ id: o.id, name: o.name, is_default: o.is_default })),
392
+ error: "This account has multiple organizations, so the images have no collection to go into. Re-call upload_images with organization_id set to the org this page belongs to — the SAME org you pass to create_page. Ask the user which one if it is not settled yet.",
393
+ });
394
+ }
395
+ }
396
+ const uploadConfig = credConfig && uploadOrgId ? { ...credConfig, orgId: uploadOrgId } : null;
345
397
  // Stdio transport: extra.requestInfo is undefined (no HTTP request headers).
346
398
  // HTTP transport: extra.requestInfo is always present.
347
399
  // We derive allowLocalFiles from the parameter passed into registerMediaTools
@@ -370,7 +422,14 @@ export function registerMediaTools(server, allowLocalFiles = true) {
370
422
  return text({
371
423
  ok: true,
372
424
  dry_run: true,
373
- endpoint: `${base}/external/upload_file`,
425
+ endpoint: uploadConfig
426
+ ? `${uploadConfig.builderBase ?? uploadConfig.base}/api/persona/upload`
427
+ : `${base}/external/upload_file`,
428
+ collection: Boolean(uploadConfig),
429
+ ...(uploadConfig ? { collection_org_id: uploadConfig.orgId } : {}),
430
+ collection_note: uploadConfig
431
+ ? `Images will be filed into the media collection of org ${uploadConfig.orgId} and be re-pickable in the editor.`
432
+ : "No credentials (WEBCAKE_JWT) resolved — images will go to the public CDN endpoint. The URLs work and the page renders, but the images will NOT appear in any collection.",
374
433
  urls_to_upload: urlsInfo,
375
434
  action_required: "DRY RUN ONLY — nothing was uploaded and NO hosted URLs exist yet. Do NOT build the page and do NOT fall back to placeholders: re-call upload_images WITHOUT dry_run (uploads by default; batch >20 entries into multiple calls) and WAIT for the returned images map before filling any specials.src / gallery link / background.",
376
435
  });
@@ -463,12 +522,20 @@ export function registerMediaTools(server, allowLocalFiles = true) {
463
522
  return [originalEntry, { ok: false, error: `Not an image — content-type: ${contentType}` }];
464
523
  }
465
524
  const ext = extFromContentType(contentType);
466
- const filename = `upload.${ext}`;
467
- const result = await uploadImageMultipart(base, bytes, filename, contentType);
525
+ const filename = uploadFilename(originalEntry, ext);
526
+ // With creds + an org, file the image into the account's media
527
+ // collection so it is re-pickable in the editor; otherwise fall back
528
+ // to the public CDN endpoint (unchanged zero-config behaviour).
529
+ const result = uploadConfig
530
+ ? await uploadImagePreferCollection(uploadConfig, bytes, filename, contentType, { folderId: in_folder })
531
+ : { ...(await uploadImageMultipart(base, bytes, filename, contentType)), collection: false };
468
532
  if (!result.ok) {
469
533
  return [originalEntry, { ok: false, error: result.error ?? "Upload failed" }];
470
534
  }
471
- return [originalEntry, { ok: true, url: result.url }];
535
+ return [
536
+ originalEntry,
537
+ { ok: true, url: result.url, collection: result.collection, ...(result.asset_id != null ? { asset_id: result.asset_id } : {}) },
538
+ ];
472
539
  }
473
540
  catch (e) {
474
541
  return [originalEntry, { ok: false, error: `Unexpected error: ${e?.message ?? e}` }];
@@ -477,10 +544,14 @@ export function registerMediaTools(server, allowLocalFiles = true) {
477
544
  const images = {};
478
545
  let uploaded = 0;
479
546
  let failed = 0;
547
+ let inCollection = 0;
480
548
  for (const [entry, result] of results) {
481
549
  images[entry] = result;
482
- if (result.ok)
550
+ if (result.ok) {
483
551
  uploaded++;
552
+ if (result.collection)
553
+ inCollection++;
554
+ }
484
555
  else
485
556
  failed++;
486
557
  }
@@ -489,6 +560,11 @@ export function registerMediaTools(server, allowLocalFiles = true) {
489
560
  images,
490
561
  uploaded,
491
562
  failed,
563
+ collection_uploads: inCollection,
564
+ ...(uploadConfig ? { collection_org_id: uploadConfig.orgId } : {}),
565
+ collection_note: inCollection > 0
566
+ ? `${inCollection} image(s) were filed into the media collection (bộ sưu tập) of org ${uploadConfig?.orgId} and are re-pickable in the editor.`
567
+ : "Images went to the public CDN endpoint, NOT the media collection — no credentials were resolved. The URLs work and the page renders; to file them into the collection, set WEBCAKE_JWT and an org (WEBCAKE_ORG_ID, or pass organization_id).",
492
568
  usage: "Put images[<original>].url into EVERY element that used <original> (image specials.src, gallery item.link, section/box background url(...)). Slots whose entry uploaded ok MUST use the hosted URL — never a placeholder. Only for entries marked ok:false, fall back to the image-source chain (search_images → your own web search + re-upload → placeholder LAST)." +
493
569
  (failed > 0 ? ` ${failed} entr${failed === 1 ? "y" : "ies"} failed — handle them via that fallback chain now.` : ""),
494
570
  });
@@ -1221,12 +1221,12 @@ export function registerPersistenceTools(server, domain) {
1221
1221
  });
1222
1222
  });
1223
1223
  // 15) Publish page (go live) -------------------------------------------------
1224
- server.tool("publish_page", "Publishes an EXISTING page LIVE via the editor's publish_html route: builds the rendered app on the Webcake build host (POST <buildBase>/render/build; prod default https://build.webcake.io, override with WEBCAKE_BUILD_BASE env / x-webcake-build-base header), then creates/updates the PagePublishedV2 record — the record ALL public serving reads. With custom_domain the page goes live at that domain (it must already point at Webcake). WITHOUT a domain there is NO permanent public URL: the returned preview link (<previewBase>/preview/<page_id>) only renders for ~10 minutes after the publish, then shows 'Preview page is expired' — tell the user to attach a domain for a lasting URL. If no build host is configured or the build fails, falls back to the LEGACY source-only publish route with a warning (saves a version; nothing goes live; the page stays blank). DEFAULTS to dry_run=true (network-free: does NOT call the build host on dry_run). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
1224
+ server.tool("publish_page", "Publishes an EXISTING page LIVE via the editor's publish_html route: builds the rendered app on the Webcake build host (POST <buildBase>/render/build; prod default https://build.webcake.io, override with WEBCAKE_BUILD_BASE env / x-webcake-build-base header), then creates/updates the PagePublishedV2 record — the record ALL public serving reads. With custom_domain the page goes live at that domain (it must already point at Webcake); OMIT custom_domain to reuse the page's currently-attached domain (mirrors the editor's publish modal — republishing keeps the page live at the same URL), falling back to a find_pages lookup by id. WITHOUT any domain (the page has none, or you pass custom_domain='') there is NO permanent public URL: the returned preview link (<previewBase>/preview/<page_id>) only renders for ~10 minutes after the publish, then shows 'Preview page is expired' — tell the user to attach a domain for a lasting URL. If no build host is configured or the build fails, falls back to the LEGACY source-only publish route with a warning (saves a version; nothing goes live; the page stays blank). DEFAULTS to dry_run=true (network-free: does NOT call the build host on dry_run). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
1225
1225
  page_id: z.string().describe("The page id to publish (must be owned by the account)."),
1226
1226
  custom_domain: z
1227
1227
  .string()
1228
1228
  .optional()
1229
- .describe("Optional custom domain to serve the page at (e.g. 'shop.example.com' — must already point at Webcake). Omit to publish without a domain (served at the preview-host URL)."),
1229
+ .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."),
1230
1230
  custom_path: z.string().optional().describe("Optional path under the custom domain (e.g. 'sale')."),
1231
1231
  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)."),
1232
1232
  }, { title: "Publish Webcake Page", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, custom_domain, custom_path, dry_run }, extra) => {
@@ -1241,11 +1241,37 @@ export function registerPersistenceTools(server, domain) {
1241
1241
  if (!res.ok || res.source == null) {
1242
1242
  return text({ published: false, reason: "page_not_found", status: res.status, error: res.error ?? "No source on this page." });
1243
1243
  }
1244
- const opts = { customDomain: custom_domain, customPath: custom_path };
1244
+ // Resolve the domain to publish to the way the editor's publish modal does:
1245
+ // an explicit arg wins, otherwise reuse the page's CURRENTLY-ATTACHED domain
1246
+ // so a plain publish_page({ page_id }) keeps the page live at the same URL
1247
+ // instead of silently dropping to an ephemeral /preview. The page record from
1248
+ // getPageSource carries it when the backend returns it; if not, fall back to a
1249
+ // find_pages lookup by id (custom_path only comes from the record). Passing
1250
+ // custom_domain:'' explicitly opts OUT (publish domain-less on purpose).
1251
+ let effectiveDomain = custom_domain ?? res.custom_domain ?? undefined;
1252
+ let effectivePath = custom_path ?? res.custom_path ?? undefined;
1253
+ let domainInherited = false;
1254
+ if (custom_domain == null && (effectiveDomain == null || effectiveDomain === "")) {
1255
+ const found = await searchPages(config, { id: page_id, limit: 1 });
1256
+ const row = found.pages?.find((p) => `${p.id}` === `${page_id}`) ?? found.pages?.[0];
1257
+ if (row?.custom_domain) {
1258
+ effectiveDomain = row.custom_domain;
1259
+ domainInherited = true;
1260
+ }
1261
+ }
1262
+ else if (custom_domain == null && effectiveDomain) {
1263
+ domainInherited = true;
1264
+ }
1265
+ // Normalize an empty-string domain to "no domain" so downstream !! checks agree.
1266
+ if (!effectiveDomain)
1267
+ effectiveDomain = undefined;
1268
+ const opts = { customDomain: effectiveDomain, customPath: effectivePath };
1245
1269
  const buildBase = config.buildBase;
1246
1270
  // Only the preview window serves a domain-less publish, and only briefly.
1247
- const previewExpiryNote = custom_domain
1248
- ? undefined
1271
+ const previewExpiryNote = effectiveDomain
1272
+ ? domainInherited
1273
+ ? `Reused the page's attached domain (${effectiveDomain}) — pass custom_domain to override, or custom_domain:'' to publish without one.`
1274
+ : undefined
1249
1275
  : "No custom_domain — the page has NO permanent public URL. The preview link only renders for ~10 minutes after the publish, then shows 'Preview page is expired'. Attach a custom_domain (already pointed at Webcake) for a lasting URL.";
1250
1276
  if (isDry) {
1251
1277
  // dry_run is network-free — do NOT call the build host.
@@ -1253,8 +1279,8 @@ export function registerPersistenceTools(server, domain) {
1253
1279
  dry_run: true,
1254
1280
  page_id,
1255
1281
  name: res.name,
1256
- would_publish_to: custom_domain
1257
- ? `https://${custom_domain}${custom_path ? `/${custom_path}` : ""}`
1282
+ would_publish_to: effectiveDomain
1283
+ ? `https://${effectiveDomain}${effectivePath ? `/${effectivePath}` : ""}`
1258
1284
  : toPreviewUrl(config, `/preview/${page_id}`),
1259
1285
  build_step: buildBase
1260
1286
  ? { 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." }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
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",