dewee-webchat 0.2.0 → 0.4.1-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
@@ -1,24 +1,72 @@
1
1
  import {
2
2
  ACTION_LIMITS,
3
+ ADMISSION_ERROR_CODES,
3
4
  ActionInputError,
4
5
  HEADER_GATEWAY_TOKEN,
5
6
  HEADER_GATEWAY_URL,
6
7
  VERSION,
7
8
  buildPageBlock,
8
9
  hashManifest,
10
+ isAdmissionErrorCode,
11
+ isIdentityErrorCode,
9
12
  normalizeManifest,
13
+ parseJobAccessLaunch,
10
14
  serializePageContext,
11
15
  stripPageBlock
12
- } from "./chunk-JX6AH37X.js";
16
+ } from "./chunk-USXVB3AC.js";
17
+
18
+ // src/server/admission-errors.ts
19
+ var MESSAGES = {
20
+ POLICY_DENIED: "This message can't be sent.",
21
+ RATE_LIMITED: "Too many messages, please wait a moment.",
22
+ // Reserved for the gateway challenge protocol: no challenge context is defined or forwarded yet.
23
+ CHALLENGE_REQUIRED: "Verification is required to continue."
24
+ };
25
+ var STATUS = { POLICY_DENIED: 403, RATE_LIMITED: 429, CHALLENGE_REQUIRED: 403 };
26
+ var DEFAULT_RETRY_AFTER_MS = 5e3;
27
+ var MAX_RETRY_AFTER_MS = 6e5;
28
+ var DECIMAL_RE = /^\s*[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?\s*$/;
29
+ function normalizeRetryAfterMs(raw) {
30
+ const n = typeof raw === "number" ? raw : typeof raw === "string" && DECIMAL_RE.test(raw) ? Number(raw) : NaN;
31
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_RETRY_AFTER_MS;
32
+ return Math.min(Math.ceil(n), MAX_RETRY_AFTER_MS);
33
+ }
34
+ var gatewayReasons = /* @__PURE__ */ new WeakMap();
35
+ function gatewayReasonOf(event) {
36
+ return gatewayReasons.get(event);
37
+ }
38
+ function admissionErrorEvent(code, retryAfterMs, gatewayReason) {
39
+ const event = code === "RATE_LIMITED" ? { type: "error", message: MESSAGES[code], code, retryAfterMs: normalizeRetryAfterMs(retryAfterMs) } : { type: "error", message: MESSAGES[code], code };
40
+ if (gatewayReason) gatewayReasons.set(event, gatewayReason);
41
+ return event;
42
+ }
43
+ function admissionErrorResponse(code, retryAfterMs) {
44
+ const { type: _type, ...error } = admissionErrorEvent(code, retryAfterMs);
45
+ const headers = error.retryAfterMs ? { "retry-after": String(Math.ceil(error.retryAfterMs / 1e3)) } : {};
46
+ return { status: STATUS[code], body: { error }, headers };
47
+ }
13
48
 
14
49
  // src/server/gateway-client.ts
15
50
  var GatewayError = class extends Error {
16
- constructor(message, code) {
51
+ /** `retryAfterMs` is the gateway's raw value for retryable refusals (`RATE_LIMITED`), unvalidated. */
52
+ constructor(message, code, remediation, retryAfterMs) {
17
53
  super(message);
18
54
  this.code = code;
55
+ this.remediation = remediation;
56
+ this.retryAfterMs = retryAfterMs;
19
57
  }
20
58
  code;
59
+ remediation;
60
+ retryAfterMs;
21
61
  };
62
+ function parseRemediation(raw) {
63
+ if (raw === null || typeof raw !== "object") return void 0;
64
+ const { action, audience } = raw;
65
+ return typeof action === "string" && typeof audience === "string" ? { action, audience } : void 0;
66
+ }
67
+ function isIdentityUser(userId) {
68
+ return typeof userId === "string" && userId.startsWith("tuc:") && userId.length > 4;
69
+ }
22
70
  function wsUrl(base) {
23
71
  const u = new URL(base);
24
72
  u.protocol = u.protocol === "http:" ? "ws:" : "wss:";
@@ -62,12 +110,15 @@ var GatewayConnection = class _GatewayConnection {
62
110
  pending = /* @__PURE__ */ new Map();
63
111
  listeners = /* @__PURE__ */ new Set();
64
112
  closed = false;
65
- static async connect(creds, userId, timeoutMs = 15e3) {
113
+ static async connect(creds, actor, timeoutMs = 15e3) {
66
114
  const conn = new _GatewayConnection(await openSocket(creds.url, timeoutMs));
67
- const params = { token: creds.token, user_id: userId };
115
+ const params = typeof actor === "string" ? { token: creds.token, user_id: actor } : { token: creds.token, identity_session: actor.identitySession };
68
116
  if (creds.tenantId) params.tenant_id = creds.tenantId;
69
117
  try {
70
- await conn.call("connect", params, timeoutMs);
118
+ const res = await conn.call("connect", params, timeoutMs);
119
+ if (typeof actor !== "string" && !isIdentityUser(res?.user_id)) {
120
+ throw new GatewayError("Gateway did not apply the identity session", "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "operator" });
121
+ }
71
122
  } catch (e) {
72
123
  conn.close();
73
124
  throw e;
@@ -120,7 +171,10 @@ var GatewayConnection = class _GatewayConnection {
120
171
  const p = this.pending.get(frame.id);
121
172
  this.pending.delete(frame.id);
122
173
  if (frame.ok) p.resolve(frame.payload);
123
- else p.reject(new GatewayError(frame.error?.message || "Gateway request failed", frame.error?.code));
174
+ else {
175
+ const e = frame.error;
176
+ p.reject(new GatewayError(e?.message || "Gateway request failed", e?.code, parseRemediation(e?.details?.remediation), e?.retryAfterMs));
177
+ }
124
178
  } else if (frame.type === "event" && frame.event) {
125
179
  for (const cb of this.listeners) cb(frame.event, frame.payload);
126
180
  }
@@ -134,8 +188,13 @@ var GatewayConnection = class _GatewayConnection {
134
188
  function buildSessionKey(agent, conversationId) {
135
189
  return `agent:${agent}:ws:direct:${conversationId}`;
136
190
  }
191
+ function actorOf(opts) {
192
+ if (opts.identitySession) return { identitySession: opts.identitySession };
193
+ if (opts.userId) return opts.userId;
194
+ throw new GatewayError("A user id or an identity session is required", "invalid_actor");
195
+ }
137
196
  async function* streamChat(opts) {
138
- const conn = await GatewayConnection.connect(opts.creds, opts.userId);
197
+ const conn = opts.connection ?? await GatewayConnection.connect(opts.creds, actorOf(opts));
139
198
  const queue = [];
140
199
  let wake = null;
141
200
  let finished = false;
@@ -188,7 +247,8 @@ async function* streamChat(opts) {
188
247
  push({ type: "done", content: res?.content ?? "", usage: res?.usage });
189
248
  end();
190
249
  }).catch((err) => {
191
- if (!finished) push({ type: "error", message: err.message, code: err.code });
250
+ if (!finished && isAdmissionErrorCode(err.code)) push(admissionErrorEvent(err.code, err.retryAfterMs, err.message));
251
+ 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 });
192
252
  end();
193
253
  });
194
254
  try {
@@ -212,8 +272,8 @@ function clip(v, max = 4e3) {
212
272
  const s = typeof v === "string" ? v : JSON.stringify(v);
213
273
  return s.length > max ? s.slice(0, max) + "\u2026" : s;
214
274
  }
215
- async function fetchHistory(creds, userId, agent, sessionKey) {
216
- const conn = await GatewayConnection.connect(creds, userId);
275
+ async function fetchHistory(creds, actor, agent, sessionKey) {
276
+ const conn = await GatewayConnection.connect(creds, actor);
217
277
  try {
218
278
  const res = await conn.call("chat.history", { agentId: agent, sessionKey }, 15e3);
219
279
  return foldHistory(res?.messages ?? []);
@@ -270,6 +330,9 @@ function b64url(bytes) {
270
330
  for (const b of bytes) s += String.fromCharCode(b);
271
331
  return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
272
332
  }
333
+ async function sha256B64url(s) {
334
+ return b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(s))));
335
+ }
273
336
  function fromB64url(s) {
274
337
  const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4));
275
338
  return Uint8Array.from(bin, (c) => c.charCodeAt(0));
@@ -283,32 +346,162 @@ function hmacKey(secret) {
283
346
  }
284
347
  return k;
285
348
  }
286
- async function signVisitorToken(secret, claims) {
287
- const body = b64url(enc.encode(JSON.stringify(claims)));
349
+ var MAX_IDENTITY_SESSION_LENGTH = 512;
350
+ var MAX_SEALED_PLAINTEXT = '{"t":"","h":""}'.length + 6 * MAX_IDENTITY_SESSION_LENGTH + 43;
351
+ var MAX_SEALED_LENGTH = Math.ceil((12 + MAX_SEALED_PLAINTEXT + 16) * 4 / 3);
352
+ var AGENT_CLAIM_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
353
+ var MAX_EDGE_SESSION_LENGTH = 1024;
354
+ var MAX_SEALED_EDGE_LENGTH = Math.ceil((12 + '{"t":""}'.length + 6 * MAX_EDGE_SESSION_LENGTH + 16) * 4 / 3);
355
+ var IDENTITY_SEAL_INFO = "dewee-webchat:identity-session:aes-256-gcm:v1";
356
+ var EDGE_SEAL_INFO = "dewee-webchat:edge-session:aes-256-gcm:v1";
357
+ var sealKeyCache = /* @__PURE__ */ new Map();
358
+ function sealKey(secret, info = IDENTITY_SEAL_INFO) {
359
+ const id = `${info}
360
+ ${secret}`;
361
+ let k = sealKeyCache.get(id);
362
+ if (!k) {
363
+ k = crypto.subtle.importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]).then(
364
+ (base) => crypto.subtle.deriveKey(
365
+ { name: "HKDF", hash: "SHA-256", salt: enc.encode("dewee-webchat"), info: enc.encode(info) },
366
+ base,
367
+ { name: "AES-GCM", length: 256 },
368
+ false,
369
+ ["encrypt", "decrypt"]
370
+ )
371
+ );
372
+ sealKeyCache.set(id, k);
373
+ }
374
+ return k;
375
+ }
376
+ var edgeAad = (v) => enc.encode(`dewee-webchat:es:${v}`);
377
+ async function sealEdgeSession(secret, v, token) {
378
+ const iv = crypto.getRandomValues(new Uint8Array(12));
379
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: edgeAad(v) }, await sealKey(secret, EDGE_SEAL_INFO), enc.encode(JSON.stringify({ t: token }))));
380
+ const out = new Uint8Array(iv.length + ct.length);
381
+ out.set(iv);
382
+ out.set(ct, iv.length);
383
+ return b64url(out);
384
+ }
385
+ async function openEdgeSession(secret, v, sealed) {
386
+ const raw = fromB64url(sealed);
387
+ if (raw.length <= 12 + 16) return null;
388
+ try {
389
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: raw.slice(0, 12), additionalData: edgeAad(v) }, await sealKey(secret, EDGE_SEAL_INFO), raw.slice(12));
390
+ const parsed = JSON.parse(new TextDecoder().decode(pt));
391
+ return typeof parsed?.t === "string" && parsed.t !== "" && parsed.t.length <= MAX_EDGE_SESSION_LENGTH ? parsed.t : null;
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+ var sealAad = (v, agent) => enc.encode(`dewee-webchat:ids:${v}:${agent}`);
397
+ async function sealIdentitySession(secret, v, agent, sealed) {
398
+ const iv = crypto.getRandomValues(new Uint8Array(12));
399
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: sealAad(v, agent) }, await sealKey(secret), enc.encode(JSON.stringify(sealed))));
400
+ const out = new Uint8Array(iv.length + ct.length);
401
+ out.set(iv);
402
+ out.set(ct, iv.length);
403
+ return b64url(out);
404
+ }
405
+ async function openIdentitySession(secret, v, agent, sealed) {
406
+ const raw = fromB64url(sealed);
407
+ if (raw.length <= 12 + 16) return null;
408
+ try {
409
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: raw.slice(0, 12), additionalData: sealAad(v, agent) }, await sealKey(secret), raw.slice(12));
410
+ const parsed = JSON.parse(new TextDecoder().decode(pt));
411
+ if (typeof parsed?.t !== "string" || parsed.t === "" || parsed.t.length > MAX_IDENTITY_SESSION_LENGTH) return null;
412
+ if (parsed.h !== void 0 && typeof parsed.h !== "string") return null;
413
+ return parsed;
414
+ } catch {
415
+ return null;
416
+ }
417
+ }
418
+ async function subjectDigest(secret, subject) {
419
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(`dewee-webchat:subject:${subject}`)));
420
+ return b64url(mac);
421
+ }
422
+ function constantTimeEqual(a, b) {
423
+ const x = enc.encode(a);
424
+ const y = enc.encode(b);
425
+ let diff = x.length ^ y.length;
426
+ for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
427
+ return diff === 0;
428
+ }
429
+ var REUSE_HINT_PURPOSE = "reuse";
430
+ function signVisitorToken(secret, claims) {
431
+ return signClaims(secret, claims);
432
+ }
433
+ function signReuseHint(secret, claims) {
434
+ if (!claims.ids || !claims.ida || !claims.sub) throw new Error("a reuse hint needs a signed-in session with its subject digest");
435
+ return signClaims(secret, claims, REUSE_HINT_PURPOSE);
436
+ }
437
+ function verifyVisitorToken(secret, token) {
438
+ return verifyClaims(secret, token);
439
+ }
440
+ async function verifyReuseHint(secret, token) {
441
+ const claims = await verifyClaims(secret, token, REUSE_HINT_PURPOSE);
442
+ return claims?.ids && claims.sub ? claims : null;
443
+ }
444
+ async function signClaims(secret, claims, purpose) {
445
+ let wire = claims;
446
+ if (claims.ids !== void 0 && claims.ida !== void 0) {
447
+ const { sub, ...rest } = claims;
448
+ wire = { ...rest, ids: await sealIdentitySession(secret, claims.v, claims.ida, sub ? { t: claims.ids, h: sub } : { t: claims.ids }) };
449
+ }
450
+ if (claims.es !== void 0) {
451
+ if (typeof claims.es !== "string" || claims.es === "" || claims.es.length > MAX_EDGE_SESSION_LENGTH) throw new Error("edge session token is empty or too long");
452
+ if (typeof claims.ese !== "number" || !Number.isFinite(claims.ese)) throw new Error("edge session expiry is required");
453
+ wire = { ...wire, es: await sealEdgeSession(secret, claims.v, claims.es) };
454
+ }
455
+ if (purpose) wire = { ...wire, p: purpose };
456
+ const body = b64url(enc.encode(JSON.stringify(wire)));
288
457
  const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body)));
