Developer Documentation

Building a Veltrix App

A Veltrix app manages one security tool's configuration as code. Instead of clicking through a SIEM or EDR console, operators author configuration on a canvas, and the app drives it through the Security-as-Code pipeline: validate → deploy → rollback → health-check → drift-detect → status. The result is versioned, reviewable, auto-rolled-back configuration for any tool you can talk to over an API or SSH.

Before You Start

Apps are open source and live in the veltrix-apps repository. You build them against the typed contract in @veltrixsecops/app-sdk and scaffold, validate, and live-develop them with the Veltrix CLI. This guide is the conceptual map; the SDK and CLI references are the exact API.

What Is an App?

An app is a self-contained package that teaches the platform how to manage a tool. It bundles everything needed to treat that tool's configuration as code:

  • Pipeline handlers — the six functions that validate, deploy, roll back, health-check, drift-detect, and report status for a configuration.
  • Canvas templates — the form schema (canvas.yaml) and default values (defaults.yaml) operators fill in.
  • Database migrations — optional app-owned tables (always table-prefixed).
  • Lifecycle hooks — onInstall / onUninstall and friends.
  • A server route module — optional Fastify routes mounted under /api/apps/<app-id>.
  • Optional UI pages — client pages that appear in the tenant's navigation.

Handlers run on the server in a hardened Node process. They receive everything they need through a typed context object — the canvas data, the target component, credentials, connectivity, and a read-only platform data API — and never reach into the platform's database or internals directly.

Security-as-Code is mandatory

Every app must define at least one configuration type with a full set of pipeline handlers. This is what makes configuration reviewable, approvable, deployable, and continuously enforced against drift. An app without a pipeline is not a valid app.

Canonical App Layout

Every app follows one predictable folder structure. veltrix init <app-id> scaffolds it, veltrix validate and repo CI warn on deviations, and the SDK exports it as APP_LAYOUT / conventionalPaths().

apps/my-security-tool/
├── manifest.yaml                     # App contract (required)
├── package.json                      # SDK + tooling devDependencies
├── tsconfig.json
├── README.md                         # What the app manages + credential setup
├── config-types/<configTypeId>/       # THE unit of extension — everything for
│   ├── canvas.yaml                   #   one configuration type in one folder:
│   ├── defaults.yaml                 #   form schema, default values, and the
│   ├── validate.ts                   #   six pipeline handlers
│   ├── deploy.ts
│   ├── rollback.ts
│   ├── healthCheck.ts
│   ├── driftDetect.ts
│   ├── getStatus.ts
│   └── __tests__/                    # Tests next to the code they cover
├── lib/                              # Shared app code (API clients, parsers)
├── hooks/                            # Lifecycle hooks (camelCase): onInstall.ts, ...
├── migrations/                       # SQL migrations (optional, tablePrefix enforced)
├── server/index.ts                   # Route module (AppRouteContext)
├── client/index.tsx + client/pages/  # Custom UI pages (optional)
└── assets/                           # Icons, logos (optional)

Adding a config type = one folder + one manifest entry

The config-types/<configTypeId>/ directory is the unit of extension. To manage another kind of configuration (say, Splunk indexes vs roles), drop in one new folder with a canvas.yaml, defaults.yaml, and the six handlers, then add one entry under pipeline.configurationTypes in the manifest. Nothing else moves.

Manifest references to code are extensionless and resolved like require() — .ts in local development, compiled .js in a released package. So config-types/indexes/deploy resolves to config-types/indexes/deploy.ts during development.

The manifest.yaml

The manifest is your app's contract with the platform. Here is an annotated walkthrough of the key sections (drawn from the real splunk-enterprise app):

id: splunk-enterprise           # Unique slug — lowercase, hyphens; must equal the directory name
name: Splunk Enterprise
version: 1.2.0                   # semver — CI requires a bump on every app change
vendor: Veltrix
description: Manage Splunk Enterprise configurations as code.
category: SIEM                   # SIEM, EDR, SOAR, IAM, NETWORK, CLOUD, COMPLIANCE, Endpoint Management, Threat Intelligence, CUSTOM
license: Apache-2.0

platform:
  minVersion: "1.0.0"           # Minimum platform version this app supports

# -- Permissions -------------------------------------------------------------
permissions:
  platform:                     # What the app NEEDS from the platform
    - configuration-canvas:read
    - configuration-canvas:write
    - component:read
    - credential:read
  app:                          # Permissions the app EXPOSES (admins assign to roles)
    - resource: indexes
      actions: [read, write, delete]
      description: Manage Splunk index configurations

