Skip to content

Commit 6db38fa

Browse files
committed
fix(backend): preserve satellite commands during account deletion
Fixed critical bugs where MCP processes weren't terminated and FK constraints blocked user deletion during self-service account removal. - Preserve pending satellite commands by setting target_team_id and created_by to NULL instead of deleting them - Delete only completed/failed/acknowledged/executing satellite commands - Added cleanup of satellite-related records (processes, usage logs, client activity) before user deletion to prevent FK violations - Follows same pattern as TeamService.deleteTeam() This ensures satellites can still process pending kill commands while allowing user and team deletion to proceed without FK constraint errors.
1 parent 9f968f2 commit 6db38fa

File tree

8 files changed

+307
-12
lines changed

8 files changed

+307
-12
lines changed

services/backend/src/db/schema-tables/satellites.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const satelliteCommands = pgTable('satelliteCommands', {
6868
id: text('id').primaryKey(),
6969
satellite_id: text('satellite_id').notNull().references(() => satellites.id, { onDelete: 'cascade' }),
7070
command_type: text('command_type', {
71-
enum: ['spawn', 'kill', 'restart', 'configure', 'health_check']
71+
enum: ['spawn', 'kill', 'restart', 'configure', 'health_check', 'invalidate_user_token_cache']
7272
}).notNull(),
7373
priority: text('priority', { enum: ['immediate', 'high', 'normal', 'low'] }).notNull().default('normal'),
7474
payload: text('payload').notNull(), // JSON command data with team context

services/backend/src/routes/users/deleteMyAccount.ts

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import type { FastifyInstance, FastifyRequest } from 'fastify';
3-
import { and, eq, ne } from 'drizzle-orm';
3+
import { and, eq, ne, or } from 'drizzle-orm';
44
import { getDb, getSchema } from '../../db/index';
55
import { McpInstallationService } from '../../services/mcpInstallationService';
66
import { SatelliteCommandService } from '../../services/satelliteCommandService';
@@ -360,15 +360,120 @@ export default async function deleteMyAccountRoute(server: FastifyInstance) {
360360
// Don't fail account deletion if event emission fails
361361
}
362362

363-
// STEP 7: Delete satellite commands targeting this team
363+
// STEP 6.5: Invalidate satellite token caches
364364
server.log.debug({
365-
operation: 'account_deletion_delete_satellite_commands',
365+
operation: 'account_deletion_invalidate_satellite_cache',
366+
userId,
367+
userEmail: user.email
368+
}, 'Sending cache invalidation commands to satellites');
369+
370+
try {
371+
const satelliteCommandService = new SatelliteCommandService(db, server.log);
372+
const commands = await satelliteCommandService.notifyUserDeletion(userId, user.email);
373+
374+
server.log.info({
375+
operation: 'account_deletion_cache_invalidation_sent',
376+
userId,
377+
userEmail: user.email,
378+
satelliteCommandsCreated: commands.length
379+
}, `Cache invalidation sent to ${commands.length} satellites`);
380+
} catch (cacheError) {
381+
server.log.error(cacheError, 'Failed to send cache invalidation - continuing deletion');
382+
// Non-fatal: caches expire naturally within 5 minutes
383+
}
384+
385+
// STEP 7: Handle satellite commands - preserve pending commands
386+
server.log.debug({
387+
operation: 'account_deletion_handle_satellite_commands',
388+
userId,
389+
teamId
390+
}, 'Handling satellite commands');
391+
392+
// Set target_team_id = NULL for PENDING commands for this team (allows team deletion without blocking satellite execution)
393+
await db
394+
.update(schema.satelliteCommands)
395+
.set({ target_team_id: null })
396+
.where(
397+
and(
398+
eq(schema.satelliteCommands.target_team_id, teamId),
399+
eq(schema.satelliteCommands.status, 'pending')
400+
)
401+
);
402+
403+
// Delete non-pending satellite commands for this team (completed/failed/executing) since they're no longer needed
404+
await db
405+
.delete(schema.satelliteCommands)
406+
.where(
407+
and(
408+
eq(schema.satelliteCommands.target_team_id, teamId),
409+
or(
410+
eq(schema.satelliteCommands.status, 'completed'),
411+
eq(schema.satelliteCommands.status, 'failed'),
412+
eq(schema.satelliteCommands.status, 'acknowledged'),
413+
eq(schema.satelliteCommands.status, 'executing')
414+
)
415+
)
416+
);
417+
418+
// Handle ALL commands created by this user (for any team) - set created_by = NULL for pending, delete completed
419+
await db
420+
.update(schema.satelliteCommands)
421+
.set({ created_by: null })
422+
.where(
423+
and(
424+
eq(schema.satelliteCommands.created_by, userId),
425+
eq(schema.satelliteCommands.status, 'pending')
426+
)
427+
);
428+
429+
await db
430+
.delete(schema.satelliteCommands)
431+
.where(
432+
and(
433+
eq(schema.satelliteCommands.created_by, userId),
434+
or(
435+
eq(schema.satelliteCommands.status, 'completed'),
436+
eq(schema.satelliteCommands.status, 'failed'),
437+
eq(schema.satelliteCommands.status, 'acknowledged'),
438+
eq(schema.satelliteCommands.status, 'executing')
439+
)
440+
)
441+
);
442+
443+
server.log.info({
444+
operation: 'account_deletion_satellite_commands_handled',
366445
userId,
367446
teamId
368-
}, 'Deleting satellite commands for team');
447+
}, 'Satellite commands handled - pending commands preserved');
369448

