---
title: Platform | Contember Fabrika
description: "How the Fabrika platform works: one proxy in front of private apps, access declared in code, deploy approvals, a mail relay and error tracking. MIT."
url: "https://www.contember.com/platform"
type: static
generatedAt: "2026-10-24T14:35:25.229Z"
---

# The Fabrika platform, for the people who have to sign off on it

A single-tenant platform in your company's own cloud account. One gate in front of every app, access declared in code and approved by people, deploys, e-mail and operations in one console. The code is public, so you can check all of this.
  [Book a call with our engineer →](https://cal.com/contember/demo) [Source on GitHub (MIT) →](https://github.com/contember/fabrika-platform) [Security overview →](/security)
 ![Fabrika console overview with deploys, approvals and errors across the company's apps](/screenshots/console-overview.webp)
## Architecture

One installation per company, in a cloud account in the company's name. Only the proxies take traffic from the internet.
     Internet: browsers, integrations with machine keys, forms on your website    HTTPS, the only way in    Your cloud account (layout on Zerops)  Proxy
The only way into an app. One per deployment namespace (apps in one environment); on Cloudflare, one proxy Worker per app environment.
    Apps
Private services, no public address.
   IAM
OIDC and password sign-in, sessions, roles and grants per app, machine keys, audit and sign-in log, JWKS.
   Control
App registry, environments, deploy runs, access snapshots and approvals, mail relay, console.
   Operations
Error ingest (Sentry protocol), releases and source maps, health checks, webhook alerts.

### The path of one request

First visit of the day. The proxy and the app share the app's domain; IAM has its own.

 1. 01 Browser → proxy
https://crm.example.com/ resolves to the namespace proxy.
1. 02 Proxy
Drops incoming X-Fabrika-* headers, requires a canonical path, selects exactly one endpoint from the host's table. No match, or a missing or malformed table: refused.
1. 03 Proxy → IAM
No session: state and PKCE verifier (in a __Host- cookie), redirect to IAM. API calls get a 401 with a login URL.
1. 04 IAM
Return address must be a registered origin, otherwise 400. Sign-in with SSO, password, or “Continue as…”.
1. 05 IAM → proxy
Single-use code, valid two minutes, bound to session, app, return URL and PKCE challenge.
1. 06 Proxy
Host-only __Host-px_session cookie, a child of the IAM session. No cookie shared between hosts, one sign-in across unrelated domains.
1. 07 Proxy → app
Checks caller kind, action and admission rules (Sec-Fetch-Site, Content-Type, origin allowlist), strips its cookies, passes the endpoint name and a five-minute JWT, reused while fresh.
1. 08 App
Verifies the JWT against IAM's JWKS and the rules digest. On Zerops, refuses requests without the per-app proxy secret (health checks aside).
1. 09 App → database
Runtime checks the action on the object; every query on a scoped table gets the scope condition.

Signing out or disabling someone in IAM ends every app session derived from that sign-in. Issued tokens expire within five minutes.

## Access model

Apps declare their objects, actions and roles in code, like policies in a cloud IAM. People decide who holds which role.

### The app declares its vocabulary

Dimensions form the object hierarchy, actions are what someone can do, roles bundle actions and are granted globally or on one object. Grants inherit downwards and only allow; there are no deny rules.


*src/vocabulary.ts*

```
export const vocabulary = defineVocabulary({
  app: 'crm',
  dimensions: {
    org: { members: { manage: 'members.manage', list: 'crm.read' } },
    company: { in: 'org' },
    deal: { in: 'company' },
  },
  actions: {
    'crm.read': 'Read companies and deals',
    'deals.write': 'Create deals and move them through the pipeline',
    // …
  },
  roles: {
    sales: {
      grants: ['crm.read', 'deals.write'],
      member: { scope: 'org', assignableBy: ['manager'] },
    },
    // …
  },
})
```

### Each endpoint names what it needs

No authentication code in the handler. The proxy and the runtime enforce the access line, and the deploy approval shows a person when it changes.

### Tables carry their own policy

Each table names the action for read, insert, update and delete, and the writable columns. The data layer scopes every query, so one org can't read another's rows. An unscoped table or raw SQL has to be declared.


*src/deals/endpoints.ts*

```
move: r.mutation({
  input: MoveDeal,
  output: Saved,
  // deals.write on this deal, or on its company or org
  access: { action: 'deals.write', on: 'deal', from: 'id' },
}).handle(async ({ input, ctx }) => {
  // ctx.db.target is already narrowed to the one deal
  // …
})
```

### Roles from code, assignments from people

Deploys update role definitions and keep assignments, so a new version can't quietly add or remove access.

### App owners manage their people

The owner assigns roles in the console, only to people already in IAM and only roles whose actions they hold. A user administrator can be limited to one app.

### People outside the company

An external role limited to their own data; password set from an e-mailed link, in Czech or English.

### Machine keys

px_ keys bound to one app, exchanged for short-lived tokens. They expire; permissions are editable without rotating the secret.

### Share links and connectors

Object-bound links and app-to-app calls are declared capabilities that go through the proxy and show up in the approval.

### Audit log

Sign-ins, sign-outs and IAM changes (grants, users, keys) are written in the same transaction. Deploys, approvals and mail decisions record who made them.

 ![People screen in the console: an app owner assigns the app's roles without IT](/screenshots/people.webp)
 ![Audit log in the console listing grants, created keys and sign-in decisions](/screenshots/audit-log.webp)
## Deploy approvals

AI agents tend to loosen whatever stopped them. So anything in a new commit that widens access compared with production waits for a named person.

 ![Approval detail in the console: a parked production deploy with lines that widen access and lines marked for closer review](/screenshots/approval-detail.webp)
### How the comparison is made

Control pins the commit and reads its access snapshot without running its code, then marks each line against the target environment as widening, narrowing or unchanged. It reads the commit, not what the build reports, so an agent can't talk its way past it.
  What the snapshot contains
Endpoints and who may call them, roles, table and column policies, public endpoints, jobs, webhooks, connectors, outside addresses, mail senders, file types and limits, rate limits, every SQL migration with its checksum.

### What the approver sees

 - widens Role sales gains members.manage
- widens New endpoint deals.export for role manager
- closer review Endpoint deals.export declares raw SQL (read)
- closer review Endpoint deals.export calls https://api.partner.example
- closer review Job overdue sends e-mail through the installation's mail transport
- narrows Role sales loses deals.write

Flagged for closer review: raw SQL, unscoped tables, new outside addresses, new connectors, issuing share links, sending through the installation's mail, triggers or functions in a migration.

### The rules

#### Parked, not published

A widening change on a protected environment parks the run; the old access stays live. Refusing fails the run, a parked run can be cancelled.

#### Narrowing passes

Changes that only remove access don't wait.

#### A person, not a key

Approving needs access.approve, held by a human and used in the console. No machine key can approve, not even the one that triggered the deploy.

#### Stale approvals don't count

If production changes while a run waits, it's compared again.

#### Per environment

Production is protected by default. access.approve holders can toggle it per environment; turning it off approves nothing already waiting and is audited.

#### No contract, no deploy

A commit without its contract file fails before any cloud provider is called.

On top of this mechanism, our service reviews changes before they go live. [Security overview →](/security)

## E-mail through one relay

App mail and the platform's sign-in mail go through one relay in Control. You see what went to whom and can hold mail to chosen domains.

Zerops installations, apps on Fabrika app libraries 0.7 or newer.

 ![Mail list in the console with held messages at the top](/screenshots/mail-list.webp)
 ![Mail detail in the console with Release and Drop buttons](/screenshots/mail-detail.webp)
### No provider credentials in apps

One transport per installation; each app environment gets a relay key (hashed, 30 days, renewed by deploys). A provider of the app's own is a declared, widening line.

### A record of every message

Sender, recipient domain, state and attempts, deduplicated by idempotency key. Content purged after 30 days, sign-in mail right after sending.

### Rules: pass, hold or drop

By sender and recipient domain; the most specific rule wins, no rules means pass. Typically test holds mail leaving the company.

### Release or drop in the console

Held mail first, with recipient, subject, text, script-free sandboxed HTML and attachment metadata. Release, drop or requeue; the decision records who made it.

### Sending lives in jobs

From scheduled jobs that declare the mailer, never request handlers. Retries for about eight and a half hours, never twice over a non-idempotent transport.

### What apps can send

HTML and text, attachments, reply-to, sender name, one-click unsubscribe (RFC 8058). Resend, or SmartEmailing without attachments and unsubscribe headers.

## Deploys

A deploy is a repeatable run from an exact commit, not clicks in a cloud console.

### Two files describe the app

fabrika.config.ts says where and on what it runs, with the proxy's endpoint table generated from the contract. enableSubdomainAccess: false: the app service never gets a public address.

fabrika.contract.ts says what it exposes and to whom: endpoints, the single-page app, file types and size limits, jobs, health check. The approval reads this file.


*fabrika.config.ts*

```
target: {
  platform: 'zerops',
  services: () => [
    { hostname: 'appdb', type: 'postgresql:single@18' },
    { hostname: 'appfiles', type: 'object-storage', objectStorageSize: 1 },
    { hostname: 'appapi', type: 'alpine/bun@1.3', enableSubdomainAccess: false },
  ],
  contract: compiled,
  proxy: { upstream: 'appapi:3000', ...contractProxyTable(compiled) },
}
```

### What starts a deploy

A push to the trigger branch, a matching tag, the console, the CLI or the AI builder. Control takes a lease for the app environment and pins the commit.

### Environments and secrets

Each environment has its own namespace, secrets, domain and protection switch; each app its own database (or schema at the smallest size). Secrets live in Zerops service variables or Control's vault on Cloudflare, are never shown back, and platform variables can't be overwritten.

### Run log

Status, who started it (person, webhook, key, builder), commit, step log, release. A new platform release still runs and deploys apps on Fabrika app libraries from the six months before it.

### A run on Zerops

verify-exposure fails the run before any version exists if a service has a public subdomain, and reports it to Operations. Control streams the repository from GitHub to Zerops without running its code.
  All ten steps
 1. 01 apply-import
1. 02 verify-exposure
1. 03 write-service-env
1. 04 raise-start-memory
1. 05 size-database
1. 06 reserve-version
1. 07 upload-source
1. 08 trigger-build
1. 09 await-version
1. 10 reconcile-schema

 ![List of deploy runs in the console with status, actor and commit](/screenshots/runs-list.webp)
 ![Deploy run detail with the step log](/screenshots/run-detail.webp)
## Public forms and hardening

Registrations, contact forms and webhooks reach an internal app without opening it to the internet. Public endpoints run on Zerops installations.
     01
### Public forms as their own workload

Only declared actions: insert a record, read declared tables; changing a record needs its ID in a signed token.
   02
### No CAPTCHA vendor

ALTCHA proof of work issued and verified by the app, so no visitor data leaves the installation. Cloudflare Turnstile optional.
   03
### Rate limits

Per client and per endpoint, plus throttled password sign-in.
  Details
Defaults: 10 a minute per client, 100 per endpoint, plus body size and handling time limits. Password sign-in is throttled per account, deployment and client. On Zerops, per-client limits count all visitors as one client for now, until the load balancer's client address is trusted.
    04
### Cross-origin only from listed sites

The endpoint must allow it and the exact origin must be listed for the environment. An empty list admits nothing, unless a public endpoint declares any origin.
   05
### Outgoing calls to declared addresses

Undeclared outside addresses are refused at runtime. A reviewed guard, not a sandbox.
   06
### Verified inbound webhooks

HMAC, basic or bearer, with presets for Slack, Twilio, Stripe, GitHub and Standard Webhooks.
   07
### Uploaded files

First bytes must match the declared type; a size limit per file kind.
   08
### Security headers on every response

CSP, HSTS and the usual hardening headers, a sandboxing CSP on downloads, a request ID for tracing.
  Details
CSP, HSTS (one year) on https hosts, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cross-Origin-Opener-Policy, Permissions-Policy. The request ID comes from the proxy and is logged by the app and IAM.

## Operations

Runs in your installation, so error reports don't leave your account.

### Sentry-compatible error ingest

Existing Sentry SDKs work. Issues are grouped; resolve, ignore, assign, comment, snooze, merge or resolve in a release. A resolved issue that returns reopens as a regression.

### Automatic, scrubbed reports

Failed requests, failed or missed jobs and crashes are reported on their own. Secrets, tokens, auth headers, request bodies and rows are scrubbed or never sent.

### Releases and source maps

A release per deploy, tied to its commit. Source maps upload with a per-run credential.

### Health checks and alerts

HTTP checks per app environment, alerting on failure and recovery. New issues, regressions, spikes and unhealthy telemetry go out as webhooks (HTTPS, idempotent, retried).

 ![Grouped errors in the console with sparklines](/screenshots/errors-list.webp)
 ![Error detail with a source-mapped stack trace](/screenshots/error-detail.webp)
 ![Health checks for each app environment](/screenshots/health.webp)
## Clouds and stack

Each installation runs on Zerops or on Cloudflare. Only Zerops has the full app runtime today, so company apps usually go there.
    ZeropsCloudflare   Sign-in, proxy, console, deploys, error tracking Zerops: yes Cloudflare: yes  Proxy Zerops: Caddy + Bun auth service per namespace Cloudflare: proxy Worker per app environment  Apps Zerops: Bun on Alpine, private service Cloudflare: Workers without routes, workers.dev off  App database with row-level policies Zerops: PostgreSQL 18 Cloudflare: not yet  Background jobs and e-mail Zerops: yes Cloudflare: not yet  Stored files Zerops: S3-compatible object storage Cloudflare: not yet
### AWS

No AWS provider in the code. If you need AWS, we can set up an installation there as part of the setup.

### One app, one cloud

Each app targets one cloud. The platform doesn't move apps between clouds for you.

### Stack
   Language TypeScript everywhere  Runtime and monorepo Bun 1.3+  Databases PostgreSQL (Zerops), D1/SQLite for platform services on Cloudflare  Storage S3-compatible (Zerops object storage, R2)  Schemas zod (v4 API)  App UI React with our own component kit  Migrations plain SQL, each service owns its schema
## Building with AI

Most apps on Fabrika are built with an AI agent. Its changes go the same way as a person's.

### A local builder

Eight guided phases from brief to build, with review gates and app rules for Claude Code and Codex. Connects to a Zerops installation only after a person approves it, with a revocable one-app key valid at most 30 days.

### Editing from your own Claude or ChatGPT

Built, still being verified end to end; per installation, Zerops only. People edit through MCP, signed in against your IAM with authoring-only rights. Edits become commits on a branch, deploy to stage and open a pull request; production needs a person's merge and the usual approval.

### Your AI plan

Always your company's own AI plan. We're happy to help you choose a provider.

### Guardrails in the repository

A checker refuses any, type casts, suppression comments, raw SQL outside the declared path and direct environment reads. Generated tests check data isolation and endpoint access.

## What the platform doesn't do

As of the current release:
    Multi-factor authentication Only through your identity provider's SSO; no TOTP or passkeys for passwords.  SAML and SCIM OIDC only, one identity provider per installation. Leavers must be disabled in IAM too.  Backups and restore Not in the platform; we set them up with you as part of the service, using the cloud's tools.  Rollback No command; deploy an older commit through approval. Zerops can switch versions outside Fabrika.  Alerts by e-mail Webhooks only.  Self-service access requests None; whoever manages the app's people grants roles.  Malware scanning of uploads ClamAV code exists, unverified on a live account, not promised.  Verification on Zerops Some paths, source map upload among them, still being verified end to end. DNS-safe alert webhook delivery isn't built.  Certifications No ISO 27001, SOC 2, published penetration test or SBOM. The code is public.  Guaranteed audit of everything IAM changes are transactional and sign-ins logged before IAM answers; events from other services are best effort.
Full answers for a supplier review: [Security questionnaire →](/security/questionnaire)

## Open source, and if you leave

Leaving us doesn't take away anything you depend on.
 [github.com/contember/fabrika-platform →](https://github.com/contember/fabrika-platform)    Your cloud account Everything runs there. We're administrators, and you can revoke that at any time.  Your Git App code is plain TypeScript and SQL migrations in your company's GitHub.  MIT licence Platform on GitHub, app libraries on npm as @fabrika/* with provenance, installation guide in the CLI.  What leaving means Running the platform yourselves or with someone else, in the same account. Not moving to another cloud.
## Go through it with our engineer.

Half an hour with the person who builds the platform. Bring your identity provider, your cloud, your security questionnaire or a repository.
  [Book a call with our engineer →](https://cal.com/contember/demo) [Or just write to us →](/contact)