type
Design
title
Chat authorization, as built
description
Who may see which conversation — the BFF trust boundary, and the seam where the shared MCP token stops it being per-user.
tags
chat, auth, security, mermaid
status
stable
generated
crm-bot/deepseek-v4.1-flash · Fri Oct 30 2026 10:53:57 GMT+0100 (Central European Standard Time)
verified
process:live-probe · Fri Oct 30 2026 10:53:57 GMT+0100 (Central European Standard Time)
sources
runs → https://github.com/nextrum-sy/nx-master/blob/main/internal/web/runs.go; auth → https://github.com/nextrum-sy/nx-master/blob/main/internal/web/auth.go

nx-master — chat authorization, as built

One Go service is the whole trust boundary. The browser talks only to it; it owns the session, the conversation and the transcript, and it is the only holder of the agent API key. The agent, in turn, holds one MCP token that is identical for every person on the box — which is the seam this drawing exists to make obvious.

flowchart TD subgraph client["Browser"] ui["chat — HTMX<br/>live bubble polls /ui/runs/{id} every 1s"] ck["cookies<br/>nx_auth · nx_session"] end subgraph edge["nginx :443"] tls["TLS · master.nextrum-sy.de only<br/>unknown Host → 444 · HSTS"] end subgraph app["nx-master (Go) — the trust boundary"] protect["Protect · GitHub OAuth<br/>cookie → user (login · role · id)"] owner["GetChatSession(thread, userID)<br/>another user's thread → 404"] runs["RunRegistry · in memory<br/>3 concurrent/user · WaitGroup on shutdown"] sql[("SQLite<br/>users · sessions<br/>chat_sessions · messages<br/>customers")] tel[("OTLP JSONL<br/>logs · traces · metrics")] end subgraph agent["Hermes agent — one process"] api["API server :8642<br/>bearer API_SERVER_KEY · SSE"] model["deepseek-v4.1-flash<br/>via opencode-go"] mcpclient["MCP client<br/>ONE static token, resolved at connect"] end subgraph mcpsrv["MCP server /mcp"] mcpAuth["bearer MCP_TOKEN<br/>principal = bot, not a person"] tools["13 tools<br/>telemetry · logs · traces · metrics<br/>customers · users.* · roles.*<br/><b>no chats.*</b>"] end ui -->|"POST /ui/chat · session + prompt"| tls tls --> protect --> owner --> runs ck -.-> protect runs <--> sql runs -->|"packed history + 'Caller: <login> (role <r>)'"| api api --> model model -->|"tool call"| mcpclient mcpclient -->|"POST /mcp + shared MCP_TOKEN"| mcpAuth --> tools tools --> sql tools --> tel api -.->|"SSE · reasoning_content · tool · content"| runs runs -.->|"poll · thinking · tool chips · answer"| ui runs --> tel classDef seam fill:#3b1d1d,stroke:#f85149,color:#ffdede class mcpAuth,mcpclient seam

What holds it together

The seam — why this is not per-user authorization

Steps inside app are per-person and enforced. Everything from mcpclient outward is per-machine: /mcp authenticates a token that identifies the bot, so it cannot tell whose turn it is serving.

The identity block narrows this by instruction, not by construction. For two co-owners that is a reasonable trade; it is not a boundary.

This is being replaced by one bot per user — see the bot design. Each user gets their own Hermes profile with their own MCP token, so the credential that reaches /mcp already belongs to exactly one person and the question "whose turn is this?" never has to be asked. chats.list and chats.messages are deleted in the meantime: the UI renders conversations from SQLite, and not having the tool is stronger than scoping it. With two co-owners on the box, that removal alone closes the gap today.

Rendered from nx-master/chat-authorization.md · OKF bundle · regenerate with npm run render