initial commit
This commit is contained in:
92
.env.example
Normal file
92
.env.example
Normal 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
83
.github/workflows/ci.yml
vendored
Normal 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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
.turbo
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
3139
HexaHost_GameCloud_Cursor_Masterprompt.md
Normal file
3139
HexaHost_GameCloud_Cursor_Masterprompt.md
Normal file
File diff suppressed because it is too large
Load Diff
19
apps/api/Dockerfile
Normal file
19
apps/api/Dockerfile
Normal 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
8
apps/api/nest-cli.json
Normal 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
17
apps/api/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
58
apps/api/src/app.module.ts
Normal file
58
apps/api/src/app.module.ts
Normal 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('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
102
apps/api/src/common/filters/rfc7807-exception.filter.ts
Normal file
102
apps/api/src/common/filters/rfc7807-exception.filter.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/api/src/common/middleware/request-id.middleware.ts
Normal file
20
apps/api/src/common/middleware/request-id.middleware.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/api/src/health/health.controller.ts
Normal file
35
apps/api/src/health/health.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/health/health.module.ts
Normal file
10
apps/api/src/health/health.module.ts
Normal 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 {}
|
||||||
51
apps/api/src/health/health.service.ts
Normal file
51
apps/api/src/health/health.service.ts
Normal 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
26
apps/api/src/main.ts
Normal 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 }));
|
||||||
|
});
|
||||||
10
apps/api/src/prisma/prisma.module.ts
Normal file
10
apps/api/src/prisma/prisma.module.ts
Normal 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 {}
|
||||||
13
apps/api/src/prisma/prisma.service.ts
Normal file
13
apps/api/src/prisma/prisma.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/test/health.test.js
Normal file
31
apps/api/test/health.test.js
Normal 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())),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
apps/api/tsconfig.build.json
Normal file
4
apps/api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||||
|
}
|
||||||
13
apps/api/tsconfig.json
Normal file
13
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
29
apps/edge-gateway/cmd/gateway/main.go
Normal file
29
apps/edge-gateway/cmd/gateway/main.go
Normal 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
3
apps/edge-gateway/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/hexahost/gamecloud/edge-gateway
|
||||||
|
|
||||||
|
go 1.23
|
||||||
24
apps/node-agent/Makefile
Normal file
24
apps/node-agent/Makefile
Normal 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
51
apps/node-agent/README.md
Normal 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`.
|
||||||
29
apps/node-agent/cmd/agent/main.go
Normal file
29
apps/node-agent/cmd/agent/main.go
Normal 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
3
apps/node-agent/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/hexahost/gamecloud/node-agent
|
||||||
|
|
||||||
|
go 1.23
|
||||||
92
apps/node-agent/internal/agent/agent.go
Normal file
92
apps/node-agent/internal/agent/agent.go
Normal 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
|
||||||
|
}
|
||||||
88
apps/node-agent/internal/config/config.go
Normal file
88
apps/node-agent/internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
78
apps/node-agent/internal/health/health.go
Normal file
78
apps/node-agent/internal/health/health.go
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
209
apps/node-agent/internal/protocol/messages.go
Normal file
209
apps/node-agent/internal/protocol/messages.go
Normal 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
|
||||||
|
}
|
||||||
151
apps/node-agent/internal/protocol/messages_test.go
Normal file
151
apps/node-agent/internal/protocol/messages_test.go
Normal 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
3
apps/web/.env.example
Normal 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
3
apps/web/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
75
apps/web/messages/de.json
Normal file
75
apps/web/messages/de.json
Normal 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
75
apps/web/messages/en.json
Normal 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
5
apps/web/next-env.d.ts
vendored
Normal 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
10
apps/web/next.config.ts
Normal 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
35
apps/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/web/postcss.config.mjs
Normal file
9
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
41
apps/web/src/app/[locale]/dashboard/layout.tsx
Normal file
41
apps/web/src/app/[locale]/dashboard/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/web/src/app/[locale]/dashboard/page.tsx
Normal file
13
apps/web/src/app/[locale]/dashboard/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
38
apps/web/src/app/[locale]/layout.tsx
Normal file
38
apps/web/src/app/[locale]/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/web/src/app/[locale]/login/page.tsx
Normal file
17
apps/web/src/app/[locale]/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
apps/web/src/app/[locale]/page.tsx
Normal file
64
apps/web/src/app/[locale]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/web/src/app/[locale]/register/page.tsx
Normal file
17
apps/web/src/app/[locale]/register/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/web/src/app/globals.css
Normal file
39
apps/web/src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/web/src/app/layout.tsx
Normal file
35
apps/web/src/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
apps/web/src/components/auth/login-form.tsx
Normal file
108
apps/web/src/components/auth/login-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
apps/web/src/components/auth/register-form.tsx
Normal file
134
apps/web/src/components/auth/register-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
apps/web/src/components/dashboard/dashboard-content.tsx
Normal file
82
apps/web/src/components/dashboard/dashboard-content.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/web/src/components/dashboard/empty-state.tsx
Normal file
29
apps/web/src/components/dashboard/empty-state.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/web/src/components/dashboard/skeleton.tsx
Normal file
29
apps/web/src/components/dashboard/skeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/web/src/components/layout/footer.tsx
Normal file
21
apps/web/src/components/layout/footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/web/src/components/layout/header.tsx
Normal file
51
apps/web/src/components/layout/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/web/src/components/layout/locale-switcher.tsx
Normal file
34
apps/web/src/components/layout/locale-switcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/web/src/components/layout/site-layout.tsx
Normal file
17
apps/web/src/components/layout/site-layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/web/src/components/layout/theme-toggle.tsx
Normal file
35
apps/web/src/components/layout/theme-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/web/src/components/providers/query-provider.tsx
Normal file
26
apps/web/src/components/providers/query-provider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/web/src/components/providers/theme-provider.tsx
Normal file
21
apps/web/src/components/providers/theme-provider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/web/src/i18n/navigation.ts
Normal file
5
apps/web/src/i18n/navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createNavigation } from "next-intl/navigation";
|
||||||
|
import { routing } from "./routing";
|
||||||
|
|
||||||
|
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||||
|
createNavigation(routing);
|
||||||
15
apps/web/src/i18n/request.ts
Normal file
15
apps/web/src/i18n/request.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
9
apps/web/src/i18n/routing.ts
Normal file
9
apps/web/src/i18n/routing.ts
Normal 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];
|
||||||
45
apps/web/src/lib/api/auth.ts
Normal file
45
apps/web/src/lib/api/auth.ts
Normal 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
7
apps/web/src/lib/env.ts
Normal 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";
|
||||||
|
}
|
||||||
48
apps/web/src/lib/schemas/auth.ts
Normal file
48
apps/web/src/lib/schemas/auth.ts
Normal 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>>;
|
||||||
8
apps/web/src/middleware.ts
Normal file
8
apps/web/src/middleware.ts
Normal 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|.*\\..*).*)"],
|
||||||
|
};
|
||||||
34
apps/web/tailwind.config.ts
Normal file
34
apps/web/tailwind.config.ts
Normal 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
23
apps/web/tsconfig.json
Normal 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
17
apps/worker/package.json
Normal 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
69
apps/worker/src/health.ts
Normal 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
80
apps/worker/src/index.ts
Normal 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
10
apps/worker/src/logger.ts
Normal 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
14
apps/worker/src/main.ts
Normal 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
11
apps/worker/src/queues.ts
Normal 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
13
apps/worker/tsconfig.json
Normal 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
23
deploy/ansible/README.md
Normal 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.
|
||||||
158
deploy/compose/compose.dev.yml
Normal file
158
deploy/compose/compose.dev.yml
Normal 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}
|
||||||
38
deploy/monitoring/prometheus.yml
Normal file
38
deploy/monitoring/prometheus.yml
Normal 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
|
||||||
28
deploy/systemd/node-agent.service
Normal file
28
deploy/systemd/node-agent.service
Normal 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
23
deploy/traefik/README.md
Normal 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.
|
||||||
53
docs/IMPLEMENTATION_STATUS.md
Normal file
53
docs/IMPLEMENTATION_STATUS.md
Normal 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
|
||||||
|
```
|
||||||
70
docs/adr/0001-monorepo-architecture.md
Normal file
70
docs/adr/0001-monorepo-architecture.md
Normal 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`
|
||||||
100
docs/architecture/overview.md
Normal file
100
docs/architecture/overview.md
Normal 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 |
|
||||||
|
| 3–7 | Panel features, multi-node, idle shutdown |
|
||||||
|
| 8 | DNS, edge gateway, join-to-start |
|
||||||
|
| 9 | Billing, production hardening |
|
||||||
112
docs/operations/installation.md
Normal file
112
docs/operations/installation.md
Normal 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)
|
||||||
59
docs/security/threat-model.md
Normal file
59
docs/security/threat-model.md
Normal 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 | Low–Medium |
|
||||||
|
| 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 | Low–Medium |
|
||||||
|
| 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`
|
||||||
65
integrations/whmcs/README.md
Normal file
65
integrations/whmcs/README.md
Normal 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 A–F 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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Server provisioning module placeholder
|
||||||
|
|
||||||
|
Implementation planned in WHMCS Phase A–B.
|
||||||
|
|
||||||
|
Target install path: `/modules/servers/hexagamecloud/`
|
||||||
|
|
||||||
|
See `integrations/whmcs/README.md` for the full module layout.
|
||||||
9
integrations/whmcs/packaging/README.md
Normal file
9
integrations/whmcs/packaging/README.md
Normal 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
|
||||||
10
integrations/whmcs/tests/README.md
Normal file
10
integrations/whmcs/tests/README.md
Normal 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
20
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
packages/auth/package.json
Normal file
15
packages/auth/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/auth/src/index.ts
Normal file
29
packages/auth/src/index.ts
Normal 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;
|
||||||
|
}
|
||||||
9
packages/auth/tsconfig.json
Normal file
9
packages/auth/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@hexahost/typescript-config/library.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
18
packages/config/package.json
Normal file
18
packages/config/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
95
packages/config/src/index.ts
Normal file
95
packages/config/src/index.ts
Normal 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;
|
||||||
|
}
|
||||||
9
packages/config/tsconfig.json
Normal file
9
packages/config/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@hexahost/typescript-config/library.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
18
packages/contracts/package.json
Normal file
18
packages/contracts/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/contracts/src/error.ts
Normal file
32
packages/contracts/src/error.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
22
packages/contracts/src/health.ts
Normal file
22
packages/contracts/src/health.ts
Normal 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>;
|
||||||
23
packages/contracts/src/index.ts
Normal file
23
packages/contracts/src/index.ts
Normal 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
Reference in New Issue
Block a user