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 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 11:50:29 +03:00
co-authored by Claude Opus 5.5
parent 9fcb4df30e
commit 2d47530069
2 changed files with 32 additions and 2 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ license_state: dict = {
"latest_version": None, "latest_version": None,
} }
# Paths that bypass all license checks (health probe) # Paths that bypass all license checks (health probe, venue identity for pairing)
EXEMPT_PATHS = {"/api/system/health"} EXEMPT_PATHS = {"/api/system/health", "/api/system/identity"}
# Paths that are always allowed so the frontend can read license status # Paths that are always allowed so the frontend can read license status
# and managers can still log in / close the workday when restricted # and managers can still log in / close the workday when restricted
+30
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import ipaddress import ipaddress
import json import json
import os
import socket import socket
import time import time
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@@ -13,6 +14,7 @@ from models.printer import Printer
from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
from routers.deps import get_current_user, require_manager, require_sysadmin from routers.deps import get_current_user, require_manager, require_sysadmin
from models.user import User from models.user import User
from models.settings import PosSettings
from models.product import Category, Product from models.product import Category, Product
from models.table import Table, TableGroup from models.table import Table, TableGroup
from services import printer_service from services import printer_service
@@ -30,6 +32,32 @@ def health():
return {"status": "ok", "version": settings.VERSION} 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") @router.get("/status")
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)): def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
from datetime import datetime, timezone 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), "sync_failed": license_state.get("sync_failed", False),
"last_sync": license_state.get("last_sync"), "last_sync": license_state.get("last_sync"),
"waiter_domain": license_state.get("waiter_domain"), "waiter_domain": license_state.get("waiter_domain"),
"site_id": settings.SITE_ID or None,
"lan_ip": _lan_ip(),
"printers": printer_statuses, "printers": printer_statuses,
} }