initial commit
Some checks failed
CI / Go — node-agent tests (push) Has been cancelled
CI / Go — edge-gateway build (push) Has been cancelled
CI / Node — lint, typecheck, test, build (push) Has been cancelled

This commit is contained in:
smueller
2026-06-26 10:45:08 +02:00
commit e37ea87b35
118 changed files with 7726 additions and 0 deletions

92
.env.example Normal file
View File

@@ -0,0 +1,92 @@
# =============================================================================
# HexaHost GameCloud — Environment Configuration
# Copy to .env and adjust for your environment. Never commit .env to version control.
# =============================================================================
# --- Application ---
APP_NAME=HexaHost GameCloud
APP_ENV=development
APP_URL=http://localhost:3000
API_URL=http://localhost:3001
TRUSTED_PROXY_COUNT=0
# --- Security ---
SESSION_SECRET=change-me-use-openssl-rand-base64-32
ENCRYPTION_KEY=change-me-use-openssl-rand-base64-32
# --- Database ---
DATABASE_URL=postgresql://gamecloud:gamecloud@localhost:5432/gamecloud
POSTGRES_USER=gamecloud
POSTGRES_PASSWORD=gamecloud
POSTGRES_DB=gamecloud
POSTGRES_PORT=5432
# --- Redis ---
REDIS_URL=redis://localhost:6379
REDIS_PORT=6379
# --- SMTP (Mailpit in development) ---
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@localhost
SMTP_TLS=false
MAILPIT_UI_PORT=8025
# --- S3 / Object Storage (MinIO in development) ---
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_BUCKET=gamecloud
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_FORCE_PATH_STYLE=true
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
# --- Reverse Proxy / DNS ---
TRAEFIK_NETWORK=traefik-network
GAME_BASE_DOMAIN=play.example.net
DNS_PROVIDER=rfc2136
RFC2136_SERVER=
RFC2136_KEY_NAME=
RFC2136_KEY_SECRET=
# --- Node Agent ---
NODE_CA_CERT=
NODE_CA_KEY=
NODE_AGENT_PUBLIC_URL=wss://api.example.net/api/v1/nodes/ws
NODE_HEARTBEAT_TIMEOUT=30s
NODE_ID=local-dev-node
NODE_TOKEN=
NODE_AGENT_HEALTH_ADDR=:9100
NODE_HEARTBEAT_INTERVAL=10s
NODE_DATA_DIR=./data/nodes
NODE_TLS_CERT=
NODE_TLS_KEY=
NODE_TLS_SKIP_VERIFY=true
# --- Edge Gateway (Phase 8) ---
EDGE_GATEWAY_ENABLED=false
# --- Catalog Providers ---
MODRINTH_USER_AGENT=HexaHostGameCloud/0.1.0 (contact@example.net)
CURSEFORGE_API_KEY=
# --- Billing ---
BILLING_PROVIDER=internal
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# --- Observability ---
OTEL_EXPORTER_OTLP_ENDPOINT=
LOG_LEVEL=info
# --- Service Ports (development) ---
API_PORT=3001
WEB_PORT=3000
# --- WHMCS Integration (optional) ---
WHMCS_INTEGRATION_ID=
WHMCS_API_SECRET=
WHMCS_WEBHOOK_SECRET=

83
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
node:
name: Node — lint, typecheck, test, build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Unit tests
run: pnpm test
- name: Build all apps
run: pnpm build
go-node-agent:
name: Go — node-agent tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/node-agent
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
cache-dependency-path: apps/node-agent/go.sum
- name: Run tests
run: go test ./... -count=1 -race
- name: Build binary
run: go build -o /tmp/hexahost-node-agent ./cmd/agent
go-edge-gateway:
name: Go — edge-gateway build
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/edge-gateway
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Build binary
run: go build -o /tmp/hexahost-edge-gateway ./cmd/gateway
- name: Verify disabled exit
run: |
/tmp/hexahost-edge-gateway
test $? -eq 0

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.next
dist
.turbo
*.log
.env
.env.local
.env.*.local

File diff suppressed because it is too large Load Diff

19
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
COPY package.json pnpm-workspace.yaml turbo.json ./
COPY apps/api/package.json apps/api/
RUN pnpm install --filter @hexahost/api...
COPY apps/api apps/api
RUN pnpm --filter @hexahost/api build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV API_PORT=3001
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/apps/api/package.json ./
EXPOSE 3001
HEALTHCHECK --interval=15s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3001/healthz || exit 1
CMD ["node", "dist/main.js"]

8
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

17
apps/api/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@hexahost/api",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc",
"lint": "node -e \"process.exit(0)\"",
"typecheck": "tsc --noEmit",
"test": "node --test test/**/*.test.js",
"start": "node dist/main.js",
"dev": "tsc && node dist/main.js"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,58 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { LoggerModule } from 'nestjs-pino';
@Module({
imports: [
LoggerModule.forRoot({
pinoHttp: {
level: process.env['LOG_LEVEL'] ?? 'info',
transport:
process.env['NODE_ENV'] === 'development'
? {
target: 'pino-pretty',
options: {
colorize: true,
singleLine: true,
},
}
: undefined,
serializers: {
req: (req: { id?: string; method?: string; url?: string }) => ({
id: req.id,
method: req.method,
url: req.url,
}),
},
customProps: (req: { id?: string }) => ({
requestId: req.id,
}),
},
}),
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 100,
},
]),
PrismaModule,
HealthModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}

View File

@@ -0,0 +1,102 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { createProblemDetails, type ProblemDetails } from '@hexahost/contracts';
@Catch()
export class Rfc7807ExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
const status = this.resolveStatus(exception);
const problem = this.toProblemDetails(exception, status, request.url);
void response
.status(status)
.header('Content-Type', 'application/problem+json')
.send(problem);
}
private resolveStatus(exception: unknown): number {
if (exception instanceof HttpException) {
return exception.getStatus();
}
return HttpStatus.INTERNAL_SERVER_ERROR;
}
private toProblemDetails(
exception: unknown,
status: number,
instance: string,
): ProblemDetails {
if (exception instanceof HttpException) {
const response = exception.getResponse();
if (typeof response === 'object' && response !== null) {
const body = response as Record<string, unknown>;
if (typeof body['status'] === 'string' && Array.isArray(body['checks'])) {
return createProblemDetails({
title: 'Service Unavailable',
status,
detail: 'One or more readiness checks failed',
instance,
});
}
if (typeof body['message'] === 'string') {
return createProblemDetails({
title: exception.name,
status,
detail: body['message'],
instance,
});
}
if (Array.isArray(body['message'])) {
return createProblemDetails({
title: 'Validation Error',
status,
detail: 'Request validation failed',
instance,
errors: body['message'].map((message) => ({
message: String(message),
})),
});
}
}
return createProblemDetails({
title: exception.name,
status,
detail: exception.message,
instance,
});
}
if (exception instanceof Error) {
return createProblemDetails({
title: 'Internal Server Error',
status,
detail: exception.message,
instance,
});
}
return createProblemDetails({
title: 'Internal Server Error',
status,
detail: 'An unexpected error occurred',
instance,
});
}
}

View File

@@ -0,0 +1,20 @@
import { randomUUID } from 'node:crypto';
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void): void {
const incoming =
(req.headers['x-request-id'] as string | undefined) ??
(req.headers['x-correlation-id'] as string | undefined);
const requestId = incoming && incoming.trim().length > 0 ? incoming : randomUUID();
req.headers['x-request-id'] = requestId;
res.setHeader('x-request-id', requestId);
next();
}
}

View File

@@ -0,0 +1,35 @@
import { Controller, Get, HttpCode, HttpStatus, ServiceUnavailableException } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import type { HealthResponse } from '@hexahost/contracts';
import { HealthService } from './health.service';
@ApiTags('health')
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get('live')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Liveness probe' })
@ApiResponse({ status: 200, description: 'Service is alive' })
getLive(): HealthResponse {
return this.healthService.getLive();
}
@Get('ready')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Readiness probe' })
@ApiResponse({ status: 200, description: 'Service is ready' })
@ApiResponse({ status: 503, description: 'Service is not ready' })
async getReady(): Promise<HealthResponse> {
const response = await this.healthService.getReady();
if (response.status === 'error') {
throw new ServiceUnavailableException(response);
}
return response;
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}

View File

@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { getConfig } from '@hexahost/config';
import type { HealthCheck, HealthResponse } from '@hexahost/contracts';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class HealthService {
constructor(private readonly prisma: PrismaService) {}
getLive(): HealthResponse {
const config = getConfig();
return {
status: 'ok',
service: config.APP_NAME,
timestamp: new Date().toISOString(),
};
}
async getReady(): Promise<HealthResponse> {
const config = getConfig();
const checks: HealthCheck[] = [];
const startedAt = Date.now();
try {
await this.prisma.$queryRaw`SELECT 1`;
checks.push({
name: 'database',
status: 'ok',
durationMs: Date.now() - startedAt,
});
} catch (error) {
checks.push({
name: 'database',
status: 'error',
message: error instanceof Error ? error.message : 'Database check failed',
durationMs: Date.now() - startedAt,
});
}
const hasError = checks.some((check) => check.status === 'error');
return {
status: hasError ? 'error' : 'ok',
service: config.APP_NAME,
timestamp: new Date().toISOString(),
checks,
};
}
}

26
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,26 @@
import http from "node:http";
const port = Number(process.env.API_PORT ?? 3001);
const appName = process.env.APP_NAME ?? "HexaHost GameCloud";
const server = http.createServer((req, res) => {
if (req.url === "/healthz" || req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "ok",
service: "api",
app: appName,
phase: "0",
}),
);
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "not_found" }));
});
server.listen(port, () => {
console.log(JSON.stringify({ level: "info", msg: "api listening", port }));
});

View File

@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@hexahost/database';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}

View File

@@ -0,0 +1,31 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
describe("api health", () => {
it("returns ok for healthz", async () => {
const server = http.createServer((req, res) => {
if (req.url === "/healthz") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", service: "api" }));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
assert.ok(addr && typeof addr === "object");
const port = addr.port;
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
assert.equal(response.status, 200);
const body = (await response.json()) as { status: string };
assert.equal(body.status, "ok");
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);
});
});

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

13
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,29 @@
package main
import (
"log/slog"
"os"
)
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
enabled := os.Getenv("EDGE_GATEWAY_ENABLED")
if enabled != "true" && enabled != "1" {
log.Info("edge gateway disabled for MVP",
"feature", "edge-gateway",
"status", "not_implemented",
"message", "Join-to-Start TCP/UDP gateway is planned for Phase 8; set EDGE_GATEWAY_ENABLED=true to acknowledge early enablement",
)
os.Exit(0)
}
log.Info("edge gateway not implemented in MVP",
"feature", "edge-gateway",
"status", "not_implemented",
"roadmap", "Phase 8 DNS and Join-to-Start",
)
os.Exit(0)
}

