mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
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:
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"}`))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)`,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user