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,
}
# 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
+30
View File
@@ -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,
}