npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@restomenum/plugin-sdk

v3.2.0

Published

Restomenum eklenti platformu için resmi geliştirici SDK'sı — webhook imza doğrulama, OAuth token exchange, Callback API istemcisi, event/scope katalogu, tipler.

Readme

@restomenum/plugin-sdk

Restomenum eklenti platformu için resmi TypeScript geliştirici SDK'sı. Webhook imza doğrulama, OAuth token exchange, tipli Callback API istemcisi, event/scope katalogu ve tipler — tek pakette.

Node 20+ (global fetch). Sıfır runtime bağımlılığı (yalnız node:crypto).

Kurulum

npm install @restomenum/plugin-sdk

Webhook imza doğrulama

İmza ham gövde üzerinden doğrulanır (JSON.parse edilmiş değil), ±5 dk replay penceresi, timing-safe.

import { verifyWebhookSignature } from '@restomenum/plugin-sdk';

// Express (ham gövdeyi sakla: app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })))
const ok = verifyWebhookSignature(req.rawBody.toString('utf8'), req.headers['x-restomenum-signature'], webhookSecret);
if (!ok) return res.status(401).json({ error: 'invalid_signature' });

⚠️ Idempotency zorunlu — retry penceresi 31 sn'den ~11,4 saate çıktı (⏳ şu an yalnız sandbox; production kuyruğu hâlâ eski ayarda). Kuyruk 6 deneme · 1 sn backoff (1+2+4+8+16 = 31 sn) yerine 20 deneme · 10 sn → 3600 sn ile çalışıyor (+ 24 saatlik sert tavan). Yani aynı olay 20 kez teslim edilebilir ve iki teslim arasında saatler geçebilir → zarftaki id ile dedup şart. 31 saniyelik pencere bu ihtiyacı pratikte gizliyordu; artık gizlemiyor. (Karşılaştırma: Shopify 8 deneme / 4 saat, Square 11 / 24 saat, Stripe 3 gün.)

Express middleware (hazır)

import { expressWebhook } from '@restomenum/plugin-sdk';

app.post('/webhook', express.json({ verify: (req, _r, buf) => { (req as any).rawBody = buf; } }),
  expressWebhook(
    { getSecret: (tenantId) => store.webhookSecretFor(tenantId) },   // senkron/async
    async (envelope) => {
      // imza + şekil doğrulandı; envelope = { id, type, version, environment, tenantId, occurredAt, data }
      if (envelope.type === 'table.created') await handle(envelope.data);
      // yanıt yazmazsan otomatik 200 { ok:true }
    },
  ),
);

Tipli event işleme (cast yok)

verifyAndParseWebhook'a tip parametresi vermezsen ayırt edilebilir birlik döner: type ile daraltınca data otomatik doğru payload tipine iner.

const envelope = await verifyAndParseWebhook(rawBody, sig, {
  // Kurulum kaydı (tenantId + environment) ile aranır — secret'lar ortam başına AYRIDIR.
  getSecret: (tenantId, environment) => store.find(tenantId, environment)?.webhookSecret,
});
if (!envelope) return res.sendStatus(401);

switch (envelope.type) {
  case 'packet.created':  envelope.data.packetId; break;   // ✓ Packet
  case 'table.updated':   envelope.data.changed;  break;   // ✓ TableUpdatedPayload
  case 'packet.reopened': envelope.data.reopenedFrom; break; // ✓ PacketReopenedPayload
  case 'app.installed':   envelope.data.scopes;   break;   // ✓ Scope[]
  case 'customer.redact': envelope.data.customerId; break; // ✓ CustomerRedactPayload
  default: break;                                          // tanımadığın yeni tip → 200 dön
}

Eşleme EventPayloadMap ile export edilir (EventPayload<'packet.created'>Packet). Payload tipini kendin vermek istersen eski davranış durur: verifyAndParseWebhook<MyType>(…).

Katalog guard'ları simetriktir: isEventType · isLifecycleType · isAnyEventType (+ AnyEventType).

İleriye dönük zarf alanları (SDK sürümü beklemeden)

parseEnvelope tanınan alanları doğrulayıp normalize eder; tanımadığı üst-seviye alanları olduğu gibi taşır. Platform zarfa yeni bir alan eklediğinde (örn. sequence) onu yeni bir SDK sürümü beklemeden okuyabilirsin — gövde imzalı olduğu için taşınan alan da özgündür.

envelope.sequence;              // ✓ tipli (tanınan alan)
envelope['brandNewField'];      // ✓ unknown olarak taşınır — kullanmadan önce tipini daralt
  • Aynı kural parseCapabilityRequest (sağlayıcı ucu) için de geçerlidir.
  • Geçersiz bir tanınan alanın ham hâli sızmaz (örn. whitelist dışı actor.type → alan hiç konmaz); __proto__/constructor/prototype anahtarları hiçbir koşulda taşınmaz.
  • ⚠️ 3.0.0 öncesi sürümler zarfı sabit bir listeden kuruyordu: actor, origin, sequence, sequenceScope düşüyordu. Bu alanları kullanıyorsan 3.0.0+'a yükselt.

Hesap yaşam döngüsü (*.updated · *.reopened · *.deleted)

Bir masa/paket hesabı açıldıktan sonra başına gelen her şey altı olayla bildirilir. Gövde upsert'tir (hesabın tam hâli, delta değil) ve zarf atıf alanını data'nın dışında taşır — actor (kim yaptı). (origin/deviceId sözleşmeden çıktı; bkz. aşağıdaki atıf maddesi.)

