catalog-info.yaml

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

Minimal Working Example

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

Current Version (0.7.0)

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

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

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

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

  tests:
    smoke:
      - path: /health
        expect: 200
    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.

VersionAdds
0.7.0spec.walkthrough — an asserted screenshot flow run by iec-test on every deploy (see spec.walkthrough)
0.6.0spec.userAccess.roles (user-facing roles, shape { id, label, orgs: [] } where id matches namespace:name or namespace_name); spec.registration (storefront metadata: displayName, shortDescription, publisher (must equal spec.owner), icon?, category?, modules?); spec.auth.passport
0.5.0spec.tests; spec.userAccess
0.4.0spec.dependencies (with scopes + transport)
0.3.0spec.storage

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:

FieldKoko keyStored as
spec.registrationyour 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

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

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

OAuth / Bio-ID Login (spec.auth)

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

spec:
  auth:
    mode: sso                           # provisions BIO_CLIENT_ID and BIO_CLIENT_SECRET on every deploy
    sessionCookie: portal_access_token  # optional — the cookie YOUR app reads (default: bio_access_token)
ModeWhat it doesWhen to use
ssoUser-facing OAuth. Provisions BIO_CLIENT_ID, BIO_CLIENT_SECRET, registers /api/auth/callbackServices with a login flow
service-onlyClient credentials only. No redirect URI registeredService-to-service, no user login
noneNo OAuth client provisionedDefault. Builder skips OAuth entirely

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

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

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

See Authentication for the full login implementation guide.

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:

spec:
  auth:
    mode: sso
    sessionCookie: portal_access_token   # default when omitted: bio_access_token
