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.6.3 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 reference stack 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.
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
groups
claim 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.
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, similar-resolution lookup, client ticket-trend digest |
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 — a pathological input cannot balloon
token spend or blow the context window.
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), 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 — team-wide or owned by one engineer. |
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 a lookalike email domain cannot misfile a ticket into
the wrong company. - 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.6.3)
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 restrictive
Permissions-Policy.- Server software fingerprints suppressed (
server_tokens off; Next.js
X-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. - Declared-size pre-check: an oversized
Content-Lengthis rejected with
413before the body is buffered — a multi-hundred-MB POST cannot exhaust
the container's memory. The post-parse size check and the nginx cap remain
as further layers. - Magic-byte validation: file content must match its claimed family, not
just its extension. - Stored under UUID filenames in per-session directories — client-chosen
names never touch the filesystem path. - Serving contract: unconditional
nosniff, a fixed content-type map
(never echoing a client-supplied type), and inline rendering only for image
types — everything else is forced to download. This combination is what
makes a hostile upload inert in the browser. - 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).
WebSocket flood controls
- Engine.IO payload buffer capped at 64 KB (down from the 1 MB default).
- Identity-keyed token buckets: chat messages are limited to a burst of
10 per identity with a 10-second refill; high-frequency signals (typing,
visibility, close requests) get a separate, larger bucket so they cannot be
used as a side-channel flood. Buckets are swept and expired so the maps
cannot grow unboundedly. - Message length cap (5,000 characters, matching the form validation) and
strict validation of every client-supplied event payload against
allowlisted values. - Per-identity concurrent-connection caps at the handshake.
- Message idempotency keys with an LRU de-duplication window, so
retries after a flaky connection cannot double-deliver.
Container & host hardening (shipping compose template)
- App runs as a non-root user, with
cap_drop: [ALL]and
no-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 — a directly-reached app instance never
lets a client spoof its own IP to dodge rate limits. - reCAPTCHA fails closed in production — a development-only bypass flag
exists but is refused outright whenNODE_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 about 1 hour ago