@restomenum/plugin-sdk
v1.11.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' });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, tenantId, occurredAt, data }
if (envelope.type === 'table.created') await handle(envelope.data);
// yanıt yazmazsan otomatik 200 { ok:true }
},
),
);OAuth token exchange
import { exchangeCode } from '@restomenum/plugin-sdk';
const cred = await exchangeCode(
{ code, clientId, clientSecret },
{ environment: 'sandbox' }, // veya { baseUrl }
);
// cred = { tenantId, apiKey, webhookSecret, scopes } → tenant başına SAKLACallback 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
}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' }));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.
// 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.
Katalog & tipler
import { EVENT_TYPES, SCOPES, PII_SCOPES, isPiiScope, isEventType } from '@restomenum/plugin-sdk';
import type { EventType, Scope, WebhookEnvelope, Customer, Packet, ActionRequest } from '@restomenum/plugin-sdk';
import type { 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
