5 · Session Token (iframe)

Custom UI sayfan iframe içinde açıldığında, hangi tenant'ın baktığını App Bridge'den aldığın session token ile öğrenirsin. Token'ı backend'inde webhookSecret ile doğrularsın; böylece iframe'den gelen istekler güvenle tenant'a bağlanır.

Akış

iframe sayfan (frontend)
   │  const { data } = await bridgeCall('getSessionToken');   // App Bridge (postMessage)
   │  // data = { token, tokenType: "Bearer", expiresIn: 120 }   — TTL 120 sn, önceden alıp bekletme
   │  fetch('/api/me', { headers: { Authorization: 'Bearer ' + data.token } })
   ▼
Senin backend'in
   1) token'ı webhookSecret ile doğrula (JWT HS256) — audience = pluginId ZORUNLU
   2) claims: { iss, aud, sub, role, tenantId, pluginId, environment, iat, exp }
        tenant = claims.tenantId · userId = claims.sub · role = claims.role (manager|staff)
        environment = claims.environment ("sandbox" | "production") ◄── HANGİ kurulum
   3) o tenant + kullanıcı için veriyi güvenle döndür

Frontend yalnız token'ı taşır; doğrulama backend'de.

App Bridge tel protokolü (bridgeCall'ı sen yazarsın)

bridgeCall bir SDK fonksiyonu değildir — iframe sayfanda sen yazarsın. Panelin tanıdığı tek mesaj şekli aşağıdadır; başka bir zarf (örn. { source, payload }) panel tarafından tanınmaz ve çağrın sessizce yanıtsız kalır.

YönMesaj şekli
istek
iframe → panel
{ type: "restomenum-bridge", requestId, action, params }
yanıt
panel → iframe
{ type: "restomenum-bridge-response", requestId, result }
result = { success, data?, message? }
// ── App Bridge TEL PROTOKOLÜ (panelin tanıdığı TEK mesaj şekli) ──
//   istek : { type: "restomenum-bridge",          requestId, action, params }
//   yanıt : { type: "restomenum-bridge-response", requestId, result }
//           result = { success, data?, message? }
// Başka bir şekil (örn. { source, payload }) panel tarafından TANINMAZ — sessizce yanıtsız kalırsın.

// Panel origin'i: sabit origin'ler VEYA offline/yerel kurulumda localhost/127.0.0.1 (DEĞİŞKEN port).
// ⚠️ postMessage targetOrigin JOKER KABUL ETMEZ ('https://localhost:*' → SyntaxError) ve üst pencerenin
// origin'i document.referrer'dan ÖĞRENİLEMEZ (iframe no-referrer ile yüklenir). Yerel panelde hedef
// ?panelOrigin= parametresinden okunur ve DOĞRULANIR (bkz. /docs/iframe-security).
const PANEL_ORIGINS = ["https://app.restomenum.com","https://test-restomenu.web.app"];
const LOCAL_PANEL = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d{1,5})?$/;
const isPanelOrigin = (o) => PANEL_ORIGINS.includes(o) || LOCAL_PANEL.test(o);   // SDK: isPanelOrigin

// Doğrulanmış hedef (yoksa null → sabit origin'lere pinli gönderim).  SDK: readPanelOrigin()
const rawPanelOrigin = new URLSearchParams(location.search).get("panelOrigin");
let panelOrigin = rawPanelOrigin && isPanelOrigin(rawPanelOrigin) ? rawPanelOrigin : null;

function bridgeCall(action, params) {
  return new Promise((res) => {
    const requestId = Math.random().toString(36).slice(2);
    function onMsg(e) {
      if (!isPanelOrigin(e.origin)) return;   // gelen origin doğrula (includes() YETMEZ — yerel port)
      const d = e.data;
      if (!d || d.type !== "restomenum-bridge-response" || d.requestId !== requestId) return;
      panelOrigin ??= e.origin;               // öğrenilen origin'i pinle (sonraki çağrılar buna gider)
      window.removeEventListener("message", onMsg);
      res(d.result);                          // { success, data?, message? }
    }
    window.addEventListener("message", onMsg);
    const msg = { type: "restomenum-bridge", requestId, action, params };
    // Hedef origin-pinli ('*' YASAK). targetOrigin eşleşmeyen gönderim teslim EDİLMEZ.
    if (panelOrigin) window.parent.postMessage(msg, panelOrigin);
    else PANEL_ORIGINS.forEach((origin) => window.parent.postMessage(msg, origin));
  });
}
// ⚠️ TIMEOUT KOYMA: openUrl / resolve / close kullanıcı etkileşimi bekler (panel onay dialogu
//    gösterir). Kısa bir timeout koyarsan kullanıcı onaylamadan "timeout" alırsın.

Aynı köprü tüm action'lar için geçerlidir (getContext, getSessionToken, resolve, close, resize, toast, openUrl) — gate akışındaki kullanımı iframe App Bridge'de.

Kullanıcı etkileşimi bekleyen çağrılara HİÇ timeout koyma. openUrl, resolve ve close panelde kullanıcıya bir onay dialogu gösterir; yanıt kullanıcı karar verene kadar gelmez. Bu "timeout'u uzun tut" demek değildir — kullanıcının ne zaman karar vereceği bilinemez, o yüzden timeout kullanma. requestId'yi eşleştir, gelen e.origin'i doğrula, hedefi pinle ('*' yasak).

