Floodguard is an open-source Go library that protects HTTP and gRPC services from rapid or abusive traffic. It is built for high-stakes endpoints—withdrawals, bets, transfers, password resets—where duplicate or automated requests can drain accounts or degrade service for everyone else.
Financial and gaming APIs attract abuse that a plain rate limiter alone does not catch:
| Threat | Example | floodguard defense |
|---|---|---|
| Request flooding | 500 bets/second from one account | Rate limiting (token bucket + sliding window) |
| Double-submit | User double-clicks "Withdraw" | Idempotency keys with cached responses |
| Race conditions | Parallel withdrawal requests | Per-account distributed locks |
| Anomaly / bot behavior | 30 withdrawals in 60 seconds | Velocity rule engine |
Each layer is swappable: use in-memory backends for development, or Redis for distributed production deployments.
go get github.com/duncanmwirigi/floodguard-rate-limiterImport path: github.com/duncanmwirigi/floodguard-rate-limiter (subpackages: /middleware, /ratelimit, /config, etc.).
Requires Go 1.22+, Redis (example server + production), and for live scenarios: curl + jq.
- Install
- Verify it works
- How to use this package
- Project structure
- Quickstart (minimal)
- Companion packages
- Architecture
- Production setup (Redis)
- Middleware reference
- Programmatic API (gRPC / custom)
- Full stack (companion packages)
- Example server
- Configuration
- Testing
- Error handling
- HTTP status codes
- Troubleshooting
- Documentation map
- Production readiness
Floodguard is a library, not a standalone service. You embed it in your Go HTTP (or gRPC) server around endpoints that move money, place bets, or change sensitive state.
| Path | When to use | Entry point |
|---|---|---|
| HTTP middleware | Standard REST API | middleware.Handler(guard, opts)(yourHandler) |
| Programmatic | gRPC, workers, non-HTTP | guard.Protect() + guard.WithLock() |
| Full stack | Withdrawals with device trust, step-up, CAPTCHA | Compose middleware layers — see example/app/app.go |
| Example server | Learn / demo / integration tests | go run ./example + ./scripts/run-scenarios.sh |
Local development (single process, in-memory stores — no Redis):
guard := floodguard.New(floodguard.Config{
Velocity: velocity.Config{
Rules: []velocity.Rule{
velocity.RateOverWindow{N: 5, Window: time.Minute, Label: "withdrawals"},
},
},
})Production (multi-instance — Redis required):
client := redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_ADDR")})
guard := floodguard.New(floodguard.Config{
IPRateLimiter: ipLimiter, // ratelimit.NewRedisSlidingWindow(...)
RateLimiter: accountLimiter,
Idempotency: idempotency.Config{Store: idempotency.NewRedisStore(client, "myapp"), TTL: 24 * time.Hour},
Lock: lock.Config{Client: lock.NewRedis(client, "myapp"), TTL: 30 * time.Second},
Velocity: velocity.Config{
Store: velocity.NewRedisStore(client, "myapp"),
Rules: []velocity.Rule{ /* see Velocity rules below */ },
},
})Or load limits from env via config.Load() and map fields into floodguard.Config (see Configuration).
Minimum for a withdrawal endpoint:
mux.Handle("/withdraw", middleware.Handler(guard, middleware.Options{
Action: "withdraw",
RequireLock: true, // serialize balance check + deduct
RequireIdempotencyKey: true, // reject missing Idempotency-Key
})(http.HandlerFunc(withdrawHandler)))Different limits per route — create separate middleware.Handler instances:
mux.Handle("/withdraw", middleware.Handler(guard, middleware.Options{
Action: "withdraw", RequireLock: true, RequireIdempotencyKey: true,
})(withdrawHandler))
mux.Handle("/bet", middleware.Handler(guard, middleware.Options{
Action: "bet", RequireLock: true,
})(betHandler))Tune velocity per action by setting Options.Action (velocity key becomes accountID:action).
For mutating endpoints protected by Floodguard:
| Header | Required? | Purpose |
|---|---|---|
X-Account-ID |
Yes (or custom KeyFunc) |
Account / wallet identity |
Idempotency-Key |
Recommended (RequireIdempotencyKey: true) |
Safe retries; duplicate keys return cached response |
X-Device-ID |
If using devicetrust / stepup |
Device fingerprint (from your frontend cookie/localStorage) |
User-Agent, Accept-Language |
If using device fingerprinting | Hashed into device identity |
After a 403 step-up response, client retries with X-Step-Up-Token from the JSON body. After a CAPTCHA challenge, retry with X-Challenge-Token + X-Captcha-Response.
Built-in rules in velocity/:
velocity.RateOverWindow{N: 3, Window: time.Minute, Label: "withdrawal attempts"}
velocity.MinInterval{Min: 200 * time.Millisecond, Label: "withdrawal"}Implement velocity.Rule for custom patterns (bet size anomalies, geo signals, etc.).
Flag vs block: default BlockOnVelocity: true returns 429. Set BlockOnVelocity: false and use OnVelocityFlag to feed step-up auth instead of blocking (see example/app/app.go).
| Need | Package | Quick usage |
|---|---|---|
| Stolen-credential detection | devicetrust |
FingerprintFromRequest(r, deviceID) + IsKnownDevice |
| OTP/2FA on risky actions | stepup |
stepup.Middleware(mgr, opts)(handler) |
| Alert owner on new device | notify |
notifier.AfterSensitiveAction(...) after successful withdraw |
| Tamper-evident audit log | ledger |
ledger.RecordTransaction(...) for every balance write |
| Platform-wide bot spike | anomaly |
detector.DetectSpike(...) → alert, not block |
| CAPTCHA on new accounts | challenge |
challenge.Middleware(mgr, opts)(handler) |
Recommended middleware order (outer → inner):
floodguard → stepup → challenge → your handler
See the wired example in example/app/app.go.
Step 1 — unit tests (no Redis required for most packages):
cd floodguard
go test ./...Step 2 — live integration scenarios (two terminals):
# Terminal 1: start Redis + example server
redis-server # if not already running
cp .env.example .env
set -a && source .env && set +a
go run ./example# Terminal 2: run all 12 scenarios (expect 12 passed, 0 failed)
./scripts/run-scenarios.shOr use Make:
make test # unit tests
make run # start example server (Terminal 1)
make test-scenarios # run ./scripts/run-scenarios.sh (Terminal 2)
make verify # unit tests + reminder to run live scenariosStep 3 — manual withdraw (trust device first, then withdraw):
# Trust this device for acct-1001
curl -s -X POST http://localhost:8080/demo/trust-device \
-H "X-Account-ID: acct-1001" \
-H "X-Device-ID: device-trusted-1001" \
-H "User-Agent: FloodguardTest/1.0" \
-H "Accept-Language: en-US"
# Withdraw KES 10.00 (1000 cents)
curl -s -X POST http://localhost:8080/withdraw \
-H "X-Account-ID: acct-1001" \
-H "Idempotency-Key: wd-manual-001" \
-H "X-Device-ID: device-trusted-1001" \
-H "User-Agent: FloodguardTest/1.0" \
-H "Accept-Language: en-US" \
-H "Content-Type: application/json" \
-d '{"amount_cents":1000}'
# Check balance
curl -s http://localhost:8080/demo/balance -H "X-Account-ID: acct-1001"floodguard/
├── config/ # Environment variable loader (.env.example)
├── ratelimit/ # Token bucket + Redis sliding window
├── idempotency/ # Atomic idempotency keys
├── lock/ # Distributed locks
├── velocity/ # Behavioral rule engine
├── middleware/ # HTTP protection stack
├── devicetrust/ # Device fingerprinting
├── stepup/ # Risk-based OTP/2FA
├── notify/ # Async sensitive-action alerts
├── ledger/ # Hash-chained audit log
├── anomaly/ # Platform-wide spike detection
├── challenge/ # Conditional CAPTCHA
├── example/
│ ├── main.go # Runnable demo server
│ ├── app/ # Server wiring + integration tests
│ ├── wallet/ # KES cents + property tests
│ └── testdata/ # Demo accounts + scenario catalog
├── scripts/
│ └── run-scenarios.sh # curl integration runner (12 scenarios)
├── Makefile # make test | run | test-scenarios
├── .env.example # Configuration template
└── PRODUCTION_READINESS.md # Pre-launch audit checklist
Local dev with in-memory stores — no Redis:
package main
import (
"log"
"net/http"
"time"
"github.com/duncanmwirigi/floodguard-rate-limiter"
"github.com/duncanmwirigi/floodguard-rate-limiter/middleware"
"github.com/duncanmwirigi/floodguard-rate-limiter/ratelimit"
"github.com/duncanmwirigi/floodguard-rate-limiter/velocity"
"golang.org/x/time/rate"
)
func main() {
g := floodguard.New(floodguard.Config{
IPRateLimiter: ratelimit.NewTokenBucket(ratelimit.TokenBucketConfig{
Rate: rate.Limit(50),
Burst: 100,
}),
RateLimiter: ratelimit.NewTokenBucket(ratelimit.TokenBucketConfig{
Rate: rate.Limit(5),
Burst: 10,
}),
Velocity: velocity.Config{
Rules: []velocity.Rule{
velocity.RateOverWindow{N: 20, Window: time.Minute, Label: "withdrawals"},
},
},
})
mux := http.NewServeMux()
mux.Handle("/withdraw", middleware.Handler(g, middleware.Options{
Action: "withdraw",
RequireLock: true,
RequireIdempotencyKey: true,
})(http.HandlerFunc(withdrawHandler)))
log.Fatal(http.ListenAndServe(":8080", mux))
}
func withdrawHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"completed"}`))
}Account identity defaults to X-Account-ID (falls back to client IP). Idempotency defaults to Idempotency-Key header. Override via middleware.Options.KeyFunc, IdempotencyKeyFunc, etc.
Floodguard is the concurrency/abuse layer. These sibling packages close adjacent gaps:
| Package | Gap | Purpose |
|---|---|---|
devicetrust/ |
Stolen credentials | Device fingerprinting + known-device detection |
stepup/ |
Stolen credentials | Risk-based OTP/2FA middleware (composes with velocity flags) |
notify/ |
Stolen credentials | Fire-and-forget alerts on sensitive actions from new devices |
ledger/ |
Insider DB access | Append-only hash-chained audit log + tamper detection |
anomaly/ |
Distributed botnet | Platform-wide spike detection (alert, not block) |
challenge/ |
Distributed botnet | Conditional CAPTCHA middleware (stub verifier for hCaptcha/Turnstile) |
example/wallet/ |
Business logic bugs | KES cents + property/regression tests |
Recommended build order: floodguard → wallet property tests → devicetrust + stepup → ledger → anomaly + challenge.
See PRODUCTION_READINESS.md and example/wallet/AUDIT.md.
A request passes through six layers before reaching your handler (when all options enabled):
flowchart TD
A([Incoming request]) --> B[1. IP rate limit]
B -->|reject| R1[429 ip_rate_limit]
B --> C[2. Account rate limit]
C -->|reject| R2[429 rate_limit]
C --> D[3. Idempotency check]
D -->|replay| R3[200 cached response]
D -->|in-flight| R4[409 conflict]
D --> E[4. Velocity rules]
E -->|reject| R5[429 velocity]
E --> F[5. Distributed lock]
F -->|reject| R6[409 locked]
F --> G[Handler: balance check + deduct]
G --> H[6. Release lock + cache result]
H --> A2([Response])
| Package | Role |
|---|---|
floodguard |
Guard wires all subsystems; call Protect or use middleware |
ratelimit/ |
Per-key token bucket (memory) or sliding window (Redis) |
idempotency/ |
Atomic claim + response cache by idempotency key |
velocity/ |
Composable rule engine for abuse patterns |
lock/ |
Distributed lock per account/resource |
middleware/ |
HTTP middleware chaining all layers |
config/ |
Environment-based configuration loader (.env.example) |
example/ |
Runnable POST /withdraw demo with Redis |
Storage backends implement small interfaces (Limiter, idempotency.Store, lock.Client, velocity.Store) so you can swap in-memory and Redis without changing handler code.
Use Redis for every shared store when running more than one app instance:
import (
"time"
"github.com/duncanmwirigi/floodguard-rate-limiter"
"github.com/duncanmwirigi/floodguard-rate-limiter/idempotency"
"github.com/duncanmwirigi/floodguard-rate-limiter/lock"
"github.com/duncanmwirigi/floodguard-rate-limiter/ratelimit"
"github.com/duncanmwirigi/floodguard-rate-limiter/velocity"
"github.com/redis/go-redis/v9"
)
client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
ipRateLimiter, _ := ratelimit.NewRedisSlidingWindow(client, "myapp:ip", ratelimit.SlidingWindowConfig{
Limit: 100, Window: time.Minute,
})
accountRateLimiter, _ := ratelimit.NewRedisSlidingWindow(client, "myapp:acct", ratelimit.SlidingWindowConfig{
Limit: 10, Window: time.Minute,
})
guard := floodguard.New(floodguard.Config{
IPRateLimiter: ipRateLimiter,
RateLimiter: accountRateLimiter,
Idempotency: idempotency.Config{Store: idempotency.NewRedisStore(client, "myapp"), TTL: 24 * time.Hour},
Lock: lock.Config{Client: lock.NewRedis(client, "myapp"), TTL: 30 * time.Second},
Velocity: velocity.Config{
Store: velocity.NewRedisStore(client, "myapp"),
Rules: []velocity.Rule{
velocity.RateOverWindow{N: 5, Window: time.Minute, Label: "withdrawals"},
velocity.MinInterval{Min: 300 * time.Millisecond, Label: "bet"},
},
},
})Copy .env.example → .env for the example server, or map config.Load() into your own bootstrap.
middleware.Handler(guard, middleware.Options{...}) accepts:
| Option | Default | Description |
|---|---|---|
Action |
"" |
Velocity label (withdraw, bet, …) |
KeyFunc |
X-Account-ID → IP fallback |
Account / wallet key |
IPKeyFunc |
Client IP from request | Layer-1 rate limit key |
LockKeyFunc |
Same as KeyFunc |
Distributed lock key |
IdempotencyKeyFunc |
Idempotency-Key header |
Idempotency key source |
RequireLock |
false |
Acquire lock before handler (use true for balance ops) |
RequireIdempotencyKey |
false |
Reject if idempotency header missing |
FailClosed |
true |
Return 503 when Redis/store unreachable |
BlockOnVelocity |
true |
Block on velocity; false = flag via OnVelocityFlag |
OnVelocityFlag |
— | Callback when velocity fires and BlockOnVelocity: false |
Audit |
— | Structured event hook (AuditEvent) |
Logger |
— | Human-readable layer traces |
For non-HTTP handlers, call Guard methods directly:
ctx := r.Context()
result, err := guard.Protect(ctx, floodguard.Request{
Key: accountID,
IPKey: clientIP,
IdempotencyKey: idemKey,
Action: "withdraw",
})
if err != nil {
return status.Errorf(codes.Unavailable, "protection layer error: %v", err)
}
if !result.Allowed {
switch result.Reason {
case floodguard.RejectCached:
return cachedResponse(result.CachedResponse)
case floodguard.RejectRateLimit:
return status.Error(codes.ResourceExhausted, "rate limit")
// ...
}
}
err = guard.WithLock(ctx, accountID, func(ctx context.Context) error {
// balance check + deduct inside lock
return nil
})
if err != nil {
return err
}
_ = guard.CompleteIdempotency(ctx, idemKey, responseBytes)TryLock is non-blocking — concurrent callers get an error and should retry (HTTP middleware maps this to 409 Conflict).
The example server wires the complete stack for a KES withdrawal API:
- Floodguard — rate limit, idempotency, velocity, lock
- Step-up —
403+ token when device unknown or velocity flagged - Challenge — CAPTCHA for new accounts / platform spikes
- Device trust — fingerprint + trusted device store
- Notify — async alert on sensitive action from new device
- Wallet + ledger —
int64KES cents + hash-chained audit log
Run it: go run ./example (Redis required). Test it: ./scripts/run-scenarios.sh.
Copy .env.example and load it before starting. See Verify it works for the full flow.
Routes:
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness check |
POST |
/withdraw |
Protected withdrawal (requires headers below) |
POST |
/demo/trust-device |
Mark device trusted (demo/tests only) |
GET |
/demo/balance |
Read demo account balance |
Required headers for /withdraw:
| Header | Example | Purpose |
|---|---|---|
X-Account-ID |
acct-1001 |
Account identity |
Idempotency-Key |
wd-001 |
Duplicate-submit protection |
X-Device-ID |
device-trusted-1001 |
Device fingerprint input |
User-Agent |
FloodguardTest/1.0 |
Fingerprint input |
Accept-Language |
en-US |
Fingerprint input |
Optional: X-Step-Up-Token (after 403 step-up), X-Challenge-Token + X-Captcha-Response: valid-captcha (after 403 CAPTCHA).
cp .env.example .env
set -a && source .env && set +a
go run ./exampleServer logs trace each layer: rate limit → idempotency → velocity → lock → step-up → challenge → handler.
All tunable settings load from environment variables via config.Load(). Defaults match .env.example.
| Variable | Default | Description |
|---|---|---|
LISTEN_ADDR |
:8080 |
HTTP listen address |
REDIS_ADDR |
localhost:6379 |
Redis connection |
REDIS_KEY_PREFIX |
floodguard |
Namespace for all Redis keys |
REDIS_PING_TIMEOUT |
3s |
Startup Redis health check timeout |
IP_RATE_LIMIT |
30 |
Max requests per IP per window |
IP_RATE_WINDOW |
1m |
IP rate limit window |
ACCOUNT_RATE_LIMIT |
10 |
Max requests per account per window |
ACCOUNT_RATE_WINDOW |
1m |
Account rate limit window |
IDEMPOTENCY_TTL |
24h |
Cached idempotent response lifetime |
VELOCITY_WITHDRAW_MAX |
3 |
Max withdrawal attempts per velocity window |
VELOCITY_WINDOW |
1m |
Velocity rule window |
VELOCITY_MIN_INTERVAL |
200ms |
Minimum time between withdrawals |
LOCK_TTL |
30s |
Distributed lock TTL |
BLOCK_ON_VELOCITY |
false |
Block on velocity (false = flag-only for step-up) |
REQUIRE_IDEMPOTENCY_KEY |
true |
Reject requests without Idempotency-Key |
REQUIRE_LOCK |
true |
Acquire distributed lock before handler |
ANOMALY_SPIKE_MULTIPLIER |
5 |
Platform spike threshold (current vs baseline) |
ANOMALY_LOOKBACK_MINUTES |
60 |
Baseline lookback for spike detection |
CHALLENGE_NEW_ACCOUNT_MAX_AGE |
24h |
Accounts younger than this require CAPTCHA |
STEPUP_TOKEN_TTL |
10m |
Step-up challenge token lifetime |
DEVICE_TRUST_STORE |
memory |
memory or redis |
NOTIFY_ENABLED |
true |
Send alerts on sensitive actions from new devices |
DEMO_ACCOUNT_BALANCES |
acct-1001=50000,... |
Demo seed balances (KES cents) |
In your own app, import github.com/duncanmwirigi/floodguard-rate-limiter/config and map the struct fields into each package's config at startup — no need to fork the loader.
go test ./... # all packages
go test -race ./... # with race detector
go test ./example/wallet/ # property-based balance invariants (gopter)
go test ./example/app/ # HTTP scenario tests (miniredis, no live server)Demo accounts and headers are defined in example/testdata/accounts.json.
Currency: amounts use KES minor units (cents) — 50000 = KES 500.00, 1000 = KES 10.00.
| Account | Balance | Notes |
|---|---|---|
acct-1001 |
KES 500.00 (50000 cents) | Primary demo account, mature |
acct-1002 |
KES 100.00 (10000 cents) | Secondary account |
acct-new |
KES 0 | Created on first request — triggers CAPTCHA |
acct-empty |
KES 1.00 (100 cents) | Insufficient-funds tests |
Trust a device before withdrawing from a mature account:
curl -s -X POST http://localhost:8080/demo/trust-device \
-H "X-Account-ID: acct-1001" \
-H "X-Device-ID: device-trusted-1001" \
-H "User-Agent: FloodguardTest/1.0" \
-H "Accept-Language: en-US"Stub CAPTCHA token for tests: valid-captcha (see challenge.StubVerifier).
example/testdata/scenarios.json catalogs each scenario. scripts/run-scenarios.sh runs them against a live server (requires curl, jq, Redis, and the example server running):
# Terminal 1
redis-server
cp .env.example .env && set -a && source .env && set +a
go run ./example
# Terminal 2
./scripts/run-scenarios.sh
# Expected: ==> Results: 12 passed, 0 failed| # | Scenario | Expected |
|---|---|---|
| 01 | Health check | 200 OK |
| 02 | Trust device | 200 + device marked trusted |
| 03 | Withdraw (trusted device) | 200 + balance deducted |
| 04 | Idempotent replay | 200 + X-Idempotent-Replay: true |
| 05 | Insufficient funds | 402 Payment Required |
| 06 | Invalid amount (negative) | 400 Bad Request |
| 07 | Missing Idempotency-Key | 400 Bad Request |
| 08 | Step-up (unknown device) | 403 + challenge_token |
| 08b | Step-up with token | 200 after X-Step-Up-Token |
| 09 | CAPTCHA (new account) | 403 + X-Challenge-Required |
| 09b | CAPTCHA solved | 402 (passed challenge; KES 0 balance) |
| 10 | Balance check | 200 + balance_cents |
The script pauses 300ms between withdrawals to avoid velocity MinInterval collisions. Override server URL: BASE_URL=http://localhost:9090 ./scripts/run-scenarios.sh
Sentinel errors support errors.Is / errors.As throughout:
result, err := g.Protect(ctx, req)
if floodguard.IsDuplicateInFlight(err) {
// 409 Conflict — same idempotency key already in flight
}
if errors.Is(err, floodguard.ErrKeyRequired) {
// missing account key
}
if floodguard.IsRejected(err, floodguard.RejectRateLimit) {
// programmatic rejection
}Subpackages export their own sentinels (ratelimit.ErrKeyRequired, lock.ErrNotAcquired, idempotency.ErrInFlight, etc.). Operational failures are wrapped with %w.
| Condition | Status |
|---|---|
| IP rate limit exceeded | 429 Too Many Requests (ip_rate_limit) |
| Account rate limit exceeded | 429 Too Many Requests (rate_limit) |
| Velocity threshold exceeded | 429 Too Many Requests |
| Redis / store unreachable | 503 Service Unavailable (when FailClosed: true) |
| Resource locked (concurrent request) | 409 Conflict |
| Duplicate in-flight idempotency key | 409 Conflict |
| Idempotent replay | 200 OK + X-Idempotent-Replay: true |
| Step-up required (unknown device) | 403 Forbidden + challenge_token |
| CAPTCHA required (new account) | 403 Forbidden + X-Challenge-Required |
| Insufficient funds (example handler) | 402 Payment Required |
| Symptom | Likely cause | Fix |
|---|---|---|
503 on every request |
Redis down or wrong REDIS_ADDR |
Start Redis; check env; FailClosed defaults to deny |
409 Conflict on concurrent withdraws |
Expected — TryLock is non-blocking |
Client retries with backoff; only one holder at a time |
403 + challenge_token |
Unknown device or velocity flagged | Complete step-up; trust device via your auth flow |
403 + X-Challenge-Required |
New account or platform spike | Solve CAPTCHA; retry with tokens |
400 missing idempotency |
RequireIdempotencyKey: true |
Client sends Idempotency-Key on every mutating request |
bind: address already in use |
Port 8080 taken | kill $(lsof -t -i:8080) or LISTEN_ADDR=:8081 go run ./example |
| Integration script failures | Velocity MinInterval between rapid curls |
Script pauses 300ms; use isolated accounts per scenario |
| Works locally, fails in prod | In-memory stores don't sync across pods | Switch all backends to Redis |
| Document | Purpose |
|---|---|
| README.md | Usage guide (this file) |
| PRODUCTION_READINESS.md | Pre-launch audit checklist |
| example/wallet/AUDIT.md | Business-logic regression findings |
| CONTRIBUTING.md | Dev setup, CI, PR guidelines |
| CHANGELOG.md | Release history |
| .env.example | All environment variables |
| example/testdata/ | Demo accounts + scenario catalog |
Before handling real funds, walk through PRODUCTION_READINESS.md — an audit checklist mapping each control to floodguard capabilities and platform responsibilities.
See CONTRIBUTING.md. CI runs go vet, go test -race, and golangci-lint on every push and pull request.
See CHANGELOG.md.
Duncan Mwirigi
GitHub: github.com/duncanmwirigi
X: https://x.com/AIStiqDan
Website: https://bytecityinc.com
MIT — see LICENSE.