289
458
  return `${body}.${b64url(sig)}`;
290
459
  }
291
- async function verifyVisitorToken(secret, token) {
460
+ async function verifyClaims(secret, token, purpose) {
461
+ if (typeof token !== "string") return null;
292
462
  const [body, sig] = token.split(".");
293
463
  if (!body || !sig) return null;
294
464
  try {
295
465
  const ok = await crypto.subtle.verify("HMAC", await hmacKey(secret), fromB64url(sig), enc.encode(body));
296
466
  if (!ok) return null;
297
467
  const claims = JSON.parse(new TextDecoder().decode(fromB64url(body)));
468
+ if (claims.p !== purpose) return null;
298
469
  if (typeof claims.v !== "string" || !/^[A-Za-z0-9_-]{8,64}$/.test(claims.v)) return null;
299
470
  if (typeof claims.exp !== "number" || claims.exp < Date.now()) return null;
300
- return claims;
471
+ let edge = {};
472
+ if (claims.es !== void 0 || claims.ese !== void 0) {
473
+ if (typeof claims.es !== "string" || claims.es === "" || claims.es.length > MAX_SEALED_EDGE_LENGTH) return null;
474
+ if (typeof claims.ese !== "number" || !Number.isFinite(claims.ese)) return null;
475
+ const es = await openEdgeSession(secret, claims.v, claims.es);
476
+ if (!es) return null;
477
+ edge = { es, ese: claims.ese };
478
+ }
479
+ if (claims.ids !== void 0 || claims.ida !== void 0) {
480
+ if (typeof claims.ids !== "string" || claims.ids === "" || claims.ids.length > MAX_SEALED_LENGTH) return null;
481
+ if (typeof claims.ida !== "string" || !AGENT_CLAIM_RE.test(claims.ida)) return null;
482
+ if (claims.sub !== void 0) return null;
483
+ const opened = await openIdentitySession(secret, claims.v, claims.ida, claims.ids);
484
+ if (!opened) return null;
485
+ return opened.h ? { v: claims.v, exp: claims.exp, ids: opened.t, ida: claims.ida, sub: opened.h, ...edge } : { v: claims.v, exp: claims.exp, ids: opened.t, ida: claims.ida, ...edge };
486
+ }
487
+ if (purpose !== void 0) return { v: claims.v, exp: claims.exp, ...edge };
488
+ return claims.es !== void 0 ? { v: claims.v, exp: claims.exp, ...edge } : claims;
301
489
  } catch {
302
490
  return null;
303
491
  }
304
492
  }
493
+ async function deriveVisitorId(secret, label) {
494
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(`dewee-webchat:visitor:${label}`)));
495
+ return b64url(mac.slice(0, 16));
496
+ }
305
497
  function randomId(bytes = 16) {
306
498
  return b64url(crypto.getRandomValues(new Uint8Array(bytes)));
307
499
  }
