catalog-info.yaml

The single source of truth for how the builder deploys your service. Every deploy reads this file. The builder generates Dockerfiles, provisions databases, configures OAuth, registers routes, and sets up DNS — all from this one file.

Minimal Working Example

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: my-svc
  description: My API service
  annotations:
    insureco.io/framework: express
spec:
  type: service
  lifecycle: production
  owner: my-org    # your org slug from Bio-ID JWT

Current Version (0.6.0)

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: my-svc
  annotations:
    insureco.io/framework: express          # REQUIRED
    insureco.io/catalog-version: "0.5.0"   # required for spec.tests
    insureco.io/pod-tier: nano             # nano|small|medium|large|xlarge
    insureco.io/health-endpoint: /health   # REQUIRED (0.2.0+)
spec:
  type: service
  lifecycle: production
  owner: my-org                            # REQUIRED (0.2.0+), not 'unknown'

  routes:
    - path: /api/my-svc/data
      methods: [GET, POST]
      auth: required
      gas: 1
      charge: service                      # who pays gas — service (the service's own org pays) is what applies when omitted; set to callerOrg to bill the caller instead

  databases:
    - type: mongodb    # → MONGODB_URI
    - type: redis      # → REDIS_URL

  # Core platform services are auto-injected on every deploy — do NOT declare them:
  #   BIO_ID_URL, KOKO_URL, JANUS_URL, IEC_WALLET_URL, SEPTOR_URL, IEC_QUEUE_URL
  # For septor and iec-queue the builder also auto-applies their NetworkPolicy
  # direct-dep label every deploy, so those calls connect with no declaration.
  #
  # Declare only the NON-platform services you call (relay, docman, …):
  dependencies:
    - service: docman                      # → DOCMAN_URL + DOCMAN_CHARGE_MODE
      charge: service                      # default — your org pays. Use callerOrg to bill the caller's org (forward their JWT as X-Forward-User)

  tests:
    smoke:
      - path: /health
        expect: 200

Catalog Version Gates

Set insureco.io/catalog-version to the highest version whose features you use. Each version is additive — newer versions include all earlier features.

VersionAdds
0.6.0spec.userAccess.roles (user-facing roles, shape { id, label, orgs: [] } where id matches namespace:name or namespace_name); spec.registration (storefront metadata: displayName, shortDescription, publisher (must equal spec.owner), icon?, category?, modules?); spec.auth.passport
0.5.0spec.tests; spec.userAccess
0.4.0spec.dependencies (with scopes + transport)
0.3.0spec.storage

Framework Types

FrameworkStart CommandPortHealth
expressnpm start3000/health
nextjsnpm start3000/api/health
hononpm start3000/health
fastifynpm start3000/health
workernode dist/worker.js3000none
staticnginx8080/health

The default start command is npm start for all Node frameworks — define a start script in package.json (e.g. node dist/index.js). Override per-service with insureco.io/start-command.

OAuth / Bio-ID Login (spec.auth)

To enable Bio-ID login (user-facing OAuth) for your service, declare spec.auth in your catalog:

spec:
  auth:
    mode: sso    # provisions BIO_CLIENT_ID and BIO_CLIENT_SECRET on every deploy
ModeWhat it doesWhen to use
ssoUser-facing OAuth. Provisions BIO_CLIENT_ID, BIO_CLIENT_SECRET, registers /api/auth/callbackServices with a login flow
service-onlyClient credentials only. No redirect URI registeredService-to-service, no user login
noneNo OAuth client provisionedDefault. Builder skips OAuth entirely

If you don't declare spec.auth, no OAuth credentials are injected. Your pod will not have BIO_CLIENT_ID or BIO_CLIENT_SECRET, and any code that calls BioAuth.fromEnv() will throw.

BIO_ID_URL is always injected by the builder as a core platform variable — no declaration needed.

Common mistake: Adding bio-id to internalDependencies does NOT enable OAuth. It only injects BIO_ID_URL (which is already injected). Use spec.auth: mode: sso instead.

See Authentication for the full login implementation guide.

Passport Configuration (catalog 0.6.0+)

Enrich the JWT with org branding, iecHash, and permissions at mint time by adding passport under spec.auth:

spec:
  auth:
    mode: sso
    passport:
      includeBranding: true    # fetch vault branding and include in JWT (default: false)
      includeIecHash: false    # include entity iecHash in JWT (default: false)
      headless: true           # enable /api/embed/* endpoints (default: false)
      allowedEmbedOrigins:     # CORS origins for browser-SDK embed calls (optional)
        - https://my-service.tawa.pro
FieldTypeDefaultEffect
includeBrandingbooleanfalseBio-ID fetches vault branding at mint time (fail-open, 3s timeout) and bundles it into the JWT branding claim
includeIecHashbooleanfalseIncludes the entity's iecHash (chain address) in the JWT
headlessbooleanfalseEnables /api/embed/* endpoints (login, signup, magic-link, provision-org) for this client. Required for EmbedClient.* calls from the @insureco/bio SDK
allowedEmbedOriginsstring[][]CORS origins allowed to call /api/embed/* with this client's credentials. Only needed when calling from a browser SDK — server-to-server calls using X-Client-Id/X-Client-Secret headers don't require this

On deploy, the builder writes includeBranding/includeIecHash into the OAuth client's passportConfig, and writes headless/allowedEmbedOrigins as top-level headlessEnabled/allowedEmbedOrigins fields on the same client. Services read req.user.branding from the decoded JWT — no additional API calls needed.

Migration note: The shorthand branding: true is still accepted and maps to includeBranding: true. The explicit form is preferred for new services.

See Passport & Branding for the full branding claim shape and consumption patterns.

Dependencies — Connecting to Other Services

There are two kinds of service connectivity, and you only declare one of them.

1. Core platform services — auto-injected, never declare

The builder injects these URLs into every pod on every deploy. You do not list them anywhere — if you declare one, the builder warns and ignores the entry.

Env varServiceNotes
BIO_ID_URLBio-ID (https://bio.tawa.pro)Identity / OAuth — always production
KOKO_URLkokoService registry
JANUS_URLjanusGateway / proxy
IEC_WALLET_URLiec-walletGas / wallet
SEPTOR_URLseptorAudit trail
IEC_QUEUE_URLiec-queueJob queue

These are injected as direct cluster URLs (http://{svc}.{svc}-{env}.svc.cluster.local:{port}), not Janus /i/ URLs. For septor and iec-queue the builder also auto-applies their NetworkPolicy direct-dep label on every deploy, so calls to them connect without any declaration. (SEPTOR_URL, IEC_QUEUE_URL, etc. are free — no gas, no scopes.)

2. Everything else — declare under spec.dependencies

For any other service you call (relay, docman, raterspot, another team's API…), declare it in a single spec.dependencies array. transport defaults to janus, and scopes are optional — so a bare entry is all most services need.

spec:
  dependencies:
    # Bare entry — routes through Janus, injects DOCMAN_URL + DOCMAN_CHARGE_MODE.
    - service: docman              # registered (Koko) name
      charge: service              # optional — 'service' (your org pays) when omitted

    # Scoped entry — add scopes only when the target defines Bio-ID scope grants.
    - service: raterspot
      scopes: [raterspot:rate]     # optional
      charge: callerOrg            # optional
      # transport omitted → defaults to janus
FieldRequiredDefaultMeaning
serviceyesThe registered Koko service name (e.g. relay, not iec-relay)
scopesno[]Bio-ID scopes to request — only when the target defines them (see Scopes)
transportnojanusHow {SERVICE}_URL is routed (see below)
chargenoserviceWho pays gas: service (your org) or callerOrg (the caller's org)

Transport modes

transportInjects {SERVICE}_URL asUse for
janus (default){JANUS_URL}/i/{service} (Janus proxy)Almost everything — JWT-verified and gas-metered
directraw K8s DNS http://{svc}.{svc}-{env}.svc.cluster.local:{port}Same-org own-service UI/API splits only. Cross-org is silently forced back through Janus. Also applies the target's direct-dep NetworkPolicy label
gateway(nothing — no URL is injected)The consumer calls the Janus public gateway directly; no URL env var needed

Scopes

Scopes are optional. Declare them only when the target service defines Bio-ID scope grants (e.g. relay:send, raterspot:rate). A scoped dependency does two extra things beyond injecting the URL:

  1. It provisions an OAuth client-credentials grant — so BIO_CLIENT_ID / BIO_CLIENT_SECRET are injected (this is how SDKs like @insureco/relay authenticate).
  2. It opens a scope grant in Bio-ID. Same-org grants auto-approve on deploy; a cross-org grant starts as pending and blocks the build until the target org approves it in Console → Permissions → API Access.

A dependency with no scopes skips both — it just gets its {SERVICE}_URL injected.

What the builder injects

For each declared dependency (that isn't a gateway transport and isn't an auto-injected platform service), the builder injects two env vars:

DeclaredEnv varExample value
service: docman (default janus)DOCMAN_URLhttp://janus.janus-prod.svc.cluster.local:3000/i/docman
service: my-api + transport: direct (same-org)MY_API_URLhttp://my-api.my-api-prod.svc.cluster.local:3000
charge: callerOrgDOCMAN_CHARGE_MODEcallerOrg
charge: service (or omitted)DOCMAN_CHARGE_MODEservice

Service name → env var name: uppercase, hyphens become underscores. iec-walletIEC_WALLET_URL.

Mixing internalDependencies and dependencies

You may declare both blocks in the same catalog — the builder merges them into one resolved list. A service that appears in both is deduped by name, and the scoped dependencies entry wins.

spec:
  internalDependencies:
    - service: legacy-api     # → LEGACY_API_URL (Janus proxy), no scopes
      charge: service
  dependencies:
    - service: docman
      transport: janus
      scopes: [docman:generate]
      charge: service
# Both legacy-api AND docman get their *_URL injected.

Deprecated: internalDependencies / externalDependencies are legacy. Whenever either appears, the builder emits a deprecation warning in tawa logs and merges them into dependencies anyway. New services should put everything under a single dependencies array. The mapping: an internalDependencies entry becomes transport: janus (or direct if it set transport: direct) with empty scopes; an externalDependencies entry becomes transport: gateway. Because transport now defaults to janus and scopes are optional, a bare dependencies entry behaves exactly like the old internalDependencies entry — no workaround needed.

Historical bug (fixed): Before the merge fix, declaring dependencies on a 0.4.0+ catalog silently dropped every internalDependencies entry — no *_URL was injected for them. If a service relied on relay/other internal deps while also using scoped dependencies, its RELAY_URL (etc.) was missing. See iec-builder#11.

Common mistakes:

  • Using transport: gateway expecting a URL env var — gateway does not inject a URL
  • Assuming scopes are required on dependencies — they are optional; omit them unless the target defines Bio-ID scope grants
  • Hardcoding K8s DNS URLs (e.g., http://docman.docman-prod.svc.cluster.local:3000) in env vars — blocked by NetworkPolicy, will fail with connection refused

Routes

routes:
  - path: /api/my-svc/screen
    methods: [POST]
    auth: required    # required | optional | none | service | public
    gas: 5            # tokens per successful call. 0=free. omit=default(1)
    charge: service # service applies when omitted; the override is callerOrg

Auth values

ValueWho can callNotes
requiredAny authenticated userJanus verifies user JWT
optionalAnyoneJWT verified and forwarded if present, but not required
serviceService-to-service onlyRequires service credentials JWT
publicAnyoneNo auth, but still metered
noneAnyoneNo auth, not metered — use for health checks

charge values

ValueWho pays gasWhen to use
serviceDefault. The service owner's org paysSubsidised/freemium routes, internal tooling, and the default for everything
callerOrgOverride. The calling user's org (orgSlug from their JWT) pays — requires forwarding the user's JWT as X-Forward-UserUser-facing APIs where the caller's org should absorb the gas cost

Queues and Schedules

spec:
  queues:
    - name: process-claim
      endpoint: /internal/jobs/process-claim
      concurrency: 5
      retries: 3
      retryDelayMs: 5000
      timeoutMs: 30000

  schedules:
    - name: nightly-sync
      cron: "0 2 * * *"
      endpoint: /internal/cron/nightly-sync
      timezone: America/Denver
      timeoutMs: 60000

Queue and schedule endpoints must NOT be listed under routes:. They are internal-only.

iec-cron and iec-queue call your service through Janus using platform service credentials. Your /internal/* endpoints are protected — only platform services can reach them.

Headers your endpoint receives

Cron callbacks (/internal/cron/*):

HeaderValue
AuthorizationBearer {platform-service-jwt}
X-Schedule-NameSchedule name from catalog-info.yaml
X-Cron-ExpressionThe cron pattern that fired
X-Fired-AtISO 8601 timestamp
Content-Typeapplication/json

Body: { scheduleName, namespace, firedAt }. Both req.headers['x-schedule-name'] and req.body.scheduleName carry the schedule name — prefer the header for consistency.

Queue callbacks (/internal/jobs/*):

HeaderValue
AuthorizationBearer {platform-service-jwt}
X-Queue-NameQueue name from catalog-info.yaml
X-Job-IdUnique job identifier
X-Job-AttemptAttempt number (1-indexed)
Content-Typeapplication/json

Body: { jobId, queue, attempt, data }.

Gas costs

TriggerProductionSandbox
Cron schedule fires5 tokens (charged upfront)0 tokens (free)
Queue job executed0 tokens (platform cost)0 tokens (free)

Cron gas is charged to the namespace owner's wallet when the schedule fires — before your endpoint is even called. Queue execution carries no gas cost because the charge was already applied when the user triggered the enqueue via a public API route.

All Annotations

annotations:
  insureco.io/framework: express           # REQUIRED
  insureco.io/catalog-version: "0.5.0"    # required for spec.tests
  insureco.io/node-version: "20"          # default: 20
  insureco.io/pod-tier: nano              # default: nano
  insureco.io/health-endpoint: /health    # REQUIRED (0.2.0+)
  insureco.io/port: "3000"               # default: 3000
  insureco.io/build-command: npm run build
  insureco.io/start-command: node dist/index.js
  insureco.io/output-dir: dist
  insureco.io/openapi: openapi.yaml       # PLANNED — not yet read by the builder; auto-generated UI tile is upcoming
  insureco.io/copy-paths: "dist,data,openapi.yaml"  # extra paths to copy into runtime image

copy-paths — Including Extra Files in the Runtime Image

If your service needs files beyond dist/ at runtime (seed data, static assets, config files), list them with copy-paths instead of writing a custom Dockerfile:

annotations:
  insureco.io/copy-paths: "dist,data,openapi.yaml"
  • Comma-separated list of files or directories
  • Paths are relative to the project root
  • Each path is copied from the builder stage into the final runtime image
  • Works for single files (openapi.yaml) and directories (data/)

Common use cases:

Needcopy-paths value
TypeScript output + seed data"dist,data"
TypeScript output + OpenAPI spec"dist,openapi.yaml"
Built UI assets alongside API"dist,ui/dist"
Source + pre-built dashboard"api,dash/dist"

You do NOT need a custom Dockerfile just to include extra files — use copy-paths.

spec.egress — Outbound IP Routing

Services that call external APIs with fixed IP allowlists (e.g. payment processors, banking partners) can join an egress group so their outbound HTTPS traffic is routed through a dedicated VPC proxy.

spec:
  egress:
    group: payments    # name of the egress group (created by a platform admin)

On deploy, the builder:

  1. Looks up the named egress group in the egress manager.
  2. Injects both HTTPS_PROXY=http://<proxyIp>:<proxyPort> and HTTP_PROXY=http://<proxyIp>:<proxyPort> into the pod's env vars.
  3. Registers the service in the group's services list so the bound DO Cloud Firewall allows its traffic.

If the group does not exist yet, the builder logs a warning and continues — proxy vars are not injected:

[warn] Egress group 'payments' not found — HTTP_PROXY/HTTPS_PROXY will not be injected

The default proxy port is 3128 if the group does not specify one.

Egress groups are created and managed by platform admins in the tawa-web Console or via the builder API (POST /egress/groups). Contact your platform admin to have a group created before you deploy.

CRITICAL — Set NO_PROXY after adding spec.egress

HTTP_PROXY is set in addition to HTTPS_PROXY. Node.js native fetch (undici) respects HTTP_PROXY, which means all outbound HTTP traffic — including internal K8s calls to Janus, relay, septor, wallet — is routed through the external egress proxy. The egress proxy cannot resolve .svc.cluster.local hostnames, so all platform SDK calls silently fail with NETWORK_ERROR.

Always set NO_PROXY immediately after adding spec.egress:

# IMPORTANT: Do NOT include a leading dot (e.g. .svc.cluster.local).
# Node.js undici checks hostname.endsWith('.' + entry). A leading dot creates a
# double-dot match (endsWith('..svc.cluster.local')) that never passes.
# Use cluster.local without a leading dot to match all K8s-internal hostnames.
tawa config set NO_PROXY="cluster.local,127.0.0.1,localhost"
tawa deploy --prod

Symptom if NO_PROXY is missing or uses a leading dot: all relay, docman, septor calls return fetch failed / NETWORK_ERROR with statusCode: 0.

spec.analytics — Matomo Tracking (frontend only)

Auto-provisions a per-environment Matomo site and injects tracking config. Only acts on nextjs / static frameworks.

spec:
  analytics:
    enabled: true

On deploy (frontend services only), the builder finds-or-creates a Matomo site for the service's environment hostname and injects two runtime env vars:

VarExampleNotes
MATOMO_URLhttps://engage.insureco.ioMatomo instance base URL
MATOMO_SITE_ID218Provisioned site ID (public by design)

Add the @insureco/analytics SDK's <MatomoAnalytics /> to your root layout (a Server Component) and you're tracking. The site ID is read server-side at request time — not as a NEXT_PUBLIC_* build-time var — so it survives the build → deploy boundary. Each environment gets its own site; traffic never mixes. The Matomo admin token lives only on the builder and is never injected into pods.

Backend frameworks (express/hono/fastify/worker) that declare spec.analytics get a build warning and no injection.

Full guide: analytics.md.

spec.owner — Immutable After Registration

spec.owner is your organization slug (e.g. acme-corp). It is set once when you first deploy and cannot be changed afterwards.

The builder enforces this with an owner tamper guard: if spec.owner in your catalog-info.yaml does not match the registered owner of the service in the platform, the deploy is rejected:

Deploy gate: catalog spec.owner "attacker-org" does not match the registered service owner "acme-corp".

Why this matters: the deploy gate charges hosting costs to the wallet of the org in spec.owner. Without this check, a bad actor could change spec.owner to another org's slug and charge deploys to their wallet.

If you legitimately need to transfer ownership of a service (e.g., rebrand or org merge), contact the platform team — this requires an admin operation, not a YAML edit.

Key Facts

  • metadata.name becomes your hostname: my-svc.tawa.insureco.io
  • You do NOT need a Dockerfile — the builder generates one from your framework
  • Pod tier defaults to nano — start here, upgrade when you have data
  • Adding a database to an existing service: just add it to catalog-info.yaml and redeploy
  • Redeploying does NOT touch your data — databases are persistent
  • Declared-dependency {SERVICE}_URL vars (default transport: janus) point at Janus /i/{service} — never hardcode K8s DNS URLs for those
  • To get BIO_CLIENT_ID / BIO_CLIENT_SECRET injected, you must declare spec.auth: mode: sso (or a scoped dependencies entry, which also provisions client-credentials)
  • BIO_ID_URL, JANUS_URL, KOKO_URL, IEC_WALLET_URL, SEPTOR_URL, and IEC_QUEUE_URL are auto-injected on every deploy (as direct cluster URLs) — do not declare them
  • septor and iec-queue are auto-labeled — as of iec-builder 9e0a2d0 (2026-06-22) the builder applies their tawa.pro/direct-dep.{svc}=true NetworkPolicy label on every deploy (ALWAYS_DIRECT_DEP_SERVICES), so audit/queue calls connect with no declaration needed. (Older services deployed before that date pick up the label on their next deploy.)
  • RELAY_URL is not auto-injected — declare relay under dependencies with scopes: [relay:send] and transport: janus (this provisions both RELAY_URL and the BIO_CLIENT_ID/BIO_CLIENT_SECRET the SDK needs; metered per send, charge defaults to service)
  • Default charge mode is service — the service owner's org pays unless you explicitly set charge: callerOrg
  • NODE_ENV is always set to production by the builder for all deployments (including sandbox) — do not set it yourself
  • spec.env is not parsed — hardcoded env vars belong in tawa config set KEY=VALUE, not catalog-info.yaml
  • See Authentication for the OAuth login implementation
  • See service-communication for calling other services from code

Last updated: July 15, 2026