jskelet 0.1.1 → 0.1.2

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 (64) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +63 -0
  3. package/README.md +21 -7
  4. package/bin/jskelet.mjs +6 -6
  5. package/docs/03-routing.md +48 -9
  6. package/docs/04-render-ve-sablonlar.md +2 -2
  7. package/docs/05-islands.md +59 -6
  8. package/docs/06-cache.md +39 -7
  9. package/docs/07-yapilandirma.md +51 -1
  10. package/docs/08-build.md +4 -4
  11. package/docs/09-dev-araclari.md +5 -0
  12. package/docs/12-panel-ve-oturum.md +384 -0
  13. package/docs/README.md +25 -2
  14. package/docs/en/01-getting-started.md +292 -0
  15. package/docs/en/02-architecture.md +305 -0
  16. package/docs/en/03-routing.md +493 -0
  17. package/docs/en/04-rendering.md +504 -0
  18. package/docs/en/05-islands.md +492 -0
  19. package/docs/en/06-caching.md +454 -0
  20. package/docs/en/07-configuration.md +736 -0
  21. package/docs/en/08-build.md +383 -0
  22. package/docs/en/09-dev-tools.md +314 -0
  23. package/docs/en/10-deployment.md +332 -0
  24. package/docs/en/11-migration.md +360 -0
  25. package/docs/en/12-dashboards-and-sessions.md +392 -0
  26. package/docs/en/README.md +112 -0
  27. package/package.json +4 -2
  28. package/src/build/build.mjs +1 -1
  29. package/src/build/tasks/client.mjs +2 -2
  30. package/src/build/tasks/fonts.mjs +3 -3
  31. package/src/build/tasks/icons.mjs +1 -1
  32. package/src/build/tasks/images.mjs +2 -2
  33. package/src/client/devtools/overlay.js +196 -164
  34. package/src/client/devtools/report.js +96 -96
  35. package/src/client/form.js +192 -0
  36. package/src/client/index.js +10 -1
  37. package/src/client/registry.js +78 -4
  38. package/src/client/swap.js +188 -0
  39. package/src/config/defaults.js +34 -0
  40. package/src/config/index.js +68 -13
  41. package/src/config/pattern.js +1 -1
  42. package/src/dev-server.mjs +1 -1
  43. package/src/http/control-flow.js +16 -1
  44. package/src/http/cookies.js +257 -0
  45. package/src/http/request-context.js +162 -0
  46. package/src/index.js +19 -2
  47. package/src/init.mjs +32 -31
  48. package/src/log.mjs +8 -2
  49. package/src/logo.png +0 -0
  50. package/src/runtime/alias-hooks.mjs +1 -1
  51. package/src/server/assets.js +1 -1
  52. package/src/server/create-app.js +12 -4
  53. package/src/server/dev/devtools.js +6 -2
  54. package/src/server/dev/version-check.mjs +139 -0
  55. package/src/server/head-hints.js +1 -1
  56. package/src/server/html-cache.js +10 -4
  57. package/src/server/middleware/csrf.js +134 -0
  58. package/src/server/prewarm.js +6 -6
  59. package/src/server/render.js +199 -16
  60. package/src/server/router.js +14 -7
  61. package/src/server/status-page.js +1 -1
  62. package/src/version.mjs +9 -4
  63. package/src/views/components/loader.js +1 -1
  64. package/src/views/helpers/tags.js +53 -1
@@ -16,6 +16,9 @@
16
16
  * redirects() → [{ source, destination, permanent?, statusCode? }]
17
17
  * rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
18
18
  * cache() → { html?: { [source]: saniye }, prewarm?: {...} }
19
+ *
20
+ * Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
21
+ * düz nesne olarak okunur.
19
22
  */
20
23
  import fs from "node:fs";
21
24
  import path from "node:path";
@@ -30,6 +33,7 @@ import {
30
33
  DEFAULT_NAVIGATION_EXCLUDE,
31
34
  DEFAULT_PREWARM,
32
35
  DEFAULT_PREWARM_SKIP,
36
+ DEFAULT_SECURITY,
33
37
  DEFAULT_STATIC,
34
38
  } from "./defaults.js";
35
39
 
@@ -68,6 +72,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
68
72
  * @property {string[]} devGateBypass
