fix(api): Beta hardening — real 500 fix, encryption guard, honest payments
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 1m36s
CI / integration (push) Successful in 23s

- analytics: getMapAnalytics queried map.name but the map_library column is
  display_name (no name column) — every map-analytics call 500'd. Fixed select
  + groupBy to map.display_name.
- setup: guard ENCRYPTION_KEY length before AES-256-GCM createCipheriv — an
  unset key crashed bare-metal setup with an opaque 'Invalid key length' 500;
  now returns a clear 503. Also stop falsely marking bare-metal connected on
  completeSetup; leave offline until the agent's first heartbeat.
- webstore: public checkout returned a FAKE PayPal order token + sandbox URL
  that resolves to nowhere. Refuse honestly with 503 (payments coming soon)
  instead of faking a transaction.
- store: module purchase wrote a fake txn_<ts> implying a charge; record it
  honestly as a free Beta grant (transaction_id=beta-free-grant, amount 0).

Backend tsc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 21:53:22 -04:00
parent d13f2cb8b1
commit f2ea415840
4 changed files with 33 additions and 27 deletions

View File

@@ -111,13 +111,13 @@ export class AnalyticsService {
.createQueryBuilder('wipe') .createQueryBuilder('wipe')
.leftJoinAndSelect('wipe.map', 'map') .leftJoinAndSelect('wipe.map', 'map')
.select('map.id', 'map_id') .select('map.id', 'map_id')
.addSelect('map.name', 'map_name') .addSelect('map.display_name', 'map_name')
.addSelect('COUNT(wipe.id)', 'usage_count') .addSelect('COUNT(wipe.id)', 'usage_count')
.where('wipe.license_id = :licenseId', { licenseId }) .where('wipe.license_id = :licenseId', { licenseId })
.andWhere('wipe.started_at >= :cutoff', { cutoff }) .andWhere('wipe.started_at >= :cutoff', { cutoff })
.andWhere('wipe.map_id IS NOT NULL') .andWhere('wipe.map_id IS NOT NULL')
.groupBy('map.id') .groupBy('map.id')
.addGroupBy('map.name') .addGroupBy('map.display_name')
.getRawMany(); .getRawMany();
return { return {

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -55,6 +55,13 @@ export class SetupService {
if (dto.panel_api_key) { if (dto.panel_api_key) {
const encryptionKey = this.configService.get<string>('encryption.key', ''); const encryptionKey = this.configService.get<string>('encryption.key', '');
const keyBuffer = Buffer.from(encryptionKey, 'hex'); const keyBuffer = Buffer.from(encryptionKey, 'hex');
// AES-256-GCM needs a 32-byte key. An unset/short ENCRYPTION_KEY would
// otherwise crash createCipheriv with an opaque "Invalid key length" 500.
if (keyBuffer.length !== 32) {
throw new ServiceUnavailableException(
'Server encryption is not configured (ENCRYPTION_KEY must be 32 bytes / 64 hex chars). Contact the platform operator.',
);
}
const iv = crypto.randomBytes(16); const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv); const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv);
const encrypted = Buffer.concat([ const encrypted = Buffer.concat([
@@ -82,9 +89,12 @@ export class SetupService {
}); });
if (connection) { if (connection) {
// For bare metal, mark as connected immediately (waiting for agent) // Bare-metal stays 'offline' until the agent's first heartbeat flips it
if (connection.connection_type === 'bare_metal') { // 'connected' (HostAgentConsumerService). Marking it connected here was a
connection.connection_status = 'connected'; // false positive — the dashboard showed a live server before any agent
// had checked in.
if (connection.connection_type === 'bare_metal' && connection.connection_status !== 'connected') {
connection.connection_status = 'offline';
connection.updated_at = new Date(); connection.updated_at = new Date();
await this.connectionRepo.save(connection); await this.connectionRepo.save(connection);
} }

View File

@@ -57,11 +57,17 @@ export class StoreService {
throw new NotFoundException('Module not found'); throw new NotFoundException('Module not found');
} }
// Beta: modules are granted free (no payment processing wired yet). Record
// it honestly as a beta grant at $0 rather than a fake `txn_*` id that
// implies a real charge occurred.
this.logger.log(
`Granting module ${moduleId} to license ${licenseId} free (Beta — no payment processing)`,
);
const purchase = this.purchaseRepo.create({ const purchase = this.purchaseRepo.create({
license_id: licenseId, license_id: licenseId,
module_id: moduleId, module_id: moduleId,
transaction_id: `txn_${Date.now()}`, transaction_id: 'beta-free-grant',
amount_paid: parseFloat(module.price_usd.toString()), amount_paid: 0,
}); });
return this.purchaseRepo.save(purchase); return this.purchaseRepo.save(purchase);

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { StoreConfig } from '../../entities/store-config.entity'; import { StoreConfig } from '../../entities/store-config.entity';
@@ -224,23 +224,13 @@ export class WebstoreService {
throw new NotFoundException('Item not found'); throw new NotFoundException('Item not found');
} }
const transaction = this.transactionRepo.create({ // Beta: real PayPal/Stripe processing is not wired yet. Refuse honestly
license_id: license.id, // instead of writing a pending transaction and handing the player a fake
item_id: item.id, // order token that resolves to nowhere. (item lookup above still validates
steam_id: dto.steam_id, // the request so the storefront UI can show the catalogue.)
player_name: dto.player_name, void item;
paypal_order_id: `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, throw new ServiceUnavailableException(
amount: parseFloat(item.price.toString()), 'Storefront checkout is not available yet — payment processing is coming soon.',
currency: 'USD', // Would get from config );
status: 'pending',
});
await this.transactionRepo.save(transaction);
// Return mock PayPal approval URL
return {
order_id: transaction.paypal_order_id,
approval_url: `https://www.sandbox.paypal.com/checkoutnow?token=${transaction.paypal_order_id}`,
};
} }
} }