370-
await db.delete(schema.satelliteCommands)
371-
.where(eq(schema.satelliteCommands.target_team_id, teamId));
449+
// STEP 7.5: Delete satellite-related records
450+
server.log.debug({
451+
operation: 'account_deletion_delete_satellite_data',
452+
userId,
453+
teamId
454+
}, 'Deleting satellite-related data');
455+
456+
// Delete satellite processes for user's default team
457+
await db.delete(schema.satelliteProcesses)
458+
.where(eq(schema.satelliteProcesses.team_id, teamId));
459+
460+
// Delete satellite usage logs for user's default team
461+
await db.delete(schema.satelliteUsageLogs)
462+
.where(eq(schema.satelliteUsageLogs.team_id, teamId));
463+
464+
// Delete MCP client activity records for the user
465+
await db.delete(schema.mcpClientActivity)
466+
.where(eq(schema.mcpClientActivity.user_id, userId));
467+
468+
// Delete MCP client activity metrics for the user
469+
await db.delete(schema.mcpClientActivityMetrics)
470+
.where(eq(schema.mcpClientActivityMetrics.user_id, userId));
471+
472+
server.log.info({
473+
operation: 'account_deletion_satellite_data_deleted',
474+
userId,
475+
teamId
476+
}, 'Satellite-related data deleted');
372477

