webcake-landing-mcp 1.0.77 → 1.0.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,49 +15,215 @@
15
15
  * - Token endpoint (POST /token) authorization_code + refresh_token
16
16
  * - Authorization Server + Protected Resource metadata (the /.well-known docs)
17
17
  *
18
- * Access tokens are OPAQUE random strings mapped to the user's `ljwt` in this
18
+ * Access tokens are OPAQUE random strings mapped to the user's `ljwt` in the
19
19
  * store (so they can be revoked and the ljwt never leaves the server). The HTTP
20
20
  * layer resolves a Bearer access token to its ljwt and injects it as the normal
21
21
  * `x-webcake-jwt` header, so the rest of the server (persistence/config.ts) is
22
22
  * UNCHANGED and the legacy `?jwt=` / `x-webcake-jwt` paths keep working untouched.
23
23
  *
24
- * STORE: in-memory + single-process. Fine for one `serve` instance; move the maps
25
- * to Redis (same interface) before running multiple instances behind a load balancer.
24
+ * STORE: Postgres when DATABASE_URL is set (tokens survive a `serve` restart and
25
+ * are shared across instances behind a load balancer), else in-memory maps
26
+ * (single-instance `serve`, stdio/`npx`, offline tests). Both implement the same
27
+ * async `OAuthStore` interface; the rest of this module is backend-agnostic. All
28
+ * exported state functions are async — callers (src/http.ts) `await` them.
26
29
  */
27
30
  import { randomBytes, createHash } from "node:crypto";
31
+ import { getPg, ensureOAuthSchema } from "../persistence/postgres.js";
28
32
  // ---- TTLs (override via env where useful) ---------------------------------
29
33
  const TEN_MIN = 10 * 60 * 1000;
30
34
  const ACCESS_TTL = Number(process.env.WEBCAKE_OAUTH_ACCESS_TTL_MS) || 60 * 60 * 1000; // 1h
31
35
  const REFRESH_TTL = Number(process.env.WEBCAKE_OAUTH_REFRESH_TTL_MS) || 30 * 24 * 60 * 60 * 1000; // 30d
32
36
  const CODE_TTL = TEN_MIN;
33
37
  const PENDING_TTL = TEN_MIN;
34
- // ---- In-memory maps -------------------------------------------------------
35
- const clients = new Map();
36
- const pending = new Map(); // key: internal state we send to /mcp-connect
37
- const codes = new Map(); // key: authorization code
38
- const accessTokens = new Map(); // key: access token
39
- const refreshTokens = new Map(); // key: refresh token
40
38
  function now() {
41
39
  return Date.now();
42
40
  }
43
41
  function token(bytes = 32) {
44
42
  return randomBytes(bytes).toString("base64url");
45
43
  }
