jskelet 0.2.5 → 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.
Files changed (106) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +403 -383
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +293 -287
  7. package/docs/03-routing.md +486 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1231 -1209
  11. package/docs/07-yapilandirma.md +44 -21
  12. package/docs/08-build.md +366 -366
  13. package/docs/09-dev-araclari.md +335 -335
  14. package/docs/10-dagitim.md +329 -329
  15. package/docs/11-tasima.md +1 -0
  16. package/docs/12-panel-ve-oturum.md +384 -384
  17. package/docs/README.md +105 -105
  18. package/docs/en/01-getting-started.md +292 -292
  19. package/docs/en/02-architecture.md +311 -305
  20. package/docs/en/03-routing.md +503 -497
  21. package/docs/en/04-rendering.md +504 -504
  22. package/docs/en/05-islands.md +492 -492
  23. package/docs/en/06-caching.md +1197 -1198
  24. package/docs/en/07-configuration.md +1009 -986
  25. package/docs/en/08-build.md +383 -383
  26. package/docs/en/09-dev-tools.md +342 -342
  27. package/docs/en/10-deployment.md +332 -332
  28. package/docs/en/11-migration.md +360 -359
  29. package/docs/en/12-dashboards-and-sessions.md +392 -392
  30. package/docs/en/README.md +112 -112
  31. package/package.json +102 -102
  32. package/src/build/ensure-build.mjs +15 -15
  33. package/src/build/paths.mjs +143 -143
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +268 -268
  36. package/src/build/tasks/css.mjs +124 -124
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +224 -224
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/client/{cache-panel → admin}/i18n.js +756 -670
  42. package/src/client/{cache-panel → admin}/login.html +74 -74
  43. package/src/client/{cache-panel → admin}/panel.css +804 -756
  44. package/src/client/admin/panel.html +486 -0
  45. package/src/client/{cache-panel → admin}/panel.js +1242 -915
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +725 -725
  48. package/src/client/dom.js +95 -95
  49. package/src/client/form.js +192 -192
  50. package/src/client/index.js +35 -35
  51. package/src/client/registry.js +297 -297
  52. package/src/client/safe-image.js +91 -91
  53. package/src/client/store.js +36 -36
  54. package/src/client/swap.js +188 -188
  55. package/src/config/defaults.js +16 -7
  56. package/src/config/index.js +38 -22
  57. package/src/config/pattern.js +107 -107
  58. package/src/http/control-flow.js +71 -71
  59. package/src/http/cookies.js +257 -257
  60. package/src/http/request-cache.js +46 -46
  61. package/src/http/request-context.js +162 -162
  62. package/src/index.js +83 -83
  63. package/src/init.mjs +221 -221
  64. package/src/log.mjs +58 -0
  65. package/src/runtime/alias-hooks.mjs +119 -119
  66. package/src/runtime/register.mjs +4 -4
  67. package/src/server/admin/actions.js +229 -0
  68. package/src/server/admin/auth.js +125 -0
  69. package/src/server/admin/event-log.js +151 -0
  70. package/src/server/admin/gate.js +209 -0
  71. package/src/server/admin/inventory.js +188 -0
  72. package/src/server/admin/mount.js +56 -0
  73. package/src/server/admin/router.js +216 -0
  74. package/src/server/admin/snapshot.js +126 -0
  75. package/src/server/assets.js +147 -147
  76. package/src/server/cache-deps.js +42 -42
  77. package/src/server/cloudflare.js +607 -607
  78. package/src/server/create-app.js +295 -291
  79. package/src/server/data-cache.js +462 -462
  80. package/src/server/dev/report.js +369 -369
  81. package/src/server/dev/socket.js +170 -170
  82. package/src/server/dev/version-check.mjs +139 -139
  83. package/src/server/html-cache.js +817 -817
  84. package/src/server/metadata.js +102 -102
  85. package/src/server/middleware/compression.js +205 -205
  86. package/src/server/middleware/csrf.js +134 -134
  87. package/src/server/middleware/dev-gate.js +62 -62
  88. package/src/server/middleware/headers.js +37 -37
  89. package/src/server/middleware/redirects.js +32 -32
  90. package/src/server/middleware/static-precompressed.js +100 -100
  91. package/src/server/middleware/trailing-slash.js +53 -0
  92. package/src/server/middleware/upstream-proxy.js +141 -141
  93. package/src/server/prewarm.js +601 -601
  94. package/src/server/redis.js +569 -569
  95. package/src/server/router.js +128 -128
  96. package/src/server/status-page.js +164 -164
  97. package/src/server/upstream-limiter.js +376 -376
  98. package/src/server/upstream-tracking.js +166 -166
  99. package/src/start.mjs +7 -7
  100. package/src/templates/layout.ejs +44 -44
  101. package/src/version.mjs +31 -31
  102. package/src/views/components/loader.js +85 -85
  103. package/src/views/helpers/html.js +102 -102
  104. package/src/views/helpers/tags.js +245 -245
  105. package/src/client/cache-panel/panel.html +0 -308
  106. package/src/server/cache-panel.js +0 -759
