3 Commits

Author SHA1 Message Date
Vantz Stockwell
4c9c322c29 feat(seo): per-route titles + meta descriptions; ci: honest runner test
Every page previously titled 'Corrosion Management' with zero meta -
marketing invisible to search and link previews. Router afterEach now
sets title/description/og per route (no new deps); marketing pages get
real content-backed descriptions, panel views mechanical titles.
index.html carries defaults for pre-JS crawlers. Verified in-browser
per page via Playwright.

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:15:36 -04:00
11 changed files with 326 additions and 20 deletions

View File

@@ -35,11 +35,16 @@ jobs:
exit 1 exit 1
fi fi
- name: Install cross toolchains # The Asgard runner executes jobs in a bare node:20-bullseye container
# (no Rust, no sudo, runs as root) — bootstrap the toolchain per-run,
# same pattern as actions/setup-go in the Go pipeline.
- name: Install Rust + cross toolchains
run: | run: |
sudo apt-get update -qq apt-get update -qq
sudo apt-get install -y -qq musl-tools gcc-mingw-w64-x86-64 apt-get install -y -qq build-essential musl-tools gcc-mingw-w64-x86-64 curl
rustup target add x86_64-unknown-linux-musl x86_64-pc-windows-gnu curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
"$HOME/.cargo/bin/rustup" target add x86_64-unknown-linux-musl x86_64-pc-windows-gnu
- name: Build Linux AMD64 (static musl) - name: Build Linux AMD64 (static musl)
run: | run: |

View File

@@ -1,5 +1,6 @@
name: Test Asgard Runner name: Test Asgard Runner
on: [push] # On-demand only — no reason to spin a container on every push.
on: [workflow_dispatch]
jobs: jobs:
test: test:
@@ -17,8 +18,15 @@ jobs:
echo "Memory: $(free -h | grep Mem | awk '{print $2}')" echo "Memory: $(free -h | grep Mem | awk '{print $2}')"
echo "Disk: $(df -h / | tail -1 | awk '{print $4}')" echo "Disk: $(df -h / | tail -1 | awk '{print $4}')"
echo "===========================================" echo "==========================================="
echo "Go: $(go version)" # Jobs run in a bare node:20-bullseye container: toolchains are NOT
echo "Rust: $(rustc --version)" # preinstalled — workflows must bootstrap them (setup-go, rustup).
echo "Docker: $(docker --version)" # 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 "==========================================="
echo "✅ Asgard runner is OPERATIONAL" echo "✅ Asgard runner reachable — container is node:20-bullseye, bootstrap toolchains per-run"

View File

@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [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) ### 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`. **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`.

View File

@@ -49,6 +49,9 @@ import { EarlyAccessModule } from './modules/early-access/early-access.module';
// Shared Services // Shared Services
import { NatsService } from './services/nats.service'; import { NatsService } from './services/nats.service';
import { NatsBridgeService } from './services/nats-bridge.service'; import { NatsBridgeService } from './services/nats-bridge.service';
import { HostAgentConsumerService } from './services/host-agent-consumer.service';
import { ServerConnection } from './entities/server-connection.entity';
import { License } from './entities/license.entity';
import { SteamService } from './services/steam.service'; import { SteamService } from './services/steam.service';
// Gateway // Gateway
@@ -91,6 +94,9 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
// Scheduler // Scheduler
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
// Repositories for app-level shared services (host-agent consumer)
TypeOrmModule.forFeature([ServerConnection, License]),
// Feature Modules // Feature Modules
AuthModule, AuthModule,
UsersModule, UsersModule,
@@ -134,6 +140,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
// Shared services // Shared services
NatsService, NatsService,
NatsBridgeService, NatsBridgeService,
HostAgentConsumerService,
SteamService, SteamService,
// WebSocket gateway // WebSocket gateway

View File

