Architecture & Security
This page explains how AVA is built and what protects it, for MSP buyers doing technical due diligence and for marketplace reviewers. Everything below describes the shipping v1.8.0 codebase. For what data the system stores and where it flows, see data-handling.md; for every environment variable mentioned here, see configuration.md.
1. Runtime shape — one Node.js process, by design
AVA is a single-process Next.js 16 (App Router) application fronted by a small custom HTTP server, so Socket.io can attach to the same server Next.js serves from. Everything — the API routes, the WebSocket layer, the in-memory chat queue and engineer-presence maps, authentication, AI calls, and AutoTask calls — runs in that one process, inside one Docker container.
┌──────────────────────┐ ┌──────────────────────────┐
│ Client Chat Page │ │ Engineer Dashboard │
│ React + Socket.io │ │ React + Socket.io │
│ (no login; reCAPTCHA)│ │ (Entra ID SSO cookie) │
└─────────┬────────────┘ └────────────┬─────────────┘
│ WebSocket + HTTPS │ WebSocket + HTTPS
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Custom server (http + Next.js 16 + Socket.io) │
│ ┌───────────┬────────────┬─────────────┬──────────────┐ │
│ │ API Routes│ Socket.io │ Queue + │ Auth: │ │
│ │ (App Rtr) │ (shared │ Presence │ MSAL Node + │ │
│ │ │ instance) │ (in-memory) │ signed JWTs │ │
│ └───────────┴────────────┴─────────────┴──────────────┘ │
└──────┬──────────────┬──────────────┬───────────┬──────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌───────────┐ ┌────────────┐ ┌──────────┐ ┌────────────┐
│ AutoTask │ │ AI provider│ │ MS Entra │ │ SMTP2GO │
│ REST API │ │ (pluggable:│ │ ID (O365)│ │ (email) │
│ (server- │ │ Anthropic,│ │ engineer │ │ │
│ side) │ │ OpenAI,…) │ │ SSO only │ │ │
└───────────┘ └────────────┘ └──────────┘ └────────────┘
│
▼
┌────────────────────────┐
│ SQLite (Prisma 7) │
│ on a Docker volume │
└────────────────────────┘
Why no Redis, no PostgreSQL, no worker fleet: a helpdesk chat for an MSP team (a handful of engineers, tens of concurrent sessions) does not need distributed infrastructure, and every extra service is another thing to patch, secure, and pay for. The design targets are deliberate:
- SQLite over a database server — zero extra RAM, zero network attack surface, one file to back up. Durable state (sessions, transcripts, ratings) lives here.
- In-memory queue and presence over Redis — the waiting-chat queue and engineer online-status maps are plain in-process structures. On a restart, the process rebuilds them from SQLite, so a container recreate loses nothing durable.
- One container — the production template is three containers total: the app, an nginx TLS terminator, and a certificate-renewal sidecar. A 1 vCPU / 2 GB host runs it comfortably; the app container itself is capped at 768 MB.
The production deployment template that ships with the product (docker-compose.production.template.yml + nginx/default.conf.template) encodes this shape, including all the container hardening described in §6.
Real-time layer
Socket.io carries the live conversation: message delivery with acknowledgement and idempotent retry (a flaky client connection never silently drops or duplicates a message), typing indicators, queue-position updates for waiting clients, client-presence ("the client closed their tab") signals for engineers, and dashboard-wide queue broadcasts. Rooms isolate traffic: each chat session has its own room, and engineers share a dashboard room; a client socket is never joined to another session's room. Engineer-only content — internal notes, activity rows, private command results and AI suggestions — is delivered only to the engineer handling the chat, never to the client.
2. Authentication model
AVA has two very different audiences, and two correspondingly different auth models. Both end in the same place: a signed, expiring JWT (HS256, signed with the deployment's NEXTAUTH_SECRET) that the server verifies on every request.
Clients (the public chat page)
Clients never create accounts. Instead:
- The contact form is protected by Google reCAPTCHA v3 (invisible) and a per-IP rate limit (3 new sessions per hour). In production, reCAPTCHA verification fails closed — if the verification key is missing or Google is unreachable, the session is refused rather than waved through.
- Form input is validated server-side (email format, bounded field lengths, description 10–5,000 characters) before anything is stored or sent anywhere.
- On success the server issues a signed session token scoped to that one chat session. Every subsequent client action — messages, file uploads, the satisfaction survey — must present that token, and the token only grants access to its own session.
Engineers (the dashboard)
Engineers sign in with Microsoft Entra ID (O365) single sign-on. The OAuth flow is entirely server-side (confidential client via MSAL): the browser never holds Azure tokens.
- Sign-in is fail-closed on group membership: the ID token's
groupsclaim must contain the security group you configure (ENGINEERING_GROUP_ID). A missing claim, a malformed claim, a user outside the group, or an unconfigured group ID all deny access — there is no "default allow" path. - On success the app issues its own JWT (24-hour expiry) in an HttpOnly cookie — inaccessible to page JavaScript.
- Every API request re-verifies the cookie and re-checks that the engineer account is still active in the database, so offboarding an engineer takes effect immediately, not at token expiry.
- Session-scoped engineer routes additionally enforce ownership: an engineer can only act on a chat assigned to them (closed sessions relax to team-readable archives). The ownership check resolves the engineer's current database record — it never trusts an ID baked into an old token — and fails closed if the lookup misses.
- Ticket changes from the engineer console (status, priority, internal notes) are limited to the engineer handling the chat, always act on that chat's own ticket, require an explicit confirmation for status and priority, and are rate-limited per engineer (
TICKET_WRITE_RATE_LIMIT_PER_MIN, default 10 per minute).
WebSocket handshake
No socket is admitted without authentication. A middleware verifies the presented credential during the handshake — clients present their signed session token in the Socket.io auth payload (not the URL, so it never lands in web-server access logs); engineers present their session cookie. Identity fields a socket claims about itself are never trusted directly: what a socket may do is derived from the verified token, and per-identity connection caps bound how many sockets one identity can hold open.
3. AI layer — provider-agnostic and bounded
All AI calls go through the Vercel AI SDK behind a two-role model factory:
| Role | Env var | Default | Used for |
|---|---|---|---|
fast | LLM_FAST | anthropic/claude-haiku-4-5-20251001 | Chat triage, engineer-requested field summaries ("Draft all"), similar-resolution lookup, client ticket-trend digest, and the engineer /draft, /rewrite and /summarize commands |
quality | LLM_QUALITY | anthropic/claude-sonnet-4-6 | Long-form transcript summarization |
Supported providers: Anthropic, OpenAI, Google, OpenRouter — switching is an env-var change plus that provider's API key. Your deployment talks directly to the provider you configure with your own key; there is no vendor-operated AI proxy in the path.
Guardrails on every call:
- Structured output where structure matters. Triage uses schema-validated object generation — the model's answer is validated against a schema and additionally cross-checked against your live AutoTask picklists (an invalid priority or issue-type ID is discarded, not written to a ticket).
- Graceful degradation. If the model errors, times out (15 s default, configurable), or returns something unparseable, triage falls back to a safe default classification and the chat proceeds normally. AI is an accelerant, never a dependency for serving a client.
- Bounded inputs. Transcripts sent for summarization are capped (~24k characters, most-recent kept); ticket lists and free-text fields fed to prompts are length- and count-capped, so token spend stays predictable.
- Audience-aware context. AI text that can reach the client (a suggested reply, a rewrite, the client-facing and resolution notes) is generated only from the conversation the client can see. Internal notes inform only engineer-private output, such as the
/summarizerecap and the internal note draft. - Engineer-reviewed, never auto-sent. Engineer AI output appears as a suggestion the engineer inserts, edits or discards.
- Usage limits. Engineer AI calls are limited per chat and per engineer per hour (
AI_MAX_CALLS_PER_SESSION,AI_MAX_CALLS_PER_ENGINEER_PER_HOUR), and each call logs one usage line with token counts — never prompt or output text.
Exactly what data each call site sends to the provider is enumerated in data-handling.md.
4. Data model overview
Five tables in SQLite (Prisma 7 with the libsql driver adapter). Schema changes ship additively and are applied idempotently at container start.
| Model | What it holds |
|---|---|
Session | One chat: lifecycle status (queued → active → closed, plus transferred), the client's contact fields, the issue description, AI triage results, AutoTask linkage (ticket / company / contact IDs), wrap-up draft + progress checkpoints, optional device-lookup hints, timestamps. |
Message | One transcript line: sender (client / engineer / system, plus engineer-only internal notes and activity rows that are never shown to the client), content, optional attachment metadata. |
Engineer | A dashboard user: email, display name, role, AutoTask resource mapping, and a disabled-at timestamp that blocks sign-in when set (offboarding). |
SurveyRating | Post-chat satisfaction: 1–5 stars + optional comment, at most one per session. |
CannedResponse | Saved replies (snippets) — team-wide or owned by one engineer, with an optional /shortcut. |
The wrap-up ("close with details") flow that writes a finished chat back to AutoTask is checkpointed: each step (time entry, notes, transcript, attachments, resolution, status) records its completion, so a retry after a mid-step AutoTask failure resumes where it stopped instead of duplicating time entries or notes. Concurrent double-submits of the same closure are rejected.
5. Integrations follow least privilege
- AutoTask — all calls are server-side; credentials never reach any browser. The API user needs only the entities the product touches (tickets, notes, time entries, attachments, companies, contacts, resources, picklist metadata). Company matching prefers exact and subdomain matches over substring matches, so tickets land in the right company. Engineer-only notes are posted with your instance's Internal Only publish value (
AUTOTASK_NOTE_PUBLISH_INTERNAL). - NinjaOne (optional) — the device-glance card uses an OAuth2 client-credentials app that you create with monitoring scope only: the credential is read-only at the API level and physically cannot modify a device. AVA's NinjaOne client is GET-only by design. The one AutoTask write this feature can perform (attaching the matched Configuration Item to the ticket) is behind its own explicit opt-in flag, default off. With no NinjaOne variables set, the feature is fully dormant — no network calls, no UI.
- Microsoft Entra ID — used solely for engineer sign-in. No client data ever flows to Entra.
- Email (SMTP2GO) — outbound only. Every sender and recipient address is operator-configured; there are no vendor fallback addresses baked into the product, and unconfigured email features skip the send rather than mailing a default.
6. Security posture — controls implemented (v1.8.0)
The list below is what the shipping product actually enforces, grouped by layer. Where a control is tunable, the env var is named — defaults are the secure setting.
Transport & HTTP headers (nginx template + app)
- TLS terminated by nginx with Let's Encrypt certificates and automatic renewal built into the compose stack (a renewal loop every 12 h, graceful nginx reloads that keep WebSockets alive).
- HSTS:
max-age=31536000; includeSubDomains. - Content-Security-Policy without
unsafe-eval, withimg-srcnarrowed to self /data:/blob:/ the reCAPTCHA badge host,object-src 'none',frame-ancestors 'self', andconnect-srclimited to self + WebSocket + reCAPTCHA verification. X-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN,Referrer-Policy: strict-origin-when-cross-origin, a restrictivePermissions-Policy.- Server software fingerprints suppressed (
server_tokens off; Next.jsX-Powered-Bydisabled). - Request bodies capped at the proxy (
client_max_body_size 5M).
File uploads
- Type allowlist (common image, document, archive, and text types), 5 MB per file.
- Size checks at both the proxy and the app; oversized uploads are rejected early with
413. - Content verification: file content must match its claimed type, not just its extension.
- Stored under generated filenames in per-session directories — the client's original filename is display metadata only.
- Safe serving: a fixed content type per file type, inline display only for images, and everything else delivered as a download.
- Per-IP upload budget (
UPLOAD_MAX_PER_IP_PER_HOUR, default 30) and a global disk quota (UPLOAD_TOTAL_MAX_BYTES, default 2 GB — exceeding it returns507, it does not fill the disk). - Retention job: uploads for closed sessions are deleted after a configurable window (default 30 days) by an in-process scheduled job. See
data-handling.md. - Upload and download are both authenticated (client session token or engineer cookie, with session-ownership enforcement).
Real-time (WebSocket) limits
- Payload-size limits on every socket message.
- Per-identity rate limits on chat messages and on high-frequency signals such as typing indicators.
- Message length cap (5,000 characters, matching the form validation) and validation of every client-supplied event payload.
- Per-identity concurrent-connection caps at the handshake.
- Message de-duplication, so a retry after a flaky connection never delivers a message twice.
Engineer console (v1.8.0)
- Engineer-only content stays with engineers. Internal notes, activity rows and AI suggestions are never shown on the client's chat page, in client emails, or in the client-facing AutoTask note, resolution or time entry.
- Commands are never sent as chat messages. A draft that starts with
/is handled as a command, not delivered to the client. - Ticket edits are deliberate: owner-only, confirmed, per-engineer rate limited, and the Complete status is reserved for the wrap-up flow.
- AI is bounded: per-chat and per-engineer-hour limits, one running request per command, and suggestions that are never sent automatically.
Container & host hardening (shipping compose template)
- App runs as a non-root user, with
cap_drop: [ALL]andno-new-privileges. - Read-only root filesystem; only the data volume (SQLite), the uploads volume, and explicit tmpfs mounts are writable.
- Memory limit (768 MB) and CPU limits on the app container, so the app cannot starve the proxy on a shared host.
- Log rotation (JSON-file driver, 10 MB × 3) on all three services.
- Secrets live only in a host-side config file mounted read-only into the container at start — never baked into the image, never committed to the repository. The entrypoint warns if that file is group- or world-readable.
Request authenticity & boot-time validation
- Proxy-header trust is opt-in: client IPs are taken from
X-Forwarded-Foronly whenNGINX_TRUSTED_PROXY=1is explicitly set (correct when the app sits behind the provided nginx). In any other configuration the header is ignored. - reCAPTCHA fails closed in production — verification always applies when
NODE_ENV=production. - The container refuses to boot half-configured: a required-variable check covers all integration credentials (including the AI key matching whichever provider you configured),
NEXTAUTH_SECRETmust be at least 32 characters and is rejected if it looks like a placeholder, and schema sync must succeed before the server starts.
Privacy-conscious logging
- Application logs identify chats by session ID, not client identity — client names, email addresses, and companies are not written to request logs, and AI-derived summaries (which restate the client's issue) are not logged either.
- Device-identifying URL parameters on the client landing page are scrubbed from the browser URL immediately after capture, and the provided nginx log format logs the request path without query strings — so those hints stay out of access logs too.
What to read next
data-handling.md— exactly what personal data is stored, where it flows, and the retention/erasure levers.requirements.md— the accounts and credentials you need to provision.configuration.md— the full environment-variable reference, including every tunable named above.quickstart.md— bringing a deployment up end-to-end.
Updated 15 days ago