500
+ var DEFAULT_PREAUTH_LIMIT = [120, 6e4];
308
501
  function memoryRateLimiter(limits) {
309
502
  const windows = /* @__PURE__ */ new Map();
310
503
  return (key, bucket) => {
311
- const [max, windowMs] = limits[bucket];
504
+ const [max, windowMs] = limits[bucket] ?? (bucket === "preauth" ? DEFAULT_PREAUTH_LIMIT : limits.session);
312
505
  const id = `${bucket}:${key}`;
313
506
  const now = Date.now();
314
507
  const w = windows.get(id);
@@ -333,9 +526,676 @@ async function verifyTurnstile(secret, response, ip) {
333
526
  return data.success === true;
334
527
  }
335
528
 
529
+ // src/server/identity-errors.ts
530
+ var IDENTITY_ERRORS = {
531
+ IDENTITY_REQUIRED: { status: 401, message: "Sign in to continue.", action: "reauthenticate" },
532
+ LINK_EXPIRED: { status: 401, message: "Your session expired.", action: "reauthenticate" },
533
+ LINK_REVOKED: { status: 401, message: "Your access was revoked.", action: "reauthenticate" },
534
+ IDENTITY_LINK_REQUIRED: { status: 403, message: "Link your account to continue.", action: "send_connect_link" },
535
+ PERMISSION_DENIED: { status: 403, message: "You are not allowed to do this.", action: "contact_admin" },
536
+ IDENTITY_CONFLICT: { status: 409, message: "This account is linked to another user.", action: "contact_admin" },
537
+ SOURCE_UNAVAILABLE: { status: 503, message: "Your sign-in could not be checked. Try again later.", action: "retry_later" }
538
+ };
539
+ var DEAD_SESSION_CODES = /* @__PURE__ */ new Set(["IDENTITY_REQUIRED", "LINK_EXPIRED", "LINK_REVOKED"]);
540
+
541
+ // src/server/ccp-client.ts
542
+ var INTEGRATION_ID_RE = /^wci_[a-z2-7]{26}$/;
543
+ var SECRET_RE = /^dwc_[A-Za-z0-9_-]{16,256}$/;
544
+ var INGRESS_PATH_RE = /\/api\/public\/webchat\/v1\/(wci_[a-z2-7]{26})$/;
545
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
546
+ var CODE_RE = /^[A-Za-z0-9_.-]{1,64}$/;
547
+ var CCP_JSON_TIMEOUT_MS = 15e3;
548
+ var REMEDIATION_ACTIONS = /* @__PURE__ */ new Set(["send_connect_link", "ask_user_to_dm", "retry_later", "contact_admin", "reauthenticate"]);
549
+ var REMEDIATION_AUDIENCES = /* @__PURE__ */ new Set(["end_user", "operator", "integrator"]);
550
+ function knownRemediation(raw) {
551
+ if (!raw || typeof raw !== "object") return void 0;
552
+ const { action, audience } = raw;
553
+ return typeof action === "string" && typeof audience === "string" && REMEDIATION_ACTIONS.has(action) && REMEDIATION_AUDIENCES.has(audience) ? { action, audience } : void 0;
554
+ }
555
+ function withDeadline(signal, ms) {
556
+ const ctl = new AbortController();
557
+ let fired = false;
558
+ const timer = setTimeout(() => {
559
+ fired = true;
560
+ ctl.abort(new DOMException("CCP request timed out", "TimeoutError"));
561
+ }, ms);
562
+ const onAbort = () => ctl.abort(signal?.reason);
563
+ if (signal?.aborted) ctl.abort(signal.reason);
564
+ else signal?.addEventListener("abort", onAbort, { once: true });
565
+ return {
566
+ signal: ctl.signal,
567
+ timedOut: () => fired && !signal?.aborted,
568
+ dispose() {
569
+ clearTimeout(timer);
570
+ signal?.removeEventListener("abort", onAbort);
571
+ }
572
+ };
573
+ }
574
+ function parseCcpOptions(raw) {
575
+ const refuse = (why) => {
576
+ throw new Error(`[dewee-webchat] ccp: ${why}`);
577
+ };
578
+ if (!raw || typeof raw !== "object") return refuse("options are required (chatUrl, integrationId, integrationSecret)");
579
+ const { chatUrl, integrationId, integrationSecret } = raw;
580
+ if (typeof chatUrl !== "string" || chatUrl.trim() === "") refuse("chatUrl (DEWEE_CCP_CHAT_URL) is required");
581
+ if (typeof integrationId !== "string" || integrationId === "") refuse("integrationId (DEWEE_WEBCHAT_INTEGRATION_ID) is required");
582
+ if (typeof integrationSecret !== "string" || integrationSecret === "") refuse("integrationSecret (DEWEE_WEBCHAT_INTEGRATION_SECRET) is required");
583
+ let u;
584
+ try {
585
+ u = new URL(chatUrl.trim());
586
+ } catch {
587
+ return refuse("chatUrl is not a valid URL");
588
+ }
589
+ if (u.username || u.password) refuse("chatUrl must not contain credentials");
590
+ if (u.search || u.hash) refuse("chatUrl must not contain a query or fragment");
591
+ const loopback = LOOPBACK_HOSTS.has(u.hostname.toLowerCase());
592
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) refuse("chatUrl must use https (plain http is allowed for localhost only)");
593
+ const path = u.pathname.replace(/\/+$/, "");
594
+ const m = INGRESS_PATH_RE.exec(path);
595
+ if (!m) refuse("chatUrl must end with /api/public/webchat/v1/<integration id> (copy it from the CCP Website chat page)");
596
+ if (!INTEGRATION_ID_RE.test(integrationId)) refuse("integrationId must look like wci_ followed by 26 characters");
597
+ if (m[1] !== integrationId) refuse("integrationId does not match the id in chatUrl");
598
+ if (!SECRET_RE.test(integrationSecret)) refuse("integrationSecret must be the dwc_ secret the CCP Website chat page shows (not a CCP or gateway API key)");
599
+ if (raw.fetch !== void 0 && typeof raw.fetch !== "function") refuse("fetch must be a function");
600
+ return { chatUrl: `${u.origin}${path}`, integrationId, integrationSecret, fetch: raw.fetch, origin: u.origin };
601
+ }
602
+ var CcpError = class extends Error {
603
+ constructor(status, code, message, retryAfterSeconds, remediation) {
604
+ super(message);
605
+ this.status = status;
606
+ this.code = code;
607
+ this.retryAfterSeconds = retryAfterSeconds;
608
+ this.remediation = remediation;
609
+ this.name = "CcpError";
610
+ }
611
+ status;
612
+ code;
613
+ retryAfterSeconds;
614
+ remediation;
615
+ };
616
+ var VISITOR_IP_RE = /^[0-9A-Fa-f:.]{2,64}$/;
617
+ var CcpClient = class {
618
+ constructor(cfg) {
619
+ this.cfg = cfg;
620
+ this.fetchImpl = cfg.fetch ?? ((input, init) => fetch(input, init));
621
+ }
622
+ cfg;
623
+ fetchImpl;
624
+ get integrationId() {
625
+ return this.cfg.integrationId;
626
+ }
627
+ /** Sends one request and returns the response when it is a 2xx; throws {@link CcpError} otherwise. */
628
+ async send(route, req = {}) {
629
+ const url = `${this.cfg.chatUrl}/${route}`;
630
+ const headers = {
631
+ authorization: `Bearer ${this.cfg.integrationSecret}`,
632
+ accept: req.accept ?? "application/json"
633
+ };
634
+ if (req.body !== void 0) headers["content-type"] = "application/json";
635
+ if (req.session) headers["x-dewee-webchat-session"] = req.session;
636
+ if (req.visitorOrigin) headers["x-dewee-visitor-origin"] = req.visitorOrigin;
637
+ if (req.visitorIp && VISITOR_IP_RE.test(req.visitorIp)) headers["x-dewee-visitor-ip"] = req.visitorIp;
638
+ if (req.requestId) headers["x-request-id"] = req.requestId;
639
+ if (req.idempotencyKey) headers["idempotency-key"] = req.idempotencyKey;
640
+ let res;
641
+ try {
642
+ res = await this.fetchImpl(url, {
643
+ method: req.method ?? (req.body !== void 0 ? "POST" : "GET"),
644
+ headers,
645
+ body: req.body !== void 0 ? JSON.stringify(req.body) : void 0,
646
+ redirect: "manual",
647
+ signal: req.signal
648
+ });
649
+ } catch (e) {
650
+ if (req.signal?.aborted) throw e;
651
+ throw new CcpError(0, "ccp_unreachable", `CCP request failed: ${e?.name ?? "error"}`);
652
+ }
653
+ if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
654
+ await res.body?.cancel().catch(() => {
655
+ });
656
+ throw new CcpError(res.status, "ccp_redirect", `CCP answered ${res.status} with a redirect; redirects are never followed`);
657
+ }
658
+ if (res.ok) return res;
659
+ throw await errorFrom(res);
660
+ }
661
+ /**
662
+ * {@link send} and parse a JSON object body, within {@link CCP_JSON_TIMEOUT_MS} (headers and body)
663
+ * on top of the caller's own signal. A deadline that passes is `ccp_timeout`; a caller that aborts
664
+ * gets its own abort error.
665
+ */
666
+ async json(route, req = {}) {
667
+ const deadline = withDeadline(req.signal, CCP_JSON_TIMEOUT_MS);
668
+ const timeout = () => new CcpError(0, "ccp_timeout", `CCP ${route} did not answer within ${CCP_JSON_TIMEOUT_MS} ms`);
669
+ try {
670
+ const res = await this.send(route, { ...req, signal: deadline.signal });
671
+ const data = await res.json().catch(() => null);
672
+ if (deadline.timedOut()) throw timeout();
673
+ if (data === null || typeof data !== "object" || Array.isArray(data)) throw new CcpError(res.status, "ccp_bad_response", `CCP ${route} returned a body that is not a JSON object`);
674
+ return data;
675
+ } catch (e) {
676
+ if (deadline.timedOut() && !(e instanceof CcpError && e.code === "ccp_timeout")) throw timeout();
677
+ throw e;
678
+ } finally {
679
+ deadline.dispose();
680
+ }
681
+ }
682
+ };
683
+ async function errorFrom(res) {
684
+ const data = await res.json().catch(() => null);
685
+ const e = data?.error;
686
+ const code = typeof e?.code === "string" && CODE_RE.test(e.code) ? e.code : `http_${res.status}`;
687
+ const message = typeof e?.message === "string" ? e.message.slice(0, 500) : `HTTP ${res.status}`;
688
+ const header = Number(res.headers.get("retry-after"));
689
+ const retry = Number.isFinite(e?.retry_after_seconds) && e.retry_after_seconds > 0 ? Number(e.retry_after_seconds) : header > 0 ? header : void 0;
690
+ return new CcpError(res.status, code, message, retry, knownRemediation(e?.remediation));
691
+ }
692
+ async function* readSseJson(body, maxFrameBytes = 1048576) {
693
+ const reader = body.getReader();
694
+ const dec = new TextDecoder();
695
+ let buf = "";
696
+ let heldCr = false;
697
+ const frameData = (frame) => frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).replace(/^ /, "")).join("\n");
698
+ try {
699
+ while (true) {
700
+ const { done, value } = await reader.read();
701
+ let text = done ? dec.decode() : dec.decode(value, { stream: true });
702
+ if (heldCr) text = `\r${text}`;
703
+ heldCr = !done && text.endsWith("\r");
704
+ if (heldCr) text = text.slice(0, -1);
705
+ buf += text.replace(/\r\n?/g, "\n");
706
+ let idx;
707
+ while ((idx = buf.indexOf("\n\n")) >= 0) {
708
+ const data2 = frameData(buf.slice(0, idx));
709
+ buf = buf.slice(idx + 2);
710
+ if (!data2) continue;
711
+ try {
712
+ yield JSON.parse(data2);
713
+ } catch {
714
+ }
715
+ }
716
+ if (buf.length > maxFrameBytes) throw new CcpError(502, "ccp_bad_response", "CCP stream frame too large");
717
+ if (done) break;
718
+ }
719
+ const data = frameData(buf);
720
+ if (data) {
721
+ try {
722
+ yield JSON.parse(data);
723
+ } catch {
724
+ }
725
+ }
726
+ } finally {
727
+ reader.cancel().catch(() => {
728
+ });
729
+ }
730
+ }
731
+
732
+ // src/server/transport-selection.ts
733
+ var legacyWarned = false;
734
+ function resolveTransport(o) {
735
+ const refuse = (why) => {
736
+ throw new Error(`[dewee-webchat] ${why}`);
737
+ };
738
+ if (o.transport !== void 0 && o.transport !== "ccp" && o.transport !== "legacy-gateway") refuse(`transport must be "ccp" or "legacy-gateway"`);
739
+ const hasCcp = o.ccp !== void 0 && o.ccp !== null && Object.values(o.ccp).some((v) => v !== void 0 && v !== "");
740
+ const gatewayOptions = ["gatewayUrl", "token", "tenantId"].filter((k) => o[k] !== void 0 && o[k] !== "");
741
+ const transport = o.transport ?? (hasCcp ? "ccp" : "legacy-gateway");
742
+ if (transport === "ccp") {
743
+ if (!hasCcp) refuse('transport "ccp" needs ccp options (DEWEE_CCP_CHAT_URL, DEWEE_WEBCHAT_INTEGRATION_ID, DEWEE_WEBCHAT_INTEGRATION_SECRET)');
744
+ if (gatewayOptions.length) {
745
+ refuse(`both CCP and gateway options are set (${gatewayOptions.join(", ")}); remove the gateway ones (DEWEE_GATEWAY_URL, DEWEE_GATEWAY_TOKEN, DEWEE_TENANT_ID), or set transport "legacy-gateway" and remove the CCP ones`);
746
+ }
747
+ if (o.allowClientCredentials) refuse("allowClientCredentials does not apply to the ccp transport");
748
+ if (o.agents !== void 0 && !(Array.isArray(o.agents) && o.agents.length === 0)) refuse("agents does not apply to the ccp transport: choose the agents on the CCP Website chat page");
749
+ if (o.defaultAgent) refuse("defaultAgent does not apply to the ccp transport: choose the default agent on the CCP Website chat page");
750
+ if (!o.secret || new TextEncoder().encode(o.secret).length < 32) refuse("the ccp transport needs secret (DEWEE_WEBCHAT_SECRET), at least 32 bytes");
751
+ return transport;
752
+ }
753
+ if (hasCcp) refuse('transport "legacy-gateway" is set together with ccp options; remove one of them');
754
+ if (!legacyWarned) {
755
+ legacyWarned = true;
756
+ console.warn(
757
+ '[dewee-webchat] transport "legacy-gateway" (a direct Gateway URL and token) is deprecated. Create a Website chat integration in CCP and switch to transport "ccp": https://github.com/nextlevelbuilder/dewee-webchat/blob/main/docs/migrating-to-ccp-transport.md'
758
+ );
759
+ }
760
+ return transport;
761
+ }
762
+
763
+ // src/server/job-access.ts
764
+ var JobAccessRefusal = class extends Error {
765
+ constructor(code) {
766
+ super("Job access was refused");
767
+ this.code = code;
768
+ }
769
+ code;
770
+ };
771
+ async function mintJobAccess(gatewayUrl, identitySession, launchOrigin) {
772
+ const gateway = new URL(gatewayUrl);
773
+ if (gateway.protocol !== "https:" || gateway.username || gateway.password || gateway.search || gateway.hash || gateway.pathname !== "/") {
774
+ throw new Error("Job access is unavailable");
775
+ }
776
+ const controller = new AbortController();
777
+ const timeout = setTimeout(() => controller.abort(), 1e4);
778
+ try {
779
+ const response = await fetch(new URL("/v1/identity/job-access/launch", gateway), {
780
+ method: "POST",
781
+ redirect: "error",
782
+ signal: controller.signal,
783
+ headers: { "Content-Type": "application/json", "X-Dewee-Identity-Session": identitySession },
784
+ body: JSON.stringify({ launch_origin: launchOrigin })
785
+ });
786
+ const data = await response.json();
787
+ if (!response.ok) {
788
+ const code = data?.error?.code;
789
+ if (isIdentityErrorCode(code)) throw new JobAccessRefusal(code);
790
+ throw new Error("Job access is unavailable");
791
+ }
792
+ const launch = parseJobAccessLaunch(data);
793
+ if (new URL(launch.bootstrap_url).origin !== gateway.origin || launch.launch_token === identitySession) {
794
+ throw new Error("Job access is unavailable");
795
+ }
796
+ return launch;
797
+ } finally {
798
+ clearTimeout(timeout);
799
+ }
800
+ }
801
+
802
+ // src/server/ccp-transport.ts
803
+ var CCP_AGENT_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
804
+ async function ccpIdempotencyKey(key) {
805
+ return sha256B64url(key);
806
+ }
807
+ var CCP_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
808
+ var EDGE_TOKEN_RE = /^dws1\.[A-Za-z0-9_.-]{1,1019}$/;
809
+ var MAX_IDENTITY_SESSION_LENGTH2 = 512;
810
+ var CONFIG_TTL_MS = 6e4;
811
+ var EDGE_MARGIN_MS = 3e4;
812
+ var EDGE_RESEAL_MS = 15 * 6e4;
813
+ var CHANNEL_RE = /^[A-Za-z0-9_.-]{1,64}$/;
814
+ var EDGE_REFRESH_WINDOW_MS = 24 * 36e5;
815
+ var MAX_CACHED_EDGES = 5e3;
816
+ var GENERIC_UNAVAILABLE = { status: 502, code: "gateway_error", message: "Dewee is unavailable, try again later" };
817
+ function browserErrorFor(e, route) {
818
+ if (!(e instanceof CcpError)) {
819
+ console.error(`[dewee-webchat] ccp ${route} failed:`, e?.name ?? "error");
820
+ return GENERIC_UNAVAILABLE;
821
+ }
822
+ const log = (level, hint) => console[level](`[dewee-webchat] ccp ${route}: ${e.code} (HTTP ${e.status}) ${hint}`);
823
+ if (isIdentityErrorCode(e.code)) return { status: IDENTITY_ERRORS[e.code].status, code: e.code, message: IDENTITY_ERRORS[e.code].message, identity: e.code, remediation: e.remediation };
824
+ const retryAfterMs = e.retryAfterSeconds ? e.retryAfterSeconds * 1e3 : void 0;
825
+ if (route === "chat") {
826
+ if (isAdmissionErrorCode(e.code)) return { status: e.code === "RATE_LIMITED" ? 429 : 403, code: e.code, message: "", admission: e.code, retryAfterMs };
827
+ if (e.code === "rate_limited" || e.code === "concurrency_limited" || e.status === 429) return { status: 429, code: "RATE_LIMITED", message: "", admission: "RATE_LIMITED", retryAfterMs };
828
+ }
829
+ switch (e.code) {
830
+ case "invalid_request":
831
+ log("error", "- the ingress refused the request shape; check the dewee-webchat version");
832
+ return { status: 400, code: "bad_request", message: "Invalid request" };
833
+ case "integration_unauthorized":
834
+ log("error", "- check DEWEE_WEBCHAT_INTEGRATION_SECRET (wrong, rotated past its overlap, or revoked) and DEWEE_CCP_CHAT_URL");
835
+ return { status: 500, code: "not_configured", message: "Chat is not configured on this server" };
836
+ case "integration_disabled":
837
+ case "workspace_unavailable":
838
+ case "agent_unavailable":
839
+ log("error", "- the integration, its workspace or its agent is not active (see the CCP Website chat page)");
840
+ return { status: 503, code: "unavailable", message: "Chat is unavailable right now, try again later" };
841
+ case "agent_not_allowed":
842
+ return { status: 403, code: "agent_not_allowed", message: "Agent not allowed" };
843
+ case "origin_not_allowed":
844
+ log("warn", "- the visitor origin is not in the integration's allowed sites");
845
+ return { status: 403, code: "forbidden_origin", message: "Origin not allowed" };
846
+ case "not_found":
847
+ return { status: 404, code: "not_found", message: "Not found" };
848
+ case "run_in_progress":
849
+ return { status: 409, code: "run_in_progress", message: "Still answering the previous message" };
850
+ case "message_too_large":
851
+ return { status: 413, code: "too_long", message: "Message is too long" };
852
+ case "rate_limited":
853
+ case "concurrency_limited":
854
+ return { status: 429, code: "rate_limited", message: "Too many messages, please wait a moment", retryAfterSeconds: e.retryAfterSeconds };
855
+ case "idempotency_key_reused":
856
+ return { status: 409, code: "idempotency_key_reused", message: "This idempotency key was already used for a different link request" };
857
+ case "link_in_progress":
858
+ return { status: 409, code: "link_in_progress", message: "A link is already in progress. Use its code or wait until it expires." };
859
+ case "linking_unavailable":
860
+ return { status: 503, code: "linking_unavailable", message: "Linking is not available right now" };
861
+ case "ccp_redirect":
862
+ log("error", "- refused to follow a redirect; DEWEE_CCP_CHAT_URL must be the final ingress URL");
863
+ return GENERIC_UNAVAILABLE;
864
+ case "ccp_timeout":
865
+ log("error", "- CCP did not answer in time");
866
+ return GENERIC_UNAVAILABLE;
867
+ default:
868
+ if (e.status === 429) return { status: 429, code: "rate_limited", message: "Too many messages, please wait a moment", retryAfterSeconds: e.retryAfterSeconds };
869
+ log("error", "");
870
+ return GENERIC_UNAVAILABLE;
871
+ }
872
+ }
873
+ function streamError(code, remediation, retryAfterMs) {
874
+ if (isIdentityErrorCode(code)) {
875
+ const valid = knownRemediation(remediation);
876
+ return { type: "error", code, message: IDENTITY_ERRORS[code].message, ...valid ? { remediation: valid } : {} };
877
+ }
878
+ if (code === "cancelled") return { type: "error", code: "cancelled", message: "Cancelled" };
879
+ if (code === "rate_limited" || code === "concurrency_limited") return admissionErrorEvent("RATE_LIMITED", retryAfterMs);
880
+ if (isAdmissionErrorCode(code)) return admissionErrorEvent(code, retryAfterMs);
881
+ if (code === "run_failed") return { type: "error", code: "run_failed", message: "The agent could not answer, please try again" };
882
+ return { type: "error", code: "gateway_error", message: "Dewee is unavailable, try again later" };
883
+ }
884
+ var str = (v, max) => typeof v === "string" ? v.slice(0, max) : void 0;
885
+ function parseConfig(data) {
886
+ const bad = (why) => {
887
+ throw new CcpError(200, "ccp_bad_response", `CCP config: ${why}`);
888
+ };
889
+ const rawAgents = Array.isArray(data.agents) ? data.agents : [];
890
+ const agents = [];
891
+ for (const a of rawAgents.slice(0, 50)) {
892
+ const key = a?.key;
893
+ if (typeof key !== "string" || !CCP_AGENT_RE.test(key) || agents.some((x) => x.id === key)) continue;
894
+ const name = str(a.name, 200);
895
+ const description = str(a.description, 1e3);
896
+ agents.push({ id: key, ...name ? { name } : {}, ...description ? { description } : {} });
897
+ }
898
+ if (!agents.length) bad("no agent");
899
+ const def = typeof data.default_agent_key === "string" && agents.some((a) => a.id === data.default_agent_key) ? data.default_agent_key : agents[0].id;
900
+ const mode = data.mode === "signed_in_optional" || data.mode === "signed_in_required" ? data.mode : "anonymous";
901
+ const max = Number(data.max_message_chars);
902
+ return {
903
+ agents,
904
+ defaultAgent: def,
905
+ // Without the flag, switching follows the number of agents.
906
+ allowAgentSwitch: typeof data.allow_agent_switch === "boolean" ? data.allow_agent_switch && agents.length > 1 : agents.length > 1,
907
+ mode,
908
+ identityEnabled: data.identity_enabled === true,
909
+ // Reasoning and tool activity stay hidden unless the integration turns them on explicitly.
910
+ showThinking: data.show_thinking === true,
911
+ showToolActivity: data.show_tool_activity === true,
912
+ maxMessageChars: Number.isInteger(max) && max > 0 ? max : Number.POSITIVE_INFINITY
913
+ };
914
+ }
915
+ function numericUsage(raw) {
916
+ if (!raw || typeof raw !== "object") return void 0;
917
+ const out = {};
918
+ for (const [k, v] of Object.entries(raw).slice(0, 32)) if (typeof v === "number" && Number.isFinite(v) && /^[a-z0-9_]{1,64}$/i.test(k)) out[k] = v;
919
+ return Object.keys(out).length ? out : void 0;
920
+ }
921
+ function createCcpTransport(client, identity) {
922
+ let config;
923
+ let pendingConfig;
924
+ const edges = /* @__PURE__ */ new Map();
925
+ async function getConfig() {
926
+ if (config && Date.now() - config.at < CONFIG_TTL_MS) return config.value;
927
+ pendingConfig ??= client.json("config").then((data) => {
928
+ const value = parseConfig(data);
929
+ config = { value, at: Date.now() };
930
+ return value;
931
+ }).finally(() => {
932
+ pendingConfig = void 0;
933
+ });
934
+ return pendingConfig;
935
+ }
936
+ function remember(visitorId, edge) {
937
+ edges.delete(visitorId);
938
+ edges.set(visitorId, edge);
939
+ if (edges.size > MAX_CACHED_EDGES) {
940
+ const now = Date.now();
941
+ for (const [k, v] of edges) if (now - v.expiresAt > EDGE_REFRESH_WINDOW_MS) edges.delete(k);
942
+ for (const k of edges.keys()) {
943
+ if (edges.size <= MAX_CACHED_EDGES) break;
944
+ edges.delete(k);
945
+ }
946
+ }
947
+ return edge;
948
+ }
949
+ const base = (ctx) => ({ visitorOrigin: ctx.visitorOrigin, visitorIp: ctx.visitorIp, requestId: ctx.requestId });
950
+ async function openEdge(ctx, visitorId, previous) {
951
+ const data = await client.json("session", { ...base(ctx), body: previous ? {} : { visitor_id: visitorId }, session: previous });
952
+ const token = data.session;
953
+ if (typeof token !== "string" || !EDGE_TOKEN_RE.test(token)) throw new CcpError(200, "ccp_bad_response", "CCP session: no edge session token");
954
+ if (data.visitor_id !== void 0 && data.visitor_id !== visitorId) throw new CcpError(200, "ccp_bad_response", "CCP session: visitor mismatch");
955
+ const seconds = Number(data.expires_in);
956
+ const lifetime = Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1e3, EDGE_REFRESH_WINDOW_MS) : 36e5;
957
+ return remember(visitorId, { token, expiresAt: Date.now() + lifetime });
958
+ }
959
+ async function edgeFor(ctx, visitorId, sealed, force, minRemainingMs = EDGE_MARGIN_MS) {
960
+ const now = Date.now();
961
+ const cached = edges.get(visitorId);
962
+ const known = [cached, sealed].filter((e) => !!e).sort((a, b) => b.expiresAt - a.expiresAt)[0];
963
+ if (!force && known && known.expiresAt - now > minRemainingMs) return known;
964
+ if (force !== "create" && known && now - known.expiresAt < EDGE_REFRESH_WINDOW_MS - EDGE_MARGIN_MS) {
965
+ try {
966
+ return await openEdge(ctx, visitorId, known.token);
967
+ } catch (e) {
968
+ if (!(e instanceof CcpError && (e.code === "ingress_session_invalid" || e.code === "ingress_session_expired"))) throw e;
969
+ }
970
+ }
971
+ return openEdge(ctx, visitorId);
972
+ }
973
+ async function withEdge(ctx, visitorId, sealed, call) {
974
+ const edge = await edgeFor(ctx, visitorId, sealed);
975
+ try {
976
+ return await call(edge);
977
+ } catch (e) {
978
+ if (!(e instanceof CcpError)) throw e;
979
+ if (e.code === "ingress_session_expired") return call(await edgeFor(ctx, visitorId, edge, "refresh"));
980
+ if (e.code === "ingress_session_invalid") {
981
+ edges.delete(visitorId);
982
+ return call(await edgeFor(ctx, visitorId, void 0, "create"));
983
+ }
984
+ throw e;
985
+ }
986
+ }
987
+ const withAgent = (agent) => agent ? { agent } : {};
988
+ return {
989
+ config: getConfig,
990
+ edgeFor,
991
+ /** Starts a turn. Resolves once CCP accepted it (2xx, SSE); refusals reject with {@link CcpError}. */
992
+ async startChat(ctx, visitorId, sealed, input) {
993
+ const body = {
994
+ message: input.message,
995
+ conversation_id: input.conversationId,
996
+ ...withAgent(input.agent),
997
+ ...input.identitySession ? { identity_session: input.identitySession } : {}
998
+ };
999
+ return withEdge(ctx, visitorId, sealed, (edge) => client.send("chat", { ...base(ctx), body, session: edge.token, accept: "text/event-stream", signal: input.signal }));
1000
+ },
1001
+ /**
1002
+ * Browser events of an accepted turn: exactly one `start` first and exactly one terminal event
1003
+ * (`done` or `error`) last. Reasoning and tool frames pass only when the integration shows them,
1004
+ * tool arguments and results never; unknown frames are dropped.
1005
+ */
1006
+ async *chatEvents(res, cfg, conversationId, requestedAgent) {
1007
+ let started = false;
1008
+ const start = (f) => {
1009
+ started = true;
1010
+ const agent = typeof f?.agent === "string" && CCP_AGENT_RE.test(f.agent) ? f.agent : requestedAgent;
1011
+ const runId = typeof f?.runId === "string" && f.runId.length <= 128 ? f.runId : void 0;
1012
+ return { type: "start", conversationId, agent, ...runId ? { runId } : {} };
1013
+ };
1014
+ if (!res.body) {
1015
+ yield start();
1016
+ yield streamError("gateway_error", void 0);
1017
+ return;
1018
+ }
1019
+ for await (const f of readSseJson(res.body)) {
1020
+ if (!f || typeof f !== "object") continue;
1021
+ if (f.type === "start") {
1022
+ if (!started) yield start(f);
1023
+ continue;
1024
+ }
1025
+ if (!started) yield start();
1026
+ switch (f.type) {
1027
+ case "delta":
1028
+ if (typeof f.text === "string") yield { type: "delta", text: f.text };
1029
+ break;
1030
+ case "thinking":
1031
+ if (cfg.showThinking && typeof f.text === "string") yield { type: "thinking", text: f.text };
1032
+ break;
1033
+ case "tool_call":
1034
+ if (cfg.showToolActivity && typeof f.id === "string" && typeof f.name === "string") yield { type: "tool_call", id: f.id.slice(0, 128), name: f.name.slice(0, 128) };
1035
+ break;
1036
+ case "tool_result":
1037
+ if (cfg.showToolActivity && typeof f.id === "string" && typeof f.name === "string") yield { type: "tool_result", id: f.id.slice(0, 128), name: f.name.slice(0, 128), isError: f.isError === true };
1038
+ break;
1039
+ case "done": {
1040
+ const usage = numericUsage(f.usage);
1041
+ yield { type: "done", content: typeof f.content === "string" ? f.content : "", ...usage ? { usage } : {} };
1042
+ return;
1043
+ }
1044
+ case "error":
1045
+ yield streamError(f.code, f.remediation, f.retryAfterMs ?? (typeof f.retry_after_seconds === "number" ? f.retry_after_seconds * 1e3 : void 0));
1046
+ return;
1047
+ default:
1048
+ break;
1049
+ }
1050
+ }
1051
+ if (!started) yield start();
1052
+ yield streamError("gateway_error", void 0);
1053
+ },
1054
+ /** Best-effort `POST abort` after the browser went away mid-turn. Never throws. */
1055
+ async abort(ctx, visitorId, sealed, input) {
1056
+ const body = {
1057
+ conversation_id: input.conversationId,
1058
+ ...input.runId ? { run_id: input.runId } : {},
1059
+ ...withAgent(input.agent),
1060
+ ...input.identitySession ? { identity_session: input.identitySession } : {}
1061
+ };
1062
+ await withEdge(ctx, visitorId, sealed, (edge) => client.json("abort", { ...base(ctx), body, session: edge.token, signal: AbortSignal.timeout(5e3) })).catch(() => {
1063
+ });
1064
+ },
1065
+ /** The visitor's own conversation, folded for the widget. An unknown or foreign conversation is empty. */
1066
+ async history(ctx, visitorId, sealed, input) {
1067
+ const cfg = await getConfig();
1068
+ const body = {
1069
+ conversation_id: input.conversationId,
1070
+ limit: 50,
1071
+ ...withAgent(input.agent),
1072
+ ...input.identitySession ? { identity_session: input.identitySession } : {}
1073
+ };
1074
+ let data;
1075
+ try {
1076
+ data = await withEdge(ctx, visitorId, sealed, (edge) => client.json("history", { ...base(ctx), body, session: edge.token }));
1077
+ } catch (e) {
1078
+ if (e instanceof CcpError && e.code === "not_found") return [];
1079
+ throw e;
1080
+ }
1081
+ const out = [];
1082
+ for (const m of Array.isArray(data.messages) ? data.messages.slice(0, 200) : []) {
1083
+ if (m?.role !== "user" && m?.role !== "assistant") continue;
1084
+ const content = typeof m.content === "string" ? m.role === "user" ? stripPageBlock(m.content) : m.content : "";
1085
+ const msg = { role: m.role, content };
1086
+ if (cfg.showThinking && typeof m.thinking === "string" && m.thinking) msg.thinking = m.thinking;
1087
+ if (cfg.showToolActivity && Array.isArray(m.tool_calls)) {
1088
+ const calls = [];
1089
+ for (const t of m.tool_calls.slice(0, 64)) {
1090
+ if (typeof t?.id !== "string" || typeof t?.name !== "string") continue;
1091
+ const status = t.status === "running" || t.status === "error" ? t.status : "done";
1092
+ calls.push({ id: t.id.slice(0, 128), name: t.name.slice(0, 128), status, isError: t.is_error === true });
1093
+ }
1094
+ if (calls.length) msg.toolCalls = calls;
1095
+ }
1096
+ if (typeof m.created_at === "string") msg.createdAt = m.created_at.slice(0, 64);
1097
+ if (!msg.content && !msg.thinking && !msg.toolCalls) continue;
1098
+ out.push(msg);
1099
+ }
1100
+ return out;
1101
+ },
1102
+ /**
1103
+ * Signs a partner assertion for `subject` and exchanges it at CCP for an identity session. `sealed`
1104
+ * is the user's edge session from an earlier token, refreshed rather than replaced when stale.
1105
+ */
1106
+ async exchange(ctx, visitorId, subject, agent, sealed) {
1107
+ if (!identity) throw new Error("identity is not configured");
1108
+ const assertion = await identity.createAssertion({ ...identity.signer(), purpose: "session", subject });
1109
+ const body = { assertion, ...identity.ttlSeconds ? { ttl_seconds: identity.ttlSeconds } : {}, ...withAgent(agent) };
1110
+ const data = await withEdge(ctx, visitorId, sealed, (edge) => client.json("identity/exchange", { ...base(ctx), body, session: edge.token }));
1111
+ const token = data.identity_session;
1112
+ const principal = data.principal_ref;
1113
+ const expiresAt = new Date(String(data.expires_at));
1114
+ if (typeof token !== "string" || token === "" || token.length > MAX_IDENTITY_SESSION_LENGTH2) throw new CcpError(201, "ccp_bad_response", "CCP identity exchange: no usable identity session");
1115
+ if (typeof principal !== "string" || principal === "") throw new CcpError(201, "ccp_bad_response", "CCP identity exchange: no principal");
1116
+ return { sessionToken: token, principalRef: principal, expiresAt };
1117
+ },
1118
+ /** Best-effort `POST identity/revoke` (the user signed out or switched). Never throws. */
1119
+ async revoke(ctx, visitorId, sealed, identitySession, agent) {
1120
+ await withEdge(ctx, visitorId, sealed, (edge) => client.json("identity/revoke", { ...base(ctx), body: { identity_session: identitySession, ...withAgent(agent) }, session: edge.token, signal: AbortSignal.timeout(5e3) })).catch((e) => {
1121
+ if (e instanceof CcpError && DEAD_SESSION_CODES.has(e.code)) return;
1122
+ console.warn("[dewee-webchat] ccp identity/revoke failed:", e instanceof CcpError ? e.code : e?.name);
1123
+ });
1124
+ },
1125
+ /** One-time hosted-page launch, with the original session kept server-side. */
1126
+ async jobAccess(ctx, visitorId, sealed, input) {
1127
+ const data = await withEdge(ctx, visitorId, sealed, (edge) => client.json("job-access", {
1128
+ ...base(ctx),
1129
+ session: edge.token,
1130
+ body: { identity_session: input.identitySession, agent: input.agent }
1131
+ }));
1132
+ try {
1133
+ const launch = parseJobAccessLaunch(data);
1134
+ if (launch.launch_token === input.identitySession) throw new Error("Invalid launch ticket");
1135
+ return launch;
1136
+ } catch {
1137
+ throw new CcpError(502, "ccp_bad_response", "Job access is unavailable");
1138
+ }
1139
+ },
1140
+ /** Web-first link for a signed-in visitor. */
1141
+ async link(ctx, visitorId, sealed, input) {
1142
+ const body = { identity_session: input.identitySession, ...withAgent(input.agent) };
1143
+ const idempotencyKey = input.idempotencyKey === void 0 ? void 0 : await ccpIdempotencyKey(input.idempotencyKey);
1144
+ const data = await withEdge(ctx, visitorId, sealed, (edge) => client.json("link", { ...base(ctx), body, session: edge.token, idempotencyKey }));
1145
+ if (typeof data.proof_code !== "string" || data.proof_code === "") throw new CcpError(201, "ccp_bad_response", "CCP link: no proof code");
1146
+ const expires = Date.parse(String(data.expires_at));
1147
+ const deepLinks = (Array.isArray(data.deep_links) ? data.deep_links.slice(0, 20) : []).map((t) => typeof t?.channel === "string" && CHANNEL_RE.test(t.channel) ? { channelType: t.channel, channelInstanceId: "", name: t.channel } : null).filter((t) => t !== null);
1148
+ return { proofCode: data.proof_code.slice(0, 64), expiresAt: Number.isFinite(expires) ? expires : Date.now() + 6e5, deepLinks };
1149
+ },
1150
+ /** Drops a cached identity session's dependents when CCP reports it dead. */
1151
+ isDeadSessionCode: (code) => typeof code === "string" && DEAD_SESSION_CODES.has(code)
1152
+ };
1153
+ }
1154
+
336
1155
  // src/server/handler.ts
