Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions migrations/20260207160515_player_connection_history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Knex } from "knex";


export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("player_connection_history", (table) => {
table.increments("id").primary();
table.string("steam_id_3").notNullable();
table.string("ip_address").notNullable();
table.string("nickname").notNullable();
table.timestamp("timestamp").notNullable().defaultTo(knex.fn.now());
});
}


export async function down(knex: Knex): Promise<void> {
Comment on lines +11 to +15

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration introduces persistent storage of players’ IP addresses and nicknames. Since IP addresses are sensitive personal data, consider adding a retention/cleanup strategy (e.g., periodic purge/TTL) and/or storing a privacy-preserving representation (hash/prefix) if full fidelity isn’t required for alt detection.

Suggested change
});
}
export async function down(knex: Knex): Promise<void> {
});
// Enforce a retention policy for sensitive connection data (e.g., 90 days)
// This SQLite trigger deletes records older than 90 days on each insert.
await knex.raw(`
CREATE TRIGGER IF NOT EXISTS player_connection_history_delete_old
AFTER INSERT ON player_connection_history
BEGIN
DELETE FROM player_connection_history
WHERE timestamp < datetime('now', '-90 days');
END;
`);
}
export async function down(knex: Knex): Promise<void> {
// Drop retention trigger before removing the table
await knex.raw(`DROP TRIGGER IF EXISTS player_connection_history_delete_old;`);

Copilot uses AI. Check for mistakes.
await knex.schema.dropTableIfExists("player_connection_history");
}

1 change: 1 addition & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export * from './src/repository/UserRepository';
export * from './src/repository/UserBanRepository';
export * from './src/repository/UserCreditsRepository';
export * from './src/repository/ServerStatusMetricsRepository';
export * from './src/repository/PlayerConnectionHistoryRepository';

// Services
export * from './src/services/BackgroundTaskQueue';
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/domain/PlayerConnectionHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type PlayerConnectionHistory = {
id?: number;
steamId3: string;
ipAddress: string;
nickname: string;
timestamp: Date;
};
1 change: 1 addition & 0 deletions packages/core/src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export * from "./GuildParameters";
export * from "./Cost";
export * from "./DateRange";
export * from "./ServerActivity";
export * from "./PlayerConnectionHistory";
export { ServerStatusParser } from "./ServerStatus";
export * from "./ServerStatusMetric";
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PlayerConnectionHistory } from "../domain/PlayerConnectionHistory";

export interface PlayerConnectionHistoryRepository {
save(params: { connectionHistory: Omit<PlayerConnectionHistory, "id"> }): Promise<PlayerConnectionHistory>;
}
8 changes: 7 additions & 1 deletion packages/entrypoints/src/discordBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { SQLiteServerRepository } from "@tf2qs/providers";
import { SQliteUserCreditsRepository } from "@tf2qs/providers";
import { SQliteUserRepository } from "@tf2qs/providers";
import { SQliteServerStatusMetricsRepository } from "@tf2qs/providers";
import { SQlitePlayerConnectionHistoryRepository } from "@tf2qs/providers";
import { AdyenPaymentService } from "@tf2qs/providers";
import { ChancePasswordGeneratorService } from "@tf2qs/providers";
import { defaultAWSServiceFactory } from "@tf2qs/providers";
Expand Down Expand Up @@ -134,6 +135,10 @@ export async function startDiscordBot() {
knex: KnexConnectionManager.client
})

const playerConnectionHistoryRepository = new SQlitePlayerConnectionHistoryRepository({
knex: KnexConnectionManager.client
})

const backgroundTaskQueue = new InMemoryBackgroundTaskQueue(defaultGracefulShutdownManager);
const deleteServerUseCase = new DeleteServerForUser({
serverManagerFactory: serverManagerFactory,
Expand Down Expand Up @@ -372,7 +377,8 @@ export async function startDiscordBot() {
serverRepository,
userRepository,
eventLogger,
backgroundTaskQueue
backgroundTaskQueue,
playerConnectionHistoryRepository
});

initializeExpress({})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,25 @@ describe("clientConnected command parser", () => {
});

});

it("should save connection history to repository", async () => {
const { services, command, handler } = createTestEnvironment();
if (!command || !handler) throw new Error("Command or handler is undefined");

await handler({
args: command.args,
password: "test-password",
services,
});

expect(services.playerConnectionHistoryRepository.save).toHaveBeenCalledWith({
connectionHistory: {
steamId3: "U:1:29162964",
ipAddress: "169.254.249.16",
nickname: "sonikro",
timestamp: expect.any(Date),
},
});
});
});
});
12 changes: 11 additions & 1 deletion packages/entrypoints/src/udp/srcdsCommands/ClientConnected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const clientConnected: SRCDSCommandParser<ClientConnectedArgs> = (rawStri
raw: rawString,
args: { nickname, ipAddress, steamId3 },
type: "clientConnected",
handler: async ({ args }) => {
handler: async ({ args, services }) => {
const { nickname, ipAddress, steamId3 } = args;

logger.emit({
Expand All @@ -33,6 +33,16 @@ export const clientConnected: SRCDSCommandParser<ClientConnectedArgs> = (rawStri
steamId3,
},
});

// Persist connection history
await services.playerConnectionHistoryRepository.save({
connectionHistory: {
steamId3,
ipAddress,
nickname,
timestamp: new Date(),
},
Comment thread
sonikro marked this conversation as resolved.
});
Comment thread
sonikro marked this conversation as resolved.
Comment thread
sonikro marked this conversation as resolved.
},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { UserRepository } from "@tf2qs/core";
import { EventLogger } from "@tf2qs/core";
import { ServerCommander } from "@tf2qs/core";
import { BackgroundTaskQueue } from "@tf2qs/core";
import { PlayerConnectionHistoryRepository } from "@tf2qs/core";

/**
* List of services available to UDP Command Handlers
Expand All @@ -15,4 +16,5 @@ export type UDPCommandsServices = {
userRepository: UserRepository
eventLogger: EventLogger;
backgroundTaskQueue: BackgroundTaskQueue;
playerConnectionHistoryRepository: PlayerConnectionHistoryRepository;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Knex } from "knex";
import { PlayerConnectionHistory, PlayerConnectionHistoryRepository } from "@tf2qs/core";

type SQlitePlayerConnectionHistoryRepositoryDependencies = {
knex: Knex;
};

export class SQlitePlayerConnectionHistoryRepository implements PlayerConnectionHistoryRepository {
constructor(private readonly dependencies: SQlitePlayerConnectionHistoryRepositoryDependencies) {}

async save(params: { connectionHistory: Omit<PlayerConnectionHistory, "id"> }): Promise<PlayerConnectionHistory> {
const { connectionHistory } = params;
const { knex } = this.dependencies;

const [id] = await knex("player_connection_history").insert({
steam_id_3: connectionHistory.steamId3,
ip_address: connectionHistory.ipAddress,
nickname: connectionHistory.nickname,
timestamp: connectionHistory.timestamp,
});

return {
id,
steamId3: connectionHistory.steamId3,
ipAddress: connectionHistory.ipAddress,
nickname: connectionHistory.nickname,
timestamp: connectionHistory.timestamp,
};
}
}
1 change: 1 addition & 0 deletions packages/providers/src/repository/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './SQliteServerRepository';
export * from './SQliteUserCreditsRepository';
export * from './SQliteUserRepository';
export * from './SQliteServerStatusMetricsRepository';
export * from './SQlitePlayerConnectionHistoryRepository';
Loading