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 +1 -1
- package/README.vi.md +1 -1
- package/dist/auth/oauth-server.js +235 -39
- package/dist/config.js +1 -1
- package/dist/db.js +48 -61
- package/dist/http.js +14 -7
- package/dist/legal.js +1 -1
- package/dist/persistence/postgres.js +132 -0
- package/dist/persistence/redis.js +66 -0
- package/dist/tools/context.js +2 -2
- package/dist/tools/images.js +3 -3
- package/dist/web-guide.js +912 -324
- package/package.json +6 -3
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
|
|
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
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
283
|
+
return (await getStore()).getClient(clientId);
|
|
119
284
|
}
|
|
120
|
-
export function startAuthorize(p) {
|
|
121
|
-
const
|
|
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
|
|
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
|
-
/**
|
|
203
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
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
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
return row ? row.value : null;
|
|
34
|
+
return key in config ? config[key] : null;
|
|
28
35
|
}
|
|
29
36
|
export function setConfig(key, value) {
|
|
30
|
-
|
|
37
|
+
config[key] = String(value);
|
|
38
|
+
writeJson(CONFIG_FILE, config);
|
|
31
39
|
}
|
|
32
40
|
export function delConfig(key) {
|
|
33
|
-
|
|
41
|
+
delete config[key];
|
|
42
|
+
writeJson(CONFIG_FILE, config);
|
|
34
43
|
}
|
|
35
44
|
export function getAllConfig() {
|
|
36
|
-
|
|
37
|
-
const result = {};
|
|
38
|
-
for (const row of rows)
|
|
39
|
-
result[row.key] = row.value;
|
|
40
|
-
return result;
|
|
45
|
+
return { ...config };
|
|
41
46
|
}
|
|
42
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
61
|
+
altCache[url_key] = { url_key, url, alt, source, updated_at: Date.now() };
|
|
62
|
+
writeJson(ALT_FILE, altCache);
|
|
78
63
|
}
|
|
79
|
-
export
|
|
64
|
+
export function setImageAlts(items) {
|
|
80
65
|
for (const it of items) {
|
|
81
|
-
|
|
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
|
|
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
|
|
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;
|