74 Commits

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

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

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

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

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

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

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

Frontend build (vue-tsc + vite) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:06:10 -04:00
Vantz Stockwell
f2ea415840 fix(api): Beta hardening — real 500 fix, encryption guard, honest payments
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 1m36s
CI / integration (push) Successful in 23s
- analytics: getMapAnalytics queried map.name but the map_library column is
  display_name (no name column) — every map-analytics call 500'd. Fixed select
  + groupBy to map.display_name.
- setup: guard ENCRYPTION_KEY length before AES-256-GCM createCipheriv — an
  unset key crashed bare-metal setup with an opaque 'Invalid key length' 500;
  now returns a clear 503. Also stop falsely marking bare-metal connected on
  completeSetup; leave offline until the agent's first heartbeat.
- webstore: public checkout returned a FAKE PayPal order token + sandbox URL
  that resolves to nowhere. Refuse honestly with 503 (payments coming soon)
  instead of faking a transaction.
- store: module purchase wrote a fake txn_<ts> implying a charge; record it
  honestly as a free Beta grant (transaction_id=beta-free-grant, amount 0).

Backend tsc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:53:22 -04:00
Vantz Stockwell
d13f2cb8b1 feat(host-agent): Phase 2 — Dune docker-compose adapter via Supervisor trait
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 35s
CI / integration (push) Has been skipped
Build Host Agent (Rust) / build (push) Successful in 1m45s
Introduce a Supervisor trait (async-trait) so the agent manages games with
different models behind one wire contract. ProcessSupervisor (spawned process:
rust/conan/soulmask) and the new DockerComposeSupervisor (dune) both impl it;
Agent.supervisors is now HashMap<String, Arc<dyn Supervisor>> and instancecmd
dispatch is game-agnostic — start/stop/restart/status identical across games,
selected by a per-game factory in main. InstanceState moved to the shared
supervisor module.

DockerComposeSupervisor drives docker-compose up-d / stop / restart against
the instance's compose project, with -f/-p/single-service support and a
configurable compose binary. New [instance.docker_compose] config block.
First cut = lifecycle + cached state; container crash-detection + restart
adoption deferred to Phase 3b (reconcilable with a compose ps probe).

Trait choice (dyn over enum) per Commander: scales to future planes (kubectl,
AMP/podman, SSH) as new struct+impl, no central match.

56 tests green (6 new docker-compose mock-binary tests + 5 refactored process
tests), zero warnings. Live verification pending a real Dune stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:33:00 -04:00
Vantz Stockwell
651a35d4be docs(reference): import Dune: Awakening server-manager references
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 22s
Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.

- icehunter/    dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
                closest analog to our agent's Dune docker control plane (compose
                lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/  Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
                daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
                Hyper-V self-host path + game-config schema

See docs/reference-repos/README.md for the full index + how we use each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:08:05 -04:00
Vantz Stockwell
0715492ddf chore(panel): fleet-aware shell footer + drop dead vuefinder dep
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 14s
CI / agent-tests (push) Successful in 49s
CI / integration (push) Successful in 22s
COA-B cleanup:
- Sidebar agent-health footer now reads the fleet store (host count / online
  count / per-host status + last heartbeat) instead of the single legacy
  server.connection row, which disagreed with the multi-host fleet. Removed the
  legacy useServerStore dependency from the shell.
- Removed the unused 'vuefinder' dependency (replaced by the native file
  manager): dep + main.ts plugin/CSS registration. Main JS chunk 588kB -> 165kB.

Recon reclassified the 'dead cmd.server v1' item: it is the LIVE license-level
command path (module config applies, plugin install, schedules, legacy
start/stop) served only by the Go agent — a Rust-agent parity gap, not dead
code. Left intact.

