jskelet 0.6.2 → 0.6.3

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 (155) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +620 -596
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +130 -130
  5. package/docs/01-baslangic.md +291 -291
  6. package/docs/02-mimari.md +310 -309
  7. package/docs/03-routing.md +515 -515
  8. package/docs/04-render-ve-sablonlar.md +661 -661
  9. package/docs/05-islands.md +486 -486
  10. package/docs/06-cache.md +1443 -1423
  11. package/docs/07-yapilandirma.md +12 -6
  12. package/docs/08-build.md +429 -428
  13. package/docs/09-dev-araclari.md +364 -364
  14. package/docs/10-dagitim.md +338 -338
  15. package/docs/12-panel-ve-oturum.md +478 -478
  16. package/docs/README.md +83 -83
  17. package/docs/en/01-getting-started.md +298 -298
  18. package/docs/en/02-architecture.md +329 -328
  19. package/docs/en/03-routing.md +531 -531
  20. package/docs/en/04-rendering.md +669 -669
  21. package/docs/en/05-islands.md +497 -497
  22. package/docs/en/06-caching.md +1453 -1431
  23. package/docs/en/07-configuration.md +1219 -1214
  24. package/docs/en/08-build.md +447 -446
  25. package/docs/en/09-dev-tools.md +373 -373
  26. package/docs/en/10-deployment.md +340 -340
  27. package/docs/en/11-migration.md +398 -398
  28. package/docs/en/12-dashboards-and-sessions.md +488 -488
  29. package/docs/en/README.md +87 -87
  30. package/package.json +137 -137
  31. package/src/build/ensure-build.mjs +19 -19
  32. package/src/build/paths.mjs +153 -153
  33. package/src/build/resolve-peer.mjs +36 -36
  34. package/src/build/tasks/client.mjs +349 -349
  35. package/src/build/tasks/css.mjs +235 -235
  36. package/src/build/tasks/fonts.mjs +146 -146
  37. package/src/build/tasks/icons.mjs +357 -357
  38. package/src/build/tasks/images.mjs +244 -244
  39. package/src/build/tasks/precompress.mjs +78 -78
  40. package/src/build/tasks/templates.mjs +20 -20
  41. package/src/client/admin/i18n.js +764 -764
  42. package/src/client/admin/login.html +74 -74
  43. package/src/client/admin/panel.css +809 -809
  44. package/src/client/admin/panel.html +495 -495
  45. package/src/client/admin/panel.js +1251 -1251
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +745 -745
  48. package/src/client/devtools/seo.js +628 -628
  49. package/src/client/dom.js +95 -95
  50. package/src/client/form.js +192 -192
  51. package/src/client/index.js +45 -45
  52. package/src/client/registry.js +305 -305
  53. package/src/client/safe-image.js +91 -91
  54. package/src/client/shared-cookie.js +225 -225
  55. package/src/client/store.js +36 -36
  56. package/src/client/swap.js +188 -188
  57. package/src/compile/codegen.js +336 -336
  58. package/src/compile/compile-all.js +149 -149
  59. package/src/compile/errors.js +66 -66
  60. package/src/compile/expr.js +409 -409
  61. package/src/compile/index.js +17 -17
  62. package/src/compile/parse.js +541 -541
  63. package/src/compile/resolve.js +211 -211
  64. package/src/compile/scan-exports.js +51 -51
  65. package/src/config/defaults.js +17 -1
  66. package/src/config/index.js +13 -0
  67. package/src/config/pattern.js +107 -107
  68. package/src/generate.mjs +163 -163
  69. package/src/http/control-flow.js +71 -71
  70. package/src/http/cookies-entry.js +21 -21
  71. package/src/http/cookies.js +277 -277
  72. package/src/http/request-cache.js +46 -46
  73. package/src/http/request-context.js +165 -165
  74. package/src/http/shared-cookie.js +178 -178
  75. package/src/index.js +101 -101
  76. package/src/init.mjs +230 -230
  77. package/src/migrate/apply.mjs +262 -262
  78. package/src/migrate/babel.mjs +79 -79
  79. package/src/migrate/classify.mjs +155 -155
  80. package/src/migrate/config.mjs +126 -126
  81. package/src/migrate/fs-walk.mjs +191 -191
  82. package/src/migrate/parse.mjs +26 -26
  83. package/src/migrate/scan.mjs +177 -177
  84. package/src/migrate/transform/expr-source.mjs +168 -168
  85. package/src/migrate/transform/island.mjs +67 -67
  86. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  87. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  88. package/src/migrate/transform/page-split.mjs +435 -435
  89. package/src/migrate/write.mjs +81 -81
  90. package/src/migrate.mjs +171 -171
  91. package/src/runtime/alias-hooks.mjs +119 -119
  92. package/src/runtime/register.mjs +4 -4
  93. package/src/server/admin/actions.js +229 -229
  94. package/src/server/admin/auth.js +125 -125
  95. package/src/server/admin/event-log.js +151 -151
  96. package/src/server/admin/gate.js +209 -209
  97. package/src/server/admin/inventory.js +188 -188
  98. package/src/server/admin/mount.js +56 -56
  99. package/src/server/admin/router.js +216 -216
  100. package/src/server/admin/snapshot.js +241 -241
  101. package/src/server/assets.js +147 -147
  102. package/src/server/auth/handoff.js +309 -309
  103. package/src/server/cache-blob.js +70 -0
  104. package/src/server/cache-deps.js +42 -42
  105. package/src/server/cache-vary.js +113 -113
  106. package/src/server/cloudflare.js +607 -607
  107. package/src/server/create-app.js +366 -366
  108. package/src/server/data-cache.js +553 -462
  109. package/src/server/dev/report.js +485 -485
  110. package/src/server/dev/socket.js +170 -170
  111. package/src/server/dev/version-check.mjs +139 -139
  112. package/src/server/disk-cache.js +233 -0
  113. package/src/server/ejs-adapter.js +59 -59
  114. package/src/server/html-cache.js +1196 -1122
  115. package/src/server/image-optimizer.js +500 -407
  116. package/src/server/logs/access-middleware.js +66 -66
  117. package/src/server/logs/file-sink.js +193 -66
  118. package/src/server/logs/pipeline.js +165 -158
  119. package/src/server/logs/s3-put.js +214 -214
  120. package/src/server/logs/s3-sink.js +112 -112
  121. package/src/server/metadata.js +102 -102
  122. package/src/server/middleware/compression.js +205 -205
  123. package/src/server/middleware/csrf.js +134 -134
  124. package/src/server/middleware/dev-gate.js +75 -75
  125. package/src/server/middleware/headers.js +37 -37
  126. package/src/server/middleware/redirects.js +32 -32
  127. package/src/server/middleware/robots-txt.js +341 -341
  128. package/src/server/middleware/static-precompressed.js +121 -100
  129. package/src/server/middleware/trailing-slash.js +53 -53
  130. package/src/server/middleware/upstream-proxy.js +141 -141
  131. package/src/server/og-image.js +356 -356
  132. package/src/server/port-guard.js +255 -255
  133. package/src/server/prewarm.js +1082 -1058
  134. package/src/server/redis.js +588 -569
  135. package/src/server/render.js +4 -4
  136. package/src/server/router.js +157 -157
  137. package/src/server/status-page.js +265 -265
  138. package/src/server/upstream-limiter.js +376 -376
  139. package/src/server/upstream-tracking.js +166 -166
  140. package/src/shared/cookie-domain.js +66 -66
  141. package/src/start.mjs +22 -22
  142. package/src/templates/layout.ejs +30 -30
  143. package/src/templates/layout.jsk +30 -30
  144. package/src/version.mjs +31 -31
  145. package/src/views/components/loader.js +101 -101
  146. package/src/views/helpers/html.js +102 -102
  147. package/src/views/helpers/tags.js +375 -375
  148. package/types/config/defaults.d.ts +15 -1
  149. package/types/config/index.d.ts +8 -0
  150. package/types/server/cache-blob.d.ts +13 -0
  151. package/types/server/data-cache.d.ts +9 -0
  152. package/types/server/disk-cache.d.ts +36 -0
  153. package/types/server/html-cache.d.ts +26 -3
  154. package/types/server/logs/file-sink.d.ts +16 -5
  155. package/types/server/redis.d.ts +2 -1
