Gate iframe — resolve/close ZORUNLU ✓ Canlı

Bir hook'a iframe UI tanımladıysan (ör. table.close, packet.close), panel o iframe'i açıp BEKLER. Akışı devam ettirmek senin sayfanın sorumluluğundadır — sayfan App Bridge resolve/close çağırmazsa gate sonuçlanmaz, işlem iptal olur, kullanıcı tekrar denediğinde iframe yeniden açılır (sonsuz döngü gibi görünür). Bu bir bug değil — eksik resolve.

← Hook'lar · ilgili: Custom UI Sayfaları.

KRİTİK: Gate iframe'i sayfan resolve(formData) (devam) veya close() (iptal) çağırana kadar sonuçlanmaz. "iframe sürekli açılıyor / işlem ilerlemiyor" = sayfan bu çağrıları yapmıyor demektir.

Nerede geçerli / nerede DEĞİL

resolve/close zorunluluğu yalnız bir hook'un UI'sı CUSTOM IFRAME ise vardır. Diğer tüm yüzeyler etkilenmez:

UI yüzeyiresolve/close?Neden
Hook gate iframe (table.close, packet.close — UI'sı iframe)✅ EVETPanel açar + bekler; resolve(formData)/close ile sonuçlanmalı, yoksa tıkanır
Hook gate declarative form❌ HayırForm'u panel render eder, submit = resolve (sen kod yazmazsın)
packet.status.update❌ HayırSunucu-içi inline gate (panel iframe açmaz; webhook karar verir)
Aksiyon butonu (ui:button)❌ HayırSenkron POST → actionUrl; iframe handshake yok
Nav/ayar sayfası (ui:page)❌ HayırNormal iframe; menüden açılır, gate değil — resolve beklenmez
Webhook event (*.created vb.)❌ HayırAsync bildirim; UI yok
Özet: resolve/close = bir hook'un UI'sı CUSTOM IFRAME ise. Yani yalnız before-action gate iframe'leri (hook + iframe UI). Event'ler, action butonları, normal nav/ayar sayfaları ve declarative-form hook'lar etkilenmez.

Akış — Desen A (resolve formData)

1) Kullanıcı işlemi tetikler (ör. paketi Kapat) → panel GATE iframe'ini açar ve BEKLER (promise).
2) Sayfan App Bridge'den getContext ile bağlamı alır (hangi paket/masa: refId).
3) Kullanıcı sayfanda seçim yapar →
     resolve(formData) → iframe kapanır; panel topladığın formData ile plugins/hook'u POST eder →
                         webhook'un (actionUrl) { decision:'allow'|'deny', message } döner →
                         allow ise işlem DEVAM eder, deny ise mesajın panelde gösterilir.
     close()           → gate { allow:false } → işlem İPTAL edilir (webhook çağrılmaz).

resolve "kullanıcı etkileşimi bitti, veriler bunlar" demektir; nihai allow/deny kararını yine webhook'un verir (resolve'daki formData webhook'a aynen ulaşır). submit, resolve'un alias'ıdır.

İki desen var. Yukarıdaki Desen A ( resolve(formData) ile veri taşı) basit akışlar için yeterlidir. Kritik akışlarda Desen B (ÖNERİLEN) kullanın: veriyi iframe'inizden kendi backend'inize yazıp kararı webhook'ta kendi state'inizden verin (aşağıda).

App Bridge köprüsü (sayfana ekle)

function bridgeCall(action, params) {
  return new Promise((res) => {
    const requestId = Math.random().toString(36).slice(2);
    function onMsg(e) {
      const d = e.data;
      if (!d || d.type !== "restomenum-bridge-response" || d.requestId !== requestId) return;
      window.removeEventListener("message", onMsg);
      res(d.result);
    }
    window.addEventListener("message", onMsg);
    window.parent.postMessage({ type: "restomenum-bridge", requestId, action, params }, "*");
  });
}

// Sayfa açılınca bağlamı al (hangi kayıt?):
const ctx = await bridgeCall("getContext");   // → { serverId, pluginId, locale, refId }  (refId = paket/masa id)

