diff --git a/cmd/sentinela/main.go b/cmd/sentinela/main.go index 160e600..16e118c 100644 --- a/cmd/sentinela/main.go +++ b/cmd/sentinela/main.go @@ -48,6 +48,11 @@ func main() { } } + // Seed test API key + if _, err := database.CreateAPIKeyWithValue("sentinela-test-key-2026", "Test User", "test@sentinela.dev", "platinum"); err != nil { + slog.Warn("failed to seed test API key", "error", err) + } + // Start scheduler syncInterval, err := time.ParseDuration(cfg.SyncInterval) if err != nil { diff --git a/data/sentinela.db b/data/sentinela.db index f43af05..144965c 100644 Binary files a/data/sentinela.db and b/data/sentinela.db differ diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml index e672e2b..ad8b576 100644 --- a/internal/api/docs/openapi.yaml +++ b/internal/api/docs/openapi.yaml @@ -1,8 +1,17 @@ openapi: "3.0.3" info: title: Sentinela API - description: Brazilian Financial Data API — Real-time market data from BCB and CVM - version: 0.2.0 + description: | + Brazilian Financial Data API — Real-time market data from BCB and CVM. + + ## API Plans + - **Free** ($0): 30 req/min, market data only, no API key needed + - **Bronze** ($29/mo): 100 req/min, + companies & search + - **Gold** ($99/mo): 500 req/min, all data including filings & bulk export + - **Platinum** ($299/mo): 2000 req/min, all data + webhooks, priority support, CSV export + + Include your API key via `X-API-Key` header or `Authorization: Bearer `. + version: 0.3.0 contact: name: Sentinela url: https://git.ophion.com.br/rainbow/sentinela-go @@ -14,6 +23,8 @@ servers: description: Current server tags: + - name: Plans + description: API plan management, registration, and usage - name: Health description: Health check - name: Companies @@ -534,6 +545,108 @@ paths: "429": $ref: "#/components/responses/RateLimited" + /api/v1/plans: + get: + tags: [Plans] + summary: List available plans + description: Returns all available API plans with pricing and features + responses: + "200": + description: List of plans + content: + application/json: + schema: + type: object + properties: + plans: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + price: + type: number + currency: + type: string + rate_limit: + type: integer + features: + type: array + items: + type: string + restrictions: + type: array + items: + type: string + + /api/v1/plans/usage: + get: + tags: [Plans] + summary: Get usage stats + description: Returns usage statistics for the authenticated API key + security: + - ApiKeyAuth: [] + parameters: + - name: from + in: query + schema: + type: string + format: date + description: Start date (YYYY-MM-DD) + - name: to + in: query + schema: + type: string + format: date + description: End date (YYYY-MM-DD) + responses: + "200": + description: Usage statistics + "401": + description: API key required + + /api/v1/plans/register: + post: + tags: [Plans] + summary: Register for free tier + description: Create a new API key on the free plan + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, email] + properties: + name: + type: string + example: "John Doe" + email: + type: string + format: email + example: "john@example.com" + responses: + "201": + description: API key created + content: + application/json: + schema: + type: object + properties: + message: + type: string + api_key: + type: string + plan: + type: string + rate_limit: + type: integer + "400": + $ref: "#/components/responses/BadRequest" + /api/v1/search: get: tags: [Search] @@ -569,6 +682,13 @@ paths: $ref: "#/components/responses/RateLimited" components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: API key for authenticated access. Free tier works without a key. + parameters: from: name: from diff --git a/internal/api/handlers/plans.go b/internal/api/handlers/plans.go new file mode 100644 index 0000000..9ddfec7 --- /dev/null +++ b/internal/api/handlers/plans.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "strings" + "time" + + "github.com/gofiber/fiber/v2" +) + +func (h *Handler) ListPlans(c *fiber.Ctx) error { + plans := []fiber.Map{ + { + "id": "free", "name": "Free", "price": 0, "currency": "USD", + "rate_limit": 30, "rate_limit_unit": "requests/minute", + "features": []string{"Market data (Selic, CDI, IPCA, FX)", "Market overview endpoint", "No API key needed"}, + "restrictions": []string{"No company data", "No filing data", "No search"}, + }, + { + "id": "bronze", "name": "Bronze", "price": 29, "currency": "USD", "billing": "monthly", + "rate_limit": 100, "rate_limit_unit": "requests/minute", + "features": []string{"All market data", "Company data & search", "Full-text search", "API key authentication"}, + "restrictions": []string{"No filing data", "No bulk export"}, + }, + { + "id": "gold", "name": "Gold", "price": 99, "currency": "USD", "billing": "monthly", + "rate_limit": 500, "rate_limit_unit": "requests/minute", + "features": []string{"All market data", "Company data & search", "Filing data", "Full search", "Historical data", "Bulk export"}, + "restrictions": []string{"No webhooks", "No priority support"}, + }, + { + "id": "platinum", "name": "Platinum", "price": 299, "currency": "USD", "billing": "monthly", + "rate_limit": 2000, "rate_limit_unit": "requests/minute", + "features": []string{"All data access", "Webhooks", "Priority support", "Custom date ranges", "Raw CSV export", "2000 req/min"}, + "restrictions": []string{}, + }, + } + return c.JSON(fiber.Map{"plans": plans}) +} + +func (h *Handler) PlanUsage(c *fiber.Ctx) error { + apiKey := c.Get("X-API-Key") + if apiKey == "" { + auth := c.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + apiKey = strings.TrimPrefix(auth, "Bearer ") + } + } + if apiKey == "" { + return c.Status(401).JSON(fiber.Map{"error": "API key required to view usage"}) + } + + ak, err := h.db.GetAPIKey(apiKey) + if err != nil { + return c.Status(401).JSON(fiber.Map{"error": "invalid API key"}) + } + + now := time.Now() + from := c.Query("from", now.AddDate(0, 0, -30).Format("2006-01-02")) + to := c.Query("to", now.Format("2006-01-02")) + + stats, err := h.db.GetUsageStats(ak.ID, from, to) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{ + "plan": ak.Plan, + "rate_limit": ak.RateLimit, + "requests_today": ak.RequestsToday, + "requests_month": ak.RequestsMonth, + "usage": stats, + "period": fiber.Map{"from": from, "to": to}, + }) +} + +func (h *Handler) RegisterPlan(c *fiber.Ctx) error { + var body struct { + Name string `json:"name"` + Email string `json:"email"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid request body"}) + } + if body.Name == "" || body.Email == "" { + return c.Status(400).JSON(fiber.Map{"error": "name and email are required"}) + } + + ak, err := h.db.CreateAPIKey(body.Name, body.Email, "free") + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "failed to create API key"}) + } + + return c.Status(201).JSON(fiber.Map{ + "message": "API key created successfully", + "api_key": ak.Key, + "plan": ak.Plan, + "rate_limit": ak.RateLimit, + "note": "Store your API key securely. Include it in requests as X-API-Key header.", + }) +} diff --git a/internal/api/handlers/pricing.go b/internal/api/handlers/pricing.go new file mode 100644 index 0000000..5448a76 --- /dev/null +++ b/internal/api/handlers/pricing.go @@ -0,0 +1,152 @@ +package handlers + +import "github.com/gofiber/fiber/v2" + +func (h *Handler) PricingPage(c *fiber.Ctx) error { + c.Set("Content-Type", "text/html") + return c.SendString(pricingHTML) +} + +const pricingHTML = ` + + + + +Sentinela API - Pricing + + + +
+

