From 2d4753006900444f75c584ba321fd25603319e4b Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 28 Sep 2026 11:50:29 +0300 Subject: [PATCH] feat(backend): public /api/system/identity for pairing + site_id/lan_ip in status GET /api/system/identity (unauthenticated, license-exempt) returns {app: 'xenia-pos', site_id, venue_name, version, api_version: 1} so a phone can confirm which venue a server is when pairing and when rediscovering the server after an IP change. No secrets: SITE_KEY never leaves the server. /api/system/status now also returns site_id and lan_ip (HOST_IP only - in Docker any auto-detected address is the unreachable bridge IP). Co-Authored-By: Claude Opus 5.5 --- local_backend/middleware/license_check.py | 4 +-- local_backend/routers/system.py | 30 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/local_backend/middleware/license_check.py b/local_backend/middleware/license_check.py index d79ee82..45dcc3b 100644 --- a/local_backend/middleware/license_check.py +++ b/local_backend/middleware/license_check.py @@ -24,8 +24,8 @@ license_state: dict = { "latest_version": None, } -# Paths that bypass all license checks (health probe) -EXEMPT_PATHS = {"/api/system/health"} +# Paths that bypass all license checks (health probe, venue identity for pairing) +EXEMPT_PATHS = {"/api/system/health", "/api/system/identity"} # Paths that are always allowed so the frontend can read license status # and managers can still log in / close the workday when restricted diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py index bb1008a..98017d6 100644 --- a/local_backend/routers/system.py +++ b/local_backend/routers/system.py @@ -1,6 +1,7 @@ import asyncio import ipaddress import json +import os import socket import time from fastapi import APIRouter, Depends, HTTPException, Query @@ -13,6 +14,7 @@ from models.printer import Printer from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut from routers.deps import get_current_user, require_manager, require_sysadmin from models.user import User +from models.settings import PosSettings from models.product import Category, Product from models.table import Table, TableGroup from services import printer_service @@ -30,6 +32,32 @@ def health(): return {"status": "ok", "version": settings.VERSION} +# Bump when the waiter app <-> backend contract changes incompatibly, so native +# apps (which ship their own UI) can tell they're talking to a server they don't support. +API_VERSION = 1 + + +def _lan_ip() -> str | None: + """The address phones should use. Only HOST_IP is trusted: inside Docker any + auto-detection returns the container's bridge IP, which phones can't reach.""" + return os.environ.get("HOST_IP", "").strip() or None + + +@router.get("/identity") +def identity(db: Session = Depends(get_db)): + """Public, unauthenticated. Lets a phone confirm which venue a server is + (pairing, and rediscovery after the server's IP changes). No secrets here: + site_id is an identifier — the secret is SITE_KEY, which never leaves the server.""" + venue = db.query(PosSettings).filter(PosSettings.key == "venue.name").first() + return { + "app": "xenia-pos", + "site_id": settings.SITE_ID or None, + "venue_name": (venue.value if venue and venue.value else None), + "version": settings.VERSION, + "api_version": API_VERSION, + } + + @router.get("/status") def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)): from datetime import datetime, timezone @@ -82,6 +110,8 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren "sync_failed": license_state.get("sync_failed", False), "last_sync": license_state.get("last_sync"), "waiter_domain": license_state.get("waiter_domain"), + "site_id": settings.SITE_ID or None, + "lan_ip": _lan_ip(), "printers": printer_statuses, }