3 Commits

Author SHA1 Message Date
Vantz Stockwell
440474290b feat: wire the panel command surface to the live Rust agent + wipe handler
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 1m35s
Build Host Agent (Rust) / build (push) Successful in 1m48s
CI / integration (push) Successful in 23s
The legacy Go agent was never deployed, so the entire backend command surface
published to a dead cmd.server/cmd.wipe/files.cmd void. Route it all to the
Rust agent's instance-scoped subjects.

Agent (corrosion-host-agent, alpha.10):
- New src/wipe.rs + 'wipe' func on {instance}.cmd: stop -> delete game files by
  type (map/blueprint/full, with optional backup) -> restart. Jailed to the
  instance root, symlink-safe (lstat, no cross-boundary follow — Lesson 26).
  8 tests incl. jail-escape + symlink-skip proofs. Agent suite 64 tests green.

Backend (NestJS):
- InstancesService is now @Global with license-scoped convenience wrappers
  (lifecycleForLicense/rconForLicense/writeFileForLicense/readFileForLicense/
  deleteFileForLicense/wipeForLicense) + resolveDefaultInstance (license ->
  primary instance).
- Routed to the agent: servers start/stop/restart/command; players kick/banid/
  unban via RCON; schedules restart/announce/command/plugin-reload; wipes ->
  wipeForLicense (real wipe now); plugins reload/unload/upload via rcon+file
  ops; all 9 plugin-config module applies -> writeFileForLicense + oxide.reload
  rcon, imports -> readFileForLicense (server:// prefix stripped).
- Honestly gated (need agent funcs not yet built): server deploy-from-panel,
  Oxide install, one-click uMod install -> 503 coming-soon instead of dead
  publishes.

Backend tsc green; agent cargo test green (64).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:30:18 -04:00
Vantz Stockwell
6f783bfac8 feat(panel): Beta sweep — multi-game coherence, honesty, UX fixes
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 22s
Multi-game rebrand (no more Rust-only leftovers): game-neutral setup wizard +
deploy/store defaults; player-id labels driven by game profile (Steam ID only
for Rust); blueprint wipe type + verify-plugins gated to uMod games; oxide
command examples + Rust-only plugin pages (AutoDoors/FurnaceSplitter/BetterChat)
guarded behind mods==='umod' with empty-states for other games.

Honesty: webstore checkout shows coming-soon (backend now 503s); 'integrated
webstore' marketed as coming-soon; Discord references neutralized to
community/webhook; migration FAQ marked in-development; analytics dev phase
labels removed; Network pricing tier set to Custom/Contact (was a confusing
duplicate of Operator); docs/PRICING.md rewritten to match live subscriptions.

UX/bugs: fixed ServerView oxide-status operator-precedence bug; dead 'Deploy
server' button wired; non-functional topbar search removed; alert()/confirm()
replaced with toasts across schedules/alerts/migration/public store+server;
analytics chart arrays null-guarded; production console.logs gated to DEV.

Frontend build (vue-tsc + vite) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:06:10 -04:00
Vantz Stockwell
f2ea415840 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>
2026-06-11 21:53:22 -04:00
54 changed files with 1403 additions and 531 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

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity'; import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto'; import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto'; import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
@@ -13,7 +13,7 @@ export class AutoDoorsService {
constructor( constructor(
@InjectRepository(AutoDoorsConfig) @InjectRepository(AutoDoorsConfig)
private readonly autoDoorsRepo: Repository<AutoDoorsConfig>, private readonly autoDoorsRepo: Repository<AutoDoorsConfig>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** List configs for a license (summaries — no JSONB) */ /** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class AutoDoorsService {
const jsonString = JSON.stringify(config.config_data, null, 2); const jsonString = JSON.stringify(config.config_data, null, 2);
try { try {
// Write AutoDoors.json via file manager NATS // Write AutoDoors.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/AutoDoors.json',
func: 'fm_save', jsonString,
path: 'server://oxide/config/AutoDoors.json',
content: jsonString,
},
30000,
); );
// Reload AutoDoors plugin via RCON // Reload AutoDoors plugin via RCON
await this.natsService.publish( await this.instancesService.rconForLicense(licenseId, 'oxide.reload AutoDoors');
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload AutoDoors',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others // Mark this config as active, deactivate others
await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false }); await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class AutoDoorsService {
/** Import AutoDoors.json from game server via NATS */ /** Import AutoDoors.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) { async importFromServer(licenseId: string, configName: string, description?: string) {
try { try {
// Read AutoDoors.json from server via file manager NATS // Read AutoDoors.json from server via Rust agent
const response = await this.natsService.request( const result = await this.instancesService.readFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/AutoDoors.json',
func: 'fm_preview',
path: 'server://oxide/config/AutoDoors.json',
},
30000,
); );
if (!response) { if (!result) {
throw new HttpException( throw new HttpException(
'No response from agent — it may be offline', 'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class AutoDoorsService {
} }
// Parse the response content as JSON // Parse the response content as JSON
const responseData = response as Record<string, any>; const responseData = (result as any).content;
let configData: Record<string, any>; let configData: Record<string, any>;
if (typeof responseData.content === 'string') { if (typeof responseData === 'string') {
configData = JSON.parse(responseData.content); configData = JSON.parse(responseData);
} else if (typeof responseData.content === 'object') { } else if (typeof responseData === 'object') {
configData = responseData.content; configData = responseData;
} else { } else {
throw new HttpException( throw new HttpException(
'Unexpected response format from agent', 'Unexpected response format from agent',

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { BetterChatConfig } from '../../entities/betterchat-config.entity'; import { BetterChatConfig } from '../../entities/betterchat-config.entity';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto'; import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto'; import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
@@ -13,7 +13,7 @@ export class BetterChatService {
constructor( constructor(
@InjectRepository(BetterChatConfig) @InjectRepository(BetterChatConfig)
private readonly repo: Repository<BetterChatConfig>, private readonly repo: Repository<BetterChatConfig>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** List configs for a license (summaries — no JSONB) */ /** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class BetterChatService {
const jsonString = JSON.stringify(config.config_data, null, 2); const jsonString = JSON.stringify(config.config_data, null, 2);
try { try {
// Write BetterChat.json via file manager NATS // Write BetterChat.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/BetterChat.json',
func: 'fm_save', jsonString,
path: 'server://oxide/config/BetterChat.json',
content: jsonString,
},
30000,
); );
// Reload BetterChat plugin via RCON // Reload BetterChat plugin via RCON
await this.natsService.publish( await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterChat');
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload BetterChat',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others // Mark this config as active, deactivate others
await this.repo.update({ license_id: licenseId }, { is_active: false }); await this.repo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class BetterChatService {
/** Import BetterChat.json from game server via NATS */ /** Import BetterChat.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) { async importFromServer(licenseId: string, configName: string, description?: string) {
try { try {
// Read BetterChat.json from server via file manager NATS // Read BetterChat.json from server via Rust agent
const response = await this.natsService.request( const result = await this.instancesService.readFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/BetterChat.json',
func: 'fm_preview',
path: 'server://oxide/config/BetterChat.json',
},
30000,
); );
if (!response) { if (!result) {
throw new HttpException( throw new HttpException(
'No response from agent — it may be offline', 'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class BetterChatService {
} }
// Parse the response content as JSON // Parse the response content as JSON
const responseData = response as Record<string, any>; const responseData = (result as any).content;
let configData: Record<string, any>; let configData: Record<string, any>;
if (typeof responseData.content === 'string') { if (typeof responseData === 'string') {
configData = JSON.parse(responseData.content); configData = JSON.parse(responseData);
} else if (typeof responseData.content === 'object') { } else if (typeof responseData === 'object') {
configData = responseData.content; configData = responseData;
} else { } else {
throw new HttpException( throw new HttpException(
'Unexpected response format from agent', 'Unexpected response format from agent',

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity'; import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto'; import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto'; import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
@@ -13,7 +13,7 @@ export class FurnaceSplitterService {
constructor( constructor(
@InjectRepository(FurnaceSplitterConfig) @InjectRepository(FurnaceSplitterConfig)
private readonly furnaceRepo: Repository<FurnaceSplitterConfig>, private readonly furnaceRepo: Repository<FurnaceSplitterConfig>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** List configs for a license (summaries — no JSONB) */ /** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class FurnaceSplitterService {
const jsonString = JSON.stringify(config.config_data, null, 2); const jsonString = JSON.stringify(config.config_data, null, 2);
try { try {
// Write FurnaceSplitter.json via file manager NATS // Write FurnaceSplitter.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/FurnaceSplitter.json',
func: 'fm_save', jsonString,
path: 'server://oxide/config/FurnaceSplitter.json',
content: jsonString,
},
30000,
); );
// Reload FurnaceSplitter plugin via RCON // Reload FurnaceSplitter plugin via RCON
await this.natsService.publish( await this.instancesService.rconForLicense(licenseId, 'oxide.reload FurnaceSplitter');
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload FurnaceSplitter',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others // Mark this config as active, deactivate others
await this.furnaceRepo.update({ license_id: licenseId }, { is_active: false }); await this.furnaceRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class FurnaceSplitterService {
/** Import FurnaceSplitter.json from game server via NATS */ /** Import FurnaceSplitter.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) { async importFromServer(licenseId: string, configName: string, description?: string) {
try { try {
// Read FurnaceSplitter.json from server via file manager NATS // Read FurnaceSplitter.json from server via Rust agent
const response = await this.natsService.request( const result = await this.instancesService.readFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/FurnaceSplitter.json',
func: 'fm_preview',
path: 'server://oxide/config/FurnaceSplitter.json',
},
30000,
); );
if (!response) { if (!result) {
throw new HttpException( throw new HttpException(
'No response from agent — it may be offline', 'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class FurnaceSplitterService {
} }
// Parse the response content as JSON // Parse the response content as JSON
const responseData = response as Record<string, any>; const responseData = (result as any).content;
let configData: Record<string, any>; let configData: Record<string, any>;
if (typeof responseData.content === 'string') { if (typeof responseData === 'string') {
configData = JSON.parse(responseData.content); configData = JSON.parse(responseData);
} else if (typeof responseData.content === 'object') { } else if (typeof responseData === 'object') {
configData = responseData.content; configData = responseData;
} else { } else {
throw new HttpException( throw new HttpException(
'Unexpected response format from agent', 'Unexpected response format from agent',

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { GatherConfig } from '../../entities/gather-config.entity'; import { GatherConfig } from '../../entities/gather-config.entity';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
import { CreateGatherConfigDto } from './dto/create-gather-config.dto'; import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto'; import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
@@ -13,7 +13,7 @@ export class GatherService {
constructor( constructor(
@InjectRepository(GatherConfig) @InjectRepository(GatherConfig)
private readonly gatherRepo: Repository<GatherConfig>, private readonly gatherRepo: Repository<GatherConfig>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** List configs for a license (summaries — no JSONB) */ /** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class GatherService {
const jsonString = JSON.stringify(config.config_data, null, 2); const jsonString = JSON.stringify(config.config_data, null, 2);
try { try {
// Write GatherManager.json via file manager NATS // Write GatherManager.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/GatherManager.json',
func: 'fm_save', jsonString,
path: 'server://oxide/config/GatherManager.json',
content: jsonString,
},
30000,
); );
// Reload GatherManager plugin via RCON // Reload GatherManager plugin via RCON
await this.natsService.publish( await this.instancesService.rconForLicense(licenseId, 'oxide.reload GatherManager');
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload GatherManager',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others // Mark this config as active, deactivate others
await this.gatherRepo.update({ license_id: licenseId }, { is_active: false }); await this.gatherRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class GatherService {
/** Import GatherManager.json from game server via NATS */ /** Import GatherManager.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) { async importFromServer(licenseId: string, configName: string, description?: string) {
try { try {
// Read GatherManager.json from server via file manager NATS // Read GatherManager.json from server via Rust agent
const response = await this.natsService.request( const result = await this.instancesService.readFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/GatherManager.json',
func: 'fm_preview',
path: 'server://oxide/config/GatherManager.json',
},
30000,
); );
if (!response) { if (!result) {
throw new HttpException( throw new HttpException(
'No response from agent — it may be offline', 'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class GatherService {
} }
// Parse the response content as JSON // Parse the response content as JSON
const responseData = response as Record<string, any>; const responseData = (result as any).content;
let configData: Record<string, any>; let configData: Record<string, any>;
if (typeof responseData.content === 'string') { if (typeof responseData === 'string') {
configData = JSON.parse(responseData.content); configData = JSON.parse(responseData);
} else if (typeof responseData.content === 'object') { } else if (typeof responseData === 'object') {
configData = responseData.content; configData = responseData;
} else { } else {
throw new HttpException( throw new HttpException(
'Unexpected response format from agent', 'Unexpected response format from agent',

View File

@@ -1,13 +1,18 @@
import { Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { InstancesController } from './instances.controller'; import { InstancesController } from './instances.controller';
import { InstancesService } from './instances.service'; import { InstancesService } from './instances.service';
import { GameInstance } from '../../entities/game-instance.entity'; import { GameInstance } from '../../entities/game-instance.entity';
import { NatsService } from '../../services/nats.service'; import { NatsService } from '../../services/nats.service';
// Global so the legacy single-server services (servers/players/schedules/wipes/
// plugins + the 9 plugin-config modules) can inject InstancesService to route
// commands at the now-only Rust agent without each importing this module.
@Global()
@Module({ @Module({
imports: [TypeOrmModule.forFeature([GameInstance])], imports: [TypeOrmModule.forFeature([GameInstance])],
controllers: [InstancesController], controllers: [InstancesController],
providers: [InstancesService, NatsService], providers: [InstancesService, NatsService],
exports: [InstancesService],
}) })
export class InstancesModule {} export class InstancesModule {}

View File

@@ -142,4 +142,82 @@ export class InstancesService {
if (!path || !dest) throw new BadRequestException('path and dest are required'); if (!path || !dest) throw new BadRequestException('path and dest are required');
return (await this.fileOp(licenseId, instanceId, { op: 'copy', path, dest })).data ?? { ok: true }; return (await this.fileOp(licenseId, instanceId, { op: 'copy', path, dest })).data ?? { ok: true };
} }
/**
* Wipe an instance's game data via the agent's jailed wipe handler: stop →
* delete files per wipe_type (map/blueprint/full) → restart. Long timeout
* because the agent does all three steps before replying.
*/
async wipe(
licenseId: string,
instanceId: string,
wipeType: 'map' | 'blueprint' | 'full',
backup = true,
): Promise<unknown> {
const inst = await this.resolveInstance(licenseId, instanceId);
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
this.logger.log(`instance ${inst.agent_instance_id}: wipe (${wipeType})`);
return this.nats.requestScoped(
licenseId,
subject,
{ func: 'wipe', wipe_type: wipeType, backup },
120_000,
);
}
// -------------------------------------------------------------------------
// License-scoped convenience wrappers. Legacy single-server services
// (servers/players/schedules/wipes/plugins + the 9 plugin-config modules)
// predate the instance model and carry only a licenseId. These resolve the
// license's primary instance, then dispatch to the agent — replacing the old
// publishes to the now-defunct `cmd.server` subject.
// -------------------------------------------------------------------------
/** The license's primary (oldest) instance. Throws if none is connected. */
async resolveDefaultInstance(licenseId: string): Promise<GameInstance> {
const inst = await this.instanceRepo.findOne({
where: { license_id: licenseId },
order: { created_at: 'ASC' },
});
if (!inst) {
throw new NotFoundException(
'No game instance is connected for this license yet — install and start the host agent first.',
);
}
return inst;
}
async lifecycleForLicense(licenseId: string, func: LifecycleFunc): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.lifecycle(licenseId, inst.id, func);
}
async rconForLicense(licenseId: string, command: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.rcon(licenseId, inst.id, command);
}
async writeFileForLicense(licenseId: string, path: string, content: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.writeFile(licenseId, inst.id, path, content);
}
async readFileForLicense(licenseId: string, path: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.readFile(licenseId, inst.id, path);
}
async deleteFileForLicense(licenseId: string, path: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.deleteFile(licenseId, inst.id, path);
}
async wipeForLicense(
licenseId: string,
wipeType: 'map' | 'blueprint' | 'full',
backup = true,
): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.wipe(licenseId, inst.id, wipeType, backup);
}
} }

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { KitsConfig } from '../../entities/kits-config.entity'; import { KitsConfig } from '../../entities/kits-config.entity';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
import { CreateKitsConfigDto } from './dto/create-kits-config.dto'; import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto'; import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
@@ -13,7 +13,7 @@ export class KitsService {
constructor( constructor(
@InjectRepository(KitsConfig) @InjectRepository(KitsConfig)
private readonly kitsRepo: Repository<KitsConfig>, private readonly kitsRepo: Repository<KitsConfig>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** List configs for a license (summaries — no JSONB) */ /** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class KitsService {
const jsonString = JSON.stringify(config.config_data, null, 2); const jsonString = JSON.stringify(config.config_data, null, 2);
try { try {
// Write Kits.json via file manager NATS // Write Kits.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/Kits.json',
func: 'fm_save', jsonString,
path: 'server://oxide/config/Kits.json',
content: jsonString,
},
30000,
); );
// Reload Kits plugin via RCON // Reload Kits plugin via RCON
await this.natsService.publish( await this.instancesService.rconForLicense(licenseId, 'oxide.reload Kits');
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload Kits',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others // Mark this config as active, deactivate others
await this.kitsRepo.update({ license_id: licenseId }, { is_active: false }); await this.kitsRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class KitsService {
/** Import Kits.json from game server via NATS */ /** Import Kits.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) { async importFromServer(licenseId: string, configName: string, description?: string) {
try { try {
// Read Kits.json from server via file manager NATS // Read Kits.json from server via Rust agent
const response = await this.natsService.request( const result = await this.instancesService.readFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/Kits.json',
func: 'fm_preview',
path: 'server://oxide/config/Kits.json',
},
30000,
); );
if (!response) { if (!result) {
throw new HttpException( throw new HttpException(
'No response from agent — it may be offline', 'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class KitsService {
} }
// Parse the response content as JSON // Parse the response content as JSON
const responseData = response as Record<string, any>; const responseData = (result as any).content;
let configData: Record<string, any>; let configData: Record<string, any>;
if (typeof responseData.content === 'string') { if (typeof responseData === 'string') {
configData = JSON.parse(responseData.content); configData = JSON.parse(responseData);
} else if (typeof responseData.content === 'object') { } else if (typeof responseData === 'object') {
configData = responseData.content; configData = responseData;
} else { } else {
throw new HttpException( throw new HttpException(
'Unexpected response format from agent', 'Unexpected response format from agent',

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { LootProfile } from '../../entities/loot-profile.entity'; import { LootProfile } from '../../entities/loot-profile.entity';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
import { CreateLootProfileDto } from './dto/create-loot-profile.dto'; import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto'; import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
import { ImportLootProfileDto } from './dto/import-loot-profile.dto'; import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
@@ -15,7 +15,7 @@ export class LootService {
constructor( constructor(
@InjectRepository(LootProfile) @InjectRepository(LootProfile)
private readonly lootRepo: Repository<LootProfile>, private readonly lootRepo: Repository<LootProfile>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** List profiles for a license (summaries — no JSONB) */ /** List profiles for a license (summaries — no JSONB) */
@@ -114,37 +114,22 @@ export class LootService {
const lootGroupsJson = JSON.stringify(scaledGroups, null, 2); const lootGroupsJson = JSON.stringify(scaledGroups, null, 2);
try { try {
// Write LootTables.json via file manager NATS // Write LootTables.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/data/BetterLoot/LootTables.json',
func: 'fm_save', lootTablesJson,
path: 'server://oxide/data/BetterLoot/LootTables.json',
content: lootTablesJson,
},
30000,
); );
// Write LootGroups.json via file manager NATS // Write LootGroups.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/data/BetterLoot/LootGroups.json',
func: 'fm_save', lootGroupsJson,
path: 'server://oxide/data/BetterLoot/LootGroups.json',
content: lootGroupsJson,
},
30000,
); );
// Reload BetterLoot plugin via RCON // Reload BetterLoot plugin via RCON
await this.natsService.publish( await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterLoot');
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload BetterLoot',
timestamp: new Date().toISOString(),
},
);
// Mark this profile as active, deactivate others // Mark this profile as active, deactivate others
await this.lootRepo.update({ license_id: licenseId }, { is_active: false }); await this.lootRepo.update({ license_id: licenseId }, { is_active: false });

