@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.
Maintainers
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ıznode:crypto).
Kurulum
npm install @restomenum/plugin-sdkWebhook 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
idile 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/prototypeanahtarları hiçbir koşulda taşınmaz. - ⚠️ 3.0.0 öncesi sürümler zarfı sabit bir listeden kuruyordu:
actor,origin,sequence,sequenceScopedüşü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ıkuuiddöndürür. Önceki sürümlerdetableId/packetIddö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ındadata.tableId ?? data.packetIdile üret. - ⚠️ Satışın kimliği
uuid'dir,tableId/packetIdDEĞİL. O alanlar adrestir ve tek satış içinde biçim değiştirir:masa-904→ geri açmadamasa-904*→ kapanışta kapanış kaydının kimliği. Sahada ölçüldü:tableIdile anahtarlayan bir mali eklentide aynı masanın kayıtları üç ayrı anahtara dağıldı (siparişler bir anahtarda, fiş başka anahtarda).uuidaçılış · düzenleme · kapanış · geri açmada sabittir;accountKey(data)bunu zatenuuidönceliğiyle çözer (uuid→saleId→ eski dokümanlardatableId/packetId). - Sıra garanti edilmez → zarftaki
sequence+sequenceScopeile bayat olayı ele (aşağıdaki "Satış sırası"); alanlar gelmiyorsaoccurredAtkarşı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;iddedup'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/roledoğrulanmaz (kasada PIN ile seçilir) → denetim izi olarak yaz, yetki kararında kullanma; doğrulanmış tek alanpluginId'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.de→registerId) 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 olarakcustomers:read+ rıza. Satırmetadata'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ı
*Decimalsonekine geçti (⏳ sandbox):discount→discountDecimal,extra→extraDecimal,lineTotal→lineTotalDecimal. Aynı ad iki farklı para ölçeğinde yaşıyordu (extraondalık,amounts.extraminor unit) ve köktekiamountExponent: 2'yi görüp satırdakidiscount'ı 100'e bölen entegratör 12,60 ₺ yerine 0,126 ₺ yazardı. Eski adlar kaldırıldı;amountsminor unit'in tek otoritesi. Kapsam:orders[],cancels[],lineChanges[].before/after,customer.order_added+ okuma uçları. Kapsam dışı: köktotal/paid/totalDiscountvepayments[].amountondalı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ıabortReasonsö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 (sequenceartmaz) → defterini kapatma. - ⏳ Yeni olay
packet.status_changed— teslimat statüsü geçişi (PacketStatusChangedPayload): gövdepacket.updatedile aynı +status/previousStatus(bilinmiyorsanull, uydurulmaz). ⚠️ Bu olayda zarfactortaşımaz (Firestore trigger yayınlar) →isOwnEchodaimafalse. 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
sequencesıra bilgisi taşımaz → tekrar teslimi zarfid'si ile dedup et; eşitlikte son gelen kazanır. - Terminal olay eşit numarada bile terminaldir:
*.closed·*.cancelled·*.deleted·*.closed_deletedsatışı sonlandırır; yalnız*.reopenedgeri açar (TERMINAL_SALE_EVENTS). - Defteri terminal olaydan hemen sonra silme (öneri: 7 gün) — geç gelen bir ilk teslim
iddedup'una takılmaz ve "scope defterde yok → kabul" yolundan satışı diriltir. - Numara atlaması normaldir:
sequencesatış düzeyi sayaçtır, "kaç olay aldım" sayacı değil. - ⚠️ Alan yoksa
sequence: 0varsayma — 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- ⚠️
amountExponentYALNIZamounts.*içindir.lineTotal,total,paid,payments[].amount,options[].priceondalı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çinamountExponenthiç gönderilmez; yalnızcurrencygelir. - ⚠️
timersatırı zamanla BÜYÜR (dakika-bazlı ürün): hiçbir durum değişikliği olmadan iki olay arasındagrossartar — "değer düştü → storno" mantığı kuruyorsan bu satırları ayrı ele al. perVatyalnız karışık oranlı satırda gelir; varken üst düzeyvat/netkırılımdan türetilmiştir.options[].vatRate: null= dondurulmamış → satırınvatRate'ine düş.- Para birimi çözülemezse üç alan da hiç konmaz (satır bozuksa yalnız o satırda
amountsdüş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. - ⚠️
cancelledadet kapsamlıdır:quantityiptal edilen adettir, aynılineIdazaltılmış adetle hâlâ aktif olabilir — satırı komple silme. before/afterşekliorders[]satırıyla birebir aynıdır (yeni tip öğrenmene gerek yok).- Sıra yoktur:
sequencesatış düzeyindedir; tek mutasyondan çıkan tüm girdiler aynı numaraya aittir, aralarından zamansal sıra çıkarma. deletedop'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. PlatformconnectUrl'inecode,environment,stateparametreleriyle 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ıtenantIdiki ortamda birden kurulu olabilir → install kaydınıtenantId+environmentile anahtarla (her kurulumunwebhookSecret'ı ayrıdır).
⚠️
clientId= eklentininpluginId'si (portal UUID) — portalda eklenti detayındaclient_idolarak gösterilir. Slug değildir; slug gönderirsen token ucu401 invalid_clientdöner.
exchangeCode, token yanıtının{ success:true, data:{…} }zarflı ve düz (zarfsız) şekillerinin ikisini de karşılar. Kendifetch'ini yazıyorsan sen de karşıla:const d = body.data ?? body;— yalnız düz gövde varsayan kod alanlarıundefinedokur 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İR — invoke('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: iframereferrerPolicy="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 gelene.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
resolvevecloseiç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.openUrlDenieddöner. PANEL_ORIGINmarkaya göre değişir vedocument.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 zinciri — resolveLocalized(map, locale) platformun hedef çözümlemesini uygular
(panel, portal, runtime ve DB aynaları aynı zinciri kullanır):
map[locale]doluysa → omap[DEFAULT_LOCALE](tr) doluysa → oLOCALESsırasındaki (tr, en, de, ru) ilk dolu değer — deterministik, JSON anahtar sırası değil- →
''
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
LOCALESgeniş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
