# Multi-Agent SIP + Duplicate Login Lockout — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Per-agent Ozonetel/SIP credentials resolved from platform Agent entity on login, with Redis-backed duplicate login lockout. **Architecture:** Sidecar queries Agent entity on CC login, checks Redis for active sessions, returns per-agent SIP config. Frontend SIP provider uses dynamic credentials from login response. Heartbeat keeps session alive. **Tech Stack:** NestJS sidecar + ioredis + FortyTwo platform GraphQL + React frontend **Spec:** `docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md` --- ## File Map ### Sidecar (`helix-engage-server/src/`) | File | Action | Responsibility | |------|--------|----------------| | `auth/session.service.ts` | Create | Redis session lock/unlock/refresh | | `auth/agent-config.service.ts` | Create | Query Agent entity, cache agent configs | | `auth/auth.controller.ts` | Modify | Use agent config + session locking on login, add logout + heartbeat | | `auth/auth.module.ts` | Modify | Register new services, import Redis | | `config/configuration.ts` | Modify | Add `REDIS_URL` + SIP domain config | ### Frontend (`helix-engage/src/`) | File | Action | Responsibility | |------|--------|----------------| | `pages/login.tsx` | Modify | Store agentConfig, handle 403/409 errors | | `providers/sip-provider.tsx` | Modify | Read SIP config from agentConfig instead of env vars | | `components/layout/app-shell.tsx` | Modify | Add heartbeat interval for CC agents | | `lib/api-client.ts` | Modify | Add logout API call | | `providers/auth-provider.tsx` | Modify | Call sidecar logout on sign-out | ### Docker | File | Action | Responsibility | |------|--------|----------------| | VPS `docker-compose.yml` | Modify | Add `REDIS_URL` to sidecar env | --- ## Task 1: Install ioredis + Redis Session Service **Files:** - Modify: `helix-engage-server/package.json` - Create: `helix-engage-server/src/auth/session.service.ts` - Modify: `helix-engage-server/src/config/configuration.ts` - [ ] **Step 1: Install ioredis** ```bash cd helix-engage-server && npm install ioredis ``` - [ ] **Step 2: Add Redis URL to config** In `config/configuration.ts`, add to the returned object: ```typescript redis: { url: process.env.REDIS_URL ?? 'redis://localhost:6379', }, sip: { domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com', wsPort: process.env.SIP_WS_PORT ?? '444', }, ``` - [ ] **Step 3: Create session service** ```typescript // src/auth/session.service.ts import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; const SESSION_TTL = 3600; // 1 hour @Injectable() export class SessionService implements OnModuleInit { private readonly logger = new Logger(SessionService.name); private redis: Redis; constructor(private config: ConfigService) {} onModuleInit() { const url = this.config.get('redis.url', 'redis://localhost:6379'); this.redis = new Redis(url); this.redis.on('connect', () => this.logger.log('Redis connected')); this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`)); } private key(agentId: string): string { return `agent:session:${agentId}`; } async lockSession(agentId: string, memberId: string): Promise { await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL); } async isSessionLocked(agentId: string): Promise { return this.redis.get(this.key(agentId)); } async refreshSession(agentId: string): Promise { await this.redis.expire(this.key(agentId), SESSION_TTL); } async unlockSession(agentId: string): Promise { await this.redis.del(this.key(agentId)); } } ``` - [ ] **Step 4: Verify sidecar compiles** ```bash cd helix-engage-server && npm run build ``` - [ ] **Step 5: Commit** ```bash git add package.json package-lock.json src/auth/session.service.ts src/config/configuration.ts git commit -m "feat: Redis session service for agent login lockout" ``` --- ## Task 2: Agent Config Service **Files:** - Create: `helix-engage-server/src/auth/agent-config.service.ts` - [ ] **Step 1: Create agent config service** ```typescript // src/auth/agent-config.service.ts import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; export type AgentConfig = { id: string; ozonetelAgentId: string; sipExtension: string; sipPassword: string; campaignName: string; sipUri: string; sipWsServer: string; }; @Injectable() export class AgentConfigService { private readonly logger = new Logger(AgentConfigService.name); private readonly cache = new Map(); private readonly sipDomain: string; private readonly sipWsPort: string; constructor( private platform: PlatformGraphqlService, private config: ConfigService, ) { this.sipDomain = config.get('sip.domain', 'blr-pub-rtc4.ozonetel.com'); this.sipWsPort = config.get('sip.wsPort', '444'); } async getByMemberId(memberId: string): Promise { // Check cache first const cached = this.cache.get(memberId); if (cached) return cached; try { const data = await this.platform.query( `{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node { id ozonetelagentid sipextension sippassword campaignname } } } }`, ); const node = data?.agents?.edges?.[0]?.node; if (!node || !node.ozonetelagentid || !node.sipextension) return null; const agentConfig: AgentConfig = { id: node.id, ozonetelAgentId: node.ozonetelagentid, sipExtension: node.sipextension, sipPassword: node.sippassword ?? node.sipextension, campaignName: node.campaignname ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265', sipUri: `sip:${node.sipextension}@${this.sipDomain}`, sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`, }; this.cache.set(memberId, agentConfig); this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`); return agentConfig; } catch (err) { this.logger.warn(`Failed to fetch agent config: ${err}`); return null; } } getFromCache(memberId: string): AgentConfig | null { return this.cache.get(memberId) ?? null; } clearCache(memberId: string): void { this.cache.delete(memberId); } } ``` - [ ] **Step 2: Verify sidecar compiles** ```bash cd helix-engage-server && npm run build ``` - [ ] **Step 3: Commit** ```bash git add src/auth/agent-config.service.ts git commit -m "feat: agent config service with platform query + in-memory cache" ``` --- ## Task 3: Update Auth Module + Controller **Files:** - Modify: `helix-engage-server/src/auth/auth.module.ts` - Modify: `helix-engage-server/src/auth/auth.controller.ts` - [ ] **Step 1: Update auth module to register new services** Read `src/auth/auth.module.ts` and add imports: ```typescript import { SessionService } from './session.service'; import { AgentConfigService } from './agent-config.service'; import { PlatformModule } from '../platform/platform.module'; @Module({ imports: [PlatformModule], controllers: [AuthController], providers: [SessionService, AgentConfigService], exports: [SessionService, AgentConfigService], }) ``` - [ ] **Step 2: Rewrite auth controller login for multi-agent** Inject new services into `AuthController`: ```typescript constructor( private config: ConfigService, private ozonetelAgent: OzonetelAgentService, private sessionService: SessionService, private agentConfigService: AgentConfigService, ) { ... } ``` Modify the CC agent section of `login()` (currently lines 115-128). Replace the hardcoded Ozonetel login with: ```typescript if (appRole === 'cc-agent') { const memberId = workspaceMember?.id; if (!memberId) throw new HttpException('Workspace member not found', 400); // Look up agent config from platform const agentConfig = await this.agentConfigService.getByMemberId(memberId); if (!agentConfig) { throw new HttpException('Agent account not configured. Contact administrator.', 403); } // Check for duplicate login const existingSession = await this.sessionService.isSessionLocked(agentConfig.ozonetelAgentId); if (existingSession && existingSession !== memberId) { throw new HttpException('You are already logged in on another device. Please log out there first.', 409); } // Lock session await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId); // Login to Ozonetel with agent-specific credentials const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$'; this.ozonetelAgent.loginAgent({ agentId: agentConfig.ozonetelAgentId, password: ozAgentPassword, phoneNumber: agentConfig.sipExtension, mode: 'blended', }).catch(err => { this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`); }); // Return agent config to frontend return { accessToken, refreshToken: tokens.refreshToken.token, user: { ... }, // same as today agentConfig: { ozonetelAgentId: agentConfig.ozonetelAgentId, sipExtension: agentConfig.sipExtension, sipPassword: agentConfig.sipPassword, sipUri: agentConfig.sipUri, sipWsServer: agentConfig.sipWsServer, campaignName: agentConfig.campaignName, }, }; } ``` Note: `workspaceMember.id` is already available from the profile query on line 87-88 of the existing code. - [ ] **Step 3: Add logout endpoint** Add after the `refresh` endpoint: ```typescript @Post('logout') async logout(@Headers('authorization') auth: string) { if (!auth) throw new HttpException('Authorization required', 401); try { // Resolve workspace member from JWT const profileRes = await axios.post(this.graphqlUrl, { query: '{ currentUser { workspaceMember { id } } }', }, { headers: { 'Content-Type': 'application/json', Authorization: auth } }); const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id; if (!memberId) return { status: 'ok' }; const agentConfig = this.agentConfigService.getFromCache(memberId); if (agentConfig) { // Unlock Redis session await this.sessionService.unlockSession(agentConfig.ozonetelAgentId); // Logout from Ozonetel this.ozonetelAgent.logoutAgent({ agentId: agentConfig.ozonetelAgentId, password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$', }).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`)); // Clear cache this.agentConfigService.clearCache(memberId); } return { status: 'ok' }; } catch (err) { this.logger.warn(`Logout cleanup failed: ${err}`); return { status: 'ok' }; } } ``` - [ ] **Step 4: Add heartbeat endpoint** ```typescript @Post('heartbeat') async heartbeat(@Headers('authorization') auth: string) { if (!auth) throw new HttpException('Authorization required', 401); try { const profileRes = await axios.post(this.graphqlUrl, { query: '{ currentUser { workspaceMember { id } } }', }, { headers: { 'Content-Type': 'application/json', Authorization: auth } }); const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id; const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null; if (agentConfig) { await this.sessionService.refreshSession(agentConfig.ozonetelAgentId); } return { status: 'ok' }; } catch { return { status: 'ok' }; } } ``` - [ ] **Step 5: Verify sidecar compiles** ```bash cd helix-engage-server && npm run build ``` - [ ] **Step 6: Commit** ```bash git add src/auth/auth.module.ts src/auth/auth.controller.ts git commit -m "feat: multi-agent login with Redis lockout, logout, heartbeat" ``` --- ## Task 4: Update Ozonetel Controller for Per-Agent Calls **Files:** - Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` - [ ] **Step 1: Add AgentConfigService to Ozonetel controller** Import and inject `AgentConfigService`. Add a helper to resolve the agent config from the auth header: ```typescript import { AgentConfigService } from '../auth/agent-config.service'; // In constructor: private readonly agentConfig: AgentConfigService, // Helper method: private async resolveAgentId(authHeader: string): Promise { try { const data = await this.platform.queryWithAuth( '{ currentUser { workspaceMember { id } } }', undefined, authHeader, ); const memberId = data.currentUser?.workspaceMember?.id; const config = memberId ? this.agentConfig.getFromCache(memberId) : null; return config?.ozonetelAgentId ?? this.defaultAgentId; } catch { return this.defaultAgentId; } } ``` - [ ] **Step 2: Update dispose, agent-state, dial, and other endpoints** Replace `this.defaultAgentId` with `await this.resolveAgentId(authHeader)` in the endpoints that pass the auth header. The key endpoints to update: - `dispose()` — add `@Headers('authorization') auth: string` param, resolve agent ID - `agentState()` — same - `dial()` — same - `agentReady()` — same For endpoints that don't currently take the auth header, add it as a parameter. - [ ] **Step 3: Update auth module to handle circular dependency** The `OzonetelAgentModule` now needs `AgentConfigService` from `AuthModule`. Use `forwardRef` if needed, or export `AgentConfigService` from a shared module. Simplest approach: move `AgentConfigService` export from `AuthModule` and import it in `OzonetelAgentModule`. - [ ] **Step 4: Verify sidecar compiles** ```bash cd helix-engage-server && npm run build ``` - [ ] **Step 5: Commit** ```bash git add src/ozonetel/ozonetel-agent.controller.ts src/ozonetel/ozonetel-agent.module.ts src/auth/auth.module.ts git commit -m "feat: per-agent Ozonetel credentials in all controller endpoints" ``` --- ## Task 5: Frontend — Store Agent Config + Dynamic SIP **Files:** - Modify: `helix-engage/src/pages/login.tsx` - Modify: `helix-engage/src/providers/sip-provider.tsx` - Modify: `helix-engage/src/providers/auth-provider.tsx` - [ ] **Step 1: Store agentConfig on login** In `login.tsx`, after successful login, store the agent config: ```typescript if (response.agentConfig) { localStorage.setItem('helix_agent_config', JSON.stringify(response.agentConfig)); } ``` Handle new error codes: ```typescript } catch (err: any) { if (err.message?.includes('not configured')) { setError('Agent account not configured. Contact your administrator.'); } else if (err.message?.includes('already logged in')) { setError('You are already logged in on another device. Please log out there first.'); } else { setError(err.message); } setIsLoading(false); } ``` - [ ] **Step 2: Update SIP provider to use stored agent config** In `sip-provider.tsx`, replace the hardcoded `DEFAULT_CONFIG`: ```typescript const getAgentSipConfig = (): SIPConfig => { try { const stored = localStorage.getItem('helix_agent_config'); if (stored) { const config = JSON.parse(stored); return { displayName: 'Helix Agent', uri: config.sipUri, password: config.sipPassword, wsServer: config.sipWsServer, stunServers: 'stun:stun.l.google.com:19302', }; } } catch {} // Fallback to env vars return { displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent', uri: import.meta.env.VITE_SIP_URI ?? '', password: import.meta.env.VITE_SIP_PASSWORD ?? '', wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '', stunServers: 'stun:stun.l.google.com:19302', }; }; ``` Use `getAgentSipConfig()` where `DEFAULT_CONFIG` was used. - [ ] **Step 3: Update auth provider logout to call sidecar** In `auth-provider.tsx`, modify `logout()` to call the sidecar first: ```typescript const logout = async () => { try { const token = localStorage.getItem('helix_access_token'); if (token) { await fetch(`${API_URL}/auth/logout`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }).catch(() => {}); } } finally { localStorage.removeItem('helix_access_token'); localStorage.removeItem('helix_refresh_token'); localStorage.removeItem('helix_user'); localStorage.removeItem('helix_agent_config'); setUser(null); } }; ``` Note: `API_URL` needs to be available here. Import from `api-client.ts` or read from env. - [ ] **Step 4: Verify frontend compiles** ```bash cd helix-engage && npm run build ``` - [ ] **Step 5: Commit** ```bash git add src/pages/login.tsx src/providers/sip-provider.tsx src/providers/auth-provider.tsx git commit -m "feat: dynamic SIP config from login response, logout cleanup" ``` --- ## Task 6: Frontend — Heartbeat **Files:** - Modify: `helix-engage/src/components/layout/app-shell.tsx` - [ ] **Step 1: Add heartbeat interval for CC agents** In `AppShell`, add a heartbeat effect: ```typescript const { isCCAgent } = useAuth(); useEffect(() => { if (!isCCAgent) return; const interval = setInterval(() => { const token = localStorage.getItem('helix_access_token'); if (token) { fetch(`${import.meta.env.VITE_API_URL ?? 'http://localhost:4100'}/auth/heartbeat`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }).catch(() => {}); } }, 5 * 60 * 1000); // Every 5 minutes return () => clearInterval(interval); }, [isCCAgent]); ``` - [ ] **Step 2: Verify frontend compiles** ```bash cd helix-engage && npm run build ``` - [ ] **Step 3: Commit** ```bash git add src/components/layout/app-shell.tsx git commit -m "feat: heartbeat every 5 min to keep agent session alive" ``` --- ## Task 7: Docker + Deploy **Files:** - Modify: VPS `docker-compose.yml` - [ ] **Step 1: Add REDIS_URL to sidecar in docker-compose** SSH to VPS and add `REDIS_URL: redis://redis:6379` to the sidecar environment section. Also add `redis` to the sidecar's `depends_on`. - [ ] **Step 2: Deploy using deploy script** ```bash ./deploy.sh all ``` - [ ] **Step 3: Verify sidecar connects to Redis** ```bash ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 10 2>&1 | grep -i redis" ``` Expected: `Redis connected` - [ ] **Step 4: Test login flow** Login as rekha.cc → should get `agentConfig` in response. SIP should connect with her specific extension. Try logging in from another browser → should get "already logged in" error. - [ ] **Step 5: Commit docker-compose change and push all to Azure** ```bash cd helix-engage && git add . && git push origin dev cd helix-engage-server && git add . && git push origin dev ```