View File

@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { PlayerAction } from '../../entities/player-action.entity'; import { PlayerAction } from '../../entities/player-action.entity';
import { PlayerSession } from '../../entities/player-session.entity'; import { PlayerSession } from '../../entities/player-session.entity';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
import { PlayerActionDto } from './dto/player-action.dto'; import { PlayerActionDto } from './dto/player-action.dto';
export interface Player { export interface Player {
@@ -23,7 +23,7 @@ export class PlayersService {
private readonly actionRepo: Repository<PlayerAction>, private readonly actionRepo: Repository<PlayerAction>,
@InjectRepository(PlayerSession) @InjectRepository(PlayerSession)
private readonly sessionRepo: Repository<PlayerSession>, private readonly sessionRepo: Repository<PlayerSession>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** /**
@@ -132,15 +132,26 @@ export class PlayersService {
await this.actionRepo.save(action); await this.actionRepo.save(action);
// Forward kick, ban, and unban to the game server via NATS // Forward kick, ban, and unban to the game server via RCON
if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') { if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
await this.natsService.sendServerCommand(licenseId, dto.action_type, { const rconCmd = this.buildRconCommand(dto);
steam_id: dto.steam_id, await this.instancesService.rconForLicense(licenseId, rconCmd);
reason: dto.reason,
duration_minutes: dto.duration_minutes,
});
} }
return { success: true }; return { success: true };
} }
private buildRconCommand(dto: PlayerActionDto): string {
switch (dto.action_type) {
case 'kick':
return `kick ${dto.steam_id}${dto.reason ? ' ' + dto.reason : ''}`;
case 'ban':
// banid <steamId> <reason> <durationSeconds> — 0 = permanent
return `banid ${dto.steam_id} ${dto.reason ?? 'banned'} ${dto.duration_minutes ? dto.duration_minutes * 60 : 0}`;
case 'unban':
return `unban ${dto.steam_id}`;
default:
return '';
}
}
} }

View File

@@ -1,10 +1,10 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException, BadRequestException, ServiceUnavailableException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { PluginRegistry } from '../../entities/plugin-registry.entity'; import { PluginRegistry } from '../../entities/plugin-registry.entity';
import { InstallPluginDto } from './dto/install-plugin.dto'; import { InstallPluginDto } from './dto/install-plugin.dto';
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto'; import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
interface UmodCacheEntry { interface UmodCacheEntry {
data: unknown; data: unknown;
@@ -20,7 +20,7 @@ export class PluginsService {
constructor( constructor(
@InjectRepository(PluginRegistry) @InjectRepository(PluginRegistry)
private readonly pluginRegistryRepo: Repository<PluginRegistry>, private readonly pluginRegistryRepo: Repository<PluginRegistry>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
async getPlugins(licenseId: string): Promise<PluginRegistry[]> { async getPlugins(licenseId: string): Promise<PluginRegistry[]> {
@@ -43,30 +43,11 @@ export class PluginsService {
throw new ConflictException(`Plugin ${dto.plugin_name} is already installed`); throw new ConflictException(`Plugin ${dto.plugin_name} is already installed`);
} }
const plugin = this.pluginRegistryRepo.create({ // One-click uMod install via agent is not yet implemented.
license_id: licenseId, // Fail fast — do not persist a DB record for a plugin that won't be deployed.
plugin_name: dto.plugin_name, throw new ServiceUnavailableException(
umod_slug: dto.umod_slug, 'One-click uMod install is coming soon — download the .cs and use Upload for now.',
source: dto.source || 'manual', );
is_installed: true,
is_loaded: false,
});
const saved = await this.pluginRegistryRepo.save(plugin);
try {
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
action: 'plugin_install',
plugin_name: dto.plugin_name,
umod_slug: dto.umod_slug,
timestamp: new Date().toISOString(),
});
this.logger.log(`Plugin install dispatched for ${dto.plugin_name} on license ${licenseId}`);
} catch (err) {
this.logger.error(`Failed to dispatch plugin install for ${dto.plugin_name} on license ${licenseId}: ${(err as Error).message}`);
}
return saved;
} }
async uninstallPlugin(licenseId: string, pluginId: string): Promise<void> { async uninstallPlugin(licenseId: string, pluginId: string): Promise<void> {
@@ -80,11 +61,8 @@ export class PluginsService {
await this.pluginRegistryRepo.delete({ id: pluginId, license_id: licenseId }); await this.pluginRegistryRepo.delete({ id: pluginId, license_id: licenseId });
await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, { await this.instancesService.rconForLicense(licenseId, `oxide.unload ${plugin.plugin_name}`);
action: 'unload', await this.instancesService.deleteFileForLicense(licenseId, `oxide/plugins/${plugin.plugin_name}.cs`);
plugin_name: plugin.plugin_name,
timestamp: new Date().toISOString(),
});
this.logger.log(`Plugin uninstall dispatched for ${plugin.plugin_name} on license ${licenseId}`); this.logger.log(`Plugin uninstall dispatched for ${plugin.plugin_name} on license ${licenseId}`);
} }
@@ -100,11 +78,7 @@ export class PluginsService {
throw new NotFoundException(`Plugin ${pluginId} not found`); throw new NotFoundException(`Plugin ${pluginId} not found`);
} }
await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, { await this.instancesService.rconForLicense(licenseId, `oxide.reload ${plugin.plugin_name}`);
action: 'reload',
plugin_name: plugin.plugin_name,
timestamp: new Date().toISOString(),
});
this.logger.log(`Plugin reload dispatched for ${plugin.plugin_name} on license ${licenseId}`); this.logger.log(`Plugin reload dispatched for ${plugin.plugin_name} on license ${licenseId}`);
return { reloaded: true, plugin_name: plugin.plugin_name }; return { reloaded: true, plugin_name: plugin.plugin_name };
@@ -215,19 +189,14 @@ export class PluginsService {
const saved = await this.pluginRegistryRepo.save(plugin); const saved = await this.pluginRegistryRepo.save(plugin);
// Dispatch to companion agent via NATS // Deploy .cs file to server via host agent
try { try {
const content = file.buffer.toString('base64'); const content = file.buffer.toString('utf8');
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, { await this.instancesService.writeFileForLicense(licenseId, `oxide/plugins/${originalName}`, content);
action: 'plugin_upload', this.logger.log(`Plugin upload deployed: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
filename: originalName,
content,
timestamp: new Date().toISOString(),
});
this.logger.log(`Plugin upload dispatched: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
} catch (err) { } catch (err) {
this.logger.error(`NATS publish failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`); this.logger.error(`File write failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`);
// Don't fail the request — plugin record is saved, NATS delivery is best-effort // Don't fail the request — plugin record is saved, file delivery is best-effort
} }
return saved; return saved;

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity'; import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto'; import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto'; import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
@@ -13,7 +13,7 @@ export class RaidableBasesService {
constructor( constructor(
@InjectRepository(RaidableBasesConfig) @InjectRepository(RaidableBasesConfig)
private readonly raidableBasesRepo: Repository<RaidableBasesConfig>, private readonly raidableBasesRepo: Repository<RaidableBasesConfig>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** List configs for a license (summaries — no JSONB) */ /** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class RaidableBasesService {
const jsonString = JSON.stringify(config.config_data, null, 2); const jsonString = JSON.stringify(config.config_data, null, 2);
try { try {
// Write RaidableBases.json via file manager NATS // Write RaidableBases.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/RaidableBases.json',
func: 'fm_save', jsonString,
path: 'server://oxide/config/RaidableBases.json',
content: jsonString,
},
30000,
); );
// Reload RaidableBases plugin via RCON // Reload RaidableBases plugin via RCON
await this.natsService.publish( await this.instancesService.rconForLicense(licenseId, 'oxide.reload RaidableBases');
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload RaidableBases',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others // Mark this config as active, deactivate others
await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false }); await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class RaidableBasesService {
/** Import RaidableBases.json from game server via NATS */ /** Import RaidableBases.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) { async importFromServer(licenseId: string, configName: string, description?: string) {
try { try {
// Read RaidableBases.json from server via file manager NATS // Read RaidableBases.json from server via Rust agent
const response = await this.natsService.request( const result = await this.instancesService.readFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/RaidableBases.json',
func: 'fm_preview',
path: 'server://oxide/config/RaidableBases.json',
},
30000,
); );
if (!response) { if (!result) {
throw new HttpException( throw new HttpException(
'No response from agent — it may be offline', 'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class RaidableBasesService {
} }
// Parse the response content as JSON // Parse the response content as JSON
const responseData = response as Record<string, any>; const responseData = (result as any).content;
let configData: Record<string, any>; let configData: Record<string, any>;
if (typeof responseData.content === 'string') { if (typeof responseData === 'string') {
configData = JSON.parse(responseData.content); configData = JSON.parse(responseData);
} else if (typeof responseData.content === 'object') { } else if (typeof responseData === 'object') {
configData = responseData.content; configData = responseData;
} else { } else {
throw new HttpException( throw new HttpException(
'Unexpected response format from agent', 'Unexpected response format from agent',

View File

@@ -10,7 +10,7 @@ import { LessThanOrEqual, Repository } from 'typeorm';
import { ScheduledTask } from '../../entities/scheduled-task.entity'; import { ScheduledTask } from '../../entities/scheduled-task.entity';
import { CreateTaskDto } from './dto/create-task.dto'; import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
/** Parse a 5-field cron expression and return the next Date after `after`. */ /** Parse a 5-field cron expression and return the next Date after `after`. */
function nextCronDate(expr: string, after: Date): Date | null { function nextCronDate(expr: string, after: Date): Date | null {
@@ -61,7 +61,7 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
constructor( constructor(
@InjectRepository(ScheduledTask) @InjectRepository(ScheduledTask)
private taskRepository: Repository<ScheduledTask>, private taskRepository: Repository<ScheduledTask>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -160,21 +160,12 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
switch (task_type) { switch (task_type) {
case 'restart': case 'restart':
await this.natsService.sendServerCommand(license_id, 'restart', { await this.instancesService.lifecycleForLicense(license_id, 'restart');
source: 'scheduler',
task_id: task.id,
});
break; break;
case 'announcement': { case 'announcement': {
const message = (task_config?.message as string) ?? 'Scheduled announcement'; const message = (task_config?.message as string) ?? 'Scheduled announcement';
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, { await this.instancesService.rconForLicense(license_id, `say ${message}`);
action: 'command',
command: `say ${message}`,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break; break;
} }
@@ -184,25 +175,13 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
this.logger.warn(`Task ${task.id} has no command configured — skipping`); this.logger.warn(`Task ${task.id} has no command configured — skipping`);
return; return;
} }
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, { await this.instancesService.rconForLicense(license_id, command);
action: 'command',
command,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break; break;
} }
case 'plugin_reload': { case 'plugin_reload': {
const plugin_name = (task_config?.plugin_name as string) ?? ''; const plugin_name = (task_config?.plugin_name as string) ?? '';
await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, { await this.instancesService.rconForLicense(license_id, `oxide.reload ${plugin_name}`);
action: 'reload',
plugin_name,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break; break;
} }

View File

@@ -1,9 +1,10 @@
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common'; import { Injectable, NotFoundException, InternalServerErrorException, ServiceUnavailableException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ServerConnection } from '../../entities/server-connection.entity'; import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerConfig } from '../../entities/server-config.entity'; import { ServerConfig } from '../../entities/server-config.entity';
import { NatsService } from '../../services/nats.service'; import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { UpdateServerConfigDto } from './dto/update-config.dto'; import { UpdateServerConfigDto } from './dto/update-config.dto';
import { DeployServerDto } from './dto/deploy-server.dto'; import { DeployServerDto } from './dto/deploy-server.dto';
@@ -17,6 +18,7 @@ export class ServersService {
@InjectRepository(ServerConfig) @InjectRepository(ServerConfig)
private readonly configRepo: Repository<ServerConfig>, private readonly configRepo: Repository<ServerConfig>,
private readonly natsService: NatsService, private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {} ) {}
/** /**
@@ -68,11 +70,11 @@ export class ServersService {
} }
/** /**
* Send a console command to the server via NATS * Send a console command to the server via the host agent (RCON)
*/ */
async sendCommand(licenseId: string, command: string) { async sendCommand(licenseId: string, command: string) {
try { try {
await this.natsService.sendServerCommand(licenseId, 'command', { command }); await this.instancesService.rconForLicense(licenseId, command);
this.logger.log(`Console command dispatched for license ${licenseId}: ${command}`); this.logger.log(`Console command dispatched for license ${licenseId}: ${command}`);
} catch (err) { } catch (err) {
this.logger.error(`Failed to dispatch console command for license ${licenseId}: ${(err as Error).message}`); this.logger.error(`Failed to dispatch console command for license ${licenseId}: ${(err as Error).message}`);
@@ -82,42 +84,45 @@ export class ServersService {
} }
/** /**
* Start the server via NATS * Start the server via the host agent
*/ */
async startServer(licenseId: string) { async startServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'start'); await this.instancesService.lifecycleForLicense(licenseId, 'start');
return { message: 'Start command sent' }; return { message: 'Start command sent' };
} }
/** /**
* Stop the server via NATS * Stop the server via the host agent
*/ */
async stopServer(licenseId: string) { async stopServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'stop'); await this.instancesService.lifecycleForLicense(licenseId, 'stop');
return { message: 'Stop command sent' }; return { message: 'Stop command sent' };
} }
/** /**
* Restart the server via NATS * Restart the server via the host agent
*/ */
async restartServer(licenseId: string) { async restartServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'restart'); await this.instancesService.lifecycleForLicense(licenseId, 'restart');
return { message: 'Restart command sent' }; return { message: 'Restart command sent' };
} }
/** /**
* Deploy Rust server via companion agent * Deploy Rust server — not yet supported via host agent.
* Install the server manually and point the host agent at it.
*/ */
async deployServer(licenseId: string, dto: DeployServerDto) { async deployServer(_licenseId: string, _dto: DeployServerDto) {
await this.natsService.sendDeployCommand(licenseId, { ...dto }); throw new ServiceUnavailableException(
return { message: 'Deployment started' }; 'Server deployment from the panel is coming soon — install the server and point the host agent at it for now.',
);
} }
/** /**
* Install Oxide/uMod via companion agent * Install Oxide/uMod — not yet supported via host agent.
*/ */
async installOxide(licenseId: string) { async installOxide(_licenseId: string) {
await this.natsService.sendOxideInstallCommand(licenseId); throw new ServiceUnavailableException(
return { message: 'Oxide installation started' }; 'Oxide install from the panel is coming soon — install Oxide/uMod on the server for now.',
);
} }
} }

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

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { TeleportConfig } from '../../entities/teleport-config.entity'; import { TeleportConfig } from '../../entities/teleport-config.entity';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto'; import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto'; import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
@@ -13,7 +13,7 @@ export class TeleportService {
constructor( constructor(
@InjectRepository(TeleportConfig) @InjectRepository(TeleportConfig)
private readonly teleportRepo: Repository<TeleportConfig>, private readonly teleportRepo: Repository<TeleportConfig>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** List configs for a license (summaries — no JSONB) */ /** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class TeleportService {
const jsonString = JSON.stringify(config.config_data, null, 2); const jsonString = JSON.stringify(config.config_data, null, 2);
try { try {
// Write NTeleportation.json via file manager NATS // Write NTeleportation.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/NTeleportation.json',
func: 'fm_save', jsonString,
path: 'server://oxide/config/NTeleportation.json',
content: jsonString,
},
30000,
); );
// Reload NTeleportation plugin via RCON // Reload NTeleportation plugin via RCON
await this.natsService.publish( await this.instancesService.rconForLicense(licenseId, 'oxide.reload NTeleportation');
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload NTeleportation',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others // Mark this config as active, deactivate others
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false }); await this.teleportRepo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class TeleportService {
/** Import NTeleportation.json from game server via NATS */ /** Import NTeleportation.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) { async importFromServer(licenseId: string, configName: string, description?: string) {
try { try {
// Read NTeleportation.json from server via file manager NATS // Read NTeleportation.json from server via Rust agent
const response = await this.natsService.request( const result = await this.instancesService.readFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/NTeleportation.json',
func: 'fm_preview',
path: 'server://oxide/config/NTeleportation.json',
},
30000,
); );
if (!response) { if (!result) {
throw new HttpException( throw new HttpException(
'No response from agent — it may be offline', 'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class TeleportService {
} }
// Parse the response content as JSON // Parse the response content as JSON
const responseData = response as Record<string, any>; const responseData = (result as any).content;
let configData: Record<string, any>; let configData: Record<string, any>;
if (typeof responseData.content === 'string') { if (typeof responseData === 'string') {
configData = JSON.parse(responseData.content); configData = JSON.parse(responseData);
} else if (typeof responseData.content === 'object') { } else if (typeof responseData === 'object') {
configData = responseData.content; configData = responseData;
} else { } else {
throw new HttpException( throw new HttpException(
'Unexpected response format from agent', 'Unexpected response format from agent',

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity'; import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto'; import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto'; import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
@@ -13,7 +13,7 @@ export class TimedExecuteService {
constructor( constructor(
@InjectRepository(TimedExecuteConfig) @InjectRepository(TimedExecuteConfig)
private readonly repo: Repository<TimedExecuteConfig>, private readonly repo: Repository<TimedExecuteConfig>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
/** List configs for a license (summaries — no JSONB) */ /** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class TimedExecuteService {
const jsonString = JSON.stringify(config.config_data, null, 2); const jsonString = JSON.stringify(config.config_data, null, 2);
try { try {
// Write TimedExecute.json via file manager NATS // Write TimedExecute.json via Rust agent
await this.natsService.request( await this.instancesService.writeFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/TimedExecute.json',
func: 'fm_save', jsonString,
path: 'server://oxide/config/TimedExecute.json',
content: jsonString,
},
30000,
); );
// Reload TimedExecute plugin via RCON // Reload TimedExecute plugin via RCON
await this.natsService.publish( await this.instancesService.rconForLicense(licenseId, 'oxide.reload TimedExecute');
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload TimedExecute',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others // Mark this config as active, deactivate others
await this.repo.update({ license_id: licenseId }, { is_active: false }); await this.repo.update({ license_id: licenseId }, { is_active: false });
@@ -126,17 +115,13 @@ export class TimedExecuteService {
/** Import TimedExecute.json from game server via NATS */ /** Import TimedExecute.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) { async importFromServer(licenseId: string, configName: string, description?: string) {
try { try {
// Read TimedExecute.json from server via file manager NATS // Read TimedExecute.json from server via Rust agent
const response = await this.natsService.request( const result = await this.instancesService.readFileForLicense(
`corrosion.${licenseId}.files.cmd`, licenseId,
{ 'oxide/config/TimedExecute.json',
func: 'fm_preview',
path: 'server://oxide/config/TimedExecute.json',
},
30000,
); );
if (!response) { if (!result) {
throw new HttpException( throw new HttpException(
'No response from agent — it may be offline', 'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
@@ -144,13 +129,13 @@ export class TimedExecuteService {
} }
// Parse the response content as JSON // Parse the response content as JSON
const responseData = response as Record<string, any>; const responseData = (result as any).content;
let configData: Record<string, any>; let configData: Record<string, any>;
if (typeof responseData.content === 'string') { if (typeof responseData === 'string') {
configData = JSON.parse(responseData.content); configData = JSON.parse(responseData);
} else if (typeof responseData.content === 'object') { } else if (typeof responseData === 'object') {
configData = responseData.content; configData = responseData;
} else { } else {
throw new HttpException( throw new HttpException(
'Unexpected response format from agent', 'Unexpected response format from agent',

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}`,
};
} }
} }

View File

@@ -8,7 +8,7 @@ import { CreateProfileDto } from './dto/create-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto';
import { CreateScheduleDto } from './dto/create-schedule.dto'; import { CreateScheduleDto } from './dto/create-schedule.dto';
import { TriggerWipeDto } from './dto/trigger-wipe.dto'; import { TriggerWipeDto } from './dto/trigger-wipe.dto';
import { NatsService } from '../../services/nats.service'; import { InstancesService } from '../instances/instances.service';
@Injectable() @Injectable()
export class WipesService { export class WipesService {
@@ -21,7 +21,7 @@ export class WipesService {
private readonly wipeScheduleRepo: Repository<WipeSchedule>, private readonly wipeScheduleRepo: Repository<WipeSchedule>,
@InjectRepository(WipeHistory) @InjectRepository(WipeHistory)
private readonly wipeHistoryRepo: Repository<WipeHistory>, private readonly wipeHistoryRepo: Repository<WipeHistory>,
private readonly natsService: NatsService, private readonly instancesService: InstancesService,
) {} ) {}
async getProfiles(licenseId: string): Promise<WipeProfile[]> { async getProfiles(licenseId: string): Promise<WipeProfile[]> {
@@ -107,13 +107,7 @@ export class WipesService {
const saved = await this.wipeHistoryRepo.save(history); const saved = await this.wipeHistoryRepo.save(history);
await this.natsService.publish(`corrosion.${licenseId}.cmd.wipe`, { await this.instancesService.wipeForLicense(licenseId, dto.wipe_type, true);
wipe_history_id: saved.id,
wipe_type: dto.wipe_type,
wipe_profile_id: dto.wipe_profile_id ?? null,
trigger_type: 'manual',
timestamp: new Date().toISOString(),
});
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`); this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
return { wipe_history_id: saved.id }; return { wipe_history_id: saved.id };

View File

@@ -287,7 +287,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "corrosion-host-agent" name = "corrosion-host-agent"
version = "2.0.0-alpha.9" version = "2.0.0-alpha.10"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-nats", "async-nats",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "corrosion-host-agent" name = "corrosion-host-agent"
version = "2.0.0-alpha.9" version = "2.0.0-alpha.10"
edition = "2021" edition = "2021"
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers" description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
license = "UNLICENSED" license = "UNLICENSED"

View File

@@ -16,6 +16,7 @@ use crate::agent::Agent;
use crate::subjects; use crate::subjects;
use crate::steamcmd; use crate::steamcmd;
use crate::supervisor::Supervisor; use crate::supervisor::Supervisor;
use crate::wipe;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct InstanceCommand { struct InstanceCommand {
@@ -23,6 +24,19 @@ struct InstanceCommand {
/// Payload for funcs that carry a text argument (e.g. rcon). /// Payload for funcs that carry a text argument (e.g. rcon).
#[serde(default)] #[serde(default)]
command: Option<String>, command: Option<String>,
/// Wipe type: "map" | "blueprint" | "full" — required for func="wipe".
#[serde(default)]
wipe_type: Option<wipe::WipeType>,
/// Whether to back up wipe targets before deleting (func="wipe").
#[serde(default)]
backup: bool,
/// Label for the backup subdirectory (func="wipe"). Defaults to "wipe-backup".
#[serde(default = "default_backup_label")]
backup_label: String,
}
fn default_backup_label() -> String {
"wipe-backup".to_string()
} }
/// Forward every supervisor state change as a status event. /// Forward every supervisor state change as a status event.
@@ -252,10 +266,79 @@ async fn dispatch(
}), }),
}; };
} }
"wipe" => {
let inst_cfg = agent.cfg.instances.iter().find(|i| i.id == sup.instance_id());
let Some(inst_cfg) = inst_cfg else {
return json!({
"status": "error",
"func": "wipe",
"instance_id": sup.instance_id(),
"message": format!("no config found for instance '{}'", sup.instance_id()),
});
};
let Some(wipe_type) = cmd.wipe_type.clone() else {
return json!({
"status": "error",
"func": "wipe",
"instance_id": sup.instance_id(),
"message": "wipe func requires a 'wipe_type' field (\"map\", \"blueprint\", or \"full\")",
});
};
let root = inst_cfg.root.clone();
let instance_id = sup.instance_id().to_string();
let wipe_req = wipe::WipeRequest {
wipe_type,
backup: cmd.backup,
backup_label: cmd.backup_label.clone(),
};
// Stop the server best-effort before wiping; proceed even if stop fails
// (the server may already be down).
if let Err(e) = sup.clone().stop().await {
tracing::warn!("wipe: stop instance '{}' failed (proceeding anyway): {e:#}", instance_id);
}
// Run the blocking I/O on the blocking thread pool.
let result = tokio::task::spawn_blocking(move || wipe::execute(&root, &wipe_req)).await;
// Restart best-effort regardless of wipe outcome.
if let Err(e) = sup.clone().start().await {
tracing::warn!("wipe: restart instance '{}' failed: {e:#}", instance_id);
}
return match result {
Ok(Ok(wr)) => {
let wipe_type_str = format!("{:?}", wr.wipe_type).to_lowercase();
json!({
"status": "success",
"func": "wipe",
"instance_id": sup.instance_id(),
"wipe_type": wipe_type_str,
"deleted_count": wr.deleted_count,
})
}
Ok(Err(e)) => json!({
"status": "error",
"func": "wipe",
"instance_id": sup.instance_id(),
"message": format!("{e:#}"),
}),
Err(e) => json!({
"status": "error",
"func": "wipe",
"instance_id": sup.instance_id(),
"message": format!("internal error: {e}"),
}),
};
}
other => { other => {
return json!({ return json!({
"status": "error", "status": "error",
"message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update)"), "message": format!("unknown func '{other}' (supported: start, stop, restart, status, rcon, steam_update, wipe)"),
}); });
} }
}; };

View File

@@ -17,3 +17,4 @@ pub mod supervisor;
pub mod telemetry; pub mod telemetry;
pub mod update; pub mod update;
pub mod version; pub mod version;
pub mod wipe;

View File

@@ -0,0 +1,412 @@
//! Jailed wipe engine for Rust (and compatible) game server instances.
//!
//! Three wipe types are supported, each a strict superset of the previous:
//!
//! | Type | What is deleted |
//! |-------------|------------------------------------------------------------------|
//! | `map` | `*.map`, `*.sav` under `<root>/server/<identity>/` |
//! | `blueprint` | map wipe + `*.blueprints.*.db` / `.blueprints.*` under save dir |
//! | `full` | blueprint wipe + `oxide/data/` contents + player state DB files |
//!
//! Identity discovery: rather than require the identity in the payload, we walk
//! `<root>/server/*/` looking for files that match each wipe type's patterns.
//! This handles any identity name without configuration churn.
//!
//! **Safety**: every path operated on is validated inside the canonicalized
//! instance root with the same two-stage (lexical + canonicalize) jail used by
//! `filemanager.rs`. We use `symlink_metadata` (lstat) everywhere we walk
//! directories — symlinks are never followed across the boundary (Lesson 26).
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use crate::filemanager::jail;
// ---------------------------------------------------------------------------
// Public API types
// ---------------------------------------------------------------------------
/// The scope of data to erase.
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WipeType {
/// Delete procedural map + save files only.
Map,
/// Map wipe + player blueprint databases.
Blueprint,
/// Blueprint wipe + oxide/data + all player state DBs.
Full,
}
/// Parameters parsed from the NATS command payload.
#[derive(Debug, serde::Deserialize)]
pub struct WipeRequest {
/// Scope of the wipe.
pub wipe_type: WipeType,
/// Copy files to `.corrosion-backups/<backup_label>/` before deleting.
#[serde(default)]
pub backup: bool,
/// Label used as the backup subdirectory name. Defaults to `"wipe-backup"`.
#[serde(default = "default_backup_label")]
pub backup_label: String,
}
fn default_backup_label() -> String {
"wipe-backup".to_string()
}
/// Result of a successful wipe operation.
#[derive(Debug)]
pub struct WipeResult {
pub deleted_count: usize,
pub wipe_type: WipeType,
}
// ---------------------------------------------------------------------------
// Core wipe logic (sync — suitable for `spawn_blocking`)
// ---------------------------------------------------------------------------
/// Execute a wipe of `wipe_type` inside `root`, optionally backing up first.
///
/// Does NOT touch the supervisor lifecycle — the caller (instancecmd dispatch)
/// must stop the server before calling this and restart it afterwards.
///
/// Returns a `WipeResult` describing what was deleted. Missing directories are
/// treated as zero-deleted, not as errors, so a fresh server never returns Err
/// just because `server/*/` doesn't exist yet.
pub fn execute(root: &Path, req: &WipeRequest) -> Result<WipeResult> {
// Canonicalize root once; every subsequent path check goes through `jail()`.
let canon_root = fs::canonicalize(root)
.with_context(|| format!("canonicalize instance root '{}'", root.display()))?;
// Collect every path to delete based on wipe type.
let targets = collect_targets(&canon_root, &req.wipe_type)?;
// Backup before any deletion when requested.
if req.backup && !targets.is_empty() {
let backup_dir = jail(root, &format!(".corrosion-backups/{}", req.backup_label))?;
fs::create_dir_all(&backup_dir)
.with_context(|| format!("create backup dir '{}'", backup_dir.display()))?;
for path in &targets {
backup_one(&canon_root, path, &backup_dir)?;
}
}
// Delete.
let mut deleted_count = 0usize;
for path in &targets {
// Final safety check: confirm inside root before deletion.
if path != &canon_root && !path.starts_with(&canon_root) {
anyhow::bail!(
"wipe safety: path '{}' is outside instance root '{}' — aborting",
path.display(),
canon_root.display()
);
}
match delete_path(path) {
Ok(n) => deleted_count += n,
Err(e) => tracing::warn!("wipe: skipping '{}': {e:#}", path.display()),
}
}
tracing::info!(
"wipe complete: type={:?} deleted={} root={}",
req.wipe_type,
deleted_count,
root.display()
);
Ok(WipeResult {
deleted_count,
wipe_type: req.wipe_type.clone(),
})
}
// ---------------------------------------------------------------------------
// Target collection
// ---------------------------------------------------------------------------
/// Walk the Rust server tree under `canon_root` and return every path (file or
/// dir) that should be deleted for the given wipe type.
///
/// Layout assumed:
/// ```text
/// <root>/
/// server/
/// <identity>/ -- any name; we walk all subdirs
/// *.map
/// *.sav
/// player.blueprints.*.db (and *.blueprints.* variants)
/// player.deaths.*.db
/// player.identities.*.db
/// player.states.*.db
/// *.db (full wipe)
/// oxide/
/// data/ -- cleared for full wipe (dir contents, not dir itself)
/// ```
fn collect_targets(canon_root: &Path, wipe_type: &WipeType) -> Result<Vec<PathBuf>> {
let mut targets: Vec<PathBuf> = Vec::new();
// --- server/<identity>/ ---
let server_dir = canon_root.join("server");
if is_real_dir(&server_dir) {
for identity_entry in read_dir_safe(&server_dir)? {
let identity_meta = fs::symlink_metadata(&identity_entry)
.with_context(|| format!("stat '{}'", identity_entry.display()))?;
// Never follow symlinks across the boundary.
if identity_meta.file_type().is_symlink() {
tracing::debug!("wipe: skipping symlink '{}'", identity_entry.display());
continue;
}
if !identity_meta.is_dir() {
continue;
}
collect_save_targets(canon_root, &identity_entry, wipe_type, &mut targets)?;
}
}
// --- oxide/data/ (full wipe only) ---
if *wipe_type == WipeType::Full {
let oxide_data = canon_root.join("oxide").join("data");
if is_real_dir(&oxide_data) {
// Delete directory *contents*, not the directory itself.
for entry in read_dir_safe(&oxide_data)? {
let meta = fs::symlink_metadata(&entry)
.with_context(|| format!("stat '{}'", entry.display()))?;
if meta.file_type().is_symlink() {
tracing::debug!("wipe: skipping symlink '{}'", entry.display());
continue;
}
// Jail-check every entry before adding.
ensure_inside(canon_root, &entry)?;
targets.push(entry);
}
}
}
Ok(targets)
}
/// Collect files from one `<root>/server/<identity>/` directory.
fn collect_save_targets(
canon_root: &Path,
identity_dir: &Path,
wipe_type: &WipeType,
out: &mut Vec<PathBuf>,
) -> Result<()> {
for entry in read_dir_safe(identity_dir)? {
let meta = fs::symlink_metadata(&entry)
.with_context(|| format!("stat '{}'", entry.display()))?;
// Never follow symlinks.
if meta.file_type().is_symlink() {
tracing::debug!("wipe: skipping symlink '{}'", entry.display());
continue;
}
ensure_inside(canon_root, &entry)?;
let file_name = entry
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let keep = match wipe_type {
WipeType::Map => !is_map_file(&file_name) && !is_sav_file(&file_name),
WipeType::Blueprint => {
!is_map_file(&file_name)
&& !is_sav_file(&file_name)
&& !is_blueprint_file(&file_name)
}
WipeType::Full => {
!is_map_file(&file_name)
&& !is_sav_file(&file_name)
&& !is_blueprint_file(&file_name)
&& !is_player_state_file(&file_name)
&& !is_generic_db_file(&file_name)
}
};
if !keep {
out.push(entry);
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Pattern matchers
// ---------------------------------------------------------------------------
fn is_map_file(name: &str) -> bool {
name.ends_with(".map")
}
fn is_sav_file(name: &str) -> bool {
name.ends_with(".sav")
}
fn is_blueprint_file(name: &str) -> bool {
// Matches both `player.blueprints.*.db` and `.blueprints.*` variants.
name.contains(".blueprints.")
}
fn is_player_state_file(name: &str) -> bool {
name.contains("player.deaths.")
|| name.contains("player.identities.")
|| name.contains("player.states.")
}
fn is_generic_db_file(name: &str) -> bool {
name.ends_with(".db")
}
// ---------------------------------------------------------------------------
// Deletion
// ---------------------------------------------------------------------------
/// Delete a single path (file or directory tree). Returns count of top-level
/// items removed (1 for a file, 1 for a directory tree). Missing paths return
/// 0 — the server may be fresh.
fn delete_path(path: &Path) -> Result<usize> {
let meta = match fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(e).with_context(|| format!("stat '{}'", path.display())),
};
if meta.file_type().is_symlink() {
// Delete the symlink itself — never follow it.
fs::remove_file(path).with_context(|| format!("remove symlink '{}'", path.display()))?;
return Ok(1);
}
if meta.is_dir() {
fs::remove_dir_all(path)
.with_context(|| format!("remove_dir_all '{}'", path.display()))?;
} else {
fs::remove_file(path)
.with_context(|| format!("remove_file '{}'", path.display()))?;
}
Ok(1)
}
// ---------------------------------------------------------------------------
// Backup
// ---------------------------------------------------------------------------
/// Copy one path (file or directory) into `backup_dir`, preserving the last
/// component of the path name. Symlinks are skipped — we never follow them.
fn backup_one(canon_root: &Path, src: &Path, backup_dir: &Path) -> Result<()> {
let meta = match fs::symlink_metadata(src) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e).with_context(|| format!("stat backup src '{}'", src.display())),
};
if meta.file_type().is_symlink() {
tracing::debug!("wipe backup: skipping symlink '{}'", src.display());
return Ok(());
}
let name = match src.file_name() {
Some(n) => n,
None => return Ok(()),
};
// Preserve relative path from root inside the backup directory to avoid
// name collisions when multiple identity dirs have a `proc.map`.
let rel = src
.strip_prefix(canon_root)
.unwrap_or_else(|_| src)
.parent()
.unwrap_or_else(|| Path::new(""));
let dest = backup_dir.join(rel).join(name);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("backup: create_dir_all '{}'", parent.display()))?;
}
copy_recursive_safe(src, &dest)?;
Ok(())
}
/// Recursive copy that uses `symlink_metadata` (lstat) and refuses to follow
/// any symlink — mirrors the same guard in `filemanager::copy_recursive`.
fn copy_recursive_safe(src: &Path, dest: &Path) -> Result<()> {
let meta = fs::symlink_metadata(src)
.with_context(|| format!("stat source '{}'", src.display()))?;
if meta.file_type().is_symlink() {
anyhow::bail!(
"refusing to copy symlink '{}' during backup — symlinks are not followed",
src.display()
);
}
if meta.is_dir() {
fs::create_dir_all(dest)
.with_context(|| format!("create_dir_all '{}'", dest.display()))?;
for entry in fs::read_dir(src)
.with_context(|| format!("read_dir '{}'", src.display()))?
{
let entry = entry?;
copy_recursive_safe(&entry.path(), &dest.join(entry.file_name()))?;
}
} else {
fs::copy(src, dest)
.with_context(|| format!("copy '{}' -> '{}'", src.display(), dest.display()))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Returns `true` if `path` exists, is a directory, and is not a symlink.
fn is_real_dir(path: &Path) -> bool {
match fs::symlink_metadata(path) {
Ok(m) => m.is_dir() && !m.file_type().is_symlink(),
Err(_) => false,
}
}
/// Read a directory and return the absolute paths of its entries.
/// Uses lstat internally via `read_dir` (entry paths; metadata is lstat'd
/// separately by callers).
fn read_dir_safe(dir: &Path) -> Result<Vec<PathBuf>> {
let mut entries = Vec::new();
let rd = match fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
Err(e) => return Err(e).with_context(|| format!("read_dir '{}'", dir.display())),
};
for item in rd {
let item = item.with_context(|| format!("read dir entry in '{}'", dir.display()))?;
entries.push(item.path());
}
Ok(entries)
}
/// Assert that `path` is strictly inside (or equal to) `canon_root`.
/// This is the final safety fence before any destructive or backup operation.
fn ensure_inside(canon_root: &Path, path: &Path) -> Result<()> {
// Canonicalize the path if it exists; otherwise use it as-is (it's
// derived from read_dir, which already returns absolute paths rooted
// under canon_root in normal operation).
let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if resolved != canon_root && !resolved.starts_with(canon_root) {
anyhow::bail!(
"wipe safety: path '{}' is outside instance root '{}' — aborting",
path.display(),
canon_root.display()
);
}
Ok(())
}

View File

@@ -0,0 +1,298 @@
//! Integration tests for the wipe engine.
//!
//! Builds a temp directory tree that mirrors a Rust dedicated server layout
//! and verifies each wipe type's targeting, the symlink-safety guarantee,
//! backup behaviour, and graceful handling of missing directories.
//!
//! Symlink tests are POSIX-only (Unix creates symlinks; Windows needs elevated
//! privileges or Developer Mode, so we skip there).
#![cfg(unix)]
use corrosion_host_agent::wipe::{execute, WipeRequest, WipeType};
use std::path::Path;
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helper: build a fake Rust server tree
//
// Layout:
// <root>/
// server/
// myserver/
// proc.map
// proc.sav
// player.blueprints.1234.db
// player.deaths.1234.db
// player.identities.1234.db
// player.states.1234.db
// players.db
// keepme.txt ← must survive every wipe
// oxide/
// data/
// killfeed.json
// another.json
// server_readme.txt ← must survive every wipe
// ---------------------------------------------------------------------------
fn make_server_tree() -> TempDir {
let dir = tempfile::tempdir().expect("create tempdir");
let root = dir.path();
let save_dir = root.join("server").join("myserver");
std::fs::create_dir_all(&save_dir).expect("create save dir");
std::fs::create_dir_all(root.join("oxide").join("data")).expect("create oxide/data");
// Save files
write_file(&save_dir.join("proc.map"), b"map data");
write_file(&save_dir.join("proc.sav"), b"sav data");
write_file(&save_dir.join("player.blueprints.1234.db"), b"bp data");
write_file(&save_dir.join("player.deaths.1234.db"), b"deaths");
write_file(&save_dir.join("player.identities.1234.db"), b"identities");
write_file(&save_dir.join("player.states.1234.db"), b"states");
write_file(&save_dir.join("players.db"), b"player db");
// Innocent file — must never be deleted.
write_file(&save_dir.join("keepme.txt"), b"keep me");
// oxide/data contents
write_file(&root.join("oxide").join("data").join("killfeed.json"), b"{}");
write_file(&root.join("oxide").join("data").join("another.json"), b"{}");
// File at root level — must survive.
write_file(&root.join("server_readme.txt"), b"readme");
dir
}
fn write_file(path: &Path, content: &[u8]) {
std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
}
fn wipe_req(wipe_type: WipeType) -> WipeRequest {
WipeRequest {
wipe_type,
backup: false,
backup_label: "test-backup".to_string(),
}
}
fn exists(root: &Path, rel: &str) -> bool {
root.join(rel).exists()
}
// ---------------------------------------------------------------------------
// Map wipe: only *.map and *.sav deleted
// ---------------------------------------------------------------------------
#[test]
fn map_wipe_deletes_map_and_sav_only() {
let dir = make_server_tree();
let root = dir.path();
let result = execute(root, &wipe_req(WipeType::Map)).expect("map wipe should succeed");
// Deleted
assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be gone");
assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be gone");
// Preserved
assert!(exists(root, "server/myserver/player.blueprints.1234.db"), "blueprints must survive map wipe");
assert!(exists(root, "server/myserver/player.deaths.1234.db"), "deaths must survive map wipe");
assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive");
assert!(exists(root, "oxide/data/killfeed.json"), "oxide/data must survive map wipe");
assert!(exists(root, "server_readme.txt"), "server_readme.txt must survive");
assert_eq!(result.deleted_count, 2);
assert_eq!(result.wipe_type, WipeType::Map);
}
// ---------------------------------------------------------------------------
// Blueprint wipe: map/sav + blueprints deleted
// ---------------------------------------------------------------------------
#[test]
fn blueprint_wipe_includes_map_files() {
let dir = make_server_tree();
let root = dir.path();
let result = execute(root, &wipe_req(WipeType::Blueprint)).expect("blueprint wipe should succeed");
// Deleted
assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be gone");
assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be gone");
assert!(!exists(root, "server/myserver/player.blueprints.1234.db"), "blueprints must be gone");
// Preserved
assert!(exists(root, "server/myserver/player.deaths.1234.db"), "deaths must survive blueprint wipe");
assert!(exists(root, "server/myserver/player.identities.1234.db"), "identities must survive");
assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive");
assert!(exists(root, "oxide/data/killfeed.json"), "oxide/data must survive blueprint wipe");
assert_eq!(result.deleted_count, 3);
assert_eq!(result.wipe_type, WipeType::Blueprint);
}
// ---------------------------------------------------------------------------
// Full wipe: everything including player state + oxide/data
// ---------------------------------------------------------------------------
#[test]
fn full_wipe_clears_all_game_data() {
let dir = make_server_tree();
let root = dir.path();
let result = execute(root, &wipe_req(WipeType::Full)).expect("full wipe should succeed");
// All save-dir game files deleted
assert!(!exists(root, "server/myserver/proc.map"));
assert!(!exists(root, "server/myserver/proc.sav"));
assert!(!exists(root, "server/myserver/player.blueprints.1234.db"));
assert!(!exists(root, "server/myserver/player.deaths.1234.db"));
assert!(!exists(root, "server/myserver/player.identities.1234.db"));
assert!(!exists(root, "server/myserver/player.states.1234.db"));
assert!(!exists(root, "server/myserver/players.db"));
// oxide/data contents deleted (directory itself preserved)
assert!(!exists(root, "oxide/data/killfeed.json"), "killfeed.json must be gone");
assert!(!exists(root, "oxide/data/another.json"), "another.json must be gone");
assert!(exists(root, "oxide/data"), "oxide/data directory itself must remain");
// Never-touched files preserved
assert!(exists(root, "server/myserver/keepme.txt"), "keepme.txt must survive full wipe");
assert!(exists(root, "server_readme.txt"), "server_readme.txt must survive full wipe");
// 7 save-dir files + 2 oxide/data files = 9
assert_eq!(result.deleted_count, 9);
assert_eq!(result.wipe_type, WipeType::Full);
}
// ---------------------------------------------------------------------------
// Missing directories: no error on fresh server
// ---------------------------------------------------------------------------
#[test]
fn missing_server_dir_does_not_error() {
let dir = tempfile::tempdir().expect("tempdir");
// Completely empty root — no server/ or oxide/ directories.
let result = execute(dir.path(), &wipe_req(WipeType::Full));
assert!(result.is_ok(), "empty root must not error: {:?}", result);
assert_eq!(result.unwrap().deleted_count, 0);
}
#[test]
fn missing_oxide_data_does_not_error() {
let dir = tempfile::tempdir().expect("tempdir");
// Has server dir but no oxide/data.
let save_dir = dir.path().join("server").join("myserver");
std::fs::create_dir_all(&save_dir).expect("mkdir");
write_file(&save_dir.join("proc.map"), b"map");
let result = execute(dir.path(), &wipe_req(WipeType::Full));
assert!(result.is_ok(), "missing oxide/data must not error: {:?}", result);
}
// ---------------------------------------------------------------------------
// Symlink safety: symlink inside root pointing outside must NOT be followed
// ---------------------------------------------------------------------------
#[test]
fn symlink_in_save_dir_is_not_deleted_via_follow() {
let dir = make_server_tree();
let root = dir.path();
// Create an external directory with sensitive data.
let outside = tempfile::tempdir().expect("outside tempdir");
write_file(&outside.path().join("secret.txt"), b"TOP SECRET");
// Plant a symlink inside the save dir pointing to the external directory.
let save_dir = root.join("server").join("myserver");
let link = save_dir.join("evil_link");
std::os::unix::fs::symlink(outside.path(), &link).expect("plant symlink");
// Perform a full wipe — should not follow the symlink or touch secret.txt
let result = execute(root, &wipe_req(WipeType::Full));
assert!(result.is_ok(), "wipe with a symlink present must not error: {:?}", result);
// External data must be untouched.
assert!(
outside.path().join("secret.txt").exists(),
"external secret.txt must not be deleted via symlink follow"
);
}
#[test]
fn symlink_at_identity_dir_level_is_skipped() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::create_dir_all(root.join("server")).expect("mkdir server");
// The identity entry itself is a symlink to an external dir.
let outside = tempfile::tempdir().expect("outside tempdir");
write_file(&outside.path().join("proc.map"), b"map");
let link = root.join("server").join("evil_identity");
std::os::unix::fs::symlink(outside.path(), &link).expect("plant identity symlink");
let result = execute(root, &wipe_req(WipeType::Map));
assert!(result.is_ok(), "symlink identity dir must be skipped, not error: {:?}", result);
// The external proc.map must not have been deleted.
assert!(
outside.path().join("proc.map").exists(),
"external proc.map must not be deleted via identity symlink"
);
assert_eq!(result.unwrap().deleted_count, 0);
}
// ---------------------------------------------------------------------------
// Backup: files are copied before deletion
// ---------------------------------------------------------------------------
#[test]
fn backup_copies_targets_before_deletion() {
let dir = make_server_tree();
let root = dir.path();
let req = WipeRequest {
wipe_type: WipeType::Map,
backup: true,
backup_label: "before-map-wipe".to_string(),
};
let result = execute(root, &req).expect("map wipe with backup should succeed");
// The files should be gone from the save dir…
assert!(!exists(root, "server/myserver/proc.map"), "proc.map must be deleted");
assert!(!exists(root, "server/myserver/proc.sav"), "proc.sav must be deleted");
// …but must exist in the backup directory.
let backup_base = root.join(".corrosion-backups").join("before-map-wipe");
assert!(backup_base.exists(), "backup directory must be created");
// Walk the backup to find the backed-up files.
let backed_up = collect_files_recursively(&backup_base);
let has_map = backed_up.iter().any(|p| p.ends_with("proc.map"));
let has_sav = backed_up.iter().any(|p| p.ends_with("proc.sav"));
assert!(has_map, "proc.map must be in backup, found: {backed_up:?}");
assert!(has_sav, "proc.sav must be in backup, found: {backed_up:?}");
assert_eq!(result.deleted_count, 2);
}
/// Recursively collect all file *names* (just the last component) under `dir`.
fn collect_files_recursively(dir: &Path) -> Vec<String> {
let mut found = Vec::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
found.extend(collect_files_recursively(&path));
} else {
if let Some(name) = path.file_name() {
found.push(name.to_string_lossy().into_owned());
}
}
}
}
found
}

View File

@@ -1,27 +1,95 @@
# Pricing # Pricing
> This document mirrors the live pricing page at corrosionmgmt.com/pricing.
--- ---
## Base License — $50 (Launch Price) ## Hobby — $9.99/month
One server. Lifetime access. 15 game server instances · non-commercial use only.
Includes: Includes:
* Full control plane - Up to 5 game server instances
* Auto-Wiper - Non-commercial servers only
* Plugin management - Auto-wiper with rollback
* Public site - Plugin management (Rust uMod/Oxide)
* RBAC - File manager + real-time console
- Scheduled tasks
## Webstore Add-On — $10/month - Public server page
- Community support
Integrated monetization platform.
## Modules — $9.99+
Optional feature expansions.
--- ---
Simple. Transparent. No hidden tiers. ## Community — $19.99/month
610 game server instances · non-commercial use only.
Includes:
- Up to 10 game server instances
- Non-commercial servers only
- Auto-wiper with rollback
- Plugin management (Rust uMod/Oxide)
- File manager + real-time console
- Scheduled tasks
- Public server page
- Community support
---
## Operator — $99.99/month _(Most popular)_
Commercial use permitted, or up to 50 servers.
Includes:
- Up to 50 game server instances
- Commercial use permitted
- All games: Rust, Dune: Awakening, Soulmask, Conan Exiles
- Auto-wiper with rollback
- Plugin + mod management
- File manager + real-time console
- Scheduled tasks + maintenance windows
- Player management + RBAC team access
- Public server page + storefront
- Community support + priority bug triage
---
## Network — Custom pricing
50+ servers · hosting partners and fleets. Contact support@corrosionmgmt.com for pricing.
Includes:
- 50 servers base included
- Fleet Blocks: +$49.99/mo per additional 50 servers
- Commercial use permitted
- All games + multi-game hosts
- Full Operator feature set
- Fleet-level management
- Priority bug triage for platform issues
- Community support
---
## Fleet Block Add-On — +$49.99/month per 50 servers
Stack as many Fleet Blocks as your Network plan operation requires.
---
## Direct 1:1 Support — $125/hour (prepaid 1-hour blocks)
Available to any customer. Billed time with a human — not a support tier. Community support (docs, forum, diagnostics, structured bug reports) is included with every plan at no extra charge.
---
## Commercial Use Definition
Commercial use includes monetized communities, paid access, VIP slots, donations, sponsorship-supported servers, hosting providers, or managing servers for others. Hobby and Community plans are non-commercial only. Operator and Network plans permit commercial use.
---
Simple. Transparent. No per-seat charges. No hidden tiers.

View File

@@ -277,17 +277,6 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
<span class="crumb crumb--cluster">{{ serverName }}</span> <span class="crumb crumb--cluster">{{ serverName }}</span>
</div> </div>
<!-- Search -->
<div class="top__search">
<svg class="top__search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input placeholder="Search servers, players, configs…" readonly />
<span class="top__kbd">
<kbd class="cc-kbd"></kbd><kbd class="cc-kbd">K</kbd>
</span>
</div>
<!-- Actions --> <!-- Actions -->
<div class="top__actions"> <div class="top__actions">
<IconButton <IconButton
@@ -296,7 +285,7 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
@click="toggleTheme" @click="toggleTheme"
/> />
<IconButton icon="bell" label="Alerts" @click="router.push('/alerts')" /> <IconButton icon="bell" label="Alerts" @click="router.push('/alerts')" />
<Button size="sm" icon="rocket">Deploy server</Button> <Button size="sm" icon="rocket" @click="router.push('/server')">Deploy server</Button>
<Avatar <Avatar
:name="userName" :name="userName"
:size="30" :size="30"

View File

@@ -66,8 +66,7 @@ const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
</div> </div>
<div class="footer__col"> <div class="footer__col">
<h5>Company</h5> <h5>Company</h5>
<RouterLink :to="{ name: 'landing' }">About</RouterLink> <RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
<RouterLink :to="{ name: 'roadmap' }">Changelog</RouterLink>
<a href="mailto:support@corrosionmgmt.com">Contact</a> <a href="mailto:support@corrosionmgmt.com">Contact</a>
</div> </div>
</div> </div>

View File

@@ -51,12 +51,12 @@ export function useWebSocket() {
function connect() { function connect() {
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated) {
console.log('[WebSocket] Not authenticated, skipping connection') if (import.meta.env.DEV) console.log('[WebSocket] Not authenticated, skipping connection')
return return
} }
if (isConnecting.value || isConnected.value) { if (isConnecting.value || isConnected.value) {
console.log('[WebSocket] Already connecting or connected') if (import.meta.env.DEV) console.log('[WebSocket] Already connecting or connected')
return return
} }
@@ -65,12 +65,12 @@ export function useWebSocket() {
error.value = null error.value = null
const url = getWebSocketUrl() const url = getWebSocketUrl()
console.log('[WebSocket] Connecting to', url.replace(/token=[^&]+/, 'token=***')) if (import.meta.env.DEV) console.log('[WebSocket] Connecting to', url.replace(/token=[^&]+/, 'token=***'))
ws.value = new WebSocket(url) ws.value = new WebSocket(url)
ws.value.onopen = () => { ws.value.onopen = () => {
console.log('[WebSocket] Connected') if (import.meta.env.DEV) console.log('[WebSocket] Connected')
isConnected.value = true isConnected.value = true
isConnecting.value = false isConnecting.value = false
reconnectAttempts.value = 0 reconnectAttempts.value = 0
@@ -80,7 +80,7 @@ export function useWebSocket() {
ws.value.onmessage = (event) => { ws.value.onmessage = (event) => {
try { try {
const message: WebSocketMessage = JSON.parse(event.data) const message: WebSocketMessage = JSON.parse(event.data)
console.log('[WebSocket] Message received:', message) if (import.meta.env.DEV) console.log('[WebSocket] Message received:', message)
// Broadcast to all handlers // Broadcast to all handlers
messageHandlers.forEach(handler => { messageHandlers.forEach(handler => {
@@ -102,7 +102,7 @@ export function useWebSocket() {
} }
ws.value.onclose = (event) => { ws.value.onclose = (event) => {
console.log('[WebSocket] Closed:', event.code, event.reason) if (import.meta.env.DEV) console.log('[WebSocket] Closed:', event.code, event.reason)
isConnected.value = false isConnected.value = false
isConnecting.value = false isConnecting.value = false
@@ -132,7 +132,7 @@ export function useWebSocket() {
30000 // Max 30 seconds 30000 // Max 30 seconds
) )
console.log( if (import.meta.env.DEV) console.log(
`[WebSocket] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.value}/${maxReconnectAttempts})` `[WebSocket] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.value}/${maxReconnectAttempts})`
) )
@@ -148,7 +148,7 @@ export function useWebSocket() {
} }
if (ws.value) { if (ws.value) {
console.log('[WebSocket] Disconnecting') if (import.meta.env.DEV) console.log('[WebSocket] Disconnecting')
ws.value.close(1000, 'Client disconnect') ws.value.close(1000, 'Client disconnect')
ws.value = null ws.value = null
} }

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { safeDate } from '@/utils/formatters' import { safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
@@ -30,6 +31,7 @@ interface AlertHistoryEntry {
} }
const api = useApi() const api = useApi()
const toast = useToastStore()
const config = ref<AlertConfig>({ const config = ref<AlertConfig>({
population_drop_enabled: false, population_drop_enabled: false,
population_drop_threshold_percent: 50, population_drop_threshold_percent: 50,
@@ -60,9 +62,9 @@ async function saveConfig() {
isSaving.value = true isSaving.value = true
try { try {
await api.put('/alerts/config', config.value) await api.put('/alerts/config', config.value)
alert('Alert configuration saved') toast.success('Alert configuration saved')
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Failed to save configuration') toast.error(err instanceof Error ? err.message : 'Failed to save configuration')
} finally { } finally {
isSaving.value = false isSaving.value = false
} }

View File

@@ -98,7 +98,7 @@ const renderCharts = () => {
}, },
xAxis: { xAxis: {
type: 'category', type: 'category',
data: timeseries.value.timestamps.map(ts => new Date(ts).toLocaleString('en-US', { data: (timeseries.value.timestamps ?? []).map(ts => new Date(ts).toLocaleString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit' hour: '2-digit'
@@ -116,7 +116,7 @@ const renderCharts = () => {
{ {
name: 'Players', name: 'Players',
type: 'line', type: 'line',
data: timeseries.value.player_count, data: timeseries.value.player_count ?? [],
smooth: true, smooth: true,
lineStyle: { color: accent, width: 2 }, lineStyle: { color: accent, width: 2 },
areaStyle: { areaStyle: {
@@ -160,7 +160,7 @@ const renderCharts = () => {
}, },
xAxis: { xAxis: {
type: 'category', type: 'category',
data: timeseries.value.timestamps.map(ts => new Date(ts).toLocaleString('en-US', { data: (timeseries.value.timestamps ?? []).map(ts => new Date(ts).toLocaleString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit' hour: '2-digit'
@@ -191,7 +191,7 @@ const renderCharts = () => {
name: 'FPS', name: 'FPS',
type: 'line', type: 'line',
yAxisIndex: 0, yAxisIndex: 0,
data: timeseries.value.fps, data: timeseries.value.fps ?? [],
smooth: true, smooth: true,
lineStyle: { color: '#10b981', width: 2 }, lineStyle: { color: '#10b981', width: 2 },
itemStyle: { color: '#10b981' } itemStyle: { color: '#10b981' }
@@ -200,7 +200,7 @@ const renderCharts = () => {
name: 'Entities', name: 'Entities',
type: 'line', type: 'line',
yAxisIndex: 1, yAxisIndex: 1,
data: timeseries.value.entity_count, data: timeseries.value.entity_count ?? [],
smooth: true, smooth: true,
lineStyle: { color: '#6366f1', width: 2 }, lineStyle: { color: '#6366f1', width: 2 },
itemStyle: { color: '#6366f1' } itemStyle: { color: '#6366f1' }
@@ -287,7 +287,7 @@ onMounted(() => {
label="Unique players" label="Unique players"
:value="summary.unique_players ?? '—'" :value="summary.unique_players ?? '—'"
icon="bar-chart-3" icon="bar-chart-3"
note="Phase 2.2" note="Coming soon"
/> />
</div> </div>
@@ -302,9 +302,9 @@ onMounted(() => {
</div> </div>
<!-- Player Retention placeholder --> <!-- Player Retention placeholder -->
<Panel eyebrow="Coming in phase 2" title="Player retention"> <Panel eyebrow="Coming soon" title="Player retention">
<template #title-append> <template #title-append>
<Badge tone="neutral">Phase 2</Badge> <Badge tone="neutral">Coming soon</Badge>
</template> </template>
<div class="analytics-view__retention-grid"> <div class="analytics-view__retention-grid">
<div class="analytics-view__retention-cell"> <div class="analytics-view__retention-cell">
@@ -324,7 +324,7 @@ onMounted(() => {
</div> </div>
</div> </div>
<p class="analytics-view__retention-footer"> <p class="analytics-view__retention-footer">
Player retention analytics will be available in phase 2. Player retention analytics are coming soon.
</p> </p>
</Panel> </Panel>
</template> </template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useAutoDoorsStore } from '@/stores/autodoors' import { useAutoDoorsStore } from '@/stores/autodoors'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.vue' import Icon from '@/components/ds/core/Icon.vue'
@@ -8,6 +10,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue' import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useAutoDoorsStore() const store = useAutoDoorsStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const showCreateModal = ref(false) const showCreateModal = ref(false)
const showImportModal = ref(false) const showImportModal = ref(false)
@@ -159,6 +163,16 @@ function getBool(path: string, def: boolean): boolean {
<template> <template>
<div class="adv"> <div class="adv">
<!-- uMod-only guard: AutoDoors is an Oxide/uMod plugin -->
<Panel v-if="gameProfile.mods !== 'umod'">
<EmptyState
icon="door-open"
title="Rust / uMod only"
description="Auto Doors is only available for Rust (uMod/Oxide) servers."
/>
</Panel>
<template v-else>
<!-- Page head --> <!-- Page head -->
<div class="adv__head"> <div class="adv__head">
<div class="adv__head-id"> <div class="adv__head-id">
@@ -504,6 +518,7 @@ function getBool(path: string, def: boolean): boolean {
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useBetterChatStore } from '@/stores/betterchat' import { useBetterChatStore } from '@/stores/betterchat'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
@@ -13,6 +15,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue' import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useBetterChatStore() const store = useBetterChatStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const activeTab = ref<string>('groups') const activeTab = ref<string>('groups')
const showCreateModal = ref(false) const showCreateModal = ref(false)
@@ -276,6 +280,16 @@ const editGroupFormatConsole = computed<string>({
<template> <template>
<div class="bch"> <div class="bch">
<!-- uMod-only guard: BetterChat is an Oxide/uMod plugin -->
<Panel v-if="gameProfile.mods !== 'umod'">
<EmptyState
icon="message-square"
title="Rust / uMod only"
description="Better Chat is only available for Rust (uMod/Oxide) servers."
/>
</Panel>
<template v-else>
<!-- Page head --> <!-- Page head -->
<div class="bch__head"> <div class="bch__head">
<div class="bch__head-id"> <div class="bch__head-id">
@@ -696,6 +710,7 @@ const editGroupFormatConsole = computed<string>({
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>

View File

@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import type { ChatMessage } from '@/types' import type { ChatMessage } from '@/types'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
@@ -14,6 +15,8 @@ import Tabs from '@/components/ds/navigation/Tabs.vue'
const api = useApi() const api = useApi()
const toast = useToastStore() const toast = useToastStore()
const { activeGame } = useThemeGame()
const playerIdLabel = computed(() => activeGame.value === 'rust' || activeGame.value === 'all' ? 'Steam ID' : 'Player ID')
const messages = ref<ChatMessage[]>([]) const messages = ref<ChatMessage[]>([])
const isLoading = ref(false) const isLoading = ref(false)
@@ -122,7 +125,7 @@ onMounted(() => {
<Input <Input
v-model="searchQuery" v-model="searchQuery"
icon="search" icon="search"
placeholder="Search messages, players, or Steam IDs…" :placeholder="`Search messages, players, or ${playerIdLabel}s…`"
size="sm" size="sm"
style="max-width: 340px;" style="max-width: 340px;"
/> />

View File

@@ -383,7 +383,7 @@ function navServer() { router.push('/server') }
v-model="consoleInput" v-model="consoleInput"
:mono="true" :mono="true"
size="sm" size="sm"
placeholder="say, kick, ban, oxide.reload …" :placeholder="profile.mods === 'umod' ? 'say, kick, ban, oxide.reload …' : 'say, kick, ban …'"
:disabled="!isConnected" :disabled="!isConnected"
style="flex: 1" style="flex: 1"
@keydown.enter="sendConsoleCommand" @keydown.enter="sendConsoleCommand"

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter' import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.vue' import Icon from '@/components/ds/core/Icon.vue'
@@ -8,6 +10,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue' import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useFurnaceSplitterStore() const store = useFurnaceSplitterStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const showCreateModal = ref(false) const showCreateModal = ref(false)
const showImportModal = ref(false) const showImportModal = ref(false)
@@ -116,6 +120,16 @@ function getBool(path: string, def: boolean): boolean {
<template> <template>
<div class="fsv"> <div class="fsv">
<!-- uMod-only guard: Furnace Splitter is an Oxide/uMod plugin -->
<Panel v-if="gameProfile.mods !== 'umod'">
<EmptyState
icon="flame"
title="Rust / uMod only"
description="Furnace Splitter is only available for Rust (uMod/Oxide) servers."
/>
</Panel>
<template v-else>
<!-- Page head --> <!-- Page head -->
<div class="fsv__head"> <div class="fsv__head">
<div class="fsv__head-id"> <div class="fsv__head-id">
@@ -326,6 +340,7 @@ function getBool(path: string, def: boolean): boolean {
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>

View File

@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { safeFileSize, safeDate } from '@/utils/formatters' import { safeFileSize, safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
@@ -20,6 +21,7 @@ interface ExportRecord {
const api = useApi() const api = useApi()
const authStore = useAuthStore() const authStore = useAuthStore()
const toast = useToastStore()
const exports = ref<ExportRecord[]>([]) const exports = ref<ExportRecord[]>([])
const isExporting = ref(false) const isExporting = ref(false)
const isImporting = ref(false) const isImporting = ref(false)
@@ -37,7 +39,7 @@ async function createExport() {
isExporting.value = true isExporting.value = true
try { try {
const result = await api.post<{ export_id: string }>('/migration/export', { export_type: exportType.value }) const result = await api.post<{ export_id: string }>('/migration/export', { export_type: exportType.value })
alert(`Export created: ${result.export_id}`) toast.success(`Export created: ${result.export_id}`)
await fetchExports() await fetchExports()
} finally { } finally {
isExporting.value = false isExporting.value = false

View File

@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
import { useServerStore } from '@/stores/server' import { useServerStore } from '@/stores/server'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue' import Badge from '@/components/ds/core/Badge.vue'
@@ -15,6 +16,8 @@ import Tabs from '@/components/ds/navigation/Tabs.vue'
const server = useServerStore() const server = useServerStore()
const api = useApi() const api = useApi()
const toast = useToastStore() const toast = useToastStore()
const { activeGame } = useThemeGame()
const playerIdLabel = computed(() => activeGame.value === 'rust' || activeGame.value === 'all' ? 'Steam ID' : 'Player ID')
interface Player { interface Player {
steam_id: string steam_id: string
@@ -166,7 +169,7 @@ onMounted(() => {
<Input <Input
v-model="searchQuery" v-model="searchQuery"
icon="search" icon="search"
placeholder="Search by name or Steam ID…" :placeholder="`Search by name or ${playerIdLabel}…`"
size="sm" size="sm"
:mono="false" :mono="false"
style="max-width: 320px;" style="max-width: 320px;"
@@ -197,7 +200,7 @@ onMounted(() => {
<thead> <thead>
<tr> <tr>
<th>Player</th> <th>Player</th>
<th>Steam ID</th> <th>{{ playerIdLabel }}</th>
<th>Status</th> <th>Status</th>
<th>Session</th> <th>Session</th>
<th>Playtime</th> <th>Playtime</th>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { safeDate } from '@/utils/formatters' import { safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
@@ -22,6 +23,7 @@ interface ScheduledTask {
} }
const api = useApi() const api = useApi()
const toast = useToastStore()
const tasks = ref<ScheduledTask[]>([]) const tasks = ref<ScheduledTask[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const showModal = ref(false) const showModal = ref(false)
@@ -93,7 +95,7 @@ async function saveTask() {
showModal.value = false showModal.value = false
await fetchTasks() await fetchTasks()
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Failed to save task') toast.error(err instanceof Error ? err.message : 'Failed to save task')
} }
} }

View File

@@ -108,7 +108,7 @@ const showCreds = ref(false)
const tomlCopied = ref(false) const tomlCopied = ref(false)
const deployForm = ref<DeploymentConfig>({ const deployForm = ref<DeploymentConfig>({
server_name: 'My Rust Server', server_name: '',
max_players: 100, max_players: 100,
world_size: 4000, world_size: 4000,
seed: Math.floor(Math.random() * 2147483647), seed: Math.floor(Math.random() * 2147483647),
@@ -465,7 +465,7 @@ onMounted(async () => {
} }
if (msg.type === 'event' && msg.event === 'oxide_status') { if (msg.type === 'event' && msg.event === 'oxide_status') {
oxideStatus.value = msg.data as { stage: string; progress: number; message: string; error?: string } oxideStatus.value = msg.data as { stage: string; progress: number; message: string; error?: string }
if (msg.data && (msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed') { if (msg.data && ((msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed')) {
isInstallingOxide.value = false isInstallingOxide.value = false
} }
} }
@@ -935,7 +935,7 @@ onMounted(async () => {
<!-- Conan Exiles special concepts (Clans / Thralls / Purge) --> <!-- Conan Exiles special concepts (Clans / Thralls / Purge) -->
<Panel <Panel
v-if="profile.accent === 'conan'" v-if="activeGame === 'conan'"
title="Conan Exiles concepts" title="Conan Exiles concepts"
subtitle="Key admin mechanics for Conan Exiles servers" subtitle="Key admin mechanics for Conan Exiles servers"
> >

View File

@@ -166,7 +166,7 @@ onMounted(() => {
<Input <Input
v-model="config.store_name" v-model="config.store_name"
label="Store name" label="Store name"
placeholder="My Rust Server Store" placeholder="My server store"
:required="true" :required="true"
hint="Displayed to players on the store page" hint="Displayed to players on the store page"
/> />

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import type { StoreCategory, StoreItem } from '@/types' import type { StoreCategory, StoreItem } from '@/types'
import { safeFixed } from '@/utils/formatters' import { safeFixed } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
@@ -14,6 +16,8 @@ import Select from '@/components/ds/forms/Select.vue'
import Checkbox from '@/components/ds/forms/Checkbox.vue' import Checkbox from '@/components/ds/forms/Checkbox.vue'
const api = useApi() const api = useApi()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const tab = ref<'categories' | 'items'>('categories') const tab = ref<'categories' | 'items'>('categories')
const isLoading = ref(false) const isLoading = ref(false)
@@ -46,12 +50,19 @@ const itemForm = ref({
enabled: true enabled: true
}) })
const itemTypes = [ const itemTypesUmod = [
{ value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' }, { value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' },
{ value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' }, { value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' },
{ value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' }, { value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' },
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' } { value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' },
] ]
const itemTypesGeneric = [
{ value: 'kit', label: 'Kit', example: 'givecontent {steam_id} item_id 1' },
{ value: 'rank', label: 'Rank', example: 'setrank {steam_id} vip' },
{ value: 'currency', label: 'Currency', example: 'addcurrency {steam_id} 1000' },
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' },
]
const itemTypes = computed(() => gameProfile.value.mods === 'umod' ? itemTypesUmod : itemTypesGeneric)
const tabItems = computed(() => [ const tabItems = computed(() => [
{ value: 'categories', label: 'Categories', count: categories.value.length }, { value: 'categories', label: 'Categories', count: categories.value.length },
@@ -251,7 +262,7 @@ function getCategoryName(categoryId: string | null): string {
} }
const selectedTypeExample = computed(() => { const selectedTypeExample = computed(() => {
const type = itemTypes.find(t => t.value === itemForm.value.item_type) const type = itemTypes.value.find(t => t.value === itemForm.value.item_type)
return type?.example ?? '' return type?.example ?? ''
}) })

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useTimedExecuteStore } from '@/stores/timedexecute' import { useTimedExecuteStore } from '@/stores/timedexecute'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
@@ -12,6 +14,8 @@ import Switch from '@/components/ds/forms/Switch.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue' import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const store = useTimedExecuteStore() const store = useTimedExecuteStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const activeTab = ref<string>('timed') const activeTab = ref<string>('timed')
const showCreateModal = ref(false) const showCreateModal = ref(false)
@@ -360,7 +364,7 @@ const importConfigNameModel = computed<string>({
<span class="te__presets-label">Quick add:</span> <span class="te__presets-label">Quick add:</span>
<button class="te__preset" @click="addPresetTimer('server.save', 300)">server.save (5 min)</button> <button class="te__preset" @click="addPresetTimer('server.save', 300)">server.save (5 min)</button>
<button class="te__preset" @click="addPresetTimer('say Server restart warning!', 3600)">Restart warning (1 h)</button> <button class="te__preset" @click="addPresetTimer('say Server restart warning!', 3600)">Restart warning (1 h)</button>
<button class="te__preset" @click="addPresetTimer('oxide.reload *', 7200)">Reload plugins (2 h)</button> <button v-if="gameProfile.mods === 'umod'" class="te__preset" @click="addPresetTimer('oxide.reload *', 7200)">Reload plugins (2 h)</button>
</div> </div>
<EmptyState <EmptyState

View File

@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe' import { useWipeStore } from '@/stores/wipe'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import type { WipeProfile } from '@/types' import type { WipeProfile } from '@/types'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue' import Button from '@/components/ds/core/Button.vue'
@@ -13,6 +15,8 @@ import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const wipeStore = useWipeStore() const wipeStore = useWipeStore()
const toast = useToastStore() const toast = useToastStore()
const { activeGame } = useThemeGame()
const gameProfile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const expandedId = ref<string | null>(null) const expandedId = ref<string | null>(null)
const showModal = ref(false) const showModal = ref(false)
@@ -242,7 +246,7 @@ onMounted(() => {
{{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }} {{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }}
</Badge> </Badge>
</div> </div>
<div class="detail-kv"> <div v-if="gameProfile.mods === 'umod'" class="detail-kv">
<span class="detail-k">Verify plugins loaded</span> <span class="detail-k">Verify plugins loaded</span>
<Badge :tone="profile.post_wipe_config.verify_plugins_loaded ? 'online' : 'neutral'"> <Badge :tone="profile.post_wipe_config.verify_plugins_loaded ? 'online' : 'neutral'">
{{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }} {{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }}
@@ -359,6 +363,7 @@ onMounted(() => {
label="Verify correct map" label="Verify correct map"
/> />
<Checkbox <Checkbox
v-if="gameProfile.mods === 'umod'"
v-model="form.post_wipe_config.verify_plugins_loaded" v-model="form.post_wipe_config.verify_plugins_loaded"
label="Verify plugins loaded" label="Verify plugins loaded"
/> />

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe' import { useWipeStore } from '@/stores/wipe'
import { useServerStore } from '@/stores/server' import { useServerStore } from '@/stores/server'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { safeDate } from '@/utils/formatters' import { safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
@@ -18,6 +20,8 @@ const wipeStore = useWipeStore()
const server = useServerStore() const server = useServerStore()
const toast = useToastStore() const toast = useToastStore()
const api = useApi() const api = useApi()
const { activeGame } = useThemeGame()
const profile = computed(() => useGameProfile(activeGame.value === 'all' ? 'rust' : activeGame.value))
const triggerType = ref<'map' | 'blueprint' | 'full'>('map') const triggerType = ref<'map' | 'blueprint' | 'full'>('map')
const selectedProfileId = ref<string>('') const selectedProfileId = ref<string>('')
@@ -71,11 +75,18 @@ async function toggleSchedule(scheduleId: string, currentlyActive: boolean) {
} }
} }
const WIPE_TYPE_OPTIONS = [ const WIPE_TYPE_OPTIONS_BASE = [
{ value: 'map', label: 'Map' },
{ value: 'full', label: 'Full' },
]
const WIPE_TYPE_OPTIONS_RUST = [
{ value: 'map', label: 'Map' }, { value: 'map', label: 'Map' },
{ value: 'blueprint', label: 'Blueprint' }, { value: 'blueprint', label: 'Blueprint' },
{ value: 'full', label: 'Full' }, { value: 'full', label: 'Full' },
] ]
const wipeTypeOptions = computed(() =>
profile.value.mods === 'umod' ? WIPE_TYPE_OPTIONS_RUST : WIPE_TYPE_OPTIONS_BASE
)
function profileOptions() { function profileOptions() {
const opts: { value: string; label: string }[] = [{ value: '', label: 'No profile' }] const opts: { value: string; label: string }[] = [{ value: '', label: 'No profile' }]
@@ -148,7 +159,7 @@ onMounted(async () => {
<div class="cc-field__label">Wipe type</div> <div class="cc-field__label">Wipe type</div>
<div class="type-seg"> <div class="type-seg">
<button <button
v-for="opt in WIPE_TYPE_OPTIONS" v-for="opt in wipeTypeOptions"
:key="opt.value" :key="opt.value"
type="button" type="button"
class="type-seg__btn" class="type-seg__btn"

View File

@@ -107,7 +107,7 @@ async function completeSetup() {
<div v-if="step === 1" class="setup-card"> <div v-if="step === 1" class="setup-card">
<div class="setup-card__head"> <div class="setup-card__head">
<h1 class="setup-card__title">Configure your server</h1> <h1 class="setup-card__title">Configure your server</h1>
<p class="setup-card__sub">Connect your Rust server to Corrosion.</p> <p class="setup-card__sub">Connect your game server to Corrosion.</p>
</div> </div>
<Alert v-if="error" tone="danger">{{ error }}</Alert> <Alert v-if="error" tone="danger">{{ error }}</Alert>
@@ -117,7 +117,7 @@ async function completeSetup() {
v-model="serverForm.server_name" v-model="serverForm.server_name"
label="Server name" label="Server name"
type="text" type="text"
placeholder="My Rust Server" placeholder="My game server"
:required="true" :required="true"
/> />

View File

@@ -1,13 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* EarlyAccess signup page. * EarlyAccess signup page.
* * Backend endpoint: POST /api/early-access — live and functional.
* Backend endpoint: POST /api/early-access
* The early_access_signups entity exists but no NestJS controller/module exposes it yet.
* TODO: Create backend-nest/src/modules/early-access/ with a @Public() POST /early-access
* controller that accepts { email, name?, game_interest? } and writes to early_access_signups.
* The server_count column on the entity is varchar(10) — map game_interest to it or add a
* migration adding a game_interest column.
*/ */
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
@@ -130,12 +124,12 @@ onUnmounted(() => { io?.disconnect() })
<div class="icard"> <div class="icard">
<div class="icard__ic"><Icon name="message-square" :size="16" /></div> <div class="icard__ic"><Icon name="message-square" :size="16" /></div>
<b>Direct feedback channel</b> <b>Direct feedback channel</b>
<p>Early access operators have a direct line for platform bug reports and feature input.</p> <p>Early access operators have a direct feedback channel for platform bug reports and feature input.</p>
</div> </div>
<div class="icard"> <div class="icard">
<div class="icard__ic"><Icon name="box" :size="16" /></div> <div class="icard__ic"><Icon name="box" :size="16" /></div>
<b>Rust-first</b> <b>Multi-game</b>
<p>Rust support is complete. Dune, Conan, and Soulmask are in active development.</p> <p>Rust is fully operational today. Dune: Awakening, Conan Exiles, and Soulmask support is in active development.</p>
</div> </div>
<div class="icard"> <div class="icard">
<div class="icard__ic"><Icon name="users" :size="16" /></div> <div class="icard__ic"><Icon name="users" :size="16" /></div>

View File

@@ -69,7 +69,7 @@ const groups: FaqGroup[] = [
{ {
question: 'Does Corrosion replace AMP or Pterodactyl?', question: 'Does Corrosion replace AMP or Pterodactyl?',
answer: answer:
'It can, depending on your use case. Corrosion is an independent control plane — it does not require an existing game panel. If you are currently using AMP or Pterodactyl, migration tooling is available in the panel.', 'It can, depending on your use case. Corrosion is an independent control plane — it does not require an existing game panel. If you are currently using AMP or Pterodactyl, migration tooling is in active development.',
}, },
{ {
question: 'What happens if Corrosion goes offline?', question: 'What happens if Corrosion goes offline?',

View File

@@ -197,6 +197,14 @@ const mockActiveGame = activeGame
</span> </span>
<span class="st"><b />online</span> <span class="st"><b />online</span>
</div> </div>
<div class="mock__row">
<span class="g"><Icon name="drama" :size="13" /></span>
<span class="nm">
Ritual Cluster · PvE
<small>soul-host · soulmask</small>
</span>
<span class="st"><b />online</span>
</div>
<div class="mock__row"> <div class="mock__row">
<span class="g"><Icon name="swords" :size="13" /></span> <span class="g"><Icon name="swords" :size="13" /></span>
<span class="nm"> <span class="nm">
@@ -244,7 +252,7 @@ const mockActiveGame = activeGame
</div> </div>
<div class="pain__item"> <div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span> <span class="pain__x"><Icon name="x" :size="14" /></span>
Juggling Discord bots &amp; cron tasks Juggling community bots &amp; cron tasks
</div> </div>
<div class="pain__item"> <div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span> <span class="pain__x"><Icon name="x" :size="14" /></span>
@@ -442,7 +450,7 @@ const mockActiveGame = activeGame
</div> </div>
<div class="feat"> <div class="feat">
<span class="feat__ic"><Icon name="bell" :size="16" /></span> <span class="feat__ic"><Icon name="bell" :size="16" /></span>
<b>Discord / status announcements</b> <b>Webhook / status announcements</b>
</div> </div>
<div class="feat"> <div class="feat">
<span class="feat__ic"><Icon name="undo-2" :size="16" /></span> <span class="feat__ic"><Icon name="undo-2" :size="16" /></span>
@@ -577,7 +585,7 @@ const mockActiveGame = activeGame
<div class="chip-card"><Icon name="timer" :size="16" style="color:var(--accent-text)" />Wipe countdown</div> <div class="chip-card"><Icon name="timer" :size="16" style="color:var(--accent-text)" />Wipe countdown</div>
<div class="chip-card"><Icon name="puzzle" :size="16" style="color:var(--accent-text)" />Mod / plugin list</div> <div class="chip-card"><Icon name="puzzle" :size="16" style="color:var(--accent-text)" />Mod / plugin list</div>
<div class="chip-card"><Icon name="megaphone" :size="16" style="color:var(--accent-text)" />Announcements</div> <div class="chip-card"><Icon name="megaphone" :size="16" style="color:var(--accent-text)" />Announcements</div>
<div class="chip-card chip-card--accent"><Icon name="shopping-cart" :size="16" />Integrated webstore</div> <div class="chip-card chip-card--accent"><Icon name="shopping-cart" :size="16" />Integrated webstore (coming soon)</div>
</div> </div>
<p <p
class="closing reveal" class="closing reveal"
@@ -620,9 +628,9 @@ const mockActiveGame = activeGame
<div class="plan"> <div class="plan">
<div class="plan__tag" /> <div class="plan__tag" />
<div class="plan__name">Network</div> <div class="plan__name">Network</div>
<div class="plan__price">$99.99<small>/mo</small></div> <div class="plan__price">Custom</div>
<div class="plan__scope">50+ servers for fleets and hosting partners. Fleet Blocks add capacity.</div> <div class="plan__scope">50+ servers for fleets and hosting partners. Fleet Blocks add capacity.</div>
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink> <a class="btn btn--ghost" href="mailto:support@corrosionmgmt.com">Contact us</a>
</div> </div>
</div> </div>
<div class="fleetblock reveal"> <div class="fleetblock reveal">

View File

@@ -41,6 +41,7 @@ interface Plan {
featured: boolean featured: boolean
cta: string cta: string
ctaVariant: 'primary' | 'ghost' ctaVariant: 'primary' | 'ghost'
ctaHref?: string
features: PlanFeature[] features: PlanFeature[]
} }
@@ -58,7 +59,7 @@ const plans: Plan[] = [
{ text: 'Up to 5 game server instances' }, { text: 'Up to 5 game server instances' },
{ text: 'Non-commercial servers only' }, { text: 'Non-commercial servers only' },
{ text: 'Auto-wiper with rollback' }, { text: 'Auto-wiper with rollback' },
{ text: 'Plugin management (Rust)' }, { text: 'Plugin management (Rust uMod/Oxide)' },
{ text: 'File manager + real-time console' }, { text: 'File manager + real-time console' },
{ text: 'Scheduled tasks' }, { text: 'Scheduled tasks' },
{ text: 'Public server page' }, { text: 'Public server page' },
@@ -78,7 +79,7 @@ const plans: Plan[] = [
{ text: 'Up to 10 game server instances' }, { text: 'Up to 10 game server instances' },
{ text: 'Non-commercial servers only' }, { text: 'Non-commercial servers only' },
{ text: 'Auto-wiper with rollback' }, { text: 'Auto-wiper with rollback' },
{ text: 'Plugin management (Rust)' }, { text: 'Plugin management (Rust uMod/Oxide)' },
{ text: 'File manager + real-time console' }, { text: 'File manager + real-time console' },
{ text: 'Scheduled tasks' }, { text: 'Scheduled tasks' },
{ text: 'Public server page' }, { text: 'Public server page' },
@@ -109,13 +110,14 @@ const plans: Plan[] = [
}, },
{ {
name: 'Network', name: 'Network',
price: '$99.99', price: 'Custom',
period: '/mo', period: '',
scope: '50+ servers · hosting partners + fleets', scope: '50+ servers · hosting partners + fleets',
tag: '', tag: '',
featured: false, featured: false,
cta: 'Join early access', cta: 'Contact us',
ctaVariant: 'ghost', ctaVariant: 'ghost',
ctaHref: 'mailto:support@corrosionmgmt.com',
features: [ features: [
{ text: '50 servers base included' }, { text: '50 servers base included' },
{ text: 'Fleet Blocks: +$49.99/mo per 50 servers' }, { text: 'Fleet Blocks: +$49.99/mo per 50 servers' },
@@ -176,7 +178,16 @@ const plans: Plan[] = [
</li> </li>
</ul> </ul>
<a
v-if="plan.ctaHref"
class="btn"
:class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'"
:href="plan.ctaHref"
>
{{ plan.cta }}
</a>
<RouterLink <RouterLink
v-else
class="btn" class="btn"
:class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'" :class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'"
:to="{ name: 'early-access' }" :to="{ name: 'early-access' }"

View File

@@ -25,7 +25,7 @@ const groups: RoadmapGroup[] = [
status: 'shipped', status: 'shipped',
label: 'Phase 1 — Foundation', label: 'Phase 1 — Foundation',
description: description:
'The core control plane is live. Rust server operators can install the agent, connect their server, and manage it entirely from the panel.', 'The core control plane is live. Game server operators can install the agent, connect their server, and manage it entirely from the panel.',
items: [ items: [
{ text: 'Host agent (Windows + Linux)', note: 'Go binary, outbound NATS, zero inbound ports' }, { text: 'Host agent (Windows + Linux)', note: 'Go binary, outbound NATS, zero inbound ports' },
{ text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' }, { text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' },

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue' import StatCard from '@/components/ds/data/StatCard.vue'
import Badge from '@/components/ds/core/Badge.vue' import Badge from '@/components/ds/core/Badge.vue'
@@ -25,6 +26,7 @@ interface ServerInfo {
const route = useRoute() const route = useRoute()
const subdomain = route.params.subdomain as string const subdomain = route.params.subdomain as string
const toast = useToastStore()
const serverInfo = ref<ServerInfo | null>(null) const serverInfo = ref<ServerInfo | null>(null)
const isLoading = ref(false) const isLoading = ref(false)
const error = ref('') const error = ref('')
@@ -48,7 +50,7 @@ async function fetchServerInfo() {
function copyConnectUrl() { function copyConnectUrl() {
if (serverInfo.value?.connect_url) { if (serverInfo.value?.connect_url) {
navigator.clipboard.writeText(serverInfo.value.connect_url) navigator.clipboard.writeText(serverInfo.value.connect_url)
alert('Connect URL copied to clipboard') toast.success('Connect URL copied to clipboard')
} }
} }
@@ -142,14 +144,14 @@ onMounted(() => {
<!-- Discord --> <!-- Discord -->
<Panel v-if="serverInfo.discord_invite" title="Community"> <Panel v-if="serverInfo.discord_invite" title="Community">
<Alert tone="info" title="Join our Discord"> <Alert tone="info" title="Join our community">
<template #actions> <template #actions>
<a <a
:href="serverInfo.discord_invite" :href="serverInfo.discord_invite"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<Button size="sm" variant="secondary" icon="external-link">Join Discord</Button> <Button size="sm" variant="secondary" icon="external-link">Join community</Button>
</a> </a>
</template> </template>
</Alert> </Alert>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import type { PublicStoreInfo, PublicStoreItem, StorePurchaseRequest, StorePurchaseResponse } from '@/types' import type { PublicStoreInfo, PublicStoreItem, StorePurchaseRequest, StorePurchaseResponse } from '@/types'
import { safeCurrency } from '@/utils/formatters' import { safeCurrency } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue' import Panel from '@/components/ds/data/Panel.vue'
@@ -15,6 +16,7 @@ import Logo from '@/components/ds/brand/Logo.vue'
const route = useRoute() const route = useRoute()
const subdomain = computed(() => route.params.subdomain as string) const subdomain = computed(() => route.params.subdomain as string)
const toast = useToastStore()
const isLoading = ref(false) const isLoading = ref(false)
const storeInfo = ref<PublicStoreInfo | null>(null) const storeInfo = ref<PublicStoreInfo | null>(null)
@@ -125,6 +127,12 @@ async function confirmPurchase() {
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
if (response.status === 503) {
closePurchaseModal()
toast.info("Checkout is coming soon — payments aren't enabled yet.")
return
}
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Purchase failed' })) const error = await response.json().catch(() => ({ message: 'Purchase failed' }))
throw new Error(error.message || 'Purchase failed') throw new Error(error.message || 'Purchase failed')
@@ -137,8 +145,7 @@ async function confirmPurchase() {
// Close modal and show success message // Close modal and show success message
closePurchaseModal() closePurchaseModal()
// TODO: Show toast notification toast.success('PayPal window opened. Complete your payment there. Your items will be delivered automatically after payment.')
alert('PayPal window opened. Complete your payment there. Your items will be delivered automatically after payment.')
} catch (error: any) { } catch (error: any) {
purchaseError.value = error.message || 'Purchase failed. Please try again.' purchaseError.value = error.message || 'Purchase failed. Please try again.'
} finally { } finally {