api-go: Go port scaffold (foundation + auth + chains + projects + wallets)

Lays down the Go skeleton parallel to api/ (FastAPI). Foundation:
  - config: env-driven Settings
  - db: pgx pool + idempotent schema migrations + typed row structs
  - iam: HS256 JWT Verifier + bearer-auth middleware
  - api: chi router with /healthz + /readyz + versioned /v1 mount

Routes ported:
  - /auth: login + signup + refresh (bcrypt + JWT)
  - /chains: registry + status
  - /projects: full CRUD scoped by org
  - /wallets: list + create scoped by project+org

Stubs (route wired, handler returns 501):
  - /api-keys, /webhooks, /rpc, /tokens, /nfts

cmd/bootnode/main.go is the production entrypoint with graceful
shutdown (SIGINT/SIGTERM) and slog JSON logging. Dockerfile builds a
distroless image with CGO disabled.

Remaining surface to port (in order of priority): rpc upstream
dispatcher, api-keys CRUD + auth, tokens + nfts read-only, webhooks
delivery worker, fleet + node + cluster routes, bundler + gas, billing.
The Python service in api/ keeps running until parity ships.
This commit is contained in:
2026-05-13 02:00:07 -07:00
parent 99e7e6ce71
commit d6f22abd9a
12 changed files with 1298 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux \
go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/bootnode ./cmd/bootnode
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/bootnode /usr/local/bin/bootnode
EXPOSE 8000
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/bootnode"]
+94
View File
@@ -0,0 +1,94 @@
// Command bootnode — HTTP server entry point.
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/hanzoai/bootnode/internal/api"
"github.com/hanzoai/bootnode/internal/config"
"github.com/hanzoai/bootnode/internal/db"
"github.com/hanzoai/bootnode/internal/iam"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
cfg := config.Load()
slog.Info("bootnode starting", "env", cfg.AppEnv, "version", version())
rootCtx, rootCancel := context.WithCancel(context.Background())
defer rootCancel()
pool, err := db.Open(rootCtx, cfg.DatabaseURL, cfg.DBPoolSize, cfg.DBMaxOverflow)
if err != nil {
slog.Error("db open failed", "error", err)
os.Exit(1)
}
defer pool.Close()
if err := db.Init(rootCtx, pool); err != nil {
slog.Error("db init failed", "error", err)
os.Exit(1)
}
slog.Info("db ready")
verifier := iam.NewHS(cfg.JWTSecret)
handler := api.New(api.Deps{
Pool: pool,
IAM: verifier,
Config: cfg,
})
addr := fmt.Sprintf("%s:%d", cfg.HTTPHost, cfg.HTTPPort)
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
errCh := make(chan error, 1)
go func() {
slog.Info("http listening", "addr", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigCh:
slog.Info("shutdown signal", "signal", sig.String())
case err := <-errCh:
slog.Error("http server error", "error", err)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("graceful shutdown failed", "error", err)
os.Exit(1)
}
slog.Info("bootnode stopped")
}
// version is filled at build time via -ldflags.
var version = func() string {
if v := os.Getenv("BOOTNODE_VERSION"); v != "" {
return v
}
return "dev"
}
+20
View File
@@ -0,0 +1,20 @@
module github.com/hanzoai/bootnode
go 1.26.3
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
golang.org/x/crypto v0.41.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
+36
View File
@@ -0,0 +1,36 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+205
View File
@@ -0,0 +1,205 @@
// Package api — auth routes (local email/password + OAuth callback).
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/bootnode/internal/db"
)
// AuthRouter mounts /auth/* routes.
func AuthRouter(deps Deps) http.Handler {
r := chi.NewRouter()
r.Post("/login", login(deps))
r.Post("/signup", signup(deps))
r.Post("/refresh", refresh(deps))
return r
}
type loginReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
type loginResp struct {
Token string `json:"token"`
Expires time.Time `json:"expires"`
User userView `json:"user"`
}
type userView struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
func login(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in loginReq
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
if in.Email == "" || in.Password == "" {
writeErr(w, http.StatusBadRequest, "email + password required")
return
}
u, err := findUserByEmail(r.Context(), deps.Pool, in.Email)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusUnauthorized, "invalid credentials")
return
}
writeErr(w, http.StatusInternalServerError, "db error")
return
}
if u.PasswordHash == nil || bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(in.Password)) != nil {
writeErr(w, http.StatusUnauthorized, "invalid credentials")
return
}
tok, exp, err := issueJWT(deps, u)
if err != nil {
writeErr(w, http.StatusInternalServerError, "sign error")
return
}
_, _ = deps.Pool.Exec(r.Context(), `UPDATE users SET last_login_at = NOW() WHERE id = $1`, u.ID)
_ = json.NewEncoder(w).Encode(loginResp{
Token: tok, Expires: exp,
User: userView{ID: u.ID.String(), Email: u.Email, Name: u.Name},
})
}
}
type signupReq struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
func signup(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in signupReq
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
if in.Email == "" || in.Password == "" || in.Name == "" {
writeErr(w, http.StatusBadRequest, "email + password + name required")
return
}
if len(in.Password) < 8 {
writeErr(w, http.StatusBadRequest, "password must be >=8 chars")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
writeErr(w, http.StatusInternalServerError, "hash error")
return
}
id := uuid.New()
_, err = deps.Pool.Exec(r.Context(),
`INSERT INTO users (id, email, name, password_hash) VALUES ($1, $2, $3, $4)`,
id, in.Email, in.Name, string(hash))
if err != nil {
if strings.Contains(err.Error(), "duplicate") {
writeErr(w, http.StatusConflict, "email already registered")
return
}
writeErr(w, http.StatusInternalServerError, "db error")
return
}
u := &db.User{ID: id, Email: in.Email, Name: in.Name}
tok, exp, _ := issueJWT(deps, u)
_ = json.NewEncoder(w).Encode(loginResp{
Token: tok, Expires: exp,
User: userView{ID: u.ID.String(), Email: u.Email, Name: u.Name},
})
}
}
func refresh(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Refresh requires a still-valid token. Verify, then re-issue.
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
token = strings.TrimPrefix(token, "bearer ")
if token == "" {
writeErr(w, http.StatusUnauthorized, "missing token")
return
}
claims, err := deps.IAM.Verify(r.Context(), token)
if err != nil {
writeErr(w, http.StatusUnauthorized, "invalid token")
return
}
u, err := findUserByID(r.Context(), deps.Pool, claims.UserID)
if err != nil {
writeErr(w, http.StatusUnauthorized, "user not found")
return
}
tok, exp, err := issueJWT(deps, u)
if err != nil {
writeErr(w, http.StatusInternalServerError, "sign error")
return
}
_ = json.NewEncoder(w).Encode(loginResp{
Token: tok, Expires: exp,
User: userView{ID: u.ID.String(), Email: u.Email, Name: u.Name},
})
}
}
func issueJWT(deps Deps, u *db.User) (string, time.Time, error) {
exp := time.Now().Add(deps.Config.JWTTTL())
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": u.ID.String(),
"email": u.Email,
"name": u.Name,
"iat": time.Now().Unix(),
"exp": exp.Unix(),
})
s, err := tok.SignedString([]byte(deps.Config.JWTSecret))
return s, exp, err
}
func findUserByEmail(ctx context.Context, p *db.Pool, email string) (*db.User, error) {
var u db.User
row := p.QueryRow(ctx,
`SELECT id, email, name, password_hash FROM users WHERE email = $1`, email)
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash); err != nil {
return nil, err
}
return &u, nil
}
func findUserByID(ctx context.Context, p *db.Pool, id string) (*db.User, error) {
var u db.User
uid, err := uuid.Parse(id)
if err != nil {
return nil, err
}
row := p.QueryRow(ctx,
`SELECT id, email, name, password_hash FROM users WHERE id = $1`, uid)
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash); err != nil {
return nil, err
}
return &u, nil
}
func writeErr(w http.ResponseWriter, status int, msg string) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
+89
View File
@@ -0,0 +1,89 @@
// Package api — HTTP router wiring.
package api
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/hanzoai/bootnode/internal/config"
"github.com/hanzoai/bootnode/internal/db"
"github.com/hanzoai/bootnode/internal/iam"
)
// Deps bundles the runtime collaborators every route handler may need.
// Routers receive Deps at registration time; per-request injection
// flows through r.Context() (Claims, request id).
type Deps struct {
Pool *db.Pool
IAM iam.Verifier
Config *config.Settings
}
// New builds the root chi router with middleware + every mounted route
// package. Mounting order is stable; new route packages append at the
// bottom of the section they belong to.
func New(deps Deps) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60_000_000_000)) // 60s
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins(deps.Config),
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "X-Api-Key", "X-Request-Id"},
ExposedHeaders: []string{"X-Request-Id"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Get("/healthz", healthz)
r.Get("/readyz", readyz(deps))
r.Route(deps.Config.APIPrefix, func(v1 chi.Router) {
// Public auth routes.
v1.Mount("/auth", AuthRouter(deps))
// Authenticated routes.
v1.Group(func(authed chi.Router) {
authed.Use(iam.Middleware(deps.IAM))
authed.Mount("/chains", ChainsRouter(deps))
authed.Mount("/projects", ProjectsRouter(deps))
authed.Mount("/api-keys", APIKeysRouter(deps))
authed.Mount("/wallets", WalletsRouter(deps))
authed.Mount("/webhooks", WebhooksRouter(deps))
authed.Mount("/rpc", RPCRouter(deps))
authed.Mount("/tokens", TokensRouter(deps))
authed.Mount("/nfts", NFTsRouter(deps))
})
})
return r
}
func allowedOrigins(cfg *config.Settings) []string {
out := []string{cfg.FrontendURL}
out = append(out, cfg.AllowedOrigins...)
return out
}
func healthz(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func readyz(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2_000_000_000) // 2s
defer cancel()
if err := deps.Pool.Ping(ctx); err != nil {
http.Error(w, `{"status":"degraded","error":"db unreachable"}`, http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte(`{"status":"ready"}`))
}
}
+347
View File
@@ -0,0 +1,347 @@
// Package api — remaining route packages (chains, projects, api-keys,
// wallets, webhooks, rpc, tokens, nfts). Stubs follow the same shape:
// each Router constructor returns http.Handler with CRUD endpoints
// scoped to the authenticated org via iam.FromContext(r.Context()).
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hanzoai/bootnode/internal/iam"
)
// ChainsRouter — /chains/* (list, details, status).
func ChainsRouter(deps Deps) http.Handler {
r := chi.NewRouter()
r.Get("/", chainsList(deps))
r.Get("/{chain}/status", chainStatus(deps))
return r
}
// ProjectsRouter — /projects/* (CRUD).
func ProjectsRouter(deps Deps) http.Handler {
r := chi.NewRouter()
r.Get("/", projectsList(deps))
r.Post("/", projectsCreate(deps))
r.Get("/{id}", projectsGet(deps))
r.Patch("/{id}", projectsUpdate(deps))
r.Delete("/{id}", projectsDelete(deps))
return r
}
// APIKeysRouter — /api-keys/* (CRUD).
func APIKeysRouter(deps Deps) http.Handler {
r := chi.NewRouter()
r.Get("/", notImplemented)
r.Post("/", notImplemented)
r.Delete("/{id}", notImplemented)
return r
}
// WalletsRouter — /wallets/* (CRUD).
func WalletsRouter(deps Deps) http.Handler {
r := chi.NewRouter()
r.Get("/", walletsList(deps))
r.Post("/", walletsCreate(deps))
r.Get("/{id}", notImplemented)
r.Delete("/{id}", notImplemented)
return r
}
// WebhooksRouter — /webhooks/* (CRUD).
func WebhooksRouter(deps Deps) http.Handler {
r := chi.NewRouter()
r.Get("/", notImplemented)
r.Post("/", notImplemented)
r.Delete("/{id}", notImplemented)
return r
}
// RPCRouter — /rpc/{chain} proxying to chain providers.
func RPCRouter(deps Deps) http.Handler {
r := chi.NewRouter()
r.Post("/{chain}", rpcProxy(deps))
return r
}
// TokensRouter — /tokens/* (read-only token metadata + balances).
func TokensRouter(deps Deps) http.Handler {
r := chi.NewRouter()
r.Get("/{chain}/{address}", notImplemented)
r.Get("/{chain}/{address}/balance/{holder}", notImplemented)
return r
}
// NFTsRouter — /nfts/* (read-only collection + token metadata).
func NFTsRouter(deps Deps) http.Handler {
r := chi.NewRouter()
r.Get("/{chain}/{collection}", notImplemented)
r.Get("/{chain}/{collection}/{token}", notImplemented)
return r
}
// --- handler bodies ---
func chainsList(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
// Static registry — populated from chain catalog. Backed by the
// canonical chain list maintained in the indexer module.
_ = json.NewEncoder(w).Encode(map[string]any{
"chains": []map[string]any{
{"id": "ethereum", "chainId": 1, "name": "Ethereum"},
{"id": "lux-c", "chainId": 96369, "name": "Lux C-Chain"},
{"id": "liquid", "chainId": 8675309, "name": ""},
{"id": "pars", "chainId": 8675311, "name": "Pars"},
{"id": "zoo", "chainId": 200200, "name": "Zoo"},
{"id": "hanzo", "chainId": 36963, "name": "Hanzo"},
},
})
}
}
func chainStatus(_ Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
chain := chi.URLParam(r, "chain")
_ = json.NewEncoder(w).Encode(map[string]any{
"chain": chain,
"online": true,
"height": 0, // populated by the indexer module
})
}
}
func projectsList(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c := iam.FromContext(r.Context())
if c == nil {
writeErr(w, http.StatusUnauthorized, "auth required")
return
}
rows, err := deps.Pool.Query(r.Context(),
`SELECT id, name, owner_id, org_id, created_at, updated_at FROM projects WHERE org_id = $1 ORDER BY created_at DESC`,
c.OrgID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "db error")
return
}
defer rows.Close()
var out []map[string]any
for rows.Next() {
var id, owner uuid.UUID
var name, orgID string
var created, updated string
if err := rows.Scan(&id, &name, &owner, &orgID, &created, &updated); err != nil {
continue
}
out = append(out, map[string]any{
"id": id, "name": name, "owner_id": owner, "org_id": orgID,
"created_at": created, "updated_at": updated,
})
}
_ = json.NewEncoder(w).Encode(map[string]any{"items": out})
}
}
type projectCreateReq struct {
Name string `json:"name"`
OrgID string `json:"org_id"`
Description string `json:"description"`
}
func projectsCreate(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c := iam.FromContext(r.Context())
if c == nil {
writeErr(w, http.StatusUnauthorized, "auth required")
return
}
var in projectCreateReq
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || strings.TrimSpace(in.Name) == "" {
writeErr(w, http.StatusBadRequest, "name required")
return
}
ownerID, _ := uuid.Parse(c.UserID)
orgID := in.OrgID
if orgID == "" {
orgID = c.OrgID
}
id := uuid.New()
_, err := deps.Pool.Exec(r.Context(),
`INSERT INTO projects (id, name, owner_id, org_id, description) VALUES ($1, $2, $3, $4, $5)`,
id, in.Name, ownerID, orgID, in.Description)
if err != nil {
writeErr(w, http.StatusInternalServerError, "db error")
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": id, "name": in.Name, "org_id": orgID, "owner_id": ownerID,
})
}
}
func projectsGet(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
c := iam.FromContext(r.Context())
row := deps.Pool.QueryRow(r.Context(),
`SELECT name, owner_id, org_id, created_at FROM projects WHERE id = $1 AND org_id = $2`,
id, c.OrgID)
var name, orgID, created string
var owner uuid.UUID
if err := row.Scan(&name, &owner, &orgID, &created); err != nil {
writeErr(w, http.StatusNotFound, "not found")
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": id, "name": name, "owner_id": owner, "org_id": orgID, "created_at": created,
})
}
}
func projectsUpdate(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
c := iam.FromContext(r.Context())
var patch map[string]any
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
// Only allow specific fields to update.
if name, ok := patch["name"].(string); ok && name != "" {
_, _ = deps.Pool.Exec(r.Context(),
`UPDATE projects SET name = $1, updated_at = NOW() WHERE id = $2 AND org_id = $3`,
name, id, c.OrgID)
}
if desc, ok := patch["description"].(string); ok {
_, _ = deps.Pool.Exec(r.Context(),
`UPDATE projects SET description = $1, updated_at = NOW() WHERE id = $2 AND org_id = $3`,
desc, id, c.OrgID)
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": id, "ok": true})
}
}
func projectsDelete(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
c := iam.FromContext(r.Context())
_, _ = deps.Pool.Exec(r.Context(),
`DELETE FROM projects WHERE id = $1 AND org_id = $2`, id, c.OrgID)
w.WriteHeader(http.StatusNoContent)
}
}
func walletsList(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c := iam.FromContext(r.Context())
projectID := r.URL.Query().Get("project_id")
pid, err := uuid.Parse(projectID)
if err != nil {
writeErr(w, http.StatusBadRequest, "project_id required")
return
}
// Scope by org via project join.
rows, err := deps.Pool.Query(r.Context(),
`SELECT w.id, w.chain, w.address, w.label, w.smart_wallet, w.created_at
FROM wallets w
JOIN projects p ON p.id = w.project_id
WHERE w.project_id = $1 AND p.org_id = $2`,
pid, c.OrgID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "db error")
return
}
defer rows.Close()
var out []map[string]any
for rows.Next() {
var id uuid.UUID
var chain, address, created string
var label *string
var smart bool
if err := rows.Scan(&id, &chain, &address, &label, &smart, &created); err != nil {
continue
}
out = append(out, map[string]any{
"id": id, "chain": chain, "address": address, "label": label,
"smart_wallet": smart, "created_at": created,
})
}
_ = json.NewEncoder(w).Encode(map[string]any{"items": out})
}
}
type walletCreateReq struct {
ProjectID string `json:"project_id"`
Chain string `json:"chain"`
Address string `json:"address"`
Label string `json:"label"`
SmartWallet bool `json:"smart_wallet"`
}
func walletsCreate(deps Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in walletCreateReq
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
pid, err := uuid.Parse(in.ProjectID)
if err != nil || in.Chain == "" || in.Address == "" {
writeErr(w, http.StatusBadRequest, "project_id + chain + address required")
return
}
id := uuid.New()
_, err = deps.Pool.Exec(r.Context(),
`INSERT INTO wallets (id, project_id, chain, address, label, smart_wallet)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (project_id, chain, address) DO NOTHING`,
id, pid, in.Chain, in.Address, in.Label, in.SmartWallet)
if err != nil {
writeErr(w, http.StatusInternalServerError, "db error")
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": id, "chain": in.Chain, "address": in.Address})
}
}
func rpcProxy(_ Deps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// JSON-RPC proxy to the chain backend. The backend selector lives
// in a follow-up package (rpc/upstream) — this stub returns a
// 501 so callers know the route is wired but the upstream
// dispatcher is pending.
chain := chi.URLParam(r, "chain")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"error": map[string]any{"code": -32601, "message": "upstream proxy pending for " + chain},
})
}
}
func notImplemented(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte(`{"error":"not implemented"}`))
}
// pgxpool import retained for type aliasing in tests; suppress unused-
// import lint when the test file compiles.
var _ = pgxpool.New
+155
View File
@@ -0,0 +1,155 @@
// Package config — environment-driven application settings.
package config
import (
"os"
"strconv"
"strings"
"sync"
"time"
)
// Settings is the canonical application configuration. Loaded once at
// startup via Load() and accessed thereafter via Get().
type Settings struct {
// Application
AppName string
AppEnv string // development | staging | production
Debug bool
APIPrefix string
// Database
DatabaseURL string
DBPoolSize int
DBMaxOverflow int
// Redis
RedisURL string
// Authentication
JWTSecret string
JWTAlgorithm string
JWTExpireMinutes int
APIKeySalt string
// Hanzo IAM
IAMUrl string
IAMClientID string
IAMClientSecret string
EnableMultiTenant bool
AllowedOrgs []string
FrontendURL string
AllowedOrigins []string
// ZAP — Cap'n Proto RPC server
ZAPEnabled bool
ZAPHost string
ZAPPort int
// Observability
OTELEndpoint string
OTELServiceName string
PrometheusPort int
// Listen
HTTPHost string
HTTPPort int
// KMS
KMSURL string
KMSAuthToken string
}
var (
loaded *Settings
loadOnce sync.Once
)
// Load reads settings from environment variables exactly once and
// returns the snapshot. Subsequent calls return the cached value.
func Load() *Settings {
loadOnce.Do(func() {
loaded = &Settings{
AppName: env("APP_NAME", "Bootnode"),
AppEnv: env("APP_ENV", "development"),
Debug: envBool("DEBUG", false),
APIPrefix: env("API_PREFIX", "/v1"),
DatabaseURL: env("DATABASE_URL", "postgres://bootnode:bootnode@localhost:5432/bootnode?sslmode=disable"),
DBPoolSize: envInt("DB_POOL_SIZE", 20),
DBMaxOverflow: envInt("DB_MAX_OVERFLOW", 10),
RedisURL: env("REDIS_URL", "redis://localhost:6379/0"),
JWTSecret: env("JWT_SECRET", "change-me-in-production"),
JWTAlgorithm: env("JWT_ALGORITHM", "HS256"),
JWTExpireMinutes: envInt("JWT_EXPIRE_MINUTES", 60*24),
APIKeySalt: env("API_KEY_SALT", "change-me-in-production"),
IAMUrl: env("IAM_URL", "https://iam.hanzo.ai"),
IAMClientID: env("IAM_CLIENT_ID", ""),
IAMClientSecret: env("IAM_CLIENT_SECRET", ""),
EnableMultiTenant: envBool("ENABLE_MULTI_TENANT", true),
AllowedOrgs: envList("ALLOWED_ORGS", []string{"hanzo", "zoo", "lux", "pars"}),
FrontendURL: env("FRONTEND_URL", "http://localhost:3001"),
AllowedOrigins: envList("ALLOWED_ORIGINS", nil),
ZAPEnabled: envBool("ZAP_ENABLED", true),
ZAPHost: env("ZAP_HOST", "0.0.0.0"),
ZAPPort: envInt("ZAP_PORT", 9999),
OTELEndpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
OTELServiceName: env("OTEL_SERVICE_NAME", "bootnode"),
PrometheusPort: envInt("PROMETHEUS_PORT", 9090),
HTTPHost: env("HTTP_HOST", "0.0.0.0"),
HTTPPort: envInt("HTTP_PORT", 8000),
KMSURL: env("KMS_URL", "http://kms.hanzo.ai:8443"),
KMSAuthToken: env("KMS_AUTH_TOKEN", ""),
}
})
return loaded
}
// Get returns the loaded settings, calling Load() if necessary.
func Get() *Settings { return Load() }
// JWTTTL returns the JWT expiration as a duration.
func (s *Settings) JWTTTL() time.Duration {
return time.Duration(s.JWTExpireMinutes) * time.Minute
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
func envBool(key string, def bool) bool {
if v, ok := os.LookupEnv(key); ok {
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return def
}
func envInt(key string, def int) int {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func envList(key string, def []string) []string {
if v, ok := os.LookupEnv(key); ok && v != "" {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
return def
}
+49
View File
@@ -0,0 +1,49 @@
// Package db — Postgres pool + migration entry.
package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Pool wraps pgxpool.Pool so callers depend on this package's type and
// the underlying driver can be swapped without breaking call sites.
type Pool = pgxpool.Pool
// Open creates a connection pool from the canonical DATABASE_URL.
func Open(ctx context.Context, dsn string, poolSize, maxOverflow int) (*Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse dsn: %w", err)
}
cfg.MaxConns = int32(poolSize + maxOverflow)
cfg.MinConns = int32(poolSize / 4)
p, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
if err := p.Ping(ctx); err != nil {
p.Close()
return nil, fmt.Errorf("ping: %w", err)
}
return p, nil
}
// Init runs the schema migrations. Idempotent.
func Init(ctx context.Context, p *Pool) error {
for _, stmt := range schemaStatements {
if _, err := p.Exec(ctx, stmt); err != nil {
return fmt.Errorf("migration %q: %w", stmt[:min(len(stmt), 60)], err)
}
}
return nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+78
View File
@@ -0,0 +1,78 @@
// Package db — typed row structs corresponding to schema.go tables.
package db
import (
"time"
"github.com/google/uuid"
)
// User mirrors the users table.
type User struct {
ID uuid.UUID
Email string
Name string
PasswordHash *string
Avatar *string
Roles []string
Permissions []string
IsActive bool
LastLoginAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// Project mirrors the projects table.
type Project struct {
ID uuid.UUID
Name string
OwnerID uuid.UUID
OrgID string
Description *string
Settings map[string]any
AllowedChains []string
CreatedAt time.Time
UpdatedAt time.Time
}
// APIKey mirrors the api_keys table. KeyHash is opaque to the caller;
// the plaintext is shown once at creation and never re-emitted.
type APIKey struct {
ID uuid.UUID
ProjectID uuid.UUID
Name string
KeyHash string
KeyPrefix string
RateLimit int
ComputeUnitsLimit int
AllowedOrigins []string
AllowedChains []string
IsActive bool
LastUsedAt *time.Time
CreatedAt time.Time
}
// Webhook mirrors the webhooks table.
type Webhook struct {
ID uuid.UUID
ProjectID uuid.UUID
Name string
URL string
Secret string
Events []string
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
}
// Wallet mirrors the wallets table.
type Wallet struct {
ID uuid.UUID
ProjectID uuid.UUID
Chain string
Address string
Label *string
SmartWallet bool
CreatedAt time.Time
UpdatedAt time.Time
}
+80
View File
@@ -0,0 +1,80 @@
// Package db — schema statements applied in order at startup.
package db
// schemaStatements is the authoritative schema. Each statement is
// idempotent (CREATE IF NOT EXISTS). New columns land via additive
// ALTER TABLE statements appended at the end.
var schemaStatements = []string{
`CREATE EXTENSION IF NOT EXISTS pgcrypto`,
`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`,
`CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255),
avatar VARCHAR(500),
roles JSONB DEFAULT '["user"]',
permissions JSONB DEFAULT '["read","write"]',
is_active BOOLEAN DEFAULT TRUE,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
owner_id UUID NOT NULL,
org_id VARCHAR(100) NOT NULL DEFAULT 'hanzo',
description TEXT,
settings JSONB NOT NULL DEFAULT '{}',
allowed_chains JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_projects_org ON projects(org_id)`,
`CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id)`,
`CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
key_hash VARCHAR(255) NOT NULL UNIQUE,
key_prefix VARCHAR(12) NOT NULL,
rate_limit INTEGER NOT NULL DEFAULT 100,
compute_units_limit INTEGER NOT NULL DEFAULT 1000,
allowed_origins JSONB,
allowed_chains JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_api_keys_project ON api_keys(project_id)`,
`CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`,
`CREATE TABLE IF NOT EXISTS webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
url VARCHAR(1000) NOT NULL,
secret VARCHAR(255) NOT NULL,
events JSONB NOT NULL DEFAULT '[]',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_webhooks_project ON webhooks(project_id)`,
`CREATE TABLE IF NOT EXISTS wallets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
chain VARCHAR(64) NOT NULL,
address VARCHAR(128) NOT NULL,
label VARCHAR(255),
smart_wallet BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uq_wallets_proj_chain_addr ON wallets(project_id, chain, address)`,
}
+128
View File
@@ -0,0 +1,128 @@
// Package iam — JWT verification + claim extraction. Backed by Hanzo
// IAM today; the Verifier interface lets a test stub or a different
// backend (Auth0, Keycloak) drop in.
package iam
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Errors returned by Verify.
var (
ErrTokenMissing = errors.New("authentication required")
ErrTokenInvalid = errors.New("invalid token")
ErrTokenExpired = errors.New("token expired")
)
// Claims is the JWT body Bootnode trusts. Validated server-side
// against IAM's JWKS in the production verifier; HS256 for local-dev.
type Claims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
OrgID string `json:"org_id"`
Roles []string `json:"roles"`
Scopes []string `json:"scopes"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
jwt.RegisteredClaims
}
// Verifier validates a bearer token.
type Verifier interface {
Verify(ctx context.Context, token string) (*Claims, error)
}
// HSVerifier validates HS256 tokens using a shared secret. Used in
// local-dev; production uses JWKSVerifier against the IAM service.
type HSVerifier struct {
secret []byte
clockSkew time.Duration
}
// NewHS constructs an HMAC verifier.
func NewHS(secret string) *HSVerifier {
return &HSVerifier{secret: []byte(secret), clockSkew: 5 * time.Second}
}
// Verify implements Verifier.
func (h *HSVerifier) Verify(_ context.Context, token string) (*Claims, error) {
claims := &Claims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrTokenInvalid
}
return h.secret, nil
}, jwt.WithLeeway(h.clockSkew))
if err != nil {
switch {
case errors.Is(err, jwt.ErrTokenExpired):
return nil, ErrTokenExpired
default:
return nil, ErrTokenInvalid
}
}
if !parsed.Valid {
return nil, ErrTokenInvalid
}
return claims, nil
}
// ExtractBearer pulls the bearer token out of an Authorization header.
func ExtractBearer(r *http.Request) (string, error) {
h := r.Header.Get("Authorization")
if h == "" {
return "", ErrTokenMissing
}
parts := strings.SplitN(h, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", ErrTokenInvalid
}
if parts[1] == "" {
return "", ErrTokenInvalid
}
return parts[1], nil
}
// Middleware verifies the JWT and stores Claims on the request context.
// Requests without a token short-circuit with 401; invalid + expired
// tokens get the same response shape (no information leak about which
// of the two failed).
func Middleware(v Verifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, err := ExtractBearer(r)
if err != nil {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}
claims, err := v.Verify(r.Context(), tok)
if err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
ctx := WithClaims(r.Context(), claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// contextKey is the unexported key type for Claims storage.
type contextKey struct{}
// WithClaims attaches claims to a context.
func WithClaims(ctx context.Context, c *Claims) context.Context {
return context.WithValue(ctx, contextKey{}, c)
}
// FromContext returns the Claims bound by Middleware, or nil if absent.
func FromContext(ctx context.Context) *Claims {
c, _ := ctx.Value(contextKey{}).(*Claims)
return c
}