24 Commits

Author SHA1 Message Date
Vantz Stockwell
9c9c7a8a97 feat(faq): wire Dr. Flask intro video into the phone-frame lightbox
Some checks failed
CI / backend-types (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / agent-tests (push) Failing after 32s
CI / integration (push) Has been skipped
The 85s v2 intro plays click-to-play in the phone mockup with fully custom
controls (play/pause, green seek bar, live timecode, mute, fullscreen) — no
loop, pause on close, Esc/backdrop/X to dismiss. Opens with sound on the cover
click (user gesture); falls back to muted autoplay if the browser blocks it.

- Transcoded the 163 MB / ~15 Mbps export -> 10.8 MB (720x1280, H.264 CRF 28,
  +faststart) so it only downloads when a visitor opts in (preload=metadata).
- Poster = a v2 frame grabbed from the video (drflask-poster.jpg, ~60 KB).
- Source 163 MB master stays untracked in docs/character/.

Verified live via Playwright: video loads (readyState 4, 85s), autoplays on
open, timecode/seek-fill track, play/pause + mute buttons both toggle state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:37:03 -04:00
Vantz Stockwell
907cfcb428 docs(brand): v2 voice lock — VHS voice rule, catchphrase bank, 'Dr. Flask Appears' series
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
Oracle's post-v2 voice position: the 'trapped in a chemistry edutainment VHS'
one-liner, refined is/is-not lists, a 10-line canonical catchphrase bank, the
'Degree not included' footer gag, and the flagship recurring short-form format
'Dr. Flask Appears' (uninvited-helper episode structure + example).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:45:19 -04:00
Vantz Stockwell
b1961df18e docs(character): v2 Dr. Flask model sheet — the 90s spoof, approved
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Failing after 30s
CI / integration (push) Has been skipped
v2 redesign: cartoon mascot with googly eyes, askew mortarboard, yellow bow tie,
lab coat, pointer stick, white gloves. Identity refreshed (Ph.D. Self-Certified,
Specialty: Server Chemistry, Height: One Flask Tall, 'No Degree Required'), with
the Clippy homage written into the board notes ('Appears whenever you need him.
Sometimes when you don't.'). New expression/posture/gesture sets recorded; v1
sheet marked superseded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:43:07 -04:00
Vantz Stockwell
cfdec62a1d docs(brand): sharpen Dr. Flask voice — core vibe, influences, signature line
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Failing after 29s
CI / integration (push) Has been skipped
Refined creative direction: 'lovable 90s help mascot with chaotic educational
confidence.' Adds the influence stack (Clippy/Mr. DNA/Weird Al/early-internet
tutorial), the homage-not-a-copy guardrail, and the canonical signature line —
'It looks like you're about to wipe a Rust server. Would you like help turning
that into a controlled reaction?' Deeper 5-video script punch-up queued for v2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:20:22 -04:00
Vantz Stockwell
e510f8b005 docs(character): 90s-spoof tone direction + v2 wardrobe + 12-beat storyboard
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 30s
CI / integration (push) Has been skipped
- Voice guide + bible gain the comedic north star: loving spoof of Clippy +
  Mr. DNA with Weird Al 'White & Nerdy' energy — Clippy's charm, never his
  intrusiveness.
- Record v2 wardrobe (bow tie, googly eyes, askew mortarboard, pointer stick),
  render incoming; v1 model sheet relabeled.
- Add drflask-storyboard.webp (12-beat sequence) + document its panel->script map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:19:10 -04:00
Vantz Stockwell
cf1f1dea9a docs(brand): brand kit — voice guide, social channels, content series, trailer brief
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 22s
Captures Oracle's full brand system as canonical collateral: positioning +
taglines, Dr. Flask voice guide (the 'plain English then one wink' rule),
handle strategy (CorrosionMgmt brand / DrFlaskPhD mascot split), copy-paste
YouTube/X/Twitch setups, the 5-video Dr. Flask mini-series, and the 9:16 brand
trailer brief + VO. Single source of truth for launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:12:04 -04:00
Vantz Stockwell
2e72850b97 docs(character): add Dr. Flask model sheet + sync bible to the board
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 30s
CI / integration (push) Has been skipped
invideo produced a full character design board (turnarounds, 8-expression
progression, micro-expressions, postures, bubble-hand gestures, silhouettes,
color palette). Committed as the definitive reference and folded its details
(palette, expression/posture/gesture lists, character note) into the bible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:02:25 -04:00
Vantz Stockwell
9f9785fc09 docs(character): Dr. Flask character bible — canonical identity + design notes
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Failing after 30s
CI / integration (push) Has been skipped
Single source of truth for Dr. Flask (Corrosion Guide / Ph.D. / Catalyst
Expert / Erlenmeyer / neon-green / mortarboard) as the character gets
storyboarded across tools. Documents the lab-zone green rationale, where he
appears, the 9:16 intro-video plan, and the asset inventory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:00:13 -04:00
Vantz Stockwell
142ba21113 feat(faq): expanded chemistry glossary + Dr. Flask lab zone
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 21s
Replaces the compact 3-column table with the full long-form glossary
(Commander + Gemini copy):
- Dr. Flask cover card beside the intro (web-optimized 560px from
  docs/character/drflask-final.png, 1.8MB -> 394KB).
- 'In plain English' callout + the Formulae->...->Lab Notes flow strip.
- 8 term cards (Catalyst Console, re-Agent, Substrate, Formulae, Reactions,
  Compounds, Lab Notes, The Exchange) — chemistry meaning / Corrosion role /
  punchy kicker each.
- Dr. Flask sign-off card with his bio + quips.
- Lab-zone treatment: green accent scoped to .sec--lab so the whole section
  reads as a deliberate 'lab' corner, breaking up the orange brand.

Visually verified via Playwright on the dev server (marketing host): 8 cards,
green theming, Dr. Flask cover + sign-off all render; no page errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:43:39 -04:00
Vantz Stockwell
04e664045b feat(faq): chemistry glossary — 'Brush up on your chemistry while managing your game server'
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 44s
CI / integration (push) Successful in 22s
Public-facing brand asset (Oracle + Commander): a glossary section on the FAQ
page mapping each chemistry term to its real role in Corrosion, plus the
chemistry-true pipeline as a flow strip.

- 8-term table (Term / Chemistry meaning / In Corrosion): Catalyst, re-Agent,
  Substrate, Formulae, Reaction, Compound, Lab Notes, The Exchange.
- Substrate is the host/bare-metal SURFACE servers run on — NOT the 'automation
  layer' (corrected the drift the Commander rejected; re-Agent installs on it,
  Reactions execute against it).
- Flow strip + closer: Formula defines -> Catalyst kicks off -> re-Agent runs it
  on the Substrate as a Reaction -> Lab Notes record the result.

Verified live via Playwright on the dev server (marketing host): table, flow
strip, and closer all render correctly; no errors from the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:14:49 -04:00
Vantz Stockwell
cef95540fc copy(roadmap): Multi-game Formulae, Operator API, The Exchange, Fleet Block clarifier
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 42s
CI / integration (push) Successful in 21s
Commander copy pass:
- 'Multi-game expansion — game Formulae' -> 'Multi-game Formulae' (cleaner grammar)
- 'API access and integrations' -> 'Operator API and integrations' (operator-grade framing)
- 'Integrated storefront' -> 'The Exchange' (chemistry-flavored; ion-exchange nod,
  no collision with the locked 'Compound' = stack group)
- 'Fleet Block capacity management' gains a clarifier note: pooled host capacity,
  allocation, and utilization

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:02:42 -04:00
Vantz Stockwell
7f2207bc28 feat(settings): password change, 2FA enable/disable, API-key UI + Swagger; fix Owner RBAC drift
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 42s
CI / integration (push) Successful in 21s
Settings was missing self-service account security and any API-key UI:
- Account security (new Security tab): change password (POST /auth/change-password
  — verifies current via Argon2, rejects unchanged), enable 2FA (wires the
  existing /auth/2fa/setup QR + /auth/2fa/verify), and disable 2FA (new
  POST /auth/2fa/disable, requires a current code so a hijacked session can't
  strip the second factor).
- New API tab: create/list/revoke per-license API keys (the overnight backend
  had no UI), plaintext shown once, plus an 'API docs' button to /api/docs (Swagger).

Root-cause RBAC fix — the system-default Owner role enumerated per-resource
wildcards (server.*, wipe.*, ...) and drifted: apikeys, webhooks, alerts,
analytics, chat, schedules, notifications, map, users and ALL plugin-config
modules (plus singular plugin.* vs granted plugins.*) were locked out for any
non-super-admin Owner. Owner = full control of its license:
- migration 025 sets the Owner role to {"*": true}
- PermissionsGuard honors '*' as allow-all
- frontend hasPermission honors '*' and resource.* wildcards (was exact-match
  only, so wildcard-based roles silently failed)

Backend tsc + frontend build green. NOTE: migration 025 auto-applies on a fresh
DB (Saturday); the live DB needs the one-line UPDATE applied to unlock the API
tab for a non-super-admin owner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 08:57:17 -04:00
Vantz Stockwell
57858a1e1c feat(agent): systemd service install/uninstall subcommands (alpha.11)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 1m34s
Build Host Agent (Rust) / build (push) Successful in 1m44s
CI / integration (push) Successful in 22s
For Saturday's Ubuntu host + Linux VM: 'corrosion-host-agent install' writes a
systemd unit (Type=simple — the agent already handles SIGTERM cleanly),
daemon-reloads, and enables+starts the service; 'uninstall' reverses it.

- new service.rs: pure unit_file_contents() generator (unit-tested) + Linux
  install/uninstall via systemctl; non-Linux returns a clear 'Linux only' error
  (Windows SCM is the follow-up).
- ExecStart honors the resolved --config path (default or explicit).
- Runs as root: the agent supervises game processes + their files, needs broad
  filesystem access.

cargo check + service unit test green. Tag agent-v2.0.0-alpha.11 -> CI signs ->
CDN /host-agent/alpha/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:31:45 -04:00
Vantz Stockwell
5b323137e0 feat(auth): API-key authentication — corr_ bearer key acts as license owner
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 31s
CI / integration (push) Has been skipped
Closes the 'Public REST API' last mile: external callers authenticate with a
per-license API key instead of a JWT. Additive and zero-regression:

- JwtAuthGuard: a corr_-prefixed bearer token (or X-API-Key header) is
  validated via ApiKeysService.validateKey and sets request.user shaped like a
  JWT user, scoped to the key's license. JWTs are eyJ... and never collide with
  the corr_ prefix, so the existing JWT path is byte-for-byte unchanged.
- API-key calls act AS the license owner: validateKey now resolves
  license.owner_user_id so sub is a real UUID — any @CurrentUser/created_by FK
  insert works and attributes correctly. (ApiKeysModule gains the License repo.)
- PermissionsGuard: is_api_key principals get full access to their own license
  (always tenant-scoped). Future: scoped/read-only keys.

Backend tsc green. Untested at runtime (no local DB) — needs a curl smoke test
on Saturday's fresh stack before the roadmap item flips to shipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:26:59 -04:00
Vantz Stockwell
4d455918f5 docs(roadmap): check off webhooks + API key management (API access -> in progress)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 21s
Webhook events and per-license API key management shipped (commits 55c9893,
0effaaf, a1768bd). Moved 'API access and integrations' to in-progress with
per-item notes; key-authenticated external API access is the remaining piece.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:22:28 -04:00
Vantz Stockwell
a1768bdd2a feat(wipes): report wipe status from agent reply + wipe_completed webhook; harden webhook delivery against SSRF
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 21s
Wipe status reporting (closes the wipe_history-stays-pending gap):
- triggerWipe now dispatches the wipe non-blocking (a wipe is stop+delete+
  start, up to a minute+) and records the outcome from the agent's reply:
  status -> success/failed, started_at/completed_at, error_message. The row
  used to be created 'pending' and never advance, so history lied.
- On success, fires the third webhook event: 'wipe_completed'
  (server_down + player_banned shipped in 0effaaf).

SSRF hardening (security review HIGH on webhook delivery):
- new common/ssrf-guard.ts: resolve the URL host and reject private /
  loopback / link-local / reserved (v4 + v6, incl. 169.254.169.254 metadata,
  IPv4-mapped, fc00::/7, fe80::/10). http/https only.
- Applied at storage (create/update -> early 400) AND immediately before each
  delivery (DNS-rebinding/TOCTOU). fetch uses redirect:'manual' so a 3xx
  can't bounce delivery to an internal host; a redirect is a failed delivery.
- Verified IP range math + IPv6 bracket-strip (URL keeps '[::1]') empirically.

Backend tsc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:20:24 -04:00
Vantz Stockwell
0effaaf86c feat(api): outbound webhooks — server_down + player_banned events
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 30s
CI / integration (push) Has been skipped
Roadmap 'Webhook events': per-license outbound webhooks with HMAC-SHA256
signatures (X-Corrosion-Signature), 5s timeout, fire-and-forget (a webhook
failure never breaks the triggering action), last_delivery_at/last_status
tracked.

- migration 024_webhooks; Webhook entity (events as simple-array);
  WebhooksModule (@Global, exports WebhooksService) wired into app.module;
  CRUD controller (license-scoped, webhooks.view/manage).
- Hooked events: players.performAction ban -> 'player_banned';
  host-agent-consumer going-offline + staleness sweep -> 'server_down'.
- 'wipe_completed' event lands next (needs wipe status from the agent reply).

Backend tsc green. Migration applies on a fresh DB (Saturday).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:13:13 -04:00
Vantz Stockwell
55c9893131 feat(api): per-license API key management + roadmap sync
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 46s
CI / integration (push) Successful in 22s
API keys (roadmap: 'API key management per license'):
- migration 023_api_keys; ApiKey entity; ApiKeysModule (@Global, exports
  ApiKeysService) wired into app.module.
- Service: create (corr_<prefix>_<secret>, returns plaintext once, stores
  sha256 hash + prefix), list (no hash), revoke, and validateKey(rawKey) ->
  { license_id } for a future API-key auth guard. Controller license-scoped +
  RBAC (apikeys.view/manage).

Roadmap: moved the shipped multi-game items (multi-instance host runtime,
per-game wipe + event scheduling) into a 'Phase 2 — Multi-game runtime' shipped
group; Dune/Conan/Soulmask Formulae stay in-progress.

Backend tsc + frontend build green. Migration applies on a fresh DB (Saturday host).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 02:04:41 -04:00
Vantz Stockwell
62bc9cd2a3 feat(wipes): wire the auto-wiper — scheduled wipes now actually fire
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 30s
CI / integration (push) Has been skipped
wipe_schedules rows existed but nothing read or fired them — an operator could
set a wipe schedule and it would never trigger (the headline auto-wipe feature
was inert; the manual trigger worked, the scheduler did not).

- WipesService now implements OnModuleInit/OnModuleDestroy with a 60s executor
  (mirrors SchedulesService): bootstraps next_scheduled_run, then fires every
  active schedule whose next_scheduled_run <= now via triggerWipe(...'scheduled')
  -> instancesService.wipeForLicense -> the agent wipe handler, advancing
  next_scheduled_run from the cron each cycle (advances even on failure so a
  broken schedule can't re-fire every 60s).
- triggerWipe parameterized with triggerType ('manual' | 'scheduled') so
  wipe_history records the real origin.
- Extracted nextCronDate into src/common/cron.util.ts (shared by the event and
  wipe schedulers; was duplicated/private). Cron is evaluated UTC — the per-
  schedule timezone column is still not honored, a known limitation shared by
  both schedulers (follow-up: tz-aware cron lib).

Backend tsc green. Scheduling logic is at parity with the in-production event
scheduler; live end-to-end (a scheduled wipe deleting real files) verifies when
a game stack + agent are connected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:50:49 -04:00
Vantz Stockwell
e23b6a7e69 feat(brand): chemistry rebrand across panel + marketing
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Failing after 34s
CI / integration (push) Has been skipped
The logged-in panel is now Catalyst Console (by Corrosion); the marketing site
keeps Corrosion as the platform/company and introduces the lexicon.

- Wordmark: panel/auth Logo lockup -> 'Catalyst' / 'by Corrosion'; the shared
  C-core house mark (CorrosionMark) is untouched. Marketing nav/footer keep the
  'Corrosion' wordmark.
- Titles: panel routes -> '{View} · Catalyst'; auth -> Catalyst; document.title
  fallback + index.html -> 'Catalyst Console'. Marketing titles stay '— Corrosion'.
- Host agent user-facing copy -> 're-Agent' across panel + marketing (the
  binary filename / CDN URLs / config paths / domains are UNCHANGED — that's the
  separate infra/binary-rename sprint; 'Download re-Agent' fetching
  corrosion-host-agent-* is the intended intermediate state).
- Deploy-recipe 'blueprint/template' -> 'Formula/Formulae' in marketing + roadmap;
  Rust in-game 'blueprint wipe' kept (game term).
- docs/BRANDING.md added (Oracle review + locked lexicon).

vue-tsc + vite green; rendered clean both faces (Catalyst panel / Corrosion
marketing), 0 console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:19:01 -04:00
Vantz Stockwell
215355d1cb fix(security): prevent RCON command injection in player kick/ban/unban (HIGH)
Some checks failed
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Failing after 29s
CI / integration (push) Has been skipped
Player id and ban reason flowed unsanitized into the single-line RCON command,
so a control char (newline/CR) in 'reason' could break the framing and inject a
second console command — an RBAC-escalation vector (a Moderator-role user could
run arbitrary RCON via the ban reason field).

- validate player id against a safe token charset /^[A-Za-z0-9_.:-]{1,64}$/ and
  reject otherwise (multi-game safe — not a Rust-only SteamID64 regex, so
  Conan/Funcom and Dune ids still pass)
- strip C0 control chars from reason, collapse whitespace, cap at 200 chars
- coerce ban duration to a non-negative integer

Flagged by automated commit security review. Backend tsc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:36:44 -04:00
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
102 changed files with 4315 additions and 762 deletions

View File

@@ -47,6 +47,8 @@ import { RaidableBasesModule } from './modules/raidablebases/raidablebases.modul
import { EarlyAccessModule } from './modules/early-access/early-access.module';
import { FleetModule } from './modules/fleet/fleet.module';
import { InstancesModule } from './modules/instances/instances.module';
import { ApiKeysModule } from './modules/api-keys/api-keys.module';
import { WebhooksModule } from './modules/webhooks/webhooks.module';
// Shared Services
import { NatsService } from './services/nats.service';
@@ -137,6 +139,8 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
EarlyAccessModule,
FleetModule,
InstancesModule,
ApiKeysModule,
WebhooksModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)

View File

@@ -0,0 +1,51 @@
/**
* Minimal 5-field cron "next run" calculator, shared by the event scheduler
* (SchedulesService) and the wipe scheduler (WipesService).
*
* Supports `*` and exact numeric fields (minute hour day-of-month month
* day-of-week). Walks minute-by-minute up to 366 days ahead. Returns null on a
* malformed expression or if no match is found within a year.
*
* NOTE: the expression is evaluated in **UTC**. A per-schedule `timezone`
* column exists on both schedule tables but is NOT yet honored here — fixing it
* properly needs a timezone-aware cron library; tracked as a shared follow-up.
*/
export function nextCronDate(expr: string, after: Date): Date | null {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
const matches = (e: string, value: number): boolean => {
if (e === '*') return true;
return parseInt(e, 10) === value;
};
// Walk minute-by-minute up to 366 days forward to find the next match.
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
candidate.setSeconds(0, 0);
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
while (candidate < limit) {
const min = candidate.getUTCMinutes();
const hour = candidate.getUTCHours();
const dom = candidate.getUTCDate();
const month = candidate.getUTCMonth() + 1; // 1-12
const dow = candidate.getUTCDay(); // 0=Sun
if (
matches(minuteExpr, min) &&
matches(hourExpr, hour) &&
matches(domExpr, dom) &&
matches(monthExpr, month) &&
matches(dowExpr, dow)
) {
return candidate;
}
candidate.setTime(candidate.getTime() + 60_000);
}
return null;
}

View File

@@ -1,20 +1,68 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { ApiKeysService } from '../../modules/api-keys/api-keys.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
constructor(
private reflector: Reflector,
private readonly apiKeysService: ApiKeysService,
) {
super();
}
canActivate(context: ExecutionContext) {
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
// Additive API-key auth: a `corr_`-prefixed bearer token (or X-API-Key
// header) authenticates programmatically AS the license owner. JWTs are
// `eyJ...` and never collide with the `corr_` prefix, so the standard JWT
// path below is left completely untouched — zero login regression risk.
const request = context.switchToHttp().getRequest();
const rawKey = this.extractApiKey(request);
if (rawKey) {
const result = await this.apiKeysService.validateKey(rawKey);
if (!result) {
throw new UnauthorizedException('Invalid or revoked API key');
}
// Shape the principal like a JWT user so @CurrentTenant / @CurrentUser and
// the permission layer behave identically. is_api_key grants full access
// to THIS license (see PermissionsGuard) — a key is full programmatic
// access to your own license, always tenant-scoped by license_id.
request.user = {
sub: result.user_id ?? undefined,
license_id: result.license_id,
is_super_admin: false,
is_api_key: true,
permissions: {},
};
return true;
}
return (await super.canActivate(context)) as boolean;
}
/** Pull a `corr_`-prefixed key from `Authorization: Bearer` or `X-API-Key`. */
private extractApiKey(request: any): string | null {
const auth = request.headers?.authorization;
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
const token = auth.slice(7).trim();
if (token.startsWith('corr_')) return token;
}
const headerKey = request.headers?.['x-api-key'];
if (typeof headerKey === 'string' && headerKey.startsWith('corr_')) {
return headerKey.trim();
}
return null;
}
}

View File

