← 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.7.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
    e2e:                                   # browser flows — see spec.tests below
      - name: "Login flow"
        steps:
          - { action: navigate, url: /login }
          - { action: assert_text, selector: h1, text: "Sign in" }
```

### 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.7.0` | `spec.walkthrough` — an asserted screenshot flow run by iec-test on every deploy (see [`spec.walkthrough`](#specwalkthrough--asserted-screenshot-walkthroughs)) |
| `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` |

### Where the 0.6.0 metadata lands in Koko

Both 0.6.0 fields are synced to Koko on every deploy, under **different keys** — worth
knowing when you go looking for the data:

| Field | Koko key | Stored as |
|-------|----------|-----------|
| `spec.registration` | your **bare service name** (`my-svc`) | a `registration` object on the service's registry document |
| `spec.userAccess.roles` | **`{service}-{environment}`** (`my-svc-prod`) | one `service_roles` document per role |

The role taxonomy is environment-scoped on purpose: that key is the same string as the
Bio-ID user-access service key behind `BIO_SERVICE_KEY`, and `bio.tawa.pro/api/v2/users`
returns `403` unless the `?service=` you pass matches your own key. So query roles as
`?service=my-svc-prod`, not `?service=my-svc`. Registration metadata is per-service rather
than per-environment, so it carries no suffix.

> **Correction (2026-08-30):** neither sync actually worked between the 0.6.0 release and
> this date. Koko's role-id validator accepted only the `namespace:name` spelling and
> rejected `namespace_name`, and the endpoint receiving `spec.registration` was never
> implemented — it returned a bare `404`. Both failures are non-fatal in the builder, so
> deploys reported success while `service_roles` stayed empty in production and no service
> ever had registration metadata stored. Fixed in iec-koko#16 / iec-builder#84. If your
> service declared either field before that date, **redeploy** to populate it — nothing is
> backfilled.

## 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
    sessionCookie: portal_access_token  # optional — the cookie YOUR app reads (default: bio_access_token)
```

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

### `sessionCookie` — the cookie your app reads its session from

Anything that drives your app as a logged-in user — a walkthrough, a Playwright
run, a coding agent checking its own work — has to put a token in the cookie your
app actually looks for. Nothing can guess that name, and getting it wrong fails
*silently*: the app renders signed out, every status check stays green, and
nothing is logged anywhere. That is exactly how iec-portal#589 happened — the
portal reads `portal_access_token`, the tooling wrote `bio_access_token`.

So declare it:

```yaml
spec:
  auth:
    mode: sso
    sessionCookie: portal_access_token   # default when omitted: bio_access_token
```

| | |
|---|---|
| Default when absent | `bio_access_token` |
| Must be | a legal cookie name (RFC 6265 token: letters, digits and `!#$%&'*+-.^_`\|~`, at most 128 characters) |
| An invalid value | **warns in the build log and is ignored** — the deploy never fails over it, and tooling falls back to the default |
| Where it goes | cached on the service record on deploy, and served by `GET /services/:name/session-info` |

It is a **hint about your app**, not a setting that changes it. The builder does
not set the cookie, read it, or configure your framework — your code still owns
the cookie entirely. Declaring it only means a tool can stop guessing.

#### Reading it back: `GET /services/:name/session-info`

The two facts a tool needs to open your app as a user, and that only the builder
knows per service **and** environment:

```
GET /services/portal/session-info?env=prod        # viewer role
```

```json
{ "success": true, "data": { "service": "portal", "environment": "prod",
    "clientId": "portal-prod", "cookieName": "portal_access_token",
    "url": "https://portal.insureco.io", "host": "portal.insureco.io" } }
```

- **`clientId`** is the service's *real* OAuth client id for that environment —
  the audience a user token must be minted for. `{service}-{env}` is a naming
  convention, not a contract; a service that pins `aud` to its own client will
  refuse a token minted for anything else.
- **`url` / `host`** are the service's public address for that environment — its
  **verified custom domain** when it has one, else the platform hostname. A
  session cookie has to be set on the host the app actually serves its session
  from, and both hostnames serve the same deployment, so this is not cosmetic.
- No secret is ever in this response. A client id is public by construction.
- `404 NO_OAUTH_CLIENT` means the service has no OAuth client in that
  environment — declare `spec.auth` and redeploy.

Declared walkthroughs get this automatically: every walkthrough run the builder
sends to iec-test carries `session: { clientId, cookieName }`.

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

    # Webhook receiver — the builder provisions the provider's signing secret.
    - service: policysign
      webhooks:
        path: /api/webhooks/policysign   # → POLICYSIGN_WEBHOOK_SECRET (managed secret)
```

| 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) |
| `webhooks.path` | no | — | Where you receive this dependency's webhooks. If the target publishes a webhook contract, its signing secret is injected on deploy (see [Provider webhook secrets](#provider-webhook-secrets-webhooks)) |

### 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.
>
> Gateway routes are a separate path: Janus evaluates the route's `auth` (and `scopes`, if any) there, in shadow mode by default (see [Auth values](#auth-values)). Koko carries no `scopes` today, so in practice no gateway route is scope-checked. The gap above is specific to internal `/i/` calls, which no mode checks.
>
> **Correction (2026-10-04):** this note previously said public routes with declared scopes are "enforced normally". They were not, and nothing carried scopes to enforce.
>
> 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`.

### Provider webhook secrets (`webhooks`)

A dependency that calls you back with signed webhooks can have its signing secret provisioned
for you. Add `webhooks.path` — the receiver path on your service:

```yaml
spec:
  dependencies:
    - service: policysign
      webhooks:
        path: /api/webhooks/policysign
```

On each deploy the builder reads the target's contract (`spec.webhookProvider`, synced to Koko),
computes your receiver URL (verified custom domain for the environment, else
`{service}.{env}.tawa.pro`), asks the provider to find or create your endpoint, and injects the
secret under the contract's env var (`POLICYSIGN_WEBHOOK_SECRET`) as a managed secret.

- Existing endpoints on the same URL (else same origin) are **adopted**; secrets are **never rotated**.
- A value you set with `tawa config set` **wins**; a mismatch is warned about, never logged.
- **Fail-open**: provider down, no contract, no token → a `[warn]` line, deploy continues.
- Each environment gets its own endpoint and secret.
- No `webhooks.path` → nothing changes.

Providers publish the contract with `spec.webhookProvider: { provisionEndpoint, secretEnv }`.
Full guide: [Provider Webhook Secrets](/reference/provider-webhook-secrets).

### 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 (platform-owned routes may also use system)
```

### Path parameters and method-aware pricing

Write a path parameter as `{name}` (the catalog / OpenAPI spelling) or `:name`. Janus treats them
identically: one non-empty path segment. Before 4 Oct 2026 a `{name}` path was matched as literal text,
so `/v1/entities/{iecHash}/validate` never matched a real request and fell through to whatever shorter
or catch-all route existed.

```yaml
routes:
  - path: /v1/entities/{iecHash}/validate
    methods: [POST]
    gas: 3
  - path: /v1/entities/{iecHash}
    methods: [GET]
    gas: 1
```

- **The path picks the service and the upstream; the method picks the route within that service.**
  Janus first finds the route that serves the path (this decides which service gets the request and
  what path it sees), then picks, among that **same service's** routes that allow the request's method,
  the best path match. That route supplies the price, `auth` and owner. A method never moves a request
  to a different service: `GET /v1/events/emit` does not leave septor for a `/` catch-all.
- **Same path, different methods, different prices.** `GET /api/products` at 1 gas and
  `POST /api/products` at 3 gas are two routes and are priced separately. `HEAD` is served by `GET` routes.
  A route with no `methods` allows any method.
- If no route in the service allows the method, Janus falls back to the path match, so the call is never
  newly rejected by this lookup.
- A `stripPrefix` route whose prefix contains `{name}` strips the parameter too (the same as `:name`).
- To see what Janus will do with a request, ask it: `GET /admin/routes/match?method=POST&path=/v1/entities/x/validate`
  (platform admin) returns the dispatch route, the policy route, the upstream path, and the gateway and
  `/i/` prices.

> **Correction (2026-10-04, iec-janus#30):** `{name}` segments were literal before this, and pricing
> ignored the method. The change moved 13 route/method pairs off the ppay-board `/` catch-all onto
> bio-vault (for example `/v1/branding/{orgSlug}`) and repriced about 70 declared routes. Declared prices
> that looked wrong against production (for example bio-vault `/v1/entities/{iecHash}/*` at 5 gas) now
> resolve to the price the route actually declares.

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

What Janus checks, per route (iec-janus#28):

- `none` and `public` allow everyone. `public` is metered; `none` is not.
- `required` needs any authenticated principal: a user JWT, a service token, or an API key. Anonymous calls get `401 AUTHENTICATION_REQUIRED`.
- `service` needs a service principal: an API key bound to a service, a client-credentials JWT (service id, no user), or a `service:*` scope. A user token gets `403 SERVICE_AUTH_REQUIRED`.
- `optional` is registered in Koko as `none` (see below), so it allows everyone.
- Scopes are checked when a route carries them (`403 INSUFFICIENT_SCOPE`). **Koko carries no `scopes` on any route today**, so no gateway route is scope-checked.

**This is shadow-first.** The route's `auth` is evaluated on every request, but by default Janus only
**records** what it would have refused (`would_refuse`, in `authz_events` and an `authz_shadow_refusal` log line) and
forwards the request anyway. The mode is a runtime setting, `authz`, changed by a Janus settings owner in the
admin settings surface (not by an env change and redeploy):

| `authz.mode` | Behaviour |
|---|---|
| `off` | Nothing is evaluated |
| `shadow` (default) | Refusals are recorded and the request is forwarded, **except** for services listed in `authz.enforceServices`, which are enforced |
| `enforce` | Refusals are returned (`401`/`403`), recorded `refused`, a Septor `auth.rejected` event is emitted, and the request is never charged |

Declaring `auth: required` or `auth: service` therefore does **not yet** stop an anonymous or wrong-type
caller at the gateway. Until the service is enforced, your service must keep doing its own auth. Janus
platform owners watch `GET /admin/authz/summary` (counts by service, route, method, reason and caller type)
and move services into `enforceServices` before switching the mode to `enforce`.

> **Correction (2026-10-04, iec-janus#28):** this page used to say Janus enforced only `required` and
> `service`. It did not enforce either: the route-level check in the gateway never refused anything. Route
> `auth` is now evaluated per request, in shadow mode first, as above.

`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 |
| `system` | The InsurEco system wallet (`wallet-insureco`) pays, whoever calls. **Platform-owned routes only** | Platform audit and infrastructure routes where callers should never be billed |

`charge: system` only counts on a route whose owner is the platform org (`PLATFORM_ORG_SLUG`). A third-party
service can declare it, but Janus ignores it there, so nobody else can bill InsurEco. A system-paid call is
never refused for lack of gas (see [Gas & Wallet](/reference/gas-wallet)). A route can also be made
system-paid at runtime, without a catalog change, through the `gas.systemPaidRoutes` Janus setting (the
default lists septor's `/v1/events/emit`). Neither the builder nor Koko carries `charge` today, so
the setting is the live path.

### Route prices are the only way a service charges (locked)

**Only Janus moves money** (locked 2026-09-24). `gas` and `charge` on a route are the whole
pricing interface: Janus reads them from Koko and charges each call. A service must never call
iec-wallet to debit or credit, meter usage itself, or hold wallet credentials. Full rule and
call-chain model: [Gas & Wallet → Only Janus moves money](/reference/gas-wallet).

> **Mostly enforced (verified 2026-10-04).** Janus charges calls through `/i/` the target route's
> declared `gas` (iec-janus#10), reads `charge` when a route carries one, and no longer needs a
> restart to pick up new routes or mangles non-`/api` paths (iec-janus#11, #12). One gap remains:
> `charge` is dropped before it reaches Janus
> ([iec-builder#182](https://git.insureco.io/insureco/iec-builder/issues/182),
> [iec-koko#38](https://git.insureco.io/insureco/iec-koko/issues/38)), so on the public gateway the
> caller's org is billed whatever you declare. Declare the price and payer you want anyway.
>
> **Correction (2026-10-04):** this note used to say calls through `/i/` cost a flat 1 gas. They
> never did: `/i/` was never metered at all before iec-janus#10 (a global free-infrastructure
> marker short-circuited its gas check). A route with no `gas` or `gas: 0` is free on `/i/`.

## 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/replicas: "2"               # default: 1 — see below before raising
  insureco.io/internal: "true"            # default: "false" — no ingress, no DNS, no uptime monitor — see below
  insureco.io/health-endpoint: /health    # REQUIRED (0.2.0+)
  insureco.io/port: "3000"               # default: 3000 (static: 8080) — see below
  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
  insureco.io/max-body-size: "50m"        # default: 10m with spec.storage, else 1m — see below
  insureco.io/memory-request: "512Mi"    # default: the pod tier's request — see below
  insureco.io/cpu-request: "50m"         # default: the pod tier's request — see below
```

### `port` — One Value, Three Consumers

The port your process listens on. Omit it and you get your framework's default — `8080` for
`static`, `3000` for everything else. Whatever it resolves to is used verbatim by:

| Consumer | What it does with it |
|----------|---------------------|
| Helm | `service.port` — the port the Kubernetes Service and container expose |
| Koko | the service's registered `upstream`, `http://{service}.{service}-{env}.svc.cluster.local:{port}` |
| Janus `/i/`, iec-cron, iec-queue | dial that Koko upstream (and the queue/schedule binding URL built from it) |

So a service on a non-default port must **declare** it. Leave it out, listen on `3215`, and the
Service, Koko and every internal call all point at `3000` — nothing connects.

A `transport: direct` dependency dials the **target's** registered port the same way, unless
the dependency entry carries its own legacy `port:`.

> **Correction (2026-09-27):** until iec-builder#187 (and #188 before it) the builder gave Helm
> the declared port but registered the service in Koko — and built the queue/schedule binding
> URLs — with `:3000` regardless. The Service listened on, say, 3215 while Koko held `:3000`, so
> Janus `/i/` calls and iec-cron callbacks to that service hung until TCP timeout (the ClusterIP
> drops SYNs to a port it does not expose). **Any service on a non-3000 port deployed before the
> fix must be redeployed once** so its Koko record and bindings are re-registered — there is no
> backfill. Check with `GET {KOKO_URL}/api/services/{service}`: `upstream` must end in your port.

### `internal` — No Public Hostname

```yaml
annotations:
  insureco.io/internal: "true"
```

For a service that must never be reachable from the internet (a policy engine, a database
sidecar such as SpiceDB). With `"true"` the builder:

| Normally | Internal |
|----------|----------|
| Ingress for `{service}.tawa.pro` plus verified custom domains | `--set ingress.enabled=false` — no Ingress object, no hosts. A verified custom domain is left off with a `[warn]` |
| Cloudflare record + Koko domain binding | skipped — the build log says `Internal service: no ingress, no DNS` |
| iec-pulse uptime monitor on the public URL | skipped (there is no public URL to probe) |
| NetworkPolicy admits Janus and ingress-nginx | `--set networkPolicy.internalOnly=true` — both rules dropped; only same-namespace pods, iec-test and `direct-dep` callers connect ([iec-builder#250](https://git.insureco.io/insureco/iec-builder/issues/250)) |

Unchanged: the service is still registered in Koko, and callers still reach it on cluster DNS.
Callers must declare it with **`transport: direct`** — that applies the `direct-dep`
NetworkPolicy label that lets their namespace in, and it is the **only** way in.

| Value | Effect |
|-------|--------|
| `"true"` | internal |
| `"false"` or absent | public, exactly as before — the Helm command is byte-identical |
| anything else | a build-log `[warn]`, treated as `"false"` |

> **Janus cannot reach it.** Janus `/i/{service}` still resolves the service from Koko,
> but the pod's NetworkPolicy no longer admits the `janus` namespace, so the connection
> is refused ([iec-builder#250](https://git.insureco.io/insureco/iec-builder/issues/250)).
> Keep the service's own authentication (a preshared key, a service token) in place anyway.

### `replicas` — Surviving a Readiness Blip

Every service runs **one** pod unless it says otherwise. That is fine for most
things and it is the cheapest default, but it has a sharp edge worth knowing:

> With one replica, **any** readiness failure returns 503 to real users.

The readiness probe allows a **3-second** response (the liveness probe too) and marks the pod NotReady
after 3 consecutive misses. When that happens to the only pod, the Service's
endpoint list goes empty and the ingress has nowhere to send traffic — so the
public URL serves **503 until the next probe succeeds**. The pod never
restarted, the logs show nothing, and by the time you look it is healthy again.
A Node process that stalls its event loop for more than three seconds, for
example under a CPU throttle, is enough to trigger it.

> **Changed 2026-09-29 (iec-builder#92):** the default chart used the
> Kubernetes default of **1 second** for both probes, so an ordinary GC
> pause could take a healthy pod out of the Service or restart it. It is now
> **3 seconds**. Periods and failure thresholds are unchanged. It applies on
> each service's **next deploy**; a running pod keeps 1 second until then.

Declare a second replica and the same blip is invisible: the healthy pod keeps
serving while the other is briefly out of the endpoint list.

```yaml
annotations:
  insureco.io/replicas: "2"
```

**Hosting gas does not change.** Gas is billed per *service* at its pod-tier
rate, not per pod, so a second replica adds no gas cost. It does consume real
cluster CPU and memory, so raise it because a service needs to stay up, not by
reflex.

**Deploys do not get slower.** Replicas roll in parallel (`maxSurge: 100%`,
`maxUnavailable: 0`, iec-builder#171), so a 2-replica rollout takes about as long
as a 1-replica one. During the rollout the service briefly holds twice its
replicas' resources. See [build-pipeline](build-pipeline.md) Step 18.

#### Your service must be stateless across pods first

A second pod is only safe if **nothing lives in one process's memory**. Check
each of these before raising the count:

| Concern | Safe | Breaks with 2 pods |
|---------|------|--------------------|
| Sessions / auth codes | MongoDB, Redis | in-process `Map` |
| Rate limits | Redis counters | module-level counter (each pod counts separately) |
| Caches | Redis, or per-pod caches you can tolerate diverging | a cache treated as authoritative |
| WebSockets / live updates | Redis pub/sub | direct in-process fan-out (only reaches clients on that pod) |
| Scheduled work | `spec.schedules` (iec-cron fires once) | `setInterval` in the app (**every pod fires it**) |
| File writes | object storage | local disk |

The `setInterval` row is the one that bites hardest — an in-process timer
doubles its work silently, so a nightly job runs twice and nothing errors.

Valid range is **1 to 10**. An out-of-range or non-integer value is ignored with
a build-log warning and the service deploys with 1 rather than failing — a typo
in an availability hint should never take a service down.

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

#### What the health response must say: the running commit

Every Tawa service's health endpoint returns JSON that carries the commit it is running, so
anyone with `curl` (and the status page) can tell which build is live:

```json
{ "status": "ok", "service": "my-svc", "commit": "3f9c2a1d7b…" }
```

`commit` is the full SHA of the build, read from `GIT_COMMIT`. The builder injects it into every
pod as a plain runtime env var on each deploy (it is reserved: `tawa config set GIT_COMMIT=…`
is refused with `RESERVED_ENV_VAR`). It is not a Docker build arg, so layer caching is
unaffected. Return `null` when it is unset. `tawa dev` sets it in `.env.tawa`, so local runs report a commit too. The builder skips injecting it when the build's SHA is a placeholder such as `push` or `latest`, so a handler must tolerate it being unset.

```typescript
// express / hono / fastify — GET /health
app.get('/health', (_req, res) => {
  res.json({ status: 'ok', service: 'my-svc', commit: process.env.GIT_COMMIT ?? null })
})
```

```typescript
// Next.js App Router — app/api/health/route.ts
export const dynamic = 'force-dynamic'   // read the env at request time, not at build
export function GET() {
  return Response.json({ status: 'ok', service: 'my-svc', commit: process.env.GIT_COMMIT ?? null })
}
```

- **Keep it cheap.** No database call, no auth, no downstream request. It runs on every
  probe, and a slow or failing dependency would take the pod out of the Service.
- **After each deploy the builder checks it.** After the rollout it makes one HTTPS GET, with a
  10s timeout, to the service's public platform hostname
  (`https://{service}.tawa.pro{healthEndpoint}`, or `.sandbox.` / `.uat.`). If the JSON has no
  `commit` equal to the deployed SHA it writes a non-fatal
  `[warn] /health does not report the running commit …` and records
  `build.healthReportsCommit: false`. The deploy never fails over it. `worker` and
  `internal: true` services are skipped, and an unreachable endpoint, a timeout or a non-2xx
  answer records `null` with no warning.
  See [build-pipeline](build-pipeline.md) Step 20.
- **JCI plane services** get `GIT_COMMIT` from the plane runner's `--build-arg GIT_COMMIT`, so
  the same handler works there unchanged.

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

### `max-body-size` — Upload Size Limit at the Ingress

nginx rejects any request body over its limit with a bare HTML **413**, before the request
reaches your app. Your own size check, and your friendlier error message, never run. Unless a
service declares a size, the builder sets:

| Service | `proxy-body-size` |
|---------|-------------------|
| declares `spec.storage` | `10m` |
| everything else | not set (nginx default, `1m`) |

If your app accepts larger uploads, declare the limit so it matches:

```yaml
annotations:
  insureco.io/max-body-size: "50m"
```

- **A unit is required**: `k`, `m` or `g`. nginx reads a bare number as bytes, so `"50"` would
  mean 50 bytes. For that reason a bare number is refused.
- **Range: 1k to 100m.** Cloudflare rejects request bodies over 100 MB before they reach the
  ingress, so a larger limit could never be used. `0` (nginx's "unlimited") is refused.
- **An invalid value is ignored with a warning**, and the service keeps its default. The deploy
  does not fail.
- **Keep it in line with the app's own limit.** If your app enforces 50 MB, declare `50m`. If
  the ingress limit is lower than the app's, users get the nginx page; if it's much higher,
  oversized uploads get further into the app before they're rejected.
- The whole request is counted, so a multipart form carrying several files needs room for all of
  them together.

Added in iec-builder#151. Before that, the 10m for storage services was hardcoded, and iec-portal,
which accepts 50 MB files, returned 413 for anything over 10 MB.

### `memory-request` / `cpu-request` — Resource Requests Below the Tier Default

The pod tier sets the container's **limits** and its hosting gas. It also sets default
**requests** (what the scheduler reserves on a node — see [Gas & Wallet](gas-wallet.md)). These two
annotations move the requests only:

```yaml
annotations:
  insureco.io/pod-tier: xlarge            # limits 4 CPU / 4Gi, gas as xlarge
  insureco.io/memory-request: "512Mi"     # instead of the tier default 2Gi
```

That is insureco/iec-test's catalog (iec-test#58, iec-builder#193): it needs xlarge's ceiling for
browser runs but idles far below xlarge's 2Gi memory request.

| | `memory-request` | `cpu-request` |
|--|------------------|---------------|
| Format | whole number with `Mi` or `Gi` (`"300Mi"`, `"1Gi"`) | millicores (`"50m"`) or cores with up to 3 decimals (`"0.5"`, `"1"`) |
| Refused spellings | `M`, `G`, `Ki`, bare bytes, decimal `Mi`/`Gi` | anything else |
| Floor | `64Mi` | `10m` |
| Ceiling | the tier's memory limit | the tier's CPU limit |

- **Limits and gas do not change.** They stay set by `pod-tier`. A request cannot exceed the
  tier's limit (Kubernetes rejects such a pod), so to get more, raise the tier.
- **An invalid value warns and falls back** to the tier default; the deploy continues:
  `[warn] Ignoring insureco.io/memory-request "1Gi": above the small tier's memory limit of 512Mi. Using the tier default of 256Mi.`
- **Every deploy logs where each request came from**, and the deploy snapshot records it under
  `resolved.resources`:
  `Resource requests: cpu 400m (xlarge tier default), memory 512Mi (insureco.io/memory-request); limits: cpu 4, memory 4Gi (xlarge tier)`
- **Applies on the next deploy.** A running pod keeps the requests it was deployed with.
- **Service container only.** Sidecars are unaffected; the Vault Agent sidecar carries its own
  fixed requests of `10m` CPU / `48Mi` memory (iec-builder#192), limits unchanged.
- **Custom Helm charts** receive the same `--set resources.*` values; if their templates never
  read `.Values.resources`, the build warns that the requests are ignored.

## `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.egress.denyPrivate` — Keep a Pod Off the Private Network

By default a pod may open a connection to anything: the internet, every other pod, the
cluster nodes, the VPC's databases, the cloud metadata address. For a service that fetches
URLs a customer chooses — iec-browser drives Chromium at arbitrary sites — an in-process
SSRF guard is then the only thing between a bug and the private network. `denyPrivate`
adds a second, kernel-enforced boundary:

```yaml
spec:
  egress:
    denyPrivate: true
    allow:                      # optional: extra private destinations
      - cidr: 10.124.0.7/32     # e.g. a carrier egress proxy
        ports: [3128]
      - service: lob-send       # an in-cluster service → namespace lob-send-{env}
```

The builder adds an **Egress** section to the service's NetworkPolicy. The pod may reach:

| Destination | How it is allowed |
|---|---|
| The internet | `0.0.0.0/0` **except** `10/8`, `172.16/12`, `192.168/16`, `169.254/16`, `100.64/10` |
| Cluster DNS | `kube-system` / `k8s-app: kube-dns`, 53 UDP + TCP |
| Its own namespace | peer replicas (session forwarding, for example) |
| Platform services | namespaces of `koko`, `janus`, `iec-wallet`, `septor`, `iec-queue` |
| Declared dependencies | `{service}-{env}` for every `spec.dependencies` / `internalDependencies` entry, any transport |
| Public platform hostnames | `ingress-nginx` (`bio.tawa.pro` and friends hairpin to it in-cluster) |
| Vault | the `vault` namespace, when the pod runs a Vault agent sidecar |
| Backing stores | the builder's MongoDB / Redis / Neo4j / MinIO host, `/32`, on its port — **only for what the catalog declares** (`databases`, `consumesDatabase`, `storage`) |
| Egress-group proxy | its `proxyIp:proxyPort`, when `spec.egress.group` resolves to one |
| `allow` entries | each `cidr` (on its `ports`) and each `service` namespace |

Everything else in the private ranges is refused by the CNI — including the cluster nodes,
other services' pods, and `169.254.169.254`.

**Additive.** A service that does not declare `denyPrivate: true` gets nothing new: the
builder passes no value, and the NetworkPolicy renders byte-for-byte as it did before.
`group` works exactly as it always has, with or without `denyPrivate`.

**The build log lists what was allowed**, one destination per entry
(`Egress policy (denyPrivate): private ranges denied; allowed — …`), and warns
(`[warn] spec.egress.denyPrivate: …`) when it could not add a rule — a backing-store host
configured as a hostname rather than an IP, for example.

**`allow` rules:**

- `cidr` must be IPv4 with its host bits clear (`10.124.0.7/32`, not `10.124.0.7`), no wider
  than `/8`. A public CIDR is accepted with a warning — the internet is already allowed.
- List `ports`. Without them every port on that range is open, and the build warns.
- `service` is a service name; it becomes the namespace `{service}-{env}`.
- At most 20 entries. `allow` without `denyPrivate: true` has no effect (warned).

**An invalid block warns, it does not fail the deploy.** `denyPrivate` and `allow` are
dropped, `group` is kept, and the pod deploys with the open egress it had before — the
warning says so. Check `tawa logs` if you expected a policy.

**Only the builder's default chart renders it.** A service with its own Helm chart receives
`networkPolicy.egress` (`{ enabled: true, rules: [...] }`, standard `NetworkPolicyEgressRule`s)
and must render it itself; the build log warns.

**Before you declare it,** make sure every private destination the service calls is covered
above or in `allow`. A call to anything else fails with a connection timeout, not an error
that names the policy. The rules are IPv4 only; the cluster's pods have no IPv6 route, so nothing reaches out over IPv6 either way.

## `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.cors` — Cross-Origin Access

A browser will not apply a cross-origin stylesheet's `@font-face`, and refuses
`fetch()` outright, unless the response carries `Access-Control-Allow-Origin`.
Nothing emits that header by default. Declare `spec.cors` and the builder applies
it at the ingress.

```yaml
spec:
  cors:
    allowOrigins: ['https://app.example.com']   # required — or ['*']
    allowMethods: [GET, HEAD]                   # optional
    allowHeaders: [Authorization]               # optional
    allowCredentials: false                     # optional
    maxAge: 86400                               # optional, seconds
```

| Field | Required | Notes |
|-------|----------|-------|
| `allowOrigins` | yes | `*`, or `scheme://host[:port]`. A leading `*.` label is allowed (`https://*.example.com`). **No path and no trailing slash** — `https://app.example.com/` matches nothing and is rejected at parse time |
| `allowMethods` | no | Any of `GET HEAD POST PUT PATCH DELETE OPTIONS` |
| `allowHeaders` | no | Request headers the caller may send. Omitting it does **not** mean no header — see below |
| `allowCredentials` | no | Default `false`. **Ignored when `allowOrigins` contains `*`** — the Fetch spec forbids that pair and browsers reject it, so the builder drops it with a warning rather than shipping a response no browser accepts |
| `maxAge` | no | Preflight cache lifetime. Values above `86400` warn; most browsers cap there |

### Omitting `allowHeaders` falls back to the ingress default

Leaving `allowHeaders` out means the builder emits no `cors-allow-headers` annotation — but
ingress-nginx then applies **its own** default rather than sending nothing, and responses carry:

```
Access-Control-Allow-Headers: DNT,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization
```

That is usually what you want, and it is harmless for a public asset host. Declare `allowHeaders`
when you need a header that list does not cover (a custom `X-…`), or when you want the allowance
narrower than the default rather than wider.

The same is *not* true of `allowCredentials`: the builder always pins that annotation explicitly,
because ingress-nginx defaults it to `true` and would otherwise pair
`Access-Control-Allow-Credentials: true` with a wildcard origin — a response browsers refuse.

### It applies at the ingress, not in your image

The builder maps the block to `nginx.ingress.kubernetes.io/enable-cors` and the
`cors-allow-*` annotations on the service's ingress. Two consequences worth knowing:

- **It works for every framework.** A `static` site cannot add response headers of
  its own — the builder generates its nginx config and overwrites
  `/etc/nginx/conf.d/default.conf`, so there is no `add_header` seam. This is why
  the setting lives here instead. An `express` or `nextjs` service that already
  sets its own CORS headers in code does not need `spec.cors`; declaring both
  means two sources for the same header.
- **It is per-service, not per-path.** Every host and path on the service's ingress
  gets the same policy. A service that needs one policy for `/assets` and another
  for `/api` should set the headers in application code instead.

Declaring nothing changes nothing: with no `spec.cors`, the rendered ingress is
byte-identical to before.

### A bad block warns, it does not fail the deploy

An invalid `spec.cors` is dropped with a build-log warning and the service deploys
without it — the state it was in before it declared anything. Check `tawa logs` if
a header you expected is missing.

## `spec.tests` — Deploy-Gated Tests

Catalog **0.5.0+**. What iec-test checks after `helm upgrade` and before the build is called
green. Three keys: `smoke`, `e2e`, `compliance`.

```yaml
spec:
  tests:
    smoke:                            # HTTP probes. path + expect + timeout, nothing else
      - path: /health
        expect: 200

    e2e:                              # Playwright browser flows
      - name: "Login flow"
        auth: portal-editor           # optional — a profile NAME, never a token
        timeout: 30000                # optional, ms
        steps:
          - { action: navigate, url: /login }
          - { action: fill, selector: "#email", value: [email protected] }
          - { action: click, selector: "button[type='submit']" }
          - { action: wait, selector: "[data-testid='dashboard']", state: visible }
          - { action: assert_text, selector: h1, text: Dashboard }

      - name: "Collections"
        spec: e2e/collections.yaml    # or point at a repo file holding { steps: [...] }

    compliance:
      septor:
        - eventType: payment.created
          entityId: "{{orgSlug}}"
```

| e2e field | | |
|-----------|--|--|
| `name` | required | |
| `steps` **or** `spec` | required | Exactly one. `spec` is a repo-relative `.yaml`/`.yml`/`.json` file holding `{ steps: [...] }` — relative to `catalog-info.yaml`, never absolute, no `..` |
| `auth` | optional | A profile name configured on iec-test. Without it the flow runs signed out |
| `timeout` | optional | Per-flow budget in ms |

Actions: `navigate` · `fill` · `click` · `wait` · `screenshot` · `scroll` · `assert_text` ·
`assert_url` · `assert_visible` · `key`. There is **no `evaluate`** — arbitrary in-page JS is
refused from a deploy-time run and a catalog declaring it fails the build.

A rooted `navigate` url (`/login`) resolves against whichever URL the run is driving, so the
flow needs no change between the cluster-internal host a deploy drives and the public host an
on-demand run drives. Write a path, not a hostname.

**Every step is validated at parse time**, before the image is built, naming
`spec.tests.e2e[N].steps[M]`, the action and the field. An unknown key on a step is an error,
not a warning — a misspelt `selector` silently dropped leaves an assertion that cannot fail.
`tawa preflight` runs the same checks without spending a build.

**Gating:** smoke and compliance block everywhere. e2e blocks in sandbox/UAT and is
**non-blocking in prod** (`[warn]`, deploy proceeds) — the same posture as `spec.walkthrough`,
for the same reason: both drive the cluster-internal URL.

Full reference, including auth profiles and the local authoring loop: [Tawa Test](tawa-test.md).

## `spec.walkthrough` — Asserted Screenshot Walkthroughs

Catalog **0.7.0+**. Declares a UI flow that iec-test drives after every deploy: it navigates each
step, **asserts** on the page, screenshots it, and publishes an immutable bundle whose
`index.html` is the page. The build log links it: `Walkthrough: https://iec-test.tawa.pro/walkthroughs/{service}/{env}/{buildId}/index.html`.
When the deploy-gated tests pass, the builder also attaches that URL to the service's changelog in Koko (`walkthroughUrl` on `GET /api/plugins/{service}/changelog`), so the latest walkthrough is linked from the status page's **What's new** panel — see [Uptime Monitoring](pulse.md).

```yaml
metadata:
  annotations:
    insureco.io/catalog-version: "0.7.0"
spec:
  walkthrough:
    title: "Collections & agency assignment"
    auth: staff                     # a NAMED profile configured on iec-test — never a token
    viewport: { width: 1440, height: 900, scale: 2 }
    steps:
      - path: /
        expect: { h1: "Dashboard" }
        note: "The console lands here. There is no /dashboard route — the dashboard IS the root."
      - path: /collections/assign
        expect: { h1: "Assign Collections", "table tbody tr": "ACME" }
        note: "Assignment now writes the collector's agency (#46)."
        annotate:
          - at: "table thead th:last-child"
            text: "New agency column"
        redact: ["[data-pii]"]
```

| Field | Required | Behaviour |
|-------|----------|-----------|
| `path` | yes* | Rooted path on the service under test. Omit only for a step that acts on the current page. |
| `expect` | **yes** | Selector → text the element must contain. **Gates the step; there is no opt-out.** A step without one fails the build at parse time, naming `steps[N]`. |
| `note` | no | Caption. Prose — never asserted. |
| `act` | no | `{click}`, `{fill,value}`, `{select,value}`, `{wait,state?}`, `{scroll}`, `{key}` — run before `expect`. |
| `shot` | no | `viewport` (default), `full`, or `{ selector }`. |
| `annotate` | no | Overlay markers anchored to a selector or `{x,y}` percentages. Unresolvable anchors warn and drop — never fail. |
| `redact` | no | Selectors blurred in the browser before the pixel is written. A selector matching nothing warns. |
| `auth` | no | A profile name resolved by iec-test from its own configuration. Anything token-shaped is rejected. |

**Gating.** The walkthrough runs in **every** environment, including prod. In sandbox/UAT a failed
step fails the build, like e2e. In **prod it is non-blocking**: the failure is logged as
`[warn] …` and the deploy proceeds, and the bundle still publishes — a failed step renders as a
failure card with expected-vs-actual, never as a clean screenshot.

**Assert on data, not chrome.** `h1` proves you are on the right page; it does not prove the page
has content. A service session can authenticate and still render an empty table. Put a
record-bearing selector in `expect` for any step whose value depends on records.

Full reference, auth profiles, storage and the local authoring loop
(`iec-test walkthrough --local --watch`): the iec-test README.

### Running one on demand

A walkthrough normally runs as part of the deploy. To run one against the service **as it is live
right now** — no build, no deploy, no gate — use `tawa walkthrough`:

```bash
tawa walkthrough --prod                  # the catalog's spec.walkthrough
tawa walkthrough tour.yaml --sandbox     # a walkthrough spec from a YAML/JSON file
tawa walkthrough tour.yaml --prod --label pr-42
```

The file (or the catalog's `spec.walkthrough` when no file is given) holds one walkthrough spec —
the same shape as above, validated the same way: a step without `expect` is rejected before
anything runs. The CLI posts it to the builder (`POST /services/:name/walkthrough`), which relays a
one-off run to iec-test with the **walkthrough suite only** — smoke, e2e and compliance are not
touched — driven against the service's **public** URL for that environment: its **verified custom
domain** for that environment if it has one, else the platform hostname (`https://{service}.tawa.pro`,
`.sandbox.`, `.uat.`). The custom domain wins because an authenticated app's cookie host and login
redirect live there, and the platform hostname serves the same deployment without them
(iec-builder#154). It then polls, prints each step as pass or
fail, and finishes with `Walkthrough: <url>` — the published bundle, including the failure card
for any step that did not pass. Exit code is `1` when a step failed.

The bundle lands under a synthetic build id, `manual-{label-}{time}-{random}`, so it never
collides with a deploy's. `--label` (letters, digits, `.`, `_`, `-`; up to 80 characters) makes it
findable later. A failed on-demand walkthrough is reported as failed in **every** environment — the
"non-blocking in prod" posture above applies to the deploy gate, not to a run you asked for.

Requires the `member` org role (`viewer` can read a run's result). If the builder answers
`IEC_TEST_UNAVAILABLE`, iec-test is not configured or not reachable from the builder — a platform
matter, not a spec problem.

## `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: <path>` on an e2e entry | it IS read — a repo-relative `.yaml`/`.yml`/`.json` file holding `{ steps: [...] }`. It is not a Playwright `.spec.ts` and never was |
| `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)
- `spec.auth.sessionCookie` names the cookie your app reads its session from (default `bio_access_token`). Declare it if your app uses any other name — session tooling cannot guess it, and getting it wrong renders the app signed out with nothing logged. Read it back, with the service's OAuth client id and public host, at `GET /services/:name/session-info?env=…`
- `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
- `GIT_COMMIT` (the full SHA of the deployed build) is also injected on every deploy, as a plain runtime env var. Reserved: `tawa config set` refuses it. Your health response should return it as `commit` — see [`health-endpoint`](#health-endpoint--one-value-three-consumers)
- **`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`; `charge: system` is honoured only on platform-owned routes
- `spec.egress.denyPrivate: true` adds an Egress NetworkPolicy: internet yes, private ranges no, except DNS, own namespace, platform services, declared dependencies, declared backing stores and `spec.egress.allow` — declaring nothing leaves the policy unchanged
- `spec.cors` is applied as ingress annotations, not baked into the image — it works for any framework, and declaring nothing leaves the rendered ingress unchanged
- `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.63252
Tokens
~18,255
Updated
2026-10-13
platformdeploycatalogdeploymentconfigurationroutesdatabasesdependencies