package adminstore import ( "context" "errors" "strings" "time" "github.com/jackc/pgx/v5/pgconn" "github.com/baicai2026-baicai/goods/api/internal/apikey" ) // APIKeyRow is an admin-facing view of an issued API key (never the secret). type APIKeyRow struct { ID string `json:"id"` Name string `json:"name"` KeyPrefix string `json:"key_prefix"` OwnerEmail *string `json:"owner_email"` Tier string `json:"tier"` RateLimitPerMin int `json:"rate_limit_per_min"` QuotaTotal int64 `json:"quota_total"` RevokedAt *string `json:"revoked_at"` CreatedBy *string `json:"created_by"` CreatedAt string `json:"created_at"` } // APIKeyInput holds the fields accepted when issuing a key. type APIKeyInput struct { Name string `json:"name"` OwnerEmail string `json:"owner_email"` Tier string `json:"tier"` RateLimitPerMin int `json:"rate_limit_per_min"` QuotaTotal int64 `json:"quota_total"` } // CreateAPIKey issues a new key, returning the one-time plaintext alongside the // stored row. Only the SHA-256 hash and a short display prefix are persisted. func (s *Store) CreateAPIKey(ctx context.Context, in APIKeyInput, createdBy string) (plaintext string, row APIKeyRow, err error) { tier := in.Tier if tier == "" { tier = "free" } rate := in.RateLimitPerMin if rate <= 0 { rate = 120 } quota := in.QuotaTotal if quota < 0 { quota = 0 } var owner *string if e := strings.TrimSpace(in.OwnerEmail); e != "" { owner = &e } key, hash, prefix, err := apikey.Generate() if err != nil { return "", row, err } var revoked, created *time.Time var createdByOut *string err = s.pool.QueryRow(ctx, ` INSERT INTO api_key (name, key_prefix, key_hash, owner_email, tier, rate_limit_per_min, quota_total, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, name, key_prefix, owner_email, tier, rate_limit_per_min, quota_total, revoked_at, created_by, created_at`, strings.TrimSpace(in.Name), prefix, hash, owner, tier, rate, quota, createdBy, ).Scan(&row.ID, &row.Name, &row.KeyPrefix, &row.OwnerEmail, &row.Tier, &row.RateLimitPerMin, &row.QuotaTotal, &revoked, &createdByOut, &created) if err != nil { return "", row, err } row.CreatedBy = createdByOut if created != nil { row.CreatedAt = created.Format(time.RFC3339) } return key, row, nil } // ListAPIKeys returns all keys (active first, newest first). func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKeyRow, error) { rows, err := s.pool.Query(ctx, ` SELECT id, name, key_prefix, owner_email, tier, rate_limit_per_min, quota_total, revoked_at, created_by, created_at FROM api_key ORDER BY (revoked_at IS NULL) DESC, created_at DESC`) if err != nil { return nil, err } defer rows.Close() out := []APIKeyRow{} for rows.Next() { var r APIKeyRow var revoked, created *time.Time if err := rows.Scan(&r.ID, &r.Name, &r.KeyPrefix, &r.OwnerEmail, &r.Tier, &r.RateLimitPerMin, &r.QuotaTotal, &revoked, &r.CreatedBy, &created); err != nil { return nil, err } if revoked != nil { v := revoked.Format(time.RFC3339) r.RevokedAt = &v } if created != nil { r.CreatedAt = created.Format(time.RFC3339) } out = append(out, r) } return out, rows.Err() } // RevokeAPIKey marks a key revoked. Revoking an already-revoked or missing key // returns ErrNotFound. func (s *Store) RevokeAPIKey(ctx context.Context, id string) error { tag, err := s.pool.Exec(ctx, "UPDATE api_key SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL", id) if err != nil { if isInvalidUUID(err) { return ErrNotFound } return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // isInvalidUUID reports whether err is a Postgres invalid-UUID-text error, // which happens when a non-UUID id is supplied. func isInvalidUUID(err error) bool { var pgErr *pgconn.PgError if errors.As(err, &pgErr) { return pgErr.Code == "22P02" } return false }