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:
- Locks down all backends with a global
UnifiedAuthGuard(deny-by-default) - Creates dedicated
/internal/*routes for service-to-service communication (Option B) - Formalizes
tenantId = spaceIdacross all schemas and endpoints - Hardens whistle/group chat with proper authentication and tenant isolation
- Consolidates duplicate endpoints and dead code
- 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:
BackendAuthGuard(APIs/backend-api/src/app/backend-auth/backend-auth.guard.ts) — setsreq.user.sub(raw JWTsubclaim)JwtAuthGuard(libs/libs-api/auth-core/src/lib/guards/jwt-auth.guard.ts) — setsreq.user.userId(normalized fromsub)InfrastructureAuthGuard(APIs/backend-ai-api/src/app/infrastructure/guards/infrastructure-auth.guard.ts) — addsisLocalhostBypass,isApiKeyAuth,userTypeProjectApiKeyGuard(APIs/backend-ai-api/src/app/mcp-gateway/guards/project-api-key.guard.ts) — API key validationTierLimitGuard(APIs/backend-ai-api/src/app/mcp-gateway/guards/tier-limit.guard.ts) — billing guard readinguser.spaceId,user.mcpTier
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):
RolesGuard— role hierarchy check. Works onrequest.userset by unified guard.TierLimitGuard— billing. Must update to readUnifiedCaller.mcpTierandUnifiedCaller.spaceId(currently reads undocumented fields from raw payload).ThrottlerGuard— rate limiting. Independent.
1.6 @Public() Audit
After APP_GUARD is registered, every currently-unguarded controller becomes protected. We must audit all 70 unguarded controllers and:
- Add
@Public()to routes that are intentionally public (health checks, webhooks, auth entry points, customer-facing) - Leave undecorated (now protected) for routes that were accidentally unguarded
Known intentionally-public routes to preserve:
- Health endpoints (
/health, AppController health methods) - Auth entry points (
MagicLinkController,PhoneAuthController) - Webhook receivers (
StripeWebhookController— uses Stripe HMAC in service layer) - Customer-facing (
CustomerControllerin vitto-api) - MCP JSON-RPC (
McpJsonRpcController— uses its own API key in protocol) - Observatory/awareness SSE (
ObservatoryController,AwarenessController) - A2A discovery (
GET .well-known/agent.json) DocsSyncController,AdminControllerin backend-docs-api (must be included in audit)
Routes that should become protected (currently accidentally public):
BenchmarkController— DB benchmarks with no authStudioGitController(backend-api) — GitLab push/delete with no authConnectorController— API keys/secrets management with no authTrainingController— Agent training with no authPlatformGenerationController— Full generation pipeline with no authSuperIntelligenceController— Intelligence CRUD with no authSecurityController— Has@Roles()without@UseGuards(JwtAuthGuard)— broken- All other controllers listed in the guard audit
Scope Warning: The @Public() audit is not "review 70 controllers." vitto.controller.ts alone is 4,061 lines with 114 300+ route methods** across all backends. The S227.2 phase must be broken into per-backend sub-tasks:@Public() annotations. The real scope is **
- S227.2a: backend-api (67 controllers, ~3
@Public()) - S227.2b: backend-ai-api (62 controllers, ~65
@Public()) - S227.2c: backend-vitto-api (21 controllers, ~85+
@Public()—vitto.controller.tsdominates) - S227.2d: backend-docs-api (needs inventory)
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.
- Client calls an authenticated
POSTendpoint to request a short-lived opaque ticket - Backend stores
{ ticket, userId, tenantId, expiresAt }(TTL 30s, single-use) - Client opens SSE with
?ticket=<token>query parameter - SSE endpoint validates the ticket, deletes it (single-use), and sets
request.user - 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:
- Same endpoint must serve two incompatible authorization models (per-user vs cross-tenant)
- Token confusion:
SYSTEM_TOKENvsBACKEND_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:
- Protected by
@CallerType('service')— only service JWTs or system tokens - Optimized for aggregation (batch queries, tenant-scoped, no per-user filtering)
- Not exposed in API documentation or client SDKs
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
tenantIdexists inChatSessionandConfigurationSessionschemas but is never populated by the chat flowAgentSessionDoc(Vitto) has notenantIdat all — usesspaceIdonlyCustomerSessionhas optionalspaceIdbut notenantIdMCP dev sessionshave no tenant concept (correct — they're dev tooling)- Frontend
SessionInfohastenantId?andtenantName?but they're never set
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):
- JWT claim
spaceId(if the token is space-scoped) - Request body
spaceIdortenantId(for creation endpoints) - URL parameter
:tenantIdor:spaceId(for scoped routes)
For CallerType.SERVICE (internal services):
- Request header
X-Tenant-Id(primary — services always specify context) - URL parameter
:tenantIdor:spaceId - 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
POST /vitto-api/engine/whistleis@Public()— no JWT guard.whistlerUserIdcomes from request body (self-asserted). Any anonymous caller can whistle anyone.No check that the whistler belongs to the space. Only the target's membership is verified. A caller can claim any
spaceId+ anywhistlerUserId.POST /ai-api/sessions/:id/whistleis@Public()— the backend-ai-api endpoint that upgrades a session to group is also unprotected.allowlistin 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.WhistleOrchestratorServicebypasses typed clients. Uses rawhttp.post()instead ofBackendAiClientService/BackendApiClientService.LLM-initiated whistle is broken.
whistle.plugin.tssendstargetUserId: ''— the display-name-to-userId resolution never happens.
Fixes
5.1 Authentication
Remove @Public() from both whistle endpoints:
POST /vitto-api/engine/whistle— require JWT.whistlerUserIdcomes fromrequest.user.userId, not body.POST /ai-api/sessions/:id/whistle— move to/ai-api/internal/sessions/:id/whistlewith@CallerType('service'). Only vitto-api calls this.
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:
this.backendApiClient.getSpaceMembers(spaceId)for membership checksthis.backendAiClient.whistleParticipant(sessionId, data)for session upgrade
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:
- Extracts JWT from
client.handshake.auth.tokenorclient.handshake.query.token - Validates the token
- Sets
client.data.userwithUnifiedCallershape - 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:
user:{userId}— only the authenticated userchat:{sessionId}— only if user is owner or participantspace:{spaceId}— only if user is a member of the spacetenant:{tenantId}— alias for space room
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:
- Remembers all conversations with a user across spaces
- Understands the context of a space (all users, sessions, modules)
- Can correlate patterns across spaces (e.g., "this user has similar issues in Space A and B")
- Can inject system messages into any session (awareness, proactive suggestions)
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:
- Build user context:
GET /ai-api/internal/sessions/user/:userId→ all sessions for a user across all tenants - Build tenant context:
GET /ai-api/internal/sessions/tenant/:tenantId→ all sessions in a space - Semantic recall:
POST /ai-api/internal/embeddings/search→ find relevant past conversations - Genome access:
GET /ai-api/internal/sessions/tenant/:tenantId/genomes→ conversation summaries for a space - 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:
- Only recall conversations from the current space (unless the user explicitly asks about another space)
AgentSessionDoc.activeContext.spaceIddetermines the scope- Cross-space intelligence requires the user to have membership in both spaces
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
- Create
UnifiedAuthGuardin@libs-api/auth-core - Register as
APP_GUARDinAuthCoreModule.forRoot() - Create
UnifiedCallerinterface and@CallerType(),@ServiceOnly(),@TenantScoped(),@OwnerOnly()decorators - Test with a single backend first (backend-ai-api)
S227.2 — @Public() audit across all backends (~300+ route methods)
- S227.2a: backend-api (67 controllers, ~3 existing
@Public()) - S227.2b: backend-ai-api (62 controllers, ~65 existing
@Public()) - S227.2c: backend-vitto-api (21 controllers, ~85+ existing
@Public()) - S227.2d: backend-docs-api (inventory + audit)
- Add
@Public()to intentionally-public routes - Remove per-controller
@UseGuards()from all 80+ controllers that had them - Remove
"Temporarily public"annotations from PlatformAIController - Delete old guard files
- IMPORTANT: Fix auth headers on
WhistleOrchestratorService.checkSpaceMembership()in this phase — it callsGET /spaces/:spaceId/memberswithout Bearer token, which will break when APP_GUARD goes live on backend-api. This must be fixed BEFORE or DURING the backend-api audit, not deferred to S227.5.
S227.3 — Tenant model enforcement
- Add
tenantIdtoAgentSessionDoc,CustomerSession - Add pre-save hooks for
tenantId ↔ spaceIdsync - Add optional
tenantIdquery parameter to user-facing list endpoints - Run data migration script on dev databases
S227.4 — Internal endpoints (Phase 1)
- Create
/internal/*controllers on backend-ai-api and backend-api - Move cross-user query endpoints to internal
- Update vitto-api connectivity clients to use internal routes
- Keep
SYSTEM_TOKENfor auth (Phase 1)
Phase 2: Hardening (S227.5–S227.7)
S227.5 — Whistle hardening
- Remove
@Public()from whistle endpoints - Add whistler membership check
- Space-scope the allowlist
- Route whistle through typed clients
- Fix LLM whistle tool userId resolution
S227.6 — Ownership enforcement on session endpoints
- Add
@OwnerOnly()or participant check to:GET /sessions/:idDELETE /sessions/:idPOST /sessions/:id/whistleGET /sessions/:id/participantsPOST /sessions/:id/messagesPOST /sessions/:id/reconcileGET /sessions/:id/genome
S227.7 — WebSocket security
- Create
WsAuthGuard - Apply to
ChatGateway,RealtimeGateway - Add room isolation checks
- Service gateways: validate service token in
handleConnection()
Phase 3: Cleanup (S227.8–S227.9)
S227.8 — Endpoint consolidation
- Merge duplicate infrastructure session endpoints
- Deprecate/remove unfiltered
/sessions/history - Remove migration/debugging endpoints from production API surface
- Remove deprecated guard files
S227.9 — Token consolidation & documentation
- Add startup token validation assertions
- Fix
McpUsageServiceto use typed client with auth - Fix
PlatformOrchestrationServicetoken variable name - Document the auth architecture in
docs/english/GoGrab-Space/architecture/
Phase 4: Future (Post-S227)
- Service JWTs: Replace static
SYSTEM_TOKENwith short-lived signed JWTs - Fine-grained permissions: Expand
permissions[]andscopes[]for granular access control - Audit logging: Log all service-to-service calls with caller identity and tenant context
- Rate limiting per tenant:
TierLimitGuardreadstenantIdfor per-space quotas
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:
- Immediate: Revert the
APP_GUARDregistration inauth-core.module.ts(single line change) - Re-add per-controller
@UseGuards()to the controllers that previously had them (git history) - Keep new
@Public()annotations — they're harmless without a global guard - Keep new internal endpoints and
UnifiedCallerinterface — they work independently
This rollback is safe because:
- The
@Public()additions are no-ops without a global guard - Internal endpoints are additive (new controllers, not modifications)
- The
UnifiedCallerinterface is consumed only by code we control
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
- Zero unguarded controllers — every route is either
@Public()(intentionally) or JWT-protected - All session schemas have tenantId — populated on every write
- Ownership checks on all session-by-ID endpoints — no cross-user data leakage
- Whistle requires authentication — no self-asserted identity
- Internal routes exist and are used — Vitto calls
/internal/*with service identity - One auth guard class —
UnifiedAuthGuardreplacesBackendAuthGuard,InfrastructureAuthGuard, andProjectApiKeyGuard - Token consistency — startup validation prevents misconfiguration
- 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