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:

  1. 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.
  2. Form input is validated server-side (email format, bounded field lengths,
    description 10–5,000 characters) before anything is stored or sent
    anywhere.
  3. 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:

RoleEnv varDefaultUsed for
fastLLM_FASTanthropic/claude-haiku-4-5-20251001Chat triage, engineer-requested field summaries, similar-resolution lookup, client ticket-trend digest
qualityLLM_QUALITYanthropic/claude-sonnet-4-6Long-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.

ModelWhat it holds
SessionOne 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.
MessageOne transcript line: sender (client / engineer / system), content, optional attachment metadata.
EngineerA dashboard user: email, display name, role, AutoTask resource mapping, and a disabled-at timestamp that blocks sign-in when set (offboarding).
SurveyRatingPost-chat satisfaction: 1–5 stars + optional comment, at most one per session.
CannedResponseSaved 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, with img-src narrowed
    to self / data: / blob: / the reCAPTCHA badge host, object-src 'none',
    frame-ancestors 'self', and connect-src limited 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-By disabled).
  • 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-Length is rejected with
    413 before 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 returns 507, 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-For only when NGINX_TRUSTED_PROXY=1 is 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 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_SECRET must 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.

Did this page help you?