337
1156
  var ID_RE = /^[A-Za-z0-9_-]{6,64}$/;
338
1157
  var AGENT_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
1158
+ var IDEMPOTENCY_KEY_RE = /^[\x21-\x7E]{1,255}$/;
1159
+ 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
+ var DEFAULT_IDENTITY_TTL_MS = 36e5;
1161
+ var MAX_IDENTITY_TTL_MS = 432e5;
1162
+ var MAX_CACHED_SESSIONS = 1e3;
1163
+ var ANON_REUSE_MS = 864e5;
1164
+ var VERIFIED_REUSE_MIN_MS = 12e4;
1165
+ function checkIdentityOptions(o, staticAgents, transport) {
1166
+ const idn = o.identity;
1167
+ const refuse = (why) => {
1168
+ throw new Error(`[dewee-webchat] identity: ${why}`);
1169
+ };
1170
+ if (o.allowClientCredentials) refuse("cannot be combined with allowClientCredentials (a visitor-chosen gateway must never receive a user's session)");
1171
+ const sdkHint = 'import { deweeIdentitySdk } from "dewee-webchat/server/identity" (needs the @dewee/identity package)';
1172
+ if (transport === "ccp") {
1173
+ if (!idn.sdk || typeof idn.sdk.createAssertion !== "function") refuse(`sdk with createAssertion is required: ${sdkHint}`);
1174
+ if (idn.allowInsecureHttp) refuse("allowInsecureHttp does not apply to the ccp transport");
1175
+ } else {
1176
+ if (!o.gatewayUrl || !o.token) refuse("needs the server's own gatewayUrl and token");
1177
+ if (!idn.sdk || typeof idn.sdk.exchangeSession !== "function" || typeof idn.sdk.startLink !== "function") refuse(`sdk is required: ${sdkHint}`);
1178
+ }
1179
+ if (typeof idn.authenticate !== "function") refuse("authenticate(req) is required");
1180
+ if (typeof idn.appId !== "string" || !UUID_RE.test(idn.appId)) refuse("appId must be the Dewee application id (a UUID)");
1181
+ if (typeof idn.issuer !== "string" || idn.issuer.trim() === "") refuse("issuer is required");
1182
+ if (idn.audience !== void 0 && String(idn.audience).toLowerCase() !== `dewee:${idn.appId.toLowerCase()}`) refuse("audience must be dewee:<appId>");
1183
+ const hasSecret = idn.assertionSecret !== void 0 && idn.assertionSecret !== "";
1184
+ const hasJwk = idn.privateKeyJwk !== void 0;
1185
+ if (hasSecret === hasJwk) refuse("set exactly one of assertionSecret or privateKeyJwk");
1186
+ if (hasSecret && new TextEncoder().encode(idn.assertionSecret).length < 32) refuse("assertionSecret is too short (at least 32 bytes)");
1187
+ if (hasJwk && (typeof idn.privateKeyJwk.d !== "string" || typeof idn.privateKeyJwk.kid !== "string")) refuse("privateKeyJwk must be a private JWK with a kid");
1188
+ const ttl = idn.sessionTtlSeconds;
1189
+ if (ttl !== void 0 && (!Number.isInteger(ttl) || ttl < 60 || ttl > 43200)) refuse("sessionTtlSeconds must be an integer from 60 to 43200");
1190
+ if (transport === "ccp") {
1191
+ if (idn.agent !== void 0 && (typeof idn.agent !== "string" || !CCP_AGENT_RE.test(idn.agent))) refuse("agent must be an agent key");
1192
+ return idn.agent ?? "";
1193
+ }
1194
+ const agent = idn.agent || o.defaultAgent || staticAgents[0]?.id;
1195
+ if (!agent || !AGENT_RE.test(agent)) refuse("no agent to bind sessions to: set identity.agent, defaultAgent or agents");
1196
+ if (staticAgents.length && !staticAgents.some((a) => a.id === agent)) refuse(`agent "${agent}" is not in agents`);
1197
+ return agent;
1198
+ }
339
1199
  function createDeweeHandler(options) {
340
1200
  const o = {
341
1201
  maxMessageLength: 4e3,
@@ -344,15 +1204,39 @@ function createDeweeHandler(options) {
344
1204
  timeoutMs: 18e4,
345
1205
  ...options
346
1206
  };
1207
+ const transport = resolveTransport(o);
347
1208
  let secret = o.secret;
348
1209
  if (!secret) {
349
1210
  secret = randomId(32);
350
1211
  console.warn("[dewee-webchat] No secret configured: using an ephemeral one. Visitor sessions reset on restart.");
351
1212
  }
352
- const limiter = o.rateLimit === false ? null : o.rateLimit ?? memoryRateLimiter({ chat: [20, 6e4], session: [10, 6e5] });
1213
+ const limiter = o.rateLimit === false ? null : o.rateLimit ?? memoryRateLimiter({ chat: [20, 6e4], session: [10, 6e5], preauth: [120, 6e4], verified: [30, 6e5], link: [5, 6e5] });
353
1214
  const staticAgents = o.agents === "*" ? [] : (o.agents ?? []).map((a) => typeof a === "string" ? { id: a } : a);
354
1215
  const wildcardAgents = o.agents === "*";
355
1216
  const byo = o.allowClientCredentials ? o.allowClientCredentials === true ? {} : o.allowClientCredentials : null;
1217
+ const idn = o.identity;
1218
+ const identityAgent = idn ? checkIdentityOptions(o, staticAgents, transport) : "";
1219
+ const ccp = transport === "ccp" ? createCcpTransport(
1220
+ new CcpClient(parseCcpOptions(o.ccp)),
1221
+ idn ? { createAssertion: (a) => idn.sdk.createAssertion(a), signer: () => identitySigner(), ttlSeconds: idn.sessionTtlSeconds } : void 0
1222
+ ) : null;
1223
+ function visitorContext(req) {
1224
+ const ip = clientIp(req);
1225
+ let visitorOrigin = req.headers.get("origin") || void 0;
1226
+ if (!visitorOrigin) {
1227
+ try {
1228
+ visitorOrigin = new URL(req.headers.get("referer") || req.url).origin;
1229
+ } catch {
1230
+ visitorOrigin = void 0;
1231
+ }
1232
+ }
1233
+ return { visitorOrigin: visitorOrigin === "null" ? void 0 : visitorOrigin, visitorIp: ip === "unknown" ? void 0 : ip, requestId: `dwc-${randomId(12)}` };
1234
+ }
1235
+ const sealedEdge = (claims) => claims?.es && typeof claims.ese === "number" ? { token: claims.es, expiresAt: claims.ese } : void 0;
1236
+ const ccpSessionTimes = (exp, edge, now = Date.now()) => {
1237
+ const expiresAt = Math.min(exp, edge.expiresAt);
1238
+ return { expiresAt, expiresIn: Math.max(0, expiresAt - now) };
1239
+ };
356
1240
  function serverCreds() {
357
1241
  return o.gatewayUrl && o.token ? { url: o.gatewayUrl, token: o.token, tenantId: o.tenantId } : null;
358
1242
  }
@@ -406,17 +1290,233 @@ function createDeweeHandler(options) {
406
1290
  if (!origin || origin === new URL(req.url).origin) return true;
407
1291
  return o.allowedOrigins === "*" || (o.allowedOrigins ?? []).includes(origin);
408
1292
  }
409
- const json = (req, body, status = 200) => new Response(JSON.stringify(body), {
1293
+ const json = (req, body, status = 200, extraHeaders = {}) => new Response(JSON.stringify(body), {
410
1294
  status,
411
- headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...corsHeaders(req) }
1295
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...extraHeaders, ...corsHeaders(req) }
412
1296
  });
413
1297
  const fail = (req, status, message, code = "bad_request") => json(req, { error: { message, code } }, status);
1298
+ const identityFail = (req, code, remediation) => {
1299
+ const e = IDENTITY_ERRORS[code];
1300
+ return json(req, { error: { message: e.message, code, remediation: remediation ?? { action: e.action, audience: "end_user" } } }, e.status);
1301
+ };
1302
+ const misconfigured = (req) => fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured");
1303
+ function ccpFail(req, e, route, sessionToken) {
1304
+ const b = browserErrorFor(e, route);
1305
+ if (b.admission) {
1306
+ const r = admissionErrorResponse(b.admission, b.retryAfterMs);
1307
+ return json(req, r.body, r.status, r.headers);
1308
+ }
1309
+ if (b.identity) {
1310
+ if (sessionToken && DEAD_SESSION_CODES.has(b.identity)) forgetCachedSession(sessionToken);
1311
+ return identityFail(req, b.identity, b.remediation);
1312
+ }
1313
+ const res = fail(req, b.status, b.message, b.code);
1314
+ if (b.retryAfterSeconds) res.headers.set("retry-after", String(Math.ceil(b.retryAfterSeconds)));
1315
+ return res;
1316
+ }
1317
+ const gatewayFail = (req, e, sessionToken) => {
1318
+ if (e instanceof GatewayError && isAdmissionErrorCode(e.code)) {
1319
+ const r = admissionErrorResponse(e.code, e.retryAfterMs);
1320
+ return json(req, r.body, r.status, r.headers);
1321
+ }
1322
+ 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);
1324
+ if (e.remediation?.audience === "operator") console.error(`[dewee-webchat] identity: ${e.message} (${e.code})`);
1325
+ return identityFail(req, e.code, e.remediation);
1326
+ };
1327
+ const sdkFail = (req, e, what) => {
1328
+ const code = e?.code;
1329
+ if (isIdentityErrorCode(code)) {
1330
+ console.warn(`[dewee-webchat] identity ${what} refused: ${code}`);
1331
+ return identityFail(req, code, e.remediation);
1332
+ }
1333
+ console.warn(`[dewee-webchat] identity ${what} failed:`, code ?? e);
1334
+ if (code === "RESOURCE_EXHAUSTED") return fail(req, 429, "Too many sign-ins, slow down", "rate_limited");
1335
+ if (code === "INVALID_ARGUMENT" || code === "SERVER_ONLY") return misconfigured(req);
1336
+ return fail(req, 502, "Dewee is unavailable, try again later", "gateway_error");
1337
+ };
1338
+ const exchangeFail = (req, e) => {
1339
+ const { code, remediation } = e ?? {};
1340
+ if (isIdentityErrorCode(code) && remediation && remediation.audience !== "end_user" || code === "NOT_FOUND") {
1341
+ console.error(`[dewee-webchat] identity session exchange refused: ${String(code)} (remediation for ${remediation?.audience ?? "operator"}: ${remediation?.action ?? "check the gateway"})`);
1342
+ return misconfigured(req);
1343
+ }
1344
+ return sdkFail(req, e, "session exchange");
1345
+ };
1346
+ const ccpExchangeFail = (req, e) => {
1347
+ const { code, remediation } = e ?? {};
1348
+ if (isIdentityErrorCode(code) && remediation && remediation.audience !== "end_user") {
1349
+ console.error(`[dewee-webchat] identity session exchange refused: ${code} (remediation for ${remediation.audience}: ${remediation.action})`);
1350
+ return misconfigured(req);
1351
+ }
1352
+ if (typeof e?.code !== "string") {
1353
+ console.warn("[dewee-webchat] identity session exchange failed:", e?.name ?? e);
1354
+ }
1355
+ return ccpFail(req, e, "identity/exchange");
1356
+ };
1357
+ const linkFail = (req, e, sessionToken) => {
1358
+ const code = e?.code;
1359
+ if (code === "IDEMPOTENCY_KEY_REUSED") return fail(req, 409, "This idempotency key was already used for a different link request", "idempotency_key_reused");
1360
+ 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");
1361
+ if (code === "NOT_FOUND") {
1362
+ console.error("[dewee-webchat] identity link start: the gateway has identity exchange switched off");
1363
+ return fail(req, 503, "Linking is not available right now", "linking_unavailable");
1364
+ }
1365
+ if (typeof code === "string" && DEAD_SESSION_CODES.has(code)) forgetCachedSession(sessionToken);
1366
+ return sdkFail(req, e, "link start");
1367
+ };
414
1368
  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";
415
1369
  async function visitorFrom(req) {
416
1370
  const auth = req.headers.get("authorization") || "";
417
1371
  const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
418
1372
  const claims = token ? await verifyVisitorToken(secret, token) : null;
419
- return claims?.v ?? null;
1373
+ if (claims?.ids && !idn) return null;
1374
+ return claims;
1375
+ }
1376
+ async function agentFor(claims, requested, creds, isByo) {
1377
+ if (claims.ids) {
1378
+ const bound = claims.ida;
1379
+ if (requested && requested !== bound) return null;
1380
+ return wildcardAgents || staticAgents.some((a) => a.id === bound) ? bound : null;
1381
+ }
1382
+ return pickAgent(requested, creds, isByo);
1383
+ }
1384
+ async function rebindRefusal(req, claims, required = false) {
1385
+ if (!idn || !required && !idn.rebindEachRequest || !claims.ids) return null;
1386
+ const throttled = await preauthRefusal(req);
1387
+ if (throttled) return throttled;
1388
+ let user;
1389
+ try {
1390
+ user = await idn.authenticate(req);
1391
+ } catch (e) {
1392
+ console.warn("[dewee-webchat] identity.authenticate failed:", e instanceof Error ? e.name : "unknown");
1393
+ return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" });
1394
+ }
1395
+ const current = !!user && typeof user.subject === "string" && user.subject !== "" ? await subjectDigest(secret, user.subject) : null;
1396
+ if (current && claims.sub && constantTimeEqual(current, claims.sub)) return null;
1397
+ if (ccp && current && claims.sub) {
1398
+ forgetCachedSession(claims.ids);
1399
+ await ccp.revoke(visitorContext(req), claims.v, sealedEdge(claims), claims.ids, claims.ida);
1400
+ }
1401
+ return identityFail(req, "IDENTITY_REQUIRED");
1402
+ }
1403
+ async function preauthRefusal(req) {
1404
+ if (!limiter || await limiter(clientIp(req), "preauth")) return null;
1405
+ return fail(req, 429, "Too many requests, slow down", "rate_limited");
1406
+ }
1407
+ const actorOf2 = (claims) => claims.ids ? { identitySession: claims.ids } : o.userIdPrefix + claims.v;
1408
+ function identitySigner() {
1409
+ if (idn.assertionSecret) return { applicationId: idn.appId, issuer: idn.issuer, secret: idn.assertionSecret };
1410
+ return { applicationId: idn.appId, issuer: idn.issuer, privateKeyJwk: idn.privateKeyJwk, algorithm: idn.algorithm };
1411
+ }
1412
+ const sessionCache = /* @__PURE__ */ new Map();
1413
+ 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);
1426
+ }
1427
+ }
1428
+ async function exchangeFor(subject, req, sealed) {
1429
+ if (ccp) return ccpExchangeFor(subject, req, sealed);
1430
+ const session = await idn.sdk.exchangeSession({
1431
+ gatewayUrl: o.gatewayUrl,
1432
+ allowInsecureHttp: idn.allowInsecureHttp,
1433
+ signer: identitySigner(),
1434
+ subject,
1435
+ agent: identityAgent,
1436
+ ttlSeconds: idn.sessionTtlSeconds
1437
+ });
1438
+ const receivedAt = Date.now();
1439
+ const bound = idn.sessionTtlSeconds ? idn.sessionTtlSeconds * 1e3 : MAX_IDENTITY_TTL_MS;
1440
+ let lifetime = session.expiresAt.getTime() - receivedAt;
1441
+ if (!(lifetime > 0 && lifetime <= bound)) {
1442
+ console.warn("[dewee-webchat] identity session expiry does not match this server's clock; using the requested lifetime");
1443
+ lifetime = idn.sessionTtlSeconds ? idn.sessionTtlSeconds * 1e3 : DEFAULT_IDENTITY_TTL_MS;
1444
+ }
1445
+ const visitorId = await deriveVisitorId(secret, `principal:${session.principalId}`);
1446
+ return { sessionToken: session.sessionToken, visitorId, deadline: receivedAt + lifetime, margin: Math.min(6e4, lifetime / 2) };
1447
+ }
1448
+ function identityLifetime(expiresAt, receivedAt) {
1449
+ const bound = idn.sessionTtlSeconds ? idn.sessionTtlSeconds * 1e3 : MAX_IDENTITY_TTL_MS;
1450
+ const lifetime = expiresAt.getTime() - receivedAt;
1451
+ if (lifetime > 0 && lifetime <= bound) return lifetime;
1452
+ console.warn("[dewee-webchat] identity session expiry does not match this server's clock; using the requested lifetime");
1453
+ return idn.sessionTtlSeconds ? idn.sessionTtlSeconds * 1e3 : DEFAULT_IDENTITY_TTL_MS;
1454
+ }
1455
+ async function ccpExchangeFor(subject, req, sealed) {
1456
+ const visitorId = await deriveVisitorId(secret, `subject:${subject}`);
1457
+ const ctx = visitorContext(req);
1458
+ const session = await ccp.exchange(ctx, visitorId, subject, await boundAgent(), sealed);
1459
+ const receivedAt = Date.now();
1460
+ const lifetime = identityLifetime(session.expiresAt, receivedAt);
1461
+ return { sessionToken: session.sessionToken, visitorId, deadline: receivedAt + lifetime, margin: Math.min(6e4, lifetime / 2), origin: ctx.visitorOrigin };
1462
+ }
1463
+ async function boundAgent() {
1464
+ if (!ccp || identityAgent) return identityAgent;
1465
+ return (await ccp.config()).defaultAgent;
1466
+ }
1467
+ async function verifiedReuse(req, hint, subject, agent, userVisitor) {
1468
+ if (!ccp || !hint?.ids || hint.ida !== agent || !hint.sub || hint.v !== userVisitor) return null;
1469
+ if (hint.exp - Date.now() <= VERIFIED_REUSE_MIN_MS) return null;
1470
+ if (!constantTimeEqual(await subjectDigest(secret, subject), hint.sub)) return null;
1471
+ return { sessionToken: hint.ids, visitorId: hint.v, deadline: hint.exp, margin: Math.min(6e4, VERIFIED_REUSE_MIN_MS / 2), origin: req.headers.get("origin") || void 0 };
1472
+ }
1473
+ async function issueIdentitySession(req, subject, hint = null) {
1474
+ let agent;
1475
+ try {
1476
+ agent = await boundAgent();
1477
+ } catch (e) {
1478
+ return ccpFail(req, e, "config");
1479
+ }
1480
+ const key = `${agent}
1481
+ ${subject}`;
1482
+ let s = sessionCache.get(key);
1483
+ const userVisitor = ccp ? await deriveVisitorId(secret, `subject:${subject}`) : void 0;
1484
+ const sealed = hint && hint.ids && hint.v === userVisitor ? sealedEdge(hint) : void 0;
1485
+ 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
+ }
1500
+ }
1501
+ rememberSession(key, s);
1502
+ }
1503
+ const now = Date.now();
1504
+ const expiresAt = Math.min(s.deadline, now + o.sessionTtlMs);
1505
+ const sub = await subjectDigest(secret, subject);
1506
+ let edge;
1507
+ if (ccp) {
1508
+ try {
1509
+ edge = await ccp.edgeFor(visitorContext(req), s.visitorId, sealed, void 0, EDGE_RESEAL_MS);
1510
+ } catch (e) {
1511
+ return ccpFail(req, e, "session");
1512
+ }
1513
+ }
1514
+ const claims = { v: s.visitorId, exp: expiresAt, ids: s.sessionToken, ida: agent, sub, ...edge ? { es: edge.token, ese: edge.expiresAt } : {} };
1515
+ const token = await signVisitorToken(secret, claims);
1516
+ const times = edge ? ccpSessionTimes(expiresAt, edge) : { expiresAt, expiresIn: expiresAt - now };
1517
+ const body = { visitorId: s.visitorId, token, expiresAt: times.expiresAt, verified: true, expiresIn: times.expiresIn };
1518
+ if (ccp) body.reuseHint = await signReuseHint(secret, claims);
1519
+ return json(req, body);
420
1520
  }
