feat: tiered API plans (Free/Bronze/Gold/Platinum)
- Free: $0, 30 req/min, market data only - Bronze: $29/mo, 100 req/min, + companies & search - Gold: $99/mo, 500 req/min, + filings & historical - Platinum: $299/mo, 2000 req/min, all features + priority - Plan-aware rate limiting per API key (or per IP for free) - Usage tracking with daily aggregation - GET /api/v1/plans — plan listing - POST /api/v1/plans/register — instant free API key - GET /api/v1/plans/usage — usage stats - /pricing — dark-themed HTML pricing page - X-RateLimit-* and X-Plan headers on every response - Restricted endpoints return upgrade prompt - Updated OpenAPI spec with security scheme - 53 handlers, compiles clean
This commit is contained in:
@@ -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
|
// Start scheduler
|
||||||
syncInterval, err := time.ParseDuration(cfg.SyncInterval)
|
syncInterval, err := time.ParseDuration(cfg.SyncInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Binary file not shown.
@@ -1,8 +1,17 @@
|
|||||||
openapi: "3.0.3"
|
openapi: "3.0.3"
|
||||||
info:
|
info:
|
||||||
title: Sentinela API
|
title: Sentinela API
|
||||||
description: Brazilian Financial Data API — Real-time market data from BCB and CVM
|
description: |
|
||||||
version: 0.2.0
|
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 <key>`.
|
||||||
|
version: 0.3.0
|
||||||
contact:
|
contact:
|
||||||
name: Sentinela
|
name: Sentinela
|
||||||
url: https://git.ophion.com.br/rainbow/sentinela-go
|
url: https://git.ophion.com.br/rainbow/sentinela-go
|
||||||
@@ -14,6 +23,8 @@ servers:
|
|||||||
description: Current server
|
description: Current server
|
||||||
|
|
||||||
tags:
|
tags:
|
||||||
|
- name: Plans
|
||||||
|
description: API plan management, registration, and usage
|
||||||
- name: Health
|
- name: Health
|
||||||
description: Health check
|
description: Health check
|
||||||
- name: Companies
|
- name: Companies
|
||||||
@@ -534,6 +545,108 @@ paths:
|
|||||||
"429":
|
"429":
|
||||||
$ref: "#/components/responses/RateLimited"
|
$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:
|
/api/v1/search:
|
||||||
get:
|
get:
|
||||||
tags: [Search]
|
tags: [Search]
|
||||||
@@ -569,6 +682,13 @@ paths:
|
|||||||
$ref: "#/components/responses/RateLimited"
|
$ref: "#/components/responses/RateLimited"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
ApiKeyAuth:
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: X-API-Key
|
||||||
|
description: API key for authenticated access. Free tier works without a key.
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
from:
|
from:
|
||||||
name: from
|
name: from
|
||||||
|
|||||||
100
internal/api/handlers/plans.go
Normal file
100
internal/api/handlers/plans.go
Normal file
@@ -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.",
|
||||||
|
})
|
||||||
|
}
|
||||||
152
internal/api/handlers/pricing.go
Normal file
152
internal/api/handlers/pricing.go
Normal file
@@ -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 = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sentinela API - Pricing</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0a0e17;color:#e1e5ee;min-height:100vh}
|
||||||
|
.container{max-width:1200px;margin:0 auto;padding:40px 20px}
|
||||||
|
h1{text-align:center;font-size:2.5rem;margin-bottom:8px;background:linear-gradient(135deg,#00d4aa,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||||
|
.subtitle{text-align:center;color:#8892a4;font-size:1.1rem;margin-bottom:48px}
|
||||||
|
.plans{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px;margin-bottom:64px}
|
||||||
|
.plan{background:#131a2b;border:1px solid #1e293b;border-radius:16px;padding:32px 24px;position:relative;transition:transform .2s,border-color .2s}
|
||||||
|
.plan:hover{transform:translateY(-4px);border-color:#7c3aed}
|
||||||
|
.plan.featured{border-color:#00d4aa;background:linear-gradient(180deg,#0f1a2e 0%,#131a2b 100%)}
|
||||||
|
.plan.featured::before{content:"MOST POPULAR";position:absolute;top:-12px;left:50%;transform:translateX(-50%);background:linear-gradient(135deg,#00d4aa,#059669);color:#fff;font-size:.7rem;font-weight:700;padding:4px 16px;border-radius:20px;letter-spacing:1px}
|
||||||
|
.plan-name{font-size:1.3rem;font-weight:700;margin-bottom:4px}
|
||||||
|
.plan-price{font-size:2.8rem;font-weight:800;margin:16px 0 4px}
|
||||||
|
.plan-price span{font-size:.9rem;font-weight:400;color:#8892a4}
|
||||||
|
.plan-billing{color:#8892a4;font-size:.85rem;margin-bottom:24px}
|
||||||
|
.plan-rate{background:#1e293b;border-radius:8px;padding:8px 12px;margin-bottom:20px;font-size:.9rem;color:#00d4aa;text-align:center}
|
||||||
|
.features{list-style:none;margin-bottom:28px}
|
||||||
|
.features li{padding:6px 0;font-size:.9rem;color:#c1c9d6}
|
||||||
|
.features li::before{content:"✓ ";color:#00d4aa;font-weight:700}
|
||||||
|
.features li.no::before{content:"✗ ";color:#4a5568}
|
||||||
|
.features li.no{color:#4a5568}
|
||||||
|
.btn{display:block;width:100%;padding:12px;border:none;border-radius:10px;font-size:1rem;font-weight:600;cursor:pointer;text-align:center;text-decoration:none;transition:opacity .2s}
|
||||||
|
.btn-free{background:#1e293b;color:#e1e5ee}
|
||||||
|
.btn-primary{background:linear-gradient(135deg,#00d4aa,#059669);color:#fff}
|
||||||
|
.btn-gold{background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff}
|
||||||
|
.btn-plat{background:linear-gradient(135deg,#7c3aed,#6d28d9);color:#fff}
|
||||||
|
.btn:hover{opacity:.85}
|
||||||
|
table{width:100%;border-collapse:collapse;margin-top:16px}
|
||||||
|
th,td{padding:12px 16px;text-align:center;border-bottom:1px solid #1e293b;font-size:.9rem}
|
||||||
|
th{color:#8892a4;font-weight:600}
|
||||||
|
th:first-child,td:first-child{text-align:left}
|
||||||
|
td.yes{color:#00d4aa}
|
||||||
|
td.no{color:#4a5568}
|
||||||
|
.matrix-section{margin-top:64px}
|
||||||
|
.matrix-section h2{text-align:center;font-size:1.8rem;margin-bottom:8px}
|
||||||
|
.matrix-sub{text-align:center;color:#8892a4;margin-bottom:24px}
|
||||||
|
footer{text-align:center;color:#4a5568;font-size:.85rem;margin-top:64px;padding-bottom:32px}
|
||||||
|
footer a{color:#00d4aa;text-decoration:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Sentinela API Plans</h1>
|
||||||
|
<p class="subtitle">Brazilian financial data API — real-time rates, companies, and filings</p>
|
||||||
|
|
||||||
|
<div class="plans">
|
||||||
|
<div class="plan">
|
||||||
|
<div class="plan-name">Free</div>
|
||||||
|
<div class="plan-price">$0<span></span></div>
|
||||||
|
<div class="plan-billing">Free forever</div>
|
||||||
|
<div class="plan-rate">30 requests/minute</div>
|
||||||
|
<ul class="features">
|
||||||
|
<li>Selic, CDI, IPCA rates</li>
|
||||||
|
<li>FX rates (USD/BRL)</li>
|
||||||
|
<li>Market overview</li>
|
||||||
|
<li class="no">Company data</li>
|
||||||
|
<li class="no">Filing data</li>
|
||||||
|
<li class="no">Search</li>
|
||||||
|
</ul>
|
||||||
|
<a href="/api/v1/plans/register" class="btn btn-free">Get Started</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plan">
|
||||||
|
<div class="plan-name">Bronze</div>
|
||||||
|
<div class="plan-price">$29<span>/mo</span></div>
|
||||||
|
<div class="plan-billing">Billed monthly</div>
|
||||||
|
<div class="plan-rate">100 requests/minute</div>
|
||||||
|
<ul class="features">
|
||||||
|
<li>All market data</li>
|
||||||
|
<li>Company data</li>
|
||||||
|
<li>Company search (FTS)</li>
|
||||||
|
<li>API key auth</li>
|
||||||
|
<li class="no">Filing data</li>
|
||||||
|
<li class="no">Bulk export</li>
|
||||||
|
</ul>
|
||||||
|
<a href="mailto:api@sentinela.dev?subject=Bronze Plan" class="btn btn-primary">Contact Us</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plan featured">
|
||||||
|
<div class="plan-name">Gold</div>
|
||||||
|
<div class="plan-price">$99<span>/mo</span></div>
|
||||||
|
<div class="plan-billing">Billed monthly</div>
|
||||||
|
<div class="plan-rate">500 requests/minute</div>
|
||||||
|
<ul class="features">
|
||||||
|
<li>All market data</li>
|
||||||
|
<li>Company data & search</li>
|
||||||
|
<li>Filing data</li>
|
||||||
|
<li>Historical data</li>
|
||||||
|
<li>Bulk export</li>
|
||||||
|
<li class="no">Webhooks</li>
|
||||||
|
</ul>
|
||||||
|
<a href="mailto:api@sentinela.dev?subject=Gold Plan" class="btn btn-gold">Contact Us</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plan">
|
||||||
|
<div class="plan-name">Platinum</div>
|
||||||
|
<div class="plan-price">$299<span>/mo</span></div>
|
||||||
|
<div class="plan-billing">Billed monthly</div>
|
||||||
|
<div class="plan-rate">2,000 requests/minute</div>
|
||||||
|
<ul class="features">
|
||||||
|
<li>Everything in Gold</li>
|
||||||
|
<li>Webhooks</li>
|
||||||
|
<li>Priority support</li>
|
||||||
|
<li>Custom date ranges</li>
|
||||||
|
<li>Raw CSV export</li>
|
||||||
|
<li>Dedicated capacity</li>
|
||||||
|
</ul>
|
||||||
|
<a href="mailto:api@sentinela.dev?subject=Platinum Plan" class="btn btn-plat">Contact Us</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="matrix-section">
|
||||||
|
<h2>Feature Comparison</h2>
|
||||||
|
<p class="matrix-sub">Detailed breakdown of what's included in each plan</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Feature</th><th>Free</th><th>Bronze</th><th>Gold</th><th>Platinum</th></tr>
|
||||||
|
<tr><td>Selic / CDI / IPCA</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td></tr>
|
||||||
|
<tr><td>FX Rates</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td></tr>
|
||||||
|
<tr><td>Market Overview</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td></tr>
|
||||||
|
<tr><td>Company Data</td><td class="no">✗</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td></tr>
|
||||||
|
<tr><td>Company Search</td><td class="no">✗</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td></tr>
|
||||||
|
<tr><td>Filing Data</td><td class="no">✗</td><td class="no">✗</td><td class="yes">✓</td><td class="yes">✓</td></tr>
|
||||||
|
<tr><td>Historical Data</td><td class="no">✗</td><td class="no">✗</td><td class="yes">✓</td><td class="yes">✓</td></tr>
|
||||||
|
<tr><td>Bulk Export</td><td class="no">✗</td><td class="no">✗</td><td class="yes">✓</td><td class="yes">✓</td></tr>
|
||||||
|
<tr><td>Webhooks</td><td class="no">✗</td><td class="no">✗</td><td class="no">✗</td><td class="yes">✓</td></tr>
|
||||||
|
<tr><td>CSV Export</td><td class="no">✗</td><td class="no">✗</td><td class="no">✗</td><td class="yes">✓</td></tr>
|
||||||
|
<tr><td>Priority Support</td><td class="no">✗</td><td class="no">✗</td><td class="no">✗</td><td class="yes">✓</td></tr>
|
||||||
|
<tr><td>Rate Limit</td><td>30/min</td><td>100/min</td><td>500/min</td><td>2,000/min</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Sentinela API — <a href="/docs">API Documentation</a> · <a href="/api/v1/plans">Plans API</a></p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
184
internal/api/middleware/plans.go
Normal file
184
internal/api/middleware/plans.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -11,19 +11,28 @@ func RegisterRoutes(app *fiber.App, database *db.DB) {
|
|||||||
h := handlers.New(database)
|
h := handlers.New(database)
|
||||||
|
|
||||||
app.Get("/health", h.Health)
|
app.Get("/health", h.Health)
|
||||||
|
app.Get("/pricing", h.PricingPage)
|
||||||
|
|
||||||
v1 := app.Group("/api/v1")
|
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", h.ListCompanies)
|
||||||
v1.Get("/companies/search", h.SearchCompanies)
|
v1.Get("/companies/search", h.SearchCompanies)
|
||||||
v1.Get("/companies/:id", h.GetCompany)
|
v1.Get("/companies/:id", h.GetCompany)
|
||||||
v1.Get("/companies/:id/filings", h.CompanyFilings)
|
v1.Get("/companies/:id/filings", h.CompanyFilings)
|
||||||
|
|
||||||
|
// Filings
|
||||||
v1.Get("/filings", h.ListFilings)
|
v1.Get("/filings", h.ListFilings)
|
||||||
v1.Get("/filings/search", h.SearchFilings)
|
v1.Get("/filings/search", h.SearchFilings)
|
||||||
v1.Get("/filings/recent", h.RecentFilings)
|
v1.Get("/filings/recent", h.RecentFilings)
|
||||||
v1.Get("/filings/:id", h.GetFiling)
|
v1.Get("/filings/:id", h.GetFiling)
|
||||||
|
|
||||||
|
// Market
|
||||||
v1.Get("/market/selic", h.ListSelic)
|
v1.Get("/market/selic", h.ListSelic)
|
||||||
v1.Get("/market/selic/current", h.CurrentSelic)
|
v1.Get("/market/selic/current", h.CurrentSelic)
|
||||||
v1.Get("/market/cdi", h.ListCDI)
|
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/fx/current", h.CurrentFX)
|
||||||
v1.Get("/market/overview", h.MarketOverview)
|
v1.Get("/market/overview", h.MarketOverview)
|
||||||
|
|
||||||
|
// Search
|
||||||
v1.Get("/search", h.GlobalSearch)
|
v1.Get("/search", h.GlobalSearch)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,8 @@ func NewServer(cfg *config.Config, database *db.DB) *fiber.App {
|
|||||||
app.Use(cors.New())
|
app.Use(cors.New())
|
||||||
RegisterSwagger(app)
|
RegisterSwagger(app)
|
||||||
|
|
||||||
app.Use(middleware.NewRateLimiter(cfg.RateLimit))
|
// Plan-based rate limiting and access control (replaces old rate limiter + API key auth)
|
||||||
|
app.Use(middleware.NewPlanMiddleware(database))
|
||||||
if cfg.APIKey != "" {
|
|
||||||
app.Use(middleware.NewAPIKeyAuth(cfg.APIKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
RegisterRoutes(app, database)
|
RegisterRoutes(app, database)
|
||||||
|
|
||||||
|
|||||||
198
internal/db/apikeys.go
Normal file
198
internal/db/apikeys.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -70,4 +70,38 @@ CREATE VIRTUAL TABLE IF NOT EXISTS filings_fts USING fts5(
|
|||||||
subject, category, type,
|
subject, category, type,
|
||||||
content='filings', content_rowid='id'
|
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)
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|||||||
Reference in New Issue
Block a user