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
10 changes: 10 additions & 0 deletions docs/http-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,16 @@ must use the configured HTTPS origin and the Dewee hosted-form route. It contain
do not log, cache, publish, or add it to chat messages. The hosted form independently requires a
workspace approval administrator.

When the Gateway and CCP advertise `member_link_proof`, the widget exposes **Link workspace
account**. The member starts a link under My linked accounts in their workspace, enters its
one-time code in the widget's separate form, then returns to the workspace to confirm. The
backend's `POST {base}/member-link-proof` accepts only `{code}`, requires the verified visitor
and a fresh authenticated host user, and forwards the code through CCP without adding it to chat.
The response is `{state: "proved"}`; it does not complete the link. The Gateway flag
`GOCLAW_WEBCHAT_MEMBER_LINK_PROOF_ENABLED` defaults off. Chat requests containing a proof code in
the message or attached content are refused so the code is not ingested as conversation content.
The member link and current workspace membership are checked again for every credential action.

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
Expand Down
2 changes: 2 additions & 0 deletions packages/dewee-webchat/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,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;
/** The signed-in visitor may submit a workspace member linking code. */
memberLinkProof?: boolean;
/** Trusted origin of the hosted credential form, present only when explicitly enabled. */
credentialFormOrigin?: string;
/**
Expand Down
2 changes: 1 addition & 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" | "credential-request" | "credential-request-handoff" | "credential-request-cancel";
export type CcpRoute = "config" | "session" | "chat" | "history" | "abort" | "identity/exchange" | "identity/revoke" | "identity/member-link-proof" | "link" | "job-access" | "credential-request" | "credential-request-handoff" | "credential-request-cancel";

/** Validated CCP endpoint options (see {@link parseCcpOptions}). */
export interface CcpOptions {
Expand Down
9 changes: 9 additions & 0 deletions packages/dewee-webchat/src/server/ccp-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface CcpConfig {
allowAgentSwitch: boolean;
mode: "anonymous" | "signed_in_optional" | "signed_in_required";
identityEnabled: boolean;
memberLinkProof: boolean;
showThinking: boolean;
showToolActivity: boolean;
maxMessageChars: number;
Expand Down Expand Up @@ -210,6 +211,7 @@ function parseConfig(data: Record<string, any>): CcpConfig {
allowAgentSwitch: typeof data.allow_agent_switch === "boolean" ? data.allow_agent_switch && agents.length > 1 : agents.length > 1,
mode,
identityEnabled: data.identity_enabled === true,
memberLinkProof: data.member_link_proof === true,
// Reasoning and tool activity stay hidden unless the integration turns them on explicitly.
showThinking: data.show_thinking === true,
showToolActivity: data.show_tool_activity === true,
Expand Down Expand Up @@ -504,6 +506,13 @@ export function createCcpTransport(client: CcpClient, identity?: CcpIdentityDeps
} catch { throw new CcpError(502, "ccp_bad_response", "Job access is unavailable"); }
},

async memberLinkProof(ctx: VisitorContext, visitorId: string, sealed: EdgeSession | undefined, input: { identitySession: string; agent: string; code: string }): Promise<void> {
const data = await withEdge(ctx, visitorId, sealed, (edge) => client.json<Record<string, unknown>>("identity/member-link-proof", {
...base(ctx), session: edge.token, body: { identity_session: input.identitySession, agent: input.agent, code: input.code },
}));
if (data.state !== "proved") throw new CcpError(502, "ccp_bad_response", "Member verification unavailable");
},

/** Web-first link for a signed-in visitor. */
async link(ctx: VisitorContext, visitorId: string, sealed: EdgeSession | undefined, input: { identitySession: string; agent?: string; idempotencyKey?: string }): Promise<LinkResponse> {
const body = { identity_session: input.identitySession, ...withAgent(input.agent) };
Expand Down
23 changes: 23 additions & 0 deletions packages/dewee-webchat/src/server/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler {
maxMessageLength: Math.min(o.maxMessageLength, cfg.maxMessageChars),
...(cfg.pageContextVersions.length ? { pageContextVersions: cfg.pageContextVersions } : {}),
...(credentialFormOrigin ? { credentialFormOrigin } : {}),
...(credentialFormOrigin && cfg.memberLinkProof ? { memberLinkProof: true } : {}),
version: VERSION,
};
if (idn) {
Expand Down Expand Up @@ -1017,6 +1018,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler {
if (!input.body || typeof input.body !== "object" || Array.isArray(input.body)) return fail(req, 400, "Invalid request", "bad_request");
const body: any = input.body;
const message = typeof body?.message === "string" ? body.message.trim() : "";
if (/MEM-[A-Z2-7]{16}/i.test(JSON.stringify(body))) return fail(req, 400, "Use the member linking form for your verification code", "member_proof_in_message");
const resumeId = body?.credentialRequestId;
if (resumeId !== undefined) {
if (!ccp || !credentialFormOrigin || !claims.ids || typeof resumeId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(resumeId) || (body.message !== undefined && typeof body.message !== "string") || message || body?.actions != null || body?.pageContext != null || body?.page_context != null || typeof body?.conversationId !== "string" || !CCP_ID_RE.test(body.conversationId)) return fail(req, 400, "Invalid request", "bad_request");
Expand Down Expand Up @@ -1398,6 +1400,26 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler {
}
}

async function handleMemberLinkProof(req: Request): Promise<Response> {
if (!ccp || !credentialFormOrigin || !idn) return fail(req, 404, "Not found", "not_found");
const origin = req.headers.get("origin");
try { const u = new URL(origin ?? ""); if (u.protocol !== "https:" || u.origin !== origin) throw new Error(); }
catch { return fail(req, 403, "Origin not allowed", "forbidden_origin"); }
const claims = await visitorFrom(req);
if (!claims?.ids || !claims.ida) return identityFail(req, "IDENTITY_REQUIRED");
const refused = await rebindRefusal(req, claims, true);
if (refused) return refused;
if (limiter && !(await limiter(`member-proof:${claims.v}`, "link"))) return fail(req, 429, "Too many requests", "rate_limited");
const raw = await readImageTicketBody(req);
let body: unknown;
try { body = raw && JSON.parse(raw); } catch { return fail(req, 400, "Invalid request", "bad_request"); }
if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some((key) => key !== "code") || typeof (body as {code?: unknown}).code !== "string" || !/^MEM-[A-Z2-7]{16}$/.test((body as {code: string}).code)) return fail(req, 400, "Invalid request", "bad_request");
try {
await ccp.memberLinkProof(visitorContext(req), claims.v, sealedEdge(claims), { identitySession: claims.ids, agent: claims.ida, code: (body as {code: string}).code });
return json(req, { state: "proved" });
} catch (error) { return ccpFail(req, error, "identity/member-link-proof", claims.ids); }
}

/** Logout hook (see {@link DeweeHandler.revoke}). */
async function revoke(subject: string, request?: Request, token?: string): Promise<void> {
if (!idn || typeof subject !== "string" || subject === "") return;
Expand Down Expand Up @@ -1487,6 +1509,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler {
if (req.method === "POST" && action === "chat") return await handleChat(req);
if (req.method === "GET" && action === "history") return await handleHistory(req);
if (req.method === "POST" && action === "link" && idn) return await handleLink(req);
if (req.method === "POST" && action === "member-link-proof") return await handleMemberLinkProof(req);
if (req.method === "POST" && action === "job-access" && idn) return await handleJobAccess(req);
if (req.method === "POST" && (action === "credential-request" || action === "credential-request-handoff" || action === "credential-request-cancel")) return await handleCredentialRequest(req, action);
return fail(req, 404, "Not found", "not_found");
Expand Down
10 changes: 10 additions & 0 deletions packages/dewee-webchat/src/widget/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,16 @@ export class ApiClient {
return this.parse<LinkResponse>(res);
}

async memberLinkProof(code: string): Promise<void> {
const session = await this.ensureSession();
if (!session.verified || !(await this.getConfig()).memberLinkProof) throw new ApiError("Member verification unavailable", 403, "member_link_unavailable");
const headers = await this.headers(true);
if (this.session !== session) throw new Error("identity changed");
const response = await this.request(this.url("member-link-proof"), { method: "POST", headers, body: JSON.stringify({ code }), redirect: "error" });
const result = await this.parse<{ state?: string }>(response);
if (this.session !== session || result.state !== "proved") throw new Error("Member verification unavailable");
}

async history(agent: string, conversationId: string): Promise<HistoryMessage[]> {
return (await this.historyDetails(agent, conversationId)).messages;
}
Expand Down
61 changes: 59 additions & 2 deletions packages/dewee-webchat/src/widget/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export class DeweeChatElement extends ElementBase {
private els!: {
launcher: HTMLButtonElement; panel: HTMLElement; log: HTMLElement; title: HTMLElement; subtitle: HTMLElement;
status: HTMLElement; avatar: HTMLElement; select: HTMLSelectElement; input: HTMLTextAreaElement; send: HTMLButtonElement;
newChat: HTMLButtonElement; jobAccess: HTMLButtonElement; close: HTMLButtonElement; foot: HTMLElement; cooldown: HTMLElement; announcer: HTMLElement;
newChat: HTMLButtonElement; jobAccess: HTMLButtonElement; memberLink: HTMLButtonElement; close: HTMLButtonElement; foot: HTMLElement; cooldown: HTMLElement; announcer: HTMLElement;
pageChoice: HTMLInputElement; pageWrap: HTMLElement; pageLabel: HTMLElement; pagePreview: HTMLElement;
};

Expand Down Expand Up @@ -341,6 +341,9 @@ export class DeweeChatElement extends ElementBase {
async send(text: string): Promise<void> {
const message = text.trim();
if (!message || this.running || this.preparingPage || this.cooldown.active) return;
if (/MEM-[A-Z2-7]{16}/i.test(message)) {
this.els.input.value = ""; this.autosize(); this.showNotice(this.strings.memberLinkPrivate); return;
}
if (this.config.mode !== "inline" && !this.hasAttribute("data-open")) this.open();
await this.activate();
let attachment: PageContextAttachment | undefined;
Expand Down Expand Up @@ -377,6 +380,9 @@ export class DeweeChatElement extends ElementBase {
// at most `maxActionRounds` times per visitor message.
let next: { message: string; hidden: boolean } | null = { message, hidden: false };
for (let round = 0; next; round++) {
if (/MEM-[A-Z2-7]{16}/i.test(next.message) || (attachment && /MEM-[A-Z2-7]{16}/i.test(JSON.stringify(attachment)))) {
this.showNotice(this.strings.memberLinkPrivate); break;
}
const reply = await this.runTurn(next.message, next.hidden, round === 0 ? attachment : undefined);
next = null;
if (reply.status !== "done" || reply.stopped || !this.actionRegistry().size) break;
Expand Down Expand Up @@ -584,6 +590,52 @@ export class DeweeChatElement extends ElementBase {
}
}

private async openMemberLink(): Promise<void> {
if (this.root.querySelector(".member-link-dialog")) return;
const s = this.strings;
try {
if (!(await this.client().ensureSession()).verified) { this.showNotice(s.memberLinkSignIn); return; }
} catch { this.showNotice(s.memberLinkError); return; }
if (this.root.querySelector(".member-link-dialog")) return;
const backdrop = document.createElement("div");
backdrop.className = "confirm-backdrop member-link-dialog";
backdrop.innerHTML = `<form class="confirm" role="dialog" aria-modal="true" aria-labelledby="dw-member-link-title">
<h3 id="dw-member-link-title"></h3><p class="member-link-instructions"></p>
<label><span class="member-link-label"></span><input class="member-link-code" type="text" autocomplete="off" spellcheck="false" maxlength="20" required></label>
<p class="member-link-error" role="alert" hidden></p><div class="confirm-buttons"><button type="button" class="btn deny"></button><button type="submit" class="btn allow"></button></div></form>`;
const form = backdrop.querySelector("form")!;
const input = backdrop.querySelector<HTMLInputElement>("input")!;
const error = backdrop.querySelector<HTMLElement>(".member-link-error")!;
const cancel = backdrop.querySelector<HTMLButtonElement>(".deny")!;
const submit = backdrop.querySelector<HTMLButtonElement>(".allow")!;
backdrop.querySelector("h3")!.textContent = s.memberLink;
backdrop.querySelector(".member-link-instructions")!.textContent = s.memberLinkInstructions;
backdrop.querySelector(".member-link-label")!.textContent = s.memberLinkCode;
cancel.textContent = s.memberLinkCancel;
submit.textContent = s.memberLinkSubmit;
const close = () => { input.value = ""; backdrop.remove(); this.els.memberLink.focus(); };
cancel.addEventListener("click", close);
backdrop.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !submit.disabled) { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const targets = [input, cancel, submit].filter((el) => !el.disabled);
const current = targets.indexOf(this.root.activeElement as HTMLInputElement & HTMLButtonElement);
event.preventDefault(); targets[(current + (event.shiftKey ? targets.length - 1 : 1)) % targets.length].focus();
}
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const code = input.value.trim().toUpperCase();
if (!/^MEM-[A-Z2-7]{16}$/.test(code)) { error.textContent = s.memberLinkError; error.hidden = false; return; }
input.value = ""; submit.disabled = true; cancel.disabled = true;
void this.client().memberLinkProof(code).then(() => { close(); this.showNotice(s.memberLinkSuccess); }).catch(() => {
error.textContent = s.memberLinkError; error.hidden = false; submit.disabled = false; cancel.disabled = false; input.focus();
});
});
this.els.panel.appendChild(backdrop);
input.focus();
}

/** Opens the hosted permission review; the verified session stays on the backend. */
async openJobAccess(): Promise<void> {
if (this.jobAccessLaunch) return;
Expand Down Expand Up @@ -762,6 +814,8 @@ export class DeweeChatElement extends ElementBase {
e.newChat.title = s.newChat;
e.jobAccess.setAttribute("aria-label", s.jobAccess);
e.jobAccess.title = s.jobAccess;
e.memberLink.setAttribute("aria-label", s.memberLink);
e.memberLink.title = s.memberLink;
e.input.placeholder = c.placeholder || s.placeholder;
e.pageLabel.textContent = s.pageShare;
this.updatePageChoice();
Expand Down Expand Up @@ -800,6 +854,7 @@ export class DeweeChatElement extends ElementBase {
</div>
<button class="icon-btn new">${icons.newChat}</button>
<button class="icon-btn job-access" hidden>${icons.jobAccess}</button>
<button class="icon-btn member-link" hidden>${icons.jobAccess}</button>
<button class="icon-btn close">${icons.close}</button>
</header>
<div class="log" part="log" role="log" aria-live="polite"></div>
Expand All @@ -819,7 +874,7 @@ export class DeweeChatElement extends ElementBase {
this.els = {
launcher: $(".launcher"), panel: $(".panel"), log: $(".log"), title: $(".title"), subtitle: $(".subtitle"),
status: $(".status"), avatar: $(".avatar"), select: $(".agent-select"), input: $("textarea"), send: $(".send"),
newChat: $(".new"), jobAccess: $(".job-access"), close: $(".close"), foot: $(".foot"), cooldown: $(".cooldown"), announcer: $(".announcer"),
newChat: $(".new"), jobAccess: $(".job-access"), memberLink: $(".member-link"), close: $(".close"), foot: $(".foot"), cooldown: $(".cooldown"), announcer: $(".announcer"),
pageChoice: $(".page-choice"), pageWrap: $(".page-context"), pageLabel: $(".page-label"), pagePreview: $(".page-preview"),
};
const e = this.els;
Expand All @@ -829,6 +884,7 @@ export class DeweeChatElement extends ElementBase {
e.log.addEventListener("scroll", () => { this.following = this.nearBottom(); }, { passive: true });
e.newChat.addEventListener("click", () => this.newConversation());
e.jobAccess.addEventListener("click", () => { void this.openJobAccess(); });
e.memberLink.addEventListener("click", () => { void this.openMemberLink(); });
e.pageChoice.addEventListener("change", () => { void this.choosePage(); });
e.select.addEventListener("change", () => this.switchAgent(e.select.value));
$<HTMLFormElement>("form").addEventListener("submit", (ev) => {
Expand Down Expand Up @@ -950,6 +1006,7 @@ export class DeweeChatElement extends ElementBase {

private renderSubtitle() {
this.els.jobAccess.hidden = this.server?.identity !== true;
this.els.memberLink.hidden = this.server?.identity !== true || this.server?.memberLinkProof !== true;
const e = this.els;
const c = this.config;
const agents = this.server?.agents ?? [];
Expand Down
Loading
Loading