dewee-webchat 0.4.2 → 0.5.0-beta.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/dist/server.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  parseJobAccessLaunch,
14
14
  serializePageContext,
15
15
  stripPageBlock
16
- } from "./chunk-SAUDVPFH.js";
16
+ } from "./chunk-KXW4EWXA.js";
17
17
 
18
18
  // src/server/admission-errors.ts
19
19
  var MESSAGES = {
@@ -49,15 +49,17 @@ function admissionErrorResponse(code, retryAfterMs) {
49
49
  // src/server/gateway-client.ts
50
50
  var GatewayError = class extends Error {
51
51
  /** `retryAfterMs` is the gateway's raw value for retryable refusals (`RATE_LIMITED`), unvalidated. */
52
- constructor(message, code, remediation, retryAfterMs) {
52
+ constructor(message, code, remediation, retryAfterMs, reason) {
53
53
  super(message);
54
54
  this.code = code;
55
55
  this.remediation = remediation;
56
56
  this.retryAfterMs = retryAfterMs;
57
+ this.reason = reason;
57
58
  }
58
59
  code;
59
60
  remediation;
60
61
  retryAfterMs;
62
+ reason;
61
63
  };
62
64
  function parseRemediation(raw) {
63
65
  if (raw === null || typeof raw !== "object") return void 0;
@@ -110,12 +112,14 @@ var GatewayConnection = class _GatewayConnection {
110
112
  pending = /* @__PURE__ */ new Map();
111
113
  listeners = /* @__PURE__ */ new Set();
112
114
  closed = false;
115
+ pageContextVersions = [];
113
116
  static async connect(creds, actor, timeoutMs = 15e3) {
114
117
  const conn = new _GatewayConnection(await openSocket(creds.url, timeoutMs));
115
118
  const params = typeof actor === "string" ? { token: creds.token, user_id: actor } : { token: creds.token, identity_session: actor.identitySession };
116
119
  if (creds.tenantId) params.tenant_id = creds.tenantId;
117
120
  try {
118
121
  const res = await conn.call("connect", params, timeoutMs);
122
+ conn.pageContextVersions = Array.isArray(res?.page_context_versions) && res.page_context_versions.includes(1) ? [1] : [];
119
123
  if (typeof actor !== "string" && !isIdentityUser(res?.user_id)) {
120
124
  throw new GatewayError("Gateway did not apply the identity session", "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "operator" });
121
125
  }
@@ -173,7 +177,7 @@ var GatewayConnection = class _GatewayConnection {
173
177
  if (frame.ok) p.resolve(frame.payload);
174
178
  else {
175
179
  const e = frame.error;
176
- p.reject(new GatewayError(e?.message || "Gateway request failed", e?.code, parseRemediation(e?.details?.remediation), e?.retryAfterMs));
180
+ p.reject(new GatewayError(e?.message || "Gateway request failed", e?.code, parseRemediation(e?.details?.remediation), e?.retryAfterMs, e?.details?.reason));
177
181
  }
178
182
  } else if (frame.type === "event" && frame.event) {
179
183
  for (const cb of this.listeners) cb(frame.event, frame.payload);
@@ -243,11 +247,12 @@ async function* streamChat(opts) {
243
247
  end();
244
248
  };
245
249
  opts.signal?.addEventListener("abort", onAbort, { once: true });
246
- conn.call("chat.send", { agentId: opts.agent, sessionKey: opts.sessionKey, message: opts.message, stream: true }, opts.timeoutMs ?? 18e4).then((res) => {
250
+ conn.call("chat.send", { agentId: opts.agent, sessionKey: opts.sessionKey, message: opts.message, stream: true, ...opts.pageContext !== void 0 ? { pageContext: opts.pageContext } : {} }, opts.timeoutMs ?? 18e4).then((res) => {
247
251
  push({ type: "done", content: res?.content ?? "", usage: res?.usage });
248
252
  end();
249
253
  }).catch((err) => {
250
- if (!finished && isAdmissionErrorCode(err.code)) push(admissionErrorEvent(err.code, err.retryAfterMs, err.message));
254
+ if (!finished && (err.reason === "page_context_invalid" || err.reason === "page_context_unsupported")) push({ type: "error", code: err.reason, message: err.reason === "page_context_invalid" ? "Invalid page context" : "Page context is not supported" });
255
+ else if (!finished && isAdmissionErrorCode(err.code)) push(admissionErrorEvent(err.code, err.retryAfterMs, err.message));
251
256
  else if (!finished) push(err.remediation ? { type: "error", message: err.message, code: err.code, remediation: err.remediation } : { type: "error", message: err.message, code: err.code });
252
257
  end();
253
258
  });
@@ -760,6 +765,67 @@ function resolveTransport(o) {
760
765
  return transport;
761
766
  }
762
767
 
768
+ // src/server/page-context.ts
769
+ async function readChatBody(request) {
770
+ const limit = 128 * 1024;
771
+ const length = request.headers.get("content-length");
772
+ if (length && (!/^\d+$/.test(length) || Number(length) > limit)) return { error: "too_large" };
773
+ if (!request.body) return { error: "invalid" };
774
+ const reader = request.body.getReader();
775
+ const chunks = [];
776
+ let size = 0;
777
+ try {
778
+ for (; ; ) {
779
+ const { done, value } = await reader.read();
780
+ if (done) break;
781
+ size += value.byteLength;
782
+ if (size > limit) {
783
+ await reader.cancel();
784
+ return { error: "too_large" };
785
+ }
786
+ chunks.push(value);
787
+ }
788
+ const bytes = new Uint8Array(size);
789
+ let offset = 0;
790
+ for (const chunk of chunks) {
791
+ bytes.set(chunk, offset);
792
+ offset += chunk.length;
793
+ }
794
+ return { body: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) };
795
+ } catch {
796
+ return { error: "invalid" };
797
+ } finally {
798
+ reader.releaseLock();
799
+ }
800
+ }
801
+ function validPageContext(value) {
802
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
803
+ const o = value;
804
+ const allowed = /* @__PURE__ */ new Set(["version", "url", "title", "description", "captured_at", "selection", "content", "source", "trust_level"]);
805
+ if (Object.keys(o).some((key) => !allowed.has(key)) || o.version !== 1 || typeof o.url !== "string") return false;
806
+ if (new TextEncoder().encode(JSON.stringify(o)).length > 65536 || new TextEncoder().encode(o.url).length > 2048) return false;
807
+ try {
808
+ const url = new URL(o.url);
809
+ if (!["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password) return false;
810
+ } catch {
811
+ return false;
812
+ }
813
+ const limits = { title: 300, description: 1e3, selection: 2e3, captured_at: 64 };
814
+ for (const [key, max] of Object.entries(limits)) {
815
+ const item = o[key];
816
+ if (item != null && (typeof item !== "string" || Array.from(item).length > max)) return false;
817
+ }
818
+ if (o.content != null) {
819
+ if (typeof o.content !== "object" || Array.isArray(o.content)) return false;
820
+ const c = o.content;
821
+ if (Object.keys(c).some((key) => !["format", "text", "truncated"].includes(key))) return false;
822
+ if (c.format != null && c.format !== "text" && c.format !== "markdown") return false;
823
+ if (c.text != null && (typeof c.text !== "string" || Array.from(c.text).length > 12e3)) return false;
824
+ if (c.truncated != null && typeof c.truncated !== "boolean") return false;
825
+ }
826
+ return true;
827
+ }
828
+
763
829
  // src/server/job-access.ts
764
830
  var JobAccessRefusal = class extends Error {
765
831
  constructor(code) {
@@ -827,6 +893,10 @@ function browserErrorFor(e, route) {
827
893
  if (e.code === "rate_limited" || e.code === "concurrency_limited" || e.status === 429) return { status: 429, code: "RATE_LIMITED", message: "", admission: "RATE_LIMITED", retryAfterMs };
828
894
  }
829
895
  switch (e.code) {
896
+ case "page_context_invalid":
897
+ return { status: 400, code: e.code, message: "Invalid page context" };
898
+ case "page_context_unsupported":
899
+ return { status: 400, code: e.code, message: "Page context is not supported" };
830
900
  case "invalid_request":
831
901
  log("error", "- the ingress refused the request shape; check the dewee-webchat version");
832
902
  return { status: 400, code: "bad_request", message: "Invalid request" };
@@ -909,7 +979,8 @@ function parseConfig(data) {
909
979
  // Reasoning and tool activity stay hidden unless the integration turns them on explicitly.
910
980
  showThinking: data.show_thinking === true,
911
981
  showToolActivity: data.show_tool_activity === true,
912
- maxMessageChars: Number.isInteger(max) && max > 0 ? max : Number.POSITIVE_INFINITY
982
+ maxMessageChars: Number.isInteger(max) && max > 0 ? max : Number.POSITIVE_INFINITY,
983
+ pageContextVersions: Array.isArray(data.page_context?.versions) && data.page_context.versions.includes(1) ? [1] : []
913
984
  };
914
985
  }
915
986
  function numericUsage(raw) {
@@ -994,7 +1065,8 @@ function createCcpTransport(client, identity) {
994
1065
  message: input.message,
995
1066
  conversation_id: input.conversationId,
996
1067
  ...withAgent(input.agent),
997
- ...input.identitySession ? { identity_session: input.identitySession } : {}
1068
+ ...input.identitySession ? { identity_session: input.identitySession } : {},
1069
+ ...input.pageContext !== void 0 ? { page_context: input.pageContext } : {}
998
1070
  };
999
1071
  return withEdge(ctx, visitorId, sealed, (edge) => client.send("chat", { ...base(ctx), body, session: edge.token, accept: "text/event-stream", signal: input.signal }));
1000
1072
  },
@@ -1152,14 +1224,403 @@ function createCcpTransport(client, identity) {
1152
1224
  };
1153
1225
  }
1154
1226
 
1227
+ // src/server/identity-session-store.ts
1228
+ function memoryIdentitySessionStore(limit = 1e3) {
1229
+ const subjects = /* @__PURE__ */ new Map();
1230
+ const revision = () => crypto.randomUUID();
1231
+ const get = (subjectKey) => {
1232
+ let entry = subjects.get(subjectKey);
1233
+ if (!entry) entry = { revision: revision(), sessions: /* @__PURE__ */ new Map() };
1234
+ subjects.delete(subjectKey);
1235
+ subjects.set(subjectKey, entry);
1236
+ while (subjects.size > limit) subjects.delete(subjects.keys().next().value);
1237
+ return entry;
1238
+ };
1239
+ return {
1240
+ async read(subjectKey, agent) {
1241
+ const entry = get(subjectKey);
1242
+ return { revision: entry.revision, session: entry.sessions.get(agent) ?? null };
1243
+ },
1244
+ async check(subjectKey, expected) {
1245
+ return subjects.get(subjectKey)?.revision === expected;
1246
+ },
1247
+ async commit(subjectKey, agent, expected, session) {
1248
+ const entry = subjects.get(subjectKey);
1249
+ if (!entry || entry.revision !== expected) return false;
1250
+ entry.sessions.set(agent, session);
1251
+ entry.revision = revision();
1252
+ return true;
1253
+ },
1254
+ async deleteToken(namespace, token) {
1255
+ for (const [subjectKey, entry] of subjects) if (subjectKey.startsWith(namespace + ":")) {
1256
+ for (const [agent, session] of entry.sessions) if (session.sessionToken === token) {
1257
+ entry.sessions.delete(agent);
1258
+ entry.revision = revision();
1259
+ }
1260
+ }
1261
+ },
1262
+ async takeSubject(subjectKey) {
1263
+ const entry = get(subjectKey);
1264
+ const held = [...entry.sessions].map(([agent, session]) => ({ agent, session }));
1265
+ entry.sessions.clear();
1266
+ entry.revision = revision();
1267
+ return held;
1268
+ }
1269
+ };
1270
+ }
1271
+
1272
+ // src/server/image-proxy.ts
1273
+ var utf8 = new TextEncoder();
1274
+ var encode = (data) => btoa(String.fromCharCode(...data)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1275
+ var decode = (data) => Uint8Array.from(atob(data.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((data.length + 3) % 4)), (c) => c.charCodeAt(0));
1276
+ async function readImageTicketBody(request) {
1277
+ if (!request.body) return null;
1278
+ const reader = request.body.getReader();
1279
+ const chunks = [];
1280
+ let size = 0;
1281
+ try {
1282
+ for (; ; ) {
1283
+ const { done, value } = await reader.read();
1284
+ if (done) break;
1285
+ size += value.byteLength;
1286
+ if (size > 8192) {
1287
+ await reader.cancel();
1288
+ return null;
1289
+ }
1290
+ chunks.push(value);
1291
+ }
1292
+ } finally {
1293
+ reader.releaseLock();
1294
+ }
1295
+ const all = new Uint8Array(size);
1296
+ let offset = 0;
1297
+ for (const chunk of chunks) {
1298
+ all.set(chunk, offset);
1299
+ offset += chunk.length;
1300
+ }
1301
+ return new TextDecoder().decode(all);
1302
+ }
1303
+ async function ticketKey(secret) {
1304
+ const base = await crypto.subtle.importKey("raw", utf8.encode(secret), "HKDF", false, ["deriveKey"]);
1305
+ return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: utf8.encode("dewee-webchat"), info: utf8.encode("dewee-webchat:image-ticket:aes-256-gcm:v1") }, base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
1306
+ }
1307
+ async function issueImageTicket(secret, namespace, visitor, source) {
1308
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1309
+ const payload = utf8.encode(JSON.stringify({ v: visitor, u: source, exp: Date.now() + 6e4 }));
1310
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: utf8.encode(`dewee-image-v1:${namespace}`) }, await ticketKey(secret), payload));
1311
+ const result = new Uint8Array(iv.length + ciphertext.length);
1312
+ result.set(iv);
1313
+ result.set(ciphertext, iv.length);
1314
+ return encode(result);
1315
+ }
1316
+ async function verifyImageTicket(secret, namespace, ticket) {
1317
+ try {
1318
+ if (ticket.length > 8192 || !/^[A-Za-z0-9_-]+$/.test(ticket)) return null;
1319
+ const raw = decode(ticket);
1320
+ if (raw.length <= 28) return null;
1321
+ const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: raw.slice(0, 12), additionalData: utf8.encode(`dewee-image-v1:${namespace}`) }, await ticketKey(secret), raw.slice(12));
1322
+ const data = JSON.parse(new TextDecoder().decode(plaintext));
1323
+ return typeof data.v === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(data.v) && typeof data.u === "string" && data.u.length <= 4096 && Number.isSafeInteger(data.exp) && data.exp > Date.now() && data.exp <= Date.now() + 6e4 ? { visitor: data.v, source: data.u } : null;
1324
+ } catch {
1325
+ return null;
1326
+ }
1327
+ }
1328
+ function allowedImageSource(raw, prefixes, endpoint, cloudflare, publicAssets = []) {
1329
+ try {
1330
+ if (!raw || raw.length > 4096 || /[\u0000-\u0020\u007f\\]/.test(raw)) return null;
1331
+ const match = /^https:\/\/[^/?#]+([^?#]*)/i.exec(raw);
1332
+ if (!match || /%(?:2f|5c|25|2e|0[0-9a-f]|1[0-9a-f]|7f)/i.test(match[1])) return null;
1333
+ const source = new URL(raw);
1334
+ if (source.protocol !== "https:" || source.username || source.password || source.hash || source.port) return null;
1335
+ const parent = endpoint.pathname.replace(/\/+$/, "").replace(/\/(?:image|image-ticket)$/, "");
1336
+ if (parent && (source.pathname === parent || source.pathname.startsWith(`${parent}/`))) return null;
1337
+ if (!parent && /^\/(?:image|image-ticket)(?:\/|$)/.test(source.pathname)) return null;
1338
+ if (cloudflare) {
1339
+ const suffix = endpoint.hostname.split(".").slice(-2).join(".");
1340
+ const sameZone = source.hostname === suffix || source.hostname.endsWith(`.${suffix}`);
1341
+ const isPublicAsset = source.origin === endpoint.origin && publicAssets.some((prefix) => source.pathname.startsWith(prefix));
1342
+ if (sameZone && !isPublicAsset) return null;
1343
+ }
1344
+ return prefixes.some((prefix) => prefix.origin === source.origin && source.pathname.startsWith(prefix.pathname)) ? source : null;
1345
+ } catch {
1346
+ return null;
1347
+ }
1348
+ }
1349
+ function safeRasterDimensions(bytes, type) {
1350
+ const ascii = (start, end) => String.fromCharCode(...bytes.slice(start, end));
1351
+ const u16 = (at, little = false) => little ? bytes[at] + bytes[at + 1] * 256 : bytes[at] * 256 + bytes[at + 1];
1352
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1353
+ const u32 = (at, little = false) => view.getUint32(at, little);
1354
+ const sizeOK = (w, h) => w > 0 && h > 0 && w <= 4096 && h <= 4096 && w * h <= 16e6;
1355
+ let width = 0, height = 0;
1356
+ if (type === "image/png") {
1357
+ let pos = 8, header = false, data = false, end = false;
1358
+ while (pos + 12 <= bytes.length) {
1359
+ const length = u32(pos), name = ascii(pos + 4, pos + 8);
1360
+ if (length > bytes.length - pos - 12) return false;
1361
+ if (name === "IHDR") {
1362
+ if (header || pos !== 8 || length !== 13) return false;
1363
+ header = true;
1364
+ width = u32(pos + 8);
1365
+ height = u32(pos + 12);
1366
+ } else if (name === "acTL" || name === "fcTL" || name === "fdAT") return false;
1367
+ else if (name === "IDAT") data = true;
1368
+ else if (name === "IEND") {
1369
+ end = length === 0;
1370
+ break;
1371
+ }
1372
+ pos += length + 12;
1373
+ }
1374
+ if (!header || !data || !end) return false;
1375
+ } else if (type === "image/gif" && bytes.length >= 13) {
1376
+ width = u16(6, true);
1377
+ height = u16(8, true);
1378
+ let pos = 13 + (bytes[10] & 128 ? 3 * (1 << (bytes[10] & 7) + 1) : 0), frames = 0, end = false;
1379
+ const blocks = () => {
1380
+ while (pos < bytes.length) {
1381
+ const n = bytes[pos++];
1382
+ if (n === 0) return true;
1383
+ if (n > bytes.length - pos) return false;
1384
+ pos += n;
1385
+ }
1386
+ return false;
1387
+ };
1388
+ while (pos < bytes.length) {
1389
+ const marker = bytes[pos++];
1390
+ if (marker === 59) {
1391
+ end = true;
1392
+ break;
1393
+ }
1394
+ if (marker === 33) {
1395
+ if (pos >= bytes.length) return false;
1396
+ pos++;
1397
+ if (!blocks()) return false;
1398
+ continue;
1399
+ }
1400
+ if (marker !== 44 || pos + 9 > bytes.length || ++frames > 1) return false;
1401
+ const left = u16(pos, true), top = u16(pos + 2, true), w = u16(pos + 4, true), h = u16(pos + 6, true), flags = bytes[pos + 8];
1402
+ if (!sizeOK(w, h) || left + w > width || top + h > height) return false;
1403
+ pos += 9 + (flags & 128 ? 3 * (1 << (flags & 7) + 1) : 0);
1404
+ if (pos >= bytes.length) return false;
1405
+ pos++;
1406
+ if (!blocks()) return false;
1407
+ }
1408
+ if (frames !== 1 || !end) return false;
1409
+ } else if (type === "image/jpeg") {
1410
+ let pos = 2, frames = 0, end = false;
1411
+ while (pos + 1 < bytes.length && bytes[pos] === 255) {
1412
+ while (bytes[pos] === 255) pos++;
1413
+ const marker = bytes[pos++];
1414
+ if (marker === 217) {
1415
+ end = true;
1416
+ break;
1417
+ }
1418
+ if (marker === 1 || marker >= 208 && marker <= 215) continue;
1419
+ if (pos + 2 > bytes.length) return false;
1420
+ const length = u16(pos);
1421
+ if (length < 2 || pos + length > bytes.length) return false;
1422
+ if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker)) {
1423
+ if (++frames !== 1 || length < 7) return false;
1424
+ height = u16(pos + 3);
1425
+ width = u16(pos + 5);
1426
+ }
1427
+ pos += length;
1428
+ if (marker === 218) {
1429
+ while (pos + 1 < bytes.length) {
1430
+ if (bytes[pos] !== 255) {
1431
+ pos++;
1432
+ continue;
1433
+ }
1434
+ const next = bytes[pos + 1];
1435
+ if (next === 0 || next >= 208 && next <= 215) {
1436
+ pos += 2;
1437
+ continue;
1438
+ }
1439
+ break;
1440
+ }
1441
+ }
1442
+ }
1443
+ if (frames !== 1 || !end) return false;
1444
+ } else if (type === "image/webp" && bytes.length >= 20) {
1445
+ const total = u32(4, true) + 8;
1446
+ if (total > bytes.length || total < 20) return false;
1447
+ let pos = 12, images = 0, canvasW = 0, canvasH = 0;
1448
+ while (pos + 8 <= total) {
1449
+ const chunk = ascii(pos, pos + 4), length = u32(pos + 4, true), start = pos + 8;
1450
+ if (length > total - start || start + length + (length & 1) > total || chunk === "ANIM" || chunk === "ANMF") return false;
1451
+ if (chunk === "VP8X") {
1452
+ if (length !== 10 || canvasW || bytes[start] & 2) return false;
1453
+ canvasW = 1 + bytes[start + 4] + (bytes[start + 5] << 8) + (bytes[start + 6] << 16);
1454
+ canvasH = 1 + bytes[start + 7] + (bytes[start + 8] << 8) + (bytes[start + 9] << 16);
1455
+ } else if (chunk === "VP8L" && length >= 5 && bytes[start] === 47) {
1456
+ images++;
1457
+ width = 1 + bytes[start + 1] + ((bytes[start + 2] & 63) << 8);
1458
+ height = 1 + (bytes[start + 2] >> 6) + (bytes[start + 3] << 2) + ((bytes[start + 4] & 15) << 10);
1459
+ } else if (chunk === "VP8 " && length >= 10 && bytes[start + 3] === 157 && bytes[start + 4] === 1 && bytes[start + 5] === 42) {
1460
+ images++;
1461
+ width = u16(start + 6, true) & 16383;
1462
+ height = u16(start + 8, true) & 16383;
1463
+ }
1464
+ pos = start + length + (length & 1);
1465
+ }
1466
+ if (pos !== total || images !== 1 || canvasW && (canvasW !== width || canvasH !== height)) return false;
1467
+ }
1468
+ return sizeOK(width, height);
1469
+ }
1470
+ async function fetchRasterImage(url, fetcher, redirect = "error") {
1471
+ const controller = new AbortController();
1472
+ const timer = setTimeout(() => controller.abort(), 1e4);
1473
+ try {
1474
+ const response = await fetcher(url, { method: "GET", redirect, credentials: "omit", signal: controller.signal, headers: { accept: "image/png,image/jpeg,image/gif,image/webp" } });
1475
+ if (!response.ok || !response.body) {
1476
+ await response.body?.cancel();
1477
+ return null;
1478
+ }
1479
+ const type = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
1480
+ if (!type || !["image/png", "image/jpeg", "image/gif", "image/webp"].includes(type)) {
1481
+ await response.body.cancel();
1482
+ return null;
1483
+ }
1484
+ const length = response.headers.get("content-length");
1485
+ const maxBytes = 2 * 1024 * 1024;
1486
+ if (length && (!/^\d+$/.test(length) || Number(length) > maxBytes)) {
1487
+ await response.body.cancel();
1488
+ return null;
1489
+ }
1490
+ const reader = response.body.getReader();
1491
+ const chunks = [];
1492
+ let size = 0;
1493
+ try {
1494
+ for (; ; ) {
1495
+ const { done, value } = await reader.read();
1496
+ if (done) break;
1497
+ size += value.byteLength;
1498
+ if (size > maxBytes) {
1499
+ await reader.cancel();
1500
+ return null;
1501
+ }
1502
+ chunks.push(value);
1503
+ }
1504
+ } finally {
1505
+ reader.releaseLock();
1506
+ }
1507
+ const bytes = new Uint8Array(size);
1508
+ let offset = 0;
1509
+ for (const chunk of chunks) {
1510
+ bytes.set(chunk, offset);
1511
+ offset += chunk.length;
1512
+ }
1513
+ const starts = (...signature) => signature.every((value, index) => bytes[index] === value);
1514
+ const ascii = (start, end) => String.fromCharCode(...bytes.slice(start, end));
1515
+ const valid = type === "image/png" ? starts(137, 80, 78, 71, 13, 10, 26, 10) : type === "image/jpeg" ? starts(255, 216, 255) : type === "image/gif" ? ["GIF87a", "GIF89a"].includes(ascii(0, 6)) : ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP";
1516
+ if (!valid || !safeRasterDimensions(bytes, type)) return null;
1517
+ return new Response(bytes, { headers: { "content-type": type, "x-content-type-options": "nosniff", "cache-control": "private, no-store", "content-security-policy": "default-src 'none'; sandbox" } });
1518
+ } catch {
1519
+ return null;
1520
+ } finally {
1521
+ clearTimeout(timer);
1522
+ }
1523
+ }
1524
+
1525
+ // src/server/public-image-fetch-node.ts
1526
+ async function publicImageFetchNode(input, init) {
1527
+ if (typeof input !== "string") throw new Error("Invalid image URL");
1528
+ const url = new URL(input);
1529
+ if (url.protocol !== "https:" || url.username || url.password || url.port && url.port !== "443") throw new Error("Invalid image URL");
1530
+ const prefix = "node:";
1531
+ const dns = await import(prefix + "dns/promises");
1532
+ const net = await import(prefix + "net");
1533
+ const https = await import(prefix + "https");
1534
+ if (net.isIP(url.hostname)) throw new Error("IP literals are not allowed");
1535
+ const lookup = dns.lookup(url.hostname, { all: true, verbatim: true });
1536
+ const signal = init?.signal;
1537
+ const addresses = signal ? await new Promise((resolve, reject) => {
1538
+ if (signal.aborted) {
1539
+ reject(new Error("Image request timed out"));
1540
+ return;
1541
+ }
1542
+ const abort = () => reject(new Error("Image request timed out"));
1543
+ signal.addEventListener("abort", abort, { once: true });
1544
+ lookup.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
1545
+ }) : await lookup;
1546
+ if (!addresses.length) throw new Error("No image address");
1547
+ if (!publicImageAddresses(addresses, net.BlockList)) throw new Error("Non-public image address");
1548
+ const chosen = addresses.find((entry) => entry.family === 4) ?? addresses[0];
1549
+ return new Promise((resolve, reject) => {
1550
+ const request = https.request(url, {
1551
+ method: "GET",
1552
+ headers: { accept: "image/png,image/jpeg,image/gif,image/webp" },
1553
+ signal: init?.signal ?? void 0,
1554
+ family: chosen.family,
1555
+ lookup: (_host, _options, callback) => callback(null, chosen.address, chosen.family)
1556
+ }, (response) => {
1557
+ if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300 || response.statusCode === 204 || response.statusCode === 205) {
1558
+ response.destroy();
1559
+ reject(new Error("Image upstream refused"));
1560
+ return;
1561
+ }
1562
+ const headers = new Headers();
1563
+ for (const [name, value] of Object.entries(response.headers)) if (typeof value === "string") headers.set(name, value);
1564
+ let received = 0;
1565
+ let settled = false;
1566
+ const body = new ReadableStream({
1567
+ start(controller) {
1568
+ response.pause();
1569
+ response.on("data", (chunk) => {
1570
+ response.pause();
1571
+ received += chunk.length;
1572
+ if (received > 2 * 1024 * 1024) {
1573
+ settled = true;
1574
+ response.destroy();
1575
+ controller.error(new Error("Image too large"));
1576
+ } else controller.enqueue(new Uint8Array(chunk));
1577
+ });
1578
+ response.on("end", () => {
1579
+ if (!settled) {
1580
+ settled = true;
1581
+ controller.close();
1582
+ }
1583
+ });
1584
+ response.on("error", (error) => {
1585
+ if (!settled) {
1586
+ settled = true;
1587
+ controller.error(error);
1588
+ }
1589
+ });
1590
+ },
1591
+ pull() {
1592
+ response.resume();
1593
+ },
1594
+ cancel() {
1595
+ settled = true;
1596
+ response.destroy();
1597
+ }
1598
+ });
1599
+ resolve(new Response(body, { status: response.statusCode ?? 502, headers }));
1600
+ });
1601
+ request.on("error", reject);
1602
+ request.end();
1603
+ });
1604
+ }
1605
+ function publicImageAddresses(addresses, BlockList) {
1606
+ const denied4 = new BlockList();
1607
+ const denied6 = new BlockList();
1608
+ for (const [ip, bits] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]]) denied4.addSubnet(ip, bits, "ipv4");
1609
+ for (const [ip, bits] of [["::", 128], ["::1", 128], ["::ffff:0:0", 96], ["64:ff9b::", 96], ["100::", 64], ["2001::", 23], ["2001:db8::", 32], ["2002::", 16], ["3fff::", 20], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]]) denied6.addSubnet(ip, bits, "ipv6");
1610
+ const publicV6 = new BlockList();
1611
+ publicV6.addSubnet("2000::", 3, "ipv6");
1612
+ return addresses.length > 0 && addresses.every(({ address, family }) => family === 6 ? !denied6.check(address, "ipv6") && publicV6.check(address, "ipv6") : family === 4 && !denied4.check(address, "ipv4"));
1613
+ }
1614
+
1155
1615
  // src/server/handler.ts
1616
+ var IdentityStoreError = class extends Error {
1617
+ };
1156
1618
  var ID_RE = /^[A-Za-z0-9_-]{6,64}$/;
1157
1619
  var AGENT_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
1158
1620
  var IDEMPOTENCY_KEY_RE = /^[\x21-\x7E]{1,255}$/;
1159
1621
  var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1160
1622
  var DEFAULT_IDENTITY_TTL_MS = 36e5;
1161
1623
  var MAX_IDENTITY_TTL_MS = 432e5;
1162
- var MAX_CACHED_SESSIONS = 1e3;
1163
1624
  var ANON_REUSE_MS = 864e5;
1164
1625
  var VERIFIED_REUSE_MIN_MS = 12e4;
1165
1626
  function checkIdentityOptions(o, staticAgents, transport) {
@@ -1205,11 +1666,35 @@ function createDeweeHandler(options) {
1205
1666
  ...options
1206
1667
  };
1207
1668
  const transport = resolveTransport(o);
1669
+ if (o.imageSources !== void 0 && !Array.isArray(o.imageSources)) throw new Error("[dewee-webchat] imageSources must be an array");
1670
+ const imageSources = (o.imageSources ?? []).map((origin) => {
1671
+ let url;
1672
+ try {
1673
+ url = new URL(origin);
1674
+ } catch {
1675
+ throw new Error("[dewee-webchat] Invalid image origin");
1676
+ }
1677
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.port || !url.pathname.endsWith("/") || /%(?:2f|5c|25|2e|0[0-9a-f]|1[0-9a-f]|7f)/i.test(url.pathname)) throw new Error("[dewee-webchat] Image sources must be HTTPS directory prefixes on the default port");
1678
+ return url;
1679
+ });
1680
+ const publicAssets = o.cloudflarePublicImageAssets;
1681
+ if (publicAssets && (typeof publicAssets.binding?.fetch !== "function" || !Array.isArray(publicAssets.prefixes) || !publicAssets.prefixes.length || publicAssets.prefixes.some((path) => typeof path !== "string" || !/^\/(?:[A-Za-z0-9_-]+\/)+$/.test(path)))) {
1682
+ throw new Error("[dewee-webchat] Public image asset prefixes must be explicit absolute directory paths");
1683
+ }
1684
+ const assetPrefixes = publicAssets?.prefixes ?? [];
1685
+ let activeImages = 0;
1686
+ const imageNamespace = `${o.ccp?.chatUrl ?? o.gatewayUrl ?? ""}
1687
+ ${o.ccp?.integrationId ?? o.tenantId ?? ""}`;
1688
+ if (o.allowedHeaders !== void 0 && (!Array.isArray(o.allowedHeaders) || o.allowedHeaders.some((name) => typeof name !== "string" || !/^[A-Za-z0-9-]+$/.test(name)))) {
1689
+ throw new Error("[dewee-webchat] allowedHeaders must contain valid header names (letters, digits and hyphens)");
1690
+ }
1208
1691
  let secret = o.secret;
1209
1692
  if (!secret) {
1693
+ if (o.allowEphemeralSecret !== true) throw new Error("[dewee-webchat] Configure secret (DEWEE_WEBCHAT_SECRET), or explicitly enable allowEphemeralSecret for development");
1210
1694
  secret = randomId(32);
1211
1695
  console.warn("[dewee-webchat] No secret configured: using an ephemeral one. Visitor sessions reset on restart.");
1212
1696
  }
1697
+ if (new TextEncoder().encode(secret).length < 32) throw new Error("[dewee-webchat] secret (DEWEE_WEBCHAT_SECRET) must be at least 32 bytes");
1213
1698
  const limiter = o.rateLimit === false ? null : o.rateLimit ?? memoryRateLimiter({ chat: [20, 6e4], session: [10, 6e5], preauth: [120, 6e4], verified: [30, 6e5], link: [5, 6e5] });
1214
1699
  const staticAgents = o.agents === "*" ? [] : (o.agents ?? []).map((a) => typeof a === "string" ? { id: a } : a);
1215
1700
  const wildcardAgents = o.agents === "*";
@@ -1280,7 +1765,7 @@ function createDeweeHandler(options) {
1280
1765
  return {
1281
1766
  "Access-Control-Allow-Origin": origin,
1282
1767
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1283
- "Access-Control-Allow-Headers": `content-type, authorization, ${HEADER_GATEWAY_URL}, ${HEADER_GATEWAY_TOKEN}`,
1768
+ "Access-Control-Allow-Headers": [.../* @__PURE__ */ new Set(["content-type", "authorization", HEADER_GATEWAY_URL, HEADER_GATEWAY_TOKEN, ...(o.allowedHeaders ?? []).map((h) => h.toLowerCase())])].join(", "),
1284
1769
  "Access-Control-Max-Age": "600",
1285
1770
  Vary: "Origin"
1286
1771
  };
@@ -1300,27 +1785,27 @@ function createDeweeHandler(options) {
1300
1785
  return json(req, { error: { message: e.message, code, remediation: remediation ?? { action: e.action, audience: "end_user" } } }, e.status);
1301
1786
  };
1302
1787
  const misconfigured = (req) => fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured");
1303
- function ccpFail(req, e, route, sessionToken) {
1788
+ async function ccpFail(req, e, route, sessionToken) {
1304
1789
  const b = browserErrorFor(e, route);
1305
1790
  if (b.admission) {
1306
1791
  const r = admissionErrorResponse(b.admission, b.retryAfterMs);
1307
1792
  return json(req, r.body, r.status, r.headers);
1308
1793
  }
1309
1794
  if (b.identity) {
1310
- if (sessionToken && DEAD_SESSION_CODES.has(b.identity)) forgetCachedSession(sessionToken);
1795
+ if (sessionToken && DEAD_SESSION_CODES.has(b.identity)) await forgetCachedSession(sessionToken);
1311
1796
  return identityFail(req, b.identity, b.remediation);
1312
1797
  }
1313
1798
  const res = fail(req, b.status, b.message, b.code);
1314
1799
  if (b.retryAfterSeconds) res.headers.set("retry-after", String(Math.ceil(b.retryAfterSeconds)));
1315
1800
  return res;
1316
1801
  }
1317
- const gatewayFail = (req, e, sessionToken) => {
1802
+ const gatewayFail = async (req, e, sessionToken) => {
1318
1803
  if (e instanceof GatewayError && isAdmissionErrorCode(e.code)) {
1319
1804
  const r = admissionErrorResponse(e.code, e.retryAfterMs);
1320
1805
  return json(req, r.body, r.status, r.headers);
1321
1806
  }
1322
1807
  if (!(e instanceof GatewayError && isIdentityErrorCode(e.code))) return fail(req, 502, e.message, "gateway_error");
1323
- if (sessionToken && DEAD_SESSION_CODES.has(e.code)) forgetCachedSession(sessionToken);
1808
+ if (sessionToken && DEAD_SESSION_CODES.has(e.code)) await forgetCachedSession(sessionToken);
1324
1809
  if (e.remediation?.audience === "operator") console.error(`[dewee-webchat] identity: ${e.message} (${e.code})`);
1325
1810
  return identityFail(req, e.code, e.remediation);
1326
1811
  };
@@ -1354,7 +1839,7 @@ function createDeweeHandler(options) {
1354
1839
  }
1355
1840
  return ccpFail(req, e, "identity/exchange");
1356
1841
  };
1357
- const linkFail = (req, e, sessionToken) => {
1842
+ const linkFail = async (req, e, sessionToken) => {
1358
1843
  const code = e?.code;
1359
1844
  if (code === "IDEMPOTENCY_KEY_REUSED") return fail(req, 409, "This idempotency key was already used for a different link request", "idempotency_key_reused");
1360
1845
  if (code === "FAILED_PRECONDITION") return fail(req, 409, "A link is already in progress. Use its code or wait until it expires.", "link_in_progress");
@@ -1362,7 +1847,7 @@ function createDeweeHandler(options) {
1362
1847
  console.error("[dewee-webchat] identity link start: the gateway has identity exchange switched off");
1363
1848
  return fail(req, 503, "Linking is not available right now", "linking_unavailable");
1364
1849
  }
1365
- if (typeof code === "string" && DEAD_SESSION_CODES.has(code)) forgetCachedSession(sessionToken);
1850
+ if (typeof code === "string" && DEAD_SESSION_CODES.has(code)) await forgetCachedSession(sessionToken);
1366
1851
  return sdkFail(req, e, "link start");
1367
1852
  };
1368
1853
  const clientIp = (req) => req.headers.get("cf-connecting-ip") || req.headers.get("x-real-ip") || req.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown";
@@ -1395,7 +1880,7 @@ function createDeweeHandler(options) {
1395
1880
  const current = !!user && typeof user.subject === "string" && user.subject !== "" ? await subjectDigest(secret, user.subject) : null;
1396
1881
  if (current && claims.sub && constantTimeEqual(current, claims.sub)) return null;
1397
1882
  if (ccp && current && claims.sub) {
1398
- forgetCachedSession(claims.ids);
1883
+ await forgetCachedSession(claims.ids);
1399
1884
  await ccp.revoke(visitorContext(req), claims.v, sealedEdge(claims), claims.ids, claims.ida);
1400
1885
  }
1401
1886
  return identityFail(req, "IDENTITY_REQUIRED");
@@ -1409,22 +1894,20 @@ function createDeweeHandler(options) {
1409
1894
  if (idn.assertionSecret) return { applicationId: idn.appId, issuer: idn.issuer, secret: idn.assertionSecret };
1410
1895
  return { applicationId: idn.appId, issuer: idn.issuer, privateKeyJwk: idn.privateKeyJwk, algorithm: idn.algorithm };
1411
1896
  }
1412
- const sessionCache = /* @__PURE__ */ new Map();
1897
+ const sessionStore = idn?.sessionStore ?? memoryIdentitySessionStore();
1413
1898
  const exchanges = /* @__PURE__ */ new Map();
1414
- function forgetCachedSession(sessionToken) {
1415
- for (const [k, c] of sessionCache) if (c.sessionToken === sessionToken) sessionCache.delete(k);
1416
- }
1417
- function rememberSession(key, c) {
1418
- sessionCache.delete(key);
1419
- sessionCache.set(key, c);
1420
- if (sessionCache.size <= MAX_CACHED_SESSIONS) return;
1421
- const now = Date.now();
1422
- for (const [k, v] of sessionCache) if (v.deadline - now <= v.margin) sessionCache.delete(k);
1423
- for (const k of sessionCache.keys()) {
1424
- if (sessionCache.size <= MAX_CACHED_SESSIONS) break;
1425
- sessionCache.delete(k);
1899
+ async function storeOperation(operation) {
1900
+ try {
1901
+ return await operation();
1902
+ } catch {
1903
+ throw new IdentityStoreError("Identity session store unavailable");
1426
1904
  }
1427
1905
  }
1906
+ async function forgetCachedSession(sessionToken) {
1907
+ await storeOperation(async () => sessionStore.deleteToken(await storeNamespace(), sessionToken));
1908
+ }
1909
+ const storeNamespace = () => subjectDigest(secret, JSON.stringify([o.ccp?.chatUrl ?? o.gatewayUrl ?? "", o.ccp?.integrationId ?? o.tenantId ?? "", idn.appId, idn.issuer]));
1910
+ const storeSubject = async (subject) => `${await storeNamespace()}:${await subjectDigest(secret, subject)}`;
1428
1911
  async function exchangeFor(subject, req, sealed) {
1429
1912
  if (ccp) return ccpExchangeFor(subject, req, sealed);
1430
1913
  const session = await idn.sdk.exchangeSession({
@@ -1477,28 +1960,44 @@ function createDeweeHandler(options) {
1477
1960
  } catch (e) {
1478
1961
  return ccpFail(req, e, "config");
1479
1962
  }
1480
- const key = `${agent}
1481
- ${subject}`;
1482
- let s = sessionCache.get(key);
1963
+ const subjectKey = await storeSubject(subject);
1964
+ const key = JSON.stringify([subjectKey, agent]);
1965
+ const observed = await storeOperation(() => sessionStore.read(subjectKey, agent));
1966
+ let s = observed.session;
1483
1967
  const userVisitor = ccp ? await deriveVisitorId(secret, `subject:${subject}`) : void 0;
1484
1968
  const sealed = hint && hint.ids && hint.v === userVisitor ? sealedEdge(hint) : void 0;
1485
1969
  if (!s || s.deadline - Date.now() <= s.margin) {
1486
- const reused = await verifiedReuse(req, hint, subject, agent, userVisitor);
1487
- if (reused) {
1488
- s = reused;
1489
- } else {
1490
- let pending = exchanges.get(key);
1491
- if (!pending) {
1492
- pending = exchangeFor(subject, req, sealed).finally(() => exchanges.delete(key));
1493
- exchanges.set(key, pending);
1494
- }
1495
- try {
1496
- s = await pending;
1497
- } catch (e) {
1498
- return ccp ? ccpExchangeFail(req, e) : exchangeFail(req, e);
1499
- }
1970
+ let pending = exchanges.get(key);
1971
+ if (!pending) {
1972
+ pending = (async () => {
1973
+ const reused = await verifiedReuse(req, hint, subject, agent, userVisitor);
1974
+ const candidate = reused ?? await exchangeFor(subject, req, sealed);
1975
+ const revokeMinted = async () => {
1976
+ if (ccp && !reused) await ccp.revoke(visitorContext(req), candidate.visitorId, sealed, candidate.sessionToken, agent).catch(() => {
1977
+ });
1978
+ };
1979
+ try {
1980
+ const committed = await storeOperation(() => sessionStore.commit(subjectKey, agent, observed.revision, candidate));
1981
+ if (!committed) {
1982
+ const current = await storeOperation(() => sessionStore.read(subjectKey, agent));
1983
+ if (current.session?.sessionToken !== candidate.sessionToken) throw new IdentityStoreError("Identity session changed");
1984
+ }
1985
+ return candidate;
1986
+ } catch (error) {
1987
+ await revokeMinted();
1988
+ throw error;
1989
+ }
1990
+ })().finally(() => exchanges.delete(key));
1991
+ exchanges.set(key, pending);
1500
1992
  }
1501
- rememberSession(key, s);
1993
+ try {
1994
+ s = await pending;
1995
+ } catch (e) {
1996
+ if (e instanceof IdentityStoreError) throw e;
1997
+ return ccp ? ccpExchangeFail(req, e) : exchangeFail(req, e);
1998
+ }
1999
+ } else if (!await storeOperation(() => sessionStore.check(subjectKey, observed.revision))) {
2000
+ return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" });
1502
2001
  }
1503
2002
  const now = Date.now();
1504
2003
  const expiresAt = Math.min(s.deadline, now + o.sessionTtlMs);
@@ -1546,6 +2045,15 @@ ${subject}`;
1546
2045
  maxMessageLength: o.maxMessageLength,
1547
2046
  version: VERSION
1548
2047
  };
2048
+ if (creds && new URL(req.url).searchParams.get("pageContext") === "1") {
2049
+ if (limiter && !await limiter(clientIp(req), "preauth")) return fail(req, 429, "Too many requests, slow down", "rate_limited");
2050
+ try {
2051
+ const probe = await GatewayConnection.connect(creds, o.userIdPrefix + "capability-probe");
2052
+ if (probe.pageContextVersions.length) body.pageContextVersions = probe.pageContextVersions;
2053
+ probe.close();
2054
+ } catch {
2055
+ }
2056
+ }
1549
2057
  if (idn) {
1550
2058
  body.defaultAgent = identityAgent;
1551
2059
  body.allowAgentSwitch = false;
@@ -1570,6 +2078,7 @@ ${subject}`;
1570
2078
  turnstileSiteKey: o.turnstile?.siteKey,
1571
2079
  ...o.turnstile ? { refreshWithoutTurnstile: true } : {},
1572
2080
  maxMessageLength: Math.min(o.maxMessageLength, cfg.maxMessageChars),
2081
+ ...cfg.pageContextVersions.length ? { pageContextVersions: cfg.pageContextVersions } : {},
1573
2082
  version: VERSION
1574
2083
  };
1575
2084
  if (idn) {
@@ -1605,7 +2114,7 @@ ${subject}`;
1605
2114
  if (!(ccp && previous) && !await turnstilePassed(body, ip)) return fail(req, 403, "Verification failed", "turnstile_failed");
1606
2115
  const visitorId = previous?.v ?? randomId(16);
1607
2116
  const expiresAt = Date.now() + o.sessionTtlMs;
1608
- if (!ccp) return json(req, { visitorId, token: await signVisitorToken(secret, { v: visitorId, exp: expiresAt }), expiresAt });
2117
+ if (!ccp) return json(req, { visitorId, token: await signVisitorToken(secret, { v: visitorId, exp: expiresAt }), expiresAt, expiresIn: Math.max(0, expiresAt - Date.now()) });
1609
2118
  let edge;
1610
2119
  try {
1611
2120
  edge = await ccp.edgeFor(visitorContext(req), visitorId, sealedEdge(previous), void 0, EDGE_RESEAL_MS);
@@ -1642,7 +2151,7 @@ ${subject}`;
1642
2151
  const previous = await anonymousPrevious(body);
1643
2152
  if (previous && previous.exp - Date.now() > ANON_REUSE_MS) {
1644
2153
  if (!ccp) {
1645
- const reused = { visitorId: previous.v, token: body.token, expiresAt: previous.exp };
2154
+ const reused = { visitorId: previous.v, token: body.token, expiresAt: previous.exp, expiresIn: Math.max(0, previous.exp - Date.now()) };
1646
2155
  return json(req, reused);
1647
2156
  }
1648
2157
  const edge = sealedEdge(previous);
@@ -1666,7 +2175,10 @@ ${subject}`;
1666
2175
  const { creds, byo: isByo, error } = resolveCreds(req);
1667
2176
  if (error) return fail(req, 400, error, "bad_credentials");
1668
2177
  if (!creds && !ccp) return fail(req, 500, "Gateway is not configured", "not_configured");
1669
- const body = await req.json().catch(() => null);
2178
+ const input = await readChatBody(req);
2179
+ if ("error" in input) return fail(req, input.error === "too_large" ? 413 : 400, input.error === "too_large" ? "Request is too large" : "Invalid request", input.error === "too_large" ? "too_long" : "bad_request");
2180
+ if (!input.body || typeof input.body !== "object" || Array.isArray(input.body)) return fail(req, 400, "Invalid request", "bad_request");
2181
+ const body = input.body;
1670
2182
  const message = typeof body?.message === "string" ? body.message.trim() : "";
1671
2183
  if (!message) return fail(req, 400, "Message is required");
1672
2184
  if (message.length > o.maxMessageLength) return fail(req, 413, `Message exceeds ${o.maxMessageLength} characters`, "too_long");
@@ -1686,15 +2198,23 @@ ${subject}`;
1686
2198
  if (e instanceof ActionInputError) return fail(req, e.status, e.message, e.code);
1687
2199
  throw e;
1688
2200
  }
1689
- if (ccp) return ccpChat(req, claims, { message: pageBlock + message, rawMessage: message, conversationId, agent, actionsHash });
2201
+ if (body?.page_context != null) {
2202
+ if (typeof body.page_context === "object" && Number.isInteger(body.page_context.version) && body.page_context.version !== 1) return fail(req, 400, "Page context is not supported", "page_context_unsupported");
2203
+ if (!validPageContext(body.page_context)) return fail(req, 400, "Invalid page context", "page_context_invalid");
2204
+ }
2205
+ if (ccp) return ccpChat(req, claims, { message: pageBlock + message, rawMessage: message, conversationId, agent, actionsHash, pageContext: body?.page_context });
1690
2206
  if (!creds) return fail(req, 500, "Gateway is not configured", "not_configured");
1691
2207
  let connection;
1692
- if (claims.ids) {
2208
+ if (claims.ids || body?.page_context != null) {
1693
2209
  try {
1694
- connection = await GatewayConnection.connect(creds, { identitySession: claims.ids });
2210
+ connection = await GatewayConnection.connect(creds, claims.ids ? { identitySession: claims.ids } : o.userIdPrefix + visitorId);
1695
2211
  } catch (e) {
1696
2212
  return gatewayFail(req, e, claims.ids);
1697
2213
  }
2214
+ if (body?.page_context != null && !connection.pageContextVersions.includes(1)) {
2215
+ connection.close();
2216
+ return fail(req, 400, "Page context is not supported", "page_context_unsupported");
2217
+ }
1698
2218
  }
1699
2219
  const sessionKey = buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`);
1700
2220
  const abort = new AbortController();
@@ -1712,12 +2232,12 @@ ${subject}`;
1712
2232
  try {
1713
2233
  await send(actionsHash ? { type: "start", conversationId, agent, actionsHash } : { type: "start", conversationId, agent });
1714
2234
  const who = connection ? { connection } : { userId: o.userIdPrefix + visitorId };
1715
- for await (const ev of streamChat({ creds, ...who, agent, sessionKey, message: pageBlock + message, signal: abort.signal, timeoutMs: o.timeoutMs })) {
2235
+ for await (const ev of streamChat({ creds, ...who, agent, sessionKey, message: pageBlock + message, pageContext: body?.page_context ?? void 0, signal: abort.signal, timeoutMs: o.timeoutMs })) {
1716
2236
  if (ev.type === "start") continue;
1717
2237
  if (ev.type === "done") reply = ev.content;
1718
2238
  if (ev.type === "error") {
1719
2239
  errMsg = gatewayReasonOf(ev) ?? ev.message;
1720
- if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) forgetCachedSession(claims.ids);
2240
+ if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) await forgetCachedSession(claims.ids);
1721
2241
  }
1722
2242
  await send(ev);
1723
2243
  if (abort.signal.aborted) break;
@@ -1773,6 +2293,20 @@ ${subject}`;
1773
2293
  let upstream;
1774
2294
  try {
1775
2295
  cfg = await ccp.config();
2296
+ if (turn.pageContext != null) {
2297
+ if (!cfg.pageContextVersions.includes(1)) {
2298
+ cleanup();
2299
+ return fail(req, 400, "Page context is not supported", "page_context_unsupported");
2300
+ }
2301
+ if (typeof turn.pageContext === "object" && "version" in turn.pageContext && Number.isInteger(turn.pageContext.version) && turn.pageContext.version !== 1) {
2302
+ cleanup();
2303
+ return fail(req, 400, "Page context is not supported", "page_context_unsupported");
2304
+ }
2305
+ if (!validPageContext(turn.pageContext)) {
2306
+ cleanup();
2307
+ return fail(req, 400, "Invalid page context", "page_context_invalid");
2308
+ }
2309
+ }
1776
2310
  if (turn.rawMessage.length > cfg.maxMessageChars) {
1777
2311
  cleanup();
1778
2312
  return fail(req, 413, `Message exceeds ${cfg.maxMessageChars} characters`, "too_long");
@@ -1782,6 +2316,7 @@ ${subject}`;
1782
2316
  conversationId: turn.conversationId,
1783
2317
  agent: turn.agent || void 0,
1784
2318
  identitySession: claims.ids,
2319
+ pageContext: turn.pageContext ?? void 0,
1785
2320
  signal: abort.signal
1786
2321
  });
1787
2322
  } catch (e) {
@@ -1828,7 +2363,7 @@ ${subject}`;
1828
2363
  if (ev.type === "error") {
1829
2364
  errMsg = ev.message;
1830
2365
  finished = true;
1831
- if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) forgetCachedSession(claims.ids);
2366
+ if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) await forgetCachedSession(claims.ids);
1832
2367
  }
1833
2368
  await send(ev);
1834
2369
  if (abort.signal.aborted) break;
@@ -1969,10 +2504,12 @@ ${subject}`;
1969
2504
  async function revoke(subject, request, token) {
1970
2505
  if (!idn || typeof subject !== "string" || subject === "") return;
1971
2506
  try {
1972
- const held = [];
1973
- for (const [k, c] of sessionCache) {
1974
- const cut = k.indexOf("\n");
1975
- if (k.slice(cut + 1) === subject) held.push({ agent: k.slice(0, cut), session: c });
2507
+ const subjectKey = await storeSubject(subject);
2508
+ let held = [];
2509
+ try {
2510
+ held = await storeOperation(() => sessionStore.takeSubject(subjectKey));
2511
+ } catch {
2512
+ console.warn("[dewee-webchat] identity session store unavailable during revoke");
1976
2513
  }
1977
2514
  if (ccp && typeof token === "string" && token !== "") {
1978
2515
  const c = await verifyReuseHint(secret, token) ?? await verifyVisitorToken(secret, token);
@@ -1980,7 +2517,6 @@ ${subject}`;
1980
2517
  held.push({ agent: c.ida, session: { sessionToken: c.ids, visitorId: c.v, deadline: c.exp, margin: 0 }, edge: sealedEdge(c) });
1981
2518
  }
1982
2519
  }
1983
- for (const { session } of held) forgetCachedSession(session.sessionToken);
1984
2520
  if (!ccp) return;
1985
2521
  const fromRequest = request ? visitorContext(request) : null;
1986
2522
  await Promise.all(
@@ -1999,6 +2535,54 @@ ${subject}`;
1999
2535
  if (!originAllowed(req)) return fail(req, 403, "Origin not allowed", "forbidden_origin");
2000
2536
  const action = new URL(req.url).pathname.replace(/\/+$/, "").split("/").pop();
2001
2537
  try {
2538
+ if (req.method === "POST" && action === "image-ticket" && imageSources.length) {
2539
+ const claims = await visitorFrom(req);
2540
+ if (!claims) return fail(req, 401, "Missing or expired visitor session", "unauthorized");
2541
+ if (idn?.required && !claims.ids) return identityFail(req, "IDENTITY_REQUIRED");
2542
+ const refused = await rebindRefusal(req, claims);
2543
+ if (refused) return refused;
2544
+ if (limiter && !await limiter(`image-ticket:${claims.v}`, "preauth")) return fail(req, 429, "Too many requests, try again later", "rate_limited");
2545
+ const body = await readImageTicketBody(req);
2546
+ if (body === null) return fail(req, 413, "Invalid image", "invalid_request");
2547
+ let parsed;
2548
+ try {
2549
+ parsed = JSON.parse(body);
2550
+ } catch {
2551
+ return fail(req, 400, "Invalid image", "invalid_request");
2552
+ }
2553
+ const raw = parsed?.url;
2554
+ const worker = globalThis.navigator?.userAgent === "Cloudflare-Workers";
2555
+ const source = typeof raw === "string" ? allowedImageSource(raw, imageSources, new URL(req.url), worker, worker ? assetPrefixes : []) : null;
2556
+ if (!source) return fail(req, 403, "Image not allowed", "forbidden_image");
2557
+ const endpoint = new URL(req.url);
2558
+ const asset = publicAssets && source.origin === endpoint.origin && assetPrefixes.some((prefix) => source.pathname.startsWith(prefix));
2559
+ const namespace = asset ? `${imageNamespace}
2560
+ assets:${endpoint.origin}` : imageNamespace;
2561
+ return json(req, { ticket: await issueImageTicket(secret, namespace, claims.v, source.href) });
2562
+ }
2563
+ if (req.method === "GET" && action === "image" && imageSources.length) {
2564
+ const ticket = new URL(req.url).searchParams.get("ticket") ?? "";
2565
+ const endpoint = new URL(req.url);
2566
+ const assetClaims = await verifyImageTicket(secret, `${imageNamespace}
2567
+ assets:${endpoint.origin}`, ticket);
2568
+ const claims = assetClaims ?? await verifyImageTicket(secret, imageNamespace, ticket);
2569
+ if (!claims) return fail(req, 401, "Image ticket expired", "unauthorized");
2570
+ const worker = globalThis.navigator?.userAgent === "Cloudflare-Workers";
2571
+ const source = allowedImageSource(claims.source, imageSources, endpoint, worker, worker ? assetPrefixes : []);
2572
+ if (!source) return fail(req, 403, "Image not allowed", "forbidden_image");
2573
+ const asset = publicAssets && source.origin === endpoint.origin && assetPrefixes.some((prefix) => source.pathname.startsWith(prefix));
2574
+ if (Boolean(asset) !== Boolean(assetClaims)) return fail(req, 403, "Image not allowed", "forbidden_image");
2575
+ if (limiter && !await limiter(`image:${claims.visitor}`, "preauth")) return fail(req, 429, "Too many requests, try again later", "rate_limited");
2576
+ if (activeImages >= 8) return fail(req, 429, "Too many requests, try again later", "rate_limited");
2577
+ activeImages++;
2578
+ try {
2579
+ const assetFetch = (input, init) => publicAssets.binding.fetch(new Request(new URL(new URL(String(input)).pathname, "https://assets.invalid"), init));
2580
+ const result = await fetchRasterImage(source.href, asset ? assetFetch : worker ? globalThis.fetch : publicImageFetchNode, worker ? "manual" : "error");
2581
+ return result ?? fail(req, 502, "Image unavailable", "image_unavailable");
2582
+ } finally {
2583
+ activeImages--;
2584
+ }
2585
+ }
2002
2586
  if (req.method === "GET" && action === "config") return await handleConfig(req);
2003
2587
  if (req.method === "POST" && action === "session") return await handleSession(req);
2004
2588
  if (req.method === "POST" && action === "chat") return await handleChat(req);
@@ -2007,6 +2591,7 @@ ${subject}`;
2007
2591
  if (req.method === "POST" && action === "job-access" && idn) return await handleJobAccess(req);
2008
2592
  return fail(req, 404, "Not found", "not_found");
2009
2593
  } catch (e) {
2594
+ if (e instanceof IdentityStoreError) return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" });
2010
2595
  if (!ccp) return fail(req, 500, e.message || "Internal error", "internal");
2011
2596
  console.error("[dewee-webchat] internal error:", e instanceof CcpError ? e.code : e);
2012
2597
  return fail(req, 500, "Internal error", "internal");
@@ -2037,6 +2622,7 @@ function optionsFromEnv(env, overrides = {}) {
2037
2622
  agents,
2038
2623
  defaultAgent: env.DEWEE_DEFAULT_AGENT,
2039
2624
  allowedOrigins: origins,
2625
+ imageSources: list(env.DEEWEE_IMAGE_SOURCES),
2040
2626
  allowClientCredentials: env.DEWEE_ALLOW_CLIENT_CREDENTIALS === "true" ? byoHosts?.length ? { allowedHosts: byoHosts } : true : false,
2041
2627
  turnstile: env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET_KEY ? { siteKey: env.TURNSTILE_SITE_KEY, secretKey: env.TURNSTILE_SECRET_KEY } : void 0,
2042
2628
  // undefined overrides must not erase env-derived values
@@ -2110,6 +2696,7 @@ export {
2110
2696
  hashManifest,
2111
2697
  isAdmissionErrorCode,
2112
2698
  listAgents,
2699
+ memoryIdentitySessionStore,
2113
2700
  memoryRateLimiter,
2114
2701
  normalizeManifest,
2115
2702
  optionsFromEnv,