App Model

What a revenexx App actually is — one Node function plus declarative JSON contracts — and how the pieces fit together.

This page is for developers about to write their first revenexx App. It explains what an app is, which files it ships, and what the platform does with each of them. Everything else in this section is a field-by-field reference for one of those files.

An App is one Node function plus a set of declarative JSON contracts. The function holds your logic. The contracts tell the platform what data you own, what HTTP operations you expose, what you are allowed to reach, what the Cockpit should render, what a merchant can configure, and how the app is listed. The platform reads the contracts and provisions the rest: tables, tenant scoping, public routes, the OpenAPI document, the admin UI, the settings form, the Marketplace entry.

You do not write migrations, endpoint plumbing, tenant filters, or admin-UI components. You write the function and the contracts.

The files an app ships

Every production app in the platform's own commerce suite ships the same eight files. Six are required, two are declared only when the app needs them.

text
my-app/
├── manifest.json                 identity, type, permissions, events, schedules
├── manifest.capabilities.json    the app's public API — its HTTP operations
├── schema.json                   the entities (tables) the app owns
├── cockpit.json                  the admin UI, declaratively
├── settings.json                 what a merchant can configure
├── billing.json                  Marketplace listing and pricing
├── src/main.js                   the function entrypoint
└── src/db.generated.js           typed data client, generated from schema.json

Optional, and only if the app needs them:

text
├── search.json                   search index declarations
└── analytics.json                analytics dataset declarations

manifest.capabilities.json is a sibling of manifest.json, not a separate concept: the platform splices it into manifest.capabilities before validation. An app ships either the inline array or the sibling file, never both. In practice apps use the sibling file, because the capability array is by far the largest thing in a manifest.

The authoritative JSON Schema for each file is served at https://schemas.revenexx.com/<name>.schema.jsonmanifest, manifest.capabilities, schema, cockpit, settings, billing, search, analytics. Point your editor at them and you get validation and completion while you type.

FileYou declareThe platform provides
manifest.jsonIdentity, type, dependencies, the access register, events, schedulesInstall eligibility, consent screen, access enforcement, event triggers, cron
manifest.capabilities.jsonTyped HTTP operations with routes, request/response schemasPublic routes under /v1, the merged /v1/openapi.json, SDK methods, API Explorer entries, MCP tools
schema.jsonEntities, columns, indexes, constraintsTables, tenant scoping, the generated data client
cockpit.jsonNavigation, list/detail/form views, widgetsRendered admin UI — no components from you
settings.jsonConfigurable keys, types, scopes, defaultsA Cockpit settings form, storage, resolution per market
billing.jsonPricing model, plans, listing copy, consent reasonsThe Marketplace entry and the billing flow

A worked example

The app below tracks device serial numbers for a manufacturer. It is deliberately small, but every file it touches is one you will write for real.

1. Identity and access — manifest.json

manifest.json
{
  "$schema": "https://schemas.revenexx.com/manifest.schema.json",
  "name": "serials",
  "vendor": "acme",
  "version": "1.0.0",
  "type": "private",
  "title": { "en": "Device Serials", "de": "Seriennummern" },
  "description": "Tracks device serial numbers from receipt through sale and into service.",
  "dependencies": { "revenexx/markets": "^0.1" },
  "permissions": [
    { "entity": "device_serials", "access": ["read", "create", "update"] },
    { "capability": "orders.get", "compatible": "^1.0" }
  ],
  "events": {
    "emits": [
      { "name": "serial.registered", "entity": "device_serials", "on": ["insert"] }
    ],
    "listens": ["app.installed"]
  }
}

Two fields here decide more than they look like they do.

type decides who may ever install the app. private means only the owner tenant can install and invoke it, and it is never listed in the Marketplace — that is the custom app you build for one customer. public means any tenant may install it once the operator publishes it. This is independent of billing.json, which is pricing only.

