@stewardhq/sdk 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +437 -0
  2. package/dist/_chunks/errors.js +73 -0
  3. package/dist/_chunks/events.d.ts +537 -0
  4. package/dist/_chunks/events.js +350 -0
  5. package/dist/_chunks/index.d.ts +6005 -0
  6. package/dist/_chunks/locale.d.ts +17 -0
  7. package/dist/_chunks/locale.js +17 -0
  8. package/dist/_chunks/src.js +1202 -0
  9. package/dist/_chunks/steward.d.ts +2588 -0
  10. package/dist/_chunks/steward.js +578 -0
  11. package/dist/_chunks/validators.d.ts +23 -0
  12. package/dist/_chunks/validators.js +106 -0
  13. package/dist/_chunks/webhook-core.d.ts +32 -0
  14. package/dist/_chunks/webhook-core.js +64 -0
  15. package/dist/_chunks/webhook.d.ts +22 -0
  16. package/dist/_chunks/webhook.js +53 -0
  17. package/dist/contract.d.ts +4 -0
  18. package/dist/contract.js +4 -0
  19. package/dist/index.d.ts +268 -0
  20. package/dist/index.js +784 -0
  21. package/dist/server.d.ts +236 -0
  22. package/dist/server.js +384 -0
  23. package/dist/testing/fixtures/events/LOCK.json +27 -0
  24. package/dist/testing/fixtures/events/account.deleted.json +36 -0
  25. package/dist/testing/fixtures/events/account.state_changed.json +53 -0
  26. package/dist/testing/fixtures/events/account.updated.json +37 -0
  27. package/dist/testing/fixtures/events/checkout.completed.json +36 -0
  28. package/dist/testing/fixtures/events/checkout.expired.json +26 -0
  29. package/dist/testing/fixtures/events/checkout.failed.json +27 -0
  30. package/dist/testing/fixtures/events/invoice.created.json +37 -0
  31. package/dist/testing/fixtures/events/invoice.issued.json +38 -0
  32. package/dist/testing/fixtures/events/invoice.voided.json +39 -0
  33. package/dist/testing/fixtures/events/subscription.activated.json +40 -0
  34. package/dist/testing/fixtures/events/subscription.cancel_scheduled.json +38 -0
  35. package/dist/testing/fixtures/events/subscription.canceled.json +29 -0
  36. package/dist/testing/fixtures/events/subscription.expired.json +27 -0
  37. package/dist/testing/fixtures/events/subscription.payment_failed.json +38 -0
  38. package/dist/testing/fixtures/events/subscription.reactivated.json +36 -0
  39. package/dist/testing/fixtures/events/subscription.renewed.json +39 -0
  40. package/dist/testing/fixtures/events/subscription.suspended.json +36 -0
  41. package/dist/testing/fixtures/events/subscription.terminated.json +28 -0
  42. package/dist/testing.d.ts +629 -0
  43. package/dist/testing.js +3759 -0
  44. package/dist/webhook/web.d.ts +17 -0
  45. package/dist/webhook/web.js +66 -0
  46. package/dist/webhook.d.ts +3 -0
  47. package/dist/webhook.js +3 -0
  48. package/package.json +70 -0
