# Multi-Agent SIP Credentials + Duplicate Login Lockout **Date**: 2026-03-23 **Status**: Approved design --- ## Problem Single Ozonetel agent account (`global`) and SIP extension (`523590`) shared across all CC agents. When multiple agents log in, calls route to whichever browser registered last. No way to have multiple simultaneous CC agents. ## Solution Per-agent Ozonetel credentials stored in the platform's Agent entity, resolved on login. Redis-backed session locking prevents duplicate logins. Frontend SIP provider uses dynamic credentials from login response. --- ## 1. Data Model **Agent entity** (already created on platform via admin portal): | Field (GraphQL) | Type | Purpose | |---|---|---| | `wsmemberId` | Relation | Links to workspace member | | `ozonetelagentid` | Text | Ozonetel agent ID (e.g. "global", "agent2") | | `sipextension` | Text | SIP extension number (e.g. "523590") | | `sippassword` | Text | SIP auth password | | `campaignname` | Text | Ozonetel campaign (e.g. "Inbound_918041763265") | Custom fields use **all-lowercase** GraphQL names. One Agent record per CC user. --- ## 2. Sidecar Changes ### 2.1 Redis Integration Add `ioredis` dependency to `helix-engage-server`. Connect to `REDIS_URL` (default `redis://redis:6379`). New service: `src/auth/session.service.ts` ``` lockSession(agentId, memberId) → SET agent:session:{agentId} {memberId} EX 3600 isSessionLocked(agentId) → GET agent:session:{agentId} → returns memberId or null refreshSession(agentId) → EXPIRE agent:session:{agentId} 3600 unlockSession(agentId) → DEL agent:session:{agentId} ``` ### 2.2 Auth Controller — Login Flow Modify `POST /auth/login`: 1. Authenticate with platform → get JWT + user profile + workspace member ID 2. Determine role (same as today) 3. **If CC agent:** a. Query platform: `agents(filter: { wsmemberId: { eq: "" } })` using server API key b. No Agent record → `403: "Agent account not configured. Contact administrator."` c. Check Redis: `isSessionLocked(agent.ozonetelagentid)` d. Locked by different user → `409: "You are already logged in on another device. Please log out there first."` e. Locked by same user → refresh TTL (re-login from same browser) f. Not locked → `lockSession(agent.ozonetelagentid, memberId)` g. Login to Ozonetel with agent's specific credentials h. Return `agentConfig` in response 4. **If manager/executive:** No Agent query, no Redis, no SIP. Same as today. **Login response** (CC agent): ```json { "accessToken": "...", "refreshToken": "...", "user": { "id": "...", "role": "cc-agent", ... }, "agentConfig": { "ozonetelAgentId": "global", "sipExtension": "523590", "sipPassword": "523590", "sipUri": "sip:523590@blr-pub-rtc4.ozonetel.com", "sipWsServer": "wss://blr-pub-rtc4.ozonetel.com:444", "campaignName": "Inbound_918041763265" } } ``` SIP domain (`blr-pub-rtc4.ozonetel.com`) and WS port (`444`) remain from env vars — these are shared infrastructure, not per-agent. ### 2.3 Auth Controller — Logout Modify `POST /auth/logout` (or add if doesn't exist): 1. Resolve agent from JWT 2. `unlockSession(agent.ozonetelagentid)` 3. Ozonetel agent logout ### 2.4 Auth Controller — Heartbeat New endpoint: `POST /auth/heartbeat` 1. Resolve agent from JWT 2. `refreshSession(agent.ozonetelagentid)` → extends TTL to 1 hour 3. Return `{ status: 'ok' }` ### 2.5 Agent Config Cache On login, store agent config in an in-memory `Map`. All Ozonetel controller endpoints currently use `this.defaultAgentId`. Change to: 1. Resolve workspace member from JWT (already done in worklist controller's `resolveAgentName`) 2. Lookup agent config from the in-memory map 3. Use the agent's `ozonetelagentid` for Ozonetel API calls This avoids querying Redis/platform on every API call. Clear the cache entry on logout. ### 2.6 Config New env var: `REDIS_URL` (default: `redis://redis:6379`) Existing env vars (`OZONETEL_AGENT_ID`, `OZONETEL_SIP_ID`, etc.) become fallbacks only — used when no Agent record exists (backward compatibility for dev). --- ## 3. Frontend Changes ### 3.1 Store Agent Config On login, store `agentConfig` from the response in localStorage (`helix_agent_config`). On logout, clear it. ### 3.2 SIP Provider `sip-provider.tsx`: Read SIP credentials from stored `agentConfig` instead of env vars. ``` const agentConfig = JSON.parse(localStorage.getItem('helix_agent_config')); const sipUri = agentConfig?.sipUri ?? import.meta.env.VITE_SIP_URI; const sipPassword = agentConfig?.sipPassword ?? import.meta.env.VITE_SIP_PASSWORD; const sipWsServer = agentConfig?.sipWsServer ?? import.meta.env.VITE_SIP_WS_SERVER; ``` If no `agentConfig` and no env vars → don't connect SIP. ### 3.3 Heartbeat Add a heartbeat interval in `AppShell` (only for CC agents): - Every 5 minutes: `POST /auth/heartbeat` - If heartbeat fails with 401 → session expired, redirect to login ### 3.4 Login Error Handling Handle new error codes from login: - `403` → "Agent account not configured. Contact administrator." - `409` → "You are already logged in on another device. Please log out there first." ### 3.5 Logout On logout, call `POST /auth/logout` before clearing tokens (so sidecar can clean up Redis + Ozonetel). --- ## 4. Docker Compose Add `REDIS_URL` to sidecar environment in `docker-compose.yml`: ```yaml sidecar: environment: REDIS_URL: redis://redis:6379 ``` --- ## 5. Edge Cases - **Sidecar restart**: Redis retains session locks. Agent config cache is lost but rebuilt on next API call (query Agent entity lazily). - **Redis restart**: All session locks cleared. Agents can re-login. Acceptable — same as TTL expiry. - **Browser crash (no logout)**: Heartbeat stops → Redis key expires in ≤1 hour → lock clears. - **Same user, same browser re-login**: Detected by comparing `memberId` in Redis → refreshes TTL instead of blocking. - **Agent record deleted while logged in**: Next Ozonetel API call fails → sidecar clears cache → agent gets logged out.