46
- /** Lazy sweep of anything expired cheap, called on the hot paths. */
47
- function sweep() {
48
- const t = now();
49
- for (const [k, v] of pending)
50
- if (v.expiresAt < t)
51
- pending.delete(k);
52
- for (const [k, v] of codes)
53
- if (v.expiresAt < t)
54
- codes.delete(k);
55
- for (const [k, v] of accessTokens)
56
- if (v.expiresAt < t)
57
- accessTokens.delete(k);
58
- for (const [k, v] of refreshTokens)
59
- if (v.expiresAt < t)
60
- refreshTokens.delete(k);
44
+ // ---- In-memory store (default; no DATABASE_URL) ---------------------------
45
+ class MemoryStore {
46
+ clients = new Map();
47
+ pending = new Map();
48
+ codes = new Map();
49
+ access = new Map();
50
+ refresh = new Map();
51
+ /** Lazy sweep of anything expired — cheap, called on the hot paths. */
52
+ sweep() {
53
+ const t = now();
54
+ for (const [k, v] of this.pending)
55
+ if (v.expiresAt < t)
56
+ this.pending.delete(k);
57
+ for (const [k, v] of this.codes)
58
+ if (v.expiresAt < t)
59
+ this.codes.delete(k);
60
+ for (const [k, v] of this.access)
61
+ if (v.expiresAt < t)
62
+ this.access.delete(k);
63
+ for (const [k, v] of this.refresh)
64
+ if (v.expiresAt < t)
65
+ this.refresh.delete(k);
66
+ }
67
+ async putClient(c) {
68
+ this.clients.set(c.client_id, c);
69
+ }
70
+ async getClient(id) {
71
+ return this.clients.get(id);
72
+ }
73
+ async putPending(state, p) {
74
+ this.sweep();
75
+ this.pending.set(state, p);
76
+ }
77
+ async takePending(state) {
78
+ this.sweep();
79
+ const p = this.pending.get(state);
80
+ this.pending.delete(state);
81
+ return p && p.expiresAt >= now() ? p : undefined;
82
+ }
83
+ async putCode(code, c) {
84
+ this.codes.set(code, c);
85
+ }
86
+ async takeCode(code) {
87
+ this.sweep();
88
+ const c = this.codes.get(code);
89
+ this.codes.delete(code);
90
+ return c && c.expiresAt >= now() ? c : undefined;
91
+ }
92
+ async putAccess(t, a) {
93
+ this.access.set(t, a);
94
+ }
95
+ async getAccess(t) {
96
+ const a = this.access.get(t);
97
+ if (a && a.expiresAt < now()) {
98
+ this.access.delete(t);
99
+ return undefined;
100
+ }
101
+ return a;
102
+ }
103
+ async putRefresh(t, r) {
104
+ this.refresh.set(t, r);
105
+ }
106
+ async takeRefresh(t) {
107
+ this.sweep();
108
+ const r = this.refresh.get(t);
109
+ this.refresh.delete(t);
110
+ return r && r.expiresAt >= now() ? r : undefined;
111
+ }
112
+ async revoke(t) {
113
+ this.access.delete(t);
114
+ this.refresh.delete(t);
115
+ }
116
+ }
117
+ // ---- Postgres store (when DATABASE_URL is set) ----------------------------
118
+ class PgStore {
119
+ pool;
120
+ constructor(pool) {
121
+ this.pool = pool;
122
+ }
123
+ async putClient(c) {
124
+ await this.pool.query(`INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_at)
125
+ VALUES ($1,$2,$3,$4)
126
+ ON CONFLICT (client_id) DO UPDATE SET client_name=$2, redirect_uris=$3`, [c.client_id, c.client_name ?? null, JSON.stringify(c.redirect_uris), c.created_at]);
127
+ }
128
+ async getClient(id) {
129
+ const { rows } = await this.pool.query(`SELECT client_id, client_name, redirect_uris, created_at FROM oauth_clients WHERE client_id=$1`, [id]);
130
+ const r = rows[0];
131
+ if (!r)
132
+ return undefined;
133
+ return {
134
+ client_id: r.client_id,
135
+ client_name: r.client_name ?? undefined,
136
+ redirect_uris: Array.isArray(r.redirect_uris) ? r.redirect_uris : JSON.parse(r.redirect_uris),
137
+ created_at: Number(r.created_at),
138
+ };
139
+ }
140
+ async putPending(state, p) {
141
+ await this.pool.query(`INSERT INTO oauth_pending (state, client_id, redirect_uri, code_challenge, client_state, scope, expires_at)
142
+ VALUES ($1,$2,$3,$4,$5,$6,$7)`, [state, p.client_id, p.redirect_uri, p.code_challenge, p.state ?? null, p.scope ?? null, p.expiresAt]);
143
+ }
144
+ async takePending(state) {
145
+ const { rows } = await this.pool.query(`DELETE FROM oauth_pending WHERE state=$1 RETURNING client_id, redirect_uri, code_challenge, client_state, scope, expires_at`, [state]);
146
+ const r = rows[0];
147
+ if (!r || Number(r.expires_at) < now())
148
+ return undefined;
149
+ return {
150
+ client_id: r.client_id,
151
+ redirect_uri: r.redirect_uri,
152
+ code_challenge: r.code_challenge,
153
+ state: r.client_state ?? undefined,
154
+ scope: r.scope ?? undefined,
155
+ expiresAt: Number(r.expires_at),
156
+ };
157
+ }
158
+ async putCode(code, c) {
159
+ await this.pool.query(`INSERT INTO oauth_codes (code, client_id, redirect_uri, code_challenge, scope, ljwt, expires_at)
160
+ VALUES ($1,$2,$3,$4,$5,$6,$7)`, [code, c.client_id, c.redirect_uri, c.code_challenge, c.scope ?? null, c.ljwt, c.expiresAt]);
161
+ }
162
+ async takeCode(code) {
163
+ const { rows } = await this.pool.query(`DELETE FROM oauth_codes WHERE code=$1 RETURNING client_id, redirect_uri, code_challenge, scope, ljwt, expires_at`, [code]);
164
+ const r = rows[0];
165
+ if (!r || Number(r.expires_at) < now())
166
+ return undefined;
167
+ return {
168
+ client_id: r.client_id,
169
+ redirect_uri: r.redirect_uri,
170
+ code_challenge: r.code_challenge,
171
+ scope: r.scope ?? undefined,
172
+ ljwt: r.ljwt,
173
+ expiresAt: Number(r.expires_at),
174
+ };
175
+ }
176
+ async putAccess(t, a) {
177
+ await this.pool.query(`INSERT INTO oauth_access_tokens (token, ljwt, scope, expires_at) VALUES ($1,$2,$3,$4)`, [t, a.ljwt, a.scope ?? null, a.expiresAt]);
178
+ }
179
+ async getAccess(t) {
180
+ const { rows } = await this.pool.query(`SELECT ljwt, scope, expires_at FROM oauth_access_tokens WHERE token=$1`, [t]);
181
+ const r = rows[0];
182
+ if (!r)
183
+ return undefined;
184
+ if (Number(r.expires_at) < now()) {
185
+ await this.pool.query(`DELETE FROM oauth_access_tokens WHERE token=$1`, [t]);
186
+ return undefined;
187
+ }
188
+ return { ljwt: r.ljwt, scope: r.scope ?? undefined, expiresAt: Number(r.expires_at) };
189
+ }
190
+ async putRefresh(t, r) {
191
+ await this.pool.query(`INSERT INTO oauth_refresh_tokens (token, ljwt, client_id, scope, expires_at) VALUES ($1,$2,$3,$4,$5)`, [t, r.ljwt, r.client_id, r.scope ?? null, r.expiresAt]);
192
+ }
193
+ async takeRefresh(t) {
194
+ const { rows } = await this.pool.query(`DELETE FROM oauth_refresh_tokens WHERE token=$1 RETURNING ljwt, client_id, scope, expires_at`, [t]);
195
+ const r = rows[0];
196
+ if (!r || Number(r.expires_at) < now())
197
+ return undefined;
198
+ return {
199
+ ljwt: r.ljwt,
200
+ client_id: r.client_id,
201
+ scope: r.scope ?? undefined,
202
+ expiresAt: Number(r.expires_at),
203
+ };
204
+ }
205
+ async revoke(t) {
206
+ await this.pool.query(`DELETE FROM oauth_access_tokens WHERE token=$1`, [t]);
207
+ await this.pool.query(`DELETE FROM oauth_refresh_tokens WHERE token=$1`, [t]);
208
+ }
209
+ }
210
+ // ---- Backend selection (memoized) -----------------------------------------
211
+ const memoryStore = new MemoryStore();
212
+ let storePromise;
213
+ /** Resolve the active store ONCE: Postgres if configured + schema ready, else memory. */
214
+ function getStore() {
215
+ if (storePromise)
216
+ return storePromise;
217
+ storePromise = (async () => {
218
+ const pool = getPg();
219
+ if (pool && (await ensureOAuthSchema(pool)))
220
+ return new PgStore(pool);
221
+ return memoryStore;
222
+ })().catch((e) => {
223
+ console.error("[oauth] store init failed, using in-memory:", e?.message ?? e);
224
+ return memoryStore;
225
+ });
226
+ return storePromise;
61
227
  }
62
228
  // ---- PKCE -----------------------------------------------------------------
63
229
  /** base64url( SHA256(verifier) ) — the S256 transform. */
@@ -76,7 +242,7 @@ export function verifyPkce(verifier, challenge) {
76
242
  diff |= a.charCodeAt(i) ^ challenge.charCodeAt(i);
77
243
  return diff === 0;
78
244
  }
79
- export function registerClient(body) {
245
+ export async function registerClient(body) {
80
246
  const uris = Array.isArray(body?.redirect_uris)
81
247
  ? body.redirect_uris.filter((u) => typeof u === "string" && /^https?:\/\//i.test(u))
82
248
  : [];
@@ -89,11 +255,13 @@ export function registerClient(body) {
89
255
  redirect_uris: uris,
90
256
  created_at: now(),
91
257
  };
92
- clients.set(client.client_id, client);
258
+ (await getStore()).putClient(client).catch((e) => console.error("[oauth] putClient:", e?.message ?? e));
93
259
  return { ok: true, client };
94
260
  }
95
- export function getClient(clientId) {
96
- return clientId ? clients.get(clientId) : undefined;
261
+ export async function getClient(clientId) {
262
+ if (!clientId)
263
+ return undefined;
264
+ return (await getStore()).getClient(clientId);
97
265
  }
98
266
  /**
99
267
  * Validate an /authorize request and park it. Returns an `internalState` to send
@@ -101,9 +269,9 @@ export function getClient(clientId) {
101
269
  * `redirectable: false` means the error must be shown as a page (we can't trust
102
270
  * the redirect_uri); `true` means it's safe to bounce the error to the client.
103
271
  */
104
- export function startAuthorize(p) {
105
- sweep();
106
- const client = getClient(p.client_id);
272
+ export async function startAuthorize(p) {
273
+ const store = await getStore();
274
+ const client = p.client_id ? await store.getClient(p.client_id) : undefined;
107
275
  if (!client) {
108
276
  return { ok: false, error: "invalid_client", error_description: "Unknown client_id. Register first via /register.", redirectable: false };
109
277
  }
@@ -118,7 +286,7 @@ export function startAuthorize(p) {
118
286
  return { ok: false, error: "invalid_request", error_description: "PKCE with code_challenge_method=S256 is required.", redirectable: true };
119
287
  }
120
288
  const internalState = token(24);
121
- pending.set(internalState, {
289
+ await store.putPending(internalState, {
122
290
  client_id: client.client_id,
123
291
  redirect_uri: p.redirect_uri,
124
292
  code_challenge: p.code_challenge,
@@ -134,18 +302,17 @@ export function startAuthorize(p) {
134
302
  * parked PKCE challenge, and return where to redirect the user (the client's
135
303
  * redirect_uri with ?code=&state=).
136
304
  */
137
- export function completeAuthorize(internalState, ljwt) {
138
- sweep();
139
- if (!internalState || !pending.has(internalState)) {
305
+ export async function completeAuthorize(internalState, ljwt) {
306
+ const store = await getStore();
307
+ const p = internalState ? await store.takePending(internalState) : undefined;
308
+ if (!p) {
140
309
  return { ok: false, error: "invalid_request", error_description: "Authorization session expired or unknown — restart the connection." };
141
310
  }
142
- const p = pending.get(internalState);
143
- pending.delete(internalState);
144
311
  if (!ljwt) {
145
312
  return { ok: false, error: "access_denied", error_description: "No Webcake token returned from login." };
146
313
  }
147
314
  const code = token(32);
148
- codes.set(code, {
315
+ await store.putCode(code, {
149
316
  client_id: p.client_id,
150
317
  redirect_uri: p.redirect_uri,
151
318
  code_challenge: p.code_challenge,
@@ -155,21 +322,20 @@ export function completeAuthorize(internalState, ljwt) {
155
322
  });
156
323
  return { ok: true, redirectUri: p.redirect_uri, code, state: p.state };
157
324
  }
158
- function issueTokens(ljwt, client_id, scope) {
325
+ async function issueTokens(store, ljwt, client_id, scope) {
159
326
  const access = token(32);
160
327
  const refresh = token(32);
161
- accessTokens.set(access, { ljwt, scope, expiresAt: now() + ACCESS_TTL });
162
- refreshTokens.set(refresh, { ljwt, client_id, scope, expiresAt: now() + REFRESH_TTL });
328
+ await store.putAccess(access, { ljwt, scope, expiresAt: now() + ACCESS_TTL });
329
+ await store.putRefresh(refresh, { ljwt, client_id, scope, expiresAt: now() + REFRESH_TTL });
163
330
  return { access_token: access, token_type: "Bearer", expires_in: Math.floor(ACCESS_TTL / 1000), refresh_token: refresh, scope };
164
331
  }
165
- export function exchangeToken(p) {
166
- sweep();
332
+ export async function exchangeToken(p) {
333
+ const store = await getStore();
167
334
  if (p.grant_type === "authorization_code") {
168
- if (!p.code || !codes.has(p.code)) {
335
+ const c = p.code ? await store.takeCode(p.code) : undefined; // one-time use
336
+ if (!c) {
169
337
  return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired authorization code." };
170
338
  }
171
- const c = codes.get(p.code);
172
- codes.delete(p.code); // one-time use
173
339
  if (c.client_id !== p.client_id) {
174
340
  return { ok: false, status: 400, error: "invalid_grant", error_description: "client_id does not match the authorization code." };
175
341
  }
@@ -179,38 +345,30 @@ export function exchangeToken(p) {
179
345
  if (!p.code_verifier || !verifyPkce(p.code_verifier, c.code_challenge)) {
180
346
  return { ok: false, status: 400, error: "invalid_grant", error_description: "PKCE verification failed." };
181
347
  }
182
- return { ok: true, body: issueTokens(c.ljwt, c.client_id, c.scope) };
348
+ return { ok: true, body: await issueTokens(store, c.ljwt, c.client_id, c.scope) };
183
349
  }
184
350
  if (p.grant_type === "refresh_token") {
185
- if (!p.refresh_token || !refreshTokens.has(p.refresh_token)) {
351
+ const r = p.refresh_token ? await store.takeRefresh(p.refresh_token) : undefined; // rotate
352
+ if (!r) {
186
353
  return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired refresh token." };
187
354
  }
188
- const r = refreshTokens.get(p.refresh_token);
189
- refreshTokens.delete(p.refresh_token); // rotate
190
- return { ok: true, body: issueTokens(r.ljwt, r.client_id, r.scope) };
355
+ return { ok: true, body: await issueTokens(store, r.ljwt, r.client_id, r.scope) };
191
356
  }
192
357
  return { ok: false, status: 400, error: "unsupported_grant_type", error_description: "grant_type must be authorization_code or refresh_token." };
193
358
  }
194
359
  // ---- Resource-server side: resolve a Bearer access token to its ljwt -------
195
360
  /** Returns the user's ljwt for a valid, unexpired access token, else undefined. */
196
- export function resolveAccessToken(accessToken) {
361
+ export async function resolveAccessToken(accessToken) {
197
362
  if (!accessToken)
198
363
  return undefined;
199
- const a = accessTokens.get(accessToken);
200
- if (!a)
201
- return undefined;
202
- if (a.expiresAt < now()) {
203
- accessTokens.delete(accessToken);
204
- return undefined;
205
- }
206
- return a.ljwt;
364
+ const a = await (await getStore()).getAccess(accessToken);
365
+ return a?.ljwt;
207
366
  }
208
367
  /** Revoke an access or refresh token (best-effort; for /revoke). */
209
- export function revokeToken(t) {
368
+ export async function revokeToken(t) {
210
369
  if (!t)
211
370
  return;
212
- accessTokens.delete(t);
213
- refreshTokens.delete(t);
371
+ await (await getStore()).revoke(t);
214
372
  }
215
373
  // ---- Metadata documents (RFC 8414 / RFC 9728) -----------------------------
216
374
  /** /.well-known/oauth-authorization-server */
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.0.78",
4
+ "d": "15/06/2026",
5
+ "type": "Added",
6
+ "en": "The OAuth token store (clients, authorization codes, access and refresh tokens) now uses Postgres when DATABASE_URL, WEBCAKE_POSTGRES_URL, or…",
7
+ "vi": "Kho lưu trữ OAuth token (clients, authorization code, access và refresh token) nay sử dụng Postgres khi DATABASE_URL, WEBCAKE_POSTGRES_URL, hoặc…"
8
+ },
2
9
  {
3
10
  "v": "1.0.77",
4
11
  "d": "15/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Added",
34
41
  "en": "ingest_html and ingest_url now extract the full design system from a tailwind.config script block when present (Google Stitch and other Tailwind-CDN…",
35
42
  "vi": "ingest_html và ingest_url nay trích xuất toàn bộ design system từ block script tailwind.config khi có (trang Google Stitch và các trang Tailwind-CDN…"
36
- },
37
- {
38
- "v": "1.0.72",
39
- "d": "13/06/2026",
40
- "type": "Fixed",
41
- "en": "When cloning a LadiPage or Webcake-published page via ingest_html / ingest_url, html-box elements' passthrough HTML content now has its…",
42
- "vi": "Khi clone trang LadiPage hoặc Webcake-published qua ingest_html / ingest_url, nội dung HTML passthrough của các phần tử html-box nay được đổi tên…"
43
43
  }
44
44
  ]
package/dist/http.js CHANGED
@@ -189,7 +189,7 @@ async function handleOAuth(req, res, path) {
189
189
  return oauthError(res, 405, "invalid_request", "Use POST."), true;
190
190
  const raw = await readRawBody(req);
191
191
  const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
192
- const result = registerClient(body);
192
+ const result = await registerClient(body);
193
193
  if (!result.ok)
194
194
  return oauthError(res, 400, result.error, result.error_description), true;
195
195
  res.writeHead(201, { "content-type": "application/json", "access-control-allow-origin": "*", "cache-control": "no-store" });
@@ -206,7 +206,7 @@ async function handleOAuth(req, res, path) {
206
206
  // ---- Authorize: validate + delegate to the SPA login, parking the request ----
207
207
  if (req.method === "GET" && path === OAUTH_AUTHORIZE) {
208
208
  const sp = new URL(req.url ?? "/", "http://x").searchParams;
209
- const result = startAuthorize({
209
+ const result = await startAuthorize({
210
210
  client_id: sp.get("client_id"),
211
211
  redirect_uri: sp.get("redirect_uri"),
212
212
  response_type: sp.get("response_type"),
@@ -238,7 +238,7 @@ async function handleOAuth(req, res, path) {
238
238
  // ---- Login callback: the SPA handed back the user's ljwt → mint a code ----
239
239
  if (req.method === "GET" && path === OAUTH_CALLBACK) {
240
240
  const sp = new URL(req.url ?? "/", "http://x").searchParams;
241
- const done = completeAuthorize(sp.get("state"), sp.get("token"));
241
+ const done = await completeAuthorize(sp.get("state"), sp.get("token"));
242
242
  if (!done.ok)
243
243
  return htmlError(res, 400, done.error_description), true;
244
244
  const r = new URL(done.redirectUri);
@@ -258,7 +258,7 @@ async function handleOAuth(req, res, path) {
258
258
  return oauthError(res, 405, "invalid_request", "Use POST."), true;
259
259
  const raw = await readRawBody(req);
260
260
  const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
261
- const result = exchangeToken(body);
261
+ const result = await exchangeToken(body);
262
262
  if (!result.ok)
263
263
  return oauthError(res, result.status, result.error, result.error_description), true;
264
264
  res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": "*", "cache-control": "no-store" });
@@ -271,7 +271,7 @@ async function handleOAuth(req, res, path) {
271
271
  return oauthError(res, 405, "invalid_request", "Use POST."), true;
272
272
  const raw = await readRawBody(req);
273
273
  const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
274
- revokeToken(body.token);
274
+ await revokeToken(body.token);
275
275
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
276
276
  res.end("{}");
277
277
  return true;
@@ -393,7 +393,7 @@ export async function startHttpServer(port) {
393
393
  // normal x-webcake-jwt header, so persistence/config.ts is unchanged. A legacy
394
394
  // raw JWT sent via x-webcake-jwt / ?jwt= still wins and passes straight through.
395
395
  const bearer = bearerFrom(req);
396
- const oauthLjwt = resolveAccessToken(bearer);
396
+ const oauthLjwt = await resolveAccessToken(bearer);
397
397
  if (oauthLjwt && req.headers["x-webcake-jwt"] == null) {
398
398
  req.headers["x-webcake-jwt"] = oauthLjwt;
399
399
  req.rawHeaders.push("x-webcake-jwt", oauthLjwt);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tiny in-memory store for sources that need a cache — three kinds:
2
+ * Tiny store for sources that need a cache — three kinds:
3
3
  *
4
4
  * - 'page' (default/absent): a full page source whose create_page FAILED validation
5
5
  * OR whose create_page network call failed/timed-out after validation passed.
@@ -23,20 +23,30 @@
23
23
  * Commit path: update_page({draft_id, dry_run:false}) or
24
24
  * patch_page({draft_id, patches?, dry_run:false}).
25
25
  *
26
+ * BACKEND: Redis when REDIS_URL is set (so drafts survive a restart and are shared
27
+ * across `serve` instances), else an in-memory Map (stdio/`npx`/offline smoke). The
28
+ * cache is DISPOSABLE either way — a lost draft (process restart, eviction, expiry)
29
+ * just means the model falls back to re-sending the full source, never a failure.
30
+ *
26
31
  * Bounded + TTL'd (SLIDING: every get/update refreshes the clock, so a draft being
27
- * actively worked on never expires mid-workflow); a lost draft (process restart,
28
- * eviction, expiry) just means the model falls back to re-sending the full source —
29
- * never a failure.
32
+ * actively worked on never expires mid-workflow). Redis does the sliding TTL natively
33
+ * via PEXPIRE; the memory path sweeps on each touch.
34
+ *
30
35
  * Process-global, but draft_ids are random/unguessable AND persisting still uses the
31
36
  * CALLER's own creds, so a draft only ever yields a page in the caller's account.
37
+ *
38
+ * All functions are async (the Redis backend is async). Callers `await` them.
32
39
  */
33
40
  import { randomUUID } from "node:crypto";
41
+ import { getRedis } from "./redis.js";
34
42
  /** Draft lifetime — default 2 hours. Override via WEBCAKE_DRAFT_TTL_MS env. */
35
43
  const TTL_MS = (() => {
36
44
  const v = parseInt(process.env.WEBCAKE_DRAFT_TTL_MS ?? "", 10);
37
45
  return Number.isFinite(v) && v > 0 ? v : 2 * 60 * 60 * 1000;
38
46
  })();
39
47
  const MAX_ENTRIES = 50;
48
+ const REDIS_PREFIX = "wcl:draft:";
49
+ // ---- In-memory fallback (used when no REDIS_URL) ---------------------------
40
50
  const store = new Map();
41
51
  function sweep(now) {
42
52
  for (const [id, d] of store)
@@ -57,16 +67,46 @@ function sweep(now) {
57
67
  break;
58
68
  }
59
69
  }
70
+ function newId() {
71
+ return `draft_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
72
+ }
60
73
  /** Cache a failed source. Returns the draft_id to hand back to the model. */
61
- export function putDraft(draft) {
74
+ export async function putDraft(draft) {
62
75
  const now = Date.now();
76
+ const id = newId();
77
+ const entry = { ...draft, created: now };
78
+ const redis = getRedis();
79
+ if (redis) {
80
+ try {
81
+ await redis.set(REDIS_PREFIX + id, JSON.stringify(entry), "PX", TTL_MS);
82
+ return id;
83
+ }
84
+ catch (e) {
85
+ console.error("[draft-cache] redis put failed, using memory:", e?.message ?? e);
86
+ }
87
+ }
63
88
  sweep(now);
64
- const id = `draft_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
65
- store.set(id, { ...draft, created: now });
89
+ store.set(id, entry);
66
90
  return id;
67
91
  }
68
92
  /** Replace a draft's source after applying patches (refreshes its TTL). */
69
- export function updateDraft(id, source) {
93
+ export async function updateDraft(id, source) {
94
+ const redis = getRedis();
95
+ if (redis) {
96
+ try {
97
+ const raw = await redis.get(REDIS_PREFIX + id);
98
+ if (raw) {
99
+ const entry = JSON.parse(raw);
100
+ entry.source = source;
101
+ entry.created = Date.now();
102
+ await redis.set(REDIS_PREFIX + id, JSON.stringify(entry), "PX", TTL_MS);
103
+ }
104
+ return;
105
+ }
106
+ catch (e) {
107
+ console.error("[draft-cache] redis update failed, using memory:", e?.message ?? e);
108
+ }
109
+ }
70
110
  const d = store.get(id);
71
111
  if (d) {
72
112
  d.source = source;
@@ -74,7 +114,22 @@ export function updateDraft(id, source) {
74
114
  }
75
115
  }
76
116
  /** Fetch a live (non-expired) draft, or null if missing/expired. Refreshes the TTL (sliding expiration) so an in-progress workflow never loses its draft. */
77
- export function getDraft(id) {
117
+ export async function getDraft(id) {
118
+ const redis = getRedis();
119
+ if (redis) {
120
+ try {
121
+ const raw = await redis.get(REDIS_PREFIX + id);
122
+ if (!raw)
123
+ return null;
124
+ await redis.pexpire(REDIS_PREFIX + id, TTL_MS); // slide the TTL on every touch
125
+ const entry = JSON.parse(raw);
126
+ entry.created = Date.now();
127
+ return entry;
128
+ }
129
+ catch (e) {
130
+ console.error("[draft-cache] redis get failed, using memory:", e?.message ?? e);
131
+ }
132
+ }
78
133
  const now = Date.now();
79
134
  sweep(now);
80
135
  const d = store.get(id);
@@ -82,6 +137,16 @@ export function getDraft(id) {
82
137
  d.created = now;
83
138
  return d ?? null;
84
139
  }
85
- export function deleteDraft(id) {
140
+ export async function deleteDraft(id) {
141
+ const redis = getRedis();
142
+ if (redis) {
143
+ try {
144
+ await redis.del(REDIS_PREFIX + id);
145
+ return;
146
+ }
147
+ catch (e) {
148
+ console.error("[draft-cache] redis delete failed, using memory:", e?.message ?? e);
149
+ }
150
+ }
86
151
  store.delete(id);
87
152
  }