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.
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
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" }
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) |
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 |
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:namespelling and rejectednamespace_name, and the endpoint receivingspec.registrationwas never implemented — it returned a bare404. Both failures are non-fatal in the builder, so deploys reported success whileservice_rolesstayed 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 | 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 startfor all Node frameworks — define astartscript inpackage.json(e.g.node dist/index.js). Override per-service withinsureco.io/start-command.
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)
| 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-idtointernalDependenciesdoes NOT enable OAuth. It only injectsBIO_ID_URL(which is already injected). Usespec.auth: mode: ssoinstead.
See Authentication for the full login implementation guide.
sessionCookie — the cookie your app reads its session fromAnything 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 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.
GET /services/:name/session-infoThe 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.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 }.
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
| 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: truewas accepted and mapped toincludeBranding: true. That was never true. No such mapping exists in the builder — it reads onlyincludeBranding. The passport parser is permissive, sobranding: trueis dropped silently: the service deploys, gets nobrandingclaim, andreq.user.brandingis undefined at runtime. UseincludeBranding: true.tawa preflight0.21.0+ warns if it seesbranding.
See Passport & Branding for the full branding claim shape and consumption patterns.
There are two kinds of service connectivity, and you only declare one of them.
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.)
spec.dependenciesFor 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)
| 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) |
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) |
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 |
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 requestandspec.scopesare 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. Declaringscopes: [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:
BIO_CLIENT_ID / BIO_CLIENT_SECRET are injected (this is how SDKs like @insureco/relay authenticate).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 needrelay:sendin order to send; it needs the dependency declared soRELAY_URLis injected.Gateway routes are a separate path: Janus evaluates the route's
auth(andscopes, if any) there, in shadow mode by default (see Auth values). Koko carries noscopestoday, 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_SECRETare injected byspec.auth: mode: ssoas 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.
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.
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.
tawa config set wins; a mismatch is warned about, never logged.[warn] line, deploy continues.webhooks.path → nothing changes.Providers publish the contract with spec.webhookProvider: { provisionEndpoint, secretEnv }.
Full guide: Provider Webhook Secrets.
internalDependencies and dependenciesYou 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/externalDependenciesare legacy. Whenever either appears, the builder emits a deprecation warning intawa logsand merges them intodependenciesanyway. New services should put everything under a singledependenciesarray. The mapping: aninternalDependenciesentry becomestransport: janus(ordirectif it settransport: direct) with empty scopes; anexternalDependenciesentry becomestransport: gateway. Becausetransportnow defaults tojanusand scopes are optional, a baredependenciesentry behaves exactly like the oldinternalDependenciesentry — no workaround needed.Historical bug (fixed): Before the merge fix, declaring
dependencieson a 0.4.0+ catalog silently dropped everyinternalDependenciesentry — no*_URLwas injected for them. If a service relied onrelay/other internal deps while also using scopeddependencies, itsRELAY_URL(etc.) was missing. See iec-builder#11.
Common mistakes:
transport: gateway expecting a URL env var — gateway does not inject a URLscopes are required on dependencies — they are optional; omit them unless the target defines Bio-ID scope grantshttp://docman.docman-prod.svc.cluster.local:3000) in env vars — blocked by NetworkPolicy, will fail with connection refusedroutes:
- 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)
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
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.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.stripPrefix route whose prefix contains {name} strips the parameter too (the same as :name).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.
| 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.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
requiredandservice. It did not enforce either: the route-level check in the gateway never refused anything. Routeauthis 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). 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.
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 declaredgas(iec-janus#10), readschargewhen a route carries one, and no longer needs a restart to pick up new routes or mangles non-/apipaths (iec-janus#11, #12). One gap remains:chargeis 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 nogasorgas: 0is free on/i/.
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.
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 }.
| 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.
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 ConsumersThe 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
:3000regardless. 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 withGET {KOKO_URL}/api/services/{service}:upstreammust end in your port.
internal — No Public Hostnameannotations:
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) |
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 thejanusnamespace, 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 BlipEvery 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.
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 ConsumersThe 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:
insureco.io/health-endpoint from this file — always wins, and is re-read on every deploy/healthOmit 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/healthand being probed at/healthshows 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.
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 })
}
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.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 ImageIf 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"
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 Ingressnginx 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:
annotations:
insureco.io/max-body-size: "50m"
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.0 (nginx's "unlimited") is refused.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.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 DefaultThe 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-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 |
pod-tier. A request cannot exceed the
tier's limit (Kubernetes rejects such a pod), so to get more, raise the tier.[warn] Ignoring insureco.io/memory-request "1Gi": above the small tier's memory limit of 512Mi. Using the tier default of 256Mi.resolved.resources:
Resource requests: cpu 400m (xlarge tier default), memory 512Mi (insureco.io/memory-request); limits: cpu 4, memory 4Gi (xlarge tier)10m CPU / 48Mi memory (iec-builder#192), limits unchanged.--set resources.* values; if their templates never
read .Values.resources, the build warns that the requests are ignored.spec.egress — Outbound IP RoutingServices 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:
HTTPS_PROXY=http://<proxyIp>:<proxyPort> and HTTP_PROXY=http://<proxyIp>:<proxyPort> into the pod's env vars.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.
NO_PROXY after adding spec.egressHTTP_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 NetworkBy 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:
| 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.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}.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:
| 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.
spec.cors — Cross-Origin AccessA 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
| 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 |
allowHeaders falls back to the ingress defaultLeaving 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.
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:
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./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.
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 TestsCatalog 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 | ||
|---|---|---|
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.
spec.walkthrough — Asserted Screenshot WalkthroughsCatalog 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]"]
| 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.
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 Registrationspec.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.
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.
metadata.name becomes your hostname: my-svc.tawa.insureco.ionano — start here, upgrade when you have data{SERVICE}_URL vars (default transport: janus) point at Janus /i/{service} — never hardcode K8s DNS URLs for thoseBIO_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 themGIT_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-endpointseptor 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)service — the service owner's org pays unless you explicitly set charge: callerOrg; charge: system is honoured only on platform-owned routesspec.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 unchangedspec.cors is applied as ingress annotations, not baked into the image — it works for any framework, and declaring nothing leaves the rendered ingress unchangedNODE_ENV is always set to production by the builder for all deployments (including sandbox) — do not set it yourselfspec.env is not parsed — hardcoded env vars belong in tawa config set KEY=VALUE, not catalog-info.yamlLast updated: October 6, 2026