@@ -0,0 +1,152 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NatsService } from './nats.service';
import { ServerConnection } from '../entities/server-connection.entity';
import { License } from '../entities/license.entity';
/**
* Consumes Corrosion wire protocol v2 host-agent subjects
* (corrosion-host-agent/PROTOCOL.md) and keeps server_connections truthful.
*
* Before this service existed, NOTHING persisted agent heartbeats:
* companion_last_seen was written once at setup and connection_status stayed
* 'connected' forever. Now: heartbeat -> last_seen + connected (row
* auto-created on first contact), going_offline beacon -> offline, and a
* staleness sweep marks hosts offline when heartbeats stop arriving.
*/
@Injectable()
export class HostAgentConsumerService implements OnModuleInit {
private readonly logger = new Logger(HostAgentConsumerService.name);
/** licenseId -> cache expiry epoch-ms. Positive = exists, absent = unknown. */
private knownLicenses = new Map<string, number>();
/** Unknown/garbage license ids we already warned about (anti log-spam). */
private warnedUnknown = new Set<string>();
private static readonly UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
private static readonly LICENSE_CACHE_TTL_MS = 5 * 60_000;
/** 3x the agent's default 60s heartbeat (which jitters to max 72s). */
private static readonly OFFLINE_AFTER_MS = 180_000;
constructor(
private readonly nats: NatsService,
@InjectRepository(ServerConnection)
private readonly connectionRepository: Repository<ServerConnection>,
@InjectRepository(License)
private readonly licenseRepository: Repository<License>,
) {}
onModuleInit() {
this.nats.subscribe('corrosion.*.host.heartbeat', (data, subject) => {
const licenseId = subject.split('.')[1];
void this.onHeartbeat(licenseId).catch((err) =>
this.logger.error(`heartbeat handling failed for ${licenseId}: ${err.message}`, err.stack),
);
void data; // payload telemetry is bridged to the browser; persistence here is liveness only
});
this.nats.subscribe('corrosion.*.host.going_offline', (_data, subject) => {
const licenseId = subject.split('.')[1];
void this.onGoingOffline(licenseId).catch((err) =>
this.logger.error(`going_offline handling failed for ${licenseId}: ${err.message}`, err.stack),
);
});
this.logger.log('Host agent (protocol v2) consumer subscriptions initialized');
}
private async onHeartbeat(licenseId: string): Promise<void> {
if (!(await this.isValidTenant(licenseId))) return;
const now = new Date();
const existing = await this.connectionRepository.findOne({
where: { license_id: licenseId },
});
if (existing) {
await this.connectionRepository.update(
{ id: existing.id },
{ companion_last_seen: now, connection_status: 'connected', updated_at: now },
);
if (existing.connection_status !== 'connected') {
this.logger.log(`host agent for license ${licenseId} is back online`);
}
} else {
// First contact from a host agent: auto-register the connection so the
// panel lights up without a manual setup step.
await this.connectionRepository.save(
this.connectionRepository.create({
license_id: licenseId,
connection_type: 'bare_metal',
connection_status: 'connected',
companion_last_seen: now,
}),
);
this.logger.log(`host agent registered for license ${licenseId} (first heartbeat)`);
}
}
private async onGoingOffline(licenseId: string): Promise<void> {
if (!(await this.isValidTenant(licenseId))) return;
await this.connectionRepository.update(
{ license_id: licenseId },
{ connection_status: 'offline', updated_at: new Date() },
);
this.logger.log(`host agent for license ${licenseId} went offline (graceful beacon)`);
}
/**
* Heartbeats stopping must flip the panel to offline — an agent that
* crashes or loses network never sends the goodbye beacon.
*/
@Interval(60_000)
async sweepStaleConnections(): Promise<void> {
const threshold = new Date(Date.now() - HostAgentConsumerService.OFFLINE_AFTER_MS);
const result = await this.connectionRepository
.createQueryBuilder()
.update(ServerConnection)
.set({ connection_status: 'offline', updated_at: () => 'NOW()' })
.where('connection_status = :connected', { connected: 'connected' })
.andWhere('companion_last_seen IS NOT NULL')
.andWhere('companion_last_seen < :threshold', { threshold })
.execute();
if (result.affected) {
this.logger.warn(`marked ${result.affected} stale host connection(s) offline`);
}
}
/**
* Tenant validation: the subject segment must be a real license UUID.
* NATS consumers must never write rows for subjects an arbitrary publisher
* invented. Existence is cached to avoid a query per heartbeat.
*/
private async isValidTenant(licenseId: string): Promise<boolean> {
if (!HostAgentConsumerService.UUID_RE.test(licenseId)) {
this.warnUnknownOnce(licenseId, 'not a UUID');
return false;
}
const cachedUntil = this.knownLicenses.get(licenseId);
if (cachedUntil && cachedUntil > Date.now()) return true;
const exists = await this.licenseRepository.exist({ where: { id: licenseId } });
if (!exists) {
this.warnUnknownOnce(licenseId, 'no such license');
return false;
}
this.knownLicenses.set(licenseId, Date.now() + HostAgentConsumerService.LICENSE_CACHE_TTL_MS);
return true;
}
private warnUnknownOnce(licenseId: string, reason: string): void {
if (this.warnedUnknown.has(licenseId)) return;
this.warnedUnknown.add(licenseId);
this.logger.warn(`ignoring host-agent traffic for invalid license '${licenseId}' (${reason})`);
}
}