69
73
  * @property {string[]} preconnect
70
74
  * @property {NavigationConfig} navigation
75
+ * @property {SecurityConfig} security
71
76
  * @property {string[]} prewarmSkip
72
77
  * @property {string[]} watch Dev sunucusunun izlediği ek dizinler.
73
78
  * @property {{ family: string, slug?: string, weights: number[] }[]} fonts
@@ -87,7 +92,7 @@ let config = null;
87
92
  function asArray(value, label) {
88
93
  if (value == null) return [];
89
94
  if (Array.isArray(value)) return value;
90
- console.warn(`[config] ${label} bir dizi döndürmeli, yok sayıldı`);
95
+ console.warn(`[config] ${label} must return an array, ignoring it`);
91
96
  return [];
92
97
  }
93
98
 
@@ -201,7 +206,7 @@ function normalizeEagerness(value, fallback, label) {
201
206
  }
202
207
 
203
208
  console.warn(
204
- `[config] navigation.${label} geçersiz (${String(value)}), varsayılana dönüldü`,
209
+ `[config] navigation.${label} is invalid (${String(value)}), falling back to the default`,
205
210
  );
206
211
  return fallback;
207
212
  }
@@ -240,6 +245,50 @@ function normalizeNavigation(raw, brand) {
240
245
  };
241
246
  }
242
247
 
248
+ /**
249
+ * @typedef {object} SecurityConfig
250
+ * @property {boolean} trustProxy
251
+ * @property {string | null} cookieSecret
252
+ * @property {{ enabled: boolean, token: boolean, allowedOrigins: string[],
253
+ * exclude: CompiledPattern[], cookieName: string, fieldName: string,
254
+ * headerName: string }} csrf
255
+ */
256
+
257
+ /**
258
+ * Güvenlik bölümü. `csrf.exclude` desenleri burada derlenir: her istekte
259
+ * yeniden derlemek gereksiz, ve bozuk bir desen sunucuyu düşürmemeli.
260
+ *
261
+ * @param {unknown} raw
262
+ * @returns {SecurityConfig}
263
+ */
264
+ function normalizeSecurity(raw) {
265
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
266
+ const csrf = { ...DEFAULT_SECURITY.csrf, ...(source.csrf ?? {}) };
267
+
268
+ const exclude = asArray(csrf.exclude, "security.csrf.exclude")
269
+ .map((entry) => compilePattern(entry))
270
+ .filter((pattern) => pattern !== null);
271
+
272
+ return {
273
+ trustProxy: source.trustProxy !== false,
274
+ cookieSecret:
275
+ typeof source.cookieSecret === "string" && source.cookieSecret
276
+ ? source.cookieSecret
277
+ : null,
278
+ csrf: {
279
+ enabled: csrf.enabled !== false,
280
+ token: csrf.token === true,
281
+ allowedOrigins: asArray(csrf.allowedOrigins, "security.csrf.allowedOrigins")
282
+ .filter((entry) => typeof entry === "string")
283
+ .map(String),
284
+ exclude: /** @type {CompiledPattern[]} */ (exclude),
285
+ cookieName: String(csrf.cookieName ?? DEFAULT_SECURITY.csrf.cookieName),
286
+ fieldName: String(csrf.fieldName ?? DEFAULT_SECURITY.csrf.fieldName),
287
+ headerName: String(csrf.headerName ?? DEFAULT_SECURITY.csrf.headerName).toLowerCase(),
288
+ },
289
+ };
290
+ }
291
+
243
292
  /**
244
293
  * Dizin adlarını mutlak yola çevirir. `styles` bir dosya yolu olduğu için
245
294
  * de aynı çözümlemeden geçer; ayrı bir alan tutmaya değmez.
@@ -307,7 +356,7 @@ export async function loadConfig(options = {}) {
307
356
 
308
357
  if (!fs.existsSync(configPath)) {
309
358
  console.warn(
310
- `[config] ${configFile} bulunamadı — yerleşik varsayılanlarla devam ediliyor.`,
359
+ `[config] ${configFile} not found — continuing with built-in defaults.`,
311
360
  );
312
361
  } else {
313
362
  try {
@@ -316,7 +365,7 @@ export async function loadConfig(options = {}) {
316
365
  source = module.default ?? module;
317
366
  loaded = true;
318
367
  } catch (error) {
319
- console.warn(`[config] ${configFile} yüklenemedi, yok sayıldı`, error);
368
+ console.warn(`[config] ${configFile} failed to load, ignoring it`, error);
320
369
  }
321
370
  }
322
371
 
@@ -327,7 +376,7 @@ export async function loadConfig(options = {}) {
327
376
  try {
328
377
  return typeof value === "function" ? await value.call(source) : value;
329
378
  } catch (error) {
330
- console.warn(`[config] ${name}() hata verdi, yok sayıldı`, error);
379
+ console.warn(`[config] ${name}() threw, ignoring it`, error);
331
380
  return null;
332
381
  }
333
382
  };
@@ -363,6 +412,7 @@ export async function loadConfig(options = {}) {
363
412
  devGateBypass: source.devGateBypass ?? DEFAULT_DEV_GATE_BYPASS,
364
413
  preconnect: source.preconnect ?? [],
365
414
  navigation: normalizeNavigation(source.navigation, brand),
415
+ security: normalizeSecurity(source.security),
366
416
  prewarmSkip: source.prewarmSkip ?? DEFAULT_PREWARM_SKIP,
367
417
  // `routes`, `views` ve `lib` zaten izlenir; buraya yalnızca ek dizinler.
368
418
  watch: source.watch ?? [],
@@ -377,15 +427,20 @@ export async function loadConfig(options = {}) {
377
427
  // Dev'de build ve sunucu ayrı alt süreçler; üçü de aynı özeti basınca satır
378
428
  // banner'ın ve build bloğunun arasına üç kez giriyor. Özeti dış süreç basar.
379
429
  if (loaded && !process.env.JSKELET_CHILD) {
430
+ /** @param {number} count @param {string} singular @param {string} plural */
431
+ const label = (count, singular, plural) =>
432
+ `${count} ${count === 1 ? singular : plural}`;
433
+
380
434
  const counts = [
381
- config.headers.length && `${config.headers.length} header`,
382
- config.redirects.length && `${config.redirects.length} redirect`,
383
- config.rewrites.length && `${config.rewrites.length} rewrite`,
384
- config.html.length && `${config.html.length} cache kuralı`,
435
+ config.headers.length && label(config.headers.length, "header", "headers"),
436
+ config.redirects.length &&
437
+ label(config.redirects.length, "redirect", "redirects"),
438
+ config.rewrites.length && label(config.rewrites.length, "rewrite", "rewrites"),
439
+ config.html.length && label(config.html.length, "cache rule", "cache rules"),
385
440
  ].filter(Boolean);
