21 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
63 changed files with 2919 additions and 238 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

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

@@ -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 { InstancesService } from '../instances/instances.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { PlayerActionDto } from './dto/player-action.dto';
export interface Player {
@@ -24,6 +25,7 @@ export class PlayersService {
@InjectRepository(PlayerSession)
private readonly sessionRepo: Repository<PlayerSession>,
private readonly instancesService: InstancesService,
private readonly webhooksService: WebhooksService,
) {}
/**
@@ -138,18 +140,52 @@ export class PlayersService {
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,
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 ${dto.steam_id}${dto.reason ? ' ' + dto.reason : ''}`;
return `kick ${id}${dto.reason ? ' ' + safeReason : ''}`;
case 'ban':
// banid <steamId> <reason> <durationSeconds> — 0 = permanent
return `banid ${dto.steam_id} ${dto.reason ?? 'banned'} ${dto.duration_minutes ? dto.duration_minutes * 60 : 0}`;
return `banid ${id} ${safeReason} ${secs}`;
case 'unban':
return `unban ${dto.steam_id}`;
return `unban ${id}`;
default:
return '';
}

View File

@@ -11,47 +11,7 @@ import { ScheduledTask } from '../../entities/scheduled-task.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { InstancesService } from '../instances/instances.service';
/** Parse a 5-field cron expression and return the next Date after `after`. */
function nextCronDate(expr: string, after: Date): Date | null {
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 { nextCronDate } from '../../common/cron.util';
@Injectable()
export class SchedulesService implements OnModuleInit, OnModuleDestroy {

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,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';
@@ -9,10 +15,13 @@ import { UpdateProfileDto } from './dto/update-profile.dto';
import { CreateScheduleDto } from './dto/create-schedule.dto';
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
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)
@@ -22,8 +31,85 @@ export class WipesService {
@InjectRepository(WipeHistory)
private readonly wipeHistoryRepo: Repository<WipeHistory>,
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,19 +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.instancesService.wipeForLicense(licenseId, dto.wipe_type, true);
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
// 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,
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);
});
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.10"
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.10"
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

@@ -11,6 +11,7 @@ pub mod instancecmd;
pub mod prober;
pub mod process;
pub mod rcon;
pub mod service;
pub mod steamcmd;
pub mod subjects;
pub mod supervisor;

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"));
}
}

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.

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,7 +272,7 @@ 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>

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

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

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

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

@@ -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
@@ -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,7 +929,7 @@ 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>
@@ -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

@@ -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' },
]
@@ -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

@@ -105,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>
@@ -114,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>
@@ -248,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,7 +69,7 @@ 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?',
@@ -74,7 +79,7 @@ const groups: FaqGroup[] = [
{
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>
@@ -219,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>
@@ -269,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>
@@ -279,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">
@@ -310,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>
@@ -320,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>
@@ -527,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>
@@ -535,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>
@@ -550,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>
@@ -562,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>

View File

@@ -346,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. Game 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' },
],
},
]