dewee-webchat 0.4.1 → 0.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  parseJobAccessLaunch,
14
14
  serializePageContext,
15
15
  stripPageBlock
16
- } from "./chunk-EVZ5K7BN.js";
16
+ } from "./chunk-WHHQ6FKK.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,401 @@ 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) {
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
+ if (source.hostname === suffix || source.hostname.endsWith(`.${suffix}`)) return null;
1341
+ }
1342
+ return prefixes.some((prefix) => prefix.origin === source.origin && source.pathname.startsWith(prefix.pathname)) ? source : null;
1343
+ } catch {
1344
+ return null;
1345
+ }
1346
+ }
1347
+ function safeRasterDimensions(bytes, type) {
1348
+ const ascii = (start, end) => String.fromCharCode(...bytes.slice(start, end));
1349
+ const u16 = (at, little = false) => little ? bytes[at] + bytes[at + 1] * 256 : bytes[at] * 256 + bytes[at + 1];
1350
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1351
+ const u32 = (at, little = false) => view.getUint32(at, little);
1352
+ const sizeOK = (w, h) => w > 0 && h > 0 && w <= 4096 && h <= 4096 && w * h <= 16e6;
1353
+ let width = 0, height = 0;
1354
+ if (type === "image/png") {
1355
+ let pos = 8, header = false, data = false, end = false;
1356
+ while (pos + 12 <= bytes.length) {
1357
+ const length = u32(pos), name = ascii(pos + 4, pos + 8);
1358
+ if (length > bytes.length - pos - 12) return false;
1359
+ if (name === "IHDR") {
1360
+ if (header || pos !== 8 || length !== 13) return false;
1361
+ header = true;
1362
+ width = u32(pos + 8);
1363
+ height = u32(pos + 12);
1364
+ } else if (name === "acTL" || name === "fcTL" || name === "fdAT") return false;
1365
+ else if (name === "IDAT") data = true;
1366
+ else if (name === "IEND") {
1367
+ end = length === 0;
1368
+ break;
1369
+ }
1370
+ pos += length + 12;
1371
+ }
1372
+ if (!header || !data || !end) return false;
1373
+ } else if (type === "image/gif" && bytes.length >= 13) {
1374
+ width = u16(6, true);
1375
+ height = u16(8, true);
1376
+ let pos = 13 + (bytes[10] & 128 ? 3 * (1 << (bytes[10] & 7) + 1) : 0), frames = 0, end = false;
1377
+ const blocks = () => {
1378
+ while (pos < bytes.length) {
1379
+ const n = bytes[pos++];
1380
+ if (n === 0) return true;
1381
+ if (n > bytes.length - pos) return false;
1382
+ pos += n;
1383
+ }
1384
+ return false;
1385
+ };
1386
+ while (pos < bytes.length) {
1387
+ const marker = bytes[pos++];
1388
+ if (marker === 59) {
1389
+ end = true;
1390
+ break;
1391
+ }
1392
+ if (marker === 33) {
1393
+ if (pos >= bytes.length) return false;
1394
+ pos++;
1395
+ if (!blocks()) return false;
1396
+ continue;
1397
+ }
1398
+ if (marker !== 44 || pos + 9 > bytes.length || ++frames > 1) return false;
1399
+ const left = u16(pos, true), top = u16(pos + 2, true), w = u16(pos + 4, true), h = u16(pos + 6, true), flags = bytes[pos + 8];
1400
+ if (!sizeOK(w, h) || left + w > width || top + h > height) return false;
1401
+ pos += 9 + (flags & 128 ? 3 * (1 << (flags & 7) + 1) : 0);
1402
+ if (pos >= bytes.length) return false;
1403
+ pos++;
1404
+ if (!blocks()) return false;
1405
+ }
1406
+ if (frames !== 1 || !end) return false;
1407
+ } else if (type === "image/jpeg") {
1408
+ let pos = 2, frames = 0, end = false;
1409
+ while (pos + 1 < bytes.length && bytes[pos] === 255) {
1410
+ while (bytes[pos] === 255) pos++;
1411
+ const marker = bytes[pos++];
1412
+ if (marker === 217) {
1413
+ end = true;
1414
+ break;
1415
+ }
1416
+ if (marker === 1 || marker >= 208 && marker <= 215) continue;
1417
+ if (pos + 2 > bytes.length) return false;
1418
+ const length = u16(pos);
1419
+ if (length < 2 || pos + length > bytes.length) return false;
1420
+ if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker)) {
1421
+ if (++frames !== 1 || length < 7) return false;
1422
+ height = u16(pos + 3);
1423
+ width = u16(pos + 5);
1424
+ }
1425
+ pos += length;
1426
+ if (marker === 218) {
1427
+ while (pos + 1 < bytes.length) {
1428
+ if (bytes[pos] !== 255) {
1429
+ pos++;
1430
+ continue;
1431
+ }
1432
+ const next = bytes[pos + 1];
1433
+ if (next === 0 || next >= 208 && next <= 215) {
1434
+ pos += 2;
1435
+ continue;
1436
+ }
1437
+ break;
1438
+ }
1439
+ }
1440
+ }
1441
+ if (frames !== 1 || !end) return false;
1442
+ } else if (type === "image/webp" && bytes.length >= 20) {
1443
+ const total = u32(4, true) + 8;
1444
+ if (total > bytes.length || total < 20) return false;
1445
+ let pos = 12, images = 0, canvasW = 0, canvasH = 0;
1446
+ while (pos + 8 <= total) {
1447
+ const chunk = ascii(pos, pos + 4), length = u32(pos + 4, true), start = pos + 8;
1448
+ if (length > total - start || start + length + (length & 1) > total || chunk === "ANIM" || chunk === "ANMF") return false;
1449
+ if (chunk === "VP8X") {
1450
+ if (length !== 10 || canvasW || bytes[start] & 2) return false;
1451
+ canvasW = 1 + bytes[start + 4] + (bytes[start + 5] << 8) + (bytes[start + 6] << 16);
1452
+ canvasH = 1 + bytes[start + 7] + (bytes[start + 8] << 8) + (bytes[start + 9] << 16);
1453
+ } else if (chunk === "VP8L" && length >= 5 && bytes[start] === 47) {
1454
+ images++;
1455
+ width = 1 + bytes[start + 1] + ((bytes[start + 2] & 63) << 8);
1456
+ height = 1 + (bytes[start + 2] >> 6) + (bytes[start + 3] << 2) + ((bytes[start + 4] & 15) << 10);
1457
+ } else if (chunk === "VP8 " && length >= 10 && bytes[start + 3] === 157 && bytes[start + 4] === 1 && bytes[start + 5] === 42) {
1458
+ images++;
1459
+ width = u16(start + 6, true) & 16383;
1460
+ height = u16(start + 8, true) & 16383;
1461
+ }
1462
+ pos = start + length + (length & 1);
1463
+ }
1464
+ if (pos !== total || images !== 1 || canvasW && (canvasW !== width || canvasH !== height)) return false;
1465
+ }
1466
+ return sizeOK(width, height);
1467
+ }
1468
+ async function fetchRasterImage(url, fetcher, redirect = "error") {
1469
+ const controller = new AbortController();
1470
+ const timer = setTimeout(() => controller.abort(), 1e4);
1471
+ try {
1472
+ const response = await fetcher(url, { method: "GET", redirect, credentials: "omit", signal: controller.signal, headers: { accept: "image/png,image/jpeg,image/gif,image/webp" } });
1473
+ if (!response.ok || !response.body) {
1474
+ await response.body?.cancel();
1475
+ return null;
1476
+ }
1477
+ const type = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
1478
+ if (!type || !["image/png", "image/jpeg", "image/gif", "image/webp"].includes(type)) {
1479
+ await response.body.cancel();
1480
+ return null;
1481
+ }
1482
+ const length = response.headers.get("content-length");
1483
+ const maxBytes = 2 * 1024 * 1024;
1484
+ if (length && (!/^\d+$/.test(length) || Number(length) > maxBytes)) {
1485
+ await response.body.cancel();
1486
+ return null;
1487
+ }
1488
+ const reader = response.body.getReader();
1489
+ const chunks = [];
1490
+ let size = 0;
1491
+ try {
1492
+ for (; ; ) {
1493
+ const { done, value } = await reader.read();
1494
+ if (done) break;
1495
+ size += value.byteLength;
1496
+ if (size > maxBytes) {
1497
+ await reader.cancel();
1498
+ return null;
1499
+ }
1500
+ chunks.push(value);
1501
+ }
1502
+ } finally {
1503
+ reader.releaseLock();
1504
+ }
1505
+ const bytes = new Uint8Array(size);
1506
+ let offset = 0;
1507
+ for (const chunk of chunks) {
1508
+ bytes.set(chunk, offset);
1509
+ offset += chunk.length;
1510
+ }
1511
+ const starts = (...signature) => signature.every((value, index) => bytes[index] === value);
1512
+ const ascii = (start, end) => String.fromCharCode(...bytes.slice(start, end));
1513
+ 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";
1514
+ if (!valid || !safeRasterDimensions(bytes, type)) return null;
1515
+ 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" } });
1516
+ } catch {
1517
+ return null;
1518
+ } finally {
1519
+ clearTimeout(timer);
1520
+ }
1521
+ }
1522
+
1523
+ // src/server/public-image-fetch-node.ts
1524
+ async function publicImageFetchNode(input, init) {
1525
+ if (typeof input !== "string") throw new Error("Invalid image URL");
1526
+ const url = new URL(input);
1527
+ if (url.protocol !== "https:" || url.username || url.password || url.port && url.port !== "443") throw new Error("Invalid image URL");
1528
+ const prefix = "node:";
1529
+ const dns = await import(prefix + "dns/promises");
1530
+ const net = await import(prefix + "net");
1531
+ const https = await import(prefix + "https");
1532
+ if (net.isIP(url.hostname)) throw new Error("IP literals are not allowed");
1533
+ const lookup = dns.lookup(url.hostname, { all: true, verbatim: true });
1534
+ const signal = init?.signal;
1535
+ const addresses = signal ? await new Promise((resolve, reject) => {
1536
+ if (signal.aborted) {
1537
+ reject(new Error("Image request timed out"));
1538
+ return;
1539
+ }
1540
+ const abort = () => reject(new Error("Image request timed out"));
1541
+ signal.addEventListener("abort", abort, { once: true });
1542
+ lookup.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
1543
+ }) : await lookup;
1544
+ if (!addresses.length) throw new Error("No image address");
1545
+ if (!publicImageAddresses(addresses, net.BlockList)) throw new Error("Non-public image address");
1546
+ const chosen = addresses.find((entry) => entry.family === 4) ?? addresses[0];
1547
+ return new Promise((resolve, reject) => {
1548
+ const request = https.request(url, {
1549
+ method: "GET",
1550
+ headers: { accept: "image/png,image/jpeg,image/gif,image/webp" },
1551
+ signal: init?.signal ?? void 0,
1552
+ family: chosen.family,
1553
+ lookup: (_host, _options, callback) => callback(null, chosen.address, chosen.family)
1554
+ }, (response) => {
1555
+ if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300 || response.statusCode === 204 || response.statusCode === 205) {
1556
+ response.destroy();
1557
+ reject(new Error("Image upstream refused"));
1558
+ return;
1559
+ }
1560
+ const headers = new Headers();
1561
+ for (const [name, value] of Object.entries(response.headers)) if (typeof value === "string") headers.set(name, value);
1562
+ let received = 0;
1563
+ let settled = false;
1564
+ const body = new ReadableStream({
1565
+ start(controller) {
1566
+ response.pause();
1567
+ response.on("data", (chunk) => {
1568
+ response.pause();
1569
+ received += chunk.length;
1570
+ if (received > 2 * 1024 * 1024) {
1571
+ settled = true;
1572
+ response.destroy();
1573
+ controller.error(new Error("Image too large"));
1574
+ } else controller.enqueue(new Uint8Array(chunk));
1575
+ });
1576
+ response.on("end", () => {
1577
+ if (!settled) {
1578
+ settled = true;
1579
+ controller.close();
1580
+ }
1581
+ });
1582
+ response.on("error", (error) => {
1583
+ if (!settled) {
1584
+ settled = true;
1585
+ controller.error(error);
1586
+ }
1587
+ });
1588
+ },
1589
+ pull() {
1590
+ response.resume();
1591
+ },
1592
+ cancel() {
1593
+ settled = true;
1594
+ response.destroy();
1595
+ }
1596
+ });
1597
+ resolve(new Response(body, { status: response.statusCode ?? 502, headers }));
1598
+ });
1599
+ request.on("error", reject);
1600
+ request.end();
1601
+ });
1602
+ }
1603
+ function publicImageAddresses(addresses, BlockList) {
1604
+ const denied4 = new BlockList();
1605
+ const denied6 = new BlockList();
1606
+ 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");
1607
+ 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");
1608
+ const publicV6 = new BlockList();
1609
+ publicV6.addSubnet("2000::", 3, "ipv6");
1610
+ return addresses.length > 0 && addresses.every(({ address, family }) => family === 6 ? !denied6.check(address, "ipv6") && publicV6.check(address, "ipv6") : family === 4 && !denied4.check(address, "ipv4"));
1611
+ }
1612
+
1155
1613
  // src/server/handler.ts