// Kullanıcı onayladı → işleme DEVAM ET (params = formData; webhook'una aynen gider):
await bridgeCall("resolve", { courierCalled: true });   // veya { decision: "allow" } gibi KENDİ alanların

// Kullanıcı vazgeçti → işlemi İPTAL ET:
await bridgeCall("close");

App Bridge action'ları

Actionİş
getContext{ serverId, pluginId, locale, refId }refId = işlemin nesnesi (paket/masa id)
resolve (≡ submit)Etkileşim bitti → params = formData, panel webhook'unu çağırır (allow/deny webhook'ta)
closeİşlemi iptal et (gate { allow:false })
resize{ height: 480 } — iframe yüksekliği
toast{ message: "…" } — kullanıcıya kısa bilgi
getSessionToken{ token, tokenType:"Bearer", expiresIn:120 } — imzalı JWT (TTL 120 sn, fetch'ten hemen önce al; backend doğrulaması için — Desen B + Session Token)

bridgeCall cevapları { success, data } zarfındadır — örneklerde ctx/tok = result.data varsayıldı; köprü helper'ınız zarfı açıyorsa birebir kullanın.

Desen B: Kendi backend'inle doğrula (ÖNERİLEN)

formData client'ta toplanır; imza onu panel→size yolda korur ama içeriğini garanti etmez. packet.close gate'i sunucu-içinde de çağrılır — o çağrıda formData olmayabilir. Desen B'de kararınız formData'ya değil kendi DB'nize dayandığı için hook nereden çağrılırsa çağrılsın tutarlı cevap verirsiniz. (Shopify App Bridge + session token deseniyle birebir aynı.)

1) iframe sayfanız (customUiOrigin'inizde çalışır)

iframe → getSessionToken → kendi backend'iniz → resolve()
// bridgeCall: yukarıdaki App Bridge köprüsü. Cevaplar { success, data } zarfındadır → ctx/tok = result.data.
const ctx = await bridgeCall("getContext");
// → { serverId, pluginId, locale, refId }   (refId = gate hedefi: paket/masa doc id)

// Kullanıcı sayfanızda gerekli işlemi yapar (mutabakat onayı, kod girişi…) → fetch'ten HEMEN ÖNCE:
const tok = await bridgeCall("getSessionToken");
// → { token, tokenType: "Bearer", expiresIn: 120 }   — TTL 120 sn, önceden alıp bekletmeyin

await fetch("https://api.SIZIN-DOMAIN.com/gate-approvals", {
  method: "POST",
  headers: { "Authorization": "Bearer " + tok.token, "Content-Type": "application/json" },
  body: JSON.stringify({ refId: ctx.refId, approved: true /* + kendi alanlarınız */ }),
});

resolve();   // ← veri TAŞIMIYOR; yalnız "etkileşim bitti" sinyali. close() = iptal.

2) Backend'iniz — session token doğrula + state yaz

Session token, OAuth exchange'de aldığınız webhookSecret ile imzalı bir JWT'dir (HS256) — Restomenum'a sormadan lokal doğrularsınız.

verifySessionToken (HS256) + POST /gate-approvals
const crypto = require("crypto");

function verifySessionToken(token, webhookSecret, myPluginId) {
  const parts = String(token || "").split(".");
  if (parts.length !== 3) return null;
  const [h, p, s] = parts;
  const expected = crypto.createHmac("sha256", webhookSecret).update(h + "." + p).digest("base64url");
  const a = Buffer.from(s), b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
  const payload = JSON.parse(Buffer.from(p, "base64url").toString("utf8"));
  if (payload.iss !== "restomenum") return null;
  if (payload.aud !== myPluginId) return null;            // başka eklentinin token'ı işe yaramaz
  if (Math.floor(Date.now() / 1000) > payload.exp) return null;
  return payload; // { tenantId, sub: <userId>, role: "manager"|"staff", pluginId, iat, exp }
}