Build-green (vue-tsc) + boots clean in-browser (0 console errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:04:09 -04:00
Vantz Stockwell
4ef5db5b0d feat(panel): drive active game from deployed fleet instances
All checks were successful
CI / backend-types (push) Successful in 8s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 40s
CI / integration (push) Successful in 23s
The shell skin / sidebar nav / dashboard terminology now follow the games
actually deployed (game_instances.game, agent-reported) instead of a
localStorage-only toggle. syncActiveGameFromFleet() derives: one game ->
auto-skin to it; zero/multiple -> 'all' neutral. A manual GameSwitcher pick
persists and overrides the heuristic. Wired into DashboardLayout via a watch
on the fleet store.

No schema change: a license's games are the distinct games of its instances
(the normalized source of truth) — deliberately not duplicating into a
licenses.game column that would drift (Lesson 20).

Build-green (vue-tsc) + boots clean in-browser (0 console errors, theming
initializes). Authenticated auto-derive confirms live on next instance deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:51:36 -04:00
Vantz Stockwell
bb71763714 docs: Lesson 28 — base64-encode multi-line CI secrets (minisign signing key)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 21s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:38:56 -04:00
Vantz Stockwell
f18b45e3f2 fix(ci): base64-decode minisign secret key (CI mangles multi-line); bump alpha.8
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 1m30s
CI / integration (push) Failing after 13s
Build Host Agent (Rust) / build (push) Successful in 1m45s
The 'Sign artifacts' step failed on alpha.7 with 'Error while loading the
secret key file' (exit 2): minisign downloaded and ran, but the reconstructed
key file was unparseable. A minisign secret key is two lines (comment + base64
blob); Gitea/act_runner secret storage mangles the embedded newline, collapsing
it to one line. Decode the secret as base64 (single-line, mangling-proof) with
auto-detect fallback to a raw two-line key. Fails loudly with the fix command
if the secret is neither form.

Requires re-storing MINISIGN_SECRET_KEY as: base64 < secret.key | tr -d '\n'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:31:48 -04:00
Vantz Stockwell
702de24e28 fix(ci): fetch minisign static binary (not in bullseye apt); bump alpha.7
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 43s
Build Host Agent (Rust) / build (push) Failing after 1m33s
CI / integration (push) Successful in 22s
alpha.6 signing failed: 'E: Unable to locate package minisign' —
minisign isn't packaged for node:20-bullseye. Download the official
static linux binary instead. Forward to alpha.7 (alpha.6 published
nothing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:18:08 -04:00
Vantz Stockwell
6b3e805ac2 feat(host-agent): Phase 3a signed self-update (minisign) + CI signing gate
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 1m27s
CI / integration (push) Successful in 21s
Build Host Agent (Rust) / build (push) Failing after 1m33s
Agent only ever runs a binary whose minisign signature verifies against
the EMBEDDED public key. NATS host.cmd func 'update' {url}: download
binary + .minisig from the CDN -> verify against embedded pubkey ->
atomic swap (.old rollback) -> relaunch. URL allowlist (https + cdn.
corrosionmgmt.com only, rejects userinfo-bypass), 100MiB cap. Closes the
supply-chain hole: even a malicious CDN upload can't run unsigned.

CI: build-host-agent.yml signs every artifact with MINISIGN_SECRET_KEY
(Gitea secret) and publishes .minisig alongside; the step FAILS the
build if the secret is absent (refuses to ship unsigned). Bumped to
alpha.6.

6 deterministic tests (accept valid / reject tampered+garbage+empty sig,
URL allowlist incl userinfo-bypass, atomic swap+rollback). Fixtures
signed with the real release key so tests need no key at runtime. Full
suite 50/50 green; musl + native build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:00:36 -04:00
Vantz Stockwell
7c84912ff5 chore(frontend): bump version 1.0.0 -> 1.0.1
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 50s
CI / integration (push) Successful in 28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:38:52 -04:00
Vantz Stockwell
355a53f6e3 feat(files): native instance-scoped file browser (replaces broken VueFinder)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 42s
CI / integration (push) Successful in 22s
FileManagerView rewritten as a native DS browser on the per-instance
file bridge: instance selector, breadcrumb nav, dir-first listing
(name/size/modified), folder drill-down, inline file editor (read/save),
toolbar (new folder/file/refresh), per-row rename + delete-confirm.
New files store wraps the /instances/:id/files* endpoints. VueFinder
import + RemoteDriver fully removed — no more retired-protocol /api/files.
Honest empty (no instance -> Server page) + error (retry) states, never
the global error boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:31:01 -04:00
Vantz Stockwell
589516a021 feat(api): complete per-instance file op-set (delete/rename/mkdir/mkfile/move/copy)
All checks were successful
CI / backend-types (push) Successful in 8s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 54s
CI / integration (push) Successful in 25s
Rounds out the per-instance file bridge to the agent's full jailed file
manager so a real file browser can be built on it: POST :id/files/
{delete,rename,mkdir,mkfile,move,copy}, all via requestScoped (license-
scoped reply) on the new agent {op,path} protocol. files.manage. The
broken legacy VueFinder /api/files (retired Go fm_* protocol, wrong
subject, default _INBOX) is superseded by this — frontend rewrite next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:24:31 -04:00
Vantz Stockwell
f60e6abd33 feat(server): config file editor — read/edit/save a host config file per instance
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 44s
CI / integration (push) Successful in 21s
The Server page's config-honesty note now leads somewhere real: a
Configuration file panel that loads a config file from the instance
(prefilled with the game's primaryConfigFile hint — server.cfg,
ServerSettings.ini, GameXishu.json), edits it in a mono textarea, and
saves it straight to the host through the jailed agent file bridge.
Not-found is handled gracefully (empty editor to create). Works across
games; gameProfiles gains primaryConfigFile per game.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:07:59 -04:00
Vantz Stockwell
877fadcb6c feat(api): per-instance file bridge — list/read/write via the new agent file manager
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 44s
CI / integration (push) Successful in 21s
GET /api/instances/:id/files (list) + /file (read), PUT /file (write) —
tenant-guarded, routed through requestScoped to the per-instance
corrosion.{license}.{instance}.files.cmd using the new agent's {op,path}
protocol (jailed to the instance root, symlink-safe). files.view /
files.manage perms. Foundation for the per-game config editor and for
fixing the legacy VueFinder File Manager (which still speaks the retired
Go fm_* protocol on the wrong subject and is broken under per-license
auth — separate reconciliation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:00:28 -04:00
Vantz Stockwell
e897a4802f fix(server): apply lifecycle reply state optimistically (heartbeat lag)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 42s
CI / integration (push) Successful in 21s
The agent reply is authoritative for the action just taken; the fleet
DB only updates on the next heartbeat (~10s), so the immediate refetch
read a stale state and reverted the UI (Start -> still Stopped). Now
apply the reply's state/uptime directly to the instance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:41:19 -04:00
Vantz Stockwell
c0b20f2f78 feat(server): instance-centric controls — real per-instance state + lifecycle
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 55s
CI / integration (push) Successful in 22s
The Server page now manages the selected GAME INSTANCE, not the legacy
host connection. New instances store flattens the fleet and drives the
command bridge. New 'Game instance' panel: real state badge
(running/stopped/crashed/configured), uptime, host, and an instance
selector when >1. Start/Stop/Restart/Refresh wired to POST
/api/instances/:id/lifecycle — gated on the actual instance state (not
host connectivity), with telemetry-only instances flagged. Works across
all four games (state + lifecycle are game-agnostic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:37:53 -04:00
Vantz Stockwell
06e832fca1 feat(fleet): remove host — DELETE /api/fleet/hosts/:id + Fleet card action
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 36s
CI / integration (push) Successful in 21s
Self-service host removal. DELETE /api/fleet/hosts/:id (server.manage,
tenant-guarded): refuses while the host is 'connected' (409 — a live
agent re-registers on its next heartbeat, stop it first), deletes the
host's game_instances explicitly (FK is SET NULL, would otherwise
orphan them; instance_stats cascade), and clears the legacy
server_connections row if it was the license's last host. Fleet view:
offline host cards get a Remove button with inline confirm + toast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:21:04 -04:00
Vantz Stockwell
009ceb86ad feat(server): real agent credentials + agent.toml setup; per-game config honesty
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 17s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 22s
Server page Host-agent panel now fetches GET /api/servers/agent-
credentials and renders the real agent.toml (license UUID, nats_user,
nats_password) instead of the broken LICENSE_ID=license_key env
commands that would never connect. Password masked by default with a
reveal toggle; copy-to-clipboard uses the real value. Setup commands
point at --config /etc/corrosion/agent.toml.

Configuration panel: World size / Current seed (Rust-only Facepunch
concepts) gated behind isRust; Conan/Soulmask/Dune get an honest note
pointing to the File Manager for their real config files instead of
fake Rust fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:23:47 -04:00
Vantz Stockwell
6f31c41dc3 feat(api): instance command bridge + agent credentials endpoint
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 43s
CI / integration (push) Successful in 21s
Backend layer wiring the panel to the host agent's per-instance command
channel (the unblocker for the Server-page rework):
- NatsService.requestScoped(): request-reply with a LICENSE-SCOPED reply
  subject (corrosion.{license}.reply.<id>) so per-license-scoped agents
  (no _INBOX permission) can actually reply — the design from the NATS
  auth work, now exercised.
- InstancesModule: POST /api/instances/:id/lifecycle {action} (start/
  stop/restart/status/steam_update, server.manage) and POST :id/rcon
  {command} (server.console). Tenant-guarded via game_instances.
- GET /api/servers/agent-credentials: derives the agent's NATS user/
  password (HMAC) so a customer can configure their agent — closes the
  post-auth setup gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:05:22 -04:00
Vantz Stockwell
99433a09d1 docs(claude): Lesson 27 — lint infra config before deploy; compose up -d recreates changed deps
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 42s
CI / integration (push) Successful in 22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:53:06 -04:00
Vantz Stockwell
b442ef4102 fix(api): consumer rejects malformed heartbeats with no host block (no phantom hosts)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 41s
CI / integration (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:49:53 -04:00
Vantz Stockwell
856106174a fix(nats): no_auth_user is top-level, not inside authorization{} — broke broker startup
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 43s
CI / integration (push) Successful in 22s
Caught during the live cutover: nats-server rejects 'unknown field
no_auth_user' when it is nested in the authorization block, taking the
whole broker down. Both the generator (open stage) and the committed
bootstrap default emitted it nested. Moved to top level. Enforce-stage
output was unaffected (no no_auth_user), which is what the live broker
now runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:47:14 -04:00
Vantz Stockwell
463908b18e fix(nats): security review — secure-by-default + per-tenant inbox isolation
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 43s
CI / integration (push) Successful in 23s
Two HIGH findings from automated review on the generator, both fixed:
1. Cross-tenant inbox access: per-license users were granted _INBOX.>,
   letting license A subscribe to license B's request-reply responses.
   Now scoped to corrosion.{license}.> ONLY; replies must ride the
   license namespace (corrosion.{license}.reply.<id>) — documented in
   PROTOCOL.md. Agent unchanged (responds to msg.reply); constraint is
   on the requester (internal user has full >).
2. Default-open auth bypass: generator defaulted to stage=open with a
   full-access anonymous user — a stale regen left the broker wide open.
   Now defaults to enforce (secure by default); the explicit 'open'
   migration stage maps anonymous to a harmless corrosion.unclaimed.>
   namespace, never real tenant subjects. Committed bootstrap default
   hardened the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:39:31 -04:00
Vantz Stockwell
00cff51ce5 feat(nats): per-license auth mechanism — agent user/password, scoped broker, generator (non-breaking)
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 17s
CI / agent-tests (push) Successful in 1m23s
Build Host Agent (Rust) / build (push) Successful in 1m38s
CI / integration (push) Successful in 23s
Closes the open broker (anonymous publish to any tenant's corrosion.*).
Per-license isolation via NATS user/password + subject permissions:
each license -> user=license_id, password=HMAC-SHA256(license_id,
NATS_TOKEN_SECRET), scoped to corrosion.{license_id}.> + _INBOX. Backend
uses a privileged internal user.

- Agent (alpha.5): nats_user/nats_password config + env, user_and_password
  auth; falls back to token/anonymous (transition-safe)
- Backend: connects with NATS_INTERNAL_USER/PASSWORD when set, else anon
- scripts/generate-nats-auth.mjs: regenerates nats-auth.conf from the
  licenses table; NATS_AUTH_STAGE=open keeps a no_auth_user fallback
  (verify creds first), =enforce rejects anonymous
- committed nats-auth.conf is the SAFE OPEN default (no secrets); the
  host copy carries real users and is not committed
- compose: NATS_INTERNAL_USER/PASSWORD/NATS_TOKEN_SECRET, mount nats-auth.conf

Entirely non-breaking until secrets+config deployed; staged cutover next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:33:27 -04:00
Vantz Stockwell
7a07d600e7 feat(fleet): Phase B — fleet overview UI + GET /api/fleet read endpoint
Tenant-scoped fleet read: GET /api/fleet returns agent_hosts (host
metrics) each with their game_instances, plus a summary
(host/instance/online counts). FleetView lists host cards (status, CPU/
mem/disk/uptime/last-heartbeat) with their instances (game, state badge,
uptime); honest empty state -> Server page when no hosts. New 'Fleet'
sidebar nav item across all four game profiles, /fleet route. Store
follows the no-throw-on-fetch pattern (error state, never bricks). The
marketing hero made real from the live fleet tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:32:55 -04:00
Vantz Stockwell
4a4ae7a5d4 docs(claude): Lesson 26 — jail-at-entry doesn't jail the recursive walk (security review caught what my review missed)
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 41s
CI / integration (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:04:23 -04:00
Vantz Stockwell
930f655bf5 feat(api): fleet data model Phase A — License -> Host -> Instance
All checks were successful
CI / backend-types (push) Successful in 14s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 42s
CI / integration (push) Successful in 22s
Migration 022 adds agent_hosts / game_instances / instance_clusters /
instance_stats (named agent_hosts to avoid the existing B2B hosts
table). HostAgentConsumerService now parses the full v2 heartbeat and
upserts an agent_hosts row (host metrics: cpu/mem/disk/agent version,
keyed by license_id+hostname until enrollment) plus one game_instances
row per heartbeat instance entry (state + uptime, the billing unit).
Legacy server_connections write retained so the current panel keeps
working — additive migration, nothing breaks. Staleness sweep + offline
beacon now flip agent_hosts too. cluster_id FK reserved for Soulmask/
Dune. Migration applied to live DB; tsc green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:00:52 -04:00
Vantz Stockwell
700dc2254d fix(host-agent): SECURITY — file manager copy/list no longer follow symlinks out of the jail
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 17s
CI / agent-tests (push) Successful in 1m21s
Build Host Agent (Rust) / build (push) Successful in 1m34s
CI / integration (push) Has been cancelled
Automated security review (HIGH) caught a jail-escape my own review
missed: copy_recursive used fs::metadata (follows symlinks). A symlink
inside the jail pointing to e.g. /etc, then a 'copy' of its parent dir,
would dereference it and pull external content INTO the jail where it
could be read — a read-escape exfiltration. jail() validates only the
top-level src/dest; the recursive walk reintroduced the escape.

Fix: copy_recursive uses symlink_metadata and refuses any symlink
('symlinks are not followed across the jail boundary'). list() likewise
switched to symlink_metadata so it reports the link, never the
dereferenced target's size/type (info leak). Two regression tests added:
copy-symlink-exfil (asserts no external content lands inside) and
list-no-deref. 44/44 tests green. Rolled forward to alpha.4 (vulnerable
alpha.3 superseded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:57:08 -04:00
Vantz Stockwell
7fdca2cd4f chore(host-agent): bump to 2.0.0-alpha.3 (RCON + supervision + SteamCMD + file manager)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 1m26s
Build Host Agent (Rust) / build (push) Successful in 1m35s
CI / integration (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:52:05 -04:00
Vantz Stockwell
18f978dde1 feat(host-agent): Phase 1c — SteamCMD update + jailed file manager
steam_update func runs SteamCMD per game (rust/conan/soulmask app-ids;
dune rejected), streaming stdout to {instance}.steam_status. Jailed
file manager on {instance}.files.cmd: list/read/write/delete/rename/
mkdir/mkfile/move/copy, all confined to instance root via two-stage
lexical-normalize + canonicalize (defeats ../ traversal AND symlink
escape — incl chained symlinks). Replaces the Go agent's UNJAILED
legacy files API (retired, not ported). 5MiB read cap.

42/42 tests green: 24 filemanager incl 7 jail-escape attempts
(dotdot, deep dotdot, absolute, symlink-inside, direct symlink,
chained symlink), 5 steamcmd app-id (cfg-gated win/linux soulmask).
Jail logic reviewed line-by-line: Path::starts_with is component-wise
(no sibling-prefix bypass), non-existent suffix components can't be
symlinks, leading .. normalizes to / and fails the prefix check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:51:46 -04:00
Vantz Stockwell
9e5e828c8d fix(docker): nginx healthcheck uses 127.0.0.1 not localhost — IPv4-only listener
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 44s
CI / integration (push) Successful in 21s
corrosion-nginx reported (unhealthy) despite serving the panel fine:
nginx listens 0.0.0.0:80 (IPv4 only, no listen [::]:80), but
'localhost' resolves to ::1 first inside the container, so the probe
got connection-refused. Verified: 127.0.0.1:80 serves the SPA. Probe
now targets IPv4 explicitly. No nginx config change — the panel was
never broken, only the healthcheck's hostname resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:43:01 -04:00
Vantz Stockwell
fccd5c61c5 docs(claude): Lessons 24-25 — onModuleInit-before-connect dead subscriptions + resurrected-path crash
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:17:02 -04:00
Vantz Stockwell
c72a280361 fix(api): WS gateways crashed on first forwarded event — WebSocket.OPEN undefined at runtime
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 40s
CI / integration (push) Successful in 20s
Lesson 10 in the flesh: the onApplicationBootstrap fix made the NATS->
WS bridge actually deliver events for the first time, which instantly
crashed the API. esModuleInterop is off, so 'import WebSocket from ws'
compiles to ws_1.default = undefined; WebSocket.OPEN threw
'Cannot read properties of undefined' and killed the process on the
first heartbeat forward. All three WS guard sites (nats-bridge x2,
console gateway) switched to the import-agnostic instance constant
client.OPEN. Latent in every build — never hit because the bridge was
dead-on-boot until today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:11:29 -04:00
Vantz Stockwell
a3b4b5cc7d fix(api): NATS subscriptions moved to onApplicationBootstrap — they silently no-oped before connect
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 47s
CI / integration (push) Successful in 22s
Production bug caught live: provider onModuleInit order put bridge/
consumer subscription hooks BEFORE NatsService finished connecting, so
every subscribe() hit the [OFFLINE] no-op path — the WS bridge has been
dead-on-boot in every production build, and the new v2 consumer never
saw a heartbeat (server_connections stayed empty under a live agent).
onApplicationBootstrap is guaranteed to run after all module inits,
including the awaited NATS connect.

The new CI contract suite fails on exactly this class of bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:02:52 -04:00
Vantz Stockwell
4e184ca571 ci: full test gate — types, frontend build, agent tests, agent<->backend contract suite
Some checks failed
CI / backend-types (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / agent-tests (push) Successful in 1m48s
CI / integration (push) Has been cancelled
ci.yml runs on every push to main: backend tsc, frontend vue-tsc+vite,
cargo test (cached), then an integration job with postgres:16 + nats
service containers — real migrations applied to a fresh DB, real
backend booted (admin seed provides the license), real agent binary
spawned. contract-tests/agent-backend.contract.mjs proves the entire
v2 pipeline: heartbeat shape + measured telemetry, auto-registered
server_connections row flipping connected, instance start/stop/status
round-trips with push events, and the offline beacon flipping the row
back. This is the test that could not run before a production rebuild
until now — it now runs before every push lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:59:44 -04:00
Vantz Stockwell
fde0926d52 feat(host-agent): Phase 1b RCON — WebRCON (rust) + Source RCON (conan/soulmask)
rcon func on the instance command channel: WebSocket JSON WebRCON with
Identifier correlation (skips chat/log noise frames) and full Valve
Source RCON over TCP (auth, exec, multi-packet reassembly via empty
probe, 1MiB cap). Protocol inferred from game, explicit kind override
in [instance.rcon]. Always 127.0.0.1 — agent is co-located.

Hardening from review: WebRCON password never interpolated into error
contexts/logs (redacted URL); probe-tolerant termination — a quiet
period after received data ends the response for servers that don't
echo the probe (Soulmask conformance unverified), so data is never
discarded on probe timeout.

13/13 tests green incl. mock Source-RCON server (auth/multi-packet/
errors) and mock WebRCON server (noise-frame skipping).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:53:52 -04:00
Vantz Stockwell
4d99c9d99d feat(frontend): validate persisted session on app boot
A stale or revoked token previously rendered the full panel chrome and
only collapsed on the first API call. App boot now calls /auth/me
through useApi (401 -> refresh -> logout already handled there); user
profile refreshes on success, and non-auth failures (network, 5xx)
never log the user out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:49:21 -04:00
Vantz Stockwell
b8f0ccba3c fix(frontend): env-driven marketing host detection
Exact-match on 'corrosionmgmt.com' meant www. or any staging host
silently served the panel instead of the marketing site. Hosts now come
from VITE_MARKETING_HOSTS (comma-separated, defaults cover bare + www).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:47:15 -04:00
Vantz Stockwell
068a476f39 feat(host-agent): Phase 1a process supervision — instance start/stop/restart/status + push state events
Per-instance ProcessSupervisor: tokio child spawn with proper arg list
(fixes Go's naive space-splitting), graceful SIGTERM with 30s budget
then force kill, monitor task classifying ordered-stop vs crash (exit
code captured), watch-channel state observable everywhere. Instance cmd
channel live on corrosion.{license}.{instance}.cmd (start/stop/restart/
status) with state events pushed on {instance}.status (keep-latest
semantics, documented). Heartbeats now carry live process state +
uptime per instance. Crate restructured lib+bin for integration tests.

Verified: 5 integration tests with real OS processes (lifecycle, crash
exit-code, restart recovery, unmanaged rejection, clean spawn failure)
+ live-NATS contract test (request-reply roundtrips, double-start
rejection, push events, heartbeat state) — all green.

Known limitation (documented): no PID adoption yet — agent restart
orphans a running game process to 'stopped' until panel restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:44:24 -04:00
Vantz Stockwell
f706c3c47e docs(claude): host-agent reality — active Rust crate, tag scheme, runner container truth, command corrections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:37:37 -04:00
Vantz Stockwell
4c9c322c29 feat(seo): per-route titles + meta descriptions; ci: honest runner test
Every page previously titled 'Corrosion Management' with zero meta -
marketing invisible to search and link previews. Router afterEach now
sets title/description/og per route (no new deps); marketing pages get
real content-backed descriptions, panel views mechanical titles.
index.html carries defaults for pre-JS crawlers. Verified in-browser
per page via Playwright.

test-runner.yml: per-tool presence checks instead of green-lighting
missing toolchains; workflow_dispatch instead of every push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:35:58 -04:00
Vantz Stockwell
47fa72763c feat(api): host-agent protocol v2 consumer — heartbeat persistence, auto-register, staleness sweep
Nothing persisted agent heartbeats before: companion_last_seen was
written once at setup and connection_status stayed 'connected' forever.
HostAgentConsumerService now consumes corrosion.*.host.heartbeat
(updates last_seen + status, auto-creates the bare_metal connection row
on first contact), host.going_offline (graceful offline), and sweeps
connections offline after 180s of heartbeat silence. License-existence
tenant validation with caching per NATS-consumer doctrine. WS bridge
forwards host_heartbeat/host_going_offline to the panel.

Contract-verified against production NATS with the backend's own nats
lib: v2 subjects, schema 2, real telemetry, offline beacon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:35:58 -04:00
Vantz Stockwell
b455bf9f14 ci(host-agent): bootstrap Rust in the runner container; roll to alpha.2
All checks were successful
Build Host Agent (Rust) / build (push) Successful in 1m29s
Test Asgard Runner / test (push) Successful in 3s
Asgard runner executes jobs in bare node:20-bullseye (no Rust, no sudo)
- install rustup + musl/mingw cross toolchains per-run, same pattern as
setup-go in the Go pipeline. agent-v2.0.0-alpha.1 predates this fix;
forward-only doctrine: version rolls to alpha.2 rather than re-pushing
the tag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:15:36 -04:00
Vantz Stockwell
4abf0ab889 ci(host-agent): Rust agent build pipeline on agent-v* tags -> CDN alpha channel
Some checks failed
Build Host Agent (Rust) / build (push) Failing after 3s
Test Asgard Runner / test (push) Successful in 3s
Separate tag namespace from the Go pipeline (v*.*.*) per the
blast-radius doctrine; artifacts publish to /host-agent/alpha/ and a
versioned dir, leaving /host-agent/latest/ on the Go build until
cutover. Linux = static musl, Windows = mingw (msvc/cargo-xwin stays
the local release path). Tag-vs-Cargo.toml version gate included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:09:43 -04:00
Vantz Stockwell
cea3d66cdd feat(host-agent): Rust rewrite Phase 0 — multi-instance foundation, v2 wire protocol, real telemetry
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
New corrosion-host-agent/ crate (Go companion-agent stays as behavior
reference until parity). Wire protocol v2 per COA-B: instance-scoped
subjects corrosion.{license}.{instance}.* + host-level .host.* — spec
in PROTOCOL.md, designed for the license->host->instance fleet model.

- Multi-instance TOML config in the foundation, not retrofitted
- NATS layer on the Vigilance production profile (infinite reconnect,
  capped backoff, 30s ping, 8192-msg offline buffer)
- Heartbeat with real sysinfo telemetry — Go agent shipped hardcoded
  disk/cpu placeholders; this is the panel's first true Resources data
- Connectivity prober (outbound TCP, periodic + on-demand)
- Host cmd channel (ping/probe/sysinfo), going-offline beacon,
  CancellationToken shutdown
- Live-fire verified against production NATS; artifacts: 3.7MB static
  linux-musl, 3.8MB windows .exe (static CRT)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:02:46 -04:00
Vantz Stockwell
1abe57ca40 fix(marketing): strip dead Discord invite from footer; docs: Scout tier -> sonnet[1m]
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
discord.gg/corrosion is an Unknown Invite (verified via Discord API) -
no dead community promises on the marketing site. Re-add when a real
server exists. Discord *webhook* feature copy stays; that's shipped.

AGENTS.md Scout tier haiku -> sonnet[1m] confirmed by Commander:
marginal price difference, 1m context window pays for itself on recon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:28:06 -04:00
Vantz Stockwell
a8722a7a07 fix(audit): kill fake install cmds + dead demo CTA; production fonts; scoped error boundary; admin bootstrap seed
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Full-site fake-data audit findings:
- SetupWizard showed a curl|sh installer (get.corrosionmgmt.com) and a
  'corrosion-agent' binary that don't exist -> real host-agent commands
- 'View live demo' CTA on 5 marketing pages linked to a login, not a
  demo -> honest 'Sign in'
- Google Fonts @import was silently dropped from the production CSS
  bundle (mid-bundle @import) -> <link> tags in index.html; prod was
  shipping system fallback fonts
- App-root ErrorBoundary bricked the entire SPA (incl. marketing) on a
  single failed fetch until manual reload -> resets on route change +
  content-scoped boundary inside DashboardLayout so nav chrome survives
- Status page KPIs showed fake zeros while the fetch failed -> em dash
- Login lacked the forgot-password link (flow already existed end-to-end)
- AdminSeedService: fresh DB had schema but no login possible; seeds
  super-admin + license from ADMIN_* env when users table is empty

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:23:44 -04:00
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
1604 changed files with 279062 additions and 8484 deletions

View File

@@ -42,3 +42,6 @@ FRONTEND_URL=http://localhost:5174
# Frontend (Vite — must be prefixed with VITE_)
VITE_PANEL_URL=https://panel.corrosionmgmt.com
# Hostnames that serve the marketing site (comma-separated); all other hosts get the panel
VITE_MARKETING_HOSTS=corrosionmgmt.com,www.corrosionmgmt.com

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
@@ -53,7 +53,7 @@ jobs:
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}" \
-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]*')
@@ -68,15 +68,15 @@ jobs:
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 \
@@ -89,43 +89,43 @@ jobs:
run: |
CDN_URL="https://cdn.corrosionmgmt.com"
# Upload Linux binary to /companion/latest/
# Upload Linux binary to /host-agent/latest/
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
"${CDN_URL}/companion/latest/corrosion-companion-linux-amd64"
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
"${CDN_URL}/host-agent/latest/corrosion-host-agent-linux-amd64"
# Upload Windows binary to /companion/latest/
# Upload Windows binary to /host-agent/latest/
curl -s -X POST \
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
"${CDN_URL}/companion/latest/corrosion-companion-windows-amd64.exe"
-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}/companion/latest/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-companion-linux-amd64" \
"${CDN_URL}/companion/${VERSION}/corrosion-companion-linux-amd64"
-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-companion-windows-amd64.exe" \
"${CDN_URL}/companion/${VERSION}/corrosion-companion-windows-amd64.exe"
-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}/companion/${VERSION}/checksums.txt"
"${CDN_URL}/host-agent/${VERSION}/checksums.txt"
echo "CDN upload complete: ${CDN_URL}/companion/latest/"
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

@@ -0,0 +1,161 @@
name: Build Host Agent (Rust)
# Rust agent ships on its own tag namespace (agent-v*) so it never collides
# with the legacy Go pipeline (v*.*.*). Artifacts publish to the CDN /alpha/
# channel — /host-agent/latest/ stays on the Go build until cutover.
on:
push:
tags:
- 'agent-v*'
jobs:
build:
runs-on: ubuntu-latest
env:
# Override the macOS toolchain names in corrosion-host-agent/.cargo/config.toml
# (real env beats the config [env] table).
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
CC_x86_64_unknown_linux_musl: musl-gcc
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/agent-v}" >> $GITHUB_OUTPUT
- name: Verify tag matches Cargo.toml
run: |
CARGO_VERSION=$(grep '^version' corrosion-host-agent/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "${{ steps.version.outputs.VERSION }}" != "$CARGO_VERSION" ]; then
echo "Tag agent-v${{ steps.version.outputs.VERSION }} does not match Cargo.toml version $CARGO_VERSION"
exit 1
fi
# The Asgard runner executes jobs in a bare node:20-bullseye container
# (no Rust, no sudo, runs as root) — bootstrap the toolchain per-run,
# same pattern as actions/setup-go in the Go pipeline.
- name: Install Rust + cross toolchains
run: |
apt-get update -qq
apt-get install -y -qq build-essential musl-tools gcc-mingw-w64-x86-64 curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
"$HOME/.cargo/bin/rustup" target add x86_64-unknown-linux-musl x86_64-pc-windows-gnu
- name: Build Linux AMD64 (static musl)
run: |
cd corrosion-host-agent
cargo build --release --target x86_64-unknown-linux-musl
mkdir -p bin
cp target/x86_64-unknown-linux-musl/release/corrosion-host-agent bin/corrosion-host-agent-linux-amd64
chmod +x bin/corrosion-host-agent-linux-amd64
- name: Build Windows AMD64 (mingw)
run: |
cd corrosion-host-agent
cargo build --release --target x86_64-pc-windows-gnu
cp target/x86_64-pc-windows-gnu/release/corrosion-host-agent.exe bin/corrosion-host-agent-windows-amd64.exe
- name: Generate checksums
run: |
cd corrosion-host-agent/bin
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
cat checksums.txt
- name: Sign artifacts (minisign)
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
run: |
if [ -z "$MINISIGN_SECRET_KEY" ]; then
echo "::error::MINISIGN_SECRET_KEY secret is not set — refusing to publish unsigned agent artifacts."
exit 1
fi
# minisign isn't packaged for bullseye — fetch the official static binary.
curl -sSL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz -o /tmp/minisign.tgz
tar -xzf /tmp/minisign.tgz -C /tmp
MINISIGN="$(find /tmp -type f -name minisign -path '*linux*' | head -1)"
chmod +x "$MINISIGN"
"$MINISIGN" -v
# A minisign secret key file is TWO lines (comment + base64 blob). CI
# secret storage mangles embedded newlines, collapsing it to one line
# so minisign can't load it. Preferred form: store the secret
# base64-encoded (single line) — we decode it here. Auto-detect so a
# correctly-stored raw two-line key still works.
if printf '%s' "$MINISIGN_SECRET_KEY" | base64 -d 2>/dev/null | head -1 | grep -q "untrusted comment:"; then
printf '%s' "$MINISIGN_SECRET_KEY" | base64 -d > /tmp/sign.key
else
printf '%s\n' "$MINISIGN_SECRET_KEY" > /tmp/sign.key
fi
if ! head -1 /tmp/sign.key | grep -q "untrusted comment:"; then
echo "::error::MINISIGN_SECRET_KEY is neither base64 of a minisign key nor a raw two-line key file. Store it as: base64 < your-secret.key | tr -d '\n'"
rm -f /tmp/sign.key
exit 1
fi
cd corrosion-host-agent/bin
# Passwordless key (-W generated); feed empty stdin so it never blocks.
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
"$MINISIGN" -S -s /tmp/sign.key -m "$f" -x "$f.minisig" < /dev/null
done
rm -f /tmp/sign.key
echo "signed: $(ls *.minisig)"
- name: Create Release
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
API_URL="${{ github.server_url }}/api/v1"
REPO="${{ github.repository }}"
VERSION="agent-v${{ steps.version.outputs.VERSION }}"
RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Rust host agent release ${VERSION}\", \"draft\": false, \"prerelease\": true}" \
"${API_URL}/repos/${REPO}/releases")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-linux-amd64.minisig \
corrosion-host-agent-windows-amd64.exe corrosion-host-agent-windows-amd64.exe.minisig \
checksums.txt checksums.txt.minisig; do
curl -s -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @corrosion-host-agent/bin/$f \
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$f"
done
- name: Upload to CDN (alpha channel)
run: |
CDN_URL="https://cdn.corrosionmgmt.com"
VERSION="${{ steps.version.outputs.VERSION }}"
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-linux-amd64.minisig \
corrosion-host-agent-windows-amd64.exe corrosion-host-agent-windows-amd64.exe.minisig \
checksums.txt checksums.txt.minisig; do
curl -s -X POST \
-F "file=@corrosion-host-agent/bin/$f" \
"${CDN_URL}/host-agent/alpha/$f"
curl -s -X POST \
-F "file=@corrosion-host-agent/bin/$f" \
"${CDN_URL}/host-agent/${VERSION}/$f"
done
echo "CDN upload complete: ${CDN_URL}/host-agent/alpha/"
- name: Build Summary
run: |
echo "## Corrosion Host Agent (Rust) 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 "**Channel:** alpha (latest/ untouched until cutover)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
echo "- Linux AMD64 static musl ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- Windows AMD64 mingw ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY

122
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,122 @@
name: CI
# Test gate for every push to main. The deploy story: main must be green here
# before the stack is rebuilt (deploy workflow enforces it once SSH transport
# secrets land). Jobs run in the runner's bare node:20-bullseye container —
# toolchains bootstrap per-run.
on:
push:
branches: [main]
pull_request:
jobs:
backend-types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Type-check NestJS backend
run: |
cd backend-nest
npm ci --no-audit --no-fund 2>&1 | tail -2
npx tsc --noEmit
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build frontend (vue-tsc gate + vite)
run: |
cd frontend
npm ci --no-audit --no-fund 2>&1 | tail -2
npm run build
agent-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
corrosion-host-agent/target
key: cargo-${{ hashFiles('corrosion-host-agent/Cargo.lock') }}
- name: Install Rust
run: |
apt-get update -qq && apt-get install -y -qq build-essential curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Test agent
run: |
cd corrosion-host-agent
cargo test
- name: Upload agent binary for integration
uses: actions/upload-artifact@v3
with:
name: agent-debug
path: corrosion-host-agent/target/debug/corrosion-host-agent
integration:
runs-on: ubuntu-latest
needs: agent-tests
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: corrosion
POSTGRES_PASSWORD: citest
POSTGRES_DB: corrosion
nats:
image: nats:2.10-alpine
steps:
- uses: actions/checkout@v4
- name: Download agent binary
uses: actions/download-artifact@v3
with:
name: agent-debug
path: agent-bin
- name: Apply migrations to fresh DB
run: |
apt-get update -qq && apt-get install -y -qq postgresql-client
until PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -c 'SELECT 1' >/dev/null 2>&1; do sleep 1; done
for f in $(ls backend/migrations/*.sql | sort); do
echo "applying $f"
PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -v ON_ERROR_STOP=1 -q -f "$f"
done
- name: Build + boot backend
run: |
cd backend-nest
npm ci --no-audit --no-fund 2>&1 | tail -2
npm run build
DATABASE_URL=postgres://corrosion:citest@postgres:5432/corrosion \
NATS_URL=nats://nats:4222 \
JWT_SECRET=ci-secret ENCRYPTION_KEY=ci-encryption-key \
ADMIN_EMAIL=ci@corrosion.test ADMIN_PASSWORD=ci-password-123 ADMIN_USERNAME=CI \
nohup node dist/main.js > /tmp/backend.log 2>&1 &
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/auth/login -X POST -H 'Content-Type: application/json' -d '{}' || true)
[ "$code" = "400" ] && echo "backend up" && exit 0
sleep 2
done
echo "backend failed to come up"; cat /tmp/backend.log; exit 1
- name: Run agent↔backend contract suite
run: |
chmod +x agent-bin/corrosion-host-agent
LICENSE_ID=$(PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -t -A -c 'SELECT id FROM licenses LIMIT 1')
echo "license under test: $LICENSE_ID"
[ -n "$LICENSE_ID" ] || { echo "admin seed did not create a license"; cat /tmp/backend.log; exit 1; }
LICENSE_ID="$LICENSE_ID" \
DATABASE_URL=postgres://corrosion:citest@postgres:5432/corrosion \
NATS_URL=nats://nats:4222 \
AGENT_BIN=$PWD/agent-bin/corrosion-host-agent \
node contract-tests/agent-backend.contract.mjs
- name: Backend log on failure
if: failure()
run: cat /tmp/backend.log || true

View File

@@ -1,5 +1,6 @@
name: Test Asgard Runner
on: [push]
# On-demand only — no reason to spin a container on every push.
on: [workflow_dispatch]
jobs:
test:
@@ -17,8 +18,15 @@ jobs:
echo "Memory: $(free -h | grep Mem | awk '{print $2}')"
echo "Disk: $(df -h / | tail -1 | awk '{print $4}')"
echo "==========================================="
echo "Go: $(go version)"
echo "Rust: $(rustc --version)"
echo "Docker: $(docker --version)"
# Jobs run in a bare node:20-bullseye container: toolchains are NOT
# preinstalled — workflows must bootstrap them (setup-go, rustup).
# Report presence honestly instead of green-lighting a missing tool.
for tool in go rustc docker node; do
if command -v "$tool" >/dev/null 2>&1; then
echo "$tool: $($tool --version 2>&1 | head -1)"
else
echo "$tool: NOT PRESENT (workflows must install per-run)"
fi
done
echo "==========================================="
echo "✅ Asgard runner is OPERATIONAL"
echo "✅ Asgard runner reachable — container is node:20-bullseye, bootstrap toolchains per-run"

View File

@@ -38,7 +38,7 @@
### **TYPE 1: THE SCOUT (Intelligence)**
- **Model:** haiku
- **Model:** sonnet[1m]
- **Role:** Reconnaissance, Context Mapping, Log Analysis.

View File

@@ -4,6 +4,79 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### Added (Host-agent Phase 2 — Dune docker-compose adapter — 2026-06-12)
**`Supervisor` trait abstraction (`corrosion-host-agent`):**
- Introduced `trait Supervisor` (via `async-trait`, the battle-tested ecosystem standard) so the agent can manage games with fundamentally different models behind one wire contract. `ProcessSupervisor` (spawned OS process — Rust/Conan/Soulmask) and the new `DockerComposeSupervisor` (Dune) both implement it; `Agent.supervisors` is now `HashMap<String, Arc<dyn Supervisor>>` and the instance command dispatch (`instancecmd::dispatch`) is fully game-agnostic — `start`/`stop`/`restart`/`status` are identical across games. A per-game factory in `main` selects the impl. `InstanceState` moved to the shared `supervisor` module.
- **Architecture call** (per Commander): chose the `dyn` trait over a zero-dependency enum because the Dune references point at *several* future management planes (kubectl, AMP/podman, SSH) — a trait makes each new plane "new struct + impl," no central match to edit.
**`DockerComposeSupervisor` (Dune: Awakening):**
- Drives `docker compose up -d` / `stop` / `restart` against the instance's compose project (a "battlegroup"), with `-f`/`-p`/single-service support and a configurable compose binary (`docker compose` default, `docker-compose` legacy). New `[instance.docker_compose]` config block (file/project/service/command, all optional). `steam_update` already rejected for Dune (Docker images, no SteamCMD).
- **Scope (first cut):** lifecycle + cached state. Deferred to Phase 3b (with process PID adoption): container crash-detection and state adoption on agent restart (both reconcilable with a `docker compose ps` probe).
- Verified: 6 new docker-compose tests (mock `docker` binary asserting exact invocations + state transitions + failure paths) + the 5 refactored process-supervisor tests; full agent suite 56 tests green, zero warnings. Live verification against a real Dune stack pending the Commander standing one up.
### Changed (Fleet-driven active game + signed-update CI fix — 2026-06-12)
**Frontend — active game follows the deployed fleet:**
- The panel's active game (shell skin + sidebar nav + dashboard terminology) is now **derived from the deployed instances** instead of a localStorage-only toggle. `syncActiveGameFromFleet()` reads the distinct `game` values of the license's instances (`game_instances.game`, reported by the host agent): exactly one game deployed → the shell auto-skins to it; zero or multiple → `all` (neutral house skin). Wired into `DashboardLayout` (the always-mounted admin shell) via a watch on the fleet store.
- A manual GameSwitcher pick still wins — it persists to `cc-active-game` and suppresses auto-derive (operator intent beats the heuristic). Un-overridden panels keep tracking the fleet across sessions.
- **No backend/schema change:** a license's game(s) are the distinct games of its instances — the normalized source of truth. Deliberately did NOT add a `licenses.game` column (would duplicate `game_instances.game` and drift; see Lesson 20).
**Frontend — sidebar agent-health footer is now fleet-aware:**
- The shell footer read a single legacy `server.connection` (one `server_connections` row), which disagreed with the multi-host fleet. Repointed it at the fleet store: one host → hostname + status + last-heartbeat; multiple → `{online}/{total} online` + total instance count. Tone aggregates (all online → healthy, some → degraded, none → offline). Dropped the legacy `useServerStore` dependency from the shell entirely.
**Frontend — removed dead `vuefinder` dependency:**
- VueFinder was replaced by the native instance-scoped file manager but the plugin (and its CSS) were still globally registered in `main.ts` and shipped in the bundle. Removed the dep + the three `main.ts` lines. Side effect: the main JS chunk dropped **588 kB → 165 kB** (vuefinder bundled an entire unused file-manager UI).
**Recon note (not a change):** `corrosion.{license}.cmd.server` was on the cleanup list as "dead v1" — it is NOT. It remains the live license-level command path for all plugin/module config applies, plugin install, scheduled tasks, and legacy start/stop/restart, served only by the legacy Go agent. The Rust agent does not implement it yet — this is a **parity/migration gap** (Phase 2+), not dead code. Left intact.
**CI — signed host-agent build:**
- Fixed the `Sign artifacts (minisign)` step (`Error while loading the secret key file`): a minisign secret key is two lines and CI secret storage mangles the embedded newline. The job now base64-decodes the secret (single-line, mangling-proof) with auto-detect fallback to a raw key. `MINISIGN_SECRET_KEY` must be stored as `base64 < secret.key | tr -d '\n'`. Verified end-to-end: `agent-v2.0.0-alpha.8` Linux + Windows binaries validate against the agent's embedded public key; tampered byte rejected.
### Added (Host-Agent v2 Consumer + SEO Meta — 2026-06-11)
**Backend (NestJS):**
- `HostAgentConsumerService` (new) — consumes wire protocol v2: `corrosion.*.host.heartbeat` updates `companion_last_seen` + `connection_status='connected'` (auto-registers the connection row on first contact); `host.going_offline` flips offline; a 60s staleness sweep marks hosts offline after 180s of silence. Previously NOTHING persisted heartbeats — `connection_status` was set once at setup and never changed again. Tenant-validated (UUID + license existence, cached) per NATS-consumer doctrine
- `NatsBridgeService` — bridges `host_heartbeat` / `host_going_offline` events to the panel WebSocket
- Verified by contract test: real agent → production NATS → captured with the backend's own `nats` lib under the real license; subjects, schema 2, real telemetry, offline beacon all confirmed
**Frontend:**
- Per-route document titles + meta descriptions (router `afterEach`, no new deps): six marketing pages get real titles/descriptions/OG tags (previously every page was "Corrosion Management" with zero meta — invisible to search and link previews); panel views get mechanical "{View} — Corrosion" titles
**CI:**
- `test-runner.yml` — honest per-tool presence checks (was printing "OPERATIONAL" while every toolchain probe failed); on-demand trigger instead of every push
### Added (Corrosion Host Agent — Rust rewrite Phase 0 — 2026-06-11)
**New: `corrosion-host-agent/`** — Rust rewrite of the Go companion agent (which stays in-tree as the behavior reference until parity). Wire protocol v2 (COA-B, Commander-approved): instance-scoped subjects `corrosion.{license}.{instance}.*` with host-level `corrosion.{license}.host.*` — full spec in `corrosion-host-agent/PROTOCOL.md`.
- Multi-instance TOML config baked into the foundation (one agent supervises N game instances; rust/conan/soulmask/dune), env overrides for secrets, strict validation (subject-safe ids, reserved segments)
- NATS layer with the production-proven Vigilance profile: infinite reconnect w/ capped backoff, 30s ping, 8192-msg offline send buffer, `tls://` scheme support
- Host heartbeat with REAL telemetry via sysinfo (CPU/mem/disks/per-instance state) — the Go agent hardcoded disk=50000MB and cpu=0.0; this is the first true Resources data
- Connectivity prober (outbound TCP + latency, periodic jittered + on-demand) — first piece of the support-triage story
- Host command channel (`ping`/`probe`/`sysinfo`, request-reply), going-offline beacon, CancellationToken graceful shutdown
- Version embedding (semver + git hash + build ts) in `--version` and every heartbeat
- Verified live against production NATS: connected, heartbeats published, clean shutdown
- Deploy artifacts verified: 3.7MB fully-static linux-musl binary, 3.8MB windows .exe (static CRT, no VC++ redist needed)
**Next phases**: 1 = process-class adapter (spawn/RCON/SteamCMD/files for Rust/Conan/Soulmask) + NestJS v2 heartbeat consumer; 2 = Dune Docker adapter; 3 = signed self-update (release gate) + service install.
### Fixed (Site Audit — Fake Data, Resilience, Fonts — 2026-06-11)
**Frontend:**
- `SetupWizardView.vue` — Replaced fake install instructions (`get.corrosionmgmt.com | sh` install script and `corrosion-agent` binary, neither of which exists) with the real host-agent download + run commands matching ServerView; multi-game copy on the completion step
- Marketing views (Landing, Pricing, HowItWorks, Roadmap, EarlyAccess) — Replaced "View live demo" CTA (no demo exists; it linked to the panel login) with an honest "Sign in" link
- `ErrorBoundary.vue` — Error state now resets on route change (previously one failed view bricked the entire SPA, including marketing pages, until manual reload); added `content` variant
- `DashboardLayout.vue` — Routed views are now wrapped in a content-scoped ErrorBoundary so the sidebar/topbar survive a view failure instead of the whole panel unmounting
- `index.html` / `styles/tokens/fonts.css` — Google Fonts moved from CSS `@import` to `<link>` tags. The bundler silently dropped the mid-bundle `@import`, so production shipped system fallback fonts (Geist/JetBrains Mono/Oxanium never loaded)
- `StatusPageView.vue` — Platform KPIs show "—" until the first successful fetch instead of fake zeros
- `LoginView.vue` — Added missing "Forgot password?" link (route + backend endpoint already existed)
**Backend (NestJS):**
- `AdminSeedService` (new, auth module) — Bootstraps a super-admin user + active license from `ADMIN_EMAIL`/`ADMIN_PASSWORD`/`ADMIN_USERNAME`/`ADMIN_LICENSE_KEY` when the users table is empty. A fresh deploy previously had a schema but no possible login. Compose already passes the env vars
**Purpose:** Findings from the full-site fake-data audit. Show real data or honest empty states — never invented values, dead URLs, or fabricated zeros.
### Fixed (Safe Formatting Utilities — 2026-02-15)
**Frontend:**

View File

@@ -55,7 +55,12 @@ frontend/ # Vue 3 + TypeScript
package.json
vite.config.ts # Proxies /api to :3000
companion-agent/ # Go binary for bare metal servers
corrosion-host-agent/ # Rust host agent (ACTIVE) — multi-game ops runtime
src/ # main, config, bus (NATS), telemetry, prober, hostcmd
PROTOCOL.md # Wire protocol v2 spec (instance-scoped subjects)
agent.example.toml # Multi-instance config reference
companion-agent/ # Go binary (LEGACY — behavior reference until Rust parity)
cmd/agent/ # main.go entry point
internal/ # Core agent logic (nats, commands, process)
Makefile # Build for Linux/Windows
@@ -91,14 +96,16 @@ cd backend-nest && npx tsc --noEmit # Type-check without building
# Frontend
cd frontend && npm run dev # Vite dev server (port 5174)
cd frontend && npm run build # Production build → dist/
cd frontend && npm run lint # ESLint
cd frontend && npm run type-check # TypeScript checking (vue-tsc)
cd frontend && npm run build # vue-tsc -b && vite build (type-check included; no separate lint/type-check scripts exist)
# Companion Agent (Go)
# Host Agent (Rust — ACTIVE)
cd corrosion-host-agent && cargo check # Fast validation
cd corrosion-host-agent && cargo build --release --target x86_64-unknown-linux-musl # Static Linux binary
cd corrosion-host-agent && cargo xwin build --release --target x86_64-pc-windows-msvc # Windows (local)
# CI: push tag agent-vX.Y.Z (must match Cargo.toml version) → Asgard builds → CDN /host-agent/alpha/
# Companion Agent (Go — LEGACY, behavior reference until Rust parity)
cd companion-agent && make build # Build for current platform
cd companion-agent && make linux # Cross-compile for Linux
cd companion-agent && make windows # Cross-compile for Windows
# Docker (from docker/ directory — Commander ALWAYS builds with --no-cache)
docker compose build --no-cache && docker compose up -d # Full rebuild + start
@@ -374,7 +381,8 @@ Default to Sonnet. Escalate to Opus when the problem demands it, not as a comfor
- 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
- **Tag agent builds when agent code is modified** — Rust agent: `agent-vX.Y.Z` (must match `corrosion-host-agent/Cargo.toml`; CI publishes to CDN `/host-agent/alpha/`, while `/latest/` stays on the Go build until cutover). Legacy Go agent: `vX.Y.Z`. Tags roll FORWARD only — never reuse or re-push a tag; cut the next version
- **The Asgard CI runner executes jobs in a bare `node:20-bullseye` container** — no Rust/Go/Docker/sudo preinstalled; workflows must bootstrap toolchains per-run (setup-go, rustup via curl)
## Development Notes
@@ -423,3 +431,25 @@ 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.
24. **`onModuleInit` runs before async `onModuleInit` of dependencies completes — register NATS/external subscriptions in `onApplicationBootstrap`.** `NatsService.onModuleInit` connects to NATS (async); `NatsBridgeService`/`HostAgentConsumerService` registered their subscriptions in their own `onModuleInit`, which fired while the connection was still null — so every `subscribe()` hit the `[OFFLINE]` no-op path and the WS bridge was dead-on-boot in *every* production build, silently. Nest guarantees `onApplicationBootstrap` runs only after all module init (including the awaited connect) finishes. Anything that depends on another provider's async startup belongs in bootstrap, not init. The tell: a subscription that "should be there" but the handler never fires and there's no error — trace the *startup ordering*, not the handler.
25. **Fixing a dead code path detonates the live code behind it — budget for the second bug.** The moment Lesson 24's fix made the NATS→WS bridge actually deliver events, the API crashed on the first forwarded heartbeat: `WebSocket.OPEN` was `undefined` at runtime because `esModuleInterop` is off, so `import WebSocket from 'ws'` compiled to `ws_1.default` (undefined). That crash had sat behind the dead bridge since the gateway was written — never hit because no event ever reached it. When you resurrect a path that was silently no-op, everything downstream of it is effectively *untested code running for the first time in production*. Verify the whole chain end-to-end (I watched the DB row appear, then flip offline), don't stop at "the subscription fires now." This is Lesson 10 with a fuse on it. Import-runtime gotcha worth remembering: when `esModuleInterop` is off, prefer instance constants (`client.OPEN`) over class statics (`WebSocket.OPEN`) for `ws`.
26. **A jail check at the entry point does not jail the recursive walk behind it — and my own "line-by-line" review missed it; the automated security review didn't.** The file manager's `jail()` correctly canonicalized and prefix-checked the top-level path, and I traced every escape vector through it and signed off. But `copy_recursive` then walked the directory tree with `fs::metadata` (which *follows* symlinks). A symlink planted inside the jail pointing at `/etc`, then a `copy` of its parent, would dereference it and pull external content *into* the jail to be read — a jail escape the entry check never sees, because the escape is reintroduced by a descendant during traversal. Fix: `symlink_metadata` (lstat) everywhere you recurse, and refuse/never-follow symlinks across the boundary. The transferable rule: **validate at the boundary AND at every step that re-derives a path** (recursion, `read_dir`, glob, archive extraction). And the humbling part — I was confident after reviewing the jail function; the security-review pass caught the HIGH I'd waved through. Trust adversarial verification over your own once-over on security-critical code, especially path/traversal logic.
27. **Validate infra config BEFORE it reaches a deploy — and know that `docker compose up -d <service>` will recreate other services whose definitions changed.** During the NATS auth cutover I ran `docker compose up -d api` to pick up new env. Because the *nats* service definition had also changed (a new volume mount), compose recreated **corrosion-nats too** — and it failed to start on a config error (`no_auth_user` nested inside `authorization{}` instead of at top level), taking the broker down for ~3 minutes with the backend in offline mode. Two lessons: (a) a broker/proxy/DB config file is code — lint it before it can reach a restart (`nats-server -t -c cfg` to test-parse, `nginx -t`, etc.), don't let the first validation be the production container's startup; (b) `compose up -d <one-service>` is not surgical — it reconciles that service's **dependencies** too, so a stale edit to a depended-on service ships when you didn't mean it to. When touching shared-infra config, restart that service explicitly and watch it come up before moving on. Recovery also surfaced a third gotcha: recreating a client (api) while its server (nats) is down leaves the client stuck on a cached DNS failure (`EAI_AGAIN`) — restart the client once the server is healthy.
28. **A multi-line secret in CI (minisign/SSH/PGP keys) must be stored base64-encoded — the runner mangles embedded newlines and the key silently fails to load.** The signed-update CI passed the toolchain build, downloaded minisign fine, then died at the sign step on `Error while loading the secret key file` (exit 2). The cause wasn't the key or minisign — a minisign secret key file is **two lines** (`untrusted comment:` + base64 blob), and Gitea/act_runner secret storage collapses the embedded newline so the reconstructed file is one unparseable line. The robust pattern: store the secret as `base64 < secret.key | tr -d '\n'` (single line, mangling-proof) and `base64 -d` it in the job, with auto-detect fallback so a correctly-stored raw key still works, and a loud `::error::` carrying the fix command if it's neither. This applies to **any** multi-line credential in CI, not just minisign. Two corollaries: (a) the tell is "the tool runs but can't load its key" — suspect newline-mangling before the key itself; (b) generating that base64 prints the **private key to the terminal/transcript** — for a supply-chain signing key, treat it as exposed and rotate before cutover (embed the new pubkey, re-store the new secret, retire the old). And verify the published artifact end-to-end against the *embedded* pubkey (`minisign -Vm bin -P <pub>`) plus a tampered-byte negative control — a green build that signs is not the same as a signature the agent will actually accept.

View File

@@ -37,10 +37,25 @@ 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';
import { FleetModule } from './modules/fleet/fleet.module';
import { InstancesModule } from './modules/instances/instances.module';
// Shared Services
import { NatsService } from './services/nats.service';
import { NatsBridgeService } from './services/nats-bridge.service';
import { HostAgentConsumerService } from './services/host-agent-consumer.service';
import { ServerConnection } from './entities/server-connection.entity';
import { License } from './entities/license.entity';
import { AgentHost } from './entities/agent-host.entity';
import { GameInstance } from './entities/game-instance.entity';
import { SteamService } from './services/steam.service';
// Gateway
@@ -83,6 +98,9 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
// Scheduler
ScheduleModule.forRoot(),
// Repositories for app-level shared services (host-agent consumer)
TypeOrmModule.forFeature([ServerConnection, License, AgentHost, GameInstance]),
// Feature Modules
AuthModule,
UsersModule,
@@ -109,6 +127,16 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
FilesModule,
LootModule,
TeleportModule,
GatherModule,
AutoDoorsModule,
KitsModule,
FurnaceSplitterModule,
BetterChatModule,
TimedExecuteModule,
RaidableBasesModule,
EarlyAccessModule,
FleetModule,
InstancesModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)
@@ -118,6 +146,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
// Shared services
NatsService,
NatsBridgeService,
HostAgentConsumerService,
SteamService,
// WebSocket gateway

View File

@@ -6,6 +6,15 @@ export default () => ({
},
nats: {
url: process.env.NATS_URL || 'nats://localhost:4222',
// Public broker address shown to agents in setup instructions.
publicUrl: process.env.NATS_PUBLIC_URL || 'nats://nats.corrosionmgmt.com:4222',
// Privileged internal credentials for the backend's own NATS connection
// (full corrosion.> access). Empty = anonymous (transition period).
internalUser: process.env.NATS_INTERNAL_USER || '',
internalPassword: process.env.NATS_INTERNAL_PASSWORD || '',
// Secret used to derive a per-license agent password:
// HMAC-SHA256(license_id, secret). Shared with the nats.conf generator.
tokenSecret: process.env.NATS_TOKEN_SECRET || '',
},
jwt: {
secret: process.env.JWT_SECRET || 'change-me',

View File

@@ -0,0 +1,74 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Check, Unique } from 'typeorm';
import { License } from './license.entity';
export interface AgentHostDisk {
mount: string;
total_mb: number;
free_mb: number;
}
/**
* One Corrosion host agent / one machine. Owns the machine-level facts.
*
* NOTE: distinct from the B2B `hosts` table (hosting-partner companies). This
* is `agent_hosts` — the physical/virtual box a customer runs the agent on.
*/
@Entity('agent_hosts')
@Unique(['license_id', 'hostname'])
@Check(`"status" IN ('connected', 'degraded', 'offline')`)
export class AgentHost {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 255, default: '' })
hostname: string;
@Column({ type: 'varchar', length: 64, nullable: true })
agent_version: string | null;
@Column({ type: 'varchar', length: 64, nullable: true })
agent_commit: string | null;
@Column({ type: 'varchar', length: 32, nullable: true })
os: string | null;
@Column({ type: 'varchar', length: 32, nullable: true })
arch: string | null;
@Column({ type: 'varchar', length: 20, default: 'offline' })
status: string;
@Column({ type: 'timestamptz', nullable: true })
last_heartbeat_at: Date | null;
@Column({ type: 'double precision', nullable: true })
cpu_percent: number | null;
@Column({ type: 'integer', nullable: true })
cpu_cores: number | null;
@Column({ type: 'bigint', nullable: true })
mem_total_mb: number | null;
@Column({ type: 'bigint', nullable: true })
mem_used_mb: number | null;
@Column({ type: 'bigint', nullable: true })
uptime_seconds: number | null;
@Column({ type: 'jsonb', nullable: true })
disks: AgentHostDisk[] | null;
@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('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,59 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { License } from './license.entity';
import { AgentHost } from './agent-host.entity';
/**
* One game server process / orchestrated unit (a Rust server, a Conan world,
* a Dune battlegroup). The billing unit — plans count instances.
* `agent_instance_id` is the agent's slug and the NATS subject segment.
*/
@Entity('game_instances')
@Unique(['license_id', 'agent_instance_id'])
export class GameInstance {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'uuid', nullable: true })
host_id: string | null;
@Column({ type: 'uuid', nullable: true })
cluster_id: string | null;
@Column({ type: 'varchar', length: 64 })
agent_instance_id: string;
@Column({ type: 'varchar', length: 32 })
game: string;
@Column({ type: 'varchar', length: 255, nullable: true })
label: string | null;
@Column({ type: 'varchar', length: 32, default: 'unknown' })
state: string;
@Column({ type: 'text', nullable: true })
root_path: string | null;
@Column({ type: 'bigint', default: 0 })
uptime_seconds: number;
@Column({ type: 'timestamptz', nullable: true })
last_seen_at: Date | null;
@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;
@ManyToOne(() => AgentHost, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'host_id' })
host: AgentHost | null;
}

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,38 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
/**
* Optional grouping of instances for games with linked topologies:
* Soulmask main/child clusters, Dune BattleGroup → Sietches. Reserved now;
* cluster orchestration ships with those game adapters.
*/
@Entity('instance_clusters')
export class InstanceCluster {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 32 })
game: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'varchar', length: 32, nullable: true })
topology: string | null;
@Column({ type: 'jsonb', nullable: true })
config: Record<string, unknown> | null;
@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,38 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { GameInstance } from './game-instance.entity';
/**
* Per-instance time-series game metrics (player count, FPS, …). Populated once
* game-level telemetry is collected via RCON/plugin — the host heartbeat
* carries host metrics, not game metrics, so this stays empty in Phase A.
*/
@Entity('instance_stats')
export class InstanceStats {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
instance_id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'integer', default: 0 })
player_count: number;
@Column({ type: 'integer', default: 0 })
max_players: number;
@Column({ type: 'double precision', default: 0 })
fps: number;
@Column({ type: 'integer', default: 0 })
memory_usage_mb: number;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
recorded_at: Date;
@ManyToOne(() => GameInstance, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'instance_id' })
instance: GameInstance;
}

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,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('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

@@ -71,7 +71,10 @@ export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconne
// Subscribe to NATS events for this license
const listener = (event: string, data: unknown) => {
if (client.readyState === WebSocket.OPEN) {
// client.OPEN (instance constant) — NOT WebSocket.OPEN: with
// esModuleInterop off, the default `ws` import is undefined at
// runtime, so the static crashes. The instance constant is safe.
if (client.readyState === client.OPEN) {
client.send(JSON.stringify({
type: 'event',
license_id: payload.license_id,

View File

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

View File

@@ -0,0 +1,82 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
import { User } from '../../entities/user.entity';
import { License } from '../../entities/license.entity';
/**
* Bootstraps the first admin account on a fresh database.
*
* A fresh deploy builds the schema via docker-entrypoint-initdb.d but contains
* zero users, so the panel has no possible login. If ADMIN_EMAIL and
* ADMIN_PASSWORD are set and the users table is empty, this creates a
* super-admin user plus an active license — the same rows the register flow
* would create. It never runs against a database that already has users.
*/
@Injectable()
export class AdminSeedService implements OnApplicationBootstrap {
private readonly logger = new Logger(AdminSeedService.name);
constructor(
private readonly config: ConfigService,
@InjectRepository(User) private readonly userRepository: Repository<User>,
@InjectRepository(License) private readonly licenseRepository: Repository<License>,
) {}
async onApplicationBootstrap(): Promise<void> {
try {
await this.seedAdminIfEmpty();
} catch (err) {
// A failed seed must not take the API down — surface it loudly and move on
this.logger.error(`Admin bootstrap failed: ${(err as Error).message}`, (err as Error).stack);
}
}
private async seedAdminIfEmpty(): Promise<void> {
const email = this.config.get<string>('admin.email');
const password = this.config.get<string>('admin.password');
const username = this.config.get<string>('admin.username') || 'Commander';
if (!email || !password) {
this.logger.log('Admin bootstrap skipped: ADMIN_EMAIL / ADMIN_PASSWORD not set');
return;
}
const userCount = await this.userRepository.count();
if (userCount > 0) {
return;
}
const password_hash = await argon2.hash(password);
const user = this.userRepository.create({
email: email.toLowerCase(),
username,
password_hash,
email_verified: true,
is_super_admin: true,
});
await this.userRepository.save(user);
const licenseKey = this.config.get<string>('admin.licenseKey') || this.generateLicenseKey();
const license = this.licenseRepository.create({
license_key: licenseKey,
owner_user_id: user.id,
status: 'active',
modules_enabled: [],
webstore_active: false,
});
await this.licenseRepository.save(license);
this.logger.log(`Bootstrap admin created: ${user.email} (license ${license.license_key})`);
}
private generateLicenseKey(): string {
const part1 = randomBytes(2).toString('hex').toUpperCase();
const part2 = randomBytes(2).toString('hex').toUpperCase();
const part3 = randomBytes(2).toString('hex').toUpperCase();
return `CORR-${part1}-${part2}-${part3}`;
}
}

View File

@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AdminSeedService } from './admin-seed.service';
import { JwtStrategy } from './jwt.strategy';
import { User } from '../../entities/user.entity';
import { License } from '../../entities/license.entity';
@@ -27,7 +28,7 @@ import { TeamMember } from '../../entities/team-member.entity';
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
providers: [AuthService, AdminSeedService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

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,165 @@
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 { InstancesService } from '../instances/instances.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 instancesService: InstancesService,
) {}
/** 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 Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/AutoDoors.json',
jsonString,
);
// Reload AutoDoors plugin via RCON
await this.instancesService.rconForLicense(licenseId, 'oxide.reload AutoDoors');
// 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 Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/AutoDoors.json',
);
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} 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,165 @@
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 { InstancesService } from '../instances/instances.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 instancesService: InstancesService,
) {}
/** 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 Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/BetterChat.json',
jsonString,
);
// Reload BetterChat plugin via RCON
await this.instancesService.rconForLicense(licenseId, 'oxide.reload BetterChat');
// 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 Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/BetterChat.json',
);
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} 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

@@ -108,7 +108,9 @@ export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect
const message = JSON.stringify({ event, data });
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
// client.OPEN, not WebSocket.OPEN — esModuleInterop is off so the
// default `ws` import is undefined at runtime (would crash on forward).
if (client.readyState === client.OPEN) {
client.send(message);
}
}

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,26 @@
import { Controller, Get, Delete, Param } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { FleetService } from './fleet.service';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('fleet')
@ApiBearerAuth()
@Controller('fleet')
export class FleetController {
constructor(private readonly fleetService: FleetService) {}
@Get()
@RequirePermission('server.view')
@ApiOperation({ summary: 'Get fleet overview — hosts and game instances for this license' })
async getFleet(@CurrentTenant() licenseId: string) {
return this.fleetService.getFleet(licenseId);
}
@Delete('hosts/:id')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Remove a host and its instances (host must be offline)' })
async deleteHost(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.fleetService.deleteHost(licenseId, id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FleetController } from './fleet.controller';
import { FleetService } from './fleet.service';
import { AgentHost } from '../../entities/agent-host.entity';
import { GameInstance } from '../../entities/game-instance.entity';
import { ServerConnection } from '../../entities/server-connection.entity';
@Module({
imports: [TypeOrmModule.forFeature([AgentHost, GameInstance, ServerConnection])],
controllers: [FleetController],
providers: [FleetService],
exports: [FleetService],
})
export class FleetModule {}

View File

@@ -0,0 +1,170 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AgentHost } from '../../entities/agent-host.entity';
import { GameInstance } from '../../entities/game-instance.entity';
import { ServerConnection } from '../../entities/server-connection.entity';
export interface FleetInstanceDto {
id: string;
agent_instance_id: string;
game: string;
label: string | null;
state: string;
uptime_seconds: number;
last_seen_at: string | null;
}
export interface FleetHostDto {
id: string;
hostname: string;
status: string;
agent_version: string | null;
os: string | null;
arch: string | null;
cpu_percent: number | null;
cpu_cores: number | null;
mem_total_mb: number | null;
mem_used_mb: number | null;
uptime_seconds: number | null;
disks: AgentHost['disks'];
last_heartbeat_at: string | null;
instances: FleetInstanceDto[];
}
export interface FleetSummaryDto {
host_count: number;
instance_count: number;
online_host_count: number;
}
export interface FleetResponseDto {
hosts: FleetHostDto[];
summary: FleetSummaryDto;
}
@Injectable()
export class FleetService {
constructor(
@InjectRepository(AgentHost)
private readonly hostRepo: Repository<AgentHost>,
@InjectRepository(GameInstance)
private readonly instanceRepo: Repository<GameInstance>,
@InjectRepository(ServerConnection)
private readonly connectionRepo: Repository<ServerConnection>,
) {}
/**
* Remove a host and its game instances from the fleet.
*
* Refuses while the host is `connected` — a live agent re-registers on its
* next heartbeat, so the operator must stop the agent first. Deletes the
* host's instances explicitly (the FK is SET NULL, which would otherwise
* orphan them); instance_stats cascade. If this was the license's last host,
* the legacy single-server connection row is cleared too so the old
* Dashboard doesn't show a stale server.
*/
async deleteHost(
licenseId: string,
hostId: string,
): Promise<{ deleted: true; instances_removed: number }> {
const host = await this.hostRepo.findOne({ where: { id: hostId, license_id: licenseId } });
if (!host) throw new NotFoundException('Host not found');
if (host.status === 'connected') {
throw new ConflictException(
'Host is online — stop the agent first, or it will re-register on its next heartbeat',
);
}
const del = await this.instanceRepo.delete({ license_id: licenseId, host_id: hostId });
await this.hostRepo.delete({ id: hostId, license_id: licenseId });
const remaining = await this.hostRepo.count({ where: { license_id: licenseId } });
if (remaining === 0) {
await this.connectionRepo.delete({ license_id: licenseId });
}
return { deleted: true, instances_removed: del.affected ?? 0 };
}
async getFleet(licenseId: string): Promise<FleetResponseDto> {
const [hosts, instances] = await Promise.all([
this.hostRepo.find({
where: { license_id: licenseId },
order: { hostname: 'ASC' },
}),
this.instanceRepo.find({
where: { license_id: licenseId },
order: { game: 'ASC', label: 'ASC' },
}),
]);
// Group instances by host_id. Bigint columns come back as strings from pg — coerce.
const instancesByHost = new Map<string | null, FleetInstanceDto[]>();
for (const inst of instances) {
const key = inst.host_id ?? null;
if (!instancesByHost.has(key)) {
instancesByHost.set(key, []);
}
instancesByHost.get(key)!.push({
id: inst.id,
agent_instance_id: inst.agent_instance_id,
game: inst.game,
label: inst.label,
state: inst.state,
uptime_seconds: Number(inst.uptime_seconds),
last_seen_at: inst.last_seen_at ? inst.last_seen_at.toISOString() : null,
});
}
const hostDtos: FleetHostDto[] = hosts.map((h) => ({
id: h.id,
hostname: h.hostname,
status: h.status,
agent_version: h.agent_version,
os: h.os,
arch: h.arch,
cpu_percent: h.cpu_percent !== null && h.cpu_percent !== undefined ? Number(h.cpu_percent) : null,
cpu_cores: h.cpu_cores !== null && h.cpu_cores !== undefined ? Number(h.cpu_cores) : null,
mem_total_mb: h.mem_total_mb !== null && h.mem_total_mb !== undefined ? Number(h.mem_total_mb) : null,
mem_used_mb: h.mem_used_mb !== null && h.mem_used_mb !== undefined ? Number(h.mem_used_mb) : null,
uptime_seconds: h.uptime_seconds !== null && h.uptime_seconds !== undefined ? Number(h.uptime_seconds) : null,
disks: h.disks,
last_heartbeat_at: h.last_heartbeat_at ? h.last_heartbeat_at.toISOString() : null,
instances: instancesByHost.get(h.id) ?? [],
}));
// Append synthetic "unassigned" bucket only if orphaned instances exist
const unassigned = instancesByHost.get(null) ?? [];
if (unassigned.length > 0) {
hostDtos.push({
id: '__unassigned__',
hostname: 'Unassigned',
status: 'offline',
agent_version: null,
os: null,
arch: null,
cpu_percent: null,
cpu_cores: null,
mem_total_mb: null,
mem_used_mb: null,
uptime_seconds: null,
disks: null,
last_heartbeat_at: null,
instances: unassigned,
});
}
const online_host_count = hosts.filter((h) => h.status === 'connected').length;
const instance_count = instances.length;
return {
hosts: hostDtos,
summary: {
host_count: hosts.length,
instance_count,
online_host_count,
},
};
}
}

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,165 @@
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 { InstancesService } from '../instances/instances.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 instancesService: InstancesService,
) {}
/** 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 Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/FurnaceSplitter.json',
jsonString,
);
// Reload FurnaceSplitter plugin via RCON
await this.instancesService.rconForLicense(licenseId, 'oxide.reload FurnaceSplitter');
// 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 Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/FurnaceSplitter.json',
);
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} 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,165 @@
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 { InstancesService } from '../instances/instances.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 instancesService: InstancesService,
) {}
/** 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 Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/GatherManager.json',
jsonString,
);
// Reload GatherManager plugin via RCON
await this.instancesService.rconForLicense(licenseId, 'oxide.reload GatherManager');
// 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 Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/GatherManager.json',
);
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} 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,133 @@
import { Controller, Post, Get, Put, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { InstancesService, LifecycleFunc } from './instances.service';
@ApiTags('instances')
@ApiBearerAuth()
@Controller('instances')
export class InstancesController {
constructor(private readonly instances: InstancesService) {}
@Post(':id/lifecycle')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Send a lifecycle command to a game instance (start/stop/restart/status/steam_update)' })
async lifecycle(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { action: LifecycleFunc },
) {
return this.instances.lifecycle(licenseId, id, body.action);
}
@Post(':id/rcon')
@RequirePermission('server.console')
@ApiOperation({ summary: 'Send an RCON/console command to a game instance' })
async rcon(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { command: string },
) {
return this.instances.rcon(licenseId, id, body.command);
}
@Get(':id/files')
@RequirePermission('files.view')
@ApiOperation({ summary: 'List a directory in the instance (jailed to its root)' })
async listFiles(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Query('path') path?: string,
) {
return this.instances.listFiles(licenseId, id, path ?? '');
}
@Get(':id/file')
@RequirePermission('files.view')
@ApiOperation({ summary: 'Read a text file from the instance (jailed, 5 MiB cap)' })
async readFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Query('path') path: string,
) {
return this.instances.readFile(licenseId, id, path);
}
@Put(':id/file')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Write a text file in the instance (jailed)' })
async writeFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string; content: string },
) {
return this.instances.writeFile(licenseId, id, body.path, body.content ?? '');
}
@Post(':id/files/delete')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Delete a file or directory (jailed)' })
async deleteFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string },
) {
return this.instances.deleteFile(licenseId, id, body.path);
}
@Post(':id/files/rename')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Rename a file/directory within its parent (jailed)' })
async renameFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string; name: string },
) {
return this.instances.renameFile(licenseId, id, body.path, body.name);
}
@Post(':id/files/mkdir')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Create a directory (jailed)' })
async mkdir(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string },
) {
return this.instances.mkdir(licenseId, id, body.path);
}
@Post(':id/files/mkfile')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Create an empty file (jailed)' })
async mkfile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string },
) {
return this.instances.mkfile(licenseId, id, body.path);
}
@Post(':id/files/move')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Move a file/directory (jailed)' })
async moveFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string; dest: string },
) {
return this.instances.moveFile(licenseId, id, body.path, body.dest);
}
@Post(':id/files/copy')
@RequirePermission('files.manage')
@ApiOperation({ summary: 'Copy a file/directory (jailed)' })
async copyFile(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() body: { path: string; dest: string },
) {
return this.instances.copyFile(licenseId, id, body.path, body.dest);
}
}

View File

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

View File

@@ -0,0 +1,223 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NatsService } from '../../services/nats.service';
import { GameInstance } from '../../entities/game-instance.entity';
/** Lifecycle funcs the agent's {instance}.cmd handler accepts. */
const LIFECYCLE_FUNCS = ['start', 'stop', 'restart', 'status', 'steam_update'] as const;
export type LifecycleFunc = (typeof LIFECYCLE_FUNCS)[number];
@Injectable()
export class InstancesService {
private readonly logger = new Logger(InstancesService.name);
constructor(
private readonly nats: NatsService,
@InjectRepository(GameInstance)
private readonly instanceRepo: Repository<GameInstance>,
) {}
/** Resolve an instance the caller's license actually owns (tenant guard). */
private async resolveInstance(licenseId: string, instanceId: string): Promise<GameInstance> {
const inst = await this.instanceRepo.findOne({
where: { id: instanceId, license_id: licenseId },
});
if (!inst) throw new NotFoundException('Instance not found');
return inst;
}
async lifecycle(licenseId: string, instanceId: string, func: LifecycleFunc): Promise<unknown> {
if (!LIFECYCLE_FUNCS.includes(func)) {
throw new BadRequestException(`Unsupported action '${func}'`);
}
const inst = await this.resolveInstance(licenseId, instanceId);
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
this.logger.log(`instance ${inst.agent_instance_id}: ${func}`);
return this.nats.requestScoped(licenseId, subject, { func });
}
async rcon(licenseId: string, instanceId: string, command: string): Promise<unknown> {
if (!command || !command.trim()) {
throw new BadRequestException('command is required');
}
const inst = await this.resolveInstance(licenseId, instanceId);
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
// RCON can take longer than a lifecycle ack — give it more headroom.
return this.nats.requestScoped(licenseId, subject, { func: 'rcon', command }, 12_000);
}
// -------------------------------------------------------------------------
// File access — jailed to the instance root by the agent's file manager.
// The agent protocol (corrosion-host-agent/src/filemanager.rs):
// { op: list|read|write|delete|rename|mkdir|mkfile|move|copy, path, ... }
// reply: { status: 'success'|'error', data?, message? }
// -------------------------------------------------------------------------
private filesSubject(inst: GameInstance, licenseId: string): string {
return `corrosion.${licenseId}.${inst.agent_instance_id}.files.cmd`;
}
private async fileOp(
licenseId: string,
instanceId: string,
payload: Record<string, unknown>,
): Promise<{ status: string; data?: unknown; message?: string }> {
const inst = await this.resolveInstance(licenseId, instanceId);
const res = await this.nats.requestScoped<{ status: string; data?: unknown; message?: string }>(
licenseId,
this.filesSubject(inst, licenseId),
payload,
12_000,
);
if (res?.status === 'error') {
throw new BadRequestException(res.message ?? 'File operation failed');
}
return res;
}
async listFiles(licenseId: string, instanceId: string, path = ''): Promise<unknown> {
const res = await this.fileOp(licenseId, instanceId, { op: 'list', path });
return res.data;
}
async readFile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
if (!path) throw new BadRequestException('path is required');
const res = await this.fileOp(licenseId, instanceId, { op: 'read', path });
return res.data;
}
async writeFile(
licenseId: string,
instanceId: string,
path: string,
content: string,
): Promise<unknown> {
if (!path) throw new BadRequestException('path is required');
const res = await this.fileOp(licenseId, instanceId, { op: 'write', path, content });
return res.data ?? { status: 'success' };
}
async deleteFile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
if (!path) throw new BadRequestException('path is required');
return (await this.fileOp(licenseId, instanceId, { op: 'delete', path })).data ?? { ok: true };
}
async renameFile(
licenseId: string,
instanceId: string,
path: string,
name: string,
): Promise<unknown> {
if (!path || !name) throw new BadRequestException('path and name are required');
return (await this.fileOp(licenseId, instanceId, { op: 'rename', path, name })).data ?? { ok: true };
}
async mkdir(licenseId: string, instanceId: string, path: string): Promise<unknown> {
if (!path) throw new BadRequestException('path is required');
return (await this.fileOp(licenseId, instanceId, { op: 'mkdir', path })).data ?? { ok: true };
}
async mkfile(licenseId: string, instanceId: string, path: string): Promise<unknown> {
if (!path) throw new BadRequestException('path is required');
return (await this.fileOp(licenseId, instanceId, { op: 'mkfile', path })).data ?? { ok: true };
}
async moveFile(
licenseId: string,
instanceId: string,
path: string,
dest: string,
): Promise<unknown> {
if (!path || !dest) throw new BadRequestException('path and dest are required');
return (await this.fileOp(licenseId, instanceId, { op: 'move', path, dest })).data ?? { ok: true };
}
async copyFile(
licenseId: string,
instanceId: string,
path: string,
dest: string,
): Promise<unknown> {
if (!path || !dest) throw new BadRequestException('path and dest are required');
return (await this.fileOp(licenseId, instanceId, { op: 'copy', path, dest })).data ?? { ok: true };
}
/**
* Wipe an instance's game data via the agent's jailed wipe handler: stop →
* delete files per wipe_type (map/blueprint/full) → restart. Long timeout
* because the agent does all three steps before replying.
*/
async wipe(
licenseId: string,
instanceId: string,
wipeType: 'map' | 'blueprint' | 'full',
backup = true,
): Promise<unknown> {
const inst = await this.resolveInstance(licenseId, instanceId);
const subject = `corrosion.${licenseId}.${inst.agent_instance_id}.cmd`;
this.logger.log(`instance ${inst.agent_instance_id}: wipe (${wipeType})`);
return this.nats.requestScoped(
licenseId,
subject,
{ func: 'wipe', wipe_type: wipeType, backup },
120_000,
);
}
// -------------------------------------------------------------------------
// License-scoped convenience wrappers. Legacy single-server services
// (servers/players/schedules/wipes/plugins + the 9 plugin-config modules)
// predate the instance model and carry only a licenseId. These resolve the
// license's primary instance, then dispatch to the agent — replacing the old
// publishes to the now-defunct `cmd.server` subject.
// -------------------------------------------------------------------------
/** The license's primary (oldest) instance. Throws if none is connected. */
async resolveDefaultInstance(licenseId: string): Promise<GameInstance> {
const inst = await this.instanceRepo.findOne({
where: { license_id: licenseId },
order: { created_at: 'ASC' },
});
if (!inst) {
throw new NotFoundException(
'No game instance is connected for this license yet — install and start the host agent first.',
);
}
return inst;
}
async lifecycleForLicense(licenseId: string, func: LifecycleFunc): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.lifecycle(licenseId, inst.id, func);
}
async rconForLicense(licenseId: string, command: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.rcon(licenseId, inst.id, command);
}
async writeFileForLicense(licenseId: string, path: string, content: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.writeFile(licenseId, inst.id, path, content);
}
async readFileForLicense(licenseId: string, path: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.readFile(licenseId, inst.id, path);
}
async deleteFileForLicense(licenseId: string, path: string): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.deleteFile(licenseId, inst.id, path);
}
async wipeForLicense(
licenseId: string,
wipeType: 'map' | 'blueprint' | 'full',
backup = true,
): Promise<unknown> {
const inst = await this.resolveDefaultInstance(licenseId);
return this.wipe(licenseId, inst.id, wipeType, backup);
}
}