@@ -19,10 +19,19 @@ export class PermissionsGuard implements CanActivate {
// Super admins bypass all permission checks
if (user.is_super_admin) return true;
// API keys are full programmatic access to their own license (always
// tenant-scoped by license_id via @CurrentTenant). Granted here rather than
// enumerating every permission. Future: scoped/read-only keys.
if (user.is_api_key) return true;
// Check permissions JSONB from role
const permissions = user.permissions as Record<string, boolean> | undefined;
if (!permissions) return false;
// Global wildcard — the Owner role (full control of its license) carries
// {"*": true}, so new features never need to amend the role enumeration.
if (permissions['*'] === true) return true;
// Support wildcard: "server.*" matches "server.view", "server.console", etc.
const parts = requiredPermission.split('.');
const wildcard = parts[0] + '.*';

View File

@@ -0,0 +1,100 @@
import { BadRequestException } from '@nestjs/common';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
/**
* SSRF guard for operator-supplied outbound URLs (webhooks today; any future
* "we POST to a URL you give us" feature should reuse this).
*
* The danger: an operator (or anyone who can create a webhook) points the URL at
* an internal address — 127.0.0.1, the NATS/DB ports, 192.168.x, or the cloud
* metadata endpoint 169.254.169.254 — and turns our server into a request proxy
* into the private network. We defend by resolving the host and refusing any
* private / loopback / link-local / reserved destination.
*
* Validate at storage (early, clear 400) AND immediately before each delivery
* (a hostname can resolve public at create time and private at send time — DNS
* rebinding / TOCTOU). `redirect: 'manual'` at the fetch call closes the
* redirect-bounce variant.
*/
function isBlockedIpv4(ip: string): boolean {
const parts = ip.split('.').map((p) => parseInt(p, 10));
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
return true; // unparseable → block defensively
}
const [a, b] = parts;
if (a === 0) return true; // 0.0.0.0/8 "this network"
if (a === 10) return true; // 10.0.0.0/8 private
if (a === 127) return true; // 127.0.0.0/8 loopback
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
if (a === 255) return true; // 255.x broadcast space
return false;
}
function isBlockedIpv6(ip: string): boolean {
const addr = ip.toLowerCase();
// IPv4-mapped (::ffff:1.2.3.4) — unwrap and apply the v4 rules.
const mapped = addr.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return isBlockedIpv4(mapped[1]);
if (addr === '::' || addr === '::1') return true; // unspecified / loopback
const head = addr.split(':')[0];
if (head.startsWith('fc') || head.startsWith('fd')) return true; // fc00::/7 ULA
if (/^fe[89ab]/.test(head)) return true; // fe80::/10 link-local
return false;
}
function isBlockedIp(ip: string): boolean {
const fam = isIP(ip);
if (fam === 4) return isBlockedIpv4(ip);
if (fam === 6) return isBlockedIpv6(ip);
return true; // not a recognizable IP → block defensively
}
/** Parse + require http/https scheme. Throws BadRequestException on anything else. */
export function parseHttpUrl(raw: string): URL {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new BadRequestException('Webhook URL is not a valid URL');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new BadRequestException('Webhook URL must use http:// or https://');
}
return url;
}
/**
* Resolve the host and reject if it maps to any private / reserved address.
* If a hostname resolves to multiple addresses, ANY blocked one rejects the
* whole URL (a DNS-rebinding response that mixes a public and a private answer
* must not slip through). Returns the parsed URL on success.
*/
export async function assertPublicHttpUrl(raw: string): Promise<URL> {
const url = parseHttpUrl(raw);
// URL keeps IPv6 literals bracketed ("[::1]") — strip so isIP/lookup see the
// bare address; otherwise IPv6 literals never reach the classifier.
const host = url.hostname.replace(/^\[|\]$/g, '');
let addresses: Array<{ address: string }>;
if (isIP(host)) {
addresses = [{ address: host }];
} else {
try {
addresses = await lookup(host, { all: true });
} catch {
throw new BadRequestException(`Webhook host could not be resolved: ${host}`);
}
}
if (addresses.length === 0 || addresses.some((a) => isBlockedIp(a.address))) {
throw new BadRequestException(
'Webhook URL resolves to a private or reserved address and is not allowed',
);
}
return url;
}

View File

@@ -0,0 +1,37 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { License } from './license.entity';
@Entity('api_keys')
@Index(['key_hash'])
@Index(['license_id'])
export class ApiKey {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
/** First 8 chars of the random token — shown in UI so users can identify keys. */
@Column({ type: 'varchar', length: 16 })
key_prefix: string;
/** SHA-256 hex digest of the full plaintext key. Never returned to clients. */
@Column({ type: 'varchar', length: 128 })
key_hash: string;
@Column({ type: 'timestamptz', nullable: true })
last_used_at: Date | null;
@Column({ type: 'boolean', default: true })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,47 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { License } from './license.entity';
@Entity('webhooks')
@Index(['license_id'])
export class Webhook {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text' })
url: string;
/**
* Comma-separated event keys stored as plain text in Postgres.
* TypeORM simple-array serialises string[] ↔ 'event1,event2' automatically.
*/
@Column({ type: 'simple-array' })
events: string[];
/** HMAC-SHA256 signing secret. Auto-generated on create if omitted. */
@Column({ type: 'varchar', length: 128 })
secret: string;
@Column({ type: 'boolean', default: true })
is_active: boolean;
/** Timestamp of the most recent delivery attempt (success or failure). */
@Column({ type: 'timestamptz', nullable: true })
last_delivery_at: Date | null;
/** 'ok' | 'failed' — outcome of the most recent delivery attempt. */
@Column({ type: 'varchar', length: 20, nullable: true })
last_status: string | null;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

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,55 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { ApiKeysService } from './api-keys.service';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('api-keys')
@ApiBearerAuth()
@Controller('api-keys')
export class ApiKeysController {
constructor(private readonly apiKeysService: ApiKeysService) {}
@Post()
@RequirePermission('apikeys.manage')
@ApiOperation({
summary: 'Create an API key',
description:
'Issues a new API key for this license. The full plaintext key is returned ONCE — store it securely; it cannot be retrieved again.',
})
@ApiResponse({ status: 201, description: 'Key created — plaintext key returned once.' })
async create(
@CurrentTenant() licenseId: string,
@Body() dto: CreateApiKeyDto,
) {
return this.apiKeysService.create(licenseId, dto.name);
}
@Get()
@RequirePermission('apikeys.view')
@ApiOperation({ summary: 'List API keys', description: 'Returns all keys (active and revoked) for this license. Key hashes are never returned.' })
@ApiResponse({ status: 200, description: 'Key list.' })
async list(@CurrentTenant() licenseId: string) {
return this.apiKeysService.list(licenseId);
}
@Delete(':id')
@RequirePermission('apikeys.manage')
@ApiOperation({ summary: 'Revoke an API key', description: 'Soft-deletes the key (is_active = false). The row is retained for audit purposes.' })
@ApiResponse({ status: 200, description: 'Key revoked.' })
@ApiResponse({ status: 404, description: 'Key not found in this license.' })
async revoke(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
) {
return this.apiKeysService.revoke(licenseId, id);
}
}

View File

@@ -0,0 +1,15 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApiKey } from '../../entities/api-key.entity';
import { License } from '../../entities/license.entity';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([ApiKey, License])],
controllers: [ApiKeysController],
providers: [ApiKeysService],
exports: [ApiKeysService],
})
export class ApiKeysModule {}

View File

@@ -0,0 +1,163 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as crypto from 'crypto';
import { ApiKey } from '../../entities/api-key.entity';
import { License } from '../../entities/license.entity';
/** Shape returned to the caller on creation — the ONLY time the plaintext key is exposed. */
export interface CreatedApiKey {
/** Full plaintext key — show once, store nowhere. */
plaintext_key: string;
id: string;
name: string;
key_prefix: string;
is_active: boolean;
created_at: Date;
}
/** Safe list view — no hash, no plaintext. */
export interface ApiKeyListItem {
id: string;
name: string;
key_prefix: string;
last_used_at: Date | null;
is_active: boolean;
created_at: Date;
}
@Injectable()
export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
constructor(
@InjectRepository(ApiKey)
private readonly apiKeyRepo: Repository<ApiKey>,
@InjectRepository(License)
private readonly licenseRepo: Repository<License>,
) {}
/**
* Issue a new API key for the given license.
*
* Key format: `corr_<prefix8>_<secret32>`
* where prefix and secret are URL-safe base64url random bytes.
*
* Returns the full plaintext key ONCE alongside the saved row.
* The hash is never returned to the caller.
*/
async create(licenseId: string, name: string): Promise<CreatedApiKey> {
const prefixBytes = crypto.randomBytes(6); // 8 base64url chars
const secretBytes = crypto.randomBytes(24); // 32 base64url chars
const prefix = prefixBytes.toString('base64url');
const secret = secretBytes.toString('base64url');
const plaintextKey = `corr_${prefix}_${secret}`;
const keyHash = crypto
.createHash('sha256')
.update(plaintextKey)
.digest('hex');
const entity = this.apiKeyRepo.create({
license_id: licenseId,
name,
key_prefix: prefix,
key_hash: keyHash,
is_active: true,
});
const saved = await this.apiKeyRepo.save(entity);
this.logger.log(
`API key created: id=${saved.id} prefix=${prefix} license=${licenseId}`,
);
return {
plaintext_key: plaintextKey,
id: saved.id,
name: saved.name,
key_prefix: saved.key_prefix,
is_active: saved.is_active,
created_at: saved.created_at,
};
}
/**
* List all keys (active and revoked) for a license.
* The key_hash is intentionally excluded.
*/
async list(licenseId: string): Promise<ApiKeyListItem[]> {
const rows = await this.apiKeyRepo.find({
where: { license_id: licenseId },
order: { created_at: 'DESC' },
select: ['id', 'name', 'key_prefix', 'last_used_at', 'is_active', 'created_at'],
});
return rows.map((r) => ({
id: r.id,
name: r.name,
key_prefix: r.key_prefix,
last_used_at: r.last_used_at,
is_active: r.is_active,
created_at: r.created_at,
}));
}
/**
* Revoke (soft-delete) a key.
* Returns the updated row or throws NotFoundException if the key
* doesn't exist within this license.
*/
async revoke(licenseId: string, id: string): Promise<{ id: string; is_active: boolean }> {
const key = await this.apiKeyRepo.findOne({
where: { id, license_id: licenseId },
});
if (!key) {
throw new NotFoundException(`API key ${id} not found`);
}
key.is_active = false;
await this.apiKeyRepo.save(key);
this.logger.log(`API key revoked: id=${id} license=${licenseId}`);
return { id: key.id, is_active: key.is_active };
}
/**
* Validate a raw API key string. Called by JwtAuthGuard.
*
* Hashes the raw key, looks up an ACTIVE row, touches last_used_at, resolves
* the license owner (so the guard can attribute the call to a real user UUID),
* and returns { license_id, user_id } on success or null on failure.
*
* user_id is the license owner — API-key calls act AS the owner, so any
* created_by / @CurrentUser FK insert gets a valid UUID and correct attribution.
*/
async validateKey(
rawKey: string,
): Promise<{ license_id: string; user_id: string | null } | null> {
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
const key = await this.apiKeyRepo.findOne({
where: { key_hash: keyHash, is_active: true },
select: ['id', 'license_id'],
});
if (!key) {
return null;
}
// Update last_used_at without loading the full row again.
await this.apiKeyRepo.update(key.id, { last_used_at: new Date() });
const license = await this.licenseRepo.findOne({
where: { id: key.license_id },
select: ['id', 'owner_user_id'],
});
return { license_id: key.license_id, user_id: license?.owner_user_id ?? null };
}
}

View File

@@ -0,0 +1,10 @@
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateApiKeyDto {
@ApiProperty({ description: 'Human-readable label for this key', maxLength: 100 })
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
}

View File

@@ -13,6 +13,7 @@ import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { VerifyTotpDto } from './dto/verify-totp.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { Public } from '../../common/decorators/public.decorator';
@@ -61,6 +62,30 @@ export class AuthController {
return this.authService.verifyTotp(userId, dto.code);
}
@Post('2fa/disable')
@ApiBearerAuth()
@ApiOperation({ summary: 'Disable TOTP 2FA (requires a current code)' })
async disableTotp(
@CurrentUser('sub') userId: string,
@Body() dto: VerifyTotpDto,
) {
return this.authService.disableTotp(userId, dto.code);
}
@Post('change-password')
@ApiBearerAuth()
@ApiOperation({ summary: 'Change the current user password' })
async changePassword(
@CurrentUser('sub') userId: string,
@Body() dto: ChangePasswordDto,
) {
return this.authService.changePassword(
userId,
dto.current_password,
dto.new_password,
);
}
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user profile' })

View File

