feat(api): public API POC — API-key auth + /api/v1 decode & OEM xref

Kapalı-beta public API temeli (rapor: api-widget-analizi-2026-07-03):
- better-auth apiKey plugin (defaultPrefix sase_, enableMetadata, anahtar
  başına 120 istek/dk); enableSessionForAPIKeys KAPALI — anahtar dashboard
  oturumu yerine geçmez, cookie AuthGuard davranışı değişmez
- apikeys tablosu (migration 0023, plugin şemasının birebir karşılığı)
- ApiKeyGuard: Authorization Bearer / x-api-key → verifyApiKey → kullanıcı
  yüklenir (status kontrolü), request.user + request.apiKey doldurulur
- /api/v1/vin/decode: mevcut slim kontrat + aday akışı; günlük başarılı-decode
  kotası (Redis, TR günü, aynı VIN idempotent; limit: key metadata
  dailyDecodeLimit → PUBLIC_API_DAILY_DECODE_LIMIT → 100) ve
  X-Decode-Quota-* header'ları
- /api/v1/oem/:code: P çapraz-referans (kotasız)
- internal/admin/api-keys: x-internal-token ile anahtar üret/listele/kapat
  (düz metin yalnız create cevabında)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 16:15:17 +03:00
parent 371aed3645
commit 7a4fe51d09
12 changed files with 637 additions and 2 deletions

View File

@@ -0,0 +1,96 @@
import {
type CanActivate,
type ExecutionContext,
HttpException,
HttpStatus,
Inject,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { eq } from "drizzle-orm";
import { type ApiKeyRecord, getApiKeyApi } from "../../auth/auth";
import { DATABASE, type Database } from "../../database/database.provider";
import { users } from "../../database/schema/core";
export type VerifiedApiKey = ApiKeyRecord;
/**
* Public API (/api/v1) kimlik doğrulaması. Cookie tabanlı AuthGuard'dan
* tamamen ayrı: anahtar `Authorization: Bearer sase_...` veya `x-api-key`
* header'ında gelir, better-auth apiKey plugin'i ile doğrulanır (hash
* karşılaştırma + enabled/expiry/anahtar-başına rate-limit). Geçerliyse
* anahtarın sahibi kullanıcı yüklenir ve `request.user` + `request.apiKey`
* doldurulur — böylece mevcut servisler (decodeVin'in abonelik/marka
* yetki kontrolü dahil) hiçbir değişiklik olmadan çalışır.
*
* Controller'lar bu guard'ı `@Public()` ile birlikte kullanmalı; @Public
* global AuthGuard'ı atlatır, kimlik işini bu guard devralır.
*/
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(@Inject(DATABASE) private readonly db: Database) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const rawKey = this.extractKey(request.headers);
if (!rawKey) {
throw new UnauthorizedException(
"API anahtarı gerekli. Anahtarınızı 'Authorization: Bearer <anahtar>' header'ında gönderin.",
);
}
let result: Awaited<ReturnType<ReturnType<typeof getApiKeyApi>["verifyApiKey"]>>;
try {
result = await getApiKeyApi().verifyApiKey({ body: { key: rawKey } });
} catch {
throw new UnauthorizedException("Geçersiz API anahtarı.");
}
if (!result.valid || !result.key) {
const code = result.error?.code ?? "INVALID_API_KEY";
if (code === "RATE_LIMITED" || code === "USAGE_EXCEEDED" || code === "TOO_MANY_REQUESTS") {
throw new HttpException(
"İstek limiti aşıldı. Lütfen bekleyip tekrar deneyin.",
HttpStatus.TOO_MANY_REQUESTS,
);
}
if (code === "KEY_EXPIRED") {
throw new UnauthorizedException("API anahtarının süresi dolmuş.");
}
if (code === "KEY_DISABLED") {
throw new UnauthorizedException("API anahtarı devre dışı bırakılmış.");
}
throw new UnauthorizedException("Geçersiz API anahtarı.");
}
const key = result.key;
const [user] = await this.db.select().from(users).where(eq(users.id, key.userId)).limit(1);
if (!user) {
throw new UnauthorizedException("API anahtarının sahibi bulunamadı.");
}
if (user.status !== "active") {
throw new UnauthorizedException(
"Hesabınız askıya alınmış durumda. Destek ile iletişime geçin.",
);
}
request.user = user;
request.apiKey = key;
return true;
}
/** Authorization: Bearer <key> (tercih edilen) veya x-api-key header'ı. */
private extractKey(headers: Record<string, unknown>): string | null {
const authHeader = headers.authorization;
if (typeof authHeader === "string" && authHeader.length > 0) {
const match = authHeader.match(/^Bearer\s+(.+)$/i);
const value = (match ? match[1] : authHeader).trim();
if (value.length > 0) return value;
}
const apiKeyHeader = headers["x-api-key"];
if (typeof apiKeyHeader === "string" && apiKeyHeader.trim().length > 0) {
return apiKeyHeader.trim();
}
return null;
}
}