feat(api): tiered cumulative quota + self-service registration
Anonymous callers get a free cumulative quota (1000 calls per IP); once exhausted they get 403 quota_exhausted and must register. Public users can self-register (email+password) to obtain a higher-quota API key, view usage, and regenerate the key. Quota counters live in Redis; the public API stays read-only except for the registration writes. - migration 0011: app_user table + api_key.quota_total + 'registered' tier - ratelimit: IncrTotal/TotalUsed/CopyTotal lifetime counters - middleware: enforce cumulative quota + X-Quota-* headers - store: RegisterUser/Authenticate/RegenerateKey (bcrypt) - handlers: POST /api/v1/register, /account, /account/regenerate - admin: quota_total column + registered tier - public: 'API 密钥' account page + API docs quota section Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||
)
|
||||
|
||||
// emailRe is a deliberately permissive sanity check; real validation is the
|
||||
// unique constraint plus the user being able to receive their own key.
|
||||
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
||||
|
||||
const minPasswordLen = 8
|
||||
|
||||
type credentials struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func decodeCredentials(w http.ResponseWriter, r *http.Request) (credentials, bool) {
|
||||
var c credentials
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&c); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, "invalid_body", "请求格式无效")
|
||||
return credentials{}, false
|
||||
}
|
||||
c.Email = strings.TrimSpace(c.Email)
|
||||
if !emailRe.MatchString(c.Email) {
|
||||
writeError(w, r, http.StatusBadRequest, "invalid_email", "邮箱格式无效")
|
||||
return credentials{}, false
|
||||
}
|
||||
if len(c.Password) < minPasswordLen {
|
||||
writeError(w, r, http.StatusBadRequest, "weak_password", "密码至少需要 8 位")
|
||||
return credentials{}, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// keyResponse is returned whenever a fresh plaintext key is issued; the key is
|
||||
// shown exactly once and cannot be recovered afterwards.
|
||||
type keyResponse struct {
|
||||
Email string `json:"email"`
|
||||
APIKey string `json:"api_key"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
RateLimitPerMin int `json:"rate_limit_per_min"`
|
||||
QuotaTotal int64 `json:"quota_total"`
|
||||
}
|
||||
|
||||
// Register creates an account and issues its first API key. POST {email, password}.
|
||||
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := decodeCredentials(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key, acct, err := h.store.RegisterUser(r.Context(), c.Email, c.Password, h.regRatePerMin, h.regQuotaTotal)
|
||||
if errors.Is(err, store.ErrEmailTaken) {
|
||||
writeError(w, r, http.StatusConflict, "email_taken", "该邮箱已注册,请直接登录查看或重置密钥")
|
||||
return
|
||||
}
|
||||
if h.handleErr(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, keyResponse{
|
||||
Email: acct.Email,
|
||||
APIKey: key,
|
||||
KeyPrefix: acct.KeyPrefix,
|
||||
RateLimitPerMin: acct.RateLimitPerMin,
|
||||
QuotaTotal: acct.QuotaTotal,
|
||||
})
|
||||
}
|
||||
|
||||
// AccountInfo verifies credentials and returns the account's key metadata plus
|
||||
// cumulative usage. POST {email, password}. The plaintext key is not returned.
|
||||
func (h *Handler) AccountInfo(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := decodeCredentials(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
acct, err := h.store.Authenticate(r.Context(), c.Email, c.Password)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "邮箱或密码错误")
|
||||
return
|
||||
}
|
||||
if h.handleErr(w, r, err) {
|
||||
return
|
||||
}
|
||||
used := h.limiter.TotalUsed(r.Context(), acct.KeyID)
|
||||
remaining := acct.QuotaTotal - used
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"email": acct.Email,
|
||||
"key_prefix": acct.KeyPrefix,
|
||||
"rate_limit_per_min": acct.RateLimitPerMin,
|
||||
"quota_total": acct.QuotaTotal,
|
||||
"quota_used": used,
|
||||
"quota_remaining": remaining,
|
||||
})
|
||||
}
|
||||
|
||||
// RegenerateKey revokes the account's current key and issues a new one, carrying
|
||||
// over cumulative usage so the quota cannot be reset. POST {email, password}.
|
||||
func (h *Handler) RegenerateKey(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := decodeCredentials(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key, acct, oldKeyID, err := h.store.RegenerateKey(r.Context(), c.Email, c.Password, h.regRatePerMin, h.regQuotaTotal)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "邮箱或密码错误")
|
||||
return
|
||||
}
|
||||
if h.handleErr(w, r, err) {
|
||||
return
|
||||
}
|
||||
h.limiter.CopyTotal(r.Context(), oldKeyID, acct.KeyID)
|
||||
writeJSON(w, http.StatusOK, keyResponse{
|
||||
Email: acct.Email,
|
||||
APIKey: key,
|
||||
KeyPrefix: acct.KeyPrefix,
|
||||
RateLimitPerMin: acct.RateLimitPerMin,
|
||||
QuotaTotal: acct.QuotaTotal,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
|
||||
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||
)
|
||||
|
||||
func cleanupCounter(t *testing.T, subject string) {
|
||||
t.Helper()
|
||||
redisURL := os.Getenv("OPENGOODS_REDIS_URL")
|
||||
if redisURL == "" {
|
||||
redisURL = "redis://localhost:6379/0"
|
||||
}
|
||||
opt, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rdb := redis.NewClient(opt)
|
||||
defer rdb.Close()
|
||||
rdb.Del(context.Background(), "usage:total:"+subject)
|
||||
}
|
||||
|
||||
// newQuotaHandler builds a handler backed by the test DB and a live Redis
|
||||
// limiter, with a small anonymous quota so exhaustion is cheap to exercise.
|
||||
func newQuotaHandler(t *testing.T, anonQuota int) *Handler {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Skipf("no database: %v", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
t.Skipf("database not reachable: %v", err)
|
||||
}
|
||||
var hasUser bool
|
||||
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.app_user') IS NOT NULL").Scan(&hasUser); err != nil || !hasUser {
|
||||
pool.Close()
|
||||
t.Skip("migrations not applied")
|
||||
}
|
||||
redisURL := os.Getenv("OPENGOODS_REDIS_URL")
|
||||
if redisURL == "" {
|
||||
redisURL = "redis://localhost:6379/0"
|
||||
}
|
||||
limiter := ratelimit.New(redisURL)
|
||||
pingCtx, pingCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer pingCancel()
|
||||
if err := limiter.Ping(pingCtx); err != nil {
|
||||
pool.Close()
|
||||
t.Skipf("redis not reachable: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return New(store.New(pool), nil).
|
||||
WithRateLimit(limiter, 1000).
|
||||
WithQuotas(anonQuota, 300, 100000)
|
||||
}
|
||||
|
||||
func cleanupAccount(t *testing.T, email string) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer pool.Close()
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM app_user WHERE lower(email)=lower($1)", email)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM api_key WHERE owner_email=$1", email)
|
||||
}
|
||||
|
||||
func TestAnonTotalQuotaExhausts(t *testing.T) {
|
||||
h := newQuotaHandler(t, 3)
|
||||
// Unique client IP so the lifetime counter starts fresh for this test; the
|
||||
// counter never expires, so drop it afterwards to keep runs independent.
|
||||
n := time.Now().UnixNano()
|
||||
ip := fmt.Sprintf("203.%d.%d.%d", n/65536%256, n/256%256, n%256)
|
||||
t.Cleanup(func() { cleanupCounter(t, "ip:"+ip) })
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/stats", nil)
|
||||
req.RemoteAddr = ip + ":12345"
|
||||
rec := httptest.NewRecorder()
|
||||
h.Router().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
if rec := call(); rec.Code != http.StatusOK {
|
||||
t.Fatalf("call %d should be allowed, got %d (%s)", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
rec := call()
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("4th call should be 403 quota_exhausted, got %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "quota_exhausted") {
|
||||
t.Fatalf("expected quota_exhausted error, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterIssuesHigherQuotaKey(t *testing.T) {
|
||||
h := newQuotaHandler(t, 1000)
|
||||
email := fmt.Sprintf("h-user-%d@example.com", time.Now().UnixNano())
|
||||
t.Cleanup(func() { cleanupAccount(t, email) })
|
||||
|
||||
body := fmt.Sprintf(`{"email":%q,"password":"supersecret"}`, email)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/"+APIVersion+"/register", strings.NewReader(body))
|
||||
req.RemoteAddr = "198.51.100.7:9999"
|
||||
rec := httptest.NewRecorder()
|
||||
h.Router().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("register status = %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp keyResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.APIKey == "" || resp.QuotaTotal != 100000 || resp.RateLimitPerMin != 300 {
|
||||
t.Fatalf("unexpected register response: %+v", resp)
|
||||
}
|
||||
|
||||
// A second registration with the same email conflicts.
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/"+APIVersion+"/register", strings.NewReader(body))
|
||||
req2.RemoteAddr = "198.51.100.7:9999"
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.Router().ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate register status = %d (%s)", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -37,20 +37,43 @@ const (
|
||||
// defaultAnonLimit is the per-minute request budget for unauthenticated
|
||||
// callers (identified by client IP) when none is configured.
|
||||
defaultAnonLimit = 60
|
||||
|
||||
// defaultAnonTotalQuota is the lifetime number of calls an anonymous caller
|
||||
// (by IP) may make before being asked to register for a higher quota.
|
||||
defaultAnonTotalQuota = 1000
|
||||
|
||||
// defaultRegRatePerMin / defaultRegQuotaTotal are the per-minute budget and
|
||||
// cumulative quota granted to a self-registered API key.
|
||||
defaultRegRatePerMin = 300
|
||||
defaultRegQuotaTotal = 100000
|
||||
|
||||
// registerRatePerMin caps account registration/login attempts per IP to
|
||||
// curb abuse; these endpoints sit outside the metered quota group.
|
||||
registerRatePerMin = 10
|
||||
)
|
||||
|
||||
// Handler holds dependencies shared by the HTTP routes.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
spa fs.FS
|
||||
limiter *ratelimit.Limiter
|
||||
anonLimit int
|
||||
store *store.Store
|
||||
spa fs.FS
|
||||
limiter *ratelimit.Limiter
|
||||
anonLimit int
|
||||
anonTotalQuota int64
|
||||
regRatePerMin int
|
||||
regQuotaTotal int64
|
||||
}
|
||||
|
||||
// New constructs a Handler backed by the given store. spa may be nil (JSON-only).
|
||||
// Rate limiting is disabled until WithRateLimit is called.
|
||||
func New(s *store.Store, spa fs.FS) *Handler {
|
||||
return &Handler{store: s, spa: spa, anonLimit: defaultAnonLimit}
|
||||
return &Handler{
|
||||
store: s,
|
||||
spa: spa,
|
||||
anonLimit: defaultAnonLimit,
|
||||
anonTotalQuota: defaultAnonTotalQuota,
|
||||
regRatePerMin: defaultRegRatePerMin,
|
||||
regQuotaTotal: defaultRegQuotaTotal,
|
||||
}
|
||||
}
|
||||
|
||||
// WithRateLimit attaches a Redis-backed limiter and the anonymous per-minute
|
||||
@@ -64,6 +87,22 @@ func (h *Handler) WithRateLimit(l *ratelimit.Limiter, anonPerMin int) *Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithQuotas configures the cumulative free quota for anonymous callers and the
|
||||
// per-minute rate + cumulative quota self-registered keys receive. Non-positive
|
||||
// values keep the defaults.
|
||||
func (h *Handler) WithQuotas(anonTotal, regPerMin, regTotal int) *Handler {
|
||||
if anonTotal > 0 {
|
||||
h.anonTotalQuota = int64(anonTotal)
|
||||
}
|
||||
if regPerMin > 0 {
|
||||
h.regRatePerMin = regPerMin
|
||||
}
|
||||
if regTotal > 0 {
|
||||
h.regQuotaTotal = int64(regTotal)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Router builds the top-level HTTP handler with middleware and routes mounted.
|
||||
func (h *Handler) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
@@ -91,6 +130,16 @@ func (h *Handler) Router() http.Handler {
|
||||
r.Get("/sources/{id}", h.SourceByID)
|
||||
r.Get("/stats", h.Stats)
|
||||
})
|
||||
|
||||
// Self-service account routes. Lightly IP-throttled to curb abuse but
|
||||
// outside the metered quota group so a user can always register or
|
||||
// check their key even after exhausting the free anonymous quota.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.registerLimit)
|
||||
r.Post("/register", h.Register)
|
||||
r.Post("/account", h.AccountInfo)
|
||||
r.Post("/account/regenerate", h.RegenerateKey)
|
||||
})
|
||||
})
|
||||
|
||||
// Public SPA (homepage + search + contribute). API routes above take
|
||||
|
||||
@@ -24,8 +24,11 @@ const apiKeyIDKey ctxKey = 0
|
||||
// are set on every response; over-budget callers get 429 + Retry-After.
|
||||
func (h *Handler) rateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := "ip:" + clientIP(r)
|
||||
ip := clientIP(r)
|
||||
id := "ip:" + ip
|
||||
subject := "ip:" + ip // cumulative-quota counter subject
|
||||
limit := h.anonLimit
|
||||
quota := h.anonTotalQuota
|
||||
keyID := ""
|
||||
|
||||
if raw := presentedKey(r); raw != "" {
|
||||
@@ -45,6 +48,8 @@ func (h *Handler) rateLimit(next http.Handler) http.Handler {
|
||||
keyID = k.ID
|
||||
limit = k.RateLimitPerMin
|
||||
id = "key:" + k.ID
|
||||
subject = k.ID
|
||||
quota = k.QuotaTotal
|
||||
}
|
||||
|
||||
res := h.limiter.Allow(r.Context(), id, limit, time.Minute)
|
||||
@@ -61,8 +66,36 @@ func (h *Handler) rateLimit(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Attribute one call to the caller's lifetime counter, then enforce the
|
||||
// cumulative quota (quota <= 0 means unlimited). Keys also get daily and
|
||||
// last-used stats recorded for the admin console.
|
||||
var used int64
|
||||
if keyID != "" {
|
||||
h.limiter.RecordUsage(r.Context(), keyID)
|
||||
used = h.limiter.TotalUsed(r.Context(), keyID)
|
||||
} else {
|
||||
used = h.limiter.IncrTotal(r.Context(), subject)
|
||||
}
|
||||
if quota > 0 {
|
||||
remaining := quota - used
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
w.Header().Set("X-Quota-Limit", strconv.FormatInt(quota, 10))
|
||||
w.Header().Set("X-Quota-Used", strconv.FormatInt(used, 10))
|
||||
w.Header().Set("X-Quota-Remaining", strconv.FormatInt(remaining, 10))
|
||||
if used > quota {
|
||||
if keyID == "" {
|
||||
writeError(w, r, http.StatusForbidden, "quota_exhausted",
|
||||
"免费额度(共 "+strconv.FormatInt(quota, 10)+" 次)已用尽,请注册账号获取更高配额的 API 密钥")
|
||||
} else {
|
||||
writeError(w, r, http.StatusForbidden, "quota_exhausted", "API 密钥配额已用尽")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if keyID != "" {
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), apiKeyIDKey, keyID)))
|
||||
return
|
||||
}
|
||||
@@ -70,6 +103,25 @@ func (h *Handler) rateLimit(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// registerLimit throttles self-service account endpoints per client IP without
|
||||
// consuming the metered free quota, so a caller can still register or recover
|
||||
// their key after exhausting the anonymous quota.
|
||||
func (h *Handler) registerLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
res := h.limiter.Allow(r.Context(), "register:"+clientIP(r), h.regRatePerMin, time.Minute)
|
||||
if !res.Allowed {
|
||||
retry := res.ResetUnix - time.Now().Unix()
|
||||
if retry < 1 {
|
||||
retry = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(retry, 10))
|
||||
writeError(w, r, http.StatusTooManyRequests, "rate_limited", "操作过于频繁,请稍后再试")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// presentedKey extracts an API key from the X-API-Key header or a Bearer token.
|
||||
func presentedKey(r *http.Request) string {
|
||||
if v := strings.TrimSpace(r.Header.Get("X-API-Key")); v != "" {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"tags": [
|
||||
{ "name": "products" },
|
||||
{ "name": "catalog" },
|
||||
{ "name": "meta" }
|
||||
{ "name": "meta" },
|
||||
{ "name": "account" }
|
||||
],
|
||||
"security": [{ "ApiKeyHeader": [] }, { "BearerKey": [] }, {}],
|
||||
"paths": {
|
||||
@@ -114,6 +115,35 @@
|
||||
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
|
||||
"responses": { "200": { "description": "Source" }, "404": { "$ref": "#/components/responses/NotFound" } }
|
||||
}
|
||||
},
|
||||
"/register": {
|
||||
"post": {
|
||||
"tags": ["account"],
|
||||
"summary": "Register an account and issue an API key",
|
||||
"description": "Self-service registration; returns the plaintext API key exactly once.",
|
||||
"security": [],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["email", "password"], "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string", "minLength": 8 } } } } } },
|
||||
"responses": { "201": { "description": "Account created; plaintext key returned once" }, "400": { "description": "Invalid email or weak password" }, "409": { "description": "Email already registered" } }
|
||||
}
|
||||
},
|
||||
"/account": {
|
||||
"post": {
|
||||
"tags": ["account"],
|
||||
"summary": "View account key metadata and cumulative quota usage",
|
||||
"security": [],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["email", "password"], "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } } } } } },
|
||||
"responses": { "200": { "description": "Account info with quota usage" }, "401": { "description": "Invalid credentials" } }
|
||||
}
|
||||
},
|
||||
"/account/regenerate": {
|
||||
"post": {
|
||||
"tags": ["account"],
|
||||
"summary": "Revoke the current key and issue a new one",
|
||||
"description": "Cumulative usage carries over; returns the plaintext key exactly once.",
|
||||
"security": [],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["email", "password"], "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } } } } } },
|
||||
"responses": { "200": { "description": "New plaintext key returned once" }, "401": { "description": "Invalid credentials" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
||||
Reference in New Issue
Block a user