dependencies keys are vendor/app, not bare app names. "revenexx/markets" is valid; "markets" is rejected when the app is applied.

title is either a plain string or a map of BCP-47 language tag to text, with en required. Real apps use the map so the Cockpit can show a translated label.

2. The public API — manifest.capabilities.json

manifest.capabilities.json
[
  {
    "type": "define",
    "capability": "serials.list",
    "version": "1.0.0",
    "summary": "List device serials",
    "route": { "method": "GET", "path": "/serials" },
    "parameters": [
      { "name": "limit", "in": "query", "schema": { "type": "integer", "maximum": 200 } },
      { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0 } }
    ],
    "response": {
      "title": "SerialList",
      "type": "object",
      "properties": {
        "items": { "type": "array", "items": { "$ref": "#/components/schemas/Serial" } }
      }
    }
  }
]

The declared route is served verbatim under the gateway's /v1 prefix. That capability answers at:

Request
curl https://api.revenexx.com/v1/serials \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..."

The capability key (serials.list) is the stable internal identifier and the operation id — it is not part of the URL. See Capabilities for the full field set, and API usage for the headers every call carries.

3. The data model — schema.json

schema.json
{
  "$schema": "https://schemas.revenexx.com/schema.schema.json",
  "entities": {
    "device_serials": {
      "columns": {
        "id": { "type": "uuid", "pk": true, "default": "gen_random_uuid()" },
        "serial": { "type": "text", "notNull": true, "unique": true },
        "product_id": { "type": "uuid", "notNull": true },
        "status": { "type": "text", "notNull": true, "default": "'in_stock'",
                    "check": "status in ('in_stock','sold','in_service')" },
        "sold_at": { "type": "timestamptz" },
        "created_at": { "type": "timestamptz", "notNull": true, "default": "now()" }
      },
      "indexes": [
        { "columns": ["product_id"] },
        { "columns": ["status"], "where": "status <> 'sold'" }
      ],
      "lookup": { "label": "serial", "search": ["serial"] }
    }
  }
}

The top-level key is entities. Table names are namespaced to {vendor}__{app}__{entity}, so this becomes acme__serials__device_serials — two apps can both own an orders entity without colliding.

type is a raw PostgreSQL type written verbatim into the DDL, and default and check are raw SQL expression strings. There is no string, no decimal, no float. You never declare tenant_id — the platform injects it, indexes it, and scopes every read and write to the calling tenant.

Schema reference has the full property list. Read the section on removals before you ever delete a column: migrations are additive, and a column dropped from this file is not dropped from the database.

4. The admin UI — cockpit.json

cockpit.json
{
  "$schema": "https://schemas.revenexx.com/cockpit.schema.json",
  "navigation": [
    {
      "label": { "en": "Serials", "de": "Seriennummern" },
      "icon": "qr-code",
      "route": "/serials",
      "position": 40
    }
  ],
  "views": [
    {
      "route": "/serials",
      "type": "list",
      "entity": "device_serials",
      "title": { "en": "Device serials" },
      "columns": [
        { "name": "serial", "label": "Serial", "type": "text", "sortable": true },
        { "name": "status", "label": "Status", "type": "badge" },
        { "name": "sold_at", "label": "Sold", "type": "relative-time" }
      ],
      "filters": [
        { "name": "q", "label": "Search", "type": "text", "columns": ["serial"] }
      ],
      "row_action": "/serials/:id",
      "permissions": ["device_serials.read"]
    }
  ]
}

There is no component name in there, because an app never ships UI code. Views declare a typelist, detail or form — and the Cockpit dispatches to a built-in renderer. See How the Cockpit renders.

5. Merchant configuration — settings.json

settings.json
{
  "$schema": "https://schemas.revenexx.com/settings.schema.json",
  "settings": {
    "warranty_months": {
      "title": { "en": "Warranty period (months)" },
      "description": { "en": "How long a registered serial stays under warranty." },
      "type": "number",
      "scope": "market",
      "default": 24,
      "validation": { "min": 0, "max": 120 }
    }
  }
}

