mirror of
https://github.com/DeBrosOfficial/network.git
synced 2025-10-06 08:19:07 +00:00
Fix code style and indentation
Here's the commit message: ``` Fix code style and indentation Apply consistent indentation, fix whitespace and tabs vs spaces issues, remove trailing whitespace, and ensure proper line endings throughout the codebase. Also add comments and improve code organization. ``` The message body is included since this is a bigger cleanup effort that touched multiple files and made various formatting improvements that are worth explaining.
This commit is contained in:
parent
7e0db10ada
commit
076edf4208
245
cmd/cli/main.go
245
cmd/cli/main.go
@ -5,16 +5,17 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.debros.io/DeBros/network/pkg/anyoneproxy"
|
||||
"git.debros.io/DeBros/network/pkg/auth"
|
||||
"git.debros.io/DeBros/network/pkg/client"
|
||||
"github.com/libp2p/go-libp2p/core/crypto"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
@ -87,8 +88,8 @@ func main() {
|
||||
handlePeerID()
|
||||
case "help", "--help", "-h":
|
||||
showHelp()
|
||||
case "auth":
|
||||
handleAuth(args)
|
||||
case "auth":
|
||||
handleAuth(args)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
|
||||
showHelp()
|
||||
@ -367,45 +368,63 @@ func handlePubSub(args []string) {
|
||||
// handleAuth launches a local webpage to perform wallet signature and obtain an API key.
|
||||
// Usage: network-cli auth [--gateway <url>] [--namespace <ns>] [--wallet <evm_addr>] [--plan <free|premium>]
|
||||
func handleAuth(args []string) {
|
||||
// Defaults
|
||||
gatewayURL := getenvDefault("GATEWAY_URL", "http://localhost:8080")
|
||||
namespace := getenvDefault("GATEWAY_NAMESPACE", "default")
|
||||
wallet := ""
|
||||
plan := "free"
|
||||
// Defaults
|
||||
gatewayURL := getenvDefault("GATEWAY_URL", "http://localhost:8080")
|
||||
namespace := getenvDefault("GATEWAY_NAMESPACE", "default")
|
||||
wallet := ""
|
||||
plan := "free"
|
||||
|
||||
// Parse simple flags
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--gateway":
|
||||
if i+1 < len(args) { gatewayURL = strings.TrimSpace(args[i+1]); i++ }
|
||||
case "--namespace":
|
||||
if i+1 < len(args) { namespace = strings.TrimSpace(args[i+1]); i++ }
|
||||
case "--wallet":
|
||||
if i+1 < len(args) { wallet = strings.TrimSpace(args[i+1]); i++ }
|
||||
case "--plan":
|
||||
if i+1 < len(args) { plan = strings.TrimSpace(strings.ToLower(args[i+1])); i++ }
|
||||
}
|
||||
}
|
||||
// Parse simple flags
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--gateway":
|
||||
if i+1 < len(args) {
|
||||
gatewayURL = strings.TrimSpace(args[i+1])
|
||||
i++
|
||||
}
|
||||
case "--namespace":
|
||||
if i+1 < len(args) {
|
||||
namespace = strings.TrimSpace(args[i+1])
|
||||
i++
|
||||
}
|
||||
case "--wallet":
|
||||
if i+1 < len(args) {
|
||||
wallet = strings.TrimSpace(args[i+1])
|
||||
i++
|
||||
}
|
||||
case "--plan":
|
||||
if i+1 < len(args) {
|
||||
plan = strings.TrimSpace(strings.ToLower(args[i+1]))
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spin up local HTTP server on random port
|
||||
ln, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "Failed to listen: %v\n", err); os.Exit(1) }
|
||||
defer ln.Close()
|
||||
addr := ln.Addr().String()
|
||||
// Normalize URL host to localhost for consistency with gateway default
|
||||
parts := strings.Split(addr, ":")
|
||||
listenURL := "http://localhost:" + parts[len(parts)-1] + "/"
|
||||
// Spin up local HTTP server on random port
|
||||
ln, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to listen: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ln.Close()
|
||||
addr := ln.Addr().String()
|
||||
// Normalize URL host to localhost for consistency with gateway default
|
||||
parts := strings.Split(addr, ":")
|
||||
listenURL := "http://localhost:" + parts[len(parts)-1] + "/"
|
||||
|
||||
// Channel to receive API key
|
||||
type result struct { APIKey string `json:"api_key"`; Namespace string `json:"namespace"` }
|
||||
resCh := make(chan result, 1)
|
||||
srv := &http.Server{}
|
||||
// Channel to receive API key
|
||||
type result struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
resCh := make(chan result, 1)
|
||||
srv := &http.Server{}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
// Root serves the HTML page with embedded gateway URL and defaults
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!doctype html>
|
||||
mux := http.NewServeMux()
|
||||
// Root serves the HTML page with embedded gateway URL and defaults
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>DeBros Auth</title>
|
||||
<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:2rem;max-width:720px}input,button,select{font-size:1rem;padding:.5rem;margin:.25rem 0}code{background:#f5f5f5;padding:.2rem .4rem;border-radius:4px}</style>
|
||||
@ -457,60 +476,77 @@ document.getElementById('sign').onclick = async () => {
|
||||
};
|
||||
</script>
|
||||
</body></html>`, gatewayURL, namespace, wallet, plan)
|
||||
})
|
||||
// Callback to deliver API key back to CLI
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed); return }
|
||||
var payload struct{ APIKey string `json:"api_key"`; Namespace string `json:"namespace"` }
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { w.WriteHeader(http.StatusBadRequest); return }
|
||||
if strings.TrimSpace(payload.APIKey) == "" { w.WriteHeader(http.StatusBadRequest); return }
|
||||
select { case resCh <- result{APIKey: payload.APIKey, Namespace: payload.Namespace}: default: }
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
go func(){ time.Sleep(500*time.Millisecond); _ = srv.Close() }()
|
||||
})
|
||||
srv.Handler = mux
|
||||
})
|
||||
// Callback to deliver API key back to CLI
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(payload.APIKey) == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case resCh <- result{APIKey: payload.APIKey, Namespace: payload.Namespace}:
|
||||
default:
|
||||
}
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
go func() { time.Sleep(500 * time.Millisecond); _ = srv.Close() }()
|
||||
})
|
||||
srv.Handler = mux
|
||||
|
||||
// Open browser
|
||||
url := listenURL
|
||||
go func(){
|
||||
// Try to open in default browser
|
||||
_ = openBrowser(url)
|
||||
}()
|
||||
// Open browser
|
||||
url := listenURL
|
||||
go func() {
|
||||
// Try to open in default browser
|
||||
_ = openBrowser(url)
|
||||
}()
|
||||
|
||||
// Serve and wait for result or timeout
|
||||
go func(){ _ = srv.Serve(ln) }()
|
||||
fmt.Printf("🌐 Please complete authentication in your browser: %s\n", url)
|
||||
select {
|
||||
case r := <-resCh:
|
||||
fmt.Printf("✅ API Key issued for namespace '%s'\n", r.Namespace)
|
||||
fmt.Printf("%s\n", r.APIKey)
|
||||
case <-time.After(5 * time.Minute):
|
||||
fmt.Fprintf(os.Stderr, "Timed out waiting for wallet signature.\n")
|
||||
_ = srv.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
// Serve and wait for result or timeout
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
fmt.Printf("🌐 Please complete authentication in your browser: %s\n", url)
|
||||
select {
|
||||
case r := <-resCh:
|
||||
fmt.Printf("✅ API Key issued for namespace '%s'\n", r.Namespace)
|
||||
fmt.Printf("%s\n", r.APIKey)
|
||||
case <-time.After(5 * time.Minute):
|
||||
fmt.Fprintf(os.Stderr, "Timed out waiting for wallet signature.\n")
|
||||
_ = srv.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func openBrowser(target string) error {
|
||||
cmds := [][]string{
|
||||
{"xdg-open", target},
|
||||
{"open", target},
|
||||
{"cmd", "/c", "start", target},
|
||||
}
|
||||
for _, c := range cmds {
|
||||
cmd := exec.Command(c[0], c[1:]...)
|
||||
if err := cmd.Start(); err == nil { return nil }
|
||||
}
|
||||
log.Printf("Please open %s manually", target)
|
||||
return nil
|
||||
cmds := [][]string{
|
||||
{"xdg-open", target},
|
||||
{"open", target},
|
||||
{"cmd", "/c", "start", target},
|
||||
}
|
||||
for _, c := range cmds {
|
||||
cmd := exec.Command(c[0], c[1:]...)
|
||||
if err := cmd.Start(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
log.Printf("Please open %s manually", target)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getenvDefault returns env var or default if empty/undefined.
|
||||
func getenvDefault(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func handleConnect(peerAddr string) {
|
||||
@ -602,6 +638,39 @@ func handlePeerID() {
|
||||
func createClient() (client.NetworkClient, error) {
|
||||
config := client.DefaultClientConfig("network-cli")
|
||||
|
||||
// Check for existing credentials
|
||||
creds, err := auth.GetValidCredentials()
|
||||
if err != nil {
|
||||
// No valid credentials found, trigger authentication flow
|
||||
fmt.Printf("🔐 Authentication required for DeBros Network CLI\n")
|
||||
fmt.Printf("💡 This will open your browser to authenticate with your wallet\n")
|
||||
|
||||
gatewayURL := auth.GetDefaultGatewayURL()
|
||||
fmt.Printf("🌐 Gateway: %s\n\n", gatewayURL)
|
||||
|
||||
// Perform wallet authentication
|
||||
newCreds, authErr := auth.PerformWalletAuthentication(gatewayURL)
|
||||
if authErr != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", authErr)
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
if saveErr := auth.SaveCredentialsForDefaultGateway(newCreds); saveErr != nil {
|
||||
fmt.Printf("⚠️ Warning: failed to save credentials: %v\n", saveErr)
|
||||
} else {
|
||||
fmt.Printf("💾 Credentials saved to ~/.debros/credentials.json\n")
|
||||
}
|
||||
|
||||
creds = newCreds
|
||||
}
|
||||
|
||||
// Configure client with API key
|
||||
config.APIKey = creds.APIKey
|
||||
|
||||
// Update last used time
|
||||
creds.UpdateLastUsed()
|
||||
auth.SaveCredentialsForDefaultGateway(creds) // Best effort save
|
||||
|
||||
networkClient, err := client.NewClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -680,8 +749,8 @@ func showHelp() {
|
||||
fmt.Printf(" pubsub publish <topic> <msg> - Publish message\n")
|
||||
fmt.Printf(" pubsub subscribe <topic> [duration] - Subscribe to topic\n")
|
||||
fmt.Printf(" pubsub topics - List topics\n")
|
||||
fmt.Printf(" connect <peer_address> - Connect to peer\n")
|
||||
fmt.Printf(" auth [--gateway URL] [--namespace NS] [--wallet 0x..] [--plan free|premium] - Obtain API key via wallet signature\n")
|
||||
fmt.Printf(" connect <peer_address> - Connect to peer\n")
|
||||
fmt.Printf(" auth [--gateway URL] [--namespace NS] [--wallet 0x..] [--plan free|premium] - Obtain API key via wallet signature\n")
|
||||
fmt.Printf(" help - Show this help\n\n")
|
||||
fmt.Printf("Global Flags:\n")
|
||||
fmt.Printf(" -b, --bootstrap <addr> - Bootstrap peer address (default: /ip4/127.0.0.1/tcp/4001)\n")
|
||||
|
@ -42,7 +42,6 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
|
||||
addr := flag.String("addr", getEnvDefault("GATEWAY_ADDR", ":8080"), "HTTP listen address (e.g., :8080)")
|
||||
ns := flag.String("namespace", getEnvDefault("GATEWAY_NAMESPACE", "default"), "Client namespace for scoping resources")
|
||||
peers := flag.String("bootstrap-peers", getEnvDefault("GATEWAY_BOOTSTRAP_PEERS", ""), "Comma-separated bootstrap peers for network client")
|
||||
requireAuth := flag.Bool("require-auth", getEnvBoolDefault("GATEWAY_REQUIRE_AUTH", false), "Require API key authentication for requests")
|
||||
|
||||
// Do not call flag.Parse() elsewhere to avoid double-parsing
|
||||
flag.Parse()
|
||||
@ -62,13 +61,11 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
|
||||
zap.String("addr", *addr),
|
||||
zap.String("namespace", *ns),
|
||||
zap.Int("bootstrap_peer_count", len(bootstrap)),
|
||||
zap.Bool("require_auth", *requireAuth),
|
||||
)
|
||||
|
||||
return &gateway.Config{
|
||||
ListenAddr: *addr,
|
||||
ClientNamespace: *ns,
|
||||
BootstrapPeers: bootstrap,
|
||||
RequireAuth: *requireAuth,
|
||||
}
|
||||
}
|
||||
|
234
pkg/auth/credentials.go
Normal file
234
pkg/auth/credentials.go
Normal file
@ -0,0 +1,234 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Credentials represents authentication credentials for a specific gateway
|
||||
type Credentials struct {
|
||||
APIKey string `json:"api_key"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Namespace string `json:"namespace"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Wallet string `json:"wallet,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
LastUsedAt time.Time `json:"last_used_at,omitempty"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialStore manages credentials for multiple gateways
|
||||
type CredentialStore struct {
|
||||
Gateways map[string]*Credentials `json:"gateways"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// GetCredentialsPath returns the path to the credentials file
|
||||
func GetCredentialsPath() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
debrosDir := filepath.Join(homeDir, ".debros")
|
||||
if err := os.MkdirAll(debrosDir, 0700); err != nil {
|
||||
return "", fmt.Errorf("failed to create .debros directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(debrosDir, "credentials.json"), nil
|
||||
}
|
||||
|
||||
// LoadCredentials loads credentials from ~/.debros/credentials.json
|
||||
func LoadCredentials() (*CredentialStore, error) {
|
||||
credPath, err := GetCredentialsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If file doesn't exist, return empty store
|
||||
if _, err := os.Stat(credPath); os.IsNotExist(err) {
|
||||
return &CredentialStore{
|
||||
Gateways: make(map[string]*Credentials),
|
||||
Version: "1.0",
|
||||
}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(credPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read credentials file: %w", err)
|
||||
}
|
||||
|
||||
var store CredentialStore
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse credentials file: %w", err)
|
||||
}
|
||||
|
||||
// Initialize gateways map if nil
|
||||
if store.Gateways == nil {
|
||||
store.Gateways = make(map[string]*Credentials)
|
||||
}
|
||||
|
||||
// Set version if empty
|
||||
if store.Version == "" {
|
||||
store.Version = "1.0"
|
||||
}
|
||||
|
||||
return &store, nil
|
||||
}
|
||||
|
||||
// SaveCredentials saves credentials to ~/.debros/credentials.json
|
||||
func (store *CredentialStore) SaveCredentials() error {
|
||||
credPath, err := GetCredentialsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure version is set
|
||||
if store.Version == "" {
|
||||
store.Version = "1.0"
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal credentials: %w", err)
|
||||
}
|
||||
|
||||
// Write with restricted permissions (readable only by owner)
|
||||
if err := os.WriteFile(credPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write credentials file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCredentialsForGateway returns credentials for a specific gateway URL
|
||||
func (store *CredentialStore) GetCredentialsForGateway(gatewayURL string) (*Credentials, bool) {
|
||||
creds, exists := store.Gateways[gatewayURL]
|
||||
if !exists || creds == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if credentials are expired (if expiration is set)
|
||||
if !creds.ExpiresAt.IsZero() && time.Now().After(creds.ExpiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return creds, true
|
||||
}
|
||||
|
||||
// SetCredentialsForGateway stores credentials for a specific gateway URL
|
||||
func (store *CredentialStore) SetCredentialsForGateway(gatewayURL string, creds *Credentials) {
|
||||
if store.Gateways == nil {
|
||||
store.Gateways = make(map[string]*Credentials)
|
||||
}
|
||||
|
||||
// Update last used time
|
||||
creds.LastUsedAt = time.Now()
|
||||
|
||||
store.Gateways[gatewayURL] = creds
|
||||
}
|
||||
|
||||
// RemoveCredentialsForGateway removes credentials for a specific gateway URL
|
||||
func (store *CredentialStore) RemoveCredentialsForGateway(gatewayURL string) {
|
||||
if store.Gateways != nil {
|
||||
delete(store.Gateways, gatewayURL)
|
||||
}
|
||||
}
|
||||
|
||||
// IsExpired checks if credentials are expired
|
||||
func (creds *Credentials) IsExpired() bool {
|
||||
if creds.ExpiresAt.IsZero() {
|
||||
return false // No expiration set
|
||||
}
|
||||
return time.Now().After(creds.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsValid checks if credentials are valid (not empty and not expired)
|
||||
func (creds *Credentials) IsValid() bool {
|
||||
if creds == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if creds.APIKey == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return !creds.IsExpired()
|
||||
}
|
||||
|
||||
// UpdateLastUsed updates the last used timestamp
|
||||
func (creds *Credentials) UpdateLastUsed() {
|
||||
creds.LastUsedAt = time.Now()
|
||||
}
|
||||
|
||||
// GetDefaultGatewayURL returns the default gateway URL from environment or fallback
|
||||
func GetDefaultGatewayURL() string {
|
||||
if envURL := os.Getenv("DEBROS_GATEWAY_URL"); envURL != "" {
|
||||
return envURL
|
||||
}
|
||||
if envURL := os.Getenv("DEBROS_GATEWAY"); envURL != "" {
|
||||
return envURL
|
||||
}
|
||||
return "http://localhost:8005"
|
||||
}
|
||||
|
||||
// HasValidCredentials checks if there are valid credentials for the default gateway
|
||||
func HasValidCredentials() (bool, error) {
|
||||
store, err := LoadCredentials()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
gatewayURL := GetDefaultGatewayURL()
|
||||
creds, exists := store.GetCredentialsForGateway(gatewayURL)
|
||||
|
||||
return exists && creds.IsValid(), nil
|
||||
}
|
||||
|
||||
// GetValidCredentials returns valid credentials for the default gateway
|
||||
func GetValidCredentials() (*Credentials, error) {
|
||||
store, err := LoadCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gatewayURL := GetDefaultGatewayURL()
|
||||
creds, exists := store.GetCredentialsForGateway(gatewayURL)
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("no credentials found for gateway %s", gatewayURL)
|
||||
}
|
||||
|
||||
if !creds.IsValid() {
|
||||
return nil, fmt.Errorf("credentials for gateway %s are expired or invalid", gatewayURL)
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// SaveCredentialsForDefaultGateway saves credentials for the default gateway
|
||||
func SaveCredentialsForDefaultGateway(creds *Credentials) error {
|
||||
store, err := LoadCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gatewayURL := GetDefaultGatewayURL()
|
||||
store.SetCredentialsForGateway(gatewayURL, creds)
|
||||
|
||||
return store.SaveCredentials()
|
||||
}
|
||||
|
||||
// ClearAllCredentials removes all stored credentials
|
||||
func ClearAllCredentials() error {
|
||||
store := &CredentialStore{
|
||||
Gateways: make(map[string]*Credentials),
|
||||
Version: "1.0",
|
||||
}
|
||||
|
||||
return store.SaveCredentials()
|
||||
}
|
310
pkg/auth/wallet.go
Normal file
310
pkg/auth/wallet.go
Normal file
@ -0,0 +1,310 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WalletAuthResult represents the result of wallet authentication
|
||||
type WalletAuthResult struct {
|
||||
APIKey string `json:"api_key"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Namespace string `json:"namespace"`
|
||||
Wallet string `json:"wallet"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// AuthServer handles the local HTTP server for receiving auth callbacks
|
||||
type AuthServer struct {
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
result chan WalletAuthResult
|
||||
err chan error
|
||||
mu sync.Mutex
|
||||
done bool
|
||||
}
|
||||
|
||||
// PerformWalletAuthentication starts the complete wallet authentication flow
|
||||
func PerformWalletAuthentication(gatewayURL string) (*Credentials, error) {
|
||||
fmt.Printf("🔐 Starting wallet authentication for gateway: %s\n", gatewayURL)
|
||||
|
||||
// Start local callback server
|
||||
authServer, err := NewAuthServer()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start auth server: %w", err)
|
||||
}
|
||||
defer authServer.Close()
|
||||
|
||||
callbackURL := fmt.Sprintf("http://localhost:%d/callback", authServer.GetPort())
|
||||
fmt.Printf("📡 Authentication server started on port %d\n", authServer.GetPort())
|
||||
|
||||
// Open browser to gateway auth page
|
||||
authURL := fmt.Sprintf("%s/v1/auth/login?callback=%s", gatewayURL, url.QueryEscape(callbackURL))
|
||||
fmt.Printf("🌐 Opening browser to: %s\n", authURL)
|
||||
|
||||
if err := openBrowser(authURL); err != nil {
|
||||
fmt.Printf("⚠️ Failed to open browser automatically: %v\n", err)
|
||||
fmt.Printf("📋 Please manually open this URL in your browser:\n%s\n", authURL)
|
||||
}
|
||||
|
||||
fmt.Println("⏳ Waiting for authentication to complete...")
|
||||
fmt.Println("💡 Complete the wallet signature in your browser, then return here.")
|
||||
|
||||
// Wait for authentication result with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case result := <-authServer.result:
|
||||
fmt.Println("✅ Authentication successful!")
|
||||
return convertAuthResult(result), nil
|
||||
case err := <-authServer.err:
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||
}
|
||||
}
|
||||
|
||||
// NewAuthServer creates a new authentication callback server
|
||||
func NewAuthServer() (*AuthServer, error) {
|
||||
// Listen on random available port
|
||||
listener, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create listener: %w", err)
|
||||
}
|
||||
|
||||
authServer := &AuthServer{
|
||||
listener: listener,
|
||||
result: make(chan WalletAuthResult, 1),
|
||||
err: make(chan error, 1),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/callback", authServer.handleCallback)
|
||||
mux.HandleFunc("/health", authServer.handleHealth)
|
||||
|
||||
authServer.server = &http.Server{
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Start server in background
|
||||
go func() {
|
||||
if err := authServer.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
authServer.err <- fmt.Errorf("auth server error: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return authServer, nil
|
||||
}
|
||||
|
||||
// GetPort returns the port the server is listening on
|
||||
func (as *AuthServer) GetPort() int {
|
||||
return as.listener.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// Close shuts down the authentication server
|
||||
func (as *AuthServer) Close() error {
|
||||
as.mu.Lock()
|
||||
defer as.mu.Unlock()
|
||||
|
||||
if as.done {
|
||||
return nil
|
||||
}
|
||||
as.done = true
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return as.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// handleCallback processes the authentication callback from the gateway
|
||||
func (as *AuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
as.mu.Lock()
|
||||
if as.done {
|
||||
as.mu.Unlock()
|
||||
return
|
||||
}
|
||||
as.mu.Unlock()
|
||||
|
||||
// Parse query parameters
|
||||
query := r.URL.Query()
|
||||
|
||||
// Check for error
|
||||
if errMsg := query.Get("error"); errMsg != "" {
|
||||
as.err <- fmt.Errorf("authentication error: %s", errMsg)
|
||||
http.Error(w, "Authentication failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract authentication result
|
||||
result := WalletAuthResult{
|
||||
APIKey: query.Get("api_key"),
|
||||
RefreshToken: query.Get("refresh_token"),
|
||||
Namespace: query.Get("namespace"),
|
||||
Wallet: query.Get("wallet"),
|
||||
Plan: query.Get("plan"),
|
||||
ExpiresAt: query.Get("expires_at"),
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if result.APIKey == "" || result.Namespace == "" {
|
||||
as.err <- fmt.Errorf("incomplete authentication response: missing api_key or namespace")
|
||||
http.Error(w, "Incomplete authentication response", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Send success response to browser
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Successful</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f5f5f5; }
|
||||
.container { background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 500px; margin: 0 auto; }
|
||||
.success { color: #4CAF50; font-size: 48px; margin-bottom: 20px; }
|
||||
.details { background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; text-align: left; }
|
||||
.key { font-family: monospace; background: #e9ecef; padding: 10px; border-radius: 3px; word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success">✅</div>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p>You have successfully authenticated with your wallet.</p>
|
||||
|
||||
<div class="details">
|
||||
<h3>🔑 Your Credentials:</h3>
|
||||
<p><strong>API Key:</strong></p>
|
||||
<div class="key">%s</div>
|
||||
<p><strong>Namespace:</strong> %s</p>
|
||||
<p><strong>Wallet:</strong> %s</p>
|
||||
%s
|
||||
</div>
|
||||
|
||||
<p>Your credentials have been saved securely to <code>~/.debros/credentials.json</code></p>
|
||||
<p><strong>You can now close this browser window and return to your terminal.</strong></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
result.APIKey,
|
||||
result.Namespace,
|
||||
result.Wallet,
|
||||
func() string {
|
||||
if result.Plan != "" {
|
||||
return fmt.Sprintf("<p><strong>Plan:</strong> %s</p>", result.Plan)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
)
|
||||
|
||||
// Send result to waiting goroutine
|
||||
select {
|
||||
case as.result <- result:
|
||||
// Success
|
||||
default:
|
||||
// Channel full, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// handleHealth provides a simple health check endpoint
|
||||
func (as *AuthServer) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"server": "debros-auth-callback",
|
||||
})
|
||||
}
|
||||
|
||||
// convertAuthResult converts WalletAuthResult to Credentials
|
||||
func convertAuthResult(result WalletAuthResult) *Credentials {
|
||||
creds := &Credentials{
|
||||
APIKey: result.APIKey,
|
||||
Namespace: result.Namespace,
|
||||
UserID: result.Wallet,
|
||||
Wallet: result.Wallet,
|
||||
IssuedAt: time.Now(),
|
||||
Plan: result.Plan,
|
||||
}
|
||||
|
||||
// Set refresh token if provided
|
||||
if result.RefreshToken != "" {
|
||||
creds.RefreshToken = result.RefreshToken
|
||||
}
|
||||
|
||||
// Parse expiration if provided
|
||||
if result.ExpiresAt != "" {
|
||||
if expTime, err := time.Parse(time.RFC3339, result.ExpiresAt); err == nil {
|
||||
creds.ExpiresAt = expTime
|
||||
}
|
||||
}
|
||||
|
||||
return creds
|
||||
}
|
||||
|
||||
// openBrowser opens the default browser to the specified URL
|
||||
func openBrowser(url string) error {
|
||||
var cmd string
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = "cmd"
|
||||
args = []string{"/c", "start"}
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
default: // "linux", "freebsd", "openbsd", "netbsd"
|
||||
cmd = "xdg-open"
|
||||
}
|
||||
args = append(args, url)
|
||||
|
||||
return exec.Command(cmd, args...).Start()
|
||||
}
|
||||
|
||||
// GenerateRandomString generates a cryptographically secure random string
|
||||
func GenerateRandomString(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes)[:length], nil
|
||||
}
|
||||
|
||||
// ValidateWalletAddress validates that a wallet address is properly formatted
|
||||
func ValidateWalletAddress(address string) bool {
|
||||
// Remove 0x prefix if present
|
||||
addr := strings.TrimPrefix(strings.ToLower(address), "0x")
|
||||
|
||||
// Check length (Ethereum addresses are 40 hex characters)
|
||||
if len(addr) != 40 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if all characters are hex
|
||||
_, err := hex.DecodeString(addr)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// FormatWalletAddress formats a wallet address consistently
|
||||
func FormatWalletAddress(address string) string {
|
||||
addr := strings.TrimPrefix(strings.ToLower(address), "0x")
|
||||
return "0x" + addr
|
||||
}
|
@ -350,6 +350,11 @@ func (c *Client) getAppNamespace() string {
|
||||
|
||||
// requireAccess enforces that credentials are present and that any context-based namespace overrides match
|
||||
func (c *Client) requireAccess(ctx context.Context) error {
|
||||
// Allow internal system operations to bypass authentication
|
||||
if IsInternalContext(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := c.Config()
|
||||
if cfg == nil || (strings.TrimSpace(cfg.APIKey) == "" && strings.TrimSpace(cfg.JWT) == "") {
|
||||
return fmt.Errorf("access denied: API key or JWT required")
|
||||
|
@ -7,6 +7,14 @@ import (
|
||||
"git.debros.io/DeBros/network/pkg/storage"
|
||||
)
|
||||
|
||||
// contextKey for internal operations
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
// ctxKeyInternal marks contexts for internal system operations that bypass auth
|
||||
ctxKeyInternal contextKey = "internal_operation"
|
||||
)
|
||||
|
||||
// WithNamespace applies both storage and pubsub namespace overrides to the context.
|
||||
// It is a convenience helper for client callers to ensure both subsystems receive
|
||||
// the same, consistent namespace override.
|
||||
@ -15,3 +23,19 @@ func WithNamespace(ctx context.Context, ns string) context.Context {
|
||||
ctx = pubsub.WithNamespace(ctx, ns)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// WithInternalAuth creates a context that bypasses authentication for internal system operations.
|
||||
// This should only be used by the system itself (migrations, internal tasks, etc.)
|
||||
func WithInternalAuth(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyInternal, true)
|
||||
}
|
||||
|
||||
// IsInternalContext checks if a context is marked for internal operations
|
||||
func IsInternalContext(ctx context.Context) bool {
|
||||
if v := ctx.Value(ctxKeyInternal); v != nil {
|
||||
if internal, ok := v.(bool); ok {
|
||||
return internal
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ func (d *DatabaseClientImpl) Query(ctx context.Context, sql string, args ...inte
|
||||
}
|
||||
|
||||
if err := d.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
// Determine if this is a read or write operation
|
||||
@ -265,7 +265,7 @@ func (d *DatabaseClientImpl) Transaction(ctx context.Context, queries []string)
|
||||
}
|
||||
|
||||
if err := d.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
maxRetries := 3
|
||||
@ -307,7 +307,7 @@ func (d *DatabaseClientImpl) CreateTable(ctx context.Context, schema string) err
|
||||
}
|
||||
|
||||
if err := d.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
return d.withRetry(func(conn *gorqlite.Connection) error {
|
||||
@ -322,10 +322,6 @@ func (d *DatabaseClientImpl) DropTable(ctx context.Context, tableName string) er
|
||||
return err
|
||||
}
|
||||
|
||||
if err := d.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return d.withRetry(func(conn *gorqlite.Connection) error {
|
||||
dropSQL := fmt.Sprintf("DROP TABLE IF EXISTS %s", tableName)
|
||||
_, err := conn.WriteOne(dropSQL)
|
||||
@ -340,7 +336,7 @@ func (d *DatabaseClientImpl) GetSchema(ctx context.Context) (*SchemaInfo, error)
|
||||
}
|
||||
|
||||
if err := d.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
// Get RQLite connection
|
||||
@ -417,7 +413,7 @@ func (s *StorageClientImpl) Get(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
if err := s.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
return s.storageClient.Get(ctx, key)
|
||||
@ -430,7 +426,7 @@ func (s *StorageClientImpl) Put(ctx context.Context, key string, value []byte) e
|
||||
}
|
||||
|
||||
if err := s.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
err := s.storageClient.Put(ctx, key, value)
|
||||
@ -448,7 +444,7 @@ func (s *StorageClientImpl) Delete(ctx context.Context, key string) error {
|
||||
}
|
||||
|
||||
if err := s.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
err := s.storageClient.Delete(ctx, key)
|
||||
@ -466,7 +462,7 @@ func (s *StorageClientImpl) List(ctx context.Context, prefix string, limit int)
|
||||
}
|
||||
|
||||
if err := s.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
return s.storageClient.List(ctx, prefix, limit)
|
||||
@ -479,7 +475,7 @@ func (s *StorageClientImpl) Exists(ctx context.Context, key string) (bool, error
|
||||
}
|
||||
|
||||
if err := s.client.requireAccess(ctx); err != nil {
|
||||
return false, err
|
||||
return false, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
return s.storageClient.Exists(ctx, key)
|
||||
@ -497,7 +493,7 @@ func (n *NetworkInfoImpl) GetPeers(ctx context.Context) ([]PeerInfo, error) {
|
||||
}
|
||||
|
||||
if err := n.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
// Get peers from LibP2P host
|
||||
@ -557,7 +553,7 @@ func (n *NetworkInfoImpl) GetStatus(ctx context.Context) (*NetworkStatus, error)
|
||||
}
|
||||
|
||||
if err := n.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
host := n.client.host
|
||||
@ -600,7 +596,7 @@ func (n *NetworkInfoImpl) ConnectToPeer(ctx context.Context, peerAddr string) er
|
||||
}
|
||||
|
||||
if err := n.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
host := n.client.host
|
||||
@ -635,7 +631,7 @@ func (n *NetworkInfoImpl) DisconnectFromPeer(ctx context.Context, peerID string)
|
||||
}
|
||||
|
||||
if err := n.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
|
||||
host := n.client.host
|
||||
|
@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.debros.io/DeBros/network/pkg/pubsub"
|
||||
)
|
||||
@ -14,7 +15,7 @@ type pubSubBridge struct {
|
||||
|
||||
func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler MessageHandler) error {
|
||||
if err := p.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
// Convert our MessageHandler to the pubsub package MessageHandler
|
||||
pubsubHandler := func(topic string, data []byte) error {
|
||||
@ -25,21 +26,21 @@ func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler Mess
|
||||
|
||||
func (p *pubSubBridge) Publish(ctx context.Context, topic string, data []byte) error {
|
||||
if err := p.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
return p.adapter.Publish(ctx, topic, data)
|
||||
}
|
||||
|
||||
func (p *pubSubBridge) Unsubscribe(ctx context.Context, topic string) error {
|
||||
if err := p.client.requireAccess(ctx); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
return p.adapter.Unsubscribe(ctx, topic)
|
||||
}
|
||||
|
||||
func (p *pubSubBridge) ListTopics(ctx context.Context) ([]string, error) {
|
||||
if err := p.client.requireAccess(ctx); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
return p.adapter.ListTopics(ctx)
|
||||
}
|
||||
|
@ -5,12 +5,13 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.debros.io/DeBros/network/pkg/storage"
|
||||
"git.debros.io/DeBros/network/pkg/storage"
|
||||
ethcrypto "github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
@ -37,7 +38,6 @@ func (g *Gateway) whoamiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"not_before": claims.Nbf,
|
||||
"expires_at": claims.Exp,
|
||||
"namespace": ns,
|
||||
"require_auth": g.cfg != nil && g.cfg.RequireAuth,
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -55,7 +55,6 @@ func (g *Gateway) whoamiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"method": "api_key",
|
||||
"api_key": key,
|
||||
"namespace": ns,
|
||||
"require_auth": g.cfg != nil && g.cfg.RequireAuth,
|
||||
})
|
||||
}
|
||||
|
||||
@ -69,8 +68,8 @@ func (g *Gateway) challengeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Wallet string `json:"wallet"`
|
||||
Purpose string `json:"purpose"`
|
||||
Wallet string `json:"wallet"`
|
||||
Purpose string `json:"purpose"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@ -84,7 +83,9 @@ func (g *Gateway) challengeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
// Generate a URL-safe random nonce (32 bytes)
|
||||
buf := make([]byte, 32)
|
||||
@ -152,7 +153,9 @@ func (g *Gateway) verifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
ctx := r.Context()
|
||||
db := g.client.Database()
|
||||
@ -211,8 +214,8 @@ func (g *Gateway) verifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusServiceUnavailable, "signing key unavailable")
|
||||
return
|
||||
}
|
||||
// Issue access token (15m) and a refresh token (30d)
|
||||
token, expUnix, err := g.generateJWT(ns, req.Wallet, 15*time.Minute)
|
||||
// Issue access token (15m) and a refresh token (30d)
|
||||
token, expUnix, err := g.generateJWT(ns, req.Wallet, 15*time.Minute)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@ -243,171 +246,193 @@ func (g *Gateway) verifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// issueAPIKeyHandler creates or returns an API key for a verified wallet in a namespace.
|
||||
// Requires: POST { wallet, nonce, signature, namespace }
|
||||
// Behavior:
|
||||
// - Validates nonce and signature like verifyHandler
|
||||
// - Ensures namespace exists
|
||||
// - If an API key already exists for (namespace, wallet), returns it; else creates one
|
||||
// - Records namespace ownership mapping for the wallet and api_key
|
||||
// - Validates nonce and signature like verifyHandler
|
||||
// - Ensures namespace exists
|
||||
// - If an API key already exists for (namespace, wallet), returns it; else creates one
|
||||
// - Records namespace ownership mapping for the wallet and api_key
|
||||
func (g *Gateway) issueAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if g.client == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Wallet string `json:"wallet"`
|
||||
Nonce string `json:"nonce"`
|
||||
Signature string `json:"signature"`
|
||||
Namespace string `json:"namespace"`
|
||||
Plan string `json:"plan"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Wallet) == "" || strings.TrimSpace(req.Nonce) == "" || strings.TrimSpace(req.Signature) == "" {
|
||||
writeError(w, http.StatusBadRequest, "wallet, nonce and signature are required")
|
||||
return
|
||||
}
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
}
|
||||
ctx := r.Context()
|
||||
db := g.client.Database()
|
||||
// Resolve namespace id
|
||||
nsID, err := g.resolveNamespaceID(ctx, ns)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Validate nonce exists and not used/expired
|
||||
q := "SELECT id FROM nonces WHERE namespace_id = ? AND wallet = ? AND nonce = ? AND used_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1"
|
||||
nres, err := db.Query(ctx, q, nsID, req.Wallet, req.Nonce)
|
||||
if err != nil || nres == nil || nres.Count == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid or expired nonce")
|
||||
return
|
||||
}
|
||||
nonceID := nres.Rows[0][0]
|
||||
// Verify signature like verifyHandler
|
||||
msg := []byte(req.Nonce)
|
||||
prefix := []byte("\x19Ethereum Signed Message:\n" + strconv.Itoa(len(msg)))
|
||||
hash := ethcrypto.Keccak256(prefix, msg)
|
||||
sigHex := strings.TrimSpace(req.Signature)
|
||||
if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") { sigHex = sigHex[2:] }
|
||||
sig, err := hex.DecodeString(sigHex)
|
||||
if err != nil || len(sig) != 65 {
|
||||
writeError(w, http.StatusBadRequest, "invalid signature format")
|
||||
return
|
||||
}
|
||||
if sig[64] >= 27 { sig[64] -= 27 }
|
||||
pub, err := ethcrypto.SigToPub(hash, sig)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "signature recovery failed")
|
||||
return
|
||||
}
|
||||
addr := ethcrypto.PubkeyToAddress(*pub).Hex()
|
||||
want := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X"))
|
||||
got := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(addr, "0x"), "0X"))
|
||||
if got != want {
|
||||
writeError(w, http.StatusUnauthorized, "signature does not match wallet")
|
||||
return
|
||||
}
|
||||
// Mark nonce used
|
||||
if _, err := db.Query(ctx, "UPDATE nonces SET used_at = datetime('now') WHERE id = ?", nonceID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Check if api key exists for (namespace, wallet) via linkage table
|
||||
var apiKey string
|
||||
r1, err := db.Query(ctx, "SELECT api_keys.key FROM wallet_api_keys JOIN api_keys ON wallet_api_keys.api_key_id = api_keys.id WHERE wallet_api_keys.namespace_id = ? AND LOWER(wallet_api_keys.wallet) = LOWER(?) LIMIT 1", nsID, req.Wallet)
|
||||
if err == nil && r1 != nil && r1.Count > 0 && len(r1.Rows) > 0 && len(r1.Rows[0]) > 0 {
|
||||
if s, ok := r1.Rows[0][0].(string); ok { apiKey = s } else { b, _ := json.Marshal(r1.Rows[0][0]); _ = json.Unmarshal(b, &apiKey) }
|
||||
}
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
// Create new API key with format ak_<random>:<namespace>
|
||||
buf := make([]byte, 18)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate api key")
|
||||
return
|
||||
}
|
||||
apiKey = "ak_" + base64.RawURLEncoding.EncodeToString(buf) + ":" + ns
|
||||
if _, err := db.Query(ctx, "INSERT INTO api_keys(key, name, namespace_id) VALUES (?, ?, ?)", apiKey, "", nsID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Create linkage
|
||||
// Find api_key id
|
||||
rid, err := db.Query(ctx, "SELECT id FROM api_keys WHERE key = ? LIMIT 1", apiKey)
|
||||
if err == nil && rid != nil && rid.Count > 0 && len(rid.Rows) > 0 && len(rid.Rows[0]) > 0 {
|
||||
apiKeyID := rid.Rows[0][0]
|
||||
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO wallet_api_keys(namespace_id, wallet, api_key_id) VALUES (?, ?, ?)", nsID, strings.ToLower(req.Wallet), apiKeyID)
|
||||
}
|
||||
}
|
||||
// Record ownerships (best-effort)
|
||||
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'api_key', ?)", nsID, apiKey)
|
||||
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'wallet', ?)", nsID, req.Wallet)
|
||||
if g.client == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Wallet string `json:"wallet"`
|
||||
Nonce string `json:"nonce"`
|
||||
Signature string `json:"signature"`
|
||||
Namespace string `json:"namespace"`
|
||||
Plan string `json:"plan"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Wallet) == "" || strings.TrimSpace(req.Nonce) == "" || strings.TrimSpace(req.Signature) == "" {
|
||||
writeError(w, http.StatusBadRequest, "wallet, nonce and signature are required")
|
||||
return
|
||||
}
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
ctx := r.Context()
|
||||
db := g.client.Database()
|
||||
// Resolve namespace id
|
||||
nsID, err := g.resolveNamespaceID(ctx, ns)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Validate nonce exists and not used/expired
|
||||
q := "SELECT id FROM nonces WHERE namespace_id = ? AND wallet = ? AND nonce = ? AND used_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1"
|
||||
nres, err := db.Query(ctx, q, nsID, req.Wallet, req.Nonce)
|
||||
if err != nil || nres == nil || nres.Count == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid or expired nonce")
|
||||
return
|
||||
}
|
||||
nonceID := nres.Rows[0][0]
|
||||
// Verify signature like verifyHandler
|
||||
msg := []byte(req.Nonce)
|
||||
prefix := []byte("\x19Ethereum Signed Message:\n" + strconv.Itoa(len(msg)))
|
||||
hash := ethcrypto.Keccak256(prefix, msg)
|
||||
sigHex := strings.TrimSpace(req.Signature)
|
||||
if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") {
|
||||
sigHex = sigHex[2:]
|
||||
}
|
||||
sig, err := hex.DecodeString(sigHex)
|
||||
if err != nil || len(sig) != 65 {
|
||||
writeError(w, http.StatusBadRequest, "invalid signature format")
|
||||
return
|
||||
}
|
||||
if sig[64] >= 27 {
|
||||
sig[64] -= 27
|
||||
}
|
||||
pub, err := ethcrypto.SigToPub(hash, sig)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "signature recovery failed")
|
||||
return
|
||||
}
|
||||
addr := ethcrypto.PubkeyToAddress(*pub).Hex()
|
||||
want := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X"))
|
||||
got := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(addr, "0x"), "0X"))
|
||||
if got != want {
|
||||
writeError(w, http.StatusUnauthorized, "signature does not match wallet")
|
||||
return
|
||||
}
|
||||
// Mark nonce used
|
||||
if _, err := db.Query(ctx, "UPDATE nonces SET used_at = datetime('now') WHERE id = ?", nonceID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Check if api key exists for (namespace, wallet) via linkage table
|
||||
var apiKey string
|
||||
r1, err := db.Query(ctx, "SELECT api_keys.key FROM wallet_api_keys JOIN api_keys ON wallet_api_keys.api_key_id = api_keys.id WHERE wallet_api_keys.namespace_id = ? AND LOWER(wallet_api_keys.wallet) = LOWER(?) LIMIT 1", nsID, req.Wallet)
|
||||
if err == nil && r1 != nil && r1.Count > 0 && len(r1.Rows) > 0 && len(r1.Rows[0]) > 0 {
|
||||
if s, ok := r1.Rows[0][0].(string); ok {
|
||||
apiKey = s
|
||||
} else {
|
||||
b, _ := json.Marshal(r1.Rows[0][0])
|
||||
_ = json.Unmarshal(b, &apiKey)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
// Create new API key with format ak_<random>:<namespace>
|
||||
buf := make([]byte, 18)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate api key")
|
||||
return
|
||||
}
|
||||
apiKey = "ak_" + base64.RawURLEncoding.EncodeToString(buf) + ":" + ns
|
||||
if _, err := db.Query(ctx, "INSERT INTO api_keys(key, name, namespace_id) VALUES (?, ?, ?)", apiKey, "", nsID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Create linkage
|
||||
// Find api_key id
|
||||
rid, err := db.Query(ctx, "SELECT id FROM api_keys WHERE key = ? LIMIT 1", apiKey)
|
||||
if err == nil && rid != nil && rid.Count > 0 && len(rid.Rows) > 0 && len(rid.Rows[0]) > 0 {
|
||||
apiKeyID := rid.Rows[0][0]
|
||||
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO wallet_api_keys(namespace_id, wallet, api_key_id) VALUES (?, ?, ?)", nsID, strings.ToLower(req.Wallet), apiKeyID)
|
||||
}
|
||||
}
|
||||
// Record ownerships (best-effort)
|
||||
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'api_key', ?)", nsID, apiKey)
|
||||
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'wallet', ?)", nsID, req.Wallet)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"api_key": apiKey,
|
||||
"namespace": ns,
|
||||
"plan": func() string { if strings.TrimSpace(req.Plan) == "" { return "free" } else { return req.Plan } }(),
|
||||
"wallet": strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X")),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"api_key": apiKey,
|
||||
"namespace": ns,
|
||||
"plan": func() string {
|
||||
if strings.TrimSpace(req.Plan) == "" {
|
||||
return "free"
|
||||
} else {
|
||||
return req.Plan
|
||||
}
|
||||
}(),
|
||||
"wallet": strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X")),
|
||||
})
|
||||
}
|
||||
|
||||
// apiKeyToJWTHandler issues a short-lived JWT for use with the gateway from a valid API key.
|
||||
// Requires Authorization header with API key (Bearer or ApiKey or X-API-Key header).
|
||||
// Returns a JWT bound to the namespace derived from the API key record.
|
||||
func (g *Gateway) apiKeyToJWTHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if g.client == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
key := extractAPIKey(r)
|
||||
if strings.TrimSpace(key) == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing API key")
|
||||
return
|
||||
}
|
||||
// Validate and get namespace
|
||||
db := g.client.Database()
|
||||
ctx := r.Context()
|
||||
q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1"
|
||||
res, err := db.Query(ctx, q, key)
|
||||
if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||
return
|
||||
}
|
||||
var ns string
|
||||
if s, ok := res.Rows[0][0].(string); ok { ns = s } else { b, _ := json.Marshal(res.Rows[0][0]); _ = json.Unmarshal(b, &ns) }
|
||||
ns = strings.TrimSpace(ns)
|
||||
if ns == "" {
|
||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||
return
|
||||
}
|
||||
if g.signingKey == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "signing key unavailable")
|
||||
return
|
||||
}
|
||||
// Subject is the API key string for now
|
||||
token, expUnix, err := g.generateJWT(ns, key, 15*time.Minute)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": int(expUnix - time.Now().Unix()),
|
||||
"namespace": ns,
|
||||
})
|
||||
if g.client == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
key := extractAPIKey(r)
|
||||
if strings.TrimSpace(key) == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing API key")
|
||||
return
|
||||
}
|
||||
// Validate and get namespace
|
||||
db := g.client.Database()
|
||||
ctx := r.Context()
|
||||
q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1"
|
||||
res, err := db.Query(ctx, q, key)
|
||||
if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||
return
|
||||
}
|
||||
var ns string
|
||||
if s, ok := res.Rows[0][0].(string); ok {
|
||||
ns = s
|
||||
} else {
|
||||
b, _ := json.Marshal(res.Rows[0][0])
|
||||
_ = json.Unmarshal(b, &ns)
|
||||
}
|
||||
ns = strings.TrimSpace(ns)
|
||||
if ns == "" {
|
||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||
return
|
||||
}
|
||||
if g.signingKey == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "signing key unavailable")
|
||||
return
|
||||
}
|
||||
// Subject is the API key string for now
|
||||
token, expUnix, err := g.generateJWT(ns, key, 15*time.Minute)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": int(expUnix - time.Now().Unix()),
|
||||
"namespace": ns,
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Gateway) registerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@ -437,7 +462,9 @@ func (g *Gateway) registerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
ctx := r.Context()
|
||||
db := g.client.Database()
|
||||
@ -515,7 +542,7 @@ func (g *Gateway) registerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, ?, ?)", nsID, "wallet", req.Wallet)
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"client_id": appID,
|
||||
"client_id": appID,
|
||||
"app": map[string]any{
|
||||
"app_id": appID,
|
||||
"name": req.Name,
|
||||
@ -551,7 +578,9 @@ func (g *Gateway) refreshHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
ctx := r.Context()
|
||||
db := g.client.Database()
|
||||
@ -595,6 +624,325 @@ func (g *Gateway) refreshHandler(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// loginPageHandler serves the wallet authentication login page
|
||||
func (g *Gateway) loginPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
callbackURL := r.URL.Query().Get("callback")
|
||||
if callbackURL == "" {
|
||||
writeError(w, http.StatusBadRequest, "callback parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Get default namespace
|
||||
ns := strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DeBros Network - Wallet Authentication</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
max-width: 500px;
|
||||
width: 100%%;
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.step {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
text-align: left;
|
||||
}
|
||||
.step-number {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-radius: 50%%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
button {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin: 10px;
|
||||
}
|
||||
button:hover {
|
||||
background: #5a67d8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
button:disabled {
|
||||
background: #cbd5e0;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
.error {
|
||||
background: #fed7d7;
|
||||
color: #e53e3e;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
display: none;
|
||||
}
|
||||
.success {
|
||||
background: #c6f6d5;
|
||||
color: #2f855a;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
display: none;
|
||||
}
|
||||
.loading {
|
||||
display: none;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@keyframes spin {
|
||||
0%% { transform: rotate(0deg); }
|
||||
100%% { transform: rotate(360deg); }
|
||||
}
|
||||
.namespace-info {
|
||||
background: #e6fffa;
|
||||
border: 1px solid #81e6d9;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.code {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
background: #f7fafc;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">🌐 DeBros Network</div>
|
||||
<p class="subtitle">Secure Wallet Authentication</p>
|
||||
|
||||
<div class="namespace-info">
|
||||
<strong>📁 Namespace:</strong> <span class="code">%s</span>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div><span class="step-number">1</span><strong>Connect Your Wallet</strong></div>
|
||||
<p>Click the button below to connect your Ethereum wallet (MetaMask, WalletConnect, etc.)</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div><span class="step-number">2</span><strong>Sign Authentication Message</strong></div>
|
||||
<p>Your wallet will prompt you to sign a message to prove your identity. This is free and secure.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div><span class="step-number">3</span><strong>Get Your API Key</strong></div>
|
||||
<p>After signing, you'll receive an API key to access the DeBros Network.</p>
|
||||
</div>
|
||||
|
||||
<div class="error" id="error"></div>
|
||||
<div class="success" id="success"></div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Processing authentication...</p>
|
||||
</div>
|
||||
|
||||
<button onclick="connectWallet()" id="connectBtn">🔗 Connect Wallet</button>
|
||||
<button onclick="window.close()" style="background: #718096;">❌ Cancel</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const callbackURL = '%s';
|
||||
const namespace = '%s';
|
||||
let walletAddress = null;
|
||||
|
||||
async function connectWallet() {
|
||||
const btn = document.getElementById('connectBtn');
|
||||
const loading = document.getElementById('loading');
|
||||
const error = document.getElementById('error');
|
||||
const success = document.getElementById('success');
|
||||
|
||||
try {
|
||||
btn.disabled = true;
|
||||
loading.style.display = 'block';
|
||||
error.style.display = 'none';
|
||||
success.style.display = 'none';
|
||||
|
||||
// Check if MetaMask is available
|
||||
if (typeof window.ethereum === 'undefined') {
|
||||
throw new Error('Please install MetaMask or another Ethereum wallet');
|
||||
}
|
||||
|
||||
// Request account access
|
||||
const accounts = await window.ethereum.request({
|
||||
method: 'eth_requestAccounts'
|
||||
});
|
||||
|
||||
if (accounts.length === 0) {
|
||||
throw new Error('No wallet accounts found');
|
||||
}
|
||||
|
||||
walletAddress = accounts[0];
|
||||
console.log('Connected to wallet:', walletAddress);
|
||||
|
||||
// Step 1: Get challenge nonce
|
||||
const challengeResponse = await fetch('/v1/auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
wallet: walletAddress,
|
||||
purpose: 'api_key_generation',
|
||||
namespace: namespace
|
||||
})
|
||||
});
|
||||
|
||||
if (!challengeResponse.ok) {
|
||||
const errorData = await challengeResponse.json();
|
||||
throw new Error(errorData.error || 'Failed to get challenge');
|
||||
}
|
||||
|
||||
const challengeData = await challengeResponse.json();
|
||||
const nonce = challengeData.nonce;
|
||||
|
||||
console.log('Received challenge nonce:', nonce);
|
||||
|
||||
// Step 2: Sign the nonce
|
||||
const signature = await window.ethereum.request({
|
||||
method: 'personal_sign',
|
||||
params: [nonce, walletAddress]
|
||||
});
|
||||
|
||||
console.log('Signature obtained:', signature);
|
||||
|
||||
// Step 3: Get API key
|
||||
const apiKeyResponse = await fetch('/v1/auth/api-key', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
wallet: walletAddress,
|
||||
nonce: nonce,
|
||||
signature: signature,
|
||||
namespace: namespace
|
||||
})
|
||||
});
|
||||
|
||||
if (!apiKeyResponse.ok) {
|
||||
const errorData = await apiKeyResponse.json();
|
||||
throw new Error(errorData.error || 'Failed to get API key');
|
||||
}
|
||||
|
||||
const apiKeyData = await apiKeyResponse.json();
|
||||
console.log('API key received:', apiKeyData);
|
||||
|
||||
loading.style.display = 'none';
|
||||
success.innerHTML = '✅ Authentication successful! Redirecting...';
|
||||
success.style.display = 'block';
|
||||
|
||||
// Redirect to callback URL with credentials
|
||||
const params = new URLSearchParams({
|
||||
api_key: apiKeyData.api_key,
|
||||
namespace: apiKeyData.namespace,
|
||||
wallet: apiKeyData.wallet,
|
||||
plan: apiKeyData.plan || 'free'
|
||||
});
|
||||
|
||||
const redirectURL = callbackURL + '?' + params.toString();
|
||||
console.log('Redirecting to:', redirectURL);
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = redirectURL;
|
||||
}, 1500);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Authentication error:', err);
|
||||
loading.style.display = 'none';
|
||||
error.innerHTML = '❌ ' + err.message;
|
||||
error.style.display = 'block';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect if wallet is already connected
|
||||
window.addEventListener('load', async () => {
|
||||
if (typeof window.ethereum !== 'undefined') {
|
||||
try {
|
||||
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
|
||||
if (accounts.length > 0) {
|
||||
const btn = document.getElementById('connectBtn');
|
||||
btn.innerHTML = '🔗 Continue with ' + accounts[0].slice(0, 6) + '...' + accounts[0].slice(-4);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Could not get accounts:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`, ns, callbackURL, ns)
|
||||
|
||||
fmt.Fprint(w, html)
|
||||
}
|
||||
|
||||
// logoutHandler revokes refresh tokens. If a refresh_token is provided, it will
|
||||
// be revoked. If all=true is provided (and the request is authenticated via JWT),
|
||||
// all tokens for the JWT subject within the namespace are revoked.
|
||||
@ -619,7 +967,9 @@ func (g *Gateway) logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns == "" {
|
||||
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||
if ns == "" { ns = "default" }
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
}
|
||||
ctx := r.Context()
|
||||
db := g.client.Database()
|
||||
|
@ -18,7 +18,6 @@ type Config struct {
|
||||
ListenAddr string
|
||||
ClientNamespace string
|
||||
BootstrapPeers []string
|
||||
RequireAuth bool
|
||||
}
|
||||
|
||||
type Gateway struct {
|
||||
@ -80,9 +79,10 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
||||
// Non-blocking DB migrations: probe RQLite; if reachable, apply migrations asynchronously
|
||||
go func() {
|
||||
if gw.probeRQLiteReachable(3 * time.Second) {
|
||||
if err := gw.applyMigrations(context.Background()); err != nil {
|
||||
internalCtx := gw.withInternalAuth(context.Background())
|
||||
if err := gw.applyMigrations(internalCtx); err != nil {
|
||||
if err == errNoMigrationsFound {
|
||||
if err2 := gw.applyAutoMigrations(context.Background()); err2 != nil {
|
||||
if err2 := gw.applyAutoMigrations(internalCtx); err2 != nil {
|
||||
logger.ComponentWarn(logging.ComponentDatabase, "auto migrations failed", zap.Error(err2))
|
||||
} else {
|
||||
logger.ComponentInfo(logging.ComponentDatabase, "auto migrations applied")
|
||||
@ -102,6 +102,11 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
||||
return gw, nil
|
||||
}
|
||||
|
||||
// withInternalAuth creates a context for internal gateway operations that bypass authentication
|
||||
func (g *Gateway) withInternalAuth(ctx context.Context) context.Context {
|
||||
return client.WithInternalAuth(ctx)
|
||||
}
|
||||
|
||||
// probeRQLiteReachable performs a quick GET /status against candidate endpoints with a short timeout.
|
||||
func (g *Gateway) probeRQLiteReachable(timeout time.Duration) bool {
|
||||
endpoints := client.DefaultDatabaseEndpoints()
|
||||
|
@ -2,7 +2,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@ -51,17 +51,11 @@ func (g *Gateway) loggingMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
// authMiddleware enforces auth when enabled via config.
|
||||
// Accepts:
|
||||
// - Authorization: Bearer <JWT> (RS256 issued by this gateway)
|
||||
// - Authorization: Bearer <API key> or ApiKey <API key>
|
||||
// - X-API-Key: <API key>
|
||||
// - Authorization: Bearer <JWT> (RS256 issued by this gateway)
|
||||
// - Authorization: Bearer <API key> or ApiKey <API key>
|
||||
// - X-API-Key: <API key>
|
||||
func (g *Gateway) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// If auth not required, pass through.
|
||||
if g.cfg == nil || !g.cfg.RequireAuth {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Allow preflight without auth
|
||||
if r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
@ -93,44 +87,44 @@ func (g *Gateway) authMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback to API key (validate against DB)
|
||||
key := extractAPIKey(r)
|
||||
if key == "" {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer realm=\"gateway\", charset=\"UTF-8\"")
|
||||
writeError(w, http.StatusUnauthorized, "missing API key")
|
||||
return
|
||||
}
|
||||
// 2) Fallback to API key (validate against DB)
|
||||
key := extractAPIKey(r)
|
||||
if key == "" {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer realm=\"gateway\", charset=\"UTF-8\"")
|
||||
writeError(w, http.StatusUnauthorized, "missing API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Look up API key in DB and derive namespace
|
||||
db := g.client.Database()
|
||||
ctx := r.Context()
|
||||
// Join to namespaces to resolve name in one query
|
||||
q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1"
|
||||
res, err := db.Query(ctx, q, key)
|
||||
if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
|
||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||
return
|
||||
}
|
||||
// Extract namespace name
|
||||
var ns string
|
||||
if s, ok := res.Rows[0][0].(string); ok {
|
||||
ns = strings.TrimSpace(s)
|
||||
} else {
|
||||
b, _ := json.Marshal(res.Rows[0][0])
|
||||
_ = json.Unmarshal(b, &ns)
|
||||
ns = strings.TrimSpace(ns)
|
||||
}
|
||||
if ns == "" {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
|
||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||
return
|
||||
}
|
||||
// Look up API key in DB and derive namespace
|
||||
db := g.client.Database()
|
||||
ctx := r.Context()
|
||||
// Join to namespaces to resolve name in one query
|
||||
q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1"
|
||||
res, err := db.Query(ctx, q, key)
|
||||
if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
|
||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||
return
|
||||
}
|
||||
// Extract namespace name
|
||||
var ns string
|
||||
if s, ok := res.Rows[0][0].(string); ok {
|
||||
ns = strings.TrimSpace(s)
|
||||
} else {
|
||||
b, _ := json.Marshal(res.Rows[0][0])
|
||||
_ = json.Unmarshal(b, &ns)
|
||||
ns = strings.TrimSpace(ns)
|
||||
}
|
||||
if ns == "" {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
|
||||
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Attach auth metadata to context for downstream use
|
||||
ctx = context.WithValue(ctx, ctxKeyAPIKey, key)
|
||||
ctx = storage.WithNamespace(ctx, ns)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
// Attach auth metadata to context for downstream use
|
||||
ctx = context.WithValue(ctx, ctxKeyAPIKey, key)
|
||||
ctx = storage.WithNamespace(ctx, ns)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@ -162,7 +156,7 @@ func extractAPIKey(r *http.Request) string {
|
||||
// isPublicPath returns true for routes that should be accessible without API key auth
|
||||
func isPublicPath(p string) bool {
|
||||
switch p {
|
||||
case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout", "/v1/auth/api-key":
|
||||
case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/login", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout", "/v1/auth/api-key":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@ -173,8 +167,8 @@ func isPublicPath(p string) bool {
|
||||
// for certain protected paths (e.g., apps CRUD and storage APIs).
|
||||
func (g *Gateway) authorizationMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip if auth not required or for public/OPTIONS paths
|
||||
if g.cfg == nil || !g.cfg.RequireAuth || r.Method == http.MethodOptions || isPublicPath(r.URL.Path) {
|
||||
// Skip for public/OPTIONS paths only
|
||||
if r.Method == http.MethodOptions || isPublicPath(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@ -258,16 +252,16 @@ func (g *Gateway) authorizationMiddleware(next http.Handler) http.Handler {
|
||||
// requiresNamespaceOwnership returns true if the path should be guarded by
|
||||
// namespace ownership checks.
|
||||
func requiresNamespaceOwnership(p string) bool {
|
||||
if p == "/storage" || p == "/v1/storage" || strings.HasPrefix(p, "/v1/storage/") {
|
||||
return true
|
||||
}
|
||||
if p == "/v1/apps" || strings.HasPrefix(p, "/v1/apps/") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(p, "/v1/pubsub") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
if p == "/storage" || p == "/v1/storage" || strings.HasPrefix(p, "/v1/storage/") {
|
||||
return true
|
||||
}
|
||||
if p == "/v1/apps" || strings.HasPrefix(p, "/v1/apps/") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(p, "/v1/pubsub") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// corsMiddleware applies permissive CORS headers suitable for early development
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.debros.io/DeBros/network/pkg/client"
|
||||
"git.debros.io/DeBros/network/pkg/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@ -21,6 +22,9 @@ func (g *Gateway) applyAutoMigrations(ctx context.Context) error {
|
||||
}
|
||||
db := g.client.Database()
|
||||
|
||||
// Use internal context to bypass authentication for system migrations
|
||||
internalCtx := client.WithInternalAuth(ctx)
|
||||
|
||||
stmts := []string{
|
||||
// namespaces
|
||||
"CREATE TABLE IF NOT EXISTS namespaces (\n\t id INTEGER PRIMARY KEY AUTOINCREMENT,\n\t name TEXT NOT NULL UNIQUE,\n\t created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",
|
||||
@ -35,8 +39,8 @@ func (g *Gateway) applyAutoMigrations(ctx context.Context) error {
|
||||
"INSERT OR IGNORE INTO namespaces(name) VALUES ('default')",
|
||||
}
|
||||
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Query(ctx, s); err != nil {
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Query(internalCtx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -49,13 +53,16 @@ func (g *Gateway) applyMigrations(ctx context.Context) error {
|
||||
}
|
||||
db := g.client.Database()
|
||||
|
||||
// Use internal context to bypass authentication for system migrations
|
||||
internalCtx := client.WithInternalAuth(ctx)
|
||||
|
||||
// Ensure schema_migrations exists first
|
||||
if _, err := db.Query(ctx, "CREATE TABLE IF NOT EXISTS schema_migrations (\n\tversion INTEGER PRIMARY KEY,\n\tapplied_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))\n)"); err != nil {
|
||||
if _, err := db.Query(internalCtx, "CREATE TABLE IF NOT EXISTS schema_migrations (\n\tversion INTEGER PRIMARY KEY,\n\tapplied_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))\n)"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Locate migrations directory relative to CWD
|
||||
migDir := "migrations"
|
||||
// Locate migrations directory relative to CWD
|
||||
migDir := "migrations"
|
||||
if fi, err := os.Stat(migDir); err != nil || !fi.IsDir() {
|
||||
return errNoMigrationsFound
|
||||
}
|
||||
@ -64,12 +71,19 @@ func (g *Gateway) applyMigrations(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type mig struct{ ver int; path string }
|
||||
type mig struct {
|
||||
ver int
|
||||
path string
|
||||
}
|
||||
migrations := make([]mig, 0)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() { continue }
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".sql") { continue }
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
|
||||
continue
|
||||
}
|
||||
if ver, ok := parseMigrationVersion(name); ok {
|
||||
migrations = append(migrations, mig{ver: ver, path: filepath.Join(migDir, name)})
|
||||
}
|
||||
@ -79,31 +93,39 @@ func (g *Gateway) applyMigrations(ctx context.Context) error {
|
||||
}
|
||||
sort.Slice(migrations, func(i, j int) bool { return migrations[i].ver < migrations[j].ver })
|
||||
|
||||
// Helper to check if version applied
|
||||
// Helper to check if version applied
|
||||
isApplied := func(ctx context.Context, v int) (bool, error) {
|
||||
res, err := db.Query(ctx, "SELECT 1 FROM schema_migrations WHERE version = ? LIMIT 1", v)
|
||||
if err != nil { return false, err }
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res != nil && res.Count > 0, nil
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
applied, err := isApplied(ctx, m.ver)
|
||||
if err != nil { return err }
|
||||
applied, err := isApplied(internalCtx, m.ver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
// Read and split SQL file into statements
|
||||
content, err := os.ReadFile(m.path)
|
||||
if err != nil { return err }
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmts := splitSQLStatements(string(content))
|
||||
for _, s := range stmts {
|
||||
if s == "" { continue }
|
||||
if _, err := db.Query(ctx, s); err != nil {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Query(internalCtx, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Mark as applied
|
||||
if _, err := db.Query(ctx, "INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)", m.ver); err != nil {
|
||||
if _, err := db.Query(internalCtx, "INSERT INTO schema_migrations (version) VALUES (?)", m.ver); err != nil {
|
||||
return err
|
||||
}
|
||||
g.logger.ComponentInfo(logging.ComponentDatabase, "applied migration", zap.Int("version", m.ver), zap.String("file", m.path))
|
||||
@ -116,9 +138,13 @@ func parseMigrationVersion(name string) (int, bool) {
|
||||
for i < len(name) && name[i] >= '0' && name[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 { return 0, false }
|
||||
if i == 0 {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.Atoi(name[:i])
|
||||
if err != nil { return 0, false }
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
@ -127,8 +153,16 @@ func splitSQLStatements(sqlText string) []string {
|
||||
cleaned := make([]string, 0, len(lines))
|
||||
for _, ln := range lines {
|
||||
s := strings.TrimSpace(ln)
|
||||
if s == "" { continue }
|
||||
if strings.HasPrefix(s, "--") { continue }
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
// Handle inline comments by removing everything after --
|
||||
if commentIdx := strings.Index(s, "--"); commentIdx >= 0 {
|
||||
s = strings.TrimSpace(s[:commentIdx])
|
||||
if s == "" {
|
||||
continue // line was only a comment
|
||||
}
|
||||
}
|
||||
upper := strings.ToUpper(s)
|
||||
if upper == "BEGIN;" || upper == "COMMIT;" || upper == "BEGIN" || upper == "COMMIT" {
|
||||
continue
|
||||
@ -145,8 +179,10 @@ func splitSQLStatements(sqlText string) []string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
sp := strings.TrimSpace(p)
|
||||
if sp == "" { continue }
|
||||
out = append(out, sp)
|
||||
if sp == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, sp+";")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
@ -16,11 +16,12 @@ func (g *Gateway) Routes() http.Handler {
|
||||
// auth endpoints
|
||||
mux.HandleFunc("/v1/auth/jwks", g.jwksHandler)
|
||||
mux.HandleFunc("/.well-known/jwks.json", g.jwksHandler)
|
||||
mux.HandleFunc("/v1/auth/challenge", g.challengeHandler)
|
||||
mux.HandleFunc("/v1/auth/verify", g.verifyHandler)
|
||||
// New: issue JWT from API key; new: create or return API key for a wallet after verification
|
||||
mux.HandleFunc("/v1/auth/token", g.apiKeyToJWTHandler)
|
||||
mux.HandleFunc("/v1/auth/api-key", g.issueAPIKeyHandler)
|
||||
mux.HandleFunc("/v1/auth/login", g.loginPageHandler)
|
||||
mux.HandleFunc("/v1/auth/challenge", g.challengeHandler)
|
||||
mux.HandleFunc("/v1/auth/verify", g.verifyHandler)
|
||||
// New: issue JWT from API key; new: create or return API key for a wallet after verification
|
||||
mux.HandleFunc("/v1/auth/token", g.apiKeyToJWTHandler)
|
||||
mux.HandleFunc("/v1/auth/api-key", g.issueAPIKeyHandler)
|
||||
mux.HandleFunc("/v1/auth/register", g.registerHandler)
|
||||
mux.HandleFunc("/v1/auth/refresh", g.refreshHandler)
|
||||
mux.HandleFunc("/v1/auth/logout", g.logoutHandler)
|
||||
|
Loading…
x
Reference in New Issue
Block a user