Service-to-Service Communication
All inter-service calls on the Tawa platform go through **Janus**. Direct pod-to-pod calls are blocked by NetworkPolicy. This gives the platform a single point…
Ruleprovision
About
All inter-service calls on the Tawa platform go through **Janus**. Direct pod-to-pod calls are blocked by NetworkPolicy. This gives the platform a single point for gas metering, attribution, auth enforcement, and observability.
Skill Content
This is the raw markdown that gets installed as a Claude Code rule.
# Service-to-Service Communication
All inter-service calls on the Tawa platform go through **Janus**. Direct pod-to-pod calls are blocked by NetworkPolicy. This gives the platform a single point for gas metering, attribution, auth enforcement, and observability.
## The Rule
> **Never hardcode K8s DNS URLs. Always use the `{SERVICE}_URL` env var injected by the builder.**
The env var always points to Janus's internal proxy path (`/i/{service}`). Hardcoded K8s DNS URLs will fail with a connection refused — NetworkPolicy blocks them.
```typescript
// CORRECT — use the injected env var
const docman = new DocmanClient({ url: process.env.DOCMAN_URL })
// WRONG — hardcoded K8s DNS, blocked by NetworkPolicy
const docman = new DocmanClient({ url: 'http://docman.docman-prod.svc.cluster.local:3000' })
```
## Declaring Dependencies
Declare every service you call in `catalog-info.yaml`. The builder injects the URL and charge mode as env vars:
```yaml
spec:
dependencies:
- service: docman # injects DOCMAN_URL + DOCMAN_CHARGE_MODE
scopes: [docman:generate]
transport: janus
- service: relay # injects RELAY_URL + RELAY_CHARGE_MODE
scopes: [relay:send]
transport: janus
charge: service # this service absorbs the gas cost
```
See [catalog-info.yaml reference](/reference/catalog-info) for the full dependency format.
## Gas Attribution
Every call through Janus is metered. Who pays is controlled by `charge`:
| `charge` | Who pays | How Janus determines it |
|----------|----------|------------------------|
| `callerOrg` (default) | The calling user's org | `orgSlug` from the user's JWT |
| `service` | The service owner's org | `orgSlug` from the service's JWT |
Gas is always billed at the **org level** — never per individual user. The JWT carries both:
- `caller` = `payload.bioId` — the individual (for audit only)
- `callerOrg` = `payload.orgSlug` — the org that is billed
### Forwarding user context (`charge: callerOrg`)
When `charge: callerOrg`, Janus needs the user's JWT to know which org to bill. Forward it via `chargeContext` in the SDK call:
```typescript
// byte-mga route handler — user's org should pay for the docman call
app.post('/api/generate', authenticate, async (req, res) => {
const doc = await docman.generate(
{ templateIds: [...], data: {...}, name: 'report' },
{
chargeContext: { userToken: req.headers.authorization }
// ↑ forward exactly as received, including "Bearer " prefix
}
)
res.json(doc)
})
```
The SDK sends this as `X-Forward-User: Bearer {user-jwt}`. Janus verifies the token via Bio-ID's JWKS endpoint and bills the user's `orgSlug`. The token cannot be tampered with — it is cryptographically verified.
### When to use `charge: service` instead
Use `charge: service` when your service absorbs the cost — for example:
- Freemium features (users get some calls free, your org pays)
- Internal tooling where your org owns the workflow end-to-end
- Background jobs triggered by your service's own cron/queue
```yaml
dependencies:
- service: relay
scopes: [relay:send]
transport: janus
charge: service # our org pays for sending emails, not the user's org
```
No `chargeContext` needed — the SDK uses the service JWT automatically.
## `/internal/*` Endpoints
Queue worker and cron callback endpoints use the `/internal/` prefix by convention:
```
/internal/jobs/process-claim ← called by iec-queue
/internal/cron/nightly-sync ← called by iec-cron
```
These endpoints are **restricted to platform services only**. If a non-platform service tries to call another service's `/internal/*` path, Janus returns 403:
```json
{
"error": "internal_route_restricted",
"message": "Internal routes (/internal/*) are reserved for platform services. They bypass gas metering and cannot be called by third-party services. Register a public route (/api/*) if you need to expose this operation.",
"docs": "https://docs.tawa.insureco.io/reference/service-communication"
}
```
This prevents bypassing paid public routes via internal paths.
## Platform-Initiated Calls (cron and queue)
When iec-cron or iec-queue calls your service, they route through Janus like any other caller.
Your `/internal/cron/*` and `/internal/jobs/*` endpoints receive a verified service JWT
(`Authorization: Bearer` header) — you can trust that the caller is a platform service.
### Headers on cron callbacks
| 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.
### Headers on queue callbacks
| Header | Value |
|--------|-------|
| `Authorization` | `Bearer {platform-service-jwt}` |
| `X-Queue-Name` | Queue name from catalog-info.yaml |
| `X-Job-Id` | Unique job identifier |
| `X-Job-Attempt` | Attempt number (1-indexed) |
| `Content-Type` | `application/json` |
Body: `{ jobId, queue, attempt, data }`
### Gas costs for platform-initiated calls
| Trigger | Production | Sandbox |
|---------|-----------|---------|
| Cron schedule fires | 5 tokens (charged upfront to namespace owner) | 0 tokens |
| Queue job executed | 0 tokens (platform cost) | 0 tokens |
Queue execution carries no gas because the cost was already charged when the user triggered the
enqueue via a public API route. You do not need to think about gas inside your cron or queue
handlers — the platform handles it before your endpoint is called.
## SDK Startup Validation
Every system service SDK validates the injected URL at startup. If you see this error:
```
Error: [docman-sdk] DOCMAN_URL must route through Janus (/i/ path prefix).
Direct K8s DNS calls are blocked by NetworkPolicy.
Redeploy to get the correct URL injected by the builder.
```
It means `DOCMAN_URL` is set to a raw K8s DNS URL instead of a Janus proxy URL. Fix: ensure `docman` is declared under `dependencies` (default `transport: janus`) in `catalog-info.yaml` and redeploy.
If the env var is missing entirely:
```
Error: [docman-sdk] DOCMAN_URL is not set.
Declare docman under dependencies in catalog-info.yaml and redeploy.
```
## Error Reference
| Error code | Status | Cause | Fix |
|------------|--------|-------|-----|
| `internal_route_restricted` | 403 | Non-platform service calling `/internal/*` | Use a public `/api/*` route instead |
| `proxy_service_not_found` | 404 | Service not in Koko registry | Check the service is deployed and declared under `dependencies` |
| `proxy_target_unreachable` | 503 | Target pod not responding | Run `tawa pods` to check pod status |
| `proxy_unauthorized` | 401 | Missing or invalid Authorization JWT | Ensure BIO_CLIENT_ID/BIO_CLIENT_SECRET are configured |
| `proxy_forward_user_invalid` | 401 | `X-Forward-User` JWT tampered or expired | Do not modify the forwarded token — pass `req.headers.authorization` directly |
| `proxy_insufficient_gas` | 402 | Caller org has no gas balance | Run `tawa wallet buy` to top up |
## Full Example: byte-mga calling docman
### catalog-info.yaml
```yaml
spec:
dependencies:
- service: docman
charge: callerOrg # the user's org pays for document generation
# transport omitted → defaults to janus
```
### Handler
```typescript
import { DocmanClient } from '@insureco/docman'
const docman = new DocmanClient({ url: process.env.DOCMAN_URL })
// SDK validates DOCMAN_URL contains /i/ at startup — throws if wrong
app.post('/api/generate-report', authenticate, async (req, res) => {
const doc = await docman.generate(
{
templateIds: ['report-template'],
data: req.body,
name: 'quarterly-report',
},
{
chargeContext: { userToken: req.headers.authorization },
// ↑ Janus verifies this JWT and bills req.user.orgSlug
}
)
res.json({ success: true, docId: doc.id })
})
```
What happens on the wire:
1. byte-mga POSTs to `http://janus.janus-prod.svc.cluster.local:3000/i/docman/api/generate`
2. Janus sees `Authorization: Bearer {byte-mga-service-jwt}` + `X-Forward-User: Bearer {user-jwt}`
3. Janus verifies both tokens via JWKS
4. Janus looks up docman in Koko → gets K8s DNS URL
5. Janus forwards to `http://docman.docman-prod.svc.cluster.local:3000/api/generate`
6. docman responds 200
7. Janus bills the user's `orgSlug` (from `X-Forward-User`) for 5 gas tokens
8. Janus returns docman's response to byte-mga
Install
Copy the skill content and save it to:
~/.claude/rules/tawa-janus-service-proxy.mdComing soon via CLI:
tawa chaac install tawa-janus-service-proxyDetails
- Format
- Rule
- Category
- provision
- Version
- 1.0.32223
- Tokens
- ~2,179
- Updated
- 2026-06-24
platformgatewayinternal-dependenciesjanusgasattributionroutinginter-service