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

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