373478
// STEP 8: Delete the default team
374479
server.log.debug({

services/backend/src/services/satelliteCommandService.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { FastifyBaseLogger } from 'fastify';
55
import { nanoid } from 'nanoid';
66

77
// Match schema enum from satelliteCommands table
8-
export type CommandType = 'spawn' | 'kill' | 'restart' | 'configure' | 'health_check';
8+
export type CommandType = 'spawn' | 'kill' | 'restart' | 'configure' | 'health_check' | 'invalidate_user_token_cache';
99
export type CommandPriority = 'immediate' | 'high' | 'normal' | 'low';
1010

1111
export interface SatelliteCommand {
@@ -89,7 +89,7 @@ export class SatelliteCommandService {
8989
await this.db.insert(satelliteCommands).values(commands.map(cmd => ({
9090
id: cmd.id,
9191
satellite_id: cmd.satellite_id,
92-
command_type: cmd.command_type as 'spawn' | 'kill' | 'restart' | 'configure' | 'health_check',
92+
command_type: cmd.command_type as 'spawn' | 'kill' | 'restart' | 'configure' | 'health_check' | 'invalidate_user_token_cache',
9393
priority: cmd.priority as 'immediate' | 'high' | 'normal' | 'low',
9494
payload: cmd.payload,
9595
status: 'pending' as const,
@@ -158,7 +158,7 @@ export class SatelliteCommandService {
158158
await this.db.insert(satelliteCommands).values({
159159
id: command.id,
160160
satellite_id: command.satellite_id,
161-
command_type: command.command_type as 'spawn' | 'kill' | 'restart' | 'configure' | 'health_check',
161+
command_type: command.command_type as 'spawn' | 'kill' | 'restart' | 'configure' | 'health_check' | 'invalidate_user_token_cache',
162162
priority: command.priority as 'immediate' | 'high' | 'normal' | 'low',
163163
payload: command.payload,
164164
status: 'pending' as const,
@@ -236,7 +236,7 @@ export class SatelliteCommandService {
236236
await this.db.insert(satelliteCommands).values(commands.map(cmd => ({
237237
id: cmd.id,
238238
satellite_id: cmd.satellite_id,
239-
command_type: cmd.command_type as 'spawn' | 'kill' | 'restart' | 'configure' | 'health_check',
239+
command_type: cmd.command_type as 'spawn' | 'kill' | 'restart' | 'configure' | 'health_check' | 'invalidate_user_token_cache',
240240
priority: cmd.priority as 'immediate' | 'high' | 'normal' | 'low',
241241
payload: cmd.payload,
242242
status: 'pending' as const,
@@ -345,4 +345,27 @@ export class SatelliteCommandService {
345345
});
346346
}
347347

348+
/**
349+
* Notify satellites to invalidate all cached tokens for a SPECIFIC deleted user
350+
* This ensures only the deleted user's tokens are removed from cache
351+
*/
352+
async notifyUserDeletion(userId: string, userEmail: string): Promise<SatelliteCommand[]> {
353+
this.logger.info({
354+
operation: 'notify_user_deletion',
355+
userId,
356+
userEmail
357+
}, `Notifying satellites to invalidate tokens for deleted user: ${userEmail}`);
358+
359+
return await this.createCommandForAllGlobalSatellites({
360+
commandType: 'invalidate_user_token_cache',
361+
priority: 'immediate',
362+
payload: {
363+
event: 'user_deleted',
364+
user_id: userId,
365+
user_email: userEmail
366+
},
367+
expiresInMinutes: 5
368+
});
369+
}
370+
348371
}

services/satellite/src/server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,10 @@ export async function createServer() {
729729
server.decorate('tokenIntrospectionService', tokenIntrospectionService);
730730
server.decorate('oauthTokenService', oauthTokenService);
731731

732+
// Configure command processor with token services for cache invalidation
733+
commandProcessor.setTokenIntrospectionService(tokenIntrospectionService);
734+
commandProcessor.setOAuthTokenService(oauthTokenService);
735+
732736
server.log.info({
733737
operation: 'oauth_services_initialized',
734738
satellite_id: satelliteId,
@@ -819,6 +823,10 @@ export async function createServer() {
819823
server.decorate('tokenIntrospectionService', tokenIntrospectionService);
820824
server.decorate('oauthTokenService', oauthTokenService);
821825

826+
// Configure command processor with token services for cache invalidation
827+
commandProcessor.setTokenIntrospectionService(tokenIntrospectionService);
828+
commandProcessor.setOAuthTokenService(oauthTokenService);
829+
822830
server.log.info({
823831
operation: 'oauth_services_initialized',
824832
satellite_id: satelliteId,

services/satellite/src/services/command-polling-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { BackendClient } from './backend-client';
33

44
export interface SatelliteCommand {
55
id: string;
6-
command_type: 'spawn' | 'kill' | 'restart' | 'configure' | 'health_check';
6+
command_type: 'spawn' | 'kill' | 'restart' | 'configure' | 'health_check' | 'invalidate_user_token_cache';
77
priority: 'immediate' | 'high' | 'normal' | 'low';
88
payload: {
99
installation_id?: string; // Primary identifier for MCP installations

services/satellite/src/services/command-processor.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export class CommandProcessor {
2828
private stdioDiscoveryManager: StdioToolDiscoveryManager | null;
2929
private unifiedToolDiscoveryManager: UnifiedToolDiscoveryManager | null = null;
3030
private eventBus: EventBus | null = null;
31+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
32+
private tokenIntrospectionService: any | null = null;
33+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
34+
private oauthTokenService: any | null = null;
3135
private processes: Map<string, ProcessInfo> = new Map();
3236
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3337
private onConfigurationUpdate?: (config: any) => Promise<void>;
@@ -68,6 +72,22 @@ export class CommandProcessor {
6872
this.eventBus = eventBus;
6973
}
7074

75+
/**
76+
* Set token introspection service for cache invalidation
77+
*/
78+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
79+
setTokenIntrospectionService(service: any): void {
80+
this.tokenIntrospectionService = service;
81+
}
82+
83+
/**
84+
* Set OAuth token service for cache invalidation
85+
*/
86+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
87+
setOAuthTokenService(service: any): void {
88+
this.oauthTokenService = service;
89+
}
90+
7191
/**
7292
* Emit status change event to backend
7393
*/
@@ -175,6 +195,9 @@ export class CommandProcessor {
175195
case 'health_check':
176196
result = await this.handleHealthCheckCommand(command);
177197
break;
198+
case 'invalidate_user_token_cache':
199+
result = await this.handleInvalidateUserTokenCacheCommand(command);
200+
break;
178201
default:
179202
throw new Error(`Unknown command type: ${command.command_type}`);
180203
}
@@ -778,6 +801,81 @@ export class CommandProcessor {
778801
};
779802
}
780803

804+
/**
805+
* Handle invalidate_user_token_cache command
806+
* Invalidates ONLY the specified user's cached tokens (preserves other users' caches)
807+
*/
808+
private async handleInvalidateUserTokenCacheCommand(command: SatelliteCommand): Promise<CommandResult> {
809+
const payload = command.payload;
810+
const userId = payload.user_id as string;
811+
const userEmail = payload.user_email as string;
812+
813+
if (!userId) {
814+
throw new Error('user_id is required for user token cache invalidation');
815+
}
816+
817+
this.logger.info({
818+
operation: 'command_invalidate_user_token_cache',
819+
command_id: command.id,
820+
user_id: userId,
821+
user_email: userEmail
822+
}, `Invalidating cached tokens for SPECIFIC user: ${userEmail}`);
823+
824+
try {
825+
let invalidatedCount = 0;
826+
827+
// Invalidate TokenIntrospectionService cache (Bearer tokens)
828+
if (this.tokenIntrospectionService) {
829+
const count = this.tokenIntrospectionService.invalidateUserTokens(userId);
830+
invalidatedCount += count;
831+
this.logger.debug({
832+
operation: 'token_introspection_cache_invalidated',
833+
user_id: userId,
834+
count
835+
}, `Invalidated ${count} bearer token cache entries for user ${userId}`);
836+
}
837+
838+
// Invalidate OAuthTokenService cache (MCP OAuth tokens)
839+
if (this.oauthTokenService) {
840+
const count = this.oauthTokenService.clearUserCache(userId);
841+
invalidatedCount += count;
842+
this.logger.debug({
843+
operation: 'oauth_token_cache_invalidated',
844+
user_id: userId,
845+
count
846+
}, `Invalidated ${count} OAuth token cache entries for user ${userId}`);
847+
}
848+
849+
this.logger.info({
850+
operation: 'user_token_cache_invalidated',
851+
command_id: command.id,
852+
user_id: userId,
853+
user_email: userEmail,
854+
total_invalidated: invalidatedCount
855+
}, `Successfully invalidated ${invalidatedCount} cached tokens for user ${userEmail}`);
856+
857+
return {
858+
command_id: command.id,
859+
status: 'completed',
860+
result: {
861+
user_id: userId,
862+
user_email: userEmail,
863+
invalidated_count: invalidatedCount,
864+
message: `Invalidated ${invalidatedCount} cached tokens for user ${userEmail}`
865+
}
866+
};
867+
} catch (error) {
868+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
869+
this.logger.error({
870+
operation: 'command_invalidate_user_token_cache_failed',
871+
command_id: command.id,
872+
user_id: userId,
873+
error: errorMessage
874+
}, `User token cache invalidation failed: ${errorMessage}`);
875+
throw error;
876+
}
877+
}
878+
781879
/**
782880
* Handle credential validation for a specific installation (Phase 9)
783881
* Tries to call tools/list with the installation's credentials

services/satellite/src/services/oauth-token-service.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,4 +254,28 @@ export class OAuthTokenService {
254254
operation: 'oauth_tokens_cache_cleared_all'
255255
}, 'Cleared all OAuth token cache');
256256
}
257+
258+
/**
259+
* Clear all cached tokens for a specific user
260+
*/
261+
clearUserCache(userId: string): number {
262+
let cleared = 0;
263+
264+
// Cache key format: `${installationId}:${userId}:${teamId}`
265+
for (const cacheKey of this.tokenCache.keys()) {
266+
const parts = cacheKey.split(':');
267+
if (parts.length === 3 && parts[1] === userId) {
268+
this.tokenCache.delete(cacheKey);
269+
cleared++;
270+
}
271+
}
272+
273+
this.logger.info({
274+
operation: 'oauth_tokens_user_cache_cleared',
275+
user_id: userId,
276+
cleared_count: cleared
277+
}, `Cleared ${cleared} OAuth cache entries for user ${userId}`);
278+
279+
return cleared;
280+
}
257281
}

0 commit comments

Comments
 (0)