Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/http-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,14 @@ Each SSE frame is `data: <json>\n\n`; `: ping` comments keep the connection aliv
| `delta` | `text` (answer delta) |
| `tool_call` | `id`, `name`, `arguments` |
| `tool_result` | `id`, `name`, `isError`, `result` |
| `credential_request` | `credentialRequest` (CCP signed-in hosted flow only) |
| `done` | `content` (full answer), `usage?` |
| `error` | `message`, `code?`, `remediation?`, `retryAfterMs?` |

Every run ends with `done` or `error`, after which the server closes the stream.
Direct Gateway transport omits `request_credentials` tool calls and their results, and omits
unmatched or malformed tool results, so credential handoffs cannot appear as tool output. CCP
continues to expose tool names and statuses only.

### Admission errors

Expand Down Expand Up @@ -339,6 +343,27 @@ Headers: `Authorization: Bearer <visitor token>`. Returns the visitor's own conv
] }
```

## Hosted credential requests

CCP transport with identity and the backend's explicit `credentialRequests.formOrigin` opt-in can
forward the Gateway's `credential_request` SSE event as `{type: "credential_request", credentialRequest}`.
History adds `credentialRequests` when the verified visitor has requests. Each card contains only
the value-free `webchat-credential-request.v1` metadata; the widget does not persist it.

`POST {base}/credential-request`, `/credential-request-handoff` and `/credential-request-cancel`
accept `{agent, conversationId, credentialRequestId}` and a verified visitor bearer. They require an
HTTPS Origin and reauthenticate the current host user. Status and cancel return `{credentialRequest}`;
handoff returns `{credential_request_id, url, expires_at}` with `Cache-Control: no-store`. The URL
must use the configured HTTPS origin and the Dewee hosted-form route. It contains a short-lived token;
do not log, cache, publish, or add it to chat messages. The hosted form independently requires a
workspace approval administrator.

After completion, `POST /chat` with an empty `message` and `credentialRequestId` resumes the bound
conversation. Gateway enforces one continuation; `credential_request_not_ready` and
`credential_request_resumed` are 409, `credential_request_not_found` is 404, and
`credential_requests_unavailable` is 403. Anonymous sessions and unconfigured backends cannot use
these routes. The request and SSE responses use `no-store`.

## Extra request headers

Hosts may configure the widget to add headers (for example `x-csrf-token`) to every request, and to use a
Expand Down
10 changes: 10 additions & 0 deletions packages/dewee-webchat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,16 @@ used), `allowInsecureHttp` does not apply, a user switch under `rebindEachReques
previous user's session at CCP, and logout revokes it through [`handler.revoke`](#signed-in-users).
The integration's sign-in mode must allow signed-in users.

### Hosted credential requests