@@ -1,478 +1,478 @@
1
- # 12 — Panel, oturum ve kişiye özel sayfalar
2
-
3
- JSkelet'in ağırlık merkezi public, önbelleklenebilir sayfalar. Bir panel ise
4
- tam ters eksende durur: her ziyaretçiye farklı HTML, önbellek yok, yoğun
5
- etkileşim. Bu belge o ekseni anlatır — `private: true`, oturum cookie'leri,
6
- CSRF, fragment uçları ve parça takası.
7
-
8
- Kapsam dışında bırakılan bir şey var ve bilinçli: **canlı veri taşıması**.
9
- SSE, WebSocket ya da aralıklı sorgu arasındaki seçim uygulamanın; framework
10
- yalnızca "şu parçayı sunucudan tazele" adımını veriyor. Sıkıştırma katmanı
11
- `text/event-stream` yanıtlarını ve başlıklarını kendisi yazan akışları
12
- atladığı için SSE'yi elle kurmak da bir şeyi bozmuyor.
13
-
14
- Çalışan karşılığı `examples/dashboard/`; bu belgedeki kod parçalarının kaynağı
15
- orası.
16
-
17
- ## Neden ayrı bir yol gerekiyor
18
-
19
- HTML önbelleğinin anahtarı yalnızca yol ve query:
20
-
21
- ```
22
- `${req.path}?${new URLSearchParams(query).toString()}`
23
- ```
24
-
25
- Kimlik anahtarın parçası değil. Yani oturuma bağlı bir sayfa normal `route()`
26
- ile kaydedilirse, ilk isteyenin HTML'i o TTL boyunca **herkese** servis edilir.
27
- Bu hata hiçbir yerde patlamaz: sayfa çalışır, testler geçer, sorun yalnızca
28
- ikinci kullanıcı geldiğinde ve genelde üretimde görünür.
29
-
30
- ## `private: true`
31
-
32
- ```js
33
- export default function register(app, { route, redirect }) {
34
- app.get(
35
- "/panel",
36
- route(
37
- async ({ req }) => {
38
- const user = currentUser(req);
39
- if (!user) redirect("/giris?next=%2Fpanel");
40
-
41
- return { view: "pages/overview", data: { user } };
42
- },
43
- { private: true },
44
- ),
45
- );
46
- }
47
- ```
48
-
49
- Bayrağın yaptığı işler:
50
-
51
- | Davranış | Public `route()` | `private: true` |
52
- | --- | --- | --- |
53
- | HTML önbelleği | TTL varsa açık | Kapalı, açılamaz |
54
- | `cache.html` deseni | TTL'i ezer | Yok sayılır |
55
- | `Cache-Control` | `public, s-maxage=…` | `private, no-store` |
56
- | `Vary` | `Accept-Encoding` | `Cookie, Accept-Encoding` |
57
- | ETag | Var | Yok |
58
- | `X-JSkelet-Cache` | `HIT`/`STALE`/`MISS` | Yazılmaz |
59
-
60
- ETag'in düşmesi ayrıntı gibi görünüyor ama değil: kullanıcıya özel bir gövdenin
61
- güçlü ETag'i, o kullanıcıya özgü bir parmak izidir ve `no-store`'a uymayan bir
62
- katmanda kimlik ayrımı için kullanılabilir.
63
-
64
- Kişiye özel bir sayfadan atılan yönlendirme de önbelleklenmez. "Giriş yapmalısın"
65
- kararı oturuma bağlı; saklanması, giriş yapmış kullanıcının da login sayfasına
66
- atılması demek.
67
-
68
- ## Bayrağı unutursanız
69
-
70
- Framework kimliğe dokunan erişimleri izliyor. Controller'a giden `req`, şu
71
- okumaları işaretleyen ince bir Proxy ile sarılı:
72
-
73
- - `req.headers.cookie`, `req.headers.authorization`,
74
- `req.headers["proxy-authorization"]`
75
- - `req.get("Cookie")` / `req.header("Authorization")`
76
- - `req.cookies`, `req.signedCookies`, `req.session`, `req.user`
77
- - `parseCookies(req)` ve `getSignedCookie(req, …)` (doğrudan bildiriyorlar)
78
-
79
- İşaretlenen bir render önbelleğe **yazılmaz**. Üretimde yanıt `no-store` ile
80
- gider ve şu satır loglanır:
81
-
82
- ```
83
- [render] /panel kimliğe bağlı veri okudu (req.headers.cookie), önbelleğe
84
- alınmadı. Route 'private: true' ile kaydedilmeli.
85
- ```
86
-
87
- Development'ta aynı durum isteği bir hatayla düşürür. Sessiz kalmamasının
88
- sebebi basit: bu hata çalışan bir sayfa üretiyor, yani kendi başına asla fark
89
- edilmiyor.
90
-
91
- `csrfField()` de aynı işaretlemeyi yapıyor. Token basan bir sayfa önbellekten
92
- dönemez — dönerse tüm ziyaretçiler aynı token'ı paylaşır ve çift gönderim
93
- kontrolü hiçbir şey doğrulamaz.
94
-
95
- ## Oturum: imzalı cookie
96
-
97
- Framework kimlik sağlamıyor. Verdiği tek şey "bu değeri ben yazdım,
98
- kurcalanmamış" garantisi:
99
-
100
- ```js
101
- import { clearCookie, getSignedCookie, setSignedCookie } from "jskelet/cookies";
102
-
103
- export function startSession(res, username) {
104
- setSignedCookie(res, "dash_session", username, { maxAge: 60 * 60 * 8 });
105
- }
106
-
107
- export function currentUser(req) {
108
- const username = getSignedCookie(req, "dash_session");
109
- return username ? findUser(username) : null;
110
- }
111
-
112
- export function endSession(res) {
113
- clearCookie(res, "dash_session");
114
- }
115
- ```
116
-
117
- İmza HMAC-SHA256, karşılaştırma sabit zamanlı. İmza uymuyorsa `getSignedCookie`
118
- `null` döner — kurcalanmış bir değer "belki geçerlidir" diye kullanılmaz.
119
-
120
- Sır `security.cookieSecret` ya da `JSKELET_SECRET` ortam değişkeninden gelir.
121
- Sır yoksa imzalı API **hata verir**; "yapılandırma hatası siteyi düşürmez"
122
- kuralı burada geçerli değil, çünkü sessiz alternatif imzasız bir cookie'ye
123
- güvenmek olurdu.
124
-
125
- Varsayılanlar kısıtlayıcı tarafta: `HttpOnly`, `SameSite=Lax`, development
126
- dışında `Secure`, `Path=/`. `SameSite=Lax` tek başına CSRF'in büyük kısmını
127
- kapatıyor — cookie çapraz site POST'larında hiç gönderilmiyor.
128
-
129
- Cookie **şifrelenmiyor**, imzalanıyor. Değer okunabilir; gizli kalması gereken
130
- veriyi değil, onun kimliğini koyun.
131
-
132
- ## Alt alan adları: paylaşımlı cookie
133
-
134
- Host tabanlı i18n (`tr.example.com` / `en.example.com`) oturumu alt alanlar
135
- arasında paylaşmak ister. Çözüm **kısa session id** + isteğe bağlı
136
- `Domain=.example.com` — JWT veya büyük access token paylaşımlı cookie'ye
137
- konmaz (`large token ≠ shared cookie`).
138
-
139
- ```js
140
- // jskelet.config.mjs
141
- export default {
142
- brand: {
143
- sharedCookieRoots: [".investvio.com", ".localhost"],
144
- },
145
- auth: {
146
- crossSubdomainHandoff: {
147
- allowedCookieNames: ["sid"], // zorunlu allowlist
148
- },
149
- },
150
- };
151
- ```
152
-
153
- ### Sunucu
154
-
155
- ```js
156
- import { writeSharedCookie } from "jskelet/cookies";
157
-
158
- export function startSession(res, req, sessionId) {
159
- const result = writeSharedCookie(res, "sid", sessionId, {
160
- req,
161
- maxAge: 60 * 60 * 8,
162
- });
163
- // result.ok === false → result.handoff; istemci handoff denemeli
164
- return result;
165
- }
166
- ```
167
-
168
- `Secure` **protokole** bakılır (`https` / `x-forwarded-proto`), `NODE_ENV`'e
169
- değil. Domain, isteğin Host'u `sharedCookieRoots` ile eşleşince yazılır.
170
- Değer ~512 baytı aşarsa yazım reddedilir ve `handoff: true` döner.
171
-
172
- ### İstemci
173
-
174
- ```js
175
- import {
176
- writeSharedCookie,
177
- createHandoffUrl,
178
- handoffViaWindowName,
179
- consumeWindowNameHandoff,
180
- } from "jskelet/client";
181
-
182
- const result = writeSharedCookie("sid", sessionId, {
183
- roots: [".investvio.com", ".localhost"],
184
- maxAge: 60 * 60 * 8,
185
- });
186
-
187
- if (!result.ok && result.handoff) {
188
- const url = await createHandoffUrl({
189
- name: "sid",
190
- value: sessionId,
191
- next: "https://tr.investvio.com/panel",
192
- });
193
- if (url) location.assign(url);
194
- else handoffViaWindowName("https://tr.investvio.com/panel", {
195
- name: "sid",
196
- value: sessionId,
197
- });
198
- }
199
-
200
- // Hedef host'ta (layout / island bootstrap):
201
- consumeWindowNameHandoff();
202
- ```
203
-
204
- `roots` verilmezse `<html data-jskelet-cookie-roots=".investvio.com,.localhost">`
205
- okunur. Yazımdan sonra **read-back** yapılır; tarayıcı Domain'i reddettiyse
206
- `handoff: true`.
207
-
208
- ### Handoff bileti
209
-
210
- `auth.crossSubdomainHandoff` açıkken:
211
-
212
- 1. `POST /_jskelet/auth/handoff` `{ name, value, next }` → `{ url }` (`?handoff=` ekli).
213
- Mint, CSRF middleware'inden **sonra** mount edilir; `name`
214
- `allowedCookieNames` içinde ve RFC 6265 token olmalı.
215
- 2. Hedef host'ta GET middleware bileti tek kullanımlık tüketir, cookie yazar
216
- (önce shared Domain, olmazsa host-only), `handoff` query'siz 303
217
-
218
- `next` yalnızca aynı `sharedCookieRoots` altındaki host'lara izinli. Bilet
219
- ~60 sn, süreç belleğinde; bekleyen bilet ve IP başına mint sınırı vardır.
220
- JWT URL'ye konmaz.
221
-
222
- `window.name` köprüsü cookie'siz yedek: kaynakta `handoffViaWindowName`,
223
- hedeefte `consumeWindowNameHandoff`. Cross-origin tab'da `window.name`
224
- okunabilir kalır — mümkünse sunucu handoff tercih edin.
225
-
226
- ## CSRF
227
-
228
- Gövdeyi framework ayrıştırıyor (`express.urlencoded` + `express.json`), yani
229
- state değiştiren istekleri kabul eden katman o. Koruma iki katmanlı.
230
-
231
- ### Katman 1 — origin kontrolü (varsayılan açık)
232
-
233
- `Origin` kendi host'umuzla uyuşmuyorsa ya da `Sec-Fetch-Site: cross-site`
234
- geldiyse güvenli olmayan metotlar 403 alır. **Başlıkların hiçbiri yoksa istek
235
- geçer**: tarayıcılar çapraz origin bir POST'ta `Origin`'i her zaman gönderir,
236
- webhook'lar ve sunucudan sunucuya çağrılar hiç göndermez. Bu ayrım korumayı
237
- açık bırakırken entegrasyonları bozmuyor.
238
-
239
- ```js
240
- security: {
241
- csrf: {
242
- // Ayrı bir alan adından gelen panel gibi meşru istisnalar.
243
- allowedOrigins: ["https://admin.example.com"],
244
- // Tarayıcıdan gelmeyen uçlar.
245
- exclude: ["/webhook/:path*"],
246
- },
247
- }
248
- ```
249
-
250
- ### Katman 2 — çift gönderim token'ı (opsiyonel)
251
-
252
- `security.csrf.token: true` ile açılır. Formlar token'ı `csrfField()` ile basar:
253
-
254
- ```ejs
255
- <form method="post" action="/panel/notlar">
256
- <%- csrfField() %>
257
- …
258
- </form>
259
- ```
260
-
261
- Token'ı **middleware üretmiyor**, `csrfField()` üretiyor — yani gerçekten bir
262
- forma basıldığı anda. Sebebi somut: token her yanıtta yazılsaydı public ve
263
- önbelleklenebilir bir sayfa da `Set-Cookie` taşırdı, bir CDN o yanıtı saklardı
264
- ve tüm ziyaretçiler aynı token'ı paylaşırdı.
265
-
266
- Sunucu tarafında token, imzalı cookie ile gönderilen alanın eşleşmesini
267
- istiyor; alan yerine `X-CSRF-Token` başlığı da kabul ediliyor.
268
-
269
- `csrfField()` `security.csrf.token` kapalıyken boş string döner, yani şablon
270
- her koşulda render edilebilir.
271
-
272
- ## Fragment uçları
273
-
274
- `fragment()` politikası sabit bir parça yanıtı üretir: layout basılmaz, yanıt
275
- `private, no-store` ve ETag'siz gider, HTML önbelleğine hiç uğramaz.
276
-
277
- ```js
278
- export default function register(app, { fragment }) {
279
- app.get(
280
- "/_fragment/siparisler",
281
- fragment(async ({ req, query }) => {
282
- const user = currentUser(req);
283
- if (!user) return { view: "partials/session-expired", status: 401 };
284
-
285
- return {
286
- view: "partials/order-table",
287
- data: { siparisler: getOrders(user.username, Number(query.sayfa ?? 1)) },
288
- };
289
- }),
290
- );
291
- }
292
- ```
293
-
294
- Controller ya `{ view, data?, status? }` ya doğrudan bir HTML string döner.
295
- Hata durumunda tüm sayfa yerine küçük bir parça döner
296
- (`<div role="alert" data-fragment-error>`): takas edilen bölge bir hata
297
- sayfasının tamamını içine almamalı.
298
-
299
- Aynı şablonun sayfanın içinde de fragment ucunda da kullanılması esas fikir —
300
- işaretlemenin tek kaynağı sunucuda kalır, istemci ikinci bir şablon taşımaz.
301
-
302
- ## İstemci: parça takası
303
-
304
- ```js
305
- import { registerAll, start, startForms, startSwapLinks } from "jskelet/client";
306
-
307
- registerAll({ "live-clock": () => import("../islands/live-clock.js") });
308
-
309
- start();
310
- startSwapLinks();
311
- startForms();
312
- ```
313
-
314
- `startSwapLinks()` `data-swap` taşıyan bağlantıları bağlar:
315
-
316
- ```html
317
- <a href="/_fragment/siparisler?sayfa=2" data-swap="#siparisler">Sonraki</a>
318
- ```
319
-
320
- `href` gerçek bir URL olduğu için JS yoksa bağlantı normal gezinmeye düşer.
321
- Programatik kullanım için `swap()`:
322
-
323
- ```js
324
- import { swap } from "jskelet/client";
325
-
326
- await swap("#siparisler", "/_fragment/siparisler?sayfa=2", { history: true });
327
- ```
328
-
329
- `swap()` sırayla: eski alt ağacın island'larını söker, içeriği değiştirir, yeni
330
- alt ağacı hidre eder ve odağı kaybolmuşsa geri getirir. İstek süresince bölgeye
331
- `aria-busy="true"` yazılır — bekleme göstergesini ayrı bir sınıfa bağlamak
332
- yerine erişilebilirlik durumuna bağlamak ikisinin birbirinden ayrı düşmesini de
333
- engelliyor.
334
-
335
- Yönlendirmeyle karşılaşırsa (oturum düştü, login'e gidiliyor) parçayı takmak
336
- yerine sayfayı o adrese götürür.
337
-
338
- ### Island sökme
339
-
340
- Bu, takasın en kolay atlanan yarısı. `mount()` bir temizlik fonksiyonu
341
- döndürebiliyor:
342
-
343
- ```js
344
- export function mount(element) {
345
- const timer = setInterval(() => tick(element), 1000);
346
- return () => clearInterval(timer);
347
- }
348
- ```
349
-
350
- `innerHTML` ile değiştirilen bir bölgenin island'ları DOM'dan çıkar ama
351
- `document`/`window` üzerine kurdukları dinleyiciler ve `setInterval`'ları
352
- yaşamaya devam eder; birkaç takastan sonra aynı iş onlarca kez çalışır.
353
- `swap()` ve form yardımcıları `unmount()` çağırıyor, elle DOM değiştiriyorsanız
354
- sizin çağırmanız gerekiyor:
355
-
356
- ```js
357
- import { hydrate, unmount } from "jskelet/client";
358
-
359
- unmount(bolge);
360
- bolge.innerHTML = html;
361
- hydrate(bolge);
362
- ```
363
-
364
- ## Formlar
365
-
366
- `startForms()` `data-enhance` taşıyan formları bağlar. Sözleşme progressive
367
- enhancement: form normal bir `<form method="post" action="…">`, JS yalnızca
368
- aradaki tam sayfa turunu kaldırıyor.
369
-
370
- ```html
371
- <form method="post" action="/panel/notlar" data-enhance data-target="#notlar">
372
- <%- csrfField() %>
373
- <textarea name="metin" required minlength="3"></textarea>
374
- <button type="submit">Kaydet</button>
375
- </form>
376
- ```
377
-
378
- Sunucu üç cevaptan birini verir:
379
-
380
- - **yönlendirme** → `location.assign` ile izlenir (başarılı mutasyon, JS'siz yol)
381
- - **4xx + parça** → formun yerine takılır (doğrulama hataları)
382
- - **2xx + parça** → `data-target` bölgesine takılır ve form sıfırlanır
383
-
384
- Sunucu tarafı ikisini de karşılıyor:
385
-
386
- ```js
387
- app.post(
388
- "/panel/notlar",
389
- fragment(async ({ req }) => {
390
- const user = currentUser(req);
391
- if (!user) seeOther("/giris?next=%2Fpanel");
392
-
393
- const result = addNote(user.username, req.body?.metin);
394
-
395
- // JS kapalıysa istemci `X-Requested-With` göndermez: tam sayfa turu.
396
- if (req.get("X-Requested-With") !== "fragment") {
397
- seeOther(result.ok ? "/panel" : "/panel?not=hata");
398
- }
399
-
400
- return result.ok
401
- ? { view: "partials/note-list", data: { notlar: getNotes(user.username) } }
402
- : { view: "partials/note-form", data: { hata: result.error }, status: 422 };
403
- }),
404
- );
405
- ```
406
-
407
- `seeOther()` yerine `redirect()` kullanmak burada bir hata olurdu: `redirect()`
408
- 307 yazıyor ve 307 metodu koruyor, yani tarayıcı hedefe yeniden POST ediyor.
409
- Form sonrası akış 303 gerektiriyor.
410
-
411
- POST handler'ının `fragment()` içinden geçmesinin sebebi de var: layout'suz
412
- render ve `no-store`'un yanında **istek bağlamı** kazandırıyor. Bağlam olmadan
413
- hata durumunda yeniden basılan formun `csrfField()`i boş çıkar ve kullanıcının
414
- ikinci denemesi 403 alır.
415
-
416
- Doğrulama hatasında odak ilk hatalı alana taşınıyor; işaret olarak
417
- `aria-invalid="true"` ya da `data-field-error` aranıyor.
418
-
419
- ## Yapılandırma
420
-
421
- ```js
422
- export default {
423
- security: {
424
- /**
425
- * Ters proxy arkasında değilsen kapat: açıkken istemci kendi
426
- * `X-Forwarded-For` başlığını uydurabilir.
427
- */
428
- trustProxy: true,
429
-
430
- cookieSecret: process.env.JSKELET_SECRET,
431
-
432
- csrf: {
433
- enabled: true,
434
- token: false,
435
- allowedOrigins: [],
436
- exclude: [],
437
- cookieName: "csrf_token",
438
- fieldName: "_csrf",
439
- headerName: "x-csrf-token",
440
- },
441
- },
442
- };
443
- ```
444
-
445
- Panel yolları için iki ek ayar işe yarıyor:
446
-
447
- ```js
448
- // Yan etkili bir bağlantının önden getirilmesi kullanıcıyı oturumdan atabilir.
449
- navigation: { exclude: ["/panel/:path*", "/cikis"] },
450
-
451
- // Isıtıcının oturumu yok; korumalı sayfalar ısıtılamaz.
452
- prewarmSkip: ["/api/", "/_fragment/", "/panel", "/cikis"],
453
- ```
454
-
455
- Ve `headers()` ile indeksleme kapatılır — `no-store` önbelleği engelliyor, ama
456
- indekslemeyi ayrıca söylemek gerekiyor:
457
-
458
- ```js
459
- {
460
- source: "/panel/:path*",
461
- headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
462
- }
463
- ```
464
-
465
- ## Kontrol listesi
466
-
467
- Kişiye özel bir bölüm eklerken:
468
-
469
- - [ ] Sayfalar `route(fn, { private: true })` ile kayıtlı.
470
- - [ ] Fragment uçları `fragment()` ile kayıtlı.
471
- - [ ] `security.cookieSecret` ortam değişkeninden geliyor, kodda sabit değil.
472
- - [ ] Mutasyon formlarında `csrfField()` var; `security.csrf.token` açık.
473
- - [ ] Çıkış bir POST; GET değil.
474
- - [ ] Girişten sonraki `next` parametresi yalnızca site içi yolları kabul ediyor.
475
- - [ ] `prewarmSkip` ve `navigation.exclude` korumalı öneki dışlıyor.
476
- - [ ] `headers()` altında `X-Robots-Tag: noindex`.
477
- - [ ] Duman testi `no-store` başlığını ve token'sız POST'un reddini kontrol
478
- ediyor — bunlar bozulduğunda ekranda hiçbir şey değişmiyor.
1
+ # 12 — Panel, oturum ve kişiye özel sayfalar
2
+
3
+ JSkelet'in ağırlık merkezi public, önbelleklenebilir sayfalar. Bir panel ise
4
+ tam ters eksende durur: her ziyaretçiye farklı HTML, önbellek yok, yoğun
5
+ etkileşim. Bu belge o ekseni anlatır — `private: true`, oturum cookie'leri,
6
+ CSRF, fragment uçları ve parça takası.
7
+
8
+ Kapsam dışında bırakılan bir şey var ve bilinçli: **canlı veri taşıması**.
9
+ SSE, WebSocket ya da aralıklı sorgu arasındaki seçim uygulamanın; framework
10
+ yalnızca "şu parçayı sunucudan tazele" adımını veriyor. Sıkıştırma katmanı
11
+ `text/event-stream` yanıtlarını ve başlıklarını kendisi yazan akışları
12
+ atladığı için SSE'yi elle kurmak da bir şeyi bozmuyor.
13
+
14
+ Çalışan karşılığı `examples/dashboard/`; bu belgedeki kod parçalarının kaynağı
15
+ orası.
16
+
17
+ ## Neden ayrı bir yol gerekiyor
18
+
19
+ HTML önbelleğinin anahtarı yalnızca yol ve query:
20
+
21
+ ```
22
+ `${req.path}?${new URLSearchParams(query).toString()}`
23
+ ```
24
+
25
+ Kimlik anahtarın parçası değil. Yani oturuma bağlı bir sayfa normal `route()`
26
+ ile kaydedilirse, ilk isteyenin HTML'i o TTL boyunca **herkese** servis edilir.
27
+ Bu hata hiçbir yerde patlamaz: sayfa çalışır, testler geçer, sorun yalnızca
28
+ ikinci kullanıcı geldiğinde ve genelde üretimde görünür.
29
+
30
+ ## `private: true`
31
+
32
+ ```js
33
+ export default function register(app, { route, redirect }) {
34
+ app.get(
35
+ "/panel",
36
+ route(
37
+ async ({ req }) => {
38
+ const user = currentUser(req);
39
+ if (!user) redirect("/giris?next=%2Fpanel");
40
+
41
+ return { view: "pages/overview", data: { user } };
42
+ },
43
+ { private: true },
44
+ ),
45
+ );
46
+ }
47
+ ```
48
+
49
+ Bayrağın yaptığı işler:
50
+
51
+ | Davranış | Public `route()` | `private: true` |
52
+ | --- | --- | --- |
53
+ | HTML önbelleği | TTL varsa açık | Kapalı, açılamaz |
54
+ | `cache.html` deseni | TTL'i ezer | Yok sayılır |
55
+ | `Cache-Control` | `public, s-maxage=…` | `private, no-store` |
56
+ | `Vary` | `Accept-Encoding` | `Cookie, Accept-Encoding` |
57
+ | ETag | Var | Yok |
58
+ | `X-JSkelet-Cache` | `HIT`/`STALE`/`MISS` | Yazılmaz |
59
+
60
+ ETag'in düşmesi ayrıntı gibi görünüyor ama değil: kullanıcıya özel bir gövdenin
61
+ güçlü ETag'i, o kullanıcıya özgü bir parmak izidir ve `no-store`'a uymayan bir
62
+ katmanda kimlik ayrımı için kullanılabilir.
63
+
64
+ Kişiye özel bir sayfadan atılan yönlendirme de önbelleklenmez. "Giriş yapmalısın"
65
+ kararı oturuma bağlı; saklanması, giriş yapmış kullanıcının da login sayfasına
66
+ atılması demek.
67
+
68
+ ## Bayrağı unutursanız
69
+
70
+ Framework kimliğe dokunan erişimleri izliyor. Controller'a giden `req`, şu
71
+ okumaları işaretleyen ince bir Proxy ile sarılı:
72
+
73
+ - `req.headers.cookie`, `req.headers.authorization`,
74
+ `req.headers["proxy-authorization"]`
75
+ - `req.get("Cookie")` / `req.header("Authorization")`
76
+ - `req.cookies`, `req.signedCookies`, `req.session`, `req.user`
77
+ - `parseCookies(req)` ve `getSignedCookie(req, …)` (doğrudan bildiriyorlar)
78
+
79
+ İşaretlenen bir render önbelleğe **yazılmaz**. Üretimde yanıt `no-store` ile
80
+ gider ve şu satır loglanır:
81
+
82
+ ```
83
+ [render] /panel kimliğe bağlı veri okudu (req.headers.cookie), önbelleğe
84
+ alınmadı. Route 'private: true' ile kaydedilmeli.
85
+ ```
86
+
87
+ Development'ta aynı durum isteği bir hatayla düşürür. Sessiz kalmamasının
88
+ sebebi basit: bu hata çalışan bir sayfa üretiyor, yani kendi başına asla fark
89
+ edilmiyor.
90
+
91
+ `csrfField()` de aynı işaretlemeyi yapıyor. Token basan bir sayfa önbellekten
92
+ dönemez — dönerse tüm ziyaretçiler aynı token'ı paylaşır ve çift gönderim
93
+ kontrolü hiçbir şey doğrulamaz.
94
+
95
+ ## Oturum: imzalı cookie
96
+
97
+ Framework kimlik sağlamıyor. Verdiği tek şey "bu değeri ben yazdım,
98
+ kurcalanmamış" garantisi:
99
+
100
+ ```js
101
+ import { clearCookie, getSignedCookie, setSignedCookie } from "jskelet/cookies";
102
+
103
+ export function startSession(res, username) {
104
+ setSignedCookie(res, "dash_session", username, { maxAge: 60 * 60 * 8 });
105
+ }
106
+
107
+ export function currentUser(req) {
108
+ const username = getSignedCookie(req, "dash_session");
109
+ return username ? findUser(username) : null;
110
+ }
111
+
112
+ export function endSession(res) {
113
+ clearCookie(res, "dash_session");
114
+ }
115
+ ```
116
+
117
+ İmza HMAC-SHA256, karşılaştırma sabit zamanlı. İmza uymuyorsa `getSignedCookie`
118
+ `null` döner — kurcalanmış bir değer "belki geçerlidir" diye kullanılmaz.
119
+
120
+ Sır `security.cookieSecret` ya da `JSKELET_SECRET` ortam değişkeninden gelir.
121
+ Sır yoksa imzalı API **hata verir**; "yapılandırma hatası siteyi düşürmez"
122
+ kuralı burada geçerli değil, çünkü sessiz alternatif imzasız bir cookie'ye
123
+ güvenmek olurdu.
124
+
125
+ Varsayılanlar kısıtlayıcı tarafta: `HttpOnly`, `SameSite=Lax`, development
126
+ dışında `Secure`, `Path=/`. `SameSite=Lax` tek başına CSRF'in büyük kısmını
127
+ kapatıyor — cookie çapraz site POST'larında hiç gönderilmiyor.
128
+
129
+ Cookie **şifrelenmiyor**, imzalanıyor. Değer okunabilir; gizli kalması gereken
130
+ veriyi değil, onun kimliğini koyun.
131
+
132
+ ## Alt alan adları: paylaşımlı cookie
133
+
134
+ Host tabanlı i18n (`tr.example.com` / `en.example.com`) oturumu alt alanlar
135
+ arasında paylaşmak ister. Çözüm **kısa session id** + isteğe bağlı
136
+ `Domain=.example.com` — JWT veya büyük access token paylaşımlı cookie'ye
137
+ konmaz (`large token ≠ shared cookie`).
138
+
139
+ ```js
140
+ // jskelet.config.mjs
141
+ export default {
142
+ brand: {
143
+ sharedCookieRoots: [".investvio.com", ".localhost"],
144
+ },
145
+ auth: {
146
+ crossSubdomainHandoff: {
147
+ allowedCookieNames: ["sid"], // zorunlu allowlist
148
+ },
149
+ },
150
+ };
151
+ ```
152
+
153
+ ### Sunucu
154
+
155
+ ```js
156
+ import { writeSharedCookie } from "jskelet/cookies";
157
+
158
+ export function startSession(res, req, sessionId) {
159
+ const result = writeSharedCookie(res, "sid", sessionId, {
160
+ req,
161
+ maxAge: 60 * 60 * 8,
162
+ });
163
+ // result.ok === false → result.handoff; istemci handoff denemeli
164
+ return result;
165
+ }
166
+ ```
167
+
168
+ `Secure` **protokole** bakılır (`https` / `x-forwarded-proto`), `NODE_ENV`'e
169
+ değil. Domain, isteğin Host'u `sharedCookieRoots` ile eşleşince yazılır.
170
+ Değer ~512 baytı aşarsa yazım reddedilir ve `handoff: true` döner.
171
+
172
+ ### İstemci
173
+
174
+ ```js
175
+ import {
176
+ writeSharedCookie,
177
+ createHandoffUrl,
178
+ handoffViaWindowName,
179
+ consumeWindowNameHandoff,
180
+ } from "jskelet/client";
181
+
182
+ const result = writeSharedCookie("sid", sessionId, {
183
+ roots: [".investvio.com", ".localhost"],
184
+ maxAge: 60 * 60 * 8,
185
+ });
186
+
187
+ if (!result.ok && result.handoff) {
188
+ const url = await createHandoffUrl({
189
+ name: "sid",
190
+ value: sessionId,
191
+ next: "https://tr.investvio.com/panel",
192
+ });
193
+ if (url) location.assign(url);
194
+ else handoffViaWindowName("https://tr.investvio.com/panel", {
195
+ name: "sid",
196
+ value: sessionId,
197
+ });
198
+ }
199
+
200
+ // Hedef host'ta (layout / island bootstrap):
201
+ consumeWindowNameHandoff();
202
+ ```
203
+
204
+ `roots` verilmezse `<html data-jskelet-cookie-roots=".investvio.com,.localhost">`
205
+ okunur. Yazımdan sonra **read-back** yapılır; tarayıcı Domain'i reddettiyse
206
+ `handoff: true`.
207
+
208
+ ### Handoff bileti
209
+
210
+ `auth.crossSubdomainHandoff` açıkken:
211
+
212
+ 1. `POST /_jskelet/auth/handoff` `{ name, value, next }` → `{ url }` (`?handoff=` ekli).
213
+ Mint, CSRF middleware'inden **sonra** mount edilir; `name`
214
+ `allowedCookieNames` içinde ve RFC 6265 token olmalı.
215
+ 2. Hedef host'ta GET middleware bileti tek kullanımlık tüketir, cookie yazar
216
+ (önce shared Domain, olmazsa host-only), `handoff` query'siz 303
217
+
218
+ `next` yalnızca aynı `sharedCookieRoots` altındaki host'lara izinli. Bilet
219
+ ~60 sn, süreç belleğinde; bekleyen bilet ve IP başına mint sınırı vardır.
220
+ JWT URL'ye konmaz.
221
+
222
+ `window.name` köprüsü cookie'siz yedek: kaynakta `handoffViaWindowName`,
223
+ hedeefte `consumeWindowNameHandoff`. Cross-origin tab'da `window.name`
224
+ okunabilir kalır — mümkünse sunucu handoff tercih edin.
225
+
226
+ ## CSRF
227
+
228
+ Gövdeyi framework ayrıştırıyor (`express.urlencoded` + `express.json`), yani
229
+ state değiştiren istekleri kabul eden katman o. Koruma iki katmanlı.
230
+
231
+ ### Katman 1 — origin kontrolü (varsayılan açık)
232
+
233
+ `Origin` kendi host'umuzla uyuşmuyorsa ya da `Sec-Fetch-Site: cross-site`
234
+ geldiyse güvenli olmayan metotlar 403 alır. **Başlıkların hiçbiri yoksa istek
235
+ geçer**: tarayıcılar çapraz origin bir POST'ta `Origin`'i her zaman gönderir,
236
+ webhook'lar ve sunucudan sunucuya çağrılar hiç göndermez. Bu ayrım korumayı
237
+ açık bırakırken entegrasyonları bozmuyor.
238
+
239
+ ```js
240
+ security: {
241
+ csrf: {
242
+ // Ayrı bir alan adından gelen panel gibi meşru istisnalar.
243
+ allowedOrigins: ["https://admin.example.com"],
244
+ // Tarayıcıdan gelmeyen uçlar.
245
+ exclude: ["/webhook/:path*"],
246
+ },
247
+ }
248
+ ```
249
+
250
+ ### Katman 2 — çift gönderim token'ı (opsiyonel)
251
+
252
+ `security.csrf.token: true` ile açılır. Formlar token'ı `csrfField()` ile basar:
253
+
254
+ ```ejs
255
+ <form method="post" action="/panel/notlar">
256
+ <%- csrfField() %>
257
+ …
258
+ </form>
259
+ ```
260
+
261
+ Token'ı **middleware üretmiyor**, `csrfField()` üretiyor — yani gerçekten bir
262
+ forma basıldığı anda. Sebebi somut: token her yanıtta yazılsaydı public ve
263
+ önbelleklenebilir bir sayfa da `Set-Cookie` taşırdı, bir CDN o yanıtı saklardı
264
+ ve tüm ziyaretçiler aynı token'ı paylaşırdı.
265
+
266
+ Sunucu tarafında token, imzalı cookie ile gönderilen alanın eşleşmesini
267
+ istiyor; alan yerine `X-CSRF-Token` başlığı da kabul ediliyor.
268
+
269
+ `csrfField()` `security.csrf.token` kapalıyken boş string döner, yani şablon
270
+ her koşulda render edilebilir.
271
+
272
+ ## Fragment uçları
273
+
274
+ `fragment()` politikası sabit bir parça yanıtı üretir: layout basılmaz, yanıt
275
+ `private, no-store` ve ETag'siz gider, HTML önbelleğine hiç uğramaz.
276
+
277
+ ```js
278
+ export default function register(app, { fragment }) {
279
+ app.get(
280
+ "/_fragment/siparisler",
281
+ fragment(async ({ req, query }) => {
282
+ const user = currentUser(req);
283
+ if (!user) return { view: "partials/session-expired", status: 401 };
284
+
285
+ return {
286
+ view: "partials/order-table",
287
+ data: { siparisler: getOrders(user.username, Number(query.sayfa ?? 1)) },
288
+ };
289
+ }),
290
+ );
291
+ }
292
+ ```
293
+
294
+ Controller ya `{ view, data?, status? }` ya doğrudan bir HTML string döner.
295
+ Hata durumunda tüm sayfa yerine küçük bir parça döner
296
+ (`<div role="alert" data-fragment-error>`): takas edilen bölge bir hata
297
+ sayfasının tamamını içine almamalı.
298
+
299
+ Aynı şablonun sayfanın içinde de fragment ucunda da kullanılması esas fikir —
300
+ işaretlemenin tek kaynağı sunucuda kalır, istemci ikinci bir şablon taşımaz.
301
+
302
+ ## İstemci: parça takası
303
+
304
+ ```js
305
+ import { registerAll, start, startForms, startSwapLinks } from "jskelet/client";
306
+
307
+ registerAll({ "live-clock": () => import("../islands/live-clock.js") });
308
+
309
+ start();
310
+ startSwapLinks();
311
+ startForms();
312
+ ```
313
+
314
+ `startSwapLinks()` `data-swap` taşıyan bağlantıları bağlar:
315
+
316
+ ```html
317
+ <a href="/_fragment/siparisler?sayfa=2" data-swap="#siparisler">Sonraki</a>
318
+ ```
319
+
320
+ `href` gerçek bir URL olduğu için JS yoksa bağlantı normal gezinmeye düşer.
321
+ Programatik kullanım için `swap()`:
322
+
323
+ ```js
324
+ import { swap } from "jskelet/client";
325
+
326
+ await swap("#siparisler", "/_fragment/siparisler?sayfa=2", { history: true });
327
+ ```
328
+
329
+ `swap()` sırayla: eski alt ağacın island'larını söker, içeriği değiştirir, yeni
330
+ alt ağacı hidre eder ve odağı kaybolmuşsa geri getirir. İstek süresince bölgeye
331
+ `aria-busy="true"` yazılır — bekleme göstergesini ayrı bir sınıfa bağlamak
332
+ yerine erişilebilirlik durumuna bağlamak ikisinin birbirinden ayrı düşmesini de
333
+ engelliyor.
334
+
335
+ Yönlendirmeyle karşılaşırsa (oturum düştü, login'e gidiliyor) parçayı takmak
336
+ yerine sayfayı o adrese götürür.
337
+
338
+ ### Island sökme
339
+
340
+ Bu, takasın en kolay atlanan yarısı. `mount()` bir temizlik fonksiyonu
341
+ döndürebiliyor:
342
+
343
+ ```js
344
+ export function mount(element) {
345
+ const timer = setInterval(() => tick(element), 1000);
346
+ return () => clearInterval(timer);
347
+ }
348
+ ```
349
+
350
+ `innerHTML` ile değiştirilen bir bölgenin island'ları DOM'dan çıkar ama
351
+ `document`/`window` üzerine kurdukları dinleyiciler ve `setInterval`'ları
352
+ yaşamaya devam eder; birkaç takastan sonra aynı iş onlarca kez çalışır.
353
+ `swap()` ve form yardımcıları `unmount()` çağırıyor, elle DOM değiştiriyorsanız
354
+ sizin çağırmanız gerekiyor:
355
+
356
+ ```js
357
+ import { hydrate, unmount } from "jskelet/client";
358
+
359
+ unmount(bolge);
360
+ bolge.innerHTML = html;
361
+ hydrate(bolge);
362
+ ```
363
+
364
+ ## Formlar
365
+
366
+ `startForms()` `data-enhance` taşıyan formları bağlar. Sözleşme progressive
367
+ enhancement: form normal bir `<form method="post" action="…">`, JS yalnızca
368
+ aradaki tam sayfa turunu kaldırıyor.
369
+
370
+ ```html
371
+ <form method="post" action="/panel/notlar" data-enhance data-target="#notlar">
372
+ <%- csrfField() %>
373
+ <textarea name="metin" required minlength="3"></textarea>
374
+ <button type="submit">Kaydet</button>
375
+ </form>
376
+ ```
377
+
378
+ Sunucu üç cevaptan birini verir:
379
+
380
+ - **yönlendirme** → `location.assign` ile izlenir (başarılı mutasyon, JS'siz yol)
381
+ - **4xx + parça** → formun yerine takılır (doğrulama hataları)
382
+ - **2xx + parça** → `data-target` bölgesine takılır ve form sıfırlanır
383
+
384
+ Sunucu tarafı ikisini de karşılıyor:
385
+
386
+ ```js
387
+ app.post(
388
+ "/panel/notlar",
389
+ fragment(async ({ req }) => {
390
+ const user = currentUser(req);
391
+ if (!user) seeOther("/giris?next=%2Fpanel");
392
+
393
+ const result = addNote(user.username, req.body?.metin);
394
+
395
+ // JS kapalıysa istemci `X-Requested-With` göndermez: tam sayfa turu.
396
+ if (req.get("X-Requested-With") !== "fragment") {
397
+ seeOther(result.ok ? "/panel" : "/panel?not=hata");
398
+ }
399
+
400
+ return result.ok
401
+ ? { view: "partials/note-list", data: { notlar: getNotes(user.username) } }
402
+ : { view: "partials/note-form", data: { hata: result.error }, status: 422 };
403
+ }),
404
+ );
405
+ ```
406
+
407
+ `seeOther()` yerine `redirect()` kullanmak burada bir hata olurdu: `redirect()`
408
+ 307 yazıyor ve 307 metodu koruyor, yani tarayıcı hedefe yeniden POST ediyor.
409
+ Form sonrası akış 303 gerektiriyor.
410
+
411
+ POST handler'ının `fragment()` içinden geçmesinin sebebi de var: layout'suz
412
+ render ve `no-store`'un yanında **istek bağlamı** kazandırıyor. Bağlam olmadan
413
+ hata durumunda yeniden basılan formun `csrfField()`i boş çıkar ve kullanıcının
414
+ ikinci denemesi 403 alır.
415
+
416
+ Doğrulama hatasında odak ilk hatalı alana taşınıyor; işaret olarak
417
+ `aria-invalid="true"` ya da `data-field-error` aranıyor.
418
+
419
+ ## Yapılandırma
420
+
421
+ ```js
422
+ export default {
423
+ security: {
424
+ /**
425
+ * Ters proxy arkasında değilsen kapat: açıkken istemci kendi
426
+ * `X-Forwarded-For` başlığını uydurabilir.
427
+ */
428
+ trustProxy: true,
429
+
430
+ cookieSecret: process.env.JSKELET_SECRET,
431
+
432
+ csrf: {
433
+ enabled: true,
434
+ token: false,
435
+ allowedOrigins: [],
436
+ exclude: [],
437
+ cookieName: "csrf_token",
438
+ fieldName: "_csrf",
439
+ headerName: "x-csrf-token",
440
+ },
441
+ },
442
+ };
443
+ ```
444
+
445
+ Panel yolları için iki ek ayar işe yarıyor:
446
+
447
+ ```js
448
+ // Yan etkili bir bağlantının önden getirilmesi kullanıcıyı oturumdan atabilir.
449
+ navigation: { exclude: ["/panel/:path*", "/cikis"] },
450
+
451
+ // Isıtıcının oturumu yok; korumalı sayfalar ısıtılamaz.
452
+ prewarmSkip: ["/api/", "/_fragment/", "/panel", "/cikis"],
453
+ ```
454
+
455
+ Ve `headers()` ile indeksleme kapatılır — `no-store` önbelleği engelliyor, ama
456
+ indekslemeyi ayrıca söylemek gerekiyor:
457
+
458
+ ```js
459
+ {
460
+ source: "/panel/:path*",
461
+ headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
462
+ }
463
+ ```
464
+
465
+ ## Kontrol listesi
466
+
467
+ Kişiye özel bir bölüm eklerken:
468
+
469
+ - [ ] Sayfalar `route(fn, { private: true })` ile kayıtlı.
470
+ - [ ] Fragment uçları `fragment()` ile kayıtlı.
471
+ - [ ] `security.cookieSecret` ortam değişkeninden geliyor, kodda sabit değil.
472
+ - [ ] Mutasyon formlarında `csrfField()` var; `security.csrf.token` açık.
473
+ - [ ] Çıkış bir POST; GET değil.
474
+ - [ ] Girişten sonraki `next` parametresi yalnızca site içi yolları kabul ediyor.
475
+ - [ ] `prewarmSkip` ve `navigation.exclude` korumalı öneki dışlıyor.
476
+ - [ ] `headers()` altında `X-Robots-Tag: noindex`.
477
+ - [ ] Duman testi `no-store` başlığını ve token'sız POST'un reddini kontrol
478
+ ediyor — bunlar bozulduğunda ekranda hiçbir şey değişmiyor.