feat: Wave 2 — entities, security guards, API key encryption (15 files)
All checks were successful
Test Asgard Runner / test (push) Successful in 2s

Entities:
- Create 5 new TypeORM entities: webstore_config, webstore_categories,
  webstore_items, webstore_transactions, module_store (all verified against live DB)
- Fix wipe-profile entity: remove incorrect default {} for pre/post wipe configs

Security:
- Add @RequirePermission guards to 7 controllers (36 endpoints total):
  team, webstore, notifications, alerts, analytics, settings, schedules
- Encrypt panel API key with AES-256-GCM in setup service (was plaintext)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-21 13:28:48 -05:00
parent 208622000c
commit e1a3ea3b78
15 changed files with 268 additions and 5 deletions

View File

@@ -0,0 +1,52 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('module_store')
export class ModuleStore {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', unique: true })
module_slug: string;
@Column({ type: 'varchar' })
module_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'text', nullable: true })
long_description: string | null;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
price: number;
@Column({ type: 'varchar', default: 'one_time' })
price_type: string;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
monthly_price: number | null;
@Column({ type: 'varchar', default: '1.0.0' })
version: string;
@Column({ type: 'text', nullable: true })
download_path: string | null;
@Column({ type: 'text', nullable: true })
thumbnail_url: string | null;
@Column({ type: 'text', array: true, nullable: true, default: () => "'{}'" })
screenshots: string[] | null;
@Column({ type: 'varchar', nullable: true })
category: string | null;
@Column({ type: 'boolean', default: true })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
}

View File

@@ -0,0 +1,24 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('webstore_categories')
export class WebstoreCategory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar' })
category_name: string;
@Column({ type: 'integer', default: 0 })
display_order: number;
@Column({ type: 'boolean', default: true })
is_active: boolean;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,39 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('webstore_config')
export class WebstoreConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'text', nullable: true })
paypal_client_id: string | null;
@Column({ type: 'text', nullable: true })
paypal_secret: string | null;
@Column({ type: 'varchar', default: 'sandbox' })
paypal_mode: string;
@Column({ type: 'varchar', nullable: true })
store_name: string | null;
@Column({ type: 'text', nullable: true })
store_description: string | null;
@Column({ type: 'varchar', default: 'USD' })
currency: string;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,47 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
import { WebstoreCategory } from './webstore-category.entity';
@Entity('webstore_items')
export class WebstoreItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'uuid' })
category_id: string;
@Column({ type: 'varchar' })
item_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'decimal', precision: 10, scale: 2 })
price: number;
@Column({ type: 'text', nullable: true })
image_url: string | null;
@Column({ type: 'varchar', default: 'kit' })
item_type: string;
@Column({ type: 'jsonb' })
delivery_config: Record<string, any>;
@Column({ type: 'boolean', default: true })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => WebstoreCategory, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'category_id' })
category: WebstoreCategory;
}

View File

@@ -0,0 +1,47 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
import { WebstoreItem } from './webstore-item.entity';
@Entity('webstore_transactions')
export class WebstoreTransaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'uuid' })
item_id: string;
@Column({ type: 'varchar' })
buyer_steam_id: string;
@Column({ type: 'varchar', nullable: true })
buyer_name: string | null;
@Column({ type: 'decimal', precision: 10, scale: 2 })
amount: number;
@Column({ type: 'varchar', default: 'USD' })
currency: string;
@Column({ type: 'varchar', nullable: true })
paypal_transaction_id: string | null;
@Column({ type: 'varchar', default: 'pending' })
status: string;
@Column({ type: 'timestamptz', nullable: true })
delivered_at: Date | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
@ManyToOne(() => WebstoreItem, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'item_id' })
item: WebstoreItem;
}

View File

@@ -15,10 +15,10 @@ export class WipeProfile {
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: {} })
@Column({ type: 'jsonb' })
pre_wipe_config: Record<string, any>;
@Column({ type: 'jsonb', default: {} })
@Column({ type: 'jsonb' })
post_wipe_config: Record<string, any>;
@Column({ type: 'timestamptz', default: () => 'NOW()' })

View File