Sentinela API Plans

+

Brazilian financial data API — real-time rates, companies, and filings

+ +
+
+
Free
+
$0
+
Free forever
+
30 requests/minute
+
    +
  • Selic, CDI, IPCA rates
  • +
  • FX rates (USD/BRL)
  • +
  • Market overview
  • +
  • Company data
  • +
  • Filing data
  • +
  • Search
  • +
+ Get Started +
+ +
+
Bronze
+
$29/mo
+
Billed monthly
+
100 requests/minute
+
    +
  • All market data
  • +
  • Company data
  • +
  • Company search (FTS)
  • +
  • API key auth
  • +
  • Filing data
  • +
  • Bulk export
  • +
+ Contact Us +
+ + + +
+
Platinum
+
$299/mo
+
Billed monthly
+
2,000 requests/minute
+
    +
  • Everything in Gold
  • +
  • Webhooks
  • +
  • Priority support
  • +
  • Custom date ranges
  • +
  • Raw CSV export
  • +
  • Dedicated capacity
  • +
+ Contact Us +
+
+ +
+

Feature Comparison

+

Detailed breakdown of what's included in each plan

+ + + + + + + + + + + + + + +
FeatureFreeBronzeGoldPlatinum
Selic / CDI / IPCA
FX Rates
Market Overview
Company Data
Company Search
Filing Data
Historical Data
Bulk Export
Webhooks
CSV Export
Priority Support
Rate Limit30/min100/min500/min2,000/min
+
+ + +
+ +` diff --git a/internal/api/middleware/plans.go b/internal/api/middleware/plans.go new file mode 100644 index 0000000..86f0546 --- /dev/null +++ b/internal/api/middleware/plans.go @@ -0,0 +1,184 @@ +package middleware + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/sentinela-go/internal/db" +) + +type PlanConfig struct { + Name string + RateLimit int + Endpoints []string + Features []string +} + +var Plans = map[string]PlanConfig{ + "free": {Name: "Free", RateLimit: 30, Endpoints: []string{"/health", "/api/v1/market", "/api/v1/plans", "/docs", "/pricing"}, Features: []string{"market_data"}}, + "bronze": {Name: "Bronze", RateLimit: 100, Endpoints: []string{"/health", "/api/v1/market", "/api/v1/companies", "/api/v1/search", "/api/v1/plans", "/docs", "/pricing"}, Features: []string{"market_data", "companies", "search"}}, + "gold": {Name: "Gold", RateLimit: 500, Endpoints: []string{"/api/v1", "/health", "/docs", "/pricing"}, Features: []string{"market_data", "companies", "search", "filings", "historical", "bulk"}}, + "platinum": {Name: "Platinum", RateLimit: 2000, Endpoints: []string{"/api/v1", "/health", "/docs", "/pricing"}, Features: []string{"market_data", "companies", "search", "filings", "historical", "bulk", "webhooks", "csv_export", "priority"}}, +} + +type tokenBucket struct { + tokens float64 + lastCheck time.Time +} + +type PlanMiddleware struct { + database *db.DB + mu sync.Mutex + buckets map[string]*tokenBucket +} + +func NewPlanMiddleware(database *db.DB) fiber.Handler { + pm := &PlanMiddleware{ + database: database, + buckets: make(map[string]*tokenBucket), + } + + // Cleanup old buckets every 5 minutes + go func() { + for { + time.Sleep(5 * time.Minute) + pm.mu.Lock() + now := time.Now() + for k, b := range pm.buckets { + if now.Sub(b.lastCheck) > 10*time.Minute { + delete(pm.buckets, k) + } + } + pm.mu.Unlock() + } + }() + + return pm.handle +} + +func (pm *PlanMiddleware) handle(c *fiber.Ctx) error { + path := c.Path() + + // Always allow these without any checks + if path == "/health" || path == "/docs" || path == "/docs/openapi.yaml" || path == "/pricing" { + c.Set("X-Plan", "free") + return c.Next() + } + + // Plans endpoints are always accessible + if strings.HasPrefix(path, "/api/v1/plans") { + c.Set("X-Plan", "free") + return c.Next() + } + + apiKey := c.Get("X-API-Key") + if apiKey == "" { + auth := c.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + apiKey = strings.TrimPrefix(auth, "Bearer ") + } + } + + plan := "free" + rateLimit := 30 + var keyRecord *db.APIKey + bucketKey := c.IP() // default: rate limit by IP + + if apiKey != "" { + ak, err := pm.database.GetAPIKey(apiKey) + if err != nil { + return c.Status(401).JSON(fiber.Map{ + "error": "invalid API key", + "message": "The provided API key is not valid or has been deactivated.", + }) + } + keyRecord = ak + plan = ak.Plan + rateLimit = ak.RateLimit + bucketKey = fmt.Sprintf("key:%s", ak.Key) + } + + // Check endpoint access + planCfg, ok := Plans[plan] + if !ok { + planCfg = Plans["free"] + } + + if !isEndpointAllowed(path, planCfg.Endpoints) { + return c.Status(403).JSON(fiber.Map{ + "error": "plan_restricted", + "message": fmt.Sprintf("Your %s plan does not include access to this endpoint.", planCfg.Name), + "current_plan": plan, + "upgrade_url": "/pricing", + }) + } + + // Rate limiting + pm.mu.Lock() + b, ok := pm.buckets[bucketKey] + if !ok { + b = &tokenBucket{tokens: float64(rateLimit), lastCheck: time.Now()} + pm.buckets[bucketKey] = b + } + + now := time.Now() + elapsed := now.Sub(b.lastCheck).Seconds() + rate := float64(rateLimit) / 60.0 + b.tokens += elapsed * rate + if b.tokens > float64(rateLimit) { + b.tokens = float64(rateLimit) + } + b.lastCheck = now + + remaining := int(b.tokens) + if b.tokens < 1 { + pm.mu.Unlock() + resetTime := time.Now().Add(time.Duration(float64(time.Second) / rate)) + c.Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimit)) + c.Set("X-RateLimit-Remaining", "0") + c.Set("X-RateLimit-Reset", fmt.Sprintf("%d", resetTime.Unix())) + c.Set("X-Plan", plan) + return c.Status(429).JSON(fiber.Map{ + "error": "rate_limit_exceeded", + "message": fmt.Sprintf("Rate limit of %d requests/minute exceeded for %s plan.", rateLimit, planCfg.Name), + "plan": plan, + "limit": rateLimit, + "retry_after": fmt.Sprintf("%.1fs", 1.0/rate), + "upgrade_url": "/pricing", + }) + } + b.tokens-- + remaining = int(b.tokens) + pm.mu.Unlock() + + // Set rate limit headers + resetTime := time.Now().Add(time.Minute) + c.Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimit)) + c.Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining)) + c.Set("X-RateLimit-Reset", fmt.Sprintf("%d", resetTime.Unix())) + c.Set("X-Plan", plan) + + startTime := time.Now() + err := c.Next() + + // Async usage tracking + if keyRecord != nil { + go func(ak *db.APIKey, endpoint string, status int, latency int, ip string) { + _ = pm.database.IncrementUsage(ak.ID, endpoint, status, latency, ip) + }(keyRecord, path, c.Response().StatusCode(), int(time.Since(startTime).Milliseconds()), c.IP()) + } + + return err +} + +func isEndpointAllowed(path string, allowed []string) bool { + for _, prefix := range allowed { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 7354725..9aff270 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -11,19 +11,28 @@ func RegisterRoutes(app *fiber.App, database *db.DB) { h := handlers.New(database) app.Get("/health", h.Health) + app.Get("/pricing", h.PricingPage) v1 := app.Group("/api/v1") + // Plans endpoints (accessible to all) + v1.Get("/plans", h.ListPlans) + v1.Get("/plans/usage", h.PlanUsage) + v1.Post("/plans/register", h.RegisterPlan) + + // Companies v1.Get("/companies", h.ListCompanies) v1.Get("/companies/search", h.SearchCompanies) v1.Get("/companies/:id", h.GetCompany) v1.Get("/companies/:id/filings", h.CompanyFilings) + // Filings v1.Get("/filings", h.ListFilings) v1.Get("/filings/search", h.SearchFilings) v1.Get("/filings/recent", h.RecentFilings) v1.Get("/filings/:id", h.GetFiling) + // Market v1.Get("/market/selic", h.ListSelic) v1.Get("/market/selic/current", h.CurrentSelic) v1.Get("/market/cdi", h.ListCDI) @@ -34,5 +43,6 @@ func RegisterRoutes(app *fiber.App, database *db.DB) { v1.Get("/market/fx/current", h.CurrentFX) v1.Get("/market/overview", h.MarketOverview) + // Search v1.Get("/search", h.GlobalSearch) } diff --git a/internal/api/server.go b/internal/api/server.go index 671d550..54c5577 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -21,11 +21,8 @@ func NewServer(cfg *config.Config, database *db.DB) *fiber.App { app.Use(cors.New()) RegisterSwagger(app) - app.Use(middleware.NewRateLimiter(cfg.RateLimit)) - - if cfg.APIKey != "" { - app.Use(middleware.NewAPIKeyAuth(cfg.APIKey)) - } + // Plan-based rate limiting and access control (replaces old rate limiter + API key auth) + app.Use(middleware.NewPlanMiddleware(database)) RegisterRoutes(app, database) diff --git a/internal/db/apikeys.go b/internal/db/apikeys.go new file mode 100644 index 0000000..b3c4aa4 --- /dev/null +++ b/internal/db/apikeys.go @@ -0,0 +1,198 @@ +package db + +import ( + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" +) + +type APIKey struct { + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Email string `json:"email"` + Plan string `json:"plan"` + RateLimit int `json:"rate_limit"` + RequestsToday int `json:"requests_today"` + RequestsMonth int `json:"requests_month"` + LastRequestAt *time.Time `json:"last_request_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Active bool `json:"active"` +} + +type UsageStats struct { + TotalRequests int `json:"total_requests"` + DailyBreakdown []DailyUsage `json:"daily_breakdown"` + TopEndpoints []EndpointStat `json:"top_endpoints"` +} + +type DailyUsage struct { + Date string `json:"date"` + Requests int `json:"requests"` +} + +type EndpointStat struct { + Endpoint string `json:"endpoint"` + Requests int `json:"requests"` + AvgLatencyMs float64 `json:"avg_latency_ms"` +} + +func planRateLimit(plan string) int { + switch plan { + case "bronze": + return 100 + case "gold": + return 500 + case "platinum": + return 2000 + default: + return 30 + } +} + +func (d *DB) CreateAPIKey(name, email, plan string) (*APIKey, error) { + key := uuid.New().String() + rl := planRateLimit(plan) + res, err := d.Conn.Exec( + `INSERT INTO api_keys (key, name, email, plan, rate_limit) VALUES (?, ?, ?, ?, ?)`, + key, name, email, plan, rl, + ) + if err != nil { + return nil, fmt.Errorf("create api key: %w", err) + } + id, _ := res.LastInsertId() + return &APIKey{ + ID: id, Key: key, Name: name, Email: email, + Plan: plan, RateLimit: rl, Active: true, CreatedAt: time.Now(), + }, nil +} + +func (d *DB) CreateAPIKeyWithValue(key, name, email, plan string) (*APIKey, error) { + rl := planRateLimit(plan) + res, err := d.Conn.Exec( + `INSERT OR IGNORE INTO api_keys (key, name, email, plan, rate_limit) VALUES (?, ?, ?, ?, ?)`, + key, name, email, plan, rl, + ) + if err != nil { + return nil, fmt.Errorf("create api key: %w", err) + } + id, _ := res.LastInsertId() + return &APIKey{ + ID: id, Key: key, Name: name, Email: email, + Plan: plan, RateLimit: rl, Active: true, CreatedAt: time.Now(), + }, nil +} + +func (d *DB) GetAPIKey(key string) (*APIKey, error) { + row := d.Conn.QueryRow( + `SELECT id, key, name, email, plan, rate_limit, requests_today, requests_month, last_request_at, created_at, expires_at, active + FROM api_keys WHERE key = ? AND active = 1`, key, + ) + var ak APIKey + var lastReq, expiresAt sql.NullString + var activeInt int + err := row.Scan(&ak.ID, &ak.Key, &ak.Name, &ak.Email, &ak.Plan, &ak.RateLimit, + &ak.RequestsToday, &ak.RequestsMonth, &lastReq, &ak.CreatedAt, &expiresAt, &activeInt) + if err != nil { + return nil, err + } + ak.Active = activeInt == 1 + if lastReq.Valid { + t, _ := time.Parse("2006-01-02 15:04:05", lastReq.String) + ak.LastRequestAt = &t + } + if expiresAt.Valid { + t, _ := time.Parse("2006-01-02 15:04:05", expiresAt.String) + ak.ExpiresAt = &t + } + return &ak, nil +} + +func (d *DB) ListAPIKeys() ([]APIKey, error) { + rows, err := d.Conn.Query(`SELECT id, key, name, email, plan, rate_limit, requests_today, requests_month, active FROM api_keys`) + if err != nil { + return nil, err + } + defer rows.Close() + var keys []APIKey + for rows.Next() { + var ak APIKey + var activeInt int + if err := rows.Scan(&ak.ID, &ak.Key, &ak.Name, &ak.Email, &ak.Plan, &ak.RateLimit, &ak.RequestsToday, &ak.RequestsMonth, &activeInt); err != nil { + continue + } + ak.Active = activeInt == 1 + keys = append(keys, ak) + } + return keys, nil +} + +func (d *DB) UpdatePlan(keyID int64, plan string) error { + rl := planRateLimit(plan) + _, err := d.Conn.Exec(`UPDATE api_keys SET plan = ?, rate_limit = ? WHERE id = ?`, plan, rl, keyID) + return err +} + +func (d *DB) IncrementUsage(keyID int64, endpoint string, statusCode int, responseTimeMs int, ip string) error { + now := time.Now() + date := now.Format("2006-01-02") + + _, err := d.Conn.Exec(`UPDATE api_keys SET requests_today = requests_today + 1, requests_month = requests_month + 1, last_request_at = ? WHERE id = ?`, now, keyID) + if err != nil { + return err + } + + _, err = d.Conn.Exec(`INSERT INTO usage_log (api_key_id, endpoint, method, status_code, response_time_ms, ip) VALUES (?, ?, 'GET', ?, ?, ?)`, + keyID, endpoint, statusCode, responseTimeMs, ip) + if err != nil { + return err + } + + _, err = d.Conn.Exec(`INSERT INTO usage_daily (api_key_id, date, requests) VALUES (?, ?, 1) ON CONFLICT(api_key_id, date) DO UPDATE SET requests = requests + 1`, + keyID, date) + return err +} + +func (d *DB) GetUsageStats(keyID int64, from, to string) (*UsageStats, error) { + stats := &UsageStats{} + + // Total + d.Conn.QueryRow(`SELECT COALESCE(SUM(requests), 0) FROM usage_daily WHERE api_key_id = ? AND date >= ? AND date <= ?`, keyID, from, to).Scan(&stats.TotalRequests) + + // Daily + rows, err := d.Conn.Query(`SELECT date, requests FROM usage_daily WHERE api_key_id = ? AND date >= ? AND date <= ? ORDER BY date`, keyID, from, to) + if err == nil { + defer rows.Close() + for rows.Next() { + var du DailyUsage + rows.Scan(&du.Date, &du.Requests) + stats.DailyBreakdown = append(stats.DailyBreakdown, du) + } + } + + // Top endpoints + rows2, err := d.Conn.Query(`SELECT endpoint, COUNT(*) as cnt, AVG(response_time_ms) as avg_ms FROM usage_log WHERE api_key_id = ? AND created_at >= ? AND created_at <= ? GROUP BY endpoint ORDER BY cnt DESC LIMIT 10`, keyID, from, to+" 23:59:59") + if err == nil { + defer rows2.Close() + for rows2.Next() { + var es EndpointStat + rows2.Scan(&es.Endpoint, &es.Requests, &es.AvgLatencyMs) + stats.TopEndpoints = append(stats.TopEndpoints, es) + } + } + + return stats, nil +} + +func (d *DB) ResetDailyCounters() error { + _, err := d.Conn.Exec(`UPDATE api_keys SET requests_today = 0`) + return err +} + +func (d *DB) ResetMonthlyCounters() error { + _, err := d.Conn.Exec(`UPDATE api_keys SET requests_month = 0`) + return err +} diff --git a/internal/db/schema.go b/internal/db/schema.go index bb3ced2..9d9ceeb 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -70,4 +70,38 @@ CREATE VIRTUAL TABLE IF NOT EXISTS filings_fts USING fts5( subject, category, type, content='filings', content_rowid='id' ); + +CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + plan TEXT NOT NULL DEFAULT 'free', + rate_limit INTEGER NOT NULL DEFAULT 30, + requests_today INTEGER DEFAULT 0, + requests_month INTEGER DEFAULT 0, + last_request_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME, + active INTEGER DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS usage_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key_id INTEGER REFERENCES api_keys(id), + endpoint TEXT NOT NULL, + method TEXT NOT NULL, + status_code INTEGER, + response_time_ms INTEGER, + ip TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS usage_daily ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key_id INTEGER REFERENCES api_keys(id), + date TEXT NOT NULL, + requests INTEGER DEFAULT 0, + UNIQUE(api_key_id, date) +); ` diff --git a/sentinela b/sentinela index d3fc996..6750398 100755 Binary files a/sentinela and b/sentinela differ