webcake-landing-mcp 1.0.77 → 1.0.79

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.
@@ -15,49 +15,215 @@
15
15
  * - Token endpoint (POST /token) authorization_code + refresh_token
16
16
  * - Authorization Server + Protected Resource metadata (the /.well-known docs)
17
17
  *
18
- * Access tokens are OPAQUE random strings mapped to the user's `ljwt` in this
18
+ * Access tokens are OPAQUE random strings mapped to the user's `ljwt` in the
19
19
  * store (so they can be revoked and the ljwt never leaves the server). The HTTP
20
20
  * layer resolves a Bearer access token to its ljwt and injects it as the normal
21
21
  * `x-webcake-jwt` header, so the rest of the server (persistence/config.ts) is
22
22
  * UNCHANGED and the legacy `?jwt=` / `x-webcake-jwt` paths keep working untouched.
23
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.
24
+ * STORE: Postgres when DATABASE_URL is set (tokens survive a `serve` restart and
25
+ * are shared across instances behind a load balancer), else in-memory maps
26
+ * (single-instance `serve`, stdio/`npx`, offline tests). Both implement the same
27
+ * async `OAuthStore` interface; the rest of this module is backend-agnostic. All
28
+ * exported state functions are async — callers (src/http.ts) `await` them.
26
29
  */
27
30
  import { randomBytes, createHash } from "node:crypto";
31
+ import { getPg, ensureOAuthSchema } from "../persistence/postgres.js";
28
32
  // ---- TTLs (override via env where useful) ---------------------------------
29
33
  const TEN_MIN = 10 * 60 * 1000;
30
34
  const ACCESS_TTL = Number(process.env.WEBCAKE_OAUTH_ACCESS_TTL_MS) || 60 * 60 * 1000; // 1h
31
35
  const REFRESH_TTL = Number(process.env.WEBCAKE_OAUTH_REFRESH_TTL_MS) || 30 * 24 * 60 * 60 * 1000; // 30d
32
36
  const CODE_TTL = TEN_MIN;
33
37
  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
38
  function now() {
41
39
  return Date.now();
42
40
  }
