Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,22 @@ llamada. Cada cultivo puede afinar sus avisos (`crop_channels`): al instante o e
resumen diario a una hora fija y hasta 10 chats con los que se comparte. El preparador de resúmenes revisa cada minuto
los pendientes, sin cron en el servidor.

## Conexiones para asistentes de IA (MCP)

Cada persona crea desde la PWA (Perfil › Conexiones) tokens de conexión `spt_…` para clientes externos, como el
servidor [SmartPot-MCP](https://github.com/SmartPotTech/SmartPot-MCP): `GET/POST /api/v1/connections` y
`DELETE /api/v1/connections/{id}`. El token solo se devuelve al crearlo; la base guarda su SHA-256, un prefijo para
reconocerlo y el último uso. El guard lo acepta igual que la sesión, pero con límites:

| Alcance | Puede |
|-----------|-----------------------------------------------------------|
| `READ` | Solo consultas (`GET`) |
| `CONTROL` | Además crear, editar, borrar y accionar actuadores |

Ninguna conexión administra conexiones, cambia la cuenta, configura canales de aviso, ve credenciales de
dispositivos ni obtiene el rol de administrador. Revocar o vencer la conexión la corta al instante (401), y borrar la
cuenta borra sus conexiones. Máximo 10 por cuenta.

## Cultivos Reales y Virtuales

Al crear un cultivo se elige, una sola vez, de dónde vienen sus lecturas. Uno **real** recibe la clave del dispositivo
Expand Down
6 changes: 6 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/SmartPotTech/SmartPot-API-Go/internal/channels/telegram"
"github.com/SmartPotTech/SmartPot-API-Go/internal/commands"
"github.com/SmartPotTech/SmartPot-API-Go/internal/config"
"github.com/SmartPotTech/SmartPot-API-Go/internal/connections"
"github.com/SmartPotTech/SmartPot-API-Go/internal/crops"
"github.com/SmartPotTech/SmartPot-API-Go/internal/health"
"github.com/SmartPotTech/SmartPot-API-Go/internal/httpserver"
Expand Down Expand Up @@ -154,6 +155,10 @@ func run() error {
userService.AddAccountDeleter(notificationService.DeleteAllForUser)
userService.AddAccountDeleter(tokenRepo.DeleteByUserID)
userService.AddAccountDeleter(cropService.DeleteAllOwnedBy)
// Conexiones personales (clientes MCP y similares): el guard las acepta además de la sesión.
connectionService := connections.NewService(postgres.NewAccessTokenRepository(pool.Pool), clock)
tokenService.UseConnections(connectionService)
userService.AddAccountDeleter(connectionService.DeleteAllForUser)
authService := auth.NewService(userRepo, tokenRepo, tokenService, mailer, notificationService, clock,
cfg.Security.PasswordResetExpiration)

Expand Down Expand Up @@ -243,6 +248,7 @@ func run() error {
overview.NewHandler(overviewService).Register(authed)
virtualdevices.NewHandler(virtualDeviceService).Register(authed)
channels.NewHandler(channelService, cropChannelService).Register(authed)
connections.NewHandler(connectionService).Register(authed)

// Completa el tipo, la forma y el lugar de los cultivos anteriores (idempotente).
runCropBackfill(logger, vdRepo, cropService)
Expand Down
60 changes: 60 additions & 0 deletions internal/connections/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package connections

import (
"net/http"

"github.com/SmartPotTech/SmartPot-API-Go/internal/httpserver"
"github.com/SmartPotTech/SmartPot-API-Go/internal/security"
"github.com/labstack/echo/v4"
)

// Handler expone las rutas de conexiones. Solo se administran con la sesión de la PWA: el guard rechaza estas
// rutas cuando la petición llega con un token de conexión.
type Handler struct {
svc *Service
}

// NewHandler crea el controlador de conexiones.
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}

// Register monta las rutas bajo el grupo autenticado /api/v1.
func (h *Handler) Register(g *echo.Group) {
g.GET("/connections", h.list)
g.POST("/connections", h.create)
g.DELETE("/connections/:id", h.revoke)
}

func (h *Handler) list(c echo.Context) error {
items, err := h.svc.List(c.Request().Context(), security.CurrentUserID(c))
if err != nil {
return err
}
out := make([]Response, 0, len(items))
for _, t := range items {
out = append(out, ToResponse(t))
}
return c.JSON(http.StatusOK, out)
}

func (h *Handler) create(c echo.Context) error {
var req CreateRequest
if err := httpserver.Bind(c, &req); err != nil {
return err
}
created, err := h.svc.Create(c.Request().Context(), security.CurrentUserID(c), req)
if err != nil {
return err
}
// El token solo viaja en esta respuesta: que ningún intermediario la guarde.
c.Response().Header().Set("Cache-Control", "no-store")
return c.JSON(http.StatusCreated, created)
}

func (h *Handler) revoke(c echo.Context) error {
if err := h.svc.Revoke(c.Request().Context(), security.CurrentUserID(c), c.Param("id")); err != nil {
return err
}
return c.NoContent(http.StatusNoContent)
}
116 changes: 116 additions & 0 deletions internal/connections/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Package connections gestiona los tokens de conexión personales: credenciales que una persona crea desde la PWA
// para que un cliente externo (un servidor MCP, un script) use la API en su nombre, con un alcance acotado y sin
// compartir su contraseña. Cada conexión se revoca por separado.
package connections

import (
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"

"github.com/SmartPotTech/SmartPot-API-Go/internal/platform/validation"
"go.mongodb.org/mongo-driver/bson/primitive"
)

// Scope es lo que una conexión puede hacer.
type Scope string

const (
// ScopeRead solo consulta (GET).
ScopeRead Scope = "READ"
// ScopeControl además crea, edita, borra y acciona actuadores.
ScopeControl Scope = "CONTROL"
)

// TokenPrefix identifica a simple vista un token de conexión (y lo distingue de un JWT de sesión).
const TokenPrefix = "spt_"

// UnmarshalJSON rechaza un alcance desconocido.
func (s *Scope) UnmarshalJSON(b []byte) error {
var raw string
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
switch Scope(raw) {
case ScopeRead, ScopeControl:
*s = Scope(raw)
return nil
default:
return fmt.Errorf("alcance inválido: %q", raw)
}
}

// AccessToken es una conexión guardada. Del token solo se conserva el SHA-256 y un prefijo para reconocerlo.
type AccessToken struct {
ID primitive.ObjectID
UserID primitive.ObjectID
Name string
TokenHash string
Prefix string
Scope Scope
CreatedAt time.Time
LastUsedAt *time.Time
ExpiresAt *time.Time
}

// Expired indica si la conexión venció en el instante dado.
func (t AccessToken) Expired(now time.Time) bool {
return t.ExpiresAt != nil && !now.Before(*t.ExpiresAt)
}

// CreateRequest pide una conexión nueva.
type CreateRequest struct {
Name string `json:"name"`
Scope Scope `json:"scope"`
ExpiresInDays *int `json:"expiresInDays"`
}

// Validate comprueba nombre, alcance y vigencia.
func (r CreateRequest) Validate() validation.Fields {
v := validation.New()
name := strings.TrimSpace(r.Name)
if name == "" {
v.Add("name", "Ponle un nombre a la conexión")
} else if utf8.RuneCountInString(name) > 60 {
v.Add("name", "El nombre admite como máximo 60 caracteres")
}
if r.Scope != ScopeRead && r.Scope != ScopeControl {
v.Add("scope", "Elige solo lectura o control")
}
if r.ExpiresInDays != nil && (*r.ExpiresInDays < 1 || *r.ExpiresInDays > 365) {
v.Add("expiresInDays", "La vigencia debe estar entre 1 y 365 días")
}
return v.Result()
}

// Response es la vista pública de una conexión (nunca incluye el token).
type Response struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Scope Scope `json:"scope"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}

// CreatedResponse devuelve la conexión recién creada con su token, que solo se muestra esta vez.
type CreatedResponse struct {
Connection Response `json:"connection"`
Token string `json:"token"`
}

// ToResponse mapea la entidad a su vista pública.
func ToResponse(t AccessToken) Response {
return Response{
ID: t.ID.Hex(),
Name: t.Name,
Prefix: t.Prefix,
Scope: t.Scope,
CreatedAt: t.CreatedAt,
LastUsedAt: t.LastUsedAt,
ExpiresAt: t.ExpiresAt,
}
}
19 changes: 19 additions & 0 deletions internal/connections/port.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package connections

import (
"context"
"time"

"go.mongodb.org/mongo-driver/bson/primitive"
)

// Repository es el puerto de almacenamiento de las conexiones; lo implementa el adaptador de PostgreSQL.
type Repository interface {
Insert(ctx context.Context, t *AccessToken) error
ListByUser(ctx context.Context, userID primitive.ObjectID) ([]AccessToken, error)
CountByUser(ctx context.Context, userID primitive.ObjectID) (int64, error)
FindByHash(ctx context.Context, hash string) (*AccessToken, error)
DeleteByIDAndUser(ctx context.Context, id, userID primitive.ObjectID) (int64, error)
DeleteByUser(ctx context.Context, userID primitive.ObjectID) error
TouchLastUsed(ctx context.Context, id primitive.ObjectID, at time.Time) error
}
149 changes: 149 additions & 0 deletions internal/connections/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package connections

import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
"time"

"github.com/SmartPotTech/SmartPot-API-Go/internal/platform/apperr"
"github.com/SmartPotTech/SmartPot-API-Go/internal/security"
"go.mongodb.org/mongo-driver/bson/primitive"
)

const (
// MaxPerUser limita las conexiones vivas de una cuenta.
MaxPerUser = 10
// touchEvery evita escribir en la base en cada petición solo para actualizar el último uso.
touchEvery = time.Minute
// prefixLength es lo que se muestra del token para reconocerlo (spt_ + 8 caracteres).
prefixLength = len(TokenPrefix) + 8
)

// Service crea, lista, revoca y verifica conexiones.
type Service struct {
repo Repository
clock func() time.Time
}

// NewService crea el servicio de conexiones.
func NewService(repo Repository, clock func() time.Time) *Service {
return &Service{repo: repo, clock: clock}
}

// Create genera una conexión y devuelve su token, que no se vuelve a mostrar.
func (s *Service) Create(ctx context.Context, userID string, req CreateRequest) (*CreatedResponse, error) {
uid, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return nil, apperr.Unauthorized("Inicia sesión para continuar")
}
count, err := s.repo.CountByUser(ctx, uid)
if err != nil {
return nil, err
}
if count >= MaxPerUser {
return nil, apperr.Conflict("Llegaste al máximo de 10 conexiones; revoca alguna para crear otra")
}
raw, err := newToken()
if err != nil {
return nil, err
}
now := s.clock()
t := AccessToken{
ID: primitive.NewObjectID(),
UserID: uid,
Name: strings.TrimSpace(req.Name),
TokenHash: HashToken(raw),
Prefix: raw[:prefixLength],
Scope: req.Scope,
CreatedAt: now,
}
if req.ExpiresInDays != nil {
expires := now.Add(time.Duration(*req.ExpiresInDays) * 24 * time.Hour)
t.ExpiresAt = &expires
}
if err := s.repo.Insert(ctx, &t); err != nil {
return nil, err
}
return &CreatedResponse{Connection: ToResponse(t), Token: raw}, nil
}

// List devuelve las conexiones de la cuenta, de la más nueva a la más antigua.
func (s *Service) List(ctx context.Context, userID string) ([]AccessToken, error) {
uid, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return nil, apperr.Unauthorized("Inicia sesión para continuar")
}
return s.repo.ListByUser(ctx, uid)
}

// Revoke borra una conexión de la cuenta; desde ese momento su token deja de servir.
func (s *Service) Revoke(ctx context.Context, userID, id string) error {
uid, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return apperr.Unauthorized("Inicia sesión para continuar")
}
oid, err := primitive.ObjectIDFromHex(id)
if err != nil {
return apperr.NotFound("La conexión no existe")
}
n, err := s.repo.DeleteByIDAndUser(ctx, oid, uid)
if err != nil {
return err
}
if n == 0 {
return apperr.NotFound("La conexión no existe")
}
return nil
}

// DeleteAllForUser borra las conexiones al eliminar la cuenta.
func (s *Service) DeleteAllForUser(ctx context.Context, userID string) error {
uid, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return nil
}
return s.repo.DeleteByUser(ctx, uid)
}

// AuthenticateConnection verifica un token de conexión y devuelve a quién representa y con qué alcance.
func (s *Service) AuthenticateConnection(ctx context.Context, raw string) (security.ConnectionPrincipal, error) {
invalid := apperr.Unauthorized("El token de conexión no es válido o fue revocado")
if !strings.HasPrefix(raw, TokenPrefix) || len(raw) < prefixLength {
return security.ConnectionPrincipal{}, invalid
}
t, err := s.repo.FindByHash(ctx, HashToken(raw))
if err != nil {
return security.ConnectionPrincipal{}, err
}
now := s.clock()
if t == nil || t.Expired(now) {
return security.ConnectionPrincipal{}, invalid
}
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) >= touchEvery {
// El último uso es informativo: si falla la escritura, la petición sigue.
_ = s.repo.TouchLastUsed(ctx, t.ID, now)
}
return security.ConnectionPrincipal{
ConnectionID: t.ID.Hex(),
UserID: t.UserID.Hex(),
Scope: string(t.Scope),
}, nil
}

// HashToken es el SHA-256 en hexadecimal: el token tiene 256 bits aleatorios, así que no hace falta sal.
func HashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}

func newToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return TokenPrefix + base64.RawURLEncoding.EncodeToString(buf), nil
}
Loading
Loading