View File

@@ -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,165 @@
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 { InstancesService } from '../instances/instances.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 instancesService: InstancesService,
) {}
/** 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 Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/Kits.json',
jsonString,
);
// Reload Kits plugin via RCON
await this.instancesService.rconForLicense(licenseId, 'oxide.reload Kits');
// 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 Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/Kits.json',
);
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} 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

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

View File

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

View File

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

@@ -10,7 +10,7 @@ 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';
import { InstancesService } from '../instances/instances.service';
/** Parse a 5-field cron expression and return the next Date after `after`. */
function nextCronDate(expr: string, after: Date): Date | null {
@@ -61,7 +61,7 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
constructor(
@InjectRepository(ScheduledTask)
private taskRepository: Repository<ScheduledTask>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
// ---------------------------------------------------------------------------
@@ -160,21 +160,12 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
switch (task_type) {
case 'restart':
await this.natsService.sendServerCommand(license_id, 'restart', {
source: 'scheduler',
task_id: task.id,
});
await this.instancesService.lifecycleForLicense(license_id, 'restart');
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(),
});
await this.instancesService.rconForLicense(license_id, `say ${message}`);
break;
}
@@ -184,25 +175,13 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
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(),
});
await this.instancesService.rconForLicense(license_id, command);
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(),
});
await this.instancesService.rconForLicense(license_id, `oxide.reload ${plugin_name}`);
break;
}