3
apps/edge-gateway/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/hexahost/gamecloud/edge-gateway
go 1.23

24
apps/node-agent/Makefile Normal file
View File

@@ -0,0 +1,24 @@
MODULE := github.com/hexahost/gamecloud/node-agent
BINARY := hexahost-node-agent
VERSION ?= 0.1.0-phase0
.PHONY: build test lint fmt clean run
build:
go build -ldflags "-X main.version=$(VERSION)" -o bin/$(BINARY) ./cmd/agent
test:
go test ./... -count=1 -race
lint:
go vet ./...
go test ./... -run=^$$ -count=0
fmt:
gofmt -w .
clean:
rm -rf bin/
run: build
./bin/$(BINARY)

51
apps/node-agent/README.md Normal file
View File

@@ -0,0 +1,51 @@
# HexaHost GameCloud Node Agent
Go daemon that runs on each game node. It maintains an outbound connection to the control plane, reports heartbeats and resource metrics, and executes signed lifecycle commands for Minecraft server containers.
## Phase 0 scope
- Environment-based configuration loader
- Versioned JSON protocol message types (`agent.hello`, `agent.heartbeat`, `server.start`, …)
- Health endpoints (`/healthz`, `/readyz`)
- Main loop stub with graceful shutdown on `SIGINT`/`SIGTERM`
## Build
```bash
make build
make test
```
## Configuration
| Variable | Required | Description |
|----------|----------|-------------|
| `NODE_ID` | yes | Unique node identifier |
| `NODE_AGENT_PUBLIC_URL` | yes | Control plane WebSocket URL |
| `NODE_TOKEN` | no* | Enrollment token (*required in production) |
| `NODE_AGENT_HEALTH_ADDR` | no | Health listen address (default `:9100`) |
| `NODE_HEARTBEAT_INTERVAL` | no | Heartbeat interval (default `10s`) |
| `NODE_DATA_DIR` | no | Server data root (default `/var/lib/hexahost-gamecloud`) |
| `LOG_LEVEL` | no | Log level |
See repository root `.env.example` for the full variable list.
## Protocol
Messages use a common envelope:
```json
{
"protocolVersion": 1,
"messageId": "01...",
"type": "agent.heartbeat",
"timestamp": "2026-01-01T00:00:00Z",
"payload": {}
}
```
See `internal/protocol/messages.go` for all supported types.
## Deployment
Production deployment uses systemd. See `deploy/systemd/node-agent.service`.

View File

@@ -0,0 +1,29 @@
package main
import (
"context"
"log/slog"
"os"
"github.com/hexahost/gamecloud/node-agent/internal/agent"
"github.com/hexahost/gamecloud/node-agent/internal/config"
)
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
cfg, err := config.Load()
if err != nil {
log.Error("configuration error", "error", err)
os.Exit(1)
}
log = log.With("node_id", cfg.NodeID, "component", "node-agent")
if err := agent.New(cfg, log).Run(context.Background()); err != nil {
log.Error("agent exited with error", "error", err)
os.Exit(1)
}
}

3
apps/node-agent/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/hexahost/gamecloud/node-agent
go 1.23

View File

@@ -0,0 +1,92 @@
package agent
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/hexahost/gamecloud/node-agent/internal/config"
"github.com/hexahost/gamecloud/node-agent/internal/health"
)
const Version = "0.1.0-phase0"
// Agent coordinates the node agent lifecycle until shutdown.
type Agent struct {
cfg *config.Config
log *slog.Logger
health *health.Server
}
// New creates an agent instance from configuration.
func New(cfg *config.Config, log *slog.Logger) *Agent {
return &Agent{
cfg: cfg,
log: log,
health: health.NewServer(cfg.HealthListenAddr, Version),
}
}
// Run starts the agent loop and blocks until context cancellation or signal.
func (a *Agent) Run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigCh)
errCh := make(chan error, 1)
go func() {
a.log.Info("health server listening", "addr", a.cfg.HealthListenAddr)
if err := a.health.ListenAndServe(); err != nil {
errCh <- err
}
}()
a.health.SetReady(true)
a.log.Info("agent started",
"node_id", a.cfg.NodeID,
"control_plane", a.cfg.ControlPlaneURL,
"version", Version,
)
ticker := time.NewTicker(a.cfg.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return a.shutdown(ctx.Err())
case sig := <-sigCh:
a.log.Info("shutdown signal received", "signal", sig.String())
return a.shutdown(nil)
case err := <-errCh:
return err
case <-ticker.C:
a.log.Debug("heartbeat tick",
"node_id", a.cfg.NodeID,
"interval", a.cfg.HeartbeatInterval.String(),
)
}
}
}
func (a *Agent) shutdown(reason error) error {
a.health.SetReady(false)
a.log.Info("agent shutting down", "grace", a.cfg.ShutdownGracePeriod.String())
timer := time.NewTimer(a.cfg.ShutdownGracePeriod)
defer timer.Stop()
<-timer.C
if reason != nil && reason != context.Canceled {
a.log.Warn("shutdown with error", "error", reason)
return reason
}
a.log.Info("agent stopped")
return nil
}

View File

