webcake-landing-mcp 1.0.75 → 1.0.77
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/auth/oauth-server.js +239 -0
- package/dist/changelog.json +14 -14
- package/dist/domains/landing/guide.js +1 -1
- package/dist/domains/landing/instructions.js +1 -1
- package/dist/domains/landing/validate.js +75 -0
- package/dist/http.js +230 -0
- package/dist/legal.js +107 -0
- package/dist/smoke.js +36 -0
- package/dist/web-guide.js +52 -0
- package/package.json +1 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A THIN OAuth 2.1 Authorization Server, embedded in the MCP server itself.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: to be listed in the Claude Connectors Directory (and ChatGPT
|
|
5
|
+
* App Directory) the remote MCP must be an OAuth 2.1 *protected resource* — each
|
|
6
|
+
* user completes a real consent/login flow and the connector gets a per-user
|
|
7
|
+
* access token. The Webcake backend (landing_page_backend) has no OAuth endpoints,
|
|
8
|
+
* so instead of building a full OAuth server in Elixir we wrap the login that
|
|
9
|
+
* ALREADY exists: the browser "connect" page (builderx_spa `/mcp-connect`) that
|
|
10
|
+
* hands back the user's landing JWT (`ljwt`). See ../auth/login.ts for that flow.
|
|
11
|
+
*
|
|
12
|
+
* The shape we implement (minimal but spec-conformant for the MCP clients):
|
|
13
|
+
* - Dynamic Client Registration (POST /register) — open, public clients
|
|
14
|
+
* - Authorization Code + PKCE S256 (GET /authorize) — code_challenge required
|
|
15
|
+
* - Token endpoint (POST /token) authorization_code + refresh_token
|
|
16
|
+
* - Authorization Server + Protected Resource metadata (the /.well-known docs)
|
|
17
|
+
*
|
|
18
|
+
* Access tokens are OPAQUE random strings mapped to the user's `ljwt` in this
|
|
19
|
+
* store (so they can be revoked and the ljwt never leaves the server). The HTTP
|
|
20
|
+
* layer resolves a Bearer access token to its ljwt and injects it as the normal
|
|
21
|
+
* `x-webcake-jwt` header, so the rest of the server (persistence/config.ts) is
|
|
22
|
+
* UNCHANGED and the legacy `?jwt=` / `x-webcake-jwt` paths keep working untouched.
|
|
23
|
+
*
|
|
24
|
+
* STORE: in-memory + single-process. Fine for one `serve` instance; move the maps
|
|
25
|
+
* to Redis (same interface) before running multiple instances behind a load balancer.
|
|
26
|
+
*/
|
|
27
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
28
|
+
// ---- TTLs (override via env where useful) ---------------------------------
|
|
29
|
+
const TEN_MIN = 10 * 60 * 1000;
|
|
30
|
+
const ACCESS_TTL = Number(process.env.WEBCAKE_OAUTH_ACCESS_TTL_MS) || 60 * 60 * 1000; // 1h
|
|
31
|
+
const REFRESH_TTL = Number(process.env.WEBCAKE_OAUTH_REFRESH_TTL_MS) || 30 * 24 * 60 * 60 * 1000; // 30d
|
|
32
|
+
const CODE_TTL = TEN_MIN;
|
|
33
|
+
const PENDING_TTL = TEN_MIN;
|
|
34
|
+
// ---- In-memory maps -------------------------------------------------------
|
|
35
|
+
const clients = new Map();
|
|
36
|
+
const pending = new Map(); // key: internal state we send to /mcp-connect
|
|
37
|
+
const codes = new Map(); // key: authorization code
|
|
38
|
+
const accessTokens = new Map(); // key: access token
|
|
39
|
+
const refreshTokens = new Map(); // key: refresh token
|
|
40
|
+
function now() {
|
|
41
|
+
return Date.now();
|
|
42
|
+
}
|
|
43
|
+
function token(bytes = 32) {
|
|
44
|
+
return randomBytes(bytes).toString("base64url");
|
|
45
|
+
}
|
|
46
|
+
/** Lazy sweep of anything expired — cheap, called on the hot paths. */
|
|
47
|
+
function sweep() {
|
|
48
|
+
const t = now();
|
|
49
|
+
for (const [k, v] of pending)
|
|
50
|
+
if (v.expiresAt < t)
|
|
51
|
+
pending.delete(k);
|
|
52
|
+
for (const [k, v] of codes)
|
|
53
|
+
if (v.expiresAt < t)
|
|
54
|
+
codes.delete(k);
|
|
55
|
+
for (const [k, v] of accessTokens)
|
|
56
|
+
if (v.expiresAt < t)
|
|
57
|
+
accessTokens.delete(k);
|
|
58
|
+
for (const [k, v] of refreshTokens)
|
|
59
|
+
if (v.expiresAt < t)
|
|
60
|
+
refreshTokens.delete(k);
|
|
61
|
+
}
|
|
62
|
+
// ---- PKCE -----------------------------------------------------------------
|
|
63
|
+
/** base64url( SHA256(verifier) ) — the S256 transform. */
|
|
64
|
+
export function s256(verifier) {
|
|
65
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
66
|
+
}
|
|
67
|
+
export function verifyPkce(verifier, challenge) {
|
|
68
|
+
if (!verifier || !challenge)
|
|
69
|
+
return false;
|
|
70
|
+
// Constant-time-ish compare on equal-length base64url strings.
|
|
71
|
+
const a = s256(verifier);
|
|
72
|
+
if (a.length !== challenge.length)
|
|
73
|
+
return false;
|
|
74
|
+
let diff = 0;
|
|
75
|
+
for (let i = 0; i < a.length; i++)
|
|
76
|
+
diff |= a.charCodeAt(i) ^ challenge.charCodeAt(i);
|
|
77
|
+
return diff === 0;
|
|
78
|
+
}
|
|
79
|
+
export function registerClient(body) {
|
|
80
|
+
const uris = Array.isArray(body?.redirect_uris)
|
|
81
|
+
? body.redirect_uris.filter((u) => typeof u === "string" && /^https?:\/\//i.test(u))
|
|
82
|
+
: [];
|
|
83
|
+
if (uris.length === 0) {
|
|
84
|
+
return { ok: false, error: "invalid_redirect_uri", error_description: "redirect_uris must contain at least one absolute http(s) URI." };
|
|
85
|
+
}
|
|
86
|
+
const client = {
|
|
87
|
+
client_id: token(16),
|
|
88
|
+
client_name: typeof body?.client_name === "string" ? body.client_name : undefined,
|
|
89
|
+
redirect_uris: uris,
|
|
90
|
+
created_at: now(),
|
|
91
|
+
};
|
|
92
|
+
clients.set(client.client_id, client);
|
|
93
|
+
return { ok: true, client };
|
|
94
|
+
}
|
|
95
|
+
export function getClient(clientId) {
|
|
96
|
+
return clientId ? clients.get(clientId) : undefined;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Validate an /authorize request and park it. Returns an `internalState` to send
|
|
100
|
+
* to the login page as its `state`; the callback uses it to find this request.
|
|
101
|
+
* `redirectable: false` means the error must be shown as a page (we can't trust
|
|
102
|
+
* the redirect_uri); `true` means it's safe to bounce the error to the client.
|
|
103
|
+
*/
|
|
104
|
+
export function startAuthorize(p) {
|
|
105
|
+
sweep();
|
|
106
|
+
const client = getClient(p.client_id);
|
|
107
|
+
if (!client) {
|
|
108
|
+
return { ok: false, error: "invalid_client", error_description: "Unknown client_id. Register first via /register.", redirectable: false };
|
|
109
|
+
}
|
|
110
|
+
if (!p.redirect_uri || !client.redirect_uris.includes(p.redirect_uri)) {
|
|
111
|
+
return { ok: false, error: "invalid_request", error_description: "redirect_uri does not match a registered URI.", redirectable: false };
|
|
112
|
+
}
|
|
113
|
+
// From here errors CAN go back to the client's redirect_uri.
|
|
114
|
+
if (p.response_type !== "code") {
|
|
115
|
+
return { ok: false, error: "unsupported_response_type", error_description: "Only response_type=code is supported.", redirectable: true };
|
|
116
|
+
}
|
|
117
|
+
if (!p.code_challenge || (p.code_challenge_method ?? "").toUpperCase() !== "S256") {
|
|
118
|
+
return { ok: false, error: "invalid_request", error_description: "PKCE with code_challenge_method=S256 is required.", redirectable: true };
|
|
119
|
+
}
|
|
120
|
+
const internalState = token(24);
|
|
121
|
+
pending.set(internalState, {
|
|
122
|
+
client_id: client.client_id,
|
|
123
|
+
redirect_uri: p.redirect_uri,
|
|
124
|
+
code_challenge: p.code_challenge,
|
|
125
|
+
state: p.state ?? undefined,
|
|
126
|
+
scope: p.scope ?? undefined,
|
|
127
|
+
expiresAt: now() + PENDING_TTL,
|
|
128
|
+
});
|
|
129
|
+
return { ok: true, internalState };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The login page (/mcp-connect) bounced back with the user's `ljwt` and our
|
|
133
|
+
* `internalState`. Mint a one-time authorization code bound to that ljwt + the
|
|
134
|
+
* parked PKCE challenge, and return where to redirect the user (the client's
|
|
135
|
+
* redirect_uri with ?code=&state=).
|
|
136
|
+
*/
|
|
137
|
+
export function completeAuthorize(internalState, ljwt) {
|
|
138
|
+
sweep();
|
|
139
|
+
if (!internalState || !pending.has(internalState)) {
|
|
140
|
+
return { ok: false, error: "invalid_request", error_description: "Authorization session expired or unknown — restart the connection." };
|
|
141
|
+
}
|
|
142
|
+
const p = pending.get(internalState);
|
|
143
|
+
pending.delete(internalState);
|
|
144
|
+
if (!ljwt) {
|
|
145
|
+
return { ok: false, error: "access_denied", error_description: "No Webcake token returned from login." };
|
|
146
|
+
}
|
|
147
|
+
const code = token(32);
|
|
148
|
+
codes.set(code, {
|
|
149
|
+
client_id: p.client_id,
|
|
150
|
+
redirect_uri: p.redirect_uri,
|
|
151
|
+
code_challenge: p.code_challenge,
|
|
152
|
+
scope: p.scope,
|
|
153
|
+
ljwt,
|
|
154
|
+
expiresAt: now() + CODE_TTL,
|
|
155
|
+
});
|
|
156
|
+
return { ok: true, redirectUri: p.redirect_uri, code, state: p.state };
|
|
157
|
+
}
|
|
158
|
+
function issueTokens(ljwt, client_id, scope) {
|
|
159
|
+
const access = token(32);
|
|
160
|
+
const refresh = token(32);
|
|
161
|
+
accessTokens.set(access, { ljwt, scope, expiresAt: now() + ACCESS_TTL });
|
|
162
|
+
refreshTokens.set(refresh, { ljwt, client_id, scope, expiresAt: now() + REFRESH_TTL });
|
|
163
|
+
return { access_token: access, token_type: "Bearer", expires_in: Math.floor(ACCESS_TTL / 1000), refresh_token: refresh, scope };
|
|
164
|
+
}
|
|
165
|
+
export function exchangeToken(p) {
|
|
166
|
+
sweep();
|
|
167
|
+
if (p.grant_type === "authorization_code") {
|
|
168
|
+
if (!p.code || !codes.has(p.code)) {
|
|
169
|
+
return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired authorization code." };
|
|
170
|
+
}
|
|
171
|
+
const c = codes.get(p.code);
|
|
172
|
+
codes.delete(p.code); // one-time use
|
|
173
|
+
if (c.client_id !== p.client_id) {
|
|
174
|
+
return { ok: false, status: 400, error: "invalid_grant", error_description: "client_id does not match the authorization code." };
|
|
175
|
+
}
|
|
176
|
+
if (c.redirect_uri !== p.redirect_uri) {
|
|
177
|
+
return { ok: false, status: 400, error: "invalid_grant", error_description: "redirect_uri does not match the authorization request." };
|
|
178
|
+
}
|
|
179
|
+
if (!p.code_verifier || !verifyPkce(p.code_verifier, c.code_challenge)) {
|
|
180
|
+
return { ok: false, status: 400, error: "invalid_grant", error_description: "PKCE verification failed." };
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, body: issueTokens(c.ljwt, c.client_id, c.scope) };
|
|
183
|
+
}
|
|
184
|
+
if (p.grant_type === "refresh_token") {
|
|
185
|
+
if (!p.refresh_token || !refreshTokens.has(p.refresh_token)) {
|
|
186
|
+
return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired refresh token." };
|
|
187
|
+
}
|
|
188
|
+
const r = refreshTokens.get(p.refresh_token);
|
|
189
|
+
refreshTokens.delete(p.refresh_token); // rotate
|
|
190
|
+
return { ok: true, body: issueTokens(r.ljwt, r.client_id, r.scope) };
|
|
191
|
+
}
|
|
192
|
+
return { ok: false, status: 400, error: "unsupported_grant_type", error_description: "grant_type must be authorization_code or refresh_token." };
|
|
193
|
+
}
|
|
194
|
+
// ---- Resource-server side: resolve a Bearer access token to its ljwt -------
|
|
195
|
+
/** Returns the user's ljwt for a valid, unexpired access token, else undefined. */
|
|
196
|
+
export function resolveAccessToken(accessToken) {
|
|
197
|
+
if (!accessToken)
|
|
198
|
+
return undefined;
|
|
199
|
+
const a = accessTokens.get(accessToken);
|
|
200
|
+
if (!a)
|
|
201
|
+
return undefined;
|
|
202
|
+
if (a.expiresAt < now()) {
|
|
203
|
+
accessTokens.delete(accessToken);
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
return a.ljwt;
|
|
207
|
+
}
|
|
208
|
+
/** Revoke an access or refresh token (best-effort; for /revoke). */
|
|
209
|
+
export function revokeToken(t) {
|
|
210
|
+
if (!t)
|
|
211
|
+
return;
|
|
212
|
+
accessTokens.delete(t);
|
|
213
|
+
refreshTokens.delete(t);
|
|
214
|
+
}
|
|
215
|
+
// ---- Metadata documents (RFC 8414 / RFC 9728) -----------------------------
|
|
216
|
+
/** /.well-known/oauth-authorization-server */
|
|
217
|
+
export function authServerMetadata(issuer) {
|
|
218
|
+
return {
|
|
219
|
+
issuer,
|
|
220
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
221
|
+
token_endpoint: `${issuer}/token`,
|
|
222
|
+
registration_endpoint: `${issuer}/register`,
|
|
223
|
+
revocation_endpoint: `${issuer}/revoke`,
|
|
224
|
+
response_types_supported: ["code"],
|
|
225
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
226
|
+
code_challenge_methods_supported: ["S256"],
|
|
227
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
228
|
+
scopes_supported: ["landing:read", "landing:write"],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** /.well-known/oauth-protected-resource */
|
|
232
|
+
export function protectedResourceMetadata(resource, issuer) {
|
|
233
|
+
return {
|
|
234
|
+
resource,
|
|
235
|
+
authorization_servers: [issuer],
|
|
236
|
+
scopes_supported: ["landing:read", "landing:write"],
|
|
237
|
+
bearer_methods_supported: ["header"],
|
|
238
|
+
};
|
|
239
|
+
}
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.0.77",
|
|
4
|
+
"d": "15/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "The remote serve transport now implements a spec-conformant OAuth 2.1 Authorization Server (Authorization Code + PKCE S256, Dynamic Client…",
|
|
7
|
+
"vi": "Transport serve từ xa nay triển khai một OAuth 2.1 Authorization Server chuẩn spec (Authorization Code + PKCE S256, Dynamic Client Registration,…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.0.76",
|
|
11
|
+
"d": "15/06/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "validate_page now warns when specials.custom_css sets layout or structural CSS properties (position, top, left, right, bottom, inset, width, height,…",
|
|
14
|
+
"vi": "validate_page nay cảnh báo khi specials.custom_css đặt các thuộc tính CSS layout hoặc cấu trúc (position, top, left, right, bottom, inset, width,…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.0.75",
|
|
4
18
|
"d": "15/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Fixed",
|
|
27
41
|
"en": "When cloning a LadiPage or Webcake-published page via ingest_html / ingest_url, html-box elements' passthrough HTML content now has its…",
|
|
28
42
|
"vi": "Khi clone trang LadiPage hoặc Webcake-published qua ingest_html / ingest_url, nội dung HTML passthrough của các phần tử html-box nay được đổi tên…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.0.71",
|
|
32
|
-
"d": "13/06/2026",
|
|
33
|
-
"type": "Added",
|
|
34
|
-
"en": "ingest_html and ingest_url now automatically convert absolute-canvas builder exports (LadiPage-family / Webcake-published HTML) into a ready-to-save…",
|
|
35
|
-
"vi": "ingest_html và ingest_url nay tự động chuyển đổi các bản export từ builder absolute-canvas (LadiPage-family / Webcake-published HTML) thành source…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.70",
|
|
39
|
-
"d": "13/06/2026",
|
|
40
|
-
"type": "Changed",
|
|
41
|
-
"en": "upload_images default changed from dry_run:true to dry_run:false — the tool now uploads images and returns the hosted URL map on every call without…",
|
|
42
|
-
"vi": "Mặc định của upload_images được đổi từ dry_run:true thành dry_run:false — tool này nay upload ảnh và trả về bản đồ URL trong mọi lần gọi mà không…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -120,7 +120,7 @@ RULES
|
|
|
120
120
|
- Every form input MUST have a unique specials.field_name.
|
|
121
121
|
- events item: { "id", "type", "action", "target", ...action-specific extra fields }. TRIGGER (type): click & hover on any element; success & error on a FORM (success = after a successful submit, error = on validation failure); delay on any element (when it scrolls into view); unset on init. Action vocab per trigger: click→CLICK_ACTIONS, hover→HOVER_ACTIONS, success→SUCCESS_ACTIONS, error→ERROR_ACTIONS, delay→DELAY_ACTIONS (all returned by get_generation_guide). For element-targeting actions (open_popup, close_popup, scroll_to, show_section, hide_section, show_hide_element, change_tab, collapse) target = the target element's id; open_link/download_file target = URL; open_sms/send_email/phone_call target = phone/email; copy target = text (or element id when copyType='elementValue'); set_field_value target = field_name; target may be null (e.g. animation_hover). Each action also reads extra fields (e.g. open_link→targetURL/delayTime, scroll_to→scrollMore, change_tab→moveTo/tabIndex, lightbox→typeLightbox/alt, show_hide_element→onlyMode, open_app→appTarget+provider fields, set_field_value→set_value) — see the action maps for the full list.
|
|
122
122
|
- ANIMATION: each breakpoint's config has config.animation = { "name":"none", "delay":0, "duration":3, "repeat":null }. Animations only run on these 9 element types: group, image-block, text-block, rectangle, button, countdown, line, list-paragraph, notify (renderer contract: landing_page_build/render/build/animate.js). Any other type with a non-"none" name renders stuck/dim in its pre-animation state — keep "none" on all other types. The name must be from the editor's animate.css set; common entrance families: fadeIn* (fadeInUp, fadeInDown, fadeInLeft, fadeInRight…), slideIn* (slideInUp, slideInDown, slideInLeft, slideInRight), zoomIn* (zoomIn, zoomInUp, zoomInDown…), bounceIn* (bounceIn, bounceInUp…), backIn* (backInDown, backInLeft…), flipIn* (flipInX, flipInY), lightSpeedIn* (lightSpeedInLeft, lightSpeedInRight), rotateIn* (rotateIn, rotateInDownLeft…), rollIn, jackInTheBox; attention seekers: bounce, pulse, tada, headShake, wobble, jello, heartBeat, rubberBand, shakeX, shakeY. The full set is enforced by validate_page — use an invalid name and the element renders stuck. NEVER set styles.opacity < 1 for a "subtle" or "muted" look — opacity is permanent and renders the element and all its content faded forever; use rgba() alpha on the color or background property instead.
|
|
123
|
-
- BEYOND ELEMENT CAPABILITY (custom CSS / class / JS — use these so the page is as COMPLETE as possible instead of dropping an effect the built-in specials can't express): the renderer supports real escape hatches — REACH FOR THEM when a reference (esp. a Google Stitch / Tailwind page) has hover/transition effects, gradients, glassmorphism, custom shadows, gradient/clipped text, sticky tricks, or keyframe animations outside the 9-type entrance set. Tools, in order of preference: (1) element specials.custom_css — extra CSS DECLARATIONS injected INTO that element's own rule "#w-<id>{ … }" (declarations only — NO selector, NO :hover, NO @keyframes). Requires specials.customAdvance:true (without it the renderer drops it). Use for: "background:linear-gradient(...)" (gradient button/hero — also reproduces the AST's gradients), "box-shadow:0 20px 40px rgba(0,0,0,.08)", "backdrop-filter:blur(20px)" (glass header — pair with an rgba background), "transition:transform .3s ease", "-webkit-background-clip:text;-webkit-text-fill-color:transparent" (gradient text). (2) element specials.custom_class (comma-separated, also needs customAdvance:true) — adds class names to "#w-<id>" so settings.extra_css can target a group of elements. (3) page settings.extra_css — a FULL stylesheet injected raw into <head>: this is where SELECTORS live — :hover, :focus, @keyframes, media queries. The DOM id of any element is "#w-<its id>". THIS is how you implement the AST's per-section hover_effects: scale → "#w-<id>{transition:transform .3s} #w-<id>:hover{transform:scale(1.05)}"; card lift → "#w-<id>:hover{transform:translateY(-8px)}"; image-zoom in a card → "#w-<cardId>{overflow:hidden} #w-<imgId>{transition:transform .5s} #w-<cardId>:hover #w-<imgId>{transform:scale(1.1)}"; color/underline on hover are also fine as a hover EVENT (change_color / change_underline) — pick whichever, but DON'T silently drop the effect. (4) page settings.extra_script — raw JS injected before </body> for behavior the event vocab can't express. (5) page settings.bhet ('before </head>') and settings.bbet ('before </body>') — raw HTML BLOCKS (not just CSS/JS): bhet for <link> webfonts/stylesheets, <style>, <meta>, verification tags, analytics/pixels (GTM, FB); bbet for chat widgets, deferred <script>, GTM <noscript>, third-party embeds. Use these when you need a real font file, a tag/pixel, or an external widget — extra_css/extra_script are for raw CSS/JS only, bhet/bbet take arbitrary HTML (valid markup only).
|
|
123
|
+
- BEYOND ELEMENT CAPABILITY (custom CSS / class / JS — use these so the page is as COMPLETE as possible instead of dropping an effect the built-in specials can't express): the renderer supports real escape hatches — REACH FOR THEM when a reference (esp. a Google Stitch / Tailwind page) has hover/transition effects, gradients, glassmorphism, custom shadows, gradient/clipped text, sticky tricks, or keyframe animations outside the 9-type entrance set. Tools, in order of preference: (1) element specials.custom_css — extra CSS DECLARATIONS injected INTO that element's own rule "#w-<id>{ … }" (declarations only — NO selector, NO :hover, NO @keyframes). Requires specials.customAdvance:true (without it the renderer drops it). Use for: "background:linear-gradient(...)" (gradient button/hero — also reproduces the AST's gradients), "box-shadow:0 20px 40px rgba(0,0,0,.08)", "backdrop-filter:blur(20px)" (glass header — pair with an rgba background), "transition:transform .3s ease", "-webkit-background-clip:text;-webkit-text-fill-color:transparent" (gradient text). (2) element specials.custom_class (comma-separated, also needs customAdvance:true) — adds class names to "#w-<id>" so settings.extra_css can target a group of elements. (3) page settings.extra_css — a FULL stylesheet injected raw into <head>: this is where SELECTORS live — :hover, :focus, @keyframes, media queries. The DOM id of any element is "#w-<its id>". THIS is how you implement the AST's per-section hover_effects: scale → "#w-<id>{transition:transform .3s} #w-<id>:hover{transform:scale(1.05)}"; card lift → "#w-<id>:hover{transform:translateY(-8px)}"; image-zoom in a card → "#w-<cardId>{overflow:hidden} #w-<imgId>{transition:transform .5s} #w-<cardId>:hover #w-<imgId>{transform:scale(1.1)}"; color/underline on hover are also fine as a hover EVENT (change_color / change_underline) — pick whichever, but DON'T silently drop the effect. (4) page settings.extra_script — raw JS injected before </body> for behavior the event vocab can't express. (5) page settings.bhet ('before </head>') and settings.bbet ('before </body>') — raw HTML BLOCKS (not just CSS/JS): bhet for <link> webfonts/stylesheets, <style>, <meta>, verification tags, analytics/pixels (GTM, FB); bbet for chat widgets, deferred <script>, GTM <noscript>, third-party embeds. Use these when you need a real font file, a tag/pixel, or an external widget — extra_css/extra_script are for raw CSS/JS only, bhet/bbet take arbitrary HTML (valid markup only). SAFETY — custom is RAW and global, so sloppy custom is the #1 way to BREAK the UI; obey these or the page shatters (validate_page warns on each): (a) SCOPE every settings.extra_css rule to a specific element — "#w-<id>" or a specials.custom_class you added. NEVER write a bare-tag (body, div, section, p, img, a, button, ul, li, h1…), universal "*", or Webcake-internal-class (.section-container, .section-wrapper, .pageview, .rectangle-css, .text-block-css, .image-block-css, .button-css, .group-*, .gallery-*, .overlay, .full-width/.full-height…) selector — those restyle the WHOLE page and wreck the layout. (b) custom_css is VISUAL props ONLY (background, box-shadow, border, border-radius, filter, backdrop-filter, transition, transform, -webkit-background-clip) and declarations-only (no selector / :hover / @keyframes) and needs customAdvance:true — NEVER put position / top / left / right / bottom / inset / width / height / display / float / flex / grid in custom_css (they override the element box and break the absolute canvas; set geometry via responsive.<bp>.styles). (c) bhet/bbet must be VALID, fully-CLOSED HTML (an unclosed <script>/<style>/<div> swallows the rest of the page); put CSS in extra_css and JS in extra_script (bhet/bbet only for real HTML like <link>/<meta>/widgets), and any <style> inside them follows rule (a). (d) extra_script: wrap in try/catch, run on DOMContentLoaded, and NEVER remove/restructure "#w-…" elements (the renderer owns them) — only attach behavior. (e) balance your braces. (f) don't use custom to replace a capability a built-in special already has (entrance animation → config.animation; click/hover behavior → events; size/position → styles).
|
|
124
124
|
- ICONS (icon-font glyphs — don't drop them, and DON'T blind-svg-mask them): references (esp. Google Stitch) render icons with an icon-font CLASS, not images or inline SVG — Material Symbols (<span class="material-symbols-outlined">verified</span>) or Font Awesome (<i class="fa-solid fa-check">). ingest gives only the NAME as block.icon "ms:<name>" / "fa:<name>" (e.g. "ms:support_agent"). render it as Webcake's NATIVE icon element — a RECTANGLE with an svg mask: STEP 1 — call get_icon_svg with those refs to get each REAL <svg> (Material Symbols outlined / Font Awesome) via Iconify (NEVER invent an SVG from a name). STEP 2 — make a rectangle and put that svg in BOTH responsive.desktop.config.svgMask AND responsive.mobile.config.svgMask, set styles.background = the icon color, and use a SQUARE box (width === height). Why each rule: the svg is only a MASK (its own fill is IGNORED) — visible pixels come ENTIRELY from styles.background, so WITHOUT a solid background the icon is BLANK (that's the "svg in a rectangle doesn't show" bug); the renderer reads each breakpoint's config separately (no fallback), so the mask must be in BOTH; and it forces preserveAspectRatio='none', so a non-square box stretches the icon. validate_page warns if background / viewBox / both breakpoints are missing. If get_icon_svg can't resolve a ref, fall back to an emoji inline in a sentence or skip that slot — NEVER leave a feature card iconless.
|
|
125
125
|
- Real data the page DISPLAYS must come from the user — never invent it: phone/hotline/Zalo, price (+ original price), address, shop/brand name, links/URLs, email, opening hours, exact stats/social-proof numbers. If a value the page needs is missing, ASK for it (in intake, or pause before generating); use a clearly-labelled placeholder ONLY when the user explicitly declines, and tell them exactly what to fill. Write ALL page copy in the SAME language the user is chatting in (mirror it), with FULL, CORRECT diacritics/accents — for Vietnamese this means proper dấu (e.g. "Trân Trọng Kính Mời", "Ngày 15 Tháng 08 Năm 2025"), NEVER accent-stripped "không dấu" text. Do not romanize, transliterate, or drop accent marks from any language.
|
|
126
126
|
|
|
@@ -39,7 +39,7 @@ MODEL (essentials):
|
|
|
39
39
|
- 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
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
41
|
|
|
42
|
-
- 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; validate_page
|
|
42
|
+
- 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
43
|
- PREVIEW vs PUBLISH: for review share the EDITOR url (the builder renders the raw source). The editor_url SIGNS THE BROWSER IN automatically (it routes through the builder's /transport with the account token), so it works even when the user isn't logged in — but for the same reason it must go to the PAGE OWNER ONLY, never into anything public. create_page AUTO-PUBLISHES on success (builds the rendered app + publish_html), so a fresh page's preview_url renders right away — but the preview host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod) only serves it for ~10 MINUTES after the last publish, then shows "Preview page is expired". The EDIT routes (update_page/add_section/patch_page) store source only — after finishing a round of edits, run publish_page({ page_id, dry_run:false }) to rebuild the rendered app (else the preview shows the STALE pre-edit build). ONLY a custom_domain (publish_page({ page_id, custom_domain, dry_run:false })) gives a permanent public URL; without one the page has just the ephemeral preview link — say so and suggest attaching a domain the user already points at Webcake.
|
|
44
44
|
|
|
45
45
|
Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, get_icon_svg, upload_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
|
|
@@ -315,6 +315,17 @@ export function validatePage(input) {
|
|
|
315
315
|
if (typeof sp.custom_css === "string" && /[{}]|@keyframes|:hover|:focus|::/.test(sp.custom_css)) {
|
|
316
316
|
warnings.push(`${path} (${type}): specials.custom_css is injected as plain declarations inside #w-${node.id}{…} — a selector/:hover/@keyframes/media-query there breaks the rule. Keep declarations only here (e.g. "box-shadow:0 20px 40px rgba(0,0,0,.08);backdrop-filter:blur(20px);"); put hover/keyframes/media rules in settings.extra_css targeting #w-${node.id} (or a specials.custom_class).`);
|
|
317
317
|
}
|
|
318
|
+
// Layout/structural props in custom_css fight the absolute-canvas system
|
|
319
|
+
// (the renderer sets the element's box + display) → the element jumps or the
|
|
320
|
+
// page breaks. Geometry belongs in responsive.<bp>.styles, not custom_css.
|
|
321
|
+
if (typeof sp.custom_css === "string") {
|
|
322
|
+
const badProps = (sp.custom_css.match(/(?:^|[;{]\s*)(position|top|left|right|bottom|inset|width|height|display|float|flex|grid)\s*:/gi) ?? [])
|
|
323
|
+
.map((m) => m.replace(/[;{:\s]/g, ""))
|
|
324
|
+
.filter((p, i, a) => a.indexOf(p) === i);
|
|
325
|
+
if (badProps.length) {
|
|
326
|
+
warnings.push(`${path} (${type}): specials.custom_css sets layout prop(s) ${badProps.join(", ")} — these override the element's box/display and break the absolute-canvas layout. Set size/position via responsive.<bp>.styles (top/left/width/height per breakpoint); keep custom_css to VISUAL props only (background, box-shadow, border, filter, backdrop-filter, transition, transform).`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
318
329
|
}
|
|
319
330
|
}
|
|
320
331
|
// animation contract — checked per breakpoint
|
|
@@ -1075,6 +1086,70 @@ export function validatePage(input) {
|
|
|
1075
1086
|
const ms = p?.responsive?.mobile?.styles ?? {};
|
|
1076
1087
|
checkBounds(p, num(ds.width) ?? rootCanvasD, num(ds.height) ?? DEFAULT_SECTION_HEIGHT, num(ms.width) ?? rootCanvasM, num(ms.height) ?? DEFAULT_SECTION_HEIGHT, `popup[${i}]`);
|
|
1077
1088
|
});
|
|
1089
|
+
// ── custom-code safety: settings.extra_css / extra_script / bhet / bbet ──────
|
|
1090
|
+
// These inject RAW into the page (extra_css in <head>, extra_script before
|
|
1091
|
+
// </body>, bhet at end of <head>, bbet at end of <body>) — unscoped/broken
|
|
1092
|
+
// custom code is the #1 way "custom breaks the UI". Flag the dangerous shapes.
|
|
1093
|
+
{
|
|
1094
|
+
// Webcake's own runtime class names — restyling them globally breaks the layout.
|
|
1095
|
+
const INTERNAL_CLASS_RE = /\.(section-container|section-wrapper|pageview|rectangle-css|text-block-css|image-block-css|button-css|group-[\w-]*|gallery-[\w-]*|popup-[\w-]*|overlay|lazy|full-mask-size|mask-position|full-(?:width|height)|ladi-[\w-]*)\b/;
|
|
1096
|
+
// Bare element selectors that, unscoped, restyle the WHOLE page.
|
|
1097
|
+
const BARE_TAG_SEL_RE = /(?:^|[,{}>+~\s])(html|body|section|article|header|footer|nav|main|aside|div|span|p|a|button|ul|ol|li|img|table|tr|td|input|form|h[1-6])\s*(?:[,{>+~:.[]|$)/i;
|
|
1098
|
+
/** Selectors in a raw stylesheet not scoped to #w-… that would restyle the whole page (broad tags, *, Webcake internals). */
|
|
1099
|
+
const broadCssSelectors = (css) => {
|
|
1100
|
+
const out = new Set();
|
|
1101
|
+
const ruleRe = /([^{}]+)\{[^{}]*\}/g;
|
|
1102
|
+
let m;
|
|
1103
|
+
while ((m = ruleRe.exec(css)) !== null && out.size < 5) {
|
|
1104
|
+
for (const selRaw of m[1].split(",")) {
|
|
1105
|
+
const sel = selRaw.trim();
|
|
1106
|
+
if (!sel || sel.startsWith("@") || sel.includes("#w-"))
|
|
1107
|
+
continue; // scoped/at-rule → safe
|
|
1108
|
+
if (/\*/.test(sel) || BARE_TAG_SEL_RE.test(sel) || INTERNAL_CLASS_RE.test(sel))
|
|
1109
|
+
out.add(sel.slice(0, 60));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return [...out];
|
|
1113
|
+
};
|
|
1114
|
+
const settings = page?.settings;
|
|
1115
|
+
if (settings && typeof settings === "object") {
|
|
1116
|
+
const extraCss = settings.extra_css;
|
|
1117
|
+
if (typeof extraCss === "string" && extraCss.trim()) {
|
|
1118
|
+
const opens = (extraCss.match(/{/g) ?? []).length;
|
|
1119
|
+
const closes = (extraCss.match(/}/g) ?? []).length;
|
|
1120
|
+
if (opens !== closes) {
|
|
1121
|
+
warnings.push(`settings.extra_css has unbalanced braces (${opens} '{' vs ${closes} '}') — a malformed rule leaks into the rest of the page CSS and breaks the layout. Fix the braces.`);
|
|
1122
|
+
}
|
|
1123
|
+
const broad = broadCssSelectors(extraCss);
|
|
1124
|
+
if (broad.length) {
|
|
1125
|
+
warnings.push(`settings.extra_css has UNSCOPED selector(s) that restyle the whole page and break the UI: ${broad.join(" | ")}. Scope EVERY rule to a specific element — #w-<element id> (or a specials.custom_class you added) — never bare tags, '*', or Webcake's own classes (.section-container / .rectangle-css / .text-block-css / .group-* / …).`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
for (const field of ["bhet", "bbet"]) {
|
|
1129
|
+
const v = settings[field];
|
|
1130
|
+
if (typeof v !== "string" || !v.trim())
|
|
1131
|
+
continue;
|
|
1132
|
+
if (!v.includes("<")) {
|
|
1133
|
+
warnings.push(`settings.${field} contains no HTML tags — it is injected as raw HTML (${field === "bhet" ? "end of <head>" : "end of <body>"}), not CSS/JS. Put CSS in settings.extra_css and JS in settings.extra_script, or wrap this in <style>…</style> / <script>…</script>.`);
|
|
1134
|
+
}
|
|
1135
|
+
for (const sb of v.match(/<style[^>]*>([\s\S]*?)<\/style>/gi) ?? []) {
|
|
1136
|
+
const broad = broadCssSelectors(sb.replace(/<\/?style[^>]*>/gi, ""));
|
|
1137
|
+
if (broad.length) {
|
|
1138
|
+
warnings.push(`settings.${field} has a <style> with UNSCOPED selector(s) that break the page UI: ${broad.join(" | ")}. Scope every rule to #w-<id> / a custom_class, or move page-wide CSS into settings.extra_css with scoped selectors.`);
|
|
1139
|
+
break;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
for (const tag of ["script", "style", "div"]) {
|
|
1143
|
+
const o = (v.match(new RegExp(`<${tag}\\b`, "gi")) ?? []).length;
|
|
1144
|
+
const c = (v.match(new RegExp(`</${tag}>`, "gi")) ?? []).length;
|
|
1145
|
+
if (o !== c) {
|
|
1146
|
+
warnings.push(`settings.${field} has an unbalanced <${tag}> tag (${o} open vs ${c} close) — an unclosed tag swallows the rest of the page and breaks rendering. Close every tag.`);
|
|
1147
|
+
break;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1078
1153
|
return {
|
|
1079
1154
|
valid: errors.length === 0,
|
|
1080
1155
|
errors,
|
package/dist/http.js
CHANGED
|
@@ -19,9 +19,29 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
|
19
19
|
import { createServer } from "./server.js";
|
|
20
20
|
import { ICON_SVG, ICON_MIME } from "./branding.js";
|
|
21
21
|
import { guideHtml, ogImageSvg, normalizeLang } from "./web-guide.js";
|
|
22
|
+
import { privacyHtml, termsHtml } from "./legal.js";
|
|
22
23
|
import { searchPexels, resolvePexelsKey } from "./persistence/pexels-client.js";
|
|
24
|
+
import { resolveEnv, ENVIRONMENTS, stripTrailingSlash } from "./persistence/config.js";
|
|
25
|
+
import { buildConnectUrl } from "./auth/login.js";
|
|
26
|
+
import { registerClient, startAuthorize, completeAuthorize, exchangeToken, resolveAccessToken, revokeToken, authServerMetadata, protectedResourceMetadata, } from "./auth/oauth-server.js";
|
|
23
27
|
const MCP_PATH = "/mcp";
|
|
24
28
|
const IMAGES_PATH = "/api/images/search";
|
|
29
|
+
// OAuth 2.1 endpoints (the embedded thin Authorization Server — see auth/oauth-server.ts).
|
|
30
|
+
const WELL_KNOWN_PR = "/.well-known/oauth-protected-resource";
|
|
31
|
+
const WELL_KNOWN_AS = "/.well-known/oauth-authorization-server";
|
|
32
|
+
const OAUTH_REGISTER = "/register";
|
|
33
|
+
const OAUTH_AUTHORIZE = "/authorize";
|
|
34
|
+
const OAUTH_CALLBACK = "/oauth/callback"; // where /mcp-connect bounces the user's ljwt back
|
|
35
|
+
const OAUTH_TOKEN = "/token";
|
|
36
|
+
const OAUTH_REVOKE = "/revoke";
|
|
37
|
+
// OAuth enforcement is ON by default so an OAuth-capable client (Claude/ChatGPT/
|
|
38
|
+
// MCP Inspector) gets the 401 + WWW-Authenticate it needs to START the OAuth flow.
|
|
39
|
+
// ALL credential types still pass straight through (?jwt= / x-webcake-jwt / a raw
|
|
40
|
+
// Bearer JWT / an OAuth access token) — only a request with NO credential at all is
|
|
41
|
+
// challenged. Opt OUT (allow anonymous /mcp, the old Level-A behavior) with
|
|
42
|
+
// WEBCAKE_OAUTH=0 (or false/no/off). The well-known + /register + /authorize +
|
|
43
|
+
// /token routes are always served regardless.
|
|
44
|
+
const OAUTH_ENFORCED = !/^(0|false|no|off)$/i.test(process.env.WEBCAKE_OAUTH ?? "");
|
|
25
45
|
// Social/search crawlers (Facebook, Zalo, Twitter/X, LinkedIn, Slack, Telegram,
|
|
26
46
|
// WhatsApp, Discord, Google, Bing…) fetch the root with `Accept: */*` rather than
|
|
27
47
|
// `text/html`, so they'd otherwise get the JSON health blob and never see the OG
|
|
@@ -88,6 +108,184 @@ function applyQueryAuth(req) {
|
|
|
88
108
|
}
|
|
89
109
|
}
|
|
90
110
|
}
|
|
111
|
+
// The public origin of THIS server, honoring the reverse proxy (Coolify/Traefik/
|
|
112
|
+
// Cloudflare) so the OAuth metadata + redirect URIs are the externally-reachable
|
|
113
|
+
// URL, not localhost. Mirrors the logic used for the OG/landing page.
|
|
114
|
+
function publicBase(req) {
|
|
115
|
+
const fwdHost = req.headers["x-forwarded-host"];
|
|
116
|
+
const host = (Array.isArray(fwdHost) ? fwdHost[0] : fwdHost) || req.headers.host || "localhost";
|
|
117
|
+
const fwdProto = req.headers["x-forwarded-proto"];
|
|
118
|
+
// Honor the reverse proxy's scheme; otherwise default to http for loopback hosts
|
|
119
|
+
// (local testing) and https everywhere else (a public deploy is behind TLS).
|
|
120
|
+
const isLocal = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host);
|
|
121
|
+
const proto = (Array.isArray(fwdProto) ? fwdProto[0] : fwdProto)?.split(",")[0] || (isLocal ? "http" : "https");
|
|
122
|
+
return `${proto}://${host}`;
|
|
123
|
+
}
|
|
124
|
+
// The browser login page that returns the user's landing JWT (`ljwt`) — the SPA's
|
|
125
|
+
// /mcp-connect (see auth/login.ts). Resolved from the same env preset the rest of
|
|
126
|
+
// the server uses: explicit WEBCAKE_APP_BASE wins, else the --env/WEBCAKE_ENV preset,
|
|
127
|
+
// else prod. This is the consent step the OAuth /authorize flow delegates to.
|
|
128
|
+
function connectPageUrl() {
|
|
129
|
+
const preset = resolveEnv(process.env.WEBCAKE_ENV) ?? ENVIRONMENTS.prod;
|
|
130
|
+
const appBase = stripTrailingSlash(process.env.WEBCAKE_APP_BASE || preset.appBase);
|
|
131
|
+
return `${appBase}/mcp-connect`;
|
|
132
|
+
}
|
|
133
|
+
async function readRawBody(req) {
|
|
134
|
+
const chunks = [];
|
|
135
|
+
for await (const c of req)
|
|
136
|
+
chunks.push(c);
|
|
137
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
138
|
+
}
|
|
139
|
+
/** Parse a request body that may be JSON or application/x-www-form-urlencoded. */
|
|
140
|
+
function parseBodyParams(raw, contentType) {
|
|
141
|
+
if (!raw)
|
|
142
|
+
return {};
|
|
143
|
+
if (contentType.includes("application/json")) {
|
|
144
|
+
try {
|
|
145
|
+
const o = JSON.parse(raw);
|
|
146
|
+
return o && typeof o === "object" ? o : {};
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const out = {};
|
|
153
|
+
for (const [k, v] of new URLSearchParams(raw))
|
|
154
|
+
out[k] = v;
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
function oauthError(res, status, error, description) {
|
|
158
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
159
|
+
res.end(JSON.stringify({ error, error_description: description }));
|
|
160
|
+
}
|
|
161
|
+
function htmlError(res, status, message) {
|
|
162
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
163
|
+
res.end(`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;padding:40px;max-width:520px;margin:auto"><h2>Webcake connector</h2><p>${message}</p></body>`);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Handle every OAuth 2.1 endpoint. Returns true when the request was an OAuth
|
|
167
|
+
* route (and a response was sent), false to let the caller continue routing.
|
|
168
|
+
*/
|
|
169
|
+
async function handleOAuth(req, res, path) {
|
|
170
|
+
const issuer = publicBase(req);
|
|
171
|
+
// ---- Metadata (RFC 8414 / RFC 9728) ----
|
|
172
|
+
if (req.method === "GET" && path === WELL_KNOWN_PR) {
|
|
173
|
+
res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": "*" });
|
|
174
|
+
res.end(JSON.stringify(protectedResourceMetadata(`${issuer}${MCP_PATH}`, issuer)));
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (req.method === "GET" && path === WELL_KNOWN_AS) {
|
|
178
|
+
res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": "*" });
|
|
179
|
+
res.end(JSON.stringify(authServerMetadata(issuer)));
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
// ---- Dynamic Client Registration ----
|
|
183
|
+
if (path === OAUTH_REGISTER) {
|
|
184
|
+
if (req.method === "OPTIONS") {
|
|
185
|
+
res.writeHead(204, { "access-control-allow-origin": "*", "access-control-allow-headers": "*", "access-control-allow-methods": "POST,OPTIONS" });
|
|
186
|
+
return res.end(), true;
|
|
187
|
+
}
|
|
188
|
+
if (req.method !== "POST")
|
|
189
|
+
return oauthError(res, 405, "invalid_request", "Use POST."), true;
|
|
190
|
+
const raw = await readRawBody(req);
|
|
191
|
+
const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
|
|
192
|
+
const result = registerClient(body);
|
|
193
|
+
if (!result.ok)
|
|
194
|
+
return oauthError(res, 400, result.error, result.error_description), true;
|
|
195
|
+
res.writeHead(201, { "content-type": "application/json", "access-control-allow-origin": "*", "cache-control": "no-store" });
|
|
196
|
+
res.end(JSON.stringify({
|
|
197
|
+
client_id: result.client.client_id,
|
|
198
|
+
client_id_issued_at: Math.floor(result.client.created_at / 1000),
|
|
199
|
+
redirect_uris: result.client.redirect_uris,
|
|
200
|
+
token_endpoint_auth_method: "none",
|
|
201
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
202
|
+
response_types: ["code"],
|
|
203
|
+
}));
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
// ---- Authorize: validate + delegate to the SPA login, parking the request ----
|
|
207
|
+
if (req.method === "GET" && path === OAUTH_AUTHORIZE) {
|
|
208
|
+
const sp = new URL(req.url ?? "/", "http://x").searchParams;
|
|
209
|
+
const result = startAuthorize({
|
|
210
|
+
client_id: sp.get("client_id"),
|
|
211
|
+
redirect_uri: sp.get("redirect_uri"),
|
|
212
|
+
response_type: sp.get("response_type"),
|
|
213
|
+
code_challenge: sp.get("code_challenge"),
|
|
214
|
+
code_challenge_method: sp.get("code_challenge_method"),
|
|
215
|
+
state: sp.get("state"),
|
|
216
|
+
scope: sp.get("scope"),
|
|
217
|
+
});
|
|
218
|
+
if (!result.ok) {
|
|
219
|
+
// Safe to bounce the error to the client only when redirect_uri is trusted.
|
|
220
|
+
if (result.redirectable) {
|
|
221
|
+
const r = new URL(sp.get("redirect_uri"));
|
|
222
|
+
r.searchParams.set("error", result.error);
|
|
223
|
+
r.searchParams.set("error_description", result.error_description);
|
|
224
|
+
const st = sp.get("state");
|
|
225
|
+
if (st)
|
|
226
|
+
r.searchParams.set("state", st);
|
|
227
|
+
res.writeHead(302, { location: r.toString() });
|
|
228
|
+
return res.end(), true;
|
|
229
|
+
}
|
|
230
|
+
return htmlError(res, 400, result.error_description), true;
|
|
231
|
+
}
|
|
232
|
+
// Send the user to the SPA login; it returns here with ?token=<ljwt>&state=<internalState>.
|
|
233
|
+
const callback = `${issuer}${OAUTH_CALLBACK}`;
|
|
234
|
+
const loginUrl = buildConnectUrl(connectPageUrl(), callback, result.internalState);
|
|
235
|
+
res.writeHead(302, { location: loginUrl });
|
|
236
|
+
return res.end(), true;
|
|
237
|
+
}
|
|
238
|
+
// ---- Login callback: the SPA handed back the user's ljwt → mint a code ----
|
|
239
|
+
if (req.method === "GET" && path === OAUTH_CALLBACK) {
|
|
240
|
+
const sp = new URL(req.url ?? "/", "http://x").searchParams;
|
|
241
|
+
const done = completeAuthorize(sp.get("state"), sp.get("token"));
|
|
242
|
+
if (!done.ok)
|
|
243
|
+
return htmlError(res, 400, done.error_description), true;
|
|
244
|
+
const r = new URL(done.redirectUri);
|
|
245
|
+
r.searchParams.set("code", done.code);
|
|
246
|
+
if (done.state)
|
|
247
|
+
r.searchParams.set("state", done.state);
|
|
248
|
+
res.writeHead(302, { location: r.toString() });
|
|
249
|
+
return res.end(), true;
|
|
250
|
+
}
|
|
251
|
+
// ---- Token ----
|
|
252
|
+
if (path === OAUTH_TOKEN) {
|
|
253
|
+
if (req.method === "OPTIONS") {
|
|
254
|
+
res.writeHead(204, { "access-control-allow-origin": "*", "access-control-allow-headers": "*", "access-control-allow-methods": "POST,OPTIONS" });
|
|
255
|
+
return res.end(), true;
|
|
256
|
+
}
|
|
257
|
+
if (req.method !== "POST")
|
|
258
|
+
return oauthError(res, 405, "invalid_request", "Use POST."), true;
|
|
259
|
+
const raw = await readRawBody(req);
|
|
260
|
+
const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
|
|
261
|
+
const result = exchangeToken(body);
|
|
262
|
+
if (!result.ok)
|
|
263
|
+
return oauthError(res, result.status, result.error, result.error_description), true;
|
|
264
|
+
res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": "*", "cache-control": "no-store" });
|
|
265
|
+
res.end(JSON.stringify(result.body));
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
// ---- Revoke (RFC 7009, best-effort) ----
|
|
269
|
+
if (path === OAUTH_REVOKE) {
|
|
270
|
+
if (req.method !== "POST")
|
|
271
|
+
return oauthError(res, 405, "invalid_request", "Use POST."), true;
|
|
272
|
+
const raw = await readRawBody(req);
|
|
273
|
+
const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
|
|
274
|
+
revokeToken(body.token);
|
|
275
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
276
|
+
res.end("{}");
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
/** Extract the Bearer token from the Authorization header, if any. */
|
|
282
|
+
function bearerFrom(req) {
|
|
283
|
+
const auth = req.headers["authorization"];
|
|
284
|
+
const v = Array.isArray(auth) ? auth[0] : auth;
|
|
285
|
+
if (!v || !/^Bearer\s+/i.test(v))
|
|
286
|
+
return undefined;
|
|
287
|
+
return v.replace(/^Bearer\s+/i, "").trim() || undefined;
|
|
288
|
+
}
|
|
91
289
|
/**
|
|
92
290
|
* Shared image proxy: GET /api/images/search?query=…&per_page=…&orientation=…
|
|
93
291
|
* Holds the server's own PEXELS_API_KEY (from env/.env) and returns the normalized
|
|
@@ -152,6 +350,15 @@ export async function startHttpServer(port) {
|
|
|
152
350
|
res.writeHead(200, { "content-type": ICON_MIME, "cache-control": "public, max-age=86400" });
|
|
153
351
|
return res.end(ogImageSvg());
|
|
154
352
|
}
|
|
353
|
+
// Public legal pages — required URLs for the Claude/ChatGPT directory submission.
|
|
354
|
+
if (req.method === "GET" && (path === "/privacy" || path === "/privacy-policy")) {
|
|
355
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" });
|
|
356
|
+
return res.end(privacyHtml());
|
|
357
|
+
}
|
|
358
|
+
if (req.method === "GET" && (path === "/terms" || path === "/tos")) {
|
|
359
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" });
|
|
360
|
+
return res.end(termsHtml());
|
|
361
|
+
}
|
|
155
362
|
// Lightweight health check for hosting platforms.
|
|
156
363
|
if (req.method === "GET" && (path === "/" || path === "/health")) {
|
|
157
364
|
// A browser/connector probing the root with `Accept: text/html` gets a tiny
|
|
@@ -175,10 +382,33 @@ export async function startHttpServer(port) {
|
|
|
175
382
|
// Shared image proxy (for `npx` clients without their own Pexels key).
|
|
176
383
|
if (path === IMAGES_PATH)
|
|
177
384
|
return handleImageSearch(req, res);
|
|
385
|
+
// OAuth 2.1 endpoints (always served; see handleOAuth). Returns true if handled.
|
|
386
|
+
if (await handleOAuth(req, res, path))
|
|
387
|
+
return;
|
|
178
388
|
if (path !== MCP_PATH)
|
|
179
389
|
return rpcError(res, 404, `Not found. Send MCP requests to ${MCP_PATH}.`);
|
|
180
390
|
// Accept credentials via ?jwt=/?api_base=/... (for clients that can't set headers).
|
|
181
391
|
applyQueryAuth(req);
|
|
392
|
+
// OAuth access token → resolve to the user's landing JWT and inject it as the
|
|
393
|
+
// normal x-webcake-jwt header, so persistence/config.ts is unchanged. A legacy
|
|
394
|
+
// raw JWT sent via x-webcake-jwt / ?jwt= still wins and passes straight through.
|
|
395
|
+
const bearer = bearerFrom(req);
|
|
396
|
+
const oauthLjwt = resolveAccessToken(bearer);
|
|
397
|
+
if (oauthLjwt && req.headers["x-webcake-jwt"] == null) {
|
|
398
|
+
req.headers["x-webcake-jwt"] = oauthLjwt;
|
|
399
|
+
req.rawHeaders.push("x-webcake-jwt", oauthLjwt);
|
|
400
|
+
}
|
|
401
|
+
// Enforcement (WEBCAKE_OAUTH=1): a request with NO recognized credential gets a
|
|
402
|
+
// 401 + WWW-Authenticate so Claude/ChatGPT kick off the OAuth flow. Legacy creds
|
|
403
|
+
// (x-webcake-jwt header or ?jwt= → mapped above) still pass; in enforced mode a
|
|
404
|
+
// raw JWT must use those, since Bearer is reserved for OAuth access tokens.
|
|
405
|
+
if (OAUTH_ENFORCED && !oauthLjwt && req.headers["x-webcake-jwt"] == null) {
|
|
406
|
+
res.writeHead(401, {
|
|
407
|
+
"www-authenticate": `Bearer resource_metadata="${publicBase(req)}${WELL_KNOWN_PR}"`,
|
|
408
|
+
"content-type": "application/json",
|
|
409
|
+
});
|
|
410
|
+
return res.end(JSON.stringify({ error: "invalid_token", error_description: "Authentication required — connect via OAuth." }));
|
|
411
|
+
}
|
|
182
412
|
const sidHeader = req.headers["mcp-session-id"];
|
|
183
413
|
const sessionId = Array.isArray(sidHeader) ? sidHeader[0] : sidHeader;
|
|
184
414
|
try {
|
package/dist/legal.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy Policy + Terms of Service pages for the Webcake Landing MCP connector.
|
|
3
|
+
*
|
|
4
|
+
* Both the Claude Connectors Directory and the ChatGPT App Directory require a
|
|
5
|
+
* PUBLIC privacy-policy URL (and terms are strongly recommended). We host them on
|
|
6
|
+
* the connector's own origin (/privacy and /terms) so the submission can point at
|
|
7
|
+
* a stable URL we control. Plain self-contained HTML — no deps, served by http.ts.
|
|
8
|
+
*
|
|
9
|
+
* Keep the facts here in sync with what the server actually does (see http.ts,
|
|
10
|
+
* auth/oauth-server.ts, persistence/*). Reviewers read these.
|
|
11
|
+
*/
|
|
12
|
+
const CONTACT_EMAIL = process.env.WEBCAKE_SUPPORT_EMAIL || "vuluu040320@gmail.com";
|
|
13
|
+
const LAST_UPDATED = "2026-06-15";
|
|
14
|
+
function page(title, bodyHtml) {
|
|
15
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
16
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${title} — Webcake Landing MCP</title>
|
|
17
|
+
<style>
|
|
18
|
+
:root{color-scheme:light dark}
|
|
19
|
+
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;line-height:1.65;color:#1e293b;background:#f8fafc}
|
|
20
|
+
@media(prefers-color-scheme:dark){body{color:#e2e8f0;background:#0f172a}}
|
|
21
|
+
main{max-width:760px;margin:0 auto;padding:48px 24px 80px}
|
|
22
|
+
h1{font-size:1.9rem;margin:0 0 4px}
|
|
23
|
+
h2{font-size:1.2rem;margin:32px 0 8px}
|
|
24
|
+
.meta{color:#64748b;font-size:.9rem;margin-bottom:28px}
|
|
25
|
+
a{color:#108B67}
|
|
26
|
+
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:rgba(127,127,127,.15);padding:1px 5px;border-radius:4px}
|
|
27
|
+
ul{padding-left:22px}
|
|
28
|
+
footer{margin-top:48px;padding-top:20px;border-top:1px solid rgba(127,127,127,.25);color:#64748b;font-size:.85rem}
|
|
29
|
+
</style></head>
|
|
30
|
+
<body><main>${bodyHtml}
|
|
31
|
+
<footer>Webcake Landing MCP · <a href="https://mcp.toolvn.io.vn">mcp.toolvn.io.vn</a> · <a href="https://github.com/vuluu2k/webcake-landing-mcp">source</a> · Contact: <a href="mailto:${CONTACT_EMAIL}">${CONTACT_EMAIL}</a></footer>
|
|
32
|
+
</main></body></html>`;
|
|
33
|
+
}
|
|
34
|
+
export function privacyHtml() {
|
|
35
|
+
return page("Privacy Policy", `<h1>Privacy Policy</h1>
|
|
36
|
+
<div class="meta">Last updated: ${LAST_UPDATED}</div>
|
|
37
|
+
<p>Webcake Landing MCP ("the connector") is a Model Context Protocol server that lets an AI assistant
|
|
38
|
+
build and edit landing pages in your <a href="https://webcake.io">Webcake</a> account. This policy explains
|
|
39
|
+
what data the connector handles and how.</p>
|
|
40
|
+
|
|
41
|
+
<h2>What we access</h2>
|
|
42
|
+
<ul>
|
|
43
|
+
<li><strong>Your Webcake identity & access token.</strong> When you connect, you log in to Webcake and the
|
|
44
|
+
connector receives a per-user access token mapped to your Webcake landing-page credential. It is used solely
|
|
45
|
+
to call the Webcake backend on your behalf (create, read, update, publish pages, list your organizations).</li>
|
|
46
|
+
<li><strong>Page content you ask the assistant to build or edit.</strong> The page-source JSON (text, images,
|
|
47
|
+
layout) flows through the connector to your Webcake account.</li>
|
|
48
|
+
<li><strong>Images.</strong> External image URLs in a page are re-hosted to the Webcake CDN on save. Optional
|
|
49
|
+
stock-photo search is served via the Pexels API.</li>
|
|
50
|
+
</ul>
|
|
51
|
+
|
|
52
|
+
<h2>What we store</h2>
|
|
53
|
+
<ul>
|
|
54
|
+
<li><strong>OAuth tokens are kept in memory only</strong>, for the lifetime of the running server, and expire
|
|
55
|
+
automatically (access tokens ~1 hour, refresh tokens ~30 days). They are never written to disk by the
|
|
56
|
+
connector and are removed on logout/revoke or when they expire.</li>
|
|
57
|
+
<li>The connector does <strong>not</strong> run an analytics database, does not sell data, and does not share
|
|
58
|
+
your data with third parties beyond the services required to perform your request (below).</li>
|
|
59
|
+
</ul>
|
|
60
|
+
|
|
61
|
+
<h2>Third-party services</h2>
|
|
62
|
+
<ul>
|
|
63
|
+
<li><strong>Webcake</strong> (api.webcake.io) — stores and serves your pages; governed by Webcake's own terms.</li>
|
|
64
|
+
<li><strong>Pexels</strong> (pexels.com) — stock-photo search, only when you request images.</li>
|
|
65
|
+
</ul>
|
|
66
|
+
|
|
67
|
+
<h2>Data retention & deletion</h2>
|
|
68
|
+
<p>Tokens expire automatically as described above; you can revoke access at any time by disconnecting the
|
|
69
|
+
connector in Claude/ChatGPT settings, or by logging out of Webcake. Pages you create live in your Webcake
|
|
70
|
+
account and are managed there. To request deletion of anything else, contact us below.</p>
|
|
71
|
+
|
|
72
|
+
<h2>Security</h2>
|
|
73
|
+
<p>All traffic uses HTTPS. Authentication follows OAuth 2.1 with PKCE; the connector validates a short-lived
|
|
74
|
+
access token per request and never exposes your raw Webcake token to the AI assistant.</p>
|
|
75
|
+
|
|
76
|
+
<h2>Contact</h2>
|
|
77
|
+
<p>Questions or requests: <a href="mailto:${CONTACT_EMAIL}">${CONTACT_EMAIL}</a>.</p>`);
|
|
78
|
+
}
|
|
79
|
+
export function termsHtml() {
|
|
80
|
+
return page("Terms of Service", `<h1>Terms of Service</h1>
|
|
81
|
+
<div class="meta">Last updated: ${LAST_UPDATED}</div>
|
|
82
|
+
<p>By connecting to and using the Webcake Landing MCP connector ("the service") you agree to these terms.</p>
|
|
83
|
+
|
|
84
|
+
<h2>What the service does</h2>
|
|
85
|
+
<p>The service exposes tools that let an AI assistant generate, validate, and persist Webcake landing pages to
|
|
86
|
+
your own Webcake account. It acts on your behalf using credentials you authorize via Webcake login.</p>
|
|
87
|
+
|
|
88
|
+
<h2>Your responsibilities</h2>
|
|
89
|
+
<ul>
|
|
90
|
+
<li>You must have a valid Webcake account and the right to create/modify content in the organizations you target.</li>
|
|
91
|
+
<li>You are responsible for the content you generate and publish, and for complying with Webcake's terms and
|
|
92
|
+
applicable law.</li>
|
|
93
|
+
<li>Do not use the service to create unlawful, infringing, or harmful content.</li>
|
|
94
|
+
</ul>
|
|
95
|
+
|
|
96
|
+
<h2>Availability & changes</h2>
|
|
97
|
+
<p>The service is provided "as is" without warranty. We may update, suspend, or discontinue it, and may change
|
|
98
|
+
these terms; continued use after a change means you accept it.</p>
|
|
99
|
+
|
|
100
|
+
<h2>Limitation of liability</h2>
|
|
101
|
+
<p>To the extent permitted by law, the operators of the connector are not liable for indirect or consequential
|
|
102
|
+
damages arising from use of the service. The service depends on third-party platforms (Webcake, the AI
|
|
103
|
+
assistant) whose own terms also apply.</p>
|
|
104
|
+
|
|
105
|
+
<h2>Contact</h2>
|
|
106
|
+
<p><a href="mailto:${CONTACT_EMAIL}">${CONTACT_EMAIL}</a></p>`);
|
|
107
|
+
}
|
package/dist/smoke.js
CHANGED
|
@@ -224,6 +224,42 @@ console.log("== validate: custom CSS/class/JS escape hatches (beyond-element cap
|
|
|
224
224
|
check("escape hatch: warns selector/:hover inside custom_css", selR.warnings.some((w) => /declarations inside/.test(w)), selR.warnings);
|
|
225
225
|
check("escape hatch: declarations-only misuse does NOT block (warning, not error)", selR.valid, selR.errors);
|
|
226
226
|
}
|
|
227
|
+
console.log("== validate: custom-code SAFETY (broad/broken custom breaks the UI) ==");
|
|
228
|
+
{
|
|
229
|
+
const cloneG = () => JSON.parse(JSON.stringify(good));
|
|
230
|
+
const W = (r, re) => r.warnings.filter((w) => re.test(w));
|
|
231
|
+
// settings.extra_css with bare-tag + Webcake-internal selectors → unscoped warning; scoped #w- rule does NOT trip it.
|
|
232
|
+
const broad = cloneG();
|
|
233
|
+
broad.settings.extra_css = "body{margin:0} .rectangle-css{opacity:.5} #w-btn1:hover{transform:scale(1.02)}";
|
|
234
|
+
const broadR = validatePage(broad);
|
|
235
|
+
check("custom-safety: unscoped extra_css selectors (body/.rectangle-css) flagged", W(broadR, /UNSCOPED selector/).length > 0, broadR.warnings);
|
|
236
|
+
check("custom-safety: a #w- scoped rule is NOT flagged", !/#w-btn1/.test(W(broadR, /UNSCOPED selector/)[0] ?? ""), W(broadR, /UNSCOPED/));
|
|
237
|
+
// unbalanced braces in extra_css.
|
|
238
|
+
const braces = cloneG();
|
|
239
|
+
braces.settings.extra_css = "#w-btn1{color:red";
|
|
240
|
+
check("custom-safety: unbalanced extra_css braces flagged", W(validatePage(braces), /unbalanced braces/).length > 0);
|
|
241
|
+
// bhet holding raw CSS (no tags) → wrong-field warning.
|
|
242
|
+
const bhetCss = cloneG();
|
|
243
|
+
bhetCss.settings.bhet = "body{margin:0}";
|
|
244
|
+
check("custom-safety: bhet with no HTML tags flagged (belongs in extra_css)", W(validatePage(bhetCss), /no HTML tags/).length > 0);
|
|
245
|
+
// bbet with an unclosed <script> → swallow warning.
|
|
246
|
+
const bbetBad = cloneG();
|
|
247
|
+
bbetBad.settings.bbet = "<script>init()";
|
|
248
|
+
check("custom-safety: bbet unclosed <script> flagged", W(validatePage(bbetBad), /unbalanced <script>/).length > 0);
|
|
249
|
+
// element custom_css with layout props → break-layout warning (visual props alone do NOT trip it).
|
|
250
|
+
const layoutCss = cloneG();
|
|
251
|
+
layoutCss.page[0].children[0].specials = { text: "X", customAdvance: true, custom_css: "width:100%;display:flex;box-shadow:0 2px 8px rgba(0,0,0,.1);" };
|
|
252
|
+
check("custom-safety: custom_css layout props (width/display) flagged", W(validatePage(layoutCss), /layout prop/).length > 0, validatePage(layoutCss).warnings);
|
|
253
|
+
const visualCss = cloneG();
|
|
254
|
+
visualCss.page[0].children[0].specials = { text: "X", customAdvance: true, custom_css: "box-shadow:0 2px 8px rgba(0,0,0,.1);backdrop-filter:blur(8px);" };
|
|
255
|
+
check("custom-safety: visual-only custom_css is NOT flagged", W(validatePage(visualCss), /layout prop/).length === 0, validatePage(visualCss).warnings);
|
|
256
|
+
// a correct, fully-scoped custom setup → none of these warnings.
|
|
257
|
+
const clean = cloneG();
|
|
258
|
+
clean.settings.extra_css = "#w-btn1{transition:transform .3s}#w-btn1:hover{transform:translateY(-2px)}";
|
|
259
|
+
clean.settings.bhet = "<link href='https://fonts.googleapis.com/css2?family=Inter' rel='stylesheet'>";
|
|
260
|
+
clean.page[0].children[0].specials = { text: "X", customAdvance: true, custom_css: "box-shadow:0 8px 24px rgba(0,0,0,.08);" };
|
|
261
|
+
check("custom-safety: correctly-scoped custom triggers no safety warning", W(validatePage(clean), /UNSCOPED|unbalanced|no HTML tags|layout prop/).length === 0, validatePage(clean).warnings);
|
|
262
|
+
}
|
|
227
263
|
console.log("== validate: icon rendering (svg-mask needs background; font-class route is clean) ==");
|
|
228
264
|
{
|
|
229
265
|
const cloneG = () => JSON.parse(JSON.stringify(good));
|
package/dist/web-guide.js
CHANGED
|
@@ -68,6 +68,9 @@ const ICONS = {
|
|
|
68
68
|
window: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 9h20"/><path d="M6 6.5h.01"/><path d="M9 6.5h.01"/>',
|
|
69
69
|
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
|
70
70
|
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
|
|
71
|
+
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
|
72
|
+
image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
|
73
|
+
figma: '<path d="M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z"/><path d="M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z"/><path d="M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z"/><path d="M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z"/><path d="M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z"/>',
|
|
71
74
|
};
|
|
72
75
|
function icon(name) {
|
|
73
76
|
return `<svg class="i" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] ?? ""}</svg>`;
|
|
@@ -205,6 +208,26 @@ const T = {
|
|
|
205
208
|
e: '"Đồng hồ đếm ngược cỡ lớn, lưới sản phẩm giảm giá, nút Mua luôn nổi."',
|
|
206
209
|
},
|
|
207
210
|
],
|
|
211
|
+
cloneH2: "Thấy trang nào đẹp? Copy về trong 1 nốt nhạc 🔥",
|
|
212
|
+
cloneSub: "Đừng ngồi dựng lại từ đầu cho mệt. Một trang web bạn mê, một bản Figma, hay vài màn hình vẽ bằng Google Stitch — quăng cho AI là có ngay landing page Webcake của riêng bạn. Nhanh đến mức bạn sẽ muốn khoe.",
|
|
213
|
+
clone: [
|
|
214
|
+
{
|
|
215
|
+
icon: "link",
|
|
216
|
+
t: "Copy nguyên một trang web",
|
|
217
|
+
d: "Dán cái link, hết. AI bê nguyên bố cục, màu mè, chữ nghĩa, hình ảnh về Webcake — y như bản gốc, mà giờ là của bạn để sửa thả ga.",
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
icon: "image",
|
|
221
|
+
t: "Google Stitch → trang thật",
|
|
222
|
+
d: "Vẽ ý tưởng trong Google Stitch xong là xong việc của bạn. AI biến mấy màn hình đó thành trang Webcake chạy thật, bấm sửa được luôn.",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
icon: "figma",
|
|
226
|
+
t: "Figma lên sóng tức thì",
|
|
227
|
+
d: "File Figma đẹp long lanh để đó làm gì? AI đọc thiết kế, dựng lại đúng bố cục - màu - chữ trên Webcake. Khỏi kéo thả lại từng li từng tí.",
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
cloneCap: "Ảnh trong mẫu được AI tự tải về, lưu thẳng trên Webcake — trang của bạn không sợ chết link, không phụ thuộc vào ai. Xem trước, chỉnh vài câu chữ, bấm đăng. Xong một trang trước khi ly cà phê kịp nguội. ☕",
|
|
208
231
|
connectH2: "Kết nối — chọn 1 trong 2 cách",
|
|
209
232
|
m1Tag: "Cách ① · Cài trên máy của bạn",
|
|
210
233
|
m1Sub: "Phù hợp khi bạn tự dùng và muốn chủ động. Cần có sẵn Node.js (một phần mềm miễn phí giúp chạy lệnh).",
|
|
@@ -316,6 +339,26 @@ const T = {
|
|
|
316
339
|
e: '"A big countdown, a grid of discounted products, a Buy button that follows you."',
|
|
317
340
|
},
|
|
318
341
|
],
|
|
342
|
+
cloneH2: "See a page you love? Steal it in seconds 🔥",
|
|
343
|
+
cloneSub: "Stop building from scratch. A website you're obsessed with, a Figma file, a few screens from Google Stitch — throw it at the AI and walk away with a Webcake landing page that's yours. So fast you'll want to brag about it.",
|
|
344
|
+
clone: [
|
|
345
|
+
{
|
|
346
|
+
icon: "link",
|
|
347
|
+
t: "Clone a whole website",
|
|
348
|
+
d: "Drop the link. Done. The AI lifts the layout, colors, copy and images straight onto Webcake — same vibe as the original, now yours to remix.",
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
icon: "image",
|
|
352
|
+
t: "Google Stitch → live page",
|
|
353
|
+
d: "Sketch the idea in Google Stitch and your job's over. The AI turns those screens into a real, editable Webcake page you can ship.",
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
icon: "figma",
|
|
357
|
+
t: "Figma, live in a snap",
|
|
358
|
+
d: "That gorgeous Figma file shouldn't just sit there. The AI reads it and rebuilds the layout, colors and text on Webcake — zero re-dragging.",
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
cloneCap: "The AI grabs every image and stores it on Webcake itself — no dead links, no depending on anyone else. Preview it, tweak a line or two, hit publish. A page done before your coffee gets cold. ☕",
|
|
319
362
|
connectH2: "Connect — pick one of two ways",
|
|
320
363
|
m1Tag: "Way ① · Install on your computer",
|
|
321
364
|
m1Sub: "Best when it's just for you and you want full control. Needs Node.js (a free program that runs commands).",
|
|
@@ -754,6 +797,15 @@ export function guideHtml(origin, lang = "vi") {
|
|
|
754
797
|
.join("\n ")}
|
|
755
798
|
</ul>
|
|
756
799
|
|
|
800
|
+
<h2 class="reveal">${t.cloneH2}</h2>
|
|
801
|
+
<p class="flow-cap reveal" style="margin-bottom:16px">${t.cloneSub}</p>
|
|
802
|
+
<div class="grid">
|
|
803
|
+
${t.clone
|
|
804
|
+
.map((c) => `<div class="glass card reveal">${tile(c.icon)}<h3>${c.t}</h3><p>${c.d}</p></div>`)
|
|
805
|
+
.join("\n ")}
|
|
806
|
+
</div>
|
|
807
|
+
<p class="flow-cap reveal">${t.cloneCap}</p>
|
|
808
|
+
|
|
757
809
|
<h2 id="connect" class="reveal">${t.connectH2}</h2>
|
|
758
810
|
|
|
759
811
|
<div class="glass card method reveal">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-landing-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.77",
|
|
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",
|