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.
- package/dist/auth/oauth-server.js +221 -63
- package/dist/changelog.json +7 -7
- package/dist/http.js +6 -6
- package/dist/persistence/draft-cache.js +75 -10
- package/dist/persistence/postgres.js +120 -0
- package/dist/persistence/redis.js +64 -0
- package/dist/persistence/rehost-cache.js +45 -0
- package/dist/persistence/rehost.js +3 -3
- package/dist/persistence/webcake-client.js +7 -9
- package/dist/smoke.js +33 -33
- package/dist/tools/persistence.js +50 -50
- package/package.json +5 -1
|
@@ -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
|
|
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:
|
|
25
|
-
*
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
332
|
+
export async function exchangeToken(p) {
|
|
333
|
+
const store = await getStore();
|
|
167
334
|
if (p.grant_type === "authorization_code") {
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
200
|
-
|
|
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
|
-
|
|
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 */
|
package/dist/changelog.json
CHANGED
|
@@ -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
|
|
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)
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
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
|
}
|