2026 03 16 Sessions Security Unification Design

Documentation for Plans

Sessions Security & Unification — Design Spec

Date: 2026-03-16 Session: S227 (implementation) Status: Draft — Awaiting Approval Scope: All 4 backends + frontend session infrastructure Risk Level: Foundation-level — pre-production (no real users)


Executive Summary

The GoGrab session infrastructure has 8 distinct session schemas, 84+ session-related endpoints across 4 APIs, 70 unguarded controllers (47%), 5 distinct guard implementations (with incompatible request.user shapes), and no global authentication on any backend. Whistle (group chat) endpoints are @Public() with self-asserted caller identity. Inter-service tokens use inconsistent variable names. The tenantId field exists in schemas but is never populated.

This spec defines a unified security and session architecture that:

  1. Locks down all backends with a global UnifiedAuthGuard (deny-by-default)
  2. Creates dedicated /internal/* routes for service-to-service communication (Option B)
  3. Formalizes tenantId = spaceId across all schemas and endpoints
  4. Hardens whistle/group chat with proper authentication and tenant isolation
  5. Consolidates duplicate endpoints and dead code
  6. Gives Vitto secure cross-tenant aggregation for its Jarvis-like intelligence

Part 1: Unified Auth Guard (APP_GUARD)

Problem

No backend registers a global guard. Every controller without @UseGuards() is fully public. 70 of 150 controllers have no class-level guard. The existing @Public() soft-auth pattern in JwtAuthGuard works well — but only when the guard is actually applied.

Additionally, 5 distinct guard classes exist with incompatible request.user shapes:

Additionally, ComputerUsePermissionGuard (APIs/backend-ai-api/src/app/infrastructure/guards/computer-use-permission.guard.ts) is a permission guard that must be audited for compatibility with UnifiedCaller.

Live bug: AiAgentsController in backend-api uses BackendAuthGuard but reads req.user.userId — always undefined.

Design

1.1 Global Guard Registration

Register UnifiedAuthGuard as APP_GUARD in AuthCoreModule.forRoot() on all 4 backends:

// libs/libs-api/auth-core/src/lib/auth-core.module.ts
providers: [
  { provide: APP_GUARD, useClass: UnifiedAuthGuard },
  { provide: APP_GUARD, useClass: RolesGuard },  // runs after UnifiedAuthGuard
]

This means every route is protected by default. Routes that need to be public must explicitly use @Public(). This inverts the current model (open by default, guard opt-in) to closed by default, public opt-in.

1.2 Authentication Strategies (tried in order)

Strategy 1: Service JWT (Bearer svc_*)        → CallerType.SERVICE
Strategy 2: User JWT (Bearer <jwt>)           → CallerType.USER
Strategy 3: System Token (Bearer <static>)    → CallerType.SERVICE (backward compat)
Strategy 4: API Key (X-MCP-API-Key header)    → CallerType.API_KEY
Strategy 5: Localhost bypass (dev-only, NODE_ENV !== 'production')
            → CallerType.DEV

If @Public() is on the route: skip auth failure, but still decode token if present (soft-auth). This preserves existing behavior for guest/anonymous endpoints.

1.3 Unified Caller Interface

Every request.user conforms to one shape:

interface UnifiedCaller {
  // Identity
  userId: string;            // MongoDB _id, 'svc:vitto-api', or 'apikey:{ownerId}'
  sub: string;               // ALIAS for userId — backward compat
  email?: string;
  displayName?: string;

  // Classification
  callerType: 'user' | 'service' | 'api-key' | 'dev';
  serviceName?: string;      // 'vitto-api', 'ai-api', etc.

  // Authorization
  roles: string[];
  platformRole?: string;     // executive, developer, admin, etc.
  permissions: string[];     // fine-grained: 'sessions:read', 'sessions:write:own'
  scopes: string[];          // service scopes: 'sessions:read:all', 'users:read:all'

  // Tenant context
  tenantId?: string;         // = spaceId
  spaceId?: string;          // alias for tenantId

  // Auth metadata
  authMethod: 'jwt' | 'service-jwt' | 'system-token' | 'api-key' | 'localhost';
  isApiKeyAuth: boolean;     // backward compat for InfrastructureAuthGuard consumers
  isLocalhostBypass: boolean; // backward compat
  mcpTier?: string;
  userType?: string;
}

Critical: sub and userId are both set to the same value as plain object properties (NOT getter aliases — getters break JSON serialization for logging/forwarding). Both always have the same value. This means zero changes to the ~80 backend-api controller methods reading req.user.sub.

1.4 Custom Decorators

@CallerType('user')            // Only human users
@CallerType('service')         // Only internal services
@CallerType('user', 'service') // Both
@ServiceOnly()                 // Shorthand for @CallerType('service')
@TenantScoped()                // Guard verifies caller has access to the tenantId in the request
@OwnerOnly()                   // Guard verifies req.user.userId matches the resource's userId

Default when no @CallerType() applied: Any authenticated caller type is allowed (user, service, api-key, dev). @CallerType() is a narrowing decorator that restricts access — not a widening one.

1.5 Guard Replacement Map

Old Guard File Action Migration
BackendAuthGuard APIs/backend-api/src/app/backend-auth/backend-auth.guard.ts Delete Remove @UseGuards(BackendAuthGuard) from 44 controllers. APP_GUARD handles it.
JwtAuthGuard (per-controller) libs/libs-api/auth-core/src/lib/guards/jwt-auth.guard.ts Remove per-controller usage Remove @UseGuards(JwtAuthGuard) from 36 controllers. Keep class for internal use by UnifiedAuthGuard.
InfrastructureAuthGuard APIs/backend-ai-api/src/app/infrastructure/guards/infrastructure-auth.guard.ts Delete Replace with @CallerType('service', 'dev') + APP_GUARD. Localhost bypass moves into Strategy 5.
ProjectApiKeyGuard APIs/backend-ai-api/src/app/mcp-gateway/guards/project-api-key.guard.ts Delete API key detection built into Strategy 4.
ComputerUsePermissionGuard APIs/backend-ai-api/src/app/infrastructure/guards/computer-use-permission.guard.ts Audit Verify compatibility with UnifiedCaller shape. Update field access. Keep as permission guard.

Kept (not auth guards — update to use UnifiedCaller):

1.6 @Public() Audit

After APP_GUARD is registered, every currently-unguarded controller becomes protected. We must audit all 70 unguarded controllers and:

Known intentionally-public routes to preserve:

Routes that should become protected (currently accidentally public):

Scope Warning: The @Public() audit is not "review 70 controllers." vitto.controller.ts alone is 4,061 lines with 114 @Public() annotations. The real scope is **300+ route methods** across all backends. The S227.2 phase must be broken into per-backend sub-tasks:

Prerequisite note: vitto.controller.ts (4,061 lines, ~114 @Public() annotations) is a god-controller that makes security auditing extremely error-prone. It should be decomposed into domain-specific controllers (similar to how VittoCoreController was already extracted) as a parallel track. This decomposition is not blocking for the guard rollout, but significantly reduces the risk of the @Public() audit.

1.7 SSE Authentication Strategy

Browser EventSource API cannot send HTTP headers — this is why many Vitto endpoints are @Public() with soft-auth. This is a fundamental browser limitation, not a design oversight.

Solution: Ticket-based SSE auth.

  1. Client calls an authenticated POST endpoint to request a short-lived opaque ticket
  2. Backend stores { ticket, userId, tenantId, expiresAt } (TTL 30s, single-use)
  3. Client opens SSE with ?ticket=<token> query parameter
  4. SSE endpoint validates the ticket, deletes it (single-use), and sets request.user
  5. If no valid ticket, SSE returns 401
// New endpoint (protected by JWT via APP_GUARD)
@Post('sse-ticket')
async createSseTicket(@CurrentUser() user: UnifiedCaller): Promise<{ ticket: string }> {
  return this.sseAuthService.createTicket(user.userId, user.tenantId);
}

// SSE endpoint (still @Public() but validates ticket)
@Public()
@Sse('stream')
async stream(@Query('ticket') ticket: string): Observable<MessageEvent> {
  const user = await this.sseAuthService.consumeTicket(ticket);
  if (!user) throw new UnauthorizedException('Invalid or expired SSE ticket');
  // ... proceed with authenticated SSE stream
}

Phase 1 (S227): Keep existing @Public() SSE endpoints working. Add ticket-based auth as an optional enhancement. Phase 2 (post-S227): Require tickets on all SSE endpoints. Remove raw @Public().

This pattern is well-established (Mercure, Firebase, etc.) and avoids the polyfill approach of wrapping EventSource.

1.8 "Temporarily Public" Cleanup

PlatformAIController in backend-ai-api has 4 methods with "Temporarily public for development" comments. These must be audited: remove @Public() if the frontend sends JWT, or convert to @CallerType('service') if only called by other backends.


Part 2: Internal Service Endpoints (Option B)

Problem

Vitto needs cross-user, cross-tenant data access to build its Jarvis-like intelligence. Today, it calls the same endpoints that users call, authenticated with SYSTEM_TOKEN. This creates two problems:

  1. Same endpoint must serve two incompatible authorization models (per-user vs cross-tenant)
  2. Token confusion: SYSTEM_TOKEN vs BACKEND_API_INTERNAL_TOKEN — same value, different env vars, easy misconfiguration

Design

2.1 Internal Route Prefix

Each backend gets a /internal/* route prefix with dedicated controllers:

backend-api:      /api/internal/...
backend-ai-api:   /ai-api/internal/...
backend-vitto-api: /vitto-api/internal/...

These routes are:

2.2 Service Identity

Replace the static SYSTEM_TOKEN pattern with short-lived service JWTs:

// Service JWT payload
interface ServiceJwtPayload {
  iss: string;        // 'vitto-api', 'ai-api', 'backend-api'
  sub: string;        // 'svc:vitto-api'
  serviceName: string;
  scopes: string[];   // ['sessions:read:all', 'users:read:tenant']
  tenantId?: string;  // optional tenant constraint
  iat: number;
  exp: number;        // 5 minute lifetime
}

Phase 1 (S227): Keep SYSTEM_TOKEN working via Strategy 3 in UnifiedAuthGuard. This is backward-compatible.

Phase 2 (future): Migrate connectivity clients to generate short-lived service JWTs signed with a shared secret. This eliminates the static token risk.

The shared secret (SERVICE_JWT_SECRET) is a new env var, distinct from JWT_SECRET (user tokens) and SYSTEM_TOKEN (legacy).

2.3 Internal Endpoint Inventory

backend-ai-api internal routes for Vitto:

GET  /ai-api/internal/sessions/tenant/:tenantId          → All sessions for a space
GET  /ai-api/internal/sessions/tenant/:tenantId/genomes  → All genomes for a space
GET  /ai-api/internal/sessions/user/:userId               → All sessions for a user (cross-tenant)
GET  /ai-api/internal/sessions/:id                        → Any session by ID (no ownership check)
POST /ai-api/internal/sessions/:id/messages               → Append as any user (Vitto injecting system messages)
GET  /ai-api/internal/sessions/search                     → Full-text search across tenant sessions
GET  /ai-api/internal/workflow/sessions/tenant/:tenantId  → Generation sessions for a space
POST /ai-api/internal/embeddings/search                   → Semantic search across tenant data

backend-api internal routes for Vitto/AI-API:

GET  /api/internal/users/:userId                   → User profile
GET  /api/internal/users/tenant/:tenantId           → All users in a space
GET  /api/internal/spaces/:spaceId                  → Space details
GET  /api/internal/spaces/:spaceId/members          → Space member list
GET  /api/internal/modules/:spaceId                 → All modules for a space
POST /api/internal/billing/events                   → Billing event submission

backend-vitto-api internal routes for AI-API:

POST /vitto-api/internal/engine/execute             → Execute a Vitto task
GET  /vitto-api/internal/mind/user/:userId          → User's Vitto memory
GET  /vitto-api/internal/training/tenant/:tenantId  → Training data for a space

2.4 Token Consolidation

Current New Action
SYSTEM_TOKEN (receiver-side) Keep for Phase 1 UnifiedAuthGuard Strategy 3
BACKEND_API_INTERNAL_TOKEN (caller-side) Keep for Phase 1 Connectivity clients send as Bearer
SERVICE_JWT_SECRET (new) Phase 2 Short-lived service JWTs

Immediate fix: Document that SYSTEM_TOKEN and BACKEND_API_INTERNAL_TOKEN MUST be the same value. Add a startup validation check in each backend.

2.5 Migrate Existing Internal Callers

Caller Current Target New Target
BackendAiClientService (vitto→ai-api) /ai-api/sessions/*, /ai-api/infrastructure/* /ai-api/internal/sessions/*, keep /ai-api/infrastructure/*
BackendApiClientService (vitto→api) /api/spaces/*, /api/users/* /api/internal/spaces/*, /api/internal/users/*
PlatformApiClientService (ai-api→api) /api/spaces/*, /api/modules/* /api/internal/spaces/*, /api/internal/modules/*
PlatformOrchestrationService (ai-api→api) Same as above Same as above
McpUsageService (ai-api→api) fetch() with no auth token Use typed client with Bearer token
WhistleOrchestratorService (vitto→ai-api, vitto→api) Raw http.post() bypassing typed clients Use BackendAiClientService and BackendApiClientService

Part 3: Tenant Model (tenantId = spaceId)

Problem

Design

3.1 The Rule

tenantId = spaceId everywhere. These are aliases for the same value. The field name is tenantId in all schemas, but spaceId is accepted as a query alias for backward compatibility.

A user can belong to multiple tenants (spaces). When making a request, the active tenant is determined by:

For CallerType.USER (human users):

  1. JWT claim spaceId (if the token is space-scoped)
  2. Request body spaceId or tenantId (for creation endpoints)
  3. URL parameter :tenantId or :spaceId (for scoped routes)

For CallerType.SERVICE (internal services):

  1. Request header X-Tenant-Id (primary — services always specify context)
  2. URL parameter :tenantId or :spaceId
  3. Request body tenantId

Security restriction: The X-Tenant-Id header is ONLY honored for CallerType.SERVICE callers. User callers sending this header have it ignored. This prevents a compromised browser or user request from overriding JWT-based tenant scoping.

3.2 Schema Changes

Schema Current Change
ChatSession tenantId?, spaceId? — both optional, only spaceId populated Make tenantId required for new sessions. Set tenantId = spaceId on write. Keep spaceId as indexed alias.
ConfigurationSession tenantId required, spaceId? optional Already correct. Ensure tenantId === spaceId on write.
AgentSessionDoc No tenantId, has spaceId? Add tenantId field. Set tenantId = spaceId on write.
CustomerSession No tenantId, has spaceId? Add tenantId field. Set tenantId = spaceId on write.
GenerationCheckpoint No tenantId, has spaceId? Add tenantId to interface. Set on checkpoint creation.
MCP dev sessions No tenant concept No change — dev tooling, not user-facing.
DocViewSession No tenant concept No change — docs visibility is project-scoped, not tenant-scoped.

3.3 Write-Path Enforcement

Enforce tenantId ↔ spaceId sync at both the schema hook and service layer:

Schema hooks (catch save and update operations):

// Pre-save hook (covers Model.create(), doc.save())
schema.pre('save', function(next) {
  if (this.spaceId && !this.tenantId) this.tenantId = this.spaceId;
  if (this.tenantId && !this.spaceId) this.spaceId = this.tenantId;
  next();
});

// Pre-update hooks (covers findOneAndUpdate, updateMany, etc.)
// NOTE: Mongoose pre-save does NOT fire on updateMany/findOneAndUpdate
schema.pre(['findOneAndUpdate', 'updateMany', 'updateOne'], function(next) {
  const update = this.getUpdate() as any;
  if (update?.$set) {
    if (update.$set.spaceId && !update.$set.tenantId) update.$set.tenantId = update.$set.spaceId;
    if (update.$set.tenantId && !update.$set.spaceId) update.$set.spaceId = update.$set.tenantId;
  }
  next();
});

Service-layer enforcement (primary — more reliable than hooks):

All services that write sessions must call a shared ensureTenantSync(data) utility before any write operation. This is the primary enforcement mechanism — hooks are the safety net.

3.4 Query-Path Changes

All user-facing session list endpoints get an optional tenantId filter:

Endpoint Current Change
GET /sessions/my/active Filters by userId only Add optional ?tenantId= filter
GET /sessions/my/history Filters by userId only Add optional ?tenantId= filter
GET /sessions/my/groups Filters by participants.userId only Add optional ?tenantId= filter
GET /workflow/sessions No filter Add required tenantId from JWT/header
GET /vitto-api/engine/computer-use/sessions No filter Add tenantId filter
GET /vitto-api/module-generation/sessions No filter Add tenantId filter

3.5 Data Migration Script

For existing data (all created by you during testing):

// One-time migration: backfill tenantId from spaceId
db.chat_sessions.updateMany(
  { spaceId: { $exists: true, $ne: null }, tenantId: { $exists: false } },
  [{ $set: { tenantId: '$spaceId' } }]
);

// Backfill spaceId from tenantId where only tenantId exists
db.configuration_sessions.updateMany(
  { tenantId: { $exists: true, $ne: null }, spaceId: { $exists: false } },
  [{ $set: { spaceId: '$tenantId' } }]
);

// Flag sessions missing userId for manual review
// NOTE: Do NOT use sentinel string values in queryable fields — they'd appear in normal queries
db.chat_sessions.updateMany(
  { userId: { $exists: false } },
  { $set: { userId: null, tenantId: null, _needsReview: true } }
);

Part 4: Session Endpoint Consolidation

Problem

84 session-related endpoints across 4 APIs. Many are duplicates, leftovers from the MCP→backend-ai-api evolution, or serve overlapping purposes.

4.1 The 8 Session Concepts (Keep All, Fix Isolation)

# Concept Collection Purpose Keep?
1 MCP Dev Session (ai-infra) sessions in ai-mcp-dev Dev agent tracking Yes — dev tooling
2 MCP Dev Session (session-repo lib) sessions (consumer DB) Dev session repository Yes — shared lib
3 Chat Session chat_sessions in ai-agents User chat with AI Yes — core product
4 Configuration Session configuration_sessions in ai-agents Platform generation wizard Yes — generation pipeline
5 Agent Session (Vitto) agent-sessions in vitto-mind Vitto per-user persistent context Yes — Vitto memory
6 Customer Session (Vitto) customer-sessions in vitto-mind External customer chat Yes — customer-facing
7 Generation Checkpoint In-memory + events Module generation pipeline Yes — generation pipeline
8 Doc View Session doc-view-sessions in docs DB Documentation access tokens Yes — docs feature

None should be merged. They serve genuinely different domains. But all product-facing ones (#3-#7) need tenantId and ownership enforcement.

4.2 Endpoint Rationalization (backend-ai-api)

/ai-api/sessions/* (14 endpoints) — User-Facing Chat Sessions

Endpoint Action Why
GET /sessions/:id Add ownership check Currently returns any session by ID
DELETE /sessions/:id Add ownership check Currently deletes any session by ID
GET /sessions/history Deprecate → remove Returns ALL sessions. Frontend uses /my/history
GET /sessions/user/:userId Move to internal Cross-user query, only for Vitto
GET /sessions/user/:userId/genomes Move to internal Cross-user query, only for Vitto
POST /sessions/sync Add tenantId enforcement Must set tenantId on upsert
POST /sessions/:id/messages Add ownership/participant check Verify caller is owner or participant
POST /sessions/:id/reconcile Add ownership/participant check Same
GET /sessions/:id/genome Add ownership/participant check Same
POST /sessions/:id/awareness-message Add ownership/participant check Same
POST /sessions/suggestions Keep as-is Stateless, user-scoped by design
DELETE /sessions/cleanup/old Move to internal or admin Maintenance operation
GET /sessions/my/active Add optional tenantId filter Currently cross-tenant
GET /sessions/my/history Add optional tenantId filter Currently cross-tenant

/ai-api/infrastructure/sessions/* (18 endpoints) — MCP Dev Sessions

These are the MCP development session endpoints. They should be scoped to @CallerType('service', 'dev') since they're only called by MCP tools and admin UIs. They do NOT need tenantId (dev tooling).

Endpoint Action
GET /infrastructure/sessions Add @CallerType('service', 'dev')
POST /infrastructure/sessions Same
POST /infrastructure/sessions/create Merge with POST /infrastructure/sessions — duplicate
GET /infrastructure/sessions/list Merge with GET /infrastructure/sessions — duplicate
GET /infrastructure/sessions/counter Add @CallerType('service', 'dev')
All other infra session endpoints Same pattern

/ai-api/workflow/sessions/* (11 endpoints) — Platform Generation Sessions

Already has tenantId required in ConfigurationSession schema. Just needs guard enforcement:

Endpoint Action
All Already correct — just add @CallerType('user', 'service') and verify tenantId matches caller
GET /workflow/sessions/stats/:tenantId Add @TenantScoped() — verify caller belongs to this tenant

/ai-api/agents/tasks/*/sessions/* (4 endpoints) — Agent Task Sessions

These are task sub-session tracking. Keep with @CallerType('service', 'dev').

4.3 Endpoint Rationalization (backend-api)

/api/ai-agents/session* (18 endpoints) — MCP Sessions on backend-api

These are the backend-api side of MCP dev session management. Many duplicate what /ai-api/infrastructure/sessions/ does:

Endpoint Action Why
GET /ai-agents/session Keep MCP reads current session
GET /ai-agents/session/current Keep Active session lookup
POST /ai-agents/session/increment Keep Sub-session counter
POST /ai-agents/session/progress Keep Progress updates
POST /ai-agents/session/sync Keep File-to-DB sync
GET /ai-agents/sessions/mongo-summary Deprecate Debugging endpoint
POST /ai-agents/sessions/pull-from-mongo Deprecate Migration tool
POST /ai-agents/sessions/sync-to-mongo Deprecate Migration tool
Task session endpoints (8) Keep Agent task lifecycle

4.4 Endpoint Rationalization (backend-vitto-api)

Session endpoints on vitto-api are correctly scoped — they're Vitto's own domain:

Endpoint Group Action
GET /customer/session/:sessionId Add @Public() explicitly (already intended public)
PUT /engine/autonomy/session Add guard — session management
Computer-use session endpoints (6) Add @CallerType('user', 'service')
Module-generation session endpoints (7) Add @CallerType('user', 'service') + @TenantScoped()

Part 5: Whistle System Hardening

Problems Found

  1. POST /vitto-api/engine/whistle is @Public() — no JWT guard. whistlerUserId comes from request body (self-asserted). Any anonymous caller can whistle anyone.

  2. No check that the whistler belongs to the space. Only the target's membership is verified. A caller can claim any spaceId + any whistlerUserId.

  3. POST /ai-api/sessions/:id/whistle is @Public() — the backend-ai-api endpoint that upgrades a session to group is also unprotected.

  4. allowlist in whistle preferences has no space scope. A flat array of userIds. If a user allows userId X in Space A, X can also whistle them from Space B.

  5. WhistleOrchestratorService bypasses typed clients. Uses raw http.post() instead of BackendAiClientService/BackendApiClientService.

  6. LLM-initiated whistle is broken. whistle.plugin.ts sends targetUserId: '' — the display-name-to-userId resolution never happens.

Fixes

5.1 Authentication

Remove @Public() from both whistle endpoints:

5.2 Authorization

Add checkWhistlerMembership() to WhistleOrchestratorService:

// Verify BOTH whistler and target are members of the same space
const members = await this.getSpaceMembers(spaceId);
const whistlerIsMember = members.some(m => m.userId === whistlerUserId);
const targetIsMember = members.some(m => m.userId === targetUserId);

if (!whistlerIsMember) throw new ForbiddenException('Whistler is not a member of this space');
if (!targetIsMember) throw new NotFoundException('Target user is not a member of this space');

Verify spaceId from JWT matches the active space, not from request body.

5.3 Space-Scoped Allowlist

Change whistlePreferences.allowlist from string[] to:

interface WhistleAllowEntry {
  userId: string;
  spaceId?: string;  // null = allowed from any space
}

When spaceId is set, only allow whistle from that specific space. When null, allow from any shared space.

5.4 Use Typed Clients

Replace raw http.post() calls in WhistleOrchestratorService with:

5.5 Fix LLM Whistle Tool

In whistle.plugin.ts, add userId resolution from displayName:

// Resolve target user from display name within the active space
const members = await this.backendApiClient.getSpaceMembers(spaceId);
const target = members.find(m =>
  m.displayName?.toLowerCase() === targetName.toLowerCase()
);
if (!target) return { error: `No member named "${targetName}" found in this space` };

Part 6: WebSocket Security

Problem

All 8 WebSocket gateways across 4 backends have no @UseGuards() at class or method level. Authentication is handled ad-hoc in handleConnection() or not at all.

Gateway Backend Current Auth
AgentRealtimeGateway backend-api Query param agentId only — no token
RealtimeGateway backend-api Unknown — needs audit
CommandCenterGateway backend-ai-api Unknown — needs audit
McpGatewayWebSocket backend-ai-api API key in query params
NativeWsGateway backend-ai-api agentId + platform query params — no token
ChatGateway backend-ai-api JWT in query param or auth header
LocalBridgeGateway backend-vitto-api None visible
DocsSyncGateway backend-docs-api None — broadcasts file/manifest changes to all clients

Design

6.1 WebSocket Auth Guard

NestJS APP_GUARD does NOT apply to WebSocket gateways — they use a separate lifecycle. Create a WsAuthGuard that:

  1. Extracts JWT from client.handshake.auth.token or client.handshake.query.token
  2. Validates the token
  3. Sets client.data.user with UnifiedCaller shape
  4. Disconnects unauthorized clients

Apply to all gateways that handle user data. Service-to-service gateways (NativeWsGateway, AgentRealtimeGateway) can use service token validation instead.

6.2 Room Isolation

After auth, ensure users only join rooms they're authorized for:

ChatGateway.handleConnection() already does some of this — formalize it and apply to all gateways.


Part 7: Vitto Cross-Tenant Intelligence

The Requirement

Vitto needs to be a Jarvis-like assistant that:

Design

7.1 Vitto's Service Identity

Vitto authenticates to other backends as svc:vitto-api with scopes:

const vittoScopes = [
  'sessions:read:all',      // Read any session
  'sessions:write:system',  // Write system messages to any session
  'users:read:all',         // Read any user profile
  'spaces:read:all',        // Read any space
  'genomes:read:all',       // Read any conversation genome
  'training:read:all',      // Read training data
  'modules:read:all',       // Read any module
];

7.2 Internal Aggregation Endpoints

Vitto uses /internal/* endpoints (defined in Part 2) to:

  1. Build user context: GET /ai-api/internal/sessions/user/:userId → all sessions for a user across all tenants
  2. Build tenant context: GET /ai-api/internal/sessions/tenant/:tenantId → all sessions in a space
  3. Semantic recall: POST /ai-api/internal/embeddings/search → find relevant past conversations
  4. Genome access: GET /ai-api/internal/sessions/tenant/:tenantId/genomes → conversation summaries for a space
  5. User enrichment: GET /api/internal/users/:userId → profile, preferences, roles

7.3 Tenant-Scoped Intelligence

When Vitto operates within a space context, it should scope its intelligence:

This prevents Vitto from accidentally leaking Space A data to a user who only has access to Space B.


Part 8: Dead Code & Duplicate Removal

8.1 Duplicate Endpoints to Merge or Remove

Duplicate Keep Remove
POST /infrastructure/sessions/create POST /infrastructure/sessions The /create variant
GET /infrastructure/sessions/list GET /infrastructure/sessions The /list variant
GET /sessions/history (unfiltered) GET /sessions/my/history (per-user) The unfiltered /history
GET /sessions/user/:userId (public) GET /internal/sessions/user/:userId (service-only) The public variant
GET /sessions/user/:userId/genomes GET /internal/sessions/user/:userId/genomes Same
GET /ai-agents/sessions/mongo-summary None Debugging tool — remove from API
POST /ai-agents/sessions/pull-from-mongo None Migration tool — remove from API
POST /ai-agents/sessions/sync-to-mongo None Migration tool — remove from API

8.2 Guard Files to Delete

After UnifiedAuthGuard is deployed:

APIs/backend-api/src/app/backend-auth/backend-auth.guard.ts              → DELETE
APIs/backend-ai-api/src/app/infrastructure/guards/infrastructure-auth.guard.ts → DELETE
APIs/backend-ai-api/src/app/mcp-gateway/guards/project-api-key.guard.ts  → DELETE

Keep but audit:

APIs/backend-ai-api/src/app/infrastructure/guards/computer-use-permission.guard.ts → KEEP (update field access for UnifiedCaller)
APIs/backend-ai-api/src/app/mcp-gateway/guards/tier-limit.guard.ts                → KEEP (update field access for UnifiedCaller)

8.3 Token Variable Cleanup

Current Action
SYSTEM_TOKEN Keep — receiver-side validation
BACKEND_API_INTERNAL_TOKEN Keep — caller-side header
Add startup validation Each backend validates its required token(s) exist and are non-empty at startup. Callers validate BACKEND_API_INTERNAL_TOKEN; receivers validate SYSTEM_TOKEN. Do NOT assert equality (they're separate env vars in production deployments).
McpUsageService raw fetch() Replace with typed client using Bearer token
PlatformOrchestrationService reading SYSTEM_TOKEN directly Change to read BACKEND_API_INTERNAL_TOKEN for outbound calls

Part 9: Implementation Phases

Phase 1: Foundation (S227.1–S227.4)

S227.1 — UnifiedAuthGuard + UnifiedCaller interface

S227.2 — @Public() audit across all backends (~300+ route methods)

S227.3 — Tenant model enforcement

S227.4 — Internal endpoints (Phase 1)

Phase 2: Hardening (S227.5–S227.7)

S227.5 — Whistle hardening

S227.6 — Ownership enforcement on session endpoints

S227.7 — WebSocket security

Phase 3: Cleanup (S227.8–S227.9)

S227.8 — Endpoint consolidation

S227.9 — Token consolidation & documentation

Phase 4: Future (Post-S227)


Risk Assessment

Risk Likelihood Impact Mitigation
APP_GUARD breaks existing public routes High High Comprehensive @Public() audit in S227.2. Test each backend individually.
sub vs userId mismatch breaks backend-api Medium High Both sub and userId set as plain properties with same value — zero controller changes needed
Frontend breaks because JWT isn't sent on some routes Medium Medium Audit all frontend HTTP interceptors. Angular HttpInterceptor should attach JWT to all API calls.
Migration script corrupts data Low Low No real users — worst case: re-seed test data
Vitto loses access to data it needs Medium High Internal endpoints are additive — old routes work during migration
WebSocket auth breaks real-time features Medium Medium Test ChatGateway thoroughly — it's the only user-facing gateway
Whistle breaks during APP_GUARD rollout High Medium Fix checkSpaceMembership() auth headers in S227.2 before APP_GUARD goes live on backend-api

Rollback Strategy

If APP_GUARD registration causes unacceptable breakage during development:

  1. Immediate: Revert the APP_GUARD registration in auth-core.module.ts (single line change)
  2. Re-add per-controller @UseGuards() to the controllers that previously had them (git history)
  3. Keep new @Public() annotations — they're harmless without a global guard
  4. Keep new internal endpoints and UnifiedCaller interface — they work independently

This rollback is safe because:

The rollback window closes after all backends are verified working with APP_GUARD. After that, the per-controller guards are deleted and rollback requires a full revert.


Success Criteria

  1. Zero unguarded controllers — every route is either @Public() (intentionally) or JWT-protected
  2. All session schemas have tenantId — populated on every write
  3. Ownership checks on all session-by-ID endpoints — no cross-user data leakage
  4. Whistle requires authentication — no self-asserted identity
  5. Internal routes exist and are used — Vitto calls /internal/* with service identity
  6. One auth guard class — UnifiedAuthGuard replaces BackendAuthGuard, InfrastructureAuthGuard, and ProjectApiKeyGuard
  7. Token consistency — startup validation prevents misconfiguration
  8. WebSocket gateways authenticate connections — no anonymous real-time access

Files to Create

libs/libs-api/auth-core/src/lib/guards/unified-auth.guard.ts
libs/libs-api/auth-core/src/lib/guards/ws-auth.guard.ts
libs/libs-api/auth-core/src/lib/decorators/caller-type.decorator.ts
libs/libs-api/auth-core/src/lib/decorators/tenant-scoped.decorator.ts
libs/libs-api/auth-core/src/lib/decorators/owner-only.decorator.ts
libs/libs-api/auth-core/src/lib/decorators/service-only.decorator.ts
libs/libs-api/auth-core/src/lib/interfaces/unified-caller.interface.ts
APIs/backend-ai-api/src/app/internal/internal-sessions.controller.ts
APIs/backend-ai-api/src/app/internal/internal.module.ts
APIs/backend-api/src/app/internal/internal-users.controller.ts
APIs/backend-api/src/app/internal/internal-spaces.controller.ts
APIs/backend-api/src/app/internal/internal.module.ts
APIs/backend-vitto-api/src/app/internal/internal-engine.controller.ts
APIs/backend-vitto-api/src/app/internal/internal.module.ts
scripts/migrations/backfill-tenant-id.ts

Files to Delete

APIs/backend-api/src/app/backend-auth/backend-auth.guard.ts
APIs/backend-ai-api/src/app/infrastructure/guards/infrastructure-auth.guard.ts
APIs/backend-ai-api/src/app/mcp-gateway/guards/project-api-key.guard.ts

Files to Modify (Major Changes)

libs/libs-api/auth-core/src/lib/auth-core.module.ts          — Register APP_GUARD
libs/libs-api/auth-core/src/lib/guards/jwt-auth.guard.ts     — Refactor into UnifiedAuthGuard
~150 controller files                                          — Remove @UseGuards() or add @Public()
APIs/backend-vitto-api/src/app/engine/whistle-orchestrator.service.ts — Auth + typed clients
APIs/backend-vitto-api/src/app/tools/plugins/whistle.plugin.ts       — Fix LLM whistle
APIs/backend-vitto-api/src/app/connectivity/*.ts                     — Use internal routes
APIs/backend-ai-api/src/app/sessions/sessions.controller.ts          — Ownership checks
APIs/backend-ai-api/src/app/sessions/sessions.schema.ts              — tenantId enforcement
APIs/backend-vitto-api/src/app/mind/schemas/agent-session.schema.ts  — Add tenantId
APIs/backend-vitto-api/src/app/mind/schemas/customer-session.schema.ts — Add tenantId
APIs/backend-ai-api/src/app/mcp-gateway/services/mcp-usage.service.ts — Replace fetch() with typed client