@@ -335,6 +335,56 @@ export class AuthService {
throw new NotImplementedException('Password reset not yet configured');
}
async changePassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
const valid = await argon2.verify(user.password_hash, currentPassword);
if (!valid) {
throw new UnauthorizedException('Current password is incorrect');
}
if (await argon2.verify(user.password_hash, newPassword)) {
throw new BadRequestException('New password must be different from the current one');
}
const password_hash = await argon2.hash(newPassword);
await this.userRepository.update(user.id, { password_hash });
this.logger.log(`Password changed for user ${user.id}`);
// NOTE: existing JWTs remain valid until expiry — this design has no
// server-side refresh-token store to revoke. Session invalidation on
// password change is a follow-up (tracked separately).
return { success: true };
}
async disableTotp(userId: string, code: string) {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
if (!user.totp_enabled) {
throw new BadRequestException('2FA is not enabled');
}
// Require a valid current code — proves possession of the second factor
// before removing it, so a hijacked session can't silently strip 2FA.
const valid = await this.verifyTotpCode(user, code);
if (!valid) {
throw new UnauthorizedException('Invalid TOTP code');
}
await this.userRepository.update(user.id, {
totp_enabled: false,
totp_secret: null,
});
this.logger.log(`TOTP disabled for user ${user.id}`);
return { success: true };
}
// Helper methods
private async generateTokens(user: User, licenseId?: string) {

View File

@@ -0,0 +1,14 @@
import { IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChangePasswordDto {
@ApiProperty({ description: 'Current account password' })
@IsString()
current_password: string;
@ApiProperty({ description: 'New password', minLength: 8, maxLength: 128 })
@IsString()
@MinLength(8)
@MaxLength(128)
new_password: string;
}

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
@@ -13,7 +13,7 @@ export class AutoDoorsService {
constructor(
@InjectRepository(AutoDoorsConfig)
private readonly autoDoorsRepo: Repository<AutoDoorsConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class AutoDoorsService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write AutoDoors.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/AutoDoors.json',
content: jsonString,
},
30000,
// Write AutoDoors.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/AutoDoors.json',
jsonString,
);
// Reload AutoDoors plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload AutoDoors',
timestamp: new Date().toISOString(),
},
);
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 });
@@ -126,17 +115,13 @@ export class AutoDoorsService {
/** Import AutoDoors.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read AutoDoors.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/AutoDoors.json',
},
30000,
// Read AutoDoors.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/AutoDoors.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 AutoDoorsService {
}
// 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

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
@@ -13,7 +13,7 @@ export class BetterChatService {
constructor(
@InjectRepository(BetterChatConfig)
private readonly repo: Repository<BetterChatConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class BetterChatService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write BetterChat.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/BetterChat.json',
content: jsonString,
},
30000,
// Write BetterChat.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/BetterChat.json',
jsonString,
);
// Reload BetterChat plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload BetterChat',
timestamp: new Date().toISOString(),
},
);
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 });
@@ -126,17 +115,13 @@ export class BetterChatService {
/** Import BetterChat.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read BetterChat.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/BetterChat.json',
},
30000,
// Read BetterChat.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/BetterChat.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 BetterChatService {
}
// 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

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
@@ -13,7 +13,7 @@ export class FurnaceSplitterService {
constructor(
@InjectRepository(FurnaceSplitterConfig)
private readonly furnaceRepo: Repository<FurnaceSplitterConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class FurnaceSplitterService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write FurnaceSplitter.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/FurnaceSplitter.json',
content: jsonString,
},
30000,
// Write FurnaceSplitter.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/FurnaceSplitter.json',
jsonString,
);
// Reload FurnaceSplitter plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload FurnaceSplitter',
timestamp: new Date().toISOString(),
},
);
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 });
@@ -126,17 +115,13 @@ export class FurnaceSplitterService {
/** Import FurnaceSplitter.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read FurnaceSplitter.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/FurnaceSplitter.json',
},
30000,
// Read FurnaceSplitter.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/FurnaceSplitter.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 FurnaceSplitterService {
}
// 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

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GatherConfig } from '../../entities/gather-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
@@ -13,7 +13,7 @@ export class GatherService {
constructor(
@InjectRepository(GatherConfig)
private readonly gatherRepo: Repository<GatherConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class GatherService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write GatherManager.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/GatherManager.json',
content: jsonString,
},
30000,
// Write GatherManager.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/GatherManager.json',
jsonString,
);
// Reload GatherManager plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload GatherManager',
timestamp: new Date().toISOString(),
},
);
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 });
@@ -126,17 +115,13 @@ export class GatherService {
/** Import GatherManager.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read GatherManager.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/GatherManager.json',
},
30000,
// Read GatherManager.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/GatherManager.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 GatherService {
}
// 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

@@ -1,13 +1,18 @@
import { Module } from '@nestjs/common';
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

@@ -142,4 +142,82 @@ export class InstancesService {
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

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { KitsConfig } from '../../entities/kits-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
@@ -13,7 +13,7 @@ export class KitsService {
constructor(
@InjectRepository(KitsConfig)
private readonly kitsRepo: Repository<KitsConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class KitsService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write Kits.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/Kits.json',
content: jsonString,
},
30000,
// Write Kits.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/Kits.json',
jsonString,
);
// Reload Kits plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload Kits',
timestamp: new Date().toISOString(),
},
);
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 });
@@ -126,17 +115,13 @@ export class KitsService {
/** Import Kits.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read Kits.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/Kits.json',
},
30000,
// Read Kits.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/Kits.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 KitsService {
}
// 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

@@ -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

@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common';
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlayerAction } from '../../entities/player-action.entity';
import { PlayerSession } from '../../entities/player-session.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { PlayerActionDto } from './dto/player-action.dto';
export interface Player {
@@ -23,7 +24,8 @@ export class PlayersService {
private readonly actionRepo: Repository<PlayerAction>,
@InjectRepository(PlayerSession)
private readonly sessionRepo: Repository<PlayerSession>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
private readonly webhooksService: WebhooksService,
) {}
/**
@@ -132,15 +134,60 @@ 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, {
const rconCmd = this.buildRconCommand(dto);
await this.instancesService.rconForLicense(licenseId, rconCmd);
}
// Fire webhook event for player bans. Fire-and-forget — a delivery failure
// must never surface to the caller or roll back the ban action.
if (dto.action_type === 'ban') {
void this.webhooksService
.dispatch(licenseId, 'player_banned', {
steam_id: dto.steam_id,
reason: dto.reason,
duration_minutes: dto.duration_minutes,
player_name: dto.player_name,
reason: dto.reason ?? null,
duration_minutes: dto.duration_minutes ?? null,
})
.catch(() => {
// dispatch() already logs internally; swallow here to guarantee
// the ban action result is unaffected.
});
}
return { success: true };
}
private buildRconCommand(dto: PlayerActionDto): string {
// Defense-in-depth against RCON command injection. The command is a single
// line; an id or reason containing a newline/control char could break the
// framing and inject a second console command. So:
// - the player id must be a safe token (no whitespace/control chars) — a
// permissive charset, not a Rust-only SteamID64 regex, so Conan (Funcom)
// and Dune ids still validate. Reject outright if not.
// - the free-text reason has control chars stripped and is length-capped.
// - duration is coerced to a non-negative integer.
const id = dto.steam_id ?? '';
if (!/^[A-Za-z0-9_.:-]{1,64}$/.test(id)) {
throw new BadRequestException('Invalid player id');
}
const safeReason =
(dto.reason ?? 'banned').replace(/[\u0000-\u001F]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) || 'banned';
const secs = Number.isFinite(dto.duration_minutes)
? Math.max(0, Math.floor((dto.duration_minutes as number) * 60))
: 0;
switch (dto.action_type) {
case 'kick':
return `kick ${id}${dto.reason ? ' ' + safeReason : ''}`;
case 'ban':
// banid <steamId> <reason> <durationSeconds> — 0 = permanent
return `banid ${id} ${safeReason} ${secs}`;
case 'unban':
return `unban ${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

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
@@ -13,7 +13,7 @@ export class RaidableBasesService {
constructor(
@InjectRepository(RaidableBasesConfig)
private readonly raidableBasesRepo: Repository<RaidableBasesConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class RaidableBasesService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write RaidableBases.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/RaidableBases.json',
content: jsonString,
},
30000,
// Write RaidableBases.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/RaidableBases.json',
jsonString,
);
// Reload RaidableBases plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload RaidableBases',
timestamp: new Date().toISOString(),
},
);
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 });
@@ -126,17 +115,13 @@ export class RaidableBasesService {
/** Import RaidableBases.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read RaidableBases.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/RaidableBases.json',
},
30000,
// Read RaidableBases.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/RaidableBases.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 RaidableBasesService {
}
// 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

@@ -10,48 +10,8 @@ import { LessThanOrEqual, Repository } from 'typeorm';
import { ScheduledTask } from '../../entities/scheduled-task.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { NatsService } from '../../services/nats.service';
/** Parse a 5-field cron expression and return the next Date after `after`. */
function nextCronDate(expr: string, after: Date): Date | null {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
function matches(expr: string, value: number): boolean {
if (expr === '*') return true;
return parseInt(expr, 10) === value;
}
// Walk minute-by-minute up to 366 days forward to find next match.
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
candidate.setSeconds(0, 0);
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
while (candidate < limit) {
const min = candidate.getUTCMinutes();
const hour = candidate.getUTCHours();
const dom = candidate.getUTCDate();
const month = candidate.getUTCMonth() + 1; // 1-12
const dow = candidate.getUTCDay(); // 0=Sun
if (
matches(minuteExpr, min) &&
matches(hourExpr, hour) &&
matches(domExpr, dom) &&
matches(monthExpr, month) &&
matches(dowExpr, dow)
) {
return candidate;
}
candidate.setTime(candidate.getTime() + 60_000);
}
return null;
}
import { InstancesService } from '../instances/instances.service';
import { nextCronDate } from '../../common/cron.util';
@Injectable()
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
@@ -61,7 +21,7 @@ export class SchedulesService implements OnModuleInit, OnModuleDestroy {
constructor(
@InjectRepository(ScheduledTask)
private taskRepository: Repository<ScheduledTask>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
// ---------------------------------------------------------------------------
@@ -160,21 +120,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 +135,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

@@ -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,6 +18,7 @@ export class ServersService {
@InjectRepository(ServerConfig)
private readonly configRepo: Repository<ServerConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/**
@@ -68,11 +70,11 @@ export class ServersService {
}
/**
* Send a console command to the server via NATS
* Send a console command to the server via the host agent (RCON)
*/
async sendCommand(licenseId: string, command: string) {
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}`);
@@ -82,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

@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
import { NatsService } from '../../services/nats.service';
import { InstancesService } from '../instances/instances.service';
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
@@ -13,7 +13,7 @@ export class TimedExecuteService {
constructor(
@InjectRepository(TimedExecuteConfig)
private readonly repo: Repository<TimedExecuteConfig>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
) {}
/** List configs for a license (summaries — no JSONB) */
@@ -81,26 +81,15 @@ export class TimedExecuteService {
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write TimedExecute.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/TimedExecute.json',
content: jsonString,
},
30000,
// Write TimedExecute.json via Rust agent
await this.instancesService.writeFileForLicense(
licenseId,
'oxide/config/TimedExecute.json',
jsonString,
);
// Reload TimedExecute plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload TimedExecute',
timestamp: new Date().toISOString(),
},
);
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 });
@@ -126,17 +115,13 @@ export class TimedExecuteService {
/** Import TimedExecute.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read TimedExecute.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/TimedExecute.json',
},
30000,
// Read TimedExecute.json from server via Rust agent
const result = await this.instancesService.readFileForLicense(
licenseId,
'oxide/config/TimedExecute.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 TimedExecuteService {
}
// 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,33 @@
import { IsString, IsNotEmpty, IsUrl, IsArray, ArrayNotEmpty, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateWebhookDto {
@ApiProperty({ description: 'Human-readable label for this webhook', maxLength: 100 })
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@ApiProperty({ description: 'HTTPS URL to POST events to' })
@IsUrl({ protocols: ['https', 'http'], require_tld: false })
url: string;
@ApiProperty({
description: 'Event keys to subscribe to',
example: ['player_banned', 'server_down'],
type: [String],
})
@IsArray()
@ArrayNotEmpty()
@IsString({ each: true })
events: string[];
@ApiPropertyOptional({
description: 'HMAC-SHA256 signing secret. Auto-generated if omitted.',
maxLength: 128,
})
@IsOptional()
@IsString()
@MaxLength(128)
secret?: string;
}

View File

@@ -0,0 +1,31 @@
import { IsString, IsUrl, IsArray, ArrayNotEmpty, IsOptional, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateWebhookDto {
@ApiPropertyOptional({ description: 'Human-readable label for this webhook', maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@ApiPropertyOptional({ description: 'HTTPS URL to POST events to' })
@IsOptional()
@IsUrl({ protocols: ['https', 'http'], require_tld: false })
url?: string;
@ApiPropertyOptional({
description: 'Event keys to subscribe to',
example: ['player_banned', 'server_down'],
type: [String],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsString({ each: true })
events?: string[];
@ApiPropertyOptional({ description: 'Enable or disable this webhook' })
@IsOptional()
@IsBoolean()
is_active?: boolean;
}

View File

@@ -0,0 +1,70 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { WebhooksService } from './webhooks.service';
import { CreateWebhookDto } from './dto/create-webhook.dto';
import { UpdateWebhookDto } from './dto/update-webhook.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('webhooks')
@ApiBearerAuth()
@Controller('webhooks')
export class WebhooksController {
constructor(private readonly webhooksService: WebhooksService) {}
@Post()
@RequirePermission('webhooks.manage')
@ApiOperation({
summary: 'Create a webhook',
description:
'Registers a new outbound webhook for this license. A signing secret is auto-generated if not provided.',
})
@ApiResponse({ status: 201, description: 'Webhook created.' })
async create(
@CurrentTenant() licenseId: string,
@Body() dto: CreateWebhookDto,
) {
return this.webhooksService.create(licenseId, dto);
}
@Get()
@RequirePermission('webhooks.view')
@ApiOperation({ summary: 'List webhooks', description: 'Returns all webhooks for this license.' })
@ApiResponse({ status: 200, description: 'Webhook list.' })
async list(@CurrentTenant() licenseId: string) {
return this.webhooksService.list(licenseId);
}
@Patch(':id')
@RequirePermission('webhooks.manage')
@ApiOperation({ summary: 'Update a webhook', description: 'Update name, URL, event subscriptions, or active state.' })
@ApiResponse({ status: 200, description: 'Webhook updated.' })
@ApiResponse({ status: 404, description: 'Webhook not found in this license.' })
async update(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateWebhookDto,
) {
return this.webhooksService.update(licenseId, id, dto);
}
@Delete(':id')
@RequirePermission('webhooks.manage')
@ApiOperation({ summary: 'Delete a webhook' })
@ApiResponse({ status: 200, description: 'Webhook deleted.' })
@ApiResponse({ status: 404, description: 'Webhook not found in this license.' })
async remove(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
) {
return this.webhooksService.remove(licenseId, id);
}
}

View File

@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Webhook } from '../../entities/webhook.entity';
import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([Webhook])],
controllers: [WebhooksController],
providers: [WebhooksService],
exports: [WebhooksService],
})
export class WebhooksModule {}

View File

@@ -0,0 +1,236 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as crypto from 'crypto';
import { Webhook } from '../../entities/webhook.entity';
import { CreateWebhookDto } from './dto/create-webhook.dto';
import { UpdateWebhookDto } from './dto/update-webhook.dto';
import { assertPublicHttpUrl } from '../../common/ssrf-guard';
/** Safe list view — secret is included (operator's own resource). */
export interface WebhookListItem {
id: string;
name: string;
url: string;
events: string[];
secret: string;
is_active: boolean;
last_delivery_at: Date | null;
last_status: string | null;
created_at: Date;
}
/** Shape returned on create — identical to list item. */
export type CreatedWebhook = WebhookListItem;
@Injectable()
export class WebhooksService {
private readonly logger = new Logger(WebhooksService.name);
constructor(
@InjectRepository(Webhook)
private readonly webhookRepo: Repository<Webhook>,
) {}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
async create(licenseId: string, dto: CreateWebhookDto): Promise<CreatedWebhook> {
// SSRF guard: reject URLs resolving to private/reserved space before storing.
await assertPublicHttpUrl(dto.url);
// Generate a secret if the caller didn't supply one.
const secret = dto.secret ?? crypto.randomBytes(32).toString('hex');
const entity = this.webhookRepo.create({
license_id: licenseId,
name: dto.name,
url: dto.url,
events: dto.events,
secret,
is_active: true,
});
const saved = await this.webhookRepo.save(entity);
this.logger.log(
`webhook created: id=${saved.id} name="${saved.name}" events=[${saved.events.join(',')}] license=${licenseId}`,
);
return this.toListItem(saved);
}
async list(licenseId: string): Promise<WebhookListItem[]> {
const rows = await this.webhookRepo.find({
where: { license_id: licenseId },
order: { created_at: 'DESC' },
});
return rows.map(this.toListItem);
}
async update(licenseId: string, id: string, dto: UpdateWebhookDto): Promise<WebhookListItem> {
const webhook = await this.findOwned(licenseId, id);
// SSRF guard on any URL change.
if (dto.url !== undefined) await assertPublicHttpUrl(dto.url);
if (dto.name !== undefined) webhook.name = dto.name;
if (dto.url !== undefined) webhook.url = dto.url;
if (dto.events !== undefined) webhook.events = dto.events;
if (dto.is_active !== undefined) webhook.is_active = dto.is_active;
const saved = await this.webhookRepo.save(webhook);
this.logger.log(`webhook updated: id=${id} license=${licenseId}`);
return this.toListItem(saved);
}
async remove(licenseId: string, id: string): Promise<{ id: string }> {
const webhook = await this.findOwned(licenseId, id);
await this.webhookRepo.remove(webhook);
this.logger.log(`webhook deleted: id=${id} license=${licenseId}`);
return { id };
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
/**
* Fire an event to all active webhooks for a license that are subscribed to
* the given event key.
*
* Contract:
* - Fire-and-forget: each delivery is attempted with a 5-second AbortController
* timeout and never throws out to the caller.
* - Each attempt updates last_delivery_at + last_status ('ok' | 'failed').
* - The triggering action is NOT blocked. All deliveries run concurrently via
* Promise.allSettled; the returned Promise resolves only after all attempts
* finish (or time out), so callers can void it for true fire-and-forget.
*
* Signature header: X-Corrosion-Signature: sha256=<hex>
* where hex = HMAC-SHA256(rawBody, webhook.secret).
*/
async dispatch(
licenseId: string,
event: string,
payload: Record<string, unknown>,
): Promise<void> {
let hooks: Webhook[];
try {
hooks = await this.webhookRepo.find({
where: { license_id: licenseId, is_active: true },
});
} catch (err) {
this.logger.error(
`dispatch: failed to query webhooks for license ${licenseId}: ${(err as Error).message}`,
);
return;
}
// Filter to those subscribed to this event.
const subscribed = hooks.filter((h) => h.events.includes(event));
if (subscribed.length === 0) return;
const body = JSON.stringify({
event,
timestamp: new Date().toISOString(),
data: payload,
});
await Promise.allSettled(
subscribed.map((hook) => this.deliverOne(hook, event, body)),
);
}
/** Deliver to a single webhook endpoint; update delivery metadata. Never throws. */
private async deliverOne(hook: Webhook, event: string, body: string): Promise<void> {
const signature = this.sign(body, hook.secret);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5_000);
let status: 'ok' | 'failed' = 'failed';
try {
// Re-validate at send time: a host that was public at create time can
// resolve to a private address now (DNS rebinding / TOCTOU). Throws → caught
// below → recorded 'failed'.
await assertPublicHttpUrl(hook.url);
const res = await fetch(hook.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Corrosion-Signature': `sha256=${signature}`,
},
body,
signal: controller.signal,
// Do not auto-follow redirects — a 3xx Location could point at an
// internal host, re-opening the SSRF we just closed. A redirect is a
// failed delivery here.
redirect: 'manual',
});
if (res.ok) {
status = 'ok';
} else {
this.logger.warn(
`webhook delivery failed: id=${hook.id} event=${event} status=${res.status}`,
);
}
} catch (err) {
const msg = (err as Error).message ?? String(err);
this.logger.warn(
`webhook delivery error: id=${hook.id} event=${event} err=${msg}`,
);
} finally {
clearTimeout(timer);
}
// Persist delivery outcome — best-effort, never throws.
try {
await this.webhookRepo.update(hook.id, {
last_delivery_at: new Date(),
last_status: status,
});
} catch (err) {
this.logger.error(
`webhook metadata update failed: id=${hook.id}: ${(err as Error).message}`,
);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private async findOwned(licenseId: string, id: string): Promise<Webhook> {
const webhook = await this.webhookRepo.findOne({
where: { id, license_id: licenseId },
});
if (!webhook) {
throw new NotFoundException(`Webhook ${id} not found`);
}
return webhook;
}
private sign(body: string, secret: string): string {
return crypto.createHmac('sha256', secret).update(body).digest('hex');
}
private toListItem(w: Webhook): WebhookListItem {
return {
id: w.id,
name: w.name,
url: w.url,
events: w.events,
secret: w.secret,
is_active: w.is_active,
last_delivery_at: w.last_delivery_at,
last_status: w.last_status,
created_at: w.created_at,
};
}
}

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

@@ -1,6 +1,12 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import {
Injectable,
NotFoundException,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
import { WipeProfile } from '../../entities/wipe-profile.entity';
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
import { WipeHistory } from '../../entities/wipe-history.entity';
@@ -8,11 +14,14 @@ 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';
import { WebhooksService } from '../webhooks/webhooks.service';
import { nextCronDate } from '../../common/cron.util';
@Injectable()
export class WipesService {
export class WipesService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WipesService.name);
private wipeExecutorInterval: ReturnType<typeof setInterval> | null = null;
constructor(
@InjectRepository(WipeProfile)
@@ -21,9 +30,86 @@ export class WipesService {
private readonly wipeScheduleRepo: Repository<WipeSchedule>,
@InjectRepository(WipeHistory)
private readonly wipeHistoryRepo: Repository<WipeHistory>,
private readonly natsService: NatsService,
private readonly instancesService: InstancesService,
private readonly webhooksService: WebhooksService,
) {}
// ---------------------------------------------------------------------------
// Scheduled-wipe executor — the auto-wiper. Mirrors SchedulesService: a 60s
// poll fires every active wipe schedule whose next_scheduled_run is due, then
// advances it from its cron expression. Without this, wipe_schedules rows
// never fire (the headline auto-wipe feature was inert).
// ---------------------------------------------------------------------------
onModuleInit(): void {
this.bootstrapWipeSchedules().catch((err) =>
this.logger.error('Failed to bootstrap wipe-schedule next runs', err),
);
this.wipeExecutorInterval = setInterval(() => {
this.executeDueWipes().catch((err) =>
this.logger.error('Wipe-schedule executor error', err),
);
}, 60_000);
this.logger.log('Wipe-schedule executor started (60s polling interval)');
}
onModuleDestroy(): void {
if (this.wipeExecutorInterval) {
clearInterval(this.wipeExecutorInterval);
this.wipeExecutorInterval = null;
}
}
/** On startup, stamp next_scheduled_run on active schedules that lack one. */
private async bootstrapWipeSchedules(): Promise<void> {
const schedules = await this.wipeScheduleRepo.find({
where: { is_active: true, next_scheduled_run: IsNull() },
});
for (const s of schedules) {
const next = nextCronDate(s.cron_expression, new Date());
if (next) {
s.next_scheduled_run = next;
await this.wipeScheduleRepo.save(s);
}
}
if (schedules.length > 0) {
this.logger.log(`Bootstrapped next run for ${schedules.length} wipe schedule(s)`);
}
}
/** Fire every active wipe schedule whose next_scheduled_run <= now. */
private async executeDueWipes(): Promise<void> {
const now = new Date();
const due = await this.wipeScheduleRepo.find({
where: { is_active: true, next_scheduled_run: LessThanOrEqual(now) },
});
if (due.length === 0) return;
this.logger.log(`Executing ${due.length} due wipe schedule(s)`);
for (const s of due) {
try {
await this.triggerWipe(
s.license_id,
{
wipe_type: s.wipe_type as TriggerWipeDto['wipe_type'],
wipe_profile_id: s.wipe_profile_id,
},
'scheduled',
);
} catch (err) {
this.logger.error(
`Scheduled wipe failed for schedule ${s.id} (${s.schedule_name})`,
(err as Error).stack,
);
} finally {
// Advance next_scheduled_run regardless, so a failing schedule doesn't
// re-fire every 60s.
s.next_scheduled_run = nextCronDate(s.cron_expression, now);
await this.wipeScheduleRepo.save(s);
}
}
}
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
return this.wipeProfileRepo.find({
where: { license_id: licenseId },
@@ -96,25 +182,56 @@ export class WipesService {
async triggerWipe(
licenseId: string,
dto: TriggerWipeDto,
triggerType: 'manual' | 'scheduled' = 'manual',
): Promise<{ wipe_history_id: string }> {
const history = this.wipeHistoryRepo.create({
license_id: licenseId,
wipe_type: dto.wipe_type,
wipe_profile_id: dto.wipe_profile_id,
trigger_type: 'manual',
status: 'pending',
trigger_type: triggerType,
status: 'wiping',
started_at: new Date(),
});
const saved = await this.wipeHistoryRepo.save(history);
this.logger.log(
`Wipe ${triggerType} dispatched for license ${licenseId} — history ${saved.id}`,
);
await this.natsService.publish(`corrosion.${licenseId}.cmd.wipe`, {
// Dispatch to the agent WITHOUT blocking the caller — a wipe is
// stop → delete → start and can take a minute+. We record the outcome on
// wipe_history from the agent's reply and fire the wipe_completed webhook
// when it lands. Previously the row was created 'pending' and never
// advanced, so history lied about every wipe.
void this.instancesService
.wipeForLicense(licenseId, dto.wipe_type, true)
.then((reply: unknown) => {
const r = (reply ?? {}) as { status?: string; message?: string; deleted_count?: number };
const ok = r.status === 'success';
saved.status = ok ? 'success' : 'failed';
saved.completed_at = new Date();
if (!ok) {
saved.error_message = r.message ?? 'agent reported wipe failure';
}
return this.wipeHistoryRepo.save(saved).then(() => {
this.logger.log(`Wipe ${saved.id} ${saved.status}`);
if (ok) {
void this.webhooksService.dispatch(licenseId, 'wipe_completed', {
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(),
trigger_type: triggerType,
deleted_count: r.deleted_count ?? null,
});
}
});
})
.catch((err: unknown) => {
saved.status = 'failed';
saved.completed_at = new Date();
saved.error_message = err instanceof Error ? err.message : 'wipe dispatch failed';
this.logger.warn(`Wipe ${saved.id} failed: ${saved.error_message}`);
void this.wipeHistoryRepo.save(saved);
});
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
return { wipe_history_id: saved.id };
}

View File

@@ -7,6 +7,7 @@ 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';
import { WebhooksService } from '../modules/webhooks/webhooks.service';
/**
* Consumes Corrosion wire protocol v2 host-agent subjects
@@ -64,6 +65,7 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
private readonly hostRepository: Repository<AgentHost>,
@InjectRepository(GameInstance)
private readonly instanceRepository: Repository<GameInstance>,
private readonly webhooksService: WebhooksService,
) {}
// Bootstrap, not module-init: subscriptions registered before NatsService
@@ -197,22 +199,52 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
{ license_id: licenseId },
{ connection_status: 'offline', updated_at: now },
);
// Capture hostname(s) before flipping status so the webhook payload is useful.
const hosts = await this.hostRepository.find({ where: { license_id: licenseId } });
await this.hostRepository.update(
{ license_id: licenseId },
{ status: 'offline', updated_at: now },
);
this.logger.log(`host(s) for license ${licenseId} went offline (graceful beacon)`);
// Dispatch server_down event for each host that went offline. Fire-and-forget.
for (const host of hosts) {
void this.webhooksService
.dispatch(licenseId, 'server_down', {
host_id: host.id,
hostname: host.hostname ?? null,
reason: 'graceful_shutdown',
})
.catch(() => {
// dispatch() logs internally; swallow here to keep the handler clean.
});
}
}
/**
* 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.
*
* Hosts that transition to offline here also fire the server_down webhook.
* We identify them BEFORE the bulk update so we can carry their identity
* into the webhook payload.
*/
@Interval(60_000)
async sweepStaleConnections(): Promise<void> {
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
// Identify stale hosts BEFORE bulk-updating so we can dispatch webhooks
// with meaningful host_id / hostname data.
const staleHosts = await this.hostRepository
.createQueryBuilder('host')
.where('host.status = :connected', { connected: 'connected' })
.andWhere('host.last_heartbeat_at IS NOT NULL')
.andWhere('host.last_heartbeat_at < :threshold', { threshold })
.getMany();
const conn = await this.connectionRepository
.createQueryBuilder()
.update(ServerConnection)
@@ -235,6 +267,20 @@ export class HostAgentConsumerService implements OnApplicationBootstrap {
if (affected) {
this.logger.warn(`marked ${affected} stale connection/host record(s) offline`);
}
// Dispatch server_down webhook for each host that just timed out.
// Fire-and-forget — webhook failures must never break the sweep.
for (const host of staleHosts) {
void this.webhooksService
.dispatch(host.license_id, 'server_down', {
host_id: host.id,
hostname: host.hostname ?? null,
reason: 'heartbeat_timeout',
})
.catch(() => {
// dispatch() logs internally; swallow here to keep the sweep clean.
});
}
}
/**

View File

@@ -0,0 +1,17 @@
-- Per-license API key management
-- Each row represents one issued key: the plaintext is shown once at creation
-- and never stored; only the SHA-256 hex digest is persisted.
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
key_prefix VARCHAR(16) NOT NULL,
key_hash VARCHAR(128) NOT NULL,
last_used_at TIMESTAMPTZ NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_keys_license ON api_keys(license_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);

View File

@@ -0,0 +1,26 @@
-- 024_webhooks.sql
-- Per-license outbound webhook registry.
-- Operators register URLs + event subscriptions; the backend POSTs signed
-- JSON payloads on matching events (player_banned, server_down, …).
CREATE TABLE webhooks (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
license_id uuid NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
name varchar(100) NOT NULL,
url text NOT NULL,
-- Comma-separated event keys, e.g. 'player_banned,server_down'
-- TypeORM simple-array maps this transparently to string[].
events text NOT NULL,
-- HMAC-SHA256 signing secret; generated server-side if omitted on create.
secret varchar(128) NOT NULL,
is_active boolean NOT NULL DEFAULT true,
-- Populated after each delivery attempt.
last_delivery_at timestamptz NULL,
-- 'ok' | 'failed' — last HTTP delivery outcome.
last_status varchar(20) NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT webhooks_pkey PRIMARY KEY (id)
);
CREATE INDEX idx_webhooks_license_id ON webhooks (license_id);

View File

@@ -0,0 +1,15 @@
-- 025_owner_full_access.sql
--
-- The system-default Owner role enumerated per-resource wildcards
-- (server.*, wipe.*, players.*, ...). Every feature added since drift past that
-- enumeration: apikeys, webhooks, alerts, analytics, chat, schedules,
-- notifications, map, users, and ALL plugin-config modules (plus a singular
-- 'plugin.*' vs granted 'plugins.*' mismatch) were silently locked out for any
-- non-super-admin Owner — PermissionsGuard denies a permission the role doesn't
-- grant. The Owner has "full control of their license" by definition, so grant
-- a global wildcard instead of an enumeration that must be amended per feature.
--
-- PermissionsGuard and the frontend auth store both honor "*" as allow-all.
UPDATE roles
SET permissions = '{"*": true}'::jsonb
WHERE role_name = 'Owner' AND is_system_default = true;

View File

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

View File

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

View File

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

View File

@@ -11,9 +11,11 @@ pub mod instancecmd;
pub mod prober;
pub mod process;
pub mod rcon;
pub mod service;
pub mod steamcmd;
pub mod subjects;
pub mod supervisor;
pub mod telemetry;
pub mod update;
pub mod version;
pub mod wipe;

View File

@@ -6,7 +6,7 @@
use corrosion_host_agent::{
agent, bus, config, docker_compose, filemanager, hostcmd, instancecmd, prober, process,
subjects, supervisor, telemetry, version,
service, subjects, supervisor, telemetry, version,
};
use anyhow::{Context, Result};
@@ -37,6 +37,10 @@ enum Command {
Check,
/// Print full version (semver, git hash, build timestamp) and exit.
Version,
/// Install as a systemd service and start it (Linux; requires root).
Install,
/// Stop and remove the systemd service (Linux; requires root).
Uninstall,
}
fn main() -> Result<()> {
@@ -58,6 +62,8 @@ fn main() -> Result<()> {
);
Ok(())
}
Some(Command::Install) => service::install(&config_path),
Some(Command::Uninstall) => service::uninstall(),
None => {
let settings = config::load(&config_path)?;
init_logging(&settings.log_level);

View File

@@ -0,0 +1,129 @@
//! systemd service installation for the host agent (Linux).
//!
//! `corrosion-host-agent install` writes a systemd unit pointing at the current
//! binary + config, reloads systemd, and enables + starts the service.
//! `uninstall` reverses it. Windows SCM support is a follow-up; on non-Linux
//! these return a clear "Linux only" error rather than silently doing nothing.
//!
//! The agent already handles SIGTERM (see main::wait_for_shutdown_signal), so a
//! plain `Type=simple` unit gives systemd clean start/stop semantics.
use anyhow::{bail, Result};
use std::path::Path;
#[cfg(target_os = "linux")]
use anyhow::Context;
pub const SERVICE_NAME: &str = "corrosion-host-agent";
#[cfg(target_os = "linux")]
const UNIT_PATH: &str = "/etc/systemd/system/corrosion-host-agent.service";
/// Render the systemd unit. Pure (no I/O) so it is unit-testable.
pub fn unit_file_contents(exec_path: &str, config_path: &str) -> String {
format!(
"[Unit]\n\
Description=Corrosion Host Agent (multi-game ops runtime)\n\
Documentation=https://corrosionmgmt.com\n\
After=network-online.target\n\
Wants=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={exec} --config {cfg}\n\
Restart=on-failure\n\
RestartSec=5\n\
# The agent supervises game-server processes and their files, so it\n\
# needs broad filesystem access and runs as root by default.\n\
User=root\n\
\n\
[Install]\n\
WantedBy=multi-user.target\n",
exec = exec_path,
cfg = config_path,
)
}
#[cfg(target_os = "linux")]
pub fn install(config_path: &Path) -> Result<()> {
let exec = std::env::current_exe().context("resolving current executable path")?;
let exec_str = exec.to_string_lossy();
let cfg_str = config_path.to_string_lossy();
let unit = unit_file_contents(&exec_str, &cfg_str);
std::fs::write(UNIT_PATH, unit)
.with_context(|| format!("writing {UNIT_PATH} (are you root?)"))?;
println!("wrote {UNIT_PATH}");
run("systemctl", &["daemon-reload"])?;
run("systemctl", &["enable", "--now", SERVICE_NAME])?;
println!(
"service '{SERVICE_NAME}' installed and started.\n \
status: systemctl status {SERVICE_NAME}\n \
logs: journalctl -u {SERVICE_NAME} -f"
);
Ok(())
}
#[cfg(target_os = "linux")]
pub fn uninstall() -> Result<()> {
// Best-effort stop+disable; don't fail if it isn't currently active.
let _ = std::process::Command::new("systemctl")
.args(["disable", "--now", SERVICE_NAME])
.status();
if Path::new(UNIT_PATH).exists() {
std::fs::remove_file(UNIT_PATH)
.with_context(|| format!("removing {UNIT_PATH} (are you root?)"))?;
println!("removed {UNIT_PATH}");
}
run("systemctl", &["daemon-reload"])?;
println!("service '{SERVICE_NAME}' uninstalled.");
Ok(())
}
#[cfg(target_os = "linux")]
fn run(cmd: &str, args: &[&str]) -> Result<()> {
let status = std::process::Command::new(cmd)
.args(args)
.status()
.with_context(|| format!("running {cmd} {}", args.join(" ")))?;
if !status.success() {
bail!("{cmd} {} failed with {status}", args.join(" "));
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn install(_config_path: &Path) -> Result<()> {
bail!(
"`install` is only supported on Linux (systemd). Windows SCM support is \
coming; for now run the agent directly or via your platform's service manager."
);
}
#[cfg(not(target_os = "linux"))]
pub fn uninstall() -> Result<()> {
bail!("`uninstall` is only supported on Linux (systemd).");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unit_contains_exec_config_and_install_target() {
let u = unit_file_contents(
"/usr/local/bin/corrosion-host-agent",
"/etc/corrosion/agent.toml",
);
assert!(u.contains(
"ExecStart=/usr/local/bin/corrosion-host-agent --config /etc/corrosion/agent.toml"
));
assert!(u.contains("Type=simple"));
assert!(u.contains("Restart=on-failure"));
assert!(u.contains("WantedBy=multi-user.target"));
assert!(u.contains("After=network-online.target"));
}
}

View File

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

View File

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

156
docs/BRANDING.md Normal file
View File

@@ -0,0 +1,156 @@
# Corrosion — Brand & Naming Taxonomy
**Source:** Oracle (brand review), 2026-06-12.
**Status:** Reference / proposal.
**Locked (2026-06-12):** **Catalyst Console** (control panel/UI — chosen over "Catalyst Control Panel") · **Substrate** (bare-metal execution / process-automation plane) · **re-Agent** (host node agent, binary `corrosion-re-agent-<platform>`), under the **Corrosion Management** house. The "brought to you by Corrosion Management and Chemistry" line is flavor copy, not canonical.
**Leaning (not yet final):** **Formula/Formulae** for deploy manifests (replacing "Blueprints"), repo = **Formulary**, CLI `catalyst deploy --formula rust`.
**Reserved well (adopt per-feature as built, don't name vaporware):** the extended terms below — Formulae, Reaction Jobs, Compounds, Lab Notes, Substrate Engine, etc. — are Oracle's proposed expansion.
---
That rebrand is **way stronger** than "Corrosion Management Panel."
You now have a real naming system instead of a generic panel name:
```text
Corrosion Management
├─ Catalyst Control Panel
├─ Substrate
└─ re-Agent
```
That works because all three names belong to the same metaphor family:
| Name | Meaning | Product fit |
| ------------- | --------------------------------------------------- | ---------------------------- |
| **Corrosion** | transformation, decay, reaction, harsh environments | game/server management brand |
| **Catalyst** | accelerates reactions without being consumed | operator/admin control panel |
| **Substrate** | material/process layer where reaction happens | automation/workflow engine |
| **re-Agent** | chemical reagent + remote agent pun | host/node agent |
**Catalyst Control Panel** is especially good. It sounds polished, memorable, and domain-specific without being cartoonish. It also gives you a clean product phrase:
> "Manage your servers from Catalyst."
or
> "Catalyst by Corrosion."
## Tightened brand hierarchy
```text
Corrosion
Game server orchestration platform
Catalyst
The web control panel
Substrate
The automation and process engine
re-Agent
The BYOH node agent
Chemistry
Optional umbrella/library/ecosystem name
```
## Tagline caution
The one thing to be careful with is the phrase:
> "brought to you by Corrosion Management and Chemistry"
That is clever, but maybe a little wordy for production branding. Use it as flavor copy, not the canonical name.
Better canonical versions:
```text
Catalyst Control Panel
by Corrosion
Catalyst
A Corrosion Management product
Catalyst
Powered by Corrosion Chemistry
```
## Binary naming
The binary naming is solid:
```text
corrosion-re-agent-win-amd64.exe
corrosion-re-agent-linux-amd64
corrosion-re-agent-linux-arm64
corrosion-re-agent-darwin-arm64
```
Use **linux** instead of **nix** in binary names unless you specifically mean all Unix-like systems — `nix-amd64` can be confused with NixOS / the Nix package manager. For clarity:
```text
corrosion-re-agent-linux-amd64
corrosion-re-agent-windows-amd64.exe
```
## Favorite full taxonomy
```text
Corrosion
├─ Catalyst Console # UI/control panel
├─ Substrate Engine # automation/workflows
├─ re-Agent # BYOH host/node agent
├─ Formulae # server templates/manifests
├─ Reaction Jobs # queued automation runs
├─ Compounds # grouped services/stacks
└─ Lab Notes # audit/log/event history
```
That gives Corrosion its own identity while still letting **OxideDock** sit underneath as the container orchestration substrate.
## Clean separation
```text
Catalyst / Corrosion
Game-aware:
- Dune BattleGroups
- Rust servers
- wipes
- mods
- game lifecycle
- player/admin-facing concepts
OxideDock
Infra-aware:
- Docker
- Compose
- Swarm
- agents
- logs
- metrics
- stack deploys
- audit
```
So in practice:
```text
Catalyst asks:
"Deploy Dune BattleGroup Alpha."
Substrate decides:
"Run the BattleGroup deployment workflow."
re-Agent reports:
"This BYOH node is ready."
OxideDock executes:
"Render/deploy/update the container stack."
```
That is a **very clean product ecosystem**.
## One rename suggestion
Consider **Catalyst Console** over **Catalyst Control Panel** for the polished SaaS/product name. But if you like the old-school "control panel" vibe, **Catalyst Control Panel** absolutely works.

View File

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

222
docs/brand/brand-kit.md Normal file
View File

@@ -0,0 +1,222 @@
# Corrosion Management — Brand Kit
Single source of truth for brand voice, the Dr. Flask mascot, social channels,
and launch content. Companion to the character model sheet in
`docs/character/` (`drflask-character-bible.md`, `drflask-characterboard.png`).
---
## 1. Positioning & taglines
**Corrosion Management** — *Game server operations, automated with
chemistry-grade control.*
| Use | Tagline |
| -------------- | ------------------------------------------------ |
| Primary | Controlled reactions for chaotic game servers. |
| Playful | Less server chaos. More beautiful bubbling. |
| Product line | Less frantic clicking. More controlled reaction. |
**The split (keep these lanes clean):**
- **Corrosion Management** = the platform/product. Official, operational, the company voice.
- **Dr. Flask, Ph.D.** = education, shorts, memes, help content, onboarding. The friendly face.
---
## 2. Dr. Flask — voice guide
**Core personality:** a friendly chemistry professor trapped inside a server-
management mascot's body — helpful, excitable, slightly overqualified, never
condescending.
**Voice:** playful, clear, confident, with controlled bursts of nerdy enthusiasm.
**Core vibe:** a lovable 90s help mascot with chaotic educational confidence.
**Voice rule (the one-liner):** Dr. Flask should sound like *a 90s software helper
got trapped in a chemistry edutainment VHS and became weirdly excellent at game
server operations.* (Post-v2, "unlicensed lab goblin professor" is the accepted
shorthand.)
**Comedic north star — influences:**
- Clippy's *"It looks like you're managing a server…"* eager-helper energy
- Mr. DNA's theme-park science-explainer flair
- Weird Al's wholesome nerd chaos
- early-internet tutorial character meets neon chemistry lab
**The crucial distinction:** he channels Clippy's *charm*, never Clippy's
*intrusiveness*. Dr. Flask is the helper mascot we actually wanted — opt-in,
dismissible, fun. We borrow the era's vibe, not its sins (which is why the intro
video is click-to-play in a dismissible lightbox, never an autoplay nuisance).
Homage, **not a direct copy** — never literally Clippy, never literally Mr. DNA.
**Signature move (the lane in one line):**
> "It looks like you're about to wipe a Rust server. Would you like help turning
> that into a controlled reaction?"
Fun, nerdy, persistent, educational — but still genuinely useful. Questionable
enthusiasm: bubbling aggressively.
**Character rule (the formula):** explain the complex server operation in plain
English first, *then* add **one** delightful chemistry wink at the end. One. Not
every sentence.
**He IS:** helpful · theatrical · nerdy · overly enthusiastic · lightly
self-important · *actually useful.*
**He is NOT:** sarcastic in a mean way · childish · modern-corporate "quirky" · a
direct copy of any one character.
**Catchphrase bank (canonical):**
- "Looks like you're managing a server. Want help? No chemistry degree required."
- "Let's turn chaos into a controlled reaction."
- "Great Scott's reagent bottle, that's a lot of plugins."
- "When in doubt, check the Lab Notes."
- "Manual setup? In this economy?"
- "Ah yes, a classic case of server entropy."
- "Deployments are just recipes with consequences."
- "Don't panic. Observe the reaction."
- "That wipe schedule needs adult supervision. Luckily, I'm one flask tall."
- "Degree not included."
**Recurring footer gag:** *"No chemistry degree required. Degree not included."*
use as a sign-off motif across videos, social, and help content.
---
## 3. Social handles
Priority order to grab across YouTube, X/Twitter, Twitch, GitHub, Discord,
TikTok, Instagram.
**Brand (primary):** `@CorrosionMgmt`
**Mascot (reserve):** `@DrFlaskPhD` or `@AskDrFlask`
Backups: `@CorrosionManagement` · `@CorrosionConsole` · `@CorrosionServers` ·
`@UseCorrosion` · `@CorrosionOps` · `@DrFlask`
> Availability is only confirmable on each platform's registration form —
> grab the brand handle on every platform first, even ones not used yet, to
> prevent squatting.
---
## 4. Channel setups (copy-paste ready)
### YouTube
- **Channel name:** Corrosion Management
- **Handle:** `@CorrosionMgmt`
- **Description:**
> Welcome to Corrosion Management — game server operations with chemistry-grade control.
>
> Corrosion helps server owners and communities manage game servers, automation, deployments, wipes, updates, backups, logs, and community systems from one powerful platform.
>
> Guided by Dr. Flask, Ph.D., our friendly chemistry mascot, we turn server chaos into controlled reactions.
>
> No chemistry degree required.
- **Sections:** Dr. Flask Explains · Product Walkthroughs · Server Automation ·
Rust Server Management · Community Ops · The Exchange · Dev Updates
### X / Twitter
- **Name:** Corrosion Management · **Handle:** `@CorrosionMgmt`
- **Bio:**
> Game server operations with chemistry-grade control. Catalyst Console, re-Agent, Formulae, Reactions, Lab Notes, and The Exchange. Guided by Dr. Flask, Ph.D.
- **Punchier alt:**
> Controlled reactions for chaotic game servers. Automation, deployments, wipes, logs, and community commerce — guided by Dr. Flask, Ph.D.
### Twitch
- **Channel name:** Corrosion Management · **Handle:** `CorrosionMgmt`
- **Bio:**
> Live server ops, dev streams, product demos, community builds, and Dr. Flask-approved experiments. We build Corrosion Management: a chemistry-inspired platform for managing game servers, automation, deployments, logs, and community systems. When the server bubbles, we observe the reaction.
- **Stream ideas:** Building Corrosion Live · Dr. Flask Explains · Server Wipe Lab ·
Rust Admin Lab · Automation Experiments · Community Server Clinic · Patch Day Reactions
---
## 5. Dr. Flask mini-series (content engine)
| # | Title | Len | Purpose | Hook |
| - | ----- | --- | ------- | ---- |
| 1 | Welcome to Corrosion | 4560s | Brand intro | "Running a game server is basically a controlled reaction. Let me explain before something bubbles over." |
| 2 | What is Catalyst Console? | 3045s | Product | "Catalyst Console is mission control for your game server community." Tag: *Less frantic clicking. More controlled reaction.* |
| 3 | What is re-Agent? | 3045s | Trust/security | "re-Agent is the tiny connector that lets Corrosion talk to your server safely." Tag: *Small agent. Big chemistry.* |
| 4 | Formulae, Reactions & Compounds | 4560s | Operating model | "Let's turn your server setup from manual chaos into repeatable science." Tag: *Repeatable deployments: because guessing is not science.* |
| 5 | Lab Notes & The Exchange | 4560s | Logs + commerce | "When something goes sideways, don't panic. Check the Lab Notes." Tag: *Observe the reaction. Reward the community.* |
Video 1 doubles as the brand trailer (below).
### Recurring series: "Dr. Flask Appears"
Short-form, evergreen, meme-able. Dr. Flask pops in **uninvited** to solve a real
admin pain — Clippy energy, actually useful.
**Episode structure:**
1. Admin has a server problem.
2. Dr. Flask pops in uninvited.
3. He explains the relevant Corrosion concept.
4. One useful plain-English takeaway.
5. Ends on a nerdy button line.
**Example open:** *"It looks like you're trying to manually update six servers at
once. Would you like to convert that panic into a Reaction?"*
This is the flagship social format — the bubbling green money goblin, on demand.
---
## 6. Brand trailer brief
- **Platform:** YouTube Shorts / TikTok / Instagram Reels
- **Duration:** 4560s · **Format:** 9:16 vertical · **Tone:** playful, polished, techy, mascot-led
- **Audience:** game server owners, admins, modders, community operators
- **Visual reference:** Dr. Flask character storyboard (`docs/character/drflask-characterboard.png`)
- **Concept:** Dr. Flask introduces Corrosion Management as a chemistry-inspired game
server ops platform. Mr. DNA meets modern server automation. Neon-green lab-console
environment.
**Voiceover script:**
> Hi! I'm Dr. Flask, Ph.D., and welcome to Corrosion Management.
>
> Running a game server is basically a controlled reaction. You need the right
> environment, the right timing, the right ingredients, and a reliable way to know
> what happened when things start bubbling.
>
> That's where Corrosion comes in.
>
> Catalyst Console is your mission control for servers, players, plugins, files,
> wipes, schedules, and automation.
>
> re-Agent securely connects your server back to Catalyst.
>
> Substrate is the hardware your world runs on.
>
> Formulae are reusable recipes for deploying game servers.
>
> Reactions are the jobs that change server state — wipes, restarts, updates,
> backups, and maintenance.
>
> Lab Notes show what happened, when it happened, and whether it worked.
>
> And The Exchange helps your community manage perks, packages, payments, and
> in-game delivery.
>
> No chemistry degree required. Just better server management — with slightly more bubbling.
> **NOTE:** at natural pace this VO runs ~7590s. For a true ≤60s Short, trim the
> intro and one descriptor per term (see the glossary-video timing notes). For the
> on-site FAQ embed, length is unconstrained.
---
*Domains are final: `corrosionmgmt.com` (company) + `panel.corrosionmgmt.com`
(the panel = Catalyst Console). Brand handle mirrors the domain: CorrosionMgmt.*

View File

@@ -0,0 +1,106 @@
# Dr. Flask — Character Bible
Corrosion's friendly chemistry guide. Appears in the FAQ and help sections to
explain Corrosion's chemistry-themed lexicon without turning the panel into a
chemistry class. Helpful? Yes. Mandatory? No. Likely bubbling with questionable
enthusiasm? Absolutely.
**Definitive reference:** `drflask-v2final.webp` — the **v2 "90s spoof" model
sheet** (current). `drflask-characterboard.png` is the v1 sheet (superseded).
## Identity (v2)
| Field | Value |
| ---------- | ------------------------------ |
| Name | Dr. Flask |
| Alias | Corrosion Guide |
| Title | Ph.D. (Self-Certified) |
| Specialty | Server Chemistry |
| Archetype | Helpful Guide |
| Height | One Flask Tall |
| Build | Erlenmeyer flask |
| Liquid | Neon green |
| Catchphrase| "No Degree Required" (degree not included) |
**Character note (from the v2 board):** *Appears whenever you need him.
Sometimes when you don't. "It looks like you're managing a server. Want help?"
No chemistry degree required.* Bubbles aggressively when excited.
**Comedic north star:** a loving spoof of the 90s helper-mascot era — Clippy +
Jurassic Park's Mr. DNA — with Weird Al "White & Nerdy" self-aware nerd-pride.
In on the joke, never the butt of it. He channels Clippy's *charm*, never
Clippy's *intrusiveness* — the helper mascot we actually wanted (opt-in,
dismissible, fun). Full voice guide: `docs/brand/brand-kit.md` §2.
## Color palette
Values as read from the model sheet — confirm exact hexes against the invideo
source if pixel-accuracy matters.
| Swatch | Hex (approx) | Use |
| --------------- | ------------ | -------------------------------- |
| Neon Green | `#00FF3D` | The liquid — primary character color |
| Tassel Green | `#39FF14` | Mortarboard tassel |
| Bubble Highlight| `#B0FFB8` | Bubble/gesture highlights |
| Glass | `#B6F7FF` | Flask glass / rim reflections |
| Charcoal Gray | `#2A2A2A` | Cap, shadow |
| Deep Black | `#0D0D0D` | Outline / background |
**In-product note:** the FAQ "lab zone" UI accent is a *readable* green
(`--accent-text: #5bd183`, scoped to `.sec--lab`) — same family as the liquid
but toned down so text/borders stay legible on dark (pure `#00FF3D` vibrates as
UI text). Character art uses the neon greens above; UI uses the readable
derivative. Can nudge the UI green brighter toward canon on request.
## Model sheet — animation reference (v2)
- **Views:** 3/4 view, side view.
- **Expression progression (8):** neutral · excited · dramatic · offended ·
conspiratorial · triumphant · worried · thumbs-up.
- **Micro-expressions (5):** liquid rises · eyebrow arch · mortarboard tilt ·
toothy grin · eyes narrow.
- **Posture variations (4):** arms-wide welcoming · leaning on pointer stick ·
pointing dramatically · celebratory bounce.
- **Hand gestures (white cartoon gloves):** finger-gun pointing · double
thumbs-up · one hand raised.
- **Silhouettes:** neutral, action.
- **Wardrobe (v2 — current):** mortarboard worn **askew** + green tassel · **bow
tie** (yellow) · **lab coat** · **pointer stick** · clip-on microphone ·
**googly eyes**. Energy = Clippy's persistence + Mr. DNA's flair + Weird Al's
chaotic sincerity. (v1 was a clean kawaii render with just the mortarboard.)
- **Added palette (v2):** Bow Tie Yellow · Lab Coat White (atop the green/charcoal core).
## Storyboard (12-beat video sequence)
`drflask-storyboard.webp` — maps panel-for-panel to the VO script:
hero intro · server world · Catalyst (mission control) · console/analytics ·
re-Agent (plugged-in shield, no inbound ports) · Substrate (server racks) ·
Formulae (recipe book) · Reactions (data wave) · Compounds (service cluster) ·
Lab Notes (clipboard) · The Exchange (marketplace grid) · outro wave.
## Where he appears
- **FAQ chemistry glossary** (`frontend/src/views/marketing/FaqView.vue`,
`#chemistry`): the cover card beside the "Brush up on your chemistry…" heading.
- **Intro video:** 7590s, 9:16 vertical (YouTube Short) explainer — Dr. Flask
reads the glossary. Plays click-to-play in a **phone-frame lightbox** (no loop,
controls at the bottom of the screen). See `phone-frame-preview.png`.
## Assets
| File | What |
| ----------------------------------------- | ------------------------------------------ |
| `drflask-v2final.webp` | **Model sheet — v2 (current, definitive)** |
| `drflask-characterboard.png` | Model sheet — v1 (superseded) |
| `drflask-storyboard.webp` | 12-beat video storyboard (invideo) |
| `drflask-final.png` | Placeholder card render (1254², source) |
| `theflask.png` / `theatom.png` | Earlier concept cards |
| `frontend/src/assets/mascots/drflask.png` | Web-optimized cover (560px, ~394 KB) |
| `phone-frame-preview.png` | Preview of the phone-frame lightbox |
## Status
Placeholder card art (ChatGPT) in use on the FAQ; full animated character +
7590s intro video in production via invideo (Gemini-scripted), now backed by
the model sheet above. Swap the cover + wire the video into the lightbox when
the render lands.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -8,7 +8,7 @@
<link rel="apple-touch-icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0a0a" />
<title>Corrosion Management</title>
<title>Catalyst Console</title>
<meta name="description" content="Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server." />
<meta property="og:title" content="Corrosion — Game Server Operations for Self-Hosted Communities" />
<meta property="og:description" content="Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server." />

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
/**
* Logo — Corrosion brand lockup.
* Composes the CorrosionMark SVG + Oxanium wordmark + optional tagline.
* Logo — Catalyst brand lockup.
* Composes the CorrosionMark SVG + Oxanium wordmark "Catalyst" + optional tagline.
*
* The mark renders in `currentColor`, so set `color: var(--accent)` on a
* parent (or pass `markColor`) to theme it per active game.
*
* Props mirror Logo.jsx exactly:
* size — base px size; drives mark em-size + wordmark scaling
* wordmark — show the "Corrosion" text (default true)
* tagline — false | true (→ "Management Panel") | custom string
* wordmark — show the "Catalyst" text (default true)
* tagline — false | true (→ "by Corrosion") | custom string
* glow — accent drop-shadow for marketing / login hero use
* markColor — force a fixed color on the mark (bypasses currentColor theming)
*/
@@ -35,7 +35,7 @@ const glowFilter = computed(() =>
props.glow ? `drop-shadow(0 0 ${props.size * 0.5}px var(--accent-glow))` : 'none'
)
const tagText = computed(() =>
typeof props.tagline === 'string' ? props.tagline : 'Management Panel'
typeof props.tagline === 'string' ? props.tagline : 'by Corrosion'
)
</script>
@@ -70,7 +70,7 @@ const tagText = computed(() =>
color: 'var(--text-primary)',
lineHeight: 1,
}"
>Corrosion</span>
>Catalyst</span>
<span
v-if="tagline"
:style="{

View File

@@ -126,7 +126,7 @@ const agentLabel = computed(() => {
})
// One host → its hostname; multiple → fleet count.
const agentName = computed(() =>
hostCount.value === 1 ? (realHosts.value[0]?.hostname ?? 'Host agent') : `${hostCount.value} hosts`,
hostCount.value === 1 ? (realHosts.value[0]?.hostname ?? 're-Agent') : `${hostCount.value} hosts`,
)
const agentMetaLine = computed(() => {
@@ -231,9 +231,9 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
<div v-else class="agent agent--empty">
<div class="agent__row">
<StatusDot tone="offline" />
<span class="agent__name agent__name--muted">No host agent connected</span>
<span class="agent__name agent__name--muted">No re-Agent connected</span>
</div>
<div class="agent__meta">Install the Corrosion host agent from the Server page</div>
<div class="agent__meta">Install re-Agent from the Server page</div>
</div>
<!-- User / logout row -->
<div class="side__user">
@@ -272,22 +272,11 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
<!-- Breadcrumb -->
<div class="top__crumbs">
<span class="crumb">Corrosion</span>
<span class="crumb">Catalyst</span>
<span class="crumb__sep">/</span>
<span class="crumb crumb--cluster">{{ serverName }}</span>
</div>
<!-- Search -->
<div class="top__search">
<svg class="top__search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input placeholder="Search servers, players, configs…" readonly />
<span class="top__kbd">
<kbd class="cc-kbd"></kbd><kbd class="cc-kbd">K</kbd>
</span>
</div>
<!-- Actions -->
<div class="top__actions">
<IconButton
@@ -296,7 +285,7 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
@click="toggleTheme"
/>
<IconButton icon="bell" label="Alerts" @click="router.push('/alerts')" />
<Button size="sm" icon="rocket">Deploy server</Button>
<Button size="sm" icon="rocket" @click="router.push('/server')">Deploy server</Button>
<Avatar
:name="userName"
:size="30"

View File

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

View File

@@ -98,7 +98,7 @@ function applyActiveGame(g: ActiveGame, persist: boolean): void {
/**
* Derive the active game from the deployed fleet — the game instances are the
* source of truth for which game(s) a license runs (game_instances.game, set by
* the host agent). Exactly one game deployed → skin the shell to it; zero or
* re-Agent). Exactly one game deployed → skin the shell to it; zero or
* multiple → 'all' (neutral house skin).
*
* NO-OP when the operator has a manual pick stored (cc-active-game present): an

View File

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

View File

@@ -52,7 +52,7 @@ const marketingChildren: RouteRecordRaw[] = [
component: () => import('@/views/marketing/HowItWorksView.vue'),
meta: {
title: 'How It Works — Corrosion',
description: 'Install one host agent on Windows or Linux. It connects outbound-only to Corrosion — no inbound ports, no SSH. Manage every game instance from the browser.',
description: 'Install one re-Agent on Windows or Linux. It connects outbound-only to Corrosion — no inbound ports, no SSH. Manage every game instance from the browser.',
},
},
{
@@ -70,7 +70,7 @@ const marketingChildren: RouteRecordRaw[] = [
component: () => import('@/views/marketing/RoadmapView.vue'),
meta: {
title: 'Roadmap — Corrosion',
description: 'Phase 1 shipped: core control plane, auto-wiper, plugin management. In progress: Dune, Conan, Soulmask multi-game blueprints. Planned: API access, integrations.',
description: 'Phase 1 shipped: core control plane, auto-wiper, plugin management. In progress: Dune, Conan, Soulmask multi-game Formulae. Planned: API access, integrations.',
},
},
{
@@ -94,25 +94,25 @@ const panelRoutes: RouteRecordRaw[] = [
path: '/login',
name: 'login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { guest: true, title: 'Sign in — Corrosion' },
meta: { guest: true, title: 'Sign in to Catalyst' },
},
{
path: '/register',
name: 'register',
component: () => import('@/views/auth/RegisterView.vue'),
meta: { guest: true, title: 'Create account — Corrosion' },
meta: { guest: true, title: 'Create account — Catalyst' },
},
{
path: '/forgot-password',
name: 'forgot-password',
component: () => import('@/views/auth/ForgotPasswordView.vue'),
meta: { guest: true, title: 'Reset password — Corrosion' },
meta: { guest: true, title: 'Reset password — Catalyst' },
},
{
path: '/setup',
name: 'setup-wizard',
component: () => import('@/views/auth/SetupWizardView.vue'),
meta: { requiresAuth: true, title: 'Setup — Corrosion' },
meta: { requiresAuth: true, title: 'Setup — Catalyst' },
},
// Admin dashboard routes (with sidebar layout)
@@ -125,260 +125,260 @@ const panelRoutes: RouteRecordRaw[] = [
path: '',
name: 'dashboard',
component: () => import('@/views/admin/DashboardView.vue'),
meta: { title: 'Dashboard Corrosion' },
meta: { title: 'Dashboard · Catalyst' },
},
{
path: 'server',
name: 'server',
component: () => import('@/views/admin/ServerView.vue'),
meta: { title: 'Server Corrosion' },
meta: { title: 'Server · Catalyst' },
},
{
path: 'console',
name: 'console',
component: () => import('@/views/admin/ConsoleView.vue'),
meta: { title: 'Console Corrosion' },
meta: { title: 'Console · Catalyst' },
},
{
path: 'players',
name: 'players',
component: () => import('@/views/admin/PlayersView.vue'),
meta: { title: 'Players Corrosion' },
meta: { title: 'Players · Catalyst' },
},
{
path: 'plugins',
name: 'plugins',
component: () => import('@/views/admin/PluginsView.vue'),
meta: { title: 'Plugins Corrosion' },
meta: { title: 'Plugins · Catalyst' },
},
{
path: 'files',
name: 'files',
component: () => import('@/views/admin/FileManagerView.vue'),
meta: { title: 'Files Corrosion' },
meta: { title: 'Files · Catalyst' },
},
{
path: 'plugin-configs',
name: 'plugin-configs',
component: () => import('@/views/admin/PluginConfigsView.vue'),
meta: { title: 'Plugin Configs Corrosion' },
meta: { title: 'Plugin Configs · Catalyst' },
},
{
path: 'loot-builder',
name: 'loot-builder',
component: () => import('@/views/admin/LootBuilderView.vue'),
meta: { title: 'Loot Builder Corrosion' },
meta: { title: 'Loot Builder · Catalyst' },
},
{
path: 'teleport-config',
name: 'teleport-config',
component: () => import('@/views/admin/TeleportConfigView.vue'),
meta: { title: 'Teleport Config Corrosion' },
meta: { title: 'Teleport Config · Catalyst' },
},
{
path: 'gather-manager',
name: 'gather-manager',
component: () => import('@/views/admin/GatherManagerView.vue'),
meta: { title: 'Gather Manager Corrosion' },
meta: { title: 'Gather Manager · Catalyst' },
},
{
path: 'autodoors',
name: 'autodoors',
component: () => import('@/views/admin/AutoDoorsView.vue'),
meta: { title: 'Auto Doors Corrosion' },
meta: { title: 'Auto Doors · Catalyst' },
},
{
path: 'kits',
name: 'kits-config',
component: () => import('@/views/admin/KitsView.vue'),
meta: { title: 'Kits Corrosion' },
meta: { title: 'Kits · Catalyst' },
},
{
path: 'furnace-splitter',
name: 'furnace-splitter',
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
meta: { title: 'Furnace Splitter Corrosion' },
meta: { title: 'Furnace Splitter · Catalyst' },
},
{
path: 'better-chat',
name: 'better-chat',
component: () => import('@/views/admin/BetterChatView.vue'),
meta: { title: 'Better Chat Corrosion' },
meta: { title: 'Better Chat · Catalyst' },
},
{
path: 'timed-execute',
name: 'timed-execute',
component: () => import('@/views/admin/TimedExecuteView.vue'),
meta: { title: 'Timed Execute Corrosion' },
meta: { title: 'Timed Execute · Catalyst' },
},
{
path: 'raidable-bases',
name: 'raidable-bases',
component: () => import('@/views/admin/RaidableBasesView.vue'),
meta: { title: 'Raidable Bases Corrosion' },
meta: { title: 'Raidable Bases · Catalyst' },
},
{
path: 'wipes',
name: 'wipes',
component: () => import('@/views/admin/WipesView.vue'),
meta: { title: 'Wipes Corrosion' },
meta: { title: 'Wipes · Catalyst' },
},
{
path: 'wipes/profiles',
name: 'wipe-profiles',
component: () => import('@/views/admin/WipeProfilesView.vue'),
meta: { title: 'Wipe Profiles Corrosion' },
meta: { title: 'Wipe Profiles · Catalyst' },
},
{
path: 'wipes/calendar',
name: 'wipe-calendar',
component: () => import('@/views/admin/WipeCalendarView.vue'),
meta: { title: 'Wipe Calendar Corrosion' },
meta: { title: 'Wipe Calendar · Catalyst' },
},
{
path: 'wipes/history',
name: 'wipe-history',
component: () => import('@/views/admin/WipeHistoryView.vue'),
meta: { title: 'Wipe History Corrosion' },
meta: { title: 'Wipe History · Catalyst' },
},
{
path: 'wipes/analytics',
name: 'wipe-analytics',
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
meta: { title: 'Wipe Analytics Corrosion' },
meta: { title: 'Wipe Analytics · Catalyst' },
},
{
path: 'maps',
name: 'maps',
component: () => import('@/views/admin/MapsView.vue'),
meta: { title: 'Maps Corrosion' },
meta: { title: 'Maps · Catalyst' },
},
{
path: 'maps/analytics',
name: 'map-analytics',
component: () => import('@/views/admin/MapAnalyticsView.vue'),
meta: { title: 'Map Analytics Corrosion' },
meta: { title: 'Map Analytics · Catalyst' },
},
{
path: 'chat',
name: 'chat',
component: () => import('@/views/admin/ChatLogView.vue'),
meta: { title: 'Chat Log Corrosion' },
meta: { title: 'Chat Log · Catalyst' },
},
{
path: 'analytics',
name: 'analytics',
component: () => import('@/views/admin/AnalyticsView.vue'),
meta: { title: 'Analytics Corrosion' },
meta: { title: 'Analytics · Catalyst' },
},
{
path: 'retention',
name: 'retention',
component: () => import('@/views/admin/PlayerRetentionView.vue'),
meta: { title: 'Player Retention Corrosion' },
meta: { title: 'Player Retention · Catalyst' },
},
{
path: 'notifications',
name: 'notifications',
component: () => import('@/views/admin/NotificationsView.vue'),
meta: { title: 'Notifications Corrosion' },
meta: { title: 'Notifications · Catalyst' },
},
{
path: 'team',
name: 'team',
component: () => import('@/views/admin/TeamView.vue'),
meta: { title: 'Team Corrosion' },
meta: { title: 'Team · Catalyst' },
},
{
path: 'store/config',
name: 'store-config',
component: () => import('@/views/admin/StoreConfigView.vue'),
meta: { title: 'Store Config Corrosion' },
meta: { title: 'Store Config · Catalyst' },
},
{
path: 'store/items',
name: 'store-items',
component: () => import('@/views/admin/StoreItemsView.vue'),
meta: { title: 'Store Items Corrosion' },
meta: { title: 'Store Items · Catalyst' },
},
{
path: 'store/revenue',
name: 'store-revenue',
component: () => import('@/views/admin/StoreRevenueView.vue'),
meta: { title: 'Store Revenue Corrosion' },
meta: { title: 'Store Revenue · Catalyst' },
},
{
path: 'modules',
name: 'modules',
component: () => import('@/views/admin/ModuleStoreView.vue'),
meta: { title: 'Modules Corrosion' },
meta: { title: 'Modules · Catalyst' },
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/admin/SettingsView.vue'),
meta: { title: 'Settings Corrosion' },
meta: { title: 'Settings · Catalyst' },
},
{
path: 'schedules',
name: 'schedules',
component: () => import('@/views/admin/SchedulesView.vue'),
meta: { title: 'Schedules Corrosion' },
meta: { title: 'Schedules · Catalyst' },
},
{
path: 'migration',
name: 'migration',
component: () => import('@/views/admin/MigrationView.vue'),
meta: { title: 'Migration Corrosion' },
meta: { title: 'Migration · Catalyst' },
},
{
path: 'changelog',
name: 'changelog',
component: () => import('@/views/admin/ChangelogView.vue'),
meta: { title: 'Changelog Corrosion' },
meta: { title: 'Changelog · Catalyst' },
},
{
path: 'alerts',
name: 'alerts',
component: () => import('@/views/admin/AlertsView.vue'),
meta: { title: 'Alerts Corrosion' },
meta: { title: 'Alerts · Catalyst' },
},
{
path: 'fleet',
name: 'fleet',
component: () => import('@/views/admin/FleetView.vue'),
meta: { title: 'Fleet Corrosion', requiresAuth: true },
meta: { title: 'Fleet · Catalyst', requiresAuth: true },
},
// Platform Admin views (super-admin only)
{
path: 'admin',
name: 'platform-admin',
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
meta: { superAdmin: true, title: 'Admin Corrosion' },
meta: { superAdmin: true, title: 'Admin · Catalyst' },
},
{
path: 'admin/licenses',
name: 'platform-licenses',
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
meta: { superAdmin: true, title: 'Admin: Licenses Corrosion' },
meta: { superAdmin: true, title: 'Admin: Licenses · Catalyst' },
},
{
path: 'admin/subscriptions',
name: 'platform-subscriptions',
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
meta: { superAdmin: true, title: 'Admin: Subscriptions Corrosion' },
meta: { superAdmin: true, title: 'Admin: Subscriptions · Catalyst' },
},
{
path: 'admin/users',
name: 'platform-users',
component: () => import('@/views/platform-admin/AdminUsers.vue'),
meta: { superAdmin: true, title: 'Admin: Users Corrosion' },
meta: { superAdmin: true, title: 'Admin: Users · Catalyst' },
},
{
path: 'admin/servers',
name: 'platform-servers',
component: () => import('@/views/platform-admin/AdminServers.vue'),
meta: { superAdmin: true, title: 'Admin: Servers Corrosion' },
meta: { superAdmin: true, title: 'Admin: Servers · Catalyst' },
},
],
},
@@ -413,7 +413,7 @@ const panelRoutes: RouteRecordRaw[] = [
path: '/status',
name: 'status',
component: () => import('@/views/public/StatusPageView.vue'),
meta: { title: 'Status Corrosion' },
meta: { title: 'Status · Catalyst' },
},
// Catch-all
@@ -488,14 +488,14 @@ function setOrClearMeta(selector: string, attr: string, value: string): void {
router.afterEach((to) => {
// Title
document.title = to.meta.title ?? 'Corrosion Management'
document.title = to.meta.title ?? 'Catalyst Console'
// Description
const desc = to.meta.description ?? ''
setOrClearMeta('meta[name="description"]', 'content', desc)
// OG title
setOrClearMeta('meta[property="og:title"]', 'content', to.meta.title ?? 'Corrosion Management')
setOrClearMeta('meta[property="og:title"]', 'content', to.meta.title ?? 'Catalyst Console')
// OG description
setOrClearMeta('meta[property="og:description"]', 'content', desc)

View File

@@ -97,7 +97,12 @@ export const useAuthStore = defineStore('auth', () => {
? decodeJwtPermissions(accessToken.value)
: {}
return perms[permission] === true
// Honor the global wildcard (Owner) and resource wildcards ("server.*")
// so role permissions stored as wildcards aren't missed by an exact match.
if (perms['*'] === true) return true
if (perms[permission] === true) return true
const resourceWildcard = permission.split('.')[0] + '.*'
return perms[resourceWildcard] === true
}
return {

View File

@@ -39,7 +39,7 @@ export const useFilesStore = defineStore('files', () => {
async function list(path: string): Promise<void> {
const id = currentId()
if (!id) {
error.value = 'No instance — connect the host agent'
error.value = 'No instance — connect re-Agent'
entries.value = []
return
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,7 +87,7 @@ function handleWebSocketMessage(message: WebSocketMessage) {
let unsubscribe: (() => void) | null = null
onMounted(() => {
addLine('Corrosion console initialized.', 'system')
addLine('Catalyst Console initialized.', 'system')
addLine('Type a command and press Enter to send it to the server.', 'system')
if (server.connection?.connection_status !== 'connected') {
addLine('Warning: server is not connected. Commands will fail.', 'warning')

View File

@@ -259,7 +259,7 @@ function navServer() { router.push('/server') }
<EmptyState
icon="server"
title="No server connected"
description="Install the Corrosion host agent on your host machine to begin managing your server from Corrosion."
description="Install re-Agent on your host machine to begin managing your server from Catalyst."
>
<template #action>
<Button icon="server" @click="navServer">Set up server</Button>
@@ -383,7 +383,7 @@ function navServer() { router.push('/server') }
v-model="consoleInput"
:mono="true"
size="sm"
placeholder="say, kick, ban, oxide.reload …"
:placeholder="profile.mods === 'umod' ? 'say, kick, ban, oxide.reload …' : 'say, kick, ban …'"
:disabled="!isConnected"
style="flex: 1"
@keydown.enter="sendConsoleCommand"
@@ -404,7 +404,7 @@ function navServer() { router.push('/server') }
<div class="dash__col dash__col--side">
<!-- Resources real stats from agent; null = '—' -->
<Panel title="Resources" subtitle="Host agent telemetry">
<Panel title="Resources" subtitle="re-Agent telemetry">
<div class="solo-meters">
<ResourceMeter
label="CPU"
@@ -418,7 +418,7 @@ function navServer() { router.push('/server') }
/>
</div>
<div v-if="soloCpu === null && soloRamMb === null" class="meters-note">
Resource metrics arrive via the host agent heartbeat.
Resource metrics arrive via re-Agent heartbeat.
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
Agent setup
</Button>

View File

@@ -193,8 +193,8 @@ async function confirmDelete(path: string) {
<Panel v-if="!instancesStore.loading && instancesStore.instances.length === 0">
<EmptyState
icon="server"
title="No host agent connected"
description="Install the host agent from the Server page to manage files on your game server."
title="No re-Agent connected"
description="Install re-Agent from the Server page to manage files on your game server."
>
<template #action>
<Button variant="secondary" size="sm" icon="server" @click="router.push('/server')">

View File

@@ -154,7 +154,7 @@ function relativeHeartbeat(iso: string | null): string {
<EmptyState
icon="server"
title="No hosts connected yet"
description="Install the Corrosion host agent on your server machine to see it here."
description="Install re-Agent on your server machine to see it here."
>
<template #action>
<Button variant="primary" @click="router.push('/server')">Go to Server page</Button>

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ const tabItems = [
function sourceLabel(source: string): string {
switch (source) {
case 'umod': return 'uMod'
case 'corrosion_module': return 'Corrosion'
case 'corrosion_module': return 'Catalyst'
case 'manual': return 'Manual'
default: return source
}
@@ -485,7 +485,7 @@ onMounted(() => {
</Panel>
<Alert tone="info">
The plugin will be registered in your plugin list immediately. Your host agent must be connected
The plugin will be registered in your plugin list immediately. Your re-Agent must be connected
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
</Alert>
</div>

View File

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

View File

@@ -108,7 +108,7 @@ const showCreds = ref(false)
const tomlCopied = ref(false)
const deployForm = ref<DeploymentConfig>({
server_name: 'My Rust Server',
server_name: '',
max_players: 100,
world_size: 4000,
seed: Math.floor(Math.random() * 2147483647),
@@ -327,7 +327,7 @@ async function saveConfig() {
async function serverAction(action: 'start' | 'stop' | 'restart') {
if (!currentInstance.value) {
toast.error('No game instance to control — connect the host agent first')
toast.error('No game instance to control — connect re-Agent first')
return
}
actionLoading.value = action
@@ -465,7 +465,7 @@ onMounted(async () => {
}
if (msg.type === 'event' && msg.event === 'oxide_status') {
oxideStatus.value = msg.data as { stage: string; progress: number; message: string; error?: string }
if (msg.data && (msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed') {
if (msg.data && ((msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed')) {
isInstallingOxide.value = false
}
}
@@ -532,7 +532,7 @@ onMounted(async () => {
v-if="!currentInstance"
icon="server"
title="No game instance connected"
:description="'Install the host agent and add a ' + profile.label + ' instance to its config to manage it here.'"
:description="'Install re-Agent and add a ' + profile.label + ' instance to its config to manage it here.'"
/>
<template v-else>
@@ -611,7 +611,7 @@ onMounted(async () => {
</Panel>
<!-- Host agent -->
<Panel title="Host agent" subtitle="Bare-metal server management binary">
<Panel title="re-Agent" subtitle="Bare-metal server management binary">
<template #actions>
<Badge :tone="isAgentConnected ? 'online' : 'offline'" :dot="true" :pulse="isAgentConnected">
{{ isAgentConnected ? 'Active' : 'Inactive' }}
@@ -640,7 +640,7 @@ onMounted(async () => {
<!-- Download -->
<div class="sv__section-head">
<Icon name="download" :size="14" />
<span>Download host agent</span>
<span>Download re-Agent</span>
</div>
<div class="sv__downloads sv__mb">
<a
@@ -683,7 +683,7 @@ onMounted(async () => {
<!-- Linux commands -->
<div v-if="setupTab === 'linux'" class="sv__codeblock">
<p class="sv__cmt"># Download the agent</p>
<p class="sv__cmt"># Download re-Agent</p>
<p>curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
<p>chmod +x corrosion-host-agent-linux-amd64</p>
<p class="sv__cmt sv__mt">&#x23; Write /etc/corrosion/agent.toml (see config block below), then run:</p>
@@ -694,7 +694,7 @@ onMounted(async () => {
<!-- Windows commands -->
<div v-if="setupTab === 'windows'" class="sv__codeblock">
<p class="sv__cmt"># Requires PowerShell (not Command Prompt)</p>
<p class="sv__cmt"># Download the agent</p>
<p class="sv__cmt"># Download re-Agent</p>
<p>Invoke-WebRequest -Uri <span class="sv__accent">"https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-windows-amd64.exe"</span> -OutFile <span class="sv__accent">"corrosion-host-agent-windows-amd64.exe"</span></p>
<p class="sv__cmt sv__mt">&#x23; Write C:\ProgramData\Corrosion\agent.toml (see config block below), then run:</p>
<p>New-Item -ItemType Directory -Force -Path <span class="sv__accent">"C:\ProgramData\Corrosion"</span></p>
@@ -726,7 +726,7 @@ onMounted(async () => {
<pre class="sv__pre">{{ agentTomlConfig }}</pre>
</div>
<Alert v-if="!agentCreds" tone="warn" class="sv__mt">
Could not load credentials from server. Copy this config and replace the placeholders with values from your Corrosion dashboard settings.
Could not load credentials from server. Copy this config and replace the placeholders with values from your Catalyst dashboard settings.
</Alert>
</Panel>
@@ -858,7 +858,7 @@ onMounted(async () => {
<EmptyState
icon="box"
title="Docker-managed deployment"
:description="profile.label + ' servers are managed via Docker Compose. Connect the host agent on your Docker host to enable lifecycle management.'"
:description="profile.label + ' servers are managed via Docker Compose. Connect re-Agent on your Docker host to enable lifecycle management.'"
>
<template #action>
<Badge tone="info">Docker · Compose</Badge>
@@ -929,13 +929,13 @@ onMounted(async () => {
<EmptyState
icon="layers"
:title="profile.label + ' mod management'"
:description="profile.label + ' loads mods directly from Steam Workshop. Manage your mod list in server config — no Corrosion install step needed.'"
:description="profile.label + ' loads mods directly from Steam Workshop. Manage your mod list in server config — no Catalyst install step needed.'"
/>
</Panel>
<!-- Conan Exiles special concepts (Clans / Thralls / Purge) -->
<Panel
v-if="profile.accent === 'conan'"
v-if="activeGame === 'conan'"
title="Conan Exiles concepts"
subtitle="Key admin mechanics for Conan Exiles servers"
>
@@ -973,7 +973,7 @@ onMounted(async () => {
<EmptyState
icon="network"
title="Cluster management coming soon"
:description="'Connect a ' + profile.label + ' host to manage the main-client cluster from this panel. Cluster configuration requires the host agent.'"
:description="'Connect a ' + profile.label + ' host to manage the main-client cluster from this panel. Cluster configuration requires re-Agent.'"
/>
</Panel>
@@ -986,7 +986,7 @@ onMounted(async () => {
<EmptyState
icon="map"
title="Sietch management requires a connected Dune host"
description="Connect the host agent on your Dune: Awakening Docker host to manage BattleGroups and Sietches from this panel."
description="Connect re-Agent on your Dune: Awakening Docker host to manage BattleGroups and Sietches from this panel."
/>
</Panel>

View File

@@ -33,6 +33,31 @@ const publicSiteForm = ref({
status_page_description: '',
})
// --- Security: password change ---
const pwForm = ref({ current_password: '', new_password: '', confirm: '' })
const changingPw = ref(false)
// --- Security: 2FA enrollment flow ---
const totp = ref<{ qr: string; secret: string; code: string; setting: boolean }>({
qr: '', secret: '', code: '', setting: false,
})
const disable2fa = ref({ open: false, code: '', busy: false })
// --- API keys ---
interface ApiKeyRow {
id: string
name: string
key_prefix: string
last_used_at: string | null
is_active: boolean
created_at: string
}
const apiKeys = ref<ApiKeyRow[]>([])
const newKeyName = ref('')
const creatingKey = ref(false)
const createdKey = ref<string | null>(null)
const loadingKeys = ref(false)
async function loadForms() {
if (auth.user) {
accountForm.value.username = auth.user.username
@@ -89,16 +114,144 @@ async function savePublicSite() {
}
}
async function changePassword() {
if (pwForm.value.new_password.length < 8) {
toast.error('New password must be at least 8 characters')
return
}
if (pwForm.value.new_password !== pwForm.value.confirm) {
toast.error('New password and confirmation do not match')
return
}
changingPw.value = true
try {
await api.post('/auth/change-password', {
current_password: pwForm.value.current_password,
new_password: pwForm.value.new_password,
})
toast.success('Password changed')
pwForm.value = { current_password: '', new_password: '', confirm: '' }
} catch (err) {
toast.error('Failed to change password: ' + (err as Error).message)
} finally {
changingPw.value = false
}
}
async function startTotpSetup() {
totp.value.setting = true
try {
const res = await api.post<{ qr_code: string; secret: string }>('/auth/2fa/setup', {})
totp.value.qr = res.qr_code
totp.value.secret = res.secret
} catch (err) {
totp.value.setting = false
toast.error('Failed to start 2FA setup: ' + (err as Error).message)
}
}
async function confirmTotpSetup() {
if (totp.value.code.length !== 6) {
toast.error('Enter the 6-digit code from your authenticator')
return
}
try {
await api.post('/auth/2fa/verify', { code: totp.value.code })
await auth.validateSession()
toast.success('Two-factor authentication enabled')
totp.value = { qr: '', secret: '', code: '', setting: false }
} catch (err) {
toast.error('Invalid code — try again: ' + (err as Error).message)
}
}
function cancelTotpSetup() {
totp.value = { qr: '', secret: '', code: '', setting: false }
}
async function confirmDisable2fa() {
if (disable2fa.value.code.length !== 6) {
toast.error('Enter your current 6-digit code to disable 2FA')
return
}
disable2fa.value.busy = true
try {
await api.post('/auth/2fa/disable', { code: disable2fa.value.code })
await auth.validateSession()
toast.success('Two-factor authentication disabled')
disable2fa.value = { open: false, code: '', busy: false }
} catch (err) {
toast.error('Failed to disable 2FA: ' + (err as Error).message)
} finally {
disable2fa.value.busy = false
}
}
async function loadApiKeys() {
loadingKeys.value = true
try {
apiKeys.value = await api.get<ApiKeyRow[]>('/api-keys')
} catch (err) {
toast.error('Failed to load API keys: ' + (err as Error).message)
} finally {
loadingKeys.value = false
}
}
async function createApiKey() {
if (!newKeyName.value.trim()) {
toast.error('Give the key a name')
return
}
creatingKey.value = true
createdKey.value = null
try {
const res = await api.post<{ plaintext_key: string }>('/api-keys', {
name: newKeyName.value.trim(),
})
createdKey.value = res.plaintext_key
newKeyName.value = ''
await loadApiKeys()
} catch (err) {
toast.error('Failed to create API key: ' + (err as Error).message)
} finally {
creatingKey.value = false
}
}
async function revokeApiKey(id: string) {
try {
await api.del(`/api-keys/${id}`)
toast.success('API key revoked')
await loadApiKeys()
} catch (err) {
toast.error('Failed to revoke key: ' + (err as Error).message)
}
}
function copyKey(value: string) {
navigator.clipboard?.writeText(value)
toast.success('Copied to clipboard')
}
onMounted(() => {
loadForms()
loadApiKeys()
})
const tabItems = [
{ value: 'account', label: 'Account', icon: 'user' },
{ value: 'security', label: 'Security', icon: 'shield' },
{ value: 'api', label: 'API', icon: 'code' },
{ value: 'license', label: 'License', icon: 'key' },
{ value: 'domain', label: 'Domain', icon: 'globe' },
{ value: 'public', label: 'Public status', icon: 'eye' },
]
const swaggerUrl = '/api/docs'
function openApiDocs() {
window.open(swaggerUrl, '_blank', 'noopener')
}
</script>
<template>
@@ -142,6 +295,120 @@ const tabItems = [
>
{{ auth.user?.totp_enabled ? 'Enabled' : 'Not configured' }}
</Badge>
<span class="totp-hint">Manage in the Security tab</span>
</div>
</Panel>
<!-- Security -->
<Panel v-if="section === 'security'" title="Security" eyebrow="Password &amp; 2FA">
<div class="sec-stack">
<!-- Change password -->
<div class="sec-block">
<h3 class="sec-title">Change password</h3>
<div class="pw-grid">
<Input v-model="pwForm.current_password" type="password" label="Current password" placeholder="••••••••" />
<Input v-model="pwForm.new_password" type="password" label="New password" placeholder="At least 8 characters" />
<Input v-model="pwForm.confirm" type="password" label="Confirm new password" placeholder="Re-enter new password" />
</div>
<Button size="sm" icon="save" :loading="changingPw" @click="changePassword">Update password</Button>
</div>
<!-- Two-factor authentication -->
<div class="sec-block">
<div class="sec-head">
<h3 class="sec-title">Two-factor authentication</h3>
<Badge :tone="auth.user?.totp_enabled ? 'online' : 'warn'" :dot="true">
{{ auth.user?.totp_enabled ? 'Enabled' : 'Not configured' }}
</Badge>
</div>
<!-- Enabled -> offer disable -->
<template v-if="auth.user?.totp_enabled">
<p class="sec-note">Your account is protected with an authenticator app.</p>
<Button v-if="!disable2fa.open" size="sm" variant="danger" icon="shield-off" @click="disable2fa.open = true">
Disable 2FA
</Button>
<div v-else class="twofa-confirm">
<p class="sec-note">Enter your current 6-digit code to confirm.</p>
<Input v-model="disable2fa.code" label="Authenticator code" placeholder="123456" />
<div class="btn-row">
<Button size="sm" variant="danger" :loading="disable2fa.busy" @click="confirmDisable2fa">Confirm disable</Button>
<Button size="sm" variant="ghost" @click="disable2fa.open = false">Cancel</Button>
</div>
</div>
</template>
<!-- Not enabled -> enrollment -->
<template v-else>
<p class="sec-note">Add a second factor with an authenticator app (Google Authenticator, Authy, 1Password).</p>
<Button v-if="!totp.setting" size="sm" icon="shield" @click="startTotpSetup">Enable 2FA</Button>
<div v-else class="twofa-enroll">
<div v-if="totp.qr" class="qr-wrap">
<img :src="totp.qr" alt="2FA QR code" class="qr-img" />
<div class="qr-side">
<p class="sec-note">Scan with your authenticator app, or enter the secret manually:</p>
<code class="secret">{{ totp.secret }}</code>
</div>
</div>
<Input v-model="totp.code" label="Enter the 6-digit code to confirm" placeholder="123456" />
<div class="btn-row">
<Button size="sm" icon="check" @click="confirmTotpSetup">Verify &amp; enable</Button>
<Button size="sm" variant="ghost" @click="cancelTotpSetup">Cancel</Button>
</div>
</div>
</template>
</div>
</div>
</Panel>
<!-- API access -->
<Panel v-if="section === 'api'" title="API access" eyebrow="Programmatic">
<template #actions>
<Button size="sm" variant="secondary" icon="book-open" icon-right="external-link" @click="openApiDocs">API docs</Button>
</template>
<div class="api-stack">
<p class="section-note">
Create a key to call the Corrosion REST API from your own tooling. Send it as
<code class="inline-code">Authorization: Bearer corr_</code> a key acts as the license owner.
The full key is shown once at creation.
</p>
<!-- Create -->
<div class="key-create">
<Input v-model="newKeyName" label="New key name" placeholder="e.g. CI deploy, monitoring" style="flex:1" />
<Button size="sm" icon="plus" :loading="creatingKey" @click="createApiKey">Create key</Button>
</div>
<!-- Just-created plaintext key (shown once) -->
<div v-if="createdKey" class="key-reveal">
<div class="key-reveal__head">
<span class="field-label">Copy your key now it won't be shown again</span>
<Button size="sm" variant="ghost" icon="copy" @click="copyKey(createdKey!)">Copy</Button>
</div>
<code class="key-reveal__value">{{ createdKey }}</code>
</div>
<!-- Existing keys -->
<div v-if="loadingKeys" class="key-empty">Loading…</div>
<div v-else-if="apiKeys.length === 0" class="key-empty">No API keys yet.</div>
<table v-else class="key-table">
<thead>
<tr><th>Name</th><th>Prefix</th><th>Last used</th><th>Status</th><th></th></tr>
</thead>
<tbody>
<tr v-for="k in apiKeys" :key="k.id">
<td>{{ k.name }}</td>
<td><code class="field-mono">corr_{{ k.key_prefix }}…</code></td>
<td>{{ k.last_used_at ? new Date(k.last_used_at).toLocaleString() : 'Never' }}</td>
<td>
<Badge :tone="k.is_active ? 'online' : 'offline'">{{ k.is_active ? 'Active' : 'Revoked' }}</Badge>
</td>
<td class="key-actions">
<Button v-if="k.is_active" size="sm" variant="danger-soft" icon="trash-2" @click="revokeApiKey(k.id)">Revoke</Button>
</td>
</tr>
</tbody>
</table>
</div>
</Panel>
@@ -309,8 +576,44 @@ const tabItems = [
.cc-textarea::placeholder { color: var(--text-muted); }
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
.totp-hint { font-size: var(--text-xs); color: var(--text-muted); margin-left: auto; }
/* Security tab */
.sec-stack { display: flex; flex-direction: column; gap: 22px; }
.sec-block { display: flex; flex-direction: column; gap: 12px; align-items: flex-start; }
.sec-block + .sec-block { padding-top: 20px; border-top: 1px solid var(--border-subtle); }
.sec-head { display: flex; align-items: center; gap: 10px; }
.sec-title { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); margin: 0; }
.sec-note { font-size: var(--text-sm); color: var(--text-tertiary); margin: 0; max-width: 60ch; }
.pw-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; width: 100%; }
.btn-row { display: flex; gap: 8px; }
.twofa-enroll, .twofa-confirm { display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 420px; }
.qr-wrap { display: flex; gap: 16px; align-items: center; }
.qr-img { width: 148px; height: 148px; border-radius: var(--radius-md); background: #fff; padding: 6px; flex: none; }
.qr-side { display: flex; flex-direction: column; gap: 8px; }
.secret { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-primary);
background: var(--surface-inset); padding: 6px 9px; border-radius: var(--radius-sm); word-break: break-all; }
/* API tab */
.api-stack { display: flex; flex-direction: column; gap: 16px; }
.inline-code, .field-mono code, code.inline-code { font-family: var(--font-mono); font-size: var(--text-xs);
background: var(--surface-inset); padding: 1px 5px; border-radius: var(--radius-sm); color: var(--text-primary); }
.key-create { display: flex; gap: 10px; align-items: flex-end; }
.key-reveal { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px;
background: var(--surface-raised-2); border-radius: var(--radius-md); box-shadow: var(--ring-default); }
.key-reveal__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.key-reveal__value { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--accent-text);
word-break: break-all; }
.key-empty { font-size: var(--text-sm); color: var(--text-muted); padding: 12px 0; }
.key-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
.key-table th { text-align: left; font-size: var(--text-xs); font-weight: 600; color: var(--text-secondary);
padding: 6px 10px; border-bottom: 1px solid var(--border-subtle); }
.key-table td { padding: 9px 10px; border-bottom: 1px solid var(--border-subtle); color: var(--text-primary); vertical-align: middle; }
.key-actions { text-align: right; }
@media (max-width: 680px) {
.form-grid { grid-template-columns: 1fr; }
.lic-grid { grid-template-columns: 1fr 1fr; }
.pw-grid { grid-template-columns: 1fr; }
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -105,7 +105,7 @@ function handleBackToLogin() {
<!-- Branding -->
<div class="auth-brand">
<div class="auth-brand__mark">
<Logo :size="40" :glow="true" tagline="Game Server Operations" />
<Logo :size="40" :glow="true" tagline="by Corrosion" />
</div>
</div>

View File

@@ -35,7 +35,7 @@ function syncPorts() {
}
const connectionTypes = [
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via Corrosion host agent' },
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via re-Agent' },
{ value: 'amp', label: 'AMP (CubeCoders)', desc: 'Connect through AMP panel API' },
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
]
@@ -107,7 +107,7 @@ async function completeSetup() {
<div v-if="step === 1" class="setup-card">
<div class="setup-card__head">
<h1 class="setup-card__title">Configure your server</h1>
<p class="setup-card__sub">Connect your Rust server to Corrosion.</p>
<p class="setup-card__sub">Connect your game server to Corrosion.</p>
</div>
<Alert v-if="error" tone="danger">{{ error }}</Alert>
@@ -117,7 +117,7 @@ async function completeSetup() {
v-model="serverForm.server_name"
label="Server name"
type="text"
placeholder="My Rust Server"
placeholder="My game server"
:required="true"
/>
@@ -183,7 +183,7 @@ async function completeSetup() {
</form>
</div>
<!-- Step 2: Corrosion host agent install -->
<!-- Step 2: re-Agent install -->
<div v-if="step === 2" class="setup-card">
<div class="setup-card__head setup-card__head--center">
<div class="setup-icon">
@@ -191,12 +191,12 @@ async function completeSetup() {
<path d="M5 12.55a11 11 0 0 1 14.08 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01" />
</svg>
</div>
<h1 class="setup-card__title">Install the Corrosion host agent</h1>
<p class="setup-card__sub">The agent runs on your server and connects to Corrosion — no inbound ports required.</p>
<h1 class="setup-card__title">Install re-Agent</h1>
<p class="setup-card__sub">re-Agent runs on your server and connects to Corrosion — no inbound ports required.</p>
</div>
<div class="setup-code">
<p class="setup-code__comment"># Download the Corrosion host agent (Linux)</p>
<p class="setup-code__comment"># Download re-Agent (Linux)</p>
<p class="setup-code__cmd">curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
<p class="setup-code__cmd">chmod +x corrosion-host-agent-linux-amd64</p>
<p class="setup-code__comment setup-code__comment--mt"># Start with your license key</p>
@@ -206,7 +206,7 @@ async function completeSetup() {
</div>
<p class="setup-hint">
On Windows, download the agent from the Server page after setup. The agent connects outbound and auto-registers with your panel.
On Windows, download re-Agent from the Server page after setup. re-Agent connects outbound and auto-registers with Catalyst.
</p>
<div class="setup-actions">

View File

@@ -1,13 +1,7 @@
<script setup lang="ts">
/**
* EarlyAccess signup page.
*
* Backend endpoint: POST /api/early-access
* The early_access_signups entity exists but no NestJS controller/module exposes it yet.
* TODO: Create backend-nest/src/modules/early-access/ with a @Public() POST /early-access
* controller that accepts { email, name?, game_interest? } and writes to early_access_signups.
* The server_count column on the entity is varchar(10) — map game_interest to it or add a
* migration adding a game_interest column.
* Backend endpoint: POST /api/early-access — live and functional.
*/
import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
@@ -111,7 +105,7 @@ onUnmounted(() => { io?.disconnect() })
<h2 class="title">Real access to a real platform.</h2>
<p class="lead">
Early access is not a waitlist gimmick. It is how we manage onboarding while the
platform stabilizes. You get the full Corrosion control plane one tier at a time
platform stabilizes. You get the full Catalyst Console control plane one tier at a time
as capacity opens.
</p>
</div>
@@ -120,7 +114,7 @@ onUnmounted(() => { io?.disconnect() })
<div class="icard">
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
<b>Full control plane</b>
<p>Agent, panel, wipes, console, plugins, schedules all of it. Not a trimmed preview.</p>
<p>re-Agent, Catalyst Console, wipes, plugins, schedules all of it. Not a trimmed preview.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
@@ -130,12 +124,12 @@ onUnmounted(() => { io?.disconnect() })
<div class="icard">
<div class="icard__ic"><Icon name="message-square" :size="16" /></div>
<b>Direct feedback channel</b>
<p>Early access operators have a direct line for platform bug reports and feature input.</p>
<p>Early access operators have a direct feedback channel for platform bug reports and feature input.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="box" :size="16" /></div>
<b>Rust-first</b>
<p>Rust support is complete. Dune, Conan, and Soulmask are in active development.</p>
<b>Multi-game</b>
<p>Rust is fully operational today. Dune: Awakening, Conan Exiles, and Soulmask support is in active development.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="users" :size="16" /></div>
@@ -254,17 +248,17 @@ onUnmounted(() => { io?.disconnect() })
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">How it works</span>
<h2 class="title">Install the agent. Never SSH again.</h2>
<h2 class="title">Install re-Agent. Never SSH again.</h2>
</div>
<div class="steps reveal">
<div class="step">
<div class="step__n">1</div>
<b>Install the host agent</b>
<p>Download the Go binary from your dashboard. Run it on Windows or Linux. One agent per machine.</p>
<b>Install re-Agent</b>
<p>Download re-Agent from your dashboard. Run it on Windows or Linux. One agent per machine.</p>
</div>
<div class="step">
<div class="step__n">2</div>
<b>Agent connects to Corrosion</b>
<b>re-Agent connects to Corrosion</b>
<p>Single outbound NATS connection. No inbound ports. No exposed management panel on your machine.</p>
</div>
<div class="step">

View File

@@ -1,8 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import Icon from '@/components/ds/core/Icon.vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
// Dr. Flask, Ph.D. — the chemistry glossary's mascot. Cover card (v1 render),
// plus the v2 intro video + a poster frame grabbed from it for the lightbox.
import drFlask from '@/assets/mascots/drflask.png'
import drFlaskVideo from '@/assets/mascots/drflask-intro.mp4'
import drFlaskPoster from '@/assets/mascots/drflask-poster.jpg'
interface FaqItem {
question: string
@@ -28,12 +33,12 @@ const groups: FaqGroup[] = [
{
question: 'What if Corrosion itself is broken?',
answer:
'Platform bugs and agent issues go through structured bug reports in the panel. Operator and Network customers receive priority bug triage for platform-level issues. Direct server administration is not included in any support tier.',
'Platform bugs and agent issues go through structured bug reports in Catalyst Console. Operator and Network customers receive priority bug triage for platform-level issues. Direct server administration is not included in any support tier.',
},
{
question: 'Do you manage my server for me?',
answer:
'No. Corrosion provides the panel, the agent, automation workflows, diagnostics, and the control plane. You remain responsible for your host machine, operating system, network, firewall, game files, mods, and community rules.',
'No. Corrosion provides Catalyst Console, re-Agent, automation workflows, diagnostics, and the control plane. You remain responsible for your host machine, operating system, network, firewall, game files, mods, and community rules.',
},
{
question: 'Is hands-on help available?',
@@ -54,7 +59,7 @@ const groups: FaqGroup[] = [
{
question: 'Do I need my own server?',
answer:
'Yes. Corrosion is bring-your-own-server. You supply the host machine — a VPS, dedicated server, or bare metal box running Windows or Linux. Corrosion provides the control plane, the agent, and the panel.',
'Yes. Corrosion is bring-your-own-server. You supply the host machine — a VPS, dedicated server, or bare metal box running Windows or Linux. Corrosion provides the control plane, re-Agent, and Catalyst Console.',
},
{
question: 'Does Corrosion host my game server for me?',
@@ -64,17 +69,17 @@ const groups: FaqGroup[] = [
{
question: 'Do I need to open inbound firewall ports for Corrosion?',
answer:
'No. The host agent establishes a single outbound NATS connection to Corrosion\'s cloud. No inbound management ports are required. Your game server\'s player ports (RCON, game ports) remain as they have always been.',
'No. re-Agent establishes a single outbound NATS connection to Corrosion\'s cloud. No inbound management ports are required. Your game server\'s player ports (RCON, game ports) remain as they have always been.',
},
{
question: 'Does Corrosion replace AMP or Pterodactyl?',
answer:
'It can, depending on your use case. Corrosion is an independent control plane — it does not require an existing game panel. If you are currently using AMP or Pterodactyl, migration tooling is available in the panel.',
'It can, depending on your use case. Corrosion is an independent control plane — it does not require an existing game panel. If you are currently using AMP or Pterodactyl, migration tooling is in active development.',
},
{
question: 'What happens if Corrosion goes offline?',
answer:
'Your game servers continue running normally. Corrosion does not proxy gameplay traffic — it only handles management operations. If the panel or cloud is unreachable, your players are unaffected.',
'Your game servers continue running normally. Corrosion does not proxy gameplay traffic — it only handles management operations. If Catalyst Console or the cloud is unreachable, your players are unaffected.',
},
{
question: 'Can multiple admins manage the same server?',
@@ -82,9 +87,9 @@ const groups: FaqGroup[] = [
'Yes. Role-based access control (RBAC) is built in. You can grant team members specific permissions — from full admin access down to read-only viewer — without sharing credentials.',
},
{
question: 'What OS does the agent run on?',
question: 'What OS does re-Agent run on?',
answer:
'Both Windows and Linux are supported for the host agent. The agent binary is downloaded directly from your dashboard — there is no manual build or dependency setup required.',
'Both Windows and Linux are supported for re-Agent. The re-Agent binary is downloaded directly from your dashboard — there is no manual build or dependency setup required.',
},
{
question: 'Is my data isolated from other customers?',
@@ -100,7 +105,7 @@ const groups: FaqGroup[] = [
{
question: 'Which games are supported?',
answer:
'Currently in active development or shipped: Rust, Dune: Awakening, Conan Exiles, and Soulmask. Each game has a purpose-built blueprint — not a generic template — that models its specific operational reality (wipe types, mod systems, cluster configurations). More games are planned.',
'Currently in active development or shipped: Rust, Dune: Awakening, Conan Exiles, and Soulmask. Each game has a purpose-built Formula that models its specific operational reality (wipe types, mod systems, cluster configurations). More games are planned.',
},
{
question: 'Does Corrosion support Rust plugin management?',
@@ -110,7 +115,7 @@ const groups: FaqGroup[] = [
{
question: 'Can I run multiple game types on the same host machine?',
answer:
'Yes. A single host agent can supervise multiple game server processes — across different games — on the same machine. Each game instance has its own lifecycle, configuration, and wipe schedule.',
'Yes. A single re-Agent can supervise multiple game server processes — across different games — on the same machine. Each game instance has its own lifecycle, configuration, and wipe schedule.',
},
{
question: 'Does Corrosion handle Rust wipes?',
@@ -152,12 +157,150 @@ const groups: FaqGroup[] = [
},
]
interface ChemTerm {
term: string
chem: string
corro: string
kicker: string
}
const chemistry: ChemTerm[] = [
{
term: 'Catalyst Console',
chem: 'A catalyst helps a reaction happen faster or more efficiently without being consumed by it.',
corro: 'The control panel where you manage your game servers, players, plugins, files, wipes, schedules, and automation.',
kicker: 'Mission control for your server community.',
},
{
term: 're-Agent',
chem: 'A reagent is something that participates in a chemical reaction.',
corro: 'The lightweight agent installed on your server. It connects your hardware to Catalyst over secure outbound communication — so you never open inbound ports.',
kicker: 'What lets Corrosion see, manage, and automate your host.',
},
{
term: 'Substrate',
chem: 'A substrate is the surface or material where a reaction happens.',
corro: 'The bare-metal or virtual machine your game server runs on — the hardware surface underneath everything, where re-Agent lives and Formulae are applied.',
kicker: 'Think "bedrock," but more chemistry.',
},
{
term: 'Formulae',
chem: 'A formula describes the ingredients, structure, or recipe for something.',
corro: 'Reusable deployment recipes for games and server types — how a Rust server, Dune BattleGroup, Conan world, or Soulmask cluster should be configured and deployed.',
kicker: 'Complex setups, made repeatable instead of manual.',
},
{
term: 'Reactions',
chem: 'A chemical reaction is a process where things change from one state to another.',
corro: 'The jobs and workflows that change your server state — wipes, restarts, updates, backups, maintenance windows, deployments, and scheduled tasks.',
kicker: 'When Corrosion does work, a Reaction is usually happening.',
},
{
term: 'Compounds',
chem: 'A compound is made when different elements combine into something that works as a whole.',
corro: 'Grouped services or stack components that belong together — a game server, supporting services, shared storage, config, and helper processes as one unit.',
kicker: 'Related pieces, treated as one operational unit.',
},
{
term: 'Lab Notes',
chem: 'Lab notes record what happened during an experiment.',
corro: 'The logs, audit history, job results, and operational records — what happened, when it happened, and whether it succeeded.',
kicker: 'If something goes sideways, start here.',
},
{
term: 'The Exchange',
chem: 'Ion exchange is a process where ions are swapped between materials.',
corro: 'The native marketplace layer for server communities — item catalogs, VIP packages, payment processing, and automated in-game delivery.',
kicker: 'Where your community trades value for perks, packages, and content.',
},
]
// The chemistry-true pipeline, rendered as a flow strip under the table.
const flow = ['Formulae', 'Catalyst', 're-Agent', 'Substrate', 'Reaction', 'Lab Notes']
const openKey = ref<string | null>(null)
function toggle(key: string): void {
openKey.value = openKey.value === key ? null : key
}
// Dr. Flask intro video — plays in a phone-frame lightbox with custom controls.
const videoOpen = ref<boolean>(false)
const videoEl = ref<HTMLVideoElement | null>(null)
const playing = ref(false)
const isMuted = ref(false)
const curTime = ref(0)
const duration = ref(0)
const progressPct = computed(() =>
duration.value > 0 ? (curTime.value / duration.value) * 100 : 0,
)
function openVideo(): void {
videoOpen.value = true
document.body.style.overflow = 'hidden'
// The cover click is a user gesture, so try to play with sound; if the
// browser's autoplay policy blocks it, fall back to muted playback (always
// allowed) and surface the unmute control.
nextTick(() => {
const v = videoEl.value
if (!v) return
v.currentTime = 0
v.muted = false
isMuted.value = false
v.play().catch(() => {
v.muted = true
isMuted.value = true
v.play().catch(() => {})
})
})
}
function closeVideo(): void {
videoOpen.value = false
document.body.style.overflow = ''
videoEl.value?.pause()
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape' && videoOpen.value) closeVideo()
}
function togglePlay(): void {
const v = videoEl.value
if (!v) return
if (v.paused) v.play().catch(() => {})
else v.pause()
}
function toggleMute(): void {
const v = videoEl.value
if (!v) return
v.muted = !v.muted
isMuted.value = v.muted
}
function seek(e: MouseEvent): void {
const v = videoEl.value
if (!v || !duration.value) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const pct = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
v.currentTime = pct * duration.value
}
function toggleFullscreen(): void {
const v = videoEl.value as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null
if (!v) return
const frame = v.closest('.phone') as (HTMLElement & { requestFullscreen?: () => Promise<void> }) | null
if (document.fullscreenElement) {
void document.exitFullscreen?.()
} else if (frame?.requestFullscreen) {
void frame.requestFullscreen()
} else if (v.webkitEnterFullscreen) {
v.webkitEnterFullscreen() // iOS Safari: only the <video> can go fullscreen
}
}
function fmtTime(s: number): string {
if (!Number.isFinite(s)) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}
function itemKey(groupLabel: string, idx: number): string {
return `${groupLabel}-${idx}`
}
@@ -181,8 +324,12 @@ function initReveal(): void {
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
}
onMounted(() => { initReveal() })
onUnmounted(() => { io?.disconnect() })
onMounted(() => { initReveal(); window.addEventListener('keydown', onKeydown) })
onUnmounted(() => {
io?.disconnect()
window.removeEventListener('keydown', onKeydown)
document.body.style.overflow = ''
})
</script>
<template>
@@ -254,6 +401,72 @@ onUnmounted(() => { io?.disconnect() })
</div>
</section>
<!-- CHEMISTRY GLOSSARY -->
<section class="sec sec--lab" id="chemistry">
<div class="wrap">
<div class="lab-intro reveal">
<button class="lab-intro__cover" type="button" @click="openVideo" aria-label="Play Dr. Flask intro video">
<img :src="drFlask" alt="Dr. Flask, Ph.D. — Chemistry Teacher" class="lab-intro__card" />
<span class="lab-intro__play"><Icon name="play" :size="24" /></span>
<span class="lab-intro__watch">Watch the intro</span>
</button>
<div class="lab-intro__copy">
<span class="eyebrow">Glossary</span>
<h2 class="title">Brush up on your chemistry while managing your game server</h2>
<p class="lead">
Corrosion uses a chemistry-inspired naming system because running game servers is a lot
like managing controlled reactions: the right ingredients, the right environment, the
right timing, and a safe way to see what happened when the smoke clears.
</p>
<p class="lead">
You don't need a chemistry degree to use Corrosion. The names are here to make the
platform more memorable — and to give each part of the system a clear job.
</p>
</div>
</div>
<!-- The one-line summary for skimmers -->
<p class="lab-plain reveal">
<strong>In plain English:</strong> <strong>Catalyst</strong> is the console,
<strong>re-Agent</strong> connects your server, <strong>Substrate</strong> is the hardware
it runs on, <strong>Formulae</strong> define game deployments, and
<strong>Lab Notes</strong> show you what happened.
</p>
<div class="flow reveal" aria-hidden="true">
<template v-for="(step, i) in flow" :key="step">
<span class="flow__step">{{ step }}</span>
<span v-if="i < flow.length - 1" class="flow__arr">→</span>
</template>
</div>
<!-- Full glossary, one card per term -->
<div class="term-grid reveal">
<div v-for="t in chemistry" :key="t.term" class="term-card">
<h3 class="term-card__name">{{ t.term }}</h3>
<p class="term-card__chem">{{ t.chem }}</p>
<p class="term-card__corro">{{ t.corro }}</p>
<p class="term-card__kick">{{ t.kicker }}</p>
</div>
</div>
<!-- Dr. Flask sign-off -->
<div class="drflask-card reveal">
<h3 class="term-card__name">Dr. Flask</h3>
<p class="term-card__corro">
Dr. Flask is Corrosion's friendly chemistry guide. He turns up in the FAQ and help
sections to explain Corrosion terms without turning your server panel into a
full-blown chemistry class.
</p>
<ul class="drflask-card__quips">
<li>Helpful? <strong>Yes.</strong></li>
<li>Mandatory? <strong>No.</strong></li>
<li>Likely bubbling with questionable enthusiasm? <strong>Absolutely.</strong></li>
</ul>
</div>
</div>
</section>
<!-- SUPPORT CTA -->
<section class="sec" id="support-cta" style="border-bottom:none">
<div class="wrap">
@@ -276,6 +489,48 @@ onUnmounted(() => { io?.disconnect() })
</div>
</div>
</section>
<!-- DR. FLASK INTRO phone-frame lightbox -->
<Teleport to="body">
<div v-if="videoOpen" class="vmodal" @click.self="closeVideo">
<button class="vmodal__close" type="button" @click="closeVideo" aria-label="Close">
<Icon name="x" :size="20" />
</button>
<div class="phone" role="dialog" aria-label="Dr. Flask intro video">
<span class="phone__island" />
<div class="phone__screen">
<video
ref="videoEl"
class="phone__media"
:src="drFlaskVideo"
:poster="drFlaskPoster"
playsinline
preload="metadata"
@click="togglePlay"
@play="playing = true"
@pause="playing = false"
@timeupdate="curTime = videoEl?.currentTime ?? 0"
@loadedmetadata="duration = videoEl?.duration ?? 0"
/>
<div class="phone__controls">
<button class="pc-btn" type="button" @click="togglePlay" :aria-label="playing ? 'Pause' : 'Play'">
<Icon :name="playing ? 'pause' : 'play'" :size="18" />
</button>
<div class="pc-track" @click="seek">
<span class="pc-fill" :style="{ width: progressPct + '%' }" />
</div>
<span class="pc-time">{{ fmtTime(curTime) }} / {{ fmtTime(duration) }}</span>
<button class="pc-btn" type="button" @click="toggleMute" :aria-label="isMuted ? 'Unmute' : 'Mute'">
<Icon :name="isMuted ? 'volume-x' : 'volume-2'" :size="17" />
</button>
<button class="pc-btn" type="button" @click="toggleFullscreen" aria-label="Fullscreen">
<Icon name="maximize" :size="16" />
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
@@ -350,4 +605,260 @@ onUnmounted(() => { io?.disconnect() })
color: var(--text-tertiary);
line-height: 1.65;
}
/* Chemistry glossary — "lab zone": scope the accent tokens to green so the whole
section (eyebrow, term color, flow chips, callout) reads as Dr. Flask's corner
without touching the orange brand everywhere else. */
.sec--lab {
--accent: #3fb968;
--accent-text: #5bd183;
--accent-soft: rgba(82, 200, 124, 0.13);
--accent-border: rgba(82, 200, 124, 0.34);
}
.lab-intro {
display: grid;
grid-template-columns: minmax(0, 300px) 1fr;
gap: 30px;
align-items: center;
max-width: 920px;
margin: 0 auto 40px;
}
.lab-intro__cover {
position: relative;
display: block;
width: 100%;
padding: 0;
border: 0;
background: none;
cursor: pointer;
border-radius: var(--radius-lg);
}
.lab-intro__card {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius-lg);
box-shadow: var(--ring-default), 0 18px 40px rgba(0, 0, 0, 0.45);
}
.lab-intro__play {
position: absolute;
inset: 0;
margin: auto;
width: 58px;
height: 58px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.45);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45), 0 8px 22px rgba(0, 0, 0, 0.45);
transition: transform var(--dur-fast), background var(--dur-fast);
}
.lab-intro__play :deep(svg) { margin-left: 3px; } /* optical-center the play triangle */
.lab-intro__watch {
position: absolute;
left: 0;
right: 0;
bottom: 12px;
text-align: center;
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.02em;
color: #fff;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}
.lab-intro__cover:hover .lab-intro__play {
transform: scale(1.08);
background: var(--accent);
color: #06170d;
}
.lab-intro__copy { text-align: left; }
.lab-intro__copy .eyebrow { display: block; margin-bottom: 12px; }
.lab-intro__copy .title { margin: 0 0 14px; }
.lab-plain {
max-width: 920px;
margin: 0 auto 14px;
font-size: var(--text-sm);
color: var(--text-primary);
line-height: 1.65;
padding: 14px 18px;
background: var(--accent-soft);
border-radius: var(--radius-md);
box-shadow: inset 0 0 0 1px var(--accent-border);
text-align: center;
}
.lab-plain strong { color: var(--accent-text); }
.flow {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px;
max-width: 920px;
margin: 0 auto 28px;
}
.flow__step {
font-size: var(--text-xs);
font-weight: 600;
color: var(--accent-text);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-border);
padding: 6px 12px;
border-radius: var(--radius-md);
white-space: nowrap;
}
.flow__arr { color: var(--text-muted); font-size: var(--text-sm); }
/* Term cards — the full glossary, one card per term */
.term-grid {
max-width: 920px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.term-card {
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--ring-default);
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.term-card__name { font-size: var(--text-base, 1rem); font-weight: 700; color: var(--accent-text); margin: 0; }
.term-card__chem { font-size: var(--text-sm); color: var(--text-tertiary); font-style: italic; line-height: 1.55; margin: 0; }
.term-card__corro { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; margin: 0; }
.term-card__kick { font-size: var(--text-sm); color: var(--accent-text); font-weight: 600; line-height: 1.5; margin: 4px 0 0; }
/* Dr. Flask sign-off */
.drflask-card {
max-width: 920px;
margin: 14px auto 0;
background: var(--accent-soft);
border-radius: var(--radius-lg);
box-shadow: inset 0 0 0 1px var(--accent-border);
padding: 20px 22px;
display: flex;
flex-direction: column;
gap: 10px;
}
.drflask-card__quips {
list-style: none;
padding: 0;
margin: 2px 0 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.drflask-card__quips strong { color: var(--accent-text); }
/* Dr. Flask intro — phone-frame lightbox */
.vmodal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(6, 8, 10, 0.8);
backdrop-filter: blur(6px);
}
.vmodal__close {
position: absolute;
top: 22px;
right: 22px;
width: 40px;
height: 40px;
border: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
cursor: pointer;
transition: background var(--dur-fast);
}
.vmodal__close:hover { background: rgba(255, 255, 255, 0.18); }
.phone {
position: relative;
height: min(78vh, 620px);
aspect-ratio: 9 / 19.5;
padding: 9px;
border-radius: 42px;
background: linear-gradient(155deg, #1c1f24, #0c0d10);
box-shadow: 0 0 0 2px #2b2e34, 0 32px 70px rgba(0, 0, 0, 0.6);
}
.phone__island {
position: absolute;
top: 19px;
left: 50%;
transform: translateX(-50%);
width: 88px;
height: 24px;
border-radius: 14px;
background: #000;
z-index: 2;
}
.phone__screen {
position: relative;
width: 100%;
height: 100%;
border-radius: 33px;
overflow: hidden;
background: #000;
}
.phone__media { width: 100%; height: 100%; object-fit: cover; background: #06070a; cursor: pointer; }
.phone__controls {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 11px;
padding: 14px 14px 18px;
color: #fff;
background: linear-gradient(to top, rgba(0, 0, 0, 0.78), transparent);
}
.pc-btn {
display: flex;
align-items: center;
justify-content: center;
flex: none;
padding: 0;
border: 0;
background: none;
color: #fff;
opacity: 0.95;
cursor: pointer;
transition: opacity var(--dur-fast), color var(--dur-fast);
}
.pc-btn:hover { opacity: 1; color: #5bd183; }
.pc-track {
flex: 1;
height: 4px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.3);
position: relative;
cursor: pointer;
}
.pc-fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; background: #5bd183; }
.pc-time { font-size: 11px; font-variant-numeric: tabular-nums; opacity: 0.85; flex: none; }
@media (max-width: 720px) {
.lab-intro { grid-template-columns: 1fr; gap: 18px; justify-items: center; text-align: center; }
.lab-intro__cover { max-width: 280px; }
.lab-intro__card { max-width: 280px; }
.lab-intro__copy { text-align: center; }
.term-grid { grid-template-columns: 1fr; }
}
</style>

View File

@@ -41,12 +41,12 @@ onUnmounted(() => { io?.disconnect() })
</div>
<span class="eyebrow">How it works</span>
<h1 style="font-size:var(--text-5xl)">
One agent.
One re-Agent.
<span class="accent">Every game. No SSH.</span>
</h1>
<p class="hero__sub">
Install the host agent once on your Windows or Linux machine. Corrosion connects
securely, outbound-only. You manage every game instance from the browser.
Install re-Agent once on your Windows or Linux machine. Corrosion connects
securely, outbound-only. You manage every game instance from Catalyst Console.
</p>
</div>
</section>
@@ -55,20 +55,20 @@ onUnmounted(() => { io?.disconnect() })
<section class="sec" id="model">
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">The agent model</span>
<span class="eyebrow">The re-Agent model</span>
<h2 class="title">Bring your own server.<br>We provide the control plane.</h2>
<p class="lead">
Corrosion is not a hosting provider. You supply the hardware or the VPS. The host
agent runs on that machine and bridges your game instances to Corrosion's control
Corrosion is not a hosting provider. You supply the hardware or the VPS. re-Agent
runs on that machine and bridges your game instances to Corrosion's control
plane — securely, without opening inbound firewall ports.
</p>
</div>
<div class="steps reveal">
<div class="step">
<div class="step__n">1</div>
<b>Install the host agent</b>
<b>Install re-Agent</b>
<p>
Download the Corrosion agent binary from your dashboard. Run it on any Windows
Download the re-Agent binary from your dashboard. Run it on any Windows
or Linux host. One agent per machine — it manages every game instance you assign
to it.
</p>
@@ -77,7 +77,7 @@ onUnmounted(() => { io?.disconnect() })
<div class="step__n">2</div>
<b>It connects to Corrosion</b>
<p>
The agent makes a single outbound NATS connection to Corrosion's cloud. No
re-Agent makes a single outbound NATS connection to Corrosion's cloud. No
inbound ports. No open panels. No SSH required after initial setup.
</p>
</div>
@@ -86,7 +86,7 @@ onUnmounted(() => { io?.disconnect() })
<b>Deploy and manage from the browser</b>
<p>
Create game instances, run wipes, manage plugins, schedule maintenance, and
monitor players all from the Corrosion panel at panel.corrosionmgmt.com.
monitor players all from Catalyst Console at panel.corrosionmgmt.com.
</p>
</div>
</div>
@@ -106,8 +106,8 @@ onUnmounted(() => { io?.disconnect() })
<span class="eyebrow">Multi-game host runtime</span>
<h2 class="title">One agent. Multiple game worlds<br>on the same machine.</h2>
<p class="lead">
The host agent is not a per-game process. It is a general-purpose ops runtime. One
agent on a single machine can supervise multiple game server processes across
re-Agent is not a per-game process. It is a general-purpose ops runtime. One
re-Agent on a single machine can supervise multiple game server processes across
different games each with its own configuration, lifecycle, and wipe schedule.
</p>
</div>
@@ -220,7 +220,7 @@ onUnmounted(() => { io?.disconnect() })
<span class="eyebrow">Connectivity model</span>
<h2 class="title">Outbound-only. No exposed panel.</h2>
<p class="lead">
The host agent establishes one secure NATS connection to Corrosion's cloud. All
re-Agent establishes one secure NATS connection to Corrosion's cloud. All
commands flow through that channel. Your machine never needs to accept inbound
connections from the internet.
</p>
@@ -234,8 +234,8 @@ onUnmounted(() => { io?.disconnect() })
</div>
<div class="icard">
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
<b>Corrosion agent</b>
<p>A single Go binary. Runs as a service. Manages game processes, files, and updates.</p>
<b>re-Agent</b>
<p>A single binary. Runs as a service. Manages game processes, files, and updates.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
@@ -250,12 +250,12 @@ onUnmounted(() => { io?.disconnect() })
<div class="icard">
<div class="icard__ic"><Icon name="layout-dashboard" :size="16" /></div>
<b>Your browser</b>
<p>The panel at panel.corrosionmgmt.com. Console, wipes, plugins, schedules — all here.</p>
<p>Catalyst Console at panel.corrosionmgmt.com. Console, wipes, plugins, schedules — all here.</p>
</div>
</div>
<div class="techrow reveal">
<span>Go host agent</span>
<span>re-Agent</span>
<span>NATS JetStream</span>
<span>NestJS API</span>
<span>PostgreSQL</span>
@@ -289,7 +289,7 @@ onUnmounted(() => { io?.disconnect() })
<div>
<b>Enough CPU and RAM for your game</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Corrosion's agent is lightweight. Your game server determines the actual
re-Agent is lightweight. Your game server determines the actual
hardware requirement.
</p>
</div>
@@ -299,7 +299,7 @@ onUnmounted(() => { io?.disconnect() })
<div>
<b>Outbound internet access</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
The agent connects out; your game server's player ports stay open as they
re-Agent connects out; your game server's player ports stay open as they
always have been.
</p>
</div>
@@ -310,7 +310,7 @@ onUnmounted(() => { io?.disconnect() })
<div class="feat">
<span class="feat__ic"><Icon name="download" :size="16" /></span>
<div>
<b>Agent binary (Windows or Linux)</b>
<b>re-Agent binary (Windows or Linux)</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Downloaded from your dashboard. No manual build. No dependency management.
</p>
@@ -321,14 +321,14 @@ onUnmounted(() => { io?.disconnect() })
<div>
<b>Your license key</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Issued when you register. The agent uses it to authenticate to the cloud.
Issued when you register. re-Agent uses it to authenticate to the cloud.
</p>
</div>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="globe" :size="16" /></span>
<div>
<b>The panel</b>
<b>Catalyst Console</b>
<p style="margin:4px 0 0;color:var(--text-tertiary);font-size:var(--text-xs)">
Everything else console, wipes, schedules, players lives at
panel.corrosionmgmt.com.
@@ -344,7 +344,7 @@ onUnmounted(() => { io?.disconnect() })
<section class="finalcta">
<div class="finalcta__atmo" />
<div class="wrap finalcta__in reveal">
<h2>Install the agent.<br>Never SSH again.</h2>
<h2>Install re-Agent.<br>Never SSH again.</h2>
<div class="cta-row">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
Join early access

View File

@@ -104,9 +104,9 @@ const mockActiveGame = activeGame
</div>
<h1>Run your game servers<span class="accent">like an operation.</span></h1>
<p class="hero__sub">
Corrosion is a management panel for self-hosted survival game servers. Deploy servers, automate
Corrosion is a management platform for self-hosted survival game servers. Deploy servers, automate
wipes, manage plugins and mods, schedule maintenance, monitor players, and orchestrate
multi-server worlds from one command center.
multi-server worlds all from Catalyst Console.
</p>
<div class="hero__cta">
<RouterLink class="btn btn--primary btn--lg" :to="{ name: 'early-access' }">
@@ -144,7 +144,7 @@ const mockActiveGame = activeGame
<aside class="mock__side">
<div class="mock__brand">
<span class="mark"><CorrosionMark :size="18" /></span>
<b>Corrosion</b>
<b>Catalyst</b>
</div>
<div class="mock__gs">
<span :class="{ on: mockActiveGame === 'rust' }">
@@ -177,7 +177,7 @@ const mockActiveGame = activeGame
<div class="v">234</div>
</div>
<div class="mock__kpi">
<div class="l">Agent nodes</div>
<div class="l">re-Agent nodes</div>
<div class="v">2<small>/2</small></div>
</div>
</div>
@@ -197,6 +197,14 @@ const mockActiveGame = activeGame
</span>
<span class="st"><b />online</span>
</div>
<div class="mock__row">
<span class="g"><Icon name="drama" :size="13" /></span>
<span class="nm">
Ritual Cluster · PvE
<small>soul-host · soulmask</small>
</span>
<span class="st"><b />online</span>
</div>
<div class="mock__row">
<span class="g"><Icon name="swords" :size="13" /></span>
<span class="nm">
@@ -211,7 +219,7 @@ const mockActiveGame = activeGame
</div>
<div class="wrap" style="text-align:center">
<div class="hero__foot">
One host agent, many game instances · Rust · Dune: Awakening · Soulmask · Conan Exiles ·
One re-Agent, many game instances · Rust · Dune: Awakening · Soulmask · Conan Exiles ·
Windows &amp; Linux hosts
</div>
</div>
@@ -244,7 +252,7 @@ const mockActiveGame = activeGame
</div>
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
Juggling Discord bots &amp; cron tasks
Juggling community bots &amp; cron tasks
</div>
<div class="pain__item">
<span class="pain__x"><Icon name="x" :size="14" /></span>
@@ -261,7 +269,7 @@ const mockActiveGame = activeGame
</div>
<p class="closing reveal">
Your community sees the server. You deal with the chaos.<br>
<span class="accent">Corrosion gives you the control plane.</span>
<span class="accent">Catalyst Console gives you the control plane.</span>
</p>
</div>
</section>
@@ -271,16 +279,16 @@ const mockActiveGame = activeGame
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">The shift</span>
<h2 class="title">Drop in the agent.<br>Take control from the panel.</h2>
<h2 class="title">Drop in re-Agent.<br>Take control from Catalyst Console.</h2>
<p class="lead">
One lightweight host agent runs on your machine and manages every game instance you assign
One lightweight re-Agent runs on your machine and manages every game instance you assign
to it an outbound-only ops runtime, not an exposed panel.
</p>
</div>
<div class="steps reveal">
<div class="step">
<div class="step__n">1</div>
<b>Install the Corrosion Agent</b>
<b>Install re-Agent</b>
<p>One runtime on your Windows or Linux host. Outbound connection only.</p>
</div>
<div class="step">
@@ -302,7 +310,7 @@ const mockActiveGame = activeGame
</div>
<p class="closing reveal" style="font-size:var(--text-lg)">
You provide the machine.
<span class="accent">Corrosion provides the control plane.</span>
<span class="accent">Catalyst Console provides the control plane.</span>
</p>
</div>
</section>
@@ -312,10 +320,9 @@ const mockActiveGame = activeGame
<div class="wrap">
<div class="sec__head reveal">
<span class="eyebrow">Supported games</span>
<h2 class="title">Game-aware blueprints,<br>not generic templates</h2>
<h2 class="title">Game-aware Formulae,<br>not generic configs</h2>
<p class="lead">
Every game has a different operational reality. Corrosion models each one as an operations
blueprint Rust wipes, Dune: Awakening battlegroups, Soulmask clusters, Conan persistent
Every game has a different operational reality. Corrosion models each one as a Formula Rust wipes, Dune: Awakening battlegroups, Soulmask clusters, Conan persistent
worlds.
</p>
</div>
@@ -442,7 +449,7 @@ const mockActiveGame = activeGame
</div>
<div class="feat">
<span class="feat__ic"><Icon name="bell" :size="16" /></span>
<b>Discord / status announcements</b>
<b>Webhook / status announcements</b>
</div>
<div class="feat">
<span class="feat__ic"><Icon name="undo-2" :size="16" /></span>
@@ -519,7 +526,7 @@ const mockActiveGame = activeGame
<span class="eyebrow">Built like infrastructure</span>
<h2 class="title">Not a skin over SSH</h2>
<p class="lead">
A hosted control plane plus a host agent with tenant isolation, command namespacing,
A hosted control plane plus re-Agent with tenant isolation, command namespacing,
health reporting, and outbound-only connectivity.
</p>
</div>
@@ -527,7 +534,7 @@ const mockActiveGame = activeGame
<div class="icard">
<div class="icard__ic"><Icon name="cpu" :size="16" /></div>
<b>Agent-based control</b>
<p>Your host connects to Corrosion. No exposed management panel required.</p>
<p>re-Agent connects to Corrosion. No exposed management panel required.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="shield" :size="16" /></div>
@@ -542,7 +549,7 @@ const mockActiveGame = activeGame
<div class="icard">
<div class="icard__ic"><Icon name="zap" :size="16" /></div>
<b>Event-driven</b>
<p>NATS-powered messaging keeps agents and panel in sync.</p>
<p>NATS-powered messaging keeps re-Agent and Catalyst Console in sync.</p>
</div>
<div class="icard">
<div class="icard__ic"><Icon name="activity" :size="16" /></div>
@@ -554,7 +561,7 @@ const mockActiveGame = activeGame
<span>NestJS</span>
<span>NATS JetStream</span>
<span>PostgreSQL</span>
<span>Go host agent</span>
<span>re-Agent</span>
<span>Outbound-only</span>
</div>
</div>
@@ -577,7 +584,7 @@ const mockActiveGame = activeGame
<div class="chip-card"><Icon name="timer" :size="16" style="color:var(--accent-text)" />Wipe countdown</div>
<div class="chip-card"><Icon name="puzzle" :size="16" style="color:var(--accent-text)" />Mod / plugin list</div>
<div class="chip-card"><Icon name="megaphone" :size="16" style="color:var(--accent-text)" />Announcements</div>
<div class="chip-card chip-card--accent"><Icon name="shopping-cart" :size="16" />Integrated webstore</div>
<div class="chip-card chip-card--accent"><Icon name="shopping-cart" :size="16" />Integrated webstore (coming soon)</div>
</div>
<p
class="closing reveal"
@@ -620,9 +627,9 @@ const mockActiveGame = activeGame
<div class="plan">
<div class="plan__tag" />
<div class="plan__name">Network</div>
<div class="plan__price">$99.99<small>/mo</small></div>
<div class="plan__price">Custom</div>
<div class="plan__scope">50+ servers for fleets and hosting partners. Fleet Blocks add capacity.</div>
<RouterLink class="btn btn--ghost" :to="{ name: 'early-access' }">Start</RouterLink>
<a class="btn btn--ghost" href="mailto:support@corrosionmgmt.com">Contact us</a>
</div>
</div>
<div class="fleetblock reveal">

View File

@@ -41,6 +41,7 @@ interface Plan {
featured: boolean
cta: string
ctaVariant: 'primary' | 'ghost'
ctaHref?: string
features: PlanFeature[]
}
@@ -58,7 +59,7 @@ const plans: Plan[] = [
{ text: 'Up to 5 game server instances' },
{ text: 'Non-commercial servers only' },
{ text: 'Auto-wiper with rollback' },
{ text: 'Plugin management (Rust)' },
{ text: 'Plugin management (Rust uMod/Oxide)' },
{ text: 'File manager + real-time console' },
{ text: 'Scheduled tasks' },
{ text: 'Public server page' },
@@ -78,7 +79,7 @@ const plans: Plan[] = [
{ text: 'Up to 10 game server instances' },
{ text: 'Non-commercial servers only' },
{ text: 'Auto-wiper with rollback' },
{ text: 'Plugin management (Rust)' },
{ text: 'Plugin management (Rust uMod/Oxide)' },
{ text: 'File manager + real-time console' },
{ text: 'Scheduled tasks' },
{ text: 'Public server page' },
@@ -109,13 +110,14 @@ const plans: Plan[] = [
},
{
name: 'Network',
price: '$99.99',
period: '/mo',
price: 'Custom',
period: '',
scope: '50+ servers · hosting partners + fleets',
tag: '',
featured: false,
cta: 'Join early access',
cta: 'Contact us',
ctaVariant: 'ghost',
ctaHref: 'mailto:support@corrosionmgmt.com',
features: [
{ text: '50 servers base included' },
{ text: 'Fleet Blocks: +$49.99/mo per 50 servers' },
@@ -176,7 +178,16 @@ const plans: Plan[] = [
</li>
</ul>
<a
v-if="plan.ctaHref"
class="btn"
:class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'"
:href="plan.ctaHref"
>
{{ plan.cta }}
</a>
<RouterLink
v-else
class="btn"
:class="plan.ctaVariant === 'primary' ? 'btn--primary' : 'btn--ghost'"
:to="{ name: 'early-access' }"
@@ -335,7 +346,7 @@ const plans: Plan[] = [
<p class="closing reveal" style="font-size:var(--text-md);color:var(--text-tertiary);font-weight:500">
Direct server administration, firewall configuration, mod installation, and wipe-day
hand-holding are not included in any plan. Corrosion gives you the panel and the tools.
hand-holding are not included in any plan. Corrosion gives you Catalyst Console and the tools.
You run the operation.
</p>
</div>

View File

@@ -25,14 +25,14 @@ const groups: RoadmapGroup[] = [
status: 'shipped',
label: 'Phase 1 — Foundation',
description:
'The core control plane is live. Rust server operators can install the agent, connect their server, and manage it entirely from the panel.',
'The core control plane is live. Game server operators can install re-Agent, connect their server, and manage it entirely from Catalyst Console.',
items: [
{ text: 'Host agent (Windows + Linux)', note: 'Go binary, outbound NATS, zero inbound ports' },
{ text: 're-Agent (Windows + Linux)', note: 'Outbound NATS, zero inbound ports' },
{ text: 'Core control plane', note: 'NestJS API, multi-tenant PostgreSQL, JWT RBAC' },
{ text: 'Auto-wiper with rollback', note: 'Map, BP, and full wipes as verified sequences' },
{ text: 'Plugin management', note: 'Rust uMod/Oxide — browse, install, configure from the browser' },
{ text: 'Real-time console', note: 'NATS-bridged live output' },
{ text: 'File manager', note: 'Browser-based file access via the agent' },
{ text: 'File manager', note: 'Browser-based file access via re-Agent' },
{ text: 'Scheduled tasks and maintenance windows' },
{ text: 'Player management and RBAC team access' },
{ text: 'Public server page', note: 'Live status, wipe countdown, player count' },
@@ -40,39 +40,47 @@ const groups: RoadmapGroup[] = [
{ text: 'Discord and notification webhooks' },
],
},
{
status: 'shipped',
label: 'Phase 2 — Multi-game runtime',
description:
're-Agent multi-instance support and the per-game scheduling engine are live. One agent process now manages N game server instances on the same host, and the auto-wiper and event scheduler fire per-game on their own cadence.',
items: [
{ text: 'Multi-instance host runtime', note: 'One re-Agent managing N game processes on the same machine' },
{ text: 'Per-game wipe and event scheduling', note: 'Auto-wiper and event scheduler both fire per-game instance' },
],
},
{
status: 'in-progress',
label: 'Multi-game expansion',
label: 'Multi-game Formulae',
description:
'The agent and control plane are being extended with per-game blueprints. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same agent model with game-specific operational logic.',
'Per-game Formulae extend the control plane with game-specific operational logic. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same deployment model.',
items: [
{ text: 'Dune: Awakening blueprint', note: 'Deep Desert wipe scheduling, battlegroup lifecycle' },
{ text: 'Conan Exiles blueprint', note: 'Persistent world management, mod support, purge tracking' },
{ text: 'Soulmask blueprint', note: 'Linked-world cluster deployment, port automation' },
{ text: 'Multi-instance host runtime', note: 'One agent managing N game processes on the same machine' },
{ text: 'Per-game wipe and event scheduling' },
{ text: 'Dune: Awakening Formula', note: 'Battlegroup lifecycle shipped; Deep Desert wipe scheduling in progress' },
{ text: 'Conan Exiles Formula', note: 'Persistent world management, mod support, purge tracking' },
{ text: 'Soulmask Formula', note: 'Linked-world cluster deployment, port automation' },
],
},
{
status: 'in-progress',
label: 'Operator API and integrations',
description:
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane. Webhooks and per-license API keys are live; key-authenticated external API access lands next.',
items: [
{ text: 'Public REST API for server management', note: 'REST API live with OpenAPI docs; key-authenticated external access wired (corr_ bearer key acts as the license owner)' },
{ text: 'Webhook events (wipe completed, server down, player banned)', note: 'Shipped — HMAC-SHA256 signed delivery, SSRF-guarded' },
{ text: 'API key management per license', note: 'Shipped — create, list, revoke with hashed storage' },
],
},
{
status: 'planned',
label: 'API access and integrations',
label: 'The Exchange',
description:
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane.',
items: [
{ text: 'Public REST API for server management' },
{ text: 'Webhook events (wipe completed, server down, player banned)' },
{ text: 'API key management per license' },
],
},
{
status: 'planned',
label: 'Integrated storefront',
description:
'A native store layer for server communities — item catalog, VIP packages, and automated in-game delivery. No Tebex dependency required.',
'Corrosion\'s native storefront for server communities — item catalog, VIP packages, and automated in-game delivery. No Tebex dependency required.',
items: [
{ text: 'Item catalog and categories' },
{ text: 'PayPal and Stripe payment processing' },
{ text: 'Automated in-game delivery via RCON/agent' },
{ text: 'Automated in-game delivery via RCON/re-Agent' },
{ text: 'Transaction history and revenue dashboard' },
],
},
@@ -83,9 +91,9 @@ const groups: RoadmapGroup[] = [
'Tools for hosting providers and multi-server operations running 50+ instances across multiple physical hosts.',
items: [
{ text: 'Fleet-level dashboards and health monitoring' },
{ text: 'Multi-host agent orchestration' },
{ text: 'Multi-host re-Agent orchestration' },
{ text: 'Bulk wipe and update scheduling across a fleet' },
{ text: 'Fleet Block capacity management' },
{ text: 'Fleet Block capacity management', note: 'Pooled host capacity, allocation, and utilization' },
],
},
{
@@ -95,7 +103,7 @@ const groups: RoadmapGroup[] = [
'Corrosion\'s agent model is not Rust-specific. The platform is being designed to support any game that can be managed via process control, file operations, and RCON-style commands.',
items: [
{ text: 'Additional survival and sandbox games' },
{ text: 'Community-requested game blueprints' },
{ text: 'Community-requested game Formulae' },
],
},
]

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