# -- Pipeline (MANDATORY) ----------------------------------------------------
pipeline:
  configurationTypes:
    - id: indexes               # Unique within the app; the config-types/<id>/ folder name
      name: Index Configuration
      canvasTemplate: config-types/indexes/canvas.yaml
      defaultConfig: config-types/indexes/defaults.yaml
      handlers:                 # Extensionless paths to the six handlers
        validate: config-types/indexes/validate
        deploy: config-types/indexes/deploy
        rollback: config-types/indexes/rollback
        healthCheck: config-types/indexes/healthCheck
        driftDetect: config-types/indexes/driftDetect   # optional
        getStatus: config-types/indexes/getStatus
        options: config-types/indexes/options           # optional — powers remote-select live-picker fields
      targets:
        componentTypes: [indexer, cluster-manager]      # Which components to deploy to
        requiresCredential: true                        # Needs tool credentials
        requiresConnectivity: true                      # Needs SSH/API access to components
  pipelineEvents:               # Events the app emits (other apps can subscribe)
    - canvas:deployed
    - drift:detected

# -- Database (optional) -----------------------------------------------------
database:
  migrations: migrations        # migrations/*.sql at the app root
  tablePrefix: splunk_          # Every app table MUST be prefixed (unique across apps)
  isolation: schema             # 'shared' | 'schema' (default for marketplace apps) | 'database' | 'external'

# -- Server ------------------------------------------------------------------
server:
  entry: server/index
  routes:
    prefix: /api/apps/splunk-enterprise   # All app routes namespaced here

# -- Client UI (optional) ----------------------------------------------------
# The platform renders the chrome (breadcrumb, app header, nav, permission
# gating, loading + error states); your component renders only the page body.
client:
  entry: client/index
  navLayout: sidebar            # 'tabs' (default) | 'sidebar' (scales to many config types)
  pages:
    - path: /indexes
      component: IndexesPage
      label: Indexes
      description: Manage index definitions
      icon: database
      nav: sidebar              # sidebar | tab | hidden (supersedes the boolean `sidebar`)
      order: 1                  # deterministic ordering (ascending; ties break on label)
      layout: standard          # standard | full-bleed | canvas
      requiresPermission:       # hides the page AND its nav entry unless held
        resource: indexes
        action: read

# -- Connectivity, connection onboarding & branding (optional) ---------------
connectivity:
  testHandler: handlers/testConnection   # backs the "Test" button on a Connection
connection:                     # optional one-click consent onboarding (e.g. Microsoft admin consent)
  onboarding:
    provider: entra-admin-consent
    label: Connect Microsoft Defender
branding:
  primaryColor: "#FC0000"       # applied by the platform in the app navbar + scoped CSS vars
  logo: ./assets/logo.svg

# -- Lifecycle hooks & settings (optional) -----------------------------------
hooks:
  onInstall: hooks/onInstall
  onUninstall: hooks/onUninstall
  # also: onEnable, onDisable, onUpgrade, onWebhook (inbound webhooks), onEvent (message-bus events)
events:                         # platform events the app SUBSCRIBES to (distinct from pipelineEvents it emits)
  - deployment:completed

settings:                       # Admin-configurable per tenant
  - key: splunk_api_port
    type: number                # 'string' | 'number' | 'boolean' | 'select' (select needs options: [{label,value}])
    label: Splunk Management Port
    default: 8089
    required: false

See the SDK Reference for the full AppManifest type, including every field on permissions, pipeline.configurationTypes, targets, and settings.

The Six Handlers

Every configuration type implements up to six handlers. Each is a default-exported async function that receives a typed context and returns a typed result. Import the types from @veltrixsecops/app-sdk.

See the handlers in motion. Every app ships an auto-generated request-flow diagram tracing how each operation — Deploy, Drift, Rollback, Test connection — routes through the pipeline, the app, and the vendor API to completion, including the RBAC permission check, the human approval gate, environment-scoped credentials, and drift reconciliation. Step through them interactively on the App Dataflows site, or read the DATAFLOW.md in any app folder.

Handler When it runs Purpose
validate Before approval Block invalid config with field-level errors; surface warnings
deploy On approved deployment Push config to the target component; return rollback data
rollback On failure or manual revert Restore the previous configuration
healthCheck During/after deployment Verify the tool is healthy; a low score triggers auto-rollback
driftDetect On a schedule (optional) Compare live state vs deployed config; report differences
getStatus On demand Report what is currently deployed and per-component health