386
441
 
387
442
  if (counts.length) {
388
- console.log(`[config] ${configFile} yüklendi — ${counts.join(", ")}`);
443
+ console.log(`[config] ${configFile} loaded — ${counts.join(", ")}`);
389
444
  }
390
445
  }
391
446
 
@@ -402,8 +457,8 @@ export async function loadConfig(options = {}) {
402
457
  export function getConfig() {
403
458
  if (!config) {
404
459
  throw new Error(
405
- "[config] loadConfig() çağrılmadan getConfig() kullanıldı. " +
406
- "Sunucuyu `jskelet` CLI ile ya da createApp() üzerinden başlatın.",
460
+ "[config] getConfig() was used before loadConfig(). " +
461
+ "Start the server with the `jskelet` CLI or through createApp().",
407
462
  );
408
463
  }
409
464
  return config;
@@ -427,7 +482,7 @@ export async function hook(name, fallback, ...args) {
427
482
  try {
428
483
  return await fn(...args);
429
484
  } catch (error) {
430
- console.warn(`[config] hooks.${name}() hata verdi, varsayılan kullanıldı`, error);
485
+ console.warn(`[config] hooks.${name}() threw, using the default`, error);
431
486
  return fallback;
432
487
  }
433
488
  }
@@ -28,7 +28,7 @@ function escapeLiteral(text) {
28
28
  */
29
29
  export function compilePattern(source) {
30
30
  if (typeof source !== "string" || !source.startsWith("/")) {
31
- console.warn(`[config] geçersiz source (\`/\` ile başlamalı): ${source}`);
31
+ console.warn(`[config] invalid source (must start with \`/\`): ${source}`);
32
32
  return null;
33
33
  }
34
34
 
@@ -350,7 +350,7 @@ function watchSources() {
350
350
  });
351
351
  } catch {
352
352
  log.warn(
353
- `${path.relative(ROOT, target)} izlenemedi; bu dizinde otomatik restart olmayacak.`,
353
+ `could not watch ${path.relative(ROOT, target)}; no auto restart for this directory.`,
354
354
  );
355
355
  }
356
356
  }
@@ -14,7 +14,7 @@ export class NotFoundError extends Error {
14
14
  export class RedirectError extends Error {
15
15
  /**
16
16
  * @param {string} location
17
- * @param {301 | 302 | 307 | 308} [statusCode]
17
+ * @param {301 | 302 | 303 | 307 | 308} [statusCode]
18
18
  */
19
19
  constructor(location, statusCode = 307) {
20
20
  super(`Redirect to ${location}`);
@@ -45,6 +45,21 @@ export function redirect(location) {
45
45
  throw new RedirectError(location, 307);
46
46
  }
47
47
 
48
+ /**
49
+ * POST sonrası yönlendirme (303 See Other).
50
+ *
51
+ * `redirect()` 307 kullanır ve 307 **metodu korur**: bir POST handler'ından
52
+ * çağrıldığında tarayıcı hedefe yeniden POST eder. Form gönderiminden sonra
53
+ * sayfayı GET olarak açmak — yani geri tuşunun formu yeniden göndermediği
54
+ * klasik "post/redirect/get" akışı — 303 gerektiriyor.
55
+ *
56
+ * @param {string} location
57
+ * @returns {never}
58
+ */
59
+ export function seeOther(location) {
60
+ throw new RedirectError(location, 303);
61
+ }
62
+
48
63
  /** @param {unknown} error */
49
64
  export function isNotFoundError(error) {
50
65
  return error instanceof NotFoundError;
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Cookie okuma/yazma ve HMAC ile imzalama.
3
+ *
4
+ * Neden framework'te: kişiye özel her sayfa bir oturum cookie'sine dayanıyor
5
+ * ve bunu elle yazan her proje aynı üç hatayı tekrar ediyor — `HttpOnly`
6
+ * unutmak, imzasız değere güvenmek, karşılaştırmayı `===` ile yapmak.
7
+ * Burada varsayılanlar güvenli tarafta ve imza doğrulaması sabit zamanlı.
8
+ *
9
+ * Framework **kimlik sağlamaz**: oturumun içinde ne olduğu, ne kadar
10
+ * yaşadığı ve kimin verdiği uygulamanın kararı. Buradaki yüzey yalnızca
11
+ * "bu değeri ben yazdım, kurcalanmamış" garantisini veriyor.
12
+ *
13
+ * Bağımlılık eklenmez; `node:crypto` yeterli.
14
+ */
15
+ import crypto from "node:crypto";
16
+ import process from "node:process";
17
+ import { getConfig } from "../config/index.js";
18
+ import { markTainted } from "./request-context.js";
19
+
20
+ /** Ayrıştırılmış cookie'ler istek başına bir kez hesaplanır. */
21
+ const PARSED = Symbol("jskelet.cookies");
22
+
23
+ /**
24
+ * @param {string} value
25
+ * @returns {string}
26
+ */
27
+ function base64url(value) {
28
+ return Buffer.from(value, "utf8").toString("base64url");
29
+ }
30
+
31
+ /**
32
+ * @param {string} value
33
+ * @returns {string | null}
34
+ */
35
+ function fromBase64url(value) {
36
+ try {
37
+ return Buffer.from(value, "base64url").toString("utf8");
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * İmza sırrı. `security.cookieSecret` ya da `JSKELET_SECRET`.
45
+ *
46
+ * Yokluğunda imzasız cookie yazmak en kötü sonuç olurdu: uygulama kendini
47
+ * güvende sanar, değer kurcalanabilir. Bu yüzden imzalı API sır olmadan
48
+ * hata verir — config hatasının siteyi düşürmemesi kuralı burada geçmez,
49
+ * çünkü sessiz alternatif bir güvenlik açığı.
50
+ *
51
+ * @returns {string}
52
+ */
53
+ function getSecret() {
54
+ /** @type {string | null} */
55
+ let configured = null;
56
+
57
+ try {
58
+ configured = getConfig().security.cookieSecret;
59
+ } catch {
60
+ // Config yüklenmemiş olabilir (script, test); env yine de geçerli.
61
+ configured = null;
62
+ }
63
+
64
+ const secret = configured ?? process.env.JSKELET_SECRET ?? null;
65
+
66
+ if (!secret) {
67
+ throw new Error(
68
+ "[cookies] a secret is required for signed cookies. Set " +
69
+ "`security.cookieSecret` in `jskelet.config.mjs` or the JSKELET_SECRET environment variable.",
70
+ );
71
+ }
72
+
73
+ return secret;
74
+ }
75
+
76
+ /**
77
+ * @param {string} value
78
+ * @returns {string}
79
+ */
80
+ function sign(value) {
81
+ return crypto.createHmac("sha256", getSecret()).update(value).digest("base64url");
82
+ }
83
+
84
+ /**
85
+ * Sabit zamanlı karşılaştırma: imza doğrulamasında erken çıkış, saldırganın
86
+ * baytları tek tek tahmin etmesine kapı aralar.
87
+ *
88
+ * @param {string} a
89
+ * @param {string} b
90
+ * @returns {boolean}
91
+ */
92
+ export function safeEqual(a, b) {
93
+ const left = Buffer.from(String(a));
94
+ const right = Buffer.from(String(b));
95
+ if (left.length !== right.length) return false;
96
+ return crypto.timingSafeEqual(left, right);
97
+ }
98
+
99
+ /**
100
+ * `Cookie` başlığını ayrıştırır.
101
+ *
102
+ * Okuma çıktının kullanıcıya bağlı olduğunu bildirir: bu sayfa artık public
103
+ * HTML cache'ine yazılamaz.
104
+ *
105
+ * @param {import('http').IncomingMessage} req
106
+ * @returns {Record<string, string>}
107
+ */
108
+ export function parseCookies(req) {
109
+ markTainted("parseCookies(req)");
110
+
111
+ const cached = /** @type {any} */ (req)[PARSED];
112
+ if (cached) return cached;
113
+
114
+ /** @type {Record<string, string>} */
115
+ const out = {};
116
+ const header = req.headers?.cookie;
117
+
118
+ if (header) {
119
+ for (const part of header.split(";")) {
120
+ const index = part.indexOf("=");
121
+ if (index === -1) continue;
122
+
123
+ const name = part.slice(0, index).trim();
124
+ if (!name) continue;
125
+
126
+ try {
127
+ out[name] = decodeURIComponent(part.slice(index + 1).trim());
128
+ } catch {
129
+ // Bozuk yüzde kodlaması tüm başlığı çöpe atmamalı.
130
+ out[name] = part.slice(index + 1).trim();
131
+ }
132
+ }
133
+ }
134
+
135
+ /** @type {any} */ (req)[PARSED] = out;
136
+ return out;
137
+ }
138
+
139
+ /**
140
+ * @typedef {object} CookieOptions
141
+ * @property {string} [path] Varsayılan `/`.
142
+ * @property {string} [domain]
143
+ * @property {number} [maxAge] Saniye.
144
+ * @property {Date} [expires]
145
+ * @property {boolean} [httpOnly] Varsayılan `true`.
146
+ * @property {boolean} [secure] Varsayılan: development dışında `true`.
147
+ * @property {"Strict" | "Lax" | "None"} [sameSite] Varsayılan `Lax`.
148
+ */
149
+
150
+ /**
151
+ * Varsayılanlar bilinçli olarak kısıtlayıcı: `HttpOnly` ile JS okuyamaz,
152
+ * `SameSite=Lax` ile çapraz site POST'larında gönderilmez (CSRF'nin büyük
153
+ * kısmını kapatan tek satır), `Secure` üretimde açık.
154
+ *
155
+ * @param {string} name
156
+ * @param {string} value
157
+ * @param {CookieOptions} [options]
158
+ * @returns {string}
159
+ */
160
+ export function serializeCookie(name, value, options = {}) {
161
+ const parts = [`${name}=${encodeURIComponent(value)}`];
162
+
163
+ parts.push(`Path=${options.path ?? "/"}`);
164
+ if (options.domain) parts.push(`Domain=${options.domain}`);
165
+ if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
166
+ if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
167
+ if (options.httpOnly !== false) parts.push("HttpOnly");
168
+ if (options.secure ?? process.env.NODE_ENV !== "development") parts.push("Secure");
169
+ parts.push(`SameSite=${options.sameSite ?? "Lax"}`);
170
+
171
+ return parts.join("; ");
172
+ }
173
+
174
+ /**
175
+ * @param {import('http').ServerResponse} res
176
+ * @param {string} name
177
+ * @param {string} value
178
+ * @param {CookieOptions} [options]
179
+ */
180
+ export function setCookie(res, name, value, options = {}) {
181
+ const existing = res.getHeader("Set-Cookie");
182
+ const serialized = serializeCookie(name, value, options);
183
+
184
+ /** @type {string[]} */
185
+ const all = existing
186
+ ? Array.isArray(existing)
187
+ ? [...existing.map(String)]
188
+ : [String(existing)]
189
+ : [];
190
+
191
+ all.push(serialized);
192
+ res.setHeader("Set-Cookie", all);
193
+ }
194
+
195
+ /**
196
+ * @param {import('http').ServerResponse} res
197
+ * @param {string} name
198
+ * @param {CookieOptions} [options]
199
+ */
200
+ export function clearCookie(res, name, options = {}) {
201
+ setCookie(res, name, "", { ...options, maxAge: 0, expires: new Date(0) });
202
+ }
203
+
204
+ /**
205
+ * İmzalı cookie yazar. Değer okunabilir kalır (şifreleme değil, imza);
206
+ * gizli kalması gereken veriyi cookie'ye koymayın, kimliğini koyun.
207
+ *
208
+ * @param {import('http').ServerResponse} res
209
+ * @param {string} name
210
+ * @param {string} value
211
+ * @param {CookieOptions} [options]
212
+ */
213
+ export function setSignedCookie(res, name, value, options = {}) {
214
+ const encoded = base64url(value);
215
+ setCookie(res, name, `${encoded}.${sign(encoded)}`, options);
216
+ }
217
+
218
+ /**
219
+ * İmzalı cookie okur. İmza uymuyorsa `null` — bozuk imza, yok sayılmalı,
220
+ * "belki geçerlidir" diye kullanılmamalı.
221
+ *
222
+ * @param {import('http').IncomingMessage} req
223
+ * @param {string} name
224
+ * @returns {string | null}
225
+ */
226
+ export function getSignedCookie(req, name) {
227
+ const raw = parseCookies(req)[name];
228
+ if (!raw) return null;
229
+
230
+ const index = raw.lastIndexOf(".");
231
+ if (index <= 0) return null;
232
+
233
+ const encoded = raw.slice(0, index);
234
+ const signature = raw.slice(index + 1);
235
+
236
+ let expected;
237
+ try {
238
+ expected = sign(encoded);
239
+ } catch {
240
+ // Sır yoksa imzalı okuma sessizce başarısız olur: sunucu ayakta kalır
241
+ // ama hiçbir oturum geçerli sayılmaz.
242
+ return null;
243
+ }
244
+
245
+ if (!safeEqual(signature, expected)) return null;
246
+ return fromBase64url(encoded);
247
+ }
248
+
249
+ /**
250
+ * Kriptografik rastgele token. CSRF token'ı ve oturum kimliği için.
251
+ *
252
+ * @param {number} [bytes]
253
+ * @returns {string}
254
+ */
255
+ export function randomToken(bytes = 32) {
256
+ return crypto.randomBytes(bytes).toString("base64url");
257
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * İstek başına yaşayan bağlam.
3
+ *
4
+ * `request-cache.js` ile aynı desen: `AsyncLocalStorage`, bağlam yoksa her şey
5
+ * sessizce devre dışı. Ayrı bir modül olmasının sebebi, taşıdığı bilginin
6
+ * memoizasyondan farklı olması — burada tutulanlar bir isteğin **cache'lenip
7
+ * cache'lenemeyeceğine** dair kararlar:
8
+ *
9
+ * private → route açıkça "bu sayfa kişiye özel" dedi
10
+ * tainted → controller cookie/Authorization okudu, yani çıktı kullanıcıya
11
+ * bağlı olabilir; HTML cache'e yazmak sızıntı olur
12
+ * csrfToken → bu istek için üretilmiş token; `csrfField()` şablonda basar
13
+ * res → token cookie'sini yazabilmek için; başka hiçbir şey için
14
+ * kullanılmaz, şablonlara yanıt nesnesi sızmaz
15
+ *
16
+ * `tainted` bir tahmin değil, gözlem: `markTainted()` yalnızca gerçekten
17
+ * kimliğe dokunan bir erişimden sonra çağrılır (bkz. `guardRequest`).
18
+ */
19
+ import { AsyncLocalStorage } from "node:async_hooks";
20
+
21
+ /**
22
+ * @typedef {object} RequestContext
23
+ * @property {boolean} private Route `private: true` ile mi kaydedildi.
24
+ * @property {boolean} tainted Kimliğe bağlı bir veri okundu mu.
25
+ * @property {string[]} taintReasons Hangi erişimler işaretledi (teşhis için).
26
+ * @property {string | null} csrfToken
27
+ * @property {import('express').Response | null} res
28
+ */
29
+
30
+ /** @type {AsyncLocalStorage<RequestContext>} */
31
+ const storage = new AsyncLocalStorage();
32
+
33
+ /**
34
+ * @param {{ private?: boolean, res?: import('express').Response }} [initial]
35
+ * @returns {RequestContext}
36
+ */
37
+ export function createRequestContext(initial = {}) {
38
+ return {
39
+ private: initial.private === true,
40
+ tainted: false,
41
+ taintReasons: [],
42
+ csrfToken: null,
43
+ res: initial.res ?? null,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * İsteği verilen bağlam içinde çalıştırır.
49
+ *
50
+ * @template T
51
+ * @param {RequestContext} context
52
+ * @param {() => T} run
53
+ * @returns {T}
54
+ */
55
+ export function withRequestContext(context, run) {
56
+ return storage.run(context, run);
57
+ }
58
+
59
+ /**
60
+ * @returns {RequestContext | undefined}
61
+ */
62
+ export function getRequestContext() {
63
+ return storage.getStore();
64
+ }
65
+
66
+ /**
67
+ * Çıktının kullanıcıya bağlı olduğunu bildirir. Bağlam yoksa (script, build,
68
+ * fragment dışı kullanım) sessizce yok sayılır.
69
+ *
70
+ * @param {string} reason Teşhis mesajında görünecek erişim adı.
71
+ */
72
+ export function markTainted(reason) {
73
+ const context = storage.getStore();
74
+ if (!context) return;
75
+
76
+ context.tainted = true;
77
+ if (!context.taintReasons.includes(reason)) {
78
+ context.taintReasons.push(reason);
79
+ }
80
+ }
81
+
82
+
83
+ /** Okunması çıktıyı kullanıcıya bağlayan başlıklar. */
84
+ const SENSITIVE_HEADERS = new Set([
85
+ "cookie",
86
+ "authorization",
87
+ "proxy-authorization",
88
+ ]);
89
+
90
+ /** Okunması çıktıyı kullanıcıya bağlayan `req` alanları. */
91
+ const SENSITIVE_PROPS = new Set(["cookies", "signedCookies", "session", "user"]);
92
+
93
+ /**
94
+ * @param {Record<string, unknown>} headers
95
+ * @returns {Record<string, unknown>}
96
+ */
97
+ function guardHeaders(headers) {
98
+ return new Proxy(headers, {
99
+ get(target, prop, receiver) {
100
+ if (typeof prop === "string" && SENSITIVE_HEADERS.has(prop.toLowerCase())) {
101
+ markTainted(`req.headers.${prop}`);
102
+ }
103
+ return Reflect.get(target, prop, receiver);
104
+ },
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Controller'a giden `req`'i kimliğe dokunan erişimleri işaretleyen bir Proxy
110
+ * ile sarar.
111
+ *
112
+ * Neden gerekli: HTML cache anahtarı yalnızca yol + query. Cookie okuyan bir
113
+ * controller cache'lenebilir bir route'a bağlanmışsa bir kullanıcının HTML'i
114
+ * bir başkasına servis edilir ve bu hiçbir yerde hata olarak görünmez.
115
+ * İşaretleme, sessiz sızıntıyı gürültülü bir uyarıya çeviriyor.
116
+ *
117
+ * Kapsam bilinçli olarak dar: yalnızca *okuma* yakalanır, yazma ve metot
118
+ * çağrıları hedefe dokunulmadan geçer. Proxy'nin prototipi korunduğu için
119
+ * `req instanceof IncomingMessage` gibi kontroller etkilenmez.
120
+ *
121
+ * @param {import('express').Request} req
122
+ * @returns {import('express').Request}
123
+ */
124
+ export function guardRequest(req) {
125
+ /** @type {Record<string, unknown> | null} */
126
+ let headersProxy = null;
127
+
128
+ return new Proxy(req, {
129
+ get(target, prop, receiver) {
130
+ if (prop === "headers" || prop === "headersDistinct") {
131
+ const raw = Reflect.get(target, prop, receiver);
132
+ if (!raw || typeof raw !== "object") return raw;
133
+ if (prop === "headers") {
134
+ headersProxy ??= guardHeaders(/** @type {Record<string, unknown>} */ (raw));
135
+ return headersProxy;
136
+ }
137
+ return guardHeaders(/** @type {Record<string, unknown>} */ (raw));
138
+ }
139
+
140
+ if (prop === "get" || prop === "header") {
141
+ const original = Reflect.get(target, prop, receiver);
142
+ if (typeof original !== "function") return original;
143
+ return function guardedHeaderLookup(/** @type {string} */ name) {
144
+ if (typeof name === "string" && SENSITIVE_HEADERS.has(name.toLowerCase())) {
145
+ markTainted(`req.get("${name}")`);
146
+ }
147
+ return original.call(target, name);
148
+ };
149
+ }
150
+
151
+ if (typeof prop === "string" && SENSITIVE_PROPS.has(prop)) {
152
+ markTainted(`req.${prop}`);
153
+ }
154
+
155
+ const value = Reflect.get(target, prop, receiver);
156
+ // Express metotları kendi iç alanlarına `this` üzerinden erişiyor;
157
+ // proxy'yi receiver olarak bırakmak bu erişimleri de yakalayıp
158
+ // gereksiz işaretlemeye yol açar.
159
+ return typeof value === "function" ? value.bind(target) : value;
160
+ },
161
+ });
162
+ }
package/src/index.js CHANGED
@@ -5,19 +5,36 @@
5
5
  * controller'lar bu yüzeyi kullanır. Alt yollardan (`jskelet/server`)
6
6
  * ithal etmek de mümkün; buradaki liste "kararlı" sayılan yüzeydir.
7
7
  */
8
- export { route, renderPage, renderView, renderNotFound } from "./server/render.js";
8
+ export {
9
+ route,
10
+ fragment,
11
+ renderPage,
12
+ renderView,
13
+ renderNotFound,
14
+ } from "./server/render.js";
9
15
  export { renderStatusPage, statusFromError } from "./server/status-page.js";
10
16
  export { createApp, startServer } from "./server/create-app.js";
11
17
  export {
12
18
  notFound,
13
19
  redirect,
14
20
  permanentRedirect,
21
+ seeOther,
15
22
  isNotFoundError,
16
23
  isRedirectError,
17
24
  NotFoundError,
18
25
  RedirectError,
19
26
  } from "./http/control-flow.js";
20
27
  export { cache, withRequestCache } from "./http/request-cache.js";
28
+ export {
29
+ clearCookie,
30
+ getSignedCookie,
31
+ parseCookies,
32
+ randomToken,
33
+ safeEqual,
34
+ serializeCookie,
35
+ setCookie,
36
+ setSignedCookie,
37
+ } from "./http/cookies.js";
21
38
  export { reportUpstreamFailure } from "./server/upstream-tracking.js";
22
39
  export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
23
40
  export { headHints } from "./server/head-hints.js";
@@ -32,4 +49,4 @@ export { prewarm, prewarmProgress } from "./server/prewarm.js";
32
49
  export { createProxy } from "./server/middleware/upstream-proxy.js";
33
50
  export { getConfig, loadConfig } from "./config/index.js";
34
51
  export { attrs, cn, cx, esc, jsonScript } from "./views/helpers/html.js";
35
- export { icon, image, link, preloadImage } from "./views/helpers/tags.js";
52
+ export { csrfField, icon, image, link, preloadImage } from "./views/helpers/tags.js";