Compare commits

..

4 Commits

Author SHA1 Message Date
CTO H3R7Tech
837cddb406 feat: Client CRUD admin blueprint + auth + subscription management (HRT-199)
- New api_v1/routes/admin.py: admin client management blueprint
- admin_users table for admin role (no ALTER TABLE needed)
- require_admin decorator for endpoint protection
- GET/PUT/DELETE /api/v1/admin/clients/<id>
- POST /api/v1/admin/setup (first-time admin init)
- POST /api/v1/admin/clients/<id>/suspend|activate
- GET /api/v1/admin/stats (client counts by plan)
- Registered in api_v1/__init__: auto-wired into portal_server.py
- No new service, no merge tables, no ALTER TABLE
2026-05-24 10:12:10 +02:00
CTO H3R7Tech
8ab42343aa feat: Token Broker infrastructure (HRT-205)
Some checks failed
CD / Deploy → Staging (push) Has been cancelled
CD / Smoke Tests on Staging (push) Has been cancelled
CD / Deploy → Production (push) Has been cancelled
CD / Rollback Production (push) Has been cancelled
- PostgreSQL dedie Docker (postgres:16-alpine, port 5434)
- 6 tables: api_tokens, refresh_tokens, token_audit_log, clients, providers, token_usage
- Init SQL + Flask init_db() mis a jour
- Systemd service token-broker (port 8783)
- Deploy script infra/scripts/deploy_token_broker.sh
- Docker compose broker (docker-compose.broker.yml)
- Health check OK: status=ok, database=connected

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-24 09:22:12 +02:00
CTO H3R7Tech
cd4cbcfb48 Fix #2+#3: Routes API 404 et conflit blueprint name
Bug #2: portal_server.py importait api_v1_bp depuis saas_api_v1 au lieu
de api_v1/__init__.py. Tous les sous-blueprints api_v1/routes/* (health,
courses, predictions, valuebets, backtest, export, metrics, ml_feedback)
n'etaient jamais enregistres -> 404.
Fix: utiliser register_api_v1(app) depuis api_v1/__init__.py.

Bug #3: Conflit de nom de blueprint entre saas_api_v1 et api_v1 (tous
deux nommes api_v1). Renomme le blueprint de saas_api_v1 en saas_api_v1_bp.
Supprime les record_once handlers de saas_api_v1 qui dupliquaient
l'enregistrement de sous-blueprints (billing, org, user, history) -
desormais geres par register_api_v1(app).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 22:57:06 +02:00
CTO H3R7Tech
c072f92794 Fix #1: Ajout job run_ml_cache dans scheduler pour alimenter ml_predictions_cache
- run_ml_cache() lit les partants, genere predictions via predict_v2,
  enrichit avec metadonnees course, calcule risque, ecrit dans cache
- Planifie 4x/jour: 09:30, 11:35, 13:30, 17:35
- Installe dependances: optuna, shap, lightgbm

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 22:54:29 +02:00
13 changed files with 1771 additions and 76 deletions

View File

@@ -41,6 +41,7 @@ from .routes.user_tokens import user_tokens_bp
from .routes.history import history_bp
from .routes.org import org_bp
from .routes.ml_feedback import ml_feedback_bp
from .routes.admin import admin_bp
# Master blueprint that aggregates all sub-routes under /api/v1
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
@@ -61,3 +62,4 @@ def register_api_v1(app):
app.register_blueprint(history_bp)
app.register_blueprint(org_bp)
app.register_blueprint(ml_feedback_bp)
app.register_blueprint(admin_bp)

587
api_v1/routes/admin.py Normal file
View File

@@ -0,0 +1,587 @@
#!/usr/bin/env python3
"""
Admin Blueprint — Client CRUD + Subscription management
HRT-199 — Foundation (Client CRUD + Auth + Subscription)
Endpoints:
POST /api/v1/admin/setup — init first admin (no auth, 1 call only)
GET /api/v1/admin/clients — list all clients (paginated, filterable)
GET /api/v1/admin/clients/<id> — client detail + subscription
PUT /api/v1/admin/clients/<id> — update client (plan, name, email)
DELETE /api/v1/admin/clients/<id> — delete client + tokens + subscription
POST /api/v1/admin/clients/<id>/suspend — suspend client (set plan=suspended)
POST /api/v1/admin/clients/<id>/activate — reactivate client (restore plan)
GET /api/v1/admin/stats — client stats (total, by plan, new/30d)
"""
import sqlite3
import logging
import os
from datetime import datetime, timezone
from functools import wraps
from flask import Blueprint, jsonify, request
from saas_auth import require_auth
from api_v1.utils import get_db, paginate_query, get_pagination_params, not_found, bad_request, internal_error
logger = logging.getLogger("turf_saas.admin")
admin_bp = Blueprint("admin", __name__, url_prefix="/api/v1/admin")
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
def _get_saas_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def migrate_admin_tables():
"""Idempotent: create admin_users table."""
conn = _get_saas_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS admin_users (
user_id TEXT PRIMARY KEY REFERENCES saas_users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by TEXT
);
""")
conn.commit()
conn.close()
try:
migrate_admin_tables()
except Exception as e:
logger.warning("admin DB init warning: %s", e)
def _is_admin(user_id: str, db=None) -> bool:
if not user_id:
return False
close = False
if db is None:
db = _get_saas_db()
close = True
try:
row = db.execute(
"SELECT 1 FROM admin_users WHERE user_id = ?", (user_id,)
).fetchone()
return row is not None
finally:
if close:
db.close()
def require_admin(f):
@wraps(f)
def decorated(*args, **kwargs):
user = getattr(request, "current_user", None)
if not user:
return jsonify({"error": "Non authentifié"}), 401
if not _is_admin(user["id"]):
return jsonify({"error": "Accès administrateur requis"}), 403
return f(*args, **kwargs)
return decorated
def _user_to_client(row) -> dict:
return {
"id": row["id"],
"email": row["email"],
"firstname": row.get("firstname", ""),
"lastname": row.get("lastname", ""),
"plan": row.get("plan", "free"),
"telegram_chat_id": row.get("telegram_chat_id"),
"alert_value_bets": bool(row.get("alert_value_bets", 1)),
"alert_top1": bool(row.get("alert_top1", 1)),
"alert_quinte_only": bool(row.get("alert_quinte_only", 0)),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
def _fetch_subscription(db, user_id: str):
return db.execute(
"""SELECT * FROM saas_subscriptions
WHERE user_id = ? ORDER BY start_date DESC LIMIT 1""",
(user_id,),
).fetchone()
# ─── POST /api/v1/admin/setup ─────────────────────────────────
@admin_bp.route("/setup", methods=["POST"])
def admin_setup():
"""Init first admin (no auth). Only works once — when admin_users is empty."""
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
if not email or "@" not in email:
return jsonify({"error": "Email valide requis"}), 400
db = _get_saas_db()
try:
existing = db.execute("SELECT 1 FROM admin_users LIMIT 1").fetchone()
if existing:
return jsonify({"error": "Admin déjà configuré"}), 409
user = db.execute(
"SELECT id, email FROM saas_users WHERE email = ?", (email,)
).fetchone()
if not user:
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
db.execute(
"INSERT INTO admin_users (user_id, created_by) VALUES (?, 'setup')",
(user["id"],),
)
db.commit()
logger.info("Admin setup: user %s (%s) promoted to admin", user["id"], email)
return jsonify({"ok": True, "user_id": user["id"], "email": email}), 201
except Exception as e:
db.rollback()
logger.error("admin_setup error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── GET /api/v1/admin/clients ─────────────────────────────────
@admin_bp.route("/clients", methods=["GET"])
@require_auth
@require_admin
def list_clients():
"""List all clients with pagination and filters.
---
tags:
- Admin
security:
- Bearer: []
parameters:
- in: query
name: page
type: integer
- in: query
name: per_page
type: integer
- in: query
name: search
type: string
description: Search by email or name
- in: query
name: plan
type: string
description: Filter by plan (free, premium, pro, suspended)
- in: query
name: sort_by
type: string
enum: [created_at, email, plan, updated_at]
- in: query
name: sort_order
type: string
enum: [asc, desc]
responses:
200:
description: Paginated client list
403:
description: Admin access required
"""
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 20, type=int)
search = request.args.get("search", "").strip()
plan_filter = request.args.get("plan", "").strip()
sort_by = request.args.get("sort_by", "created_at").strip()
sort_order = request.args.get("sort_order", "desc").strip()
if sort_by not in ("created_at", "email", "plan", "updated_at"):
sort_by = "created_at"
if sort_order not in ("asc", "desc"):
sort_order = "desc"
if per_page < 1 or per_page > 100:
per_page = 20
if page < 1:
page = 1
offset = (page - 1) * per_page
db = _get_saas_db()
try:
conditions = []
params = []
if search:
conditions.append("(email LIKE ? OR firstname LIKE ? OR lastname LIKE ?)")
p = f"%{search}%"
params.extend([p, p, p])
if plan_filter:
conditions.append("plan = ?")
params.append(plan_filter)
where = (" WHERE " + " AND ".join(conditions)) if conditions else ""
total = db.execute(
f"SELECT COUNT(*) FROM saas_users{where}", params
).fetchone()[0]
rows = db.execute(
f"SELECT * FROM saas_users{where} ORDER BY {sort_by} {sort_order} LIMIT ? OFFSET ?",
params + [per_page, offset],
).fetchall()
result = []
for row in rows:
client = _user_to_client(row)
sub = _fetch_subscription(db, row["id"])
if sub:
client["subscription"] = {
"plan": sub["plan"],
"status": sub["status"],
"start_date": sub["start_date"],
"current_period_end": sub["current_period_end"],
"stripe_customer_id": sub["stripe_customer_id"],
}
else:
client["subscription"] = None
result.append(client)
return jsonify({
"clients": result,
"pagination": {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": (total + per_page - 1) // per_page,
},
}), 200
except Exception as e:
logger.error("list_clients error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── GET /api/v1/admin/clients/<id> ────────────────────────────
@admin_bp.route("/clients/<string:client_id>", methods=["GET"])
@require_auth
@require_admin
def get_client(client_id: str):
"""Get client details with subscription info.
---
tags:
- Admin
security:
- Bearer: []
parameters:
- in: path
name: id
type: string
required: true
responses:
200:
description: Client details
404:
description: Client not found
"""
db = _get_saas_db()
try:
row = db.execute(
"SELECT * FROM saas_users WHERE id = ?", (client_id,)
).fetchone()
if not row:
return jsonify({"error": "Client introuvable"}), 404
client = _user_to_client(row)
sub = _fetch_subscription(db, client_id)
if sub:
client["subscription"] = {
"id": sub["id"],
"plan": sub["plan"],
"status": sub["status"],
"start_date": sub["start_date"],
"end_date": sub["end_date"],
"current_period_end": sub["current_period_end"],
"grace_period_end": sub["grace_period_end"],
"stripe_customer_id": sub["stripe_customer_id"],
"stripe_subscription_id": sub["stripe_subscription_id"],
}
else:
client["subscription"] = None
return jsonify({"client": client}), 200
except Exception as e:
logger.error("get_client error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── PUT /api/v1/admin/clients/<id> ────────────────────────────
@admin_bp.route("/clients/<string:client_id>", methods=["PUT"])
@require_auth
@require_admin
def update_client(client_id: str):
"""Update client fields (plan, firstname, lastname, email).
---
tags:
- Admin
security:
- Bearer: []
parameters:
- in: path
name: id
type: string
required: true
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
firstname: { type: string }
lastname: { type: string }
email: { type: string }
plan: { type: string, enum: [free, premium, pro, suspended] }
responses:
200:
description: Client updated
400:
description: Invalid parameters
404:
description: Client not found
"""
data = request.get_json(silent=True) or {}
if not data:
return jsonify({"error": "Corps JSON requis"}), 400
db = _get_saas_db()
try:
existing = db.execute(
"SELECT id FROM saas_users WHERE id = ?", (client_id,)
).fetchone()
if not existing:
return jsonify({"error": "Client introuvable"}), 404
fields = {}
if "firstname" in data:
fields["firstname"] = data["firstname"].strip()
if "lastname" in data:
fields["lastname"] = data["lastname"].strip()
if "email" in data:
email = data["email"].strip().lower()
if "@" not in email:
return jsonify({"error": "Email invalide"}), 400
fields["email"] = email
if "plan" in data:
plan = data["plan"].strip().lower()
if plan not in ("free", "premium", "pro", "suspended"):
return jsonify({"error": "Plan invalide. free|premium|pro|suspended"}), 400
fields["plan"] = plan
if not fields:
return jsonify({"ok": True}), 200
set_clause = ", ".join(f"{k}=?" for k in fields)
values = list(fields.values()) + [datetime.now(timezone.utc).isoformat(), client_id]
db.execute(
f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values
)
db.commit()
logger.info("Admin %s updated client %s: %s",
request.current_user["id"], client_id, fields)
return jsonify({"ok": True, "updated": list(fields.keys())}), 200
except sqlite3.IntegrityError:
return jsonify({"error": "Cet email est déjà utilisé"}), 409
except Exception as e:
db.rollback()
logger.error("update_client error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── DELETE /api/v1/admin/clients/<id> ─────────────────────────
@admin_bp.route("/clients/<string:client_id>", methods=["DELETE"])
@require_auth
@require_admin
def delete_client(client_id: str):
"""Delete client and all associated data (tokens, subscriptions).
---
tags:
- Admin
security:
- Bearer: []
parameters:
- in: path
name: id
type: string
required: true
responses:
200:
description: Client deleted
404:
description: Client not found
"""
admin_id = request.current_user["id"]
if client_id == admin_id:
return jsonify({"error": "Impossible de supprimer votre propre compte"}), 400
db = _get_saas_db()
try:
existing = db.execute(
"SELECT id FROM saas_users WHERE id = ?", (client_id,)
).fetchone()
if not existing:
return jsonify({"error": "Client introuvable"}), 404
db.execute("DELETE FROM saas_tokens WHERE user_id = ?", (client_id,))
db.execute("DELETE FROM saas_subscriptions WHERE user_id = ?", (client_id,))
db.execute("DELETE FROM admin_users WHERE user_id = ?", (client_id,))
db.execute("DELETE FROM saas_users WHERE id = ?", (client_id,))
db.commit()
logger.info("Admin %s deleted client %s", admin_id, client_id)
return jsonify({"ok": True, "deleted_id": client_id}), 200
except Exception as e:
db.rollback()
logger.error("delete_client error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── POST /api/v1/admin/clients/<id>/suspend ───────────────────
@admin_bp.route("/clients/<string:client_id>/suspend", methods=["POST"])
@require_auth
@require_admin
def suspend_client(client_id: str):
"""Suspend a client by setting plan to 'suspended'.
---
tags:
- Admin
security:
- Bearer: []
responses:
200:
description: Client suspended
404:
description: Client not found
"""
return _set_client_plan(client_id, "suspended")
# ─── POST /api/v1/admin/clients/<id>/activate ──────────────────
@admin_bp.route("/clients/<string:client_id>/activate", methods=["POST"])
@require_auth
@require_admin
def activate_client(client_id: str):
"""Reactivate a suspended client to 'free' plan.
---
tags:
- Admin
security:
- Bearer: []
responses:
200:
description: Client activated
404:
description: Client not found
"""
return _set_client_plan(client_id, "free")
def _set_client_plan(client_id: str, plan: str):
db = _get_saas_db()
try:
existing = db.execute(
"SELECT id, plan FROM saas_users WHERE id = ?", (client_id,)
).fetchone()
if not existing:
return jsonify({"error": "Client introuvable"}), 404
db.execute(
"UPDATE saas_users SET plan=?, updated_at=? WHERE id=?",
(plan, datetime.now(timezone.utc).isoformat(), client_id),
)
db.commit()
action = "suspendu" if plan == "suspended" else "réactivé"
logger.info("Client %s %s par admin %s", client_id, action,
request.current_user["id"])
return jsonify({"ok": True, "client_id": client_id, "plan": plan, "action": action}), 200
except Exception as e:
db.rollback()
logger.error("_set_client_plan error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── GET /api/v1/admin/stats ────────────────────────────────────
@admin_bp.route("/stats", methods=["GET"])
@require_auth
@require_admin
def admin_stats():
"""Client stats: totals by plan, new this month/30d.
---
tags:
- Admin
security:
- Bearer: []
responses:
200:
description: Admin stats
"""
db = _get_saas_db()
try:
total = db.execute("SELECT COUNT(*) FROM saas_users").fetchone()[0]
by_plan = {}
for row in db.execute(
"SELECT plan, COUNT(*) AS cnt FROM saas_users GROUP BY plan"
).fetchall():
by_plan[row["plan"]] = row["cnt"]
new_30d = db.execute(
"SELECT COUNT(*) FROM saas_users WHERE created_at >= datetime('now', '-30 days')"
).fetchone()[0]
new_7d = db.execute(
"SELECT COUNT(*) FROM saas_users WHERE created_at >= datetime('now', '-7 days')"
).fetchone()[0]
active_subs = db.execute(
"SELECT COUNT(DISTINCT user_id) FROM saas_subscriptions WHERE status = 'active'"
).fetchone()[0]
return jsonify({
"total_clients": total,
"clients_by_plan": by_plan,
"new_last_30d": new_30d,
"new_last_7d": new_7d,
"active_subscriptions": active_subs,
}), 200
except Exception as e:
logger.error("admin_stats error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()

View File

@@ -21,10 +21,7 @@ from api_v1.utils import (
paginate_query,
)
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
try:
from auth import jwt_required_middleware
except ImportError:
from saas_auth import require_auth as jwt_required_middleware
from saas_auth import require_auth as jwt_required_middleware
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")

32
docker-compose.broker.yml Normal file
View File

@@ -0,0 +1,32 @@
# Token Broker Infrastructure
# PostgreSQL dedicated instance on port 5434
networks:
turf-net:
driver: bridge
services:
token-broker-db:
image: postgres:16-alpine
container_name: token-broker-db
restart: unless-stopped
environment:
POSTGRES_DB: token_broker
POSTGRES_USER: token_broker
POSTGRES_PASSWORD: ${TOKEN_BROKER_DB_PASSWORD:-CHANGE_ME_PASSWORD}
volumes:
- token-broker-pgdata:/var/lib/postgresql/data
- ./infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U token_broker -d token_broker"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- turf-net
ports:
- "127.0.0.1:5434:5432"
volumes:
token-broker-pgdata:
driver: local

View File

@@ -0,0 +1,94 @@
-- Token Broker PostgreSQL init script
-- 6 tables: api_tokens, refresh_tokens, token_audit_log, clients, providers, token_usage
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS api_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT 'default',
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
scopes TEXT[] DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
revoked_at TIMESTAMPTZ,
replaced_by UUID
);
CREATE TABLE IF NOT EXISTS token_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER,
action TEXT NOT NULL,
token_prefix TEXT,
ip_address TEXT,
user_agent TEXT,
details JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id TEXT NOT NULL UNIQUE,
client_secret TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
redirect_uris TEXT[] DEFAULT '{}',
scopes TEXT[] DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
provider_type TEXT NOT NULL DEFAULT 'oauth2',
issuer_url TEXT,
client_id TEXT,
client_secret TEXT,
scopes TEXT[] DEFAULT '{}',
config JSONB DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS token_usage (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
token_id UUID,
action TEXT NOT NULL DEFAULT 'verify',
endpoint TEXT,
status TEXT NOT NULL DEFAULT 'success',
response_time_ms INTEGER,
ip_address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id);
CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name);
CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id);
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO token_broker;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO token_broker;

View File

@@ -0,0 +1,90 @@
#!/bin/bash
# ============================================================
# Deploy Token Broker — systemd service + Docker PG
# ============================================================
set -euo pipefail
APP_DIR="/home/h3r7/turf_saas"
SERVICE_NAME="token-broker"
PID_FILE="/tmp/token_broker.pid"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "[$(date -Iseconds)] === Deploying Token Broker ==="
# Step 1: Backup current code
echo "[$(date -Iseconds)] Backing up current code..."
mkdir -p /home/h3r7/backups/token-broker
cp "${APP_DIR}/services/token-broker/token_broker_api.py" \
"/home/h3r7/backups/token-broker/token_broker_api_${TIMESTAMP}.py"
# Step 2: Ensure Docker PG is running
echo "[$(date -Iseconds)] Ensuring PostgreSQL container..."
if ! docker inspect token-broker-db >/dev/null 2>&1; then
echo "Creating PG container..."
docker run -d \
--name token-broker-db \
--restart unless-stopped \
-e POSTGRES_DB=token_broker \
-e POSTGRES_USER=token_broker \
-e POSTGRES_PASSWORD="${TOKEN_BROKER_DB_PASSWORD}" \
-v token-broker-pgdata:/var/lib/postgresql/data \
-v "${APP_DIR}/infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro" \
-p 127.0.0.1:5434:5432 \
postgres:16-alpine
elif ! docker ps --filter name=token-broker-db --format '{{.Status}}' | grep -q Up; then
echo "Starting existing PG container..."
docker start token-broker-db
else
echo "PG container already running."
fi
# Wait for PG readiness
echo "[$(date -Iseconds)] Waiting for PG to be ready..."
for i in $(seq 1 20); do
if docker exec token-broker-db pg_isready -U token_broker -d token_broker >/dev/null 2>&1; then
echo "PG ready."
break
fi
sleep 2
done
# Step 3: Ensure psycopg2-binary is installed
echo "[$(date -Iseconds)] Checking Python deps..."
source "${APP_DIR}/venv/bin/activate"
pip install -q psycopg2-binary PyJWT flask-cors python-dotenv gunicorn 2>/dev/null || true
# Step 4: Stop current service
echo "[$(date -Iseconds)] Stopping current service..."
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
systemctl stop ${SERVICE_NAME}
elif [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then
kill $(cat "$PID_FILE") 2>/dev/null || true
fi
sleep 2
# Step 5: Copy systemd unit and start
echo "[$(date -Iseconds)] Starting via systemd..."
cp "${APP_DIR}/services/token-broker/token-broker.service" /etc/systemd/system/
systemctl daemon-reload
systemctl enable ${SERVICE_NAME}
systemctl start ${SERVICE_NAME}
# Wait for startup
sleep 3
# Step 6: Health check
echo "[$(date -Iseconds)] Running health check..."
HEALTH=$(curl -s http://127.0.0.1:8783/health 2>/dev/null || echo '{"status":"failed"}')
STATUS=$(echo "$HEALTH" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null || echo "unknown")
if [ "$STATUS" = "ok" ]; then
echo "[$(date -Iseconds)] ✅ Health check passed: ${HEALTH}"
echo "[$(date -Iseconds)] === Token Broker deploy SUCCESS ==="
else
echo "[$(date -Iseconds)] ❌ Health check failed: ${HEALTH}"
echo "[$(date -Iseconds)] === Token Broker deploy FAILED ==="
exit 1
fi
# Step 7: Clean old backups (keep last 30)
find /home/h3r7/backups/token-broker -name "*.py" -mtime +30 -delete

View File

@@ -18,14 +18,12 @@ SAAS_DIR = "/home/h3r7/turf_saas"
# ─── SaaS Auth & API v1 blueprints ────────────────────────────────────────────
try:
from saas_auth import auth_bp
from saas_api_v1 import api_v1_bp
from api_v1.routes.ml_feedback import ml_feedback_bp
from api_v1.routes.metrics import metrics_bp
from saas_api_v1 import saas_api_v1_bp
from api_v1 import register_api_v1
app.register_blueprint(auth_bp)
app.register_blueprint(api_v1_bp)
app.register_blueprint(ml_feedback_bp)
app.register_blueprint(metrics_bp)
app.register_blueprint(saas_api_v1_bp)
register_api_v1(app)
print("[portal] SaaS auth & API v1 blueprints registered ✅")
except Exception as e:
print(f"[portal] Warning: could not register SaaS blueprints: {e}")

View File

@@ -13,7 +13,7 @@ from saas_auth import require_auth
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
saas_api_v1_bp = Blueprint("saas_api_v1", __name__, url_prefix="/api/v1")
def get_db():
@@ -30,7 +30,7 @@ def plan_allows(user_plan: str, required: str) -> bool:
# ─── Stats ────────────────────────────────────────────────────────────────────
@api_v1_bp.route("/stats/summary", methods=["GET"])
@saas_api_v1_bp.route("/stats/summary", methods=["GET"])
@require_auth
def stats_summary():
"""GET /api/v1/stats/summary — résumé dashboard."""
@@ -94,7 +94,7 @@ def stats_summary():
# ─── Predictions ──────────────────────────────────────────────────────────────
@api_v1_bp.route("/predictions/today", methods=["GET"])
@saas_api_v1_bp.route("/predictions/today", methods=["GET"])
@require_auth
def predictions_today():
"""GET /api/v1/predictions/today — prédictions du jour selon le plan."""
@@ -149,7 +149,7 @@ def predictions_today():
return jsonify({"error": str(e), "predictions": []}), 200
@api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
@saas_api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
@require_auth
def predictions_race(race_label):
"""GET /api/v1/predictions/race/<label> — prédictions d'une course."""
@@ -187,7 +187,7 @@ def predictions_race(race_label):
# ─── Value Bets ───────────────────────────────────────────────────────────────
@api_v1_bp.route("/value-bets/today", methods=["GET"])
@saas_api_v1_bp.route("/value-bets/today", methods=["GET"])
@require_auth
def value_bets_today():
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
@@ -220,7 +220,7 @@ def value_bets_today():
# ─── Export ───────────────────────────────────────────────────────────────────
@api_v1_bp.route("/export/csv", methods=["GET"])
@saas_api_v1_bp.route("/export/csv", methods=["GET"])
@require_auth
def export_csv():
"""GET /api/v1/export/csv — export CSV (Pro only)."""
@@ -257,15 +257,13 @@ def export_csv():
)
# ─── Billing Blueprint (Stripe) + JWT init — HRT-49 ─────────────────────────
# Registers /api/v1/billing/* routes via nested Blueprint (Flask 2.0+)
# Also initializes JWTManager on the Flask app (required for jwt_required_middleware)
# ─── JWT init — HRT-49 ────────────────────────────────────────────────────────
# Initialize JWTManager on the Flask app (required for jwt_required_middleware)
# Called when saas_api_v1_bp is registered (portal_server.py)
try:
from flask_jwt_extended import JWTManager
from api_v1.routes.billing import billing_bp
# Initialize JWTManager on the Flask app when api_v1_bp is registered
@api_v1_bp.record_once
@saas_api_v1_bp.record_once
def _init_jwt(state):
app = state.app
if not app.config.get("JWT_SECRET_KEY"):
@@ -276,57 +274,6 @@ try:
)
if "flask_jwt_extended" not in app.extensions:
JWTManager(app)
# Register billing blueprint with url_prefix='/billing'
# (parent api_v1_bp has '/api/v1', so result is /api/v1/billing/*)
api_v1_bp.register_blueprint(billing_bp, url_prefix="/billing")
print("[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅")
except Exception as _billing_err:
print(f"[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}")
# ─── Org Blueprint — HRT-82 ───────────────────────────────────────────────────
# Registers /api/v1/org/* routes (Pro plan only, multi-compte max 5 users)
try:
from api_v1.routes.org import org_bp
@api_v1_bp.record_once
def _register_org_bp(state):
app = state.app
app.register_blueprint(org_bp)
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
except Exception as _org_err:
print(f"[saas_api_v1] Warning: org blueprint not loaded: {_org_err}")
# ─── User Blueprint — HRT-79 (Telegram) + HRT-80 (API Token + Webhook) ───────
# Registers /api/v1/user/* routes (Premium+ for telegram, Pro for api-token/webhook)
try:
from api_v1.routes.user import user_bp
from api_v1.routes.user_tokens import user_tokens_bp
@api_v1_bp.record_once
def _register_user_bp(state):
app = state.app
app.register_blueprint(user_bp)
app.register_blueprint(user_tokens_bp)
print('[saas_api_v1] User blueprint (Telegram config + API token + Webhook) registered ✅')
except Exception as _user_err:
print(f'[saas_api_v1] Warning: user blueprints not loaded: {_user_err}')
# ─── History Blueprint — HRT-81 ───────────────────────────────────────────────
# Registers /api/v1/history route (Free:7j, Premium:90j, Pro:illimité)
try:
from api_v1.routes.history import history_bp
@api_v1_bp.record_once
def _register_history_bp(state):
app = state.app
app.register_blueprint(history_bp)
print('[saas_api_v1] History blueprint (plan-limited history) registered ✅')
except Exception as _history_err:
print(f'[saas_api_v1] Warning: history blueprint not loaded: {_history_err}')
print("[saas_api_v1] JWT init registered ✅")
except Exception as _jwt_err:
print(f"[saas_api_v1] Warning: JWT init not loaded: {_jwt_err}")

View File

@@ -0,0 +1,10 @@
# Token Broker API — Configuration
TOKEN_BROKER_PORT=8783
TOKEN_BROKER_DB_HOST=127.0.0.1
TOKEN_BROKER_DB_PORT=5434
TOKEN_BROKER_DB_NAME=token_broker
TOKEN_BROKER_DB_USER=token_broker
TOKEN_BROKER_DB_PASSWORD=CHANGE_ME
TOKEN_BROKER_JWT_SECRET=CHANGE_ME_GENERATE_64_HEX
TOKEN_BROKER_ACCESS_EXPIRY=900
TOKEN_BROKER_REFRESH_EXPIRY=2592000

View File

@@ -0,0 +1,6 @@
Flask==3.1.3
flask-cors==5.0.1
gunicorn==23.0.0
psycopg2-binary==2.9.12
PyJWT==2.10.1
python-dotenv==1.1.0

View File

@@ -0,0 +1,21 @@
[Unit]
Description=Token Broker API (Port 8783)
Documentation=https://portal-kolifee.duckdns.org
After=network.target postgresql.service
[Service]
Type=simple
User=h3r7
WorkingDirectory=/home/h3r7/turf_saas/services/token-broker
EnvironmentFile=/home/h3r7/turf_saas/services/token-broker/.env
Environment=PYTHONPATH=/home/h3r7/turf_saas
Environment=FLASK_ENV=production
ExecStart=/home/h3r7/turf_saas/venv/bin/python3 /home/h3r7/turf_saas/services/token-broker/token_broker_api.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,679 @@
#!/usr/bin/env python3
"""
Token Broker API — JWT token management service
Port: 8783 | DB: PostgreSQL 5434
HRT-198 — Setup infra (PostgreSQL + Flask scaffold)
Endpoints:
GET /health — Healthcheck
POST /api/v1/tokens — Issue new token (create)
GET /api/v1/tokens/:id — Get token by ID
POST /api/v1/tokens/verify — Verify token
POST /api/v1/tokens/revoke/:id — Revoke token
GET /api/v1/tokens/user/:userId — List tokens for user
"""
import os
import sys
import uuid
import hashlib
import secrets
import logging
import logging.handlers
from datetime import datetime, timedelta, timezone
from functools import wraps
from flask import Flask, request, jsonify, g
from flask_cors import CORS
LOG_DIR = os.path.join(os.path.dirname(__file__), "logs")
os.makedirs(LOG_DIR, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] token-broker: %(name)s: %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.handlers.RotatingFileHandler(
os.path.join(LOG_DIR, "token_broker.log"),
maxBytes=5 * 1024 * 1024,
backupCount=3,
),
],
)
logger = logging.getLogger("token_broker")
DB_HOST = os.environ.get("TOKEN_BROKER_DB_HOST", "127.0.0.1")
DB_PORT = int(os.environ.get("TOKEN_BROKER_DB_PORT", "5434"))
DB_NAME = os.environ.get("TOKEN_BROKER_DB_NAME", "token_broker")
DB_USER = os.environ.get("TOKEN_BROKER_DB_USER", "token_broker")
DB_PASSWORD = os.environ.get("TOKEN_BROKER_DB_PASSWORD", "")
JWT_SECRET = os.environ.get(
"TOKEN_BROKER_JWT_SECRET", "CHANGE_ME_" + secrets.token_hex(32)
)
ACCESS_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_ACCESS_EXPIRY", "900"))
REFRESH_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_REFRESH_EXPIRY", "2592000"))
def get_pg_conn():
try:
import psycopg2
import psycopg2.extras
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
)
conn.autocommit = True
return conn
except Exception as e:
logger.error(f"PostgreSQL connection failed: {e}")
return None
def init_db():
conn = get_pg_conn()
if not conn:
logger.error("Cannot initialize DB — no connection")
return False
try:
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS api_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT 'default',
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
scopes TEXT[] DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
revoked_at TIMESTAMPTZ,
replaced_by UUID
);
CREATE TABLE IF NOT EXISTS token_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER,
action TEXT NOT NULL,
token_prefix TEXT,
ip_address TEXT,
user_agent TEXT,
details JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id TEXT NOT NULL UNIQUE,
client_secret TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
redirect_uris TEXT[] DEFAULT '{}',
scopes TEXT[] DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
provider_type TEXT NOT NULL DEFAULT 'oauth2',
issuer_url TEXT,
client_id TEXT,
client_secret TEXT,
scopes TEXT[] DEFAULT '{}',
config JSONB DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS token_usage (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
token_id UUID,
action TEXT NOT NULL DEFAULT 'verify',
endpoint TEXT,
status TEXT NOT NULL DEFAULT 'success',
response_time_ms INTEGER,
ip_address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id);
CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name);
CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id);
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);
""")
cur.close()
conn.close()
logger.info("Database tables initialized successfully")
return True
except Exception as e:
logger.error(f"Database initialization failed: {e}")
return False
def create_app():
app = Flask(__name__)
app.config["JWT_SECRET"] = JWT_SECRET
app.config["ACCESS_TOKEN_EXPIRY"] = ACCESS_TOKEN_EXPIRY
app.config["REFRESH_TOKEN_EXPIRY"] = REFRESH_TOKEN_EXPIRY
CORS(app)
register_routes(app)
register_error_handlers(app)
return app
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "missing_token", "message": "Bearer token required"}), 401
token = auth_header.split(" ", 1)[1]
payload = verify_jwt_token(token)
if not payload:
return jsonify({"error": "invalid_token", "message": "Token invalid or expired"}), 401
g.user_id = payload.get("user_id")
g.token_id = payload.get("token_id")
g.scopes = payload.get("scopes", [])
return f(*args, **kwargs)
return decorated
def generate_token_pair(user_id, scopes=None, metadata=None):
import jwt as pyjwt
now = datetime.now(timezone.utc)
access_payload = {
"user_id": user_id,
"token_id": str(uuid.uuid4()),
"scopes": scopes or [],
"type": "access",
"iat": now,
"exp": now + timedelta(seconds=ACCESS_TOKEN_EXPIRY),
}
access_token = pyjwt.encode(access_payload, JWT_SECRET, algorithm="HS256")
refresh_id = str(uuid.uuid4())
refresh_raw = secrets.token_urlsafe(48)
refresh_payload = {
"user_id": user_id,
"refresh_id": refresh_id,
"token_hash": hashlib.sha256(refresh_raw.encode()).hexdigest(),
"type": "refresh",
"iat": now,
"exp": now + timedelta(seconds=REFRESH_TOKEN_EXPIRY),
}
refresh_token = pyjwt.encode(refresh_payload, JWT_SECRET, algorithm="HS256")
store_refresh_token(user_id, refresh_id, refresh_payload["token_hash"])
log_audit(user_id, "token_issued", access_payload["token_id"][:8])
return {
"access_token": access_token,
"refresh_token": refresh_raw,
"expires_in": ACCESS_TOKEN_EXPIRY,
"token_type": "Bearer",
}
def verify_jwt_token(token):
import jwt as pyjwt
try:
payload = pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
if payload.get("type") == "refresh":
token_hash = hashlib.sha256(token.encode()).hexdigest()
conn = get_pg_conn()
if conn:
cur = conn.cursor()
cur.execute(
"SELECT revoked FROM refresh_tokens WHERE token_hash = %s AND expires_at > NOW()",
(token_hash,),
)
row = cur.fetchone()
cur.close()
conn.close()
if not row or row[0]:
return None
return payload
except Exception:
return None
def store_refresh_token(user_id, refresh_id, token_hash):
conn = get_pg_conn()
if not conn:
return
try:
cur = conn.cursor()
cur.execute(
"""INSERT INTO refresh_tokens (id, user_id, token_hash, token_prefix, expires_at)
VALUES (%s, %s, %s, %s, NOW() + INTERVAL '30 days')""",
(refresh_id, user_id, token_hash, token_hash[:8]),
)
cur.close()
conn.close()
except Exception as e:
logger.error(f"Failed to store refresh token: {e}")
def log_audit(user_id, action, token_prefix, details=None):
conn = get_pg_conn()
if not conn:
return
try:
cur = conn.cursor()
cur.execute(
"""INSERT INTO token_audit_log (user_id, action, token_prefix, ip_address, user_agent, details)
VALUES (%s, %s, %s, %s, %s, %s)""",
(
user_id,
action,
token_prefix,
request.remote_addr if request else None,
request.user_agent.string if request and request.user_agent else None,
"{}" if details is None else details,
),
)
cur.close()
conn.close()
except Exception:
pass
def register_routes(app):
@app.route("/health", methods=["GET"])
def healthcheck():
conn = get_pg_conn()
db_ok = conn is not None
if conn:
conn.close()
return jsonify({
"status": "ok" if db_ok else "degraded",
"service": "token-broker",
"version": "1.0.0",
"database": "connected" if db_ok else "disconnected",
"timestamp": datetime.now(timezone.utc).isoformat(),
}), 200 if db_ok else 503
@app.route("/api/v1/tokens", methods=["POST"])
@token_required
def issue_token():
data = request.get_json(silent=True) or {}
user_id = g.user_id
scopes = data.get("scopes", [])
name = data.get("name", "default")
metadata = data.get("metadata", {})
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error", "message": "Database unavailable"}), 503
try:
cur = conn.cursor()
import psycopg2.extras
raw_token = "tb_" + secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
token_prefix = raw_token[:12] + "..."
cur.execute(
"""INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, metadata)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, created_at, expires_at""",
(user_id, name, token_hash, token_prefix, scopes,
psycopg2.extras.Json(metadata)),
)
row = cur.fetchone()
cur.close()
conn.close()
log_audit(user_id, "api_token_created", token_prefix)
return jsonify({
"id": str(row[0]),
"token": raw_token,
"name": name,
"scopes": scopes,
"created_at": row[1].isoformat(),
"expires_at": row[2].isoformat() if row[2] else None,
}), 201
except Exception as e:
logger.error(f"Token creation failed: {e}")
return jsonify({"error": "creation_failed", "message": str(e)}), 500
@app.route("/api/v1/tokens/verify", methods=["POST"])
def verify_token():
data = request.get_json(silent=True) or {}
raw_token = data.get("token", "")
if not raw_token:
return jsonify({"valid": False, "error": "token_required"}), 400
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
conn = get_pg_conn()
if not conn:
return jsonify({"valid": False, "error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at
FROM api_tokens
WHERE token_hash = %s""",
(token_hash,),
)
row = cur.fetchone()
if not row:
cur.close()
conn.close()
return jsonify({"valid": False, "error": "token_not_found"}), 404
token_id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at = row
if not is_active:
cur.close()
conn.close()
return jsonify({"valid": False, "error": "token_revoked"}), 403
if expires_at and expires_at < datetime.now(timezone.utc):
cur.close()
conn.close()
return jsonify({"valid": False, "error": "token_expired"}), 403
cur.execute(
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = %s",
(token_id,),
)
cur.close()
conn.close()
return jsonify({
"valid": True,
"token_id": str(token_id),
"user_id": user_id,
"name": name,
"scopes": scopes,
})
except Exception as e:
logger.error(f"Token verification failed: {e}")
return jsonify({"valid": False, "error": "verification_failed"}), 500
@app.route("/api/v1/tokens/<token_id>", methods=["GET"])
@token_required
def get_token(token_id):
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at, metadata
FROM api_tokens WHERE id = %s AND user_id = %s""",
(token_id, g.user_id),
)
row = cur.fetchone()
cur.close()
conn.close()
if not row:
return jsonify({"error": "not_found"}), 404
return jsonify({
"id": str(row[0]),
"user_id": row[1],
"name": row[2],
"scopes": row[3],
"is_active": row[4],
"created_at": row[5].isoformat(),
"expires_at": row[6].isoformat() if row[6] else None,
"last_used_at": row[7].isoformat() if row[7] else None,
"metadata": row[8] if row[8] else {},
})
except Exception as e:
logger.error(f"Get token failed: {e}")
return jsonify({"error": "query_failed"}), 500
@app.route("/api/v1/tokens/revoke/<token_id>", methods=["POST"])
@token_required
def revoke_token(token_id):
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""UPDATE api_tokens SET is_active = FALSE WHERE id = %s AND user_id = %s
RETURNING id, name""",
(token_id, g.user_id),
)
row = cur.fetchone()
cur.close()
conn.close()
if not row:
return jsonify({"error": "not_found"}), 404
log_audit(g.user_id, "api_token_revoked", str(row[0])[:8])
return jsonify({"status": "revoked", "token_id": str(row[0])})
except Exception as e:
logger.error(f"Revoke token failed: {e}")
return jsonify({"error": "revoke_failed"}), 500
@app.route("/api/v1/tokens/user/<int:user_id>", methods=["GET"])
@token_required
def list_user_tokens(user_id):
if g.user_id != user_id and "admin" not in g.scopes:
return jsonify({"error": "forbidden"}), 403
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at
FROM api_tokens
WHERE user_id = %s
ORDER BY created_at DESC""",
(user_id,),
)
rows = cur.fetchall()
cur.close()
conn.close()
tokens = []
for row in rows:
tokens.append({
"id": str(row[0]),
"user_id": row[1],
"name": row[2],
"scopes": row[3],
"is_active": row[4],
"created_at": row[5].isoformat(),
"expires_at": row[6].isoformat() if row[6] else None,
"last_used_at": row[7].isoformat() if row[7] else None,
})
return jsonify({"tokens": tokens, "total": len(tokens)})
except Exception as e:
logger.error(f"List tokens failed: {e}")
return jsonify({"error": "query_failed"}), 500
@app.route("/api/v1/auth/token", methods=["POST"])
def exchange_token():
data = request.get_json(silent=True) or {}
grant_type = data.get("grant_type", "client_credentials")
raw_token = data.get("client_token", "") or data.get("token", "")
refresh_raw = data.get("refresh_token", "")
if grant_type == "refresh_token" and refresh_raw:
return refresh_access_token(refresh_raw)
if not raw_token:
return jsonify({"error": "invalid_request", "message": "client_token required"}), 400
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""SELECT id, user_id, scopes, is_active, expires_at
FROM api_tokens WHERE token_hash = %s""",
(token_hash,),
)
row = cur.fetchone()
cur.close()
conn.close()
if not row:
return jsonify({"error": "invalid_token"}), 401
if not row[3]:
return jsonify({"error": "token_revoked"}), 403
if row[4] and row[4] < datetime.now(timezone.utc):
return jsonify({"error": "token_expired"}), 403
token_pair = generate_token_pair(row[1], row[2])
return jsonify(token_pair), 200
except Exception as e:
logger.error(f"Token exchange failed: {e}")
return jsonify({"error": "exchange_failed"}), 500
@app.route("/api/v1/auth/refresh", methods=["POST"])
def refresh_token_endpoint():
data = request.get_json(silent=True) or {}
refresh_raw = data.get("refresh_token", "")
return refresh_access_token(refresh_raw)
@app.route("/api/v1/auth/revoke", methods=["POST"])
@token_required
def revoke_refresh_token():
data = request.get_json(silent=True) or {}
refresh_raw = data.get("refresh_token", "")
if not refresh_raw:
return jsonify({"error": "refresh_token_required"}), 400
token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest()
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE token_hash = %s",
(token_hash,),
)
cur.close()
conn.close()
log_audit(g.user_id, "refresh_token_revoked", token_hash[:8])
return jsonify({"status": "revoked"})
except Exception as e:
logger.error(f"Revoke refresh token failed: {e}")
return jsonify({"error": "revoke_failed"}), 500
def refresh_access_token(refresh_raw):
if not refresh_raw:
return jsonify({"error": "refresh_token_required"}), 400
token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest()
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""SELECT id, user_id, revoked, expires_at
FROM refresh_tokens WHERE token_hash = %s""",
(token_hash,),
)
row = cur.fetchone()
if not row:
cur.close()
conn.close()
return jsonify({"error": "invalid_token"}), 401
if row[2]:
cur.close()
conn.close()
return jsonify({"error": "token_revoked"}), 403
if row[3] < datetime.now(timezone.utc):
cur.close()
conn.close()
return jsonify({"error": "token_expired"}), 403
refresh_id = row[0]
user_id = row[1]
cur.execute(
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE id = %s",
(refresh_id,),
)
pairs = generate_token_pair(user_id)
cur.close()
conn.close()
return jsonify(pairs), 200
except Exception as e:
logger.error(f"Refresh token failed: {e}")
return jsonify({"error": "refresh_failed"}), 500
def register_error_handlers(app):
@app.errorhandler(404)
def not_found(e):
return jsonify({"error": "not_found", "message": "Route not found"}), 404
@app.errorhandler(405)
def method_not_allowed(e):
return jsonify({"error": "method_not_allowed", "message": "Method not allowed"}), 405
@app.errorhandler(500)
def internal_error(e):
logger.error(f"Internal error: {e}")
return jsonify({"error": "internal_error", "message": "Internal server error"}), 500
if __name__ == "__main__":
logger.info("=" * 60)
logger.info("Token Broker API starting...")
logger.info(f"DB: {DB_HOST}:{DB_PORT}/{DB_NAME}")
logger.info(f"Port: {os.environ.get('TOKEN_BROKER_PORT', '8783')}")
logger.info("=" * 60)
init_db()
port = int(os.environ.get("TOKEN_BROKER_PORT", "8783"))
debug = os.environ.get("FLASK_ENV", "production") == "development"
app = create_app()
app.run(host="0.0.0.0", port=port, debug=debug)

View File

@@ -107,6 +107,34 @@ def run_analytics():
traceback.print_exc()
def run_sync_turf_db():
"""Synchronise turf.db vers turf_saas.db"""
logger.info("🔄 [SCHEDULER] Sync turf.db -> turf_saas.db...")
try:
import subprocess
result = subprocess.run(
[
"python3",
"/home/h3r7/turf_saas/sync_turf_db.py",
"--date",
datetime.now().strftime("%Y-%m-%d"),
],
capture_output=True,
text=True,
timeout=300,
)
if result.returncode == 0:
logger.info("✅ [SCHEDULER] Sync turf.db terminé")
else:
logger.error(f"❌ [SCHEDULER] Sync turf.db échoué: {result.stderr}")
except Exception as e:
logger.error(f"❌ [SCHEDULER] Erreur sync turf.db: {e}")
import traceback
traceback.print_exc()
def get_todays_race_time():
"""Récupère l'heure de la course principale du jour depuis la DB
Returns: timestamp en ms ou None
@@ -315,6 +343,16 @@ def main():
schedule.every().day.at("20:00").do(run_results).tag("results", "daily_fallback")
schedule.every().day.at("19:00").do(run_scraper).tag("scraper", "late_evening")
# Sync turf.db -> turf_saas.db (2x/jour: post-scraping + post-cotes)
schedule.every().day.at("11:00").do(run_sync_turf_db).tag("sync", "post_scraping")
schedule.every().day.at("17:00").do(run_sync_turf_db).tag("sync", "post_cotes")
# ML Cache: populate ml_predictions_cache après chaque sync
schedule.every().day.at("11:35").do(run_ml_cache).tag("ml_cache", "post_sync_am")
schedule.every().day.at("17:35").do(run_ml_cache).tag("ml_cache", "post_sync_pm")
schedule.every().day.at("09:30").do(run_ml_cache).tag("ml_cache", "morning")
schedule.every().day.at("13:30").do(run_ml_cache).tag("ml_cache", "pre_race")
schedule.every().sunday.at("02:00").do(run_ml).tag("ml", "weekly")
schedule.every().wednesday.at("02:00").do(run_ml).tag("ml", "midweek")
@@ -335,6 +373,200 @@ def main():
time.sleep(30)
def run_ml_cache():
"""Populate ml_predictions_cache with ensemble (predict_v2) predictions"""
logger.info("🤖 [SCHEDULER] Mise à jour cache prédictions ML (ensemble)...")
try:
os.chdir("/home/h3r7/turf_saas")
import predict_v2
model = predict_v2.load_ensemble()
if model is None:
logger.warning("⚠️ [SCHEDULER] Ensemble model not available, skipping")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
today = datetime.now().strftime("%Y-%m-%d")
rows = conn.execute("""
SELECT p.*, c.distance, c.discipline, c.specialite,
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule,
c.libelle as course_libelle, c.libelle_court as hippodrome,
c.heure_depart_str, c.parcours
FROM pmu_partants p
LEFT JOIN pmu_courses c ON p.date_programme = c.date_programme
AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
WHERE p.date_programme = ?
ORDER BY p.num_reunion, p.num_course, p.num_pmu
""", (today,)).fetchall()
if not rows:
logger.info(" [SCHEDULER] No partants today, skipping ML cache")
conn.close()
return
partants = [dict(r) for r in rows]
course_lookup = {}
for p in partants:
key = (p["num_reunion"], p["num_course"])
if key not in course_lookup:
course_lookup[key] = {
"libelle": p.get("course_libelle", ""),
"libelle_court": p.get("hippodrome", ""),
"discipline": p.get("discipline", ""),
"distance": p.get("distance", 0),
"heure_depart_str": p.get("heure_depart_str", ""),
}
odds_by_horse = {}
for p in partants:
odds_by_horse[(p["num_reunion"], p["num_course"], p["num_pmu"])] = p.get("cote_direct", 0)
preds = predict_v2.predict_top3(partants, model=model)
if not preds:
logger.warning("⚠️ [SCHEDULER] No predictions generated")
conn.close()
return
enriched = []
for p in preds:
key = (p.get("num_reunion"), p.get("num_course"))
ci = course_lookup.get(key, {})
odds_key = (p.get("num_reunion"), p.get("num_course"), p.get("num_pmu"))
enriched.append({
"num_reunion": p.get("num_reunion"),
"num_course": p.get("num_course"),
"horse_name": p.get("horse_name"),
"horse_number": p.get("num_pmu"),
"odds": odds_by_horse.get(odds_key, 0),
"prob_top1": p.get("prob_top1"),
"prob_top3": p.get("prob_top3"),
"ml_score": p.get("ml_score"),
"recommendation": p.get("recommendation"),
"is_value_bet": p.get("is_value_bet", 0),
"is_outlier": 0,
"race_label": f"R{p.get('num_reunion', 0)}C{p.get('num_course', 0)}",
"race_name": ci.get("libelle", ""),
"hippodrome": ci.get("libelle_court", ""),
"discipline": ci.get("discipline", ""),
"distance": ci.get("distance", 0),
"heure": ci.get("heure_depart_str", ""),
})
# Calculate risques per race (same logic as dashboard_api.calculate_risque)
from collections import defaultdict
race_horses = defaultdict(list)
for p in enriched:
rkey = (p.get("num_reunion"), p.get("num_course"))
race_horses[rkey].append({
"odds": p.get("odds", 999),
"ml_score": p.get("ml_score", 0),
"prob_top1": p.get("prob_top1", 0),
"prob_top3": p.get("prob_top3", 0),
})
race_risque = {}
for rkey, partants_list in race_horses.items():
label, score = _calc_risque(partants_list)
race_risque[rkey] = (label or "neutral", score or 50)
# Ensure table exists with all columns
conn.execute("""
CREATE TABLE IF NOT EXISTS ml_predictions_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL, num_reunion INTEGER, num_course INTEGER,
horse_name TEXT, horse_number INTEGER, odds REAL,
prob_top1 REAL, prob_top3 REAL, ml_score REAL,
recommendation TEXT, is_value_bet INTEGER DEFAULT 0,
is_outlier INTEGER DEFAULT 0, race_label TEXT, race_name TEXT,
hippodrome TEXT, discipline TEXT, distance REAL, heure TEXT,
model_version TEXT DEFAULT 'xgboost_v1',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
risque_label TEXT DEFAULT 'neutral', risque_score INTEGER DEFAULT 50,
UNIQUE(date, num_reunion, num_course, horse_name)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ml_cache_date ON ml_predictions_cache(date)")
try:
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_label TEXT DEFAULT 'neutral'")
except Exception:
pass
try:
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_score INTEGER DEFAULT 50")
except Exception:
pass
conn.execute("DELETE FROM ml_predictions_cache WHERE date = ?", (today,))
for p in enriched:
rkey = (p.get("num_reunion"), p.get("num_course"))
rl, rs = race_risque.get(rkey, ("neutral", 50))
conn.execute("""
INSERT INTO ml_predictions_cache
(date, num_reunion, num_course, horse_name, horse_number, odds,
prob_top1, prob_top3, ml_score, recommendation, is_value_bet, is_outlier,
race_label, race_name, hippodrome, discipline, distance, heure,
risque_label, risque_score, model_version)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
today, p.get("num_reunion"), p.get("num_course"),
p.get("horse_name"), p.get("horse_number"), p.get("odds"),
p.get("prob_top1"), p.get("prob_top3"), p.get("ml_score"),
p.get("recommendation"), p.get("is_value_bet", 0), p.get("is_outlier", 0),
p.get("race_label"), p.get("race_name"), p.get("hippodrome"),
p.get("discipline"), p.get("distance"), p.get("heure"),
rl, rs, "ensemble_v1",
))
conn.commit()
conn.close()
logger.info(f"✅ [SCHEDULER] ML cache mis à jour: {len(enriched)} prédictions pour {today}")
except Exception as e:
logger.error(f"❌ [SCHEDULER] Erreur ML cache: {e}")
import traceback
traceback.print_exc()
def _calc_risque(partants_list):
"""Same logic as dashboard_api.calculate_risque — kept local to avoid import side effects"""
if not partants_list:
return None, None
sorted_p = sorted(
partants_list,
key=lambda x: x.get("ml_score") or x.get("prob_top1") or 0,
reverse=True,
)
top1_score = sorted_p[0].get("ml_score") or sorted_p[0].get("prob_top1") or 0
top2_score = (
sorted_p[1].get("ml_score") or sorted_p[1].get("prob_top1") or 0
if len(sorted_p) > 1 else 0
)
gap_1_2 = top1_score - top2_score
nb_dangerous = sum(1 for p in sorted_p if (p.get("ml_score") or 0) > 40)
odds_fav = sorted(partants_list, key=lambda x: x.get("odds") or 999)
fav_odds = odds_fav[0].get("odds") or 999 if odds_fav else 999
fav_ml = (
odds_fav[0].get("ml_score") or odds_fav[0].get("prob_top1") or 0
if odds_fav else 0
)
fav_surprise = fav_odds < 5 and fav_ml < 25
if top1_score >= 65 and gap_1_2 >= 20:
score = min(100, int(50 + gap_1_2 * 1.5))
return "safe", score
if fav_surprise:
return "trap", max(10, int(35 - (25 - fav_ml)))
if nb_dangerous >= 4 and top1_score < 70:
return "trap", max(10, int(40 - nb_dangerous * 2))
if gap_1_2 < 8 and top2_score > 45:
return "trap", max(15, int(30 + gap_1_2))
score = min(64, max(35, int(35 + gap_1_2 * 1.2)))
return "neutral", score
def run_metrics_alerts():
"""Verifie les metriques du jour et envoie une alerte email si ROI > 1.0€"""
logger.info("📧 [SCHEDULER] Vérification alertes métriques...")