← Back to Skills

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,…

Ruledeploy

About

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.

Skill Content

This is the raw markdown that gets installed as a Claude Code rule.

# 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

```yaml
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)

```yaml
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.

| Version | Adds |
|---------|------|
| `0.6.0` | `spec.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.0` | `spec.tests`; `spec.userAccess` |
| `0.4.0` | `spec.dependencies` (with `scopes` + `transport`) |
| `0.3.0` | `spec.storage` |

## Framework Types

| Framework | Start Command | Port | Health |
|-----------|--------------|------|--------|
| `express` | `node dist/index.js` | 3000 | `/health` |
| `nextjs` | `npm start` | 3000 | `/api/health` |
| `hono` | `node dist/index.js` | 3000 | `/health` |
| `fastify` | `node dist/index.js` | 3000 | `/health` |
| `worker` | `node dist/worker.js` | 3000 | none |
| `static` | nginx | 80 | `/health` |

## OAuth / Bio-ID Login (`spec.auth`)

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

```yaml
spec:
  auth:
    mode: sso    # provisions BIO_CLIENT_ID and BIO_CLIENT_SECRET on every deploy
```

| Mode | What it does | When to use |
|------|-------------|-------------|
| `sso` | User-facing OAuth. Provisions `BIO_CLIENT_ID`, `BIO_CLIENT_SECRET`, registers `/api/auth/callback` | Services with a login flow |
| `service-only` | Client credentials only. No redirect URI registered | Service-to-service, no user login |
| `none` | No OAuth client provisioned | Default. 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](/reference/auth) 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`:

```yaml
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
```

| Field | Type | Default | Effect |
|-------|------|---------|--------|
| `includeBranding` | boolean | `false` | Bio-ID fetches vault branding at mint time (fail-open, 3s timeout) and bundles it into the JWT `branding` claim |
| `includeIecHash` | boolean | `false` | Includes the entity's `iecHash` (chain address) in the JWT |
| `headless` | boolean | `false` | Enables `/api/embed/*` endpoints (login, signup, magic-link, provision-org) for this client. Required for `EmbedClient.*` calls from the `@insureco/bio` SDK |
| `allowedEmbedOrigins` | string[] | `[]` | 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](/reference/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 var | Service | Notes |
|---------|---------|-------|
| `BIO_ID_URL` | Bio-ID (`https://bio.tawa.pro`) | Identity / OAuth — always production |
| `KOKO_URL` | koko | Service registry |
| `JANUS_URL` | janus | Gateway / proxy |
| `IEC_WALLET_URL` | iec-wallet | Gas / wallet |
| `SEPTOR_URL` | septor | Audit trail |
| `IEC_QUEUE_URL` | iec-queue | Job 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.

```yaml
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
```

| Field | Required | Default | Meaning |
|-------|----------|---------|---------|
| `service` | yes | — | The registered **Koko** service name (e.g. `relay`, **not** `iec-relay`) |
| `scopes` | no | `[]` | Bio-ID scopes to request — only when the target defines them (see [Scopes](#scopes)) |
| `transport` | no | `janus` | How `{SERVICE}_URL` is routed (see below) |
| `charge` | no | `service` | Who pays gas: `service` (your org) or `callerOrg` (the caller's org) |

### Transport modes

| `transport` | Injects `{SERVICE}_URL` as | Use for |
|-------------|---------------------------|---------|
| `janus` (default) | `{JANUS_URL}/i/{service}` (Janus proxy) | Almost everything — JWT-verified and gas-metered |
| `direct` | raw 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:

| Declared | Env var | Example value |
|----------|---------|---------------|
| `service: docman` (default `janus`) | `DOCMAN_URL` | `http://janus.janus-prod.svc.cluster.local:3000/i/docman` |
| `service: my-api` + `transport: direct` (same-org) | `MY_API_URL` | `http://my-api.my-api-prod.svc.cluster.local:3000` |
| `charge: callerOrg` | `DOCMAN_CHARGE_MODE` | `callerOrg` |
| `charge: service` (or omitted) | `DOCMAN_CHARGE_MODE` | `service` |

Service name → env var name: uppercase, hyphens become underscores. `iec-wallet` → `IEC_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.

```yaml
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

```yaml
routes:
  - path: /api/my-svc/screen
    methods: [POST]
    auth: required    # required | 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

| Value | Who can call | Notes |
|-------|-------------|-------|
| `required` | Any authenticated user | Janus verifies user JWT |
| `service` | Service-to-service only | Requires service credentials JWT |
| `public` | Anyone | No auth, but still metered |
| `none` | Anyone | No auth, not metered — use for health checks |

### `charge` values

| Value | Who pays gas | When to use |
|-------|-------------|-------------|
| `service` | Default. The service owner's org pays | Subsidised/freemium routes, internal tooling, and the default for everything |
| `callerOrg` | Override. The calling user's org (`orgSlug` from their JWT) pays — requires forwarding the user's JWT as `X-Forward-User` | User-facing APIs where the caller's org should absorb the gas cost |

## Queues and Schedules

```yaml
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/*`):

| Header | Value |
|--------|-------|
| `Authorization` | `Bearer {platform-service-jwt}` |
| `X-Schedule-Name` | Schedule name from catalog-info.yaml |
| `X-Cron-Expression` | The cron pattern that fired |
| `X-Fired-At` | ISO 8601 timestamp |
| `Content-Type` | `application/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/*`):

| Header | Value |
|--------|-------|
| `Authorization` | `Bearer {platform-service-jwt}` |
| `X-Queue-Name` | Queue name from catalog-info.yaml |
| `X-Job-Id` | Unique job identifier |
| `X-Job-Attempt` | Attempt number (1-indexed) |
| `Content-Type` | `application/json` |

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

### Gas costs

| Trigger | Production | Sandbox |
|---------|-----------|---------|
| Cron schedule fires | 5 tokens (charged upfront) | 0 tokens (free) |
| Queue job executed | 0 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

```yaml
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       # enables auto-generated UI tile
  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:

```yaml
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:**

| Need | copy-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.

```yaml
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`:

```bash
# 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.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`](https://git.insureco.io/insureco/iec-builder/src/branch/main/src/services/builder.ts)), 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](/reference/auth) for the OAuth login implementation
- See [service-communication](/reference/service-communication) for calling other services from code

Install

Copy the skill content and save it to:

~/.claude/rules/tawa-catalog-info.md
Download .md

Coming soon via CLI:

tawa chaac install tawa-catalog-info

Details

Format
Rule
Category
deploy
Version
1.0.10169
Tokens
~5,663
Updated
2026-06-24
platformdeploycatalogdeploymentconfigurationroutesdatabasesdependencies