jskelet 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/02-mimari.md CHANGED
@@ -35,6 +35,7 @@ JSkelet bu gözlemi mimarinin merkezine alır:
35
35
  ├─ headers statik cache + config headers()
36
36
  ├─ devGate DEV_TOKEN varsa token yoksa 404
37
37
  ├─ redirects config redirects(), ilk eşleşen kazanır
38
+ ├─ trailingSlash config trailingSlash: true ise 308
38
39
  ├─ staticPrecompressed build'de üretilmiş .br/.gz kopyalar (kalite 11)
39
40
  ├─ express.static public/ altındaki dosyalar
40
41
  ├─ (dev) devtools yalnızca NODE_ENV=development
@@ -60,13 +61,18 @@ sebebi var ve yer değiştirmek sessiz bozulmalara yol açıyor.
60
61
  `express.static` isteği önce yanıtlar.
61
62
  - **`compression`, static'ten önce.** Sonra gelirse statik dosyalar hiç
62
63
  sıkışmaz.
63
- - **`headers` → `devGate` → `redirects`.** Gate'in 404'ü redirect'ten önce
64
- gelmeli: yayına açılmamış bir ortam, yönlendirme kurallarını bile dışarıya
65
- sızdırmamalı.
64
+ - **`headers` → `devGate` → `redirects` → `trailingSlash`.** Gate'in 404'ü
65
+ redirect'ten önce gelmeli: yayına açılmamış bir ortam, yönlendirme kurallarını
66
+ bile dışarıya sızdırmamalı. `trailingSlash` config redirects'ten sonra durur,
67
+ böylece açık kurallar istenen yolu önce görür; kanonik slash biçimi ikinci
68
+ adımda dayatılır.
66
69
  - **`staticPrecompressed`, `express.static`ten önce.** Build'de üretilmiş
67
70
  `.br`/`.gz` kopyalar varsa onlar servis edilir (brotli kalite 11); yoksa
68
71
  istek altındaki `static`e düşer ve middleware anında sıkıştırır (kalite 5).
69
72
  Hash'li ve `immutable` bir dosyayı her istekte yeniden sıkıştırmak boşa CPU.
73
+ - **Admin paneli** (açıksa, `admin().enabled` / `JSKELET_ADMIN`): statikten
74
+ sonra, body parser ve route'lardan önce. Kendi gövde ayrıştırıcısını taşır;
75
+ uygulama aynı yolu gölgeleyemez. Kapalıyken modül yüklenmez.
70
76
  - **Body parser'lar statikten sonra.** Görsel isteklerinde gövde ayrıştırma
71
77
  maliyeti ödenmesin.
72
78
  - **`rewrites(afterFiles)`, statik denendikten sonra ve sayfalardan önce.**
@@ -388,6 +388,12 @@ Davranış:
388
388
  için 301.
389
389
  - `source` ya da `destination` geçersizse kural sessizce düşmez, uyarı basılır.
390
390
 
