Il widget embeddabile è un servizio separato (packages/widget, porta 3211) che espone
una chat AI sul tuo sito. Architettura: Express + Preact IIFE in iframe sandboxed,
proxy SSE trasparente verso il server, sessioni anonime, lead capture, branding per-sito.
Il widget è una feature Enterprise (license
widget_enabled); Community ha 1 widget di default ma la creazione richiede il flag attivo.
Come embeddare (quick start)
-
Crea il widget (admin UI → Settings → Widgets, o API):
POST /api/widgets Authorization: Bearer <admin JWT> { "name": "Sito Azienda", "allowedOrigins": ["https://www.azienda.it"], "workspaceIds": ["<uuid>"], "primaryColor": "#4c6ef5", "botName": "Assistente" }Risposta:
{ widget }conwidget.id. -
Whitelist knowledge base —
PUT /api/widgets/:id/workspacescon gli UUID dei workspace il cui RAG sarà accessibile al widget (tabellaWidgetWorkspace, PK composita[widgetId, workspaceId]). -
Inserisci lo snippet nella pagina host:
<div id="simmetric-widget" data-widget-id="<widget.id>" data-position="bottom-right"></div> <script src="https://widget.tuodominio.it/widget/<widget.id>.js" integrity="sha384-<HASH>" crossorigin="anonymous" async ></script>
Sicurezza: se servi il loader da un CDN di terze parti, genera l'hash SHA-384 del bundle e aggiungi
integrity+crossorigin="anonymous"(Subresource Integrity). Se il loader è same-origin con il tuo widget service, SRI è opzionale ma consigliato per difendere da compromissione del CDN/asset.
Il loader (packages/widget/src/routes/loader.ts) legge gli attributi data-*, crea un
<iframe sandbox="allow-scripts allow-forms"> (no allow-same-origin, no
allow-top-navigation), carica /widget/<id> (HTML) che monta il bundle Preact
/widget/app.js.
Configurazione branding & trigger
Campi del modello Widget (validati da widget.schema.ts in shared):
| Campo | Default | Descrizione |
|---|---|---|
primaryColor | #4c6ef5 | Colore accento (regex hex) |
botName | AI Assistant | Nome bot |
logoUrl / avatarUrl | — | URL immagine (http/https) |
welcomeMessage / fallbackMessage | — | Messaggi iniziale/fallback |
position | bottom-right | bottom-right/bottom-left |
allowedOrigins | — | Array URL (max 50) per CORS dinamico |
autoOpenDelay | 0 | ms auto-open (0 = disabled, 1–300s) |
autoOpenUrlPatterns | — | Glob patterns (**,*,?) per auto-open su URL |
exitIntentEnabled | false | Apertura su exit-intent |
exitIntentCooldownMs | — | 60000–86400000 ms |
leadCaptureEnabled | false | Lead capture |
leadCapturePrompt | — | Prompt lead (max 500) |
Query param URL (?primaryColor=...&position=...&locale=...) override runtime.
Trigger (widget/hooks/useTriggers.ts): ascolta postMessage dal loader —
simmetric:urlChange (check autoOpenUrlPatterns via globToRegex), simmetric:exitIntent
(open con cooldown). Exit intent rilevato lato loader via mouseleave con
e.clientY <= 10.
Autenticazione & API key
- Widget ↔ Server: header
X-Api-Key: <WIDGET_API_KEY>verso/api/internal/widget/*. La key deve matchare trapackages/widget/.envepackages/server/.env. - Visitatore ↔ Widget service: header
x-session-token(sessione anonima, non JWT). - CORS dinamico (
widgetCors.ts): header CORS per-origin basati suallowedOrigins. Fail-closed su DB error (no header = browser blocca).
Sessioni anonime
POST /api/sessions (widget) → server crea WidgetSession: token 256-bit hex
(crypto.randomBytes(32)), 24h expiry, IP logged. sessionMiddleware valida il token
ad ogni richiesta chat e attacca req.widgetSession + req.widgetConfig. A scadenza →
401 → il Preact useWidgetConfig ricrea la sessione transparentemente.
Chat flow (SSE proxy)
Visitatore → POST /api/chat/:widgetId/stream (x-session-token, body {message, chatId?})
→ sessionMiddleware valida sessione
→ check hourlyRemaining (DB-tracked, 20/h) → 429 se esaurito
→ increment messageCount (fire-and-forget)
→ pre-search RAG: POST /api/internal/widget/search {query, widgetId}
server risolve workspaceIds dalla whitelist WidgetWorkspace (IDOR prevention)
→ proxy a SERVER_URL/api/workspaces/:workspaceId/chat/stream
headers: X-Api-Key, X-Widget-Id, X-Widget-Session-Id, X-Accel-Buffering: no
→ relay byte SSE verbatim (responseType: "stream")
→ Preact useWidgetChat parse: token | citations | done(modelUsed,mcpSources,dlp_warning) | error
Il proxy è trasparente: non parse/filtra/trasforma gli eventi SSE. Se la pre-search
RAG fallisce, emette status: rag-degraded ma la chat continua senza contesto.
Lead capture
POST /api/lead/:widgetId (widget, sessionMiddleware):
- Check
config.leadCaptureEnabled(403 se off). - Body
widgetLeadSubmitSchema:email(req),name(opt),transcript[](role/content/timestamp). submitLead(widgetId, email, name, transcript, session.id)— passasession.id(UUID FK).- Limiter 3 leads/hour per IP.
- Admin recupera:
GET /api/widgets/:id/leads, export CSVGET /api/widgets/:id/leads/export(licenselead_export).
Rate limit (doppio layer)
| Limiter | Scope | Limite (prod) | Window |
|---|---|---|---|
| widgetChatLimiter | per widgetId | 30/min | 1 min |
| widgetSessionLimiter | per IP | 5/day | 24h |
| widgetLeadLimiter | per IP | 3/hour | 1h |
| server hourlyRemaining | per sessione | 20/h | 1h |
| server dailyRemaining | per sessione | 5/24h | 24h |
Sicurezza
- iframe sandbox
allow-scripts allow-forms(no same-origin, no top-navigation). helmetrilassato per embedding (no CSP, no frameguard, no COOP/COEP/CORP).- Messaggi renderizzati via
markdown-it+dompurify(anti-XSS). - DLP server-side:
dlp_warningnell'SSEdone→ widget mostraPIIWarningPrompt. - No cookie, no localStorage dal sandboxed iframe.
- Loader usa
getAttribute()(noinnerHTML) +encodeURIComponent.
MCP (indiretto)
Il widget non gestisce MCP: installa/unpin avviene nel server admin. I workspace
linked possono avere MCP tool installati → l'agente li usa durante la chat widget
automaticamente (zero codice widget). mcpSources nel SSE done indica quali MCP
hanno contribuito.
Estendere
- Nuovo componente →
packages/widget/src/widget/components/(Preact + Tailwind, no shadcn/ui). - Nuovo trigger → estendi
useTriggers.ts+ campoWidget+ routeinternalWidget.ts. - Nuovo rate limit →
src/middleware/rateLimit.ts+ mount inindex.ts. - CSS themable → namespace
--widget-*insrc/widget/index.css; statiche con Tailwind utility.
File chiave
| File | Ruolo |
|---|---|
packages/widget/src/index.ts | createApp() factory, mount routes |
src/routes/loader.ts | Loader JS + HTML iframe |
src/routes/chat.ts | SSE proxy + pre-search RAG |
src/routes/session.ts | Sessione anonima |
src/routes/config.ts | Config (cache 5 min) |
src/routes/lead.ts | Lead capture |
src/middleware/session.ts | sessionMiddleware |
src/widget/App.tsx | Root Preact, hook coord |
src/widget/hooks/{useWidgetChat,useWidgetConfig,useTriggers}.ts | State |
Cross-link
- Endpoint widget: 04 — API Reference
- Internals RAG/agent: 05 — Feature Guide