From 90f9527c0852a7ca744c724d8e96196aa18a411f Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 09:07:17 +0000 Subject: [PATCH 1/5] feat(admin): secure /admin/stats + /admin/usage with industrial API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - MetricsController: prefix _stats → admin/stats, all 4 endpoints get @RequiresTier('industrial'). ApiKeyModule imported into MetricsModule. - UsageController: prefix _usage → admin/usage, x-admin-token header auth replaced with @RequiresTier('industrial'). ApiKeyModule imported into UsageModule dynamic module. - UsageInterceptor: /admin/* paths are skipped entirely — admin tooling must never be blocked by its own rate-limit counters. - app.module.ts: adminToken option removed (no longer referenced). Frontend: - StatsService: URLs updated to /api/admin/stats. apiKey signal persisted in sessionStorage (key: admin_api_key). All requests send x-api-key header from the signal value. setApiKey() updates signal + sessionStorage. - StatsDashboardComponent: statsService exposed as public readonly. onApiKeyChange() wired to new password input in template. - stats-dashboard.component.html: API key input row added at top — shows green 'Key set' badge when a key is present, amber warning when not. Input calls onApiKeyChange and triggers immediate refresh. --- apps/server/src/app/app.module.ts | 1 - .../src/app/metrics/metrics.controller.ts | 35 ++++++++++++---- apps/server/src/app/metrics/metrics.module.ts | 2 + apps/server/src/app/usage/usage.controller.ts | 31 +++++++------- .../server/src/app/usage/usage.interceptor.ts | 4 ++ apps/server/src/app/usage/usage.module.ts | 2 + .../stats/stats-dashboard.component.html | 23 ++++++++++ .../stats/stats-dashboard.component.ts | 8 +++- .../src/app/features/stats/stats.service.ts | 42 ++++++++++++++++--- 9 files changed, 116 insertions(+), 32 deletions(-) diff --git a/apps/server/src/app/app.module.ts b/apps/server/src/app/app.module.ts index 530d3ad..734b5f3 100644 --- a/apps/server/src/app/app.module.ts +++ b/apps/server/src/app/app.module.ts @@ -88,7 +88,6 @@ import { UsageModule } from './usage/usage.module'; // Authenticated callers get TIER_LIMITS from ApiKeyService (applied // per-request in UsageInterceptor via overrideRule — no global mutation). UsageModule.forRoot({ - adminToken: process.env.ADMIN_TOKEN, defaultRule: { perMinute: 10, // anonymous / free tier fallback }, diff --git a/apps/server/src/app/metrics/metrics.controller.ts b/apps/server/src/app/metrics/metrics.controller.ts index a2fa9d2..0298afe 100644 --- a/apps/server/src/app/metrics/metrics.controller.ts +++ b/apps/server/src/app/metrics/metrics.controller.ts @@ -1,42 +1,59 @@ -import { Controller, Get, HttpCode, Query } from '@nestjs/common'; +import { Controller, Delete, Get, HttpCode, Query } from '@nestjs/common'; +import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { RequiresTier } from '../api-key/api-key.decorator'; import { MetricsService } from './metrics.service'; import { SkipMetrics } from './metrics.decorator'; import { BlocklistService } from './blocklist.service'; -@Controller() +/** + * Admin-only metrics & security dashboard. + * + * All routes require an `industrial` API key via `x-api-key` header. + * Rate limiting is intentionally skipped — admin tooling must not be + * blocked by its own counters. + */ +@ApiTags('Admin') +@ApiSecurity('x-api-key') +@Controller('admin/stats') export class MetricsController { constructor( private readonly metrics: MetricsService, private readonly blocklist: BlocklistService, ) {} - @Get('_stats') + @Get() @HttpCode(200) @SkipMetrics() + @RequiresTier('industrial') + @ApiOperation({ summary: 'Request metrics snapshot (admin)' }) getStats() { return this.metrics.snapshot(); } - @Get('_stats/reset') + @Delete('reset') @HttpCode(200) @SkipMetrics() + @RequiresTier('industrial') + @ApiOperation({ summary: 'Reset metrics counters (admin)' }) async reset() { await this.metrics.reset(); return { ok: true }; } - @Get('_stats/security') + @Get('security') @HttpCode(200) @SkipMetrics() + @RequiresTier('industrial') + @ApiOperation({ summary: 'List currently blocked IPs (admin)' }) security() { - return { - blocked: this.blocklist.list(), - }; + return { blocked: this.blocklist.list() }; } - @Get('_stats/security/unban') + @Get('security/unban') @HttpCode(200) @SkipMetrics() + @RequiresTier('industrial') + @ApiOperation({ summary: 'Unban an IP address (admin)' }) unban(@Query('ip') ip: string) { if (!ip) return { ok: false, error: 'ip required' }; const ok = this.blocklist.unban(ip); diff --git a/apps/server/src/app/metrics/metrics.module.ts b/apps/server/src/app/metrics/metrics.module.ts index 3c56d0c..cbf6d59 100644 --- a/apps/server/src/app/metrics/metrics.module.ts +++ b/apps/server/src/app/metrics/metrics.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ApiKeyModule } from '../api-key/api-key.module'; import { APP_GUARD, APP_INTERCEPTOR, Reflector } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MetricsService } from './metrics.service'; @@ -14,6 +15,7 @@ import { SecurityBlockEntity } from './entities/security-block.entity'; @Module({ imports: [ + ApiKeyModule, TypeOrmModule.forFeature([MetricMetaEntity, MetricRouteEntity, MetricDailyEntity, SecurityBlockEntity]), ], providers: [ diff --git a/apps/server/src/app/usage/usage.controller.ts b/apps/server/src/app/usage/usage.controller.ts index 58817da..ae3a96e 100644 --- a/apps/server/src/app/usage/usage.controller.ts +++ b/apps/server/src/app/usage/usage.controller.ts @@ -1,29 +1,30 @@ -import { Controller, Get, Headers, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { RequiresTier } from '../api-key/api-key.decorator'; import { UsageService } from './usage.service'; -@Controller('_usage') +/** + * Rate-limit usage snapshot — admin only. + * Secured via x-api-key (industrial tier), same as /admin/stats. + */ +@ApiTags('Admin') +@ApiSecurity('x-api-key') +@Controller('admin/usage') export class UsageController { constructor(private readonly usage: UsageService) {} - private authOk(token?: string) { - const adminToken = this.usage.getAdminToken(); - return !!adminToken && token === adminToken; - } - @Get('stats') - getStats(@Headers('x-admin-token') token?: string) { - if (!this.authOk(token)) { - return { error: 'unauthorized' }; - } + @RequiresTier('industrial') + @ApiOperation({ summary: 'Rate-limit counters snapshot (admin)' }) + getStats() { return this.usage.snapshot(); } @Post('reset') @HttpCode(HttpStatus.NO_CONTENT) - reset(@Headers('x-admin-token') token?: string) { - if (!this.authOk(token)) return { error: 'unauthorized' }; + @RequiresTier('industrial') + @ApiOperation({ summary: 'Reset rate-limit counters (admin)' }) + reset() { this.usage.resetAll(); - return; } } - diff --git a/apps/server/src/app/usage/usage.interceptor.ts b/apps/server/src/app/usage/usage.interceptor.ts index 250b755..e29220e 100644 --- a/apps/server/src/app/usage/usage.interceptor.ts +++ b/apps/server/src/app/usage/usage.interceptor.ts @@ -16,6 +16,10 @@ export class UsageInterceptor implements NestInterceptor { const path = req.originalUrl || req.url; + // Admin routes are authenticated via API key (industrial tier) and must + // never be blocked by their own rate-limit counters. + if (path.startsWith('/admin/')) return next.handle(); + // Prefer the resolved key attached by ApiKeyGuard (validated, tier known). // Fall back to the raw header string, then real client IP, then 'anonymous'. // diff --git a/apps/server/src/app/usage/usage.module.ts b/apps/server/src/app/usage/usage.module.ts index 4ea57f6..a037dbc 100644 --- a/apps/server/src/app/usage/usage.module.ts +++ b/apps/server/src/app/usage/usage.module.ts @@ -1,4 +1,5 @@ import { DynamicModule, Module } from '@nestjs/common'; +import { ApiKeyModule } from '../api-key/api-key.module'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { UsageController } from './usage.controller'; import { UsageInterceptor } from './usage.interceptor'; @@ -10,6 +11,7 @@ export class UsageModule { static forRoot(options: UsageModuleOptions = {}): DynamicModule { return { module: UsageModule, + imports: [ApiKeyModule], providers: [ UsageService, { provide: USAGE_OPTS, useValue: options }, diff --git a/apps/simonapi/src/app/features/stats/stats-dashboard.component.html b/apps/simonapi/src/app/features/stats/stats-dashboard.component.html index 36955e6..ee1eb37 100644 --- a/apps/simonapi/src/app/features/stats/stats-dashboard.component.html +++ b/apps/simonapi/src/app/features/stats/stats-dashboard.component.html @@ -1,3 +1,26 @@ + +
+
+
+ Admin API Key + + @if (statsService.apiKey()) { + Key set + } @else { + No key — requests will fail + } +
+
+
+
diff --git a/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts b/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts index 8f0016e..893e986 100644 --- a/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts +++ b/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts @@ -40,7 +40,7 @@ export class StatsDashboardComponent implements OnDestroy { private readonly isBrowser: boolean; private readonly platformId = inject(PLATFORM_ID); - private readonly statsService = inject(StatsService); + readonly statsService = inject(StatsService); readonly vm$: Observable; constructor( @@ -60,6 +60,12 @@ export class StatsDashboardComponent implements OnDestroy { this.vm$ = this.isBrowser ? this.createClientStream() : of(this.initialState); } + onApiKeyChange(e: Event): void { + const val = (e.target as HTMLInputElement).value; + this.statsService.setApiKey(val); + this.refresh(); + } + refresh(): void { if (!this.isBrowser) { return; diff --git a/apps/simonapi/src/app/features/stats/stats.service.ts b/apps/simonapi/src/app/features/stats/stats.service.ts index 29a8e3e..fa10182 100644 --- a/apps/simonapi/src/app/features/stats/stats.service.ts +++ b/apps/simonapi/src/app/features/stats/stats.service.ts @@ -1,5 +1,5 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; import { Observable } from 'rxjs'; import { environment } from '../../../environments/environments'; import { isPlatformBrowser } from '@angular/common'; @@ -36,30 +36,60 @@ export interface SecuritySnapshot { blocked: BlockEntryView[]; } +const STORAGE_KEY = 'admin_api_key'; + @Injectable({ providedIn: 'root' }) export class StatsService { - private readonly http= inject(HttpClient) + private readonly http = inject(HttpClient); private readonly platformId = inject(PLATFORM_ID); private readonly API: string; isBrowser = false; + /** Currently configured admin API key — persisted in sessionStorage. */ + readonly apiKey = signal(''); + constructor() { this.isBrowser = isPlatformBrowser(this.platformId); const origin = this.isBrowser ? window.origin : ''; this.API = (environment.API_BASE_URL || origin) + '/api'; + + if (this.isBrowser) { + const stored = sessionStorage.getItem(STORAGE_KEY) ?? ''; + this.apiKey.set(stored); + } + } + + setApiKey(key: string): void { + this.apiKey.set(key.trim()); + if (this.isBrowser) { + if (key.trim()) { + sessionStorage.setItem(STORAGE_KEY, key.trim()); + } else { + sessionStorage.removeItem(STORAGE_KEY); + } + } + } + + private headers(): { headers: HttpHeaders } { + return { + headers: new HttpHeaders({ 'x-api-key': this.apiKey() }), + }; } getStats(): Observable { - return this.http.get(`${this.API}/_stats`); + return this.http.get(`${this.API}/admin/stats`, this.headers()); } getSecurity(): Observable { - return this.http.get(`${this.API}/_stats/security`); + return this.http.get(`${this.API}/admin/stats/security`, this.headers()); } unban(ip: string): Observable { const params = new HttpParams({ fromObject: { ip } }); - return this.http.get(`${this.API}/_stats/security/unban`, { params }); + return this.http.get(`${this.API}/admin/stats/security/unban`, { + ...this.headers(), + params, + }); } } From b29e960905a6fda38f465bfebe17d1d7803a29f4 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 10:39:53 +0000 Subject: [PATCH 2/5] feat(admin): ADMIN_KEY env-var gate for /admin/stats + /admin/usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the RequiresTier('industrial') approach with a dedicated static admin key that lives exclusively in the environment — no DB lookup, no tier hierarchy, no way for an API customer to accidentally gain access. api-key.decorator.ts: REQUIRES_ADMIN_KEY metadata key + RequiresAdminKey() decorator added. api-key.guard.ts: First check in canActivate() handles @RequiresAdminKey() before any tier logic. Uses constant-time Buffer comparison (prevents timing attacks). Throws 403 if ADMIN_KEY env var is not set (server misconfiguration), 401 if key is wrong or missing. metrics.controller.ts / usage.controller.ts: @RequiresTier('industrial') → @RequiresAdminKey() on all endpoints. metrics.module.ts / usage.module.ts: ApiKeyModule import removed — admin check is pure env, no ORM needed. .env.example: ADMIN_KEY documented with generation hint (openssl rand -hex 32). --- .env.example | 13 ++++++++ .../src/app/api-key/api-key.decorator.ts | 13 ++++++++ apps/server/src/app/api-key/api-key.guard.ts | 33 ++++++++++++++++++- .../src/app/metrics/metrics.controller.ts | 10 +++--- apps/server/src/app/metrics/metrics.module.ts | 2 -- apps/server/src/app/usage/usage.controller.ts | 6 ++-- apps/server/src/app/usage/usage.module.ts | 2 -- 7 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..edfcfa6 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# ── Database ────────────────────────────────────────────────────────────────── +TYPEORM_DB=./data/simonapi.sqlite # SQLite path (dev) or ignored when Postgres env set + +# ── Signpack ────────────────────────────────────────────────────────────────── +DATA_DIR=/data # Host path mounted into container (see docker-compose.yml) +FILE_MAX_BYTES=26214400 # 25 MB default +PURGE_CRON="0 * * * *" # hourly purge of expired signpacks + +# ── Admin ───────────────────────────────────────────────────────────────────── +# Static key protecting /admin/stats and /admin/usage. +# Must be long and random — e.g.: openssl rand -hex 32 +# Send as: x-api-key: when calling admin endpoints. +ADMIN_KEY=changeme-generate-with-openssl-rand-hex-32 diff --git a/apps/server/src/app/api-key/api-key.decorator.ts b/apps/server/src/app/api-key/api-key.decorator.ts index 215537c..fb8b79d 100644 --- a/apps/server/src/app/api-key/api-key.decorator.ts +++ b/apps/server/src/app/api-key/api-key.decorator.ts @@ -35,3 +35,16 @@ export const RequiresTier = (minTier: ApiKeyTier) => * \@Post('sscc/build') */ export const TierRateLimit = () => SetMetadata(TIER_RATE_LIMIT_KEY, true); + +/** + * Hard gate: the caller MUST supply the static admin key from ADMIN_KEY env var. + * Completely independent of the tier system and DB — purely env-based. + * + * Use only on internal admin/ops endpoints (/admin/stats, /admin/usage). + * + * @example + * \@RequiresAdminKey() + * \@Get('admin/stats') + */ +export const REQUIRES_ADMIN_KEY = 'requiresAdminKey'; +export const RequiresAdminKey = () => SetMetadata(REQUIRES_ADMIN_KEY, true); diff --git a/apps/server/src/app/api-key/api-key.guard.ts b/apps/server/src/app/api-key/api-key.guard.ts index d7b17e6..cf58c7c 100644 --- a/apps/server/src/app/api-key/api-key.guard.ts +++ b/apps/server/src/app/api-key/api-key.guard.ts @@ -8,7 +8,7 @@ import { import { Reflector } from '@nestjs/core'; import { ApiKeyTier } from './entities/api-key.entity'; import { ApiKeyService, ResolvedKey } from './api-key.service'; -import { REQUIRES_TIER_KEY, TIER_RATE_LIMIT_KEY } from './api-key.decorator'; +import { REQUIRES_ADMIN_KEY, REQUIRES_TIER_KEY, TIER_RATE_LIMIT_KEY } from './api-key.decorator'; const TIER_ORDER: ApiKeyTier[] = ['free', 'pro', 'industrial']; @@ -28,6 +28,37 @@ export class ApiKeyGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); + // ── @RequiresAdminKey() — env-based admin gate ─────────────────────────── + // Compared with constant-time equality to prevent timing attacks. + // The key must be set in the ADMIN_KEY environment variable. + const requiresAdmin = this.reflector.getAllAndOverride( + REQUIRES_ADMIN_KEY, + [context.getHandler(), context.getClass()], + ); + if (requiresAdmin) { + const adminKey = process.env['ADMIN_KEY']; + if (!adminKey) { + // Server misconfiguration — ADMIN_KEY not set + throw new ForbiddenException('Admin endpoint not configured (ADMIN_KEY missing)'); + } + const provided: string = req.headers['x-api-key'] ?? ''; + // Constant-time comparison via Buffer to mitigate timing attacks + const aLen = Buffer.byteLength(adminKey); + const bLen = Buffer.byteLength(provided); + const a = Buffer.alloc(aLen, 0); + const b = Buffer.alloc(aLen, 0); + Buffer.from(adminKey).copy(a); + Buffer.from(provided.slice(0, aLen)).copy(b); + const match = a.equals(b) && aLen === bLen; + if (!match) { + throw new UnauthorizedException({ + message: 'Admin key required', + hint: 'Send your ADMIN_KEY as header: x-api-key: ', + }); + } + return true; + } + // ── Determine mode from metadata ──────────────────────────────────────── const minTier = this.reflector.getAllAndOverride( REQUIRES_TIER_KEY, diff --git a/apps/server/src/app/metrics/metrics.controller.ts b/apps/server/src/app/metrics/metrics.controller.ts index 0298afe..057f6c5 100644 --- a/apps/server/src/app/metrics/metrics.controller.ts +++ b/apps/server/src/app/metrics/metrics.controller.ts @@ -1,6 +1,6 @@ import { Controller, Delete, Get, HttpCode, Query } from '@nestjs/common'; import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; -import { RequiresTier } from '../api-key/api-key.decorator'; +import { RequiresAdminKey } from '../api-key/api-key.decorator'; import { MetricsService } from './metrics.service'; import { SkipMetrics } from './metrics.decorator'; import { BlocklistService } from './blocklist.service'; @@ -24,7 +24,7 @@ export class MetricsController { @Get() @HttpCode(200) @SkipMetrics() - @RequiresTier('industrial') + @RequiresAdminKey() @ApiOperation({ summary: 'Request metrics snapshot (admin)' }) getStats() { return this.metrics.snapshot(); @@ -33,7 +33,7 @@ export class MetricsController { @Delete('reset') @HttpCode(200) @SkipMetrics() - @RequiresTier('industrial') + @RequiresAdminKey() @ApiOperation({ summary: 'Reset metrics counters (admin)' }) async reset() { await this.metrics.reset(); @@ -43,7 +43,7 @@ export class MetricsController { @Get('security') @HttpCode(200) @SkipMetrics() - @RequiresTier('industrial') + @RequiresAdminKey() @ApiOperation({ summary: 'List currently blocked IPs (admin)' }) security() { return { blocked: this.blocklist.list() }; @@ -52,7 +52,7 @@ export class MetricsController { @Get('security/unban') @HttpCode(200) @SkipMetrics() - @RequiresTier('industrial') + @RequiresAdminKey() @ApiOperation({ summary: 'Unban an IP address (admin)' }) unban(@Query('ip') ip: string) { if (!ip) return { ok: false, error: 'ip required' }; diff --git a/apps/server/src/app/metrics/metrics.module.ts b/apps/server/src/app/metrics/metrics.module.ts index cbf6d59..3c56d0c 100644 --- a/apps/server/src/app/metrics/metrics.module.ts +++ b/apps/server/src/app/metrics/metrics.module.ts @@ -1,5 +1,4 @@ import { Module } from '@nestjs/common'; -import { ApiKeyModule } from '../api-key/api-key.module'; import { APP_GUARD, APP_INTERCEPTOR, Reflector } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MetricsService } from './metrics.service'; @@ -15,7 +14,6 @@ import { SecurityBlockEntity } from './entities/security-block.entity'; @Module({ imports: [ - ApiKeyModule, TypeOrmModule.forFeature([MetricMetaEntity, MetricRouteEntity, MetricDailyEntity, SecurityBlockEntity]), ], providers: [ diff --git a/apps/server/src/app/usage/usage.controller.ts b/apps/server/src/app/usage/usage.controller.ts index ae3a96e..7a03690 100644 --- a/apps/server/src/app/usage/usage.controller.ts +++ b/apps/server/src/app/usage/usage.controller.ts @@ -1,6 +1,6 @@ import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; -import { RequiresTier } from '../api-key/api-key.decorator'; +import { RequiresAdminKey } from '../api-key/api-key.decorator'; import { UsageService } from './usage.service'; /** @@ -14,7 +14,7 @@ export class UsageController { constructor(private readonly usage: UsageService) {} @Get('stats') - @RequiresTier('industrial') + @RequiresAdminKey() @ApiOperation({ summary: 'Rate-limit counters snapshot (admin)' }) getStats() { return this.usage.snapshot(); @@ -22,7 +22,7 @@ export class UsageController { @Post('reset') @HttpCode(HttpStatus.NO_CONTENT) - @RequiresTier('industrial') + @RequiresAdminKey() @ApiOperation({ summary: 'Reset rate-limit counters (admin)' }) reset() { this.usage.resetAll(); diff --git a/apps/server/src/app/usage/usage.module.ts b/apps/server/src/app/usage/usage.module.ts index a037dbc..4ea57f6 100644 --- a/apps/server/src/app/usage/usage.module.ts +++ b/apps/server/src/app/usage/usage.module.ts @@ -1,5 +1,4 @@ import { DynamicModule, Module } from '@nestjs/common'; -import { ApiKeyModule } from '../api-key/api-key.module'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { UsageController } from './usage.controller'; import { UsageInterceptor } from './usage.interceptor'; @@ -11,7 +10,6 @@ export class UsageModule { static forRoot(options: UsageModuleOptions = {}): DynamicModule { return { module: UsageModule, - imports: [ApiKeyModule], providers: [ UsageService, { provide: USAGE_OPTS, useValue: options }, From 632cda8a9175a028681439e505005eeb96175b97 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 10:44:06 +0000 Subject: [PATCH 3/5] feat(admin): API key management UI at /admin/api-keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - api-key-admin.controller.ts: prefix _admin/api-keys → admin/api-keys, x-admin-token header auth replaced with @RequiresAdminKey() on all three endpoints (GET list, POST create, DELETE revoke). Frontend: - api-keys.service.ts (new): list(), create(), revoke() — all requests include x-api-key from shared StatsService.apiKey() signal. - api-keys.component.ts (new): signals-based component — loads all keys on init, create form (label, tier select, optional expiry date), inline revoke with two-step confirmation, raw-key banner after creation with copy button. Shares the admin key input with StatsService. - api-keys.component.html (new): Bootstrap card layout — create form row, table with label/prefix/tier/status/expiry/created columns, revoke button with inline confirm (Yes/No). Tier badges color-coded (free=grey, pro=blue, industrial=amber). - app.routes.ts: /admin/api-keys route lazy-loads ApiKeysComponent. - stats-dashboard + api-keys templates: shared tab nav (📊 Stats / 🔑 API Keys) with routerLinkActive styling added to both pages. - stats-dashboard.component.ts: RouterLink + RouterLinkActive imported. --- .../app/api-key/api-key-admin.controller.ts | 34 ++-- apps/simonapi/src/app/app.routes.ts | 7 +- .../features/stats/api-keys.component.html | 182 ++++++++++++++++++ .../app/features/stats/api-keys.component.ts | 128 ++++++++++++ .../app/features/stats/api-keys.service.ts | 64 ++++++ .../stats/stats-dashboard.component.html | 16 ++ .../stats/stats-dashboard.component.ts | 3 + 7 files changed, 412 insertions(+), 22 deletions(-) create mode 100644 apps/simonapi/src/app/features/stats/api-keys.component.html create mode 100644 apps/simonapi/src/app/features/stats/api-keys.component.ts create mode 100644 apps/simonapi/src/app/features/stats/api-keys.service.ts diff --git a/apps/server/src/app/api-key/api-key-admin.controller.ts b/apps/server/src/app/api-key/api-key-admin.controller.ts index 4b7af9f..8b51566 100644 --- a/apps/server/src/app/api-key/api-key-admin.controller.ts +++ b/apps/server/src/app/api-key/api-key-admin.controller.ts @@ -3,41 +3,35 @@ import { Controller, Delete, Get, - Headers, HttpCode, HttpStatus, Param, Post, - UnauthorizedException, } from '@nestjs/common'; -import { ApiExcludeController } from '@nestjs/swagger'; +import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { RequiresAdminKey } from './api-key.decorator'; import { ApiKeyService } from './api-key.service'; import { ApiKeyTier } from './entities/api-key.entity'; -@ApiExcludeController() -@Controller('_admin/api-keys') +@ApiTags('Admin') +@ApiSecurity('x-api-key') +@Controller('admin/api-keys') export class ApiKeyAdminController { constructor(private readonly svc: ApiKeyService) {} - private guard(token?: string) { - const expected = process.env.ADMIN_TOKEN; - if (!expected || token !== expected) { - throw new UnauthorizedException('Invalid admin token'); - } - } - @Get() - list(@Headers('x-admin-token') token?: string) { - this.guard(token); + @RequiresAdminKey() + @ApiOperation({ summary: 'List all API keys (admin)' }) + list() { return this.svc.list(); } @Post() + @RequiresAdminKey() + @ApiOperation({ summary: 'Create a new API key (admin)' }) async create( - @Headers('x-admin-token') token: string | undefined, @Body() body: { label: string; tier: ApiKeyTier; expiresAt?: string }, ) { - this.guard(token); const expires = body.expiresAt ? new Date(body.expiresAt) : undefined; const { rawKey, entity } = await this.svc.create(body.label, body.tier, expires); return { @@ -51,11 +45,9 @@ export class ApiKeyAdminController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - async revoke( - @Headers('x-admin-token') token: string | undefined, - @Param('id') id: string, - ) { - this.guard(token); + @RequiresAdminKey() + @ApiOperation({ summary: 'Revoke an API key (admin)' }) + async revoke(@Param('id') id: string) { await this.svc.revoke(id); } } diff --git a/apps/simonapi/src/app/app.routes.ts b/apps/simonapi/src/app/app.routes.ts index 83dc61c..0664396 100644 --- a/apps/simonapi/src/app/app.routes.ts +++ b/apps/simonapi/src/app/app.routes.ts @@ -45,7 +45,12 @@ export const appRoutes: Route[] = [ { path: 'admin/stats', loadComponent: () => import('./features/stats/stats-dashboard.component').then(m => m.StatsDashboardComponent), - title: 'API Stats', + title: 'Admin – Stats', + }, + { + path: 'admin/api-keys', + loadComponent: () => import('./features/stats/api-keys.component').then(m => m.ApiKeysComponent), + title: 'Admin – API Keys', }, { path: 'crypto', diff --git a/apps/simonapi/src/app/features/stats/api-keys.component.html b/apps/simonapi/src/app/features/stats/api-keys.component.html new file mode 100644 index 0000000..695918a --- /dev/null +++ b/apps/simonapi/src/app/features/stats/api-keys.component.html @@ -0,0 +1,182 @@ + +
+
+
+ Admin API Key + + @if (statsService.apiKey()) { + Key set + } @else { + No key — requests will fail + } +
+
+ + + + + +
+ + @if (createdRawKey()) { + + } + + +
+
+ Create API Key +
+
+ @if (createError()) { +
{{ createError() }}
+ } +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+
+ API Keys + +
+
+ @if (error()) { +
{{ error() }}
+ } + @if (loading() && !keys().length) { +
+ Loading… +
+ } @else if (!keys().length) { +

No API keys yet.

+ } @else { +
+ + + + + + + + + + + + + + @for (key of keys(); track key.id) { + + + + + + + + + + } + +
LabelPrefixTierStatusExpiresCreated
{{ key.label }}{{ key.prefix }}… + {{ key.tier }} + + + {{ key.active ? 'active' : 'revoked' }} + + + {{ key.expiresAt ? (key.expiresAt | date:'mediumDate') : '—' }} + {{ key.createdAt | date:'mediumDate' }} + @if (key.active) { + @if (confirmRevoke() === key.id) { + Revoke? + + + } @else { + + } + } +
+
+ } +
+
+
diff --git a/apps/simonapi/src/app/features/stats/api-keys.component.ts b/apps/simonapi/src/app/features/stats/api-keys.component.ts new file mode 100644 index 0000000..1a56e84 --- /dev/null +++ b/apps/simonapi/src/app/features/stats/api-keys.component.ts @@ -0,0 +1,128 @@ +import { CommonModule, DatePipe, isPlatformBrowser } from '@angular/common'; +import { RouterLink, RouterLinkActive } from '@angular/router'; +import { Component, inject, OnInit, PLATFORM_ID, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ApiKeyRecord, ApiKeyTier, ApiKeysService, CreateApiKeyDto } from './api-keys.service'; +import { StatsService } from './stats.service'; + +@Component({ + selector: 'app-api-keys', + standalone: true, + imports: [CommonModule, DatePipe, FormsModule, RouterLink, RouterLinkActive], + templateUrl: './api-keys.component.html', +}) +export class ApiKeysComponent implements OnInit { + private readonly svc = inject(ApiKeysService); + readonly statsService = inject(StatsService); + private readonly platformId = inject(PLATFORM_ID); + + readonly keys = signal([]); + readonly loading = signal(false); + readonly error = signal(null); + + // Create form + readonly creating = signal(false); + readonly newLabel = signal(''); + readonly newTier = signal('free'); + readonly newExpiry = signal(''); + readonly createdRawKey = signal(null); + readonly createError = signal(null); + + // Revoke state + readonly revoking = signal(null); + readonly confirmRevoke = signal(null); + + readonly tiers: ApiKeyTier[] = ['free', 'pro', 'industrial']; + + ngOnInit(): void { + if (isPlatformBrowser(this.platformId)) { + this.load(); + } + } + + load(): void { + this.loading.set(true); + this.error.set(null); + this.svc.list().subscribe({ + next: (keys) => { this.keys.set(keys); this.loading.set(false); }, + error: (err) => { + this.error.set(err?.error?.message ?? err?.message ?? 'Failed to load keys'); + this.loading.set(false); + }, + }); + } + + submitCreate(): void { + const label = this.newLabel().trim(); + if (!label) return; + this.creating.set(true); + this.createError.set(null); + this.createdRawKey.set(null); + + const dto: CreateApiKeyDto = { label, tier: this.newTier() }; + if (this.newExpiry()) dto.expiresAt = new Date(this.newExpiry()).toISOString(); + + this.svc.create(dto).subscribe({ + next: (res) => { + this.createdRawKey.set(res.rawKey); + this.newLabel.set(''); + this.newTier.set('free'); + this.newExpiry.set(''); + this.creating.set(false); + this.load(); + }, + error: (err) => { + this.createError.set(err?.error?.message ?? 'Failed to create key'); + this.creating.set(false); + }, + }); + } + + startRevoke(id: string): void { + this.confirmRevoke.set(id); + } + + cancelRevoke(): void { + this.confirmRevoke.set(null); + } + + confirmRevokeKey(id: string): void { + this.revoking.set(id); + this.confirmRevoke.set(null); + this.svc.revoke(id).subscribe({ + next: () => { this.revoking.set(null); this.load(); }, + error: (err) => { + this.error.set(err?.error?.message ?? 'Failed to revoke key'); + this.revoking.set(null); + }, + }); + } + + copyKey(key: string): void { + navigator.clipboard?.writeText(key); + } + + dismissRawKey(): void { + this.createdRawKey.set(null); + } + + onApiKeyChange(e: Event): void { + const val = (e.target as HTMLInputElement).value; + this.statsService.setApiKey(val); + this.load(); + } + + tierBadgeClass(tier: ApiKeyTier): string { + return { + free: 'bg-secondary-subtle text-secondary border-secondary-subtle', + pro: 'bg-primary-subtle text-primary border-primary-subtle', + industrial: 'bg-warning-subtle text-warning border-warning-subtle', + }[tier]; + } + + activeBadge(active: boolean): string { + return active + ? 'bg-success-subtle text-success border-success-subtle' + : 'bg-danger-subtle text-danger border-danger-subtle'; + } +} diff --git a/apps/simonapi/src/app/features/stats/api-keys.service.ts b/apps/simonapi/src/app/features/stats/api-keys.service.ts new file mode 100644 index 0000000..6e68ab1 --- /dev/null +++ b/apps/simonapi/src/app/features/stats/api-keys.service.ts @@ -0,0 +1,64 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { Observable } from 'rxjs'; +import { isPlatformBrowser } from '@angular/common'; +import { environment } from '../../../environments/environments'; +import { StatsService } from './stats.service'; + +export type ApiKeyTier = 'free' | 'pro' | 'industrial'; + +export interface ApiKeyRecord { + id: string; + label: string; + prefix: string; + tier: ApiKeyTier; + active: boolean; + expiresAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateApiKeyResult { + message: string; + rawKey: string; + id: string; + tier: ApiKeyTier; + label: string; +} + +export interface CreateApiKeyDto { + label: string; + tier: ApiKeyTier; + expiresAt?: string; +} + +@Injectable({ providedIn: 'root' }) +export class ApiKeysService { + private readonly http = inject(HttpClient); + private readonly platformId = inject(PLATFORM_ID); + private readonly statsService = inject(StatsService); + + private readonly API: string; + + constructor() { + const isBrowser = isPlatformBrowser(this.platformId); + const origin = isBrowser ? window.origin : ''; + this.API = (environment.API_BASE_URL || origin) + '/api'; + } + + private headers(): { headers: HttpHeaders } { + return { headers: new HttpHeaders({ 'x-api-key': this.statsService.apiKey() }) }; + } + + list(): Observable { + return this.http.get(`${this.API}/admin/api-keys`, this.headers()); + } + + create(dto: CreateApiKeyDto): Observable { + return this.http.post(`${this.API}/admin/api-keys`, dto, this.headers()); + } + + revoke(id: string): Observable { + return this.http.delete(`${this.API}/admin/api-keys/${id}`, this.headers()); + } +} diff --git a/apps/simonapi/src/app/features/stats/stats-dashboard.component.html b/apps/simonapi/src/app/features/stats/stats-dashboard.component.html index ee1eb37..4cf9da3 100644 --- a/apps/simonapi/src/app/features/stats/stats-dashboard.component.html +++ b/apps/simonapi/src/app/features/stats/stats-dashboard.component.html @@ -21,6 +21,22 @@
+ + +
diff --git a/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts b/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts index 893e986..3192383 100644 --- a/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts +++ b/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts @@ -1,4 +1,5 @@ import { CommonModule, isPlatformBrowser } from '@angular/common'; +import { RouterLink, RouterLinkActive } from '@angular/router'; import { Component, inject, Inject, OnDestroy, PLATFORM_ID } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Observable, Subject, forkJoin, merge, of, timer } from 'rxjs'; @@ -25,6 +26,8 @@ interface ViewModel { imports: [ CommonModule, FormsModule, + RouterLink, + RouterLinkActive, StatsCardComponent, SecurityTableComponent, DurationPipe, From 7598b17adbf0d44b5c6b665201afe5903c9970ee Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 10:49:14 +0000 Subject: [PATCH 4/5] fix(admin): skip anomaly guard on all /admin/* controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnomalyGuard runs as APP_GUARD before every request. Without @SkipAnomalyGuard() the admin dashboard auto-refresh (5s interval) and repeated GET /admin/stats/security calls could trigger the burst_per_minute threshold, banning the admin's own IP — which would then prevent viewing the blocklist they just ended up on. Added @SkipAnomalyGuard() at class level to: - MetricsController (/admin/stats/*) - UsageController (/admin/usage/*) - ApiKeyAdminController (/admin/api-keys/*) The guard already supports class-level skipping via reflector.get(SKIP_SECURITY, context.getClass()). --- apps/server/src/app/api-key/api-key-admin.controller.ts | 2 ++ apps/server/src/app/metrics/metrics.controller.ts | 2 ++ apps/server/src/app/usage/usage.controller.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/apps/server/src/app/api-key/api-key-admin.controller.ts b/apps/server/src/app/api-key/api-key-admin.controller.ts index 8b51566..ff0d8a0 100644 --- a/apps/server/src/app/api-key/api-key-admin.controller.ts +++ b/apps/server/src/app/api-key/api-key-admin.controller.ts @@ -10,11 +10,13 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { RequiresAdminKey } from './api-key.decorator'; +import { SkipAnomalyGuard } from '../metrics/anomaly.guard'; import { ApiKeyService } from './api-key.service'; import { ApiKeyTier } from './entities/api-key.entity'; @ApiTags('Admin') @ApiSecurity('x-api-key') +@SkipAnomalyGuard() @Controller('admin/api-keys') export class ApiKeyAdminController { constructor(private readonly svc: ApiKeyService) {} diff --git a/apps/server/src/app/metrics/metrics.controller.ts b/apps/server/src/app/metrics/metrics.controller.ts index 057f6c5..b7e026d 100644 --- a/apps/server/src/app/metrics/metrics.controller.ts +++ b/apps/server/src/app/metrics/metrics.controller.ts @@ -3,6 +3,7 @@ import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { RequiresAdminKey } from '../api-key/api-key.decorator'; import { MetricsService } from './metrics.service'; import { SkipMetrics } from './metrics.decorator'; +import { SkipAnomalyGuard } from './anomaly.guard'; import { BlocklistService } from './blocklist.service'; /** @@ -14,6 +15,7 @@ import { BlocklistService } from './blocklist.service'; */ @ApiTags('Admin') @ApiSecurity('x-api-key') +@SkipAnomalyGuard() @Controller('admin/stats') export class MetricsController { constructor( diff --git a/apps/server/src/app/usage/usage.controller.ts b/apps/server/src/app/usage/usage.controller.ts index 7a03690..1324dc3 100644 --- a/apps/server/src/app/usage/usage.controller.ts +++ b/apps/server/src/app/usage/usage.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { RequiresAdminKey } from '../api-key/api-key.decorator'; +import { SkipAnomalyGuard } from '../metrics/anomaly.guard'; import { UsageService } from './usage.service'; /** @@ -9,6 +10,7 @@ import { UsageService } from './usage.service'; */ @ApiTags('Admin') @ApiSecurity('x-api-key') +@SkipAnomalyGuard() @Controller('admin/usage') export class UsageController { constructor(private readonly usage: UsageService) {} From fbed347aaf724626cc0fdd2178961d4068e8b800 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 12:00:27 +0000 Subject: [PATCH 5/5] =?UTF-8?q?feat(home):=20unify=20API=20catalog=20?= =?UTF-8?q?=E2=80=94=207=20cards=20with=20Info/Try=20tabs,=20remove=20Quic?= =?UTF-8?q?k=20Start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick Start (3 cards) and API Catalog (5 cards) were redundant and both incomplete. Merged into a single unified catalog section: - 7 catalog cards: QR, Barcodes, GS1, Crypto, Watermark, Signpack, Dev Utils - Each card gets an optional Try tab showing a curl snippet + copy button - Info tab shows a short description (new field on CatalogCard) - Routes list always visible regardless of active tab - Quick Start section removed entirely - ServiceCard type removed; catalog data is single source of truth - setTab() now operates on catalog[] instead of services[] - catalog-tabs, catalog-desc, catalog-try styles added to SCSS - GS1 ctaSecondary uses external:true href (mailto) — no routerLink --- .../src/app/features/home/home.component.html | 120 +++++++++--------- .../src/app/features/home/home.component.scss | 42 ++++++ .../src/app/features/home/home.component.ts | 98 ++++++++------ 3 files changed, 156 insertions(+), 104 deletions(-) diff --git a/apps/simonapi/src/app/features/home/home.component.html b/apps/simonapi/src/app/features/home/home.component.html index 0773c65..c2f496c 100644 --- a/apps/simonapi/src/app/features/home/home.component.html +++ b/apps/simonapi/src/app/features/home/home.component.html @@ -91,7 +91,7 @@
@@ -122,15 +122,31 @@

Available APIs

{{ card.sub }}
- @if (card.badge) { - - {{ card.badge }} - - } +
+ @if (card.badge) { + + {{ card.badge }} + + } + @if (card.curl) { + + } +
- +
@for (r of card.routes; track r.path) {
@@ -148,15 +164,42 @@

Available APIs

}
+ + @if (card.activeTab === 'try' && card.curl) { +
+
+ {{ card.curlTitle }} + +
+
{{ card.curl }}
+
+ } @else if (card.description) { +

{{ card.description }}

+ } +
- - {{ card.ctaPrimary.label }} - + @if (card.ctaPrimary.external) { + + {{ card.ctaPrimary.label }} + + } @else { + + {{ card.ctaPrimary.label }} + + } @if (card.ctaSecondary) { - - {{ card.ctaSecondary.label }} - + @if (card.ctaSecondary.external) { + + {{ card.ctaSecondary.label }} + + } @else { + + {{ card.ctaSecondary.label }} + + } }
@@ -169,55 +212,6 @@

Available APIs

- -
-
- -
- @for (s of services; track s.key) { -
-
-
-

- {{ s.icon }}{{ s.title }} -

- -
-
- @if (s.activeTab === 'info') { -

{{ s.description }}

- Open - } @else { -
- {{ s.apiTitle }} - -
-
{{ s.curl }}
- } -
-
-
- } -
-
-
-