1. Stack
| Backend | Node.js 24 LTS · Express 4 · raw pg driver (no ORM at the query layer, Drizzle for schema definitions only) |
| Database | PostgreSQL 16 · single shared deployment · per-tenant row isolation via tenant_id |
| Frontend | Vue 3 SFC · Pinia · Naive UI · Vite IIFE bundle · ECharts for charts |
| Auth | JWT RS256 (RSA keypair) · TOTP 2FA · HTTP-only cookies for refresh · CSRF double-submit on mutating routes |
| Deployment | Linux/Windows · nginx front · Node behind on a private port · Postgres on its own service · TLS terminated at the edge |
| Storage | File attachments on local disk; S3-compatible bucket (BYO for data-residency) · on the roadmap |
2. Keeping every customer's data separate
Tenant isolation is enforced at the database layer, so the application code never has to remember it.
Every tenant-scoped table carries a
tenant_id integer column.
Every authenticated request lands on a route protected by the requireTenant
middleware, which reads req.user.tenantId from the JWT and sets
req.tenantId. From there, Postgres Row-Level Security does the enforcing: an
rlsContext middleware runs SET LOCAL app.tenant_id
at the start of each request's transaction, and a FORCE RLS policy on every tenant table makes the database itself
filter every read and write to that tenant. A query that forgets to scope returns nothing, never another tenant's rows.
Cross-tenant access returns 404 (never 403) so the tenant id space can't be enumerated. Partner users
have an additional scope: requireVendor middleware pins requests to their
vendor_id as well, so a partner user authenticated to tenant A cannot see
tenant B's data even if both tenants share that partner.
Tenant-scoped tables (partial list)
tenants, users, projects, equipment_items, fieldops, fieldopnotes, vendor_credentials, platform_licenses, discussion_threads, thread_messages, thread_status_changes, thread_mentions, thread_reads, room_status, room_schedules, vendor_invite_tokens, project_vendor_messages, cisco_devices, cisco_locations, cisco_workspaces, ...
3. How logins and 2FA work
APEX issues JWT access tokens signed with an RSA private key (RS256). The public verification key is loaded
from disk at boot. Tokens carry userId, email,
name, role, tenantId,
and (for partner users) vendorId.
Both the access and refresh tokens are moving to httpOnly cookies (apex_access / apex_refresh), so the token is not readable by JavaScript; a legacy bearer header is still accepted during the cookie migration. CSRF protection uses a double-submit pattern: a non-HTTP-only
apex_csrf cookie is read by the SPA and echoed back in the
X-CSRF-Token header on mutating requests.
TOTP 2FA is built into the auth layer and recommended for owner / admin roles; self-serve enrollment is rolling out. The secret is encrypted at rest with an AES-256-GCM key loaded from an environment variable that fails-fast at boot.
Auth endpoints
- POST /api/auth/login
- POST /api/auth/refresh
- POST /api/auth/logout
- POST /api/auth/tenant-signup
- POST /api/auth/2fa/enroll
- POST /api/auth/2fa/verify-enroll
- POST /api/auth/2fa/disable
- POST /api/auth/verify-totp
- POST /api/auth/forgot-password
- POST /api/auth/reset-password
4. Who can do what: six roles
Each role sees and touches only what the job requires. The exact reach:
| Role | Can do | Cannot do |
|---|---|---|
| owner | Everything in the tenant including user + role management, tenant settings, billing | Touch other tenants |
| admin | All project / partner / license / report ops; invite users; manage non-owner roles | Touch owner role, delete tenant, modify billing |
| project_manager | Manage projects + tasks + visits + threads on projects they have membership in | User mgmt, license mgmt, tenant settings |
| field_ops | Read all projects, update fieldops visits they're assigned to, post notes | Mutate projects, manage partners, see admin pages |
| auditor | Workspace-wide read-only, including the full audit log, built for compliance reviewers | Any write operation; tenant config |
| viewer | Read-only across all projects + reports | Any write operation |
Role enforcement is in middleware (requireRole(['admin','owner']),
denyVendor, requireVendor) ahead of
every route. Routes that need finer access (e.g. a PM can only edit projects they're a member of) use
getProjectAccess to look up membership. Partners (your AV vendors) sign in to a
separate, scoped portal via requireVendor rather than holding a tenant role.
5. A complete record of who changed what
Every change is logged: who did what, and when. Each mutating endpoint runs through an
auditLog(action, category, severity)
middleware that writes a row into the auditlog table
after the response is sent, capturing:
id, tenant_id, timestamp, user, action, resource,
details (jsonb), category, severity, ipAddress,
projectId, taskId, created_at
Audit logs are append-only (no UPDATE or DELETE policy on the table from the runtime apex_app
role) and tenant-scoped. Today they're queryable via the admin API as JSON; full before/after diffs and CSV/SIEM export are on the roadmap.
Audit query endpoints
- GET /api/admin-portal/administration/audit
6. Connect your own systems: the REST API
Anything you can do in the app you can do from your own tools. All endpoints are mounted under
/api. JSON in, JSON out. Auth is an httpOnly cookie
(apex_access) set at login and sent automatically by the browser, never exposed to
JavaScript. The access token is an RS256 JWT, refreshed through a separate httpOnly cookie. (A legacy
Authorization: Bearer header is still accepted during the cookie migration.)
Tenant admin
- /api/projects, /api/projects/:id/...
- /api/fieldops, /api/fieldops/:id/{approve,decline,counter-propose}
- /api/equipment, /api/equipment/:id/attachments
- /api/vendors, /api/vendors/:id/credentials
- /api/licenses, /api/licenses/summary
- /api/threads/:type/:itemId
- /api/rooms, /api/room-status
- /api/locations
- /api/admin-portal/administration/users, /api/roles
- /api/admin-portal/administration/audit
- /api/reports
- /api/cisco/...
Partner portal
- /api/vendor/projects
- /api/vendor/equipment/:id
- /api/vendor/equipment/:id/service-events
- /api/vendor/equipment/:id/attachments
- /api/vendor/locations/:id/documents
- /api/vendor/fieldops, /api/vendor/fieldops/:id/{mark-on-site,complete,reschedule,cancel,accept-counter}
- /api/vendor/credentials
- /api/vendor/licenses
- /api/vendor/threads/:type/:itemId (same shape as tenant threads, partner-scoped)
Full per-route OpenAPI specification is available on request; the auto-generated reference is being built and will publish here when ready.
7. How the data is structured
The full schema lives in
node/drizzle/schema.ts (~50 tables). The relationships that matter
most if you're building against it:
- projects has a JSONB
taskscolumn holding the phased task list with prerequisites + subtasks. Equipment items referenceproject_id. - fieldops (onsite visits) reference project + partner; the row carries the full lifecycle state including outcome, structured reschedule reason, ticket numbers (JSONB array), and equipment items touched (JSONB array of equipment IDs).
- discussion_threads are polymorphic on
(work_item_type, work_item_id)withwork_item_type ∈ {project, equipment, room, fieldop, sitevisit}. Thread messages carry author role (client / partner) and an is_internal_only flag. - vendor_credentials with nullable
tenant_id(NULL = global to the partner, set = scoped to a specific tenant for their compliance requirements). A derived viewvendor_credentials_with_statusexposes valid / expiring_soon / expired. - platform_licenses with
managing_vendor_idlinking to the partner responsible for renewals. Derived viewplatform_licenses_with_statusexposes active / renewal_upcoming / renewal_imminent / expired. - cisco_devices mirrors what Control Hub knows; rows are joined into project equipment when the asset tag / serial matches.
8. How APEX connects to your other platforms
APEX pulls live data from the manufacturer platforms you already run, so room health and inventory stay current without anyone re-keying it. Each connection ("plugin") runs in the Node backend, exposes only a bounded set of actions, and uses per-tenant OAuth credentials stored encrypted in the database. Sync jobs respect rate limits and record an audit entry for every external call.
| Plugin | Status | Capabilities |
|---|---|---|
| Cisco Control Hub | SHIPPED | Live device sync, OAuth token auto-refresh, rate-limited polling, full audit trail emission |
| Microsoft Graph + Teams Rooms | Q3 2026 | MTR pairing state, calendar bookings, peripheral inventory |
| Crestron XiO Cloud | FUTURE | Device monitoring, room scheduling, firmware deployment |
| Q-SYS Reflect | FUTURE | Core health, design version sync, alerting |
| Logitech Sync | FUTURE | Rally / Tap fleet management, firmware push |
Plugins are NOT free-form code execution. Each one exposes a typed capability surface (sync devices, sync rooms, fetch usage telemetry). Tenants opt in per-plugin; OAuth flows happen per-tenant; no credential is ever shared across tenants.
9. How and where APEX runs
The footprint is small and standard. Host it yourself or let us host it. A typical deployment runs three processes: nginx at the edge for TLS termination and static asset serving, Node.js on a private port for the API, and PostgreSQL on its own service for the database. File attachments go to local disk today; S3-compatible bucket storage is on the roadmap.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Internet │ → │ nginx │ → │ Node API │
│ │ │ :443 TLS │ │ :3001 │
└──────────────┘ └──────────────┘ └──────┬───────┘
│ │
│ static │ pg
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Vue bundle │ │ Postgres │
│ + marketing │ │ :5432 │
└──────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ S3-compat │
│ (roadmap) │
└──────────────┘
Hosting model: APEX runs as a shared multi-tenant deployment (one Postgres database serving all tenants with row-level scoping). On-prem deployment is supported; managed cloud is the default. Each managed tenant gets its own subdomain and tenant_id scope, not its own VM.
10. Security posture
APEX is built against an internal APEX Security Review Baseline (ASRB). The major properties:
- No hardcoded secrets. Every credential, key, and signing material lives in environment variables or external secret stores. Required env vars are validated at boot and the process fails-fast if any are missing.
- No SQL injection vectors. All queries use parameterized statements via the
pgdriver. No template-string SQL construction. Audited as a static analysis step. - JWT signing keys fail-fast validated on boot. The RS256 keypair is loaded from disk; missing or malformed keys prevent server startup.
- Database role separation. The runtime
apex_approle has CRUD on tables but no DDL. Schema changes go through a separateapex_ddlrole used by migrations. - CSRF protection via double-submit cookie pattern on all mutating endpoints. The non-HTTP-only
apex_csrfcookie is echoed back in theX-CSRF-Tokenheader. - Password handling. Bcrypt with cost 12 for the password hash. Forgotten-password flow uses single-use tokens with short TTL.
- TLS terminated at nginx with HSTS. TLS 1.2+ only (1.0/1.1 disabled). HTTP requests redirect to HTTPS.
- Per-tenant data isolation enforced at the database layer by Postgres Row-Level Security (FORCE RLS), not just in application code. A route that forgets to scope a query returns nothing rather than another tenant's rows. The database fails closed.
- Audit trail append-only, tenant-scoped; CSV/SIEM export on the roadmap (see section 5).
Open questions / improvements on the roadmap: optional WebAuthn / passkey support (instead of TOTP only), per-row encryption at rest for license keys + credential numbers, full SOC 2 audit cycle (currently building toward Type 1 readiness).
Engineering review request?
We'll walk your engineering team through the actual code and answer specific questions about isolation, audit, or any of the security properties above.
Schedule a reviewBuild your own integration?
The REST API is stable and documented. We're happy to share the OpenAPI spec and walk through a custom plugin or integration with your existing systems.
Request API access