1614
+ var IdentityStoreError = class extends Error {
1615
+ };
1156
1616
  var ID_RE = /^[A-Za-z0-9_-]{6,64}$/;
1157
1617
  var AGENT_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
1158
1618
  var IDEMPOTENCY_KEY_RE = /^[\x21-\x7E]{1,255}$/;
1159
1619
  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
1620
  var DEFAULT_IDENTITY_TTL_MS = 36e5;
1161
1621
  var MAX_IDENTITY_TTL_MS = 432e5;
1162
- var MAX_CACHED_SESSIONS = 1e3;
1163
1622
  var ANON_REUSE_MS = 864e5;
1164
1623
  var VERIFIED_REUSE_MIN_MS = 12e4;
1165
1624
  function checkIdentityOptions(o, staticAgents, transport) {
@@ -1205,11 +1664,30 @@ function createDeweeHandler(options) {
1205
1664
  ...options
1206
1665
  };
1207
1666
  const transport = resolveTransport(o);
1667
+ if (o.imageSources !== void 0 && !Array.isArray(o.imageSources)) throw new Error("[dewee-webchat] imageSources must be an array");
1668
+ const imageSources = (o.imageSources ?? []).map((origin) => {
1669
+ let url;
1670
+ try {
1671
+ url = new URL(origin);
1672
+ } catch {
1673
+ throw new Error("[dewee-webchat] Invalid image origin");
1674
+ }
1675
+ 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");
1676
+ return url;
1677
+ });
1678
+ let activeImages = 0;
1679
+ const imageNamespace = `${o.ccp?.chatUrl ?? o.gatewayUrl ?? ""}
1680
+ ${o.ccp?.integrationId ?? o.tenantId ?? ""}`;
1681
+ if (o.allowedHeaders !== void 0 && (!Array.isArray(o.allowedHeaders) || o.allowedHeaders.some((name) => typeof name !== "string" || !/^[A-Za-z0-9-]+$/.test(name)))) {
1682
+ throw new Error("[dewee-webchat] allowedHeaders must contain valid header names (letters, digits and hyphens)");
1683
+ }
1208
1684
  let secret = o.secret;
1209
1685
  if (!secret) {
1686
+ if (o.allowEphemeralSecret !== true) throw new Error("[dewee-webchat] Configure secret (DEWEE_WEBCHAT_SECRET), or explicitly enable allowEphemeralSecret for development");
1210
1687
  secret = randomId(32);
1211
1688
  console.warn("[dewee-webchat] No secret configured: using an ephemeral one. Visitor sessions reset on restart.");
1212
1689
  }
1690
+ if (new TextEncoder().encode(secret).length < 32) throw new Error("[dewee-webchat] secret (DEWEE_WEBCHAT_SECRET) must be at least 32 bytes");
1213
1691
  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
1692
  const staticAgents = o.agents === "*" ? [] : (o.agents ?? []).map((a) => typeof a === "string" ? { id: a } : a);
1215
1693
  const wildcardAgents = o.agents === "*";
@@ -1280,7 +1758,7 @@ function createDeweeHandler(options) {
1280
1758
  return {
1281
1759
  "Access-Control-Allow-Origin": origin,
1282
1760
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1283
- "Access-Control-Allow-Headers": `content-type, authorization, ${HEADER_GATEWAY_URL}, ${HEADER_GATEWAY_TOKEN}`,
1761
+ "Access-Control-Allow-Headers": [.../* @__PURE__ */ new Set(["content-type", "authorization", HEADER_GATEWAY_URL, HEADER_GATEWAY_TOKEN, ...(o.allowedHeaders ?? []).map((h) => h.toLowerCase())])].join(", "),
1284
1762
  "Access-Control-Max-Age": "600",
1285
1763
  Vary: "Origin"
1286
1764
  };
@@ -1300,27 +1778,27 @@ function createDeweeHandler(options) {
1300
1778
  return json(req, { error: { message: e.message, code, remediation: remediation ?? { action: e.action, audience: "end_user" } } }, e.status);
1301
1779
  };
1302
1780
  const misconfigured = (req) => fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured");
1303
- function ccpFail(req, e, route, sessionToken) {
1781
+ async function ccpFail(req, e, route, sessionToken) {
1304
1782
  const b = browserErrorFor(e, route);
1305
1783
  if (b.admission) {
1306
1784
  const r = admissionErrorResponse(b.admission, b.retryAfterMs);
1307
1785
  return json(req, r.body, r.status, r.headers);
1308
1786
  }
1309
1787
  if (b.identity) {
1310
- if (sessionToken && DEAD_SESSION_CODES.has(b.identity)) forgetCachedSession(sessionToken);
1788
+ if (sessionToken && DEAD_SESSION_CODES.has(b.identity)) await forgetCachedSession(sessionToken);
1311
1789
  return identityFail(req, b.identity, b.remediation);
1312
1790
  }
1313
1791
  const res = fail(req, b.status, b.message, b.code);
1314
1792
  if (b.retryAfterSeconds) res.headers.set("retry-after", String(Math.ceil(b.retryAfterSeconds)));
1315
1793
  return res;
1316
1794
  }
1317
- const gatewayFail = (req, e, sessionToken) => {
1795
+ const gatewayFail = async (req, e, sessionToken) => {
1318
1796
  if (e instanceof GatewayError && isAdmissionErrorCode(e.code)) {
1319
1797
  const r = admissionErrorResponse(e.code, e.retryAfterMs);
1320
1798
  return json(req, r.body, r.status, r.headers);
1321
1799
  }
1322
1800
  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);
1801
+ if (sessionToken && DEAD_SESSION_CODES.has(e.code)) await forgetCachedSession(sessionToken);
1324
1802
  if (e.remediation?.audience === "operator") console.error(`[dewee-webchat] identity: ${e.message} (${e.code})`);
1325
1803
  return identityFail(req, e.code, e.remediation);
1326
1804
  };
@@ -1354,7 +1832,7 @@ function createDeweeHandler(options) {
1354
1832
  }
1355
1833
  return ccpFail(req, e, "identity/exchange");
1356
1834
  };
1357
- const linkFail = (req, e, sessionToken) => {
1835
+ const linkFail = async (req, e, sessionToken) => {
1358
1836
  const code = e?.code;
1359
1837
  if (code === "IDEMPOTENCY_KEY_REUSED") return fail(req, 409, "This idempotency key was already used for a different link request", "idempotency_key_reused");
1360
1838
  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 +1840,7 @@ function createDeweeHandler(options) {
1362
1840
  console.error("[dewee-webchat] identity link start: the gateway has identity exchange switched off");
1363
1841
  return fail(req, 503, "Linking is not available right now", "linking_unavailable");
1364
1842
  }
1365
- if (typeof code === "string" && DEAD_SESSION_CODES.has(code)) forgetCachedSession(sessionToken);
1843
+ if (typeof code === "string" && DEAD_SESSION_CODES.has(code)) await forgetCachedSession(sessionToken);
1366
1844
  return sdkFail(req, e, "link start");
1367
1845
  };
1368
1846
  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 +1873,7 @@ function createDeweeHandler(options) {
1395
1873
  const current = !!user && typeof user.subject === "string" && user.subject !== "" ? await subjectDigest(secret, user.subject) : null;
1396
1874
  if (current && claims.sub && constantTimeEqual(current, claims.sub)) return null;
1397
1875
  if (ccp && current && claims.sub) {
1398
- forgetCachedSession(claims.ids);
1876
+ await forgetCachedSession(claims.ids);
1399
1877
  await ccp.revoke(visitorContext(req), claims.v, sealedEdge(claims), claims.ids, claims.ida);
1400
1878
  }
1401
1879
  return identityFail(req, "IDENTITY_REQUIRED");
@@ -1409,22 +1887,20 @@ function createDeweeHandler(options) {
1409
1887
  if (idn.assertionSecret) return { applicationId: idn.appId, issuer: idn.issuer, secret: idn.assertionSecret };
1410
1888
  return { applicationId: idn.appId, issuer: idn.issuer, privateKeyJwk: idn.privateKeyJwk, algorithm: idn.algorithm };
1411
1889
  }
1412
- const sessionCache = /* @__PURE__ */ new Map();
1890
+ const sessionStore = idn?.sessionStore ?? memoryIdentitySessionStore();
1413
1891
  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);
1892
+ async function storeOperation(operation) {
1893
+ try {
1894
+ return await operation();
1895
+ } catch {
1896
+ throw new IdentityStoreError("Identity session store unavailable");
1426
1897
  }
1427
1898
  }
1899
+ async function forgetCachedSession(sessionToken) {
1900
+ await storeOperation(async () => sessionStore.deleteToken(await storeNamespace(), sessionToken));
1901
+ }
1902
+ const storeNamespace = () => subjectDigest(secret, JSON.stringify([o.ccp?.chatUrl ?? o.gatewayUrl ?? "", o.ccp?.integrationId ?? o.tenantId ?? "", idn.appId, idn.issuer]));
1903
+ const storeSubject = async (subject) => `${await storeNamespace()}:${await subjectDigest(secret, subject)}`;
1428
1904
  async function exchangeFor(subject, req, sealed) {
1429
1905
  if (ccp) return ccpExchangeFor(subject, req, sealed);
1430
1906
  const session = await idn.sdk.exchangeSession({
@@ -1477,28 +1953,44 @@ function createDeweeHandler(options) {
1477
1953
  } catch (e) {
1478
1954
  return ccpFail(req, e, "config");
1479
1955
  }
1480
- const key = `${agent}
1481
- ${subject}`;
1482
- let s = sessionCache.get(key);
1956
+ const subjectKey = await storeSubject(subject);
1957
+ const key = JSON.stringify([subjectKey, agent]);
1958
+ const observed = await storeOperation(() => sessionStore.read(subjectKey, agent));
1959
+ let s = observed.session;
1483
1960
  const userVisitor = ccp ? await deriveVisitorId(secret, `subject:${subject}`) : void 0;
1484
1961
  const sealed = hint && hint.ids && hint.v === userVisitor ? sealedEdge(hint) : void 0;
1485
1962
  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
- }
1963
+ let pending = exchanges.get(key);
1964
+ if (!pending) {
1965
+ pending = (async () => {
1966
+ const reused = await verifiedReuse(req, hint, subject, agent, userVisitor);
1967
+ const candidate = reused ?? await exchangeFor(subject, req, sealed);
1968
+ const revokeMinted = async () => {
1969
+ if (ccp && !reused) await ccp.revoke(visitorContext(req), candidate.visitorId, sealed, candidate.sessionToken, agent).catch(() => {
1970
+ });
1971
+ };
1972
+ try {
1973
+ const committed = await storeOperation(() => sessionStore.commit(subjectKey, agent, observed.revision, candidate));
1974
+ if (!committed) {
1975
+ const current = await storeOperation(() => sessionStore.read(subjectKey, agent));
1976
+ if (current.session?.sessionToken !== candidate.sessionToken) throw new IdentityStoreError("Identity session changed");
1977
+ }
1978
+ return candidate;
1979
+ } catch (error) {
1980
+ await revokeMinted();
1981
+ throw error;
1982
+ }
1983
+ })().finally(() => exchanges.delete(key));
1984
+ exchanges.set(key, pending);
1985
+ }
1986
+ try {
1987
+ s = await pending;
1988
+ } catch (e) {
1989
+ if (e instanceof IdentityStoreError) throw e;
1990
+ return ccp ? ccpExchangeFail(req, e) : exchangeFail(req, e);
1500
1991
  }
1501
- rememberSession(key, s);
1992
+ } else if (!await storeOperation(() => sessionStore.check(subjectKey, observed.revision))) {
1993
+ return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" });
1502
1994
  }
1503
1995
  const now = Date.now();
1504
1996
  const expiresAt = Math.min(s.deadline, now + o.sessionTtlMs);
@@ -1546,6 +2038,15 @@ ${subject}`;
1546
2038
  maxMessageLength: o.maxMessageLength,
1547
2039
  version: VERSION
1548
2040
  };
2041
+ if (creds && new URL(req.url).searchParams.get("pageContext") === "1") {
2042
+ if (limiter && !await limiter(clientIp(req), "preauth")) return fail(req, 429, "Too many requests, slow down", "rate_limited");
2043
+ try {
2044
+ const probe = await GatewayConnection.connect(creds, o.userIdPrefix + "capability-probe");
2045
+ if (probe.pageContextVersions.length) body.pageContextVersions = probe.pageContextVersions;
2046
+ probe.close();
2047
+ } catch {
2048
+ }
2049
+ }
1549
2050
  if (idn) {
1550
2051
  body.defaultAgent = identityAgent;
1551
2052
  body.allowAgentSwitch = false;
@@ -1570,6 +2071,7 @@ ${subject}`;
1570
2071
  turnstileSiteKey: o.turnstile?.siteKey,
1571
2072
  ...o.turnstile ? { refreshWithoutTurnstile: true } : {},
1572
2073
  maxMessageLength: Math.min(o.maxMessageLength, cfg.maxMessageChars),
2074
+ ...cfg.pageContextVersions.length ? { pageContextVersions: cfg.pageContextVersions } : {},
1573
2075
  version: VERSION
1574
2076
  };
1575
2077
  if (idn) {
@@ -1605,7 +2107,7 @@ ${subject}`;
1605
2107
  if (!(ccp && previous) && !await turnstilePassed(body, ip)) return fail(req, 403, "Verification failed", "turnstile_failed");
1606
2108
  const visitorId = previous?.v ?? randomId(16);
1607
2109
  const expiresAt = Date.now() + o.sessionTtlMs;
1608
- if (!ccp) return json(req, { visitorId, token: await signVisitorToken(secret, { v: visitorId, exp: expiresAt }), expiresAt });
2110
+ if (!ccp) return json(req, { visitorId, token: await signVisitorToken(secret, { v: visitorId, exp: expiresAt }), expiresAt, expiresIn: Math.max(0, expiresAt - Date.now()) });
1609
2111
  let edge;
1610
2112
  try {
1611
2113
  edge = await ccp.edgeFor(visitorContext(req), visitorId, sealedEdge(previous), void 0, EDGE_RESEAL_MS);
@@ -1642,7 +2144,7 @@ ${subject}`;
1642
2144
  const previous = await anonymousPrevious(body);
1643
2145
  if (previous && previous.exp - Date.now() > ANON_REUSE_MS) {
1644
2146
  if (!ccp) {
1645
- const reused = { visitorId: previous.v, token: body.token, expiresAt: previous.exp };
2147
+ const reused = { visitorId: previous.v, token: body.token, expiresAt: previous.exp, expiresIn: Math.max(0, previous.exp - Date.now()) };
1646
2148
  return json(req, reused);
1647
2149
  }
1648
2150
  const edge = sealedEdge(previous);
@@ -1666,7 +2168,10 @@ ${subject}`;
1666
2168
  const { creds, byo: isByo, error } = resolveCreds(req);
1667
2169
  if (error) return fail(req, 400, error, "bad_credentials");
1668
2170
  if (!creds && !ccp) return fail(req, 500, "Gateway is not configured", "not_configured");
1669
- const body = await req.json().catch(() => null);
2171
+ const input = await readChatBody(req);
2172
+ 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");
2173
+ if (!input.body || typeof input.body !== "object" || Array.isArray(input.body)) return fail(req, 400, "Invalid request", "bad_request");
2174
+ const body = input.body;
1670
2175
  const message = typeof body?.message === "string" ? body.message.trim() : "";
1671
2176
  if (!message) return fail(req, 400, "Message is required");
1672
2177
  if (message.length > o.maxMessageLength) return fail(req, 413, `Message exceeds ${o.maxMessageLength} characters`, "too_long");
@@ -1686,15 +2191,23 @@ ${subject}`;
1686
2191
  if (e instanceof ActionInputError) return fail(req, e.status, e.message, e.code);
1687
2192
  throw e;
1688
2193
  }
1689
- if (ccp) return ccpChat(req, claims, { message: pageBlock + message, rawMessage: message, conversationId, agent, actionsHash });
2194
+ if (body?.page_context != null) {
2195
+ 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");
2196
+ if (!validPageContext(body.page_context)) return fail(req, 400, "Invalid page context", "page_context_invalid");
2197
+ }
2198
+ if (ccp) return ccpChat(req, claims, { message: pageBlock + message, rawMessage: message, conversationId, agent, actionsHash, pageContext: body?.page_context });
1690
2199
  if (!creds) return fail(req, 500, "Gateway is not configured", "not_configured");
1691
2200
  let connection;
1692
- if (claims.ids) {
2201
+ if (claims.ids || body?.page_context != null) {
1693
2202
  try {
1694
- connection = await GatewayConnection.connect(creds, { identitySession: claims.ids });
2203
+ connection = await GatewayConnection.connect(creds, claims.ids ? { identitySession: claims.ids } : o.userIdPrefix + visitorId);
1695
2204
  } catch (e) {
1696
2205
  return gatewayFail(req, e, claims.ids);
1697
2206
  }
2207
+ if (body?.page_context != null && !connection.pageContextVersions.includes(1)) {
2208
+ connection.close();
2209
+ return fail(req, 400, "Page context is not supported", "page_context_unsupported");
2210
+ }
1698
2211
  }
1699
2212
  const sessionKey = buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`);
1700
2213
  const abort = new AbortController();
@@ -1712,12 +2225,12 @@ ${subject}`;
1712
2225
  try {
1713
2226
  await send(actionsHash ? { type: "start", conversationId, agent, actionsHash } : { type: "start", conversationId, agent });
1714
2227
  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 })) {
2228
+ 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
2229
  if (ev.type === "start") continue;
1717
2230
  if (ev.type === "done") reply = ev.content;
1718
2231
  if (ev.type === "error") {
1719
2232
  errMsg = gatewayReasonOf(ev) ?? ev.message;
1720
- if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) forgetCachedSession(claims.ids);
2233
+ if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) await forgetCachedSession(claims.ids);
1721
2234
  }
1722
2235
  await send(ev);
1723
2236
  if (abort.signal.aborted) break;
@@ -1773,6 +2286,20 @@ ${subject}`;
1773
2286
  let upstream;
1774
2287
  try {
1775
2288
  cfg = await ccp.config();
2289
+ if (turn.pageContext != null) {
2290
+ if (!cfg.pageContextVersions.includes(1)) {
2291
+ cleanup();
2292
+ return fail(req, 400, "Page context is not supported", "page_context_unsupported");
2293
+ }
2294
+ if (typeof turn.pageContext === "object" && "version" in turn.pageContext && Number.isInteger(turn.pageContext.version) && turn.pageContext.version !== 1) {
2295
+ cleanup();
2296
+ return fail(req, 400, "Page context is not supported", "page_context_unsupported");
2297
+ }
2298
+ if (!validPageContext(turn.pageContext)) {
2299
+ cleanup();
2300
+ return fail(req, 400, "Invalid page context", "page_context_invalid");
2301
+ }
2302
+ }
1776
2303
  if (turn.rawMessage.length > cfg.maxMessageChars) {
1777
2304
  cleanup();
1778
2305
  return fail(req, 413, `Message exceeds ${cfg.maxMessageChars} characters`, "too_long");
@@ -1782,6 +2309,7 @@ ${subject}`;
1782
2309
  conversationId: turn.conversationId,
1783
2310
  agent: turn.agent || void 0,
1784
2311
  identitySession: claims.ids,
2312
+ pageContext: turn.pageContext ?? void 0,
1785
2313
  signal: abort.signal
1786
2314
  });
