feat(api): gömülebilir katalog widget'ı (Faz 2 POC)

- GET /widget/loader.js: müşterinin yapıştırdığı tek script — iframe kurar,
  postMessage köprüsü (yükseklik + sase:partClick/sase:decoded olayları),
  parça tıklamasını data-part-click-url şablonuna ({code},{brand}) yönlendirir,
  window.saseWidget.onPartClick/onDecode callback'leri; CORP cross-origin
  (helmet same-origin varsayılanı müşteri sitesinde script'i bloklardı)
- GET /widget?key=sase_pk_...: iframe shell — vanilla JS katalog uygulaması
  (VIN formu, çok-aday seçimi, kategori grid + breadcrumb, parça tablosu,
  şema görseli, açık/koyu tema). Domain kilidi = anahtarın metadata.domains
  listesinden üretilen frame-ancestors CSP'si (tarayıcı zorlar); publishable
  olmayan anahtar 403; X-Frame-Options bu cevapta kaldırılır
- internal-admin: publishable:true + domains[] ile sase_pk_ anahtar üretimi
- api.sase.tr kısa yolu: /widget* → /api/widget*

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 17:50:29 +03:00
parent acadd84e4a
commit ae61ac055e
5 changed files with 480 additions and 5 deletions

View File

@@ -37,6 +37,11 @@ export class ApiKeysAdminController {
* 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.
*
* `publishable: true` → widget anahtarı (sase_pk_): müşteri sayfasında
* görünür, /widget shell'i yalnız bunları kabul eder ve `domains`
* listesinden frame-ancestors CSP kilidi üretir. domains zorunludur
* (boş liste = hiçbir 3. parti sitede render olmaz, sadece test).
*/
@Post()
@HttpCode(HttpStatus.CREATED)
@@ -50,6 +55,8 @@ export class ApiKeysAdminController {
expiresInDays?: number;
rateLimitMax?: number;
test?: boolean;
publishable?: boolean;
domains?: string[];
},
) {
const user = await this.resolveUser(body);
@@ -60,19 +67,30 @@ export class ApiKeysAdminController {
) {
throw new BadRequestException("dailyDecodeLimit pozitif bir sayı olmalı");
}
if (body.publishable && body.domains !== undefined && !Array.isArray(body.domains)) {
throw new BadRequestException('domains bir dizi olmalı (ör. ["musteri.com"])');
}
if (body.domains && !body.publishable) {
throw new BadRequestException("domains yalnız publishable anahtarlarda kullanılır");
}
const metadata: Record<string, unknown> = {};
if (body.dailyDecodeLimit) metadata.dailyDecodeLimit = Math.floor(body.dailyDecodeLimit);
if (body.publishable) {
metadata.publishable = true;
metadata.domains = (body.domains ?? []).filter((d) => typeof d === "string" && d.trim());
}
const created = await getApiKeyApi().createApiKey({
body: {
userId: user.id,
name: body.name ?? `${user.email} public-api`,
prefix: body.test ? "sase_test_" : "sase_live_",
prefix: body.publishable ? "sase_pk_" : body.test ? "sase_test_" : "sase_live_",
...(body.expiresInDays ? { expiresIn: body.expiresInDays * DAY_SECONDS } : {}),
...(body.rateLimitMax
? { rateLimitEnabled: true, rateLimitTimeWindow: 60_000, rateLimitMax: body.rateLimitMax }
: {}),
...(body.dailyDecodeLimit
? { metadata: { dailyDecodeLimit: Math.floor(body.dailyDecodeLimit) } }
: {}),
...(Object.keys(metadata).length ? { metadata } : {}),
},
});
@@ -85,6 +103,7 @@ export class ApiKeysAdminController {
name: created.name,
expiresAt: created.expiresAt,
dailyDecodeLimit: body.dailyDecodeLimit ?? null,
...(body.publishable ? { publishable: true, domains: metadata.domains } : {}),
};
}