Credential requests are off by default and require CCP transport, signed-in identity, and Gateway/CCP
support enabled for the agent. To opt in, add `credentialRequests: { formOrigin: "https://app.example.com" }`
to the server handler options, using the exact HTTPS origin of your Dewee-hosted form. Use the ASCII
punycode form for an internationalized domain. The form is
opened only after the visitor chooses it; a workspace approval administrator must authenticate there.
The widget never collects the credential value. Anonymous sessions, direct Gateway transport, and
an untrusted form origin cannot use this flow. See the [HTTP contract](../../docs/http-contract.md#hosted-credential-requests).

```ts
import { createDeweeHandler, optionsFromEnv } from "dewee-webchat/server";
import { deweeIdentitySdk } from "dewee-webchat/server/identity";
Expand Down
49 changes: 49 additions & 0 deletions packages/dewee-webchat/src/credential-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/** A value-free, server-authorized credential request. Never accept form values here. */
export interface CredentialRequestView {
id: string;
schema_version: "webchat-credential-request.v1";
status: "pending" | "applying" | "resolved" | "cancelled" | "expired" | "failed";
title: string;
purpose: string;
target: { type: string; mode: string };
fields: Array<{ key: string; label: string; kind: string; required: boolean }>;
expires_at: string;
agent_key: string;
completion: "hosted";
resumable: boolean;
}

export interface CredentialHandoff { credential_request_id: string; url: string; expires_at: string }
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const statuses = new Set(["pending", "applying", "resolved", "cancelled", "expired", "failed"]);
const record = (v: unknown): Record<string, unknown> | null => v && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : null;
const text = (v: unknown, max: number): v is string => typeof v === "string" && v.length <= max;
const date = (v: unknown): v is string => text(v, 64) && Number.isFinite(Date.parse(v));

/** Rebuild known fields, reject newer or malformed cards and never forward arbitrary properties. */
export function parseCredentialRequest(raw: unknown, expectedAgent: string): CredentialRequestView | null {
const v = record(raw); const target = record(v?.target);
if (!v || !target || !text(v.id, 36) || !uuid.test(v.id) || v.schema_version !== "webchat-credential-request.v1" ||
typeof v.status !== "string" || !statuses.has(v.status) || !text(v.title, 300) || !text(v.purpose, 4000) ||
!text(target.type, 100) || !text(target.mode, 100) || !Array.isArray(v.fields) || v.fields.length > 32 ||
!date(v.expires_at) || v.agent_key !== expectedAgent || v.completion !== "hosted" || typeof v.resumable !== "boolean") return null;
const fields: CredentialRequestView["fields"] = [];
for (const item of v.fields) {
const f = record(item);
if (!f || !text(f.key, 100) || !/^[A-Za-z0-9_.-]+$/.test(f.key) || !text(f.label, 300) || !text(f.kind, 100) || typeof f.required !== "boolean") return null;
fields.push({ key: f.key, label: f.label, kind: f.kind, required: f.required });
}
return { id: v.id, schema_version: "webchat-credential-request.v1", status: v.status as CredentialRequestView["status"], title: v.title, purpose: v.purpose,
target: { type: target.type, mode: target.mode }, fields, expires_at: v.expires_at, agent_key: expectedAgent, completion: "hosted", resumable: v.resumable };
}

/** The one-time link is accepted only at a host configured by the integrator. */
export function parseCredentialHandoff(raw: unknown, expectedId: string, trustedOrigin: string): CredentialHandoff | null {
const v = record(raw);
if (!v || v.credential_request_id !== expectedId || !text(v.url, 4096) || !date(v.expires_at) || Date.parse(v.expires_at) <= Date.now()) return null;
try {
const url = new URL(v.url);
if (url.protocol !== "https:" || url.origin !== trustedOrigin || url.username || url.password || url.hash || url.pathname !== "/credential-requests/open" || !/^\?token=[A-Za-z0-9_-]{43}$/.test(url.search)) return null;
return { credential_request_id: expectedId, url: url.href, expires_at: v.expires_at };
} catch { return null; }
}
4 changes: 4 additions & 0 deletions packages/dewee-webchat/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type ChatStreamEvent =
| { type: "delta"; text: string }
| { type: "tool_call"; id: string; name: string; arguments?: unknown }
| { type: "tool_result"; id: string; name: string; isError: boolean; result?: string }
| { type: "credential_request"; credentialRequest: import("./credential-request").CredentialRequestView }
| { type: "done"; content: string; usage?: Record<string, number> }
| { type: "error"; message: string; code?: string; remediation?: Remediation; retryAfterMs?: number };

Expand Down Expand Up @@ -75,6 +76,8 @@ export interface PublicConfig {
pageContextVersions?: number[];
/** Present (true) when the backend signs users in through Dewee: the widget then asks `/session` on every page load. */
identity?: boolean;
/** Trusted origin of the hosted credential form, present only when explicitly enabled. */
credentialFormOrigin?: string;
/**
* Present (true) when a returning visitor's `/session` refresh (a valid anonymous `token` in the
* body) needs no Turnstile token (ccp transport); only new visitors are challenged then. With
Expand Down Expand Up @@ -181,6 +184,7 @@ export interface ChatRequestBody {
pageContext?: unknown;
/** Ephemeral, untrusted content for this turn; distinct from the legacy page-action context. */
page_context?: import("./widget/page-context").PageContextAttachment;
credentialRequestId?: string;
}

export const HEADER_GATEWAY_URL = "x-dewee-gateway-url";
Expand Down
4 changes: 3 additions & 1 deletion packages/dewee-webchat/src/server/ccp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// another origin. Nothing a visitor sends ever becomes part of a URL.

/** Routes of the public ingress this client calls. Fixed names: callers cannot inject path segments. */
export type CcpRoute = "config" | "session" | "chat" | "history" | "abort" | "identity/exchange" | "identity/revoke" | "link" | "job-access";
export type CcpRoute = "config" | "session" | "chat" | "history" | "abort" | "identity/exchange" | "identity/revoke" | "link" | "job-access" | "credential-request" | "credential-request-handoff" | "credential-request-cancel";

/** Validated CCP endpoint options (see {@link parseCcpOptions}). */
export interface CcpOptions {
Expand Down Expand Up @@ -133,6 +133,7 @@ export interface CcpRequest {
idempotencyKey?: string;
accept?: string;
signal?: AbortSignal;
credentialRequests?: boolean;
}

/** Thin client bound to one validated ingress base. */
Expand Down Expand Up @@ -161,6 +162,7 @@ export class CcpClient {
if (req.visitorIp && VISITOR_IP_RE.test(req.visitorIp)) headers["x-dewee-visitor-ip"] = req.visitorIp;
if (req.requestId) headers["x-request-id"] = req.requestId;
if (req.idempotencyKey) headers["idempotency-key"] = req.idempotencyKey;
if (req.credentialRequests) headers["x-dewee-webchat-features"] = "credential-requests";
let res: Response;
try {
// "manual" instead of "error": Cloudflare Workers reject redirect "error"; a 3xx (or an opaque
Expand Down
38 changes: 30 additions & 8 deletions packages/dewee-webchat/src/server/ccp-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// shown, quotas, and every authorization of the visitor. Nothing CCP says is passed to a browser
// verbatim: events and errors are rebuilt from known fields with this module's own messages.
import { stripPageBlock } from "../actions/page-block";
import { parseCredentialRequest, type CredentialRequestView } from "../credential-request";
import { parseJobAccessLaunch, type JobAccessLaunch } from "../job-access";
import { isAdmissionErrorCode, isIdentityErrorCode, type AdmissionErrorCode, type AgentInfo, type ChatStreamEvent, type HistoryMessage, type IdentityErrorCode, type LinkResponse, type Remediation, type ToolCallView } from "../protocol";
import { CcpClient, CcpError, knownRemediation, readSseJson, type CcpRequest, type CcpRoute } from "./ccp-client";
Expand Down Expand Up @@ -113,6 +114,13 @@ export function browserErrorFor(e: unknown, route: CcpRoute): BrowserError {
if (e.code === "rate_limited" || e.code === "concurrency_limited" || e.status === 429) return { status: 429, code: "RATE_LIMITED", message: "", admission: "RATE_LIMITED", retryAfterMs };
}
switch (e.code) {
case "credential_requests_unavailable":
return { status: 403, code: e.code, message: "Credential requests are unavailable" };
case "credential_request_not_found":
return { status: 404, code: e.code, message: "Credential request not found" };
case "credential_request_not_ready":
case "credential_request_resumed":
return { status: 409, code: e.code, message: "Credential request cannot be resumed" };
case "page_context_invalid":
return { status: 400, code: e.code, message: "Invalid page context" };
case "page_context_unsupported":
Expand Down Expand Up @@ -231,7 +239,7 @@ export interface CcpExchangeResult {

export type CcpTransport = ReturnType<typeof createCcpTransport>;

export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps) {
export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps, credentialRequests = false) {
let config: { value: CcpConfig; at: number } | undefined;
let pendingConfig: Promise<CcpConfig> | undefined;
const edges = new Map<string, EdgeSession>();
Expand Down Expand Up @@ -330,24 +338,25 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps
ctx: VisitorContext,
visitorId: string,
sealed: EdgeSession | undefined,
input: { message: string; conversationId: string; agent?: string; identitySession?: string; pageContext?: unknown; signal: AbortSignal },
input: { message: string; conversationId: string; agent?: string; identitySession?: string; pageContext?: unknown; credentialRequestId?: string; signal: AbortSignal },
): Promise<Response> {
const body = {
message: input.message,
conversation_id: input.conversationId,
...withAgent(input.agent),
...(input.identitySession ? { identity_session: input.identitySession } : {}),
...(input.pageContext !== undefined ? { page_context: input.pageContext } : {}),
...(input.credentialRequestId ? { credential_request_id: input.credentialRequestId } : {}),
};
return withEdge(ctx, visitorId, sealed, (edge) => client.send("chat", { ...base(ctx), body, session: edge.token, accept: "text/event-stream", signal: input.signal }));
return withEdge(ctx, visitorId, sealed, (edge) => client.send("chat", { ...base(ctx), body, session: edge.token, accept: "text/event-stream", signal: input.signal, credentialRequests: credentialRequests && !!input.identitySession }));
},

/**
* Browser events of an accepted turn: exactly one `start` first and exactly one terminal event
* (`done` or `error`) last. Reasoning and tool frames pass only when the integration shows them,
* tool arguments and results never; unknown frames are dropped.
*/
async *chatEvents(res: Response, cfg: CcpConfig, conversationId: string, requestedAgent: string): AsyncGenerator<ChatStreamEvent> {
async *chatEvents(res: Response, cfg: CcpConfig, conversationId: string, requestedAgent: string, verified = false): AsyncGenerator<ChatStreamEvent> {
let started = false;
const start = (f?: any): ChatStreamEvent => {
started = true;
Expand Down Expand Up @@ -380,6 +389,11 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps
case "tool_result":
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 };
break;
case "credential_request": {
const view = credentialRequests && verified ? parseCredentialRequest(f.credential_request, requestedAgent) : null;
if (view) yield { type: "credential_request", credentialRequest: view };
break;
}
case "done": {
const usage = numericUsage(f.usage);
yield { type: "done", content: typeof f.content === "string" ? f.content : "", ...(usage ? { usage } : {}) };
Expand Down Expand Up @@ -408,7 +422,7 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps
},

/** The visitor's own conversation, folded for the widget. An unknown or foreign conversation is empty. */
async history(ctx: VisitorContext, visitorId: string, sealed: EdgeSession | undefined, input: { conversationId: string; agent?: string; identitySession?: string }): Promise<HistoryMessage[]> {
async history(ctx: VisitorContext, visitorId: string, sealed: EdgeSession | undefined, input: { conversationId: string; agent?: string; identitySession?: string }): Promise<{ messages: HistoryMessage[]; credentialRequests?: CredentialRequestView[] }> {
const cfg = await getConfig();
const body = {
conversation_id: input.conversationId,
Expand All @@ -418,9 +432,9 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps
};
let data: Record<string, any>;
try {
data = await withEdge(ctx, visitorId, sealed, (edge) => client.json("history", { ...base(ctx), body, session: edge.token }));
data = await withEdge(ctx, visitorId, sealed, (edge) => client.json("history", { ...base(ctx), body, session: edge.token, credentialRequests: credentialRequests && !!input.identitySession }));
} catch (e) {
if (e instanceof CcpError && e.code === "not_found") return [];
if (e instanceof CcpError && e.code === "not_found") return { messages: [] };
throw e;
}
const out: HistoryMessage[] = [];
Expand All @@ -442,7 +456,15 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps
if (!msg.content && !msg.thinking && !msg.toolCalls) continue;
out.push(msg);
}
return out;
const cards = credentialRequests && input.identitySession && Array.isArray(data.credential_requests)
? data.credential_requests.slice(0, 100).map((view: unknown) => parseCredentialRequest(view, input.agent || cfg.defaultAgent)).filter((view: CredentialRequestView | null): view is CredentialRequestView => view !== null)
: [];
return { messages: out, ...(cards.length ? { credentialRequests: cards } : {}) };
},

async credentialRequest(ctx: VisitorContext, visitorId: string, sealed: EdgeSession | undefined, route: "credential-request" | "credential-request-handoff" | "credential-request-cancel", input: { conversationId: string; requestId: string; agent: string; identitySession: string }): Promise<unknown> {
return withEdge(ctx, visitorId, sealed, (edge) => client.json(route, { ...base(ctx), session: edge.token, credentialRequests: true,
body: { conversation_id: input.conversationId, credential_request_id: input.requestId, identity_session: input.identitySession, agent: input.agent } }));
},

/**
Expand Down
Loading
Loading