View File

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

View File

@@ -44,6 +44,17 @@ export class NatsBridgeService implements OnModuleInit {
this.emit(licenseId, 'oxide_status', data); this.emit(licenseId, 'oxide_status', data);
}); });
// Wire protocol v2 (corrosion-host-agent) — host-level telemetry
this.nats.subscribe('corrosion.*.host.heartbeat', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'host_heartbeat', data);
});
this.nats.subscribe('corrosion.*.host.going_offline', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'host_going_offline', data);
});
this.logger.log('NATS bridge subscriptions initialized'); this.logger.log('NATS bridge subscriptions initialized');
} }

View File

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

View File

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

View File

@@ -9,6 +9,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0a0a" /> <meta name="theme-color" content="#0a0a0a" />
<title>Corrosion Management</title> <title>Corrosion Management</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." />
<!-- Fonts via <link>, NOT a CSS @import — the bundler drops @import rules <!-- Fonts via <link>, NOT a CSS @import — the bundler drops @import rules
that land mid-file after concatenation, silently shipping system fonts --> that land mid-file after concatenation, silently shipping system fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />

View File

@@ -1,6 +1,17 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth' 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 // Domain detection — runs once at module load
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -15,31 +26,55 @@ const marketingChildren: RouteRecordRaw[] = [
path: '', path: '',
name: 'landing', name: 'landing',
component: () => import('@/views/marketing/LandingView.vue'), 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', path: 'pricing',
name: 'pricing', name: 'pricing',
component: () => import('@/views/marketing/PricingView.vue'), component: () => import('@/views/marketing/PricingView.vue'),
meta: {
title: 'Pricing — Corrosion',
description: 'Plans from $9.99/mo (Hobby, 15 servers) to Network ($99.99+/mo, 50+ servers). Non-commercial and commercial tiers. No hosting fees — bring your own server.',
},
}, },
{ {
path: 'how-it-works', path: 'how-it-works',
name: 'how-it-works', name: 'how-it-works',
component: () => import('@/views/marketing/HowItWorksView.vue'), 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', path: 'faq',
name: 'faq', name: 'faq',
component: () => import('@/views/marketing/FaqView.vue'), 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', path: 'roadmap',
name: 'roadmap', name: 'roadmap',
component: () => import('@/views/marketing/RoadmapView.vue'), 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', path: 'early-access',
name: 'early-access', name: 'early-access',
component: () => import('@/views/marketing/EarlyAccessView.vue'), 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', path: '/login',
name: 'login', name: 'login',
component: () => import('@/views/auth/LoginView.vue'), component: () => import('@/views/auth/LoginView.vue'),
meta: { guest: true }, meta: { guest: true, title: 'Sign in — Corrosion' },
}, },
{ {
path: '/register', path: '/register',
name: 'register', name: 'register',
component: () => import('@/views/auth/RegisterView.vue'), component: () => import('@/views/auth/RegisterView.vue'),
meta: { guest: true }, meta: { guest: true, title: 'Create account — Corrosion' },
}, },
{ {
path: '/forgot-password', path: '/forgot-password',
name: 'forgot-password', name: 'forgot-password',
component: () => import('@/views/auth/ForgotPasswordView.vue'), component: () => import('@/views/auth/ForgotPasswordView.vue'),
meta: { guest: true }, meta: { guest: true, title: 'Reset password — Corrosion' },
}, },
{ {
path: '/setup', path: '/setup',
name: 'setup-wizard', name: 'setup-wizard',
component: () => import('@/views/auth/SetupWizardView.vue'), component: () => import('@/views/auth/SetupWizardView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true, title: 'Setup — Corrosion' },
}, },
// Admin dashboard routes (with sidebar layout) // Admin dashboard routes (with sidebar layout)
@@ -84,217 +119,254 @@ const panelRoutes: RouteRecordRaw[] = [
path: '', path: '',
name: 'dashboard', name: 'dashboard',
component: () => import('@/views/admin/DashboardView.vue'), component: () => import('@/views/admin/DashboardView.vue'),
meta: { title: 'Dashboard — Corrosion' },
}, },
{ {
path: 'server', path: 'server',
name: 'server', name: 'server',
component: () => import('@/views/admin/ServerView.vue'), component: () => import('@/views/admin/ServerView.vue'),
meta: { title: 'Server — Corrosion' },
}, },
{ {
path: 'console', path: 'console',
name: 'console', name: 'console',
component: () => import('@/views/admin/ConsoleView.vue'), component: () => import('@/views/admin/ConsoleView.vue'),
meta: { title: 'Console — Corrosion' },
}, },
{ {
path: 'players', path: 'players',
name: 'players', name: 'players',
component: () => import('@/views/admin/PlayersView.vue'), component: () => import('@/views/admin/PlayersView.vue'),
meta: { title: 'Players — Corrosion' },
}, },
{ {
path: 'plugins', path: 'plugins',
name: 'plugins', name: 'plugins',
component: () => import('@/views/admin/PluginsView.vue'), component: () => import('@/views/admin/PluginsView.vue'),
meta: { title: 'Plugins — Corrosion' },
}, },
{ {
path: 'files', path: 'files',
name: 'files', name: 'files',
component: () => import('@/views/admin/FileManagerView.vue'), component: () => import('@/views/admin/FileManagerView.vue'),
meta: { title: 'Files — Corrosion' },
}, },
{ {
path: 'plugin-configs', path: 'plugin-configs',
name: 'plugin-configs', name: 'plugin-configs',
component: () => import('@/views/admin/PluginConfigsView.vue'), component: () => import('@/views/admin/PluginConfigsView.vue'),
meta: { title: 'Plugin Configs — Corrosion' },
}, },
{ {
path: 'loot-builder', path: 'loot-builder',
name: 'loot-builder', name: 'loot-builder',
component: () => import('@/views/admin/LootBuilderView.vue'), component: () => import('@/views/admin/LootBuilderView.vue'),
meta: { title: 'Loot Builder — Corrosion' },
}, },
{ {
path: 'teleport-config', path: 'teleport-config',
name: 'teleport-config', name: 'teleport-config',
component: () => import('@/views/admin/TeleportConfigView.vue'), component: () => import('@/views/admin/TeleportConfigView.vue'),
meta: { title: 'Teleport Config — Corrosion' },
}, },
{ {
path: 'gather-manager', path: 'gather-manager',
name: 'gather-manager', name: 'gather-manager',
component: () => import('@/views/admin/GatherManagerView.vue'), component: () => import('@/views/admin/GatherManagerView.vue'),
meta: { title: 'Gather Manager — Corrosion' },
}, },
{ {
path: 'autodoors', path: 'autodoors',
name: 'autodoors', name: 'autodoors',
component: () => import('@/views/admin/AutoDoorsView.vue'), component: () => import('@/views/admin/AutoDoorsView.vue'),
meta: { title: 'Auto Doors — Corrosion' },
}, },
{ {
path: 'kits', path: 'kits',
name: 'kits-config', name: 'kits-config',
component: () => import('@/views/admin/KitsView.vue'), component: () => import('@/views/admin/KitsView.vue'),
meta: { title: 'Kits — Corrosion' },
}, },
{ {
path: 'furnace-splitter', path: 'furnace-splitter',
name: 'furnace-splitter', name: 'furnace-splitter',
component: () => import('@/views/admin/FurnaceSplitterView.vue'), component: () => import('@/views/admin/FurnaceSplitterView.vue'),
meta: { title: 'Furnace Splitter — Corrosion' },
}, },
{ {
path: 'better-chat', path: 'better-chat',
name: 'better-chat', name: 'better-chat',
component: () => import('@/views/admin/BetterChatView.vue'), component: () => import('@/views/admin/BetterChatView.vue'),
meta: { title: 'Better Chat — Corrosion' },
}, },
{ {
path: 'timed-execute', path: 'timed-execute',
name: 'timed-execute', name: 'timed-execute',
component: () => import('@/views/admin/TimedExecuteView.vue'), component: () => import('@/views/admin/TimedExecuteView.vue'),
meta: { title: 'Timed Execute — Corrosion' },
}, },
{ {
path: 'raidable-bases', path: 'raidable-bases',
name: 'raidable-bases', name: 'raidable-bases',
component: () => import('@/views/admin/RaidableBasesView.vue'), component: () => import('@/views/admin/RaidableBasesView.vue'),
meta: { title: 'Raidable Bases — Corrosion' },
}, },
{ {
path: 'wipes', path: 'wipes',
name: 'wipes', name: 'wipes',
component: () => import('@/views/admin/WipesView.vue'), component: () => import('@/views/admin/WipesView.vue'),
meta: { title: 'Wipes — Corrosion' },
}, },
{ {
path: 'wipes/profiles', path: 'wipes/profiles',
name: 'wipe-profiles', name: 'wipe-profiles',
component: () => import('@/views/admin/WipeProfilesView.vue'), component: () => import('@/views/admin/WipeProfilesView.vue'),
meta: { title: 'Wipe Profiles — Corrosion' },
}, },
{ {
path: 'wipes/calendar', path: 'wipes/calendar',
name: 'wipe-calendar', name: 'wipe-calendar',
component: () => import('@/views/admin/WipeCalendarView.vue'), component: () => import('@/views/admin/WipeCalendarView.vue'),
meta: { title: 'Wipe Calendar — Corrosion' },
}, },
{ {
path: 'wipes/history', path: 'wipes/history',
name: 'wipe-history', name: 'wipe-history',
component: () => import('@/views/admin/WipeHistoryView.vue'), component: () => import('@/views/admin/WipeHistoryView.vue'),
meta: { title: 'Wipe History — Corrosion' },
}, },
{ {
path: 'wipes/analytics', path: 'wipes/analytics',
name: 'wipe-analytics', name: 'wipe-analytics',
component: () => import('@/views/admin/WipeAnalyticsView.vue'), component: () => import('@/views/admin/WipeAnalyticsView.vue'),
meta: { title: 'Wipe Analytics — Corrosion' },
}, },
{ {
path: 'maps', path: 'maps',
name: 'maps', name: 'maps',
component: () => import('@/views/admin/MapsView.vue'), component: () => import('@/views/admin/MapsView.vue'),
meta: { title: 'Maps — Corrosion' },
}, },
{ {
path: 'maps/analytics', path: 'maps/analytics',
name: 'map-analytics', name: 'map-analytics',
component: () => import('@/views/admin/MapAnalyticsView.vue'), component: () => import('@/views/admin/MapAnalyticsView.vue'),
meta: { title: 'Map Analytics — Corrosion' },
}, },
{ {
path: 'chat', path: 'chat',
name: 'chat', name: 'chat',
component: () => import('@/views/admin/ChatLogView.vue'), component: () => import('@/views/admin/ChatLogView.vue'),
meta: { title: 'Chat Log — Corrosion' },
}, },
{ {
path: 'analytics', path: 'analytics',
name: 'analytics', name: 'analytics',
component: () => import('@/views/admin/AnalyticsView.vue'), component: () => import('@/views/admin/AnalyticsView.vue'),
meta: { title: 'Analytics — Corrosion' },
}, },
{ {
path: 'retention', path: 'retention',
name: 'retention', name: 'retention',
component: () => import('@/views/admin/PlayerRetentionView.vue'), component: () => import('@/views/admin/PlayerRetentionView.vue'),
meta: { title: 'Player Retention — Corrosion' },
}, },
{ {
path: 'notifications', path: 'notifications',
name: 'notifications', name: 'notifications',
component: () => import('@/views/admin/NotificationsView.vue'), component: () => import('@/views/admin/NotificationsView.vue'),
meta: { title: 'Notifications — Corrosion' },
}, },
{ {
path: 'team', path: 'team',
name: 'team', name: 'team',
component: () => import('@/views/admin/TeamView.vue'), component: () => import('@/views/admin/TeamView.vue'),
meta: { title: 'Team — Corrosion' },
}, },
{ {
path: 'store/config', path: 'store/config',
name: 'store-config', name: 'store-config',
component: () => import('@/views/admin/StoreConfigView.vue'), component: () => import('@/views/admin/StoreConfigView.vue'),
meta: { title: 'Store Config — Corrosion' },
}, },
{ {
path: 'store/items', path: 'store/items',
name: 'store-items', name: 'store-items',
component: () => import('@/views/admin/StoreItemsView.vue'), component: () => import('@/views/admin/StoreItemsView.vue'),
meta: { title: 'Store Items — Corrosion' },
}, },
{ {
path: 'store/revenue', path: 'store/revenue',
name: 'store-revenue', name: 'store-revenue',
component: () => import('@/views/admin/StoreRevenueView.vue'), component: () => import('@/views/admin/StoreRevenueView.vue'),
meta: { title: 'Store Revenue — Corrosion' },
}, },
{ {
path: 'modules', path: 'modules',
name: 'modules', name: 'modules',
component: () => import('@/views/admin/ModuleStoreView.vue'), component: () => import('@/views/admin/ModuleStoreView.vue'),
meta: { title: 'Modules — Corrosion' },
}, },
{ {
path: 'settings', path: 'settings',
name: 'settings', name: 'settings',
component: () => import('@/views/admin/SettingsView.vue'), component: () => import('@/views/admin/SettingsView.vue'),
meta: { title: 'Settings — Corrosion' },
}, },
{ {
path: 'schedules', path: 'schedules',
name: 'schedules', name: 'schedules',
component: () => import('@/views/admin/SchedulesView.vue'), component: () => import('@/views/admin/SchedulesView.vue'),
meta: { title: 'Schedules — Corrosion' },
}, },
{ {
path: 'migration', path: 'migration',
name: 'migration', name: 'migration',
component: () => import('@/views/admin/MigrationView.vue'), component: () => import('@/views/admin/MigrationView.vue'),
meta: { title: 'Migration — Corrosion' },
}, },
{ {
path: 'changelog', path: 'changelog',
name: 'changelog', name: 'changelog',
component: () => import('@/views/admin/ChangelogView.vue'), component: () => import('@/views/admin/ChangelogView.vue'),
meta: { title: 'Changelog — Corrosion' },
}, },
{ {
path: 'alerts', path: 'alerts',
name: 'alerts', name: 'alerts',
component: () => import('@/views/admin/AlertsView.vue'), component: () => import('@/views/admin/AlertsView.vue'),
meta: { title: 'Alerts — Corrosion' },
}, },
// Platform Admin views (super-admin only) // Platform Admin views (super-admin only)
{ {
path: 'admin', path: 'admin',
name: 'platform-admin', name: 'platform-admin',
component: () => import('@/views/platform-admin/AdminDashboard.vue'), component: () => import('@/views/platform-admin/AdminDashboard.vue'),
meta: { superAdmin: true }, meta: { superAdmin: true, title: 'Admin — Corrosion' },
}, },
{ {
path: 'admin/licenses', path: 'admin/licenses',
name: 'platform-licenses', name: 'platform-licenses',
component: () => import('@/views/platform-admin/AdminLicenses.vue'), component: () => import('@/views/platform-admin/AdminLicenses.vue'),
meta: { superAdmin: true }, meta: { superAdmin: true, title: 'Admin: Licenses — Corrosion' },
}, },
{ {
path: 'admin/subscriptions', path: 'admin/subscriptions',
name: 'platform-subscriptions', name: 'platform-subscriptions',
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'), component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
meta: { superAdmin: true }, meta: { superAdmin: true, title: 'Admin: Subscriptions — Corrosion' },
}, },
{ {
path: 'admin/users', path: 'admin/users',
name: 'platform-users', name: 'platform-users',
component: () => import('@/views/platform-admin/AdminUsers.vue'), component: () => import('@/views/platform-admin/AdminUsers.vue'),
meta: { superAdmin: true }, meta: { superAdmin: true, title: 'Admin: Users — Corrosion' },
}, },
{ {
path: 'admin/servers', path: 'admin/servers',
name: 'platform-servers', name: 'platform-servers',
component: () => import('@/views/platform-admin/AdminServers.vue'), 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', path: '/status',
name: 'status', name: 'status',
component: () => import('@/views/public/StatusPageView.vue'), component: () => import('@/views/public/StatusPageView.vue'),
meta: { title: 'Status — Corrosion' },
}, },
// Catch-all // Catch-all
@@ -366,6 +439,7 @@ const marketingRoutes: RouteRecordRaw[] = [
path: '/status', path: '/status',
name: 'status', name: 'status',
component: () => import('@/views/public/StatusPageView.vue'), component: () => import('@/views/public/StatusPageView.vue'),
meta: { title: 'Status — Corrosion' },
}, },
// Catch-all: unknown routes → landing page // Catch-all: unknown routes → landing page
@@ -383,6 +457,38 @@ const router = createRouter({
routes: isMarketingDomain ? marketingRoutes : panelRoutes, 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<HTMLMetaElement>(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) // Auth guard — only meaningful on panel domain (marketing has no requiresAuth routes)
router.beforeEach((to, _from, next) => { router.beforeEach((to, _from, next) => {
const auth = useAuthStore() const auth = useAuthStore()