webcake-storefront-mcp 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -150,7 +150,7 @@ Base URLs come from a **named environment** — set `WEBCAKE_ENV` (or `--env`) a
150
150
 
151
151
  Override a preset with `WEBCAKE_API_URL` / `WEBCAKE_APP_URL`. Optional, configured server-side:
152
152
  `PEXELS_API_KEY` (search_images), `MONGO_URI` (image-alt cache). Token / session / site can also be set
153
- in chat via `update_auth` and `switch_site` — saved to a local SQLite db at `~/.webcake-storefront-mcp/`.
153
+ in chat via `update_auth` and `switch_site` — saved to a local config file at `~/.webcake-storefront-mcp/`.
154
154
 
155
155
  <details>
156
156
  <summary><b>How to get your token + session</b></summary>
package/README.vi.md CHANGED
@@ -149,7 +149,7 @@ URL gốc lấy theo **môi trường có tên** — đặt `WEBCAKE_ENV` (hoặ
149
149
 
150
150
  Override bằng `WEBCAKE_API_URL` / `WEBCAKE_APP_URL`. Tuỳ chọn, đặt phía server:
151
151
  `PEXELS_API_KEY` (search_images), `MONGO_URI` (cache alt ảnh). Token / session / site cũng có thể đặt
152
- trong chat bằng `update_auth` và `switch_site` — lưu vào SQLite tại `~/.webcake-storefront-mcp/`.
152
+ trong chat bằng `update_auth` và `switch_site` — lưu vào file cấu hình tại `~/.webcake-storefront-mcp/`.
153
153
 
154
154
  <details>
155
155
  <summary><b>Cách lấy token + session</b></summary>
@@ -8,29 +8,47 @@
8
8
  * the authorization code and carried through to the access token so the HTTP
9
9
  * layer can inject BOTH x-webcake-jwt AND x-webcake-session-id headers.
10
10
  *
11
- * Store: in-memory only (no Postgres dependency in the storefront MCP). For a
12
- * multi-instance deploy, replace with a shared store behind the same interface.
11
+ * STORE: Postgres when DATABASE_URL is set (tokens survive a `serve` restart and
12
+ * are shared across instances behind a load balancer), else in-memory maps
13
+ * (single-instance `serve`, stdio/`npx`, offline tests). Both implement the same
14
+ * async `OAuthStore` interface; the rest of this module is backend-agnostic.
15
+ *
16
+ * CACHE: Redis (when REDIS_URL is set) caches access_token → {jwt,wsid} so
17
+ * /mcp requests avoid a Postgres round-trip on every call. Postgres remains the
18
+ * source of truth; a Redis miss just falls through to Postgres. On Redis
19
+ * absence / error the lookup goes directly to the store.
20
+ *
21
+ * All exported state functions are async — callers (src/http.ts) already await them.
22
+ * All exported function names and signatures are IDENTICAL to the previous version
23
+ * so http.ts requires no changes.
13
24
  */
14
25
  import { randomBytes, createHash } from "node:crypto";
15
- // ---- TTLs ------------------------------------------------------------------
26
+ import { getPg, ensureOAuthSchema } from "../persistence/postgres.js";
27
+ import { getRedis } from "../persistence/redis.js";
28
+ // ---- TTLs (override via env where useful) ----------------------------------
16
29
  const TEN_MIN = 10 * 60 * 1000;
17
30
  const ACCESS_TTL = Number(process.env.WEBCAKE_OAUTH_ACCESS_TTL_MS) || 60 * 60 * 1000; // 1h
18
31
  const REFRESH_TTL = Number(process.env.WEBCAKE_OAUTH_REFRESH_TTL_MS) || 30 * 24 * 60 * 60 * 1000; // 30d
19
32
  const CODE_TTL = TEN_MIN;
20
33
  const PENDING_TTL = TEN_MIN;
34
+ // Redis key prefix + TTL for the access-token cache (slightly shorter than ACCESS_TTL
35
+ // so stale cache entries never outlive the canonical Postgres row).
36
+ const REDIS_TOKEN_PREFIX = "wc:at:";
37
+ const REDIS_TOKEN_TTL_MS = ACCESS_TTL - 60_000; // 1 min earlier than token expiry
21
38
  function now() {
22
39
  return Date.now();
23
40
  }
24
41
  function token(bytes = 32) {
25
42
  return randomBytes(bytes).toString("base64url");
26
43
  }
27
- // ---- In-memory store -------------------------------------------------------
44
+ // ---- In-memory store (default; no DATABASE_URL) ----------------------------
28
45
  class MemoryStore {
29
46
  clients = new Map();
30
47
  pending = new Map();
31
48
  codes = new Map();
32
49
  access = new Map();
33
50
  refresh = new Map();
51
+ /** Lazy sweep of anything expired — cheap, called on the hot paths. */
34
52
  sweep() {
35
53
  const t = now();
36
54
  for (const [k, v] of this.pending)
@@ -46,24 +64,24 @@ class MemoryStore {
46
64
  if (v.expiresAt < t)
47
65
  this.refresh.delete(k);
48
66
  }
49
- putClient(c) { this.clients.set(c.client_id, c); }
50
- getClient(id) { return this.clients.get(id); }
51
- putPending(state, p) { this.sweep(); this.pending.set(state, p); }
52
- takePending(state) {
67
+ async putClient(c) { this.clients.set(c.client_id, c); }
68
+ async getClient(id) { return this.clients.get(id); }
69
+ async putPending(state, p) { this.sweep(); this.pending.set(state, p); }
70
+ async takePending(state) {
53
71
  this.sweep();
54
72
  const p = this.pending.get(state);
55
73
  this.pending.delete(state);
56
74
  return p && p.expiresAt >= now() ? p : undefined;
57
75
  }
58
- putCode(code, c) { this.codes.set(code, c); }
59
- takeCode(code) {
76
+ async putCode(code, c) { this.codes.set(code, c); }
77
+ async takeCode(code) {
60
78
  this.sweep();
61
79
  const c = this.codes.get(code);
62
80
  this.codes.delete(code);
63
81
  return c && c.expiresAt >= now() ? c : undefined;
64
82
  }
65
- putAccess(t, a) { this.access.set(t, a); }
66
- getAccess(t) {
83
+ async putAccess(t, a) { this.access.set(t, a); }
84
+ async getAccess(t) {
67
85
  const a = this.access.get(t);
68
86
  if (a && a.expiresAt < now()) {
69
87
  this.access.delete(t);
@@ -71,23 +89,170 @@ class MemoryStore {
71
89
  }
72
90
  return a;
73
91
  }
74
- putRefresh(t, r) { this.refresh.set(t, r); }
75
- takeRefresh(t) {
92
+ async putRefresh(t, r) { this.refresh.set(t, r); }
93
+ async takeRefresh(t) {
76
94
  this.sweep();
77
95
  const r = this.refresh.get(t);
78
96
  this.refresh.delete(t);
79
97
  return r && r.expiresAt >= now() ? r : undefined;
80
98
  }
81
- revoke(t) { this.access.delete(t); this.refresh.delete(t); }
99
+ async revoke(t) { this.access.delete(t); this.refresh.delete(t); }
100
+ }
101
+ // ---- Postgres store (when DATABASE_URL is set) -----------------------------
102
+ class PgStore {
103
+ pool;
104
+ constructor(pool) {
105
+ this.pool = pool;
106
+ }
107
+ async putClient(c) {
108
+ await this.pool.query(`INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_at)
109
+ VALUES ($1,$2,$3,$4)
110
+ 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]);
111
+ }
112
+ async getClient(id) {
113
+ const { rows } = await this.pool.query(`SELECT client_id, client_name, redirect_uris, created_at FROM oauth_clients WHERE client_id=$1`, [id]);
114
+ const r = rows[0];
115
+ if (!r)
116
+ return undefined;
117
+ return {
118
+ client_id: r.client_id,
119
+ client_name: r.client_name ?? undefined,
120
+ redirect_uris: Array.isArray(r.redirect_uris)
121
+ ? r.redirect_uris
122
+ : JSON.parse(r.redirect_uris),
123
+ created_at: Number(r.created_at),
124
+ };
125
+ }
126
+ async putPending(state, p) {
127
+ await this.pool.query(`INSERT INTO oauth_pending (state, client_id, redirect_uri, code_challenge, client_state, scope, expires_at)
128
+ 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]);
129
+ }
130
+ async takePending(state) {
131
+ const { rows } = await this.pool.query(`DELETE FROM oauth_pending WHERE state=$1
132
+ RETURNING client_id, redirect_uri, code_challenge, client_state, scope, expires_at`, [state]);
133
+ const r = rows[0];
134
+ if (!r || Number(r.expires_at) < now())
135
+ return undefined;
136
+ return {
137
+ client_id: r.client_id,
138
+ redirect_uri: r.redirect_uri,
139
+ code_challenge: r.code_challenge,
140
+ state: r.client_state ?? undefined,
141
+ scope: r.scope ?? undefined,
142
+ expiresAt: Number(r.expires_at),
143
+ };
144
+ }
145
+ async putCode(code, c) {
146
+ await this.pool.query(`INSERT INTO oauth_codes (code, client_id, redirect_uri, code_challenge, scope, jwt, wsid, expires_at)
147
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, [code, c.client_id, c.redirect_uri, c.code_challenge, c.scope ?? null, c.cred.jwt, c.cred.wsid, c.expiresAt]);
148
+ }
149
+ async takeCode(code) {
150
+ const { rows } = await this.pool.query(`DELETE FROM oauth_codes WHERE code=$1
151
+ RETURNING client_id, redirect_uri, code_challenge, scope, jwt, wsid, expires_at`, [code]);
152
+ const r = rows[0];
153
+ if (!r || Number(r.expires_at) < now())
154
+ return undefined;
155
+ return {
156
+ client_id: r.client_id,
157
+ redirect_uri: r.redirect_uri,
158
+ code_challenge: r.code_challenge,
159
+ scope: r.scope ?? undefined,
160
+ cred: { jwt: r.jwt, wsid: r.wsid ?? "" },
161
+ expiresAt: Number(r.expires_at),
162
+ };
163
+ }
164
+ async putAccess(t, a) {
165
+ await this.pool.query(`INSERT INTO oauth_access_tokens (token, jwt, wsid, scope, expires_at) VALUES ($1,$2,$3,$4,$5)`, [t, a.cred.jwt, a.cred.wsid, a.scope ?? null, a.expiresAt]);
166
+ }
167
+ async getAccess(t) {
168
+ const { rows } = await this.pool.query(`SELECT jwt, wsid, scope, expires_at FROM oauth_access_tokens WHERE token=$1`, [t]);
169
+ const r = rows[0];
170
+ if (!r)
171
+ return undefined;
172
+ if (Number(r.expires_at) < now()) {
173
+ await this.pool.query(`DELETE FROM oauth_access_tokens WHERE token=$1`, [t]);
174
+ return undefined;
175
+ }
176
+ return {
177
+ cred: { jwt: r.jwt, wsid: r.wsid ?? "" },
178
+ scope: r.scope ?? undefined,
179
+ expiresAt: Number(r.expires_at),
180
+ };
181
+ }
182
+ async putRefresh(t, r) {
183
+ await this.pool.query(`INSERT INTO oauth_refresh_tokens (token, jwt, wsid, client_id, scope, expires_at) VALUES ($1,$2,$3,$4,$5,$6)`, [t, r.cred.jwt, r.cred.wsid, r.client_id, r.scope ?? null, r.expiresAt]);
184
+ }
185
+ async takeRefresh(t) {
186
+ const { rows } = await this.pool.query(`DELETE FROM oauth_refresh_tokens WHERE token=$1
187
+ RETURNING jwt, wsid, client_id, scope, expires_at`, [t]);
188
+ const r = rows[0];
189
+ if (!r || Number(r.expires_at) < now())
190
+ return undefined;
191
+ return {
192
+ cred: { jwt: r.jwt, wsid: r.wsid ?? "" },
193
+ client_id: r.client_id,
194
+ scope: r.scope ?? undefined,
195
+ expiresAt: Number(r.expires_at),
196
+ };
197
+ }
198
+ async revoke(t) {
199
+ await this.pool.query(`DELETE FROM oauth_access_tokens WHERE token=$1`, [t]);
200
+ await this.pool.query(`DELETE FROM oauth_refresh_tokens WHERE token=$1`, [t]);
201
+ }
202
+ }
203
+ // ---- Backend selection (memoized) ------------------------------------------
204
+ const memoryStore = new MemoryStore();
205
+ let storePromise;
206
+ /** Resolve the active store ONCE: Postgres if configured + schema ready, else memory. */
207
+ function getStore() {
208
+ if (storePromise)
209
+ return storePromise;
210
+ storePromise = (async () => {
211
+ const pool = getPg();
212
+ if (pool && (await ensureOAuthSchema(pool)))
213
+ return new PgStore(pool);
214
+ return memoryStore;
215
+ })().catch((e) => {
216
+ console.error("[oauth] store init failed, using in-memory:", e?.message ?? e);
217
+ return memoryStore;
218
+ });
219
+ return storePromise;
220
+ }
221
+ // ---- Redis cache helpers (access-token only) --------------------------------
222
+ /** Write {jwt,wsid} to Redis with the access-token TTL. Fire-and-forget. */
223
+ function redisCachePut(redis, tok, cred) {
224
+ const val = JSON.stringify(cred);
225
+ redis.set(REDIS_TOKEN_PREFIX + tok, val, "PX", REDIS_TOKEN_TTL_MS).catch((e) => {
226
+ console.error("[redis] cache put error:", e?.message ?? e);
227
+ });
228
+ }
229
+ /** Read {jwt,wsid} from Redis. Returns null on miss or error. */
230
+ async function redisCacheGet(redis, tok) {
231
+ try {
232
+ const raw = await redis.get(REDIS_TOKEN_PREFIX + tok);
233
+ if (!raw)
234
+ return null;
235
+ return JSON.parse(raw);
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
241
+ /** Evict an access-token key from Redis. Fire-and-forget. */
242
+ function redisCacheDel(redis, tok) {
243
+ redis.del(REDIS_TOKEN_PREFIX + tok).catch((e) => {
244
+ console.error("[redis] cache del error:", e?.message ?? e);
245
+ });
82
246
  }
83
- const store = new MemoryStore();
84
247
  // ---- PKCE ------------------------------------------------------------------
248
+ /** base64url( SHA256(verifier) ) — the S256 transform. */
85
249
  export function s256(verifier) {
86
250
  return createHash("sha256").update(verifier).digest("base64url");
87
251
  }
88
252
  export function verifyPkce(verifier, challenge) {
89
253
  if (!verifier || !challenge)
90
254
  return false;
255
+ // Constant-time-ish compare on equal-length base64url strings.
91
256
  const a = s256(verifier);
92
257
  if (a.length !== challenge.length)
93
258
  return false;
@@ -96,7 +261,7 @@ export function verifyPkce(verifier, challenge) {
96
261
  diff |= a.charCodeAt(i) ^ challenge.charCodeAt(i);
97
262
  return diff === 0;
98
263
  }
99
- export function registerClient(body) {
264
+ export async function registerClient(body) {
100
265
  const uris = Array.isArray(body?.redirect_uris)
101
266
  ? body.redirect_uris.filter((u) => typeof u === "string" && /^https?:\/\//i.test(u))
102
267
  : [];
@@ -109,16 +274,17 @@ export function registerClient(body) {
109
274
  redirect_uris: uris,
110
275
  created_at: now(),
111
276
  };
112
- store.putClient(client);
277
+ (await getStore()).putClient(client).catch((e) => console.error("[oauth] putClient:", e?.message ?? e));
113
278
  return { ok: true, client };
114
279
  }
115
- export function getClient(clientId) {
280
+ export async function getClient(clientId) {
116
281
  if (!clientId)
117
282
  return undefined;
118
- return store.getClient(clientId);
283
+ return (await getStore()).getClient(clientId);
119
284
  }
120
- export function startAuthorize(p) {
121
- const client = p.client_id ? store.getClient(p.client_id) : undefined;
285
+ export async function startAuthorize(p) {
286
+ const store = await getStore();
287
+ const client = p.client_id ? await store.getClient(p.client_id) : undefined;
122
288
  if (!client) {
123
289
  return { ok: false, error: "invalid_client", error_description: "Unknown client_id. Register first via /register.", redirectable: false };
124
290
  }
@@ -132,7 +298,7 @@ export function startAuthorize(p) {
132
298
  return { ok: false, error: "invalid_request", error_description: "PKCE with code_challenge_method=S256 is required.", redirectable: true };
133
299
  }
134
300
  const internalState = token(24);
135
- store.putPending(internalState, {
301
+ await store.putPending(internalState, {
136
302
  client_id: client.client_id,
137
303
  redirect_uri: p.redirect_uri,
138
304
  code_challenge: p.code_challenge,
@@ -146,8 +312,9 @@ export function startAuthorize(p) {
146
312
  * The consent page (/mcp-storefront) bounced back with the user's jwt + wsid and our
147
313
  * internalState. Mint a one-time authorization code bound to that credential pair.
148
314
  */
149
- export function completeAuthorize(internalState, jwt, wsid) {
150
- const p = internalState ? store.takePending(internalState) : undefined;
315
+ export async function completeAuthorize(internalState, jwt, wsid) {
316
+ const store = await getStore();
317
+ const p = internalState ? await store.takePending(internalState) : undefined;
151
318
  if (!p) {
152
319
  return { ok: false, error: "invalid_request", error_description: "Authorization session expired or unknown — restart the connection." };
153
320
  }
@@ -155,7 +322,7 @@ export function completeAuthorize(internalState, jwt, wsid) {
155
322
  return { ok: false, error: "access_denied", error_description: "No WebCake token returned from login." };
156
323
  }
157
324
  const code = token(32);
158
- store.putCode(code, {
325
+ await store.putCode(code, {
159
326
  client_id: p.client_id,
160
327
  redirect_uri: p.redirect_uri,
161
328
  code_challenge: p.code_challenge,
@@ -165,16 +332,21 @@ export function completeAuthorize(internalState, jwt, wsid) {
165
332
  });
166
333
  return { ok: true, redirectUri: p.redirect_uri, code, state: p.state };
167
334
  }
168
- function issueTokens(cred, client_id, scope) {
335
+ async function issueTokens(store, cred, client_id, scope) {
169
336
  const access = token(32);
170
337
  const refresh = token(32);
171
- store.putAccess(access, { cred, scope, expiresAt: now() + ACCESS_TTL });
172
- store.putRefresh(refresh, { cred, client_id, scope, expiresAt: now() + REFRESH_TTL });
338
+ await store.putAccess(access, { cred, scope, expiresAt: now() + ACCESS_TTL });
339
+ await store.putRefresh(refresh, { cred, client_id, scope, expiresAt: now() + REFRESH_TTL });
340
+ // Warm the Redis cache immediately after issuing so the first /mcp request is a cache hit.
341
+ const redis = getRedis();
342
+ if (redis)
343
+ redisCachePut(redis, access, cred);
173
344
  return { access_token: access, token_type: "Bearer", expires_in: Math.floor(ACCESS_TTL / 1000), refresh_token: refresh, scope };
174
345
  }
175
- export function exchangeToken(p) {
346
+ export async function exchangeToken(p) {
347
+ const store = await getStore();
176
348
  if (p.grant_type === "authorization_code") {
177
- const c = p.code ? store.takeCode(p.code) : undefined;
349
+ const c = p.code ? await store.takeCode(p.code) : undefined;
178
350
  if (!c) {
179
351
  return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired authorization code." };
180
352
  }
@@ -187,29 +359,53 @@ export function exchangeToken(p) {
187
359
  if (!p.code_verifier || !verifyPkce(p.code_verifier, c.code_challenge)) {
188
360
  return { ok: false, status: 400, error: "invalid_grant", error_description: "PKCE verification failed." };
189
361
  }
190
- return { ok: true, body: issueTokens(c.cred, c.client_id, c.scope) };
362
+ return { ok: true, body: await issueTokens(store, c.cred, c.client_id, c.scope) };
191
363
  }
192
364
  if (p.grant_type === "refresh_token") {
193
- const r = p.refresh_token ? store.takeRefresh(p.refresh_token) : undefined;
365
+ const r = p.refresh_token ? await store.takeRefresh(p.refresh_token) : undefined;
194
366
  if (!r) {
195
367
  return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired refresh token." };
196
368
  }
197
- return { ok: true, body: issueTokens(r.cred, r.client_id, r.scope) };
369
+ return { ok: true, body: await issueTokens(store, r.cred, r.client_id, r.scope) };
198
370
  }
199
371
  return { ok: false, status: 400, error: "unsupported_grant_type", error_description: "grant_type must be authorization_code or refresh_token." };
200
372
  }
201
373
  // ---- Resource-server side: resolve a Bearer access token to the cred pair --
202
- /** Returns the { jwt, wsid } for a valid, unexpired access token, else undefined. */
203
- export function resolveAccessToken(accessToken) {
374
+ /**
375
+ * Returns the { jwt, wsid } for a valid, unexpired access token, else undefined.
376
+ *
377
+ * Hot path — hit on every /mcp request. Resolution order:
378
+ * 1. Redis cache (sub-millisecond, no Postgres round-trip)
379
+ * 2. Postgres / in-memory store (source of truth, backfills Redis on hit)
380
+ */
381
+ export async function resolveAccessToken(accessToken) {
204
382
  if (!accessToken)
205
383
  return undefined;
206
- return store.getAccess(accessToken)?.cred;
384
+ // 1. Try Redis cache first.
385
+ const redis = getRedis();
386
+ if (redis) {
387
+ const cached = await redisCacheGet(redis, accessToken);
388
+ if (cached)
389
+ return cached;
390
+ }
391
+ // 2. Fall through to the store (Postgres or memory).
392
+ const a = await (await getStore()).getAccess(accessToken);
393
+ if (!a)
394
+ return undefined;
395
+ // Backfill the Redis cache for next time.
396
+ if (redis)
397
+ redisCachePut(redis, accessToken, a.cred);
398
+ return a.cred;
207
399
  }
208
400
  /** Revoke an access or refresh token (best-effort; for /revoke). */
209
- export function revokeToken(t) {
401
+ export async function revokeToken(t) {
210
402
  if (!t)
211
403
  return;
212
- store.revoke(t);
404
+ // Evict from Redis cache before hitting the store so the slot is gone immediately.
405
+ const redis = getRedis();
406
+ if (redis)
407
+ redisCacheDel(redis, t);
408
+ await (await getStore()).revoke(t);
213
409
  }
214
410
  // ---- Metadata documents (RFC 8414 / RFC 9728) ------------------------------
215
411
  export function authServerMetadata(issuer) {
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Central resolution of connection settings for every entry path (stdio, install,
2
2
  // login, remote HTTP). Precedence: explicit overrides > environment variables >
3
- // saved config in the local SQLite db.
3
+ // saved config in the local config file.
4
4
  import { WebcakeCmsApi } from "./api.js";
5
5
  import { getSavedConfig } from "./tools/context.js";
6
6
  // Per-environment endpoints so you can switch with `--env <name>` / WEBCAKE_ENV
package/dist/db.js CHANGED
@@ -1,96 +1,83 @@
1
- import Database from "better-sqlite3";
2
- import { mkdirSync } from "node:fs";
1
+ // Tiny JSON-file persistence (no native deps) for: (1) the saved connection config
2
+ // (token / session / site / api_url / confirm_mode) and (2) the image-alt cache.
3
+ //
4
+ // Stored under a stable home dir so it survives `npx` (ephemeral package cache) and
5
+ // container restarts. Two flat JSON files instead of SQLite — keeps the package light
6
+ // and works in any runtime (Alpine, Docker `--ignore-scripts`, serverless) with no
7
+ // native binding to build. The API is synchronous to match the call sites.
8
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
9
  import { homedir } from "node:os";
4
10
  import { join } from "node:path";
5
- // Persist in a stable home directory so saved config survives `npx` (where the
6
- // package lives in an ephemeral cache dir) and rebuilds.
7
11
  const CONFIG_DIR = process.env.WEBCAKE_CONFIG_DIR || join(homedir(), ".webcake-storefront-mcp");
8
12
  mkdirSync(CONFIG_DIR, { recursive: true });
9
- const DB_PATH = join(CONFIG_DIR, "webcake-mcp.db");
10
- const db = new Database(DB_PATH);
11
- // WAL mode for better concurrent reads
12
- db.pragma("journal_mode = WAL");
13
- // ── Schema ──
14
- db.exec(`
15
- CREATE TABLE IF NOT EXISTS config (
16
- key TEXT PRIMARY KEY,
17
- value TEXT NOT NULL
18
- );
19
- `);
20
- // ── Simple key-value helpers ──
21
- const stmtGet = db.prepare("SELECT value FROM config WHERE key = ?");
22
- const stmtSet = db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)");
23
- const stmtDel = db.prepare("DELETE FROM config WHERE key = ?");
24
- const stmtAll = db.prepare("SELECT key, value FROM config");
13
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
14
+ const ALT_FILE = join(CONFIG_DIR, "image-alt-cache.json");
15
+ function readJson(file, fallback) {
16
+ try {
17
+ return JSON.parse(readFileSync(file, "utf-8"));
18
+ }
19
+ catch {
20
+ return fallback;
21
+ }
22
+ }
23
+ function writeJson(file, data) {
24
+ try {
25
+ writeFileSync(file, JSON.stringify(data), "utf-8");
26
+ }
27
+ catch (e) {
28
+ console.error("[db] write failed:", e?.message ?? e);
29
+ }
30
+ }
31
+ // ── Config (key/value) ───────────────────────────────────────────────────────
32
+ const config = readJson(CONFIG_FILE, {});
25
33
  export function getConfig(key) {
26
- const row = stmtGet.get(key);
27
- return row ? row.value : null;
34
+ return key in config ? config[key] : null;
28
35
  }
29
36
  export function setConfig(key, value) {
30
- stmtSet.run(key, String(value));
37
+ config[key] = String(value);
38
+ writeJson(CONFIG_FILE, config);
31
39
  }
32
40
  export function delConfig(key) {
33
- stmtDel.run(key);
41
+ delete config[key];
42
+ writeJson(CONFIG_FILE, config);
34
43
  }
35
44
  export function getAllConfig() {
36
- const rows = stmtAll.all();
37
- const result = {};
38
- for (const row of rows)
39
- result[row.key] = row.value;
40
- return result;
45
+ return { ...config };
41
46
  }
42
- // ── Image alt cache ──
43
- db.exec(`
44
- CREATE TABLE IF NOT EXISTS image_alt_cache (
45
- url_key TEXT PRIMARY KEY,
46
- url TEXT NOT NULL,
47
- alt TEXT NOT NULL,
48
- source TEXT,
49
- updated_at INTEGER NOT NULL
50
- );
51
- `);
52
- const stmtAltGet = db.prepare("SELECT url_key, url, alt, source, updated_at FROM image_alt_cache WHERE url_key = ?");
53
- const stmtAltSet = db.prepare(`
54
- INSERT INTO image_alt_cache (url_key, url, alt, source, updated_at)
55
- VALUES (@url_key, @url, @alt, @source, @updated_at)
56
- ON CONFLICT(url_key) DO UPDATE SET
57
- url = excluded.url,
58
- alt = excluded.alt,
59
- source = excluded.source,
60
- updated_at = excluded.updated_at
61
- `);
62
- const stmtAltList = db.prepare("SELECT url_key, url, alt, source, updated_at FROM image_alt_cache ORDER BY updated_at DESC LIMIT ? OFFSET ?");
63
- const stmtAltCount = db.prepare("SELECT COUNT(*) AS n FROM image_alt_cache");
47
+ const altCache = readJson(ALT_FILE, {});
64
48
  export function getImageAlt(urlKey) {
65
- return stmtAltGet.get(urlKey) || null;
49
+ return altCache[urlKey] || null;
66
50
  }
67
51
  export function getImageAlts(urlKeys) {
68
52
  const out = new Map();
69
53
  for (const k of urlKeys) {
70
- const row = stmtAltGet.get(k);
54
+ const row = altCache[k];
71
55
  if (row)
72
56
  out.set(k, row);
73
57
  }
74
58
  return out;
75
59
  }
76
60
  export function setImageAlt({ url_key, url, alt, source = "ai" }) {
77
- stmtAltSet.run({ url_key, url, alt, source, updated_at: Date.now() });
61
+ altCache[url_key] = { url_key, url, alt, source, updated_at: Date.now() };
62
+ writeJson(ALT_FILE, altCache);
78
63
  }
79
- export const setImageAlts = db.transaction((items) => {
64
+ export function setImageAlts(items) {
80
65
  for (const it of items) {
81
- stmtAltSet.run({
66
+ altCache[it.url_key] = {
82
67
  url_key: it.url_key,
83
68
  url: it.url,
84
69
  alt: it.alt,
85
70
  source: it.source || "ai",
86
71
  updated_at: Date.now(),
87
- });
72
+ };
88
73
  }
89
- });
74
+ writeJson(ALT_FILE, altCache);
75
+ }
90
76
  export function listImageAlts(limit = 100, offset = 0) {
91
- return stmtAltList.all(limit, offset);
77
+ return Object.values(altCache)
78
+ .sort((a, b) => b.updated_at - a.updated_at)
79
+ .slice(offset, offset + limit);
92
80
  }
93
81
  export function countImageAlts() {
94
- return stmtAltCount.get().n;
82
+ return Object.keys(altCache).length;
95
83
  }
96
- export default db;
package/dist/http.js CHANGED
@@ -11,7 +11,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
11
11
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
12
12
  import { createServer } from "./server.js";
13
13
  import { makeApi, resolveEnv, ENVIRONMENTS, DEFAULT_ENV } from "./config.js";
14
- import { landingHtml, faviconSvg } from "./web-guide.js";
14
+ import { landingHtml, faviconSvg, ogImageSvg, normalizeLang } from "./web-guide.js";
15
15
  import { privacyHtml, termsHtml } from "./legal.js";
16
16
  import { registerClient, startAuthorize, completeAuthorize, exchangeToken, resolveAccessToken, revokeToken, authServerMetadata, protectedResourceMetadata, } from "./auth/oauth-server.js";
17
17
  const MCP_PATH = "/mcp";
@@ -177,7 +177,7 @@ async function handleOAuth(req, res, path) {
177
177
  }
178
178
  const raw = await readRawBody(req);
179
179
  const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
180
- const result = registerClient(body);
180
+ const result = await registerClient(body);
181
181
  if (!result.ok) {
182
182
  oauthError(res, 400, result.error, result.error_description);
183
183
  return true;
@@ -196,7 +196,7 @@ async function handleOAuth(req, res, path) {
196
196
  // ---- Authorize: validate + redirect to storefront consent page ----
197
197
  if (req.method === "GET" && path === OAUTH_AUTHORIZE) {
198
198
  const sp = new URL(req.url ?? "/", "http://x").searchParams;
199
- const result = startAuthorize({
199
+ const result = await startAuthorize({
200
200
  client_id: sp.get("client_id"),
201
201
  redirect_uri: sp.get("redirect_uri"),
202
202
  response_type: sp.get("response_type"),
@@ -235,7 +235,7 @@ async function handleOAuth(req, res, path) {
235
235
  // Accept both 'token' and 'jwt' from the SPA (login.ts uses both aliases).
236
236
  const jwt = sp.get("token") || sp.get("jwt");
237
237
  const wsid = sp.get("wsid") || sp.get("session_id") || "";
238
- const done = completeAuthorize(sp.get("state"), jwt, wsid);
238
+ const done = await completeAuthorize(sp.get("state"), jwt, wsid);
239
239
  if (!done.ok) {
240
240
  htmlError(res, 400, done.error_description);
241
241
  return true;
@@ -261,7 +261,7 @@ async function handleOAuth(req, res, path) {
261
261
  }
262
262
  const raw = await readRawBody(req);
263
263
  const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
264
- const result = exchangeToken(body);
264
+ const result = await exchangeToken(body);
265
265
  if (!result.ok) {
266
266
  oauthError(res, result.status, result.error, result.error_description);
267
267
  return true;
@@ -295,6 +295,12 @@ export async function startHttpServer(port) {
295
295
  res.end(faviconSvg());
296
296
  return;
297
297
  }
298
+ // ---- OG social card ----
299
+ if (req.method === "GET" && path === "/og.svg") {
300
+ res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" });
301
+ res.end(ogImageSvg());
302
+ return;
303
+ }
298
304
  // ---- Legal pages ----
299
305
  if (req.method === "GET" && (path === "/privacy" || path === "/privacy-policy")) {
300
306
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" });
@@ -312,8 +318,9 @@ export async function startHttpServer(port) {
312
318
  const accept = String(req.headers["accept"] ?? "");
313
319
  const ua = String(req.headers["user-agent"] ?? "");
314
320
  if (accept.includes("text/html") || BOT_UA.test(ua)) {
321
+ const lang = normalizeLang(new URL(req.url ?? "/", "http://x").searchParams.get("lang"));
315
322
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
316
- res.end(landingHtml(publicBase(req)));
323
+ res.end(landingHtml(publicBase(req), lang));
317
324
  return;
318
325
  }
319
326
  }
@@ -334,7 +341,7 @@ export async function startHttpServer(port) {
334
341
  // Resolve an OAuth Bearer access token to { jwt, wsid } and inject both headers
335
342
  // so apiFromRequest picks them up — existing header/query paths remain untouched.
336
343
  const bearer = bearerFrom(req);
337
- const oauthCred = resolveAccessToken(bearer);
344
+ const oauthCred = await resolveAccessToken(bearer);
338
345
  if (oauthCred) {
339
346
  if (req.headers["x-webcake-jwt"] == null) {
340
347
  req.headers["x-webcake-jwt"] = oauthCred.jwt;