diff --git a/docs/http-contract.md b/docs/http-contract.md index 6b9b9f5..69c847d 100644 --- a/docs/http-contract.md +++ b/docs/http-contract.md @@ -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 diff --git a/packages/dewee-webchat/src/protocol.ts b/packages/dewee-webchat/src/protocol.ts index 3a9ebc7..be5b1d5 100644 --- a/packages/dewee-webchat/src/protocol.ts +++ b/packages/dewee-webchat/src/protocol.ts @@ -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; /** diff --git a/packages/dewee-webchat/src/server/ccp-client.ts b/packages/dewee-webchat/src/server/ccp-client.ts index efff283..0c7dead 100644 --- a/packages/dewee-webchat/src/server/ccp-client.ts +++ b/packages/dewee-webchat/src/server/ccp-client.ts @@ -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 { diff --git a/packages/dewee-webchat/src/server/ccp-transport.ts b/packages/dewee-webchat/src/server/ccp-transport.ts index 1e695ec..10ab6f0 100644 --- a/packages/dewee-webchat/src/server/ccp-transport.ts +++ b/packages/dewee-webchat/src/server/ccp-transport.ts @@ -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; @@ -210,6 +211,7 @@ function parseConfig(data: Record): 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, @@ -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 { + const data = await withEdge(ctx, visitorId, sealed, (edge) => client.json>("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 { const body = { identity_session: input.identitySession, ...withAgent(input.agent) }; diff --git a/packages/dewee-webchat/src/server/handler.ts b/packages/dewee-webchat/src/server/handler.ts index 9f97596..b2d5f8c 100644 --- a/packages/dewee-webchat/src/server/handler.ts +++ b/packages/dewee-webchat/src/server/handler.ts @@ -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) { @@ -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"); @@ -1398,6 +1400,26 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { } } + async function handleMemberLinkProof(req: Request): Promise { + 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 { if (!idn || typeof subject !== "string" || subject === "") return; @@ -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"); diff --git a/packages/dewee-webchat/src/widget/api-client.ts b/packages/dewee-webchat/src/widget/api-client.ts index 6f501d9..55fb7ae 100644 --- a/packages/dewee-webchat/src/widget/api-client.ts +++ b/packages/dewee-webchat/src/widget/api-client.ts @@ -345,6 +345,16 @@ export class ApiClient { return this.parse(res); } + async memberLinkProof(code: string): Promise { + 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 { return (await this.historyDetails(agent, conversationId)).messages; } diff --git a/packages/dewee-webchat/src/widget/element.ts b/packages/dewee-webchat/src/widget/element.ts index c2a3d73..8f43b72 100644 --- a/packages/dewee-webchat/src/widget/element.ts +++ b/packages/dewee-webchat/src/widget/element.ts @@ -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; }; @@ -341,6 +341,9 @@ export class DeweeChatElement extends ElementBase { async send(text: string): Promise { 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; @@ -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; @@ -584,6 +590,52 @@ export class DeweeChatElement extends ElementBase { } } + private async openMemberLink(): Promise { + 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 = ``; + const form = backdrop.querySelector("form")!; + const input = backdrop.querySelector("input")!; + const error = backdrop.querySelector(".member-link-error")!; + const cancel = backdrop.querySelector(".deny")!; + const submit = backdrop.querySelector(".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 { if (this.jobAccessLaunch) return; @@ -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(); @@ -800,6 +854,7 @@ export class DeweeChatElement extends ElementBase { +
@@ -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; @@ -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)); $("form").addEventListener("submit", (ev) => { @@ -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 ?? []; diff --git a/packages/dewee-webchat/src/widget/i18n.ts b/packages/dewee-webchat/src/widget/i18n.ts index 07a54f0..226a3c5 100644 --- a/packages/dewee-webchat/src/widget/i18n.ts +++ b/packages/dewee-webchat/src/widget/i18n.ts @@ -48,6 +48,15 @@ export interface Strings { jobAccessUnavailable: string; jobAccessPopupBlocked: string; jobAccessPopupClosed: string; + memberLink: string; + memberLinkInstructions: string; + memberLinkCode: string; + memberLinkSubmit: string; + memberLinkCancel: string; + memberLinkSuccess: string; + memberLinkError: string; + memberLinkSignIn: string; + memberLinkPrivate: string; pageShare: string; pageChanged: string; pageUnavailable: string; @@ -109,6 +118,15 @@ const en: Strings = { linkExpires: (m) => `The code expires in ${m} min.`, linkNoTargets: "No chat app is set up for linking yet.", jobAccess: "Job access", + memberLink: "Link workspace account", + memberLinkInstructions: "In your workspace, open My linked accounts and start linking. Enter the code here, then return there to confirm.", + memberLinkCode: "Verification code", + memberLinkSubmit: "Verify code", + memberLinkCancel: "Cancel", + memberLinkSuccess: "Code verified. Return to My linked accounts in your workspace to confirm.", + memberLinkError: "The code could not be verified. Check it or start again in your workspace.", + memberLinkSignIn: "Sign in to this website, then choose Link workspace account again.", + memberLinkPrivate: "Enter your verification code only in the Link workspace account form, not in chat or shared page content.", jobAccessOpened: "Job access opened in a new window. Review and confirm any permissions there.", jobAccessUnavailable: "Job access could not be opened. Sign in again or retry.", jobAccessPopupBlocked: "Allow popups for this site, then choose Job access again.", @@ -174,6 +192,15 @@ const vi: Strings = { linkExpires: (m) => `Mã hết hạn sau ${m} phút.`, linkNoTargets: "Chưa có ứng dụng chat nào được thiết lập để liên kết.", jobAccess: "Quyền công việc", + memberLink: "Liên kết tài khoản workspace", + memberLinkInstructions: "Trong workspace, mở Tài khoản đã liên kết và bắt đầu liên kết. Nhập mã tại đây, sau đó quay lại để xác nhận.", + memberLinkCode: "Mã xác minh", + memberLinkSubmit: "Xác minh mã", + memberLinkCancel: "Hủy", + memberLinkSuccess: "Đã xác minh mã. Hãy quay lại Tài khoản đã liên kết trong workspace để xác nhận.", + memberLinkError: "Không xác minh được mã. Hãy kiểm tra hoặc bắt đầu lại trong workspace.", + memberLinkSignIn: "Hãy đăng nhập vào website, rồi chọn Liên kết tài khoản workspace lần nữa.", + memberLinkPrivate: "Chỉ nhập mã xác minh vào biểu mẫu Liên kết tài khoản workspace, không đưa vào chat hoặc nội dung trang chia sẻ.", jobAccessOpened: "Đã mở cửa sổ quyền công việc. Hãy xem lại và xác nhận quyền tại đó.", jobAccessUnavailable: "Chưa mở được quyền công việc. Hãy đăng nhập lại hoặc thử lại.", jobAccessPopupBlocked: "Cho phép cửa sổ bật lên cho trang này rồi chọn Quyền công việc lần nữa.", diff --git a/packages/dewee-webchat/test/ccp-client.test.ts b/packages/dewee-webchat/test/ccp-client.test.ts index cb1597d..71d457f 100644 --- a/packages/dewee-webchat/test/ccp-client.test.ts +++ b/packages/dewee-webchat/test/ccp-client.test.ts @@ -116,7 +116,7 @@ describe("SSE framing", () => { it("ends a turn whose terminal frame came without a blank line", async () => { const transport = createCcpTransport(client(hangingFetch() as unknown as typeof fetch)); - const cfg: CcpConfig = { agents: [{ id: "shop" }], defaultAgent: "shop", allowAgentSwitch: false, mode: "anonymous", identityEnabled: false, showThinking: false, showToolActivity: false, maxMessageChars: 100, pageContextVersions: [] }; + const cfg: CcpConfig = { agents: [{ id: "shop" }], defaultAgent: "shop", allowAgentSwitch: false, mode: "anonymous", identityEnabled: false, memberLinkProof: false, showThinking: false, showToolActivity: false, maxMessageChars: 100, pageContextVersions: [] }; const res = new Response(stream(['data: {"type":"start","runId":"r1"}\r\n\r', '\ndata: {"type":"delta","text":"hi"}\r\n\r\ndata: {"type":"done","content":"hi"}'])); const events: ChatStreamEvent[] = []; for await (const e of transport.chatEvents(res, cfg, "conv_sse_0001", "shop")) events.push(e); @@ -135,7 +135,7 @@ describe("remediation from CCP", () => { it("drops an unknown remediation from a stream error", async () => { const transport = createCcpTransport(client(hangingFetch() as unknown as typeof fetch)); - const cfg: CcpConfig = { agents: [{ id: "shop" }], defaultAgent: "shop", allowAgentSwitch: false, mode: "anonymous", identityEnabled: false, showThinking: false, showToolActivity: false, maxMessageChars: 100, pageContextVersions: [] }; + const cfg: CcpConfig = { agents: [{ id: "shop" }], defaultAgent: "shop", allowAgentSwitch: false, mode: "anonymous", identityEnabled: false, memberLinkProof: false, showThinking: false, showToolActivity: false, maxMessageChars: 100, pageContextVersions: [] }; const run = async (remediation: unknown) => { const res = new Response(stream([`data: ${JSON.stringify({ type: "error", code: "LINK_REVOKED", remediation })}\n\n`])); const out: ChatStreamEvent[] = []; diff --git a/packages/dewee-webchat/test/ccp-credential-request.test.ts b/packages/dewee-webchat/test/ccp-credential-request.test.ts index 2962b3f..43eed5f 100644 --- a/packages/dewee-webchat/test/ccp-credential-request.test.ts +++ b/packages/dewee-webchat/test/ccp-credential-request.test.ts @@ -38,6 +38,21 @@ function request(token:string, route:string, extraHeaders:Record= } describe("CCP credential handoff", () => { + it("submits member proof only for the current signed-in user without sending it to chat", async () => { + ccp.state.config = {...ccp.state.config, member_link_proof: true}; + const h = handler(); const token = await session(h); const code = "MEM-ABCDEFGHIJKLMNOP"; + const config = await (await h(new Request(`${base}/config`))).json(); + expect(config.memberLinkProof).toBe(true); + const ok = await h(request(token, "member-link-proof", {}, { code })); + expect(ok.status).toBe(200); expect(await ok.json()).toEqual({state:"proved"}); + expect(ccp.routes("identity/member-link-proof").at(-1)?.body).toMatchObject({agent:"shop", code}); + expect((await h(request(token, "member-link-proof", {cookie:"uid=bob"}, {code}))).status).toBeGreaterThanOrEqual(400); + expect((await h(request(token, "member-link-proof", {}, {code:"bad"}))).status).toBe(400); + const blocked = await h(request(token, "chat", {}, {agent:"shop",conversationId:"conversation_01",message:`my code ${code}`})); + expect(blocked.status).toBe(400); expect(await blocked.text()).not.toContain(code); expect(ccp.routes("chat")).toHaveLength(0); + const page = await h(request(token, "chat", {}, {agent:"shop",conversationId:"conversation_01",message:"help",page_context:{version:1,url:"https://shop.example",content:{format:"text",text:code}}})); + expect(page.status).toBe(400); expect(ccp.routes("chat")).toHaveLength(0); + }); it("requires an ASCII HTTPS origin, allowing explicit punycode", () => { expect(()=>handler(true,undefined,"https://bücher.example")).toThrow(/ASCII HTTPS origin/); expect(()=>handler(true,undefined,"https://%61pp.example")).toThrow(/ASCII HTTPS origin/); diff --git a/packages/dewee-webchat/test/helpers/fake-ccp-ingress.ts b/packages/dewee-webchat/test/helpers/fake-ccp-ingress.ts index 4d00161..af4d2d4 100644 --- a/packages/dewee-webchat/test/helpers/fake-ccp-ingress.ts +++ b/packages/dewee-webchat/test/helpers/fake-ccp-ingress.ts @@ -240,6 +240,10 @@ export async function startFakeCcp(): Promise { if (s) s.revoked = true; return send(res, 200, { revoked: true }); } + if (route === "identity/member-link-proof") { + if (!actor.startsWith("tuc:") || !/^MEM-[A-Z2-7]{16}$/.test(body?.code ?? "")) return error(res, 400, "invalid_request"); + return send(res, 200, { state: "proved" }); + } if (route === "link") { // The real shape: `{channel, url}` per chat account (contract §1.8). return send(res, 201, { proof_code: "482913", expires_at: new Date(Date.now() + 600_000).toISOString(), deep_links: [{ channel: "telegram", url: "https://t.me/acme_bot?start=482913" }, { channel: "bad channel!", url: "https://x.example" }] }); diff --git a/packages/dewee-webchat/test/php-identity-ccp.test.ts b/packages/dewee-webchat/test/php-identity-ccp.test.ts index 4af59c9..f216c2c 100644 --- a/packages/dewee-webchat/test/php-identity-ccp.test.ts +++ b/packages/dewee-webchat/test/php-identity-ccp.test.ts @@ -121,6 +121,23 @@ describe.skipIf(!available)("PHP signed-in CCP sessions", () => { expect((await fetch(`${base}/credential-request`, { method: "POST", headers: { ...headers, cookie: "uid=user-b" }, body })).status).toBe(401); }); + it("requires the current identity for member proof and keeps the code out of chat", async () => { + ccp.state.config = { ...ccp.state.config, member_link_proof: true }; + const issued = await (await session("user-a")).json(); + const headers = { cookie:"uid=user-a", origin:"https://shop.example", authorization:`Bearer ${issued.token}`, "x-test-feature":"on" }; + const config = await (await fetch(`${base}/config`, {headers})).json(); + expect(config.memberLinkProof).toBe(true); + const code = "MEM-ABCDEFGHIJKLMNOP"; + const proof = await fetch(`${base}/member-link-proof`, {method:"POST",headers,body:JSON.stringify({code})}); + expect(proof.status).toBe(200); expect(await proof.json()).toEqual({state:"proved"}); + expect(ccp.routes("identity/member-link-proof").at(-1)?.body).toMatchObject({agent:"shop",code}); + expect((await fetch(`${base}/member-link-proof`, {method:"POST",headers:{...headers,cookie:"uid=user-b"},body:JSON.stringify({code})})).status).toBe(401); + const chat = await fetch(`${base}/chat`, {method:"POST",headers,body:JSON.stringify({agent:"shop",conversationId:"conversation_01",message:`my code ${code}`})}); + expect(chat.status).toBe(400); expect(await chat.text()).not.toContain(code); expect(ccp.routes("chat")).toHaveLength(0); + const page = await fetch(`${base}/chat`, {method:"POST",headers,body:JSON.stringify({agent:"shop",conversationId:"conversation_01",message:"help",page_context:{version:1,url:"https://shop.example",content:{format:"text",text:code}}})}); + expect(page.status).toBe(400); expect(ccp.routes("chat")).toHaveLength(0); + }); + it.each([false, true])("defers selected-agent credential policy to the runtime when default capability is %s", async (defaultEnabled) => { ccp.state.config = { ...ccp.state.config, credential_requests: defaultEnabled }; const id = "0b7e4c1e-2f6a-4d1b-9c3e-5a8f7d6e4b21"; diff --git a/packages/dewee-webchat/test/widget-member-link.test.ts b/packages/dewee-webchat/test/widget-member-link.test.ts new file mode 100644 index 0000000..e7c53ab --- /dev/null +++ b/packages/dewee-webchat/test/widget-member-link.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +import { afterEach, beforeAll, expect, it, vi } from "vitest"; +import { defineDeweeChat, type DeweeChatElement } from "../src/index"; + +beforeAll(() => defineDeweeChat()); +afterEach(() => { document.body.innerHTML = ""; localStorage.clear(); sessionStorage.clear(); vi.unstubAllGlobals(); }); + +it("proves membership through a separate form without retaining the code in chat or storage", async () => { + const code = "MEM-ABCDEFGHIJKLMNOP"; + const calls: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { + const path = new URL(url, "https://site.test").pathname; calls.push(path); + const json = (value: unknown) => Response.json(value); + if (path.endsWith("/config")) return json({ identity: true, memberLinkProof: true, agents: [{id:"support"}], defaultAgent:"support", allowAgentSwitch:false, allowClientCredentials:false, maxMessageLength:4000, version:"test" }); + if (path.endsWith("/session")) return json({visitorId:"v1",token:"sealed-visitor",verified:true,expiresAt:Date.now()+60000}); + if (path.endsWith("/history")) return json({messages:[]}); + if (path.endsWith("/member-link-proof")) { expect(JSON.parse(String(init?.body))).toEqual({code}); return json({state:"proved"}); } + throw new Error("unexpected route"); + })); + const el = document.createElement("dewee-chat") as DeweeChatElement; + for (const [name, value] of Object.entries({endpoint:"/api/dewee",mode:"inline",persist:"false"})) el.setAttribute(name,value); + document.body.append(el); + const button = el.shadowRoot!.querySelector(".member-link")!; + await vi.waitFor(() => expect(button.hidden).toBe(false)); + const sent: unknown[] = []; + el.addEventListener("dewee:send", (event) => sent.push((event as CustomEvent).detail)); + await el.send(`my code is ${code}`); + expect(sent).toHaveLength(0); + expect(el.shadowRoot!.textContent).not.toContain(code); + button.click(); + await vi.waitFor(() => expect(el.shadowRoot!.querySelector(".member-link-dialog form")).not.toBeNull()); + const dialog = el.shadowRoot!.querySelector(".member-link-dialog form")!; + const input = dialog.querySelector("input")!; + input.value = code; + dialog.dispatchEvent(new Event("submit", {bubbles:true,cancelable:true})); + expect(input.value).toBe(""); + await vi.waitFor(() => expect(el.shadowRoot!.querySelector(".member-link-dialog")).toBeNull()); + expect(calls.filter((path) => path.endsWith("/member-link-proof"))).toHaveLength(1); + expect(calls.filter((path) => path.endsWith("/chat"))).toHaveLength(0); + for (const value of [el.shadowRoot!.innerHTML, JSON.stringify(localStorage), JSON.stringify(sessionStorage)]) expect(value).not.toContain(code); +}); + +it("asks anonymous visitors to sign in before showing the form, then allows it after session renewal", async () => { + let signedIn = false; + vi.stubGlobal("fetch", vi.fn(async (url: string) => { + const path = new URL(url, "https://site.test").pathname; + if (path.endsWith("/config")) return Response.json({ identity:true, memberLinkProof:true, agents:[{id:"support"}], defaultAgent:"support", allowAgentSwitch:false, allowClientCredentials:false, maxMessageLength:4000, version:"test" }); + if (path.endsWith("/session")) return Response.json({visitorId:signedIn ? "member" : "visitor",token:"sealed-visitor",verified:signedIn,expiresAt:Date.now()+60000}); + if (path.endsWith("/history")) return Response.json({messages:[]}); + throw new Error("unexpected route"); + })); + const el = document.createElement("dewee-chat") as DeweeChatElement; + for (const [name, value] of Object.entries({endpoint:"/api/dewee",mode:"inline",persist:"false"})) el.setAttribute(name,value); + document.body.append(el); + const button = el.shadowRoot!.querySelector(".member-link")!; + await vi.waitFor(() => expect(button.hidden).toBe(false)); + button.click(); + await vi.waitFor(() => expect(el.shadowRoot!.textContent).toContain("Sign in to this website")); + expect(el.shadowRoot!.querySelector(".member-link-dialog")).toBeNull(); + signedIn = true; + await el.renewSession(); + button.click(); + await vi.waitFor(() => expect(el.shadowRoot!.querySelector(".member-link-dialog form")).not.toBeNull()); +}); diff --git a/php/dewee-webchat.php b/php/dewee-webchat.php index 8a2c4e9..4952894 100644 --- a/php/dewee-webchat.php +++ b/php/dewee-webchat.php @@ -434,6 +434,7 @@ private function run(): void $method === 'POST' && $action === 'image-ticket' && !empty($this->o['imageSources']) => $this->handleImageTicket(), $method === 'GET' && $action === 'image' && !empty($this->o['imageSources']) => $this->handleImage(), $method === 'POST' && $action === 'link' && $this->identity !== null => $this->handleLink(), + $method === 'POST' && $action === 'member-link-proof' => $this->handleMemberLinkProof(), $method === 'POST' && $action === 'job-access' && $this->identity !== null => $this->handleJobAccess(), $method === 'POST' && in_array($action, ['credential-request', 'credential-request-handoff', 'credential-request-cancel'], true) => $this->handleCredentialRequest($action), default => $this->fail(404, 'Not found', 'not_found'), @@ -976,6 +977,28 @@ private function handleCredentialRequest(string $route): void } catch (DeweeCcpError $e) { $this->ccpFail($e, $route); } } + private function handleMemberLinkProof(): void + { + if ($this->ccp === null || $this->identity === null || $this->credentialFormOrigin === null) { $this->fail(404, 'Not found', 'not_found'); return; } + $origin = self::header('origin') ?? ''; + $parsed = parse_url($origin); + if (!is_array($parsed) || ($parsed['scheme'] ?? '') !== 'https' || empty($parsed['host']) || isset($parsed['user']) || isset($parsed['pass']) || isset($parsed['path']) || isset($parsed['query']) || isset($parsed['fragment']) || (isset($parsed['port']) && $parsed['port'] === 443) || $origin !== 'https://' . strtolower($parsed['host']) . (isset($parsed['port']) ? ':' . $parsed['port'] : '')) { $this->fail(403, 'Origin not allowed', 'forbidden_origin'); return; } + $claims = $this->claimsFromRequest(); + if (!isset($claims['ids'], $claims['ida'])) { $this->identityFail('IDENTITY_REQUIRED'); return; } + if (!$this->identityRebind($claims, true)) return; + if (!$this->rateLimit('member-proof:' . $claims['v'], 'link')) { $this->fail(429, 'Too many requests', 'rate_limited'); return; } + $raw = file_get_contents('php://input', false, null, 0, 1025); + $body = is_string($raw) && strlen($raw) <= 1024 ? json_decode($raw, true) : null; + if (!is_array($body) || array_keys($body) !== ['code'] || !is_string($body['code']) || !preg_match('/^MEM-[A-Z2-7]{16}$/D', $body['code'])) { $this->fail(400, 'Invalid request', 'bad_request'); return; } + try { + $ctx = $this->visitorContext(); + $input = ['identity_session' => $claims['ids'], 'agent' => $claims['ida'], 'code' => $body['code']]; + $result = $this->withEdge($ctx, $claims['v'], self::sealedEdge($claims), fn (array $edge) => $this->ccp->json('identity/member-link-proof', $ctx + ['body' => $input, 'session' => $edge['token']])); + if (($result['state'] ?? null) !== 'proved') { $this->fail(502, 'Member verification unavailable', 'member_link_unavailable'); return; } + $this->json(['state' => 'proved']); + } catch (DeweeCcpError $e) { $this->ccpFail($e, 'identity/member-link-proof'); } + } + private function handleLink(): void { $claims = $this->claimsFromRequest(); @@ -1066,6 +1089,7 @@ private function handleChat(): void $body = is_string($raw) ? json_decode($raw, true) : null; if (!$rawBody instanceof stdClass || !is_array($body)) { $this->fail(400, 'Invalid request', 'bad_request'); return; } $message = is_string($body['message'] ?? null) ? self::jsTrim($body['message']) : ''; + if (preg_match('/MEM-[A-Z2-7]{16}/i', (string) json_encode($body))) { $this->fail(400, 'Use the member linking form for your verification code', 'member_proof_in_message'); return; } $resume = $body['credentialRequestId'] ?? null; if (array_key_exists('credentialRequestId', $body) && $resume === null) { $this->fail(400, 'Invalid request', 'bad_request'); return; } if ($resume !== null) { @@ -1305,6 +1329,7 @@ private function handleCcpConfig(): void 'maxMessageLength' => min((int) $this->o['maxMessageLength'], $cfg['maxMessageChars']), 'pageContextVersions' => !empty($cfg['pageContextVersions']) ? $cfg['pageContextVersions'] : null, 'credentialFormOrigin' => $this->credentialFormOrigin, + 'memberLinkProof' => $this->credentialFormOrigin !== null && ($cfg['memberLinkProof'] ?? false) ? true : null, 'version' => self::VERSION, ])); } @@ -1748,6 +1773,7 @@ public static function parseCcpConfig(array $data): array 'allowAgentSwitch' => is_bool($data['allow_agent_switch'] ?? null) ? $data['allow_agent_switch'] && count($agents) > 1 : count($agents) > 1, 'mode' => $mode, 'identityEnabled' => ($data['identity_enabled'] ?? null) === true, + 'memberLinkProof' => ($data['member_link_proof'] ?? null) === true, // Reasoning and tool activity stay hidden unless the integration turns them on explicitly. 'showThinking' => ($data['show_thinking'] ?? null) === true, 'showToolActivity' => ($data['show_tool_activity'] ?? null) === true, @@ -2949,7 +2975,7 @@ public static function clip(mixed $v, int $max = 4000): ?string */ final class DeweeCcpClient { - public const ROUTES = ['config', 'session', 'chat', 'history', 'abort', 'identity/exchange', 'identity/revoke', 'link', 'job-access', 'credential-request', 'credential-request-handoff', 'credential-request-cancel']; + public const ROUTES = ['config', 'session', 'chat', 'history', 'abort', 'identity/exchange', 'identity/revoke', 'identity/member-link-proof', 'link', 'job-access', 'credential-request', 'credential-request-handoff', 'credential-request-cancel']; private const INTEGRATION_ID_RE = '/^wci_[a-z2-7]{26}$/'; private const SECRET_RE = '/^dwc_[A-Za-z0-9_-]{16,256}$/'; private const INGRESS_PATH_RE = '#/api/public/webchat/v1/(wci_[a-z2-7]{26})$#';