1787
2315
  } catch (e) {
@@ -1828,7 +2356,7 @@ ${subject}`;
1828
2356
  if (ev.type === "error") {
1829
2357
  errMsg = ev.message;
1830
2358
  finished = true;
1831
- if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) forgetCachedSession(claims.ids);
2359
+ if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) await forgetCachedSession(claims.ids);
1832
2360
  }
1833
2361
  await send(ev);
1834
2362
  if (abort.signal.aborted) break;
@@ -1969,10 +2497,12 @@ ${subject}`;
1969
2497
  async function revoke(subject, request, token) {
1970
2498
  if (!idn || typeof subject !== "string" || subject === "") return;
1971
2499
  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 });
2500
+ const subjectKey = await storeSubject(subject);
2501
+ let held = [];
2502
+ try {
2503
+ held = await storeOperation(() => sessionStore.takeSubject(subjectKey));
2504
+ } catch {
2505
+ console.warn("[dewee-webchat] identity session store unavailable during revoke");
1976
2506
  }
1977
2507
  if (ccp && typeof token === "string" && token !== "") {
1978
2508
  const c = await verifyReuseHint(secret, token) ?? await verifyVisitorToken(secret, token);
@@ -1980,7 +2510,6 @@ ${subject}`;
1980
2510
  held.push({ agent: c.ida, session: { sessionToken: c.ids, visitorId: c.v, deadline: c.exp, margin: 0 }, edge: sealedEdge(c) });
1981
2511
  }
1982
2512
  }
1983
- for (const { session } of held) forgetCachedSession(session.sessionToken);
1984
2513
  if (!ccp) return;
1985
2514
  const fromRequest = request ? visitorContext(request) : null;
1986
2515
  await Promise.all(
@@ -1999,6 +2528,44 @@ ${subject}`;
1999
2528
  if (!originAllowed(req)) return fail(req, 403, "Origin not allowed", "forbidden_origin");
2000
2529
  const action = new URL(req.url).pathname.replace(/\/+$/, "").split("/").pop();
2001
2530
  try {
2531
+ if (req.method === "POST" && action === "image-ticket" && imageSources.length) {
2532
+ const claims = await visitorFrom(req);
2533
+ if (!claims) return fail(req, 401, "Missing or expired visitor session", "unauthorized");
2534
+ if (idn?.required && !claims.ids) return identityFail(req, "IDENTITY_REQUIRED");
2535
+ const refused = await rebindRefusal(req, claims);
2536
+ if (refused) return refused;
2537
+ if (limiter && !await limiter(`image-ticket:${claims.v}`, "preauth")) return fail(req, 429, "Too many requests, try again later", "rate_limited");
2538
+ const body = await readImageTicketBody(req);
2539
+ if (body === null) return fail(req, 413, "Invalid image", "invalid_request");
2540
+ let parsed;
2541
+ try {
2542
+ parsed = JSON.parse(body);
2543
+ } catch {
2544
+ return fail(req, 400, "Invalid image", "invalid_request");
2545
+ }
2546
+ const raw = parsed?.url;
2547
+ const worker = globalThis.navigator?.userAgent === "Cloudflare-Workers";
2548
+ const source = typeof raw === "string" ? allowedImageSource(raw, imageSources, new URL(req.url), worker) : null;
2549
+ if (!source) return fail(req, 403, "Image not allowed", "forbidden_image");
2550
+ return json(req, { ticket: await issueImageTicket(secret, imageNamespace, claims.v, source.href) });
2551
+ }
2552
+ if (req.method === "GET" && action === "image" && imageSources.length) {
2553
+ const ticket = new URL(req.url).searchParams.get("ticket") ?? "";
2554
+ const claims = await verifyImageTicket(secret, imageNamespace, ticket);
2555
+ if (!claims) return fail(req, 401, "Image ticket expired", "unauthorized");
2556
+ const worker = globalThis.navigator?.userAgent === "Cloudflare-Workers";
2557
+ const source = allowedImageSource(claims.source, imageSources, new URL(req.url), worker);
2558
+ if (!source) return fail(req, 403, "Image not allowed", "forbidden_image");
2559
+ if (limiter && !await limiter(`image:${claims.visitor}`, "preauth")) return fail(req, 429, "Too many requests, try again later", "rate_limited");
2560
+ if (activeImages >= 8) return fail(req, 429, "Too many requests, try again later", "rate_limited");
2561
+ activeImages++;
2562
+ try {
2563
+ const result = await fetchRasterImage(source.href, worker ? globalThis.fetch : publicImageFetchNode, worker ? "manual" : "error");
2564
+ return result ?? fail(req, 502, "Image unavailable", "image_unavailable");
2565
+ } finally {
2566
+ activeImages--;
2567
+ }
2568
+ }
2002
2569
  if (req.method === "GET" && action === "config") return await handleConfig(req);
2003
2570
  if (req.method === "POST" && action === "session") return await handleSession(req);
2004
2571
  if (req.method === "POST" && action === "chat") return await handleChat(req);
@@ -2007,6 +2574,7 @@ ${subject}`;
2007
2574
  if (req.method === "POST" && action === "job-access" && idn) return await handleJobAccess(req);
2008
2575
  return fail(req, 404, "Not found", "not_found");
2009
2576
  } catch (e) {
2577
+ if (e instanceof IdentityStoreError) return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" });
2010
2578
  if (!ccp) return fail(req, 500, e.message || "Internal error", "internal");
2011
2579
  console.error("[dewee-webchat] internal error:", e instanceof CcpError ? e.code : e);
2012
2580
  return fail(req, 500, "Internal error", "internal");
@@ -2037,6 +2605,7 @@ function optionsFromEnv(env, overrides = {}) {
2037
2605
  agents,
2038
2606
  defaultAgent: env.DEWEE_DEFAULT_AGENT,
2039
2607
  allowedOrigins: origins,
2608
+ imageSources: list(env.DEEWEE_IMAGE_SOURCES),
2040
2609
  allowClientCredentials: env.DEWEE_ALLOW_CLIENT_CREDENTIALS === "true" ? byoHosts?.length ? { allowedHosts: byoHosts } : true : false,
2041
2610
  turnstile: env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET_KEY ? { siteKey: env.TURNSTILE_SITE_KEY, secretKey: env.TURNSTILE_SECRET_KEY } : void 0,
2042
2611
  // undefined overrides must not erase env-derived values
@@ -2110,6 +2679,7 @@ export {
2110
2679
  hashManifest,
2111
2680
  isAdmissionErrorCode,
2112
2681
  listAgents,
2682
+ memoryIdentitySessionStore,
2113
2683
  memoryRateLimiter,
2114
2684
  normalizeManifest,
2115
2685
  optionsFromEnv,