12 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
18 changed files with 1264 additions and 9 deletions

View File

@@ -28,6 +28,10 @@ export class PermissionsGuard implements CanActivate {
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

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

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

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

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

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

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

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

@@ -52,7 +52,7 @@ const groups: RoadmapGroup[] = [
},
{
status: 'in-progress',
label: 'Multi-game expansion — game Formulae',
label: 'Multi-game Formulae',
description:
'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: [
@@ -63,7 +63,7 @@ const groups: RoadmapGroup[] = [
},
{
status: 'in-progress',
label: 'API access and integrations',
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: [
@@ -74,9 +74,9 @@ const groups: RoadmapGroup[] = [
},
{
status: 'planned',
label: 'Integrated storefront',
label: 'The Exchange',
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' },
@@ -93,7 +93,7 @@ const groups: RoadmapGroup[] = [
{ text: 'Fleet-level dashboards and health monitoring' },
{ 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' },
],
},
{