@@ -1,392 +1,392 @@
1
- # 12 — Dashboards, sessions and per-visitor pages
2
-
3
- JSkelet's center of gravity is public, cacheable pages. A dashboard sits on the
4
- opposite axis: different HTML for every visitor, no cache, heavy interaction.
5
- This document covers that axis — `private: true`, session cookies, CSRF,
6
- fragment endpoints and region swapping.
7
-
8
- One thing is deliberately out of scope: **live data transport**. Choosing
9
- between SSE, WebSocket and polling belongs to the application; the framework
10
- only provides the "refresh this region from the server" step. The compression
11
- layer skips `text/event-stream` responses and streams that write their own
12
- headers, so wiring up SSE by hand does not break anything either.
13
-
14
- The runnable counterpart is `examples/dashboard/`, which is where the snippets
15
- below come from.
16
-
17
- ## Why a separate path is needed
18
-
19
- The HTML cache key is only the path and the query string:
20
-
21
- ```
22
- `${req.path}?${new URLSearchParams(query).toString()}`
23
- ```
24
-
25
- Identity is not part of the key. So if a session-dependent page is registered
26
- with a plain `route()`, the first visitor's HTML is served to **everyone** for
27
- the length of the TTL. Nothing about this mistake fails loudly: the page works,
28
- the tests pass, and the problem only appears once a second user arrives —
29
- usually in production.
30
-
31
- ## `private: true`
32
-
33
- ```js
34
- export default function register(app, { route, redirect }) {
35
- app.get(
36
- "/dashboard",
37
- route(
38
- async ({ req }) => {
39
- const user = currentUser(req);
40
- if (!user) redirect("/sign-in?next=%2Fdashboard");
41
-
42
- return { view: "pages/overview", data: { user } };
43
- },
44
- { private: true },
45
- ),
46
- );
47
- }
48
- ```
49
-
50
- What the flag does:
51
-
52
- | Behaviour | Public `route()` | `private: true` |
53
- | --- | --- | --- |
54
- | HTML cache | On when a TTL exists | Off, cannot be turned on |
55
- | `cache.html` pattern | Overrides the TTL | Ignored |
56
- | `Cache-Control` | `public, s-maxage=…` | `private, no-store` |
57
- | `Vary` | `Accept-Encoding` | `Cookie, Accept-Encoding` |
58
- | ETag | Present | Absent |
59
- | `X-JSkelet-Cache` | `HIT`/`STALE`/`MISS` | Not written |
60
-
61
- Dropping the ETag looks like a detail but is not: a strong ETag over a
62
- per-visitor body is a fingerprint of that visitor, and a layer that ignores
63
- `no-store` could use it to tell them apart.
64
-
65
- A redirect thrown from a per-visitor page is not cacheable either. "You need to
66
- sign in" is a session-dependent decision; storing it means sending signed-in
67
- users to the sign-in page too.
68
-
69
- ## When you forget the flag
70
-
71
- The framework watches for reads that touch identity. The `req` passed to the
72
- controller is wrapped in a thin Proxy that marks these accesses:
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)` and `getSignedCookie(req, …)`, which report it directly
79
-
80
- A marked render is **never stored**. In production the response is sent with
81
- `no-store` and this line is logged:
82
-
83
- ```
84
- [render] /dashboard read identity-bound data (req.headers.cookie), not cached.
85
- Register the route with 'private: true'.
86
- ```
87
-
88
- In development the same situation fails the request. The reason it is not silent
89
- is simple: this mistake produces a working page, so it is never noticed on its
90
- own.
91
-
92
- `csrfField()` marks the render the same way. A page that prints a token cannot
93
- come from the cache — if it did, every visitor would share the same token and
94
- the double-submit check would verify nothing.
95
-
96
- ## Sessions: signed cookies
97
-
98
- The framework does not provide identity. The only thing it gives you is the
99
- guarantee that "I wrote this value and it has not been tampered with":
100
-
101
- ```js
102
- import { clearCookie, getSignedCookie, setSignedCookie } from "jskelet/cookies";
103
-
104
- export function startSession(res, username) {
105
- setSignedCookie(res, "session", username, { maxAge: 60 * 60 * 8 });
106
- }
107
-
108
- export function currentUser(req) {
109
- const username = getSignedCookie(req, "session");
110
- return username ? findUser(username) : null;
111
- }
112
-
113
- export function endSession(res) {
114
- clearCookie(res, "session");
115
- }
116
- ```
117
-
118
- The signature is HMAC-SHA256 and the comparison is constant time. If the
119
- signature does not match, `getSignedCookie` returns `null` — a tampered value is
120
- never used on the assumption that it "might be valid".
121
-
122
- The secret comes from `security.cookieSecret` or the `JSKELET_SECRET`
123
- environment variable. Without a secret the signed API **throws**; the rule that
124
- a configuration error must not take the site down does not apply here, because
125
- the silent alternative would be trusting an unsigned cookie.
126
-
127
- The defaults are on the restrictive side: `HttpOnly`, `SameSite=Lax`, `Secure`
128
- outside development, `Path=/`. `SameSite=Lax` alone closes most of CSRF — the
129
- cookie is simply not sent on cross-site POSTs.
130
-
131
- Cookies are **signed, not encrypted**. The value is readable, so store the
132
- identifier of a secret rather than the secret itself.
133
-
134
- ## CSRF
135
-
136
- The framework parses the request body (`express.urlencoded` + `express.json`),
137
- which makes it the layer that accepts state-changing requests. The protection
138
- has two layers.
139
-
140
- ### Layer 1 — origin check (on by default)
141
-
142
- Unsafe methods get a 403 when `Origin` does not match our host, or when
143
- `Sec-Fetch-Site: cross-site` arrives. **If neither header is present the request
144
- passes**: browsers always send `Origin` on a cross-origin POST, while webhooks
145
- and server-to-server calls send neither. That distinction keeps the protection
146
- on without breaking integrations.
147
-
148
- ```js
149
- security: {
150
- csrf: {
151
- // Legitimate exceptions, such as a panel on a separate domain.
152
- allowedOrigins: ["https://admin.example.com"],
153
- // Endpoints that do not come from a browser.
154
- exclude: ["/webhook/:path*"],
155
- },
156
- }
157
- ```
158
-
159
- ### Layer 2 — double-submit token (optional)
160
-
161
- Enabled with `security.csrf.token: true`. Forms print the token with
162
- `csrfField()`:
163
-
164
- ```ejs
165
- <form method="post" action="/dashboard/notes">
166
- <%- csrfField() %>
167
- …
168
- </form>
169
- ```
170
-
171
- The token is **not** produced by the middleware but by `csrfField()` — that is,
172
- at the moment it is actually printed into a form. The reason is concrete: if the
173
- token were written on every response, a public and cacheable page would carry a
174
- `Set-Cookie` too, a CDN would store that response, and every visitor would share
175
- the same token.
176
-
177
- On the server the token in the signed cookie must match the submitted field; an
178
- `X-CSRF-Token` header is accepted in place of the field.
179
-
180
- `csrfField()` returns an empty string while `security.csrf.token` is off, so the
181
- template renders under any configuration.
182
-
183
- ## Fragment endpoints
184
-
185
- `fragment()` produces a partial response with a fixed policy: no layout, sent
186
- with `private, no-store` and no ETag, never touching the HTML cache.
187
-
188
- ```js
189
- export default function register(app, { fragment }) {
190
- app.get(
191
- "/_fragment/orders",
192
- fragment(async ({ req, query }) => {
193
- const user = currentUser(req);
194
- if (!user) return { view: "partials/session-expired", status: 401 };
195
-
196
- return {
197
- view: "partials/order-table",
198
- data: { orders: getOrders(user.username, Number(query.page ?? 1)) },
199
- };
200
- }),
201
- );
202
- }
203
- ```
204
-
205
- The controller returns either `{ view, data?, status? }` or an HTML string
206
- directly. On failure it responds with a small partial rather than a whole page
207
- (`<div role="alert" data-fragment-error>`), because the swapped region must not
208
- end up containing an entire error page.
209
-
210
- Using the same template both inside the page and at the fragment endpoint is the
211
- central idea: the markup has a single source on the server and the client does
212
- not carry a second template.
213
-
214
- ## Client: swapping a region
215
-
216
- ```js
217
- import { registerAll, start, startForms, startSwapLinks } from "jskelet/client";
218
-
219
- registerAll({ "live-clock": () => import("../islands/live-clock.js") });
220
-
221
- start();
222
- startSwapLinks();
223
- startForms();
224
- ```
225
-
226
- `startSwapLinks()` binds links carrying `data-swap`:
227
-
228
- ```html
229
- <a href="/_fragment/orders?page=2" data-swap="#orders">Next</a>
230
- ```
231
-
232
- Because `href` is a real URL, the link falls back to normal navigation without
233
- JavaScript. For programmatic use there is `swap()`:
234
-
235
- ```js
236
- import { swap } from "jskelet/client";
237
-
238
- await swap("#orders", "/_fragment/orders?page=2", { history: true });
239
- ```
240
-
241
- In order, `swap()` unmounts the islands in the old subtree, replaces the
242
- content, hydrates the new subtree and restores focus if it was lost. While the
243
- request is in flight the region gets `aria-busy="true"` — binding the pending
244
- indicator to the accessibility state instead of a separate class also keeps the
245
- two from drifting apart.
246
-
247
- If it meets a redirect (the session expired and the server points at the sign-in
248
- page), it navigates there instead of swapping the partial in.
249
-
250
- ### Unmounting islands
251
-
252
- This is the half of swapping that is easiest to skip. A `mount()` function may
253
- return a cleanup callback:
254
-
255
- ```js
256
- export function mount(element) {
257
- const timer = setInterval(() => tick(element), 1000);
258
- return () => clearInterval(timer);
259
- }
260
- ```
261
-
262
- The islands inside a region replaced with `innerHTML` leave the DOM, but the
263
- listeners they installed on `document`/`window` and their `setInterval` timers
264
- keep running; after a few swaps the same work runs dozens of times. `swap()` and
265
- the form helpers call `unmount()` for you; when you change the DOM by hand, you
266
- call it:
267
-
268
- ```js
269
- import { hydrate, unmount } from "jskelet/client";
270
-
271
- unmount(region);
272
- region.innerHTML = html;
273
- hydrate(region);
274
- ```
275
-
276
- ## Forms
277
-
278
- `startForms()` binds forms carrying `data-enhance`. The contract is progressive
279
- enhancement: the form is a normal `<form method="post" action="…">` and
280
- JavaScript only removes the full page round trip in between.
281
-
282
- ```html
283
- <form method="post" action="/dashboard/notes" data-enhance data-target="#notes">
284
- <%- csrfField() %>
285
- <textarea name="text" required minlength="3"></textarea>
286
- <button type="submit">Save</button>
287
- </form>
288
- ```
289
-
290
- The server answers with one of three things:
291
-
292
- - **a redirect** → followed with `location.assign` (successful mutation, and the
293
- no-JavaScript path)
294
- - **4xx + a partial** → swapped in place of the form (validation errors)
295
- - **2xx + a partial** → swapped into the `data-target` region, and the form is
296
- reset
297
-
298
- The server side handles both clients:
299
-
300
- ```js
301
- app.post(
302
- "/dashboard/notes",
303
- fragment(async ({ req }) => {
304
- const user = currentUser(req);
305
- if (!user) seeOther("/sign-in?next=%2Fdashboard");
306
-
307
- const result = addNote(user.username, req.body?.text);
308
-
309
- // Without JavaScript the client sends no `X-Requested-With`: full round trip.
310
- if (req.get("X-Requested-With") !== "fragment") {
311
- seeOther(result.ok ? "/dashboard" : "/dashboard?note=error");
312
- }
313
-
314
- return result.ok
315
- ? { view: "partials/note-list", data: { notes: getNotes(user.username) } }
316
- : { view: "partials/note-form", data: { error: result.error }, status: 422 };
317
- }),
318
- );
319
- ```
320
-
321
- Using `redirect()` instead of `seeOther()` here would be a bug: `redirect()`
322
- sends 307 and 307 preserves the method, so the browser POSTs to the target
323
- again. The post-form flow needs 303.
324
-
325
- Routing the POST handler through `fragment()` has a reason too: alongside the
326
- layout-less render and `no-store`, it establishes the **request context**.
327
- Without the context, `csrfField()` in a form re-printed after a validation error
328
- comes out empty and the user's second attempt gets a 403.
329
-
330
- On a validation error focus moves to the first invalid field; the markers looked
331
- for are `aria-invalid="true"` and `data-field-error`.
332
-
333
- ## Configuration
334
-
335
- ```js
336
- export default {
337
- security: {
338
- /**
339
- * Turn this off when you are not behind a reverse proxy: while it is on, a
340
- * client can forge its own `X-Forwarded-For`.
341
- */
342
- trustProxy: true,
343
-
344
- cookieSecret: process.env.JSKELET_SECRET,
345
-
346
- csrf: {
347
- enabled: true,
348
- token: false,
349
- allowedOrigins: [],
350
- exclude: [],
351
- cookieName: "csrf_token",
352
- fieldName: "_csrf",
353
- headerName: "x-csrf-token",
354
- },
355
- },
356
- };
357
- ```
358
-
359
- Two more settings pay off for dashboard paths:
360
-
361
- ```js
362
- // Speculatively fetching a link with side effects can sign the user out.
363
- navigation: { exclude: ["/dashboard/:path*", "/sign-out"] },
364
-
365
- // The warmer has no session; protected pages cannot be warmed.
366
- prewarmSkip: ["/api/", "/_fragment/", "/dashboard", "/sign-out"],
367
- ```
368
-
369
- And indexing is turned off through `headers()` — `no-store` prevents caching,
370
- but indexing has to be said separately:
371
-
372
- ```js
373
- {
374
- source: "/dashboard/:path*",
375
- headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
376
- }
377
- ```
378
-
379
- ## Checklist
380
-
381
- When adding a per-visitor section:
382
-
383
- - [ ] Pages are registered with `route(fn, { private: true })`.
384
- - [ ] Fragment endpoints are registered with `fragment()`.
385
- - [ ] `security.cookieSecret` comes from the environment, not from the source.
386
- - [ ] Mutation forms contain `csrfField()` and `security.csrf.token` is on.
387
- - [ ] Signing out is a POST, not a GET.
388
- - [ ] The `next` parameter after sign-in only accepts same-site paths.
389
- - [ ] `prewarmSkip` and `navigation.exclude` exclude the protected prefix.
390
- - [ ] `X-Robots-Tag: noindex` under `headers()`.
391
- - [ ] The smoke test checks the `no-store` header and the rejection of a POST
392
- without a token — nothing on screen changes when those break.
1
+ # 12 — Dashboards, sessions and per-visitor pages
2
+
3
+ JSkelet's center of gravity is public, cacheable pages. A dashboard sits on the
4
+ opposite axis: different HTML for every visitor, no cache, heavy interaction.
5
+ This document covers that axis — `private: true`, session cookies, CSRF,
6
+ fragment endpoints and region swapping.
7
+
8
+ One thing is deliberately out of scope: **live data transport**. Choosing
9
+ between SSE, WebSocket and polling belongs to the application; the framework
10
+ only provides the "refresh this region from the server" step. The compression
11
+ layer skips `text/event-stream` responses and streams that write their own
12
+ headers, so wiring up SSE by hand does not break anything either.
13
+
14
+ The runnable counterpart is `examples/dashboard/`, which is where the snippets
15
+ below come from.
16
+
17
+ ## Why a separate path is needed
18
+
19
+ The HTML cache key is only the path and the query string:
20
+
21
+ ```
22
+ `${req.path}?${new URLSearchParams(query).toString()}`
23
+ ```
24
+
25
+ Identity is not part of the key. So if a session-dependent page is registered
26
+ with a plain `route()`, the first visitor's HTML is served to **everyone** for
27
+ the length of the TTL. Nothing about this mistake fails loudly: the page works,
28
+ the tests pass, and the problem only appears once a second user arrives —
29
+ usually in production.
30
+
31
+ ## `private: true`
32
+
33
+ ```js
34
+ export default function register(app, { route, redirect }) {
35
+ app.get(
36
+ "/dashboard",
37
+ route(
38
+ async ({ req }) => {
39
+ const user = currentUser(req);
40
+ if (!user) redirect("/sign-in?next=%2Fdashboard");
41
+
42
+ return { view: "pages/overview", data: { user } };
43
+ },
44
+ { private: true },
45
+ ),
46
+ );
47
+ }
48
+ ```
49
+
50
+ What the flag does:
51
+
52
+ | Behaviour | Public `route()` | `private: true` |
53
+ | --- | --- | --- |
54
+ | HTML cache | On when a TTL exists | Off, cannot be turned on |
55
+ | `cache.html` pattern | Overrides the TTL | Ignored |
56
+ | `Cache-Control` | `public, s-maxage=…` | `private, no-store` |
57
+ | `Vary` | `Accept-Encoding` | `Cookie, Accept-Encoding` |
58
+ | ETag | Present | Absent |
59
+ | `X-JSkelet-Cache` | `HIT`/`STALE`/`MISS` | Not written |
60
+
61
+ Dropping the ETag looks like a detail but is not: a strong ETag over a
62
+ per-visitor body is a fingerprint of that visitor, and a layer that ignores
63
+ `no-store` could use it to tell them apart.
64
+
65
+ A redirect thrown from a per-visitor page is not cacheable either. "You need to
66
+ sign in" is a session-dependent decision; storing it means sending signed-in
67
+ users to the sign-in page too.
68
+
69
+ ## When you forget the flag
70
+
71
+ The framework watches for reads that touch identity. The `req` passed to the
72
+ controller is wrapped in a thin Proxy that marks these accesses:
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)` and `getSignedCookie(req, …)`, which report it directly
79
+
80
+ A marked render is **never stored**. In production the response is sent with
81
+ `no-store` and this line is logged:
82
+
83
+ ```
84
+ [render] /dashboard read identity-bound data (req.headers.cookie), not cached.
85
+ Register the route with 'private: true'.
86
+ ```
87
+
88
+ In development the same situation fails the request. The reason it is not silent
89
+ is simple: this mistake produces a working page, so it is never noticed on its
90
+ own.
91
+
92
+ `csrfField()` marks the render the same way. A page that prints a token cannot
93
+ come from the cache — if it did, every visitor would share the same token and
94
+ the double-submit check would verify nothing.
95
+
96
+ ## Sessions: signed cookies
97
+
98
+ The framework does not provide identity. The only thing it gives you is the
99
+ guarantee that "I wrote this value and it has not been tampered with":
100
+
101
+ ```js
102
+ import { clearCookie, getSignedCookie, setSignedCookie } from "jskelet/cookies";
103
+
104
+ export function startSession(res, username) {
105
+ setSignedCookie(res, "session", username, { maxAge: 60 * 60 * 8 });
106
+ }
107
+
108
+ export function currentUser(req) {
109
+ const username = getSignedCookie(req, "session");
110
+ return username ? findUser(username) : null;
111
+ }
112
+
113
+ export function endSession(res) {
114
+ clearCookie(res, "session");
115
+ }
116
+ ```
117
+
118
+ The signature is HMAC-SHA256 and the comparison is constant time. If the
119
+ signature does not match, `getSignedCookie` returns `null` — a tampered value is
120
+ never used on the assumption that it "might be valid".
121
+
122
+ The secret comes from `security.cookieSecret` or the `JSKELET_SECRET`
123
+ environment variable. Without a secret the signed API **throws**; the rule that
124
+ a configuration error must not take the site down does not apply here, because
125
+ the silent alternative would be trusting an unsigned cookie.
126
+
127
+ The defaults are on the restrictive side: `HttpOnly`, `SameSite=Lax`, `Secure`
128
+ outside development, `Path=/`. `SameSite=Lax` alone closes most of CSRF — the
129
+ cookie is simply not sent on cross-site POSTs.
130
+
131
+ Cookies are **signed, not encrypted**. The value is readable, so store the
132
+ identifier of a secret rather than the secret itself.
133
+
134
+ ## CSRF
135
+
136
+ The framework parses the request body (`express.urlencoded` + `express.json`),
137
+ which makes it the layer that accepts state-changing requests. The protection
138
+ has two layers.
139
+
140
+ ### Layer 1 — origin check (on by default)
141
+
142
+ Unsafe methods get a 403 when `Origin` does not match our host, or when
143
+ `Sec-Fetch-Site: cross-site` arrives. **If neither header is present the request
144
+ passes**: browsers always send `Origin` on a cross-origin POST, while webhooks
145
+ and server-to-server calls send neither. That distinction keeps the protection
146
+ on without breaking integrations.
147
+
148
+ ```js
149
+ security: {
150
+ csrf: {
151
+ // Legitimate exceptions, such as a panel on a separate domain.
152
+ allowedOrigins: ["https://admin.example.com"],
153
+ // Endpoints that do not come from a browser.
154
+ exclude: ["/webhook/:path*"],
155
+ },
156
+ }
157
+ ```
158
+
159
+ ### Layer 2 — double-submit token (optional)
160
+
161
+ Enabled with `security.csrf.token: true`. Forms print the token with
162
+ `csrfField()`:
163
+
164
+ ```ejs
165
+ <form method="post" action="/dashboard/notes">
166
+ <%- csrfField() %>
167
+ …
168
+ </form>
169
+ ```
170
+
171
+ The token is **not** produced by the middleware but by `csrfField()` — that is,
172
+ at the moment it is actually printed into a form. The reason is concrete: if the
173
+ token were written on every response, a public and cacheable page would carry a
174
+ `Set-Cookie` too, a CDN would store that response, and every visitor would share
175
+ the same token.
176
+
177
+ On the server the token in the signed cookie must match the submitted field; an
178
+ `X-CSRF-Token` header is accepted in place of the field.
179
+
180
+ `csrfField()` returns an empty string while `security.csrf.token` is off, so the
181
+ template renders under any configuration.
182
+
183
+ ## Fragment endpoints
184
+
185
+ `fragment()` produces a partial response with a fixed policy: no layout, sent
186
+ with `private, no-store` and no ETag, never touching the HTML cache.
187
+
188
+ ```js
189
+ export default function register(app, { fragment }) {
190
+ app.get(
191
+ "/_fragment/orders",
192
+ fragment(async ({ req, query }) => {
193
+ const user = currentUser(req);
194
+ if (!user) return { view: "partials/session-expired", status: 401 };
195
+
196
+ return {
197
+ view: "partials/order-table",
198
+ data: { orders: getOrders(user.username, Number(query.page ?? 1)) },
199
+ };
200
+ }),
201
+ );
202
+ }
203
+ ```
204
+
205
+ The controller returns either `{ view, data?, status? }` or an HTML string
206
+ directly. On failure it responds with a small partial rather than a whole page
207
+ (`<div role="alert" data-fragment-error>`), because the swapped region must not
208
+ end up containing an entire error page.
209
+
210
+ Using the same template both inside the page and at the fragment endpoint is the
211
+ central idea: the markup has a single source on the server and the client does
212
+ not carry a second template.
213
+
214
+ ## Client: swapping a region
215
+
216
+ ```js
217
+ import { registerAll, start, startForms, startSwapLinks } from "jskelet/client";
218
+
219
+ registerAll({ "live-clock": () => import("../islands/live-clock.js") });
220
+
221
+ start();
222
+ startSwapLinks();
223
+ startForms();
224
+ ```
225
+
226
+ `startSwapLinks()` binds links carrying `data-swap`:
227
+
228
+ ```html
229
+ <a href="/_fragment/orders?page=2" data-swap="#orders">Next</a>
230
+ ```
231
+
232
+ Because `href` is a real URL, the link falls back to normal navigation without
233
+ JavaScript. For programmatic use there is `swap()`:
234
+
235
+ ```js
236
+ import { swap } from "jskelet/client";
237
+
238
+ await swap("#orders", "/_fragment/orders?page=2", { history: true });
239
+ ```
240
+
241
+ In order, `swap()` unmounts the islands in the old subtree, replaces the
242
+ content, hydrates the new subtree and restores focus if it was lost. While the
243
+ request is in flight the region gets `aria-busy="true"` — binding the pending
244
+ indicator to the accessibility state instead of a separate class also keeps the
245
+ two from drifting apart.
246
+
247
+ If it meets a redirect (the session expired and the server points at the sign-in
248
+ page), it navigates there instead of swapping the partial in.
249
+
250
+ ### Unmounting islands
251
+
252
+ This is the half of swapping that is easiest to skip. A `mount()` function may
253
+ return a cleanup callback:
254
+
255
+ ```js
256
+ export function mount(element) {
257
+ const timer = setInterval(() => tick(element), 1000);
258
+ return () => clearInterval(timer);
259
+ }
260
+ ```
261
+
262
+ The islands inside a region replaced with `innerHTML` leave the DOM, but the
263
+ listeners they installed on `document`/`window` and their `setInterval` timers
264
+ keep running; after a few swaps the same work runs dozens of times. `swap()` and
265
+ the form helpers call `unmount()` for you; when you change the DOM by hand, you
266
+ call it:
267
+
268
+ ```js
269
+ import { hydrate, unmount } from "jskelet/client";
270
+
271
+ unmount(region);
272
+ region.innerHTML = html;
273
+ hydrate(region);
274
+ ```
275
+
276
+ ## Forms
277
+
278
+ `startForms()` binds forms carrying `data-enhance`. The contract is progressive
279
+ enhancement: the form is a normal `<form method="post" action="…">` and
280
+ JavaScript only removes the full page round trip in between.
281
+
282
+ ```html
283
+ <form method="post" action="/dashboard/notes" data-enhance data-target="#notes">
284
+ <%- csrfField() %>
285
+ <textarea name="text" required minlength="3"></textarea>
286
+ <button type="submit">Save</button>
287
+ </form>
288
+ ```
289
+
290
+ The server answers with one of three things:
291
+
292
+ - **a redirect** → followed with `location.assign` (successful mutation, and the
293
+ no-JavaScript path)
294
+ - **4xx + a partial** → swapped in place of the form (validation errors)
295
+ - **2xx + a partial** → swapped into the `data-target` region, and the form is
296
+ reset
297
+
298
+ The server side handles both clients:
299
+
300
+ ```js
301
+ app.post(
302
+ "/dashboard/notes",
303
+ fragment(async ({ req }) => {
304
+ const user = currentUser(req);
305
+ if (!user) seeOther("/sign-in?next=%2Fdashboard");
306
+
307
+ const result = addNote(user.username, req.body?.text);
308
+
309
+ // Without JavaScript the client sends no `X-Requested-With`: full round trip.
310
+ if (req.get("X-Requested-With") !== "fragment") {
311
+ seeOther(result.ok ? "/dashboard" : "/dashboard?note=error");
312
+ }
313
+
314
+ return result.ok
315
+ ? { view: "partials/note-list", data: { notes: getNotes(user.username) } }
316
+ : { view: "partials/note-form", data: { error: result.error }, status: 422 };
317
+ }),
318
+ );
319
+ ```
320
+
321
+ Using `redirect()` instead of `seeOther()` here would be a bug: `redirect()`
322
+ sends 307 and 307 preserves the method, so the browser POSTs to the target
323
+ again. The post-form flow needs 303.
324
+
325
+ Routing the POST handler through `fragment()` has a reason too: alongside the
326
+ layout-less render and `no-store`, it establishes the **request context**.
327
+ Without the context, `csrfField()` in a form re-printed after a validation error
328
+ comes out empty and the user's second attempt gets a 403.
329
+
330
+ On a validation error focus moves to the first invalid field; the markers looked
331
+ for are `aria-invalid="true"` and `data-field-error`.
332
+
333
+ ## Configuration
334
+
335
+ ```js
336
+ export default {
337
+ security: {
338
+ /**
339
+ * Turn this off when you are not behind a reverse proxy: while it is on, a
340
+ * client can forge its own `X-Forwarded-For`.
341
+ */
342
+ trustProxy: true,
343
+
344
+ cookieSecret: process.env.JSKELET_SECRET,
345
+
346
+ csrf: {
347
+ enabled: true,
348
+ token: false,
349
+ allowedOrigins: [],
350
+ exclude: [],
351
+ cookieName: "csrf_token",
352
+ fieldName: "_csrf",
353
+ headerName: "x-csrf-token",
354
+ },
355
+ },
356
+ };
357
+ ```
358
+
359
+ Two more settings pay off for dashboard paths:
360
+
361
+ ```js
362
+ // Speculatively fetching a link with side effects can sign the user out.
363
+ navigation: { exclude: ["/dashboard/:path*", "/sign-out"] },
364
+
365
+ // The warmer has no session; protected pages cannot be warmed.
366
+ prewarmSkip: ["/api/", "/_fragment/", "/dashboard", "/sign-out"],
367
+ ```
368
+
369
+ And indexing is turned off through `headers()` — `no-store` prevents caching,
370
+ but indexing has to be said separately:
371
+
372
+ ```js
373
+ {
374
+ source: "/dashboard/:path*",
375
+ headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
376
+ }
377
+ ```
378
+
379
+ ## Checklist
380
+
381
+ When adding a per-visitor section:
382
+
383
+ - [ ] Pages are registered with `route(fn, { private: true })`.
384
+ - [ ] Fragment endpoints are registered with `fragment()`.
385
+ - [ ] `security.cookieSecret` comes from the environment, not from the source.
386
+ - [ ] Mutation forms contain `csrfField()` and `security.csrf.token` is on.
387
+ - [ ] Signing out is a POST, not a GET.
388
+ - [ ] The `next` parameter after sign-in only accepts same-site paths.
389
+ - [ ] `prewarmSkip` and `navigation.exclude` exclude the protected prefix.
390
+ - [ ] `X-Robots-Tag: noindex` under `headers()`.
391
+ - [ ] The smoke test checks the `no-store` header and the rejection of a POST
392
+ without a token — nothing on screen changes when those break.