From 0bf2c277ab1c1170322c777cc74730d4da6e51c4 Mon Sep 17 00:00:00 2001 From: Saif Ullah jafri Date: Fri, 30 Oct 2026 12:57:09 +0530 Subject: [PATCH] feat(widget): userToken can be a function that returns a fresh token (#461) The widget calls it once each time it asks the server for a session, not per message. A function that throws, rejects, returns a non-string or takes over 5 seconds means the session starts without the visitor, with a console warning. A string still works as before, and setUser() replaces the function. --- .changeset/widget-user-token-provider.md | 5 + api/widget.api.md | 4 +- e2e/server.mjs | 6 + e2e/tests/real-server.spec.ts | 52 +++++++ packages/widget/src/api/client.ts | 41 ++++- packages/widget/src/index.ts | 1 + packages/widget/src/widget.ts | 7 +- packages/widget/tests/api.test-d.ts | 10 ++ packages/widget/tests/api/session.test.ts | 145 ++++++++++++++++++ packages/widget/tests/user-token.test.ts | 87 +++++++++++ website/src/content/docs/server/setup.mdx | 16 ++ website/src/content/docs/widget/api.mdx | 2 + .../src/content/docs/widget/configuration.mdx | 2 +- 13 files changed, 368 insertions(+), 10 deletions(-) create mode 100644 .changeset/widget-user-token-provider.md create mode 100644 packages/widget/tests/user-token.test.ts diff --git a/.changeset/widget-user-token-provider.md b/.changeset/widget-user-token-provider.md new file mode 100644 index 00000000..a1785bfb --- /dev/null +++ b/.changeset/widget-user-token-provider.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": minor +--- + +`userToken` can be a function (sync or async) that returns a fresh signed token. The widget calls it once each time it asks the server for a session, not per message. If it throws, rejects or takes over 5 seconds, the session starts without the visitor and a warning is logged. A string still works as before. diff --git a/api/widget.api.md b/api/widget.api.md index 29575668..646e5364 100644 --- a/api/widget.api.md +++ b/api/widget.api.md @@ -84,6 +84,8 @@ export interface ThemeConfig { export type ThemeMode = 'light' | 'dark' | 'auto'; +export type UserToken = string | (() => string | undefined | Promise); + export declare class Widget { get isInline(): boolean; constructor(config: WidgetConfig); @@ -114,7 +116,7 @@ export interface WidgetConfig { nonce?: string; timeout?: number; turnstileSiteKey?: string; - userToken?: string; + userToken?: UserToken; mode?: 'popup' | 'inline'; siteId?: string; container?: string | HTMLElement; diff --git a/e2e/server.mjs b/e2e/server.mjs index 6c384675..a597bf22 100644 --- a/e2e/server.mjs +++ b/e2e/server.mjs @@ -191,6 +191,12 @@ const endpoints = { '/api/chat': chatEndpoint(), // Session tokens on, for the session spec. '/api/secure-chat': chatEndpoint({ sessionSecrets: ['an-e2e-session-secret-of-32-bytes-or-more'], allowAnonymous: false }), + // Signed-in visitors: the userToken spec signs a token with this secret. + '/api/identity-chat': chatEndpoint({ + sessionSecrets: ['an-e2e-session-secret-of-32-bytes-or-more'], + identity: { secret: 'an-e2e-identity-secret-of-32-bytes-or-more' }, + allowAnonymous: false, + }), }; /** Runs a Fetch API handler for a Node request. */ diff --git a/e2e/tests/real-server.spec.ts b/e2e/tests/real-server.spec.ts index 7a7b164b..948d0228 100644 --- a/e2e/tests/real-server.spec.ts +++ b/e2e/tests/real-server.spec.ts @@ -1,3 +1,4 @@ +import { createHmac } from 'node:crypto'; import { expect, test } from '@playwright/test'; // These run against the real @chatcops/server handler (see e2e/server.mjs). @@ -57,3 +58,54 @@ test('with session tokens on, the widget gets a token and sends it', async ({ pa expect(requests[2].auth).toMatch(/^Bearer /); expect(requests[3].auth).toBe(requests[2].auth); }); + +const IDENTITY_SECRET = 'an-e2e-identity-secret-of-32-bytes-or-more'; +const signedToken = (userId: string, lifetimeSeconds: number) => { + const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const body = `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode({ sub: userId, exp: Math.floor(Date.now() / 1000) + lifetimeSeconds })}`; + return `${body}.${createHmac('sha256', IDENTITY_SECRET).update(body).digest('base64url')}`; +}; + +test('a userToken function gives the server a fresh token when the session is requested', async ({ page }) => { + // A token is signed when the session is requested, not when the page loads, so it cannot have expired by then. + let calls = 0; + await page.exposeFunction('signToken', () => { + calls++; + return signedToken('visitor-1', 300); + }); + await page.goto('/sessions.html'); + await page.evaluate(() => { + const w = window as unknown as { signToken(): Promise; ChatCops: { init(config: object): void } }; + w.ChatCops.init({ apiUrl: '/api/identity-chat', userToken: () => w.signToken() }); + }); + const sessions: Array<{ status: number }> = []; + page.on('response', (res) => { + if (res.url().endsWith('/api/identity-chat/session')) sessions.push({ status: res.status() }); + }); + + await page.locator(fab).click(); + expect(calls).toBe(0); + await page.locator(input).fill('Hi there'); + await page.locator(send).click(); + await expect(page.locator('.cc-message-assistant').last()).toContainText('Hello from the test server.'); + await page.locator(input).fill('Again'); + await page.locator(send).click(); + await expect(page.locator('.cc-message-assistant', { hasText: 'Hello from the test server.' })).toHaveCount(2); + + expect(sessions).toEqual([{ status: 200 }]); + expect(calls).toBe(1); +}); + +test('with a userToken that is not a valid token, the server refuses the session', async ({ page }) => { + await page.goto('/sessions.html'); + await page.evaluate(() => { + (window as unknown as { ChatCops: { init(config: object): void } }).ChatCops.init({ + apiUrl: '/api/identity-chat', + userToken: () => 'not.a.token', + }); + }); + await page.locator(fab).click(); + await page.locator(input).fill('Hi there'); + await page.locator(send).click(); + await expect(page.locator('.cc-message-assistant').last()).toContainText('Something went wrong'); +}); diff --git a/packages/widget/src/api/client.ts b/packages/widget/src/api/client.ts index 0a6d8987..75de6884 100644 --- a/packages/widget/src/api/client.ts +++ b/packages/widget/src/api/client.ts @@ -2,14 +2,23 @@ import type { WirePart } from './protocol.js'; import { keyStore, type KeyStore } from '../storage.js'; import type { WidgetChatRequest, WidgetChatChunk } from './types.js'; +/** A signed token for the visitor, or a function that returns one (or none) each time a session is requested. */ +export type UserToken = string | (() => string | undefined | Promise); + +/** How long a `userToken` function may take before the session starts without an identity. */ +const USER_TOKEN_TIMEOUT = 5000; + export interface ChatClientOptions { apiUrl: string; /** Inactivity timeout in milliseconds. Defaults to 30000. */ timeout?: number; /** Cloudflare Turnstile site key, when the server checks Turnstile before issuing a session. */ turnstileSiteKey?: string; - /** The host site's signed token for a signed-in visitor, sent when asking for a session. */ - userToken?: string; + /** + * The host site's signed token for a signed-in visitor, sent when asking for a session. + * A function is called for a fresh one each time, and may be async. + */ + userToken?: UserToken; /** Where the session token is kept. Defaults to localStorage; the widget passes its own store. */ store?: KeyStore; } @@ -34,7 +43,7 @@ export class ChatClient { private saved?: Partial; private store: KeyStore; private turnstileSiteKey?: string; - private userToken?: string; + private userToken?: UserToken; constructor(options: ChatClientOptions) { this.apiUrl = options.apiUrl; @@ -52,7 +61,7 @@ export class ChatClient { * different user). `forget: false` leaves the stored token alone, for a widget that * may not use storage yet. */ - setUserToken(token: string | undefined, forget = true): void { + setUserToken(token: UserToken | undefined, forget = true): void { if (token === this.userToken) return; this.userToken = token; if (forget) this.forgetSession(); @@ -65,10 +74,30 @@ export class ChatClient { this.store.remove(this.sessionKey); } - get identity(): string | undefined { + get identity(): UserToken | undefined { return this.userToken; } + /** The token to send with a session request. A function that fails, or is too slow, means no identity. */ + private async resolveUserToken(): Promise { + const source = this.userToken; + if (typeof source !== 'function') return source; + let timer: ReturnType | undefined; + try { + const token = await Promise.race([ + source(), + new Promise((_, reject) => { + timer = setTimeout(reject, USER_TOKEN_TIMEOUT, new Error('timed out')); + }), + ]); + return typeof token === 'string' && token ? token : undefined; + } catch (error) { + console.warn('ChatCops: userToken() failed, so the session starts without the visitor', error); + } finally { + clearTimeout(timer); + } + } + async *sendMessage( request: WidgetChatRequest, options: SendMessageOptions = {} @@ -145,7 +174,7 @@ export class ChatClient { const minted = await post(this.apiUrl.replace(/\/?(\?.*)?$/, '/session$1'), { conversationId: request.conversationId, previousToken: saved.token, - userToken: this.userToken, + userToken: await this.resolveUserToken(), // Its own chunk: only sites with a Turnstile key download it. turnstileToken: this.turnstileSiteKey && !resumable diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 19744062..9611c4c0 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -23,6 +23,7 @@ export { export type { MessageData, MessageErrorType, MessageStatus } from './dom/messages.js'; export type { ThemeConfig, ThemeMode } from './theme.js'; export type { Persistence } from './storage.js'; +export type { UserToken } from './api/client.js'; export type { WidgetLocaleStrings } from './i18n.js'; export { parseAutoOpen } from './script-config.js'; diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 13221663..8b1cc40f 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -2,7 +2,7 @@ import { createShadowRoot, destroyShadowRoot } from './dom/shadow.js'; import { FAB } from './dom/fab.js'; import { Panel, type PanelLabels } from './dom/panel.js'; import { BUBBLE_SHOWN_KEY, WelcomeBubble } from './dom/bubble.js'; -import { ChatClient } from './api/client.js'; +import { ChatClient, type UserToken } from './api/client.js'; import { ConversationStorage, type Persistence } from './storage.js'; import { claimNamespace, type ClaimedNamespace } from './namespace.js'; import { applyTheme, type ThemeConfig } from './theme.js'; @@ -53,8 +53,11 @@ export interface WidgetConfig { /** * For a visitor your site has signed in: an HS256 JWT your server signs * with the chat server's `identity.secret` (`sub`, `exp`, optional `tier`). + * Give a function (it may be async) to get a fresh token each time the + * widget asks for a session; if it fails or takes over 5 seconds, the + * session starts without the visitor. */ - userToken?: string; + userToken?: UserToken; mode?: 'popup' | 'inline'; /** * Names this widget's saved conversation. Set a different one on each widget diff --git a/packages/widget/tests/api.test-d.ts b/packages/widget/tests/api.test-d.ts index 6ea3b126..9c46b6b1 100644 --- a/packages/widget/tests/api.test-d.ts +++ b/packages/widget/tests/api.test-d.ts @@ -6,6 +6,7 @@ import ChatCops, { type PreChatFormConfig, type ThemeConfig, type ThemeMode, + type UserToken, type WidgetConfig, type WidgetLocaleStrings, type WidgetUpdate, @@ -41,6 +42,15 @@ describe('the widget API types', () => { ChatCops.setConsent('yes'); }); + it('takes a string or a function for userToken', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf string | undefined | Promise)>(); + const config: WidgetConfig = { apiUrl: '/api/chat', userToken: async () => 'signed.token' }; + ChatCops.init({ ...config, userToken: () => undefined }); + // @ts-expect-error: a function has to return a string + ChatCops.init({ apiUrl: '/api/chat', userToken: () => 42 }); + }); + it('types update() the same in both builds, without position', () => { expectTypeOf(ChatCops.update).parameters.toEqualTypeOf<[WidgetUpdate]>(); expectTypeOf().parameters.toEqualTypeOf<[WidgetUpdate]>(); diff --git a/packages/widget/tests/api/session.test.ts b/packages/widget/tests/api/session.test.ts index 91a3b8d3..fcf596b1 100644 --- a/packages/widget/tests/api/session.test.ts +++ b/packages/widget/tests/api/session.test.ts @@ -182,3 +182,148 @@ describe('ChatClient with session tokens', () => { expect(String(fetch.mock.calls[1][0])).toBe(sessionUrl); }); }); + +describe('ChatClient with a userToken provider', () => { + const sessionFlow = () => + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(minted('c1')) + .mockResolvedValueOnce(v1Reply()); + const sessionBody = (fetch: ReturnType) => calls(fetch)[1].body; + + it('asks for a token when it requests a session, and sends what the provider returns', async () => { + const fetch = sessionFlow(); + const userToken = vi.fn(async () => 'fresh.signed.token'); + await collect(new ChatClient({ apiUrl: API, userToken })); + expect(userToken).toHaveBeenCalledTimes(1); + expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1', userToken: 'fresh.signed.token' }); + }); + + it('accepts a provider that answers at once', async () => { + const fetch = sessionFlow(); + await collect(new ChatClient({ apiUrl: API, userToken: () => 'sync.token' })); + expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1', userToken: 'sync.token' }); + }); + + it('does not call the provider when the server has no sessions', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(v1Reply()); + const userToken = vi.fn(() => 'token'); + await collect(new ChatClient({ apiUrl: API, userToken })); + expect(userToken).not.toHaveBeenCalled(); + }); + + it('calls it once per session request, not once per message', async () => { + const fetch = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(minted('c1')) + .mockResolvedValueOnce(v1Reply()) + .mockResolvedValueOnce(v1Reply()) + .mockResolvedValueOnce(v1Reply()); + const userToken = vi.fn(() => 'token'); + const client = new ChatClient({ apiUrl: API, userToken }); + await collect(client); + await collect(client); + await collect(client); + expect(userToken).toHaveBeenCalledTimes(1); + expect(calls(fetch as never).filter((c) => c.url.endsWith('/session'))).toHaveLength(1); + }); + + it('gets a new token for the next session, once the session token has expired', async () => { + const fetch = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(minted('c1', 'tok-1')) + .mockResolvedValueOnce(v1Reply()) + .mockResolvedValueOnce(unauthorized('token_expired')) + .mockResolvedValueOnce(minted('c1', 'tok-2')) + .mockResolvedValueOnce(v1Reply()); + let n = 0; + const client = new ChatClient({ apiUrl: API, userToken: async () => `signed-${++n}` }); + await collect(client); + await collect(client); + const sessions = calls(fetch as never).filter((c) => c.url.endsWith('/session')); + expect(sessions.map((s) => s.body.userToken)).toEqual(['signed-1', 'signed-2']); + }); + + it('sends no identity when the provider has none (a signed-out visitor)', async () => { + const fetch = sessionFlow(); + await collect(new ChatClient({ apiUrl: API, userToken: async () => undefined })); + expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1' }); + }); + + it.each([ + ['throws', 1, () => { throw new Error('signed out'); }], + ['rejects', 1, () => Promise.reject(new Error('network'))], + ['returns something that is not a string', 0, () => 42 as never], + ['returns an empty string', 0, () => ''], + ])('starts the session without an identity when the provider %s', async (_, logged, userToken) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const fetch = sessionFlow(); + const chunks = await collect(new ChatClient({ apiUrl: API, userToken })); + expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1' }); + expect(chunks).toContainEqual({ done: true }); + expect(warn).toHaveBeenCalledTimes(logged); + }); + + it('logs why the provider failed', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + sessionFlow(); + const error = new Error('token endpoint is down'); + await collect(new ChatClient({ apiUrl: API, userToken: () => Promise.reject(error) })); + expect(warn.mock.calls[0]).toContain(error); + }); + + it('starts the session without an identity when the provider takes too long', async () => { + vi.useFakeTimers(); + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const fetch = sessionFlow(); + const done = collect(new ChatClient({ apiUrl: API, userToken: () => new Promise(() => undefined), timeout: 60_000 })); + await vi.advanceTimersByTimeAsync(4_900); + expect(calls(fetch as never)).toHaveLength(1); + await vi.advanceTimersByTimeAsync(200); + await done; + expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1' }); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('leaves no timer behind when the provider answers', async () => { + vi.useFakeTimers(); + try { + sessionFlow(); + const done = collect(new ChatClient({ apiUrl: API, userToken: async () => 'token', timeout: 60_000 })); + await vi.advanceTimersByTimeAsync(0); + await done; + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('still sends a fixed string as before', async () => { + const fetch = sessionFlow(); + await collect(new ChatClient({ apiUrl: API, userToken: 'host.signed.token' })); + expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1', userToken: 'host.signed.token' }); + }); + + it('can be replaced by a fixed string, or by none, with setUserToken', async () => { + const userToken = vi.fn(() => 'from-provider'); + const client = new ChatClient({ apiUrl: API, userToken }); + client.setUserToken('fixed'); + expect(client.identity).toBe('fixed'); + const fetch = sessionFlow(); + await collect(client); + expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1', userToken: 'fixed' }); + expect(userToken).not.toHaveBeenCalled(); + }); + + it('can be set after construction with setUserToken', async () => { + const client = new ChatClient({ apiUrl: API }); + client.setUserToken(async () => 'late-provider'); + const fetch = sessionFlow(); + await collect(client); + expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1', userToken: 'late-provider' }); + }); +}); diff --git a/packages/widget/tests/user-token.test.ts b/packages/widget/tests/user-token.test.ts new file mode 100644 index 00000000..2967ec63 --- /dev/null +++ b/packages/widget/tests/user-token.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ChatCops from '../src/index.js'; +import { Widget } from '../src/widget.js'; + +const API = 'https://api.test/chat'; + +const reply = () => + new Response( + ['data: {"type":"start","protocol":1,"conversationId":"c","messageId":"m1"}', 'data: {"type":"text-delta","id":"t","delta":"Hi"}', 'data: {"type":"finish","reason":"stop"}', 'data: [DONE]', ''].join('\n\n'), + { status: 200 } + ); +const unauthorized = () => new Response(JSON.stringify({ error: { code: 'session_required' } }), { status: 401 }); +const minted = () => new Response(JSON.stringify({ token: 'tok', conversationId: 'c1', expiresAt: Date.now() + 900_000 }), { status: 200 }); + +/** The identity sent with each /session request, as the page's fetch sees it. */ +function sessionRequests() { + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = String(input); + const hasToken = (init?.headers as Record | undefined)?.Authorization; + if (url.endsWith('/session')) return minted(); + return hasToken ? reply() : unauthorized(); + }); + return () => + fetch.mock.calls.filter(([url]) => String(url).endsWith('/session')).map(([, init]) => JSON.parse(String(init?.body)).userToken); +} + +let widget: Widget | undefined; +beforeEach(() => { + document.body.innerHTML = ''; + localStorage.clear(); +}); +afterEach(() => { + widget?.destroy(); + widget = undefined; + ChatCops.destroy(); + vi.restoreAllMocks(); +}); + +describe('userToken as a function', () => { + it('is called when the visitor first sends a message, not when the page loads', async () => { + const sent = sessionRequests(); + const userToken = vi.fn(async () => 'fresh.token'); + widget = new Widget({ apiUrl: API, userToken }); + widget.init(); + widget.open(); + expect(userToken).not.toHaveBeenCalled(); + + await widget.sendMessage('Hello'); + expect(userToken).toHaveBeenCalledTimes(1); + expect(sent()).toEqual(['fresh.token']); + }); + + it('works through ChatCops.init() in the npm and single-file builds', async () => { + const sent = sessionRequests(); + widget = ChatCops.init({ apiUrl: API, userToken: () => 'from.init' }); + await ChatCops.sendMessage('Hello'); + expect(sent()).toEqual(['from.init']); + }); + + it('is replaced by the token of setUser()', async () => { + const sent = sessionRequests(); + const userToken = vi.fn(() => 'from.config'); + widget = new Widget({ apiUrl: API, userToken }); + widget.init(); + widget.setUser({ name: 'Ada', token: 'from.setUser' }); + await widget.sendMessage('Hello'); + expect(sent()).toEqual(['from.setUser']); + expect(userToken).not.toHaveBeenCalled(); + }); + + it('sends nothing when setUser(null) signs the visitor out', async () => { + const sent = sessionRequests(); + widget = new Widget({ apiUrl: API, userToken: () => 'from.config' }); + widget.init(); + widget.setUser(null); + await widget.sendMessage('Hello'); + expect(sent()).toEqual([undefined]); + }); + + it('still takes a fixed string', async () => { + const sent = sessionRequests(); + widget = new Widget({ apiUrl: API, userToken: 'fixed.token' }); + widget.init(); + await widget.sendMessage('Hello'); + expect(sent()).toEqual(['fixed.token']); + }); +}); diff --git a/website/src/content/docs/server/setup.mdx b/website/src/content/docs/server/setup.mdx index 83446be2..58d1d9c8 100644 --- a/website/src/content/docs/server/setup.mdx +++ b/website/src/content/docs/server/setup.mdx @@ -278,6 +278,22 @@ Then put it on the page with `data-user-token="..."` on the script tag, or `user The user id goes into the session token and is stored on the conversation with its first message. The same user can resume that conversation later with a fresh token, even without the old session token. Another user, or an anonymous visitor, can't. A `tier` swaps in that tier's budgets (merged over `budgets`) for the user's replies. A `userToken` that doesn't verify or has expired gets `401 session_required`, rather than an anonymous session, so a bug on your site shows up. The widget sends the token every time it asks for a session, so give it a lifetime longer than a visit, such as a day. +### A fresh token for each session + +A page can stay open for hours before the visitor opens the chat, and a short-lived token may have expired by then. Pass a function as `userToken` instead of a string, and the widget calls it each time it asks for a session, right before the request. It is not called for every message, only when a session is requested. + +```typescript +ChatCops.init({ + apiUrl: '/api/chat', + userToken: async () => { + const response = await fetch('/api/chat-token', { credentials: 'same-origin' }); + return response.ok ? (await response.json()).token : undefined; + }, +}); +``` + +The function may be async, and may return `undefined` for a visitor who is signed out. If it throws, rejects, returns something that is not a string, or takes longer than 5 seconds, the session starts without the visitor and the reason is logged to the console with `console.warn`, so the chat still opens. The token is sent only to your configured `apiUrl`. Keep the signing secret on your server and sign in an endpoint like the one above, never in the browser. A string still works as before. + ## Budgets Budgets cap what replies can cost, so a script hammering your endpoint can't run up the bill. They're counted in `storage`, so they hold across instances. diff --git a/website/src/content/docs/widget/api.mdx b/website/src/content/docs/widget/api.mdx index 20c1cdce..5ccfd757 100644 --- a/website/src/content/docs/widget/api.mdx +++ b/website/src/content/docs/widget/api.mdx @@ -148,6 +148,8 @@ ChatCops.setUser({ name: session.name, email: session.email, token: session.chat ChatCops.setUser(null); ``` +`setUser` replaces the identity the widget was started with, including a [`userToken` function](/server/setup/#a-fresh-token-for-each-session). + ### `ChatCops.setConsent(consent)` Tell the widget whether the visitor accepted functional cookies, for a chat with [`waitForConsent`](/widget/configuration/#waiting-for-cookie-consent). `true` replaces the notice with the normal chat; `false` closes the chat, removes the conversation and session token the widget saved in this browser, and shows the notice again on the next open. With the script tag build, a call before `init()` or before the chat has loaded is kept until it does, and the last call wins. While consent is missing, `sendMessage()` resolves without sending, so don't wait on a reply. diff --git a/website/src/content/docs/widget/configuration.mdx b/website/src/content/docs/widget/configuration.mdx index 9ab17496..6eb45b7e 100644 --- a/website/src/content/docs/widget/configuration.mdx +++ b/website/src/content/docs/widget/configuration.mdx @@ -252,7 +252,7 @@ interface WidgetConfig { nonce?: string; // CSP nonce for the widget's injected styles timeout?: number; // Inactivity timeout in ms, default: 30000 turnstileSiteKey?: string; // Cloudflare Turnstile site key, when the server has `turnstile` set - userToken?: string; // Your server's signed token for a signed-in visitor (server `identity`) + userToken?: string | (() => string | undefined | Promise); // Your server's signed token for a signed-in visitor (server `identity`), or a function that returns a fresh one // Display mode mode?: 'popup' | 'inline'; // Default: 'popup'