The Cockpit renders the form; the platform stores the values and resolves them per market. Your function reads the resolved value with one call — no settings screen, no storage of your own.

6. Distribution — billing.json

billing.json
{
  "$schema": "https://schemas.revenexx.com/billing.schema.json",
  "type": "free",
  "support": { "email": "support@acme.example", "url": "https://docs.acme.example" },
  "categories": ["commerce"],
  "available_countries": ["*"]
}

For a private app this is mostly a formality. For a public app it is the Marketplace listing. See Billing.

7. The function — src/main.js

src/main.js
'use strict';

const { createApp, mountCrud, notFound } = require('@revenexx/app-sdk/router');
const { createDb, ENTITIES } = require('./db.generated');

module.exports = async (context) => {
  const app = createApp({ name: 'serials' });
  const db = createDb({ adapter: 'runtime', context });

  mountCrud(app, db.device_serials, {
    path: '/serials',
    columns: ENTITIES.device_serials.columns,
  });

  app.post('/serials/{id}/sold', async (c) => {
    const row = await db.device_serials.get(c.params.id);
    if (!row) throw notFound();
    const settings = await c.settings();
    return c.json(await db.device_serials.update(row.id, {
      status: 'sold',
      sold_at: new Date().toISOString(),
      warranty_months: settings.warranty_months,
    }));
  });

  return app.handler()(context);
};

src/db.generated.js is generated from schema.json — it is the typed data client for your own entities, with list, page, get, create, update and delete per entity. mountCrud wires an entity to the five standard REST routes. The App SDK documents both.

What the platform does with all of this

text
manifest.capabilities.json ──► routes under /v1  ──► /v1/openapi.json ──► SDKs, API Explorer, MCP
schema.json                ──► namespaced tables + tenant scoping + db.generated.js
manifest.permissions       ──► install-time consent + runtime enforcement
cockpit.json               ──► rendered admin UI
settings.json              ──► Cockpit settings form + per-market resolution
billing.json               ──► Marketplace listing

Two consequences of that diagram are worth internalising.

The contract is the documentation. Your capability summary, description, response schemas and declared statuses are what land in /v1/openapi.json, and from there in the generated SDKs, the API Explorer and the MCP server. There is no second place to fix a wrong description — you fix it in the manifest and redeploy.

Data is scoped to the tenant, always. Your reads and writes only ever see the calling tenant's rows. The scoping is applied by the platform, not by your code, so there is no query you can write that forgets it.

Dependencies and peer dependencies

manifest.json
"dependencies": { "revenexx/markets": "^0.1" },
"peerDependencies": { "revenexx/prices": "^1.0" }

Both declare apps yours needs. The difference is who installs them: a dependencies entry is installed automatically when your app is activated for a tenant, while a peerDependencies entry turns into a prompt the merchant answers. Use a peer dependency when the dependency costs money or is a genuine choice.

Keys are vendor/app in both blocks. Values are semver ranges; ^ is the right default.

Calling another app

An app talks to another app through the gateway, not through its tables. Declare the capability you intend to call:

manifest.json
"permissions": [
  { "capability": "orders.get", "compatible": "^1.0" }
]

The gateway resolves a compatible implementation at call time. Foreign keys across apps are rejected — the boundary between two apps is their capabilities, not their schemas.

Events are declarations, not calls

There is no publish API to call. You declare an event, optionally bound to a row change, and the platform publishes it:

manifest.json
"events": {
  "emits": [
    { "name": "serial.registered", "entity": "device_serials", "on": ["insert"] }
  ],
  "listens": ["app.installed"]
}

Receiving is equally plain: your function is invoked with an x-revenexx-trigger: event header and you branch on it. Events you emit and Events you receive cover both directions.

Where to go next

Was this page helpful?