# 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<string>('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<void> {
        await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL);
    }

    async isSessionLocked(agentId: string): Promise<string | null> {
        return this.redis.get(this.key(agentId));
    }

    async refreshSession(agentId: string): Promise<void> {
        await this.redis.expire(this.key(agentId), SESSION_TTL);
    }

    async unlockSession(agentId: string): Promise<void> {
        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<string, AgentConfig>();
    private readonly sipDomain: string;
    private readonly sipWsPort: string;

    constructor(
        private platform: PlatformGraphqlService,
        private config: ConfigService,
    ) {
        this.sipDomain = config.get<string>('sip.domain', 'blr-pub-rtc4.ozonetel.com');
        this.sipWsPort = config.get<string>('sip.wsPort', '444');
    }

    async getByMemberId(memberId: string): Promise<AgentConfig | null> {
        // Check cache first
        const cached = this.cache.get(memberId);
        if (cached) return cached;

        try {
            const data = await this.platform.query<any>(
                `{ 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<string> {
    try {
        const data = await this.platform.queryWithAuth<any>(
            '{ 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
```
