stack-init/docs/APP-INTEGRATION-GUIDE.md

23 KiB

App Integration Guide — HNF Stack

This document is the reference for porting existing apps into the HNF Proxmox stack. Each app gets its own porting session — read this first before starting any port.


Multi-Site Design Principle

This stack is designed to deploy identically across multiple hotel Proxmox hosts. The internal container network (10.10.10.0/24) is the same at every site — it is isolated inside each Proxmox host and has no conflict with the hotel's own LAN subnet.

All docker-compose files, DATABASE_URLs, and internal service references use 10.10.10.x addresses and are therefore identical across all sites.

Only these values differ per site, set via env vars at provision time:

Env var Example (HNF) Notes
DOMAIN manage.hotelnumberfour.com Per-site domain for NPM + auth cookie
OFFICE_PUBLIC_IP x.x.x.x Site WAN IP for offsite access restriction
LAN_SUBNET 10.4.0.0/22 Used when assigning NPM LXC's LAN IP

Forgejo deploy webhooks: each hotel's management container registers its own webhook in the shared Forgejo repo. A push to main fires to all registered hotels simultaneously — all sites update in parallel. To stage a rollout, temporarily disable a site's webhook.


Stack Overview

Hotel LAN (any subnet — e.g. 10.4.0.0/22 at HNF, 192.168.x.x elsewhere)
  │
  └── LXC: NPM  <LAN IP from site pool>   ← only container with a LAN IP
        │   <DOMAIN> — single SSL cert
        │
        └── Internal network (vmbr1: 10.10.10.0/24) — SAME at every site
              ├── 10.10.10.100  PostgreSQL       :5432  (internal only)
              ├── 10.10.10.101  Auth service     :3001
              ├── 10.10.10.102  Portal           :3000
              ├── 10.10.10.105  Management       :3002 (Uptime Kuma → /monitor/)
              │                                  :9000 (Forgejo webhook, internal only)
              ├── 10.10.10.110  Kitchen Flash    :3080
              ├── 10.10.10.114  Housekeeping     :3014
              └── 10.10.10.1xx  (future apps — same IPs at all sites)

NPM LXC is the only container with a LAN IP. All others are on the internal vmbr1 bridge — invisible from the hotel LAN. NPM proxies paths to internal LXC IPs.

