diff --git a/.gitea/workflows/test-runner.yml b/.gitea/workflows/test-runner.yml
index a543263..9c69a09 100644
--- a/.gitea/workflows/test-runner.yml
+++ b/.gitea/workflows/test-runner.yml
@@ -1,5 +1,6 @@
name: Test Asgard Runner
-on: [push]
+# On-demand only — no reason to spin a container on every push.
+on: [workflow_dispatch]
jobs:
test:
@@ -17,8 +18,15 @@ jobs:
echo "Memory: $(free -h | grep Mem | awk '{print $2}')"
echo "Disk: $(df -h / | tail -1 | awk '{print $4}')"
echo "==========================================="
- echo "Go: $(go version)"
- echo "Rust: $(rustc --version)"
- echo "Docker: $(docker --version)"
+ # Jobs run in a bare node:20-bullseye container: toolchains are NOT
+ # preinstalled — workflows must bootstrap them (setup-go, rustup).
+ # Report presence honestly instead of green-lighting a missing tool.
+ for tool in go rustc docker node; do
+ if command -v "$tool" >/dev/null 2>&1; then
+ echo "$tool: $($tool --version 2>&1 | head -1)"
+ else
+ echo "$tool: NOT PRESENT (workflows must install per-run)"
+ fi
+ done
echo "==========================================="
- echo "✅ Asgard runner is OPERATIONAL"
+ echo "✅ Asgard runner reachable — container is node:20-bullseye, bootstrap toolchains per-run"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1bdccf9..7b777b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+### Added (Host-Agent v2 Consumer + SEO Meta — 2026-06-11)
+
+**Backend (NestJS):**
+- `HostAgentConsumerService` (new) — consumes wire protocol v2: `corrosion.*.host.heartbeat` updates `companion_last_seen` + `connection_status='connected'` (auto-registers the connection row on first contact); `host.going_offline` flips offline; a 60s staleness sweep marks hosts offline after 180s of silence. Previously NOTHING persisted heartbeats — `connection_status` was set once at setup and never changed again. Tenant-validated (UUID + license existence, cached) per NATS-consumer doctrine
+- `NatsBridgeService` — bridges `host_heartbeat` / `host_going_offline` events to the panel WebSocket
+- Verified by contract test: real agent → production NATS → captured with the backend's own `nats` lib under the real license; subjects, schema 2, real telemetry, offline beacon all confirmed
+
+**Frontend:**
+- Per-route document titles + meta descriptions (router `afterEach`, no new deps): six marketing pages get real titles/descriptions/OG tags (previously every page was "Corrosion Management" with zero meta — invisible to search and link previews); panel views get mechanical "{View} — Corrosion" titles
+
+**CI:**
+- `test-runner.yml` — honest per-tool presence checks (was printing "OPERATIONAL" while every toolchain probe failed); on-demand trigger instead of every push
+
### Added (Corrosion Host Agent — Rust rewrite Phase 0 — 2026-06-11)
**New: `corrosion-host-agent/`** — Rust rewrite of the Go companion agent (which stays in-tree as the behavior reference until parity). Wire protocol v2 (COA-B, Commander-approved): instance-scoped subjects `corrosion.{license}.{instance}.*` with host-level `corrosion.{license}.host.*` — full spec in `corrosion-host-agent/PROTOCOL.md`.
diff --git a/frontend/index.html b/frontend/index.html
index 919a143..3eb779f 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -9,6 +9,9 @@
Corrosion Management
+
+
+
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 8366d56..a1ac312 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -1,6 +1,17 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
+// Extend vue-router's RouteMeta so title/description are typed throughout
+declare module 'vue-router' {
+ interface RouteMeta {
+ title?: string
+ description?: string
+ requiresAuth?: boolean
+ guest?: boolean
+ superAdmin?: boolean
+ }
+}
+
// ---------------------------------------------------------------------------
// Domain detection — runs once at module load
// ---------------------------------------------------------------------------
@@ -15,31 +26,55 @@ const marketingChildren: RouteRecordRaw[] = [
path: '',
name: 'landing',
component: () => import('@/views/marketing/LandingView.vue'),
+ meta: {
+ title: 'Corrosion — Game Server Operations for Self-Hosted Communities',
+ description: 'Management panel for self-hosted survival game servers — Rust, Dune: Awakening, Conan Exiles, Soulmask. Wipe automation, plugins, monitoring. Bring your own server.',
+ },
},
{
path: 'pricing',
name: 'pricing',
component: () => import('@/views/marketing/PricingView.vue'),
+ meta: {
+ title: 'Pricing — Corrosion',
+ description: 'Plans from $9.99/mo (Hobby, 1–5 servers) to Network ($99.99+/mo, 50+ servers). Non-commercial and commercial tiers. No hosting fees — bring your own server.',
+ },
},
{
path: 'how-it-works',
name: 'how-it-works',
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.',
+ },
},
{
path: 'faq',
name: 'faq',
component: () => import('@/views/marketing/FaqView.vue'),
+ meta: {
+ title: 'FAQ — Corrosion',
+ description: 'Honest answers: Corrosion is self-service (BYOS, no hosting). Support is docs + community; 1:1 at $125/hr. Supports Rust, Dune, Conan Exiles, Soulmask.',
+ },
},
{
path: 'roadmap',
name: 'roadmap',
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.',
+ },
},
{
path: 'early-access',
name: 'early-access',
component: () => import('@/views/marketing/EarlyAccessView.vue'),
+ meta: {
+ title: 'Early Access — Corrosion',
+ description: 'Join the early access list. Get full control plane access — wipe automation, plugin management, real-time console — and lock in launch pricing.',
+ },
},
]
@@ -53,25 +88,25 @@ const panelRoutes: RouteRecordRaw[] = [
path: '/login',
name: 'login',
component: () => import('@/views/auth/LoginView.vue'),
- meta: { guest: true },
+ meta: { guest: true, title: 'Sign in — Corrosion' },
},
{
path: '/register',
name: 'register',
component: () => import('@/views/auth/RegisterView.vue'),
- meta: { guest: true },
+ meta: { guest: true, title: 'Create account — Corrosion' },
},
{
path: '/forgot-password',
name: 'forgot-password',
component: () => import('@/views/auth/ForgotPasswordView.vue'),
- meta: { guest: true },
+ meta: { guest: true, title: 'Reset password — Corrosion' },
},
{
path: '/setup',
name: 'setup-wizard',
component: () => import('@/views/auth/SetupWizardView.vue'),
- meta: { requiresAuth: true },
+ meta: { requiresAuth: true, title: 'Setup — Corrosion' },
},
// Admin dashboard routes (with sidebar layout)
@@ -84,217 +119,254 @@ const panelRoutes: RouteRecordRaw[] = [
path: '',
name: 'dashboard',
component: () => import('@/views/admin/DashboardView.vue'),
+ meta: { title: 'Dashboard — Corrosion' },
},
{
path: 'server',
name: 'server',
component: () => import('@/views/admin/ServerView.vue'),
+ meta: { title: 'Server — Corrosion' },
},
{
path: 'console',
name: 'console',
component: () => import('@/views/admin/ConsoleView.vue'),
+ meta: { title: 'Console — Corrosion' },
},
{
path: 'players',
name: 'players',
component: () => import('@/views/admin/PlayersView.vue'),
+ meta: { title: 'Players — Corrosion' },
},
{
path: 'plugins',
name: 'plugins',
component: () => import('@/views/admin/PluginsView.vue'),
+ meta: { title: 'Plugins — Corrosion' },
},
{
path: 'files',
name: 'files',
component: () => import('@/views/admin/FileManagerView.vue'),
+ meta: { title: 'Files — Corrosion' },
},
{
path: 'plugin-configs',
name: 'plugin-configs',
component: () => import('@/views/admin/PluginConfigsView.vue'),
+ meta: { title: 'Plugin Configs — Corrosion' },
},
{
path: 'loot-builder',
name: 'loot-builder',
component: () => import('@/views/admin/LootBuilderView.vue'),
+ meta: { title: 'Loot Builder — Corrosion' },
},
{
path: 'teleport-config',
name: 'teleport-config',
component: () => import('@/views/admin/TeleportConfigView.vue'),
+ meta: { title: 'Teleport Config — Corrosion' },
},
{
path: 'gather-manager',
name: 'gather-manager',
component: () => import('@/views/admin/GatherManagerView.vue'),
+ meta: { title: 'Gather Manager — Corrosion' },
},
{
path: 'autodoors',
name: 'autodoors',
component: () => import('@/views/admin/AutoDoorsView.vue'),
+ meta: { title: 'Auto Doors — Corrosion' },
},
{
path: 'kits',
name: 'kits-config',
component: () => import('@/views/admin/KitsView.vue'),
+ meta: { title: 'Kits — Corrosion' },
},
{
path: 'furnace-splitter',
name: 'furnace-splitter',
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
+ meta: { title: 'Furnace Splitter — Corrosion' },
},
{
path: 'better-chat',
name: 'better-chat',
component: () => import('@/views/admin/BetterChatView.vue'),
+ meta: { title: 'Better Chat — Corrosion' },
},
{
path: 'timed-execute',
name: 'timed-execute',
component: () => import('@/views/admin/TimedExecuteView.vue'),
+ meta: { title: 'Timed Execute — Corrosion' },
},
{
path: 'raidable-bases',
name: 'raidable-bases',
component: () => import('@/views/admin/RaidableBasesView.vue'),
+ meta: { title: 'Raidable Bases — Corrosion' },
},
{
path: 'wipes',
name: 'wipes',
component: () => import('@/views/admin/WipesView.vue'),
+ meta: { title: 'Wipes — Corrosion' },
},
{
path: 'wipes/profiles',
name: 'wipe-profiles',
component: () => import('@/views/admin/WipeProfilesView.vue'),
+ meta: { title: 'Wipe Profiles — Corrosion' },
},
{
path: 'wipes/calendar',
name: 'wipe-calendar',
component: () => import('@/views/admin/WipeCalendarView.vue'),
+ meta: { title: 'Wipe Calendar — Corrosion' },
},
{
path: 'wipes/history',
name: 'wipe-history',
component: () => import('@/views/admin/WipeHistoryView.vue'),
+ meta: { title: 'Wipe History — Corrosion' },
},
{
path: 'wipes/analytics',
name: 'wipe-analytics',
component: () => import('@/views/admin/WipeAnalyticsView.vue'),
+ meta: { title: 'Wipe Analytics — Corrosion' },
},
{
path: 'maps',
name: 'maps',
component: () => import('@/views/admin/MapsView.vue'),
+ meta: { title: 'Maps — Corrosion' },
},
{
path: 'maps/analytics',
name: 'map-analytics',
component: () => import('@/views/admin/MapAnalyticsView.vue'),
+ meta: { title: 'Map Analytics — Corrosion' },
},
{
path: 'chat',
name: 'chat',
component: () => import('@/views/admin/ChatLogView.vue'),
+ meta: { title: 'Chat Log — Corrosion' },
},
{
path: 'analytics',
name: 'analytics',
component: () => import('@/views/admin/AnalyticsView.vue'),
+ meta: { title: 'Analytics — Corrosion' },
},
{
path: 'retention',
name: 'retention',
component: () => import('@/views/admin/PlayerRetentionView.vue'),
+ meta: { title: 'Player Retention — Corrosion' },
},
{
path: 'notifications',
name: 'notifications',
component: () => import('@/views/admin/NotificationsView.vue'),
+ meta: { title: 'Notifications — Corrosion' },
},
{
path: 'team',
name: 'team',
component: () => import('@/views/admin/TeamView.vue'),
+ meta: { title: 'Team — Corrosion' },
},
{
path: 'store/config',
name: 'store-config',
component: () => import('@/views/admin/StoreConfigView.vue'),
+ meta: { title: 'Store Config — Corrosion' },
},
{
path: 'store/items',
name: 'store-items',
component: () => import('@/views/admin/StoreItemsView.vue'),
+ meta: { title: 'Store Items — Corrosion' },
},
{
path: 'store/revenue',
name: 'store-revenue',
component: () => import('@/views/admin/StoreRevenueView.vue'),
+ meta: { title: 'Store Revenue — Corrosion' },
},
{
path: 'modules',
name: 'modules',
component: () => import('@/views/admin/ModuleStoreView.vue'),
+ meta: { title: 'Modules — Corrosion' },
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/admin/SettingsView.vue'),
+ meta: { title: 'Settings — Corrosion' },
},
{
path: 'schedules',
name: 'schedules',
component: () => import('@/views/admin/SchedulesView.vue'),
+ meta: { title: 'Schedules — Corrosion' },
},
{
path: 'migration',
name: 'migration',
component: () => import('@/views/admin/MigrationView.vue'),
+ meta: { title: 'Migration — Corrosion' },
},
{
path: 'changelog',
name: 'changelog',
component: () => import('@/views/admin/ChangelogView.vue'),
+ meta: { title: 'Changelog — Corrosion' },
},
{
path: 'alerts',
name: 'alerts',
component: () => import('@/views/admin/AlertsView.vue'),
+ meta: { title: 'Alerts — Corrosion' },
},
// Platform Admin views (super-admin only)
{
path: 'admin',
name: 'platform-admin',
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
- meta: { superAdmin: true },
+ meta: { superAdmin: true, title: 'Admin — Corrosion' },
},
{
path: 'admin/licenses',
name: 'platform-licenses',
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
- meta: { superAdmin: true },
+ meta: { superAdmin: true, title: 'Admin: Licenses — Corrosion' },
},
{
path: 'admin/subscriptions',
name: 'platform-subscriptions',
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
- meta: { superAdmin: true },
+ meta: { superAdmin: true, title: 'Admin: Subscriptions — Corrosion' },
},
{
path: 'admin/users',
name: 'platform-users',
component: () => import('@/views/platform-admin/AdminUsers.vue'),
- meta: { superAdmin: true },
+ meta: { superAdmin: true, title: 'Admin: Users — Corrosion' },
},
{
path: 'admin/servers',
name: 'platform-servers',
component: () => import('@/views/platform-admin/AdminServers.vue'),
- meta: { superAdmin: true },
+ meta: { superAdmin: true, title: 'Admin: Servers — Corrosion' },
},
],
},
@@ -329,6 +401,7 @@ const panelRoutes: RouteRecordRaw[] = [
path: '/status',
name: 'status',
component: () => import('@/views/public/StatusPageView.vue'),
+ meta: { title: 'Status — Corrosion' },
},
// Catch-all
@@ -366,6 +439,7 @@ const marketingRoutes: RouteRecordRaw[] = [
path: '/status',
name: 'status',
component: () => import('@/views/public/StatusPageView.vue'),
+ meta: { title: 'Status — Corrosion' },
},
// Catch-all: unknown routes → landing page
@@ -383,6 +457,38 @@ const router = createRouter({
routes: isMarketingDomain ? marketingRoutes : panelRoutes,
})
+// ---------------------------------------------------------------------------
+// Document title + meta description/OG update on every navigation
+// ---------------------------------------------------------------------------
+function setOrClearMeta(selector: string, attr: string, value: string): void {
+ let el = document.querySelector(selector)
+ if (!el) {
+ el = document.createElement('meta')
+ // Parse the selector to set the right attribute (name="..." or property="...")
+ const nameMatch = selector.match(/\[name="([^"]+)"\]/)
+ const propMatch = selector.match(/\[property="([^"]+)"\]/)
+ if (nameMatch?.[1]) el.setAttribute('name', nameMatch[1])
+ if (propMatch?.[1]) el.setAttribute('property', propMatch[1])
+ document.head.appendChild(el)
+ }
+ el.setAttribute(attr, value)
+}
+
+router.afterEach((to) => {
+ // Title
+ document.title = to.meta.title ?? 'Corrosion Management'
+
+ // 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')
+
+ // OG description
+ setOrClearMeta('meta[property="og:description"]', 'content', desc)
+})
+
// Auth guard — only meaningful on panel domain (marketing has no requiresAuth routes)
router.beforeEach((to, _from, next) => {
const auth = useAuthStore()