// POST /gate-approvals
app.post("/gate-approvals", (req, res) => {
  const claims = verifySessionToken(
    req.headers.authorization?.replace(/^Bearer /, ""), WEBHOOK_SECRET, MY_PLUGIN_ID);
  if (!claims) return res.status(401).end();
  // State: tenant + refId anahtarıyla, KISA TTL ile (ör. 10 dk) sakla
  db.set(`gate:${claims.tenantId}:${req.body.refId}`,
         { approved: true, by: claims.sub, role: claims.role, at: Date.now() },
         { ttlSec: 600 });
  res.json({ ok: true });
});
refId'yi client gövdesinden alıyorsunuz ama anahtarı claims.tenantId ile birleştiriyorsunuz — başka tenant'ın kaydını yazamaz. İsterseniz paketi packets/get?packetId= ile doğrulayın. TTL şart: "onay verildi" kaydı sonsuza yaşarsa saatler sonra alakasız bir kapanış da allow alır.

3) Webhook'unuz — kararı state'ten ver

hook handler: gate state → allow/deny
// actionUrl/webhookUrl handler'ınızda (imza doğrulaması sonrası):
if (body.type === "hook" && (body.event === "packet.close" || body.event === "table.close")) {
  const key = `gate:${body.tenantId}:${body.target.id}`;
  const state = await db.get(key);
  if (state && state.approved) return res.json({ decision: "allow" });
  return res.json({ decision: "deny", message: "Önce eklenti panelindeki doğrulamayı tamamlayın." });
}
  • İdempotent olun: aynı refId için hook birden çok kez gelebilir (panel + sunucu-içi gate, retry). State okuma idempotenttir; kaydı allow sonrası silmeyin (kısa TTL yetsin).
  • Karar bütçeniz timeoutMs (1–10 sn) — DB okuması hızlı olmalı; aşımda failMode uygulanır.

Sıra diyagramı

Personel "Kapat"a basar
  → panel gate iframe'inizi açar
      → iframe: getContext (refId) → kullanıcı işlemi → getSessionToken
      → iframe → SİZİN backend: POST /gate-approvals (Bearer sessionToken, refId)   ← veri SİZİN kanaldan
      → iframe: resolve()   ← sadece sinyal
  → panel/sunucu hook'unuzu çağırır: { event, target:{id: refId}, tenantId, ... }
      → backend'iniz: gate:{tenantId}:{refId} var mı? → { decision: allow/deny }
  → allow ise işlem yapılır; deny ise mesajınız personele gösterilir
Panel değişikliği gerekmez — Desen B'nin ihtiyaçları mevcut: getContext cevabında refId döner, getSessionToken her aktif kurulumda kayıtlıdır (rate-limit'li). Kozmetik nüans: getContext tenant kimliğini serverId adıyla döndürür (diğer yüzeylerde tenantId) — işlevsel etkisi yok, ikisini de kabul edecek şekilde okuyun.

Önemli notlar

  • Gate iframe vs normal sayfa: Yalnız bir hook'un UI'sı olarak açılan iframe resolve/close bekler. Menüden (ui:nav / ui:page) açtığın normal ayar/panel sayfaların gate DEĞİLDİR — orada resolve gerekmez.
  • Tıkanma belirtisi: "iframe sürekli açılıyor / işlem ilerlemiyor" → sayfan resolve/close çağırmıyor demektir.
  • Güvenlik: Panel yanıtı yalnız senin customUiOrigin'ine postalanır; gelen mesajda origin + kaynak pencere doğrulanır. Sayfanın kayıtlı domain'inde kalması yeterli (iframe Güvenliği).
  • Karar webhook'ta: İstersen resolve formData'sına decision gibi alanlar koyup webhook'unda ona göre allow/deny dönebilirsin; ya da webhook kendi kuralını uygular. Panel formData'yı yalnız taşır.

Hata → çözüm

BelirtiSebepÇözüm
Seçim sonrası akış devam etmiyor, iframe tekrar açılıyorSayfa resolve/close çağırmıyorOnayda resolve(formData), iptalde close() çağır
Webhook hiç çağrılmıyorclose çağrılmış (veya kullanıcı ✕ ile kapatmış) → iptalDevam için resolve kullan (close yalnız iptal)
deny döndün ama mesaj görünmüyorwebhook message alanı boş{ decision:'deny', message:'…' } döndür