Yabanin — Development Guide
Prerequisites
- Go 1.27+ (
go versionshould reportgo1.27.xor newer; the module builds clean on 1.27) - Docker with Docker Compose v2
make(bundled with Xcode Command Line Tools on macOS)psql— optional, needed only for local migration runs outside Docker
Quick Start
# 1. Copy the environment template
cp .env.example .env # edit if you need non-default values
# 2. Bring up Postgres, Redis, and all three services
make docker-up
# 3. Run the smoke test (starts infra if not running, checks health)
bash scripts/smoke_test.sh
# 4. Tear everything down
make docker-down
After docker-up the services are available at:
| Service | Listen (container) | Host (compose) | Health |
|---|---|---|---|
| gateway | :8082 | 8082 | /healthz |
| gateway admin/metrics | :9090 | 9090 | — |
| admin control plane | :8086 | 9091 | /health |
| loggerd | :8085 | 8085 | /healthz |
| mcp SSE | :8787 | — (helm only) | — |
Project Structure
.
├── services/ # Service binaries (each is its own package)
│ ├── gateway/cmd/main.go # Reverse-proxy / API router (data-plane)
│ ├── loggerd/cmd/main.go # Redis stream consumer → Postgres writer
│ └── admin/cmd/main.go # Admin panel + CRUD APIs (control-plane)
├── pkg/ # Shared library packages
│ ├── config/ # YAML config loading + env-var resolution
│ ├── auth/ # API key & session authentication
│ ├── adminauth/ # Admin-specific HMAC cookie auth
│ ├── adminstore/ # Admin user store (Postgres)
│ ├── rulestore/ # Routing rules CRUD store
│ ├── evaluator/ # Rule matching engine
│ ├── openai/ # OpenAI API request/response types
│ ├── resolver/ # Model routing resolution
│ ├── redisutil/ # Redis client wrapper
│ ├── log/ # Structured logging (slog)
│ ├── logging/ # Logrus bridge / formatting
│ ├── events/ # Event payload types
│ ├── eventpublisher/ # Redis stream publisher interface
│ ├── cli/ # Service lifecycle helpers
│ └── encryption/ # AES-GCM token encryption
├── migrations/ # SQL migration files (applied in lexical order)
│ ├── 0001_init.sql # Core schema: orgs, models, api_keys, sessions, messages, rules, logs
│ ├── 0002_admin_and_teams.sql
│ ├── 0003_audit_logs.sql
│ ├── 0004_updated_at.sql
│ └── 0005_add_api_key_team_id.sql
├── deploy/ # Production deployment artifacts
│ ├── docker-compose.yml # Full stack definition (postgres, redis, gateway, loggerd, admin)
│ ├── .env # Docker Compose environment variables
│ └── secrets/ # Docker secrets (one file per secret)
│ ├── pg_uri
│ ├── pg_password
│ ├── session_key
│ ├── cache_hmac
│ └── admin_secrets
├── scripts/ # Helper scripts
│ └── smoke_test.sh # E2E smoke test (health checks + proxy call)
├── config.yaml # Per-service YAML config (merged with env vars)
├── Dockerfile # Multi-stage Go build for all services
├── Makefile # Build, test, run, docker targets
└── go.mod # Module: github.com/reireirei/yabanin
Note: the unified binary source lives in
cmd/yabanin/(historical spelling with "i"); the binary and CLI are namedyabanin. Do not create newyabanin-spelled paths.
Development Workflow
All commands are via make:
For the same reproducible interface locally, from an agent, or in future CI,
start with make quality-changed (plan), then
make quality-changed QUALITY_EXECUTE=1 (execute). make quality-full runs
all standard profiles and records logs/results in artifacts/quality/.
See scripts/quality/README.md in the repository for prerequisites,
profiles, explicit Docker integration, and validating evidence against source.
| Command | Description |
|---|---|
make build | Compile all three services (yabanin-gateway, yabanin-loggerd, yabanin-admin) |
make test | Run all unit tests with race detection |
make test-integration | Run integration tests (uses testcontainers for real Postgres/Redis) |
make lint | Full static analysis with go vet and golangci-lint |
make analyze-fast | Fast static-analysis loop after Go edits |
make analyze-security | Module verification and reachable vulnerability scan |
make analyze-tests | Race detector, shuffled tests, and coverage report |
make analyze | Legacy Go-only aggregate: lint, security, and tests |
make tidy | go mod tidy |
make run-gateway | Build and run gateway in foreground |
make run-loggerd | Build and run loggerd in foreground |
make run-admin | Build and run admin in foreground |
make run-mcp | MCP partner bridge (stdio; set YABANIN_MCP_HTTP=1 for SSE) |
make dev-opencode | Generate OpenCode config and start the fake subscription bridge |
make opencode-remote-wire | Wire OpenCode agents to the public yabanin.ai stand (key: YABANIN_GATEWAY_API_KEY) |
make test-subscription-bridge | Fake Codex/Cursor sidecar contract tests (Node) |
make bench | Run benchmarks |
Run a single service's tests:
go test -race -count=1 ./services/gateway/...
go test -race -count=1 ./pkg/rulesengine/...
The analysis targets install pinned versions of golangci-lint and govulncheck
into $(go env GOPATH)/bin when necessary. Project-local OpenCode skills under
.opencode/skills/ automatically direct agents to the applicable gate after
Go, security-sensitive, dependency, test, or concurrency changes.
OpenCode orchestration (local)
Three separate workflows:
yb audit— offline analyzer. No gateway, no MCP, no subscription bridge.- Ordinary gateway — OpenAI-compatible
/v1/chat/completionsfor Z.AI Coding Plan and local models. Feature flagYABANIN_ZAI_CODING_ENABLEDis off by default. - OpenCode orchestration — OpenCode talks to the gateway for chat/tools, and to local MCP
tr_delegatefor Codex/Cursor agents. Delegation is off unlessYABANIN_DELEGATION_ENABLED=true.
OpenCode wiring (the isolated local stand make stand-opencode was retired on
2026-10-12; its launcher scripts were removed):
make opencode-remote-wire # wires global OpenCode agents (yabanin, luna, sol-review) to https://yabanin.ai/gw
The wire target needs YABANIN_GATEWAY_API_KEY (operator-issued stand/org key)
and a locally built yabanin binary for the MCP server (make build). It
updates ~/.local/share/opencode/auth.json and ~/.config/opencode/opencode.jsonc
(both chmod 600). Restart OpenCode and Tab-switch to the agent.
Credentials stay on the machine: gateway key in auth.json (never committed),
Codex from ~/.codex/auth.json, Cursor from official agent login (subscription, not a dashboard API key).
One-command local mock loop:
make docker-up # or make run-gateway against local infra
export YABANIN_API_KEY=... # gateway client key; do not commit
export YABANIN_PARTNER_API_KEY=... # MCP → gateway key; do not commit
make dev-opencode # writes examples/opencode.generated.json and starts a fake bridge on :8091
Then point OpenCode at the generated config (or copy examples/opencode.json). Login for real agents stays in official CLIs:
- Z.AI Coding Plan: enable
YABANIN_ZAI_CODING_ENABLED=trueand store the Coding Plan key on thezai-codingmodel in the registry (do not commit it) - Codex: official
codexlogin orCODEX_API_KEY(never browser cookies) - Cursor: official
agent login(Cursor subscription). Do not setCURSOR_API_KEY
Set YABANIN_BRIDGE_FAKE=0 when you want the sidecar in services/subscription-bridge/ to call the official SDKs. The leading model should use native OpenCode tools for ordinary work and tr_delegate only for hard coding tasks; see examples/opencode-yabanin.md.
Docker Deployment
docker-compose.yml
deploy/docker-compose.yml defines five services:
- postgres — PostgreSQL 16 Alpine, health-checked, read-only filesystem
- redis — Redis 7 Alpine with AOF persistence, read-only
- migrate — one-shot container that runs all
migrations/*.sqlagainst Postgres (runs once on firstup) - gateway — the data-plane reverse proxy (image:
yabanin-gateway:latest) - loggerd — async log consumer (image:
yabanin-loggerd:latest) - admin — control-plane admin panel (image:
yabanin-admin:latest)
All app services sit on the app-internal bridge network. Ports 8082, 8085, 9091, and 5432/6379 are exposed for local development.
Building images
# Build all three service images locally
make docker-build-all
# Each image is tagged: yabanin-gateway, yabanin-loggerd, yabanin-admin
The Dockerfile uses a Go 1.25 Alpine builder with multi-stage builds and cache mounts. The final stage runs as a non-root user (yabanin).
Secrets management
Secrets are passed via Docker secrets (mounted at /run/secrets/<name>) and consumed through the _FILE env-var convention:
| Secret file | Env var used by app |
|---|---|
deploy/secrets/pg_uri | YABANIN_PG_URI_FILE → reads DB connection string |
deploy/secrets/session_key | YABANIN_SESSION_KEY_FILE → HMAC cookie signing key |
deploy/secrets/cache_hmac | YABANIN_CACHE_HMAC_SECRET_FILE → Redis response signing |
To add a new secret:
- Create the file:
echo -n "my-secret-value" > deploy/secrets/my_secret - Add it to
.dockerignore(never commit secrets) - Declare it in
deploy/docker-compose.ymlundersecrets:and reference it per-service - The
_FILEsuffix is auto-resolved bypkg/config/env()
Testing
Unit tests
make test # all packages, race detector enabled
go test -race -count=1 ./pkg/rulesengine/... # single package
Integration tests
Integration tests use testcontainers-go to spin up real Postgres and Redis containers:
make test-integration # runs services/gateway/integration/...
go test -race -count=1 ./services/loggerd/integration/...
Test helpers live in each service's integration/ subdirectory. Use test_helpers.go for shared setup (seed data, client creation) and *_test.go for test cases.
Smoke / E2E test
bash scripts/smoke_test.sh # starts compose, waits for health, proxies a request, tears down
Adding a New Service
-
Create the directory structure:
mkdir -p services/<name>/cmd -
Write
services/<name>/cmd/main.gofollowing the existing pattern:package mainimport ("github.com/reireirei/yabanin/pkg/config""github.com/reireirei/yabanin/pkg/logging")func main() {cfg, _ := config.Load("config.yaml")// validate cfg.<ServiceName>, connect to Postgres/Redis, start server} -
Add a
maketarget inMakefile:<name>:go build -o yabanin-<name> ./services/<name>/cmd/...run-<name>: build./yabanin-<name>Add
<name>to the.PHONYline and to thebuild:dependency. -
Add config fields in
pkg/config/config.go:type MyService struct { ... }func DefaultMyService() MyService { ... }func (c MyService) Validate() error { ... }// Add to All struct and DefaultAll() -
Add Docker support:
- Copy the service block in
deploy/docker-compose.ymlfrom an existing service as a template - Update
SERVICEbuild arg, ports, healthcheck, secrets - Run
make docker-build-allto build the image
- Copy the service block in
-
Add tests under
services/<name>/integration/if it needs integration coverage.
Migrations
Migration files live in migrations/ and are applied in lexical order (filename prefix determines sequence). Each file should be idempotent where possible (CREATE TABLE IF NOT EXISTS).
Running migrations
# Via Docker compose (uses the migrate one-shot service)
make docker-up # migrate runs automatically on first up
# Via local psql
export YABANIN_PG_URI="postgres://yabanin:yabanin@localhost/yabanin?sslmode=disable"
make migrate-up
# Manually
for f in migrations/*.sql; do psql $YABANIN_PG_URI -f "$f"; done
Writing a new migration
- Create
migrations/0006_<description>.sql(next sequential number) - Use
CREATE TABLE IF NOT EXISTS,ALTER TABLE, etc. - Test locally: run
make migrate-upagainst a fresh DB - The Docker Compose
migrateservice runs migrations on everyup— usedocker compose down --volumes && make docker-upto test a full reset
Configuration
config.yaml
A single config.yaml at the project root holds all service configs, each under its own top-level key:
gateway:
listen_address: ":8082"
admin_listen: ":9090"
provider: "localhost:8081"
redis_addr: "redis:6379"
pg_uri: "postgres://yabanin:yabanin@postgres/yabanin?sslmode=disable"
log_level: "info"
log_format: "json"
auth_mode: "header" # header | strict
api_key: ""
fallback_api_key: "yabanin-dev-key"
fallback_model: "smart" # smart | cheap | turbo
event_stream: "events:queue"
loggerd:
listen_address: ":8085"
stream_name: "events:queue"
redis_addr: "redis:6379"
pg_uri: "postgres://yabanin:yabanin@postgres/yabanin?sslmode=disable"
log_level: "info"
log_format: "json"
admin:
listen_address: ":8086"
pg_uri: "postgres://yabanin:yabanin@postgres/yabanin?sslmode=disable"
session_key: "" # HMAC signing key for admin cookies
admin_domain: "localhost"
encryption_key: "" # 32-byte key for AES-GCM token encryption
Environment variables
Every config field can be overridden via YABANIN_* env vars. The _FILE suffix reads the value from a file (for Docker secrets / Kubernetes):
| Env var | Default | Description |
|---|---|---|
YABANIN_CONFIG | config.yaml | Path to YAML config file |
YABANIN_CONFIG_STRICT | off | Fail-closed env parsing for CI: 1/true makes a malformed numeric env value (YABANIN_* int/float) fail config load with the variable named; default (unset/0) warns and keeps the built-in default. Duration fields (*_timeout, *_ttl, stream_max_duration, …) are always checked by Validate() regardless of this flag |
YABANIN_GATEWAY_LISTEN | :8082 | Gateway HTTP listen address |
YABANIN_GATEWAY_ADMIN | :9090 | Gateway admin metrics endpoint |
YABANIN_PROVIDER_ADDR | localhost:8081 | Upstream API provider address |
YABANIN_REDIS_ADDR | localhost:6379 | Redis connection address |
YABANIN_PG_URI | — | PostgreSQL DSN (required) |
YABANIN_LOG_LEVEL | info | Log level: debug, info, warn, error |
YABANIN_LOGGERD_STREAM | events:queue | Redis stream name |
YABANIN_ADMIN_LISTEN | :8086 | Admin panel listen address |
YABANIN_ADMIN_DOMAIN | localhost | Cookie domain for admin sessions |
YABANIN_SESSION_KEY | — | HMAC signing key (required for admin) |
YABANIN_CACHE_HMAC_SECRET | — | Secret for signing Redis-cached responses |
Config loading priority
- Defaults — hardcoded in
pkg/config/*.go - YAML config file — loaded from path in
YABANIN_CONFIG(orconfig.yaml) - Environment variables — override YAML values;
_FILEsuffix reads from file paths
The resolution order is: defaults → YAML merge → env var override. For secrets, use the _FILE variant to point at Docker secret mounts.
Malformed numeric env values (YABANIN_CIRCUIT_FAILURE_THRESHOLD=abc, …) always produce a startup warning that names the variable and keep the built-in default. Set YABANIN_CONFIG_STRICT=1 (recommended in CI) to turn that warning into a load failure. Malformed duration values (YAML or env) fail Validate() at startup in both modes; existing valid configs are unaffected.
Boolean env vars accept exactly 1 or true (case-insensitive) as true; every other non-empty value (0, false, yes, garbage, untrimmed) is false — not the fallback. Only unset/empty keeps the default, so for flags that default to true the variable must be left unset to keep it. This is a silent contract: boolean parsing never warns and YABANIN_CONFIG_STRICT does not apply to it.