Default when absentbio_access_token
Must bea legal cookie name (RFC 6265 token: letters, digits and !#$%&'*+-.^_|~`, at most 128 characters)
An invalid valuewarns in the build log and is ignored — the deploy never fails over it, and tooling falls back to the default
Where it goescached 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
{ "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:

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

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

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 for the full branding claim shape and consumption patterns.

Dependencies — Connecting to Other Services

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

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

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

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

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

2. Everything else — declare under spec.dependencies

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

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

    # Scoped entry — add scopes only when the target defines Bio-ID scope grants.
    - service: raterspot
      scopes: [raterspot:rate]     # optional
      charge: callerOrg            # optional
      # transport omitted → defaults to janus

    # Webhook receiver — the builder provisions the provider's signing secret.
    - service: policysign
      webhooks:
        path: /api/webhooks/policysign   # → POLICYSIGN_WEBHOOK_SECRET (managed secret)
FieldRequiredDefaultMeaning
serviceyes—The registered Koko service name (e.g. relay, not iec-relay)
scopesno[]Bio-ID scopes to request — only when the target defines them (see Scopes)
transportnojanusHow {SERVICE}_URL is routed (see below)
chargenoserviceWho pays gas: service (your org) or callerOrg (the caller's org)
webhooks.pathno—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)

Transport modes

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

Scopes

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 accessBio-ID scopes — API access
Declared inspec.scopes (owner) + tawa scopes request (consumer)spec.modules[].scopes (owner) + spec.dependencies[].scopes (consumer)
GovernsMongoDB / Redis / Neo4j / usersService-to-service API calls
EffectA connection-string env var is injected — genuinely enforcedDeploy-time approval gate + client-credentials provisioning
Enforced per request?n/aNo — 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.

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

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

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

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.

Mixing internalDependencies and dependencies

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

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

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

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

Common mistakes:

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

Routes

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

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

ValueWho can callNotes
requiredAny authenticated userJanus verifies user JWT
optionalAnyoneJWT verified and forwarded if present, but not required. Registered in Koko as none — see below
serviceService-to-service onlyRequires service credentials JWT
publicAnyoneNo auth, but still metered
noneAnyoneNo 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.modeBehaviour
offNothing is evaluated
shadow (default)Refusals are recorded and the request is forwarded, except for services listed in authz.enforceServices, which are enforced
enforceRefusals 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

ValueWho pays gasWhen to use
serviceDefault. The service owner's org paysSubsidised/freemium routes, internal tooling, and the default for everything
callerOrgOverride. The calling user's org (orgSlug from their JWT) pays — requires forwarding the user's JWT as X-Forward-UserUser-facing APIs where the caller's org should absorb the gas cost
systemThe InsurEco system wallet (wallet-insureco) pays, whoever calls. Platform-owned routes onlyPlatform 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). 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.

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, iec-koko#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

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

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

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

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

Headers your endpoint receives

Cron callbacks (/internal/cron/*):

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

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

Queue callbacks (/internal/jobs/*):

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

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

Gas costs

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

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

All Annotations

annotations:
  insureco.io/framework: express           # REQUIRED
  insureco.io/catalog-version: "0.5.0"    # required for spec.tests
  insureco.io/node-version: "20"          # default: 20
  insureco.io/pod-tier: nano              # default: nano
  insureco.io/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:

ConsumerWhat it does with it
Helmservice.port — the port the Kubernetes Service and container expose
Kokothe service's registered upstream, http://{service}.{service}-{env}.svc.cluster.local:{port}
Janus /i/, iec-cron, iec-queuedial 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

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:

NormallyInternal
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 bindingskipped — the build log says Internal service: no ingress, no DNS
iec-pulse uptime monitor on the public URLskipped (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)

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.

ValueEffect
"true"internal
"false" or absentpublic, exactly as before — the Helm command is byte-identical
anything elsea 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). 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.

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

ConcernSafeBreaks with 2 pods
Sessions / auth codesMongoDB, Redisin-process Map
Rate limitsRedis countersmodule-level counter (each pod counts separately)
CachesRedis, or per-pod caches you can tolerate diverginga cache treated as authoritative
WebSockets / live updatesRedis pub/subdirect in-process fan-out (only reaches clients on that pod)
Scheduled workspec.schedules (iec-cron fires once)setInterval in the app (every pod fires it)
File writesobject storagelocal 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:

ConsumerWhat it does with it
KubernetesLiveness + readiness probe paths on your pod
KokoStored on your service registry entry
iec-pulseProbes 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:

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

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

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

Common use cases:

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

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

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:

Serviceproxy-body-size
declares spec.storage10m
everything elsenot set (nginx default, 1m)

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

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). These two annotations move the requests only:

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-requestcpu-request
Formatwhole number with Mi or Gi ("300Mi", "1Gi")millicores ("50m") or cores with up to 3 decimals ("0.5", "1")
Refused spellingsM, G, Ki, bare bytes, decimal Mi/Gianything else
Floor64Mi10m
Ceilingthe tier's memory limitthe 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.

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

On deploy, the builder:

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

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

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

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

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

CRITICAL — Set NO_PROXY after adding spec.egress

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

Always set NO_PROXY immediately after adding spec.egress:

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

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

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

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:

DestinationHow it is allowed
The internet0.0.0.0/0 except 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10
Cluster DNSkube-system / k8s-app: kube-dns, 53 UDP + TCP
Its own namespacepeer replicas (session forwarding, for example)
Platform servicesnamespaces of koko, janus, iec-wallet, septor, iec-queue
Declared dependencies{service}-{env} for every spec.dependencies / internalDependencies entry, any transport
Public platform hostnamesingress-nginx (bio.tawa.pro and friends hairpin to it in-cluster)
Vaultthe vault namespace, when the pod runs a Vault agent sidecar
Backing storesthe builder's MongoDB / Redis / Neo4j / MinIO host, /32, on its port — only for what the catalog declares (databases, consumesDatabase, storage)
Egress-group proxyits proxyIp:proxyPort, when spec.egress.group resolves to one
allow entrieseach 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 NetworkPolicyEgressRules) 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.

spec:
  analytics:
    enabled: true

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

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

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

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

Full guide: analytics.md.

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

spec:
  cors:
    allowOrigins: ['https://app.example.com']   # required — or ['*']
    allowMethods: [GET, HEAD]                   # optional
    allowHeaders: [Authorization]               # optional
    allowCredentials: false                     # optional
    maxAge: 86400                               # optional, seconds
FieldRequiredNotes
allowOriginsyes*, 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
allowMethodsnoAny of GET HEAD POST PUT PATCH DELETE OPTIONS
allowHeadersnoRequest headers the caller may send. Omitting it does not mean no header — see below
allowCredentialsnoDefault 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
maxAgenoPreflight 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.

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
namerequired
steps or specrequiredExactly one. spec is a repo-relative .yaml/.yml/.json file holding { steps: [...] } — relative to catalog-info.yaml, never absolute, no ..
authoptionalA profile name configured on iec-test. Without it the flow runs signed out
timeoutoptionalPer-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.

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.

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]"]
FieldRequiredBehaviour
pathyes*Rooted path on the service under test. Omit only for a step that acts on the current page.
expectyesSelector → 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].
notenoCaption. Prose — never asserted.
actno{click}, {fill,value}, {select,value}, {wait,state?}, {scroll}, {key} — run before expect.
shotnoviewport (default), full, or { selector }.
annotatenoOverlay markers anchored to a selector or {x,y} percentages. Unresolvable anchors warn and drop — never fail.
redactnoSelectors blurred in the browser before the pixel is written. A selector matching nothing warns.
authnoA 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:

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 wroteWhere it actually goes
spec.compliancespec.tests.compliance — see the warning below before moving it
spec.smoke / spec.e2espec.tests.smoke / spec.tests.e2e
spec: <path> on an e2e entryit IS read — a repo-relative .yaml/.yml/.json file holding { steps: [...] }. It is not a Playwright .spec.ts and never was
spec.oauthspec.auth (mode: sso | service-only | none) — without it BIO_CLIENT_ID / BIO_CLIENT_SECRET are never injected
spec.scopesper dependency: spec.dependencies[].scopes
spec.relevantRolesno such field — roles a service grants go under spec.userAccess.roles (0.6.0+)
expectBody on a smoke entrynowhere — 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
  • septor and iec-queue are auto-labeled — as of iec-builder 9e0a2d0 (2026-06-22) the builder applies their tawa.pro/direct-dep.{svc}=true NetworkPolicy label on every deploy (ALWAYS_DIRECT_DEP_SERVICES), so audit/queue calls connect with no declaration needed. (Older services deployed before that date pick up the label on their next deploy.)
  • RELAY_URL is not auto-injected — declare relay under dependencies with scopes: [relay:send] and transport: janus (this provisions both RELAY_URL and the BIO_CLIENT_ID/BIO_CLIENT_SECRET the SDK needs; metered per send, charge defaults to service)
  • Default charge mode is service — the service owner's org pays unless you explicitly set charge: callerOrg; 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 for the OAuth login implementation
  • See service-communication for calling other services from code

Last updated: October 6, 2026