Doğrulama — resmi SDK (önerilen)

@restomenum/plugin-sdk verifySessionToken tüm kontrolleri (HS256, aud=pluginId, iss=restomenum, HMAC timing-safe, exp zorunlu, iat ileri-tarih reddi) tek çağrıda yapar — imza/kripto kendin yazma:

import { verifySessionToken, SessionError } from '@restomenum/plugin-sdk';

// GET /api/me — iframe'in Bearer token'ını doğrula:
try {
  const claims = await verifySessionToken(req.headers.authorization, {
    pluginId,                                                  // aud bununla eşleşmeli
    // Kurulumu tenantId + environment ile ara — aynı tenant iki ortamda kurulu olabilir,
    // secret'lar ayrıdır (yalnız tenantId ile ararsan yanlış secret'ı seçebilirsin).
    getSecret: (tenantId, environment) => installStore.find(tenantId, environment)?.webhookSecret,
  });
  // claims.tenantId · claims.environment · claims.sub (userId) · claims.role ('manager' | 'staff')
} catch (e) {
  if (e instanceof SessionError) res.status(401).json({ error: e.reason });
}
Çalışan tam örnek (canlı doğrulanmış): SDK örnek eklentisinin /ui (App Bridge) + /api/me (verifySessionToken) uçları.

Referans — manuel doğrulama (SDK altında ne yapar)

// iframe Custom UI — App Bridge session token doğrulama (Node)
// Sayfan Restomenum içinde iframe olarak açılır; tenant kimliğini App Bridge'den alırsın:
//   const { data } = await bridgeCall('getSessionToken');  // App Bridge → { token, tokenType:"Bearer", expiresIn:120 }
//   fetch('/api/me', { headers: { Authorization: 'Bearer ' + data.token } });
// Backend'inde token'ı webhookSecret ile doğrula (JWT HS256) — veya resmi SDK: verifySessionToken (önerilen):
import jwt from 'jsonwebtoken';

function verifySessionToken(bearer, webhookSecret, pluginId) {
  const token = bearer.replace(/^Bearer /, '');
  // HS256, webhookSecret ile imzalı. Claim'ler (canlı token'dan teyitli):
  //   { iss, aud, sub, role, tenantId, pluginId, environment, iat, exp }
  //   sub = userId (iframe'i açan kullanıcı)   role = manager | staff
  //   environment = "sandbox" | "production" → HANGİ kurulum (secret'ı bununla çöz)
  // audience (= pluginId) doğrulaması ZORUNLU — başka eklentinin token'ı kabul edilmesin:
  const claims = jwt.verify(token, webhookSecret, { algorithms: ['HS256'], audience: pluginId });
  // per-user yetki: claims.role / claims.sub ile kur (ad/PII için users/get — users:read + consent)
  return {
    tenantId: claims.tenantId,
    environment: claims.environment,
    userId: claims.sub,
    role: claims.role,
    exp: claims.exp,
  };
}

// ⚠️ webhookSecret'ı ÇÖZERKEN ortamı da kullan: aynı tenantId hem sandbox hem production'da kurulu
//    olabilir ve secret'lar AYRIDIR. Doğru sıra: token'ı decode et (imzasız) → tenantId+environment
//    ile kurulumu bul → o kurulumun secret'ıyla imzayı DOĞRULA.
//    Resmi SDK verifySessionToken bunu yapar: getSecret(tenantId, environment).

Önemli noktalar

  • Token kısa ömürlüdür; her isteği backend'de doğrula, iframe'e güvenme.
  • İmza anahtarı tenant'ın webhookSecret'ıdır (token exchange'ten).
  • audience = pluginId doğrulaması ZORUNLU (jwt.verify(…, { audience: pluginId }') — başka eklentinin token'ı kabul edilmesin. exp'in geçmediğini de doğrula.
  • sub = userId (iframe'i açan kullanıcı), role = manager | staff → per-user yetkiyi bununla kur (ad/PII için users/get, users:read + consent).
  • environment (sandbox | production) — hangi kurulumun token'ı olduğunu söyler. Aynı tenantId iki ortamda birden kurulu olabilir ve her kurulumun webhookSecret'ı ayrıdır → imza anahtarını tenantId + environment ile çöz. Ayrıntı: Ortamlar.
Erişim modeli: Plugin UI/aksiyon kullanımı (/plugins/ui, bridge, action, hook) tüm tenant kullanıcılarına açıktır (yalnız eklenti yönetimi manager-only). Belirli işlemleri kısıtlamak senin işin — gövdedeki/token'daki actor.role ile uygula (örn. "iadeyi yalnız manager yapabilir").
Session token, iframe'i çerçeveleyen panelden gelir. Panel origin'leri (DEV panel dahil): https://app.restomenum.com + https://test-restomenu.web.app — postMessage yalnız bu origin'lerle yapılmalı. Sayfan mutlaka iframe güvenliği kurallarına uymalı (CSP frame-ancestors + origin-pinli postMessage).