This document describes the high-level architecture of Cella.
- frequent-use or heavy use web applications
- focused on user-generated content that requires authentication/authorization
- Requires a great UX on different devices, bur native apps are not a direct priority
- Development budget and time is limited
- Fullstack development is seen as beneficial to work effectively
- Type safe, without overdoing it.
- Prevent abstraction layers, use composable functions.
- A narrow stack: Cella uses Drizzle ORM and will not make it replaceable with another ORM.
- Focus on proven Postgres, OpenAPI & React Query patterns. Three foundational layers.
- Modularity: As Cella grows, be able to scaffold only modules that you need.
- Open standards: long-term vision is that each Cella can speak with other cell(a)s.
- Focused on client-side rendering (CSR) and in future static site generation (SSG).
Cella is a flat-root monorepo.
.
├── backend
│ ├── .db Location of db when using pglite
│ ├── drizzle DB migrations
│ ├── emails Email templates with jsx-email
│ ├── scripts Seed scripts and other dev scripts
│ ├── src
│ │ ├── db Connect, table schemas
│ │ ├── lib 3rd party libs & important helpers
│ │ ├── middlewares Hono middlewares
│ │ ├── modules Modular distribution of routes, schemas etc
│ │ ├── permissions Permission/authorization layer
│ │ ├── schemas Shared Zod schemas
│ │ ├── sync Sync engine utilities
│ │ └── utils Reusable functions
├── cdc Change Data Capture worker (WAL → activities → SSE)
├── frontend
│ ├── public
│ ├── src
│ │ ├── api.gen Generated SDK client from openapi.json
│ │ ├── hooks Generic react hooks
│ │ ├── lib Library code and core helper functions
│ │ ├── modules Modular distribution of components
│ │ ├── query Query client with offline/realtime logic
│ │ ├── routes Code-based routes
│ │ ├── store Zustand data stores
│ │ ├── styling Tailwind styling
│ │ └── utils Reusable functions
│ └── vite Vite-related plugins & scripts
├── infra Terraform IaC (Scaleway)
├── info Documentation, changelog, migration plans
├── locales Translations
└── shared Shared config, types and utils
Tables can be split in entity, resource and other tables (see backend/src/db/schema/). Entities are split in categories:
ContextEntityType: Has memberships (organization)ProductEntityType: Content related, no membership (attachment,page)- All entities, including
user: (user,organization,attachment,page)
The cella setup itself has a single context entity : organization. It has two product entities: attachment - with parent organization - and a public product entity, page. But in a typical app you would likely have more context entities such as a 'bookclub' and more product entities such as 'book' and 'review'.
Both frontend and backend have business logic split in modules. Most of them are in both backend and frontend, such as authentication, user and organization. The benefit of modularity is twofold: better code (readability, portability etc) and to pull upstream cella changes with less friction.
The entity taxonomy is defined using createEntityHierarchy() (in shared/src/builder/). Forks customize their entity setup in shared/hierarchy-config.ts.
createEntityHierarchy(roles).user().context('organization', ...).product('attachment', ...).build()
The builder validates at construction time that parents exist before children and that public-access inheritance is consistent. The resulting frozen EntityHierarchy object is the central configuration artifact — it drives RLS policy selection, permission checks, menu structure, count tracking, and SSE dispatcher routing.
Key methods: getOrderedAncestors(), getChildren(), getOrderedDescendants(), canBePublic().
Cella has a hybrid approach to sync and offline. Context entities (e.g. organizations) use standard CRUD OpenAPI endpoints — they have read-only offline access via prefetched menu data. Product entities (e.g. attachments, pages) can be upgraded with a full sync layer using a 'notify-then-fetch' pattern. All data is consistently collected by the react-query queryClient.
The pipeline flows: Postgres WAL → CDC Worker → WebSocket → ActivityBus → SSE → Client. There are two independent SSE streams:
- App stream (
/app/stream): authenticated, carries membership events, org events, and product entity notifications. Uses leader-tab pattern (Web Locks API) — one tab holds the SSE connection, followers sync via BroadcastChannel. - Public stream (
/public/stream): unauthenticated, carries events for public product entities (e.g. pages). Each tab maintains its own connection (no leader election).
Sequence numbers are hierarchy-aware: a PostgreSQL trigger (stamp_entity_seq_at) atomically stamps seqAt on all product entity rows. The seq is scoped to the entity's direct parent context (e.g., organization_id for attachments, project_id for project-scoped entities in forks). Public entities without an org parent (e.g. pages) use a global public scope. List endpoints support afterSeq for efficient delta fetches during catchup.
On every stream connect (including reconnects), a two-phase sync cycle runs:
-
Phase A (catchup) — fast, synchronous, before SSE opens:
- Patches deletes directly into detail + list caches (no invalidation)
- Compares entity-type seqs, invalidates active list queries for changed types (
refetchType: 'active') - Handles membership changes
- Cache integrity check: compares server entity counts vs cached totals to catch seq/cache drift
-
Phase B (sync service) — background, after SSE reaches
live:- High priority:
ensureQueryDatafor current org (resolves staleness from Phase A) - Low priority:
ensureQueryDatafor other orgs (only whenofflineAccessenabled, for offline cache fill) - Without
offlineAccess, other orgs refetch naturally via React Query hooks on navigation
- High priority:
-
Live SSE — handles individual notifications with priority routing:
- High priority (current org): fetch single entity + patch into list caches
- Low priority (other orgs): mark stale, refetch on next access
Offline mutations are queued with stx metadata and squashed per entity until connectivity returns.
For full details on CDC, the realtime pipeline, stx transactions, offline mutations, and context counters, see SYNC_ENGINE.md.
React Query (TanStack Query) is the central data layer on the frontend (frontend/src/query/). Each entity module creates standardized query keys via createEntityKeys(entityType) and registers them in a central entityQueryRegistry, enabling dynamic lookup by stream handlers, cache ops, and invalidation helpers. Optimistic updates (useMutateQueryData, createOptimisticEntity) and last-mutation-wins invalidation helpers are core patterns.
Product entity queries (attachment, page) use a sync-aware staleTime (syncStaleTime in query/basic/sync-stale-config.ts): Infinity when the sync stream is live, 5 minutes as fallback when disconnected. Freshness is controlled by catchup-based seq invalidation and count-based integrity checks — not time-based staleness. Non-synced queries (users, tenants, requests) keep the global 30-second default.
The React Query cache is persisted to IndexedDB via Dexie with two modes controlled by the offlineAccess toggle:
- Offline mode (
offlineAccess=true): shared key, survives browser restart for full offline capability. Sync service eagerly fills cache for all orgs. - Session mode (
offlineAccess=false): per-tab session key, survives refresh but cleaned up on tab close. Sync service only resolves staleness for the current org.
Only the leader tab (elected via Web Locks API) persists mutations to prevent cross-tab conflicts. Since mutationFn cannot be serialized, entity modules register their defaults via addMutationRegistrar() at load time so paused mutations can resume after page reload.
A QueryCache subscriber (frontend/src/query/enrichment/) auto-enriches context entity list data whenever cache entries change. Three enrichers run in sequence on each item:
- Membership: attaches the user's cached membership to the entity.
- Permissions: computes a
canmap (action →true | false | 'own', keyed by entity type + descendants) from the membership role andaccessPolicies. The'own'value indicates the action is allowed only for entities created by the current user (implicit owner relation). UseresolvePermission(permission, entity.createdBy?.id, userId)to resolve per-entity. System admins get full permissions. - Ancestor slugs: walks the entity hierarchy to build URL-friendly slug paths from cached data.
This is how item.membership, item.can, and item.ancestorSlugs are populated on context entities without extra API calls.
Cella supports four auth strategies (configurable per fork via appConfig.authStrategies):
| Strategy | Description | Key details |
|---|---|---|
| Password | Email + password | Argon2id hashing via ARGON_SECRET |
| Passkey | FIDO2/WebAuthn | Credentials stored in passkeys table |
| OAuth | GitHub, Google, Microsoft | Uses arctic library. Google + Microsoft use PKCE. |
| TOTP | Time-based one-time password | MFA fallback, only usable after passkey primary auth |
Cookie-based sessions (hashed, typed as regular/impersonation/mfa) with single-use tokens for verification, password reset, and invitation flows. Auth endpoints are rate-limited with parallel brute-force protection. Sysadmin impersonation is supported with IP allowlist enforcement.
A tenant is the top-level isolation unit. Tenants are not entities — they are system resources managed by system admins only.
Tenant-scoped routes use /:tenantId/ in the path. Guards (authGuard → tenantGuard → orgGuard) validate membership and wrap each request in a transaction with RLS session variables set. Session variables are transaction-scoped to prevent connection pool leakage. See AGENTS.md for the full guard chain.
Cella uses three defense-in-depth layers. The permission manager is the primary authorization mechanism; RLS, composite FKs, and immutability triggers are safety nets that catch application bugs.
| Layer | What it catches | Key files |
|---|---|---|
| Permission Manager | Unauthorized actions (role/membership checks) | backend/src/permissions/ |
| Row-Level Security | Cross-tenant and cross-org data leaks | backend/src/db/rls-helpers.ts, backend/src/db/schema/ |
| Composite Foreign Keys | Franken-rows (mismatched tenant/org references) | backend/src/db/schema/ |
RLS session variables (app.tenant_id, app.user_id, app.is_authenticated) are set per transaction by the guard chain. tenant_id is a hard column-match boundary — every tenant-scoped table's RLS policy compares the row's tenant_id directly against the session variable.
Organization isolation works differently: there is no app.organization_id session variable. Instead, org-scoped policies (orgScopedCrudPolicies, orgOwnedCrudPolicies) use a membershipExists() subquery — the user can only access rows in organizations where they have an active membership. Public/tenant-only entities (e.g. pages) have no org boundary since they lack organization_id.
In forks with nested context entities (e.g. projects within an org), isolation between sibling contexts is application-layer only — RLS checks membership at the organization level, not at the nested entity level. All policies are fail-closed: missing or empty session context returns zero rows.
| Category | SELECT | Write | Builder | Use case |
|---|---|---|---|---|
| Standard | tenant + org | tenant + org | orgScopedCrudPolicies() |
Org-scoped product entities (attachments) |
| Org-owned | tenant + org OR createdBy | tenant + auth | orgOwnedCrudPolicies() |
Context entities (projects, workspaces) — SELECT includes createdBy match so RETURNING works after INSERT before membership exists |
| Hybrid | public OR tenant+org | tenant + auth | publicAccessCrudPolicies() |
Entities with public_access column (pages) |
| Cross-tenant | authenticated | tenant | Custom | Authenticated users can read all memberships; writes are tenant-scoped (memberships) |
| Privilege-based | role | role | Custom | Append-only / system-only (activities) |
| Role | RLS | Purpose |
|---|---|---|
runtime_role |
Enforced | All app requests via Hono handlers |
cdc_role |
No RLS | CDC worker — append-only, minimal privileges |
admin_role |
BYPASSRLS |
Migrations, seeds, system jobs |
Identity columns (tenant_id, organization_id, user_id on memberships, etc.) are protected by BEFORE UPDATE triggers that reject changes after creation. Activities are fully append-only. See backend/src/db/immutability-triggers.ts.
getAllDecisions() resolves permissions by walking the entity hierarchy (most-specific context → root), matching memberships against access policies defined in configureAccessPolicies() (shared/permissions-config.ts). Policies support three values: 1 (allowed), 0 (denied), and 'own' (allowed only when entity.createdBy === userId — an implicit "owner" relation inspired by Zanzibar). Grant attribution tracks whether access was granted via a membership or an owner relation. System admins bypass all checks. See AGENTS.md for the full list of permission helpers.
Every tenant-scoped table must have
tenant_id. Tables with an organization parent must also haveorganization_idwith a composite FK toorganizations(tenant_id, id). Parentless product entities requiretenant_idonly. The entity hierarchy config (shared/hierarchy-config.ts) determines which pattern applies.
The API runs through zod-openapi to build an OpenAPI 3.1 specification. Please read the readme in this middleware before you get started. An api client is generated in the frontend using openapi-ts, which produces zod schemas, types, and an sdk for the frontend.
The OpenAPI spec is enriched with custom x-* specification extensions that describe guards, rate limiters, and caches per operation. Middleware wrapped with xMiddleware carries typed metadata; createXRoute (used instead of createRoute) automatically collects this metadata into x-guard, x-rate-limiter, and x-cache spec extensions. New extension types are added via a central registry.
At startup the backend builds the full spec (including an info.x-extensions block with all definitions and values), writes a cached openapi.cache.json. A custom Vite plugin pre-parses the spec into static JSON at build time; the frontend docs UI dynamically generates table columns from the extension definitions — no hardcoding of extension names.
Mock generators in backend/mocks/ are a single source of truth serving three purposes:
| Purpose | Mock type | ID context |
|---|---|---|
| OpenAPI examples | Response mocks (deterministic via seeded faker) | 'example' — no prefix |
| Database seeding | Insert mocks | 'script' — gen- prefix |
| Test fixtures | Insert mocks | 'test' — test- prefix |
Response mocks are passed as example: values to .openapi() on Zod schemas and route responses — the sole source of OpenAPI examples. Insert mocks return Drizzle Insert*Model types with UniqueEnforcer for uniqueness. The context-aware ID prefix system lets CDC workers and test cleanup distinguish generated data.
See info/TESTING.md for test modes, infrastructure, and writing guidelines.