One real signature per handler — note the distinct context and result type for each:

import type {
  PipelineContext, ValidationResult,
  DeployContext, DeployResult,
  RollbackContext, RollbackResult,
  HealthCheckContext, HealthCheckResult,
  DriftContext, DriftResult,
  ConfigStatus,
} from '@veltrixsecops/app-sdk'

// 1. validate — runs before approval; return field-level errors to block
export default async function validate(ctx: PipelineContext): Promise<ValidationResult> { /* ... */ }

// 2. deploy — push config to ctx.component; return rollbackData for later
export default async function deploy(ctx: DeployContext): Promise<DeployResult> { /* ... */ }

// 3. rollback — revert using ctx.rollbackData / ctx.targetVersion
export default async function rollback(ctx: RollbackContext): Promise<RollbackResult> { /* ... */ }

// 4. healthCheck — return { healthy, score, checks[] }; low score auto-rolls-back
export default async function healthCheck(ctx: HealthCheckContext): Promise<HealthCheckResult> { /* ... */ }

// 5. driftDetect — compare ctx.deployedConfig vs live; return { hasDrift, diffs[] }
export default async function driftDetect(ctx: DriftContext): Promise<DriftResult> { /* ... */ }

// 6. getStatus — report current deployment + per-component health
export default async function getStatus(ctx: PipelineContext): Promise<ConfigStatus> { /* ... */ }

A minimal but complete validate handler looks like this:

import type { PipelineContext, ValidationResult } from '@veltrixsecops/app-sdk'

export default async function validate(ctx: PipelineContext): Promise<ValidationResult> {
  const errors: ValidationResult['errors'] = []
  const warnings: ValidationResult['warnings'] = []

  for (const section of ctx.canvas.sections) {
    const name = section.fields.name as string | undefined
    if (!name) {
      errors.push({ field: `${section.name}.name`, message: 'Name is required', code: 'required' })
    }
  }

  return { valid: errors.length === 0, errors, warnings }
}

Prefer the typed define* helpers from @veltrixsecops/app-sdk/pipeline for extra inference — see the SDK Reference.

App UI & Navigation

Apps get UI without owning the frame. The design principle is predictable shell, flexible body: the platform renders the chrome around every app page — the breadcrumb (Apps / <App> / <Page>), the app header, navigation, permission gating, loading skeletons, and the error boundary — and your component renders only the page body, composed from @veltrixsecops/app-sdk/ui. Apps cannot alter the chrome or the brand palette, and in exchange they get full freedom inside the frame and a UI that is consistent across every vendor's app.

You declare pages under client.pages[] in the manifest. Each entry maps a route to an exported component and tells the platform how to place and frame it:

Field Meaning
pathRoute beneath the app (must start with /); mounted under /apps/<app-id>
componentComponent name exported from the client entry
label, description?, icon?Nav label, optional subtitle, and platform icon (falls back to the app icon)
navsidebar | tab | hidden — supersedes the legacy boolean sidebar (still accepted, emits a deprecation warning)
parentRequired for nav: tab; must match another page's path
group, orderOptional sidebar section label; deterministic ordering (ascending, ties break on label)
layoutstandard | full-bleed | canvas — full-bleed is the escape hatch for custom canvases; canvas gets Configuration Canvas chrome
requiresPermissionHides the page and its nav entry unless the user holds { resource, action }; CI rejects a permission not declared in permissions.app
client:
  entry: client/index
  pages:
    - path: /                       # /apps/my-app
      component: Dashboard
      label: Overview
      nav: sidebar
      order: 1
      layout: standard

    - path: /configs                # a tab inside Overview, permission-gated
      component: ConfigsTab
      label: Configurations
      nav: tab
      parent: /
      requiresPermission: { resource: "my-app:configs", action: read }

Live and shipped

Both halves are live today: the platform reads client.pages[], places nav entries, and CI enforces the permission and field rules; and the CLI compiles your client/index.tsx into a hermetic bundle that mounts the page body inside the shell (React and every @veltrixsecops/app-sdk import resolve from the host at runtime). Build the body from @veltrixsecops/app-sdk/ui.

Connections & Credentials