391
+ ## Config: `trailingSlash`
392
+
393
+ `trailingSlash: true` iken kanonik sayfa URL'leri `/` ile biter ve **200**
394
+ döner; slash'sız istek **308** ile slash'lıya gider. Ayrıntı ve istisnalar:
395
+ [07-yapilandirma.md](./07-yapilandirma.md#trailingslash).
396
+
391
397
  ## Config: `rewrites()`
392
398
 
393
399
  Rewrite, tarayıcının adres çubuğunu değiştirmeden isteği başka bir yere taşır.
package/docs/06-cache.md CHANGED
@@ -768,13 +768,24 @@ yalnızca `NODE_ENV=development` iken var, panel ise ortama bakmaz — "bu sayfa
768
768
  neden bayat", "webhook purge'ü geçti mi", "Redis gerçekten bağlı mı" soruları
769
769
  üretimde soruluyor.
770
770
 
771
+ Panel üst düzey `admin()` ile açılır (`cache()` içinde değil) ve kökü
772
+ `/_jskelet/admin`'dır. Altında Overview, Cache, Routes, Views, Logs ve System
773
+ sayfaları vardır; Cache sayfası eski tek sayfalık panelle aynı işlemleri
774
+ taşır.
775
+
771
776
  ```js
772
777
  // jskelet.config.mjs
773
778
  export default {
779
+ admin() {
780
+ return {
781
+ enabled: process.env.JSKELET_ADMIN === "1",
782
+ allowIps: ["10.0.0.0/8"], // boş = IP kısıtı yok
783
+ blockBots: true,
784
+ };
785
+ },
774
786
  cache() {
775
787
  return {
776
788
  html: { "/haber/:slug": 300 },
777
- panel: { enabled: process.env.CACHE_PANEL === "1" },
778
789
  };
779
790
  },
780
791
  };
@@ -782,14 +793,20 @@ export default {
782
793
 
783
794
  `enabled` verilmedikçe **hiçbir şey mount edilmez**: yol yoktur, modül
784
795
  yüklenmez, üretim sürecinde hiçbir maliyeti olmaz. Ortam değişkeni
785
- (`JSKELET_CACHE_PANEL=1`) config'i ezer; panel genelde bir arıza sırasında tek
796
+ (`JSKELET_ADMIN=1`) config'i ezer; panel genelde bir arıza sırasında tek
786
797
  seferlik açılıyor ve o an config dosyasını değiştirip yeniden dağıtmak
787
798
  istenmiyor.
788
799
 
789
800
  Panel açıldığında sunucu logu şifreyi basar:
790
801
 
791
802
  ```
792
- [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
803
+ ┌─ ADMIN ──────────────────────────────────────┐
804
+ │ http://localhost:3000/_jskelet/admin │
805
+ │ │
806
+ │ password 3f9c… │
807
+ │ │
808
+ │ Valid until this process restarts. │
809
+ └──────────────────────────────────────────────┘
793
810
  ```
794
811
 
795
812
  ### Erişim ve güvenlik
@@ -799,15 +816,20 @@ Panel açıldığında sunucu logu şifreyi basar:
799
816
  yetkisi demek ve bir deploy eski erişimi kendiliğinden iptal etmeli.
800
817
  - **Şifre query string ile kabul edilmez.** Erişim logları, tarayıcı geçmişi ve
801
818
  `Referer` başlığı sırrı taşımasın; giriş yalnızca form üzerinden.
819
+ - **`allowIps`** verilirse (exact IP veya CIDR) listede olmayan her istek
820
+ login dahil `404` alır.
821
+ - **`blockBots`** (varsayılan `true`) bilinen crawler UA'larını (Googlebot,
822
+ Bingbot, Ahrefs, …) `404` ile reddeder.
802
823
  - **Üç başarısız denemede IP 24 saat yasaklanır** (`banAttempts`, `banHours`).
803
- Yanlış şifre kadar oturumsuz istek de sayılır; başarılı giriş sayacı sıfırlar.
824
+ Yanlış şifre kadar oturumsuz yazma isteği de sayılır; başarılı giriş sayacı
825
+ sıfırlar.
804
826
  - **Yasaklı ve yetkisiz her cevap `404`.** 401/403 panelin var olduğunu
805
827
  doğrular, 404 hiç yokmuş gibi davranır. Panelin dışındaki site etkilenmez.
806
828
  - **Hiçbir yeri indekslenmez:** her cevapta `X-Robots-Tag: noindex, nofollow,
807
829
  noarchive, nosnippet`, `Cache-Control: no-store` ve `Referrer-Policy:
808
830
  no-referrer`. Yol ayrıca ısıtma listesinden ve gezinme ipuçlarından muaftır.
809
- - Aksiyonlar `X-JSkelet-Cache-Panel` başlığı ister — çapraz siteden
810
- gönderilemeyen bir başlık, yani panelin kendi CSRF freni.
831
+ - Aksiyonlar `X-JSkelet-Admin` başlığı ister — çapraz siteden gönderilemeyen
832
+ bir başlık, yani panelin kendi CSRF freni.
811
833
  - Oturumlar ve yasak sayaçları süreç belleğinde durur; şifresi zaten her
812
834
  restart'ta değişen bir panel için diske yazmak yanlış takas olurdu.
813
835
 
@@ -815,12 +837,12 @@ Panel açıldığında sunucu logu şifreyi basar:
815
837
 
816
838
  | Bölüm | Gösterdiği |
817
839
  | --- | --- |
818
- | Üst satır | Sürüm, ortam, pid, uptime, RSS ve dil seçimi (Türkçe / İngilizce) |
819
- | Kartlar | HTML girdi sayısı ve sınırı, bellekteki HTML boyutu, bayat girdi sayısı, veri girdisi sayısı, Redis durumu (`connected` / `bypassed` / `off`), ısıtma turunun ilerlemesi |
820
- | Paylaşımlı kademe | Bağlantının **nereye** kurulduğu (adres, TLS, veritabanı), anahtar öneki ve `namespace`, `buildId`, hangi türlerin paylaşıldığı, sıkıştırılmış gövde ve purge yayını durumu, komut zaman aşımı ve hata sayısı. Kapalıysa yerine Redis önerisi ve kurulum parçacığı çıkar. |
821
- | Cloudflare | Zone, plan, cache ile ilgili zone ayarları, development mode'un kalan süresi, Tiered Cache / Cache Reserve durumu ve cache isabet oranı. Bağlı değilse kurulum parçacığı çıkar. |
822
- | Host | Makinenin RAM kullanımı ve projenin bulunduğu diskin doluluğu |
823
- | Girdi listesi | HTML: yol (yeni sekmede açılır), taze/bayat, boyut, durum kodu, kalan TTL, bağımlılık sayısı, hazır sıkıştırılmış gövdeler. Veri: anahtar (tıklayınca panoya kopyalanır), taze/bayat, kalan TTL |
840
+ | Overview | HTML/data/Redis/prewarm kartları ve upstream freni özeti |
841
+ | Cache | Paylaşımlı kademe, Cloudflare, aksiyonlar, girdi listesi (eski panel) |
842
+ | Routes | Express'e kayıtlı path/method'lar, route modül dosyaları, son istek özeti |
843
+ | Views | `views/` altındaki şablon envanteri |
844
+ | Logs | Canlı SSE kuyruğu; method/status/cache/kind/path ve metin filtresi |
845
+ | System | Makine RAM / disk |
824
846
 
825
847
  Liste **anahtar bazında filtrelenir** ve filtre sunucuda uygulanır: veri
826
848
  önbelleğinde on binlerce anahtar olabiliyor. Her istekte en fazla 500 satır
@@ -53,6 +53,7 @@ export default {
53
53
 
54
54
  layout: "views/layout.ejs",
55
55
  routes: ["./routes/10-pages.mjs", "./routes/99-catch-all.mjs"],
56
+ trailingSlash: false,
56
57
 
57
58
  static: {
58
59
  extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2"],
@@ -214,6 +215,29 @@ Ayrıntı: [03-routing.md](./03-routing.md).
214
215
  routes: ["./routes/api.js", "./routes/pages.js", "./routes/catch-all.js"]
215
216
  ```
216
217
 
218
+ ## `trailingSlash`
219
+
220
+ **Tip:** `boolean` — **Varsayılan:** `false`
221
+
222
+ `true` iken kanonik URL'ler `/` ile biter: `/hakkinda/` doğrudan **200**
223
+ döner; slash'sız `/hakkinda` **308** ile `/hakkinda/` adresine gider (301
224
+ değil — metodu koruyan kalıcı yönlendirme, framework'ün diğer `permanent`
225
+ redirect'leriyle aynı). Query string korunur.
226
+
227
+ İstisnalar: kök `/`, uzantılı dosya yolları (`/robots.txt`, `/assets/app.js`)
228
+ ve `/.well-known/**`. Bunlara slash eklenmez.
229
+
230
+ `false` iken (varsayılan) slash dayatılmaz. Express non-strict eşleşme ile
231
+ `/x` ve `/x/` ikisi de 200 olabilir — Next.js'in varsayılan "slash'ı kırp"
232
+ davranışından bilinçli fark; mevcut siteleri kırmamak için.
233
+
234
+ Açıkken şablonlardaki `href`, sitemap ve `redirects()` hedeflerini de
235
+ slash'lı yazın; aksi hâlde tarayıcı her tıklamada ekstra bir 308 görür.
236
+
237
+ ```js
238
+ trailingSlash: true
239
+ ```
240
+
217
241
  ## `static`
218
242
 
219
243
  **Tip:** `{ extensions?: string[], prefixes?: string[] }` — **Varsayılan:**
@@ -728,33 +752,38 @@ redis: {
728
752
  }
729
753
  ```
730
754
 
731
- ### `cache().panel`
755
+ ### `admin()`
732
756
 
733
- Önbellek yönetim paneli. Bellek içi kademenin ve Redis kademesinin durumunu
734
- gösterir; hedefli invalidation, tek girdi silme ve ısıtma tetikler.
757
+ Framework yönetim paneli (`/_jskelet/admin`). Bellek içi / Redis / Cloudflare
758
+ önbelleğini yönetir; route ve view envanteri ile canlı log kuyruğu sunar.
735
759
 
736
760
  Ortama bakmaz: `enabled` verilmedikçe **hiç mount edilmez** ve yol da yoktur.
737
761
  Açıkken production'da da çalışır — asıl sorular ("bu sayfa neden bayat",
738
- "webhook purge'ü geçti mi") orada soruluyor.
762
+ "webhook purge'ü geçti mi") orada soruluyor. `cache()` bölümünden ayrıdır.
739
763
 
740
764
  | Alan | Tip | Varsayılan | Anlamı |
741
765
  | --- | --- | --- | --- |
742
- | `enabled` | `boolean` | `false` | Yalnızca açıkça `true` verildiğinde açılır (`JSKELET_CACHE_PANEL` ezer) |
743
- | `basePath` | `string` | `"/_jskelet/cache"` | Panelin kökü |
766
+ | `enabled` | `boolean` | `false` | Yalnızca açıkça `true` verildiğinde açılır (`JSKELET_ADMIN` ezer) |
767
+ | `basePath` | `string` | `"/_jskelet/admin"` | Panelin kökü |
768
+ | `allowIps` | `string[]` | `[]` | Exact IP veya CIDR; boş = kısıt yok. Listede olmayan her istek 404 |
769
+ | `blockBots` | `boolean` | `true` | Bilinen crawler UA'ları 404 |
744
770
  | `banAttempts` | `number` | `3` | Kaç başarısız denemeden sonra IP yasaklanır |
745
771
  | `banHours` | `number` | `24` | Yasağın süresi |
746
772
  | `sessionHours` | `number` | `12` | Oturum çerezinin ömrü |
773
+ | `logSize` | `number` | `500` | Canlı log ring boyutu |
747
774
 
748
- Şifre **her süreç başlangıcında** üretilir ve yalnızca sunucu logunda görünür:
775
+ Şifre **her süreç başlangıcında** üretilir ve yalnızca sunucu logundaki
776
+ `ADMIN` kutusunda görünür. Yasaklı ve yetkisiz her cevap `404`'tür. Kullanım
777
+ ve ekran ayrıntıları: [06-cache.md](./06-cache.md).
749
778
 
779
+ ```js
780
+ admin() {
781
+ return {
782
+ enabled: process.env.JSKELET_ADMIN === "1",
783
+ allowIps: ["203.0.113.10", "10.0.0.0/8"],
784
+ };
785
+ }
750
786
  ```
751
- [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
752
- ```
753
-
754
- Kalıcı bir sır (config alanı ya da env) tutulmaz; sızması önbelleği boşaltma
755
- yetkisi demek ve her deploy eski erişimi kendiliğinden iptal etmeli. Yasaklı ve
756
- yetkisiz her cevap `404`'tür. Kullanım ve ekran ayrıntıları:
757
- [06-cache.md](./06-cache.md).
758
787
 
759
788
  ### `cache().cloudflare`
760
789
 
@@ -780,12 +809,6 @@ hata dönerse ilgili bölüm hatayı yazar ve panelin kalanı çalışmaya devam
780
809
  Neyin sorulabildiği — özellikle "bu sayfa kaç edge'de cache'li" sorusunun neden
781
810
  tam cevabı olmadığı — [06-cache.md](./06-cache.md) içinde.
782
811
 
783
- ```js
784
- panel: {
785
- enabled: process.env.CACHE_PANEL === "1",
786
- }
787
- ```
788
-
789
812
  ### `cache().prewarm`
790
813
 
791
814
  | Alan | Tip | Varsayılan | Anlamı |
@@ -909,7 +932,7 @@ basılmaz.
909
932
  | `HOST` | `startServer` | `::` | Bağlanılacak arayüz. Varsayılan çift yığın dinler (IPv6 + IPv4); IPv6 yoksa `0.0.0.0`'a düşer |
910
933
  | `JSKELET_SECRET` | `jskelet/cookies` | — | İmzalı cookie sırrı. `security.cookieSecret` verilmediğinde buradan okunur; ikisi de yoksa imzalı cookie API'si hata verir. [12](./12-panel-ve-oturum.md) |
911
934
  | `DEV_TOKEN` | `devGate`, `prewarm` | — | Ayarlıysa token taşımayan her isteğe 404 döner. Isıtma token'ı çerez olarak taşır. [09](./09-dev-araclari.md) |
912
- | `JSKELET_CACHE_PANEL` | `createApp` | — | Ayarlıysa önbellek panelini açar; `0` config'te açık olan paneli kapatır. Env config'i ezer, çünkü panel genelde bir arıza sırasında tek seferlik açılır. [06](./06-cache.md) |
935
+ | `JSKELET_ADMIN` | `createApp` | — | Ayarlıysa yönetim panelini açar; `0` config'te açık olan paneli kapatır. Env config'i ezer, çünkü panel genelde bir arıza sırasında tek seferlik açılır. [06](./06-cache.md) |
913
936
  | `JSKELET_CLOUDFLARE_KEY` | Cloudflare cache yüzeyi | — | API token. Verilene kadar CDN purge'ü ve edge analitiği kapalıdır; config'teki `apiToken`'ı ezer. Token hiçbir cevapta dönmez. [06](./06-cache.md) |
914
937
  | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache yüzeyi | — | Zone kimliği. Token'la birlikte verilmedikçe hiçbir Cloudflare ucu çağrılmaz |
915
938
  | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache yüzeyi | — | Purge URL'lerinin kökü. Panel iç bir adresten açılıyorsa gerekir |
package/docs/11-tasima.md CHANGED
@@ -16,6 +16,7 @@ alt kümesine benzetildi — `next.config` sözdizimi, Metadata API, `notFound()
16
16
  | `next.config.mjs` | `jskelet.config.mjs` | Aynı ruh, daha küçük yüzey ([07](./07-yapilandirma.md)) |
17
17
  | `headers()` | `headers()` | Aynı şekil: `{ source, headers: [{ key, value }] }` |
18
18
  | `redirects()` | `redirects()` | `permanent` → 308, aksi hâlde 307; `statusCode` ile ezilebilir |
19
+ | `trailingSlash` | `trailingSlash` | `true` → kanonik URL `/` ile biter (308); varsayılan `false` slash kırpmaz ([07](./07-yapilandirma.md)) |
19
20
  | `rewrites()` | `rewrites()` | `beforeFiles` / `afterFiles` fazları var; `fallback` yok |
20
21
  | `compress: true` | Otomatik | `node:zlib` ile brotli + gzip |
21
22
  | `images.deviceSizes` | `images.widths` | Build zamanı webp üretimi ([08](./08-build.md)) |
@@ -35,6 +35,7 @@ Request
35
35
  ├─ headers static cache + config headers()
36
36
  ├─ devGate if DEV_TOKEN is set, 404 without a token
37
37
  ├─ redirects config redirects(), first match wins
38
+ ├─ trailingSlash 308 when config trailingSlash is true
38
39
  ├─ staticPrecompressed .br/.gz copies produced at build (quality 11)
39
40
  ├─ express.static files under public/
40
41
  ├─ (dev) devtools only when NODE_ENV=development
@@ -60,14 +61,19 @@ position has a reason, and moving things around leads to silent breakage.
60
61
  because `express.static` answers the request first.
61
62
  - **`compression` before static.** If it came after, static files would never be
62
63
  compressed.
63
- - **`headers` → `devGate` → `redirects`.** The gate's 404 must come before the
64
- redirects: an environment that has not gone live should not leak even its
65
- redirect rules to the outside.
64
+ - **`headers` → `devGate` → `redirects` → `trailingSlash`.** The gate's 404 must
65
+ come before the redirects: an environment that has not gone live should not
66
+ leak even its redirect rules to the outside. `trailingSlash` sits after config
67
+ redirects so explicit rules see the requested path first; the canonical slash
68
+ form is enforced as a second step.
66
69
  - **`staticPrecompressed` before `express.static`.** If there are `.br`/`.gz`
67
70
  copies produced at build time, those are served (brotli quality 11);
68
71
  otherwise the request falls through to the `static` below it and the
69
72
  middleware compresses on the fly (quality 5). Recompressing a hashed,
70
73
  `immutable` file on every request is wasted CPU.
74
+ - **Admin panel** (when `admin().enabled` / `JSKELET_ADMIN`): after static,
75
+ before body parsers and routes. Carries its own body parsers so the app
76
+ cannot shadow the path. When off, the module is never loaded.
71
77
  - **Body parsers after static.** Image requests should not pay the cost of body
72
78
  parsing.
73
79
  - **`rewrites(afterFiles)` after static has been tried and before pages.** The
@@ -400,6 +400,12 @@ Behaviour:
400
400
  - If `source` or `destination` is invalid, the rule does not drop silently; a
401
401
  warning is printed.
402
402
 
403
+ ## Config: `trailingSlash`
404
+
405
+ With `trailingSlash: true`, canonical page URLs end with `/` and return **200**;
406
+ a request without the slash is sent to the slashed form with a **308**. Details
407
+ and exceptions: [07-configuration.md](./07-configuration.md#trailingslash).
408
+
403
409
  ## Config: `rewrites()`
404
410
 
405
411
  A rewrite moves a request somewhere else without changing the browser's address
@@ -778,78 +778,77 @@ Two more diagnostic surfaces:
778
778
 
779
779
  The full list of settings: [07-configuration.md](./07-configuration.md).
780
780
 
781
- ## The admin panel
782
-
783
- Instead of hand-writing the `getHtmlCacheEntries()` / `getRedisStatus()`
784
- endpoints above, the framework ships a panel. It is deliberately separate from
785
- the dev overlay: the overlay only exists while `NODE_ENV=development`, while the
786
- panel does not look at the environment — "why is this page stale", "did the
787
- webhook purge land", "is Redis actually connected" are production questions.
788
-
789
- ```js
790
- // jskelet.config.mjs
791
- export default {
792
- cache() {
793
- return {
794
- html: { "/news/:slug": 300 },
795
- panel: { enabled: process.env.CACHE_PANEL === "1" },
796
- };
797
- },
798
- };
799
- ```
800
-
801
- Without `enabled` **nothing is mounted**: the path does not exist, the module is
802
- never loaded and it costs the production process nothing. The environment
803
- variable (`JSKELET_CACHE_PANEL=1`) overrides the config, because the panel is
804
- usually opened once during an incident and editing the config file and
805
- redeploying is the last thing you want at that moment.
806
-
807
- When the panel is on, the server log prints the password:
808
-
809
- ```
810
- [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
811
- ```
812
-
813
- ### Access and hardening
814
-
815
- - **The password is regenerated on every process start** (32 hex characters) and
816
- only ever appears in the log. There is no persistent secret to leak: leaking
817
- one means handing out the right to flush the cache, and a deploy should revoke
818
- old access on its own.
819
- - **The password is not accepted in the query string,** so access logs, browser
820
- history and the `Referer` header never carry it. Sign-in goes through the form.
821
- - **Three failed attempts ban the IP for 24 hours** (`banAttempts`, `banHours`).
822
- Requests without a session count just like a wrong password; a successful
823
- sign-in resets the counter.
824
- - **Banned and unauthorised requests get a `404`.** A 401 or 403 confirms the
825
- panel exists; a 404 behaves as if it never did. The rest of the site is
826
- untouched.
827
- - **Nothing is indexable:** every response carries `X-Robots-Tag: noindex,
828
- nofollow, noarchive, nosnippet`, `Cache-Control: no-store` and
829
- `Referrer-Policy: no-referrer`. The path is also exempt from prewarming and
830
- from navigation speculation.
831
- - Actions require an `X-JSkelet-Cache-Panel` header — a header a cross-site form
832
- cannot send, which is the panel's own CSRF brake.
833
- - Sessions and ban counters live in process memory; persisting them to disk
834
- would be the wrong trade for a panel whose password changes on every restart.
835
-
836
- ### What the panel shows
837
-
838
- | Area | Contents |
839
- | --- | --- |
840
- | Top bar | Version, environment, pid, uptime, RSS and the language picker (Turkish / English) |
841
- | Cards | HTML entry count and limit, HTML bytes in memory, stale entry count, data entry count, Redis state (`connected` / `bypassed` / `off`), prewarm progress |
842
- | Shared tier | **Where** the connection points (address, TLS, database), the key prefix and `namespace`, the `buildId`, which kinds are shared, the state of compressed bodies and the purge broadcast, the command timeout and the error count. When it is off, a Redis recommendation with an install snippet takes its place. |
843
- | Cloudflare | Zone, plan, cache related zone settings, how long development mode has left, Tiered Cache / Cache Reserve state and the cache hit ratio. When no zone is connected, a setup snippet takes its place. |
844
- | Host | The machine's memory usage and how full the disk holding the project is |
845
- | Entry list | HTML: path (opens in a new tab), fresh/stale, size, status code, remaining TTL, dependency count, precompressed bodies. Data: key (click to copy), fresh/stale, remaining TTL |
846
-
847
- The list is **filtered by key** and the filter runs on the server: a data cache
848
- can hold tens of thousands of keys. At most 500 rows come back per request and
849
- the counter in the heading says how many matches were cut. HTML bodies and
850
- cached values are **never returned** — the panel's job is to show state, not to
851
- export content.
852
-
781
+ ## The admin panel
782
+
783
+ Instead of hand-writing the `getHtmlCacheEntries()` / `getRedisStatus()`
784
+ endpoints above, the framework ships a panel. It is deliberately separate from
785
+ the dev overlay: the overlay only exists while `NODE_ENV=development`, while the
786
+ panel does not look at the environment — "why is this page stale", "did the
787
+ webhook purge land", "is Redis actually connected" are production questions.
788
+
789
+ The panel is enabled with top-level `admin()` (not inside `cache()`) at
790
+ `/_jskelet/admin`, with Overview, Cache, Routes, Views, Logs and System pages.
791
+ The Cache page carries the same operations as the former single-page panel.
792
+
793
+ ```js
794
+ // jskelet.config.mjs
795
+ export default {
796
+ admin() {
797
+ return {
798
+ enabled: process.env.JSKELET_ADMIN === "1",
799
+ allowIps: ["10.0.0.0/8"], // empty = no IP restriction
800
+ blockBots: true,
801
+ };
802
+ },
803
+ cache() {
804
+ return {
805
+ html: { "/news/:slug": 300 },
806
+ };
807
+ },
808
+ };
809
+ ```
810
+
811
+ Without `enabled` **nothing is mounted**: the path does not exist, the module is
812
+ never loaded and it costs the production process nothing. The environment
813
+ variable (`JSKELET_ADMIN=1`) overrides the config, because the panel is
814
+ usually opened once during an incident and editing the config file and
815
+ redeploying is the last thing you want at that moment.
816
+
817
+ When the panel is on, the server log prints the password in an `ADMIN` box at
818
+ `http://localhost:3000/_jskelet/admin`.
819
+
820
+ ### Access and hardening
821
+
822
+ - **The password is regenerated on every process start** (32 hex characters) and
823
+ only ever appears in the log. There is no persistent secret to leak.
824
+ - **The password is not accepted in the query string.**
825
+ - **`allowIps`** (exact IP or CIDR), when set, returns `404` for every request
826
+ outside the list — including the login page.
827
+ - **`blockBots`** (default `true`) rejects known crawler UAs with `404`.
828
+ - **Three failed attempts ban the IP for 24 hours** (`banAttempts`, `banHours`).
829
+ - **Banned and unauthorised requests get a `404`.**
830
+ - **Nothing is indexable:** `X-Robots-Tag`, `Cache-Control: no-store`,
831
+ `Referrer-Policy: no-referrer`; exempt from prewarming and navigation speculation.
832
+ - Actions require an `X-JSkelet-Admin` header — the panel's own CSRF brake.
833
+ - Sessions and ban counters live in process memory.
834
+
835
+ ### What the panel shows
836
+
837
+ | Area | Contents |
838
+ | --- | --- |
839
+ | Overview | HTML/data/Redis/prewarm cards and upstream limiter summary |
840
+ | Cache | Shared tier, Cloudflare, actions, entry list (former panel) |
841
+ | Routes | Express path/method inventory, route modules, last-request summary |
842
+ | Views | Template inventory under `views/` |
843
+ | Logs | Live SSE queue with method/status/cache/kind/path and text filters |
844
+ | System | Host RAM / disk |
845
+
846
+ The list is **filtered by key** and the filter runs on the server: a data cache
847
+ can hold tens of thousands of keys. At most 500 rows come back per request and
848
+ the counter in the heading says how many matches were cut. HTML bodies and
849
+ cached values are **never returned** — the panel's job is to show state, not to
850
+ export content.
851
+
853
852
  ### What you can do from it
854
853
 
855
854
  | Action | Equivalent call |
@@ -57,6 +57,7 @@ export default {
57
57
 
58
58
  layout: "views/layout.ejs",
59
59
  routes: ["./routes/10-pages.mjs", "./routes/99-catch-all.mjs"],
60
+ trailingSlash: false,
60
61
 
61
62
  static: {
62
63
  extensions: [".svg", ".png", ".webp", ".avif", ".ico", ".woff2"],
@@ -219,6 +220,29 @@ alphabetically and recursively. Details: [03-routing.md](./03-routing.md).
219
220
  routes: ["./routes/api.js", "./routes/pages.js", "./routes/catch-all.js"]
220
221
  ```
221
222
 
223
+ ## `trailingSlash`
224
+
225
+ **Type:** `boolean` — **Default:** `false`
226
+
227
+ When `true`, canonical URLs end with `/`: `/about/` returns **200** directly;
228
+ bare `/about` is sent to `/about/` with a **308** (not 301 — a permanent
229
+ redirect that preserves the method, same as the framework's other `permanent`
230
+ redirects). The query string is kept.
231
+
232
+ Exceptions: the root `/`, paths with a file extension (`/robots.txt`,
233
+ `/assets/app.js`) and `/.well-known/**`. Those do not get a slash appended.
234
+
235
+ When `false` (the default) no slash is enforced. Express non-strict matching may
236
+ serve both `/x` and `/x/` as 200 — a deliberate difference from Next.js's
237
+ default "strip the slash" behaviour, so existing sites are not broken.
238
+
239
+ With the option on, write `href`s, sitemap entries and `redirects()` destinations
240
+ with a trailing slash too; otherwise every click pays an extra 308.
241
+
242
+ ```js
243
+ trailingSlash: true
244
+ ```
245
+
222
246
  ## `static`
223
247
 
224
248
  **Type:** `{ extensions?: string[], prefixes?: string[] }` — **Default:**
@@ -744,35 +768,40 @@ redis: {
744
768
  }
745
769
  ```
746
770
 
747
- ### `cache().panel`
771
+ ### `admin()`
748
772
 
749
- The cache admin panel. It shows the state of the in-process tier and the Redis
750
- tier, and it triggers targeted invalidation, single-entry drops and prewarming.
773
+ The framework admin panel (`/_jskelet/admin`). It manages the in-process /
774
+ Redis / Cloudflare caches and exposes route and view inventories plus a live
775
+ log queue.
751
776
 
752
777
  It does not look at the environment: without `enabled` **nothing is mounted**
753
778
  and the path does not exist. When it is on it also works in production — that is
754
779
  where the real questions ("why is this page stale", "did the webhook purge
755
- land") get asked.
780
+ land") get asked. It is separate from the `cache()` section.
756
781
 
757
782
  | Field | Type | Default | Meaning |
758
783
  | --- | --- | --- | --- |
759
- | `enabled` | `boolean` | `false` | Only turns on when explicitly `true` (`JSKELET_CACHE_PANEL` overrides it) |
760
- | `basePath` | `string` | `"/_jskelet/cache"` | Root of the panel |
784
+ | `enabled` | `boolean` | `false` | Only turns on when explicitly `true` (`JSKELET_ADMIN` overrides it) |
785
+ | `basePath` | `string` | `"/_jskelet/admin"` | Root of the panel |
786
+ | `allowIps` | `string[]` | `[]` | Exact IP or CIDR; empty = no restriction. Anyone outside gets 404 |
787
+ | `blockBots` | `boolean` | `true` | Known crawler UAs get 404 |
761
788
  | `banAttempts` | `number` | `3` | How many failed attempts ban an IP |
762
789
  | `banHours` | `number` | `24` | How long the ban lasts |
763
790
  | `sessionHours` | `number` | `12` | Lifetime of the session cookie |
791
+ | `logSize` | `number` | `500` | Live log ring size |
764
792
 
765
793
  The password is generated **on every process start** and only appears in the
766
- server log:
794
+ server log `ADMIN` box. Banned and unauthorised requests all get a `404`.
795
+ Usage and screens: [06-caching.md](./06-caching.md).
767
796
 
797
+ ```js
798
+ admin() {
799
+ return {
800
+ enabled: process.env.JSKELET_ADMIN === "1",
801
+ allowIps: ["203.0.113.10", "10.0.0.0/8"],
802
+ };
803
+ }
768
804
  ```
769
- [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
770
- ```
771
-
772
- There is no persistent secret (no config field, no environment variable):
773
- leaking one means handing out the right to flush the cache, and a deploy should
774
- revoke old access on its own. Banned and unauthorised requests all get a `404`.
775
- Usage and screens: [06-caching.md](./06-caching.md).
776
805
 
777
806
  ### `cache().cloudflare`
778
807
 
@@ -800,12 +829,6 @@ panel keeps working. What can actually be asked — in particular why "how many
800
829
  edges hold this page" has no exact answer — is in
801
830
  [06-caching.md](./06-caching.md).
802
831
 
803
- ```js
804
- panel: {
805
- enabled: process.env.CACHE_PANEL === "1",
806
- }
807
- ```
808
-
809
832
  ### `cache().prewarm`
810
833
 
811
834
  | Field | Type | Default | Meaning |
@@ -930,7 +953,7 @@ and no warning is printed.
930
953
  | `HOST` | `startServer` | `::` | Interface to bind to. The default listens dual-stack (IPv6 + IPv4); it falls back to `0.0.0.0` where IPv6 is unavailable |
931
954
  | `JSKELET_SECRET` | `jskelet/cookies` | — | The signed cookie secret. Read when `security.cookieSecret` is not set; if neither exists, the signed cookie API throws. [12](./12-dashboards-and-sessions.md) |
932
955
  | `DEV_TOKEN` | `devGate`, `prewarm` | — | If set, every request without a token gets a 404. Prewarming carries the token as a cookie. [09](./09-dev-tools.md) |
933
- | `JSKELET_CACHE_PANEL` | `createApp` | — | When set, turns the cache panel on; `0` turns off a panel enabled in the config. The env wins because the panel is usually opened once during an incident. [06](./06-caching.md) |
956
+ | `JSKELET_ADMIN` | `createApp` | — | When set, turns the admin panel on; `0` turns off a panel enabled in the config. The env wins because the panel is usually opened once during an incident. [06](./06-caching.md) |
934
957
  | `JSKELET_CLOUDFLARE_KEY` | Cloudflare cache surface | — | API token. Until it is set, CDN purging and edge analytics stay off; it overrides `apiToken` in the config. The token is never returned in a response. [06](./06-caching.md) |
935
958
  | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache surface | — | Zone identifier. No Cloudflare endpoint is called unless it is set alongside the token |
936
959
  | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache surface | — | The root for purge URLs. Required when the panel is opened over an internal address |
@@ -17,6 +17,7 @@ will feel familiar. The *reasons* behind the differences are in
17
17
  | `next.config.mjs` | `jskelet.config.mjs` | Same spirit, smaller surface ([07](./07-configuration.md)) |
18
18
  | `headers()` | `headers()` | Same shape: `{ source, headers: [{ key, value }] }` |
19
19
  | `redirects()` | `redirects()` | `permanent` → 308, otherwise 307; can be overridden with `statusCode` |
20
+ | `trailingSlash` | `trailingSlash` | `true` → canonical URLs end with `/` (308); default `false` does not strip ([07](./07-configuration.md)) |
20
21
  | `rewrites()` | `rewrites()` | There are `beforeFiles` / `afterFiles` phases; no `fallback` |
21
22
  | `compress: true` | Automatic | brotli + gzip via `node:zlib` |
22
23
  | `images.deviceSizes` | `images.widths` | Build-time webp generation ([08](./08-build.md)) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "A framework that feels like no framework: Express 5 + EJS server rendering, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",