43
41
  function token(bytes = 32) {
44
42
  return randomBytes(bytes).toString("base64url");
45
43
  }
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);
44
+ // ---- In-memory store (default; no DATABASE_URL) ---------------------------
45
+ class MemoryStore {
46
+ clients = new Map();
47
+ pending = new Map();
48
+ codes = new Map();
49
+ access = new Map();
50
+ refresh = new Map();
51
+ /** Lazy sweep of anything expired — cheap, called on the hot paths. */
52
+ sweep() {
53
+ const t = now();
54
+ for (const [k, v] of this.pending)
55
+ if (v.expiresAt < t)
56
+ this.pending.delete(k);
57
+ for (const [k, v] of this.codes)
58
+ if (v.expiresAt < t)
59
+ this.codes.delete(k);
60
+ for (const [k, v] of this.access)
61
+ if (v.expiresAt < t)
62
+ this.access.delete(k);
63
+ for (const [k, v] of this.refresh)
64
+ if (v.expiresAt < t)
65
+ this.refresh.delete(k);
66
+ }
67
+ async putClient(c) {
68
+ this.clients.set(c.client_id, c);
69
+ }
70
+ async getClient(id) {
71
+ return this.clients.get(id);
72
+ }
73
+ async putPending(state, p) {
74
+ this.sweep();
75
+ this.pending.set(state, p);
76
+ }
77
+ async takePending(state) {
78
+ this.sweep();
79
+ const p = this.pending.get(state);
80
+ this.pending.delete(state);
81
+ return p && p.expiresAt >= now() ? p : undefined;
82
+ }
83
+ async putCode(code, c) {
84
+ this.codes.set(code, c);
85
+ }
86
+ async takeCode(code) {
87
+ this.sweep();
88
+ const c = this.codes.get(code);
89
+ this.codes.delete(code);
90
+ return c && c.expiresAt >= now() ? c : undefined;
91
+ }
92
+ async putAccess(t, a) {
93
+ this.access.set(t, a);
94
+ }
95
+ async getAccess(t) {
96
+ const a = this.access.get(t);
97
+ if (a && a.expiresAt < now()) {
98
+ this.access.delete(t);
99
+ return undefined;
100
+ }
101
+ return a;
102
+ }
103
+ async putRefresh(t, r) {
104
+ this.refresh.set(t, r);
105
+ }
106
+ async takeRefresh(t) {
107
+ this.sweep();
108
+ const r = this.refresh.get(t);
109
+ this.refresh.delete(t);
110
+ return r && r.expiresAt >= now() ? r : undefined;
111
+ }
112
+ async revoke(t) {
113
+ this.access.delete(t);
114
+ this.refresh.delete(t);
115
+ }
116
+ }
117
+ // ---- Postgres store (when DATABASE_URL is set) ----------------------------
118
+ class PgStore {
119
+ pool;
120
+ constructor(pool) {
121
+ this.pool = pool;
122
+ }
123
+ async putClient(c) {
124
+ await this.pool.query(`INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_at)
125
+ VALUES ($1,$2,$3,$4)
126
+ ON CONFLICT (client_id) DO UPDATE SET client_name=$2, redirect_uris=$3`, [c.client_id, c.client_name ?? null, JSON.stringify(c.redirect_uris), c.created_at]);
127
+ }
128
+ async getClient(id) {
129
+ const { rows } = await this.pool.query(`SELECT client_id, client_name, redirect_uris, created_at FROM oauth_clients WHERE client_id=$1`, [id]);
130
+ const r = rows[0];
131
+ if (!r)
132
+ return undefined;
133
+ return {
134
+ client_id: r.client_id,
135
+ client_name: r.client_name ?? undefined,
136
+ redirect_uris: Array.isArray(r.redirect_uris) ? r.redirect_uris : JSON.parse(r.redirect_uris),
137
+ created_at: Number(r.created_at),
138
+ };
139
+ }
140
+ async putPending(state, p) {
141
+ await this.pool.query(`INSERT INTO oauth_pending (state, client_id, redirect_uri, code_challenge, client_state, scope, expires_at)
142
+ VALUES ($1,$2,$3,$4,$5,$6,$7)`, [state, p.client_id, p.redirect_uri, p.code_challenge, p.state ?? null, p.scope ?? null, p.expiresAt]);
143
+ }
144
+ async takePending(state) {
145
+ const { rows } = await this.pool.query(`DELETE FROM oauth_pending WHERE state=$1 RETURNING client_id, redirect_uri, code_challenge, client_state, scope, expires_at`, [state]);
146
+ const r = rows[0];
147
+ if (!r || Number(r.expires_at) < now())
148
+ return undefined;
149
+ return {
150
+ client_id: r.client_id,
151
+ redirect_uri: r.redirect_uri,
152
+ code_challenge: r.code_challenge,
153
+ state: r.client_state ?? undefined,
154
+ scope: r.scope ?? undefined,
155
+ expiresAt: Number(r.expires_at),
156
+ };
157
+ }
158
+ async putCode(code, c) {
159
+ await this.pool.query(`INSERT INTO oauth_codes (code, client_id, redirect_uri, code_challenge, scope, ljwt, expires_at)
160
+ VALUES ($1,$2,$3,$4,$5,$6,$7)`, [code, c.client_id, c.redirect_uri, c.code_challenge, c.scope ?? null, c.ljwt, c.expiresAt]);
161
+ }
162
+ async takeCode(code) {
163
+ const { rows } = await this.pool.query(`DELETE FROM oauth_codes WHERE code=$1 RETURNING client_id, redirect_uri, code_challenge, scope, ljwt, expires_at`, [code]);
164
+ const r = rows[0];
165
+ if (!r || Number(r.expires_at) < now())
166
+ return undefined;
167
+ return {
168
+ client_id: r.client_id,
169
+ redirect_uri: r.redirect_uri,
170
+ code_challenge: r.code_challenge,
171
+ scope: r.scope ?? undefined,
172
+ ljwt: r.ljwt,
173
+ expiresAt: Number(r.expires_at),
174
+ };
175
+ }
176
+ async putAccess(t, a) {
177
+ await this.pool.query(`INSERT INTO oauth_access_tokens (token, ljwt, scope, expires_at) VALUES ($1,$2,$3,$4)`, [t, a.ljwt, a.scope ?? null, a.expiresAt]);
178
+ }
179
+ async getAccess(t) {
180
+ const { rows } = await this.pool.query(`SELECT ljwt, scope, expires_at FROM oauth_access_tokens WHERE token=$1`, [t]);
181
+ const r = rows[0];
182
+ if (!r)
183
+ return undefined;
184
+ if (Number(r.expires_at) < now()) {
185
+ await this.pool.query(`DELETE FROM oauth_access_tokens WHERE token=$1`, [t]);
186
+ return undefined;
187
+ }
188
+ return { ljwt: r.ljwt, scope: r.scope ?? undefined, expiresAt: Number(r.expires_at) };
189
+ }
190
+ async putRefresh(t, r) {
191
+ await this.pool.query(`INSERT INTO oauth_refresh_tokens (token, ljwt, client_id, scope, expires_at) VALUES ($1,$2,$3,$4,$5)`, [t, r.ljwt, r.client_id, r.scope ?? null, r.expiresAt]);
192
+ }
193
+ async takeRefresh(t) {
194
+ const { rows } = await this.pool.query(`DELETE FROM oauth_refresh_tokens WHERE token=$1 RETURNING ljwt, client_id, scope, expires_at`, [t]);
195
+ const r = rows[0];
196
+ if (!r || Number(r.expires_at) < now())
197
+ return undefined;
198
+ return {
199
+ ljwt: r.ljwt,
200
+ client_id: r.client_id,
201
+ scope: r.scope ?? undefined,
202
+ expiresAt: Number(r.expires_at),
203
+ };
204
+ }
205
+ async revoke(t) {
206
+ await this.pool.query(`DELETE FROM oauth_access_tokens WHERE token=$1`, [t]);
207
+ await this.pool.query(`DELETE FROM oauth_refresh_tokens WHERE token=$1`, [t]);
208
+ }
209
+ }
210
+ // ---- Backend selection (memoized) -----------------------------------------
211
+ const memoryStore = new MemoryStore();
212
+ let storePromise;
213
+ /** Resolve the active store ONCE: Postgres if configured + schema ready, else memory. */
214
+ function getStore() {
215
+ if (storePromise)
216
+ return storePromise;
217
+ storePromise = (async () => {
218
+ const pool = getPg();
219
+ if (pool && (await ensureOAuthSchema(pool)))
220
+ return new PgStore(pool);
221
+ return memoryStore;
222
+ })().catch((e) => {
223
+ console.error("[oauth] store init failed, using in-memory:", e?.message ?? e);
224
+ return memoryStore;
225
+ });
226
+ return storePromise;
61
227
  }