421
1521
  async function pickAgent(requested, creds, isByo) {
422
1522
  if (isByo || wildcardAgents) {
@@ -428,6 +1528,7 @@ function createDeweeHandler(options) {
428
1528
  return staticAgents.some((a) => a.id === requested) ? requested : null;
429
1529
  }
430
1530
  async function handleConfig(req) {
1531
+ if (ccp) return handleCcpConfig(req);
431
1532
  const { creds, byo: isByo, error } = resolveCreds(req);
432
1533
  if (error) return fail(req, 400, error, "bad_credentials");
433
1534
  let agents = [];
@@ -445,36 +1546,135 @@ function createDeweeHandler(options) {
445
1546
  maxMessageLength: o.maxMessageLength,
446
1547
  version: VERSION
447
1548
  };
1549
+ if (idn) {
1550
+ body.defaultAgent = identityAgent;
1551
+ body.allowAgentSwitch = false;
1552
+ body.identity = true;
1553
+ }
1554
+ return json(req, body);
1555
+ }
1556
+ async function handleCcpConfig(req) {
1557
+ const { error } = resolveCreds(req);
1558
+ if (error) return fail(req, 400, error, "bad_credentials");
1559
+ let cfg;
1560
+ try {
1561
+ cfg = await ccp.config();
1562
+ } catch (e) {
1563
+ return ccpFail(req, e, "config");
1564
+ }
1565
+ const body = {
1566
+ agents: cfg.agents,
1567
+ defaultAgent: cfg.defaultAgent,
1568
+ allowAgentSwitch: o.allowAgentSwitch === false ? false : cfg.allowAgentSwitch,
1569
+ allowClientCredentials: false,
1570
+ turnstileSiteKey: o.turnstile?.siteKey,
1571
+ ...o.turnstile ? { refreshWithoutTurnstile: true } : {},
1572
+ maxMessageLength: Math.min(o.maxMessageLength, cfg.maxMessageChars),
1573
+ version: VERSION
1574
+ };
1575
+ if (idn) {
1576
+ body.defaultAgent = identityAgent || cfg.defaultAgent;
1577
+ body.allowAgentSwitch = false;
1578
+ body.identity = true;
1579
+ }
448
1580
  return json(req, body);
449
1581
  }
1582
+ async function turnstilePassed(body, ip) {
1583
+ if (!o.turnstile) return true;
1584
+ return !!body?.turnstileToken && await verifyTurnstile(o.turnstile.secretKey, String(body.turnstileToken), ip);
1585
+ }
450
1586
  async function handleSession(req) {
1587
+ if (idn) return handleIdentitySession(req);
451
1588
  const ip = clientIp(req);
1589
+ if (ccp) {
1590
+ const body2 = await req.json().catch(() => ({}));
1591
+ const previous = await anonymousPrevious(body2);
1592
+ if (limiter && !await limiter(previous ? `visitor:${previous.v}` : ip, "session")) return fail(req, 429, "Too many sessions, slow down", "rate_limited");
1593
+ return issueAnonymousSession(req, body2, ip, previous);
1594
+ }
452
1595
  if (limiter && !await limiter(ip, "session")) return fail(req, 429, "Too many sessions, slow down", "rate_limited");
453
1596
  const body = await req.json().catch(() => ({}));
454
- if (o.turnstile) {
455
- if (!body?.turnstileToken || !await verifyTurnstile(o.turnstile.secretKey, String(body.turnstileToken), ip)) {
456
- return fail(req, 403, "Verification failed", "turnstile_failed");
457
- }
458
- }
1597
+ return issueAnonymousSession(req, body, ip);
1598
+ }
1599
+ async function anonymousPrevious(body) {
459
1600
  const previous = typeof body?.token === "string" ? await verifyVisitorToken(secret, body.token) : null;
1601
+ return previous && !previous.ids ? previous : null;
1602
+ }
1603
+ async function issueAnonymousSession(req, body, ip, known) {
1604
+ const previous = known === void 0 ? await anonymousPrevious(body) : known;
1605
+ if (!(ccp && previous) && !await turnstilePassed(body, ip)) return fail(req, 403, "Verification failed", "turnstile_failed");
460
1606
  const visitorId = previous?.v ?? randomId(16);
461
1607
  const expiresAt = Date.now() + o.sessionTtlMs;
462
- return json(req, { visitorId, token: await signVisitorToken(secret, { v: visitorId, exp: expiresAt }), expiresAt });
1608
+ if (!ccp) return json(req, { visitorId, token: await signVisitorToken(secret, { v: visitorId, exp: expiresAt }), expiresAt });
1609
+ let edge;
1610
+ try {
1611
+ edge = await ccp.edgeFor(visitorContext(req), visitorId, sealedEdge(previous), void 0, EDGE_RESEAL_MS);
1612
+ } catch (e) {
1613
+ return ccpFail(req, e, "session");
1614
+ }
1615
+ const token = await signVisitorToken(secret, { v: visitorId, exp: expiresAt, es: edge.token, ese: edge.expiresAt });
1616
+ const body2 = { visitorId, token, ...ccpSessionTimes(expiresAt, edge) };
1617
+ return json(req, body2);
1618
+ }
1619
+ async function handleIdentitySession(req) {
1620
+ const ip = clientIp(req);
1621
+ const body = await req.json().catch(() => ({}));
1622
+ const throttled = await preauthRefusal(req);
1623
+ if (throttled) return throttled;
1624
+ let user;
1625
+ try {
1626
+ user = await idn.authenticate(req);
1627
+ } catch (e) {
1628
+ console.warn("[dewee-webchat] identity.authenticate failed:", e);
1629
+ return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" });
1630
+ }
1631
+ if (user != null) {
1632
+ if (typeof user !== "object" || typeof user.subject !== "string" || user.subject.trim() === "") {
1633
+ console.error("[dewee-webchat] identity.authenticate must return { subject } or null");
1634
+ return misconfigured(req);
1635
+ }
1636
+ if (limiter && !await limiter(`subject:${user.subject}`, "verified")) return fail(req, 429, "Too many sessions, slow down", "rate_limited");
1637
+ if (!await turnstilePassed(body, ip)) return fail(req, 403, "Verification failed", "turnstile_failed");
1638
+ const hint = ccp && typeof body?.reuseHint === "string" ? await verifyReuseHint(secret, body.reuseHint) : null;
1639
+ return issueIdentitySession(req, user.subject, hint);
1640
+ }
1641
+ if (idn.required) return identityFail(req, "IDENTITY_REQUIRED");
1642
+ const previous = await anonymousPrevious(body);
1643
+ if (previous && previous.exp - Date.now() > ANON_REUSE_MS) {
1644
+ if (!ccp) {
1645
+ const reused = { visitorId: previous.v, token: body.token, expiresAt: previous.exp };
1646
+ return json(req, reused);
1647
+ }
1648
+ const edge = sealedEdge(previous);
1649
+ if (edge && edge.expiresAt - Date.now() > EDGE_RESEAL_MS) {
1650
+ const reused = { visitorId: previous.v, token: body.token, ...ccpSessionTimes(previous.exp, edge) };
1651
+ return json(req, reused);
1652
+ }
1653
+ }
1654
+ const key = ccp && previous ? `visitor:${previous.v}` : ip;
1655
+ if (limiter && !await limiter(key, "session")) return fail(req, 429, "Too many sessions, slow down", "rate_limited");
1656
+ return issueAnonymousSession(req, body, ip, previous);
463
1657
  }
464
1658
  async function handleChat(req) {
465
- const visitorId = await visitorFrom(req);
466
- if (!visitorId) return fail(req, 401, "Missing or expired visitor session", "unauthorized");
1659
+ const claims = await visitorFrom(req);
1660
+ if (!claims) return fail(req, 401, "Missing or expired visitor session", "unauthorized");
1661
+ if (idn?.required && !claims.ids) return identityFail(req, "IDENTITY_REQUIRED");
1662
+ const refused = await rebindRefusal(req, claims);
1663
+ if (refused) return refused;
1664
+ const visitorId = claims.v;
467
1665
  if (limiter && !await limiter(`${visitorId}|${clientIp(req)}`, "chat")) return fail(req, 429, "Too many messages, please wait a moment", "rate_limited");
468
1666
  const { creds, byo: isByo, error } = resolveCreds(req);
469
1667
  if (error) return fail(req, 400, error, "bad_credentials");
470
- if (!creds) return fail(req, 500, "Gateway is not configured", "not_configured");
1668
+ if (!creds && !ccp) return fail(req, 500, "Gateway is not configured", "not_configured");
471
1669
  const body = await req.json().catch(() => null);
472
1670
  const message = typeof body?.message === "string" ? body.message.trim() : "";
473
1671
  if (!message) return fail(req, 400, "Message is required");
474
1672
  if (message.length > o.maxMessageLength) return fail(req, 413, `Message exceeds ${o.maxMessageLength} characters`, "too_long");
475
- const conversationId = typeof body?.conversationId === "string" && ID_RE.test(body.conversationId) ? body.conversationId : randomId(9);
476
- const agent = await pickAgent(body?.agent, creds, isByo);
477
- if (!agent) return fail(req, 403, "Agent not allowed", "agent_not_allowed");
1673
+ const idRe = ccp ? CCP_ID_RE : ID_RE;
1674
+ const conversationId = typeof body?.conversationId === "string" && idRe.test(body.conversationId) ? body.conversationId : randomId(9);
1675
+ const agent = ccp ? ccpAgentFor(req, claims, body?.agent) : await agentFor(claims, body?.agent, creds, isByo);
1676
+ if (agent instanceof Response) return agent;
1677
+ if (agent === null) return fail(req, 403, "Agent not allowed", "agent_not_allowed");
478
1678
  let pageBlock = "";
479
1679
  let actionsHash;
480
1680
  try {
@@ -486,6 +1686,16 @@ function createDeweeHandler(options) {
486
1686
  if (e instanceof ActionInputError) return fail(req, e.status, e.message, e.code);
487
1687
  throw e;
488
1688
  }
1689
+ if (ccp) return ccpChat(req, claims, { message: pageBlock + message, rawMessage: message, conversationId, agent, actionsHash });
1690
+ if (!creds) return fail(req, 500, "Gateway is not configured", "not_configured");
1691
+ let connection;
1692
+ if (claims.ids) {
1693
+ try {
1694
+ connection = await GatewayConnection.connect(creds, { identitySession: claims.ids });
1695
+ } catch (e) {
1696
+ return gatewayFail(req, e, claims.ids);
1697
+ }
1698
+ }
489
1699
  const sessionKey = buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`);
490
1700
  const abort = new AbortController();
491
1701
  req.signal?.addEventListener("abort", () => abort.abort(), { once: true });
@@ -501,17 +1711,23 @@ function createDeweeHandler(options) {
501
1711
  let errMsg;
502
1712
  try {
503
1713
  await send(actionsHash ? { type: "start", conversationId, agent, actionsHash } : { type: "start", conversationId, agent });
504
- for await (const ev of streamChat({ creds, userId: o.userIdPrefix + visitorId, agent, sessionKey, message: pageBlock + message, signal: abort.signal, timeoutMs: o.timeoutMs })) {
1714
+ 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 })) {
505
1716
  if (ev.type === "start") continue;
506
1717
  if (ev.type === "done") reply = ev.content;
507
- if (ev.type === "error") errMsg = ev.message;
1718
+ if (ev.type === "error") {
1719
+ errMsg = gatewayReasonOf(ev) ?? ev.message;
1720
+ if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) forgetCachedSession(claims.ids);
1721
+ }
508
1722
  await send(ev);
509
1723
  if (abort.signal.aborted) break;
510
1724
  }
511
1725
  } catch (e) {
1726
+ const admission = e instanceof GatewayError && isAdmissionErrorCode(e.code) ? admissionErrorEvent(e.code, e.retryAfterMs) : null;
512
1727
  errMsg = e.message;
513
- await send({ type: "error", message: errMsg, code: "gateway_error" });
1728
+ await send(admission ?? { type: "error", message: errMsg, code: "gateway_error" });
514
1729
  } finally {
1730
+ connection?.close();
515
1731
  clearInterval(keepAlive);
516
1732
  try {
517
1733
  o.onChat?.({ visitorId, agent, message, reply, error: errMsg });
@@ -530,26 +1746,255 @@ function createDeweeHandler(options) {
530
1746
  }
531
1747
  });
532
1748
  }
1749
+ function ccpAgentFor(req, claims, requested) {
1750
+ if (requested !== void 0 && requested !== null && requested !== "" && (typeof requested !== "string" || !CCP_AGENT_RE.test(requested))) {
1751
+ return fail(req, 400, "Invalid agent");
1752
+ }
1753
+ const asked = requested || "";
1754
+ if (claims.ids && asked && asked !== claims.ida) return fail(req, 403, "Agent not allowed", "agent_not_allowed");
1755
+ return claims.ids ? claims.ida : asked;
1756
+ }
1757
+ async function ccpChat(req, claims, turn) {
1758
+ const ctx = visitorContext(req);
1759
+ const visitorId = claims.v;
1760
+ const abort = new AbortController();
1761
+ const onAbort = () => abort.abort();
1762
+ req.signal?.addEventListener("abort", onAbort, { once: true });
1763
+ let timedOut = false;
1764
+ const timer = setTimeout(() => {
1765
+ timedOut = true;
1766
+ abort.abort();
1767
+ }, o.timeoutMs);
1768
+ const cleanup = () => {
1769
+ clearTimeout(timer);
1770
+ req.signal?.removeEventListener("abort", onAbort);
1771
+ };
1772
+ let cfg;
1773
+ let upstream;
1774
+ try {
1775
+ cfg = await ccp.config();
1776
+ if (turn.rawMessage.length > cfg.maxMessageChars) {
1777
+ cleanup();
1778
+ return fail(req, 413, `Message exceeds ${cfg.maxMessageChars} characters`, "too_long");
1779
+ }
1780
+ upstream = await ccp.startChat(ctx, visitorId, sealedEdge(claims), {
1781
+ message: turn.message,
1782
+ conversationId: turn.conversationId,
1783
+ agent: turn.agent || void 0,
1784
+ identitySession: claims.ids,
1785
+ signal: abort.signal
1786
+ });
1787
+ } catch (e) {
1788
+ cleanup();
1789
+ if (timedOut) return fail(req, 504, "The agent took too long to answer", "timeout");
1790
+ if (abort.signal.aborted && !(e instanceof CcpError)) return fail(req, 499, "Request cancelled", "cancelled");
1791
+ return ccpFail(req, e, "chat", claims.ids);
1792
+ }
1793
+ const { readable, writable } = new TransformStream();
1794
+ const writer = writable.getWriter();
1795
+ const enc2 = new TextEncoder();
1796
+ const send = (e) => writer.write(enc2.encode(typeof e === "string" ? e : `data: ${JSON.stringify(e)}
1797
+
1798
+ `)).catch(() => abort.abort());
1799
+ let runId;
1800
+ let finished = false;
1801
+ abort.signal.addEventListener(
1802
+ "abort",
1803
+ () => {
1804
+ if (finished) return;
1805
+ void ccp.abort(ctx, visitorId, sealedEdge(claims), { conversationId: turn.conversationId, runId, agent: turn.agent || void 0, identitySession: claims.ids });
1806
+ if (!timedOut) writer.abort().catch(() => {
1807
+ });
1808
+ },
1809
+ { once: true }
1810
+ );
1811
+ (async () => {
1812
+ const keepAlive = setInterval(() => send(": ping\n\n"), 15e3);
1813
+ let reply = "";
1814
+ let errMsg;
1815
+ let agent = turn.agent || cfg.defaultAgent;
1816
+ try {
1817
+ for await (const ev of ccp.chatEvents(upstream, cfg, turn.conversationId, agent)) {
1818
+ if (ev.type === "start") {
1819
+ runId = ev.runId;
1820
+ agent = ev.agent;
1821
+ await send(turn.actionsHash ? { ...ev, actionsHash: turn.actionsHash } : ev);
1822
+ continue;
1823
+ }
1824
+ if (ev.type === "done") {
1825
+ reply = ev.content;
1826
+ finished = true;
1827
+ }
1828
+ if (ev.type === "error") {
1829
+ errMsg = ev.message;
1830
+ finished = true;
1831
+ if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) forgetCachedSession(claims.ids);
1832
+ }
1833
+ await send(ev);
1834
+ if (abort.signal.aborted) break;
1835
+ }
1836
+ } catch (e) {
1837
+ if (timedOut) {
1838
+ errMsg = "The agent took too long to answer";
1839
+ await send({ type: "error", message: errMsg, code: "timeout" });
1840
+ } else if (!abort.signal.aborted) {
1841
+ console.error("[dewee-webchat] ccp chat stream failed:", e?.name ?? "error");
1842
+ errMsg = "Dewee is unavailable, try again later";
1843
+ await send({ type: "error", message: errMsg, code: "gateway_error" });
1844
+ finished = true;
1845
+ }
1846
+ } finally {
1847
+ clearInterval(keepAlive);
1848
+ cleanup();
1849
+ if (!finished && abort.signal.aborted) errMsg ??= "cancelled";
1850
+ finished = true;
1851
+ try {
1852
+ o.onChat?.({ visitorId, agent, message: turn.rawMessage, reply, error: errMsg });
1853
+ } catch {
1854
+ }
1855
+ writer.close().catch(() => {
1856
+ });
1857
+ }
1858
+ })();
1859
+ return new Response(readable, {
1860
+ headers: {
1861
+ "content-type": "text/event-stream; charset=utf-8",
1862
+ "cache-control": "no-cache, no-transform",
1863
+ "x-accel-buffering": "no",
1864
+ ...corsHeaders(req)
1865
+ }
1866
+ });
1867
+ }
1868
+ async function ccpHistory(req, claims) {
1869
+ const url = new URL(req.url);
1870
+ const conversationId = url.searchParams.get("conversationId") || "";
1871
+ if (!ID_RE.test(conversationId)) return fail(req, 400, "Invalid conversationId");
1872
+ const agent = ccpAgentFor(req, claims, url.searchParams.get("agent"));
1873
+ if (agent instanceof Response) return agent;
1874
+ if (!CCP_ID_RE.test(conversationId)) return json(req, { messages: [] });
1875
+ try {
1876
+ const messages = await ccp.history(visitorContext(req), claims.v, sealedEdge(claims), { conversationId, agent: agent || void 0, identitySession: claims.ids });
1877
+ return json(req, { messages });
1878
+ } catch (e) {
1879
+ return ccpFail(req, e, "history", claims.ids);
1880
+ }
1881
+ }
533
1882
  async function handleHistory(req) {
534
- const visitorId = await visitorFrom(req);
535
- if (!visitorId) return fail(req, 401, "Missing or expired visitor session", "unauthorized");
1883
+ const claims = await visitorFrom(req);
1884
+ if (!claims) return fail(req, 401, "Missing or expired visitor session", "unauthorized");
1885
+ if (idn?.required && !claims.ids) return identityFail(req, "IDENTITY_REQUIRED");
1886
+ const refused = await rebindRefusal(req, claims);
1887
+ if (refused) return refused;
1888
+ const visitorId = claims.v;
536
1889
  const { creds, byo: isByo, error } = resolveCreds(req);
537
1890
  if (error) return fail(req, 400, error, "bad_credentials");
1891
+ if (ccp) return ccpHistory(req, claims);
538
1892
  if (!creds) return fail(req, 500, "Gateway is not configured", "not_configured");
539
1893
  const url = new URL(req.url);
540
1894
  const conversationId = url.searchParams.get("conversationId") || "";
541
1895
  if (!ID_RE.test(conversationId)) return fail(req, 400, "Invalid conversationId");
542
- const agent = await pickAgent(url.searchParams.get("agent"), creds, isByo);
1896
+ const agent = await agentFor(claims, url.searchParams.get("agent"), creds, isByo);
543
1897
  if (!agent) return fail(req, 403, "Agent not allowed", "agent_not_allowed");
544
1898
  try {
545
- const messages = await fetchHistory(creds, o.userIdPrefix + visitorId, agent, buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`));
1899
+ const messages = await fetchHistory(creds, actorOf2(claims), agent, buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`));
546
1900
  return json(req, { messages });
547
1901
  } catch (e) {
548
1902
  if (e.code === "NOT_FOUND") return json(req, { messages: [] });
549
- return fail(req, 502, e.message, "gateway_error");
1903
+ return gatewayFail(req, e, claims.ids);
550
1904
  }
551
1905
  }
552
- return async function deweeHandler(req) {
1906
+ async function handleJobAccess(req) {
1907
+ const origin = req.headers.get("origin");
1908
+ try {
1909
+ const parsed = new URL(origin ?? "");
1910
+ if (parsed.protocol !== "https:" || parsed.origin !== origin) return fail(req, 403, "Open job access from your signed-in chat", "forbidden_origin");
1911
+ } catch {
1912
+ return fail(req, 403, "Open job access from your signed-in chat", "forbidden_origin");
1913
+ }
1914
+ const claims = await visitorFrom(req);
1915
+ if (!claims?.ids || !claims.ida) return identityFail(req, "IDENTITY_REQUIRED");
1916
+ const refused = await rebindRefusal(req, claims, true);
1917
+ if (refused) return refused;
1918
+ const agent = ccp ? ccpAgentFor(req, claims, claims.ida) : await agentFor(claims, claims.ida, serverCreds(), false);
1919
+ if (agent instanceof Response) return agent;
1920
+ if (!agent) return fail(req, 403, "Agent not allowed", "agent_not_allowed");
1921
+ if (limiter && !await limiter(`visitor:${claims.v}`, "link")) return fail(req, 429, "Too many requests, try again later", "rate_limited");
1922
+ let body;
1923
+ try {
1924
+ body = await req.json();
1925
+ } catch {
1926
+ return fail(req, 400, "Invalid request", "invalid_request");
1927
+ }
1928
+ if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length) return fail(req, 400, "Invalid request", "invalid_request");
1929
+ try {
1930
+ const launch = ccp ? await ccp.jobAccess(visitorContext(req), claims.v, sealedEdge(claims), { identitySession: claims.ids, agent }) : await mintJobAccess(o.gatewayUrl, claims.ids, origin);
1931
+ return json(req, launch);
1932
+ } catch (error) {
1933
+ if (error instanceof JobAccessRefusal) return identityFail(req, error.code);
1934
+ if (error instanceof CcpError) return ccpFail(req, error, "job-access", claims.ids);
1935
+ return fail(req, 502, "Job access is unavailable. Try again later.", "job_access_unavailable");
1936
+ }
1937
+ }
1938
+ async function handleLink(req) {
1939
+ const claims = await visitorFrom(req);
1940
+ if (!claims?.ids) return identityFail(req, "IDENTITY_REQUIRED");
1941
+ const refused = await rebindRefusal(req, claims);
1942
+ if (refused) return refused;
1943
+ if (limiter && !await limiter(`visitor:${claims.v}`, "link")) return fail(req, 429, "Too many link requests, slow down", "rate_limited");
1944
+ const body = await req.json().catch(() => ({}));
1945
+ const idempotencyKey = body?.idempotencyKey;
1946
+ if (idempotencyKey !== void 0 && (typeof idempotencyKey !== "string" || !IDEMPOTENCY_KEY_RE.test(idempotencyKey))) {
1947
+ return fail(req, 400, "idempotencyKey must be 1 to 255 printable ASCII characters without spaces");
1948
+ }
1949
+ if (ccp) {
1950
+ try {
1951
+ const res = await ccp.link(visitorContext(req), claims.v, sealedEdge(claims), { identitySession: claims.ids, agent: claims.ida, idempotencyKey });
1952
+ return json(req, res);
1953
+ } catch (e) {
1954
+ return ccpFail(req, e, "link", claims.ids);
1955
+ }
1956
+ }
1957
+ try {
1958
+ const link = await idn.sdk.startLink({ gatewayUrl: o.gatewayUrl, allowInsecureHttp: idn.allowInsecureHttp, sessionToken: claims.ids, idempotencyKey });
1959
+ const res = {
1960
+ proofCode: link.proofCode,
1961
+ expiresAt: link.expiresAt.getTime(),
1962
+ deepLinks: link.deepLinks.map((t) => ({ channelType: t.channelType, channelInstanceId: t.channelInstanceId, name: t.name, ...t.displayName ? { displayName: t.displayName } : {} }))
1963
+ };
1964
+ return json(req, res);
1965
+ } catch (e) {
1966
+ return linkFail(req, e, claims.ids);
1967
+ }
1968
+ }
1969
+ async function revoke(subject, request, token) {
1970
+ if (!idn || typeof subject !== "string" || subject === "") return;
1971
+ 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 });
1976
+ }
1977
+ if (ccp && typeof token === "string" && token !== "") {
1978
+ const c = await verifyReuseHint(secret, token) ?? await verifyVisitorToken(secret, token);
1979
+ if (c?.ids && c.ida && c.sub && constantTimeEqual(await subjectDigest(secret, subject), c.sub) && !held.some((h) => h.session.sessionToken === c.ids)) {
1980
+ held.push({ agent: c.ida, session: { sessionToken: c.ids, visitorId: c.v, deadline: c.exp, margin: 0 }, edge: sealedEdge(c) });
1981
+ }
1982
+ }
1983
+ for (const { session } of held) forgetCachedSession(session.sessionToken);
1984
+ if (!ccp) return;
1985
+ const fromRequest = request ? visitorContext(request) : null;
1986
+ await Promise.all(
1987
+ held.map(({ agent, session, edge }) => {
1988
+ const ctx = fromRequest ?? { visitorOrigin: session.origin, requestId: `dwc-${randomId(12)}` };
1989
+ return ccp.revoke(ctx, session.visitorId, edge, session.sessionToken, agent || void 0).catch(() => {
1990
+ });
1991
+ })
1992
+ );
1993
+ } catch (e) {
1994
+ console.warn("[dewee-webchat] revoke failed:", e instanceof CcpError ? e.code : "unexpected error");
1995
+ }
1996
+ }
1997
+ const deweeHandler = async function deweeHandler2(req) {
553
1998
  if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders(req) });
554
1999
  if (!originAllowed(req)) return fail(req, 403, "Origin not allowed", "forbidden_origin");
555
2000
  const action = new URL(req.url).pathname.replace(/\/+$/, "").split("/").pop();
@@ -558,18 +2003,33 @@ function createDeweeHandler(options) {
558
2003
  if (req.method === "POST" && action === "session") return await handleSession(req);
559
2004
  if (req.method === "POST" && action === "chat") return await handleChat(req);
560
2005
  if (req.method === "GET" && action === "history") return await handleHistory(req);
2006
+ if (req.method === "POST" && action === "link" && idn) return await handleLink(req);
2007
+ if (req.method === "POST" && action === "job-access" && idn) return await handleJobAccess(req);
561
2008
  return fail(req, 404, "Not found", "not_found");
562
2009
  } catch (e) {
563
- return fail(req, 500, e.message || "Internal error", "internal");
2010
+ if (!ccp) return fail(req, 500, e.message || "Internal error", "internal");
2011
+ console.error("[dewee-webchat] internal error:", e instanceof CcpError ? e.code : e);
2012
+ return fail(req, 500, "Internal error", "internal");
564
2013
  }
565
2014
  };
2015
+ return Object.assign(deweeHandler, { revoke });
566
2016
  }
567
2017
  function optionsFromEnv(env, overrides = {}) {
568
2018
  const list = (v) => v?.split(",").map((s) => s.trim()).filter(Boolean);
569
2019
  const agents = env.DEWEE_AGENTS?.trim() === "*" ? "*" : list(env.DEWEE_AGENTS);
570
2020
  const origins = env.DEWEE_ALLOWED_ORIGINS?.trim() === "*" ? "*" : list(env.DEWEE_ALLOWED_ORIGINS);
571
2021
  const byoHosts = list(env.DEWEE_CLIENT_CREDENTIAL_HOSTS);
2022
+ const ccpEnv = {
2023
+ chatUrl: env.DEWEE_CCP_CHAT_URL?.trim() || void 0,
2024
+ integrationId: env.DEWEE_WEBCHAT_INTEGRATION_ID?.trim() || void 0,
2025
+ integrationSecret: env.DEWEE_WEBCHAT_INTEGRATION_SECRET?.trim() || void 0
2026
+ };
2027
+ const transport = env.DEWEE_WEBCHAT_TRANSPORT?.trim() || void 0;
572
2028
  return {
2029
+ // Validated by createDeweeHandler, which refuses unknown values.
2030
+ transport,
2031
+ // Partial CCP settings are kept so createDeweeHandler can name the missing variable.
2032
+ ccp: ccpEnv.chatUrl || ccpEnv.integrationId || ccpEnv.integrationSecret ? ccpEnv : void 0,
573
2033
  gatewayUrl: env.DEWEE_GATEWAY_URL || env.GOCLAW_GATEWAY_URL,
574
2034
  token: env.DEWEE_GATEWAY_TOKEN || env.GOCLAW_GATEWAY_TOKEN,
575
2035
  tenantId: env.DEWEE_TENANT_ID,
@@ -635,14 +2095,20 @@ function toNodeHandler(handler) {
635
2095
  }
636
2096
  export {
637
2097
  ACTION_LIMITS,
2098
+ ADMISSION_ERROR_CODES,
2099
+ CcpError,
2100
+ DEFAULT_RETRY_AFTER_MS,
638
2101
  GatewayConnection,
639
2102
  GatewayError,
2103
+ MAX_RETRY_AFTER_MS,
2104
+ admissionErrorEvent,
640
2105
  buildPageBlock,
641
2106
  buildSessionKey,
642
2107
  createDeweeHandler,
643
2108
  fetchHistory,
644
2109
  foldHistory,
645
2110
  hashManifest,
2111
+ isAdmissionErrorCode,
646
2112
  listAgents,
647
2113
  memoryRateLimiter,
648
2114
  normalizeManifest,