webcake-storefront-mcp 1.1.0 → 1.1.1

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.
@@ -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/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;
package/dist/legal.js CHANGED
@@ -18,7 +18,7 @@ function page(title, bodyHtml) {
18
18
  h1{font-size:1.9rem;margin:0 0 4px}
19
19
  h2{font-size:1.2rem;margin:32px 0 8px}
20
20
  .meta{color:#64748b;font-size:.9rem;margin-bottom:28px}
21
- a{color:#108B67}
21
+ a{color:#6d5efc}
22
22
  code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:rgba(127,127,127,.15);padding:1px 5px;border-radius:4px}
23
23
  ul{padding-left:22px}
24
24
  footer{margin-top:48px;padding-top:20px;border-top:1px solid rgba(127,127,127,.25);color:#64748b;font-size:.85rem}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Lazy, shared Postgres pool used to PERSIST the OAuth 2.1 Authorization Server
3
+ * state (clients, pending auths, codes, access + refresh tokens) so tokens
4
+ * survive a `serve` restart and are shared across instances behind a load
5
+ * balancer — unlike the caches (Redis/disposable), OAuth state is durable.
6
+ *
7
+ * Returns null when no DATABASE_URL is configured OR `pg` isn't installed — the
8
+ * OAuth store then falls back to in-memory maps, so single-instance `serve`,
9
+ * stdio/`npx`, and the offline smoke gate keep working with ZERO infra.
10
+ *
11
+ * `pg` is an OPTIONAL, CJS dependency (see package.json), required via
12
+ * createRequire under ESM/Node16. The pool connects lazily per query.
13
+ *
14
+ * Configure with DATABASE_URL (or WEBCAKE_POSTGRES_URL), e.g.
15
+ * postgres://user:pw@host:5432/webcake_storefront
16
+ *
17
+ * KEY DIFFERENCE vs. landing-mcp: the credential is a PAIR (jwt + wsid), so
18
+ * oauth_codes / oauth_access_tokens / oauth_refresh_tokens carry two columns
19
+ * (`jwt text NOT NULL` and `wsid text`) instead of a single `ljwt` column.
20
+ */
21
+ import { createRequire } from "node:module";
22
+ const require = createRequire(import.meta.url);
23
+ let cached; // undefined = not yet resolved
24
+ function redactUrl(u) {
25
+ try {
26
+ const x = new URL(u);
27
+ if (x.password)
28
+ x.password = "***";
29
+ return x.toString();
30
+ }
31
+ catch {
32
+ return "postgres";
33
+ }
34
+ }
35
+ /**
36
+ * Returns the shared Postgres pool, or null if Postgres isn't configured/available.
37
+ * Memoized: resolves the pool (or its absence) exactly once per process.
38
+ */
39
+ export function getPg() {
40
+ if (cached !== undefined)
41
+ return cached;
42
+ const url = process.env.DATABASE_URL || process.env.WEBCAKE_POSTGRES_URL;
43
+ if (!url)
44
+ return (cached = null);
45
+ try {
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
47
+ const { Pool } = require("pg");
48
+ const pool = new Pool({
49
+ connectionString: url,
50
+ max: Number(process.env.WEBCAKE_PG_POOL_MAX) || 5,
51
+ // Managed Postgres (Supabase, Neon, …) often requires TLS; allow opting in
52
+ // without verifying the chain via WEBCAKE_PG_SSL=1.
53
+ ssl: /^(1|true|yes|on)$/i.test(process.env.WEBCAKE_PG_SSL ?? "")
54
+ ? { rejectUnauthorized: false }
55
+ : undefined,
56
+ });
57
+ pool.on("error", (e) => console.error("[pg] pool error:", e?.message ?? e));
58
+ console.error(`[pg] OAuth store backend: ${redactUrl(url)}`);
59
+ cached = pool;
60
+ }
61
+ catch (e) {
62
+ console.error("[pg] unavailable, using in-memory OAuth store:", e?.message ?? e);
63
+ cached = null;
64
+ }
65
+ return cached;
66
+ }
67
+ /**
68
+ * Create the OAuth tables if absent. Idempotent and memoized to a single
69
+ * in-flight promise per process, so concurrent callers share one round-trip. On
70
+ * any failure it logs and resolves false; the caller degrades to in-memory.
71
+ *
72
+ * Storefront schema: codes/tokens carry TWO credential columns:
73
+ * jwt text NOT NULL — the user's WebCake JWT
74
+ * wsid text — the WebCake session/workspace ID (may be empty)
75
+ */
76
+ let schemaReady;
77
+ export function ensureOAuthSchema(pool) {
78
+ if (schemaReady)
79
+ return schemaReady;
80
+ schemaReady = (async () => {
81
+ try {
82
+ await pool.query(`
83
+ CREATE TABLE IF NOT EXISTS oauth_clients (
84
+ client_id text PRIMARY KEY,
85
+ client_name text,
86
+ redirect_uris jsonb NOT NULL,
87
+ created_at bigint NOT NULL
88
+ );
89
+ CREATE TABLE IF NOT EXISTS oauth_pending (
90
+ state text PRIMARY KEY,
91
+ client_id text NOT NULL,
92
+ redirect_uri text NOT NULL,
93
+ code_challenge text NOT NULL,
94
+ client_state text,
95
+ scope text,
96
+ expires_at bigint NOT NULL
97
+ );
98
+ CREATE TABLE IF NOT EXISTS oauth_codes (
99
+ code text PRIMARY KEY,
100
+ client_id text NOT NULL,
101
+ redirect_uri text NOT NULL,
102
+ code_challenge text NOT NULL,
103
+ scope text,
104
+ jwt text NOT NULL,
105
+ wsid text,
106
+ expires_at bigint NOT NULL
107
+ );
108
+ CREATE TABLE IF NOT EXISTS oauth_access_tokens (
109
+ token text PRIMARY KEY,
110
+ jwt text NOT NULL,
111
+ wsid text,
112
+ scope text,
113
+ expires_at bigint NOT NULL
114
+ );
115
+ CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
116
+ token text PRIMARY KEY,
117
+ jwt text NOT NULL,
118
+ wsid text,
119
+ client_id text NOT NULL,
120
+ scope text,
121
+ expires_at bigint NOT NULL
122
+ );
123
+ `);
124
+ return true;
125
+ }
126
+ catch (e) {
127
+ console.error("[pg] OAuth schema init failed, using in-memory:", e?.message ?? e);
128
+ return false;
129
+ }
130
+ })();
131
+ return schemaReady;
132
+ }