@@ -3,6 +3,7 @@ import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'
import { AlertsService } from './alerts.service';
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('alerts')
@ApiBearerAuth()
@@ -11,12 +12,14 @@ export class AlertsController {
constructor(private readonly alertsService: AlertsService) {}
@Get('config')
@RequirePermission('alerts.view')
@ApiOperation({ summary: 'Get alert configuration' })
async getConfig(@CurrentTenant() licenseId: string) {
return this.alertsService.getConfig(licenseId);
}
@Put('config')
@RequirePermission('alerts.manage')
@ApiOperation({ summary: 'Update alert configuration' })
async updateConfig(
@CurrentTenant() licenseId: string,
@@ -26,6 +29,7 @@ export class AlertsController {
}
@Get('history')
@RequirePermission('alerts.view')
@ApiOperation({ summary: 'Get alert history' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Max records to return (default: 50)' })
async getHistory(

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Query, Header } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { AnalyticsService } from './analytics.service';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('analytics')
@ApiBearerAuth()
@@ -10,6 +11,7 @@ export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Get('summary')
@RequirePermission('analytics.view')
@ApiOperation({ summary: 'Get analytics summary for time range' })
@ApiQuery({ name: 'range', required: false, type: Number, description: 'Hours to analyze (default: 24)' })
async getSummary(
@@ -21,6 +23,7 @@ export class AnalyticsController {
}
@Get('timeseries')
@RequirePermission('analytics.view')
@ApiOperation({ summary: 'Get timeseries data for charts' })
@ApiQuery({ name: 'range', required: false, type: Number })
@ApiQuery({ name: 'granularity', required: false, enum: ['raw', 'hourly'] })
@@ -35,6 +38,7 @@ export class AnalyticsController {
}
@Get('wipes/performance')
@RequirePermission('analytics.view')
@ApiOperation({ summary: 'Get wipe performance metrics' })
@ApiQuery({ name: 'range', required: false, type: Number, description: 'Hours (default: 720 = 30 days)' })
async getWipePerformance(
@@ -46,6 +50,7 @@ export class AnalyticsController {
}
@Get('maps')
@RequirePermission('analytics.view')
@ApiOperation({ summary: 'Get map usage analytics' })
@ApiQuery({ name: 'range', required: false, type: Number })
async getMapAnalytics(
@@ -57,6 +62,7 @@ export class AnalyticsController {
}
@Get('players')
@RequirePermission('analytics.view')
@ApiOperation({ summary: 'Get player analytics' })
@ApiQuery({ name: 'range', required: false, type: Number })
@ApiQuery({ name: 'metric', required: false, enum: ['sessions', 'retention'] })
@@ -71,6 +77,7 @@ export class AnalyticsController {
}
@Get('retention')
@RequirePermission('analytics.view')
@ApiOperation({ summary: 'Get player retention across wipes' })
@ApiQuery({ name: 'wipe_count', required: false, type: Number, description: 'Number of recent wipes (default: 5)' })
async getRetention(
@@ -82,6 +89,7 @@ export class AnalyticsController {
}
@Get('export')
@RequirePermission('analytics.view')
@ApiOperation({ summary: 'Export analytics data as CSV' })
@ApiQuery({ name: 'range', required: false, type: Number })
@Header('Content-Type', 'text/csv')

View File

@@ -7,6 +7,7 @@ import {
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { NotificationsService } from './notifications.service';
import { UpdateConfigDto } from './dto/update-config.dto';
@@ -18,6 +19,7 @@ export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get('config')
@RequirePermission('notifications.view')
@ApiOperation({
summary: 'Get notification configuration',
description: 'Returns notification settings for this license',
@@ -32,6 +34,7 @@ export class NotificationsController {
}
@Put('config')
@RequirePermission('notifications.manage')
@ApiOperation({
summary: 'Update notification configuration',
description: 'Update notification settings for this license',

View File

@@ -16,6 +16,7 @@ import {
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { SchedulesService } from './schedules.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
@@ -28,6 +29,7 @@ export class SchedulesController {
constructor(private readonly schedulesService: SchedulesService) {}
@Get('tasks')
@RequirePermission('schedules.view')
@ApiOperation({
summary: 'Get all scheduled tasks',
description: 'Returns all scheduled tasks for this license',
@@ -41,6 +43,7 @@ export class SchedulesController {
}
@Post('tasks')
@RequirePermission('schedules.manage')
@ApiOperation({
summary: 'Create a scheduled task',
description: 'Create a new scheduled task (restart, announcement, command, or plugin reload)',
@@ -61,6 +64,7 @@ export class SchedulesController {
}
@Put('tasks/:id')
@RequirePermission('schedules.manage')
@ApiOperation({
summary: 'Update a scheduled task',
description: 'Update task configuration, schedule, or settings',
@@ -82,6 +86,7 @@ export class SchedulesController {
}
@Delete('tasks/:id')
@RequirePermission('schedules.manage')
@ApiOperation({
summary: 'Delete a scheduled task',
description: 'Remove a scheduled task and unregister from scheduler',

View File

@@ -7,6 +7,7 @@ import {
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { SettingsService } from './settings.service';
import { UpdatePublicSiteDto } from './dto/update-public-site.dto';
import { UpdateDomainDto } from './dto/update-domain.dto';
@@ -19,6 +20,7 @@ export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get('public-site')
@RequirePermission('settings.view')
@ApiOperation({
summary: 'Get public site configuration',
description: 'Returns public site settings for this license',
@@ -32,6 +34,7 @@ export class SettingsController {
}
@Put('public-site')
@RequirePermission('settings.manage')
@ApiOperation({
summary: 'Update public site configuration',
description: 'Update public site settings for this license',
@@ -48,6 +51,7 @@ export class SettingsController {
}
@Put('domain')
@RequirePermission('settings.manage')
@ApiOperation({
summary: 'Update domain settings',
description: 'Update subdomain or custom domain for this license',

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ServerConnection } from '../../entities/server-connection.entity';
@@ -12,6 +13,7 @@ import * as crypto from 'crypto';
@Injectable()
export class SetupService {
constructor(
private readonly configService: ConfigService,
@InjectRepository(ServerConnection)
private readonly connectionRepo: Repository<ServerConnection>,
@InjectRepository(ServerConfig)
@@ -51,8 +53,17 @@ export class SetupService {
// Store encrypted API key if provided
if (dto.panel_api_key) {
// Stub - would encrypt in production
connection.panel_api_key_encrypted = dto.panel_api_key;
const encryptionKey = this.configService.get<string>('encryption.key', '');
const keyBuffer = Buffer.from(encryptionKey, 'hex');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv);
const encrypted = Buffer.concat([
cipher.update(dto.panel_api_key, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
connection.panel_api_key_encrypted =
`${iv.toString('hex')}:${encrypted.toString('hex')}:${authTag.toString('hex')}`;
}
connection.updated_at = new Date();

View File

@@ -17,6 +17,7 @@ import {
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { TeamService } from './team.service';
import { InviteMemberDto } from './dto/invite-member.dto';
import { CreateRoleDto } from './dto/create-role.dto';
@@ -30,6 +31,7 @@ export class TeamController {
constructor(private readonly teamService: TeamService) {}
@Get()
@RequirePermission('team.view')
@ApiOperation({
summary: 'Get team members and roles',
description: 'Returns all team members with their roles and all available roles',
@@ -43,6 +45,7 @@ export class TeamController {
}
@Post('invite')
@RequirePermission('team.manage')
@ApiOperation({
summary: 'Invite a team member',
description: 'Invite a user by email and assign them a role',
@@ -68,6 +71,7 @@ export class TeamController {
}
@Delete(':id')
@RequirePermission('team.manage')
@ApiOperation({
summary: 'Remove a team member',
description: 'Remove a team member by ID',
@@ -88,6 +92,7 @@ export class TeamController {
}
@Post('roles')
@RequirePermission('team.manage')
@ApiOperation({
summary: 'Create a custom role',
description: 'Create a new custom role for this license',
@@ -108,6 +113,7 @@ export class TeamController {
}
@Put('roles/:id')
@RequirePermission('team.manage')
@ApiOperation({
summary: 'Update a role',
description: 'Update role permissions and details',
@@ -133,6 +139,7 @@ export class TeamController {
}
@Delete('roles/:id')
@RequirePermission('team.manage')
@ApiOperation({
summary: 'Delete a role',
description: 'Delete a custom role (cannot delete system roles or roles in use)',

View File

@@ -7,6 +7,7 @@ import { CreateItemDto } from './dto/create-item.dto';
import { PurchaseDto } from './dto/purchase.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { Public } from '../../common/decorators/public.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('webstore')
@Controller()
@@ -15,6 +16,7 @@ export class WebstoreController {
// Admin Routes
@Get('webstore/config')
@RequirePermission('store.manage')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get webstore configuration' })
async getConfig(@CurrentTenant() licenseId: string) {
@@ -23,6 +25,7 @@ export class WebstoreController {
}
@Put('webstore/config')
@RequirePermission('store.manage')
@ApiBearerAuth()
@ApiOperation({ summary: 'Update webstore configuration' })
async updateConfig(
@@ -34,6 +37,7 @@ export class WebstoreController {
}
@Get('webstore/categories')
@RequirePermission('store.manage')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all store categories' })
async getCategories(@CurrentTenant() licenseId: string) {
@@ -41,6 +45,7 @@ export class WebstoreController {
}
@Post('webstore/categories')
@RequirePermission('store.manage')
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new category' })
async createCategory(
@@ -51,6 +56,7 @@ export class WebstoreController {
}
@Put('webstore/categories/:id')
@RequirePermission('store.manage')
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a category' })
@ApiParam({ name: 'id', description: 'Category ID' })
@@ -63,6 +69,7 @@ export class WebstoreController {
}
@Delete('webstore/categories/:id')
@RequirePermission('store.manage')
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a category' })
@ApiParam({ name: 'id', description: 'Category ID' })
@@ -75,6 +82,7 @@ export class WebstoreController {
}
@Get('webstore/items')
@RequirePermission('store.manage')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all store items' })
async getItems(@CurrentTenant() licenseId: string) {
@@ -82,6 +90,7 @@ export class WebstoreController {
}
@Post('webstore/items')
@RequirePermission('store.manage')
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new store item' })
async createItem(
@@ -92,6 +101,7 @@ export class WebstoreController {
}
@Put('webstore/items/:id')
@RequirePermission('store.manage')
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a store item' })
@ApiParam({ name: 'id', description: 'Item ID' })
@@ -104,6 +114,7 @@ export class WebstoreController {
}
@Delete('webstore/items/:id')
@RequirePermission('store.manage')
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a store item' })
@ApiParam({ name: 'id', description: 'Item ID' })
@@ -116,6 +127,7 @@ export class WebstoreController {
}
@Get('webstore/transactions')
@RequirePermission('store.manage')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all store transactions' })
async getTransactions(@CurrentTenant() licenseId: string) {