External Forgejo (on developer's own server) is the source of truth for all app repos. The management container's update service receives webhooks from it and deploys to app LXCs.

  • All apps share a single PostgreSQL instance (LXC .100) — each app gets its own database.
  • Auth is enforced on each app independently via a shared httpOnly cookie (hnf_session).
  • The portal shell loads app UIs in <iframe> elements — same origin, no CORS issues.

Central Auth Service

Base URL (internal): http://10.10.10.101:3001 (same at all sites) Base URL (via NPM): https://<DOMAIN>/api/auth (site-specific, e.g. manage.hotelnumberfour.com)

Endpoints

Method Path Description
POST /api/auth/login Email + password → sets hnf_session httpOnly cookie
POST /api/auth/logout Clears cookie
GET /api/auth/me Returns user profile + apps[] permission list
GET /api/auth/verify?app=<slug> Validate cookie + check app permission + IP restriction

/api/auth/verify Response

// 200 OK — proceed
{
  "user_id": 1,
  "email": "jane@hotelnumberfour.com",
  "name": "Jane Smith",
  "is_admin": false,
  "app": "kitchen"
}

// 401 — no valid session cookie
// 403 — valid session but no permission for this app, OR offsite restriction
  • Name: hnf_session
  • Domain: .manage.hotelnumberfour.com (leading dot = all subpaths)
  • Flags: httpOnly, Secure, SameSite=Lax
  • Payload: { sub: "user@email.com", apps: ["kitchen","hk"], offsite_allowed: true, exp: ... }
  • Algorithm: HS256, secret is CENTRAL_AUTH_SECRET env var

JWT Payload Structure

{
  "sub": "user@hotelnumberfour.com",
  "name": "Jane Smith",
  "apps": ["kitchen", "hk", "cashup"],
  "offsite_allowed": true,
  "iat": 1234567890,
  "exp": 1234654290
}

Shared PostgreSQL

Host (internal LAN): 10.10.10.100:5432 Per-app connection string pattern: postgresql://<app>:<password>@10.10.10.100:5432/<app>_db

Each app gets:

  • Its own database (e.g. kitchen_db, cashup_db, hk_db)
  • Its own postgres user with access only to that database
  • Its own schema within that database (matching existing schema if migrating)

To migrate an existing app DB: export from app's current postgres, import into shared PG. Schema init SQL files live in infrastructure/postgres/init/<app>.sql in this repo.


Per-App Integration Checklist

1. Base Path Configuration

Every app must serve itself under its path prefix (e.g. /kitchen). NPM strips nothing — it proxies the full path to the app's port. The app must handle the prefix.

React/Vite apps (like Kitchen Flash):

vite.config.ts:

export default defineConfig({
  base: '/kitchen/',   // <-- add this
  plugins: [react()],
})

src/App.tsx (or wherever BrowserRouter is):

<BrowserRouter basename="/kitchen">

Any hardcoded API calls must use a base URL from an env var:

VITE_API_BASE=/kitchen

Python/FastAPI backends:

FastAPI doesn't need changes — the frontend nginx handles path stripping. Update nginx.conf (see section below).

Next.js apps:

next.config.js:

module.exports = { basePath: '/hk' }

2. Nginx Config (for apps with their own nginx frontend)

Replace the location / block with a path-aware version:

# In the app's nginx.conf — replace root location block
location /kitchen/ {
    alias /usr/share/nginx/html/;
    try_files $uri $uri/ /kitchen/index.html;
}

location /kitchen/api/ {
    proxy_pass http://backend:8000/api/;
    # ... existing proxy headers ...
}

location /kitchen/auth/ {
    proxy_pass http://backend:8000/auth/;
    # ... existing proxy headers ...
}

3. Auth Integration — Python/FastAPI Apps

Add to backend/auth/jwt.py (before the existing Bearer check):

from fastapi import Request, Cookie
from typing import Optional
import os

CENTRAL_AUTH_SECRET = os.getenv("CENTRAL_AUTH_SECRET")
APP_SLUG = os.getenv("APP_SLUG", "kitchen")  # set per-app in docker-compose

async def get_current_user(
    request: Request,
    credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
    db: AsyncSession = Depends(get_db)
) -> User:
    # 1. Try central auth cookie first
    central_token = request.cookies.get("hnf_session")
    if central_token and CENTRAL_AUTH_SECRET:
        try:
            payload = jwt.decode(central_token, CENTRAL_AUTH_SECRET, algorithms=["HS256"])
            email = payload.get("sub")
            if email and APP_SLUG in payload.get("apps", []):
                # Auto-create or find local user by email
                result = await db.execute(select(User).where(User.email == email))
                user = result.scalar_one_or_none()
                if not user:
                    user = User(
                        email=email,
                        name=payload.get("name", email),
                        password_hash="",  # no local password for SSO users
                        kitchen_id=1,       # adapt per app
                        is_admin=False,
                        is_active=True
                    )
                    db.add(user)
                    await db.commit()
                    await db.refresh(user)
                if user.is_active:
                    return user
        except Exception:
            pass  # fall through to Bearer check

    # 2. Fall back to existing Bearer token (backwards compat)
    if credentials:
        token = credentials.credentials
        user = await get_current_user_from_token(token, db)
        if user:
            return user

    raise HTTPException(status_code=401, detail="Not authenticated")

Add to docker-compose.yml backend environment:

environment:
  - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
  - APP_SLUG=kitchen
  - DATABASE_URL=postgresql://kitchen:${KITCHEN_DB_PASS}@10.10.10.100:5432/kitchen_db

Remove the db: service from docker-compose (now uses shared PG).

4. Auth Integration — Node/Express or Fastify Apps

// middleware/central-auth.js
const jwt = require('jsonwebtoken')

function centralAuth(appSlug) {
  return (req, res, next) => {
    const token = req.cookies?.hnf_session
    if (!token) return res.status(401).json({ error: 'Not authenticated' })
    
    try {
      const payload = jwt.verify(token, process.env.CENTRAL_AUTH_SECRET)
      if (!payload.apps?.includes(appSlug)) {
        return res.status(403).json({ error: 'No permission for this app' })
      }
      req.user = { email: payload.sub, name: payload.name }
      next()
    } catch {
      res.status(401).json({ error: 'Invalid session' })
    }
  }
}

5. Health Endpoint

Every app must expose GET /health returning { "status": "healthy" } with HTTP 200. This is what Uptime Kuma polls. For NPM routing it must be at the prefixed path: GET /kitchen/health should return 200.

Add to nginx.conf:

location /kitchen/health {
    proxy_pass http://backend:8000/health;
}

6. Docker Compose Template

services:
  backend:
    build: ./backend
    environment:
      - DATABASE_URL=postgresql://appname:${DB_PASS}@10.10.10.100:5432/appname_db
      - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
      - APP_SLUG=appname
    ports:
      - "8000:8000"   # internal only, NPM hits the frontend port
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"]
      interval: 10s
      retries: 5
    restart: unless-stopped

  frontend:
    build: ./frontend
    ports:
      - "3080:80"    # this port is what NPM proxies to
    depends_on:
      backend:
        condition: service_healthy
    restart: unless-stopped

7. Environment Variables (.env on each LXC)

# Required on every app LXC
CENTRAL_AUTH_SECRET=<shared secret from auth service — same value on all LXCs>
DB_PASS=<app-specific postgres password>

# App-specific
APP_SLUG=kitchen   # or cashup, hk, etc.

The CENTRAL_AUTH_SECRET value is generated once during auth service setup and copied to all app LXCs. Store it in the ops notes / password manager.


PWA Multi-Install Architecture

Each app in the stack is independently installable as a PWA while still running through the same auth and backend infrastructure. This lets different staff roles have a focused home-screen app without exposing anything directly.

Manager        →  installs manage.hotelnumberfour.com     →  "HNF Manage" (full portal)
Housekeeper    →  installs manage.hotelnumberfour.com/hk/  →  "Housekeeping" (HK only)
Kitchen staff  →  installs manage.hotelnumberfour.com/kitchen/  →  "Kitchen Flash"

All three use the same hnf_session cookie and central auth. The distinction is purely in which manifest.json the browser fetches when the user chooses "Add to Home Screen".

Per-App Manifest

Each app serves its own manifest.json at its root path. Key fields:

{
  "name": "Kitchen Flash",
  "short_name": "Kitchen",
  "start_url": "/kitchen/",
  "scope": "/",
  "display": "standalone",
  "theme_color": "#e85d04",
  "background_color": "#1a1a2e",
  "icons": [
    { "src": "/kitchen/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/kitchen/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

Critical: scope must be "/" (not "/kitchen/"). This keeps the login flow and any cross-app navigation inside the PWA context rather than opening the browser.

start_url controls where the app opens — this is the differentiator between installs.

Each app should have distinct theme_color and icons so installed apps are visually distinguishable on the home screen.

Suggested colour scheme:

App theme_color
Portal / HNF Manage #1e3a5f (navy)
Kitchen Flash #e85d04 (orange)
Housekeeping #2d6a4f (green)
Cashup #6b2d8b (purple)
Forecasting #0077b6 (blue)

Auth Within PWA Scope (no page-navigation login)

When a sub-app PWA opens and no session cookie exists, we cannot redirect to /login because that's outside the start_url path — in practice with scope: "/" it's fine, but the cleaner pattern is in-app auth so the experience stays seamless:

React auth wrapper pattern (add to every app's frontend):

// src/components/AuthGate.tsx
import { useEffect, useState } from 'react'

export function AuthGate({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  useEffect(() => {
    fetch('/api/auth/verify?app=APPSLUG', { credentials: 'include' })
      .then(r => setState(r.ok ? 'authed' : 'login'))
      .catch(() => setState('login'))
  }, [])

  async function login(e: React.FormEvent) {
    e.preventDefault()
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    })
    if (res.ok) {
      // Verify permission for this specific app
      const verify = await fetch('/api/auth/verify?app=APPSLUG', { credentials: 'include' })
      if (verify.ok) setState('authed')
      else setError("You don't have access to this app.")
    } else {
      setError('Invalid email or password')
    }
  }

  if (state === 'checking') return <div className="auth-loading">Loading</div>

  if (state === 'login') return (
    <div className="auth-screen">
      <img src="/kitchen/icons/icon-192.png" alt="App icon" />
      <h1>Kitchen Flash</h1>
      <form onSubmit={login}>
        <input type="email" value={email} onChange={e => setEmail(e.target.value)}
               placeholder="Email" required />
        <input type="password" value={password} onChange={e => setPassword(e.target.value)}
               placeholder="Password" required />
        {error && <p className="error">{error}</p>}
        <button type="submit">Sign in</button>
      </form>
    </div>
  )

  return <>{children}</>
}

Wrap the app root: <AuthGate><App /></AuthGate>

fetch calls are XHR — they don't navigate the page, so the PWA scope is never broken. The cookie is set domain-wide (manage.hotelnumberfour.com), so once the housekeeper logs in via the HK PWA, they won't need to log in again if they open another app.

Portal "Install" Shortcuts

The portal dashboard shows each permitted app as a tile. Each tile has a secondary "Install" action that navigates to /<app>/?install=1.

Each app detects this query param and triggers the install prompt:

// In app's main entry (e.g. main.tsx or App.tsx)
useEffect(() => {
  const params = new URLSearchParams(window.location.search)
  if (params.get('install') === '1') {
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault()
      ;(e as any).prompt()  // trigger install banner immediately
    }, { once: true })
  }
}, [])

The portal tile install button links to the app URL with ?install=1. On mobile, this triggers the browser's "Add to Home Screen" flow for that specific app manifest.

Portal can also generate a QR code or shareable install link per app for managers to distribute to relevant staff.

Three Install Tiers

Role Install URL PWA Name Access
Manager manage.hotelnumberfour.com HNF Manage All permitted apps in portal
Dept. head manage.hotelnumberfour.com/kitchen Kitchen Flash Kitchen only, full UI
Operative manage.hotelnumberfour.com/hk Housekeeping HK tasks only

All tiers use the same auth cookie — a user who has both Kitchen and HK permissions can install both and they'll both work from the same session.

Service Worker Scope Considerations

Each app's service worker must be registered with its path as scope to avoid conflicts between apps:

// In kitchen app's sw registration
navigator.serviceWorker.register('/kitchen/sw.js', { scope: '/kitchen/' })

The portal registers its service worker at /sw.js with default scope /. This caches the portal shell. App service workers cache their own assets independently.


WordPress Plugin → Standalone App

For plugins like the housekeeping tools:

  1. Identify the data: Find all $wpdb->query, $wpdb->get_results, custom table creates in the activator class. These become the app's DB schema.

  2. Identify the API surface: Find all wp_ajax_* hooks in the AJAX class. These become REST endpoints in the new backend.

  3. Identify the frontend: PHP views + JS files in /public/js/ describe the UI. Rewrite as React components.

  4. Newbook integration: If the plugin uses Newbook API, the integration logic is usually in a class-hhc-newbook-api.php or similar. Port the HTTP calls to Python requests or Node fetch.

  5. Auth: Plugins depend on WordPress user auth. In the new stack, auth comes from the central hnf_session cookie — no WordPress needed.

Reference files:

  • Hour calculator plugin: /home/jtr/laptop-archive/hotel-housekeeping-hour-calculator/
  • Housekeeping PWA plugin: /home/jtr/laptop-archive/housekeeping-pwa-app/

Management Container

LXC 105 runs three services:

Service Port Role
Uptime Kuma 3002 Health monitoring (exposed via NPM at /monitor/)
Update service 9000 Forgejo webhook receiver (internal network only — never via NPM)
Backup service Cron-based pg_dump + volume snapshots

App Updates via Forgejo Webhooks

Each app repo on your Forgejo server has a webhook pointing at the management container:

Webhook URL: http://10.10.10.105:9000/webhook
Secret: <shared secret — set in management container .env>
Events: Push (to main branch only)

The update service maps incoming repo names to target LXC IPs and SSH commands:

// management repo: updater/deploy-map.js
module.exports = {
  'kitchen-flash':   { ip: '10.10.10.110', path: '/opt/kitchen' },
  'housekeeping':    { ip: '10.10.10.114', path: '/opt/hk' },
  'cashup':          { ip: '10.10.10.111', path: '/opt/cashup' },
  'hnf-portal':      { ip: '10.10.10.102', path: '/opt/portal' },
  'hnf-auth':        { ip: '10.10.10.101', path: '/opt/auth' },
}

On receiving a valid webhook:

  1. Validate Forgejo HMAC signature against shared secret
  2. Look up repo name in deploy map
  3. SSH to target LXC: cd <path> && git pull && docker compose up -d --build
  4. Poll http://<ip>:<port>/health every 5s for up to 60s
  5. Log result (success/fail) — optionally notify via portal admin or webhook back to Forgejo commit status

Each app LXC must have the management container's SSH public key in ~/.ssh/authorized_keys. The management container's SSH key is generated at provisioning time and distributed to all LXCs.

Per-app Forgejo repo setup: Each app lives in its own repo on your Forgejo server. The main branch is production. The app LXC clones it at provision time; updates pull from it.

# On app LXC at provision time
git clone https://forgejo.yourserver.com/hnf/kitchen-flash.git /opt/kitchen

Backup Service

Runs as a cron container (docker-compose service with restart: unless-stopped and cron inside).

Schedule:

  • Daily at 02:00: pg_dump each database → compressed → rotate (keep 7 days)
  • Weekly Sunday at 03:00: full pg_dumpall + data volume snapshot → rotate (keep 4 weeks)
  • After each backup: rsync to remote destination

PostgreSQL backup (per database):

PGPASSWORD=$PG_PASS pg_dump -h 10.10.10.100 -U <app_user> <db_name> \
  | gzip > /backups/postgres/<db_name>_$(date +%Y%m%d).sql.gz

Volume backup (for apps with persistent file data, e.g. kitchen invoice PDFs):

docker run --rm \
  -v kitchen_invoice_data:/source:ro \
  -v /backups/volumes:/backup \
  alpine tar czf /backup/kitchen_invoices_$(date +%Y%m%d).tar.gz -C /source .

Rsync to remote (your own server, same place as Forgejo):

rsync -az --delete /backups/ user@yourserver.com:/backups/hnf-proxmox/

Retention cleanup (run after each backup cycle):

find /backups/postgres -name "*.gz" -mtime +7 -delete   # daily: keep 7
find /backups/postgres -name "*_weekly_*.gz" -mtime +28 -delete  # weekly: keep 4

Backup .env on management LXC:

PG_PASS=<postgres superuser password>
BACKUP_REMOTE_HOST=user@yourserver.com
BACKUP_REMOTE_PATH=/backups/hnf-proxmox
BACKUP_DATABASES=kitchen_db cashup_db hk_db forecast_db auth_db
BACKUP_VOLUMES=kitchen_invoice_data    # space-separated docker volume names

Backup status is reported to Uptime Kuma via a heartbeat push URL — if the backup script fails to complete, Kuma marks it down. Add a monitor of type "Push" in Kuma and paste the push URL into the backup script as the last step.


NPM LXC Config

NPM runs as a dual-homed LXC container — the only container with a LAN IP.

Proxmox LXC network config (in Proxmox UI or /etc/pve/lxc/<id>.conf):

net0: name=eth0,bridge=vmbr0,ip=10.4.X.X/22,gw=10.4.0.1   # LAN-facing — assign static IP from your pool
net1: name=eth1,bridge=vmbr1,ip=10.10.10.2/24                    # internal

NPM proxy host config (one entry per app, all on same domain):

Location Forward to Port Notes
/ 10.10.10.102 3000 Portal
/api/auth/ 10.10.10.101 3001 Auth service
/kitchen/ 10.10.10.110 3080 Kitchen (enable WS support — uses SSE for KDS)
/hk/ 10.10.10.114 3014 Housekeeping
/monitor/ 10.10.10.105 3002 Uptime Kuma (restrict to admin users via auth)

All under domain manage.hotelnumberfour.com. Single Let's Encrypt cert via NPM's built-in ACME.

Required NPM custom nginx snippet (add to each proxy host's Advanced tab):

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

The X-Real-IP header is what the auth service uses for offsite access restriction.


Portal Registration

When a new app is ported and running, register it in the auth service DB:

INSERT INTO auth.apps (slug, name, description, base_path, port)
VALUES ('kitchen', 'Kitchen Flash', 'Invoice processing and GP tracking', '/kitchen', 3080);

Then grant access to users via the portal admin UI (or directly in the DB during early setup):

INSERT INTO auth.user_app_perms (user_id, app_id)
SELECT u.id, a.id FROM auth.users u, auth.apps a
WHERE u.email = 'jane@hotelnumberfour.com' AND a.slug = 'kitchen';

LXC Provisioning

Each app LXC is provisioned with:

# Run infrastructure/lxc-templates/provision.sh <lxc-id> <ip> <app-name>
./infrastructure/lxc-templates/provision.sh 110 10.10.10.110 kitchen

This installs: Ubuntu 22.04, Docker, Docker Compose, copies .env template. Then git clone or rsync the app directory and docker-compose up -d.