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:
148
apps/api/src/internal-admin/api-keys.controller.ts
Normal file
148
apps/api/src/internal-admin/api-keys.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user