Two seams reach a tool: the transport (how you reach the host — ctx.connectivityProvider, below) and the credential (how you authenticate). Most apps ship a /connections client page (nav group: settings) that renders the shared ConnectionsManager, plus a manifest connectivity.testHandler that backs the "Test" button:

// client/pages/ConnectionsPage.tsx
import { ConnectionsManager } from '@veltrixsecops/app-sdk/connections'
export default () => <ConnectionsManager appId="my-app" appName="My App" usernameLabel="Client ID" tokenLabel="Secret" />

// handlers/testConnection.ts — one lightweight authenticated probe
import { defineConnectionTester } from '@veltrixsecops/app-sdk/pipeline'
export default defineConnectionTester(async (ctx) => ({ ok: await probe(ctx), details: [] }))

From a server route, reach the connected system with a decrypted secret — without re-implementing the platform's crypto — via ctx.resolveConnection(customerId, credentialId) (returns { endpoint, username, password, apiToken, certificate }; server-side only, never sent to the client). Connections onboarded through the platform's one-click consent flow (declared under manifest connection.onboarding) carry no secret — handlers instead mint an app-only token with ctx.identity.getAccessToken(...).

A config field can also offer a live picker sourced from the tool — declare it remote-select / remote-multiselect with an optionsSource in canvas.yaml, and add an options provider at config-types/<type>/options.ts (wired via handlers.options). The platform resolves the connection and runs it in-process, so users pick by name while a stable id is stored.

Remote Connectivity (Zero-Trust Access)

The platform provides a shared remote connectivity system that every app inherits automatically. You do not build your own SSH, VPN, or tunnel handling. When a configuration type sets requiresConnectivity: true, handlers receive the tenant's configured provider on ctx.connectivityProvider.

export default async function deploy(ctx: DeployContext): Promise<DeployResult> {
  if (ctx.connectivityProvider) {
    const { providerType, config } = ctx.connectivityProvider
    switch (providerType) {
      case 'tailscale': /* config.tailnet, config.apiKey — reach the device via Tailscale */ break
      case 'ssh':       /* config.username, config.privateKey, config.port */ break
      case 'wireguard': /* config.endpoint, config.privateKey */ break
      // ...other providers
    }
  }
  return { success: true, message: `Deployed to ${ctx.component.hostname}` }
}

Admins configure one of nine providers in the platform Settings UI; the app just reads ctx.connectivityProvider:

Provider Type providerType
TailscaleMesh VPNtailscale
SSH KeysTraditionalssh
WireGuardMesh VPNwireguard
Cloudflare TunnelZero Trustcloudflare_tunnel
ZeroTierMesh VPNzerotier
NebulaMesh VPNnebula
OpenVPNTraditionalopenvpn
AWS SSMCloud Nativeaws_ssm
HashiCorp BoundaryZero Trustboundary

A legacy per-component ctx.connectivity (SSH command / HTTPS URL / Tailscale IP) is also provided for backward compatibility. Prefer ctx.connectivityProvider for new apps.

Submission & Release

Apps ship through a reviewed, automated pipeline. There is no manual publishing step:

  1. Open a pull request against main in the veltrix-apps repo describing what the app does, which tools it integrates, and how to test it.
  2. CI validates automatically — the same rules veltrix validate runs locally: manifest schema, handler completeness, semver bump, no executables, package size cap, import boundaries, TypeScript compile, and a secret scan.
  3. A maintainer reviews for code quality and security (see .github/CODEOWNERS).
  4. On merge, CI packages the app (server-side TypeScript compiled to JavaScript) and publishes an immutable release tagged <app-id>-v<version> with the ZIP and its SHA-256 as assets.
  5. The marketplace catalog is regenerated and served from GitHub Pages, so every Veltrix instance sees the update.

The marketplace catalog consumed by Veltrix instances is published at:

https://captivatortechnologies.github.io/veltrix-apps/catalog.json

Tenants install an app from the Apps page — from the catalog, or by URL from a release asset:

https://github.com/captivatortechnologies/veltrix-apps/releases/download/<app-id>-v<version>/<app-id>.zip

Shipping a new version

Bump version in the app's manifest.yaml (semver: patch for fixes, minor for new capability). CI rejects app changes that do not bump the version, and releases are immutable — a version tag is never overwritten.

Keep Building

Ready for the exact contracts and tooling?

  • SDK Reference — every context, result type, and helper in @veltrixsecops/app-sdk.
  • CLI Reference — init, validate, package, and the sandbox dev loop.
  • Design System & UI — the shared component library for app pages.