← 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` | `npm start` | 3000 | `/health` |
| `nextjs` | `npm start` | 3000 | `/api/health` |
| `hono` | `npm start` | 3000 | `/health` |
| `fastify` | `npm start` | 3000 | `/health` |
| `worker` | `node dist/worker.js` | 3000 | none |
| `static` | nginx | 8080 | `/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:

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

> **Correction (2026-08-27):** Earlier revisions of this page said the shorthand `branding: true` was accepted and mapped to `includeBranding: true`. **That was never true.** No such mapping exists in the builder — it reads only `includeBranding`. The passport parser is permissive, so `branding: true` is dropped silently: the service deploys, gets no `branding` claim, and `req.user.branding` is undefined at runtime. Use `includeBranding: true`. `tawa preflight` 0.21.0+ warns if it sees `branding`.

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

> **First: "scopes" means two unrelated things on this platform.** They share a word and nothing else. Confusing them is the single most common catalog authoring error.
>
> | | **Koko scopes** — database access | **Bio-ID scopes** — API access |
> |---|---|---|
> | Declared in | `spec.scopes` (owner) + `tawa scopes request` (consumer) | `spec.modules[].scopes` (owner) + `spec.dependencies[].scopes` (consumer) |
> | Governs | MongoDB / Redis / Neo4j / users | Service-to-service API calls |
> | Effect | A connection-string env var is injected — genuinely enforced | Deploy-time approval gate + client-credentials provisioning |
> | Enforced per request? | n/a | **No** — see below |
>
> This section, and everything under `spec.dependencies`, is about the **Bio-ID / API** kind. `tawa scopes request` and `spec.scopes` are the **database** kind — see [Service Dependencies & Scopes](/reference/scopes).

> **Do not invent scope names.** A scope only exists if the *target* service publishes it under `spec.modules[].scopes`. Declaring `scopes: [docman:generate]` when docman publishes no scopes is inert — no error, no warning, no effect. As of 2026-08-27 the only service publishing API scopes is **relay** (`relay:send`, `relay:templates`, `relay:admin`). Check the target's catalog before writing a scope.

Scopes are **optional**. Declare them only when the target service defines Bio-ID scope grants (e.g. `relay:send`). 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 a scope grant does *not* do (verified 2026-08-27).** A grant is a **deploy-time** gate, not a runtime one. Internal calls routed through Janus (`/i/{service}`) are **not** scope-checked per request: Janus runs no scope middleware on that path, and a target service that checks scopes itself bypasses the check whenever Janus supplies its identity headers — which it always does. So a service does **not** need `relay:send` in order to send; it needs the dependency declared so `RELAY_URL` is injected.
>
> Public routes registered in Koko with declared `scopes` **are** enforced normally. The gap is specific to internal `/i/` calls.
>
> Point 1 above is also conditional: `BIO_CLIENT_ID` / `BIO_CLIENT_SECRET` are injected by `spec.auth: mode: sso` as well, so a service with SSO already has them and the scoped dependency adds nothing on that front.
>
> Full analysis: Bible → `tawa-platform/architecture/janus-internal-auth-verified`.

### 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 | 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

| Value | Who can call | Notes |
|-------|-------------|-------|
| `required` | Any authenticated user | Janus verifies user JWT |
| `optional` | Anyone | JWT verified and forwarded if present, but not required. Registered in Koko as `none` — see below |
| `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 |

Janus enforces only two of these: `required` (the caller must be an authenticated user) and `service` (the caller must present service credentials). `optional`, `public`, and `none` are all non-enforcing at the gateway — they differ in intent, not in behaviour.

`optional` is therefore a documentation-level synonym for `none`. It stays valid in `catalog-info.yaml`, but the builder registers it in Koko as `none`: Koko's service registry accepts only `none | required | service | public`, and sending `optional` makes it reject the **entire** service registration — leaving the service missing from the registry, `/i/{service}` returning 404, and declared schedules and queues never firing. The mapping does not change gas: Janus meters on the calling token, not on the declared `auth` value. A misspelled `auth` value is registered as `required` (fail closed) and warns in the build log.

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

### `health-endpoint` — One Value, Three Consumers

The path you declare here is the single source of truth for every health check the platform performs:

| Consumer | What it does with it |
|----------|---------------------|
| Kubernetes | Liveness + readiness probe paths on your pod |
| Koko | Stored on your service registry entry |
| iec-pulse | Probes `https://{service}.tawa.pro{health-endpoint}` — this is what the public status board reports |

Resolution order on every deploy:

1. `insureco.io/health-endpoint` from **this file** — always wins, and is re-read on every deploy
2. The value cached on your service record from an earlier deploy
3. `/health`

Omit the annotation and you get your framework's default — `/api/health` for `nextjs`, `/health` for everything else. Declare it and that value is used verbatim.

> Declaring `/api/health` and being probed at `/health` shows as **0% uptime on the status board while your service serves traffic normally** — and triggers outage alerts. If a status page disagrees with reality, check this annotation first, then redeploy: the catalog value is re-registered in Koko on every build.

### `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.analytics` — Matomo Tracking (frontend only)

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

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

| Var | Example | Notes |
|-----|---------|-------|
| `MATOMO_URL` | `https://engage.insureco.io` | Matomo instance base URL |
| `MATOMO_SITE_ID` | `218` | Provisioned 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](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.

## Keys the builder does not read

The parser takes the fields it knows and ignores the rest. Since 2026-08-21 it **warns** for
each ignored key in the build log rather than discarding it in silence — check the log after a
deploy that seems to have skipped something you configured.

The dangerous case is not a misspelling, which you find quickly. It is a real block nested one
level too high: it parses, it deploys, and it does nothing, while you believe it is in force.

| You wrote | Where it actually goes |
|-----------|------------------------|
| `spec.compliance` | `spec.tests.compliance` — see the warning below before moving it |
| `spec.smoke` / `spec.e2e` | `spec.tests.smoke` / `spec.tests.e2e` |
| `spec.oauth` | `spec.auth` (`mode: sso \| service-only \| none`) — without it `BIO_CLIENT_ID` / `BIO_CLIENT_SECRET` are never injected |
| `spec.scopes` | per dependency: `spec.dependencies[].scopes` |
| `spec.relevantRoles` | no such field — roles a service grants go under `spec.userAccess.roles` (0.6.0+) |
| `expectBody` on a smoke entry | nowhere — smoke entries carry `path`, `expect`, `timeout` only. The status check runs; the body assertion never did |

Standard Backstage fields (`system`, `providesApis`, `consumesApis`, `subcomponentOf`,
`dependsOn`) never warn. The builder does not use them, but they belong in a
`catalog-info.yaml` on their own terms.

> **Moving a compliance block turns it on.** In its correct position under `spec.tests`,
> compliance becomes a **deploy gate**: it queries Septor for the declared event types on every
> build. An environment that has never emitted them — a new service, or a fresh prod — fails
> every deploy until it has real history. Move the block once the service has produced the
> events you are asserting on, not before.

## 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.46411
Tokens
~7,643
Updated
2026-08-28
platformdeploycatalogdeploymentconfigurationroutesdatabasesdependencies