← Back to Skills

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

Coming soon via CLI:

tawa chaac install tawa-janus-service-proxy

Details

Format
Rule
Category
provision
Version
1.0.32223
Tokens
~2,179
Updated
2026-06-24
platformgatewayinternal-dependenciesjanusgasattributionroutinginter-service