Cytario Web
A web-based viewer and data platform for digital pathology and spatial biology. Cytario Web lets you explore, visualize, and annotate whole-slide imaging data (WSI) and other large-scale scientific datasets (OME-TIFF, OME-Zarr, GeoTIFF, Parquet) stored in S3-compatible object storage — from raw slide browse to quantitative analysis results.
For the hosted product, see cytario.com.

Architecture
| Layer | Technology |
|---|---|
| Framework | React Router v8 (SSR), React 19, Vite 7 |
| Language | TypeScript (strict mode) |
| Visualization | deck.gl, Viv, DuckDB-WASM, Apache Arrow |
| Styling | Tailwind CSS v4, @cytario/design |
| Auth | OAuth 2.0 via Keycloak, STS for S3 credentials |
| Database | PostgreSQL (Prisma ORM), Redis/Valkey (sessions) |
| Cloud | AWS SDK v3 (S3, STS); RustFS and other S3-compatible endpoints |
| CI/CD | GitHub Actions, semantic-release, GHCR |
Plugin model
Cytario Web supports third-party plugins that contribute additional image
formats to the viewer. A plugin is a regular npm package whose default
export satisfies the @cytario/plugin-api contract (see
packages/plugin-api/src/):
import type { CytarioPlugin } from "@cytario/plugin-api";
const plugin: CytarioPlugin = {
name: "@vendor/my-loader",
apiVersion: "^1.0.0",
register(ctx) {
// `extension` accepts a string, a string[] of aliases, or a RegExp
// tested against the URL. See `FormatExtension` in @cytario/plugin-api.
ctx.formats.register(["myext", "myext.gz"], {
load: async (url, opts) => {
const res = await opts.signedFetch(url, { signal: opts.signal });
// …parse and return { data: Loader, metadata: Image }
},
fileTypeMeta: { label: "My Format", icon: "Microscope" },
});
},
};
export default plugin;
Loading. The set of plugins is fixed at build time. The
CYTARIO_PLUGINS env var (comma-separated npm package names) drives a
Vite codegen step that writes app/plugins.generated.ts; the host
imports that module at startup, calls plugin.register(ctx) for each
entry, and hands the plugin a PluginContext containing a scoped
FormatRegistry and a Logger. A plugin can only register handlers
under its own name — the registry rejects cross-plugin extension
collisions.
Compatibility gate. Each plugin declares an apiVersion semver
range, checked against the host's bundled @cytario/plugin-api version
on bootstrap. Mismatched plugins are logged and skipped — the host keeps
running.
Security boundary. All S3 traffic flows through the host-supplied
signedFetch. Plugin-supplied headers pass through sanitizeHeaders
(allowlist: Range, If-None-Match, Accept, Cache-Control;
always denied: Authorization, Host, Cookie, x-amz-*) before
being merged behind the signed headers, so a plugin cannot override the
SigV4 signature or smuggle credentials.
Reference. Built-in OME-TIFF and OME-Zarr handlers in
app/components/.client/ImageViewer/state/formats/builtins.ts are
implemented against the same contract. A minimal stub plugin lives in
__tests__/fixtures/noop-plugin/ and exercises the registry, the
apiVersion gate, and the FILE_TYPE_REGISTRY auto-derivation.
Using @cytario/web as a package
@cytario/web can be installed as an npm dependency and assembled into
a deployable container together with one or more format-handler
plugins. This is how Cytario Enterprise Edition is built: the
AGPL-licensed open core (@cytario/web) is bundled with proprietary
plugins (for example, vendor-specific microscopy format loaders) to
produce a single deployable image. Anyone with a plugin that satisfies
the @cytario/plugin-api contract can follow
the same pattern.
License obligation.
@cytario/webis licensed under AGPL-3.0. Distributing or operating an assembly that includes it — including over a network as a service — triggers the AGPL's source-disclosure requirement: the complete corresponding source of the assembly (including any proprietary plugins linked into it) must be made available to its users under AGPL-3.0. If that is incompatible with your distribution model, a commercial license is available — contact us at cytario.com.
The consumer's job is to install the packages, set CYTARIO_PLUGINS,
and invoke the bundled CLI:
# .npmrc — only required if any of the plugins ship from a registry
# other than public npm. The example below routes a hypothetical
# closed plugin to GitHub Packages while keeping @cytario/web and
# @cytario/plugin-api on public npm.
@your-org/closed-plugin:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GH_TOKEN}
npm install @cytario/web @cytario/plugin-api @your-org/my-plugin
# Production build with the bundled plugin set.
CYTARIO_PLUGINS=@your-org/my-plugin npx cytario-web build
# Dev server with HMR.
CYTARIO_PLUGINS=@your-org/my-plugin npx cytario-web dev
# Production server against the existing build/ output.
npx cytario-web start
CYTARIO_PLUGINS is a comma-separated list of npm package names. Each
package's default export must satisfy the
@cytario/plugin-api contract.
CLI
| Command | Behaviour |
|---|---|
cytario-web build | Codegen (Vite plugin reads CYTARIO_PLUGINS) + react-router build against the installed package root. |
cytario-web dev | Codegen + react-router dev. Extra args (--port, --host, …) are forwarded. |
cytario-web start | NODE_ENV=production node server.ts against the bundled build/server/index.js. |
All subcommands operate against @cytario/web's own install directory;
the consumer never needs to know the on-disk layout.
Reference Dockerfile
# syntax=docker/dockerfile:1.7
FROM node:24-slim AS build
WORKDIR /app
COPY .npmrc package.json package-lock.json ./
# BuildKit secret: GH_TOKEN is exposed as an env var only for this
# RUN. It is never written to a layer and never appears in `docker
# history` or `docker inspect`. Do NOT add `ARG GH_TOKEN` — that
# would bake the value into image metadata.
RUN --mount=type=secret,id=gh_token,env=GH_TOKEN \
npm ci
ENV CYTARIO_PLUGINS=@your-org/my-plugin
RUN npx cytario-web build
FROM node:24-slim
WORKDIR /app
COPY --from=build /app .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npx", "cytario-web", "start"]
Build the image with the BuildKit secret bound from your environment (or a file):
# Docker — environment source (e.g. CI runner with a GH_TOKEN secret):
GH_TOKEN=… docker build --secret id=gh_token,env=GH_TOKEN -t my-cytario-image .
# Docker — file source:
docker build --secret id=gh_token,src=./gh_token.txt -t my-cytario-image .
# Podman — only file source is supported (no env= flavor); write the
# token to a temp file first:
printf '%s' "$GH_TOKEN" > /tmp/gh_token && \
podman build --secret id=gh_token,src=/tmp/gh_token -t my-cytario-image . && \
rm -f /tmp/gh_token
The # syntax=docker/dockerfile:1.7 parser directive at the top of
the Dockerfile is honored by Docker's BuildKit and silently ignored by
Podman's native parser — the rest of the file (multi-stage,
--mount=type=cache, --mount=type=secret) works on both engines.
Mixed-registry note.
@cytario/weband@cytario/plugin-apipublish to public npm. Plugins are free to publish wherever they like — public npm, GitHub Packages, or a private registry. Because a single scope-wide@cytario:registrydirective cannot route both public-npm and GitHub-Packages packages under the same scope, consumers pin individual plugin packages with a per-package<pkg>:registry=…override as shown above.
License
This project is licensed under AGPL-3.0. The source code is publicly available to provide full transparency and ensure long-term access for our users, independent of Cytario as a company.
Local Development
Prerequisites
The application requires several backend services. A local cluster is provided via Podman:
cd devenv
podman kube play local-deployment.yaml
| Service | Port | Description |
|---|---|---|
| Keycloak | 8080 | Identity provider (admin/admin) |
| RustFS | 9000, 9001 | S3-compatible object storage (console on 9001) |
| PostgreSQL | 5433 | Application database |
| Valkey | 6379 | Session cache (Redis-compatible) |
To stop: podman kube down devenv/local-deployment.yaml
Postgres 18 data layout: the
postgres:18image stores data in a major-version subdirectory and mounts at/var/lib/postgresql(not/var/lib/postgresql/data— see docker-library/postgres#37). If you previously ran this cluster with the older 16/17-based file, yourdevenv/*-dataPostgres dirs hold the old flat layout and the 18 container will refuse to start over them. The devenv data dirs are disposable —rm -rf devenv/keycloak-db-data devenv/cytario-db-dataand replay the cluster (then re-run the OpenTofu bootstrap). Postgres 17 and 16 also mount fine under the parent path, so nothing is version-locked.
Keycloak
The cluster runs a stock Keycloak with the
organization feature enabled. First boot gives you an empty master realm —
provision it either with the one-shot OpenTofu bootstrap below (recommended)
or by hand through the admin console (http://localhost:8080, admin/admin):
- Create a realm (e.g.
cytario) and toggle Organizations on (Realm settings → General). - Create a confidential client
cytario-webwith a secret, valid redirect URIs forhttp://localhost:3000/*, and web originshttp://localhost:3000. PointBASE_URL/CLIENT_ID/CLIENT_SECRETin.envat it. - Create a service-account client for the Keycloak Admin API (see
KC_ADMIN_CLIENT_IDin.env.template) withmanage-realm,manage-users,view-realmfrom therealm-managementclient. - Create a user, an organization, assign the user to it, and add them to groups as needed (see Keycloak Organizations).
OpenTofu bootstrap (recommended)
devenv/terraform/ creates everything the app needs in one apply: the
realm with Organizations enabled, the cytario-web client (secret 1234567,
matching .env.template) with the built-in organization client scope (the
nested org claim the app reads) and a top-level groups claim, a demo
organization, the Lab/TeamX org groups plus the admins group that grants
the * admin scope, and two users ([email protected] / demo-admin,
[email protected] / demo-viewer):
cd devenv/terraform
tofu init
tofu apply # ~10s against the running Keycloak
# Org-group membership goes through the org-scoped admin API, which the
# terraform provider cannot express — the companion script does it:
./org-membership.sh cytario "$(tofu output -raw organization_id)" [email protected]:admins [email protected]:Lab [email protected]:Lab
Then set in .env:
BASE_URL=http://localhost:8080/realms/cytario
CLIENT_ID=cytario-web
CLIENT_SECRET=1234567
tofu destroy removes the realm; re-running apply after a
podman kube down (data dirs kept) converges the state.
Why stock Keycloak? Cytario's production identity stack carries custom protocol mappers (for the RustFS
groupsclaim) in a private image. For the open-source local setup, a stock Keycloak covers authentication and tenant routing; the storage mappers are only needed when connecting a RustFS provider for bucket-policy sharing (see below).
RustFS (S3-compatible storage)
The cluster runs RustFS — the S3-compatible object
storage the platform targets for self-hosted deployments. It boots with
web-identity federation against the local Keycloak realm (client
cytario-web, claim groups).
- Console —
http://localhost:9001(rustfsadmin/rustfsadmin). Create buckets, IAM policies, and groups here. - Connecting from the app — a RustFS provider connection needs an
endpoint(http://localhost:9000for the local cluster) and the operator-side provisioning described in the user guide's RustFS storage onboarding runbook: per-organization IAM policies and the claim-named groups they attach to. In an OSS deployment (no admin portal), the connection selectors read from a deploy-time catalog file — seedevenv/providers.example.yamlfor both an AWS and a RustFS entry.
Object storage in .env
The browser talks to object storage directly (SigV4-signed fetches). The
app's Content-Security-Policy connect-src is derived per request from the
organization's provider catalog, so every storage endpoint the organization
has a provider connection for — including the local RustFS — is admitted with
no allowlist configuration. Endpoints must use https: in production; local
dev environments can admit a plain-http object store via
CYTARIO_DEV_CONNECT_HOSTS:
# .env — local dev only: admit the local RustFS object store
CYTARIO_DEV_CONNECT_HOSTS=http://localhost:9000
Getting Started
npm install
cp .env.template .env # Pre-configured for the Podman cluster
npm run dev
Keycloak Organizations
The app uses Keycloak Organizations as the tenant boundary. Every session must carry an active organization — sessions without one are redirected to /onboarding and cannot reach any tenant-scoped route.
The local Podman cluster boots a stock Keycloak with KC_FEATURES=organization enabled but an empty realm — follow the setup steps under Keycloak above to create the realm, client, and organization. To run against a custom Keycloak deployment:
- Enable the Organizations realm feature (
KC_FEATURES=organizationon the Keycloak server and the per-realm toggle in the admin UI). - Grant the
cytario-web-adminservice accountview-realm+manage-realmon therealm-managementclient. No dedicatedview-organizationsrole exists — the broader realm roles are required. - Assign every login-eligible user to at least one organization. Users without an organization land on
/onboarding.
Group membership inside an organization (subgroups under /admin/users) drives in-tenant authorization. A /admins subgroup at a given scope confers admin rights over that scope; the * sentinel scope covers the entire organization.
Session Cache (Redis/Valkey)
Sessions hold OAuth access/refresh/ID tokens and short-lived STS credentials. TLS is required in production. The app refuses to boot when NODE_ENV !== "development" unless one of the following is true:
| Env var | Value | Meaning |
|---|---|---|
REDIS_TLS | "true" | Wrap the ioredis connection in TLS (recommended). |
REDIS_CA_CERT | PEM | Optional CA bundle for self-signed deployments. Multi-line PEM string. |
REDIS_TLS_SERVER_NAME | hostname | Optional SNI / certificate hostname override. |
REDIS_INSECURE_ALLOW_PLAINTEXT | "true" | Explicit opt-out for trusted private networks. Logs a warning. Not for use on shared infrastructure. |
The local Podman cluster runs Valkey without TLS, which is allowed because NODE_ENV=development. Managed Valkey deployments (helm chart, AWS ElastiCache, etc.) should set REDIS_TLS=true. Valkey reuses the standard 6379 port for TLS when tls.enabled is set — it does not move the listener to 6380 and refuses plaintext on the same port — so leave REDIS_PORT at 6379 unless your provider explicitly publishes a separate TLS endpoint.
In a production cluster, a common pattern is a cluster-internal CA (e.g. cert-manager + trust-manager distributing the CA's public cert as a ConfigMap) whose PEM is then fed to the app through the normal REDIS_CA_CERT env var path — the app has no knowledge of the trust source. The CA's public cert is distributed to every namespace as a cytario-internal-ca ConfigMap by trust-manager, and the cytario-web helm chart's redis.caCertConfigMap.{name,key} wires it into the pod as REDIS_CA_CERT via valueFrom.configMapKeyRef. The app sees the PEM through the normal env var path — no code-side knowledge of the trust source is required.
Database
PostgreSQL with Prisma ORM. Connection configured via DATABASE_URL in .env.
npx prisma migrate dev --name <migration-name> # Create + apply migration
npx prisma migrate deploy # Apply pending migrations
npx prisma studio # Database GUI
npx prisma generate # Regenerate client
Cloning the DEV database
scripts/clone-dev-db.mjs copies the DEV PostgreSQL (schema + data) into the local Podman cluster's Postgres. It uses the pg driver rather than pg_dump/psql, so it works through pgbouncer (transaction-pooling mode) — pg_dump fails on session-level operations there.
# 1. Make sure the local cluster is up and Postgres is reachable on :5433.
cd devenv && podman kube play local-deployment.yaml && cd ..
# 2. Clone. Pass the DEV DATABASE_URL as the source and the local URL
# (from .env.template) as the target. The script refuses to write to
# any host that doesn't resolve to loopback.
npm run clone-dev-db -- \
"$DEV_DATABASE_URL" \
"postgresql://cytario:cytario@localhost:5433/cytario"
# 3. Apply any migrations the local checkout has that DEV doesn't yet.
DATABASE_URL="postgresql://cytario:cytario@localhost:5433/cytario" \
npx prisma migrate deploy
The clone copies every public-schema table, including _prisma_migrations, so Prisma recognises which migrations are already applied. If the target database already has tables, the script lists them and asks for confirmation before dropping them.
Testing
npm test # Unit & component tests (vitest, watch mode)
npm run coverage # Unit tests with coverage report
E2E tests (Playwright) live in a sibling repository and are triggered automatically on every PR via cross-repo dispatch.
Design System
To develop @cytario/design — or any @cytario package — locally, point CYTARIO_LOCAL_PATHS at the sibling checkout before starting the dev server:
CYTARIO_LOCAL_PATHS='{"@cytario/design":"../cytario-design"}' npm run dev
Each entry is aliased to the sibling's src/index.ts, so Vite serves its TypeScript source directly and picks up edits via HMR — no npm link, no rebuild step. Subpath imports follow the sibling tree (styles/tokens.css → src/…); the compiled stylesheet (styles.css → dist/index.css) is served from the sibling's dist/, so run npm run watch:css in the design repo alongside.
With the variable unset, the app uses the published packages from npm — nothing about the build changes.
Debugging
The app uses Zustand for state management with the devtools middleware. Install the Redux DevTools browser extension to inspect store state and actions.
Commits
Conventional Commits enforced via commitlint (feat, fix, docs, refactor, test, build, ci, chore, etc.).
Deployment
npm run build # Outputs build/server and build/client
npm start # Production server
For containerized deployments, see the Dockerfile. The image is built on distroless (gcr.io/distroless/nodejs24-debian12); database migrations run automatically on startup via docker-entrypoint.mjs.
Acknowledgements
Built on Viv, a library for multiscale visualization of high-resolution, highly multiplexed bioimaging data on the web, developed by the HIDIVE Lab at Harvard Medical School.