View File

@@ -23,6 +23,13 @@ export class ServersController {
return await this.serversService.getServer(licenseId);
}
@Get('agent-credentials')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'NATS credentials for this license\'s host agent' })
async getAgentCredentials(@CurrentTenant() licenseId: string) {
return await this.serversService.getAgentCredentials(licenseId);
}
@Put('config')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Update server configuration' })

View File

@@ -1,9 +1,10 @@
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, InternalServerErrorException, ServiceUnavailableException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ServerConnection } from '../../entities/server-connection.entity';
import { ServerConfig } from '../../entities/server-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { UpdateServerConfigDto } from './dto/update-config.dto';
import { DeployServerDto } from './dto/deploy-server.dto';
@@ -17,8 +18,18 @@ export class ServersService {
@InjectRepository(ServerConfig)
private readonly configRepo: Repository<ServerConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/**
* NATS credentials the customer puts in their host agent's config so it can
* authenticate to the per-license-scoped broker. Returns null if the broker
* isn't enforcing auth yet (NATS_TOKEN_SECRET unset).
*/
async getAgentCredentials(licenseId: string) {
return this.natsService.getAgentCredentials(licenseId);
}
/**
* Get server connection and config for a license.
* Returns null fields if no server has been set up yet.
@@ -59,11 +70,11 @@ export class ServersService {
}
/**
* Send a console command to the server via NATS
* Send a console command to the server via the host agent (RCON)
*/
async sendCommand(licenseId: string, command: string) {
try {
await this.natsService.sendServerCommand(licenseId, 'command', { command });
await this.instancesService.rconForLicense(licenseId, 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}`);
@@ -73,42 +84,45 @@ export class ServersService {
}
/**
* Start the server via NATS
* Start the server via the host agent
*/
async startServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'start');
await this.instancesService.lifecycleForLicense(licenseId, 'start');
return { message: 'Start command sent' };
}
/**
* Stop the server via NATS
* Stop the server via the host agent
*/
async stopServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'stop');
await this.instancesService.lifecycleForLicense(licenseId, 'stop');
return { message: 'Stop command sent' };
}
/**
* Restart the server via NATS
* Restart the server via the host agent
*/
async restartServer(licenseId: string) {
await this.natsService.sendServerCommand(licenseId, 'restart');
await this.instancesService.lifecycleForLicense(licenseId, 'restart');
return { message: 'Restart command sent' };
}
/**
* Deploy Rust server via companion agent
* Deploy Rust server — not yet supported via host agent.
* Install the server manually and point the host agent at it.
*/
async deployServer(licenseId: string, dto: DeployServerDto) {
await this.natsService.sendDeployCommand(licenseId, { ...dto });
return { message: 'Deployment started' };
async deployServer(_licenseId: string, _dto: DeployServerDto) {
throw new ServiceUnavailableException(
'Server deployment from the panel is coming soon — install the server and point the host agent at it for now.',
);
}
/**
* Install Oxide/uMod via companion agent
* Install Oxide/uMod — not yet supported via host agent.
*/
async installOxide(licenseId: string) {
await this.natsService.sendOxideInstallCommand(licenseId);
return { message: 'Oxide installation started' };
async installOxide(_licenseId: string) {
throw new ServiceUnavailableException(
'Oxide install from the panel is coming soon — install Oxide/uMod on the server for now.',
);
}
}

View File

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

View File

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

View File

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

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,165 @@
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 { InstancesService } from '../instances/instances.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 instancesService: InstancesService,
) {}
/** 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 Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/TimedExecute.json',
jsonString,
);
// Reload TimedExecute plugin via RCON
await this.instancesService.rconForLicense(licenseId, 'oxide.reload TimedExecute');
// 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 Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/TimedExecute.json',
);
if (!result) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = (result as any).content;
let configData: Record<string, any>;
if (typeof responseData === 'string') {
configData = JSON.parse(responseData);
} else if (typeof responseData === 'object') {
configData = responseData;
} 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,
);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,267 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NatsService } from './nats.service';
import { ServerConnection } from '../entities/server-connection.entity';
import { License } from '../entities/license.entity';
import { AgentHost, AgentHostDisk } from '../entities/agent-host.entity';
import { GameInstance } from '../entities/game-instance.entity';
/**
* Consumes Corrosion wire protocol v2 host-agent subjects
* (corrosion-host-agent/PROTOCOL.md) and keeps the fleet model truthful.
*
* Writes the License → Host → Instance model (hosts + game_instances) from
* each heartbeat, AND maintains the legacy single-server `server_connections`
* row so the current panel keeps working during the fleet UI transition.
*
* Host identity: until enrollment issues a stable host id, a host is keyed by
* (license_id, hostname). One agent = one host today; the schema is already
* multi-host-ready.
*/
interface HeartbeatPayload {
schema?: number;
timestamp?: string;
agent?: { version?: string; commit?: string; os?: string; arch?: string };
host?: {
hostname?: string | null;
cpu_percent?: number;
cpu_cores?: number;
mem_total_mb?: number;
mem_used_mb?: number;
uptime_seconds?: number;
disks?: AgentHostDisk[];
};
instances?: Array<{
id: string;
game: string;
label?: string | null;
state?: string;
uptime_seconds?: number;
}>;
}
@Injectable()
export class HostAgentConsumerService implements OnApplicationBootstrap {
private readonly logger = new Logger(HostAgentConsumerService.name);
private knownLicenses = new Map<string, number>();
private warnedUnknown = new Set<string>();
private static readonly UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
private static readonly LICENSE_CACHE_TTL_MS = 5 * 60_000;
private static readonly OFFLINE_AFTER_MS = 180_000;
constructor(
private readonly nats: NatsService,
@InjectRepository(ServerConnection)
private readonly connectionRepository: Repository<ServerConnection>,
@InjectRepository(License)
private readonly licenseRepository: Repository<License>,
@InjectRepository(AgentHost)
private readonly hostRepository: Repository<AgentHost>,
@InjectRepository(GameInstance)
private readonly instanceRepository: Repository<GameInstance>,
) {}
// Bootstrap, not module-init: subscriptions registered before NatsService
// finished connecting silently no-op (see NatsBridgeService note).
onApplicationBootstrap() {
this.nats.subscribe('corrosion.*.host.heartbeat', (data, subject) => {
const licenseId = subject.split('.')[1];
void this.onHeartbeat(licenseId, data as HeartbeatPayload).catch((err) =>
this.logger.error(`heartbeat handling failed for ${licenseId}: ${err.message}`, err.stack),
);
});
this.nats.subscribe('corrosion.*.host.going_offline', (_data, subject) => {
const licenseId = subject.split('.')[1];
void this.onGoingOffline(licenseId).catch((err) =>
this.logger.error(`going_offline handling failed for ${licenseId}: ${err.message}`, err.stack),
);
});
this.logger.log('Host agent (protocol v2) consumer subscriptions initialized');
}
private async onHeartbeat(licenseId: string, payload: HeartbeatPayload): Promise<void> {
if (!(await this.isValidTenant(licenseId))) return;
// A well-formed v2 heartbeat always carries a host block. Reject malformed
// payloads so a stray/empty publish can't create a phantom host row.
if (!payload || typeof payload.host !== 'object' || payload.host === null) {
this.logger.warn(`ignoring malformed heartbeat for license ${licenseId} (no host block)`);
return;
}
const now = new Date();
await this.updateLegacyConnection(licenseId, now);
const host = await this.upsertHost(licenseId, payload, now);
await this.upsertInstances(licenseId, host, payload, now);
}
/** Legacy single-server row — keeps the current panel working. */
private async updateLegacyConnection(licenseId: string, now: Date): Promise<void> {
const existing = await this.connectionRepository.findOne({ where: { license_id: licenseId } });
if (existing) {
await this.connectionRepository.update(
{ id: existing.id },
{ companion_last_seen: now, connection_status: 'connected', updated_at: now },
);
} else {
await this.connectionRepository.save(
this.connectionRepository.create({
license_id: licenseId,
connection_type: 'bare_metal',
connection_status: 'connected',
companion_last_seen: now,
}),
);
}
}
/** Upsert the fleet host row, keyed by (license_id, hostname). */
private async upsertHost(licenseId: string, payload: HeartbeatPayload, now: Date): Promise<AgentHost> {
const hostname = payload.host?.hostname ?? '';
const fields = {
agent_version: payload.agent?.version ?? null,
agent_commit: payload.agent?.commit ?? null,
os: payload.agent?.os ?? null,
arch: payload.agent?.arch ?? null,
status: 'connected',
last_heartbeat_at: now,
cpu_percent: payload.host?.cpu_percent ?? null,
cpu_cores: payload.host?.cpu_cores ?? null,
mem_total_mb: payload.host?.mem_total_mb ?? null,
mem_used_mb: payload.host?.mem_used_mb ?? null,
uptime_seconds: payload.host?.uptime_seconds ?? null,
disks: payload.host?.disks ?? null,
updated_at: now,
};
const existing = await this.hostRepository.findOne({
where: { license_id: licenseId, hostname },
});
if (existing) {
await this.hostRepository.update({ id: existing.id }, fields);
return { ...existing, ...fields } as AgentHost;
}
const created = await this.hostRepository.save(
this.hostRepository.create({ license_id: licenseId, hostname, ...fields }),
);
this.logger.log(`host registered for license ${licenseId} (hostname '${hostname || 'unknown'}')`);
return created;
}
/** Upsert one game_instances row per heartbeat instance entry. */
private async upsertInstances(
licenseId: string,
host: AgentHost,
payload: HeartbeatPayload,
now: Date,
): Promise<void> {
for (const inst of payload.instances ?? []) {
if (!inst?.id || !inst?.game) continue;
const fields = {
host_id: host.id,
game: inst.game,
label: inst.label ?? null,
state: inst.state ?? 'unknown',
uptime_seconds: inst.uptime_seconds ?? 0,
last_seen_at: now,
updated_at: now,
};
const existing = await this.instanceRepository.findOne({
where: { license_id: licenseId, agent_instance_id: inst.id },
});
if (existing) {
await this.instanceRepository.update({ id: existing.id }, fields);
} else {
await this.instanceRepository.save(
this.instanceRepository.create({
license_id: licenseId,
agent_instance_id: inst.id,
...fields,
}),
);
this.logger.log(`instance '${inst.id}' (${inst.game}) registered for license ${licenseId}`);
}
}
}
private async onGoingOffline(licenseId: string): Promise<void> {
if (!(await this.isValidTenant(licenseId))) return;
const now = new Date();
await this.connectionRepository.update(
{ license_id: licenseId },
{ connection_status: 'offline', updated_at: now },
);
await this.hostRepository.update(
{ license_id: licenseId },
{ status: 'offline', updated_at: now },
);
this.logger.log(`host(s) for license ${licenseId} went offline (graceful beacon)`);
}
/**
* Heartbeats stopping must flip the panel to offline — an agent that
* crashes or loses network never sends the goodbye beacon. Sweeps both the
* legacy connection and fleet hosts.
*/
@Interval(60_000)
async sweepStaleConnections(): Promise<void> {
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
const conn = await this.connectionRepository
.createQueryBuilder()
.update(ServerConnection)
.set({ connection_status: 'offline', updated_at: () => 'NOW()' })
.where('connection_status = :connected', { connected: 'connected' })
.andWhere('companion_last_seen IS NOT NULL')
.andWhere('companion_last_seen < :threshold', { threshold })
.execute();
const hosts = await this.hostRepository
.createQueryBuilder()
.update(AgentHost)
.set({ status: 'offline', updated_at: () => 'NOW()' })
.where('status = :connected', { connected: 'connected' })
.andWhere('last_heartbeat_at IS NOT NULL')
.andWhere('last_heartbeat_at < :threshold', { threshold })
.execute();
const affected = (conn.affected ?? 0) + (hosts.affected ?? 0);
if (affected) {
this.logger.warn(`marked ${affected} stale connection/host record(s) offline`);
}
}
/**
* Tenant validation: the subject segment must be a real license UUID.
* NATS consumers must never write rows for subjects an arbitrary publisher
* invented. Existence is cached to avoid a query per heartbeat.
*/
private async isValidTenant(licenseId: string): Promise<boolean> {
if (!HostAgentConsumerService.UUID_RE.test(licenseId)) {
this.warnUnknownOnce(licenseId, 'not a UUID');
return false;
}
const cachedUntil = this.knownLicenses.get(licenseId);
if (cachedUntil && cachedUntil > Date.now()) return true;
const exists = await this.licenseRepository.exist({ where: { id: licenseId } });
if (!exists) {
this.warnUnknownOnce(licenseId, 'no such license');
return false;
}
this.knownLicenses.set(licenseId, Date.now() + HostAgentConsumerService.LICENSE_CACHE_TTL_MS);
return true;
}
private warnUnknownOnce(licenseId: string, reason: string): void {
if (this.warnedUnknown.has(licenseId)) return;
this.warnedUnknown.add(licenseId);
this.logger.warn(`ignoring host-agent traffic for invalid license '${licenseId}' (${reason})`);
}
}

View File

@@ -1,3 +1,4 @@
export { NatsService } from './nats.service';
export { NatsBridgeService } from './nats-bridge.service';
export { HostAgentConsumerService } from './host-agent-consumer.service';
export { SteamService } from './steam.service';

View File

@@ -1,14 +1,19 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { Injectable, OnApplicationBootstrap, Logger } from '@nestjs/common';
import { NatsService } from './nats.service';
@Injectable()
export class NatsBridgeService implements OnModuleInit {
export class NatsBridgeService implements OnApplicationBootstrap {
private readonly logger = new Logger(NatsBridgeService.name);
private listeners: Map<string, Set<(event: string, data: unknown) => void>> = new Map();
constructor(private nats: NatsService) {}
onModuleInit() {
// Subscriptions MUST happen in onApplicationBootstrap, not onModuleInit:
// provider onModuleInit order is not guaranteed, and these hooks once ran
// before NatsService connected — every subscribe() silently no-oped and the
// WS bridge was dead from boot. Bootstrap runs after ALL module inits
// (including the awaited NATS connect) complete.
onApplicationBootstrap() {
this.nats.subscribe('corrosion.*.companion.heartbeat', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'heartbeat', data);
@@ -44,6 +49,17 @@ export class NatsBridgeService implements OnModuleInit {
this.emit(licenseId, 'oxide_status', data);
});
// Wire protocol v2 (corrosion-host-agent) — host-level telemetry
this.nats.subscribe('corrosion.*.host.heartbeat', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'host_heartbeat', data);
});
this.nats.subscribe('corrosion.*.host.going_offline', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'host_going_offline', data);
});
this.logger.log('NATS bridge subscriptions initialized');
}

View File

@@ -1,6 +1,14 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { connect, NatsConnection, StringCodec, Subscription } from 'nats';
import { createHmac, randomUUID } from 'crypto';
export interface AgentCredentials {
license_id: string;
nats_user: string;
nats_password: string;
nats_url: string;
}
@Injectable()
export class NatsService implements OnModuleInit, OnModuleDestroy {
@@ -13,8 +21,13 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
try {
const url = this.config.get<string>('nats.url') || 'nats://localhost:4222';
this.nc = await connect({ servers: url });
this.logger.log(`Connected to NATS at ${url}`);
const user = this.config.get<string>('nats.internalUser');
const pass = this.config.get<string>('nats.internalPassword');
// Authenticate with the privileged internal user when configured;
// otherwise connect anonymously (broker hasn't enforced auth yet).
const opts = user && pass ? { servers: url, user, pass } : { servers: url };
this.nc = await connect(opts);
this.logger.log(`Connected to NATS at ${url}${user ? ` as ${user}` : ' (anonymous)'}`);
} catch (err) {
this.logger.warn(`NATS connection failed — running in offline mode: ${(err as Error).message}`);
}
@@ -62,6 +75,64 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
return sub;
}
/**
* Request-reply to a host-agent subject with a LICENSE-SCOPED reply subject.
*
* Per-license agent users are confined to corrosion.{license}.> and have no
* _INBOX permission, so the agent cannot publish a reply to the default
* global inbox. The reply must live inside the license namespace
* (corrosion.{license}.reply.<id>); the privileged backend subscribes there.
* See corrosion-host-agent/PROTOCOL.md ("Reply-subject rule").
*/
async requestScoped<T = unknown>(
licenseId: string,
subject: string,
payload: Record<string, unknown>,
timeoutMs = 8000,
): Promise<T> {
if (!this.nc) {
throw new Error('NATS unavailable — agent is not reachable');
}
const replySubject = `corrosion.${licenseId}.reply.${randomUUID()}`;
const nc = this.nc;
return new Promise<T>((resolve, reject) => {
nc.subscribe(replySubject, {
max: 1,
timeout: timeoutMs,
callback: (err, msg) => {
if (err) {
reject(new Error(`agent did not respond within ${timeoutMs}ms`));
return;
}
try {
resolve(JSON.parse(this.sc.decode(msg.data)) as T);
} catch {
resolve(this.sc.decode(msg.data) as unknown as T);
}
},
});
nc.publish(subject, this.sc.encode(JSON.stringify(payload)), { reply: replySubject });
});
}
/**
* Derive a license's agent NATS credentials. Password is
* HMAC-SHA256(license_id, NATS_TOKEN_SECRET) — must match the broker config
* generated by scripts/generate-nats-auth.mjs. Returns null if the secret
* isn't configured (broker not yet enforcing auth).
*/
getAgentCredentials(licenseId: string): AgentCredentials | null {
const secret = this.config.get<string>('nats.tokenSecret');
if (!secret) return null;
const password = createHmac('sha256', secret).update(licenseId).digest('hex');
return {
license_id: licenseId,
nats_user: licenseId,
nats_password: password,
nats_url: this.config.get<string>('nats.publicUrl') || 'nats://nats.corrosionmgmt.com:4222',
};
}
/** Publish a command to a specific license's server */
async sendServerCommand(licenseId: string, action: string, payload: Record<string, unknown> = {}): Promise<void> {
await this.publish(`corrosion.${licenseId}.cmd.server`, {

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS gather_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_gather_configs_license ON gather_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS kits_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_kits_configs_license ON kits_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS betterchat_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_betterchat_configs_license ON betterchat_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS autodoors_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_autodoors_configs_license ON autodoors_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS furnacesplitter_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_furnacesplitter_configs_license ON furnacesplitter_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS timedexecute_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_timedexecute_configs_license ON timedexecute_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS raidablebases_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_raidablebases_configs_license ON raidablebases_configs(license_id);

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