# 10 — Odoo 20 Integration (OWL Shell + Backend Addon + JWT + Nginx + Docker) This chapter covers the Odoo 20 integration layer in the MIT approach. **Critical finding**: Odoo's built-in WebSocket server is **unsuitable** for compositor-scale traffic. We need a separate aiohttp Python process for the compositor proxy. ## Why Odoo's built-in WebSocket server is unsuitable Odoo does **NOT** use Werkzeug's WebSocket support (Werkzeug has no first-party WS). Instead, Odoo ships its own pure-Python RFC 6455 implementation in `addons/bus/websocket.py` (~900 LOC) running in a **separate gevent worker process** on port 8072. ### Hard limits that disqualify it for compositor use - **`MESSAGE_MAX_SIZE = 2**20 = 1 MB`** per frame (cannot be configured) — too small for H.264 video frames + multiplexed channels - **Shared PostgreSQL connection pool** — sustained WS streaming starves other Odoo requests - **Single gevent process** — one Python GIL for ALL Discuss/chat/presence traffic - `ir.websocket` only dispatches two events: `subscribe` and `update_presence`. New event types require forking `addons/bus/websocket.py`. - Odoo.sh user confirmed edits to `ir.websocket` need gevent-worker restart ### Conclusion Run a **separate aiohttp Python process** (port 8081) for the compositor proxy. Route via Nginx for single-origin HTTPS. ## Recommended architecture ``` ┌─────────────────────────────────────────────┐ │ User's Browser │ │ ┌─────────────────────────────────────────┐ │ │ │ Odoo web client + OWL Compositor Shell │ │ │ │ (ir.actions.client, target=fullscreen) │ │ │ │ ┌──────────────────────────────────┐ │ │ │ │ │ + WebGL compositor lib │ │ │ │ │ │ (MIT-licensed browser compositor) │ │ │ │ │ └──────────────────────────────────┘ │ │ │ └─────────────────────────────────────────┘ │ └────────────────────┬────────────────────────┘ │ HTTPS (single origin: https://your-odoo.example.com) session_id cookie + JWT (in JS payload) │ ┌────────────────────▼────────────────────────┐ │ Nginx (TLS termination) │ │ / → Odoo :8069 (HTTP workers) │ │ /websocket → Odoo :8072 (gevent worker) │ │ /compositor/ → Proxy :8081 (aiohttp WS) │ └─────────┬──────────────────┬─────────────────┘ │ │ ┌────────────────┴─────────┐ ┌────▼───────────────────┐ │ Odoo 20 (HTTP) │ │ Odoo 20 (geevent) │ │ N preforked workers │ │ 1 process │ │ │ │ /websocket (Discuss, │ │ Controllers: │ │ chatter, presence) │ │ /web/compositor/token │ │ bus.bus notifications│ │ /web/compositor/apps │ │ (NOT for compositor!)│ │ /web/compositor/launch │ │ 1 MB cap, pgsql pool │ │ /web/compositor/terminate│ └───────────────────────┘ │ /web/compositor/audit │ │ Models: │ │ compositor.app │ │ compositor.session │ │ (mail.thread mixin) │ │ compositor.audit.log │ │ (append-only) │ │ JWT issuer (HS256 secret│ │ in ir.config_parameter)│ └────────────┬─────────────┘ │ ▼ ┌────────────────────────────────────────────────────┐ │ PostgreSQL (Odoo schema) │ └────────────────────────────────────────────────────┘ ▲ ┌────────────────────────────────────────────────┴──┐ │ Compositor Proxy (aiohttp, :8081) │ │ Async Python (asyncio + aiohttp) │ │ ┌────────────────────────────────────────────┐ │ │ │ WS server (aiohttp.web.Application) │ │ │ │ /signal, /channel, /mkfifo, /mkstemp-mmap,│ │ │ │ /read-fd, /write-fd-as-stream, │ │ │ │ /request-keyframe │ │ │ └────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────┐ │ │ │ JWT validator (pyjwt, HS256, secret env) │ │ │ └────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────┐ │ │ │ Per-session fork: pywayland + bwrap + Gst │ │ │ │ WAYLAND_DISPLAY=wayland-N, /dev/dri │ │ │ └────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────┐ │ │ │ Audit callback → Odoo /web/compositor/audit│ │ │ └────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────┘ ``` ## Auth bridging — JWT (HS256) is recommended ### Why JWT (not session cookie or OAuth2) - Odoo's `session_id` cookie reuse pattern is too fragile and adds Odoo round-trips per WS message - OAuth2 is overkill — we control both Odoo and the proxy - JWT (HS256, symmetric) is stateless — no DB lookup per frame, fast validation ### JWT payload ```json { "iss": "odoo.example.com", "aud": "compositor-proxy", "sub": 42, // Odoo user ID "db": "production", // Odoo database name "company_ids": [1, 3], "scope": "compositor", "compositor_session_id": "uuid-here", "iat": 1736000000, "exp": 1736000900 } ``` - 15-minute expiry - Signed with `compositor.jwt_secret` from Odoo's `ir.config_parameter` (shared via env var at proxy startup) - Validated by the proxy using `pyjwt` (MIT license) ### Issuance flow 1. OWL shell calls `/web/compositor/token` (Odoo controller) 2. Odoo backend signs JWT with the shared secret 3. Returns `{ jwt, proxy_url, expires_at }` to the OWL shell 4. OWL shell passes JWT to the compositor library 5. Compositor library includes `Authorization: Bearer ` on every WebSocket connection ## Multi-tenancy ### Recommended: one compositor proxy per Odoo database - `db` claim in JWT scopes every WS to one Odoo database - Per-session wayland socket (`$XDG_RUNTIME_DIR/wayland-N`) isolates Wayland displays - Per-session forked worker process (matches Greenfield's existing model) - For SaaS with strict SLA: Docker container per tenant + bubblewrap per session ### Optional: per-tenant Docker containers For strict isolation between tenants: - One Docker container per Odoo database, each running its own compositor proxy - Nginx routes based on the JWT's `db` claim - `--device /dev/dri:/dev/dri` for GPU passthrough - `--cpus` and `--memory` limits per container ## OWL shell integration — `ir.actions.client` with `target=fullscreen` ### Critical Odoo 20 / OWL 3.x gotcha `useState()` is **REMOVED** in Odoo 20. Must use `proxy()` from `@odoo/owl` instead. This is a breaking change from Odoo 19, verified by Cybrosys Sep 2026 blog + OWL migration guide gist. ### OWL shell component (pseudocode) ```typescript // compositor_shell/static/src/components/compositor_shell.js import { Component } from "@odoo/owl"; import { useService } from "@web/core/utils/hooks"; import { createCompositorSession } from "@odoo/compositor/compositor_lib"; export class CompositorShell extends Component { setup() { this.rpc = useService("rpc"); this.notification = useService("notification"); this.state = proxy({ surfaces: new Map(), // id → { title, appId, active } clients: new Map(), }); this.canvasRef = useRef("canvas"); onMounted(async () => { // 1. Get JWT from Odoo backend const { jwt, proxy_url, expires_at } = await this.rpc("/web/compositor/token"); this.jwt = jwt; this.proxyUrl = proxy_url; // 2. Initialize compositor session const session = createCompositorSession({ jwt, proxyUrl: proxy_url, }); this.session = session; // 3. Wire UserShellApi events to OWL reactive state session.userShell.events.clientCreated = (client) => { this.state.clients.set(client.id, { id: client.id }); }; session.userShell.events.clientDestroyed = (client) => { this.state.clients.delete(client.id); }; session.userShell.events.surfaceCreated = (surface) => { this.state.surfaces.set(surface.id, { title: "", appId: "", active: false, }); }; session.userShell.events.surfaceDestroyed = (surface) => { this.state.surfaces.delete(surface.id); }; session.userShell.events.surfaceTitleUpdated = (surface, title) => { const s = this.state.surfaces.get(surface.id); if (s) s.title = title; }; session.userShell.events.surfaceActivationUpdated = (surface, active) => { // Deactivate all, then activate the new one for (const s of this.state.surfaces.values()) { s.active = false; } const s = this.state.surfaces.get(surface.id); if (s) s.active = active; }; // NEW vs Greenfield: surface state changes session.userShell.events.surfaceStateChanged = (surface, state) => { const s = this.state.surfaces.get(surface.id); if (s) Object.assign(s, state); }; // 4. Initialize the canvas (OWL owns the lifecycle) const canvas = this.canvasRef.el; session.userShell.actions.initScene(() => ({ canvas, width: canvas.clientWidth, height: canvas.clientHeight, })); }); } // User clicks app in launcher async launchApp(appId) { try { const response = await this.rpc("/web/compositor/launch", { app_id: appId, }); // Backend returns { signal_url, pid, name } // The compositor library handles the rest (opens WS, etc.) } catch (e) { this.notification.add("Failed to launch app: " + e.message, { type: "danger" }); } } // User clicks taskbar entry activateSurface(surfaceId) { this.session.userShell.actions.activateSurface({ id: surfaceId }); } // User closes window closeSurface(surfaceId) { this.session.userShell.actions.closeSurface({ id: surfaceId }); } } CompositorShell.template = "compositor_shell.CompositorShell"; CompositorShell.target = "fullscreen"; // Odoo ir.actions.client target ``` ### Odoo action registration ```xml Compositor Shell compositor_shell fullscreen ``` ## Backend addon structure ### Models ```python # compositor_shell/models/compositor_app.py from odoo import models, fields, api class CompositorApp(models.Model): _name = "compositor.app" _description = "Compositor Application" name = fields.Char(required=True, translate=True) app_id = fields.Char(required=True, help="Unique identifier, e.g. 'gnome-calculator'") executable = fields.Char(required=True, help="Binary path, e.g. '/usr/bin/gnome-calculator'") icon = fields.Image() description = fields.Text() allowed_group_ids = fields.Many2many("res.groups", string="Allowed Groups") memory_limit_mb = fields.Integer(default=1024, help="Max memory per app instance (MB)") cpu_quota_percent = fields.Integer(default=200, help="CPU quota (100% = 1 core)") active = fields.Boolean(default=True) ``` ```python # compositor_shell/models/compositor_session.py from odoo import models, fields, api class CompositorSession(models.Model): _name = "compositor.session" _description = "Compositor Session" _inherit = ["mail.thread"] user_id = fields.Many2one("res.users", required=True, tracking=True) compositor_session_id = fields.Char(required=True, help="UUID") app_ids = fields.One2many("compositor.app.instance", "session_id", string="Apps") app_count = fields.Integer(compute="_compute_app_count") state = fields.Selection([ ("active", "Active"), ("disconnected", "Disconnected"), ("closed", "Closed"), ], default="active", tracking=True) started_at = fields.Datetime(default=fields.Datetime.now) ended_at = fields.Datetime() @api.depends("app_ids") def _compute_app_count(self): for record in self: record.app_count = len(record.app_ids) ``` ```python # compositor_shell/models/compositor_audit_log.py from odoo import models, fields class CompositorAuditLog(models.Model): _name = "compositor.audit.log" _description = "Compositor Audit Log (append-only)" _order = "create_date desc" user_id = fields.Many2one("res.users", required=True) app_id = fields.Char(required=True) action = fields.Selection([ ("launch", "App Launched"), ("terminate", "App Terminated"), ("crash", "App Crashed"), ], required=True) pid = fields.Integer() exit_code = fields.Integer() timestamp = fields.Datetime(default=fields.Datetime.now) def write(self, vals): raise UserError("Audit logs are append-only and cannot be modified.") def unlink(self): raise UserError("Audit logs are append-only and cannot be deleted.") ``` ### Controllers ```python # compositor_shell/controllers/main.py from odoo import http from odoo.http import request import jwt, datetime, uuid class CompositorController(http.Controller): @http.route("/web/compositor/token", type="json", auth="user") def get_token(self): user = request.env.user secret = request.env["ir.config_parameter"].sudo().get_param("compositor.jwt_secret") if not secret: raise UserError("Compositor JWT secret not configured") compositor_session_id = str(uuid.uuid4()) payload = { "iss": request.http.host, "aud": "compositor-proxy", "sub": user.id, "db": request.db, "company_ids": user.company_ids.ids, "scope": "compositor", "compositor_session_id": compositor_session_id, "iat": datetime.datetime.utcnow(), "exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=15), } token = jwt.encode(payload, secret, algorithm="HS256") # Create session record request.env["compositor.session"].sudo().create({ "user_id": user.id, "compositor_session_id": compositor_session_id, }) proxy_url = request.env["ir.config_parameter"].sudo().get_param( "compositor.proxy_url", default="http://localhost:8081" ) return { "jwt": token, "proxy_url": proxy_url, "expires_at": payload["exp"].isoformat(), "compositor_session_id": compositor_session_id, } @http.route("/web/compositor/apps", type="json", auth="user") def list_apps(self): user = request.env.user apps = request.env["compositor.app"].search_read( [("allowed_group_ids", "in", user.groups_id.ids), ("active", "=", True)], ["name", "app_id", "icon", "description"], ) return apps @http.route("/web/compositor/launch", type="json", auth="user") def launch_app(self, app_id): user = request.env.user app = request.env["compositor.app"].search([ ("app_id", "=", app_id), ("allowed_group_ids", "in", user.groups_id.ids), ], limit=1) if not app: raise UserError("App not found or not allowed") # Get JWT (re-issued here for the proxy call) # ... JWT issuance ... # Tell the compositor proxy to spawn the app # ... HTTP call to proxy ... # Audit log request.env["compositor.audit.log"].sudo().create({ "user_id": user.id, "app_id": app_id, "action": "launch", }) return {"signal_url": "...", "pid": 12345, "name": app.name} @http.route("/web/compositor/audit", type="json", auth="user") def audit_callback(self, **kwargs): """Called by the compositor proxy to report events.""" # Verify this is from the proxy (shared secret or JWT) # ... request.env["compositor.audit.log"].sudo().create({ "user_id": kwargs.get("user_id"), "app_id": kwargs.get("app_id"), "action": kwargs.get("action"), "pid": kwargs.get("pid"), "exit_code": kwargs.get("exit_code"), }) return {"ok": True} ``` ### Security ```xml Compositor / User Compositor / Manager Compositor App: User sees allowed apps only ["|", ("allowed_group_ids", "=", False), ("allowed_group_ids", "in", [g.id for g in user.groups_id])] ``` ## Nginx configuration ```nginx # /etc/nginx/sites-available/odoo-compositor server { listen 443 ssl http2; server_name your-odoo.example.com; ssl_certificate /etc/letsencrypt/live/your-odoo.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-odoo.example.com/privkey.pem; # Odoo HTTP workers location / { proxy_pass http://127.0.0.1:8069; proxy_set_header Host $host; 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; } # Odoo gevent worker (Discuss, chat, presence — NOT compositor!) location /websocket { proxy_pass http://127.0.0.1:8072; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # Compositor proxy (aiohttp, high-throughput WS) location /compositor/ { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # CRITICAL: disable buffering for low-latency video streaming proxy_buffering off; proxy_request_buffering off; # WebSocket keepalive proxy_read_timeout 86400s; proxy_send_timeout 86400s; } } ``` ## Docker Compose ```yaml # docker-compose.yml version: "3.8" services: odoo: image: odoo:20 ports: - "8069:8069" - "8072:8072" # gevent environment: - HOST=db - USER=odoo - PASSWORD=odoo volumes: - odoo-data:/var/lib/odoo - ./addons:/mnt/extra-addons depends_on: - db db: image: postgres:16 environment: - POSTGRES_DB=postgres - POSTGRES_USER=odoo - POSTGRES_PASSWORD=odoo volumes: - db-data:/var/lib/postgresql/data compositor-proxy: build: ./compositor-proxy ports: - "8081:8081" environment: - COMPOSITOR_JWT_SECRET=${COMPOSITOR_JWT_SECRET} - COMPOSITOR_BIND_IP=0.0.0.0 - COMPOSITOR_BIND_PORT=8081 - COMPOSITOR_ENCODER=vaapih264 # or nvh264 or openh264 - COMPOSITOR_RENDER_DEVICE=/dev/dri/renderD128 devices: - /dev/dri:/dev/dri # GPU passthrough volumes: - /tmp/.X11-unix:/tmp/.X11-unix # XWayland (if enabled) - compositor-runtime:/run/user/1000 # XDG_RUNTIME_DIR depends_on: - odoo nginx: image: nginx:latest ports: - "443:443" volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./certs:/etc/nginx/certs:ro depends_on: - odoo - compositor-proxy volumes: odoo-data: db-data: compositor-runtime: ``` ## Effort estimates | Component | Days | Notes | |---|---|---| | Odoo addon skeleton (models + controllers + views + security) | 2 | compositor.app, compositor.session, compositor.audit.log | | JWT issuance + audit log model | 1.5 | HS256, 15-min expiry, pyjwt | | OWL 3 fullscreen client action + canvas + WS wiring | 2.5 | ir.actions.client with target=fullscreen, **must use proxy() not useState()** | | Browser compositor library integration | 2 | userShell.actions.initScene(canvasCreator) | | Compositor proxy JWT middleware + WS handshake | 1.5 | pyjwt validation | | Audit POSTback + Nginx + Docker Compose + GPU passthrough | 2 | | | Multi-tenant: per-DB proxy spawning + db claim scoping | 1.5 | | | Tests (HttpCase + TransactionCase) | 2 | | | Documentation + buffer | 3 | | | **Total** | **~18 days** | (parallelizable to ~10 with 2 engineers) | Critical path: **~13 working days** for single-tenant MVP, **+5 days** for multi-tenant + bubblewrap isolation. ## File reference ### Odoo 20 backend addon | Path | Role | |---|---| | `compositor_shell/__manifest__.py` | Addon manifest | | `compositor_shell/models/compositor_app.py` | App catalog model | | `compositor_shell/models/compositor_session.py` | Session model (mail.thread) | | `compositor_shell/models/compositor_audit_log.py` | Append-only audit log | | `compositor_shell/controllers/main.py` | HTTP controllers (token, apps, launch, audit) | | `compositor_shell/security/compositor_security.xml` | Groups + rules | | `compositor_shell/security/ir.model.access.csv` | ACLs | | `compositor_shell/views/compositor_app_views.xml` | App management views | | `compositor_shell/views/compositor_session_views.xml` | Session views | | `compositor_shell/views/ir_actions_client_views.xml` | OWL action registration | | `compositor_shell/static/src/components/compositor_shell.js` | OWL shell component | | `compositor_shell/static/src/components/compositor_shell.xml` | OWL template | | `compositor_shell/static/src/components/compositor_shell.scss` | Styles | | `compositor_shell/static/src/lib/compositor_lib/` | Custom browser compositor library (clean-room TS) | ### Python compositor proxy | Path | Role | |---|---| | `compositor_proxy/main.py` | HTTP + WS hub, forks per session | | `compositor_proxy/session.py` | Top-level proxy session | | `compositor_proxy/session_controller.py` | aiohttp WS server with 9 endpoint paths | | `compositor_proxy/jwt_auth.py` | JWT validation (pyjwt) | | `compositor_proxy/native_app_context.py` | Per-app state, signaling WS, kill timers, bwrap spawn | | ... | (see `07-native-app-bridge.md` for full file list) | ### Nginx config | Path | Role | |---|---| | `nginx/sites-available/odoo-compositor` | Nginx reverse proxy with WS upgrade + proxy_buffering off | ### Docker | Path | Role | |---|---| | `docker-compose.yml` | Odoo + PostgreSQL + compositor-proxy + Nginx | | `compositor-proxy/Dockerfile` | Python proxy image with GPU passthrough |