jskelet 0.6.3 → 0.6.5

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