From 8d22185360b94c1f020161f34c28b64fbcd7a366 Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 20 Oct 2026 20:28:13 +0700 Subject: [PATCH] feat: let hosts add request headers and a custom fetch - headers option (object or per-request function) merged into every widget request; content-type, authorization and the gateway credential headers cannot be overridden - fetch option replaces globalThis.fetch for all widget requests - both available via configure(), element properties, and React/Vue props - reuse visitor sessions until 10% of the token lifetime remains (capped at 24 h) instead of a fixed 24 h - bump to 0.3.0 --- docs/http-contract.md | 11 ++ packages/dewee-webchat/README.md | 23 ++++ packages/dewee-webchat/package.json | 2 +- packages/dewee-webchat/src/index.ts | 2 +- packages/dewee-webchat/src/react.ts | 6 +- packages/dewee-webchat/src/vue.ts | 4 +- .../dewee-webchat/src/widget/api-client.ts | 55 ++++++-- packages/dewee-webchat/src/widget/element.ts | 31 ++++- .../test/request-headers.test.ts | 120 ++++++++++++++++++ php/dewee-webchat.php | 2 +- 10 files changed, 231 insertions(+), 25 deletions(-) create mode 100644 packages/dewee-webchat/test/request-headers.test.ts diff --git a/docs/http-contract.md b/docs/http-contract.md index e528b6f..64bc4f5 100644 --- a/docs/http-contract.md +++ b/docs/http-contract.md @@ -126,6 +126,17 @@ Headers: `Authorization: Bearer `. Returns the visitor's own conv ] } ``` +## Extra request headers + +Hosts may configure the widget to add headers (for example `x-csrf-token`) to every request, and to use a +custom `fetch`. The widget always sets `content-type`, `authorization` and the gateway credential headers +itself; host headers with those names are dropped. A cross-origin backend must list any extra header in +`Access-Control-Allow-Headers`. + +The widget refreshes its visitor session (`POST /session` with the old `token`) once less than 10% of the +lifetime it observed (`expiresAt` minus the time the session was issued), capped at 24 h and floored at 30 s, +remains. + ## Bring-your-own credentials (optional) When the backend enables it, the widget may send `x-dewee-gateway-url` and `x-dewee-gateway-token` diff --git a/packages/dewee-webchat/README.md b/packages/dewee-webchat/README.md index b3b256e..d42b793 100644 --- a/packages/dewee-webchat/README.md +++ b/packages/dewee-webchat/README.md @@ -127,12 +127,35 @@ Attributes use kebab-case (`show-reasoning="false"`), `data-*` on the script tag | `actions`, `confirm`, `getPageContext` | | [Page actions](#agent-page-actions) (JS/props only) | | `maxActionRounds` | `5` | Automatic action turns per visitor message | | `actionTimeoutMs` | `30000` | Per-handler timeout | +| `headers` | | Extra request headers, object or `() => object \| Promise` called before every request (JS/props only) | +| `fetch` | `globalThis.fetch` | Custom fetch for every widget request (JS/props only) | Methods: `open()`, `close()`, `toggle()`, `send(text)`, `stop()`, `newConversation()`, `configure(partial)`, `registerActions(actions)`, `unregisterAction(name)`. Events: `dewee:ready`, `dewee:open`, `dewee:close`, `dewee:send`, `dewee:reply`, `dewee:error`, `dewee:action`. CSS variables: `--dw-accent`, `--dw-font`, `--dw-radius`, `--dw-width`, `--dw-height`, `--dw-z`. +## Custom headers and fetch + +Behind a same-origin BFF that wants a CSRF header, cookies or tracing: + +```tsx + ({ "x-csrf-token": readCsrfToken() })} // evaluated before every request + fetch={(input, init) => fetch(input, { ...init, credentials: "include" })} +/> +``` + +Vanilla: `chat.configure({ headers, fetch })` or the `chat.headers` / `chat.fetch` properties. `headers` applies to +every request to `endpoint` (config, session, history, chat). It cannot override `content-type`, `authorization`, +`x-dewee-gateway-url` or `x-dewee-gateway-token` (case-insensitive; such keys are dropped). Header names are sent +lower-cased. For a cross-origin `endpoint`, the backend's CORS preflight must allow the extra headers; the built-in +handlers only allow their own. + +Visitor sessions are reused until less than 10% of the token lifetime remains (at most 24 h, at least 30 s), so +backends with short session TTLs do not mint a new session on every request. + ## Agent page actions Let the agent operate the page the visitor is on: navigate, filter a table, cancel a job. The page diff --git a/packages/dewee-webchat/package.json b/packages/dewee-webchat/package.json index f2c0cd3..ccf0ea3 100644 --- a/packages/dewee-webchat/package.json +++ b/packages/dewee-webchat/package.json @@ -1,6 +1,6 @@ { "name": "dewee-webchat", - "version": "0.2.0", + "version": "0.3.0", "description": "Embeddable chat widget for Dewee AI agents — web component, React/Vue/Astro wrappers and a secure backend handler (Workers, Next.js, Node, Bun, Deno).", "license": "MIT", "type": "module", diff --git a/packages/dewee-webchat/src/index.ts b/packages/dewee-webchat/src/index.ts index 8a944bc..2b17d0f 100644 --- a/packages/dewee-webchat/src/index.ts +++ b/packages/dewee-webchat/src/index.ts @@ -1,7 +1,7 @@ import { DeweeChatElement, type DeweeChatConfig } from "./widget/element"; export { DeweeChatElement, type DeweeChatConfig }; -export { ApiClient, ApiError, type ApiClientOptions } from "./widget/api-client"; +export { ApiClient, ApiError, RESERVED_HEADERS, type ApiClientOptions, type RequestHeadersOption } from "./widget/api-client"; export { renderMarkdown } from "./widget/markdown"; export type { ActionManifestEntry, ActionSensitivity, AgentInfo, ChatRequestBody, ChatStreamEvent, HistoryMessage, PublicConfig, SessionResponse, ToolCallView } from "./protocol"; export type { ActionCallStatus, ActionConfirmHook, PageAction, PageActionContext, PageActionInfo } from "./actions/action-runner"; diff --git a/packages/dewee-webchat/src/react.ts b/packages/dewee-webchat/src/react.ts index 9f3273b..5237438 100644 --- a/packages/dewee-webchat/src/react.ts +++ b/packages/dewee-webchat/src/react.ts @@ -28,7 +28,7 @@ type EventProp = keyof typeof EVENTS; export const DeweeChat = forwardRef(function DeweeChat(props, ref) { const el = useRef(null); - const { className, style, onReady, onSend, onReply, onError, onOpen, onClose, onAction, actions, confirm, getPageContext, ...config } = props; + const { className, style, onReady, onSend, onReply, onError, onOpen, onClose, onAction, actions, confirm, getPageContext, headers, fetch, ...config } = props; const handlers: Partial) => void>> = { onReady, onSend, onReply, onError, onOpen, onClose, onAction }; const handlersRef = useRef(handlers); handlersRef.current = handlers; @@ -44,7 +44,7 @@ export const DeweeChat = forwardRef(fun // without re-rendering the widget (inline `actions={[...]}` arrays are fine). useEffect(() => { defineDeweeChat(); - el.current?.configure({ actions, confirm, getPageContext }); + el.current?.configure({ actions, confirm, getPageContext, headers, fetch }); }); useEffect(() => { @@ -63,7 +63,7 @@ export const DeweeChat = forwardRef(fun }); export default DeweeChat; -export type { ActionConfirmHook, ActionEventDetail, PageAction, PageActionContext } from "./index"; +export type { ActionConfirmHook, ActionEventDetail, PageAction, PageActionContext, RequestHeadersOption } from "./index"; export type { DeweeChatConfig, DeweeChatElement }; declare module "react" { diff --git a/packages/dewee-webchat/src/vue.ts b/packages/dewee-webchat/src/vue.ts index 330b759..c7689c6 100644 --- a/packages/dewee-webchat/src/vue.ts +++ b/packages/dewee-webchat/src/vue.ts @@ -6,7 +6,7 @@ const keys = [ "endpoint", "agent", "title", "subtitle", "greeting", "intro", "placeholder", "suggestions", "avatar", "launcherAvatar", "position", "mode", "theme", "accentColor", "locale", "showReasoning", "showToolCalls", "allowAgentSwitch", "open", "persist", "hideBranding", "gatewayUrl", "gatewayToken", "width", "height", "zIndex", "actions", "confirm", "getPageContext", - "maxActionRounds", "actionTimeoutMs", + "maxActionRounds", "actionTimeoutMs", "headers", "fetch", ] as const; const props = Object.fromEntries(keys.map((k) => [k, { type: null as unknown as PropType, default: undefined }])) as { @@ -44,5 +44,5 @@ export const DeweeChat = defineComponent({ }); export default DeweeChat; -export type { ActionConfirmHook, ActionEventDetail, PageAction, PageActionContext } from "./index"; +export type { ActionConfirmHook, ActionEventDetail, PageAction, PageActionContext, RequestHeadersOption } from "./index"; export type { DeweeChatConfig, DeweeChatElement }; diff --git a/packages/dewee-webchat/src/widget/api-client.ts b/packages/dewee-webchat/src/widget/api-client.ts index 0e7b77c..797fdbf 100644 --- a/packages/dewee-webchat/src/widget/api-client.ts +++ b/packages/dewee-webchat/src/widget/api-client.ts @@ -9,6 +9,14 @@ import { type SessionResponse, } from "../protocol"; +/** Extra request headers: a static map, or a function evaluated before every request. */ +export type RequestHeadersOption = + | Record + | (() => Record | Promise>); + +/** Headers the widget owns; `headers` cannot override them (compared case-insensitively). */ +export const RESERVED_HEADERS: readonly string[] = ["content-type", "authorization", HEADER_GATEWAY_URL, HEADER_GATEWAY_TOKEN]; + export interface ApiClientOptions { /** Backend base URL, e.g. "/api/dewee" or "https://example.com/api/dewee". */ endpoint: string; @@ -17,6 +25,10 @@ export interface ApiClientOptions { gatewayToken?: string; /** Called when the backend requires a Turnstile challenge; must resolve to a token. */ turnstile?: (siteKey: string) => Promise; + /** Extra headers merged into every request (reserved headers excluded). */ + headers?: RequestHeadersOption; + /** Replaces `globalThis.fetch` for every request. */ + fetch?: typeof fetch; } export class ApiError extends Error { @@ -32,7 +44,7 @@ const store = { }; export class ApiClient { - private session: SessionResponse | null = null; + private session: (SessionResponse & { obtainedAt?: number }) | null = null; private config: PublicConfig | null = null; private sessionPromise: Promise | null = null; @@ -48,8 +60,24 @@ export class ApiClient { return `dewee-webchat:session:${this.o.endpoint}`; } - private headers(auth = false): Record { - const h: Record = { "content-type": "application/json" }; + /** Session reuse: refresh once less than 10% of the token lifetime (at most 24 h, at least 30 s) remains. */ + private fresh(s: SessionResponse & { obtainedAt?: number }): boolean { + const lifetime = s.obtainedAt ? s.expiresAt - s.obtainedAt : Infinity; + const margin = Math.min(86_400_000, Math.max(30_000, lifetime * 0.1)); + return s.expiresAt - Date.now() > margin; + } + + private request(url: string, init: RequestInit): Promise { + return (this.o.fetch ?? globalThis.fetch)(url, init); + } + + private async headers(auth = false): Promise> { + const h: Record = {}; + const extra = typeof this.o.headers === "function" ? await this.o.headers() : this.o.headers; + for (const [k, v] of Object.entries(extra ?? {})) { + if (typeof v === "string" && !RESERVED_HEADERS.includes(k.toLowerCase())) h[k.toLowerCase()] = v; + } + h["content-type"] = "application/json"; if (this.o.gatewayUrl && this.o.gatewayToken) { h[HEADER_GATEWAY_URL] = this.o.gatewayUrl; h[HEADER_GATEWAY_TOKEN] = this.o.gatewayToken; @@ -66,19 +94,19 @@ export class ApiClient { async getConfig(force = false): Promise { if (this.config && !force) return this.config; - this.config = await this.parse(await fetch(this.url("config"), { headers: this.headers() })); + this.config = await this.parse(await this.request(this.url("config"), { headers: await this.headers() })); return this.config; } /** Returns a valid visitor session, creating or refreshing it when needed. */ ensureSession(): Promise { - if (this.session && this.session.expiresAt - Date.now() > 86_400_000) return Promise.resolve(this.session); + if (this.session && this.fresh(this.session)) return Promise.resolve(this.session); if (!this.session) { const saved = store.get(this.storageKey); if (saved) { try { const s = JSON.parse(saved) as SessionResponse; - if (s.expiresAt - Date.now() > 86_400_000) return Promise.resolve((this.session = s)); + if (this.fresh(s)) return Promise.resolve((this.session = s)); this.session = s; // close to expiry: refresh below, keeping the same visitor id } catch { /* corrupted entry */ } } @@ -95,11 +123,12 @@ export class ApiClient { body.turnstileToken = await this.o.turnstile(cfg.turnstileSiteKey); } const s = await this.parse( - await fetch(this.url("session"), { method: "POST", headers: this.headers(), body: JSON.stringify(body) }), + await this.request(this.url("session"), { method: "POST", headers: await this.headers(), body: JSON.stringify(body) }), ); - this.session = s; - store.set(this.storageKey, JSON.stringify(s)); - return s; + // obtainedAt lets fresh() size the refresh margin to the server's token lifetime. + this.session = { ...s, obtainedAt: Date.now() }; + store.set(this.storageKey, JSON.stringify(this.session)); + return this.session; } forgetSession(): void { @@ -109,7 +138,7 @@ export class ApiClient { async history(agent: string, conversationId: string): Promise { await this.ensureSession(); - const res = await fetch(this.url("history", { agent, conversationId }), { headers: this.headers(true) }); + const res = await this.request(this.url("history", { agent, conversationId }), { headers: await this.headers(true) }); return (await this.parse<{ messages: HistoryMessage[] }>(res)).messages; } @@ -120,11 +149,11 @@ export class ApiClient { signal?: AbortSignal, ): Promise { await this.ensureSession(); - let res = await fetch(this.url("chat"), { method: "POST", headers: this.headers(true), body: JSON.stringify(input), signal }); + let res = await this.request(this.url("chat"), { method: "POST", headers: await this.headers(true), body: JSON.stringify(input), signal }); if (res.status === 401) { this.forgetSession(); await this.ensureSession(); - res = await fetch(this.url("chat"), { method: "POST", headers: this.headers(true), body: JSON.stringify(input), signal }); + res = await this.request(this.url("chat"), { method: "POST", headers: await this.headers(true), body: JSON.stringify(input), signal }); } if (!res.ok || !res.body) await this.parse(res); diff --git a/packages/dewee-webchat/src/widget/element.ts b/packages/dewee-webchat/src/widget/element.ts index ff14e98..9dd813a 100644 --- a/packages/dewee-webchat/src/widget/element.ts +++ b/packages/dewee-webchat/src/widget/element.ts @@ -12,7 +12,7 @@ import { type ParsedCall, } from "../actions/directives"; import { ACTION_LIMITS, stripPageBlock } from "../actions/page-block"; -import { ApiClient, ApiError } from "./api-client"; +import { ApiClient, ApiError, type RequestHeadersOption } from "./api-client"; import { showConfirmDialog } from "./confirm-dialog"; import { icons, pickStrings, type Strings } from "./i18n"; import { renderMarkdown } from "./markdown"; @@ -67,6 +67,13 @@ export interface DeweeChatConfig { maxActionRounds?: number; /** Per-handler timeout. Default 30000 ms. */ actionTimeoutMs?: number; + /** + * Extra headers for every request to `endpoint` (e.g. a CSRF token). A function is called before + * each request. Cannot override content-type, authorization or the gateway credential headers. + */ + headers?: RequestHeadersOption; + /** Replaces `globalThis.fetch` for every widget request (e.g. to add credentials or tracing). */ + fetch?: typeof fetch; } /** A page action call as shown in the transcript. */ @@ -93,7 +100,7 @@ export interface ActionEventDetail { } /** Option keys that hold functions/objects: applied without re-rendering the widget. */ -type BehaviorKey = "actions" | "confirm" | "getPageContext"; +type BehaviorKey = "actions" | "confirm" | "getPageContext" | "headers" | "fetch"; interface ViewRefs { root: HTMLElement; @@ -168,7 +175,7 @@ export class DeweeChatElement extends ElementBase { private mql: MediaQueryList | null = null; private registry = new ActionRegistry(); private propActionNames = new Set(); - private hooks: { confirm?: ActionConfirmHook; getPageContext?: () => unknown } = {}; + private hooks: Pick = {}; private acks = new Map(); private els!: { launcher: HTMLButtonElement; panel: HTMLElement; log: HTMLElement; title: HTMLElement; subtitle: HTMLElement; @@ -199,11 +206,13 @@ export class DeweeChatElement extends ElementBase { /** Updates configuration at runtime (wins over attributes). */ configure(partial: Partial): void { - const { actions, confirm, getPageContext, ...rest } = partial; + const { actions, confirm, getPageContext, headers, fetch, ...rest } = partial; const has = (k: BehaviorKey) => Object.prototype.hasOwnProperty.call(partial, k); if (has("actions")) this.setPropActions(actions ?? []); if (has("confirm")) this.hooks.confirm = confirm; if (has("getPageContext")) this.hooks.getPageContext = getPageContext; + if (has("headers")) this.hooks.headers = headers; + if (has("fetch")) this.hooks.fetch = fetch; if (!Object.keys(rest).length) return; this.overrides = { ...this.overrides, ...rest }; if (this.els) this.applyConfig(); // otherwise applied on connect @@ -220,6 +229,14 @@ export class DeweeChatElement extends ElementBase { return this.registry.unregister(name); } + /** Extra request headers (property only; same as `configure({ headers })`). */ + get headers(): RequestHeadersOption | undefined { return this.hooks.headers; } + set headers(v: RequestHeadersOption | undefined) { this.hooks.headers = v; } + + /** Custom fetch for widget requests (property only; same as `configure({ fetch })`). */ + get fetch(): typeof fetch | undefined { return this.hooks.fetch; } + set fetch(v: typeof fetch | undefined) { this.hooks.fetch = v; } + /** Names of the currently registered page actions. */ get actionNames(): string[] { return this.registry.names(); @@ -458,6 +475,12 @@ export class DeweeChatElement extends ElementBase { gatewayUrl: c.gatewayUrl, gatewayToken: c.gatewayToken, turnstile: (siteKey) => this.challenge(siteKey), + // Read the current hooks on every request so configure({ headers, fetch }) applies at once. + headers: () => { + const h = this.hooks.headers; + return (typeof h === "function" ? h() : h) ?? {}; + }, + fetch: (input, init) => (this.hooks.fetch ?? globalThis.fetch)(input, init), }); } return this.api; diff --git a/packages/dewee-webchat/test/request-headers.test.ts b/packages/dewee-webchat/test/request-headers.test.ts new file mode 100644 index 0000000..44807ed --- /dev/null +++ b/packages/dewee-webchat/test/request-headers.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom +// Custom request headers, custom fetch and session reuse in the browser API client. +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { ApiClient, defineDeweeChat, type DeweeChatElement } from "../src/index"; + +interface Seen { path: string; headers: Record } + +function backend(opts: { ttlMs?: number } = {}) { + const seen: Seen[] = []; + let sessions = 0; + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const path = new URL(String(url), "https://site.test").pathname.replace("/api/dewee/", ""); + seen.push({ path, headers: { ...(init?.headers as Record) } }); + const json = (v: unknown) => new Response(JSON.stringify(v), { headers: { "content-type": "application/json" } }); + if (path === "config") return json({ agents: [{ id: "support" }], defaultAgent: "support", allowAgentSwitch: false, allowClientCredentials: false, maxMessageLength: 4000, version: "test" }); + if (path === "session") return json({ visitorId: "v1", token: `tok${++sessions}`, expiresAt: Date.now() + (opts.ttlMs ?? 30 * 86_400_000) }); + if (path === "history") return json({ messages: [] }); + return new Response(`data: ${JSON.stringify({ type: "done", content: "ok" })}\n\n`, { headers: { "content-type": "text/event-stream" } }); + }); + return { seen, fetchImpl, sessions: () => sessions }; +} + +afterEach(() => { + localStorage.clear(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); +}); + +describe("ApiClient headers", () => { + it("merges custom headers into every request and evaluates a function per request", async () => { + const b = backend(); + let n = 0; + const api = new ApiClient({ endpoint: "/api/dewee", fetch: b.fetchImpl as typeof fetch, headers: async () => ({ "X-CSRF-Token": `csrf-${++n}` }) }); + await api.history("support", "conv_abc123"); + await api.chat({ message: "hi", agent: "support", conversationId: "conv_abc123" }, () => {}); + expect(b.seen.map((s) => s.path)).toEqual(["config", "session", "history", "chat"]); + expect(b.seen.map((s) => s.headers["x-csrf-token"])).toEqual(["csrf-1", "csrf-2", "csrf-3", "csrf-4"]); + expect(b.seen[3].headers.authorization).toBe("Bearer tok1"); + }); + + it("never lets custom headers override reserved ones", async () => { + const b = backend(); + const api = new ApiClient({ + endpoint: "/api/dewee", + gatewayUrl: "https://gw.example.com", + gatewayToken: "real", + fetch: b.fetchImpl as typeof fetch, + headers: { Authorization: "Bearer evil", "Content-Type": "text/plain", "X-Dewee-Gateway-Url": "https://evil.test", "x-dewee-gateway-token": "evil", "x-extra": "1" }, + }); + await api.history("support", "conv_abc123"); + const h = b.seen.at(-1)!.headers; + expect(h).toEqual({ "x-extra": "1", "content-type": "application/json", "x-dewee-gateway-url": "https://gw.example.com", "x-dewee-gateway-token": "real", authorization: "Bearer tok1" }); + }); + + it("uses globalThis.fetch by default", async () => { + const b = backend(); + vi.stubGlobal("fetch", b.fetchImpl); + await new ApiClient({ endpoint: "/api/dewee", headers: { "x-a": "1" } }).getConfig(); + expect(b.seen[0].headers["x-a"]).toBe("1"); + }); +}); + +describe("session reuse", () => { + it("reuses a short-lived session until 10% of its lifetime remains", async () => { + vi.useFakeTimers({ now: 1_000_000_000_000 }); + try { + const b = backend({ ttlMs: 10 * 60_000 }); // 10 min tokens + const api = new ApiClient({ endpoint: "/api/dewee", fetch: b.fetchImpl as typeof fetch }); + await api.ensureSession(); + vi.advanceTimersByTime(8 * 60_000); + await api.ensureSession(); + expect(b.sessions()).toBe(1); // 2 min left > 1 min margin + vi.advanceTimersByTime(90_000); + await api.ensureSession(); + expect(b.sessions()).toBe(2); // 30 s left: refreshed + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the 24 h margin for long-lived sessions", async () => { + vi.useFakeTimers({ now: 1_000_000_000_000 }); + try { + const b = backend({ ttlMs: 30 * 86_400_000 }); + const api = new ApiClient({ endpoint: "/api/dewee", fetch: b.fetchImpl as typeof fetch }); + await api.ensureSession(); + vi.advanceTimersByTime(28 * 86_400_000); + await api.ensureSession(); + expect(b.sessions()).toBe(1); + vi.advanceTimersByTime(1.5 * 86_400_000); + await api.ensureSession(); + expect(b.sessions()).toBe(2); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe(" headers and fetch", () => { + beforeAll(() => defineDeweeChat()); + + it("applies configure({ headers, fetch }) and property updates without remounting", async () => { + const b = backend(); + const el = document.createElement("dewee-chat") as DeweeChatElement; + el.setAttribute("endpoint", "/api/dewee"); + el.setAttribute("mode", "inline"); + el.setAttribute("persist", "false"); + let csrf = "a"; + el.configure({ headers: () => ({ "x-csrf-token": csrf }), fetch: b.fetchImpl as typeof fetch }); + document.body.appendChild(el); + await el.send("one"); + csrf = "b"; + await el.send("two"); + el.headers = { "x-csrf-token": "c" }; + await el.send("three"); + const chats = b.seen.filter((s) => s.path === "chat").map((s) => s.headers["x-csrf-token"]); + expect(chats).toEqual(["a", "b", "c"]); + expect(b.seen.every((s) => s.headers["x-csrf-token"])).toBe(true); + }); +}); diff --git a/php/dewee-webchat.php b/php/dewee-webchat.php index 0fd6918..54a17a8 100644 --- a/php/dewee-webchat.php +++ b/php/dewee-webchat.php @@ -31,7 +31,7 @@ public function __construct(string $message, public readonly string $errorCode, final class DeweeWebchat { - public const VERSION = '0.2.0'; + public const VERSION = '0.3.0'; public const PAGE_BLOCK_OPEN = '[[dewee-page v1]]'; public const PAGE_BLOCK_CLOSE = '[[/dewee-page]]'; /** Mirrors ACTION_LIMITS in src/actions/page-block.ts. */