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,148 @@
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { desc, eq } from "drizzle-orm";
import { getApiKeyApi } from "../auth/auth";
import { Public } from "../common/decorators/public.decorator";
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
import { DATABASE, type Database } from "../database/database.provider";
import { apikeys, users } from "../database/schema/core";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Public API anahtarı yönetimi — kapalı beta döneminde anahtarlar self-service
* değil, buradan (Süper Panel / curl + x-internal-token) elle üretilir.
* Anahtarın düz metni YALNIZCA create cevabında döner; DB'de hash saklanır.
*/
@Controller("internal/admin/api-keys")
@Public()
@UseGuards(InternalTokenGuard)
export class ApiKeysAdminController {
constructor(@Inject(DATABASE) private readonly db: Database) {}
/**
* Anahtar üret. Kullanıcı `userId` veya `email` ile seçilir (anahtar o
* kullanıcının aboneliği/marka yetkisiyle çalışır). `dailyDecodeLimit`
* anahtar metadata'sına yazılır; verilmezse env/100 varsayılanı geçerli.
*/
@Post()
@HttpCode(HttpStatus.CREATED)
async create(
@Body()
body: {
userId?: string;
email?: string;
name?: string;
dailyDecodeLimit?: number;
expiresInDays?: number;
rateLimitMax?: number;
test?: boolean;
},
) {
const user = await this.resolveUser(body);
if (
body.dailyDecodeLimit !== undefined &&
(typeof body.dailyDecodeLimit !== "number" || body.dailyDecodeLimit <= 0)
) {
throw new BadRequestException("dailyDecodeLimit pozitif bir sayı olmalı");
}
const created = await getApiKeyApi().createApiKey({
body: {
userId: user.id,
name: body.name ?? `${user.email} public-api`,
prefix: body.test ? "sase_test_" : "sase_live_",
...(body.expiresInDays ? { expiresIn: body.expiresInDays * DAY_MS } : {}),
...(body.rateLimitMax
? { rateLimitEnabled: true, rateLimitTimeWindow: 60_000, rateLimitMax: body.rateLimitMax }
: {}),
...(body.dailyDecodeLimit
? { metadata: { dailyDecodeLimit: Math.floor(body.dailyDecodeLimit) } }
: {}),
},
});
return {
id: created.id,
// Düz metin anahtar — sadece bu cevapta görünür, bir daha üretilemez.
key: created.key,
start: created.start,
user: { id: user.id, email: user.email },
name: created.name,
expiresAt: created.expiresAt,
dailyDecodeLimit: body.dailyDecodeLimit ?? null,
};
}
/** Kullanıcının anahtarlarını listele (hash asla dönmez). */
@Get()
async list(@Query("userId") userId?: string, @Query("email") email?: string) {
const user = await this.resolveUser({ userId, email });
const rows = await this.db
.select({
id: apikeys.id,
name: apikeys.name,
start: apikeys.start,
prefix: apikeys.prefix,
enabled: apikeys.enabled,
requestCount: apikeys.requestCount,
lastRequest: apikeys.lastRequest,
expiresAt: apikeys.expiresAt,
createdAt: apikeys.createdAt,
metadata: apikeys.metadata,
})
.from(apikeys)
.where(eq(apikeys.userId, user.id))
.orderBy(desc(apikeys.createdAt));
return { user: { id: user.id, email: user.email }, keys: rows };
}
/** Anahtarı devre dışı bırak (geri açmak için enable). */
@Post(":id/disable")
@HttpCode(HttpStatus.OK)
async disable(@Param("id") id: string) {
return this.setEnabled(id, false);
}
@Post(":id/enable")
@HttpCode(HttpStatus.OK)
async enable(@Param("id") id: string) {
return this.setEnabled(id, true);
}
private async setEnabled(id: string, enabled: boolean) {
const [row] = await this.db
.update(apikeys)
.set({ enabled, updatedAt: new Date() })
.where(eq(apikeys.id, id))
.returning({ id: apikeys.id, enabled: apikeys.enabled });
if (!row) throw new NotFoundException("API anahtarı bulunamadı");
return row;
}
private async resolveUser(input: { userId?: string; email?: string }) {
if (!input.userId && !input.email) {
throw new BadRequestException("userId veya email gerekli");
}
const [user] = await this.db
.select({ id: users.id, email: users.email })
.from(users)
.where(input.userId ? eq(users.id, input.userId) : eq(users.email, input.email as string))
.limit(1);
if (!user) throw new NotFoundException("Kullanıcı bulunamadı");
return user;
}
}