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

721 lines
24 KiB
Markdown

# 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_IP_CHECK` | `10.4.0.0/22,auto` | Onsite matchers (IP/CIDR/DDNS/`auto`/`disabled`) for offsite 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
```json
// 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
```
### Cookie Details
- **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
```json
{
"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`:
```ts
export default defineConfig({
base: '/kitchen/', // <-- add this
plugins: [react()],
})
```
`src/App.tsx` (or wherever BrowserRouter is):
```tsx
<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`:
```js
module.exports = { basePath: '/hk' }
```
### 2. Nginx Config (for apps with their own nginx frontend)
Replace the `location /` block with a path-aware version:
```nginx
# 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):
```python
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:
```yaml
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
```js
// 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:
```nginx
location /kitchen/health {
proxy_pass http://backend:8000/health;
}
```
### 6. Docker Compose Template
```yaml
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)
```bash
# 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:
```json
{
"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):
```tsx
// 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:
```tsx
// 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:
```js
// 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:
```js
// 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.
```bash
# 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):
```bash
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):
```bash
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):
```bash
rsync -az --delete /backups/ user@yourserver.com:/backups/hnf-proxmox/
```
**Retention cleanup** (run after each backup cycle):
```bash
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:
```bash
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):
```nginx
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.
### Offsite restriction in an app (shared `ip-check.js`)
Each app enforces the offsite rule locally for users without `offsite_allowed`.
Copy `ip-check.js` (from the `auth` or `noticeboard` repo) into the app and call
`isOnsite(clientIP)`:
```js
import { isOnsite } from './ip-check.js'
// ...after verifying the cookie:
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) return reply.status(403).send({ error: 'Access restricted to site network' })
}
```
`OFFICE_IP_CHECK` is a comma-separated list; a request is onsite if ANY matcher
matches. Each matcher: an IP, a CIDR, a DDNS hostname, or `auto` (self-detect the
site's public IP via an external service — no DDNS client needed). Recommended
for a dynamic public IP: `10.4.0.0/22,auto` (LAN clients match the CIDR;
public-IP/hairpin clients match `auto`). `disabled` turns the check off.
---
## Portal Registration
When a new app is ported and running, register it in the auth service DB:
```sql
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):
```sql
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:
```bash
# 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`.