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