diff --git a/backend-nest/src/app.module.ts b/backend-nest/src/app.module.ts index cc154cf..3935ec1 100644 --- a/backend-nest/src/app.module.ts +++ b/backend-nest/src/app.module.ts @@ -47,6 +47,7 @@ import { RaidableBasesModule } from './modules/raidablebases/raidablebases.modul import { EarlyAccessModule } from './modules/early-access/early-access.module'; import { FleetModule } from './modules/fleet/fleet.module'; import { InstancesModule } from './modules/instances/instances.module'; +import { ApiKeysModule } from './modules/api-keys/api-keys.module'; // Shared Services import { NatsService } from './services/nats.service'; @@ -137,6 +138,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway'; EarlyAccessModule, FleetModule, InstancesModule, + ApiKeysModule, ], providers: [ // Global guards (order matters: auth first, then license, then permissions) diff --git a/backend-nest/src/entities/api-key.entity.ts b/backend-nest/src/entities/api-key.entity.ts new file mode 100644 index 0000000..d9581eb --- /dev/null +++ b/backend-nest/src/entities/api-key.entity.ts @@ -0,0 +1,37 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { License } from './license.entity'; + +@Entity('api_keys') +@Index(['key_hash']) +@Index(['license_id']) +export class ApiKey { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + license_id: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + /** First 8 chars of the random token — shown in UI so users can identify keys. */ + @Column({ type: 'varchar', length: 16 }) + key_prefix: string; + + /** SHA-256 hex digest of the full plaintext key. Never returned to clients. */ + @Column({ type: 'varchar', length: 128 }) + key_hash: string; + + @Column({ type: 'timestamptz', nullable: true }) + last_used_at: Date | null; + + @Column({ type: 'boolean', default: true }) + is_active: boolean; + + @Column({ type: 'timestamptz', default: () => 'NOW()' }) + created_at: Date; + + @ManyToOne(() => License, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'license_id' }) + license: License; +} diff --git a/backend-nest/src/modules/api-keys/api-keys.controller.ts b/backend-nest/src/modules/api-keys/api-keys.controller.ts new file mode 100644 index 0000000..d8c0d50 --- /dev/null +++ b/backend-nest/src/modules/api-keys/api-keys.controller.ts @@ -0,0 +1,55 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiKeysService } from './api-keys.service'; +import { CreateApiKeyDto } from './dto/create-api-key.dto'; +import { CurrentTenant } from '../../common/decorators/current-tenant.decorator'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; + +@ApiTags('api-keys') +@ApiBearerAuth() +@Controller('api-keys') +export class ApiKeysController { + constructor(private readonly apiKeysService: ApiKeysService) {} + + @Post() + @RequirePermission('apikeys.manage') + @ApiOperation({ + summary: 'Create an API key', + description: + 'Issues a new API key for this license. The full plaintext key is returned ONCE — store it securely; it cannot be retrieved again.', + }) + @ApiResponse({ status: 201, description: 'Key created — plaintext key returned once.' }) + async create( + @CurrentTenant() licenseId: string, + @Body() dto: CreateApiKeyDto, + ) { + return this.apiKeysService.create(licenseId, dto.name); + } + + @Get() + @RequirePermission('apikeys.view') + @ApiOperation({ summary: 'List API keys', description: 'Returns all keys (active and revoked) for this license. Key hashes are never returned.' }) + @ApiResponse({ status: 200, description: 'Key list.' }) + async list(@CurrentTenant() licenseId: string) { + return this.apiKeysService.list(licenseId); + } + + @Delete(':id') + @RequirePermission('apikeys.manage') + @ApiOperation({ summary: 'Revoke an API key', description: 'Soft-deletes the key (is_active = false). The row is retained for audit purposes.' }) + @ApiResponse({ status: 200, description: 'Key revoked.' }) + @ApiResponse({ status: 404, description: 'Key not found in this license.' }) + async revoke( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + ) { + return this.apiKeysService.revoke(licenseId, id); + } +} diff --git a/backend-nest/src/modules/api-keys/api-keys.module.ts b/backend-nest/src/modules/api-keys/api-keys.module.ts new file mode 100644 index 0000000..5f4e986 --- /dev/null +++ b/backend-nest/src/modules/api-keys/api-keys.module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApiKey } from '../../entities/api-key.entity'; +import { ApiKeysController } from './api-keys.controller'; +import { ApiKeysService } from './api-keys.service'; + +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([ApiKey])], + controllers: [ApiKeysController], + providers: [ApiKeysService], + exports: [ApiKeysService], +}) +export class ApiKeysModule {} diff --git a/backend-nest/src/modules/api-keys/api-keys.service.ts b/backend-nest/src/modules/api-keys/api-keys.service.ts new file mode 100644 index 0000000..dc5d8ac --- /dev/null +++ b/backend-nest/src/modules/api-keys/api-keys.service.ts @@ -0,0 +1,150 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as crypto from 'crypto'; +import { ApiKey } from '../../entities/api-key.entity'; + +/** Shape returned to the caller on creation — the ONLY time the plaintext key is exposed. */ +export interface CreatedApiKey { + /** Full plaintext key — show once, store nowhere. */ + plaintext_key: string; + id: string; + name: string; + key_prefix: string; + is_active: boolean; + created_at: Date; +} + +/** Safe list view — no hash, no plaintext. */ +export interface ApiKeyListItem { + id: string; + name: string; + key_prefix: string; + last_used_at: Date | null; + is_active: boolean; + created_at: Date; +} + +@Injectable() +export class ApiKeysService { + private readonly logger = new Logger(ApiKeysService.name); + + constructor( + @InjectRepository(ApiKey) + private readonly apiKeyRepo: Repository, + ) {} + + /** + * Issue a new API key for the given license. + * + * Key format: `corr__` + * where prefix and secret are URL-safe base64url random bytes. + * + * Returns the full plaintext key ONCE alongside the saved row. + * The hash is never returned to the caller. + */ + async create(licenseId: string, name: string): Promise { + const prefixBytes = crypto.randomBytes(6); // 8 base64url chars + const secretBytes = crypto.randomBytes(24); // 32 base64url chars + + const prefix = prefixBytes.toString('base64url'); + const secret = secretBytes.toString('base64url'); + const plaintextKey = `corr_${prefix}_${secret}`; + + const keyHash = crypto + .createHash('sha256') + .update(plaintextKey) + .digest('hex'); + + const entity = this.apiKeyRepo.create({ + license_id: licenseId, + name, + key_prefix: prefix, + key_hash: keyHash, + is_active: true, + }); + + const saved = await this.apiKeyRepo.save(entity); + + this.logger.log( + `API key created: id=${saved.id} prefix=${prefix} license=${licenseId}`, + ); + + return { + plaintext_key: plaintextKey, + id: saved.id, + name: saved.name, + key_prefix: saved.key_prefix, + is_active: saved.is_active, + created_at: saved.created_at, + }; + } + + /** + * List all keys (active and revoked) for a license. + * The key_hash is intentionally excluded. + */ + async list(licenseId: string): Promise { + const rows = await this.apiKeyRepo.find({ + where: { license_id: licenseId }, + order: { created_at: 'DESC' }, + select: ['id', 'name', 'key_prefix', 'last_used_at', 'is_active', 'created_at'], + }); + + return rows.map((r) => ({ + id: r.id, + name: r.name, + key_prefix: r.key_prefix, + last_used_at: r.last_used_at, + is_active: r.is_active, + created_at: r.created_at, + })); + } + + /** + * Revoke (soft-delete) a key. + * Returns the updated row or throws NotFoundException if the key + * doesn't exist within this license. + */ + async revoke(licenseId: string, id: string): Promise<{ id: string; is_active: boolean }> { + const key = await this.apiKeyRepo.findOne({ + where: { id, license_id: licenseId }, + }); + + if (!key) { + throw new NotFoundException(`API key ${id} not found`); + } + + key.is_active = false; + await this.apiKeyRepo.save(key); + + this.logger.log(`API key revoked: id=${id} license=${licenseId}`); + + return { id: key.id, is_active: key.is_active }; + } + + /** + * Validate a raw API key string. + * Called by a future ApiKeyAuthGuard — not exposed via HTTP. + * + * Hashes the raw key, looks up an ACTIVE row, touches last_used_at, + * and returns { license_id } on success or null on failure. + */ + async validateKey(rawKey: string): Promise<{ license_id: string } | null> { + const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex'); + + const key = await this.apiKeyRepo.findOne({ + where: { key_hash: keyHash, is_active: true }, + select: ['id', 'license_id'], + }); + + if (!key) { + return null; + } + + // Update last_used_at without loading the full row again. + await this.apiKeyRepo.update(key.id, { last_used_at: new Date() }); + + return { license_id: key.license_id }; + } +} diff --git a/backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts b/backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts new file mode 100644 index 0000000..0f35c88 --- /dev/null +++ b/backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts @@ -0,0 +1,10 @@ +import { IsString, IsNotEmpty, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateApiKeyDto { + @ApiProperty({ description: 'Human-readable label for this key', maxLength: 100 }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + name: string; +} diff --git a/backend/migrations/023_api_keys.sql b/backend/migrations/023_api_keys.sql new file mode 100644 index 0000000..462dd33 --- /dev/null +++ b/backend/migrations/023_api_keys.sql @@ -0,0 +1,17 @@ +-- Per-license API key management +-- Each row represents one issued key: the plaintext is shown once at creation +-- and never stored; only the SHA-256 hex digest is persisted. + +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + key_prefix VARCHAR(16) NOT NULL, + key_hash VARCHAR(128) NOT NULL, + last_used_at TIMESTAMPTZ NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_license ON api_keys(license_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash); diff --git a/frontend/src/views/marketing/RoadmapView.vue b/frontend/src/views/marketing/RoadmapView.vue index 6e3c6e8..0735ebe 100644 --- a/frontend/src/views/marketing/RoadmapView.vue +++ b/frontend/src/views/marketing/RoadmapView.vue @@ -41,16 +41,24 @@ const groups: RoadmapGroup[] = [ ], }, { - status: 'in-progress', - label: 'Multi-game expansion', + status: 'shipped', + label: 'Phase 2 — Multi-game runtime', description: - 're-Agent and the control plane are being extended with per-game Formulae. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same deployment model with game-specific operational logic.', + 're-Agent multi-instance support and the per-game scheduling engine are live. One agent process now manages N game server instances on the same host, and the auto-wiper and event scheduler fire per-game on their own cadence.', items: [ - { text: 'Dune: Awakening Formula', note: 'Deep Desert wipe scheduling, battlegroup lifecycle' }, + { text: 'Multi-instance host runtime', note: 'One re-Agent managing N game processes on the same machine' }, + { text: 'Per-game wipe and event scheduling', note: 'Auto-wiper and event scheduler both fire per-game instance' }, + ], + }, + { + status: 'in-progress', + label: 'Multi-game expansion — game Formulae', + description: + 'Per-game Formulae extend the control plane with game-specific operational logic. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same deployment model.', + items: [ + { text: 'Dune: Awakening Formula', note: 'Battlegroup lifecycle shipped; Deep Desert wipe scheduling in progress' }, { text: 'Conan Exiles Formula', note: 'Persistent world management, mod support, purge tracking' }, { text: 'Soulmask Formula', note: 'Linked-world cluster deployment, port automation' }, - { text: 'Multi-instance host runtime', note: 'One re-Agent managing N game processes on the same machine' }, - { text: 'Per-game wipe and event scheduling' }, ], }, {