webcake-landing-mcp 1.0.76 → 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 +7 -7
- package/dist/http.js +230 -0
- package/dist/legal.js +107 -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,11 @@
|
|
|
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
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"v": "1.0.76",
|
|
4
11
|
"d": "15/06/2026",
|
|
@@ -33,12 +40,5 @@
|
|
|
33
40
|
"type": "Fixed",
|
|
34
41
|
"en": "When cloning a LadiPage or Webcake-published page via ingest_html / ingest_url, html-box elements' passthrough HTML content now has its…",
|
|
35
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…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.71",
|
|
39
|
-
"d": "13/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "ingest_html and ingest_url now automatically convert absolute-canvas builder exports (LadiPage-family / Webcake-published HTML) into a ready-to-save…",
|
|
42
|
-
"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…"
|
|
43
43
|
}
|
|
44
44
|
]
|
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/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",
|