Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
048b46af41 | ||
|
|
96a014ecbe | ||
|
|
15331cdccf | ||
|
|
d323be1088 |
@@ -0,0 +1,4 @@
|
||||
# cloud-service
|
||||
|
||||
Read [docs/README.md](docs/README.md) (pack rules) and the global [../docs/README.md](../docs/README.md) (Working Rules) before making changes.
|
||||
This is its own git repo. Commit here first, then bump the pointer in the parent `xenia-pos` repo.
|
||||
@@ -19,6 +19,10 @@ class Settings(BaseSettings):
|
||||
ADMIN_USERNAME: str = "sysadmin"
|
||||
ADMIN_PASSWORD: str = "changeme"
|
||||
LATEST_VERSION: str = "0.0.0"
|
||||
# Ed25519 private key (base64, raw 32 bytes) that signs sites' license tokens.
|
||||
# Generated once; the matching public key is built into client-services
|
||||
# (local_backend/services/license.py). Keep secret, back up.
|
||||
LICENSE_SIGNING_KEY: str = ""
|
||||
# Manager JWT (separate secret from admin JWT)
|
||||
MANAGER_JWT_SECRET: str = "change-me-manager-secret"
|
||||
MANAGER_JWT_EXPIRE_HOURS: int = 72
|
||||
|
||||
@@ -8,3 +8,4 @@ passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.9
|
||||
httpx==0.27.2
|
||||
cryptography==46.0.4
|
||||
|
||||
@@ -7,6 +7,7 @@ from config import settings
|
||||
from database import get_db
|
||||
from models.site import Site
|
||||
from schemas.site import HeartbeatRequest, HeartbeatResponse
|
||||
from services.license_token import issue_license_token
|
||||
|
||||
router = APIRouter()
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
@@ -40,4 +41,5 @@ def heartbeat(
|
||||
latest_version=settings.LATEST_VERSION,
|
||||
waiter_domain=site.waiter_domain,
|
||||
site_numeric_id=site.id,
|
||||
license_token=issue_license_token(site, now),
|
||||
)
|
||||
|
||||
@@ -73,3 +73,5 @@ class HeartbeatResponse(BaseModel):
|
||||
latest_version: str | None = None
|
||||
waiter_domain: str | None = None
|
||||
site_numeric_id: int | None = None # cloud DB pk — needed by Connect sync loops
|
||||
# Signed license the site stores and enforces offline (services/license_token.py)
|
||||
license_token: str | None = None
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Signed license tokens for on-site servers (client-services KI-006).
|
||||
|
||||
Every heartbeat answer carries a token the site stores and verifies with the
|
||||
public key built into its code. The site then enforces the license OFFLINE:
|
||||
it keeps running until `expires_at` (+ its grace), however long it is without
|
||||
internet, and nobody can extend it by editing the stored copy.
|
||||
|
||||
Format: <base64url(payload JSON)>.<base64url(Ed25519 signature of the first part)>
|
||||
Payload: { v: 1, site_id, active, locked, lock_reason, expires_at, issued_at }
|
||||
issued_at = the cloud's clock — the site uses it as a floor against clock rollback.
|
||||
|
||||
Key: LICENSE_SIGNING_KEY in the cloud .env (base64 raw Ed25519 private key,
|
||||
generated once; see docs). Without it, heartbeats carry no token — old-style
|
||||
behaviour — and a warning is logged.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_key: Ed25519PrivateKey | None = None
|
||||
_warned = False
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def _signing_key() -> Ed25519PrivateKey | None:
|
||||
global _key, _warned
|
||||
if _key is None and settings.LICENSE_SIGNING_KEY:
|
||||
_key = Ed25519PrivateKey.from_private_bytes(base64.b64decode(settings.LICENSE_SIGNING_KEY))
|
||||
if _key is None and not _warned:
|
||||
logger.warning("LICENSE_SIGNING_KEY not set — heartbeats carry no signed license token")
|
||||
_warned = True
|
||||
return _key
|
||||
|
||||
|
||||
def _iso(dt: datetime) -> str:
|
||||
return (dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)).astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def issue_license_token(site, now: datetime) -> str | None:
|
||||
key = _signing_key()
|
||||
if key is None:
|
||||
return None
|
||||
payload = {
|
||||
"v": 1,
|
||||
"site_id": site.site_id,
|
||||
"active": bool(site.is_active),
|
||||
"locked": bool(site.is_locked),
|
||||
"lock_reason": site.lock_reason,
|
||||
"expires_at": _iso(site.license_expires_at),
|
||||
"issued_at": _iso(now),
|
||||
}
|
||||
body = _b64url(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode())
|
||||
return f"{body}.{_b64url(key.sign(body.encode()))}"
|
||||
@@ -0,0 +1,72 @@
|
||||
# cloud-service — VPS Pack
|
||||
|
||||
Everything that runs **on the developer's VPS** and serves all client sites.
|
||||
This folder is its **own git repo**, nested inside the `xenia-pos` parent repo.
|
||||
|
||||
> **Read the global rules first:** [../../docs/README.md](../../docs/README.md). This file adds only the rules specific to this pack.
|
||||
> The feature catalog, the local ↔ cloud contract, known issues and site records all live in the parent's `docs/`.
|
||||
|
||||
---
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Stack | Host port | Audience |
|
||||
|---|---|---|---|
|
||||
| `cloud_backend` | FastAPI + SQLAlchemy + SQLite (`${BACKEND_DATA}`) | 8001 | local sites (heartbeat/sync), sysadmin, connect apps |
|
||||
| `sysadmin_panel` | React + Vite (`VITE_CLOUD_URL` baked in at build) | 5175 | the developer only |
|
||||
| `connect_frontend` | nginx serving two SPAs: `menu-app` at `/menu/`, `manager-app` at `/manage/` | 3100 | restaurant customers (QR menu), remote managers |
|
||||
|
||||
TLS and domains are handled in front of these ports on the VPS (`xenia-api.bonamin.net` → cloud_backend, `xenia-admin.bonamin.net` → sysadmin panel, `registry.bonamin.net` → image registry).
|
||||
|
||||
## Routers (`cloud_backend/routers/`)
|
||||
|
||||
| Prefix | File | Auth | Used by |
|
||||
|---|---|---|---|
|
||||
| `/api/auth` | `auth.py` | admin login | sysadmin panel |
|
||||
| `/api/sites` | `sites.py` | admin bearer | sysadmin panel |
|
||||
| `/api/heartbeat` | `heartbeat.py` | `X-Site-ID` + `X-Site-Key` | local sites |
|
||||
| `/api/menu` | `menu.py` | public GET; site headers for sync | menu-app, local sites |
|
||||
| `/api/orders` | `orders.py` | public create/status; site headers for pending/synced/status | menu-app, local sites |
|
||||
| `/api/manager` | `manager_auth.py` | manager bearer | manager-app, sysadmin |
|
||||
| `/api/remote` | `remote_dashboard.py` | manager bearer; site headers for `POST /snapshot` | manager-app, local sites |
|
||||
|
||||
---
|
||||
|
||||
## Pack-specific rules
|
||||
|
||||
**CL-1. Old sites are always talking to you.**
|
||||
Sites upgrade slowly, and some may be several versions behind. Every endpoint a site calls (the contract, C-1 … C-7) must stay backward compatible:
|
||||
- add optional fields only
|
||||
- never rename, remove or retype fields
|
||||
- never make a new request field required
|
||||
|
||||
Any change to one of these endpoints updates the parent's `docs/reference/cloud-contract.md` (global Rule 6).
|
||||
|
||||
**CL-2. Schema changes go through `_run_migrations()`** in `cloud_backend/main.py`: additive `ALTER TABLE ... ADD COLUMN` only, and never drop columns. Superseded columns stay, with a comment (e.g. `menu_hours_en`).
|
||||
|
||||
**CL-3. The cloud is never on the service-critical path.**
|
||||
If the cloud is down, restaurants must keep taking orders (global Rule 8). Don't design features where the local site blocks while waiting on the cloud.
|
||||
|
||||
**CL-4. Public endpoints are hostile territory.**
|
||||
`menu-app` endpoints are reachable by anyone on the internet. Validate everything, rate-limit where it makes sense, and never return data from another site. Site secrets (`SITE_KEY`) are shown **once**, at registration.
|
||||
|
||||
**CL-6. The license signing key is permanent.**
|
||||
`LICENSE_SIGNING_KEY` (cloud `.env`) signs every site's license. Its public half is built into the site code. Never regenerate or lose it: a new key means every site needs a new image. Keep the backup at `%USERPROFILE%\.xenia\license_signing_key.txt` safe. Never add fields to the token payload that old sites would misread; bump `v` for incompatible changes.
|
||||
|
||||
**CL-5. Frontend URLs are baked in at build time.**
|
||||
`VITE_CLOUD_URL` is a build arg, so changing it means rebuilding the image, not restarting it.
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
# Deploy on the VPS
|
||||
cd ~/stacks/xenia-pos-cloud && docker compose up -d --build
|
||||
|
||||
# Dev
|
||||
cd cloud_backend && uvicorn main:app --reload --port 8001
|
||||
cd sysadmin_panel && npm run dev
|
||||
cd connect_frontend/menu-app && npm run dev
|
||||
cd connect_frontend/manager-app && npm run dev
|
||||
```
|
||||
|
||||
Releasing a new client version also means bumping `LATEST_VERSION` in `cloud_backend/.env` and redeploying. See the parent's `DEPLOYMENT_GUIDE.md`.
|
||||
@@ -328,7 +328,9 @@ export default function SiteDetailPage() {
|
||||
{/* Waiter Domain */}
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Waiter Domain</h2>
|
||||
{/* Retired per-client domain setup (client-services plan step 9) — kept only for sites
|
||||
not yet migrated; new sites use http://<LAN IP> and the pairing QR in the manager */}
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Waiter Domain (legacy — not needed for new sites)</h2>
|
||||
<button
|
||||
onClick={() => { setNewDomain(site.waiter_domain || ''); setModal('domain') }}
|
||||
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user