MSO Cloud · Documentation

Technology

Source: docs/architecture/tech-stack.md Updated 2026-09-21
On this page

Each layer of MSO Cloud is chosen against a specific constraint the platform has to hold: many tenants sharing one warehouse, VND amounts that have to be exact, and a metric definition that has to be the same for the dashboard, the data assistant, and alerts.

Summary#

Layer Technology Why
Web Next.js 16 App Router / RSC Read-heavy dashboards render server-first; apps/web stays thin, business logic lives in workspace packages.
API tRPC v11 + Zod + superjson A typed boundary end to end; every resolver runs inside an RLS context, every payload has a named Zod schema.
ORM / DB Drizzle ORM + Postgres 16 + FORCE RLS Tenancy is enforced inside the database (org, then brand), not only in application code; a table missing its policy fails an inventory check.
Runtime identity Non-bypass roles + transactional outbox Runtime roles cannot bypass row-level security; a business row and its async intent commit in the same transaction.
Queue BullMQ 6 on Redis Durable, idempotent, rate-limited ingest; per-org scheduled jobs on sub-minute cron.
Auth NextAuth v5 + LDAP/AD Password, magic-link, passkey, and TOTP for the platform, plus a per-org enterprise directory login (ADR 0034).
Semantic layer Cube One metric definition, generated from the metric catalog, shared by the dashboard, the data assistant, and alerts.
Charts ECharts via @yng/charts One shared theme, standard VND and Indochina-time formatters, exact exports registered for the client bundle.
Encryption Envelope encryption via @yng/crypto Connector credentials are wrapped under a versioned key; rotating a key does not invalidate data written under the old one.
Data warehouse Medallion layers + promoters Bronze, Silver, Gold; idempotent promoters make the pipeline replayable and auditable.
AI @yng/ai failover Provider-agnostic per org, an ordered failover chain, the model hidden from the end user.
Edge *.msocloud.net tenant domains A subdomain per tenant plus white-label domain support, under one wildcard certificate.

Why each layer#

Next.js 16 App Router / RSC

The platform's main surface is a read-heavy dashboard. React Server Components render on the server, which keeps apps/web thin: rules, schemas, formatting, and connector logic live in workspace packages, and client components are limited to interactive islands. This keeps the JavaScript shipped to the browser small and time-to-first-byte low for large KPI tables.

tRPC v11 + Zod + superjson

The client-to-server boundary is typed without a separate REST code-generation step. Every input is a named Zod schema; superjson preserves types such as Date and bigint across the wire. Every org-scoped resolver runs inside an RLS context, so authorization and typing live together in one place.

Drizzle ORM + Postgres 16 + FORCE RLS

Tenancy is enforced inside the database, not left to application code. Every org-scoped table carries a read policy keyed to the app.current_org_id session variable (brand-partitioned tables also key on app.current_brand_id); a database-backed inventory fails if any table is missing its policy. FORCE RLS is on even for the table owner, so row-level security is a real backstop for trusted code, not only for external connections.

Non-bypass runtime roles + transactional outbox

The production runtime database roles are NOSUPERUSER NOBYPASSRLS: they cannot bypass row-level security even with arbitrary SQL. Grants are generated from a single exact, default-deny ACL inventory. Durable async work is never published straight to the queue: the business row and its outbox intent are written in the same transaction, and a dispatcher leases, publishes, and retries idempotently, so the business record and the intent to act on it never drift apart.

BullMQ 6 on Redis

Ingest has to be durable, idempotent, and rate-limited: a watermark only advances on success, duplicate payloads are deduplicated by content hash, and permanent failures land in a dead-letter queue that self-heals on the next successful sync. A shared Redis token bucket coordinates each marketplace's rate limit across workers, and scheduled jobs run per org on sub-minute cron.

NextAuth v5 + LDAP / AD

Platform login supports password, magic-link, passkey, and TOTP two-factor. Enterprise customers need directory-based login scoped per org (ADR 0034): the bind fails closed, LDAPS is the default, matching is by email rather than auto-provisioning, super-admin accounts are refused over LDAP, and outbound connections are restricted to an allow-list, because the directory is controlled by the org's own admin and the blast radius of a misconfiguration has to stay contained to that org.

Cube semantic layer

A metric is defined exactly once. The Cube schema is generated from the metric catalog rather than hand-edited, so the dashboard, the data assistant, and alerts read the same definition instead of three definitions that can drift apart. Tenant isolation is enforced at the driver layer through a dedicated read-only database role.

ECharts via @yng/charts

Charts share one registered theme, plus standard VND and Indochina-time formatters. The client bundle registers only the chart exports it actually uses rather than importing the whole namespace, which keeps the shipped payload smaller.

Envelope encryption via @yng/crypto

Connector credentials are envelope-encrypted with AES-256-GCM: every write is stamped with a key version, and every read resolves against that envelope's own key. Rotating a key keeps every prior key available until a rewrap step confirms no row still references it, so a key is never retired on a guess, and a secret is never logged or returned in a response.

Medallion warehouse + promoters

The warehouse is layered into Bronze, Silver, and Gold (see Data Warehouse). Idempotent promoters turn Bronze into typed facts deterministically, so the pipeline is replayable and auditable: a fix to a transformer can be replayed from Bronze without paying for the source API call again.

@yng/ai failover

LLM access is pluggable per org through ai_connections: multiple providers in an explicit order, keys envelope-encrypted. One resolver builds the candidate list, and one failover primitive wraps it into a single model interface; a retryable error moves to the next candidate, and failover only happens before the first content token is emitted. The model and provider stay hidden from the end user; the chat surface never shows a vendor badge.

Tenant domains: *.msocloud.net

Each tenant gets its own subdomain, plus support for a separate white-label domain. The request host is resolved to exactly one org before any data is touched, and every host-dependent URL is built from that validated host, never from a configured default. A wildcard certificate covers every tenant subdomain.

Runtime topology#

flowchart TB
  CF["Edge · *.msocloud.net (wildcard cert)"]

  subgraph RT["Runtime"]
    direction LR
    WEB["apps/web<br/>Next.js 16 RSC · tRPC v11"]
    WRK["apps/worker<br/>BullMQ + cron"]
    CUBE["Cube<br/>semantic layer"]
  end

  subgraph ST["State"]
    direction LR
    PG["Postgres 16<br/>FORCE RLS"]
    RD["Redis<br/>queues + rate limits"]
  end

  EXT["Email delivery · LLM provider per org"]

  CF --> RT
  WEB <-->|"outbox"| WRK
  RT --> ST
  RT -.->|"calls out"| EXT

Self-hosted posture and security summary#

The application, Postgres, and Redis run on infrastructure YouNet operates. Outbound calls leave that boundary in three declared paths: email delivery, each org's chosen LLM provider, and the data-source APIs an org has connected (marketplace, ad, and analytics platforms). No other external service touches tenant data. Tenant isolation is enforced twice: inside Postgres through row-level security keyed to org and brand, and again at the runtime-role level, where the production database roles cannot bypass row-level security under any query. The full threat model and security invariants are in Security.