package/README.md ADDED
@@ -0,0 +1,437 @@
1
+ # @stewardhq/sdk
2
+
3
+ Steward ↔ ürün entegrasyonu: sözleşme şemaları (zod), tipli API istemcisi ve
4
+ webhook imzası. Kaynak: [z9cloud/steward](https://github.com/z9cloud/steward/tree/main/sdk).
5
+
6
+ ## Kurulum
7
+
8
+ npmjs'te public; token gerekmez.
9
+
10
+ ```bash
11
+ pnpm add @stewardhq/sdk
12
+ ```
13
+
14
+ ESM, Node ≥ 22. Tek çalışma zamanı bağımlılığı zod.
15
+
16
+ ## Alt yollar
17
+
18
+ | Import | İçerik |
19
+ | --- | --- |
20
+ | `@stewardhq/sdk` | Tüm şema/tipler + istemciler (`createSteward`, `createCatalogCache`, `stateCache`, fırlatmayan `createBillingClient`) + yardımcılar: hata sınıfları, `httpStatusFor`, `errorMessages`/`messageFor`, `defineCatalog`, `formatPrice`/`taxBreakdown`, `isValidTckn`/`isValidVkn`/`isE164` |
21
+ | `@stewardhq/sdk/contract` | Yalnızca şemalar ve tipler |
22
+ | `@stewardhq/sdk/webhook` | `signWebhook` / `verifyWebhook` / `verifyWebhookRaw` (Node HMAC, senkron) |
23
+ | `@stewardhq/sdk/webhook/web` | Aynı API WebCrypto ile (async; edge/workerd/tarayıcı) |
24
+ | `@stewardhq/sdk/testing/fixtures/events/<tür>.json` | Her event türünün örnek gövdesi |
25
+ | `@stewardhq/sdk/server` | `Webhooks()`: imzalı event alıcısı, canlı durum + önbellek, tipli hook'lar (Node); `Checkout()`: hosted checkout oturumu (`create` → `url`) ve dönüş sonucu (`result`); `CustomerPortal()`: hosted müşteri portalı oturumu (`create` → `url`) |
26
+ | `@stewardhq/sdk/testing` | Test çiftleri (Node): `createFakeSteward()` (HTTP düzeyinde sahte steward, `simulate.*`, `connect`), `signedEvent()` |
27
+
28
+ Kök giriş, `./contract` ve `./webhook/web` `node:*` kullanmaz (edge/tarayıcı);
29
+ `./webhook` senkron Node HMAC'idir.
30
+
31
+ ## Kullanım
32
+
33
+ Hesap durumu (okuma modeli) — ürün DB'sinde hak projeksiyonu tutulmaz; kapılar steward'ı
34
+ pod başına bir önbellek üzerinden canlı okur:
35
+
36
+ ```ts
37
+ import { createSteward, stateCache } from "@stewardhq/sdk";
38
+
39
+ export const steward = createSteward();
40
+ export const state = stateCache(steward, {
41
+ catalog, // entitlements() tipli olur
42
+ // steward kapalı ve bayat değer yoksa (AccountState | null); catalog.stateOf: katalogdan tam durum (version 0)
43
+ fallback: async (ref) => {
44
+ const org = await findOrg(ref);
45
+ return org ? catalog.stateOf(ref, org.plan, { displayName: org.name }) : null;
46
+ },
47
+ onFallback: ({ ref, reason }) => metrics.billingFallback.inc({ reason }),
48
+ // missing: "fallback" → steward'da hesabı olmayan kayıt da fallback'e (hesap ilk checkout'ta açılıyorsa)
49
+ // ttlMs: 30_000, staleIfErrorMs: 600_000, maxEntries: 10_000, logger
50
+ });
51
+
52
+ const e = await state.entitlements(orgId); // { max_projects: number | null; sso: boolean }
53
+ await state.state(orgId, { fresh: true }); // TTL'i beklemeden
54
+ state.prime((await steward.grants.create(orgId, input)).account); // mutasyon yanıtı önbelleğe
55
+ const byRef = await state.many(orgIds); // Map<ref, AccountState>, en çok 8 paralel
56
+ ```
57
+
58
+ | Durum | Davranış |
59
+ | --- | --- |
60
+ | TTL içinde | Bellekten, istek yok; aynı hesabın eşzamanlı okumaları tek istek |
61
+ | TTL doldu | `If-None-Match`: 304 süreyi yeniler, 200 yeni durumu yazar |
62
+ | Ağ hatası, zaman aşımı, 5xx, 429 | Süre dolduktan sonra `staleIfErrorMs` boyunca bayat değer (WARN), sonra `fallback` (WARN + `onFallback`), o da yoksa (ya da `null`) asıl `StewardNetworkError`/`StewardApiError`. Başarısız istekten sonra o hesap için `min(ttlMs, 5 sn)` steward'a gidilmez |
63
+ | 404 `account_not_found` (varsayılan `missing: "throw"`) | Kesinti değil: fallback yok, önbelleğe girmez, `StewardApiError` fırlar (eksik `upsert` görünür kalır) |
64
+ | 404 `account_not_found`, `missing: "fallback"` | `fallback(ref)` (zorunlu seçenek); WARN yok, `onFallback` nedeni `account_not_found` (kesinti metriğinden ayrılır); bayat değer kullanılmaz. Sonuç önbelleğe girmez, "yok" bilgisi `min(ttlMs, 5 sn)` tutulur; `prime`/`invalidate`/`fresh` bitirir (checkout sonucu ve webhook `prime` eder). Fallback `null` → 404 fırlar |
65
+ | `prime(account)` | Yalnızca `version >= önbellekteki` ise yazar (geri gitmez); `null`/`undefined` yok sayılır |
66
+
67
+ Önbellek pod başına bellek içidir (LRU); çok pod'da tutarlılık TTL kadardır.
68
+ `invalidate(ref)` sonraki okumanın hemen (ETag'le) sormasını sağlar.
69
+
70
+ Webhook alıcısı (`/server`, Node) — imza ham gövdede, sonra sözleşme; durum steward'dan
71
+ canlı okunur, önbelleğe yazılır; hook'lar tipli:
72
+
73
+ ```ts
74
+ import { Webhooks } from "@stewardhq/sdk/server";
75
+
76
+ const webhooks = Webhooks({
77
+ steward, cache: state, // refetch (steward verilince varsayılan): payload yalnız tetikleyici
78
+ // secrets verilmezse her teslimatta STEWARD_WEBHOOK_SECRET ("yeni,eski" rotasyonu); toleranceSeconds: 300
79
+ onStateChanged: async ({ ref, state, cause }) => {
80
+ const org = await findOrg(ref);
81
+ if (!org) return "ignore"; // bilinmeyen hesap: 200, diğer hook'lar ve after koşmaz
82
+ await applyOrgPlan(org, state.planCode, state.accessState);
83
+ },
84
+ onSubscriptionTerminated: ({ ref }) => flagOps(ref), // on<Tip>: eski tipten ya da state_changed cause'undan
85
+ onPaymentFailed: ({ ref, state, profile }) => mailOwner(ref, profile), // + onSuspended, onTerminated
86
+ after: ({ ref, state }) => propagate(ref, state.accessState), // diğer hook'lardan sonra, her teslimatta
87
+ // includeProfile: true → bildirim hook'ları fatura profilini (e-posta) alır; refetch: false → gövdedeki durum
88
+ });
89
+
90
+ app.post("/v1/billing/events", (c) => webhooks.handle(c.req.raw)); // ya da: const { status, body } = await webhooks.receive(rawBody, headers)
91
+ ```
92
+
93
+ | Durum | Yanıt |
94
+ | --- | --- |
95
+ | Hook'lar koştu | 200 `{ok: true, handled: "received"}` |
96
+ | `onStateChanged` `"ignore"` döndü | 200 `ignored` + WARN; neden hook'ları ve `after` koşmaz |
97
+ | Hesap silindi: `account.deleted` ya da `cause: "account.deleted"` | 200 `deleted`; canlı okuma yok, `onAccountDeleted` + `after` gövdedeki anonim son durumla (`onStateChanged` koşmaz, `cache.invalidate` varsa çağrılır). Silmeden önce kuyruğa girmiş teslimatın canlı okuması `account_deleted` (410) alırsa hook'suz 200 `deleted` + WARN |
98
+ | `endpoint.ping` | 200 `ping`, hook yok |
99
+ | `refetch: false` ve gövdede yalnızca snapshot (yükseltme öncesi kuyruk) | 200 `no_state` + WARN: `onStateChanged` koşmaz, neden hook'ları ve `after` `state: null` ile koşar |
100
+ | Tanınmayan tip | 200; `onUnknownEvent` (durum varsa önce `onStateChanged`) |
101
+ | İmza/başlık/zaman penceresi | 401 |
102
+ | İmzalı ama sözleşmeye uymayan gövde, id uyuşmazlığı | 400 + ALARM (steward yeniden dener) |
103
+ | Sır yok, güncel durum okunamadı (`state_unavailable`), profil okunamadı, bir hook fırlattı | 503 (steward yeniden dener, hook'ların hepsi yeniden koşar) |
104
+
105
+ Tipler: `steward` verilmiş ve `refetch: false` değilse her hook'un (`onStateChanged`, `on<Tip>`,
106
+ `onPaymentFailed`…, `onUnknownEvent`, `after`) `state`'i `AccountState`'tir — durum okunamazsa 503 döner,
107
+ hook koşmaz; `after`'ın `handled`'ı `"received"`. `refetch: false` ya da `steward`'sız alıcıda neden hook'ları,
108
+ `onUnknownEvent` ve `after` `AccountState | null` alır (yükseltme öncesi kuyruk; `onStateChanged` yine tam
109
+ durumla koşar). Ayrım `refetch` ve `steward` alanlarının tiplerinden çıkarılır (tip belirsizse null'lı).
110
+
111
+ **SDK tekrar ayıklamaz, version kapısı ve kilit tutmaz.** `refetch` ile her teslimat
112
+ durumu steward'dan canlı okur: sırasız ya da tekrar teslimat en güncel durumu görür,
113
+ yalnızca snapshot taşıyan eski gövde de tam kullanılır (`event.data.account` bayat
114
+ olabilir; `state`'i kullan). Aynı event yine birden çok kez ve aynı hesabın teslimatları
115
+ eşzamanlı gelebilir: hook'lar idempotent yazmalı (plan alanını durumdan yaz, "zaten
116
+ askıdaysa dokunma", sonlandırma işareti `coalesce`). Yeniden denenmesi istenen durumda hook
117
+ fırlatır → 503. `refetch: false` gövdedeki durumu kullanır (sıra garantisi yok; eski
118
+ kuyruğun boşalmasını bekle). Aynı olay için hem eski tipi hem `account.state_changed`'i
119
+ isteyen endpoint neden hook'larını iki kez çalıştırır: endpoint filtresinde birini seç.
120
+
121
+ Hosted checkout (`/server`) — profil, TCKN/VKN, onaylar ve ödeme formu steward'ın sayfasında;
122
+ ürün yalnızca oturumu açar, kullanıcıyı `url`'e yönlendirir ve dönüşte sonucu sorar:
123
+
124
+ ```ts
125
+ import { Checkout } from "@stewardhq/sdk/server";
126
+
127
+ const checkout = Checkout({
128
+ steward, cache: state,
129
+ // mutlak; origin steward ayarında izinli (appUrl + extraReturnOrigins). Yer tutucular varsa
130
+ // steward yalnızca onları doldurur, yoksa ?session=&status= ekler.
131
+ successUrl: `${process.env.APP_URL}/{LOCALE}/app/orgs/{ACCOUNT_REF}/billing?checkout={CHECKOUT_ID}`,
132
+ consents: (locale) => legalDocs(locale), // [{document, version, url}] ya da dizi; sayfada onaylatılır
133
+ // cancelUrl (yoksa ayar appUrl), locale ve currency (yoksa ürün ayarı)
134
+ });
135
+
136
+ app.post("/v1/orgs/:orgId/billing/checkout", async (c) => {
137
+ if (!(await isOwner(c))) return c.json({ error: "forbidden" }, 403); // yetki ÇAĞRIDAN ÖNCE ürünün işi
138
+ const { url } = await checkout.create({ ref: c.req.param("orgId"), planCode: "team", interval: "month",
139
+ locale: c.get("locale"), actorRef: `user:${c.get("user").id}` }); // displayName?, prefill?: {email, name, kind}
140
+ return c.json({ url }); // tarayıcı 303 url
141
+ });
142
+
143
+ app.get("/v1/orgs/:orgId/billing/checkout/:id", async (c) => // dönüş sayfası ?checkout=<id>
144
+ c.json(await checkout.result(c.req.param("id"), { ref: c.req.param("orgId") })));
145
+ // { status: "open" | "completed" | "failed" | "expired" | "canceled", planCode?, account? }
146
+ ```
147
+
148
+ | Durum | Davranış |
149
+ | --- | --- |
150
+ | `create` | `POST …/checkout-sessions` hosted gövdeyle (profil/kimlik yok); sağlayıcıya çağrı yok; `{id, url, expiresAt}`. Hesap yoksa `displayName` ile açılır, o da yoksa `account_not_found`. Steward hataları aynen (`already_subscribed` 409, `plan_not_sellable` 422, `return_url_not_allowed` → `httpStatusFor` 500 + ALARM) |
151
+ | `result`, oturum başka hesabın / id geçersiz / oturum yok | Aynı hata: `StewardApiError` `checkout_session_not_found` (404) — `?checkout=` kullanıcı girdisidir, sahiplik ele verilmez |
152
+ | `result`, `completed` | Yanıttaki `account` `cache.prime` edilir (dönüş sayfası webhook'u beklemeden yeni planı gösterir; webhook yine kaynak) |
153
+ | `locale` | `CustomerPortal` ile aynı kural: BCP-47 benzeri etiketin birincil alt etiketi (`tr-TR` → `tr`, `en-US` → `en`). Çağrıdaki desteklenmeyen dil yok sayılır: seçenekteki dil, o da yoksa gönderilmez (steward `defaultLocale`); `consents` işlevi bu dille çağrılır |
154
+ | Göreli/http(s) olmayan `successUrl`, `cancelUrl`, belge adresi; seçenekte desteklenmeyen `locale` | Oluşturmada `StewardConfigError` |
155
+ | Yanıtta `url` yok (F2b öncesi steward) | `StewardContractError` |
156
+
157
+ Kullanıcı sayfayı kapatırsa oturum süresi dolunca (`checkoutTtlMinutes`) steward kapatır;
158
+ ürün sonucu `account.state_changed` ile öğrenir. `profile` + `identityNumber` taşıyan eski gövde
159
+ (`steward.checkoutSessions.create`) açılışta sağlayıcı formu döndürmeye devam eder (N3 + 1
160
+ release sonra kaldırılır).
161
+
162
+ Hosted müşteri portalı (`/server`) — abonelik durumu, dönem sonunda iptal, ödemeyi yeniden dene,
163
+ fatura listesi/PDF ve kurumsal fatura profili steward'ın sayfasında; ürün yalnızca yetkiyi
164
+ denetler, oturumu açar ve yönlendirir. Ürün tarafında iptal/yeniden dene/fatura ucu yoktur:
165
+ sonuçlar `account.state_changed` ile `Webhooks()`'a gelir:
166
+
167
+ ```ts
168
+ import { CustomerPortal } from "@stewardhq/sdk/server";
169
+
170
+ const portal = CustomerPortal({ steward, returnUrl: `${process.env.APP_URL}/billing` }); // returnUrl yoksa ayar appUrl
171
+
172
+ app.post("/v1/orgs/:orgId/billing/portal", async (c) => {
173
+ if (!(await isOwner(c))) return c.json({ error: "forbidden" }, 403); // yetki ÇAĞRIDAN ÖNCE ürünün işi
174
+ const { url } = await portal.create({ ref: c.req.param("orgId"), locale: c.get("locale"), actorRef: `user:${c.get("user").id}` });
175
+ return c.json({ url }); // tarayıcı 303 url
176
+ });
177
+ ```
178
+
179
+ | Durum | Davranış |
180
+ | --- | --- |
181
+ | `create` | `POST …/portal-sessions {locale?, returnUrl?}` → `{id, url, expiresAt}`; durum değişmez, event yok. Süre ürün ayarı `portalTtlMinutes` (varsayılan 30 dk); süresi geçen ya da anahtarı yanlış bağlantı 404 |
182
+ | Hesap yok | `StewardApiError` `account_not_found` (404) — checkout'tan farklı olarak hesap açılmaz |
183
+ | `returnUrl` origin'i izinli değil | `return_url_not_allowed` (422; `httpStatusFor` 500 + ALARM) |
184
+ | `locale` | `tr`/`en` (ör. `tr-TR` → `tr`; `Checkout` ile aynı kural); başka dil gönderilmez, steward `defaultLocale`'i kullanır |
185
+ | `actorRef` eksik/biçimsiz, göreli ya da http(s) olmayan `returnUrl` | `StewardConfigError` (istek gitmez) |
186
+ | Portal aksiyonları | Aktör steward denetim izinde `customer:<ref>`; iptal `subscription.cancel_scheduled` (neden `customer_portal`), profil `billing_profile.updated`, yeniden dene durum değiştirmez (tahsilat sonucu `subscription.reactivated`) |
187
+
188
+ Düşük seviyede imza ham gövde üzerinde doğrulanır, sonra sözleşmeyle parse edilir:
189
+
190
+ ```ts
191
+ import { verifyWebhook } from "@stewardhq/sdk/webhook";
192
+
193
+ const rawBody = await request.text();
194
+ const result = verifyWebhook({ secrets: [process.env.STEWARD_WEBHOOK_SECRET!], headers: request.headers, rawBody });
195
+ if (!result.ok) return new Response(null, { status: 400 });
196
+ // result.event: BillingEvent — tekrarı `id` ile ayıkla, eski `version`'ı uygulama.
197
+ ```
198
+
199
+ API istemcisi — `createSteward()` ayarları seçeneklerden, yoksa ortamdan okur
200
+ (`STEWARD_URL`, `STEWARD_API_KEY`; edge'de `env` verilir) ve eksikse **açılışta**
201
+ `StewardConfigError` fırlatır (yalnızca admin ayarlı istemci hariç, aşağıda). Başarısız çağrı fırlatır:
202
+
203
+ ```ts
204
+ import { createSteward } from "@stewardhq/sdk";
205
+
206
+ export const steward = createSteward({ actorRef: "system" }); // ya da createSteward({ env, fetch, timeoutMs: 10_000 })
207
+
208
+ const state = await steward.accounts.state(accountRef); // AccountState
209
+ await steward.grants.create(accountRef, { planCode: "team", reason: "kurumsal sözleşme" }, { actorRef: `staff:${staffId}` });
210
+ ```
211
+
212
+ | Kaynak | Metotlar |
213
+ | --- | --- |
214
+ | `accounts` | `upsert(ref, input)`, `get(ref, {include?})`, `state(ref)`, `readState(ref, {etag?})` (304 → `{notModified: true}`), `entitlements(ref)`, `putBillingProfile(ref, profile)`, `setPlan(ref, {planCode \| null, reason, mode?, endsAt?, overrides?})` → `{grant, account, warnings}` (canlı abonelik planı hedefi geçiyorsa `mode: "ensure"` uyarı, `"strict"` `subscription_plan_higher`), `resync(ref)` → `{queued, eventId}` (saklı durum `account.state_changed`, `cause: "resync"`), `delete(ref)` → `{ref, deletedAt}` (KVKK anonimleştirme; faturalar kalır; önce canlı abonelik `immediate` iptal — yoksa `subscription_active`, formu açık checkout'ta `checkout_in_progress`; sonra hesabın her ucu `account_deleted` 410) |
215
+ | `checkoutSessions` | `create(ref, input)`, `get(id)` |
216
+ | `portalSessions` | `create(ref, {locale?, returnUrl?})` → `{id, url, expiresAt}` (hosted müşteri portalı; `CustomerPortal()` bunu sarar) |
217
+ | `subscriptions` | `cancel(id, input)` |
218
+ | `grants` | `create(ref, input)`, `revoke(id)` |
219
+ | `invoices` | `issue(id, input)`, `document(id)` → `{contentType, bytes}`, `createManual(ref, {lines, currency, taxRateBps, taxInclusive?, dueAt?, note?})` (manuel taslak; profil gerekir, `issue` ile kesilir), `void(id, {reason})` (yalnızca manuel fatura; `invoice.voided` yalnızca filtresinde isteyen endpoint'e) |
220
+ | `catalog` | `get()`, `read({etag?})` |
221
+ | `events` | `list({accountRef?, cursor?, limit?})`, `redeliver(id)` |
222
+ | — | `stats()`, `me()`, `doctor()` → kurulum teşhisi (`GET /v1/doctor`; aşağıda) |
223
+ | `admin` | `catalog.sync(input)`, `accounts.refresh()`, `settings.{get(), update(patch)}`, `endpoints.{list(), create(input), rotate(id, {secret}), deactivate(id)}` — `STEWARD_ADMIN_URL` + `STEWARD_ADMIN_API_KEY` (ya da `adminBaseUrl`/`adminApiKey`) ister; yoksa çağrıda `StewardConfigError` |
224
+ | `raw` | Fırlatmayan, tekrar denemeyen sözleşme istemcisi (`createBillingClient`; `{ ok, data }` / `{ ok: false, status, error }`) |
225
+
226
+ - **Mutasyonlar** son argümanda `{ idempotencyKey?, actorRef? }` alır. Anahtar
227
+ verilmezse çağrı başına üretilir; `actorRef` verilmezse `createSteward`'daki varsayılan gider.
228
+ İkisi de yoksa ya da biçimi geçersizse (`tür[:kimlik]`) istek **gitmez**: `StewardConfigError`
229
+ (`code` servisinkiyle aynı: `actor_ref_required` / `invalid_actor_ref`). `raw` bu denetimi yapmaz.
230
+ - **Yalnızca yönetim:** `STEWARD_URL` ve `STEWARD_API_KEY` ikisi de yoksa ve `STEWARD_ADMIN_URL` +
231
+ `STEWARD_ADMIN_API_KEY` (ya da `adminBaseUrl`/`adminApiKey`) verilmişse istemci kurulur (CLI, katalog
232
+ senkronu): `admin.*` çalışır, iki kapsama açık `doctor()` ve `me()` admin anahtarıyla gider, ürün uçları
233
+ ve `raw.*` çağrıda `StewardConfigError` ile reddeder (istek gitmez). Ürün ayarının yalnızca yarısı
234
+ verilmişse yine açılışta hata.
235
+ - **Hatalar:** yanıtlı hata `StewardApiError` (`code`, `httpStatus`, `category`,
236
+ `retryable`, `requestId`, `retryAfterMs`), yanıt yok `StewardNetworkError`
237
+ (`network_error`/`timeout`), şemaya uymayan yanıt `StewardContractError`.
238
+ - **Tekrar deneme** (`retries: { attempts: 2, on: ["network", "5xx", "429"], maxDelayMs: 5000 }`):
239
+ ağ hatası/zaman aşımı, 5xx ve 429'da GET'ler ve `Idempotency-Key` taşıyan mutasyonlar
240
+ en çok `attempts` kez daha (varsayılan en çok 3 istek) denenir; anahtar bütün denemelerde
241
+ aynıdır. Bekleme `Retry-After` kadar, yoksa üstel (250 ms, 500 ms…) + jitter; ikisi de
242
+ `maxDelayMs` ile sınırlı. 429 dışındaki 4xx ve `retryable: false` yanıtlar
243
+ (`provider_error`, `provider_not_configured`) denenmez. `timeoutMs` deneme başınadır.
244
+ - İstekler `Steward-Api-Version` ve `X-Steward-Sdk: @stewardhq/sdk/<sürüm>` taşır (yalnızca gözlem).
245
+ - Yalnızca web standartları (`fetch`, `crypto.randomUUID`, `AbortSignal.timeout`): Node 22,
246
+ edge ve workerd'de aynı kod. Kök giriş ve `./webhook/web` her paket kontrolünde
247
+ workerd'de `nodejs_compat` kapalı koşturulur (`scripts/check-workerd.mjs`).
248
+
249
+ Yönetim (admin anahtarı; cluster içi, port-forward) — ürün ayarları ve webhook endpoint'leri.
250
+ Webhook sırrını **çağıran üretir** ve kendi Secret'ına yazar; steward sırrı hiçbir yanıtta
251
+ geri vermez:
252
+
253
+ ```ts
254
+ import { generateWebhookSecret } from "@stewardhq/sdk";
255
+
256
+ await steward.admin.settings.update({
257
+ appUrl: "https://app.example.com", // origin'i checkout dönüşüne izinli; + extraReturnOrigins
258
+ privacyUrl: "https://app.example.com/kvkk",
259
+ branding: { name: "Acme", accentColor: "#0f766e" }, // sığ birleşir; alanı null temizler
260
+ checkoutTtlMinutes: 30,
261
+ });
262
+
263
+ const secret = generateWebhookSecret(); // whsec_ + 32 rastgele bayt
264
+ const endpoint = await steward.admin.endpoints.create({ url: "http://api.acme.svc.cluster.local/billing/events", secret });
265
+ // eventTypes verilmezse ["account.state_changed"]; adres https:// ya da http://*.svc.cluster.local
266
+ await steward.admin.endpoints.rotate(endpoint.id, { secret: generateWebhookSecret() }); // eski sır geçiş için ikinci imza
267
+ ```
268
+
269
+ Kurulum teşhisi — ürün anahtarı yeter; ilk checkout'tan önce `ok: true` beklenir:
270
+
271
+ ```ts
272
+ const report = await steward.doctor();
273
+ // report.ok: `error` önemde uyarı yok
274
+ // report.warnings: [{ code: "endpoint_wrong_secret", severity: "error", message, endpointId }, ...]
275
+ // report.endpoints[].diagnosis: ok | wrong_secret (son teslimat 401) | old_contract (400) | unreachable | failing | no_deliveries
276
+ // report.catalog, report.provider (environment: sandbox | live | fake), report.hosted.ready, report.dunning.dryRun
277
+ ```
278
+
279
+ Kodlar ve teşhisler açık kümedir (bilinmeyeni yalnızca gösterin). `provider.healthy` ve
280
+ `lastError` yanıtı veren steward pod'unun gözlemidir.
281
+
282
+ Katalog önbelleği — TTL içinde bellekten, sonra `If-None-Match` (304 gövdeyi korur);
283
+ eşzamanlı çağrılar tek istek; başarılı okumadan sonraki hatada bayat değer + `logger.warn`:
284
+
285
+ ```ts
286
+ import { createCatalogCache } from "@stewardhq/sdk";
287
+
288
+ const catalogCache = createCatalogCache(steward, { ttlMs: 60_000, logger });
289
+ const { plans, prices } = await catalogCache.get();
290
+ ```
291
+
292
+ Katalog — kodda tipli tanım; eksik/fazla hak, yanlış tip (`limit` → tam sayı ya da
293
+ `null` = sınırsız, `flag` → boolean, `text` → string) ve tanımsız plana fiyat derleme
294
+ hatasıdır, tipler atlatılırsa tanım anında `StewardConfigError` fırlatır (TypeScript ≥ 5.4):
295
+
296
+ ```ts
297
+ import { defineCatalog, flag, limit } from "@stewardhq/sdk";
298
+
299
+ export const catalog = defineCatalog({
300
+ defaultPlan: "free",
301
+ features: { max_projects: limit(), sso: flag() },
302
+ plans: {
303
+ free: { name: "Free", rank: 0, sellable: false, entitlements: { max_projects: 1, sso: false } },
304
+ team: { name: "Team", rank: 10, sellable: true, entitlements: { max_projects: null, sso: true } },
305
+ },
306
+ prices: [{ plan: "team", interval: "month", currency: "TRY", amountMinor: 125_000, taxInclusive: true, taxRateBps: 2000 }],
307
+ });
308
+
309
+ await steward.admin.catalog.sync(catalog.toSyncInput()); // admin anahtarıyla (CLI/deploy adımı)
310
+ const e = catalog.resolve(snapshot); // { max_projects: number | null; sso: boolean }
311
+ const placeholder = catalog.stateOf("org_1", "team", { accessState: "active", displayName: "Acme" });
312
+ // tam AccountState: version 0, updatedAt 1970, catalogVersion null, haklar planınki, abonelik/açık checkout yok,
313
+ // profil { present: false }; plan verilmezse varsayılan plan (stateCache fallback'i için)
314
+ ```
315
+
316
+ `resolve` eksik ya da tipi uymayan anahtarda değeri snapshot'ın planından (plan
317
+ katalogda yoksa varsayılan plandan) alır, asla "sınırsız" üretmez; `onFallback`
318
+ ile alarm verilebilir. Snapshot yoksa varsayılan planın hakları.
319
+
320
+ Hatalar ve metinler — ürün API'sinin durum kodu ve kullanıcı metni:
321
+
322
+ ```ts
323
+ import { httpStatusFor, messageFor, StewardError } from "@stewardhq/sdk";
324
+
325
+ if (error instanceof StewardError) {
326
+ const { status, headers } = httpStatusFor(error, { logger }); // already_subscribed 409, rate_limited 503 + Retry-After…
327
+ return Response.json({ error: error.code, message: messageFor(error.code, locale) }, { status, headers });
328
+ }
329
+ ```
330
+
331
+ Tabloda olmayan kod `category`'ye göre eşlenir (`not_found` 404, `conflict` 409,
332
+ `provider` 502, `unavailable` 503; `validation`/`config` 500 + ERROR log).
333
+ `errorMessages.{tr,en}` sözleşmenin `ERROR_CODES` kaydındaki her kodu ve istemci
334
+ kodlarını (`network_error`, `timeout`, `invalid_response`, `config_error`) kapsar.
335
+
336
+ Para ve doğrulayıcılar: `formatPrice(price, "tr")` → `₺1.250,00 · KDV dahil`;
337
+ `taxBreakdown(price)` servisin faturadaki matrah/KDV ayrıştırmasıyla kuruşu kuruşuna
338
+ aynıdır. `isValidTckn`, `isValidVkn` (sağlama hanesi), `isE164(value, {country: "TR"}?)`
339
+ değeri olduğu gibi denetler (boşluk kırpmaz).
340
+
341
+ Testler (`/testing`, Node) — sahte steward sunucu açmaz, `fetch` verir; başlıkları
342
+ (`Bearer`, `X-Actor-Ref`, `Idempotency-Key` tekrarı), ETag/304, hata gövdelerini ve
343
+ event kurallarını servis gibi uygular. Aynı uyumluluk paketi hem sahteye hem gerçek
344
+ servis imajına karşı koşar:
345
+
346
+ ```ts
347
+ import { createFakeSteward, signedEvent } from "@stewardhq/sdk/testing";
348
+
349
+ const fake = createFakeSteward({
350
+ catalog, // oluşturmada senkronlanır
351
+ webhook: { secret }, // eventTypes verilmezse ["account.state_changed"]; "legacy" = 15 eski tip
352
+ });
353
+ fake.connect((req) => webhooks.handle(req)); // alıcı sonradan (ya da webhook.handler); uygulama fake'ten sonra kurulabilir
354
+ const steward = fake.steward; // ya da ürünün kendi createSteward({ fetch: fake.fetch, baseUrl: fake.baseUrl, … })
355
+ await steward.accounts.upsert("org_1", { displayName: "Org" });
356
+ const session = await steward.checkoutSessions.create("org_1", input);
357
+ await fake.simulate.checkoutCompleted(session.id); // ya da checkoutFailed; hosted oturumda checkoutCanceled da
358
+
359
+ // Hosted (Checkout().create): url sahte steward'ın sayfası; tarayıcı gibi sürülebilir
360
+ const { id, url } = await checkout.create({ ref: "org_1", planCode: "team", interval: "month", actorRef: "user:1" });
361
+ const { redirectUrl, session: done } = await fake.simulate.completeHostedCheckout(url); // sayfa GET + "Tamamla" (outcome: "failed" → Reddet)
362
+ const page = await fake.fetch(url); // ya da elle: "Tamamla / Reddet / Vazgeç" formları (k gizli alanda)
363
+ const paid = await fake.fetch(new URL(`${id}/pay`, url), { method: "POST", body: new URLSearchParams({ k: new URL(url).searchParams.get("k")!, outcome: "succeeded" }) });
364
+ paid.headers.get("location"); // successUrl, yer tutucular servisle aynı kuralla dolu
365
+
366
+ // Portal (CustomerPortal().create): url sahte portal sayfası; servisle aynı rotalar ve bildirim kodları
367
+ const portalSession = await portal.create({ ref: "org_1", actorRef: "user:1" });
368
+ const k = new URL(portalSession.url).searchParams.get("k")!;
369
+ const base = `${fake.baseUrl}/public/v1/portal/${portalSession.id}`;
370
+ await fake.fetch(`${base}/cancel`, { method: "POST", body: new URLSearchParams({ k }) }); // 200 onay sayfası
371
+ await fake.fetch(`${base}/cancel`, { method: "POST", body: new URLSearchParams({ k, confirm: "1" }) }); // 303 …&done=canceled
372
+ await fake.simulate.portalCancel(portalSession.id); // ya da doğrudan: { result: "ok" | "already_scheduled" | "no_subscription", account }
373
+ await fake.simulate.portalRetryPayment(portalSession.id); // past_due'da { result: "ok" } — durum değişmez; sonuç renewal ile
374
+
375
+ await fake.simulate.flush(); // bekleyen event'ler alıcıya, sırayla (2xx dışı → bekler)
376
+
377
+ await fake.simulate.renewal("org_1", { fail: true }); // saat dönem sonuna; past_due + dunning
378
+ await fake.simulate.advance({ days: 7 }); // zamanlanmış işler kendi anında (dunning, dönem sonu iptali, checkout süresi, grant penceresi, portal oturumu silme)
379
+ fake.simulate.outage(true, { mode: "503" }); // ya da "network": fetch reddeder
380
+ await fake.simulate.deliver("account.state_changed", { ref: "org_1", duplicate: true, outOfOrder: true });
381
+
382
+ const { request } = signedEvent("subscription.renewed", { accountRef: "org_1" }, { secret });
383
+ await webhooks.handle(request());
384
+ ```
385
+
386
+ `fake.requests` gelen istekleri, `fake.events(ref?)` yayınlanan event'leri tutar. Endpoint `webhook`
387
+ seçeneğiyle oluşturmada açılır; filtre verilmezse yönetim ucunun yeni ürüne verdiği
388
+ `["account.state_changed"]`, `eventTypes: "legacy"` pod CLI'nın filtresiz (15 eski tip) endpoint'i.
389
+ Alıcı bağlanmadan `flush`/`deliver` fırlatır (event'ler outbox'ta bekler); `connect` alıcıyı değiştirir.
390
+ Sahtenin bilerek farklı olduğu yerler: teslimat asenkron değil (`flush()`), `dead`
391
+ olmaz ve geri çekilme beklenmez; sağlayıcı hata vermez; dönüş origin'i (`settings.appUrl`,
392
+ `settings.extraReturnOrigins` ya da kısayolu `returnOrigins`) hiç ayarlanmazsa her
393
+ `returnUrl` kabul; `admin.endpoints` ile açılan endpoint yalnızca kayıttır (teslimat
394
+ `webhook` seçeneğinin alıcısına gider, `lastDelivery` hep null; `doctor()`'da teşhisi
395
+ `no_deliveries`, sağlayıcı `fake` ve sağlıklı); dunning'de yeniden deneme
396
+ sonucu `renewal` ile gelir; her yanıt `X-Request-Id` (hata gövdesinde `requestId`) taşır;
397
+ idempotency kayıtları süpürülmez. Hosted checkout'ta sahte sayfa servisin sayfasını taklit
398
+ etmez, yalnızca `url` ↔ `simulate` sözleşmesini sürülebilir kılar: profil formu, TCKN/VKN
399
+ doğrulaması, onay kutuları, deneme hakkı, CSP ve POST kaynak denetimi yok. Servis profili
400
+ müşterinin `details` gönderiminde yazar; sahte bunu tamamlanma/red anında yapar — hesabın
401
+ profili yoksa `prefill`den (ad, e-posta, tür; geri kalanı sabit test verisi, kurumsalda VKN
402
+ `1234567890`) sahte profil yazılır (`billing_profile.updated`), varsa aynen kalır; belgeler
403
+ o anda onaylanmış sayılır. Sayfada sağlayıcı formu olmadığından vazgeç her zaman `canceled`
404
+ olur ve süresi dolan hosted oturumun nedeni `expired`dır.
405
+
406
+ Portal oturumu (`portalSessions.create`) servisle aynı kurallarla açılır (hesap yoksa 404,
407
+ `returnUrl` origin politikası, süre `portalTtlMinutes`, 43 karakterlik sayfa anahtarı; süresi
408
+ geçen, anahtarsız ya da anahtarı yanlış oturum aynı 404). Sahte portal sayfası da servisin
409
+ sayfasını taklit etmez — marka, tr/en metinler, CSP, POST kaynak denetimi (`Sec-Fetch-Site`),
410
+ IP başına yanlış anahtar ve oturum başına aksiyon sayaçları (429) yok — ama rotaları
411
+ (`GET ?k=`, `POST …/cancel` iki adım `confirm=1`, `…/retry-payment`, `…/profile`,
412
+ `GET …/invoices/:id/document?k=`), 303 bildirim kodları (`done=canceled|retry_requested|profile_saved`,
413
+ `error=no_subscription|already_canceled|nothing_to_retry|retry_too_soon`), profil alan hata kodları
414
+ (422) ve sonuçları servisle aynıdır; uyumluluk paketi iki hedefi de sayfa üzerinden sürer.
415
+ Yönlendirmeler mutlak adrestir (servis köke göre yol verir). Sahte sağlayıcı hata vermediğinden
416
+ `provider_error` bildirimi yok. "Ödemeyi yeniden dene" isteği kabul eder (`ok`, abonelik başına
417
+ 10 dakikada bir; dry-run olmayan dunning retry adımı da sayılır) ama tahsilatı kendisi
418
+ sonuçlandırmaz: servisteki gibi sonuç sonradan gelir — `simulate.renewal(ref)` aynı başarısız
419
+ siparişi başarılı sayar (`subscription.reactivated`), `{fail: true}` yine başarısız.
420
+ `simulate.portalCancel/portalRetryPayment` bilinmeyen ya da süresi geçmiş oturumda fırlatır.
421
+
422
+ ## `@z9cloud/steward-contract`'tan geçiş
423
+
424
+ | Eski | Yeni |
425
+ | --- | --- |
426
+ | `@z9cloud/steward-contract` | `@stewardhq/sdk` (ya da yalnız şema için `@stewardhq/sdk/contract`) |
427
+ | `@z9cloud/steward-contract/client` | `@stewardhq/sdk` (`createBillingClient` aynen; fırlatan istemci `createSteward`) |
428
+ | `@z9cloud/steward-contract/webhook` | `@stewardhq/sdk/webhook` |
429
+ | `@z9cloud/steward-contract/fixtures/events/*.json` | `@stewardhq/sdk/testing/fixtures/events/*.json` |
430
+
431
+ GitHub Packages `.npmrc` satırı ve Docker `--secret` bağlaması artık gerekmez.
432
+
433
+ ## Sürümleme
434
+
435
+ Semver. Alan kaldırma/yeniden adlandırma kırıcıdır (major; 1.0 öncesi minor).
436
+ Yayın: `sdk/package.json` sürümü artırılır, `sdk-v<sürüm>` etiketi push edilir
437
+ (`.github/workflows/sdk-publish.yml`, npm provenance ile).
@@ -0,0 +1,73 @@
1
+ import { R as errorCodeSpec } from "./src.js";
2
+ //#region src/errors.ts
3
+ var StewardError = class extends Error {
4
+ code;
5
+ httpStatus;
6
+ retryable;
7
+ constructor(options) {
8
+ super(options.message ?? options.code, options.cause === void 0 ? void 0 : { cause: options.cause });
9
+ this.name = "StewardError";
10
+ this.code = options.code;
11
+ this.httpStatus = options.httpStatus ?? 0;
12
+ this.retryable = options.retryable ?? false;
13
+ if (options.category !== void 0) this.category = options.category;
14
+ if (options.requestId !== void 0) this.requestId = options.requestId;
15
+ if (options.retryAfterMs !== void 0) this.retryAfterMs = options.retryAfterMs;
16
+ }
17
+ };
18
+ /**
19
+ * Steward 2xx dışı yanıt döndü (`{error, message?}`). Yanıt `category` /
20
+ * `retryable` taşımıyorsa sözleşmenin `ERROR_CODES` kaydı kullanılır; kod orada
21
+ * da yoksa 429 ve 5xx tekrar denenebilir sayılır.
22
+ */
23
+ var StewardApiError = class extends StewardError {
24
+ constructor(options) {
25
+ const spec = errorCodeSpec(options.code);
26
+ const category = options.category ?? spec?.category;
27
+ super({
28
+ ...options,
29
+ ...category !== void 0 ? { category } : {},
30
+ retryable: options.retryable ?? spec?.retryable ?? (options.httpStatus === 429 || options.httpStatus >= 500)
31
+ });
32
+ this.name = "StewardApiError";
33
+ }
34
+ };
35
+ /** Steward'a ulaşılamadı ya da zaman aşımı; yanıt yok (`httpStatus` 0), tekrar denenebilir. */
36
+ var StewardNetworkError = class extends StewardError {
37
+ constructor(options = {}) {
38
+ super({
39
+ ...options,
40
+ code: options.code ?? "network_error",
41
+ httpStatus: 0,
42
+ category: "unavailable",
43
+ retryable: options.retryable ?? true
44
+ });
45
+ this.name = "StewardNetworkError";
46
+ }
47
+ };
48
+ /** Yanıt sözleşme şemasına uymadı (sürüm kayması); aynı istek aynı sonucu verir. */
49
+ var StewardContractError = class extends StewardError {
50
+ constructor(options = {}) {
51
+ super({
52
+ ...options,
53
+ code: options.code ?? "invalid_response",
54
+ retryable: options.retryable ?? false
55
+ });
56
+ this.name = "StewardContractError";
57
+ }
58
+ };
59
+ /** SDK yanlış yapılandırıldı (eksik `STEWARD_URL`, geçersiz katalog…); istek hiç gönderilmedi. */
60
+ var StewardConfigError = class extends StewardError {
61
+ constructor(options = {}) {
62
+ super({
63
+ ...options,
64
+ code: options.code ?? "config_error",
65
+ httpStatus: 0,
66
+ category: "config",
67
+ retryable: false
68
+ });
69
+ this.name = "StewardConfigError";
70
+ }
71
+ };
72
+ //#endregion
73
+ export { StewardNetworkError as a, StewardError as i, StewardConfigError as n, StewardContractError as r, StewardApiError as t };