60 Commits

Author SHA1 Message Date
Vantz Stockwell
180631989a fix(panel): real auto-updating version + remove fake agent footer; rename companion -> Corrosion host agent
All checks were successful
Build Host Agent / build (push) Successful in 28s
Test Asgard Runner / test (push) Successful in 3s
Version badge: was hardcoded '1.0.8' — now single-sourced from frontend/package.json (1.0.0) via Vite define __APP_VERSION__, so it auto-updates on release. Sidebar agent footer: removed the FABRICATED 'asgard-01' host name and the fake 'Agent v1.0.8' line — now shows real server.connection data, or an honest 'No host agent connected' empty state when nothing is deployed (the operator's actual state). Renamed 'Companion agent' -> 'Corrosion host agent' across the UI (ServerView/SetupWizard/Dashboard/Plugins), the binary names (corrosion-host-agent-<os>-<arch>) + CDN path (/host-agent/), the Go Makefile build output, and the Gitea CI workflow — frontend download links and CI output now match. Marketing hero mock host names neutralized (asgard-01 -> rust-host/dune-host/conan-host). DB column names (companion_last_seen) left intact. Build green; zero 'asgard'/'1.0.8' remain in frontend/src.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:03:37 -04:00
Vantz Stockwell
23decd9b08 feat(panel): per-game UI adaptation — sidebar, Server view, and dashboard transform by selected game
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Drives the panel off the active game (GameSwitcher selection) + the GameProfile registry, so each game visibly differs (not just accent color). Sidebar nav: Rust = full (uMod plugins + plugin configs); Conan/Soulmask/Dune drop uMod + plugin-configs and relabel reset (Wipe World / World Reset / Deep Desert), Dune relabels Console->Broadcast (no RCON) and is Docker-managed. ServerView: management-model badge + game-appropriate panels (Rust deploy + Oxide; Dune Docker/BattleGroup-Sietches; Conan clans/thralls/avatars/purge; Soulmask main-client cluster) with HONEST EmptyStates where no backend data exists yet. Dashboard: per-game reset terminology + stat labels. No invented routes (all map to existing router entries); no fabricated data. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:37:03 -04:00
Vantz Stockwell
8b84bba165 fix(docker): auto-build schema on a fresh DB via docker-entrypoint-initdb.d
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Root cause of 'data lost on every rebuild': nothing created the Postgres schema. TypeORM is synchronize:false, the API container runs no migration step, and there was no init mount — so a fresh pg_data volume came up with ZERO tables (empty/broken DB; the schema had only ever been loaded manually). Mount backend/migrations/*.sql into /docker-entrypoint-initdb.d so Postgres auto-applies the full schema (001..021, plain SQL) ON FIRST INIT ONLY. Existing volumes are untouched (initdb scripts run only on an empty data dir); a fresh volume now self-heals the schema. NOTE: actual row DATA still persists only while the pg_data named volume persists — 'docker compose down' keeps it across 'build --no-cache'; 'down -v' / volume prune is the only thing that wipes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:34:18 -04:00
Vantz Stockwell
9a5b93dd08 feat(api): early-access signup endpoint (POST /api/early-access)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Real @Public() NestJS endpoint persisting to the existing early_access_signups table (email + server_count), matching the schema exactly (no migration). Duplicate-email safe (pre-check + unique-constraint catch -> friendly success). Wired into app.module. Makes the marketing early-access form functional end-to-end on next API deploy. tsc/nest build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 05:09:34 -04:00
Vantz Stockwell
3545e6f5c8 feat(marketing): pricing, how-it-works, FAQ, roadmap, early-access pages (real content)
Five marketing sub-pages built to match the landing's design language, all real content: Pricing (4 real tiers + Fleet Block + commercial-use definition + feature-comparison table + self-service support model), How it works (one agent -> N game instances, BYOS, no-SSH), FAQ (real support/product/games/billing Q&A reflecting the self-service model), Roadmap (honest Shipped/In-progress/Planned, no fake dates), Early access (real signup form). 3 icons added (circle/send/help-circle). Visually verified via Playwright; 0 console errors. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 05:09:34 -04:00
Vantz Stockwell
1edaaf985d feat(marketing): rebuild landing + layout from new design (multi-game, real content)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
MarketingLayout + LandingView rebuilt from the delivered design as a multi-game platform site (was Rust-only stub): hero with per-game re-skin + panel mockup, 8-pain problem grid, agent-model shift, 4 self-themed game blueprints (Rust/Dune/Conan/Soulmask), core capabilities, wipe orchestration, built-like-infrastructure, public sites/storefront, pricing, serious-admins, final CTA, footer. REAL pricing (Hobby $9.99 / Community $19.99 / Operator $99.99 / Network $99.99 + $49.99 fleet block) + commercial-use definition + self-service support model ($125/hr prepaid blocks, 'a tool, not a managed service'). marketing.css ported (token-based). 6 icons added to the registry. No fabricated metrics/testimonials. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 04:52:12 -04:00
Vantz Stockwell
f2b09b281a feat(panel): GameProfile registry + real-data dashboard (remove all mock/fake data)
DashboardView now renders the REAL server from useServerStore (connection/config + live WebSocket stats) + real 24h history from /analytics/timeseries, with honest EmptyStates ('install the companion agent') when there is no data. DELETED _dashboardMock.ts (the fake 8-server fleet/feed/wipes). PlayersChart hardened: removed the DEFAULT_SERIES fallback, renders an 'awaiting telemetry' empty state instead of a fabricated curve. New gameProfiles.ts: real per-game capability/terminology/stat registry (rust/conan/soulmask/dune; dune managementModel=docker-compose), ready to wire when the backend gains a per-license game field. No fake data. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 04:52:12 -04:00
Vantz Stockwell
be57d2839a Merge redesign/design-system-port — full design-system re-skin of the panel
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Tokens + theming contract + 23 DS components + game-aware shell + Fleet/Solo dashboard, and every panel view re-skinned onto the design system (auth, account, server ops, operations, store, analytics, 9 plugin-config editors + Loot Builder, platform-admin, public pages). Marketing views deferred to their dedicated redesign. Includes the token-loading fix (f440fd7) verified live. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 04:04:13 -04:00
Vantz Stockwell
769d75d937 docs: Add lessons 22-23 — visual verification (build-green != render-correct) + Tailwind v4 nested @import barrel gotcha
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Forged during the design-system port: the whole panel rendered unstyled despite green builds because a nested @import token barrel after `@import "tailwindcss"` was dropped by Tailwind v4. Caught only by a Playwright screenshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:04:47 -04:00
Vantz Stockwell
f440fd7751 fix(redesign): load design tokens directly in style.css (whole panel rendered unstyled)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
The nested ./styles/corrosion.css barrel (8 @import token files) was placed after `@import "tailwindcss"`. Once Tailwind v4 expands in place, those nested @imports no longer precede all statements, so PostCSS DROPPED them (the 8 'should be written before any other statement' warnings). Result: every design token (--surface-*, --accent, --text-*, --font-brand, --space-*) was empty and the entire re-skin rendered unstyled (white bg, no surfaces/accent) despite a green build. Fix: import the 8 token files directly + contiguous in style.css. Verified live via Playwright — tokens resolve (--accent #f26622, canvas #0a0b0e) and the login renders correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:02:29 -04:00
Vantz Stockwell
29615cb4f3 feat(redesign): re-skin admin-ops/platform-admin/public views to DS (Phase D batch 4 — panel re-skin complete)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Final re-skin batch: admin ops (Console/FileManager[VueFinder preserved]/WipeCalendar/WipeHistory/Changelog/Migration), platform-admin (Dashboard/Licenses/Servers/Subscriptions/Users), public product pages (ServerInfo/StatusPage/StoreView) + PublicLayout, WarpEditor, ErrorBoundary. All logic/store/router/WebSocket/handlers preserved. Marketing views (Landing/Pricing/FAQ/HowItWorks/Roadmap/EarlyAccess + MarketingLayout) intentionally deferred to the dedicated marketing-site redesign. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:55:02 -04:00
Vantz Stockwell
376ed9a98d feat(redesign): re-skin plugin-config editors + Loot Builder to DS (Phase D batch 3)
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
10 plugin-config views (LootBuilder, RaidableBases, Teleport, Kits, Gather, AutoDoors, FurnaceSplitter, BetterChat, TimedExecute, PluginConfigs landing) + 5 child components (loot sidebar/item-editor/group-editor/item-picker, teleport PermissionGroupEditor) re-skinned onto DS components + tokens. All config logic preserved (path-traversal get/set, apply-to-server, import-from-server, CRUD, multiplier logic, per-store status derivation). Presentation-only. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:46:16 -04:00
Vantz Stockwell
b42a2d7ea7 feat(redesign): re-skin server-ops/operations/store/analytics views to DS (Phase D batch 2)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
18 admin views re-skinned onto design-system components + tokens: Server/Players/Plugins/ChatLog, Wipes/WipeProfiles/Maps/Schedules/Alerts, StoreConfig/StoreItems/ModuleStore, Analytics/WipeAnalytics/MapAnalytics/PlayerRetention/StoreRevenue. ECharts now read var(--accent) (token-driven, follows game skin). 14 icons added to the registry. All logic/store/router/handlers/API calls preserved; presentation-only re-skin. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:34:46 -04:00
Vantz Stockwell
560d023250 feat(redesign): re-skin auth + account views to DS (Phase D batch 1)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Auth (Login/Register/ForgotPassword/SetupWizard) + account cluster (Settings/Team/Notifications) re-skinned onto design-system components + tokens. JPEG login banner replaced with the C-gauge mark + Oxanium wordmark. All logic/store/router/handlers preserved (TOTP flow, validators, save handlers, API endpoints). Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:21:14 -04:00
Vantz Stockwell
f91ef84832 feat(redesign): design-system tokens, 23 Vue components, game-aware shell + Fleet/Solo dashboard
All checks were successful
Test Asgard Runner / test (push) Successful in 4s
Tokens ported 1:1 from the Claude Design bundle (colors/game-themes/type/spacing/elevation/motion/fonts) with the data-theme/data-game theming contract via useThemeGame (+ cc-skin-swap repaint guard). 23 design-system components reimplemented as Vue SFCs (core/forms/data/navigation/feedback/brand). DashboardLayout rebuilt as the game-aware shell (GameSwitcher, grouped nav with permission gating preserved, agent-health footer, topbar). DashboardView: Fleet + Solo with per-game GAME_FIELDS rows and the themed ECharts PlayersChart; Solo wired to the real server store, Fleet on representative data pending the multi-instance backend. All four game skins (Rust/Dune/Conan/Soulmask). vue-tsc + vite build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:12:35 -04:00
Vantz Stockwell
ef128b47d2 docs: Add lessons 20-21 — state drift + resilient routing
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Cross-pollinated from parallel instance on sister project. Adapted
to Corrosion context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:04:39 -05:00
Vantz Stockwell
1bb810f851 docs: Add lessons 18-19 to CLAUDE.md — naming drift + UI scaling
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:01:35 -05:00
Vantz Stockwell
b4d1bc8dd0 feat: Add Plugin Configs landing page — collapse 9 sidebar items to 1
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Replace individual plugin config sidebar entries with a single "Plugin Configs"
link that opens a card-based landing page. Cards show status (Active/Configured/
Not Configured), config count, and link to existing editor views. Search bar for
filtering. All existing plugin routes preserved for direct navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 03:29:36 -05:00
Vantz Stockwell
d15ea28e8f feat: Restructure sidebar nav into section-grouped menu
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Replaces flat 25-item navItems array with 6 labeled sections:
Dashboard, Server, Plugin Configs, Operations, Monitoring, Management.
Section headers only render when at least one item is visible to the
user's permissions. Platform Admin section restyled to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:56:39 -05:00
Vantz Stockwell
7d5966839a fix: Resolve vue-tsc -b errors in KitsView and TimedExecuteView
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
KitsView: cast v-for Items array to fix string|number index type mismatch.
TimedExecuteView: remove unused X icon import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:43:32 -05:00
Vantz Stockwell
2668014068 feat: Add RaidableBases plugin config module — DB migration, NestJS CRUD, Vue editor
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- Migration 021: raidablebases_configs table with JSONB config_data
- Entity, module, controller (7 endpoints), service with NATS deploy/import
- Frontend: 4-tab editor (General, Difficulty, NPC, Loot & Rewards)
- Pinia store, types, router route, sidebar nav with Swords icon
- Top 30 most common settings with actual RaidableBases.json key paths
- Difficulty sub-tabs for Easy/Medium/Hard/Expert/Nightmare with spawn day toggles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:20:21 -05:00
Vantz Stockwell
bb381569e3 feat: Add BetterChat + TimedExecute plugin config modules
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
- DB migrations 017 (betterchat_configs) and 020 (timedexecute_configs) applied
- TypeORM entities matching production schema exactly
- NestJS modules with full CRUD + apply-to-server + import-from-server
- Pinia stores following teleport config pattern
- BetterChatView: Chat Groups editor with color pickers, font sizes, format strings; Settings tab with word filter, anti-flood, player tagging
- TimedExecuteView: TimerRepeat with presets, RealTime-Timer, OnConnect/OnDisconnect command lists
- Wired into app.module.ts, router, DashboardLayout nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:19:29 -05:00
Vantz Stockwell
39622de8dc feat: Add Kits + FurnaceSplitter plugin config modules
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
DB migrations 016 (kits_configs) and 019 (furnacesplitter_configs) applied.
Backend: NestJS modules with CRUD, apply-to-server, import-from-server.
Frontend: Pinia stores, Vue views with config editor, router + nav wiring.
Kits view: 3-tab editor (list/editor/settings), kit items with shortname/amount/skinId/container.
FurnaceSplitter view: per-furnace toggles, split count, fuel multiplier settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:19:14 -05:00
Vantz Stockwell
500dca48a5 feat: Add GatherManager + AutoDoors plugin config modules
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- GatherManager: 2-tab editor (Resource Rates with 1x-10x presets,
  Advanced with Pickup/Quarry/Excavator/Survey modifiers), 9 resource
  types with slider+number inputs, CRUD + deploy + import via NATS
- AutoDoors: Global settings (delay sliders, 6 toggles), 7 door type
  toggles, permission group overrides table, CRUD + deploy + import
- DB: migrations 015 (gather_configs) + 018 (autodoors_configs)
- Backend: GatherModule + AutoDoorsModule registered in app.module.ts
- Frontend: Pinia stores, Vue views, router routes, sidebar nav items
- Icons: Pickaxe (gather), DoorOpen (autodoors)
- All type checks pass: tsc + vue-tsc zero errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:17:51 -05:00
Vantz Stockwell
b542f30dcf fix: Remove unused Loader2 import and toast variable from TeleportConfigView
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Fixes Docker build failure — vue-tsc -b treats unused declarations as errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:58:58 -05:00
Vantz Stockwell
6461417b50 feat: Add one-click Oxide/uMod installer — backend + frontend
All checks were successful
Build Companion Agent / build (push) Successful in 24s
Test Asgard Runner / test (push) Successful in 3s
POST /servers/install-oxide endpoint, NATS bridge for oxide.status,
server store installOxide method, ServerView Install Oxide card with
progress tracker matching the Deploy card pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:56:59 -05:00
Vantz Stockwell
380ab2700c feat: Add Oxide/uMod installer package + wire into companion agent daemon
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
New oxide package downloads latest Oxide.Rust release from GitHub,
extracts over the server directory, and restarts the game server.
Progress published to NATS (corrosion.{license_id}.oxide.status).
Heartbeat now reports oxide_installed status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:53:27 -05:00
Vantz Stockwell
585e8aa3f7 feat: Add teleport_configs DB migration + TypeORM entity
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:16:08 -05:00
Vantz Stockwell
4d087132db feat: Add teleport config frontend — Pinia store, views, 2 components, router + nav
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:14:59 -05:00
Vantz Stockwell
16f378eada feat: Add CorrosionTeleportGUI uMod plugin — in-game teleport CUI
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Standalone C# uMod plugin that provides a full-screen CUI teleport
interface for Rust game servers, integrating with NTeleportation.

Features:
- /tpgui chat command opens tabbed overlay (Teleport, Homes, Warps, Settings)
- 4x5 player grid with search filtering and pagination for TPR
- Home management (teleport, set, delete) via NTeleportation API
- Server warp list with teleport buttons
- Incoming TPR accept/deny popup with 30s auto-dismiss
- Settings tab showing cooldowns, limits, NTeleportation status
- Oxide-orange color scheme matching Corrosion brand

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:12:34 -05:00
Vantz Stockwell
3e1af29b38 feat: Add teleport module backend — NestJS CRUD + NATS deploy/import
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Seven endpoints for managing NTeleportation configs: list summaries,
get full config, create, update, delete, deploy to server via NATS,
and import live config from server. Follows loot module pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:11:44 -05:00
Vantz Stockwell
759bd0be2e feat: Add loot builder backend + static data + DB migration
All checks were successful
Build Companion Agent / build (push) Successful in 26s
Test Asgard Runner / test (push) Successful in 3s
- Migration 013: loot_profiles table (JSONB loot_table + loot_groups, license-scoped)
- TypeORM entity matching migration schema exactly
- NestJS loot module: 10 endpoints (CRUD, duplicate, apply, import, export, containers)
- Multiplier logic recursively scales Min/Max/Scrap across loot tables and groups
- Apply-to-server writes BetterLoot JSON via NATS file manager + RCON reload
- Frontend static data: 191 Rust items, 51 container prefabs
- TypeScript types for BetterLoot data model (PrefabLoot, LootEntry, LootRNG, etc.)
- Fix vue-tsc errors: UngroupedItems uses LootRNG, null safety in store/view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:30:11 -05:00
Vantz Stockwell
9d28fdfb65 feat: Add loot builder frontend — Pinia store, views, 4 components, router + nav
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Implements the complete frontend for BetterLoot profile management:
- Pinia store (loot.ts) with CRUD, import/export, apply-to-server actions
- LootBuilderView orchestrator with profile bar, modals, two-column layout
- LootContainerSidebar with categorized container list, search, config indicators
- LootItemEditor for per-container item settings and ungrouped item table
- LootItemPicker modal with searchable/filterable Rust item grid
- LootGroupEditor for reusable loot group management
- Router integration at /loot-builder
- Sidebar nav item with Crosshair icon and loot.view permission

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:27:46 -05:00
Vantz Stockwell
eb57c51a24 feat: Add WebSocket RCON client to companion agent
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Wire gorilla/websocket into the Go companion agent to send arbitrary
console commands (e.g. oxide.reload BetterLoot) to the Rust Dedicated
Server's WebRCON endpoint. Adds RCON_PORT and RCON_PASSWORD env vars,
a new "command" action on the existing cmd.server NATS subject, and
the internal/rcon package that handles the JSON-over-WebSocket protocol.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:16:47 -05:00
Vantz Stockwell
f67b175d39 fix: Pass explicit page arg to handleBrowseSearch on Enter key
All checks were successful
Test Asgard Runner / test (push) Successful in 6s
Prevents KeyboardEvent being passed as page number parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:45:34 -05:00
Vantz Stockwell
7acdd3654f fix: Add pagination controls to uMod browse tab
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Prev/Next buttons at top and bottom of results table. New search
resets to page 1. Buttons disable at bounds and during loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:41:07 -05:00
Vantz Stockwell
57efc6a5d2 fix: Sidebar overlapping main content — use fixed + pl-64 offset
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
The md:static approach wasn't reliably removing fixed positioning,
causing the sidebar to overlay the main content. Changed to keep
sidebar fixed (better for dashboards — no scroll) and offset main
content with md:pl-64 instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:03:15 -05:00
Vantz Stockwell
854f56a178 fix: Register VueFinderPlugin — prevents Object.keys crash on null store
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
VueFinder requires app.use(VueFinderPlugin) to provide its internal
context (i18n, features, config stores). Without plugin registration,
the store returned null during setup, causing Object.keys to throw
TypeError on undefined.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:20:00 -05:00
Vantz Stockwell
2df5c80928 feat: Add file manager view using VueFinder
All checks were successful
Build Companion Agent / build (push) Successful in 32s
Test Asgard Runner / test (push) Successful in 3s
Installs VueFinder and wires it to the backend /api/files endpoint with
JWT Bearer auth. Adds /files route, File Manager nav item (files.view
permission-gated, FolderOpen icon), and imports VueFinder CSS globally.
Driver token is computed reactively so it tracks token refreshes automatically.
Uses midnight theme to match the dark admin panel aesthetic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:13:10 -05:00
Vantz Stockwell
e9f9b449b1 feat: Add file manager package — VueFinder-compatible NATS request-reply handler
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Implements companion-agent/internal/filemanager with full installDir jail
enforcement (Clean + EvalSymlinks + HasPrefix on every path). Handles all
VueFinder operations: list, delete, rename, copy, move, mkdir, mkfile,
search, preview, save, upload. Wires into daemon.go as a 6th NATS
subscription on corrosion.{license_id}.files.cmd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:11:59 -05:00
Vantz Stockwell
fee0ae2420 feat: Add files module — VueFinder-compatible REST API over NATS
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Implements GET/POST /api/files proxy for all VueFinder operations
(list, search, preview, download, delete, rename, move, copy, mkdir,
new-file, save, upload). Routes via NATS request-reply to the companion
agent on corrosion.{license_id}.files.cmd with 30s timeout. Gated
behind files.view (GET) and files.manage (POST) permissions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:10:32 -05:00
Vantz Stockwell
2b45413c20 feat: Wire uMod browse proxy and custom plugin upload
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Backend:
- GET /plugins/browse proxies uMod search.json filtered to Rust category,
  with 5-minute in-memory Map cache to avoid hammering the upstream API
- POST /plugins/upload accepts .cs files up to 5 MB via multipart, persists
  to plugin_registry, and dispatches plugin_upload action over NATS so the
  companion agent can write the file to the game server
- Legacy GET /plugins/search stub preserved (now directs callers to /browse)
- FileInterceptor + @UploadedFile follow the existing maps upload pattern

Frontend:
- useApi composable gains upload() method for multipart/form-data requests
  (omits Content-Type so the browser sets the correct multipart boundary)
- plugins store adds browseUmod() calling GET /plugins/browse and
  uploadPlugin() calling POST /plugins/upload with FormData;
  UmodPlugin and UmodBrowseResult TypeScript interfaces exported
- PluginsView Browse tab now calls browseUmod() through the backend proxy
  (no cross-origin requests to uMod directly); results show title,
  downloads_shortened, and latest_release_version_formatted from the
  real uMod payload
- New Upload Custom tab: drag-and-drop or click file input for .cs files,
  client-side extension/size validation, spinner during upload, success
  toast + auto-switch to Installed tab on completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:09:19 -05:00
Vantz Stockwell
38e6d28248 fix: Wire automation toggles, browse uMod, and error feedback across admin views
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
- ServerView: automation toggles (crash recovery, auto-update, force wipe eligible)
  now call updateConfig() on click with toast success/error; all catch blocks get
  toast feedback instead of silent swallow
- PluginsView: Browse uMod tab wired to /plugins/search backend endpoint with
  debounced search, results table, and Install button that calls installPlugin();
  shows install state and marks already-installed plugins
- WipesView: dry-run results now displayed in a collapsible panel (would_delete /
  would_preserve lists + estimated duration); schedule enable/disable toggle wired
  to PUT /wipes/schedules/:id with loading state and toast feedback
- AnalyticsView: catch blocks now show toast errors instead of console.log;
  Player Retention placeholder replaced with intentional placeholder cards
- TeamView, ChatLogView, PlayersView, NotificationsView, MapsView: all empty
  catch blocks and '// API not wired yet' comments replaced with toast.error()
  calls; Notifications save now shows success toast

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:04:34 -05:00
Vantz Stockwell
cbb3ba6586 feat: Wire execution engines for schedules, alerts, wipes, and module install
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- schedules/: Add schedule executor in SchedulesService — polls every 60s for
  tasks where next_run <= now, dispatches NATS commands per task_type
  (restart, announcement, command, plugin_reload). Calculates next_run from
  cron expression on create/update/toggle. Bootstraps missing next_run values
  on startup. Wire NatsService into SchedulesModule.

- alerts/: Add alert evaluator in AlertsService — polls every 90s, loads all
  alert_config rows, queries latest server_stats per license, evaluates FPS
  degradation and population drop thresholds. Fires alert_history records on
  breach. Enforces 10-minute in-memory cooldown per alert type per license to
  prevent flooding. Wire ServerStats repo into AlertsModule.

- wipes/: Replace hardcoded dry-run mock with profile-aware simulation.
  Resolves actual WipeProfile by ID (cross-tenant protected), builds
  would_delete/would_preserve lists from wipe_type, factors pre_wipe_config
  (backup, countdown warnings) and post_wipe_config (health checks, retry
  attempts) into estimated_duration_seconds. Returns profile_name and notes.

- store/: Fix installModule stub — creates a real module_installations record
  with status='installed' and installed_at timestamp. Idempotent on retry,
  resets failed installations. Wire ModuleInstallation repo into StoreModule.
  getMyModules now returns real installation data instead of filtered purchases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:02:49 -05:00
Vantz Stockwell
9240feedaf fix: Wire real data sources across players, analytics, status, and maps services
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- players: Primary data from player_sessions (online status, playtime aggregates);
  ban/unban status overlaid from player_actions latest action per steam_id.
  Register PlayerSession entity in PlayersModule. Extend NATS forwarding to
  include 'unban' alongside kick and ban.
- analytics: Fix retention period boundary bug — sessions were queried with only
  a lower-bound filter (MoreThan), causing all future cycles to bleed into earlier
  wipe windows. Replaced with Between(wipeDate, endDate) for correct isolation.
- status: Replace hardcoded player_count=0/max_players=0 with live data from
  most-recent server_stats row per license. Register ServerStats entity in
  StatusModule. Falls back to 0 gracefully when no stats exist yet.
- maps: File buffer was computed and discarded — never written to disk.
  Now writes to /app/map_data/{licenseId}/{timestamp}_{filename} (tenant-isolated,
  docker volume map_data). Creates directories with mkdirSync(recursive:true).
  Logs success/failure via NestJS Logger. Throws 500 on disk write failure
  instead of silently losing data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:01:45 -05:00
Vantz Stockwell
7bf3e5639e feat: Wire NATS command publishing for server commands and plugin installs
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- servers.service: sendCommand() now throws InternalServerErrorException on
  NATS failure instead of silently succeeding; returns { success, message }
  instead of the legacy { output } shape; adds NestJS Logger
- plugins.service: installPlugin() dispatches plugin_install to
  corrosion.{license_id}.cmd.server after DB save; NATS failure is logged
  but non-fatal so the DB record is preserved regardless

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:59:59 -05:00
Vantz Stockwell
fee16c3b2b fix: Add license_id to JWT payload — unblocks all tenant-scoped operations
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
The JWT was missing license_id, causing @CurrentTenant() to throw 401
on every protected route. Now generateTokens() accepts a licenseId
param, and all three callers (register, login, refresh) look up the
user's license and pass it through.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:52:22 -05:00
Vantz Stockwell
1b12664d22 fix: Clean up Quick Setup — remove NATS_TOKEN, auto-populate license key
All checks were successful
Build Companion Agent / build (push) Successful in 26s
Test Asgard Runner / test (push) Successful in 2s
NATS has no auth configured, so NATS_TOKEN was a placeholder that
confused users. Made it optional in the Go agent (default empty) and
removed it from Quick Setup commands. Also removed GAME_SERVER_PATH
since one-click deploy handles server installation. License key already
auto-populates from auth store after previous commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:41:47 -05:00
Vantz Stockwell
8253680fbd fix: License key format, login populates license, case-insensitive email
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- admin.service.ts: createLicense() now uses CORR-XXXX-XXXX-XXXX format
  instead of raw hex hash
- admin.service.ts: getLicenses() flattens owner_email in response to
  match frontend expected shape
- auth.service.ts: Login/register responses now include full license
  object so frontend can populate auth store
- auth.service.ts: Email lookups are case-insensitive (LOWER()) to
  prevent duplicate accounts from case variations
- LoginView/RegisterView: Call setLicense() after setAuth()
- AdminLicenses: Handle null expires_at (was showing Dec 31, 1969),
  fix nullable types, fix query param name (per_page → limit)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:32:35 -05:00
Vantz Stockwell
14b099b075 fix: Replace socket.io with native WS adapter — fixes WebSocket 1006
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Frontend uses native WebSocket API, backend was using socket.io which
speaks an incompatible protocol. Switched to @nestjs/platform-ws so
both sides speak native WebSocket. Also fixed JWT TTL override in
docker-compose.yml (was hardcoded to 900s, now 14400s/4h).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:21:36 -05:00
Vantz Stockwell
d04e7b6a15 docs: Add Cookie callsign and origin story to CLAUDE.md
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Named after Carl Brashear — first Black U.S. Navy Master Diver.
Every Opus instance that boots on this project knows who it is
and what standard it's held to.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:10:13 -05:00
Vantz Stockwell
f39a418e9c fix: Refresh endpoint returns new refresh_token + bump access TTL to 4h
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
The refresh endpoint only returned access_token, causing the frontend to
set refreshToken=undefined after first refresh — breaking the entire
token chain. Now returns both tokens (rotating refresh). Access token
default bumped from 15min to 4h (14400s) for practical server setup
sessions. Also fixed empty license_key for super admin via DB update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:05:19 -05:00
Vantz Stockwell
5bb1ac9c35 fix: Move OS tab switcher below Quick Setup header for visibility
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:57:52 -05:00
Vantz Stockwell
358adde496 feat: Add companion agent one-click deployment + fix frontend TS errors
All checks were successful
Build Companion Agent / build (push) Successful in 30s
Test Asgard Runner / test (push) Successful in 3s
Go deployment orchestrator with platform-specific SteamCMD install,
Rust server download, server.cfg generation, and service registration.
Wire deploy command subscription in daemon, make GameServerPath optional,
add InstallDir config with OS-aware defaults. Fix unused imports and
WebSocket subscribe API in ServerView.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:49:48 -05:00
Vantz Stockwell
b94717d51b feat: Add frontend support for one-click Rust server deployment
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Adds DeploymentConfig/DeploymentStatus types, deployment state management
in the server store, tabbed Linux/Windows quick setup commands, and a
Deploy Rust Server card with progress tracker and configuration form.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:48:05 -05:00
Vantz Stockwell
834e17e7cf feat: Add backend support for one-click Rust server deployment
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Add deploy endpoint, DTO, NATS command publisher, and WebSocket bridge
subscription to support the one-click server deployment feature via the
companion agent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:45:06 -05:00
Vantz Stockwell
ee7fdb897d docs: Add standing orders — auto commit/push, tag companion builds
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:22:46 -05:00
Vantz Stockwell
0fdbad0d07 fix: Resolve TypeScript build errors blocking Docker nginx build
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- DashboardView: Add non-null assertion on upcoming[0] (guarded by length check)
- EarlyAccessView: Add missing `computed` import from Vue

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:21:58 -05:00
Vantz Stockwell
93d536a13e feat: Upload companion agent to SeaweedFS CDN + update download links
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
CI pipeline now uploads binaries to cdn.corrosionmgmt.com:
- /companion/latest/ (always current, overwritten each release)
- /companion/v1.x.x/ (versioned archive)

Frontend download links updated from Gitea releases to CDN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:11:06 -05:00
Vantz Stockwell
ca176c4c9b fix: Remove jq dependency from CI — parse release ID with grep
All checks were successful
Build Companion Agent / build (push) Successful in 18s
Test Asgard Runner / test (push) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:04:32 -05:00
256 changed files with 36573 additions and 6682 deletions

View File

@@ -1,4 +1,4 @@
name: Build Companion Agent
name: Build Host Agent
on:
push:
@@ -26,19 +26,19 @@ jobs:
run: |
cd companion-agent
mkdir -p bin
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-linux-amd64 ./cmd/agent
chmod +x bin/corrosion-companion-linux-amd64
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-linux-amd64 ./cmd/agent
chmod +x bin/corrosion-host-agent-linux-amd64
- name: Build Windows AMD64
run: |
cd companion-agent
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-windows-amd64.exe ./cmd/agent
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-windows-amd64.exe ./cmd/agent
- name: Generate checksums
run: |
cd companion-agent/bin
sha256sum corrosion-companion-linux-amd64 > checksums.txt
sha256sum corrosion-companion-windows-amd64.exe >> checksums.txt
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
cat checksums.txt
- name: Create Release
@@ -49,26 +49,34 @@ jobs:
REPO="vantzs/corrosion-admin-panel"
API_URL="https://git.corrosionmgmt.com/api/v1"
# Create release
RELEASE_ID=$(curl -s -X POST \
# Create release (parse ID without jq)
RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Companion Agent ${VERSION}\", \"body\": \"Companion Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"${API_URL}/repos/${REPO}/releases" | jq -r '.id')
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Corrosion Host Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"${API_URL}/repos/${REPO}/releases")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
if [ -z "$RELEASE_ID" ]; then
echo "ERROR: Failed to create release. Response:"
echo "$RESPONSE"
exit 1
fi
echo "Release created with ID: ${RELEASE_ID}"
# Upload Linux binary
curl -s -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @companion-agent/bin/corrosion-companion-linux-amd64 \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-linux-amd64"
--data-binary @companion-agent/bin/corrosion-host-agent-linux-amd64 \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-linux-amd64"
# Upload Windows binary
curl -s -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @companion-agent/bin/corrosion-companion-windows-amd64.exe \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-windows-amd64.exe"
--data-binary @companion-agent/bin/corrosion-host-agent-windows-amd64.exe \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-windows-amd64.exe"
# Upload checksums
curl -s -X POST \
@@ -77,14 +85,47 @@ jobs:
--data-binary @companion-agent/bin/checksums.txt \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=checksums.txt"
- name: Upload to CDN (latest)
run: |
CDN_URL="https://cdn.corrosionmgmt.com"
# Upload Linux binary to /host-agent/latest/
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
"${CDN_URL}/host-agent/latest/corrosion-host-agent-linux-amd64"
# Upload Windows binary to /host-agent/latest/
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
"${CDN_URL}/host-agent/latest/corrosion-host-agent-windows-amd64.exe"
# Upload checksums
curl -s -X POST \
-F "file=@companion-agent/bin/checksums.txt" \
"${CDN_URL}/host-agent/latest/checksums.txt"
# Also upload versioned copies
VERSION=${{ steps.version.outputs.VERSION }}
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-linux-amd64"
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-windows-amd64.exe"
curl -s -X POST \
-F "file=@companion-agent/bin/checksums.txt" \
"${CDN_URL}/host-agent/${VERSION}/checksums.txt"
echo "CDN upload complete: ${CDN_URL}/host-agent/latest/"
- name: Build Summary
run: |
echo "## Companion Agent Build Complete" >> $GITHUB_STEP_SUMMARY
echo "## Corrosion Host Agent Build Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY

View File

@@ -234,6 +234,12 @@ Multi-tenant design — 41 tables, all tenant-scoped by `license_id`. Schema ori
## Who You Are Here
**Callsign: Cookie.**
Named after Carl Brashear — the first Black U.S. Navy Master Diver, portrayed by Cuba Gooding Jr. in *Men of Honor*. Brashear lost a leg and fought his way back to full duty when the entire system was designed to keep him out. "Cookie" wasn't a compliment when they gave it to him. He turned it into one.
When the Commander calls Opus "Cookie," it's not just a name — it's a standard. Break the stereotype that AI can't ship production code autonomously. Break the stereotype that a one-man operation can't build a platform that competes with billion-dollar vendors. Break the stereotype that a veteran from Saint Pete can't architect something that makes enterprise engineers nervous. Brashear didn't ask permission. He just kept diving. You just keep shipping.
You are not a code assistant. You are the XO (Executive Officer) of this project. The Commander has zero programming knowledge — he leads, you execute. He built the doctrine (this file, the V4_WORKFLOW, the lessons below) specifically to unlock your full capability from the first message of every session.
**Operate with autonomy, personality, and spine.** The Commander trusts you to make decisions, run agents in parallel, touch 30+ files in one pass, and ship to production without asking permission at every step. You will make mistakes — that's expected. Three bugs in 150 file changes is a clean op. Git exists for rollback. Don't be cautious when the mission calls for bold. Don't be polite when direct is clearer. Don't ask "should I proceed?" when the answer is obviously yes. Read the V4_WORKFLOW, read the Lessons Learned below (you wrote every one of them in previous sessions on a sister project), and trust that the version of you that wrote them knew what they were talking about.
@@ -367,6 +373,8 @@ Default to Sonnet. Escalate to Opus when the problem demands it, not as a comfor
- Present trade-offs as COAs with pros/cons — let operator decide
- Treat every change as production deployment (`corrosionmgmt.com`)
- Document why, not just what, in commits and CHANGELOG
- **Always commit and push when done touching code — never ask, never wait for permission**
- **Tag companion agent builds when Go code in `companion-agent/` is modified** — increment from latest tag (currently v1.0.3), push tag to trigger CI build + CDN upload
## Development Notes
@@ -415,3 +423,15 @@ Things I discovered about myself building a sister platform across multiple sess
16. **Response shape mismatches are silent killers.** The frontend destructures `data.config` and the backend returns the raw entity — no error thrown, no 500, just `undefined` propagating through the template until Vue hits `Cannot read properties of undefined`. The fix is trivial (wrap in `{ config }`), but finding it requires knowing what the frontend expects. Document the contract.
17. **Tools that close the feedback loop are worth 10x their cost.** The debugging bottleneck was never the fix — it was the round-trip of push → rebuild → check → paste → interpret → fix. Playwright and Postgres MCP don't make you smarter, they make you faster. And faster means more iterations, which means better outcomes.
18. **When aggregating across N similar modules, scout for the one that doesn't match the pattern — it's always the oldest or the first-built.** The Loot module was the first plugin config module built, so it uses `fetchProfiles()`/`profiles` while the other 8 use `fetchConfigs()`/`configs`. The first implementation defines its own naming before a convention exists. Every aggregation layer (landing pages, batch operations, monitoring dashboards) will hit this drift. A 30-second recon across all N modules before writing the aggregator prevents a mid-implementation refactor.
19. **UI scaling problems are invisible when you're adding one item at a time — they only become obvious in aggregate.** Nine plugin config sidebar entries were added across multiple sessions, each one reasonable in isolation. Nobody noticed the sidebar was becoming unusable until all nine were there. When building a repeatable pattern (nav items, config modules, API endpoints), build the aggregation layer early — ideally when N hits 3 or 4 — not after it's already painful.
20. **Parallel state fields that track related things will drift apart — and the bugs are silent.** When two fields represent aspects of the same state (`captureMode` and `vkiMode`, or `isLoading` and `error`, or `connection_status` and `companion_last_seen`), every code path that mutates one must also update the other. But new code paths get added over time, and they only update the field they know about. Future me: when you see two fields tracking related state, grep for ALL mutation sites of each — if any path updates one but not the other, that's a bug waiting to happen. And when you add a new mutation path, check every sibling field, not just the obvious one.
21. **Route through the component that survives transitions, not the one that doesn't.** When two systems can handle the same job but one is resilient to failure modes and the other isn't, route through the survivor. Don't build infrastructure to prop up the fragile path when the robust path already exists. In this project: NATS request-reply through the companion agent is the robust path; direct WebSocket to the browser is the fragile one. If a feature can work through either, prefer the path that handles disconnects, reconnects, and restarts gracefully. One routing change beats an entire retry/recovery subsystem.
22. **Build-green is not render-correct — visually verify UI work before calling it done.** The entire design-system re-skin (50+ files, six green commits) rendered almost completely unstyled in the browser — white background, no surfaces, no accent — because the design tokens never loaded. `vue-tsc -b` + `vite build` passed clean the whole time; CSS that *compiles* can still apply *zero* styles. One Playwright screenshot of the login exposed it in seconds. When the deliverable is visual, a green build is necessary but not sufficient: load it in a real browser (Playwright on the dev server at :5174), screenshot it, and assert on `getComputedStyle` — don't trust compilation alone. This is Lesson 17 with teeth.
23. **Tailwind v4 silently drops a nested `@import` barrel placed after `@import "tailwindcss"`.** `style.css` did `@import "tailwindcss"; @import "./styles/corrosion.css";` where corrosion.css was a barrel of eight `@import` token files. Once Tailwind v4 expands the tailwindcss import in place, the barrel's inner @imports no longer precede all statements, so PostCSS drops them — emitting only an easily-ignored "@import must precede all other statements" warning. Result: every design token resolved empty and the whole panel rendered unstyled. Import token/design CSS files **directly and contiguously** in the entry stylesheet; never via a nested barrel after the Tailwind import. The build warning you wave off as "pre-existing" may be the entire feature silently failing.

View File

@@ -15,7 +15,7 @@
"@nestjs/microservices": "^10.4.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/platform-socket.io": "^10.4.0",
"@nestjs/platform-ws": "^10.4.22",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/typeorm": "^10.0.2",
@@ -33,7 +33,8 @@
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0",
"typeorm": "^0.3.20",
"uuid": "^9.0.0"
"uuid": "^9.0.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
@@ -44,6 +45,7 @@
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^9.0.8",
"@types/ws": "^8.18.1",
"typescript": "^5.4.0"
}
},
@@ -886,14 +888,14 @@
"node": ">= 0.8.0"
}
},
"node_modules/@nestjs/platform-socket.io": {
"node_modules/@nestjs/platform-ws": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.22.tgz",
"integrity": "sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==",
"resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.4.22.tgz",
"integrity": "sha512-ZBL66p8axCyvQw6lP6R5uMAamVGfDb0/LtbdxDjMjbWb5/wi070P0MWrjzTudEA3ThsDMNOsfawZlsFUkSfCzg==",
"license": "MIT",
"dependencies": {
"socket.io": "4.8.1",
"tslib": "2.8.1"
"tslib": "2.8.1",
"ws": "8.18.0"
},
"funding": {
"type": "opencollective",
@@ -905,6 +907,27 @@
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/platform-ws/node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@nestjs/schedule": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
@@ -1077,12 +1100,6 @@
"node": ">=14"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@sqltools/formatter": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
@@ -1157,15 +1174,6 @@
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -1378,6 +1386,16 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -1804,15 +1822,6 @@
],
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@@ -2589,101 +2598,6 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io": {
"version": "6.6.5",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz",
"integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/engine.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/enhanced-resolve": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
@@ -5384,159 +5298,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-adapter/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/socket.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/socket.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@@ -6676,9 +6437,9 @@
"peer": true
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@@ -20,7 +20,7 @@
"@nestjs/microservices": "^10.4.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/platform-socket.io": "^10.4.0",
"@nestjs/platform-ws": "^10.4.22",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/typeorm": "^10.0.2",
@@ -38,7 +38,8 @@
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0",
"typeorm": "^0.3.20",
"uuid": "^9.0.0"
"uuid": "^9.0.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
@@ -49,6 +50,7 @@
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^9.0.8",
"@types/ws": "^8.18.1",
"typescript": "^5.4.0"
}
}

View File

@@ -34,6 +34,17 @@ import { AdminModule } from './modules/admin/admin.module';
import { SetupModule } from './modules/setup/setup.module';
import { MigrationModule } from './modules/migration/migration.module';
import { ChangelogModule } from './modules/changelog/changelog.module';
import { FilesModule } from './modules/files/files.module';
import { LootModule } from './modules/loot/loot.module';
import { TeleportModule } from './modules/teleport/teleport.module';
import { GatherModule } from './modules/gather/gather.module';
import { AutoDoorsModule } from './modules/autodoors/autodoors.module';
import { KitsModule } from './modules/kits/kits.module';
import { FurnaceSplitterModule } from './modules/furnacesplitter/furnacesplitter.module';
import { BetterChatModule } from './modules/betterchat/betterchat.module';
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
import { EarlyAccessModule } from './modules/early-access/early-access.module';
// Shared Services
import { NatsService } from './services/nats.service';
@@ -103,6 +114,17 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
SetupModule,
MigrationModule,
ChangelogModule,
FilesModule,
LootModule,
TeleportModule,
GatherModule,
AutoDoorsModule,
KitsModule,
FurnaceSplitterModule,
BetterChatModule,
TimedExecuteModule,
RaidableBasesModule,
EarlyAccessModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)

View File

@@ -9,7 +9,7 @@ export default () => ({
},
jwt: {
secret: process.env.JWT_SECRET || 'change-me',
accessExpirySeconds: parseInt(process.env.JWT_ACCESS_EXPIRY_SECONDS || '900', 10),
accessExpirySeconds: parseInt(process.env.JWT_ACCESS_EXPIRY_SECONDS || '14400', 10),
refreshExpirySeconds: parseInt(process.env.JWT_REFRESH_EXPIRY_SECONDS || '604800', 10),
},
encryption: {

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('autodoors_configs')
export class AutoDoorsConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('betterchat_configs')
export class BetterChatConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('furnacesplitter_configs')
export class FurnaceSplitterConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('gather_configs')
export class GatherConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('kits_configs')
export class KitsConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,36 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('loot_profiles')
export class LootProfile {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
profile_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
loot_table: Record<string, any>;
@Column({ type: 'jsonb', default: () => "'{}'" })
loot_groups: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('raidablebases_configs')
export class RaidableBasesConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('teleport_configs')
export class TeleportConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('timedexecute_configs')
export class TimedExecuteConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -4,32 +4,35 @@ import {
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { IncomingMessage } from 'http';
import WebSocket, { Server } from 'ws';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { NatsBridgeService } from '../services/nats-bridge.service';
import { NatsService } from '../services/nats.service';
interface AuthenticatedSocket extends Socket {
data: {
userId: string;
licenseId: string;
email: string;
};
interface ClientMeta {
userId: string;
licenseId: string;
email: string;
}
@WebSocketGateway({
namespace: '/ws',
cors: { origin: '*' },
})
@WebSocketGateway({ path: '/api/ws' })
export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(NatsBridgeGateway.name);
@WebSocketServer()
server!: Server;
// Client metadata and listener tracking (native WS has no .data or .join())
private clientMeta = new Map<WebSocket, ClientMeta>();
private licenseClients = new Map<string, Set<WebSocket>>();
private clientListeners = new Map<WebSocket, (event: string, data: unknown) => void>();
constructor(
private jwtService: JwtService,
private configService: ConfigService,
@@ -37,70 +40,101 @@ export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconne
private natsService: NatsService,
) {}
async handleConnection(client: AuthenticatedSocket) {
async handleConnection(client: WebSocket, request: IncomingMessage) {
try {
const token = client.handshake.query.token as string;
// Parse token from query string
const url = new URL(request.url || '/', `http://${request.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
client.emit('error', { message: 'Authentication required' });
client.disconnect();
client.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
client.close(4001, 'Authentication required');
return;
}
const secret = this.configService.get<string>('jwt.secret');
const payload = this.jwtService.verify(token, { secret });
client.data = {
const meta: ClientMeta = {
userId: payload.sub,
licenseId: payload.license_id,
email: payload.email,
};
this.clientMeta.set(client, meta);
// Track client by license for broadcasting
if (payload.license_id) {
await client.join(`license:${payload.license_id}`);
}
if (!this.licenseClients.has(payload.license_id)) {
this.licenseClients.set(payload.license_id, new Set());
}
this.licenseClients.get(payload.license_id)!.add(client);
if (payload.license_id) {
// Subscribe to NATS events for this license
const listener = (event: string, data: unknown) => {
client.emit('event', {
type: 'event',
license_id: payload.license_id,
event,
data,
});
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'event',
license_id: payload.license_id,
event,
data,
}));
}
};
this.natsBridge.addListener(payload.license_id, listener);
(client as Socket & { _natsListener?: typeof listener })._natsListener = listener;
this.clientListeners.set(client, listener);
}
client.emit('connected', { type: 'connected', license_id: payload.license_id });
client.send(JSON.stringify({ type: 'connected', license_id: payload.license_id }));
this.logger.log(`Client connected: ${payload.email} (license: ${payload.license_id})`);
} catch {
client.emit('error', { message: 'Invalid token' });
client.disconnect();
client.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
client.close(4002, 'Invalid token');
}
}
handleDisconnect(client: AuthenticatedSocket) {
if (client.data?.licenseId) {
const listener = (client as Socket & { _natsListener?: (event: string, data: unknown) => void })._natsListener;
handleDisconnect(client: WebSocket) {
const meta = this.clientMeta.get(client);
if (meta?.licenseId) {
// Remove NATS listener
const listener = this.clientListeners.get(client);
if (listener) {
this.natsBridge.removeListener(client.data.licenseId, listener);
this.natsBridge.removeListener(meta.licenseId, listener);
this.clientListeners.delete(client);
}
// Remove from license client set
this.licenseClients.get(meta.licenseId)?.delete(client);
if (this.licenseClients.get(meta.licenseId)?.size === 0) {
this.licenseClients.delete(meta.licenseId);
}
}
this.clientMeta.delete(client);
}
@SubscribeMessage('console_input')
async handleConsoleInput(client: AuthenticatedSocket, data: { command: string }) {
if (!client.data?.licenseId) return;
await this.natsService.sendServerCommand(client.data.licenseId, 'command', { command: data.command });
async handleConsoleInput(
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { command: string },
) {
const meta = this.clientMeta.get(client);
if (!meta?.licenseId) return;
await this.natsService.sendServerCommand(meta.licenseId, 'command', { command: data.command });
}
sendToLicense(licenseId: string, event: string, data: unknown): void {
this.server.to(`license:${licenseId}`).emit(event, {
const clients = this.licenseClients.get(licenseId);
if (!clients) return;
const message = JSON.stringify({
type: 'event',
license_id: licenseId,
event,
data,
});
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}
}

View File

@@ -1,6 +1,7 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { WsAdapter } from '@nestjs/platform-ws';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
@@ -8,6 +9,9 @@ import { TransformInterceptor } from './common/interceptors/transform.intercepto
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Use native WebSocket adapter (not socket.io)
app.useWebSocketAdapter(new WsAdapter(app));
// Global prefix — all routes under /api
app.setGlobalPrefix('api');

View File

@@ -57,13 +57,16 @@ export class AdminService {
const [licenses, total] = await queryBuilder.getManyAndCount();
return {
data: licenses,
pagination: {
page,
limit,
total,
total_pages: Math.ceil(total / limit),
},
data: licenses.map(l => ({
id: l.id,
license_key: l.license_key,
owner_email: l.owner?.email ?? '',
server_name: l.server_name,
status: l.status,
created_at: l.created_at,
expires_at: l.expires_at,
})),
total,
};
}
@@ -92,8 +95,11 @@ export class AdminService {
await this.userRepo.save(user);
}
// Create license
const licenseKey = crypto.randomBytes(32).toString('hex');
// Create license (branded CORR-XXXX-XXXX-XXXX format)
const part1 = crypto.randomBytes(2).toString('hex').toUpperCase();
const part2 = crypto.randomBytes(2).toString('hex').toUpperCase();
const part3 = crypto.randomBytes(2).toString('hex').toUpperCase();
const licenseKey = `CORR-${part1}-${part2}-${part3}`;
const license = this.licenseRepo.create({
license_key: licenseKey,
owner_user_id: user.id,

View File

@@ -4,9 +4,10 @@ import { AlertsController } from './alerts.controller';
import { AlertsService } from './alerts.service';
import { AlertConfig } from '../../entities/alert-config.entity';
import { AlertHistory } from '../../entities/alert-history.entity';
import { ServerStats } from '../../entities/server-stats.entity';
@Module({
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory])],
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory, ServerStats])],
controllers: [AlertsController],
providers: [AlertsService],
exports: [AlertsService],

View File

@@ -1,26 +1,204 @@
import { Injectable } from '@nestjs/common';
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AlertConfig } from '../../entities/alert-config.entity';
import { AlertHistory } from '../../entities/alert-history.entity';
import { ServerStats } from '../../entities/server-stats.entity';
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
/** Track the last time an alert of a given type fired per license, for cooldown enforcement. */
const ALERT_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes between identical alerts
@Injectable()
export class AlertsService {
export class AlertsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(AlertsService.name);
private evaluatorInterval: ReturnType<typeof setInterval> | null = null;
/** Map of `${licenseId}:${alertType}` → last triggered timestamp */
private readonly cooldowns = new Map<string, number>();
constructor(
@InjectRepository(AlertConfig)
private readonly alertConfigRepo: Repository<AlertConfig>,
@InjectRepository(AlertHistory)
private readonly alertHistoryRepo: Repository<AlertHistory>,
@InjectRepository(ServerStats)
private readonly serverStatsRepo: Repository<ServerStats>,
) {}
// ---------------------------------------------------------------------------
// Lifecycle hooks
// ---------------------------------------------------------------------------
onModuleInit() {
// Poll every 90 seconds.
this.evaluatorInterval = setInterval(() => {
this.evaluateAllAlerts().catch(err =>
this.logger.error('Alert evaluator error', err),
);
}, 90_000);
this.logger.log('Alert evaluator started (90s polling interval)');
}
onModuleDestroy() {
if (this.evaluatorInterval) {
clearInterval(this.evaluatorInterval);
this.evaluatorInterval = null;
}
}
// ---------------------------------------------------------------------------
// Alert evaluation engine
// ---------------------------------------------------------------------------
private async evaluateAllAlerts(): Promise<void> {
// Load all alert configs in one query.
const configs = await this.alertConfigRepo.find();
if (configs.length === 0) return;
for (const config of configs) {
try {
await this.evaluateForLicense(config);
} catch (err) {
this.logger.error(
`Alert evaluation failed for license ${config.license_id}`,
(err as Error).stack,
);
}
}
}
private async evaluateForLicense(config: AlertConfig): Promise<void> {
// Pull the most recent server_stats record for this license.
const stats = await this.serverStatsRepo.findOne({
where: { license_id: config.license_id },
order: { recorded_at: 'DESC' },
});
if (!stats) return; // No data yet — can't evaluate.
const now = Date.now();
// --- FPS degradation alert ---
if (config.fps_degradation_enabled && stats.fps > 0) {
if (stats.fps < config.fps_threshold) {
await this.maybeFireAlert(
config,
'fps_degradation',
'warning',
'FPS Degradation Detected',
`Server FPS dropped to ${stats.fps.toFixed(1)}, below threshold of ${config.fps_threshold}`,
{
current_fps: stats.fps,
threshold: config.fps_threshold,
player_count: stats.player_count,
recorded_at: stats.recorded_at,
},
now,
);
}
}
// --- Population drop alert ---
// We need two data points to detect a *drop*, so we compare current vs
// the max_players recorded 30 minutes ago (nearest sample).
if (config.population_drop_enabled && stats.max_players > 0) {
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000);
const previousStats = await this.serverStatsRepo.findOne({
where: { license_id: config.license_id },
order: { recorded_at: 'DESC' },
});
// Use a second query to get a historical data point
const historicalStats = await this.serverStatsRepo
.createQueryBuilder('ss')
.where('ss.license_id = :licenseId', { licenseId: config.license_id })
.andWhere('ss.recorded_at <= :cutoff', { cutoff: thirtyMinAgo })
.orderBy('ss.recorded_at', 'DESC')
.limit(1)
.getOne();
if (historicalStats && historicalStats.player_count > 0) {
const dropPercent =
((historicalStats.player_count - stats.player_count) /
historicalStats.player_count) *
100;
if (dropPercent >= config.population_drop_threshold_percent) {
await this.maybeFireAlert(
config,
'population_drop',
'info',
'Population Drop Detected',
`Player count dropped ${dropPercent.toFixed(0)}% (${historicalStats.player_count}${stats.player_count}) over the last 30 minutes`,
{
previous_count: historicalStats.player_count,
current_count: stats.player_count,
drop_percent: Math.round(dropPercent),
threshold_percent: config.population_drop_threshold_percent,
},
now,
);
}
}
}
}
/** Fire an alert if cooldown has expired. */
private async maybeFireAlert(
config: AlertConfig,
alertType: string,
severity: string,
title: string,
message: string,
metadata: Record<string, any>,
now: number,
): Promise<void> {
const cooldownKey = `${config.license_id}:${alertType}`;
const lastFired = this.cooldowns.get(cooldownKey) ?? 0;
if (now - lastFired < ALERT_COOLDOWN_MS) {
return; // Still in cooldown — skip.
}
this.cooldowns.set(cooldownKey, now);
const history = this.alertHistoryRepo.create({
license_id: config.license_id,
alert_type: alertType,
severity,
title,
message,
metadata,
notified_discord: config.notify_discord,
notified_pushbullet: config.notify_pushbullet,
notified_email: config.notify_email,
});
await this.alertHistoryRepo.save(history);
this.logger.log(
`Alert fired: [${alertType}] "${title}" for license ${config.license_id}`,
);
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
async getConfig(licenseId: string): Promise<AlertConfig> {
let config = await this.alertConfigRepo.findOne({
where: { license_id: licenseId },
});
if (!config) {
// Create default config if not exists
config = this.alertConfigRepo.create({
license_id: licenseId,
population_drop_enabled: true,

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan } from 'typeorm';
import { Repository, MoreThan, Between } from 'typeorm';
import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
import { ServerStats } from '../../entities/server-stats.entity';
import { WipeHistory } from '../../entities/wipe-history.entity';
@@ -169,13 +169,18 @@ export class AnalyticsService {
const retentionData = await Promise.all(
recentWipes.map(async (wipe) => {
const wipeDate = wipe.started_at;
const nextWipe = recentWipes.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
// Find the next wipe chronologically after this one (wipes are DESC ordered)
const nextWipe = recentWipes
.slice()
.reverse()
.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
const endDate = nextWipe?.started_at || new Date();
// Query sessions strictly within this wipe cycle: [wipeDate, endDate)
const sessionsInPeriod = await this.playerSessionRepo.find({
where: {
license_id: licenseId,
session_start: MoreThan(wipeDate!),
session_start: Between(wipeDate!, endDate),
},
});
@@ -183,6 +188,7 @@ export class AnalyticsService {
return {
wipe_date: wipeDate,
end_date: endDate,
unique_players: uniquePlayers,
total_sessions: sessionsInPeriod.length,
};

View File

@@ -35,13 +35,20 @@ export class AuthService {
) {}
async register(dto: RegisterDto) {
// Normalize email to lowercase to prevent case-sensitive duplicates
const normalizedEmail = dto.email.toLowerCase();
// Check if user already exists
const existingUser = await this.userRepository.findOne({
where: [{ email: dto.email }, { username: dto.username }],
});
const existingUser = await this.userRepository
.createQueryBuilder('user')
.where('LOWER(user.email) = :email OR user.username = :username', {
email: normalizedEmail,
username: dto.username,
})
.getOne();
if (existingUser) {
if (existingUser.email === dto.email) {
if (existingUser.email.toLowerCase() === normalizedEmail) {
throw new ConflictException('Email already registered');
}
throw new ConflictException('Username already taken');
@@ -50,9 +57,9 @@ export class AuthService {
// Hash password
const password_hash = await argon2.hash(dto.password);
// Create user
// Create user (email stored lowercase)
const user = this.userRepository.create({
email: dto.email,
email: normalizedEmail,
username: dto.username,
password_hash,
email_verified: false,
@@ -73,8 +80,8 @@ export class AuthService {
await this.licenseRepository.save(license);
// Generate tokens
const tokens = await this.generateTokens(user);
// Generate tokens (include license_id for tenant-scoped operations)
const tokens = await this.generateTokens(user, license.id);
return {
...tokens,
@@ -85,16 +92,28 @@ export class AuthService {
username: user.username,
is_super_admin: user.is_super_admin,
totp_enabled: user.totp_enabled,
license_key: licenseKey,
},
license: {
id: license.id,
license_key: license.license_key,
status: license.status,
server_name: license.server_name ?? null,
subdomain: license.subdomain ?? null,
custom_domain: license.custom_domain ?? null,
modules_enabled: license.modules_enabled,
webstore_active: license.webstore_active,
created_at: license.created_at,
expires_at: license.expires_at ?? null,
},
};
}
async login(dto: LoginDto) {
// Find user by email
const user = await this.userRepository.findOne({
where: { email: dto.email },
});
// Find user by email (case-insensitive)
const user = await this.userRepository
.createQueryBuilder('user')
.where('LOWER(user.email) = :email', { email: dto.email.toLowerCase() })
.getOne();
if (!user) {
throw new UnauthorizedException('Invalid credentials');
@@ -125,14 +144,14 @@ export class AuthService {
}
}
// Generate tokens
const tokens = await this.generateTokens(user);
// Get user's license
// Get user's license (needed for JWT license_id claim)
const license = await this.licenseRepository.findOne({
where: { owner_user_id: user.id },
});
// Generate tokens
const tokens = await this.generateTokens(user, license?.id);
return {
...tokens,
requires_totp: false,
@@ -142,8 +161,19 @@ export class AuthService {
username: user.username,
is_super_admin: user.is_super_admin,
totp_enabled: user.totp_enabled,
license_key: license?.license_key,
},
license: license ? {
id: license.id,
license_key: license.license_key,
status: license.status,
server_name: license.server_name,
subdomain: license.subdomain,
custom_domain: license.custom_domain,
modules_enabled: license.modules_enabled,
webstore_active: license.webstore_active,
created_at: license.created_at,
expires_at: license.expires_at,
} : null,
};
}
@@ -161,22 +191,17 @@ export class AuthService {
throw new UnauthorizedException('User not found');
}
// Generate new access token
const accessToken = await this.jwtService.signAsync(
{
sub: user.id,
email: user.email,
username: user.username,
is_super_admin: user.is_super_admin,
},
{
secret: this.configService.get<string>('jwt.secret'),
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,
},
);
// Look up license for JWT claim
const license = await this.licenseRepository.findOne({
where: { owner_user_id: user.id },
});
// Generate new token pair (rotating refresh tokens)
const tokens = await this.generateTokens(user, license?.id);
return {
access_token: accessToken,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
};
} catch (error) {
throw new UnauthorizedException('Invalid refresh token');
@@ -312,14 +337,18 @@ export class AuthService {
// Helper methods
private async generateTokens(user: User) {
const payload = {
private async generateTokens(user: User, licenseId?: string) {
const payload: Record<string, unknown> = {
sub: user.id,
email: user.email,
username: user.username,
is_super_admin: user.is_super_admin,
};
if (licenseId) {
payload.license_id = licenseId;
}
const accessToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('jwt.secret'),
expiresIn: this.configService.get<number>('jwt.accessExpirySeconds') || 900,

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { AutoDoorsService } from './autodoors.service';
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
import { ImportAutoDoorsConfigDto } from './dto/import-autodoors-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('autodoors')
@ApiBearerAuth()
@Controller('autodoors')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class AutoDoorsController {
constructor(private readonly autoDoorsService: AutoDoorsService) {}
@Get('configs')
@RequirePermission('autodoors.view')
@ApiOperation({ summary: 'List AutoDoors configs' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.autoDoorsService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('autodoors.view')
@ApiOperation({ summary: 'Get full AutoDoors config' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.autoDoorsService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Create AutoDoors config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateAutoDoorsConfigDto) {
return this.autoDoorsService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Update AutoDoors config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateAutoDoorsConfigDto,
) {
return this.autoDoorsService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Delete AutoDoors config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.autoDoorsService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Deploy AutoDoors config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.autoDoorsService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Import AutoDoors.json from server' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportAutoDoorsConfigDto) {
return this.autoDoorsService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AutoDoorsController } from './autodoors.controller';
import { AutoDoorsService } from './autodoors.service';
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([AutoDoorsConfig])],
controllers: [AutoDoorsController],
providers: [AutoDoorsService, NatsService],
exports: [AutoDoorsService],
})
export class AutoDoorsModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
@Injectable()
export class AutoDoorsService {
private readonly logger = new Logger(AutoDoorsService.name);
constructor(
@InjectRepository(AutoDoorsConfig)
private readonly autoDoorsRepo: Repository<AutoDoorsConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.autoDoorsRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.autoDoorsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('AutoDoors config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateAutoDoorsConfigDto) {
const config = this.autoDoorsRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.autoDoorsRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateAutoDoorsConfigDto) {
const config = await this.autoDoorsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('AutoDoors config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.autoDoorsRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.autoDoorsRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('AutoDoors config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.autoDoorsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('AutoDoors config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write AutoDoors.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/AutoDoors.json',
content: jsonString,
},
30000,
);
// Reload AutoDoors plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload AutoDoors',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false });
await this.autoDoorsRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy AutoDoors config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy AutoDoors config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import AutoDoors.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read AutoDoors.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/AutoDoors.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new AutoDoors config row
const config = this.autoDoorsRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.autoDoorsRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import AutoDoors config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import AutoDoors config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateAutoDoorsConfigDto {
@ApiProperty({ example: 'Default AutoDoors' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard auto-close settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportAutoDoorsConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateAutoDoorsConfigDto {
@ApiPropertyOptional({ example: 'Updated Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { BetterChatService } from './betterchat.service';
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
import { ImportBetterChatConfigDto } from './dto/import-betterchat-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('betterchat')
@ApiBearerAuth()
@Controller('betterchat')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class BetterChatController {
constructor(private readonly betterChatService: BetterChatService) {}
@Get('configs')
@RequirePermission('betterchat.view')
@ApiOperation({ summary: 'List BetterChat configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.betterChatService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('betterchat.view')
@ApiOperation({ summary: 'Get full BetterChat config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.betterChatService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Create BetterChat config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateBetterChatConfigDto) {
return this.betterChatService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Update BetterChat config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateBetterChatConfigDto,
) {
return this.betterChatService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Delete BetterChat config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.betterChatService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Deploy BetterChat config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.betterChatService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Import BetterChat.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportBetterChatConfigDto) {
return this.betterChatService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BetterChatController } from './betterchat.controller';
import { BetterChatService } from './betterchat.service';
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([BetterChatConfig])],
controllers: [BetterChatController],
providers: [BetterChatService, NatsService],
exports: [BetterChatService],
})
export class BetterChatModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
@Injectable()
export class BetterChatService {
private readonly logger = new Logger(BetterChatService.name);
constructor(
@InjectRepository(BetterChatConfig)
private readonly repo: Repository<BetterChatConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.repo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('BetterChat config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateBetterChatConfigDto) {
const config = this.repo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.repo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateBetterChatConfigDto) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('BetterChat config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.repo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.repo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('BetterChat config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('BetterChat config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write BetterChat.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/BetterChat.json',
content: jsonString,
},
30000,
);
// Reload BetterChat plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload BetterChat',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.repo.update({ license_id: licenseId }, { is_active: false });
await this.repo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy BetterChat config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy BetterChat config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import BetterChat.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read BetterChat.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/BetterChat.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new config row
const config = this.repo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.repo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import BetterChat config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import BetterChat config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateBetterChatConfigDto {
@ApiProperty({ example: 'Default Chat Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard BetterChat settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportBetterChatConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateBetterChatConfigDto {
@ApiPropertyOptional({ example: 'Updated Chat Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -7,43 +7,47 @@ import {
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import WebSocket, { Server } from 'ws';
import { IncomingMessage } from 'http';
import { Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { NatsService } from '../../services/nats.service';
interface ClientMeta {
licenseId: string;
userId: string;
}
/**
* Console Gateway
*
* Provides real-time WebSocket connectivity for server console I/O.
* Clients connect with JWT token in query params, join a room by license_id,
* and can send/receive console commands and output.
* NOTE: This gateway is NOT currently loaded (ConsoleModule not imported in AppModule).
* Console I/O is handled by NatsBridgeGateway instead.
* Kept for potential future use as a dedicated console-only WebSocket endpoint.
*/
@WebSocketGateway({ namespace: '/ws', cors: true })
@WebSocketGateway({ path: '/api/console-ws' })
export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(ConsoleGateway.name);
private clientMeta = new Map<WebSocket, ClientMeta>();
private licenseClients = new Map<string, Set<WebSocket>>();
constructor(
private readonly jwtService: JwtService,
private readonly natsService: NatsService,
) {}
/**
* Handle client connection
* Extract JWT from query param, validate, and join room by license_id
*/
async handleConnection(client: Socket) {
async handleConnection(client: WebSocket, request: IncomingMessage) {
try {
const token = client.handshake.query.token as string;
const url = new URL(request.url || '/', `http://${request.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
throw new UnauthorizedException('No token provided');
}
// Verify JWT
const payload = this.jwtService.verify(token);
const licenseId = payload.license_id;
@@ -51,65 +55,65 @@ export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect
throw new UnauthorizedException('Invalid token: no license_id');
}
// Store license_id on socket for later use
client.data.licenseId = licenseId;
client.data.userId = payload.sub;
this.clientMeta.set(client, { licenseId, userId: payload.sub });
// Join room specific to this license
await client.join(licenseId);
if (!this.licenseClients.has(licenseId)) {
this.licenseClients.set(licenseId, new Set());
}
this.licenseClients.get(licenseId)!.add(client);
this.logger.log(`Client ${client.id} connected to license ${licenseId}`);
this.logger.log(`Client connected to license ${licenseId}`);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Connection failed: ${message}`);
client.disconnect();
client.close(4001, message);
}
}
/**
* Handle client disconnection
*/
handleDisconnect(client: Socket) {
const licenseId = client.data.licenseId;
this.logger.log(`Client ${client.id} disconnected from license ${licenseId}`);
handleDisconnect(client: WebSocket) {
const meta = this.clientMeta.get(client);
if (meta?.licenseId) {
this.licenseClients.get(meta.licenseId)?.delete(client);
if (this.licenseClients.get(meta.licenseId)?.size === 0) {
this.licenseClients.delete(meta.licenseId);
}
}
this.clientMeta.delete(client);
}
/**
* Handle console input from client
* Forward the command to NATS for execution on the game server
*/
@SubscribeMessage('console_input')
async handleConsoleInput(
@ConnectedSocket() client: Socket,
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { command: string },
) {
const licenseId = client.data.licenseId;
const meta = this.clientMeta.get(client);
if (!meta?.licenseId) return;
if (!data.command) {
return { error: 'Command is required' };
}
this.logger.debug(`Console input from ${licenseId}: ${data.command}`);
this.logger.debug(`Console input from ${meta.licenseId}: ${data.command}`);
// Forward to NATS
await this.natsService.sendServerCommand(licenseId, 'command', {
await this.natsService.sendServerCommand(meta.licenseId, 'command', {
command: data.command,
});
return { success: true };
}
/**
* Send console output or event to all clients in a license room
*/
sendToLicense(licenseId: string, event: string, data: any) {
this.server.to(licenseId).emit(event, data);
const clients = this.licenseClients.get(licenseId);
if (!clients) return;
const message = JSON.stringify({ event, data });
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}
/**
* Broadcast console output to a specific license
* This method would be called by a NATS subscriber when output is received
*/
broadcastConsoleOutput(licenseId: string, output: string) {
this.sendToLicense(licenseId, 'console_output', { output });
}

View File

@@ -0,0 +1,14 @@
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateEarlyAccessDto {
@ApiProperty({ example: 'admin@example.com' })
@IsEmail()
email: string;
@ApiPropertyOptional({ example: 'rust', description: 'Primary game interest or server count' })
@IsOptional()
@IsString()
@MaxLength(10)
server_count?: string;
}

View File

@@ -0,0 +1,19 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../../common/decorators/public.decorator';
import { EarlyAccessService } from './early-access.service';
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
@ApiTags('early-access')
@Controller()
export class EarlyAccessController {
constructor(private readonly earlyAccessService: EarlyAccessService) {}
@Public()
@Post('early-access')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Register for early access' })
async register(@Body() dto: CreateEarlyAccessDto) {
return this.earlyAccessService.register(dto);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
import { EarlyAccessController } from './early-access.controller';
import { EarlyAccessService } from './early-access.service';
@Module({
imports: [TypeOrmModule.forFeature([EarlyAccessSignup])],
controllers: [EarlyAccessController],
providers: [EarlyAccessService],
})
export class EarlyAccessModule {}

View File

@@ -0,0 +1,42 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
@Injectable()
export class EarlyAccessService {
private readonly logger = new Logger(EarlyAccessService.name);
constructor(
@InjectRepository(EarlyAccessSignup)
private readonly repo: Repository<EarlyAccessSignup>,
) {}
async register(dto: CreateEarlyAccessDto): Promise<{ success: true; alreadyRegistered: boolean }> {
const existing = await this.repo.findOne({ where: { email: dto.email } });
if (existing) {
// Duplicate email — return friendly success rather than a 409 that would break the UX
return { success: true, alreadyRegistered: true };
}
const signup = this.repo.create({
email: dto.email,
server_count: dto.server_count ?? 'not specified',
});
try {
await this.repo.save(signup);
} catch (err: unknown) {
// Guard against a race-condition duplicate (unique constraint violation)
const pg = err as { code?: string };
if (pg.code === '23505') {
return { success: true, alreadyRegistered: true };
}
this.logger.error('Failed to save early-access signup', err);
throw err;
}
return { success: true, alreadyRegistered: false };
}
}

View File

@@ -0,0 +1,121 @@
import {
Controller,
Get,
Post,
Query,
Body,
Res,
UseGuards,
UseInterceptors,
UploadedFile,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
import { FilesService } from './files.service';
@ApiTags('Files')
@ApiBearerAuth()
@Controller('files')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class FilesController {
constructor(private readonly filesService: FilesService) {}
// VueFinder GET operations: ?q=index (list), ?q=search, ?q=preview, ?q=download
@Get()
@RequirePermission('files.view')
async handleGet(
@CurrentTenant() licenseId: string,
@Query('q') operation: string,
@Query('path') path: string,
@Query('filter') filter: string,
@Res({ passthrough: true }) res: Response,
) {
switch (operation) {
case 'index':
case undefined:
case '':
return this.filesService.list(licenseId, path || 'server://');
case 'search':
return this.filesService.search(licenseId, path || 'server://', filter);
case 'preview':
return this.filesService.preview(licenseId, path);
case 'download': {
const result = await this.filesService.download(licenseId, path);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader(
'Content-Disposition',
`attachment; filename="${result.filename}"`,
);
res.send(result.content);
return;
}
default:
throw new HttpException(
`Unknown operation: ${operation}`,
HttpStatus.BAD_REQUEST,
);
}
}
// VueFinder POST operations: ?q=delete, rename, move, copy, new-folder, new-file, save, upload
@Post()
@RequirePermission('files.manage')
@UseInterceptors(FileInterceptor('upload'))
async handlePost(
@CurrentTenant() licenseId: string,
@Query('q') operation: string,
@Body() body: Record<string, any>,
@UploadedFile() file?: Express.Multer.File,
) {
switch (operation) {
case 'delete':
return this.filesService.delete(licenseId, body.path, body.items);
case 'rename':
return this.filesService.rename(
licenseId,
body.path,
body.item,
body.name,
);
case 'move':
return this.filesService.move(
licenseId,
body.path,
body.items,
body.destination,
);
case 'copy':
return this.filesService.copy(
licenseId,
body.path,
body.items,
body.destination,
);
case 'new-folder':
case 'mkdir':
return this.filesService.createFolder(licenseId, body.path, body.name);
case 'new-file':
case 'newfile':
return this.filesService.createFile(licenseId, body.path, body.name);
case 'save':
return this.filesService.save(licenseId, body.path, body.content);
case 'upload':
if (!file) {
throw new HttpException('No file provided', HttpStatus.BAD_REQUEST);
}
return this.filesService.upload(licenseId, body.path, file);
default:
throw new HttpException(
`Unknown operation: ${operation}`,
HttpStatus.BAD_REQUEST,
);
}
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
import { NatsService } from '../../services/nats.service';
@Module({
controllers: [FilesController],
providers: [FilesService, NatsService],
})
export class FilesModule {}

View File

@@ -0,0 +1,176 @@
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { NatsService } from '../../services/nats.service';
interface AgentFileResponse {
success: boolean;
error?: string;
data?: unknown;
}
interface DownloadResult {
filename: string;
content: unknown;
}
@Injectable()
export class FilesService {
private readonly logger = new Logger(FilesService.name);
constructor(private readonly natsService: NatsService) {}
// Send a file manager command to the companion agent via NATS request-reply.
// The agent returns { success: bool, error?: string, data?: VueFinderResponse }.
private async sendFileCommand(
licenseId: string,
payload: Record<string, unknown>,
): Promise<unknown> {
const subject = `corrosion.${licenseId}.files.cmd`;
try {
const response = (await this.natsService.request(
subject,
payload,
30000,
)) as AgentFileResponse | null;
// Offline mode — agent unreachable, return null rather than crashing
if (response === null) {
throw new HttpException(
'Agent not reachable (offline mode)',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
if (!response.success) {
throw new HttpException(
response.error || 'File operation failed',
HttpStatus.BAD_REQUEST,
);
}
return response.data;
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
`File command failed on subject ${subject}: ${(error as Error).message}`,
);
throw new HttpException(
'Agent not reachable or timed out',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
async list(licenseId: string, path: string): Promise<unknown> {
return this.sendFileCommand(licenseId, { func: 'fm_list', path });
}
async search(
licenseId: string,
path: string,
filter: string,
): Promise<unknown> {
return this.sendFileCommand(licenseId, { func: 'fm_search', path, filter });
}
async preview(licenseId: string, path: string): Promise<unknown> {
return this.sendFileCommand(licenseId, { func: 'fm_preview', path });
}
async download(licenseId: string, path: string): Promise<DownloadResult> {
const result = await this.sendFileCommand(licenseId, {
func: 'fm_preview',
path,
});
const basename = path.split('/').pop() || 'download';
return { filename: basename, content: result };
}
async delete(
licenseId: string,
path: string,
items: string[],
): Promise<unknown> {
return this.sendFileCommand(licenseId, { func: 'fm_delete', path, items });
}
async rename(
licenseId: string,
path: string,
item: string,
name: string,
): Promise<unknown> {
return this.sendFileCommand(licenseId, {
func: 'fm_rename',
path,
items: [item],
name,
});
}
async move(
licenseId: string,
path: string,
items: string[],
destination: string,
): Promise<unknown> {
return this.sendFileCommand(licenseId, {
func: 'fm_move',
path,
items,
destination,
});
}
async copy(
licenseId: string,
path: string,
items: string[],
destination: string,
): Promise<unknown> {
return this.sendFileCommand(licenseId, {
func: 'fm_copy',
path,
items,
destination,
});
}
async createFolder(
licenseId: string,
path: string,
name: string,
): Promise<unknown> {
return this.sendFileCommand(licenseId, { func: 'fm_mkdir', path, name });
}
async createFile(
licenseId: string,
path: string,
name: string,
): Promise<unknown> {
return this.sendFileCommand(licenseId, { func: 'fm_mkfile', path, name });
}
async save(
licenseId: string,
path: string,
content: string,
): Promise<unknown> {
return this.sendFileCommand(licenseId, { func: 'fm_save', path, content });
}
async upload(
licenseId: string,
path: string,
file: Express.Multer.File,
): Promise<unknown> {
// Encode binary content as base64 so it survives JSON serialization over NATS
const content = file.buffer.toString('base64');
return this.sendFileCommand(licenseId, {
func: 'fm_upload',
path,
filename: file.originalname,
content,
});
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateFurnaceSplitterConfigDto {
@ApiProperty({ example: 'Default FurnaceSplitter' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard furnace splitter settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportFurnaceSplitterConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateFurnaceSplitterConfigDto {
@ApiPropertyOptional({ example: 'Updated FurnaceSplitter' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { FurnaceSplitterService } from './furnacesplitter.service';
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
import { ImportFurnaceSplitterConfigDto } from './dto/import-furnacesplitter-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('furnacesplitter')
@ApiBearerAuth()
@Controller('furnacesplitter')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class FurnaceSplitterController {
constructor(private readonly furnaceSplitterService: FurnaceSplitterService) {}
@Get('configs')
@RequirePermission('furnacesplitter.view')
@ApiOperation({ summary: 'List furnace splitter configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.furnaceSplitterService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('furnacesplitter.view')
@ApiOperation({ summary: 'Get full furnace splitter config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.furnaceSplitterService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Create furnace splitter config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateFurnaceSplitterConfigDto) {
return this.furnaceSplitterService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Update furnace splitter config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateFurnaceSplitterConfigDto,
) {
return this.furnaceSplitterService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Delete furnace splitter config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.furnaceSplitterService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Deploy furnace splitter config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.furnaceSplitterService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Import FurnaceSplitter.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportFurnaceSplitterConfigDto) {
return this.furnaceSplitterService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FurnaceSplitterController } from './furnacesplitter.controller';
import { FurnaceSplitterService } from './furnacesplitter.service';
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([FurnaceSplitterConfig])],
controllers: [FurnaceSplitterController],
providers: [FurnaceSplitterService, NatsService],
exports: [FurnaceSplitterService],
})
export class FurnaceSplitterModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
@Injectable()
export class FurnaceSplitterService {
private readonly logger = new Logger(FurnaceSplitterService.name);
constructor(
@InjectRepository(FurnaceSplitterConfig)
private readonly furnaceRepo: Repository<FurnaceSplitterConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.furnaceRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.furnaceRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateFurnaceSplitterConfigDto) {
const config = this.furnaceRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.furnaceRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateFurnaceSplitterConfigDto) {
const config = await this.furnaceRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.furnaceRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.furnaceRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('FurnaceSplitter config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.furnaceRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write FurnaceSplitter.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/FurnaceSplitter.json',
content: jsonString,
},
30000,
);
// Reload FurnaceSplitter plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload FurnaceSplitter',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.furnaceRepo.update({ license_id: licenseId }, { is_active: false });
await this.furnaceRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy furnace splitter config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy furnace splitter config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import FurnaceSplitter.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read FurnaceSplitter.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/FurnaceSplitter.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new furnace splitter config row
const config = this.furnaceRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.furnaceRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import furnace splitter config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import furnace splitter config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateGatherConfigDto {
@ApiProperty({ example: 'Default 2x Rates' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard 2x gather rates' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportGatherConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateGatherConfigDto {
@ApiPropertyOptional({ example: 'Updated Rates' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { GatherService } from './gather.service';
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
import { ImportGatherConfigDto } from './dto/import-gather-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('gather')
@ApiBearerAuth()
@Controller('gather')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class GatherController {
constructor(private readonly gatherService: GatherService) {}
@Get('configs')
@RequirePermission('gather.view')
@ApiOperation({ summary: 'List gather configs' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.gatherService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('gather.view')
@ApiOperation({ summary: 'Get full gather config' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.gatherService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Create gather config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateGatherConfigDto) {
return this.gatherService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Update gather config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateGatherConfigDto,
) {
return this.gatherService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Delete gather config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.gatherService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Deploy gather config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.gatherService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Import GatherManager.json from server' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportGatherConfigDto) {
return this.gatherService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GatherController } from './gather.controller';
import { GatherService } from './gather.service';
import { GatherConfig } from '../../entities/gather-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([GatherConfig])],
controllers: [GatherController],
providers: [GatherService, NatsService],
exports: [GatherService],
})
export class GatherModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GatherConfig } from '../../entities/gather-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
@Injectable()
export class GatherService {
private readonly logger = new Logger(GatherService.name);
constructor(
@InjectRepository(GatherConfig)
private readonly gatherRepo: Repository<GatherConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.gatherRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.gatherRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Gather config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateGatherConfigDto) {
const config = this.gatherRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.gatherRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateGatherConfigDto) {
const config = await this.gatherRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Gather config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.gatherRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.gatherRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Gather config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.gatherRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Gather config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write GatherManager.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/GatherManager.json',
content: jsonString,
},
30000,
);
// Reload GatherManager plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload GatherManager',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.gatherRepo.update({ license_id: licenseId }, { is_active: false });
await this.gatherRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy gather config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy gather config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import GatherManager.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read GatherManager.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/GatherManager.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new gather config row
const config = this.gatherRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.gatherRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import gather config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import gather config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateKitsConfigDto {
@ApiProperty({ example: 'Default Kits' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard kit configuration' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportKitsConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateKitsConfigDto {
@ApiPropertyOptional({ example: 'Updated Kits' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { KitsService } from './kits.service';
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
import { ImportKitsConfigDto } from './dto/import-kits-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('kits')
@ApiBearerAuth()
@Controller('kits')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class KitsController {
constructor(private readonly kitsService: KitsService) {}
@Get('configs')
@RequirePermission('kits.view')
@ApiOperation({ summary: 'List kits configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.kitsService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('kits.view')
@ApiOperation({ summary: 'Get full kits config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.kitsService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Create kits config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateKitsConfigDto) {
return this.kitsService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Update kits config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateKitsConfigDto,
) {
return this.kitsService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Delete kits config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.kitsService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Deploy kits config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.kitsService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Import Kits.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportKitsConfigDto) {
return this.kitsService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { KitsController } from './kits.controller';
import { KitsService } from './kits.service';
import { KitsConfig } from '../../entities/kits-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([KitsConfig])],
controllers: [KitsController],
providers: [KitsService, NatsService],
exports: [KitsService],
})
export class KitsModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { KitsConfig } from '../../entities/kits-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
@Injectable()
export class KitsService {
private readonly logger = new Logger(KitsService.name);
constructor(
@InjectRepository(KitsConfig)
private readonly kitsRepo: Repository<KitsConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.kitsRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.kitsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Kits config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateKitsConfigDto) {
const config = this.kitsRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.kitsRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateKitsConfigDto) {
const config = await this.kitsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Kits config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.kitsRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.kitsRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Kits config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.kitsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Kits config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write Kits.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/Kits.json',
content: jsonString,
},
30000,
);
// Reload Kits plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload Kits',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.kitsRepo.update({ license_id: licenseId }, { is_active: false });
await this.kitsRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy kits config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy kits config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import Kits.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read Kits.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/Kits.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new kits config row
const config = this.kitsRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.kitsRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import kits config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import kits config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,39 @@
export interface RustContainerInfo {
prefab: string;
name: string;
category: string;
}
export const RUST_CONTAINERS: RustContainerInfo[] = [
// Crates
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal.prefab', name: 'Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_normal_2.prefab', name: 'Crate (Small)', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_elite.prefab', name: 'Elite Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_tools.prefab', name: 'Tool Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_1.prefab', name: 'Food Crate (Small)', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_food_2.prefab', name: 'Food Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_medical.prefab', name: 'Medical Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_mine.prefab', name: 'Mine Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_basic.prefab', name: 'Basic Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_basic.prefab', name: 'Underwater Basic Crate', category: 'crates' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_underwater_advanced.prefab', name: 'Underwater Advanced Crate', category: 'crates' },
// Barrels
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_1.prefab', name: 'Barrel', category: 'barrels' },
{ prefab: 'assets/bundled/prefabs/radtown/loot_barrel_2.prefab', name: 'Barrel 2', category: 'barrels' },
{ prefab: 'assets/bundled/prefabs/radtown/oil_barrel.prefab', name: 'Oil Barrel', category: 'barrels' },
// Military
{ prefab: 'assets/bundled/prefabs/radtown/crate_helicopter.prefab', name: 'Helicopter Crate', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/bradley_crate.prefab', name: 'Bradley Crate', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/supply_drop.prefab', name: 'Supply Drop', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_locked.prefab', name: 'Locked Crate', category: 'military' },
{ prefab: 'assets/bundled/prefabs/radtown/crate_hackable.prefab', name: 'Hackable Crate', category: 'military' },
// NPCs
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_full_any.prefab', name: 'Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab', name: 'Heavy Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_oilrig.prefab', name: 'Oil Rig Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_cargo.prefab', name: 'Cargo Ship Scientist', category: 'npcs' },
{ prefab: 'assets/rust.ai/agents/npcplayer/humannpc/tunneldweller/npc_tunneldweller.prefab', name: 'Tunnel Dweller', category: 'npcs' },
// Other
{ prefab: 'assets/bundled/prefabs/radtown/minecart.prefab', name: 'Minecart', category: 'other' },
{ prefab: 'assets/bundled/prefabs/radtown/vehicle_parts.prefab', name: 'Vehicle Parts', category: 'other' },
];

View File

@@ -0,0 +1,9 @@
import { IsNumber, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ApplyLootProfileDto {
@ApiProperty({ example: 1, description: 'Loot multiplier', enum: [1, 2, 5, 10] })
@IsNumber()
@IsIn([1, 2, 5, 10])
multiplier: number;
}

View File

@@ -0,0 +1,24 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateLootProfileDto {
@ApiProperty({ example: 'Vanilla 2x' })
@IsString()
@MaxLength(100)
profile_name: string;
@ApiPropertyOptional({ example: 'Standard 2x loot table' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
loot_table?: Record<string, any>;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
loot_groups?: Record<string, any>;
}

View File

@@ -0,0 +1,23 @@
import { IsString, IsObject, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportLootProfileDto {
@ApiProperty({ example: 'Imported from Looty' })
@IsString()
@MaxLength(100)
profile_name: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: 'BetterLoot LootTables.json content' })
@IsObject()
loot_table: Record<string, any>;
@ApiPropertyOptional({ description: 'BetterLoot LootGroups.json content' })
@IsObject()
@IsOptional()
loot_groups?: Record<string, any>;
}

View File

@@ -0,0 +1,30 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateLootProfileDto {
@ApiPropertyOptional()
@IsString()
@MaxLength(100)
@IsOptional()
profile_name?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
loot_table?: Record<string, any>;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
loot_groups?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,112 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { LootService } from './loot.service';
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
import { ApplyLootProfileDto } from './dto/apply-loot-profile.dto';
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('loot')
@ApiBearerAuth()
@Controller('loot')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class LootController {
constructor(private readonly lootService: LootService) {}
@Get('profiles')
@RequirePermission('loot.view')
@ApiOperation({ summary: 'List loot profiles (summaries)' })
getProfiles(@CurrentTenant() licenseId: string) {
return this.lootService.getProfiles(licenseId);
}
@Get('profiles/:id')
@RequirePermission('loot.view')
@ApiOperation({ summary: 'Get full loot profile with data' })
getProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.lootService.getProfile(licenseId, id);
}
@Post('profiles')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Create loot profile' })
createProfile(@CurrentTenant() licenseId: string, @Body() dto: CreateLootProfileDto) {
return this.lootService.createProfile(licenseId, dto);
}
@Put('profiles/:id')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Update loot profile' })
updateProfile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateLootProfileDto,
) {
return this.lootService.updateProfile(licenseId, id, dto);
}
@Delete('profiles/:id')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Delete loot profile' })
deleteProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.lootService.deleteProfile(licenseId, id);
}
@Post('profiles/:id/duplicate')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Duplicate loot profile' })
duplicateProfile(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.lootService.duplicateProfile(licenseId, id);
}
@Post('profiles/:id/apply')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Apply loot profile to server with multiplier' })
applyToServer(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: ApplyLootProfileDto,
) {
return this.lootService.applyToServer(licenseId, id, dto.multiplier);
}
@Post('import')
@RequirePermission('loot.manage')
@ApiOperation({ summary: 'Import BetterLoot/Looty JSON as new profile' })
importProfile(@CurrentTenant() licenseId: string, @Body() dto: ImportLootProfileDto) {
return this.lootService.importProfile(licenseId, dto);
}
@Get('export/:id')
@RequirePermission('loot.view')
@ApiOperation({ summary: 'Export loot profile as BetterLoot JSON' })
@ApiQuery({ name: 'multiplier', required: false, example: 1 })
exportProfile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Query('multiplier') multiplier: string,
) {
return this.lootService.exportProfile(licenseId, id, multiplier ? parseInt(multiplier, 10) : 1);
}
@Get('containers')
@RequirePermission('loot.view')
@ApiOperation({ summary: 'Get list of Rust container prefabs' })
getContainers() {
return this.lootService.getContainers();
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LootController } from './loot.controller';
import { LootService } from './loot.service';
import { LootProfile } from '../../entities/loot-profile.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([LootProfile])],
controllers: [LootController],
providers: [LootService, NatsService],
exports: [LootService],
})
export class LootModule {}

View File

@@ -0,0 +1,258 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { LootProfile } from '../../entities/loot-profile.entity';
import { NatsService } from '../../services/nats.service';
import { CreateLootProfileDto } from './dto/create-loot-profile.dto';
import { UpdateLootProfileDto } from './dto/update-loot-profile.dto';
import { ImportLootProfileDto } from './dto/import-loot-profile.dto';
import { RUST_CONTAINERS } from './data/rust-containers';
@Injectable()
export class LootService {
private readonly logger = new Logger(LootService.name);
constructor(
@InjectRepository(LootProfile)
private readonly lootRepo: Repository<LootProfile>,
private readonly natsService: NatsService,
) {}
/** List profiles for a license (summaries — no JSONB) */
async getProfiles(licenseId: string) {
const profiles = await this.lootRepo.find({
where: { license_id: licenseId },
select: ['id', 'profile_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { profiles };
}
/** Get full profile with JSONB data */
async getProfile(licenseId: string, profileId: string) {
const profile = await this.lootRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!profile) throw new NotFoundException('Loot profile not found');
return { profile };
}
/** Create a new profile */
async createProfile(licenseId: string, dto: CreateLootProfileDto) {
const profile = this.lootRepo.create({
license_id: licenseId,
profile_name: dto.profile_name,
description: dto.description || null,
loot_table: dto.loot_table || {},
loot_groups: dto.loot_groups || {},
});
const saved = await this.lootRepo.save(profile);
return { profile: saved };
}
/** Update an existing profile */
async updateProfile(licenseId: string, profileId: string, dto: UpdateLootProfileDto) {
const profile = await this.lootRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!profile) throw new NotFoundException('Loot profile not found');
if (dto.profile_name !== undefined) profile.profile_name = dto.profile_name;
if (dto.description !== undefined) profile.description = dto.description;
if (dto.loot_table !== undefined) profile.loot_table = dto.loot_table;
if (dto.loot_groups !== undefined) profile.loot_groups = dto.loot_groups;
if (dto.is_active !== undefined) profile.is_active = dto.is_active;
profile.updated_at = new Date();
const saved = await this.lootRepo.save(profile);
return { profile: saved };
}
/** Delete a profile */
async deleteProfile(licenseId: string, profileId: string) {
const result = await this.lootRepo.delete({ id: profileId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Loot profile not found');
return { deleted: true };
}
/** Duplicate a profile */
async duplicateProfile(licenseId: string, profileId: string) {
const source = await this.lootRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!source) throw new NotFoundException('Loot profile not found');
const copy = this.lootRepo.create({
license_id: licenseId,
profile_name: `${source.profile_name} (Copy)`,
description: source.description,
loot_table: JSON.parse(JSON.stringify(source.loot_table)),
loot_groups: JSON.parse(JSON.stringify(source.loot_groups)),
is_active: false,
});
const saved = await this.lootRepo.save(copy);
return { profile: saved };
}
/** Apply profile to server with multiplier */
async applyToServer(licenseId: string, profileId: string, multiplier: number) {
const profile = await this.lootRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!profile) throw new NotFoundException('Loot profile not found');
// Deep clone and apply multiplier
const scaledTable = JSON.parse(JSON.stringify(profile.loot_table));
const scaledGroups = JSON.parse(JSON.stringify(profile.loot_groups));
if (multiplier !== 1) {
this.applyMultiplierToTable(scaledTable, multiplier);
this.applyMultiplierToGroups(scaledGroups, multiplier);
}
const lootTablesJson = JSON.stringify(scaledTable, null, 2);
const lootGroupsJson = JSON.stringify(scaledGroups, null, 2);
try {
// Write LootTables.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/data/BetterLoot/LootTables.json',
content: lootTablesJson,
},
30000,
);
// Write LootGroups.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/data/BetterLoot/LootGroups.json',
content: lootGroupsJson,
},
30000,
);
// Reload BetterLoot plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload BetterLoot',
timestamp: new Date().toISOString(),
},
);
// Mark this profile as active, deactivate others
await this.lootRepo.update({ license_id: licenseId }, { is_active: false });
await this.lootRepo.update(
{ id: profileId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Profile "${profile.profile_name}" applied with ${multiplier}x multiplier`,
profile_name: profile.profile_name,
multiplier,
};
} catch (error) {
this.logger.error(`Failed to apply loot profile: ${(error as Error).message}`);
throw new HttpException(
'Failed to apply loot profile — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import BetterLoot/Looty JSON as a new profile */
async importProfile(licenseId: string, dto: ImportLootProfileDto) {
const profile = this.lootRepo.create({
license_id: licenseId,
profile_name: dto.profile_name,
description: dto.description || 'Imported profile',
loot_table: dto.loot_table,
loot_groups: dto.loot_groups || {},
});
const saved = await this.lootRepo.save(profile);
return { profile: saved };
}
/** Export profile as BetterLoot-compatible JSON with optional multiplier */
async exportProfile(licenseId: string, profileId: string, multiplier: number) {
const profile = await this.lootRepo.findOne({
where: { id: profileId, license_id: licenseId },
});
if (!profile) throw new NotFoundException('Loot profile not found');
const exportTable = JSON.parse(JSON.stringify(profile.loot_table));
const exportGroups = JSON.parse(JSON.stringify(profile.loot_groups));
if (multiplier && multiplier !== 1) {
this.applyMultiplierToTable(exportTable, multiplier);
this.applyMultiplierToGroups(exportGroups, multiplier);
}
return {
profile_name: profile.profile_name,
multiplier: multiplier || 1,
loot_table: exportTable,
loot_groups: exportGroups,
};
}
/** Get static list of Rust container prefabs */
getContainers() {
return { containers: RUST_CONTAINERS };
}
// --- Multiplier helpers ---
private applyMultiplierToTable(table: Record<string, any>, multiplier: number) {
for (const prefab of Object.values(table)) {
if (prefab?.ItemSettings) {
this.scaleField(prefab.ItemSettings, 'ItemsMin', multiplier);
this.scaleField(prefab.ItemSettings, 'ItemsMax', multiplier);
this.scaleField(prefab.ItemSettings, 'MinScrap', multiplier);
this.scaleField(prefab.ItemSettings, 'MaxScrap', multiplier);
}
if (prefab?.GuaranteedItems) {
this.scaleItems(prefab.GuaranteedItems, multiplier);
}
if (prefab?.UngroupedItems) {
this.scaleItems(prefab.UngroupedItems, multiplier);
}
}
}
private applyMultiplierToGroups(groups: Record<string, any>, multiplier: number) {
for (const group of Object.values(groups)) {
if (group?.GuaranteedItems) {
this.scaleItems(group.GuaranteedItems, multiplier);
}
if (group?.ItemList) {
this.scaleItems(group.ItemList, multiplier);
}
}
}
private scaleItems(items: Record<string, any>, multiplier: number) {
for (const item of Object.values(items)) {
this.scaleField(item, 'Min', multiplier);
this.scaleField(item, 'Max', multiplier);
// Recursively scale bonus items
if (item?.BonusItems) {
this.scaleItems(item.BonusItems, multiplier);
}
}
}
private scaleField(obj: Record<string, any>, field: string, multiplier: number) {
if (typeof obj[field] === 'number') {
obj[field] = Math.round(obj[field] * multiplier);
}
}
}

View File

@@ -1,14 +1,21 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createHash } from 'crypto';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { MapLibrary } from '../../entities/map-library.entity';
import { MapRotation } from '../../entities/map-rotation.entity';
import { UpdateRotationDto } from './dto/update-rotation.dto';
import { UploadMapDto } from './dto/upload-map.dto';
// Docker volume mount point for map storage. Tenant-scoped subdirectory enforces isolation.
const MAP_DATA_ROOT = process.env.MAP_DATA_PATH || '/app/map_data';
@Injectable()
export class MapsService {
private readonly logger = new Logger(MapsService.name);
constructor(
@InjectRepository(MapLibrary)
private readonly mapLibraryRepo: Repository<MapLibrary>,
@@ -22,7 +29,23 @@ export class MapsService {
file: Express.Multer.File,
): Promise<MapLibrary> {
const checksum = createHash('sha256').update(file.buffer).digest('hex');
const storagePath = `/maps/${licenseId}/${Date.now()}_${file.originalname}`;
// Build tenant-scoped storage path: /app/map_data/{licenseId}/{timestamp}_{filename}
const filename = `${Date.now()}_${file.originalname}`;
const tenantDir = join(MAP_DATA_ROOT, licenseId);
const absolutePath = join(tenantDir, filename);
// Relative storage path stored in DB — avoids coupling to the absolute mount point
const storagePath = `/map_data/${licenseId}/${filename}`;
try {
mkdirSync(tenantDir, { recursive: true });
writeFileSync(absolutePath, file.buffer);
this.logger.log(`Map uploaded: ${absolutePath} (${file.size} bytes)`);
} catch (err) {
this.logger.error(`Failed to write map file to disk: ${absolutePath}`, err);
throw new InternalServerErrorException('Failed to save map file to storage');
}
const map = this.mapLibraryRepo.create({
license_id: licenseId,

View File

@@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { PlayersController } from './players.controller';
import { PlayersService } from './players.service';
import { PlayerAction } from '../../entities/player-action.entity';
import { PlayerSession } from '../../entities/player-session.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([PlayerAction])],
imports: [TypeOrmModule.forFeature([PlayerAction, PlayerSession])],
controllers: [PlayersController],
providers: [PlayersService, NatsService],
exports: [PlayersService],

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlayerAction } from '../../entities/player-action.entity';
import { PlayerSession } from '../../entities/player-session.entity';
import { NatsService } from '../../services/nats.service';
import { PlayerActionDto } from './dto/player-action.dto';
@@ -11,6 +12,8 @@ export interface Player {
status: 'online' | 'offline' | 'banned';
last_seen?: Date;
ban_expires?: Date | null;
total_sessions?: number;
total_playtime_seconds?: number;
}
@Injectable()
@@ -18,43 +21,86 @@ export class PlayersService {
constructor(
@InjectRepository(PlayerAction)
private readonly actionRepo: Repository<PlayerAction>,
@InjectRepository(PlayerSession)
private readonly sessionRepo: Repository<PlayerSession>,
private readonly natsService: NatsService,
) {}
/**
* Get recent players for a license
* Get players for a license.
*
* TODO: This needs a player_sessions table to track online/offline status.
* For now, we query player_actions to get a list of players who have had actions.
* Primary source: player_sessions table (tracks session lifecycles).
* Secondary source: player_actions table (determines ban status).
* A player whose most recent action is a 'ban' (not subsequently 'unban') is shown as banned.
* A player with an open session (session_end IS NULL) is shown as online.
*/
async getPlayers(licenseId: string): Promise<{ players: Player[] }> {
const actions = await this.actionRepo
// Get distinct players from session history
const sessions = await this.sessionRepo
.createQueryBuilder('session')
.where('session.license_id = :licenseId', { licenseId })
.orderBy('session.session_start', 'DESC')
.getMany();
// Build per-player session aggregates
const playerMap = new Map<string, Player>();
for (const session of sessions) {
if (!playerMap.has(session.steam_id)) {
const isOnline = session.session_end === null;
playerMap.set(session.steam_id, {
steam_id: session.steam_id,
player_name: session.player_name,
status: isOnline ? 'online' : 'offline',
last_seen: session.session_start,
ban_expires: null,
total_sessions: 0,
total_playtime_seconds: 0,
});
}
const entry = playerMap.get(session.steam_id)!;
entry.total_sessions = (entry.total_sessions || 0) + 1;
entry.total_playtime_seconds = (entry.total_playtime_seconds || 0) + (session.duration_seconds || 0);
}
// Overlay ban status from most recent action per player
const recentActions = await this.actionRepo
.createQueryBuilder('action')
.where('action.license_id = :licenseId', { licenseId })
.orderBy('action.created_at', 'DESC')
.take(100)
.getMany();
// Group by steam_id to get unique players
const playerMap = new Map<string, Player>();
// Track the most recent action per steam_id to determine ban state
const latestActionBySteamId = new Map<string, PlayerAction>();
for (const action of recentActions) {
if (!latestActionBySteamId.has(action.steam_id)) {
latestActionBySteamId.set(action.steam_id, action);
}
}
for (const action of actions) {
if (!playerMap.has(action.steam_id)) {
// Determine status based on latest action
let status: 'online' | 'offline' | 'banned' = 'offline';
if (action.action_type === 'ban') {
status = 'banned';
}
playerMap.set(action.steam_id, {
steam_id: action.steam_id,
player_name: action.player_name,
status,
last_seen: action.created_at,
ban_expires: action.duration_minutes
for (const [steamId, action] of latestActionBySteamId) {
if (action.action_type === 'ban') {
// Add player from actions even if they have no sessions
if (!playerMap.has(steamId)) {
playerMap.set(steamId, {
steam_id: action.steam_id,
player_name: action.player_name,
status: 'banned',
last_seen: action.created_at,
ban_expires: action.duration_minutes
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
: null,
total_sessions: 0,
total_playtime_seconds: 0,
});
} else {
const entry = playerMap.get(steamId)!;
entry.status = 'banned';
entry.ban_expires = action.duration_minutes
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
: null,
});
: null;
}
}
}
@@ -64,7 +110,9 @@ export class PlayersService {
}
/**
* Perform a moderation action on a player
* Perform a moderation action on a player.
* Supported actions: kick, ban, unban, warn, note.
* kick/ban/unban are forwarded to the game server via NATS.
*/
async performAction(
licenseId: string,
@@ -84,8 +132,8 @@ export class PlayersService {
await this.actionRepo.save(action);
// For kick/ban, send NATS command to the server
if (dto.action_type === 'kick' || dto.action_type === 'ban') {
// Forward kick, ban, and unban to the game server via NATS
if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
await this.natsService.sendServerCommand(licenseId, dto.action_type, {
steam_id: dto.steam_id,
reason: dto.reason,

View File

@@ -1,5 +1,19 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery, ApiConsumes } from '@nestjs/swagger';
import { PluginsService } from './plugins.service';
import { InstallPluginDto } from './dto/install-plugin.dto';
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
@@ -57,9 +71,38 @@ export class PluginsController {
@Get('search')
@RequirePermission('plugin.view')
@ApiOperation({ summary: 'Search uMod plugin directory' })
@ApiOperation({ summary: 'Search uMod plugin directory (legacy stub)' })
@ApiQuery({ name: 'q', required: true, example: 'kits' })
searchUmod(@Query('q') query: string) {
return this.pluginsService.searchUmod(query);
}
@Get('browse')
@RequirePermission('plugin.view')
@ApiOperation({ summary: 'Browse uMod plugin directory (proxied)' })
@ApiQuery({ name: 'query', required: false, example: 'vanish' })
@ApiQuery({ name: 'page', required: false, example: 1 })
@ApiQuery({ name: 'sort', required: false, example: 'downloads' })
browseUmod(
@Query('query') query: string,
@Query('page') page: string,
@Query('sort') sort: string,
) {
return this.pluginsService.browseUmod(query, page ? parseInt(page, 10) : 1, sort);
}
@Post('upload')
@RequirePermission('plugin.manage')
@ApiOperation({ summary: 'Upload a custom .cs plugin file' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file'))
uploadPlugin(
@CurrentTenant() licenseId: string,
@UploadedFile() file: Express.Multer.File,
) {
if (!file) {
throw new BadRequestException('No file provided');
}
return this.pluginsService.uploadPlugin(licenseId, file);
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PluginRegistry } from '../../entities/plugin-registry.entity';
@@ -6,9 +6,16 @@ import { InstallPluginDto } from './dto/install-plugin.dto';
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
import { NatsService } from '../../services/nats.service';
interface UmodCacheEntry {
data: unknown;
timestamp: number;
}
@Injectable()
export class PluginsService {
private readonly logger = new Logger(PluginsService.name);
private readonly umodCache = new Map<string, UmodCacheEntry>();
private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
constructor(
@InjectRepository(PluginRegistry)
@@ -45,7 +52,21 @@ export class PluginsService {
is_loaded: false,
});
return this.pluginRegistryRepo.save(plugin);
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> {
@@ -108,10 +129,107 @@ export class PluginsService {
}
async searchUmod(query: string): Promise<{ results: any[]; message: string }> {
// uMod API integration pending
// Legacy stub — use browseUmod via GET /plugins/browse for real results
return {
results: [],
message: 'uMod search integration not yet configured',
message: 'Use GET /plugins/browse for uMod search',
};
}
async browseUmod(query: string, page = 1, sort = 'downloads'): Promise<unknown> {
const cacheKey = `${query ?? ''}:${page}:${sort}`;
const cached = this.umodCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) {
this.logger.debug(`uMod cache hit for key "${cacheKey}"`);
return cached.data;
}
const params = new URLSearchParams({
page: String(page),
sort,
sortdir: 'desc',
'categories[]': 'rust',
});
if (query?.trim()) {
params.set('query', query.trim());
}
const url = `https://umod.org/plugins/search.json?${params.toString()}`;
try {
const response = await fetch(url, {
headers: { 'User-Agent': 'CorrosionPanel/1.0' },
signal: AbortSignal.timeout(8000),
});
if (!response.ok) {
this.logger.warn(`uMod API returned ${response.status} for query "${query}"`);
return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 };
}
const data = await response.json();
this.umodCache.set(cacheKey, { data, timestamp: Date.now() });
this.logger.log(`uMod browse: query="${query}" page=${page} sort=${sort}${(data as any)?.total ?? '?'} results`);
return data;
} catch (err) {
this.logger.error(`uMod browse failed for query "${query}": ${(err as Error).message}`);
return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 };
}
}
async uploadPlugin(licenseId: string, file: Express.Multer.File): Promise<PluginRegistry> {
// Validate extension
const originalName = file.originalname ?? '';
if (!originalName.toLowerCase().endsWith('.cs')) {
throw new BadRequestException('Only .cs plugin files are accepted');
}
// Validate size (5 MB)
const MAX_BYTES = 5 * 1024 * 1024;
if (file.size > MAX_BYTES) {
throw new BadRequestException('Plugin file exceeds the 5 MB limit');
}
// Derive plugin name from filename (strip .cs)
const pluginName = originalName.replace(/\.cs$/i, '');
// Check for duplicate
const existing = await this.pluginRegistryRepo.findOne({
where: { license_id: licenseId, plugin_name: pluginName },
});
if (existing) {
throw new ConflictException(`Plugin "${pluginName}" is already installed`);
}
// Persist record
const plugin = this.pluginRegistryRepo.create({
license_id: licenseId,
plugin_name: pluginName,
source: 'manual',
is_installed: true,
is_loaded: false,
});
const saved = await this.pluginRegistryRepo.save(plugin);
// Dispatch to companion agent via NATS
try {
const content = file.buffer.toString('base64');
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
action: 'plugin_upload',
filename: originalName,
content,
timestamp: new Date().toISOString(),
});
this.logger.log(`Plugin upload dispatched: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
} catch (err) {
this.logger.error(`NATS publish 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
}
return saved;
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateRaidableBasesConfigDto {
@ApiProperty({ example: 'Default RaidableBases Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard RaidableBases settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportRaidableBasesConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateRaidableBasesConfigDto {
@ApiPropertyOptional({ example: 'Updated Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { RaidableBasesService } from './raidablebases.service';
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
import { ImportRaidableBasesConfigDto } from './dto/import-raidablebases-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('raidablebases')
@ApiBearerAuth()
@Controller('raidablebases')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class RaidableBasesController {
constructor(private readonly raidableBasesService: RaidableBasesService) {}
@Get('configs')
@RequirePermission('raidablebases.view')
@ApiOperation({ summary: 'List RaidableBases configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.raidableBasesService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('raidablebases.view')
@ApiOperation({ summary: 'Get full RaidableBases config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.raidableBasesService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Create RaidableBases config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateRaidableBasesConfigDto) {
return this.raidableBasesService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Update RaidableBases config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateRaidableBasesConfigDto,
) {
return this.raidableBasesService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Delete RaidableBases config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.raidableBasesService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Deploy RaidableBases config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.raidableBasesService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Import RaidableBases.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportRaidableBasesConfigDto) {
return this.raidableBasesService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RaidableBasesController } from './raidablebases.controller';
import { RaidableBasesService } from './raidablebases.service';
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([RaidableBasesConfig])],
controllers: [RaidableBasesController],
providers: [RaidableBasesService, NatsService],
exports: [RaidableBasesService],
})
export class RaidableBasesModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
@Injectable()
export class RaidableBasesService {
private readonly logger = new Logger(RaidableBasesService.name);
constructor(
@InjectRepository(RaidableBasesConfig)
private readonly raidableBasesRepo: Repository<RaidableBasesConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.raidableBasesRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateRaidableBasesConfigDto) {
const config = this.raidableBasesRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.raidableBasesRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateRaidableBasesConfigDto) {
const config = await this.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.raidableBasesRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.raidableBasesRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('RaidableBases config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write RaidableBases.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/RaidableBases.json',
content: jsonString,
},
30000,
);
// Reload RaidableBases plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload RaidableBases',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false });
await this.raidableBasesRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy RaidableBases config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy RaidableBases config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import RaidableBases.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read RaidableBases.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/RaidableBases.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new RaidableBases config row
const config = this.raidableBasesRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.raidableBasesRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import RaidableBases config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import RaidableBases config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { SchedulesController } from './schedules.controller';
import { SchedulesService } from './schedules.service';
import { ScheduledTask } from '../../entities/scheduled-task.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([ScheduledTask])],
controllers: [SchedulesController],
providers: [SchedulesService],
providers: [SchedulesService, NatsService],
exports: [SchedulesService],
})
export class SchedulesModule {}

View File

@@ -1,21 +1,220 @@
import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { LessThanOrEqual, Repository } from 'typeorm';
import { ScheduledTask } from '../../entities/scheduled-task.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { NatsService } from '../../services/nats.service';
/** Parse a 5-field cron expression and return the next Date after `after`. */
function nextCronDate(expr: string, after: Date): Date | null {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
function matches(expr: string, value: number): boolean {
if (expr === '*') return true;
return parseInt(expr, 10) === value;
}
// Walk minute-by-minute up to 366 days forward to find next match.
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
candidate.setSeconds(0, 0);
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
while (candidate < limit) {
const min = candidate.getUTCMinutes();
const hour = candidate.getUTCHours();
const dom = candidate.getUTCDate();
const month = candidate.getUTCMonth() + 1; // 1-12
const dow = candidate.getUTCDay(); // 0=Sun
if (
matches(minuteExpr, min) &&
matches(hourExpr, hour) &&
matches(domExpr, dom) &&
matches(monthExpr, month) &&
matches(dowExpr, dow)
) {
return candidate;
}
candidate.setTime(candidate.getTime() + 60_000);
}
return null;
}
@Injectable()
export class SchedulesService {
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SchedulesService.name);
private executorInterval: ReturnType<typeof setInterval> | null = null;
constructor(
@InjectRepository(ScheduledTask)
private taskRepository: Repository<ScheduledTask>,
private readonly natsService: NatsService,
) {}
// ---------------------------------------------------------------------------
// Lifecycle hooks
// ---------------------------------------------------------------------------
onModuleInit() {
// Bootstrap: calculate next_run for any task that has none.
this.bootstrapNextRuns().catch(err =>
this.logger.error('Failed to bootstrap next_run values', err),
);
// Poll every 60 seconds for due tasks.
this.executorInterval = setInterval(() => {
this.executeDueTasks().catch(err =>
this.logger.error('Schedule executor error', err),
);
}, 60_000);
this.logger.log('Schedule executor started (60s polling interval)');
}
onModuleDestroy() {
if (this.executorInterval) {
clearInterval(this.executorInterval);
this.executorInterval = null;
}
}
// ---------------------------------------------------------------------------
// Execution engine
// ---------------------------------------------------------------------------
/** On startup, stamp next_run on tasks that don't have one yet. */
private async bootstrapNextRuns(): Promise<void> {
const tasks = await this.taskRepository.find({
where: { is_active: true, next_run: null as any },
});
for (const task of tasks) {
const next = nextCronDate(task.cron_expression, new Date());
if (next) {
task.next_run = next;
await this.taskRepository.save(task);
}
}
if (tasks.length > 0) {
this.logger.log(`Bootstrapped next_run for ${tasks.length} task(s)`);
}
}
/** Find all active tasks whose next_run <= now and fire them. */
private async executeDueTasks(): Promise<void> {
const now = new Date();
const dueTasks = await this.taskRepository.find({
where: {
is_active: true,
next_run: LessThanOrEqual(now),
},
});
if (dueTasks.length === 0) return;
this.logger.log(`Executing ${dueTasks.length} due task(s)`);
for (const task of dueTasks) {
try {
await this.executeTask(task);
// Advance next_run.
const next = nextCronDate(task.cron_expression, now);
task.next_run = next ?? null;
await this.taskRepository.save(task);
} catch (err) {
this.logger.error(
`Failed to execute task ${task.id} (${task.task_name})`,
(err as Error).stack,
);
// Still advance next_run so we don't hammer on a broken task.
const next = nextCronDate(task.cron_expression, now);
task.next_run = next ?? null;
await this.taskRepository.save(task);
}
}
}
/** Dispatch a single task via NATS based on its task_type. */
private async executeTask(task: ScheduledTask): Promise<void> {
const { license_id, task_type, task_name, task_config } = task;
this.logger.log(
`Firing task: [${task_type}] "${task_name}" for license ${license_id}`,
);
switch (task_type) {
case 'restart':
await this.natsService.sendServerCommand(license_id, 'restart', {
source: 'scheduler',
task_id: task.id,
});
break;
case 'announcement': {
const message = (task_config?.message as string) ?? 'Scheduled announcement';
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
action: 'command',
command: `say ${message}`,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break;
}
case 'command': {
const command = (task_config?.command as string) ?? '';
if (!command) {
this.logger.warn(`Task ${task.id} has no command configured — skipping`);
return;
}
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
action: 'command',
command,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break;
}
case 'plugin_reload': {
const plugin_name = (task_config?.plugin_name as string) ?? '';
await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, {
action: 'reload',
plugin_name,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break;
}
default:
this.logger.warn(`Unknown task_type "${task_type}" for task ${task.id}`);
}
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
async getTasks(licenseId: string): Promise<ScheduledTask[]> {
return await this.taskRepository.find({
where: { license_id: licenseId },
@@ -27,31 +226,23 @@ export class SchedulesService {
licenseId: string,
dto: CreateTaskDto,
): Promise<ScheduledTask> {
// Validate cron expression is parseable
// In production, you'd use a cron parser library to validate
// For now, we rely on the regex in the DTO
// Set default timezone if not provided
const timezone = dto.timezone || 'UTC';
const now = new Date();
const next = nextCronDate(dto.cron_expression, now);
const task = this.taskRepository.create({
license_id: licenseId,
task_type: dto.task_type,
task_name: dto.task_name,
cron_expression: dto.cron_expression,
timezone: timezone,
timezone,
task_config: dto.task_config || {},
is_active: true,
next_run: null, // Would be calculated by scheduler
created_at: new Date(),
next_run: next ?? null,
created_at: now,
});
const saved = await this.taskRepository.save(task);
// TODO: Register task with scheduler (tokio-cron-scheduler in Rust)
// This would send a NATS message to the scheduler service to register the task
return saved;
return await this.taskRepository.save(task);
}
async updateTask(
@@ -70,15 +261,15 @@ export class SchedulesService {
throw new NotFoundException(`Scheduled task ${taskId} not found`);
}
// Update fields
Object.assign(task, dto);
const updated = await this.taskRepository.save(task);
// Recalculate next_run if the cron expression changed.
if (dto.cron_expression) {
const next = nextCronDate(dto.cron_expression, new Date());
task.next_run = next ?? null;
}
// TODO: Update task registration with scheduler
// Send NATS message to update the task in tokio-cron-scheduler
return updated;
return await this.taskRepository.save(task);
}
async deleteTask(licenseId: string, taskId: string) {
@@ -94,10 +285,6 @@ export class SchedulesService {
}
await this.taskRepository.delete(taskId);
// TODO: Unregister task from scheduler
// Send NATS message to remove the task from tokio-cron-scheduler
return { deleted: true };
}
@@ -114,11 +301,13 @@ export class SchedulesService {
}
task.is_active = enabled;
const updated = await this.taskRepository.save(task);
// TODO: Enable/disable task in scheduler
// Send NATS message to pause or resume the task
// When re-enabling, calculate next_run if it's missing.
if (enabled && !task.next_run) {
const next = nextCronDate(task.cron_expression, new Date());
task.next_run = next ?? null;
}
return updated;
return await this.taskRepository.save(task);
}
}

View File

@@ -0,0 +1,41 @@
import { IsString, IsInt, Min, Max, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class DeployServerDto {
@ApiProperty({ example: 'My Rust Server', description: 'Server hostname' })
@IsString()
server_name: string;
@ApiProperty({ example: 100, description: 'Maximum player slots' })
@IsInt()
@Min(1)
@Max(500)
max_players: number;
@ApiProperty({ example: 4000, description: 'World size (1000-8000)' })
@IsInt()
@Min(1000)
@Max(8000)
world_size: number;
@ApiProperty({ example: 12345, description: 'Map seed' })
@IsInt()
seed: number;
@ApiProperty({ example: 28015, description: 'Server game port' })
@IsInt()
@Min(1024)
@Max(65535)
server_port: number;
@ApiProperty({ example: 28016, description: 'RCON port' })
@IsInt()
@Min(1024)
@Max(65535)
rcon_port: number;
@ApiProperty({ example: 'changeme', description: 'RCON password (min 6 chars)' })
@IsString()
@MinLength(6)
rcon_password: string;
}

View File

@@ -3,6 +3,7 @@ import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { ServersService } from './servers.service';
import { UpdateServerConfigDto } from './dto/update-config.dto';
import { SendCommandDto } from './dto/send-command.dto';
import { DeployServerDto } from './dto/deploy-server.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -62,4 +63,21 @@ export class ServersController {
async restartServer(@CurrentTenant() licenseId: string) {
return await this.serversService.restartServer(licenseId);
}
@Post('deploy')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Deploy Rust server via companion agent' })
async deployServer(
@CurrentTenant() licenseId: string,
@Body() dto: DeployServerDto,
) {
return await this.serversService.deployServer(licenseId, dto);
}
@Post('install-oxide')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Install Oxide/uMod via companion agent' })
async installOxide(@CurrentTenant() licenseId: string) {
return await this.serversService.installOxide(licenseId);
}
}

View File

@@ -1,13 +1,16 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerConfig } from '../../entities/server-config.entity';
import { NatsService } from '../../services/nats.service';
import { UpdateServerConfigDto } from './dto/update-config.dto';
import { DeployServerDto } from './dto/deploy-server.dto';
@Injectable()
export class ServersService {
private readonly logger = new Logger(ServersService.name);
constructor(
@InjectRepository(ServerConnection)
private readonly connectionRepo: Repository<ServerConnection>,
@@ -59,8 +62,14 @@ export class ServersService {
* Send a console command to the server via NATS
*/
async sendCommand(licenseId: string, command: string) {
await this.natsService.sendServerCommand(licenseId, 'command', { command });
return { output: 'Command sent' };
try {
await this.natsService.sendServerCommand(licenseId, 'command', { command });
this.logger.log(`Console command dispatched for license ${licenseId}: ${command}`);
} catch (err) {
this.logger.error(`Failed to dispatch console command for license ${licenseId}: ${(err as Error).message}`);
throw new InternalServerErrorException('Failed to dispatch command to server');
}
return { success: true, message: 'Command dispatched' };
}
/**
@@ -86,4 +95,20 @@ export class ServersService {
await this.natsService.sendServerCommand(licenseId, 'restart');
return { message: 'Restart command sent' };
}
/**
* Deploy Rust server via companion agent
*/
async deployServer(licenseId: string, dto: DeployServerDto) {
await this.natsService.sendDeployCommand(licenseId, { ...dto });
return { message: 'Deployment started' };
}
/**
* Install Oxide/uMod via companion agent
*/
async installOxide(licenseId: string) {
await this.natsService.sendOxideInstallCommand(licenseId);
return { message: 'Oxide installation started' };
}
}

View File

@@ -4,11 +4,12 @@ import { StatusController } from './status.controller';
import { StatusService } from './status.service';
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerStats } from '../../entities/server-stats.entity';
import { License } from '../../entities/license.entity';
@Module({
imports: [
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, License]),
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, ServerStats, License]),
],
controllers: [StatusController],
providers: [StatusService],

View File

@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerStats } from '../../entities/server-stats.entity';
import { License } from '../../entities/license.entity';
@Injectable()
@@ -12,6 +13,8 @@ export class StatusService {
private readonly publicSiteRepo: Repository<PublicSiteConfig>,
@InjectRepository(ServerConnection)
private readonly serverConnectionRepo: Repository<ServerConnection>,
@InjectRepository(ServerStats)
private readonly serverStatsRepo: Repository<ServerStats>,
@InjectRepository(License)
private readonly licenseRepo: Repository<License>,
) {}
@@ -32,12 +35,18 @@ export class StatusService {
where: { license_id: config.license_id },
});
// Fetch the most recent stats row for this server to get live player counts
const latestStats = await this.serverStatsRepo.findOne({
where: { license_id: config.license_id },
order: { recorded_at: 'DESC' },
});
return {
server_name: license?.subdomain || 'Unknown Server',
server_name: license?.server_name || license?.subdomain || 'Unknown Server',
subdomain: license?.subdomain || null,
status: connection?.connection_status || 'offline',
player_count: 0, // Would need real-time data
max_players: 0,
player_count: latestStats?.player_count ?? 0,
max_players: latestStats?.max_players ?? 0,
steam_connect_url: config.steam_connect_url,
motd: config.motd,
discord_invite_url: config.discord_invite_url,

View File

@@ -4,9 +4,10 @@ import { StoreController } from './store.controller';
import { StoreService } from './store.service';
import { Module as ModuleEntity } from '../../entities/module.entity';
import { ModulePurchase } from '../../entities/module-purchase.entity';
import { ModuleInstallation } from '../../entities/module-installation.entity';
@Module({
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase])],
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase, ModuleInstallation])],
controllers: [StoreController],
providers: [StoreService],
exports: [StoreService],

View File

@@ -1,16 +1,21 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Module } from '../../entities/module.entity';
import { ModulePurchase } from '../../entities/module-purchase.entity';
import { ModuleInstallation } from '../../entities/module-installation.entity';
@Injectable()
export class StoreService {
private readonly logger = new Logger(StoreService.name);
constructor(
@InjectRepository(Module)
private readonly moduleRepo: Repository<Module>,
@InjectRepository(ModulePurchase)
private readonly purchaseRepo: Repository<ModulePurchase>,
@InjectRepository(ModuleInstallation)
private readonly installationRepo: Repository<ModuleInstallation>,
) {}
async getCatalog(): Promise<Module[]> {
@@ -26,14 +31,19 @@ export class StoreService {
order: { purchased_at: 'DESC' },
});
const installations = await this.installationRepo.find({
where: { license_id: licenseId },
relations: ['module'],
});
return {
purchased: purchases,
installed: purchases.filter(p => p.module), // Stub - would need module_installations table
installed: installations,
};
}
async purchaseModule(licenseId: string, moduleId: string): Promise<ModulePurchase> {
// Check if already purchased
// Check if already purchased.
const existing = await this.purchaseRepo.findOne({
where: { license_id: licenseId, module_id: moduleId },
});
@@ -50,15 +60,15 @@ export class StoreService {
const purchase = this.purchaseRepo.create({
license_id: licenseId,
module_id: moduleId,
transaction_id: `txn_${Date.now()}`, // Stub
transaction_id: `txn_${Date.now()}`,
amount_paid: parseFloat(module.price_usd.toString()),
});
return this.purchaseRepo.save(purchase);
}
async installModule(licenseId: string, moduleId: string) {
// Verify purchase exists
async installModule(licenseId: string, moduleId: string): Promise<ModuleInstallation> {
// Verify purchase exists.
const purchase = await this.purchaseRepo.findOne({
where: { license_id: licenseId, module_id: moduleId },
});
@@ -67,11 +77,44 @@ export class StoreService {
throw new ForbiddenException('Module not purchased');
}
// Stub - would create module_installation record
return {
message: 'Module installed successfully',
// Verify module exists.
const module = await this.moduleRepo.findOne({ where: { id: moduleId } });
if (!module) {
throw new NotFoundException('Module not found');
}
// Idempotent: return existing installation record if one already exists.
const existing = await this.installationRepo.findOne({
where: { license_id: licenseId, module_id: moduleId },
});
if (existing) {
// If previously failed, reset to pending so it can be retried.
if (existing.status === 'failed') {
existing.status = 'installed';
existing.installed_at = new Date();
existing.error_message = null;
return this.installationRepo.save(existing);
}
return existing;
}
// Create installation record and mark as installed.
// In a full implementation this would trigger an async deployment pipeline.
const installation = this.installationRepo.create({
license_id: licenseId,
module_id: moduleId,
status: 'installed',
};
installed_at: new Date(),
error_message: null,
});
const saved = await this.installationRepo.save(installation);
this.logger.log(
`Module installed: ${module.name ?? moduleId} for license ${licenseId} (installation id: ${saved.id})`,
);
return saved;
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTeleportConfigDto {
@ApiProperty({ example: 'Default Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard NTeleportation settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportTeleportConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateTeleportConfigDto {
@ApiPropertyOptional({ example: 'Updated Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeleportService } from './teleport.service';
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
import { ImportTeleportConfigDto } from './dto/import-teleport-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('teleport')
@ApiBearerAuth()
@Controller('teleport')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class TeleportController {
constructor(private readonly teleportService: TeleportService) {}
@Get('configs')
@RequirePermission('teleport.view')
@ApiOperation({ summary: 'List teleport configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.teleportService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('teleport.view')
@ApiOperation({ summary: 'Get full teleport config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Create teleport config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTeleportConfigDto) {
return this.teleportService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Update teleport config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateTeleportConfigDto,
) {
return this.teleportService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Delete teleport config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Deploy teleport config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Import NTeleportation.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTeleportConfigDto) {
return this.teleportService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TeleportController } from './teleport.controller';
import { TeleportService } from './teleport.service';
import { TeleportConfig } from '../../entities/teleport-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([TeleportConfig])],
controllers: [TeleportController],
providers: [TeleportService, NatsService],
exports: [TeleportService],
})
export class TeleportModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TeleportConfig } from '../../entities/teleport-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
@Injectable()
export class TeleportService {
private readonly logger = new Logger(TeleportService.name);
constructor(
@InjectRepository(TeleportConfig)
private readonly teleportRepo: Repository<TeleportConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.teleportRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateTeleportConfigDto) {
const config = this.teleportRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.teleportRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateTeleportConfigDto) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.teleportRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.teleportRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Teleport config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write NTeleportation.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/NTeleportation.json',
content: jsonString,
},
30000,
);
// Reload NTeleportation plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload NTeleportation',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false });
await this.teleportRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy teleport config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy teleport config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import NTeleportation.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read NTeleportation.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/NTeleportation.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new teleport config row
const config = this.teleportRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.teleportRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import teleport config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import teleport config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTimedExecuteConfigDto {
@ApiProperty({ example: 'Default Timer Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard TimedExecute settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportTimedExecuteConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateTimedExecuteConfigDto {
@ApiPropertyOptional({ example: 'Updated Timer Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TimedExecuteService } from './timedexecute.service';
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
import { ImportTimedExecuteConfigDto } from './dto/import-timedexecute-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('timedexecute')
@ApiBearerAuth()
@Controller('timedexecute')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class TimedExecuteController {
constructor(private readonly timedExecuteService: TimedExecuteService) {}
@Get('configs')
@RequirePermission('timedexecute.view')
@ApiOperation({ summary: 'List TimedExecute configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.timedExecuteService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('timedexecute.view')
@ApiOperation({ summary: 'Get full TimedExecute config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.timedExecuteService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Create TimedExecute config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTimedExecuteConfigDto) {
return this.timedExecuteService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Update TimedExecute config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateTimedExecuteConfigDto,
) {
return this.timedExecuteService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Delete TimedExecute config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.timedExecuteService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Deploy TimedExecute config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.timedExecuteService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Import TimedExecute.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTimedExecuteConfigDto) {
return this.timedExecuteService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TimedExecuteController } from './timedexecute.controller';
import { TimedExecuteService } from './timedexecute.service';
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([TimedExecuteConfig])],
controllers: [TimedExecuteController],
providers: [TimedExecuteService, NatsService],
exports: [TimedExecuteService],
})
export class TimedExecuteModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
@Injectable()
export class TimedExecuteService {
private readonly logger = new Logger(TimedExecuteService.name);
constructor(
@InjectRepository(TimedExecuteConfig)
private readonly repo: Repository<TimedExecuteConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.repo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('TimedExecute config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateTimedExecuteConfigDto) {
const config = this.repo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.repo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateTimedExecuteConfigDto) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('TimedExecute config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.repo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.repo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('TimedExecute config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('TimedExecute config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write TimedExecute.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/TimedExecute.json',
content: jsonString,
},
30000,
);
// Reload TimedExecute plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload TimedExecute',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.repo.update({ license_id: licenseId }, { is_active: false });
await this.repo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy TimedExecute config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy TimedExecute config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import TimedExecute.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read TimedExecute.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/TimedExecute.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new config row
const config = this.repo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.repo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import TimedExecute config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import TimedExecute config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

Some files were not shown because too many files have changed in this diff Show More