uvako-cli 0.4.0 → 0.6.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.
@@ -0,0 +1,357 @@
1
+ // Uvako örnek uygulaması — bağımlılık yok (Node.js 22+).
2
+ //
3
+ // Ne yapar:
4
+ // GET / Mağaza panelinden açılış (shop, timestamp, hmac imzalı). Kurulu değilse kurulumu başlatır.
5
+ // embedded=1 ile panelin içindeki iframe'de açılır: sayfa @uvako/app-bridge'i
6
+ // mağazadan yükler, sunucuya her istekte oturum belirteci (JWT) gönderir.
7
+ // GET /api/shop (Authorization: Bearer <oturum belirteci>) mağaza bilgisi
8
+ // POST /api/billing (Bearer) aylık ücret oluşturur; panel onay sayfasının yolunu döner
9
+ // GET /billing/return Ücret kararından sonra dönüş: durumu okur, panele geri gönderir
10
+ // GET /auth?shop=… OAuth kurulumunu başlatır: mağazanın onay ekranına gönderir.
11
+ // GET /auth/callback Onay dönüşü: hmac ve state doğrulanır, kod erişim anahtarıyla değiştirilir.
12
+ // POST /webhooks Mağaza olayları: X-Uvako-Hmac-Sha256 imzası doğrulanır.
13
+ // POST /webhooks/… KVKK/GDPR zorunlu konuları (customers/data_request, customers/redact, shop/redact).
14
+ //
15
+ // Ortam değişkenleri (`uvako app dev` hepsini kendisi verir):
16
+ // UVAKO_CLIENT_ID, UVAKO_CLIENT_SECRET Partner panelindeki uygulama kimliği ve sırrı
17
+ // UVAKO_APP_URL Uygulamanın adresi (yerelde http://localhost:3000)
18
+ // UVAKO_SCOPES İstenecek kapsamlar (virgülle)
19
+ // UVAKO_ADMIN_ORIGIN Yalnızca yerel geliştirme: mağazanın adresi (ör. http://demo.localhost:5174)
20
+ // UVAKO_SHOP Yalnızca yerel geliştirme: mağazanın platform adresi (ör. demo.localhost)
21
+ // UVAKO_EMBEDDED "0": uygulama panele gömülmez, kendi adresinde açılır (varsayılan gömülü)
22
+ // UVAKO_TEST_CHARGES "0": ücretler gerçek olur (varsayılan test: ödeme alınmaz)
23
+ // PORT Dinlenecek port
24
+ //
25
+ // Erişim anahtarları .uvako/tokens.json dosyasında durur (depoya koymayın; .gitignore'da).
26
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
27
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
28
+ import { createServer } from "node:http";
29
+
30
+ const PORT = Number(process.env.PORT || 3000);
31
+ const CLIENT_ID = process.env.UVAKO_CLIENT_ID || "";
32
+ const CLIENT_SECRET = process.env.UVAKO_CLIENT_SECRET || "";
33
+ const APP_URL = (process.env.UVAKO_APP_URL || `http://localhost:${PORT}`).replace(/\/+$/, "");
34
+ const SCOPES = process.env.UVAKO_SCOPES || "read_products";
35
+ const ADMIN_ORIGIN = process.env.UVAKO_ADMIN_ORIGIN || "";
36
+ const API_VERSION = "2026-10";
37
+ const TOKENS = process.env.UVAKO_TOKENS_FILE || ".uvako/tokens.json";
38
+ const EMBEDDED = process.env.UVAKO_EMBEDDED !== "0";
39
+ const TEST_CHARGES = process.env.UVAKO_TEST_CHARGES !== "0";
40
+
41
+ if (!CLIENT_ID || !CLIENT_SECRET) {
42
+ console.error("UVAKO_CLIENT_ID ve UVAKO_CLIENT_SECRET gerekli. Yerelde `uvako app dev` ile çalıştırın.");
43
+ process.exit(1);
44
+ }
45
+
46
+ // ---- güvenlik yardımcıları ---------------------------------------------------------------------
47
+
48
+ const safeEqual = (a, b) => {
49
+ const x = Buffer.from(String(a));
50
+ const y = Buffer.from(String(b));
51
+ return x.length === y.length && timingSafeEqual(x, y);
52
+ };
53
+
54
+ /** Uvako'nun yönlendirmelere eklediği hmac: diğer parametreler ada göre sıralı, "k=v" ve "&" ile. */
55
+ function validQuery(params) {
56
+ const hmac = params.get("hmac");
57
+ if (!hmac) return false;
58
+ const message = [...params.keys()].filter((k) => k !== "hmac").sort().map((k) => `${k}=${params.get(k)}`).join("&");
59
+ const expected = createHmac("sha256", CLIENT_SECRET).update(message).digest("hex");
60
+ const timestamp = Number(params.get("timestamp"));
61
+ // Eski bağlantılar tekrar kullanılamasın.
62
+ return safeEqual(hmac, expected) && Math.abs(Date.now() / 1000 - timestamp) < 300;
63
+ }
64
+
65
+ /** Webhook imzası: base64(HMAC-SHA256(ham gövde, client secret)). Gövde ayrıştırılmadan doğrulanır. */
66
+ const validWebhook = (raw, signature) => safeEqual(createHmac("sha256", CLIENT_SECRET).update(raw).digest("base64"), signature || "");
67
+
68
+ // Mağaza adresi yalnızca platform adresi olabilir; client secret'ı başka bir adrese göndermeyiz.
69
+ const validShop = (shop) => /^[a-z0-9][a-z0-9-]*\.(uvoo\.store|localhost)$/.test(shop || "") && (!shop.endsWith(".localhost") || ADMIN_ORIGIN !== "");
70
+ const shopOrigin = (shop) => (shop.endsWith(".localhost") ? ADMIN_ORIGIN : `https://${shop}`);
71
+
72
+ /**
73
+ * Oturum belirteci (panelin verdiği JWT): HS256, client secret ile imzalı, aud = client id, bir dakika
74
+ * geçerli; dest mağazanın adresidir. Çerez yerine her API isteğinde Authorization başlığıyla gelir.
75
+ * Doğrulanınca isteğin hangi mağazadan geldiği bilinir.
76
+ */
77
+ function verifySessionToken(header) {
78
+ const match = /^Bearer ([\w-]+)\.([\w-]+)\.([\w-]+)$/.exec(header || "");
79
+ if (!match) return null;
80
+ const [, head, body, signature] = match;
81
+ let alg, claims;
82
+ try {
83
+ alg = JSON.parse(Buffer.from(head, "base64url").toString("utf8")).alg;
84
+ claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
85
+ } catch {
86
+ return null;
87
+ }
88
+ if (alg !== "HS256") return null;
89
+ const expected = createHmac("sha256", CLIENT_SECRET).update(`${head}.${body}`).digest("base64url");
90
+ if (!safeEqual(expected, signature)) return null;
91
+ const now = Date.now() / 1000;
92
+ const leeway = 5;
93
+ if (claims.aud !== CLIENT_ID || !(now <= claims.exp + leeway) || now + leeway < claims.nbf || claims.iss !== `${claims.dest}/admin`) return null;
94
+ const shop = shopFromOrigin(claims.dest);
95
+ return shop ? { shop, claims } : null;
96
+ }
97
+
98
+ function shopFromOrigin(origin) {
99
+ try {
100
+ const u = new URL(origin);
101
+ if (u.protocol === "https:" && validShop(u.hostname)) return u.hostname;
102
+ // Yerel geliştirme: panel ADMIN_ORIGIN'de çalışır; mağaza `uvako app dev`in verdiği UVAKO_SHOP'tur.
103
+ const devShop = process.env.UVAKO_SHOP || u.hostname;
104
+ if (ADMIN_ORIGIN && u.origin === ADMIN_ORIGIN && validShop(devShop)) return devShop;
105
+ } catch {
106
+ // geçersiz adres
107
+ }
108
+ return "";
109
+ }
110
+
111
+ /** Uygulama yalnızca kendi mağazasının panelinde çerçevelenebilir (clickjacking'e karşı). */
112
+ const frameHeaders = (shop) => ({ "Content-Security-Policy": `frame-ancestors ${shop ? shopOrigin(shop) : "'none'"}` });
113
+ const panelApp = (shop) => `${shopOrigin(shop)}/admin/uygulamalar/ac/${CLIENT_ID}`;
114
+
115
+ function sign(value) {
116
+ return `${value}.${createHmac("sha256", CLIENT_SECRET).update(value).digest("base64url")}`;
117
+ }
118
+ function unsign(cookie) {
119
+ const i = String(cookie || "").lastIndexOf(".");
120
+ if (i < 1) return "";
121
+ const value = cookie.slice(0, i);
122
+ return safeEqual(sign(value), cookie) ? value : "";
123
+ }
124
+ const cookies = (req) => Object.fromEntries((req.headers.cookie || "").split(/;\s*/).filter(Boolean).map((c) => [c.split("=")[0], decodeURIComponent(c.split("=").slice(1).join("="))]));
125
+
126
+ // ---- erişim anahtarları -------------------------------------------------------------------------
127
+
128
+ function readTokens() {
129
+ try {
130
+ return JSON.parse(readFileSync(TOKENS, "utf8"));
131
+ } catch {
132
+ return {};
133
+ }
134
+ }
135
+ function saveToken(shop, entry) {
136
+ const all = readTokens();
137
+ if (entry) all[shop] = entry;
138
+ else delete all[shop];
139
+ mkdirSync(TOKENS.split("/").slice(0, -1).join("/") || ".", { recursive: true });
140
+ writeFileSync(TOKENS, JSON.stringify(all, null, 2), { mode: 0o600 });
141
+ }
142
+
143
+ /** Admin API çağrısı: https://<mağaza>/api/admin/2026-10/… */
144
+ async function adminApi(shop, path, { method = "GET", body } = {}) {
145
+ const entry = readTokens()[shop];
146
+ if (!entry) return { status: 401, body: {} };
147
+ const response = await fetch(`${shopOrigin(shop)}/api/admin/${API_VERSION}${path}`, {
148
+ method,
149
+ headers: { "X-Uvako-Access-Token": entry.token, Accept: "application/json", ...(body ? { "Content-Type": "application/json" } : {}) },
150
+ body: body ? JSON.stringify(body) : undefined,
151
+ redirect: "error",
152
+ });
153
+ return { status: response.status, body: await response.json().catch(() => ({})) };
154
+ }
155
+
156
+ // ---- yönlendirmeler -----------------------------------------------------------------------------
157
+
158
+ function send(res, status, body, headers = {}) {
159
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "X-Content-Type-Options": "nosniff", ...headers });
160
+ res.end(body);
161
+ }
162
+ const escape = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
163
+
164
+ function startInstall(res, shop) {
165
+ const state = randomBytes(16).toString("hex");
166
+ const authorize = new URL(`${shopOrigin(shop)}/admin/oauth/authorize`);
167
+ authorize.searchParams.set("client_id", CLIENT_ID);
168
+ authorize.searchParams.set("scope", SCOPES);
169
+ authorize.searchParams.set("redirect_uri", `${APP_URL}/auth/callback`);
170
+ authorize.searchParams.set("state", state);
171
+ // state, isteği başlatan tarayıcıya bağlanır (CSRF'e karşı).
172
+ send(res, 302, "", { Location: authorize.href, "Set-Cookie": `uvako_state=${sign(`${shop}|${state}`)}; HttpOnly; Path=/auth; Max-Age=600; SameSite=Lax` });
173
+ }
174
+
175
+ const missingScopes = (granted) => SCOPES.split(",").map((s) => s.trim()).filter((s) => s && !granted.includes(s) && !(s.startsWith("read_") && granted.includes(`write_${s.slice(5)}`)));
176
+
177
+ /** Panelin içindeki sayfa. Köprü mağazanın kendi adresinden yüklenir; veri yalnızca belirteçli API'den gelir. */
178
+ function embeddedPage(res, shop) {
179
+ const origin = shopOrigin(shop);
180
+ send(res, 200, `<!doctype html>
181
+ <html lang="tr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
182
+ <title>Uvako örnek uygulaması</title>
183
+ <style>body{font-family:system-ui,sans-serif;margin:24px;color:#1f2933}button{margin:0 8px 8px 0;padding:8px 14px;border-radius:8px;border:1px solid #cbd2d9;background:#fff;cursor:pointer}pre{background:#f5f7fa;padding:12px;border-radius:8px;white-space:pre-wrap}</style>
184
+ <script src="${escape(origin)}/admin/app-bridge.js"></script></head>
185
+ <body>
186
+ <h1>Uvako örnek uygulaması</h1>
187
+ <p id="shop">Mağaza bilgisi yükleniyor…</p>
188
+ <p><button id="toast">Bildirim göster</button><button id="products">Ürünler sayfasına git</button><button id="modal">Onay penceresi</button><button id="plan">Pro plana geç (aylık ücret)</button></p>
189
+ <pre id="log"></pre>
190
+ <script>
191
+ const log = (text) => { document.getElementById("log").textContent += text + "\\n"; };
192
+ const app = UvakoAppBridge.createApp();
193
+ app.title.set("Ana sayfa");
194
+ app.fetch("/api/shop").then((r) => r.json()).then((b) => {
195
+ if (b.error) throw new Error(b.error);
196
+ document.getElementById("shop").textContent = "Mağaza: " + b.name;
197
+ log("Oturum belirteci sunucuda doğrulandı (" + b.dest + ").");
198
+ }).catch((e) => log("Hata: " + e.message));
199
+ document.getElementById("toast").onclick = () => app.toast.show("Örnek uygulamadan merhaba");
200
+ document.getElementById("products").onclick = () => app.redirect.toAdmin("/urunler");
201
+ document.getElementById("modal").onclick = () => app.modal.open({ title: "Emin misiniz?", message: "Bu yalnızca bir örnek.", primary: "Evet", secondary: "Vazgeç" }).then((a) => log("Seçim: " + a));
202
+ document.getElementById("plan").onclick = () => app.fetch("/api/billing", { method: "POST" }).then((r) => r.json()).then((b) => {
203
+ if (!b.confirmation_url) throw new Error(b.error || "ücret oluşturulamadı");
204
+ // Onay panelde: yalnızca panel içi yol verilir, panel kendisi gider.
205
+ app.redirect.toAdmin(new URL(b.confirmation_url).pathname);
206
+ }).catch((e) => log("Hata: " + e.message));
207
+ </script>
208
+ </body></html>`, frameHeaders(shop));
209
+ }
210
+
211
+ function json(res, status, payload) {
212
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
213
+ res.end(JSON.stringify(payload));
214
+ }
215
+
216
+ /** /api/*: yalnızca geçerli oturum belirteciyle; çerez kabul edilmez. */
217
+ async function api(req, res, url) {
218
+ const session = verifySessionToken(req.headers.authorization);
219
+ if (!session) return json(res, 401, { error: "oturum belirteci geçersiz" });
220
+ const { shop, claims } = session;
221
+ if (req.method === "GET" && url.pathname === "/api/shop") {
222
+ const { status, body } = await adminApi(shop, "/shop");
223
+ if (status !== 200) return json(res, 502, { error: `Admin API ${status}` });
224
+ return json(res, 200, { name: body.shop?.name ?? shop, dest: claims.dest });
225
+ }
226
+ if (req.method === "POST" && url.pathname === "/api/billing") {
227
+ const back = new URL(`${APP_URL}/billing/return`);
228
+ back.searchParams.set("shop", shop);
229
+ const { status, body } = await adminApi(shop, "/recurring_application_charges", {
230
+ method: "POST",
231
+ body: { recurring_application_charge: { name: "Pro plan", price: "99.00", return_url: back.href, trial_days: 0, test: TEST_CHARGES } },
232
+ });
233
+ if (status !== 201) return json(res, 422, { error: body.errors || `Admin API ${status}` });
234
+ console.log(`[ücret] ${shop} · ${body.recurring_application_charge.id} onay bekliyor`);
235
+ return json(res, 200, { confirmation_url: body.recurring_application_charge.confirmation_url });
236
+ }
237
+ json(res, 404, { error: "bulunamadı" });
238
+ }
239
+
240
+ /** Ücret kararından sonra (panel return_url?charge_id=… ile gönderir): durumu okur, panele döner. */
241
+ async function billingReturn(res, url) {
242
+ const shop = url.searchParams.get("shop");
243
+ const id = url.searchParams.get("charge_id") || "";
244
+ if (!validShop(shop) || !/^[0-9a-f-]{36}$/.test(id)) return send(res, 400, "Geçersiz dönüş.");
245
+ const { body } = await adminApi(shop, `/recurring_application_charges/${id}`);
246
+ console.log(`[ücret] ${shop} · ${id}: ${body.recurring_application_charge?.status ?? "okunamadı"}`);
247
+ send(res, 302, "", { Location: EMBEDDED ? panelApp(shop) : "/" });
248
+ }
249
+
250
+ async function home(req, res, url) {
251
+ let shop = "";
252
+ if (url.searchParams.has("hmac")) {
253
+ if (!validQuery(url.searchParams)) return send(res, 400, "İmza geçersiz ya da bağlantının süresi doldu.");
254
+ shop = url.searchParams.get("shop");
255
+ } else {
256
+ shop = unsign(cookies(req).uvako_shop);
257
+ }
258
+ if (!validShop(shop)) return send(res, 200, `<h1>Uvako örnek uygulaması</h1><p>Uygulamayı mağaza panelinden açın.</p>`, frameHeaders(""));
259
+ const entry = readTokens()[shop];
260
+ if (url.searchParams.get("embedded") === "1") {
261
+ // Panel yalnızca kurulu uygulamayı çerçeveler; kurulum bilgisi burada yoksa çerçeve içinde OAuth yapılmaz.
262
+ if (!entry || missingScopes(entry.scope.split(",")).length) return send(res, 200, "<p>Kurulum bilgisi bulunamadı. Uygulamayı Uygulamalar sayfasından kaldırıp yeniden kurun.</p>", frameHeaders(shop));
263
+ return embeddedPage(res, shop);
264
+ }
265
+ // Kurulu değilse ya da yeni sürüm ek kapsam istiyorsa onay ekranına gönderilir.
266
+ if (!entry || missingScopes(entry.scope.split(",")).length) return startInstall(res, shop);
267
+ // Gömülü uygulama kendi adresinde açılmaz: panelin içindeki sayfasına döner.
268
+ if (EMBEDDED && url.searchParams.has("hmac")) return send(res, 302, "", { Location: panelApp(shop) });
269
+ const { status, body } = await adminApi(shop, "/shop");
270
+ if (status === 401) {
271
+ saveToken(shop, null);
272
+ return startInstall(res, shop);
273
+ }
274
+ send(res, 200, `<h1>Uvako örnek uygulaması</h1><p>Mağaza: <b>${escape(body.shop?.name ?? shop)}</b></p><p>Kapsamlar: ${escape(entry.scope)}</p>`, {
275
+ "Set-Cookie": `uvako_shop=${sign(shop)}; HttpOnly; Path=/; Max-Age=86400; SameSite=Lax`,
276
+ });
277
+ }
278
+
279
+ async function callback(req, res, url) {
280
+ const q = url.searchParams;
281
+ if (q.get("error")) return send(res, 200, "<p>Kurulum iptal edildi.</p>");
282
+ if (!validQuery(q)) return send(res, 400, "İmza geçersiz ya da bağlantının süresi doldu.");
283
+ const shop = q.get("shop");
284
+ const [stateShop, state] = unsign(cookies(req).uvako_state).split("|");
285
+ if (!validShop(shop) || stateShop !== shop || !state || !safeEqual(state, q.get("state") || "")) return send(res, 400, "state doğrulanamadı; kurulumu yeniden başlatın.");
286
+ const response = await fetch(`${shopOrigin(shop)}/api/admin/oauth/access_token`, {
287
+ method: "POST",
288
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
289
+ body: JSON.stringify({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: q.get("code") }),
290
+ redirect: "error",
291
+ });
292
+ const token = await response.json().catch(() => ({}));
293
+ if (!response.ok) return send(res, 502, `Kod değiştirilemedi: ${escape(token.error_description || response.status)}`);
294
+ saveToken(shop, { token: token.access_token, scope: token.scope, installedAt: new Date().toISOString() });
295
+ console.log(`[kurulum] ${shop} · kapsamlar: ${token.scope}`);
296
+ send(res, 302, "", { Location: EMBEDDED ? panelApp(shop) : "/", "Set-Cookie": [`uvako_shop=${sign(shop)}; HttpOnly; Path=/; Max-Age=86400; SameSite=Lax`, "uvako_state=; Path=/auth; Max-Age=0"] });
297
+ }
298
+
299
+ const seen = new Set();
300
+
301
+ function webhook(req, res) {
302
+ const chunks = [];
303
+ let size = 0;
304
+ req.on("data", (chunk) => {
305
+ size += chunk.length;
306
+ if (size > 1 << 20) req.destroy();
307
+ else chunks.push(chunk);
308
+ });
309
+ req.on("end", () => {
310
+ const raw = Buffer.concat(chunks);
311
+ if (!validWebhook(raw, req.headers["x-uvako-hmac-sha256"])) {
312
+ console.warn("[webhook] imza geçersiz, reddedildi");
313
+ return send(res, 401, "imza geçersiz");
314
+ }
315
+ // Yeniden denemelerde X-Uvako-Webhook-Id aynı kalır: aynı teslimat bir kez işlenir.
316
+ const id = req.headers["x-uvako-webhook-id"];
317
+ const topic = req.headers["x-uvako-topic"];
318
+ const shop = req.headers["x-uvako-shop-domain"];
319
+ if (!seen.has(id)) {
320
+ seen.add(id);
321
+ const body = JSON.parse(raw.toString("utf8"));
322
+ console.log(`[webhook] ${topic} · ${shop} · ${body.order_number ?? body.title ?? body.name ?? body.id ?? ""}`);
323
+ if (topic === "app/uninstalled") saveToken(shop, null);
324
+ // KVKK/GDPR zorunlu konuları: bu örnek müşteri verisi saklamaz; gerçek uygulama burada
325
+ // veriyi mağazaya iletir (data_request) ya da siler (redact). 30 gün içinde tamamlanmalı.
326
+ if (topic === "customers/data_request") console.log(`[kvkk] ${shop}: ${body.customer?.email ?? body.customer?.id} için veri talebi`);
327
+ if (topic === "customers/redact") console.log(`[kvkk] ${shop}: ${body.customer?.email ?? body.customer?.id} silinecek (${body.orders_to_redact?.length ?? 0} sipariş)`);
328
+ if (topic === "shop/redact") {
329
+ saveToken(shop, null);
330
+ console.log(`[kvkk] ${shop}: mağazanın tüm verisi silinecek`);
331
+ }
332
+ }
333
+ send(res, 200, "ok");
334
+ });
335
+ }
336
+
337
+ export const server = createServer((req, res) => {
338
+ const url = new URL(req.url, APP_URL);
339
+ const route = `${req.method} ${url.pathname}`;
340
+ const done = (promise) => promise.catch((error) => {
341
+ console.error(error);
342
+ send(res, 500, "Bir hata oluştu.");
343
+ });
344
+ if (route === "GET /") return done(home(req, res, url));
345
+ if (route === "GET /auth") {
346
+ const shop = url.searchParams.get("shop");
347
+ return validShop(shop) ? startInstall(res, shop) : send(res, 400, "shop parametresi bir Uvako mağazası olmalı.");
348
+ }
349
+ if (route === "GET /auth/callback") return done(callback(req, res, url));
350
+ if (url.pathname.startsWith("/api/")) return done(api(req, res, url));
351
+ if (route === "GET /billing/return") return done(billingReturn(res, url));
352
+ // /webhooks ve KVKK uçları (/webhooks/customers/redact …) aynı doğrulamadan geçer.
353
+ if (req.method === "POST" && (url.pathname === "/webhooks" || url.pathname.startsWith("/webhooks/"))) return webhook(req, res);
354
+ send(res, 404, "Bulunamadı");
355
+ });
356
+
357
+ server.listen(PORT, () => console.log(`Uygulama ${APP_URL} adresinde çalışıyor.`));