# Architecture and trust boundaries

> The services, ports and data flows of a critlab install, and why prototypes are served from their own origin.

Source: https://critlab.app/docs/self-hosting/architecture

A critlab install is three stateless Node.js services in front of Postgres, Redis and an S3-compatible object store. The one design decision that shapes everything else: **uploaded prototypes are untrusted code**, so they are never served from the app's origin.

```text
Browser ──https──▶ web (Next.js, :3000) ──▶ Postgres (:5432)
   │                 │  └──────────────▶ Redis (:6379) ◀── realtime (:4010)
   │                 └────────────────▶ S3-compatible storage (:9000)
   │                                          ▲
   ├──wss──▶ realtime (/ws, :4010)              │
   └──https (iframe, per-version origin)──▶ Caddy/ingress ──▶ sandbox (:4001) ──┘
                                              sandbox ──internal──▶ web /api/internal/*
Optional: SMTP relay (sign-in codes), OIDC/SAML identity provider.
```

## Components and ports

| Component | Default port | What it does | State |
| --- | --- | --- | --- |
| web | 3000 | Next.js app: dashboard, viewer page, REST API, sign-in (email code, OIDC, SAML), SCIM, licence checks. Health: `GET /api/health`. | Stateless |
| sandbox | 4001 | Serves prototype files from the sandbox origin, injects the overlay bridge, sets the isolation headers. Health: `GET /health`. | Stateless (in-memory file cache) |
| realtime | 4010 | WebSocket at `/ws` for presence, cursors, follow and spotlight, and live comment updates. Health: `GET /health`. | Stateless (in-memory rooms) |
| Postgres | 5432 | Every record: workspaces, prototypes, versions, comments, audit log. Version 16 is what the images and chart ship. | **Stateful** |
| Redis | 6379 | Pub/sub between web and realtime instances. | Ephemeral |
| Object storage | 9000 (SeaweedFS) | Package files (content-addressed), voice notes, screenshots, thumbnails. Any S3-compatible store. | **Stateful** |
| Caddy or an ingress controller | 80 / 443 | TLS termination and routing by host. | Certificates only |

A migration job (the `migrate` image) runs database migrations before web starts; it exits when done. See [Upgrades and migrations](/docs/self-hosting/upgrades).

## Hostnames

An install answers on at least two names, and they must differ:

- **The app host**, e.g. `critlab.example.com` — web, and realtime at `/ws` unless you give realtime its own host.
- **The sandbox zone**, e.g. `s.example.com` — every prototype version is served from its own subdomain, `https://<label>.s.example.com/`. This needs a **wildcard certificate** for `*.s.example.com`. See [Domains and TLS](/docs/self-hosting/domains-tls).

Optionally, workspaces on a licensed install can serve reviewer links on their own hostname (custom share domains).

## Trust boundaries

**The sandbox origin is untrusted.** Prototypes are HTML and JavaScript written by an AI agent or a person you may not know. critlab contains them:

- **A separate origin per version.** The label in each sandbox hostname is a signed, short-lived capability issued by web for one version. A tampered, expired or never-issued label gets the same 404. Two prototypes never share an origin, so one cannot read another's storage.
- **Cookieless.** The sandbox never sets or receives a session cookie. There is nothing in it to steal.
- **Sandboxed iframe with a strict CSP.** The viewer frames the prototype in a sandboxed iframe with an opaque origin. The sandbox's own CSP carries a `sandbox` directive, so opening a prototype URL directly is sandboxed too. External images and fonts are off unless a prototype's owner turns them on; scripts, connections and frames to other origins are never allowed.
- **A narrow message bridge.** The comment overlay talks to the prototype only through a token-checked `postMessage` bridge that forwards a fixed list of messages.
- **Never indexed.** Every sandbox and viewer response carries `X-Robots-Tag: noindex, nofollow, noarchive`, and both origins serve a deny-all `robots.txt`.
- **Server-side rendering is framed too.** Thumbnails are rendered in a headless browser that frames the prototype the same way, behind a network egress allowlist.

**The app origin is trusted.** It holds sessions, API keys and the only copy of decrypted credentials for workspace-owned buckets. The sandbox asks web for those credentials over an internal route protected by a shared secret (`/api/internal/*`); nothing else crosses that line.

**realtime trusts only signed tokens.** web mints a presence token after its own access check; realtime verifies it with the shared `SANDBOX_TOKEN_SECRET` and checks no origin or cookie.

## Data flows

**Opening a share link.** The browser loads the viewer page from web. web checks access (link, password or membership), mints a sandbox label for the version, and returns a page that frames `https://<label>.<zone>/`. The sandbox verifies the label, looks up the version's file manifest in Postgres, streams files from object storage, and injects the bridge into HTML documents.

**Leaving a comment.** The bridge captures the element's selectors, text and position and a screenshot inside the sandboxed frame, and hands them to the overlay. The overlay posts them to web, which stores the comment in Postgres and the images in object storage, then publishes an ids-only event to Redis. realtime relays it to everyone viewing that prototype, whose overlays refetch.

**An agent picking up feedback.** The `crit` CLI or MCP server calls web's API with an API key. web returns threads with their screenshots, selectors and environment. The agent publishes a new version the same way the browser does.

**Outbound requests from web.** Only when configured: your SMTP relay, an identity provider's metadata URL, Slack-compatible webhooks, Linear or Jira when enabled, a workspace's own bucket, and page titles for links pasted into comments. Each user-supplied URL goes through one guard that refuses private, loopback and cloud-metadata addresses and re-checks every redirect. [Air-gapped installs](/docs/self-hosting/air-gapped) lists how to turn each off.
