feat(api): monta los endpoints VCard en /api/v2 con scope propio

La integración VCard vivía solo en /api/v1, que autentica por cookie de
sesión: obliga a manejar cookie jar, no permite allowlist por IP y no se
puede revocar sin tocar la contraseña del usuario (ver
docs/api-v1-contrato.md, punto 7).

Ahora los mismos controladores están también bajo /api/v2/vcard/* con
Bearer + IP + scope, que es lo que administra la pantalla /app/api-keys.
El scope "vcard" acota la llave a estos 12 endpoints: sin él, esa
integración tendría acceso a los otros ~300 de v2.

/api/v1 se mantiene intacto — esto es un camino nuevo, no un reemplazo
forzado, así que lo que ya está instalado sigue andando mientras migran.

Se agregan dos chequeos porque el compilador no ve ninguno de los dos
errores: que las 12 rutas queden registradas con su método y path (un
typo se descubriría recién con un 404 del lado del integrador), y que
todas figuren en el spec de /api/v2, que se mantiene a mano y se
desincroniza en silencio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-15 02:06:49 -05:00
co-authored by Claude Sonnet 5
parent ff4237eced
commit 2e3be1ca92
5 changed files with 133 additions and 2 deletions
+2 -2
View File
@@ -27,7 +27,7 @@
<div x-show="errorMsg" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
<div x-show="successMsg" x-cloak class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700" x-text="successMsg"></div>
<div class="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-700">
Solo estos scopes restringen de verdad hoy: <strong>oss</strong>, <strong>query_runner</strong>, <strong>usuarios</strong>, <strong>pasarelas</strong>. El resto de <code>/api/v2</code> sigue funcionando únicamente con la llave maestra (fase 2 pendiente).
Solo estos scopes restringen de verdad hoy: <strong>oss</strong>, <strong>query_runner</strong>, <strong>usuarios</strong>, <strong>pasarelas</strong>, <strong>vcard</strong>. El resto de <code>/api/v2</code> sigue funcionando únicamente con la llave maestra (fase 2 pendiente).
</div>
<div class="overflow-x-auto">
@@ -105,7 +105,7 @@
<div>
<label class="block text-xs font-medium text-gray-600 mb-2">Scopes *</label>
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
<template x-for="s in ['oss', 'query_runner', 'usuarios', 'pasarelas']" :key="s">
<template x-for="s in ['oss', 'query_runner', 'usuarios', 'pasarelas', 'vcard']" :key="s">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" :checked="form.scopes.includes(s)"
@change="toggleScope(s)" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
@@ -433,6 +433,24 @@ func AdminApiSpec(c *fiber.Ctx) error {
{"method": "POST", "path": "/api/v2/pasarelas/paypal/save", "desc": "Guardar config PayPal"},
},
},
{
"nombre": "VCard (QR, VCF y cobros)",
"desc": "Requiere el scope 'vcard'. Mismos controladores que /api/v1, pero con Bearer + IP en vez de cookie de sesión.",
"endpoints": []fiber.Map{
{"method": "POST", "path": "/api/v2/vcard/qr-tmp", "desc": "QR temporal de contacto → {\"url\": \"...\"}"},
{"method": "POST", "path": "/api/v2/vcard/qr", "desc": "QR de contacto — devuelve imagen image/webp, NO JSON"},
{"method": "POST", "path": "/api/v2/vcard/qr-url", "desc": "QR de una URL → {\"url\": \"...\"}"},
{"method": "POST", "path": "/api/v2/vcard/vcf", "desc": "Generar archivo .vcf → {\"success\": true, \"url\": \"...\"}"},
{"method": "POST", "path": "/api/v2/vcard/dlocal/planes", "desc": "Crear plan de suscripción"},
{"method": "GET", "path": "/api/v2/vcard/dlocal/planes", "desc": "Listar planes"},
{"method": "GET", "path": "/api/v2/vcard/dlocal/planes/:planID", "desc": "Ver un plan"},
{"method": "PATCH", "path": "/api/v2/vcard/dlocal/planes/:planID", "desc": "Actualizar un plan"},
{"method": "PATCH", "path": "/api/v2/vcard/dlocal/planes/:planId/suscripciones/:subscriptionId/desactivar", "desc": "Desactivar una suscripción"},
{"method": "GET", "path": "/api/v2/vcard/dlocal/suscripciones/:subscriptionId/ejecuciones/:invoiceId", "desc": "Ver una ejecución de cobro"},
{"method": "POST", "path": "/api/v2/vcard/dlocal/pagos", "desc": "Crear un pago"},
{"method": "POST", "path": "/api/v2/vcard/rapyd/wallets", "desc": "Crear wallet Rapyd"},
},
},
{
"nombre": "Query Runner",
"desc": "Ejecutar queries SQL directamente contra las conexiones DB configuradas.",
+28
View File
@@ -3,6 +3,7 @@ package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/rest/controllers"
apiControllers "github.com/sujit-baniya/fiber-boilerplate/rest/controllers/api"
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
)
@@ -377,6 +378,33 @@ func AdminApiRoutes(api fiber.Router) {
qr.Get("/history", controllers.GetHistory)
qr.Get("/columns", controllers.GetTableColumnsHandler)
// ─── VCard: QR, VCF y cobros ─────────────────────────────────────────────
// Los mismos controladores que /api/v1, montados acá para que una
// integración externa pueda autenticarse con Bearer + IP en vez de la
// cookie de sesión (ver docs/api-v1-contrato.md, punto 7).
//
// El scope "vcard" acota la llave a esto y nada más: sin él, la
// integración de VCard tendría acceso a los otros 300 endpoints de v2.
//
// /api/v1 se mantiene funcionando tal cual para no romper lo que ya está
// instalado; esto es el camino nuevo, no un reemplazo forzado.
vcard := h.Group("/vcard", middlewares.RequireScope("vcard"))
vcard.Post("/qr-tmp", apiControllers.CreateQrTmp)
vcard.Post("/qr", apiControllers.CreateQr) // responde image/webp, no JSON
vcard.Post("/qr-url", apiControllers.CreateUrlQr)
vcard.Post("/vcf", apiControllers.CreateVcf)
// dLocal y Rapyd bajo el mismo scope: son las operaciones de cobro que
// acompañan a la VCard, y quien integra una necesita la otra.
vcard.Post("/dlocal/planes", apiControllers.CreatePlan)
vcard.Get("/dlocal/planes", apiControllers.SeePlanes)
vcard.Get("/dlocal/planes/:planID", apiControllers.SeePlan)
vcard.Patch("/dlocal/planes/:planID", apiControllers.UpdatedPlan)
vcard.Patch("/dlocal/planes/:planId/suscripciones/:subscriptionId/desactivar", apiControllers.DeactivatePlan)
vcard.Get("/dlocal/suscripciones/:subscriptionId/ejecuciones/:invoiceId", apiControllers.SeeSubscription)
vcard.Post("/dlocal/pagos", apiControllers.CreatePago)
vcard.Post("/rapyd/wallets", apiControllers.MakeWallet)
// ─── Hostinger ───────────────────────────────────────────────────────────
h.Get("/hostinger/vps", controllers.GetHostingerVPS)
h.Get("/hostinger/domains", controllers.GetHostingerDomains)
+13
View File
@@ -0,0 +1,13 @@
package routes
import "os"
// leerSpecComoTexto lee el fuente del spec en vez de invocar el handler, que
// necesitaría un contexto de Fiber y la configuración cargada.
func leerSpecComoTexto() (string, error) {
b, err := os.ReadFile("../controllers/hermes_spec_controller.go")
if err != nil {
return "", err
}
return string(b), nil
}
+72
View File
@@ -0,0 +1,72 @@
package routes
import (
"strings"
"testing"
"github.com/gofiber/fiber/v2"
)
// Las rutas de VCard en /api/v2 son el camino que va a usar una integración
// externa. Un error de tipeo en un path no lo detecta el compilador — se
// descubre cuando el integrador recibe un 404. Esto lo fija.
func TestRutasVcardRegistradas(t *testing.T) {
app := fiber.New()
AdminApiRoutes(app)
registradas := map[string]bool{}
for _, capa := range app.Stack() {
for _, r := range capa {
registradas[r.Method+" "+r.Path] = true
}
}
esperadas := []string{
"POST /v2/vcard/qr-tmp",
"POST /v2/vcard/qr",
"POST /v2/vcard/qr-url",
"POST /v2/vcard/vcf",
"POST /v2/vcard/dlocal/planes",
"GET /v2/vcard/dlocal/planes",
"GET /v2/vcard/dlocal/planes/:planID",
"PATCH /v2/vcard/dlocal/planes/:planID",
"PATCH /v2/vcard/dlocal/planes/:planId/suscripciones/:subscriptionId/desactivar",
"GET /v2/vcard/dlocal/suscripciones/:subscriptionId/ejecuciones/:invoiceId",
"POST /v2/vcard/dlocal/pagos",
"POST /v2/vcard/rapyd/wallets",
}
for _, e := range esperadas {
if !registradas[e] {
t.Errorf("falta la ruta %q", e)
}
}
}
// El spec de /api/v2 se mantiene a mano, así que se desincroniza en silencio.
// Este chequeo obliga a que cada ruta nueva de vcard aparezca documentada.
func TestSpecDocumentaLasRutasVcard(t *testing.T) {
app := fiber.New()
AdminApiRoutes(app)
var paths []string
for _, capa := range app.Stack() {
for _, r := range capa {
if strings.HasPrefix(r.Path, "/v2/vcard/") && r.Method != "HEAD" {
paths = append(paths, "/api"+r.Path)
}
}
}
if len(paths) == 0 {
t.Fatal("no se registró ninguna ruta de vcard")
}
spec, err := leerSpecComoTexto()
if err != nil {
t.Skipf("no se pudo leer el spec: %v", err)
}
for _, p := range paths {
if !strings.Contains(spec, p) {
t.Errorf("la ruta %q está registrada pero no figura en el spec de /api/v2", p)
}
}
}