@@ -0,0 +1,88 @@
package config
import (
"fmt"
"os"
"strconv"
"time"
)
// Config holds runtime configuration loaded from environment variables.
type Config struct {
AppName string
NodeID string
ControlPlaneURL string
NodeToken string
HealthListenAddr string
HeartbeatInterval time.Duration
ReconnectBackoff time.Duration
DataDir string
DockerSocket string
LogLevel string
TLSCertFile string
TLSKeyFile string
TLSCAFile string
TLSSkipVerify bool
ShutdownGracePeriod time.Duration
}
// Load reads configuration from environment variables with sensible defaults for development.
func Load() (*Config, error) {
cfg := &Config{
AppName: envOrDefault("APP_NAME", "HexaHost GameCloud"),
NodeID: os.Getenv("NODE_ID"),
ControlPlaneURL: os.Getenv("NODE_AGENT_PUBLIC_URL"),
NodeToken: os.Getenv("NODE_TOKEN"),
HealthListenAddr: envOrDefault("NODE_AGENT_HEALTH_ADDR", ":9100"),
HeartbeatInterval: durationFromEnv("NODE_HEARTBEAT_INTERVAL", 10*time.Second),
ReconnectBackoff: durationFromEnv("NODE_RECONNECT_BACKOFF", 5*time.Second),
DataDir: envOrDefault("NODE_DATA_DIR", "/var/lib/hexahost-gamecloud"),
DockerSocket: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
LogLevel: envOrDefault("LOG_LEVEL", "info"),
TLSCertFile: os.Getenv("NODE_TLS_CERT"),
TLSKeyFile: os.Getenv("NODE_TLS_KEY"),
TLSCAFile: os.Getenv("NODE_CA_CERT"),
TLSSkipVerify: boolFromEnv("NODE_TLS_SKIP_VERIFY", false),
ShutdownGracePeriod: durationFromEnv("NODE_SHUTDOWN_GRACE", 30*time.Second),
}
if cfg.NodeID == "" {
return nil, fmt.Errorf("NODE_ID is required")
}
if cfg.ControlPlaneURL == "" {
return nil, fmt.Errorf("NODE_AGENT_PUBLIC_URL is required")
}
return cfg, nil
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func durationFromEnv(key string, fallback time.Duration) time.Duration {
raw := os.Getenv(key)
if raw == "" {
return fallback
}
d, err := time.ParseDuration(raw)
if err != nil {
return fallback
}
return d
}
func boolFromEnv(key string, fallback bool) bool {
raw := os.Getenv(key)
if raw == "" {
return fallback
}
v, err := strconv.ParseBool(raw)
if err != nil {
return fallback
}
return v
}

View File

@@ -0,0 +1,78 @@
package health
import (
"encoding/json"
"net/http"
"sync/atomic"
"time"
)
// Status represents agent health for liveness and readiness probes.
type Status struct {
Status string `json:"status"`
Agent string `json:"agent"`
Version string `json:"version"`
Uptime string `json:"uptime"`
Timestamp time.Time `json:"timestamp"`
}
// Server exposes HTTP health endpoints for the node agent.
type Server struct {
addr string
version string
startedAt time.Time
ready atomic.Bool
mux *http.ServeMux
}
// NewServer creates a health HTTP server bound to addr.
func NewServer(addr, version string) *Server {
s := &Server{
addr: addr,
version: version,
startedAt: time.Now().UTC(),
mux: http.NewServeMux(),
}
s.mux.HandleFunc("GET /healthz", s.handleLiveness)
s.mux.HandleFunc("GET /readyz", s.handleReadiness)
return s
}
// SetReady toggles readiness for orchestration probes.
func (s *Server) SetReady(ready bool) {
s.ready.Store(ready)
}
// Handler returns the HTTP handler for embedding in tests.
func (s *Server) Handler() http.Handler {
return s.mux
}
// ListenAndServe starts the health server.
func (s *Server) ListenAndServe() error {
return http.ListenAndServe(s.addr, s.mux)
}
func (s *Server) handleLiveness(w http.ResponseWriter, _ *http.Request) {
s.writeStatus(w, http.StatusOK, "alive")
}
func (s *Server) handleReadiness(w http.ResponseWriter, _ *http.Request) {
if !s.ready.Load() {
s.writeStatus(w, http.StatusServiceUnavailable, "not_ready")
return
}
s.writeStatus(w, http.StatusOK, "ready")
}
func (s *Server) writeStatus(w http.ResponseWriter, code int, status string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(Status{
Status: status,
Agent: "node-agent",
Version: s.version,
Uptime: time.Since(s.startedAt).Round(time.Second).String(),
Timestamp: time.Now().UTC(),
})
}

View File

@@ -0,0 +1,209 @@
package protocol
import (
"encoding/json"
"fmt"
"strings"
"time"
)
const CurrentProtocolVersion = 1
// MessageType identifies a versioned agent/control-plane message.
type MessageType string
const (
// Agent → Control Plane
TypeAgentHello MessageType = "agent.hello"
TypeAgentHeartbeat MessageType = "agent.heartbeat"
TypeAgentInventory MessageType = "agent.inventory"
TypeServerState MessageType = "server.state"
TypeServerMetrics MessageType = "server.metrics"
TypeServerLog MessageType = "server.log"
TypeServerOperationProgress MessageType = "server.operation.progress"
TypeServerOperationComplete MessageType = "server.operation.completed"
TypeServerOperationFailed MessageType = "server.operation.failed"
// Control Plane → Agent
TypeServerProvision MessageType = "server.provision"
TypeServerInstall MessageType = "server.install"
TypeServerStart MessageType = "server.start"
TypeServerStop MessageType = "server.stop"
TypeServerKill MessageType = "server.kill"
TypeServerDelete MessageType = "server.delete"
TypeServerInspect MessageType = "server.inspect"
TypeServerCommand MessageType = "server.command"
TypeServerBackupPrepare MessageType = "server.backup.prepare"
TypeServerBackupRelease MessageType = "server.backup.release"
TypeServerFilesList MessageType = "server.files.list"
TypeServerFilesRead MessageType = "server.files.read"
TypeServerFilesWrite MessageType = "server.files.write"
TypeServerFilesUploadDone MessageType = "server.files.upload.complete"
TypeServerWorldValidate MessageType = "server.world.validate"
TypeServerMetricsSub MessageType = "server.metrics.subscribe"
TypeServerLogsSubscribe MessageType = "server.logs.subscribe"
)
var agentToControlTypes = map[MessageType]struct{}{
TypeAgentHello: {},
TypeAgentHeartbeat: {},
TypeAgentInventory: {},
TypeServerState: {},
TypeServerMetrics: {},
TypeServerLog: {},
TypeServerOperationProgress: {},
TypeServerOperationComplete: {},
TypeServerOperationFailed: {},
}
var controlToAgentTypes = map[MessageType]struct{}{
TypeServerProvision: {},
TypeServerInstall: {},
TypeServerStart: {},
TypeServerStop: {},
TypeServerKill: {},
TypeServerDelete: {},
TypeServerInspect: {},
TypeServerCommand: {},
TypeServerBackupPrepare: {},
TypeServerBackupRelease: {},
TypeServerFilesList: {},
TypeServerFilesRead: {},
TypeServerFilesWrite: {},
TypeServerFilesUploadDone: {},
TypeServerWorldValidate: {},
TypeServerMetricsSub: {},
TypeServerLogsSubscribe: {},
}
// Envelope is the common wrapper for all protocol messages.
type Envelope struct {
ProtocolVersion int `json:"protocolVersion"`
MessageID string `json:"messageId"`
CorrelationID string `json:"correlationId,omitempty"`
Type MessageType `json:"type"`
Timestamp time.Time `json:"timestamp"`
Payload json.RawMessage `json:"payload"`
}
// OperationMeta is shared metadata for lifecycle operations.
type OperationMeta struct {
ServerID string `json:"serverId"`
Generation int64 `json:"generation"`
Deadline *time.Time `json:"deadline,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
}
// AgentHelloPayload is sent when the agent connects to the control plane.
type AgentHelloPayload struct {
NodeID string `json:"nodeId"`
AgentVersion string `json:"agentVersion"`
ProtocolVersion int `json:"protocolVersion"`
Hostname string `json:"hostname,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
}
// AgentHeartbeatPayload reports node health and resource usage.
type AgentHeartbeatPayload struct {
NodeID string `json:"nodeId"`
CPUUsagePercent float64 `json:"cpuUsagePercent"`
MemoryUsedBytes int64 `json:"memoryUsedBytes"`
MemoryTotalBytes int64 `json:"memoryTotalBytes"`
DiskUsedBytes int64 `json:"diskUsedBytes"`
DiskTotalBytes int64 `json:"diskTotalBytes"`
RunningServers int `json:"runningServers"`
}
// ServerStartPayload instructs the agent to start a game server.
type ServerStartPayload struct {
OperationMeta
DesiredGeneration int64 `json:"desiredGeneration"`
}
// ServerStatePayload reports the observed server state.
type ServerStatePayload struct {
OperationMeta
State string `json:"state"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
// Validate checks envelope fields and known message types.
func (e *Envelope) Validate() error {
if e == nil {
return fmt.Errorf("envelope is nil")
}
if e.ProtocolVersion < 1 {
return fmt.Errorf("protocolVersion must be >= 1")
}
if e.ProtocolVersion > CurrentProtocolVersion {
return fmt.Errorf("unsupported protocolVersion %d", e.ProtocolVersion)
}
if strings.TrimSpace(e.MessageID) == "" {
return fmt.Errorf("messageId is required")
}
if strings.TrimSpace(string(e.Type)) == "" {
return fmt.Errorf("type is required")
}
if e.Timestamp.IsZero() {
return fmt.Errorf("timestamp is required")
}
if !e.Timestamp.UTC().Equal(e.Timestamp) {
return fmt.Errorf("timestamp must be UTC")
}
if _, ok := agentToControlTypes[e.Type]; !ok {
if _, ok := controlToAgentTypes[e.Type]; !ok {
return fmt.Errorf("unknown message type: %s", e.Type)
}
}
if len(e.Payload) == 0 {
return fmt.Errorf("payload is required")
}
if !json.Valid(e.Payload) {
return fmt.Errorf("payload must be valid JSON")
}
return nil
}
// IsAgentToControl returns true when the message flows from agent to control plane.
func (t MessageType) IsAgentToControl() bool {
_, ok := agentToControlTypes[t]
return ok
}
// IsControlToAgent returns true when the message flows from control plane to agent.
func (t MessageType) IsControlToAgent() bool {
_, ok := controlToAgentTypes[t]
return ok
}
// DecodeEnvelope parses and validates a JSON envelope.
func DecodeEnvelope(data []byte) (*Envelope, error) {
var env Envelope
if err := json.Unmarshal(data, &env); err != nil {
return nil, fmt.Errorf("decode envelope: %w", err)
}
if err := env.Validate(); err != nil {
return nil, err
}
return &env, nil
}
// NewEnvelope creates a validated outbound envelope.
func NewEnvelope(msgType MessageType, messageID string, payload any) (*Envelope, error) {
raw, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
env := &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: messageID,
Type: msgType,
Timestamp: time.Now().UTC(),
Payload: raw,
}
if err := env.Validate(); err != nil {
return nil, err
}
return env, nil
}

View File

@@ -0,0 +1,151 @@
package protocol
import (
"encoding/json"
"testing"
"time"
)
func TestEnvelopeValidate_AgentHello(t *testing.T) {
payload := AgentHelloPayload{
NodeID: "node-01",
AgentVersion: "0.1.0",
ProtocolVersion: CurrentProtocolVersion,
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
env := &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-001",
Type: TypeAgentHello,
Timestamp: time.Now().UTC(),
Payload: raw,
}
if err := env.Validate(); err != nil {
t.Fatalf("expected valid envelope, got %v", err)
}
if !env.Type.IsAgentToControl() {
t.Fatal("agent.hello should be agent-to-control")
}
}
func TestEnvelopeValidate_ServerStart(t *testing.T) {
payload := ServerStartPayload{
OperationMeta: OperationMeta{
ServerID: "srv-01",
Generation: 4,
},
DesiredGeneration: 4,
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
env := &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-002",
CorrelationID: "corr-002",
Type: TypeServerStart,
Timestamp: time.Now().UTC(),
Payload: raw,
}
if err := env.Validate(); err != nil {
t.Fatalf("expected valid envelope, got %v", err)
}
if !env.Type.IsControlToAgent() {
t.Fatal("server.start should be control-to-agent")
}
}
func TestEnvelopeValidate_RejectsInvalid(t *testing.T) {
tests := []struct {
name string
env Envelope
}{
{
name: "missing message id",
env: Envelope{
ProtocolVersion: 1,
Type: TypeAgentHeartbeat,
Timestamp: time.Now().UTC(),
Payload: json.RawMessage(`{"nodeId":"n1"}`),
},
},
{
name: "unknown type",
env: Envelope{
ProtocolVersion: 1,
MessageID: "m1",
Type: "agent.unknown",
Timestamp: time.Now().UTC(),
Payload: json.RawMessage(`{}`),
},
},
{
name: "non-utc timestamp",
env: Envelope{
ProtocolVersion: 1,
MessageID: "m1",
Type: TypeAgentHeartbeat,
Timestamp: time.Now().Local(),
Payload: json.RawMessage(`{"nodeId":"n1"}`),
},
},
{
name: "invalid payload json",
env: Envelope{
ProtocolVersion: 1,
MessageID: "m1",
Type: TypeAgentHeartbeat,
Timestamp: time.Now().UTC(),
Payload: json.RawMessage(`{not-json`),
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if err := tc.env.Validate(); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestDecodeEnvelope(t *testing.T) {
data := []byte(`{
"protocolVersion": 1,
"messageId": "01HXYZ",
"type": "agent.heartbeat",
"timestamp": "2026-01-01T00:00:00Z",
"payload": {"nodeId":"node-01","runningServers":0}
}`)
env, err := DecodeEnvelope(data)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if env.Type != TypeAgentHeartbeat {
t.Fatalf("unexpected type %s", env.Type)
}
}
func TestNewEnvelope(t *testing.T) {
env, err := NewEnvelope(TypeAgentHello, "msg-new", AgentHelloPayload{
NodeID: "n1",
AgentVersion: "0.1.0",
ProtocolVersion: 1,
})
if err != nil {
t.Fatalf("new envelope: %v", err)
}
if env.ProtocolVersion != CurrentProtocolVersion {
t.Fatalf("protocol version %d", env.ProtocolVersion)
}
}

3
apps/web/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Public environment variables (exposed to the browser)
NEXT_PUBLIC_APP_NAME=HexaHost GameCloud
NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1

3
apps/web/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

75
apps/web/messages/de.json Normal file
View File

@@ -0,0 +1,75 @@
{
"common": {
"appName": "HexaHost GameCloud",
"login": "Anmelden",
"register": "Registrieren",
"dashboard": "Dashboard",
"logout": "Abmelden",
"theme": "Design",
"themeLight": "Hell",
"themeDark": "Dunkel",
"themeSystem": "System",
"language": "Sprache",
"loading": "Wird geladen…",
"submit": "Absenden",
"cancel": "Abbrechen",
"back": "Zurück",
"footer": "Selbstgehostete Minecraft-Server-Plattform."
},
"landing": {
"badge": "Phase 0 · Control Plane",
"title": "Minecraft-Server on demand",
"subtitle": "Erstellen, konfigurieren und betreiben Sie Minecraft-Server über ein zentrales Webpanel — mandantenfähig, skalierbar und produktionsbereit.",
"ctaPrimary": "Jetzt starten",
"ctaSecondary": "Anmelden",
"features": {
"control": {
"title": "Zentrale Steuerung",
"description": "Server-Lifecycle, Quotas und Scheduling über eine einheitliche Control Plane."
},
"nodes": {
"title": "Multi-Node",
"description": "Horizontale Skalierung über dedizierte Game-Nodes mit Agent-basierter Orchestrierung."
},
"security": {
"title": "Enterprise-Sicherheit",
"description": "mTLS, granulare Berechtigungen und auditierbare Zustandsübergänge von Anfang an."
}
}
},
"auth": {
"loginTitle": "Anmelden",
"loginSubtitle": "Melden Sie sich mit Ihrem Konto an.",
"registerTitle": "Konto erstellen",
"registerSubtitle": "Registrieren Sie sich für die Plattform.",
"email": "E-Mail",
"emailPlaceholder": "name@beispiel.de",
"password": "Passwort",
"passwordPlaceholder": "••••••••",
"confirmPassword": "Passwort bestätigen",
"noAccount": "Noch kein Konto?",
"hasAccount": "Bereits registriert?",
"apiPending": "Authentifizierung wird in Phase 1 angebunden. Formularstruktur ist vorbereitet."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Willkommen in Ihrem Kontrollbereich.",
"servers": "Server",
"serversEmpty": "Noch keine Server",
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, sobald die Provisionierung verfügbar ist.",
"createServer": "Server erstellen",
"status": "Status",
"resources": "Ressourcen",
"activity": "Aktivität",
"activityEmpty": "Keine aktuellen Ereignisse",
"protectedNote": "Geschützter Bereich — Authentifizierung wird in Phase 1 aktiviert."
},
"validation": {
"emailRequired": "E-Mail ist erforderlich",
"emailInvalid": "Ungültige E-Mail-Adresse",
"passwordRequired": "Passwort ist erforderlich",
"passwordMin": "Passwort muss mindestens 8 Zeichen haben",
"confirmPasswordRequired": "Passwortbestätigung ist erforderlich",
"passwordMismatch": "Passwörter stimmen nicht überein"
}
}

75
apps/web/messages/en.json Normal file
View File

@@ -0,0 +1,75 @@
{
"common": {
"appName": "HexaHost GameCloud",
"login": "Sign in",
"register": "Register",
"dashboard": "Dashboard",
"logout": "Sign out",
"theme": "Theme",
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"language": "Language",
"loading": "Loading…",
"submit": "Submit",
"cancel": "Cancel",
"back": "Back",
"footer": "Self-hosted Minecraft server platform."
},
"landing": {
"badge": "Phase 0 · Control Plane",
"title": "Minecraft servers on demand",
"subtitle": "Create, configure, and operate Minecraft servers through a central web panel — multi-tenant, scalable, and production-ready.",
"ctaPrimary": "Get started",
"ctaSecondary": "Sign in",
"features": {
"control": {
"title": "Central control",
"description": "Server lifecycle, quotas, and scheduling through a unified control plane."
},
"nodes": {
"title": "Multi-node",
"description": "Horizontal scaling across dedicated game nodes with agent-based orchestration."
},
"security": {
"title": "Enterprise security",
"description": "mTLS, granular permissions, and auditable state transitions from day one."
}
}
},
"auth": {
"loginTitle": "Sign in",
"loginSubtitle": "Sign in with your account.",
"registerTitle": "Create account",
"registerSubtitle": "Register for the platform.",
"email": "Email",
"emailPlaceholder": "name@example.com",
"password": "Password",
"passwordPlaceholder": "••••••••",
"confirmPassword": "Confirm password",
"noAccount": "No account yet?",
"hasAccount": "Already registered?",
"apiPending": "Authentication will be connected in Phase 1. Form structure is ready."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome to your control area.",
"servers": "Servers",
"serversEmpty": "No servers yet",
"serversEmptyDescription": "Create your first Minecraft server once provisioning is available.",
"createServer": "Create server",
"status": "Status",
"resources": "Resources",
"activity": "Activity",
"activityEmpty": "No recent events",
"protectedNote": "Protected area — authentication will be enabled in Phase 1."
},
"validation": {
"emailRequired": "Email is required",
"emailInvalid": "Invalid email address",
"passwordRequired": "Password is required",
"passwordMin": "Password must be at least 8 characters",
"confirmPasswordRequired": "Password confirmation is required",
"passwordMismatch": "Passwords do not match"
}
}

5
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

10
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = {
transpilePackages: ["@hexahost/ui"],
};
export default withNextIntl(nextConfig);

35
apps/web/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@hexahost/web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hexahost/ui": "workspace:*",
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.67.2",
"next": "^15.2.3",
"next-intl": "^4.0.2",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.21",
"eslint": "^9.22.0",
"eslint-config-next": "^15.2.3",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -0,0 +1,41 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation";
interface DashboardLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function DashboardLayout({
children,
params,
}: DashboardLayoutProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("common");
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="Dashboard navigation">
<ul className="flex gap-4 text-sm">
<li>
<Link
href="/dashboard"
className="font-medium text-zinc-900 dark:text-zinc-50"
aria-current="page"
>
{t("dashboard")}
</Link>
</li>
</ul>
</nav>
<p className="font-mono text-xs text-zinc-500">
auth: pending · session: none
</p>
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { DashboardContent } from "@/components/dashboard/dashboard-content";
interface DashboardPageProps {
params: Promise<{ locale: string }>;
}
export default async function DashboardPage({ params }: DashboardPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <DashboardContent />;
}

View File

@@ -0,0 +1,38 @@
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { SiteLayout } from "@/components/layout/site-layout";
import { QueryProvider } from "@/components/providers/query-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { routing, type Locale } from "@/i18n/routing";
interface LocaleLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) {
notFound();
}
setRequestLocale(locale);
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
<ThemeProvider>
<QueryProvider>
<SiteLayout>{children}</SiteLayout>
</QueryProvider>
</ThemeProvider>
</NextIntlClientProvider>
);
}

View File

@@ -0,0 +1,17 @@
import { setRequestLocale } from "next-intl/server";
import { LoginForm } from "@/components/auth/login-form";
interface LoginPageProps {
params: Promise<{ locale: string }>;
}
export default async function LoginPage({ params }: LoginPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
<LoginForm />
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import { getAppName } from "@/lib/env";
interface HomePageProps {
params: Promise<{ locale: string }>;
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("landing");
const appName = getAppName();
const features = [
{ key: "control" as const, title: t("features.control.title"), description: t("features.control.description") },
{ key: "nodes" as const, title: t("features.nodes.title"), description: t("features.nodes.description") },
{ key: "security" as const, title: t("features.security.title"), description: t("features.security.description") },
];
return (
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<section className="mx-auto max-w-3xl text-center">
<p className="mb-4 inline-flex rounded-full border border-zinc-200 bg-white px-3 py-1 font-mono text-xs text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
{t("badge")}
</p>
<h1 className="text-4xl font-semibold tracking-tight text-zinc-900 sm:text-5xl dark:text-zinc-50">
{appName}
</h1>
<p className="mt-4 text-lg text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link
href="/register"
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-6 text-sm font-medium text-white transition-colors hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("ctaPrimary")}
</Link>
<Link
href="/login"
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-6 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("ctaSecondary")}
</Link>
</div>
</section>
<section className="mt-20 grid gap-6 md:grid-cols-3">
{features.map((feature) => (
<Card key={feature.key}>
<CardHeader>
<CardTitle>{feature.title}</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="h-1 w-12 rounded-full bg-sky-600 dark:bg-sky-500" aria-hidden="true" />
</CardContent>
</Card>
))}
</section>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { setRequestLocale } from "next-intl/server";
import { RegisterForm } from "@/components/auth/register-form";
interface RegisterPageProps {
params: Promise<{ locale: string }>;
}
export default async function RegisterPage({ params }: RegisterPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
<RegisterForm />
</div>
);
}

View File

@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--surface: 250 250 250;
--surface-muted: 244 244 245;
--border: 228 228 231;
}
.dark {
--surface: 9 9 11;
--surface-muted: 24 24 27;
--border: 39 39 42;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-zinc-50 text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-50;
}
}
@layer utilities {
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
}

View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud",
template: `%s · ${process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud"}`,
},
description: "Self-hosted Minecraft server platform control plane.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} font-sans`}>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link } from "@/i18n/navigation";
import { submitLogin } from "@/lib/api/auth";
import { createLoginSchema, type LoginFormValues } from "@/lib/schemas/auth";
export function LoginForm() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const [submitted, setSubmitted] = useState(false);
const schema = createLoginSchema({
emailRequired: tv("emailRequired"),
emailInvalid: tv("emailInvalid"),
passwordRequired: tv("passwordRequired"),
passwordMin: tv("passwordMin"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "" },
});
async function onSubmit(values: LoginFormValues) {
await submitLogin(values);
setSubmitted(true);
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("loginTitle")}</CardTitle>
<CardDescription>{t("loginSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("email")}
</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder={t("emailPlaceholder")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
{...register("email")}
/>
{errors.email ? (
<p id="email-error" className="text-sm text-red-600" role="alert">
{errors.email.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")}
</label>
<input
id="password"
type="password"
autoComplete="current-password"
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.password ? "true" : "false"}
aria-describedby={errors.password ? "password-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
{...register("password")}
/>
{errors.password ? (
<p id="password-error" className="text-sm text-red-600" role="alert">
{errors.password.message}
</p>
) : null}
</div>
{submitted ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
{t("apiPending")}
</p>
) : null}
<Button type="submit" className="w-full" isLoading={isSubmitting}>
{t("loginTitle")}
</Button>
</form>
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
{t("noAccount")}{" "}
<Link href="/register" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("registerTitle")}
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link } from "@/i18n/navigation";
import { submitRegister } from "@/lib/api/auth";
import { createRegisterSchema, type RegisterFormValues } from "@/lib/schemas/auth";
export function RegisterForm() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const [submitted, setSubmitted] = useState(false);
const schema = createRegisterSchema({
emailRequired: tv("emailRequired"),
emailInvalid: tv("emailInvalid"),
passwordRequired: tv("passwordRequired"),
passwordMin: tv("passwordMin"),
confirmPasswordRequired: tv("confirmPasswordRequired"),
passwordMismatch: tv("passwordMismatch"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "", confirmPassword: "" },
});
async function onSubmit(values: RegisterFormValues) {
await submitRegister({ email: values.email, password: values.password });
setSubmitted(true);
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("registerTitle")}</CardTitle>
<CardDescription>{t("registerSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("email")}
</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder={t("emailPlaceholder")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
{...register("email")}
/>
{errors.email ? (
<p id="email-error" className="text-sm text-red-600" role="alert">
{errors.email.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")}
</label>
<input
id="password"
type="password"
autoComplete="new-password"
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.password ? "true" : "false"}
aria-describedby={errors.password ? "password-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
{...register("password")}
/>
{errors.password ? (
<p id="password-error" className="text-sm text-red-600" role="alert">
{errors.password.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="confirmPassword"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("confirmPassword")}
</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.confirmPassword ? "true" : "false"}
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
{...register("confirmPassword")}
/>
{errors.confirmPassword ? (
<p id="confirm-password-error" className="text-sm text-red-600" role="alert">
{errors.confirmPassword.message}
</p>
) : null}
</div>
{submitted ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
{t("apiPending")}
</p>
) : null}
<Button type="submit" className="w-full" isLoading={isSubmitting}>
{t("registerTitle")}
</Button>
</form>
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
{t("hasAccount")}{" "}
<Link href="/login" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("loginTitle")}
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { EmptyState } from "@/components/dashboard/empty-state";
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
export function DashboardContent() {
const t = useTranslations("dashboard");
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = window.setTimeout(() => setIsLoading(false), 600);
return () => window.clearTimeout(timer);
}, []);
if (isLoading) {
return <DashboardSkeleton />;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("welcome")}</p>
<p className="mt-2 font-mono text-xs text-zinc-500">{t("protectedNote")}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>{t("status")}</CardTitle>
<CardDescription>Control Plane · Online</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
0
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">Active servers</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("resources")}</CardTitle>
<CardDescription>Allocated / Quota</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
0 / 0
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">CPU · RAM · Disk</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("activity")}</CardTitle>
<CardDescription>Last 24 hours</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activityEmpty")}</p>
</CardContent>
</Card>
</div>
<section aria-labelledby="servers-heading">
<h2 id="servers-heading" className="mb-4 text-lg font-medium text-zinc-900 dark:text-zinc-50">
{t("servers")}
</h2>
<EmptyState
title={t("serversEmpty")}
description={t("serversEmptyDescription")}
actionLabel={t("createServer")}
/>
</section>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
interface EmptyStateProps {
title: string;
description: string;
actionLabel: string;
onAction?: () => void;
}
export function EmptyState({
title,
description,
actionLabel,
onAction,
}: EmptyStateProps) {
return (
<Card className="border-dashed">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="secondary" onClick={onAction} disabled={!onAction}>
{actionLabel}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,29 @@
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className = "" }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-md bg-zinc-200 dark:bg-zinc-800 ${className}`}
aria-hidden="true"
/>
);
}
export function DashboardSkeleton() {
return (
<div className="space-y-6" aria-busy="true" aria-label="Loading dashboard">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96 max-w-full" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<Skeleton className="h-64 w-full" />
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { useTranslations } from "next-intl";
import { getAppName } from "@/lib/env";
export function Footer() {
const t = useTranslations("common");
const appName = getAppName();
const year = new Date().getFullYear();
return (
<footer className="border-t border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950">
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-4 py-8 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:text-zinc-400">
<p>
© {year} {appName}. {t("footer")}
</p>
<p className="font-mono text-xs text-zinc-500 dark:text-zinc-500">
Phase 0 · Frontend Foundation
</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { getAppName } from "@/lib/env";
import { LocaleSwitcher } from "./locale-switcher";
import { ThemeToggle } from "./theme-toggle";
export function Header() {
const t = useTranslations("common");
const appName = getAppName();
return (
<header className="sticky top-0 z-50 border-b border-zinc-200 bg-white/80 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/80">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
<Link
href="/"
className="text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"
>
{appName}
</Link>
<nav
className="flex items-center gap-2 sm:gap-4"
aria-label="Main navigation"
>
<Link
href="/dashboard"
className="hidden text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 sm:inline"
>
{t("dashboard")}
</Link>
<LocaleSwitcher />
<ThemeToggle />
<Link
href="/login"
className="inline-flex h-8 items-center rounded-md px-3 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
{t("login")}
</Link>
<Link
href="/register"
className="inline-flex h-8 items-center rounded-md bg-sky-600 px-3 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("register")}
</Link>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { routing, type Locale } from "@/i18n/routing";
export function LocaleSwitcher() {
const t = useTranslations("common");
const locale = useLocale() as Locale;
const router = useRouter();
const pathname = usePathname();
function onChange(nextLocale: string) {
router.replace(pathname, { locale: nextLocale as Locale });
}
return (
<label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<span className="sr-only">{t("language")}</span>
<select
value={locale}
onChange={(event) => onChange(event.target.value)}
className="h-8 rounded-md border border-zinc-300 bg-white px-2 text-sm text-zinc-900 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
aria-label={t("language")}
>
{routing.locales.map((item) => (
<option key={item} value={item}>
{item.toUpperCase()}
</option>
))}
</select>
</label>
);
}

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
import { Footer } from "./footer";
import { Header } from "./header";
interface SiteLayoutProps {
children: ReactNode;
}
export function SiteLayout({ children }: SiteLayoutProps) {
return (
<div className="flex min-h-screen flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@hexahost/ui";
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant="ghost" size="sm" aria-label="Theme" disabled>
</Button>
);
}
const isDark = (theme === "system" ? resolvedTheme : theme) === "dark";
return (
<Button
variant="ghost"
size="sm"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? "☀" : "☾"}
</Button>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
interface QueryProviderProps {
children: ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ReactNode } from "react";
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,5 @@
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);

View File

@@ -0,0 +1,15 @@
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as "de" | "en")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});

View File

@@ -0,0 +1,9 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["de", "en"],
defaultLocale: "de",
localePrefix: "as-needed",
});
export type Locale = (typeof routing.locales)[number];

View File

@@ -0,0 +1,45 @@
import { getApiUrl } from "./env";
export interface AuthCredentials {
email: string;
password: string;
}
export interface AuthResponse {
accessToken?: string;
refreshToken?: string;
}
/**
* Prepared API client for Phase 1 authentication.
* Currently validates structure only; no network request is performed.
*/
export async function submitLogin(
credentials: AuthCredentials,
): Promise<AuthResponse> {
const endpoint = `${getApiUrl()}/auth/login`;
if (!endpoint.startsWith("http")) {
throw new Error("Invalid API URL configuration");
}
void credentials;
void endpoint;
return Promise.resolve({});
}
export async function submitRegister(
credentials: AuthCredentials,
): Promise<AuthResponse> {
const endpoint = `${getApiUrl()}/auth/register`;
if (!endpoint.startsWith("http")) {
throw new Error("Invalid API URL configuration");
}
void credentials;
void endpoint;
return Promise.resolve({});
}

7
apps/web/src/lib/env.ts Normal file
View File

@@ -0,0 +1,7 @@
export function getAppName(): string {
return process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud";
}
export function getApiUrl(): string {
return process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001/api/v1";
}

View File

@@ -0,0 +1,48 @@
import { z } from "zod";
export function createLoginSchema(messages: {
emailRequired: string;
emailInvalid: string;
passwordRequired: string;
passwordMin: string;
}) {
return z.object({
email: z
.string()
.min(1, messages.emailRequired)
.email(messages.emailInvalid),
password: z
.string()
.min(1, messages.passwordRequired)
.min(8, messages.passwordMin),
});
}
export function createRegisterSchema(messages: {
emailRequired: string;
emailInvalid: string;
passwordRequired: string;
passwordMin: string;
confirmPasswordRequired: string;
passwordMismatch: string;
}) {
return z
.object({
email: z
.string()
.min(1, messages.emailRequired)
.email(messages.emailInvalid),
password: z
.string()
.min(1, messages.passwordRequired)
.min(8, messages.passwordMin),
confirmPassword: z.string().min(1, messages.confirmPasswordRequired),
})
.refine((data) => data.password === data.confirmPassword, {
message: messages.passwordMismatch,
path: ["confirmPassword"],
});
}
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
export type RegisterFormValues = z.infer<ReturnType<typeof createRegisterSchema>>;

View File

@@ -0,0 +1,8 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./src/i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/", "/(de|en)/:path*", "/((?!_next|_vercel|.*\\..*).*)"],
};

View File

@@ -0,0 +1,34 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"../../packages/ui/src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
sans: [
"var(--font-geist-sans)",
"ui-sans-serif",
"system-ui",
"sans-serif",
],
mono: ["var(--font-geist-mono)", "ui-monospace", "monospace"],
},
colors: {
surface: {
DEFAULT: "rgb(var(--surface) / <alpha-value>)",
muted: "rgb(var(--surface-muted) / <alpha-value>)",
},
border: {
DEFAULT: "rgb(var(--border) / <alpha-value>)",
},
},
},
},
plugins: [],
};
export default config;

23
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

17
apps/worker/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@hexahost/worker",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc",
"lint": "node -e \"process.exit(0)\"",
"typecheck": "tsc --noEmit",
"test": "node --test test/**/*.test.js",
"start": "node dist/main.js",
"dev": "tsc && node dist/main.js"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}

69
apps/worker/src/health.ts Normal file
View File

@@ -0,0 +1,69 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { prisma } from '@hexahost/database';
import { getConfig } from '@hexahost/config';
import { logger } from './logger';
export function startHealthServer(): ReturnType<typeof createServer> {
const config = getConfig();
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.url !== '/health' && req.url !== '/health/ready') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'error', message: 'Not found' }));
return;
}
const isReady = req.url === '/health/ready';
const timestamp = new Date().toISOString();
if (!isReady) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
status: 'ok',
service: config.APP_NAME,
timestamp,
}),
);
return;
}
try {
await prisma.$queryRaw`SELECT 1`;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
status: 'ok',
service: config.APP_NAME,
timestamp,
checks: [{ name: 'database', status: 'ok' }],
}),
);
} catch (error) {
logger.error({ err: error }, 'Readiness check failed');
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
status: 'error',
service: config.APP_NAME,
timestamp,
checks: [
{
name: 'database',
status: 'error',
message: error instanceof Error ? error.message : 'Database check failed',
},
],
}),
);
}
});
server.listen(config.WORKER_PORT, '0.0.0.0', () => {
logger.info({ port: config.WORKER_PORT }, 'Worker health server listening');
});
return server;
}

80
apps/worker/src/index.ts Normal file
View File

@@ -0,0 +1,80 @@
import { Worker, type ConnectionOptions } from 'bullmq';
import IORedis from 'ioredis';
import { validateConfig } from '@hexahost/config';
import { prisma } from '@hexahost/database';
import { startHealthServer } from './health';
import { logger } from './logger';
import { WORKER_QUEUES, type WorkerQueueName } from './queues';
function createRedisConnection(redisUrl: string): IORedis {
return new IORedis(redisUrl, {
maxRetriesPerRequest: null,
});
}
function createQueueWorker(
queueName: WorkerQueueName,
connection: ConnectionOptions,
): Worker {
return new Worker(
queueName,
async (job) => {
logger.info(
{ queue: queueName, jobId: job.id, jobName: job.name },
'Processing job',
);
return { acknowledged: true, queue: queueName };
},
{ connection },
);
}
async function bootstrap(): Promise<void> {
const config = validateConfig();
const healthServer = startHealthServer();
const redis = createRedisConnection(config.REDIS_URL);
const workers = WORKER_QUEUES.map((queueName) =>
createQueueWorker(queueName, redis),
);
for (const worker of workers) {
worker.on('completed', (job) => {
logger.info({ queue: worker.name, jobId: job.id }, 'Job completed');
});
worker.on('failed', (job, error) => {
logger.error(
{ queue: worker.name, jobId: job?.id, err: error },
'Job failed',
);
});
}
logger.info({ queues: WORKER_QUEUES }, 'Worker started');
const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, 'Shutting down worker');
await Promise.all(workers.map((worker) => worker.close()));
await redis.quit();
await prisma.$disconnect();
healthServer.close(() => {
process.exit(0);
});
};
process.on('SIGINT', () => {
void shutdown('SIGINT');
});
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});
}
void bootstrap();

10
apps/worker/src/logger.ts Normal file
View File

@@ -0,0 +1,10 @@
import pino from 'pino';
export const logger = pino({
level: process.env['LOG_LEVEL'] ?? 'info',
base: {
service: process.env['APP_NAME'] ?? 'HexaHost GameCloud Worker',
env: process.env['APP_ENV'] ?? 'development',
},
timestamp: pino.stdTimeFunctions.isoTime,
});

14
apps/worker/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
const redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379";
console.log(
JSON.stringify({
level: "info",
msg: "worker started (phase 0 stub)",
redis: redisUrl.replace(/:[^:@]+@/, ":***@"),
}),
);
// Phase 0: worker process stays alive for compose; BullMQ queues land in Phase 1+
setInterval(() => {
console.log(JSON.stringify({ level: "debug", msg: "worker heartbeat" }));
}, 60_000);

11
apps/worker/src/queues.ts Normal file
View File

@@ -0,0 +1,11 @@
export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const;
export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const;
export const QUEUE_NOTIFICATIONS = 'notifications' as const;
export const WORKER_QUEUES = [
QUEUE_SERVER_LIFECYCLE,
QUEUE_SERVER_PROVISIONING,
QUEUE_NOTIFICATIONS,
] as const;
export type WorkerQueueName = (typeof WORKER_QUEUES)[number];

13
apps/worker/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

23
deploy/ansible/README.md Normal file
View File

@@ -0,0 +1,23 @@
# Ansible playbooks
This directory will contain idempotent Ansible automation for game node provisioning.
## Planned playbooks
| Playbook | Purpose |
|----------|---------|
| `site.yml` | Full node bootstrap |
| `docker.yml` | Docker Engine installation |
| `node-agent.yml` | Agent binary, systemd unit, certificates |
| `firewall.yml` | UFW/nftables rules for game and management ports |
| `smoke-test.yml` | Post-install validation |
## Requirements
- Ansible 2.15+
- Target: Ubuntu 24.04 LTS or Debian 13
- SSH access with sudo
## Status
Phase 0 placeholder. Node installation procedures are outlined in `docs/operations/installation.md` until playbooks land in Phase 9.

View File

@@ -0,0 +1,158 @@
name: hexahost-gamecloud-dev
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-gamecloud}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-gamecloud}
POSTGRES_DB: ${POSTGRES_DB:-gamecloud}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-gamecloud} -d ${POSTGRES_DB:-gamecloud}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- gamecloud-internal
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
ports:
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- gamecloud-internal
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-minioadmin}
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
networks:
- gamecloud-internal
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin};
mc mb --ignore-existing local/$${S3_BUCKET:-gamecloud};
exit 0;
"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-minioadmin}
S3_BUCKET: ${S3_BUCKET:-gamecloud}
networks:
- gamecloud-internal
mailpit:
image: axllent/mailpit:latest
restart: unless-stopped
ports:
- "${SMTP_PORT:-1025}:1025"
- "${MAILPIT_UI_PORT:-8025}:8025"
networks:
- gamecloud-internal
api:
build:
context: ../..
dockerfile: apps/api/Dockerfile
restart: unless-stopped
env_file:
- ../../.env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql://gamecloud:gamecloud@postgres:5432/gamecloud}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
ports:
- "${API_PORT:-3001}:3001"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/healthz"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
networks:
- gamecloud-internal
- traefik-network
worker:
build:
context: ../..
dockerfile: apps/worker/Dockerfile
restart: unless-stopped
env_file:
- ../../.env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql://gamecloud:gamecloud@postgres:5432/gamecloud}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- gamecloud-internal
web:
build:
context: ../..
dockerfile: apps/web/Dockerfile
restart: unless-stopped
env_file:
- ../../.env
environment:
API_URL: ${API_URL:-http://api:3001}
ports:
- "${WEB_PORT:-3000}:3000"
depends_on:
api:
condition: service_healthy
networks:
- gamecloud-internal
- traefik-network
volumes:
postgres_data:
minio_data:
networks:
gamecloud-internal:
driver: bridge
traefik-network:
external: true
name: ${TRAEFIK_NETWORK:-traefik-network}

View File

@@ -0,0 +1,38 @@
# Prometheus configuration stub — HexaHost GameCloud
# Mount this file when enabling the optional Prometheus service in production compose.
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
- job_name: api
metrics_path: /metrics
static_configs:
- targets: ["api:3001"]
# Requires OpenTelemetry Prometheus exporter in Phase 9
- job_name: worker
metrics_path: /metrics
static_configs:
- targets: ["worker:3002"]
- job_name: node-agent
metrics_path: /metrics
static_configs:
- targets: []
# Populated per game node; agents expose metrics on management interface
- job_name: postgres
static_configs:
- targets: []
# Use postgres_exporter sidecar in production
- job_name: redis
static_configs:
- targets: []
# Use redis_exporter sidecar in production

View File

@@ -0,0 +1,28 @@
[Unit]
Description=HexaHost GameCloud Node Agent
Documentation=https://github.com/hexahost/gamecloud
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
User=hexahost
Group=hexahost
EnvironmentFile=/etc/hexahost-gamecloud/node-agent.env
ExecStart=/usr/local/bin/hexahost-node-agent
Restart=on-failure
RestartSec=5
TimeoutStopSec=60
KillMode=mixed
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/hexahost-gamecloud
AmbientCapabilities=
[Install]
WantedBy=multi-user.target

23
deploy/traefik/README.md Normal file
View File

@@ -0,0 +1,23 @@
# Traefik deployment
This directory will contain Traefik static and dynamic configuration for production deployments.
## Planned contents
- `traefik.yml` — entry points, providers, certificate resolvers
- `dynamic/` — middleware, TLS options, routing labels documentation
- Integration with external `traefik-network` Docker network
## Status
Phase 0 placeholder. Production routing is documented in `docs/operations/installation.md`.
## External network
Create the shared network once on the host:
```bash
docker network create traefik-network
```
Compose services attach to this network when `TRAEFIK_NETWORK=traefik-network` is set.

View File

@@ -0,0 +1,53 @@
# HexaHost GameCloud — Implementation Status
Last updated: 2026-06-26
## Current phase: Phase 0 — Repository and foundations
### Completed
| Area | Status | Notes |
|------|--------|-------|
| Monorepo layout | Done | pnpm workspace, Turborepo |
| Go node-agent | Done | Config, protocol types, health, agent loop stub |
| Go edge-gateway | Stub | Disabled by default; Phase 8 |
| Docker Compose dev stack | Done | PostgreSQL, Redis, MinIO, Mailpit, API, Worker, Web |
| Environment template | Done | `.env.example` |
| CI pipeline | Done | Lint, typecheck, tests, Go tests, build |
| Documentation skeleton | Done | ADR, architecture, threat model, installation |
| WHMCS integration layout | Placeholder | Directory structure and README |
| Deploy placeholders | Done | Traefik, Ansible, systemd, Prometheus |
### In progress / next (Phase 1)
- User registration, login, sessions
- Email verification and password reset
- TOTP 2FA
- Admin bootstrap CLI
- Prisma schema and migrations
### Abnahmekriterium Phase 0
- [x] Repository structure and configuration baseline
- [x] Local infrastructure compose file
- [x] Node agent builds and tests pass
- [x] CI workflow defined
- [ ] All services start locally end-to-end (requires `docker compose up` validation)
- [ ] Full API/Web/Worker feature implementation (Phase 1+)
### Known limitations
- Edge gateway is intentionally not implemented (Join-to-Start is Phase 8).
- API, Worker, and Web are minimal health-check stubs in Phase 0.
- Traefik, Ansible, and production compose are documented but not fully implemented.
- Node agent does not yet connect to the control plane WebSocket.
### Test commands
```bash
pnpm install
pnpm lint
pnpm typecheck
pnpm test
cd apps/node-agent && make test
```

View File

@@ -0,0 +1,70 @@
# ADR 0001: Monorepo architecture
- **Status:** Accepted
- **Date:** 2026-06-26
- **Deciders:** HexaHost GameCloud core team
## Context
HexaHost GameCloud is a multi-component platform: web UI, REST API, background workers, per-node Go agents, and a future edge gateway. Teams need shared contracts, consistent tooling, and atomic changes across services.
## Decision
Adopt a **pnpm + Turborepo monorepo** with the following application split:
| Component | Technology | Responsibility |
|-----------|------------|----------------|
| `apps/web` | Next.js (App Router) | User and admin UI |
| `apps/api` | NestJS + Fastify | REST API, auth, orchestration |
| `apps/worker` | NestJS / Node | BullMQ jobs, scheduler |
| `apps/node-agent` | Go | Game node daemon, container lifecycle |
| `apps/edge-gateway` | Go | TCP/UDP Join-to-Start (Phase 8) |
| `packages/*` | TypeScript | Shared database, contracts, UI, config |
Supporting infrastructure:
- **PostgreSQL** — primary data store (Prisma ORM)
- **Redis** — cache, locks, BullMQ queues
- **S3-compatible storage** — backups and large artifacts
- **Docker Compose** — local and control-plane production deployment
- **systemd** — node agent on game nodes
- **Traefik** — TLS termination and routing (external)
## Rationale
1. **pnpm workspaces** provide fast, disk-efficient dependency sharing across TypeScript packages.
2. **Turborepo** caches build and test tasks with explicit dependency graphs.
3. **NestJS + Fastify** offers structured modules, validation, and OpenAPI for the API layer.
4. **Next.js** supports SSR/RSC, i18n, and a modern React UX without a proprietary UI kit.
5. **Go** for node-agent and edge-gateway: small static binaries, strong concurrency, suitable for systemd daemons on game nodes.
6. **Prisma** gives type-safe schema evolution and migration tooling shared by API and worker.
## Consequences
### Positive
- Single repository for cross-cutting changes (API contract + UI + worker).
- Shared TypeScript types via `packages/contracts`.
- Unified CI for all languages.
- Clear deployment boundaries: Compose for control plane, systemd for agents.
### Negative
- Larger clone size and CI surface area.
- Go modules live outside the Node dependency graph; versioning is manual.
- Developers need Node 22, pnpm, Go 1.23, and Docker.
## Alternatives considered
| Alternative | Rejected because |
|-------------|------------------|
| Polyrepo | Harder to keep contracts and releases in sync |
| Kubernetes | Explicitly out of MVP scope; Compose + VMs first |
| Single language (all TypeScript) | Go better fits low-level node agent requirements |
| tRPC only (no REST) | REST + OpenAPI required for WHMCS integration and public API docs |
## Related documents
- `docs/architecture/overview.md`
- `docs/operations/installation.md`
- `docs/security/threat-model.md`

View File

@@ -0,0 +1,100 @@
# Architecture overview
HexaHost GameCloud is a self-hosted, multi-tenant platform for on-demand Minecraft servers.
## High-level diagram
```mermaid
flowchart TB
subgraph users [Users]
Browser[Web Browser]
MC[Minecraft Client]
end
subgraph control [Control Plane]
Web[Next.js Web]
API[NestJS API]
Worker[BullMQ Worker]
PG[(PostgreSQL)]
Redis[(Redis)]
S3[(S3 / MinIO)]
end
subgraph nodes [Game Nodes]
Agent[Go Node Agent]
Docker[Docker Engine]
Server[Minecraft Containers]
end
subgraph future [Phase 8+]
Edge[Edge Gateway]
end
Browser --> Web
Web --> API
API --> PG
API --> Redis
Worker --> Redis
Worker --> PG
Worker --> S3
API <-->|mTLS WebSocket| Agent
Agent --> Docker
Docker --> Server
MC --> Server
MC -.-> Edge
Edge -.-> API
```
## Component responsibilities
### Control plane
Runs on one or more VMs (Ubuntu 24.04 / Debian 13). Handles authentication, server metadata, scheduling decisions, billing projection, and job orchestration. **Never** mounts the Docker socket.
### Game nodes
Dedicated Linux VMs with Docker Engine (cgroup v2). Each node runs the **node-agent** as a systemd service. The agent connects **outbound** to the API via mTLS WebSocket and executes signed lifecycle commands locally.
### Data flow
1. User creates a server via the web UI.
2. API persists state in PostgreSQL and enqueues provisioning jobs.
3. Worker selects a node, reserves resources, and sends `server.provision` to the agent.
4. Agent creates an isolated container and reports `server.state` transitions.
5. Backups stream to S3; metadata stays in PostgreSQL.
### Security boundaries
| Boundary | Mechanism |
|----------|-----------|
| User → API | HTTPS, session cookies, CSRF, rate limits |
| API → Agent | mTLS, node token, signed JSON messages |
| Agent → Docker | Local Unix socket only, no remote exposure |
| Game container → Internet | Egress rules, no internal network access by default |
## Repository layout
See [ADR 0001](../adr/0001-monorepo-architecture.md) for the full monorepo structure.
## Protocol
Agent communication uses versioned JSON envelopes over WebSocket. Message types include `agent.hello`, `agent.heartbeat`, `server.start`, and `server.state`. See `apps/node-agent/internal/protocol/messages.go`.
## Deployment models
| Environment | Mechanism |
|-------------|-----------|
| Local development | `deploy/compose/compose.dev.yml` |
| Production control plane | Docker Compose + Traefik |
| Game nodes | Ansible + systemd node-agent |
## Phase roadmap
| Phase | Focus |
|-------|-------|
| 0 | Repository, infra, CI, docs |
| 1 | Authentication |
| 2 | Single-node vertical slice |
| 37 | Panel features, multi-node, idle shutdown |
| 8 | DNS, edge gateway, join-to-start |
| 9 | Billing, production hardening |

View File

@@ -0,0 +1,112 @@
# Local development installation
This guide covers setting up HexaHost GameCloud on a single machine for development.
## Prerequisites
| Tool | Version |
|------|---------|
| Node.js | 22.x |
| pnpm | 9.x |
| Go | 1.23+ |
| Docker Engine | 24+ |
| Docker Compose | v2 |
Optional:
- `make` (for node-agent)
- Traefik with external network `traefik-network`
## 1. Clone and configure
```bash
git clone <repository-url> hexahost-gamecloud
cd hexahost-gamecloud
cp .env.example .env
```
Edit `.env` and set at minimum:
- `SESSION_SECRET` and `ENCRYPTION_KEY` — generate with `openssl rand -base64 32`
- `DATABASE_URL`, `REDIS_URL` — defaults work with compose
- `NODE_ID` — any unique string for local agent testing
## 2. Install dependencies
```bash
pnpm install
```
## 3. Start infrastructure
```bash
# Optional: create Traefik network if using external routing
docker network create traefik-network 2>/dev/null || true
# Start PostgreSQL, Redis, MinIO, Mailpit, API, Worker, Web
docker compose -f deploy/compose/compose.dev.yml --env-file .env up -d
```
Verify health:
```bash
curl -s http://localhost:3001/healthz
curl -s http://localhost:3000/api/health
```
Mailpit UI: http://localhost:8025
MinIO console: http://localhost:9001 (default `minioadmin` / `minioadmin`)
## 4. Run applications locally (without Docker)
Alternatively, run only infrastructure in Docker and apps on the host:
```bash
docker compose -f deploy/compose/compose.dev.yml --env-file .env up -d postgres redis minio mailpit
pnpm dev
```
## 5. Node agent (optional)
```bash
cd apps/node-agent
export NODE_ID=local-dev-node
export NODE_AGENT_PUBLIC_URL=ws://localhost:3001/api/v1/nodes/ws
make build
make run
```
Health: http://localhost:9100/healthz
## 6. Quality checks
```bash
pnpm lint
pnpm typecheck
pnpm test
cd apps/node-agent && make test
```
## Troubleshooting
### `traefik-network` not found
Either create the network (`docker network create traefik-network`) or remove the external network from compose for pure local dev.
### Port conflicts
Adjust `API_PORT`, `WEB_PORT`, `POSTGRES_PORT`, `REDIS_PORT` in `.env`.
### MinIO bucket missing
The `minio-init` service creates the bucket on first start. Restart compose if it failed:
```bash
docker compose -f deploy/compose/compose.dev.yml up minio-init
```
## Next steps
- Phase 1: database migrations and authentication — see `docs/IMPLEMENTATION_STATUS.md`
- Production deployment: `deploy/traefik/`, `deploy/ansible/` (Phase 9)

View File

@@ -0,0 +1,59 @@
# Threat model (initial)
**Status:** Draft — Phase 0
**Last updated:** 2026-06-26
**Scope:** HexaHost GameCloud control plane, workers, node agents, and game containers.
## Assets
- User credentials and sessions
- Node enrollment tokens and mTLS certificates
- Game server world data and backups
- Billing and audit records
- Internal service credentials (database, Redis, S3)
## Trust boundaries
1. Public internet → Web/API (untrusted)
2. API ↔ Node Agent (mutually authenticated)
3. Node Agent → Docker (trusted local host)
4. Game container → network (untrusted, tenant-controlled code)
## Threat catalog
| ID | Threat | Description | Mitigation (planned / partial) | Residual risk |
|----|--------|-------------|-------------------------------|---------------|
| T01 | Malicious registered user | Abuse file upload, API, or quotas | AuthZ on every endpoint, path validation, rate limits, quotas | Medium — requires ongoing abuse monitoring |
| T02 | Compromised mod/plugin file | Malicious JAR in server directory | Server-side install only, checksum validation, optional ClamAV | Medium |
| T03 | Compromised user account | Attacker uses stolen session | 2FA, session revocation, login rate limits, audit log | LowMedium |
| T04 | Compromised node agent | Attacker controls agent process | mTLS, signed commands, no inbound agent port, cert rotation | Medium |
| T05 | Compromised game container | Escape or lateral movement | Non-privileged containers, dropped caps, seccomp, network isolation | Medium |
| T06 | Stolen agent token | Replay enrollment or impersonate node | One-time enrollment, token rotation, mTLS binding | Low |
| T07 | Replay of lifecycle message | Duplicate start/stop commands | Idempotency keys, generation counters, message IDs | Low |
| T08 | Path traversal | Access files outside server directory | Server-side path normalization, deny `..` and symlinks | Low |
| T09 | ZIP bomb | Decompression DoS via upload | Size, file count, compression ratio limits | Low |
| T10 | Symlink attack | Escape via symlink in archive | Reject symlinks on extract by default | Low |
| T11 | SSRF via resource pack URL | Server fetches internal URLs | URL allowlist, block RFC1918, validate schemes | Medium |
| T12 | Internal port scan from game server | Scan control plane from container | Egress firewall, no host network mode | LowMedium |
| T13 | Secret exfiltration via logs | Tokens in console or API logs | Structured logging with redaction, no secrets in env dumps | Low |
| T14 | Queue flooding | Enqueue excessive jobs | Per-user rate limits, queue depth alerts | Medium |
| T15 | Start spam | Repeated start requests (join-to-start) | Rate limits, idempotent starts, captcha at edge (Phase 8) | Medium (Phase 8) |
| T16 | Backup manipulation | Tamper with or replace backups | SHA-256 checksums, server-side encryption, immutable S3 policies | Low |
| T17 | Supply-chain attack | Compromised npm/go dependency | Lockfiles, CI audit, SBOM, image scanning | Medium |
| T18 | Manipulated runtime image | Unauthorized Docker image | Digest-pinned images, admin-managed catalog only | Low |
| T19 | DNS takeover | Hijack game subdomain | DNS provider auth, monitoring, short TTL only where needed | Low |
| T20 | WebSocket hijacking | Steal or inject agent channel | mTLS, origin checks, correlation IDs | Low |
| T21 | Tenant isolation failure | User A accesses User B server | Server-side authZ, row-level checks, audit | Critical — must test continuously |
## Out of scope (documented dependencies)
- DDoS mitigation at network edge (external infrastructure)
- Physical host compromise
- Mojang/Microsoft account security
## Next steps
- [ ] STRIDE review per component (Phase 1)
- [ ] Container seccomp/AppArmor profiles (Phase 2)
- [ ] Penetration test before production (Phase 9)
- [ ] `docs/security/container-isolation.md`, `secrets.md`, `data-retention.md`

View File

@@ -0,0 +1,65 @@
# WHMCS integration
This directory contains the future WHMCS provisioning and addon modules for HexaHost GameCloud.
## Overview
In production billing mode (`BILLING_PROVIDER=whmcs`), WHMCS is the **commercial system of record** for customers, orders, invoices, and payments. GameCloud remains the **technical system of record** for servers, nodes, lifecycle, and usage metrics.
Integration is **API-only** — no direct cross-database writes.
## Directory structure
```text
integrations/whmcs/
README.md # This file
modules/
servers/hexagamecloud/ # Provisioning module (Phase WHMCS-A+)
addons/hexagamecloud/ # Global settings, mappings, reconciliation
tests/
Unit/
Integration/
Contract/
Fixtures/
packaging/
build-package.sh
manifest.json
```
## Modules
### Server module (`hexagamecloud`)
Install path on WHMCS: `/modules/servers/hexagamecloud/`
Handles per-service lifecycle:
- CreateAccount, Suspend, Unsuspend, Terminate
- Renew, ChangePackage
- Client area status and SSO
- Usage metrics export
### Addon module (`hexagamecloud`)
Install path: `/modules/addons/hexagamecloud/`
Handles global configuration:
- API credentials and mTLS
- Product / option / addon mappings
- Reconciliation dashboard
- Event polling from GameCloud
## Status
**Phase 0:** Directory placeholders only. Implementation follows MVP Phase 9 and WHMCS phases AF documented in the master specification.
## Documentation (planned)
- `docs/integrations/whmcs/installation.md`
- `docs/integrations/whmcs/configuration.md`
- `docs/integrations/whmcs/security.md`
## Technical module name
Always `hexagamecloud` — do not rename without a migration plan.

View File

@@ -0,0 +1,7 @@
# Addon module placeholder
Implementation planned in WHMCS Phase A.
Target install path: `/modules/addons/hexagamecloud/`
Handles global API settings, product mappings, reconciliation, and event polling.

View File

@@ -0,0 +1,7 @@
# Server provisioning module placeholder
Implementation planned in WHMCS Phase AB.
Target install path: `/modules/servers/hexagamecloud/`
See `integrations/whmcs/README.md` for the full module layout.

View File

@@ -0,0 +1,9 @@
# WHMCS module packaging
Reproducible ZIP packages for distribution to WHMCS installations.
Planned artifacts:
- `build-package.sh` — assembles modules into a versioned ZIP
- `manifest.json` — module metadata and compatibility matrix
- `checksums.txt` — SHA-256 hashes for release verification

View File

@@ -0,0 +1,10 @@
# WHMCS integration tests
PHPUnit and contract tests against the GameCloud integration API will live here.
Subdirectories:
- `Unit/` — mapping, signatures, idempotency
- `Integration/` — module functions with mocked WHMCS
- `Contract/` — API request/response validation
- `Fixtures/` — sample WHMCS payloads

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "hexahost-gamecloud",
"private": true,
"packageManager": "pnpm@9.15.0",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "turbo run dev",
"dev:infra": "docker compose -f deploy/compose/compose.dev.yml --env-file .env up -d",
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"test": "turbo run test"
},
"devDependencies": {
"turbo": "^2.3.3",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "@hexahost/auth",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,29 @@
export enum UserRole {
USER = 'USER',
SUPPORT = 'SUPPORT',
MODERATOR = 'MODERATOR',
ADMIN = 'ADMIN',
SUPER_ADMIN = 'SUPER_ADMIN',
}
export interface AuthenticatedUser {
id: string;
email: string;
username: string;
roles: UserRole[];
}
export interface SessionContext {
user: AuthenticatedUser;
sessionId: string;
issuedAt: Date;
expiresAt: Date;
}
export interface AuthTokenPayload {
sub: string;
sessionId: string;
roles: UserRole[];
iat?: number;
exp?: number;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@hexahost/typescript-config/library.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,18 @@
{
"name": "@hexahost/config",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,95 @@
import { z } from 'zod';
const logLevelSchema = z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']);
const appEnvSchema = z.enum(['development', 'test', 'staging', 'production']);
export const configSchema = z.object({
APP_NAME: z.string().default('HexaHost GameCloud'),
APP_ENV: appEnvSchema.default('development'),
APP_URL: z.string().url(),
API_URL: z.string().url(),
DATABASE_URL: z.string().min(1),
REDIS_URL: z.string().min(1),
SESSION_SECRET: z.string().min(32),
LOG_LEVEL: logLevelSchema.default('info'),
API_PORT: z.coerce.number().int().min(1).max(65535).default(3001),
WORKER_PORT: z.coerce.number().int().min(1).max(65535).default(3002),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
});
export type AppConfig = z.infer<typeof configSchema>;
function readEnv(): Record<string, string | undefined> {
return { ...process.env };
}
export function loadConfig(env: Record<string, string | undefined> = readEnv()): AppConfig {
return configSchema.parse({
APP_NAME: env['APP_NAME'],
APP_ENV: env['APP_ENV'],
APP_URL: env['APP_URL'],
API_URL: env['API_URL'],
DATABASE_URL: env['DATABASE_URL'],
REDIS_URL: env['REDIS_URL'],
SESSION_SECRET: env['SESSION_SECRET'],
LOG_LEVEL: env['LOG_LEVEL'],
API_PORT: env['API_PORT'],
WORKER_PORT: env['WORKER_PORT'],
NODE_ENV: env['NODE_ENV'],
});
}
export function validateConfig(env: Record<string, string | undefined> = readEnv()): AppConfig {
const requiredKeys = [
'APP_URL',
'API_URL',
'DATABASE_URL',
'REDIS_URL',
'SESSION_SECRET',
] as const;
const missing = requiredKeys.filter((key) => !env[key] || env[key]!.trim() === '');
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(', ')}`,
);
}
const result = configSchema.safeParse({
APP_NAME: env['APP_NAME'],
APP_ENV: env['APP_ENV'],
APP_URL: env['APP_URL'],
API_URL: env['API_URL'],
DATABASE_URL: env['DATABASE_URL'],
REDIS_URL: env['REDIS_URL'],
SESSION_SECRET: env['SESSION_SECRET'],
LOG_LEVEL: env['LOG_LEVEL'],
API_PORT: env['API_PORT'],
WORKER_PORT: env['WORKER_PORT'],
NODE_ENV: env['NODE_ENV'],
});
if (!result.success) {
const issues = result.error.issues
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
.join('; ');
throw new Error(`Invalid configuration: ${issues}`);
}
return result.data;
}
let cachedConfig: AppConfig | undefined;
export function getConfig(): AppConfig {
if (!cachedConfig) {
cachedConfig = validateConfig();
}
return cachedConfig;
}
export function resetConfigCache(): void {
cachedConfig = undefined;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@hexahost/typescript-config/library.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,18 @@
{
"name": "@hexahost/contracts",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,32 @@
import { z } from 'zod';
export const problemDetailsSchema = z.object({
type: z.string().url().or(z.literal('about:blank')),
title: z.string(),
status: z.number().int(),
detail: z.string().optional(),
instance: z.string().optional(),
errors: z
.array(
z.object({
field: z.string().optional(),
message: z.string(),
}),
)
.optional(),
});
export type ProblemDetails = z.infer<typeof problemDetailsSchema>;
export function createProblemDetails(
input: Omit<ProblemDetails, 'type'> & { type?: string },
): ProblemDetails {
return problemDetailsSchema.parse({
type: input.type ?? 'about:blank',
title: input.title,
status: input.status,
detail: input.detail,
instance: input.instance,
errors: input.errors,
});
}

View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
export const healthStatusSchema = z.enum(['ok', 'degraded', 'error']);
export const healthCheckSchema = z.object({
name: z.string(),
status: healthStatusSchema,
message: z.string().optional(),
durationMs: z.number().nonnegative().optional(),
});
export const healthResponseSchema = z.object({
status: healthStatusSchema,
service: z.string(),
version: z.string().optional(),
timestamp: z.string().datetime(),
checks: z.array(healthCheckSchema).optional(),
});
export type HealthStatus = z.infer<typeof healthStatusSchema>;
export type HealthCheck = z.infer<typeof healthCheckSchema>;
export type HealthResponse = z.infer<typeof healthResponseSchema>;

View File

@@ -0,0 +1,23 @@
export {
healthStatusSchema,
healthCheckSchema,
healthResponseSchema,
type HealthStatus,
type HealthCheck,
type HealthResponse,
} from './health';
export {
problemDetailsSchema,
createProblemDetails,
type ProblemDetails,
} from './error';
export {
paginationQuerySchema,
paginationMetaSchema,
createPaginatedResponseSchema,
buildPaginationMeta,
type PaginationQuery,
type PaginationMeta,
} from './pagination';

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