import { accountKey, isOwnEcho, DELETE_REASON_TARGETS } from '@restomenum/plugin-sdk';

switch (envelope.type) {
  case 'table.updated':
  case 'packet.updated':
    // ⚠️ İLK: kendi yazdığın olay da sana gelir → if (isOwnEcho(envelope, MY_PLUGIN_ID)) return;
    // data.changed YALNIZ filtre ipucudur (null ve yeni değerler mümkün) — olayı her hâlükârda işle.
    store.set(accountKey(envelope.data), envelope.data);   // kopyanı TAMAMEN değiştir
    break;

  case 'table.reopened':
  case 'packet.reopened':
    // ⚠️ YENİ SATIŞ DEĞİL: kapanışta kestiğin kaydı storno et, hesabı tekrar açık say.
    // Zincir reopenedFrom.saleId üzerinden kurulur (= KAPANIŞ KAYDININ kimliği).
    // Kendi kopyanın anahtarı değişmez: accountKey uuid'yi kullanır, tableId'nin yeniden
    // adlandırılması (masa-904 → masa-904*) anahtarı BÖLMEZ.
    if (envelope.data.reopenedFrom) await ledger.reverse(envelope.data.reopenedFrom.saleId);
    break;

  case 'table.deleted':
  case 'packet.deleted':
    // Hesap KAPANMADAN devredildi (birleştirme / cari hesaba aktarma) — ciroyu ikinci kez yazma.
    if (envelope.data.movedTo) await ledger.transfer(accountKey(envelope.data), envelope.data.movedTo);
    break;
}
  • ⚠️ 3.1.0 davranış değişikliği — accountKey() artık uuid döndürür. Önceki sürümlerde tableId/packetId dönüyordu; caret bağımlılıkla (^3.0.0) bu sürüm otomatik gelir, yani store anahtarların değişir. Kayıtlarını uuid'ye taşı (aşağıdaki sebep) ya da eski anahtarı kendi tarafında data.tableId ?? data.packetId ile üret.
  • ⚠️ Satışın kimliği uuid'dir, tableId/packetId DEĞİL. O alanlar adrestir ve tek satış içinde biçim değiştirir: masa-904 → geri açmada masa-904* → kapanışta kapanış kaydının kimliği. Sahada ölçüldü: tableId ile anahtarlayan bir mali eklentide aynı masanın kayıtları üç ayrı anahtara dağıldı (siparişler bir anahtarda, fiş başka anahtarda). uuid açılış · düzenleme · kapanış · geri açmada sabittir; accountKey(data) bunu zaten uuid önceliğiyle çözer (uuidsaleId → eski dokümanlarda tableId/packetId).
  • Sıra garanti edilmez → zarftaki sequence + sequenceScope ile bayat olayı ele (aşağıdaki "Satış sırası"); alanlar gelmiyorsa occurredAt karşılaştır.
  • ⚠️ Echo koruması SENDE: Callback API ile yaptığın değişikliğin olayı sana da teslim edilir — platform kaynağa göre eleme yapmaz (Stripe/Shopify/Slack/GitHub ile aynı). Handler'ının ilk satırında isOwnEcho(envelope, MY_PLUGIN_ID) ile ele; id dedup'u bunu yapmaz (echo farklı id'li yeni bir olaydır) ve atlarsan sonsuz döngüye girersin.
  • Atıf: actor = kim (type, userId?, role?, pluginId?). ⚠️ userId/role doğrulanmaz (kasada PIN ile seçilir) → denetim izi olarak yaz, yetki kararında kullanma; doğrulanmış tek alan pluginId'dir. origin (deviceId) sözleşmeden çıktı — platform göndermiyor (@deprecated, sonraki majörde kaldırılacak): mali/kasa atfını buna dayandırma. Kasa/terminal kimliği senin beyanındır (fiscal.deregisterId) ve personel/cihaz → kasa eşlemesi eklentinin kendi ayarlarındadır.
  • Kapsam: altısında da orders:read; müşteri alanları ve serbest metin için ek olarak customers:read + rıza. Satır metadata'sı rıza olmadan da kalır.

Yardımcılar: isOwnEcho (döngü koruması — zorunlu) · accountKey · DELETE_REASON_TARGETS.

Gövdedeki kimlik/tutar alanları: uuid (satış kimliği — ömür boyu sabit) · cancelPayments[] (iptal edilen tahsilatlar; payments[] ile birebir aynı şekil, paid'e dahil değil) · currency · amountExponent.

  • ⚠️ Kırıcı — satır ondalık tutarları *Decimal sonekine geçti (⏳ sandbox): discountdiscountDecimal, extraextraDecimal, lineTotallineTotalDecimal. Aynı ad iki farklı para ölçeğinde yaşıyordu (extra ondalık, amounts.extra minor unit) ve kökteki amountExponent: 2'yi görüp satırdaki discount'ı 100'e bölen entegratör 12,60 ₺ yerine 0,126 ₺ yazardı. Eski adlar kaldırıldı; amounts minor unit'in tek otoritesi. Kapsam: orders[], cancels[], lineChanges[].before/after, customer.order_added + okuma uçları. Kapsam dışı: kök total/paid/totalDiscount ve payments[].amount ondalık kalır (minor-unit ikizleri yok).
  • table.close_aborted · packet.close_aborted — gate onayından sonra düşen kapanış (TableCloseAbortedPayload / PacketCloseAbortedPayload): iki kanal, tek sözleşme (aynı abortReason sözlüğü — CLOSE_ABORT_REASONS, aynı sıra kuralı). Gördüğün an kestiğin fişi storno et; ⚠️ iptal kapanışın tersi değildir: hat sürer (sequence artmaz) → defterini kapatma.
  • ⏳ Yeni olay packet.status_changed — teslimat statüsü geçişi (PacketStatusChangedPayload): gövde packet.updated ile aynı + status / previousStatus (bilinmiyorsa null, uydurulmaz). ⚠️ Bu olayda zarf actor taşımaz (Firestore trigger yayınlar) → isOwnEcho daima false. Statü değişmeyen yazımlarda olay çıkmaz.

Tipler: TableUpdatedPayload · TableReopenedPayload · TableDeletedPayload (+ paket karşılıkları), PacketStatusChangedPayload · PACKET_STATUSES · TableCloseAbortedPayload · PacketCloseAbortedPayload · CLOSE_ABORT_REASONS, Actor · Origin · ActorRole · ChangedReason · DeleteReason · MovedTo · ReopenedFrom · CancelledLine. Ek yardımcı: isAccountLifecycleEvent.

Satış sırası (sequence + sequenceScope)

Teslim sıralı değildir: retry, kuyruk ve elle yeniden teslim yüzünden eski bir snapshot yeniden gelebilir. Zarftaki sequence (satışın kaçıncı durum değişikliği) ve sequenceScope (sq_ önekli opak satış hattı kimliği) bunu ayırt eder — occurredAt yetmez (saat kayması + aynı ms'de iki yazım mümkündür). İkisi de opsiyoneldir; gelmezlerse bugünkü davranışını sürdür.

import { sequenceKey, sequenceVerdict, advanceSequenceCursor, isSaleSequenceEvent } from '@restomenum/plugin-sdk';

if (isSaleSequenceEvent(envelope.type)) {
  const key = sequenceKey(envelope.tenantId, envelope);        // (tenantId, sequenceScope) — TEK SLOT YASAK
  const cursor = key ? await ledger.get(key) : null;
  switch (sequenceVerdict(envelope, cursor)) {
    case 'stale':    return;                                    // daha küçük numara → bayat
    case 'terminal': return;                                    // satış sonlanmış; *.updated onu diriltmez
    default: break;                                             // 'apply' | 'unsequenced' → işle
  }
  if (key) await ledger.set(key, advanceSequenceCursor(cursor, envelope));   // ≥7 gün sakla
}
  • Defteri (tenantId, sequenceScope) ile anahtarla. Tek slotlu ("yalnız son gördüğüm scope") tasarım yanlıştır: yeni satış başladıktan sonra gelen geç bir eski-satış olayı kabul edilir.
  • Eşit sequence sıra bilgisi taşımaz → tekrar teslimi zarf id'si ile dedup et; eşitlikte son gelen kazanır.
  • Terminal olay eşit numarada bile terminaldir: *.closed · *.cancelled · *.deleted · *.closed_deleted satışı sonlandırır; yalnız *.reopened geri açar (TERMINAL_SALE_EVENTS).
  • Defteri terminal olaydan hemen sonra silme (öneri: 7 gün) — geç gelen bir ilk teslim id dedup'una takılmaz ve "scope defterde yok → kabul" yolundan satışı diriltir.
  • Numara atlaması normaldir: sequence satış düzeyi sayaçtır, "kaç olay aldım" sayacı değil.
  • ⚠️ Alan yoksa sequence: 0 varsayma — yokluk "sıra bilinmiyor" demektir.

Yardımcılar: sequenceVerdict · advanceSequenceCursor · sequenceKey · isSaleSequenceEvent · isTerminalSaleEvent · isReopenSaleEvent. Tipler: SaleSequenceCursor · SequenceVerdict.

Tutarlar (amounts — tamsayı minor unit)

Satır tutarları artık bileşen kırılımıyla da geliyor: belge kökünde currency (ISO-4217) + amountExponent, satırda amounts: { base, options, extra, discount, timer, gross, net, vat } (karışık KDV oranlı satırda ayrıca perVat[]). Garantiler bit-bit tutar: base + options + extra − discount + timer === gross ve net + vat === gross.

import { minorToDecimal, sumLineAmounts, vatBreakdown } from '@restomenum/plugin-sdk';

const { currency, amountExponent, orders } = envelope.data;      // ör. "TRY", 2
const totals = sumLineAmounts(orders);                            // null → ondalık `total`'a geri düş
if (totals && amountExponent !== undefined) {
  console.log(minorToDecimal(totals.gross, amountExponent), currency);   // 129 TRY
}
const buckets = vatBreakdown(orders);   // [{ rate: 19, gross, vat, net }, …] — yüksek oran önce
  • ⚠️ amountExponent YALNIZ amounts.* içindir. lineTotal, total, paid, payments[].amount, options[].price ondalıktır ve değişmemiştir — üssü onlara uygularsan 100 kat sapma alırsın.
  • ⚠️ Özet uçlarında (tables/open, packets/open) minor-unit alan olmadığı için amountExponent hiç gönderilmez; yalnız currency gelir.
  • ⚠️ timer satırı zamanla BÜYÜR (dakika-bazlı ürün): hiçbir durum değişikliği olmadan iki olay arasında gross artar — "değer düştü → storno" mantığı kuruyorsan bu satırları ayrı ele al.
  • perVat yalnız karışık oranlı satırda gelir; varken üst düzey vat/net kırılımdan türetilmiştir. options[].vatRate: null = dondurulmamış → satırın vatRate'ine düş.
  • Para birimi çözülemezse üç alan da hiç konmaz (satır bozuksa yalnız o satırda amounts düşer).

Yardımcılar: minorToDecimal · parseLineAmounts · lineAmountsBalanced · sumLineAmounts · vatBreakdown. Tipler: LineAmounts · VatBucket · AmountLine.

Satır deltası (lineChanges)

table.updated / packet.updated gövdesi satır düzeyi delta taşır: hangi kalem değişti, ne kadarı iptal edildi, hangi yeni satır hangisinin devamı. Alan opsiyoneldir — yalnız satır düzeyinde bir şey değiştiğinde konur (ödeme eklenmesinde gelmez), bu yüzden ?? [] ile oku.

import { isKnownLineChangeOp } from '@restomenum/plugin-sdk';

for (const change of envelope.data.lineChanges ?? []) {   // alan yoksa döngü hiç dönmez
  if (!isKnownLineChangeOp(change.op)) continue;          // sözlük büyüyebilir → tanımadığını ATLA
  switch (change.op) {
    case 'created':
      // ⚠️ SOY ALANI VARSA YENİ SATIŞ DEĞİL: splitFrom (aynı satışta bölündü) /
      //    movedFrom (başka satıştan geldi) → önceki kaydının DEVAMI, storno YAZMA.
      if (change.splitFrom || change.movedFrom) ledger.continue(change); else ledger.open(change);
      break;
    case 'updated':   ledger.adjust(change.before, change.after); break;
    case 'cancelled': ledger.reverse(change.lineId, change.quantity); break;  // ⚠️ YALNIZ bu adet
    case 'moved_out': ledger.transfer(change.lineId, change.movedTo); break;  // ⚠️ iade DEĞİL, devir
  }
}
  • ⚠️ Optimizasyondur, kaynak değildir. Gerçeğin kaynağı orders[] tam durumudur; blok gelmezse/eksikse tam durumdan yakınsamaya devam et. Delta'yı uygulama, doğrula.
  • ⚠️ cancelled adet kapsamlıdır: quantity iptal edilen adettir, aynı lineId azaltılmış adetle hâlâ aktif olabilir — satırı komple silme.
  • before/after şekli orders[] satırıyla birebir aynıdır (yeni tip öğrenmene gerek yok).
  • Sıra yoktur: sequence satış düzeyindedir; tek mutasyondan çıkan tüm girdiler aynı numaraya aittir, aralarından zamansal sıra çıkarma.
  • deleted op'u yoktur — bir satır ya iptal edilir ya devredilir; mali işlemleri zıttır.

Tipler: LineChange · LineChangeOp (genişletilebilir) · KnownLineChangeOp · LineMovedFrom · LINE_CHANGE_OPS · isKnownLineChangeOp.

Test zarfı üretimi

Zarfı elle kurmak yerine — zorunlu alan (özellikle environment) atlanmasın:

import { signTestEnvelope } from '@restomenum/plugin-sdk';

const { rawBody, signature } = signTestEnvelope(
  { type: 'table.created', data: tableFixture, tenantId: 'tnt_test' }, // data TİPLİ denetlenir
  webhookSecret,
);
// rawBody'yi OLDUĞU GİBİ geçir — imza ham baytlar üzerinden doğrulanır.

environment alanı: her imzalı gövdede (event webhook, lifecycle, type:"hook" gate, type:"action", type:"capability") environment: "sandbox" | "production" bulunur — teslimin test/dev mağazasından mı gerçek mağazadan mı geldiğini söyler. Aynı değer X-Restomenum-Environment header'ında da vardır ama o kolaylık kopyasıdır: header imzaya dahil değildir ve gövdeyi kuyruğa atıp sonra işleyen tasarımlarda kaybolur; header değeri zaten gövdeden okunur (ikisi ayrışamaz) — daima gövdedeki alanı kullan.

OAuth token exchange

import { exchangeCode } from '@restomenum/plugin-sdk';

// /connect?code=…&environment=production&state=…  → environment'ı GELEN değerden al
const cred = await exchangeCode(
  { code, clientId, clientSecret },
  { environment },            // ← sabit ortam GÖMME (veya { baseUrl })
);
// cred = { tenantId, apiKey, webhookSecret, scopes }  → tenant + ortam başına SAKLA

⚠️ Ortamı /connect'ten al. Platform connectUrl'ine code, environment, state parametreleriyle gelir. environment (sandbox | production) hangi API kökünün geçerli olduğunu belirler; tek bir ortama sabitlenmiş kod production kurulumunda token takasını yanlış köke gönderir. Aynı tenantId iki ortamda birden kurulu olabilir → install kaydını tenantId + environment ile anahtarla (her kurulumun webhookSecret'ı ayrıdır).

⚠️ clientId = eklentinin pluginId'si (portal UUID) — portalda eklenti detayında client_id olarak gösterilir. Slug değildir; slug gönderirsen token ucu 401 invalid_client döner.

exchangeCode, token yanıtının { success:true, data:{…} } zarflı ve düz (zarfsız) şekillerinin ikisini de karşılar. Kendi fetch'ini yazıyorsan sen de karşıla: const d = body.data ?? body; — yalnız düz gövde varsayan kod alanları undefined okur ve canlıda patlar.

Callback API istemcisi (tipli)

import { RestomenumClient } from '@restomenum/plugin-sdk';

const client = new RestomenumClient({ apiKey: cred.apiKey, environment: 'production' });

const packets = await client.packets.open();
const packet  = await client.packets.get('packetId');
const products = await client.products.list();          // { data, truncated?, total? }
const customer = await client.customers.get('custId');  // PII consent kuralı sunucuda uygulanır

// Müşteri listesi — cursor pagination (offset YOK):
for await (const c of client.customers.listAll(200)) {
  // tüm müşteriler, sayfa sayfa otomatik
}

// Dine-in masa AÇ (QR self-order / kiosk) — tableId LAYOUT'tan gelmeli, uydurulamaz:
const [section] = await client.tables.layout();
const opened = await client.tables.create({
  tableId: section.tables[0].id,          // tanımsız/pasif masa → 400
  personCount: 4,                          // kuver hesabında kullanılır
  cart: [{ product: 'urun-abc123', quantity: 2 }],  // ops — boşsa siparişsiz açılır
  idempotencyKey: 'self-order-9f2c',       // retry'da yeni masa AÇMAZ (24sa)
});                                        // → { tableId, docNo, total } (total kuver DAHİL, otoriter)
// Masa zaten açıksa 409 → mevcut adisyona dokunulmaz; kalem eklemek için tables.updateOrders.
// Scope: orders:write + orders:read (layout). Eklenti masayı KAPATAMAZ.

Kaynak grupları: packets, tables, products, categories, paymentMethods, ingredients, users, customers.

Hata yönetimi

REST hata kodları ApiError'a çevrilir; OAuth hataları OAuthError; imza SignatureError.

import { ApiError } from '@restomenum/plugin-sdk';
try {
  await client.packets.get('yok');
} catch (e) {
  if (e instanceof ApiError) {
    console.log(e.status, e.code);          // 404, "plugin.packets.notFound"
    if (e.status === 429) wait(e.retryAfterSec); // Retry-After (sn)
  }
}

Action (buton/hook) yanıtı

import { actionResponse } from '@restomenum/plugin-sdk';
res.json(actionResponse(true, 'İşlem tamam', { level: 'success', display: 'toast' }));

Gate (before-hook) yanıtı + fişe ek alanlar

Gate isteği aynı uca type:"hook" ile düşer ve aynı HMAC imzasını taşır. Yanıt allow/deny'dir; kapanış gate'lerinde (table.close / packet.close) allow ile birlikte receiptExtras dönerek fişe basılacak alanları (mali blok, belge no, sadakat satırı) kapanış yazılmadan önce adisyona işletebilirsin — ayrı bir API çağrısı yok.

import { gateResponse, receiptExtra } from '@restomenum/plugin-sdk';

// Mali belge kesildi → karar + fiş alanları AYNI yanıtta:
res.json(gateResponse('allow', {
  receiptExtras: [
    receiptExtra('tse.qr', qrPayload, { type: 'qr' }),                        // ≤2000 karakter
    receiptExtra('tse.txNumber', String(txNumber), { label: 'Beleg-Nr' }),    // value HER ZAMAN string
  ],
}));

// Belge KESİLMEDİ → deny; receiptExtras gönderilse bile helper düşürür (platform da yazmaz):
res.json(gateResponse('deny', { message: 'Ödeme TSE ile eşlenemedi' }));

tse.* namespace'i sahiplenilmiştir: yalnız capability:invoice.issue:provide yetkisi olan eklenti yazabilir (requiredScopeForReceiptExtraKey ile kontrol edin), yetkisiz key sessizce düşer ve verified işaretini almaz. Bir key bir kez yazıldıysa üzerine yazılmaz. Sınırlar: RECEIPT_EXTRA_LIMITS.

Eklentiler-arası mesajlaşma (messaging.send)

Tenant'ın bağladığı mesaj sağlayıcısı (WhatsApp/SMS eklentisi) üzerinden mesaj gönder — sağlayıcının kimliğini bilmezsin; platform yönlendirir. to tipli birliktir — tam olarak biri: { customerId } (Cari/CRM müşteri) veya { packetId } (paket/teslimat müşterisi, walk-in dahil). Ham telefon YASAK; telefonu sağlayıcı gönderim anında resolveRecipientPhone ile çözer (rehber saklanmaz). idempotencyKey zorunlu; status:"accepted" teslim DEĞİLDİR.

// TÜKETİCİ (scope: messaging:send) — bağlı sağlayıcı üzerinden gönder:
const r = await client.messaging.send({
  to: { customerId },                            // veya { packetId } — teslimat siparişine SMS
  text: 'Rezervasyonunuz onaylandı',
  idempotencyKey: `rez-${rezId}-onay`,          // timeout'ta AYNI key ile retry (çift mesaj koruması)
});
// r = { requestId, status: 'accepted'|'sent'|'failed', providerMessageId?, idempotentReplay? }

// SAĞLAYICI (scope: messaging:provide) — actionUrl'e gelen imzalı type:"capability" POST'u işle:
import { verifyAndParseCapability, capabilityResponse, resolveRecipientPhone } from '@restomenum/plugin-sdk';
import type { MessagingSendPayload, MessagingStatus } from '@restomenum/plugin-sdk';
const req = await verifyAndParseCapability<MessagingSendPayload>(rawBody, headers['x-restomenum-signature'], {
  getSecret: (tenantId) => installStore.find(tenantId)?.webhookSecret,  // imza webhook ile AYNI şema
});
if (!req) return res.status(401).json({ error: 'invalid_signature' });
// ZORUNLU dedupe: aynı req.requestId tekrar gelirse mesajı YENİDEN GÖNDERME (önceki yanıtı dön).
// TEK RESOLVER: opak to → telefon (customerId→customers.get, packetId→packets.get; PII yetkisi yoksa null).
const phone = await resolveRecipientPhone(client, req.payload.to);
if (!phone) return res.json(capabilityResponse<MessagingStatus>('failed', { error: { code: 'no_phone' } }));
res.json(capabilityResponse<MessagingStatus>('accepted', { providerMessageId }));

// SAĞLAYICI: upstream DLR gelince teslim durumunu raporla — platform yalnız istek sahibi
// tüketiciye hedefli `messaging.message.status` event'i teslim eder (broadcast yok):
await client.messaging.reportStatus({ requestId: req.requestId, status: 'delivered', providerMessageId });

// TÜKETİCİ: manifest events[] += 'messaging.message.status' → webhook'ta
// data: { requestId, status: 'sent'|'delivered'|'read'|'failed', providerMessageId?, error? }

Tam örnek: examples/sample-plugin /capability (sağlayıcı) + api/messaging/sendMessage.mjs (tüketici).

Eklentiler-arası yetenekler (capabilities)

messaging.send'in jenerik hali: tenant'ın bağladığı sağlayıcı eklenti üzerinden herhangi bir yeteneği çağır — client.capabilities.invoke(capabilityId, payload) (tüketici) + client.capabilities.reportStatus(capabilityId, report) (sağlayıcı asenkron durum raporu). messaging.send de bu yolla çağrılabilir (capability-özel client.messaging yardımcıları korunur). Payload tipi capability id'den TÜRETİLİRinvoke('invoice.issue', …) girdiyi InvoiceIssueInput'a, sonucu InvoiceIssueResult'a otomatik daraltır (elle <…> tip argümanı GEREKMEZ; yanlış payload derleme-zamanı yakalanır). idempotencyKey her istekte zorunlu.

Hata kodları — tam kod plugin.<errorPrefix>.<suffix>; errorPrefix capability id'nin İLK segmentidir (plugin.messaging / plugin.notify / plugin.invoice — ör. notify.staff için plugin.notify.noProvider, plugin.notify.staff.* DEĞİL). Suffix sabitleri CAPABILITY_ERRORS'ta (tip-güvenli guard): noProvider→424 (sağlayıcı bağlı değil; özelliği gizle, retry etme), providerUnavailable→503, timeout→504, duplicateInProgress/idempotencyKeyReused/selfTarget/providerChanged→409, suspended/consumerBlocked→403, rawPiiForbidden/invalidPayload/idempotencyKeyRequired→400, notFound→404 (bilinmeyen capability). providerChanged = belirsiz sonuçtan (timeout) sonra tenant sağlayıcıyı değiştirdi → çift-işlem koruması, bu key ile retry engellendi.

import { CAPABILITY_ERRORS, capabilityErrorCode, ApiError } from '@restomenum/plugin-sdk';

// notify.staff — personele bildirim (asenkron event YOK; sonuç senkron döner):
const n = await client.capabilities.invoke('notify.staff', {
  to: { role: 'manager' },                 // veya { userId } — ham personel PII taşınmaz
  title: 'Gün sonu raporu hazır',          // zorunlu, ≤200
  body: 'Rapor panelde görüntülenebilir.', // opsiyonel, ≤1000
  idempotencyKey: `report-${gun}-hazir`,   // ZORUNLU (çift bildirim koruması)
});                                        // n: NotifyStaffResult (tip otomatik)
// n = { requestId, status: 'accepted'|'sent'|'failed', providerMessageId?, idempotentReplay? }

// invoice.issue — e-fatura kes (nihai sonuç ASENKRON invoice.status event'iyle gelir):
try {
  const f = await client.capabilities.invoke('invoice.issue', {
    to: { customerId },                        // opsiyonel; OPAK referans (ham VKN/ad/adres YASAK)
    amount: { total: 14550, currency: 'TRY' }, // KURUŞ (integer) — 14550 = ₺145,50; asla float; ≤ MAX_SAFE_INTEGER
    items: [{ description: 'Lahmacun', quantity: 2, unitPrice: 6000 }], // unitPrice KURUŞ (≥0); 1–100 kalem
    reference: `packet-${packetId}`,           // opsiyonel dış referans (≤100)
    idempotencyKey: `packet-${packetId}-fatura`, // ZORUNLU (mükerrer kesim = mali risk)
  });                                          // f: InvoiceIssueResult (tip otomatik)
  // f = { requestId, status: 'accepted'|'issued'|'failed', providerMessageId?, idempotentReplay? }
} catch (e) {
  if (e instanceof ApiError && e.code === capabilityErrorCode('invoice.issue', CAPABILITY_ERRORS.noProvider)) {
    // 424: tenant fatura sağlayıcısı bağlamamış → butonu gizle, retry etme
  }
}

Sağlayıcı tarafı: verifyAndParseCapability capability-agnostiktir — request.capability'ye göre payload'ı daralt (<NotifyStaffPayload> / <InvoiceIssuePayload>), aynı requestId'yi DEDUPE et (aynı requestId → işi tekrar YAPMA, önceki providerMessageId'yi dön), capabilityResponse<Status>(…) dön. Senkron durum kümesi capability-özeldir (capabilityResponse<InvoiceIssueStatus>('issued', …) — messaging'de 'sent', invoice'da 'issued'):

import { verifyAndParseCapability, capabilityResponse } from '@restomenum/plugin-sdk';
import type { InvoiceIssuePayload, InvoiceIssueStatus } from '@restomenum/plugin-sdk';

const req = await verifyAndParseCapability<InvoiceIssuePayload>(rawBody, sigHeader, { getSecret });
if (!req) return res.status(401).json({ error: 'invalid_signature' });
if (await alreadyProcessed(req.requestId)) return res.json(await priorResponse(req.requestId)); // DEDUPE zorunlu
const providerMessageId = await issueInvoiceAtGib(req.payload);
res.json(capabilityResponse<InvoiceIssueStatus>('issued', { providerMessageId }));

// Nihai GİB sonucu dakikalar sonra geldiğinde ASENKRON raporla → yalnız istek sahibi tüketiciye event:
await client.capabilities.reportStatus('invoice.issue', { requestId: req.requestId, status: 'paid', providerMessageId });

invoice.status event'i (tüketici): manifest events[] += 'invoice.status' → webhook'ta data: { requestId, status: 'issued'|'paid'|'void'|'failed', providerMessageId?, error? } (InvoiceStatusEventData — şekil messaging.message.status ile aynı). Yalnız istek sahibi tüketiciye hedefli, at-least-once → zarf id ile dedupe et. Durumlar monotonik: issued→paid yalnız ileri; void/failed terminal. (notify.staff'ın asenkron event'i yoktur.)

Session Token (iframe Custom UI)

Custom UI sayfan Restomenum panelinde iframe olarak açılır. Frontend App Bridge'den kısa-ömürlü bir session token alır ve backend'ine taşır; backend verifySessionToken ile doğrular → hangi tenant + kullanıcının baktığını güvenle öğrenir. Token JWT HS256'dır, tenant'ın webhookSecret'ı ile imzalı, aud = pluginId.

Panel origin'leri (PANEL_ORIGINS): iframe'ini çerçeveleyen panel — DEV panel dahil — https://app.restomenum.com ve https://test-restomenu.web.app origin'lerinden açılır. Custom UI sayfanın HTTP yanıtı Content-Security-Policy: frame-ancestors ile ikisine de izin vermeli (header yok / '*' / 'none' → sürüm onayı reddedilir); App Bridge postMessage hedefi bu listeden pinlenmeli ve gelen event.origin bu listeyle doğrulanmalı. Liste SDK'dan gelir:

import { PANEL_ORIGINS } from '@restomenum/plugin-sdk';
// ['https://app.restomenum.com', 'https://test-restomenu.web.app']
res.setHeader('Content-Security-Policy', `frame-ancestors ${PANEL_ORIGINS.join(' ')}`);

⚠️ Üst pencerenin origin'ini document.referrer'dan tespit edemezsiniz: iframe referrerPolicy="no-referrer" ile yüklenir → referrer boştur. Hedefi bu sabit listeden pinleyin. Panel origin'i markaya göre değişir (ayrı markalar = ayrı origin'ler); birden fazla markada çalışacaksanız origin'leri dizi tutup gelen e.origin'i o listeye karşı doğrulayın.

// iframe (frontend) — App Bridge ile token al, backend'ine taşı:
//   const { data } = await bridgeCall('getSessionToken');   // data = { token, tokenType:"Bearer", expiresIn:120 }
//   fetch('/api/me', { headers: { Authorization: 'Bearer ' + data.token } });

// backend — SDK ile doğrula (imza/kripto SDK'da; kendin yazma):
import { verifySessionToken, SessionError } from '@restomenum/plugin-sdk';

try {
  const claims = await verifySessionToken(req.headers.authorization, {
    pluginId,                                                  // aud bununla eşleşmeli (cross-plugin reddi)
    getSecret: (tenantId) => installStore.find(tenantId)?.webhookSecret,
  });
  // claims.tenantId · claims.sub (userId) · claims.role ('manager' | 'staff')
} catch (e) {
  if (e instanceof SessionError) res.status(401).json({ error: e.reason }); // malformed | wrong_algorithm | invalid_issuer | audience_mismatch | invalid_signature | expired | not_yet_valid
}

Doğrular: alg=HS256 (alg-confusion/"none" reddi) · iss=restomenum · aud=pluginId · HMAC imza (timing-safe) · exp ZORUNLU (exp'siz/expired red) · iat ileri-tarih reddi. Token kısa ömürlü → her isteği backend'de doğrula. Tam örnek: examples/sample-plugin /ui + /api/me.

Dış link açma (App Bridge openUrl)

iframe sayfanızdan dış bir adrese (ödeme sayfası, rapor, yardım) gitmek için tek yol App Bridge'in openUrl action'ıdır — window.open ve <a target="_blank"> çalışmaz (iframe sandbox'ında allow-popups yok).

const PANEL_ORIGIN = 'https://app.restomenum.com'; // panelin gömüldüğü origin

function openUrl(url) {
  return new Promise((resolve) => {
    const requestId = 'openUrl-' + Date.now();
    function onMessage(e) {
      if (e.origin !== PANEL_ORIGIN) return;
      const m = e.data;
      if (!m || m.type !== 'restomenum-bridge-response' || m.requestId !== requestId) return;
      window.removeEventListener('message', onMessage);
      resolve(m.result);
    }
    window.addEventListener('message', onMessage);
    window.parent.postMessage(
      { type: 'restomenum-bridge', requestId, action: 'openUrl', params: { url } },
      PANEL_ORIGIN,
    );
  });
}

const res = await openUrl('https://ornek.com/rapor');
// { success: true,  data: { opened: true } }   → kullanıcı onayladı
// { success: true,  data: { opened: false } }  → kullanıcı iptal etti
// { success: false, message: 'openUrlDenied' } → URL doğrulamayı geçemedi
  • Panel, açmadan önce kullanıcıya hedef adresi gösteren bir onay dialogu çıkarır; promise o cevaplanana kadar bekler → çağrıya hiç timeout koymayın (uzun bir timeout da değil). Aynı kural resolve ve close için de geçerlidir — hepsi kullanıcı etkileşimi bekler.
  • URL mutlak http(s) ve en fazla 2048 karakter olmalı; javascript:, data:, relative yol vb. openUrlDenied döner.
  • PANEL_ORIGIN markaya göre değişir ve document.referrer'dan öğrenilemez (yukarıdaki uyarı).

Detay: https://dev.restomenum.com/docs/open-url

Çoklu dil (i18n)

Platformda kullanıcıya görünen metinler (mağaza listelemesinde eklenti adı/açıklaması; manifest'te nav[].label, pages[].title, buttons[].label, forms[].fields[].label) dil haritasıdır:

{ "tr": "Kurye Entegrasyonu", "en": "Courier Integration" }

| | | |---|---| | Desteklenen diller | tr (Türkçe) · en (English) · de (Deutsch) · ru (Русский) — LOCALES | | Taban dil | tr (DEFAULT_LOCALE) — zorunlu: dolu bir haritada tr yoksa portal kaydı reddeder | | Uzunluk (mağaza listelemesi) | ad 3–60, açıklama ≤280 karakter — her dil değeri için ayrı | | Bilinmeyen dil anahtarı | sessizce düşürülür (fr → yok sayılır) |

Fallback zinciriresolveLocalized(map, locale) platformun hedef çözümlemesini uygular (panel, portal, runtime ve DB aynaları aynı zinciri kullanır):

  1. map[locale] doluysa → o
  2. map[DEFAULT_LOCALE] (tr) doluysa → o
  3. LOCALES sırasındaki (tr, en, de, ru) ilk dolu değer — deterministik, JSON anahtar sırası değil
  4. ''

Boş / yalnızca boşluk içeren değer "o dilde çeviri yok" sayılır ve bir sonraki halkaya düşülür; dönen metin trim'lidir.

import { resolveLocalized, isLocale, LOCALES, LOCALE_LABELS, DEFAULT_LOCALE } from '@restomenum/plugin-sdk';
import type { Locale, LocalizedText } from '@restomenum/plugin-sdk';

const title: LocalizedText = { tr: 'Kurye Entegrasyonu', en: 'Courier Integration' };

resolveLocalized(title, 'en');   // 'Courier Integration'
resolveLocalized(title, 'de');   // 'Kurye Entegrasyonu'  ← tr fallback (de çevirisi yok)
resolveLocalized(title, 'fr');   // 'Kurye Entegrasyonu'  ← desteklenmeyen dil de tr'ye düşer
resolveLocalized({ en: 'Only EN' }, 'de'); // 'Only EN'   ← tr yoksa LOCALES sırasındaki ilk dolu

isLocale('de');                  // true   (tip daraltma: value is Locale)
isLocale('fr');                  // false
LOCALE_LABELS.ru;                // 'Русский'

iframe Custom UI'da kullanıcının dilini App Bridge getContext verir ({ serverId, pluginId, locale, refId }) — o locale'i doğrudan resolveLocalized'e geçir:

// bridge yanıtı: { serverId, pluginId, locale, refId }
const label = resolveLocalized(page.title, ctx.locale);

Yeni bir dil ancak LOCALES genişletildiğinde kabul edilir (SDK + portal + runtime + DB birlikte güncellenir). Bu paketteki liste platformun aynasıdır — kendi listenizi türetmeyin, LOCALES'i import edin.

Detay: https://dev.restomenum.com/docs/i18n

Katalog & tipler

import { EVENT_TYPES, SCOPES, PII_SCOPES, isPiiScope, isEventType } from '@restomenum/plugin-sdk';
import { LOCALES, DEFAULT_LOCALE, LOCALE_LABELS, isLocale, resolveLocalized } from '@restomenum/plugin-sdk';
import type { EventType, Scope, WebhookEnvelope, Customer, Packet, ActionRequest } from '@restomenum/plugin-sdk';
import type { Locale, LocalizedText, SessionTokenClaims } from '@restomenum/plugin-sdk';

İlgili

  • Dokümanlar: https://dev.restomenum.com/docs
  • OpenAPI spec: https://dev.restomenum.com/openapi.json
  • Örnek eklenti (starter): examples/sample-plugin — bu repo içinde, SDK'yı uçtan uca kullanır