feat(api): per-license API key management + roadmap sync
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 46s
CI / integration (push) Successful in 22s

API keys (roadmap: 'API key management per license'):
- migration 023_api_keys; ApiKey entity; ApiKeysModule (@Global, exports
  ApiKeysService) wired into app.module.
- Service: create (corr_<prefix>_<secret>, returns plaintext once, stores
  sha256 hash + prefix), list (no hash), revoke, and validateKey(rawKey) ->
  { license_id } for a future API-key auth guard. Controller license-scoped +
  RBAC (apikeys.view/manage).

Roadmap: moved the shipped multi-game items (multi-instance host runtime,
per-game wipe + event scheduling) into a 'Phase 2 — Multi-game runtime' shipped
group; Dune/Conan/Soulmask Formulae stay in-progress.

Backend tsc + frontend build green. Migration applies on a fresh DB (Saturday host).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-12 02:04:41 -04:00
parent 62bc9cd2a3
commit 55c9893131
8 changed files with 299 additions and 6 deletions

View File

@@ -47,6 +47,7 @@ import { RaidableBasesModule } from './modules/raidablebases/raidablebases.modul
import { EarlyAccessModule } from './modules/early-access/early-access.module'; import { EarlyAccessModule } from './modules/early-access/early-access.module';
import { FleetModule } from './modules/fleet/fleet.module'; import { FleetModule } from './modules/fleet/fleet.module';
import { InstancesModule } from './modules/instances/instances.module'; import { InstancesModule } from './modules/instances/instances.module';
import { ApiKeysModule } from './modules/api-keys/api-keys.module';
// Shared Services // Shared Services
import { NatsService } from './services/nats.service'; import { NatsService } from './services/nats.service';
@@ -137,6 +138,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
EarlyAccessModule, EarlyAccessModule,
FleetModule, FleetModule,
InstancesModule, InstancesModule,
ApiKeysModule,
], ],
providers: [ providers: [
// Global guards (order matters: auth first, then license, then permissions) // Global guards (order matters: auth first, then license, then permissions)

View File

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

View File

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

View File

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

View File

@@ -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<ApiKey>,
) {}
/**
* Issue a new API key for the given license.
*
* Key format: `corr_<prefix8>_<secret32>`
* 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<CreatedApiKey> {
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<ApiKeyListItem[]> {
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 };
}
}

View File

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

View File

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

View File

@@ -41,16 +41,24 @@ const groups: RoadmapGroup[] = [
], ],
}, },
{ {
status: 'in-progress', status: 'shipped',
label: 'Multi-game expansion', label: 'Phase 2 — Multi-game runtime',
description: 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: [ 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: 'Conan Exiles Formula', note: 'Persistent world management, mod support, purge tracking' },
{ text: 'Soulmask Formula', note: 'Linked-world cluster deployment, port automation' }, { 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' },
], ],
}, },
{ {