62
228
  // ---- PKCE -----------------------------------------------------------------
63
229
  /** base64url( SHA256(verifier) ) — the S256 transform. */
@@ -76,7 +242,7 @@ export function verifyPkce(verifier, challenge) {
76
242
  diff |= a.charCodeAt(i) ^ challenge.charCodeAt(i);
77
243
  return diff === 0;
78
244
  }
79
- export function registerClient(body) {
245
+ export async function registerClient(body) {
80
246
  const uris = Array.isArray(body?.redirect_uris)
81
247
  ? body.redirect_uris.filter((u) => typeof u === "string" && /^https?:\/\//i.test(u))
82
248
  : [];
@@ -89,11 +255,13 @@ export function registerClient(body) {
89
255
  redirect_uris: uris,
90
256
  created_at: now(),
91
257
  };
92
- clients.set(client.client_id, client);
258
+ (await getStore()).putClient(client).catch((e) => console.error("[oauth] putClient:", e?.message ?? e));
93
259
  return { ok: true, client };
94
260
  }
95
- export function getClient(clientId) {
96
- return clientId ? clients.get(clientId) : undefined;
261
+ export async function getClient(clientId) {
262
+ if (!clientId)
263
+ return undefined;
264
+ return (await getStore()).getClient(clientId);
97
265
  }
98
266
  /**
99
267
  * Validate an /authorize request and park it. Returns an `internalState` to send
@@ -101,9 +269,9 @@ export function getClient(clientId) {
101
269
  * `redirectable: false` means the error must be shown as a page (we can't trust
102
270
  * the redirect_uri); `true` means it's safe to bounce the error to the client.
103
271
  */
104
- export function startAuthorize(p) {
105
- sweep();
106
- const client = getClient(p.client_id);
272
+ export async function startAuthorize(p) {
273
+ const store = await getStore();
274
+ const client = p.client_id ? await store.getClient(p.client_id) : undefined;
107
275
  if (!client) {
108
276
  return { ok: false, error: "invalid_client", error_description: "Unknown client_id. Register first via /register.", redirectable: false };
109
277
  }
@@ -118,7 +286,7 @@ export function startAuthorize(p) {
118
286
  return { ok: false, error: "invalid_request", error_description: "PKCE with code_challenge_method=S256 is required.", redirectable: true };
119
287
  }
120
288
  const internalState = token(24);
121
- pending.set(internalState, {
289
+ await store.putPending(internalState, {
122
290
  client_id: client.client_id,
123
291
  redirect_uri: p.redirect_uri,
124
292
  code_challenge: p.code_challenge,
@@ -134,18 +302,17 @@ export function startAuthorize(p) {
134
302
  * parked PKCE challenge, and return where to redirect the user (the client's
135
303
  * redirect_uri with ?code=&state=).
136
304
  */
137
- export function completeAuthorize(internalState, ljwt) {
138
- sweep();
139
- if (!internalState || !pending.has(internalState)) {
305
+ export async function completeAuthorize(internalState, ljwt) {
306
+ const store = await getStore();
307
+ const p = internalState ? await store.takePending(internalState) : undefined;
308
+ if (!p) {
140
309
  return { ok: false, error: "invalid_request", error_description: "Authorization session expired or unknown — restart the connection." };
141
310
  }
142
- const p = pending.get(internalState);
143
- pending.delete(internalState);
144
311
  if (!ljwt) {
145
312
  return { ok: false, error: "access_denied", error_description: "No Webcake token returned from login." };
146
313
  }
147
314
  const code = token(32);
148
- codes.set(code, {
315
+ await store.putCode(code, {
149
316
  client_id: p.client_id,
150
317
  redirect_uri: p.redirect_uri,
151
318
  code_challenge: p.code_challenge,
@@ -155,21 +322,20 @@ export function completeAuthorize(internalState, ljwt) {
155
322
  });
156
323
  return { ok: true, redirectUri: p.redirect_uri, code, state: p.state };
157
324
  }
158
- function issueTokens(ljwt, client_id, scope) {
325
+ async function issueTokens(store, ljwt, client_id, scope) {
159
326
  const access = token(32);
160
327
  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 });
328
+ await store.putAccess(access, { ljwt, scope, expiresAt: now() + ACCESS_TTL });
329
+ await store.putRefresh(refresh, { ljwt, client_id, scope, expiresAt: now() + REFRESH_TTL });
163
330
  return { access_token: access, token_type: "Bearer", expires_in: Math.floor(ACCESS_TTL / 1000), refresh_token: refresh, scope };
164
331
  }
165
- export function exchangeToken(p) {
166
- sweep();
332
+ export async function exchangeToken(p) {
333
+ const store = await getStore();
167
334
  if (p.grant_type === "authorization_code") {
168
- if (!p.code || !codes.has(p.code)) {
335
+ const c = p.code ? await store.takeCode(p.code) : undefined; // one-time use
336
+ if (!c) {
169
337
  return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired authorization code." };
170
338
  }
171
- const c = codes.get(p.code);
172
- codes.delete(p.code); // one-time use
173
339
  if (c.client_id !== p.client_id) {
174
340
  return { ok: false, status: 400, error: "invalid_grant", error_description: "client_id does not match the authorization code." };
175
341
  }
@@ -179,38 +345,30 @@ export function exchangeToken(p) {
179
345
  if (!p.code_verifier || !verifyPkce(p.code_verifier, c.code_challenge)) {
180
346
  return { ok: false, status: 400, error: "invalid_grant", error_description: "PKCE verification failed." };
181
347
  }
182
- return { ok: true, body: issueTokens(c.ljwt, c.client_id, c.scope) };
348
+ return { ok: true, body: await issueTokens(store, c.ljwt, c.client_id, c.scope) };
183
349
  }
184
350
  if (p.grant_type === "refresh_token") {
185
- if (!p.refresh_token || !refreshTokens.has(p.refresh_token)) {
351
+ const r = p.refresh_token ? await store.takeRefresh(p.refresh_token) : undefined; // rotate
352
+ if (!r) {
186
353
  return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired refresh token." };
187
354
  }
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) };
355
+ return { ok: true, body: await issueTokens(store, r.ljwt, r.client_id, r.scope) };
191
356
  }
192
357
  return { ok: false, status: 400, error: "unsupported_grant_type", error_description: "grant_type must be authorization_code or refresh_token." };
193
358
  }
194
359
  // ---- Resource-server side: resolve a Bearer access token to its ljwt -------
195
360
  /** Returns the user's ljwt for a valid, unexpired access token, else undefined. */
196
- export function resolveAccessToken(accessToken) {
361
+ export async function resolveAccessToken(accessToken) {
197
362
  if (!accessToken)
198
363
  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;
364
+ const a = await (await getStore()).getAccess(accessToken);
365
+ return a?.ljwt;
207
366
  }
208
367
  /** Revoke an access or refresh token (best-effort; for /revoke). */
209
- export function revokeToken(t) {
368
+ export async function revokeToken(t) {
210
369
  if (!t)
211
370
  return;
212
- accessTokens.delete(t);
213
- refreshTokens.delete(t);
371
+ await (await getStore()).revoke(t);
214
372
  }
215
373
  // ---- Metadata documents (RFC 8414 / RFC 9728) -----------------------------
216
374
  /** /.well-known/oauth-authorization-server */
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.0.79",
4
+ "d": "15/06/2026",
5
+ "type": "Changed",
6
+ "en": "The Privacy Policy served at /privacy is updated with GDPR-style data-category headings, per-category purpose statements, documentation of…",
7
+ "vi": "Trang Privacy Policy tại /privacy được cập nhật với các tiêu đề phân loại dữ liệu theo chuẩn GDPR, ghi rõ mục đích xử lý từng loại dữ liệu, bổ sung…"
8
+ },
9
+ {
10
+ "v": "1.0.78",
11
+ "d": "15/06/2026",
12
+ "type": "Added",
13
+ "en": "The OAuth token store (clients, authorization codes, access and refresh tokens) now uses Postgres when DATABASE_URL, WEBCAKE_POSTGRES_URL, or…",
14
+ "vi": "Kho lưu trữ OAuth token (clients, authorization code, access và refresh token) nay sử dụng Postgres khi DATABASE_URL, WEBCAKE_POSTGRES_URL, hoặc…"
15
+ },
2
16
  {
3
17
  "v": "1.0.77",
4
18
  "d": "15/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "ingest_html and ingest_url now extract Tailwind gradient utilities (bg-gradient-to-*/from-*/via-*/to-*) from the page's class attributes, resolve…",
28
42
  "vi": "ingest_html và ingest_url nay trích xuất các utility gradient Tailwind (bg-gradient-to-*/from-*/via-*/to-*) từ các thuộc tính class của trang,…"
29
- },
30
- {
31
- "v": "1.0.73",
32
- "d": "13/06/2026",
33
- "type": "Added",
34
- "en": "ingest_html and ingest_url now extract the full design system from a tailwind.config script block when present (Google Stitch and other Tailwind-CDN…",
35
- "vi": "ingest_html và ingest_url nay trích xuất toàn bộ design system từ block script tailwind.config khi có (trang Google Stitch và các trang Tailwind-CDN…"
36
- },
37
- {
38
- "v": "1.0.72",
39
- "d": "13/06/2026",
40
- "type": "Fixed",
41
- "en": "When cloning a LadiPage or Webcake-published page via ingest_html / ingest_url, html-box elements' passthrough HTML content now has its…",
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…"
43
43
  }
44
44
  ]
package/dist/http.js CHANGED
@@ -189,7 +189,7 @@ async function handleOAuth(req, res, path) {
189
189
  return oauthError(res, 405, "invalid_request", "Use POST."), true;
190
190
  const raw = await readRawBody(req);
191
191
  const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
192
- const result = registerClient(body);
192
+ const result = await registerClient(body);
193
193
  if (!result.ok)
194
194
  return oauthError(res, 400, result.error, result.error_description), true;
195
195
  res.writeHead(201, { "content-type": "application/json", "access-control-allow-origin": "*", "cache-control": "no-store" });
@@ -206,7 +206,7 @@ async function handleOAuth(req, res, path) {
206
206
  // ---- Authorize: validate + delegate to the SPA login, parking the request ----
207
207
  if (req.method === "GET" && path === OAUTH_AUTHORIZE) {
208
208
  const sp = new URL(req.url ?? "/", "http://x").searchParams;
209
- const result = startAuthorize({
209
+ const result = await startAuthorize({
210
210
  client_id: sp.get("client_id"),
211
211
  redirect_uri: sp.get("redirect_uri"),
212
212
  response_type: sp.get("response_type"),
@@ -238,7 +238,7 @@ async function handleOAuth(req, res, path) {
238
238
  // ---- Login callback: the SPA handed back the user's ljwt → mint a code ----
239
239
  if (req.method === "GET" && path === OAUTH_CALLBACK) {
240
240
  const sp = new URL(req.url ?? "/", "http://x").searchParams;
241
- const done = completeAuthorize(sp.get("state"), sp.get("token"));
241
+ const done = await completeAuthorize(sp.get("state"), sp.get("token"));
242
242
  if (!done.ok)
243
243
  return htmlError(res, 400, done.error_description), true;
244
244
  const r = new URL(done.redirectUri);
@@ -258,7 +258,7 @@ async function handleOAuth(req, res, path) {
258
258
  return oauthError(res, 405, "invalid_request", "Use POST."), true;
259
259
  const raw = await readRawBody(req);
260
260
  const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
261
- const result = exchangeToken(body);
261
+ const result = await exchangeToken(body);
262
262
  if (!result.ok)
263
263
  return oauthError(res, result.status, result.error, result.error_description), true;
264
264
  res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": "*", "cache-control": "no-store" });
@@ -271,7 +271,7 @@ async function handleOAuth(req, res, path) {
271
271
  return oauthError(res, 405, "invalid_request", "Use POST."), true;
272
272
  const raw = await readRawBody(req);
273
273
  const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
274
- revokeToken(body.token);
274
+ await revokeToken(body.token);
275
275
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
276
276
  res.end("{}");
277
277
  return true;
@@ -393,7 +393,7 @@ export async function startHttpServer(port) {
393
393
  // normal x-webcake-jwt header, so persistence/config.ts is unchanged. A legacy
394
394
  // raw JWT sent via x-webcake-jwt / ?jwt= still wins and passes straight through.
395
395
  const bearer = bearerFrom(req);
396
- const oauthLjwt = resolveAccessToken(bearer);
396
+ const oauthLjwt = await resolveAccessToken(bearer);
397
397
  if (oauthLjwt && req.headers["x-webcake-jwt"] == null) {
398
398
  req.headers["x-webcake-jwt"] = oauthLjwt;
399
399
  req.rawHeaders.push("x-webcake-jwt", oauthLjwt);
package/dist/legal.js CHANGED
@@ -36,38 +36,52 @@ export function privacyHtml() {
36
36
  <div class="meta">Last updated: ${LAST_UPDATED}</div>
37
37
  <p>Webcake Landing MCP ("the connector") is a Model Context Protocol server that lets an AI assistant
38
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>
39
+ what categories of data the connector handles, why, who receives it, and how long it is kept.</p>
40
40
 
41
- <h2>What we access</h2>
41
+ <h2>Categories of personal data we access</h2>
42
42
  <ul>
43
43
  <li><strong>Your Webcake identity &amp; 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
44
+ connector receives a per-user access token mapped to your Webcake landing-page credential. <em>Purpose:</em> solely
45
45
  to call the Webcake backend on your behalf (create, read, update, publish pages, list your organizations).</li>
46
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>
47
+ layout) flows through the connector to your Webcake account. <em>Purpose:</em> to assemble and save the page you request.</li>
48
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>
49
+ stock-photo search uses the Pexels API and icon lookup uses the Iconify API. <em>Purpose:</em> to supply the
50
+ visuals for your page.</li>
50
51
  </ul>
51
52
 
52
- <h2>What we store</h2>
53
+ <h2>What we store and for how long</h2>
53
54
  <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>
55
+ <li><strong>OAuth tokens.</strong> Stored in the connector's database (PostgreSQL) when the server is configured
56
+ with one so a logged-in session survives a server restart and works across instances otherwise only in
57
+ process memory. Either way they <strong>expire automatically</strong> (access tokens ~1 hour, refresh tokens
58
+ ~30 days) and are removed on logout/revoke or expiry.</li>
59
+ <li><strong>Transient draft cache.</strong> If a page fails validation while being created, its draft page-source
60
+ is cached briefly (about 2 hours, in Redis or memory) so you can fix and retry without re-sending everything.
61
+ It is removed on success or expiry.</li>
62
+ <li>The connector does <strong>not</strong> run an analytics database, does <strong>not</strong> sell data, and
63
+ does <strong>not</strong> perform tracking, behavioral profiling, or advertising.</li>
59
64
  </ul>
60
65
 
61
- <h2>Third-party services</h2>
66
+ <h2>Categories of recipients (third-party services)</h2>
67
+ <p>Your data is shared only with the services required to perform your request:</p>
62
68
  <ul>
63
- <li><strong>Webcake</strong> (api.webcake.io) stores and serves your pages; governed by Webcake's own terms.</li>
69
+ <li><strong>Webcake</strong> (api.webcake.io, the Webcake CDN, and the publish/build host) stores, renders,
70
+ and serves your pages; governed by Webcake's own terms.</li>
64
71
  <li><strong>Pexels</strong> (pexels.com) — stock-photo search, only when you request images.</li>
72
+ <li><strong>Iconify</strong> (iconify.design) — resolves icon names to SVG, only when a page uses icons.</li>
65
73
  </ul>
66
74
 
75
+ <h2>Data we do NOT collect</h2>
76
+ <p>The connector never asks for or stores payment-card data, health data, government identifiers, or
77
+ authentication secrets (passwords, API keys, MFA/OTP codes) as tool inputs. It operates only on the page
78
+ content you explicitly ask the assistant to build — it does <strong>not</strong> read, reconstruct, or infer
79
+ your full conversation or chat history.</p>
80
+
67
81
  <h2>Data retention &amp; 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>
82
+ <p>Tokens and the draft cache expire automatically as described above; you can revoke access at any time by
83
+ disconnecting the connector in Claude/ChatGPT settings, or by logging out of Webcake. Pages you create live in
84
+ your Webcake account and are managed there. To request deletion of anything else, contact us below.</p>
71
85
 
72
86
  <h2>Security</h2>
73
87
  <p>All traffic uses HTTPS. Authentication follows OAuth 2.1 with PKCE; the connector validates a short-lived