# 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: "<memberId>" } })` 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<workspaceMemberId, AgentConfig>`.

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.
