proteum 2.5.25 → 2.7.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/README.md +4 -0
- package/agents/project/AGENTS.md +2 -0
- package/agents/project/client/pages/AGENTS.md +22 -1
- package/cli/compiler/client/index.ts +10 -5
- package/cli/compiler/client/outputNames.ts +28 -0
- package/cli/compiler/common/files/autres.ts +3 -1
- package/cli/scaffold/templates.ts +4 -0
- package/client/app/staleBuild.ts +80 -0
- package/client/services/router/components/Link.tsx +25 -0
- package/client/services/router/components/Page.tsx +19 -3
- package/client/services/router/components/router.tsx +165 -64
- package/client/services/router/index.tsx +132 -31
- package/client/services/router/navigation.ts +235 -0
- package/client/services/router/request/api.ts +123 -75
- package/client/services/router/response/index.tsx +5 -0
- package/client/services/router/response/page.ts +20 -4
- package/common/router/contracts.ts +2 -2
- package/common/router/definitions.ts +28 -10
- package/common/router/index.ts +2 -0
- package/common/router/pageData.ts +20 -2
- package/common/router/response/page.ts +73 -7
- package/docs/HISTORY.md +8 -0
- package/docs/client-assets.md +37 -0
- package/docs/client-navigation.md +128 -0
- package/docs/http-security-headers.md +114 -0
- package/package.json +2 -1
- package/server/services/router/http/cache.ts +4 -1
- package/server/services/router/http/index.ts +26 -25
- package/server/services/router/http/securityHeaders.ts +343 -0
- package/server/services/router/response/page/index.tsx +2 -2
- package/tests/app-shaped-typecheck.test.cjs +74 -0
- package/tests/client-api-fetch-sync.test.cjs +180 -0
- package/tests/client-output-names.test.cjs +114 -0
- package/tests/client-router-hooks.test.cjs +73 -0
- package/tests/client-router-navigation.test.cjs +857 -0
- package/tests/client-router-prefetch.test.cjs +197 -0
- package/tests/clientRouterHarness.cjs +70 -0
- package/tests/definition-contracts.test.cjs +45 -1
- package/tests/fakeDom.cjs +90 -0
- package/tests/http-server-security-headers.test.cjs +669 -0
- package/tests/router-cache-config.test.cjs +32 -0
- package/tests/stale-build-recovery.test.cjs +206 -0
- package/types/global/vendors.d.ts +9 -0
- package/types/vendors.d.ts +9 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Client Navigation
|
|
2
|
+
|
|
3
|
+
A client navigation runs in one of two modes. **Blocking**, the default, waits for the page data before it swaps the page in. **Deferred** swaps the page in first, with a pending state the page renders as a skeleton, then fills in the data. SSR first loads are the same in both modes: the page arrives with its data.
|
|
4
|
+
|
|
5
|
+
Deferred navigation is opt-in twice: the router must run in deferred mode, and each page must declare it. An app that sets neither compiles and behaves as before.
|
|
6
|
+
|
|
7
|
+
## Router Config
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
public Router = new Router(this, {
|
|
11
|
+
preload: [],
|
|
12
|
+
// Pages declaring navigation: 'deferred' swap in before their data. Default: { mode: 'blocking' }
|
|
13
|
+
navigation: { mode: 'deferred' },
|
|
14
|
+
// Route paths whose chunks load at idle after the first render
|
|
15
|
+
prefetch: ['/', '/radars', '/sales'],
|
|
16
|
+
context: (context, router) => ({ ...context }),
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`preload` keeps its meanings: the router config glob list, `client/pages/preload.json` (chunks bundled with the entry) and the page option `preload?: boolean`. `prefetch` is a different, runtime list: route paths whose chunks load in the background once the first page is rendered.
|
|
21
|
+
|
|
22
|
+
## Page Option
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { definePageRoute } from '@common/router/definitions';
|
|
26
|
+
|
|
27
|
+
export default definePageRoute({
|
|
28
|
+
path: '/radars',
|
|
29
|
+
options: { auth: true, navigation: 'deferred' },
|
|
30
|
+
data: ({ Radars }) => ({ radars: Radars.list() }),
|
|
31
|
+
render: ({ radars, navigation }) =>
|
|
32
|
+
radars === undefined ? (
|
|
33
|
+
<RadarsSkeleton error={navigation.error} onRetry={navigation.retry} />
|
|
34
|
+
) : (
|
|
35
|
+
<RadarsPage radars={radars} dimmed={navigation.pending} />
|
|
36
|
+
),
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A page defers only when all three hold: the router mode is `deferred`, the page declares `navigation: 'deferred'`, and the page has a `data` loader. Otherwise it navigates in blocking mode, including a page that declares `deferred` under a blocking router; its render then always sees `status: 'ready'`.
|
|
41
|
+
|
|
42
|
+
`navigation: 'deferred'` changes the render type. `definePageRoute` picks `TDeferredFrontRenderer<T>`: every data key is `T | undefined`, and the render receives a `navigation` prop. Pages without the option keep `TFrontRenderer<T>` and receive no `navigation` prop, so a blocking page may still return a data key named `navigation`. A deferred page may not: its data loader throws if it returns that key. The blocking overload accepts `navigation: 'blocking'` or no option, never `'deferred'`: a deferred page whose render does not fit the deferred renderer (an annotated props type with defined keys) is a type error, not a blocking page in disguise.
|
|
43
|
+
|
|
44
|
+
## Navigation State
|
|
45
|
+
|
|
46
|
+
`navigation` is a `TPageNavigation`, discriminated on `status`:
|
|
47
|
+
|
|
48
|
+
| status | pending | stale | reloading | error | Meaning |
|
|
49
|
+
| --- | --- | --- | --- | --- | --- |
|
|
50
|
+
| `ready` | `false` | `false` | `false` | `null` | The data of this page is on screen |
|
|
51
|
+
| `pending` | `true` | `boolean` | `boolean` | `null` | The data step runs |
|
|
52
|
+
| `error` | `false` | `boolean` | `boolean` | `Error` | The data step failed |
|
|
53
|
+
|
|
54
|
+
- `stale: true` means the previous data is still on screen. It happens on a same-chunk navigation (the page component is kept, as in blocking mode) when the previous page showed data, and on a retry of a page that showed data. A cross-chunk navigation remounts the page component: every data key is `undefined` while pending.
|
|
55
|
+
- `reloading: true` means the data step runs again for a navigation whose data already reached the screen (`api.reload`, or `retry()` after `ready`): the stale data is this page's own, for this URL. It is `false` on a navigation, even a stale one, and on a retry that recovers a failed first data step. A page whose render reads the URL beside its data can keep its stale data on screen while `reloading`, and only then.
|
|
56
|
+
- `since` is the `Date.now()` timestamp of the last status change, for a delayed skeleton or a slow-network hint.
|
|
57
|
+
- `retry()` re-runs the data step only: no route resolution, no swap, no loader. It does nothing once the page left the screen.
|
|
58
|
+
- `error` is what the data step threw. A request that never reached the server is a `NetworkError` (`@common/errors`); an answer from the server is the error it encoded (`AuthRequired`, `Forbidden`, `Anomaly`...).
|
|
59
|
+
|
|
60
|
+
`api.reload(ids?, params?)` on a deferred page goes through `navigation.retry()`: the page turns `pending` with `stale: true` and `reloading: true`, then `ready` with the reloaded data. A reload merges what it fetched into the page state, so values written with `api.set` or `page.setData` survive. The first data render of a navigation replaces the state instead, and so does a retry that recovers a failed first data step: until then the page component may still hold the previous page's data (a same-chunk navigation keeps it), which a merge would carry over. On a blocking page `api.reload` keeps its previous behavior.
|
|
61
|
+
|
|
62
|
+
Layout data reaches the same render props: on a page declaring `deferred`, a layout `data` loader returning a `navigation` key throws like the page loader would.
|
|
63
|
+
|
|
64
|
+
## Order Of Operations
|
|
65
|
+
|
|
66
|
+
Both modes start the same way: `page.change` hook, scroll to top, loader on, route chunk load.
|
|
67
|
+
|
|
68
|
+
Blocking:
|
|
69
|
+
|
|
70
|
+
1. Data providers run and their data is fetched.
|
|
71
|
+
2. The page swaps in with its data. The swap render releases the loader, then `page.changed` and `page.ready` fire.
|
|
72
|
+
|
|
73
|
+
Deferred:
|
|
74
|
+
|
|
75
|
+
1. Data providers run, without fetching. A provider that throws aborts the navigation like a blocking data failure.
|
|
76
|
+
2. The page swaps in with `status: 'pending'`. `page.changed` fires; the loader stays on.
|
|
77
|
+
3. The data is fetched. On success the data and `status: 'ready'` render in one batch, the loader goes off, and after that render the router refreshes the title, body classes and hash scroll, then fires `page.ready`. On failure the page renders `status: 'error'` and the loader goes off.
|
|
78
|
+
|
|
79
|
+
Whether the swap render was pending is read at that render, not in its effect: data that lands within the frame between the two still fires `page.ready` after the data render, never before it.
|
|
80
|
+
|
|
81
|
+
Body classes are reset twice on a deferred page: `page.updateClient()` rewrites `document.body.className` (with the body id and the title) from the page at the swap, before `page.changed`, and again after the data render, before `page.ready`. A class the app adds to the body itself is gone after each: re-add it in both `page.changed` and `page.ready`. A blocking page resets them once, before both hooks.
|
|
82
|
+
|
|
83
|
+
The router context's `page` is the page on screen. It changes when a navigation swaps its page in, never when the route resolves, so a navigation that never commits (superseded, failed) leaves `api.set` and `api.reload` working on the page still shown.
|
|
84
|
+
|
|
85
|
+
A hash scrolls to the element whose id it names (decoded, looked up with `getElementById`), on a same-page hash change and after the render that shows the data.
|
|
86
|
+
|
|
87
|
+
Every navigation takes a new token and gives up after any await that returns to an older one: a slower chunk, an error page chunk or an older data response never replaces a newer page, and a deferred page whose provider starts another navigation (a redirect) never swaps in. A deferred data step also gives up once its page left the screen, or when a newer data step for the same page started (retry, reload). A route chunk that fails to load is logged, goes to the app error handler (the "new version" notice) and releases the loader instead of leaving it on.
|
|
88
|
+
|
|
89
|
+
## Hooks
|
|
90
|
+
|
|
91
|
+
`router.on(hook, callback)` returns a remover that removes this callback, by reference: a callback registered twice is removed twice by one call. A listener added while its hook runs first runs on the next call of that hook; one removed while the hook runs still runs in that pass.
|
|
92
|
+
|
|
93
|
+
| Hook | When |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `page.change` | A client navigation starts |
|
|
96
|
+
| `page.changed` | The rendered page changed. Fires on the first load too |
|
|
97
|
+
| `page.rendered` | Hydration finished (first load only) |
|
|
98
|
+
| `page.ready` | The page shows its data: once after hydration, then once per navigation (with the swap on a blocking page, when the data lands on a deferred one) |
|
|
99
|
+
|
|
100
|
+
Count page views on `page.ready`: it fires for the landing page and once per navigation in both modes. On a deferred page it fires the first time the data of the navigation is on screen, which includes a `retry()` that recovers a failed first data step; it never fires again for a later retry or a reload. A page replaced in the same render as its data render never reaches the screen with its data and does not fire it, as with a blocking page swapped out in the same render.
|
|
101
|
+
|
|
102
|
+
## Page Data Fetchers
|
|
103
|
+
|
|
104
|
+
The data step partitions what a `data` loader returns:
|
|
105
|
+
|
|
106
|
+
- api fetchers (controller calls) go to the server in one `POST /api` batch;
|
|
107
|
+
- other promises and thenables resolve in the browser; a rejection goes to the app error handler, like an `/api` failure, then fails the data step;
|
|
108
|
+
- plain values are page data with no round trip.
|
|
109
|
+
|
|
110
|
+
Values resolved in the browser go through JSON (with the circular-safe stringify SSR uses), so a client navigation sees what SSR sees: a `Date` becomes a string, `undefined` keys disappear, `toJSON()` applies, and the page gets its own copy rather than the provider's object.
|
|
111
|
+
|
|
112
|
+
Only an `/api` call reaches the server. A plain value such as a parsed URL filter or a date key is never echoed through `/api`, and a promise that is not an `/api` call resolves to its value instead of serializing to `{}`.
|
|
113
|
+
|
|
114
|
+
Behavior note for 2.6.0: a data loader that returns no api fetcher no longer sends any request on a client navigation. Server-side `/api` request hooks, session refresh and 401 detection therefore do not run for such a navigation; a page that relies on them needs at least one controller call in its loader.
|
|
115
|
+
|
|
116
|
+
## Prefetch
|
|
117
|
+
|
|
118
|
+
- `router.prefetch(path)` loads the route chunk of `path` and keeps it, without fetching data. A navigation that starts while the chunk loads joins the same load. A failure is silent; the navigation retries the load and reports it.
|
|
119
|
+
- `<Link to="/radars" prefetch>` calls it on hover and focus.
|
|
120
|
+
- The router config `prefetch` list loads at idle after `page.rendered`, one chunk at a time. Without `requestIdleCallback` (Safari, iOS) it starts 2 seconds after hydration instead, so the page's own requests go first. With data saver on (`navigator.connection.saveData`) the list is not loaded at all; `Link` prefetch on hover still runs, since the visitor is pointing at that page.
|
|
121
|
+
|
|
122
|
+
## Contracts For Agents
|
|
123
|
+
|
|
124
|
+
- Blocking stays the default at both levels. Opting a page into `deferred` means its render must handle every data key as possibly `undefined` and render `navigation.status === 'error'` with a retry.
|
|
125
|
+
- A provider that throws synchronously aborts before the swap in both modes. A rejected fetch on a deferred page becomes an error state on screen (an `/api` failure still reaches the app error handler first).
|
|
126
|
+
- Pages that rely on a redirect or an error page coming from their data, and public full-load pages, should stay blocking.
|
|
127
|
+
- Use `page.ready`, not `page.changed`, for anything that needs the page data on screen, such as analytics page views. Read the page's URL from the request the hook passes, not from `window.location`, which may already be the next navigation's.
|
|
128
|
+
- An app that adds its own classes to `document.body` re-adds them on `page.ready` as well as `page.changed`: the router resets the body classes before each.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# HTTP Security Headers
|
|
2
|
+
|
|
3
|
+
Proteum's HTTP server sends the same security headers on every answer of its own stack, whatever the path, and picks the Content-Security-Policy by the path and, under `/api`, by the kind of answer at the moment the headers are written. Middleware an app mounts on the Express app before `HttpServer.start()` (a maintenance page, for example) answers before that stack and carries none of these headers, as in 2.6.
|
|
4
|
+
|
|
5
|
+
Source: `server/services/router/http/index.ts` (`HttpServer.start()`) and `server/services/router/http/securityHeaders.ts`. Tests: `tests/http-server-security-headers.test.cjs`.
|
|
6
|
+
|
|
7
|
+
## The Contract
|
|
8
|
+
|
|
9
|
+
The rule in one sentence: outside `/api` no router answer changes its policy (every one carries the app policy, as in 2.6); under `/api`, HTML gets the app policy and everything else gets helmet's default.
|
|
10
|
+
|
|
11
|
+
| Answer | Base headers | `Content-Security-Policy` | Report-only policy | `X-Powered-By` |
|
|
12
|
+
|---|---|---|---|---|
|
|
13
|
+
| Any answer from the router outside `/api` (pages, their 404, the error page, a cached static page, JSON, text, Markdown, feeds, a worker script a route serves) | yes | the app policy | the app's, when configured | absent |
|
|
14
|
+
| HTML from the router under `/api` (pages, their 404, the error page, a cached static page) | yes | the app policy | the app's, when configured | absent |
|
|
15
|
+
| Any other answer from the router under `/api` (JSON, Markdown, text, scripts, the router's redirects) | yes | helmet's default policy | none | absent |
|
|
16
|
+
| A 304 Not Modified from the router under `/api` | yes | none: the browser keeps the policy its stored copy came with | none | absent |
|
|
17
|
+
| A route or controller that set its own `Content-Security-Policy` | yes | the route's own, untouched | none added | absent |
|
|
18
|
+
| A body-parser error under `/api`, before the router (for example a malformed JSON body) | yes | Express's own `default-src 'none'` | none | absent |
|
|
19
|
+
| Static files (`/public/*`, `/robots.txt` and `/favicon.ico` when the file exists), `/ping` | yes | helmet's default policy | none | absent |
|
|
20
|
+
|
|
21
|
+
When the app ships no `robots.txt` or `favicon.ico`, the request falls through to the router and its answer carries the app policy, as in 2.6. A redirect under `/api` that is typed `text/html` (one sent from the router's static page cache branch, or Express's `res.redirect` answered to a browser) counts as HTML and gets the app policy; the router's own redirects carry no `Content-Type` and keep helmet's default.
|
|
22
|
+
|
|
23
|
+
"HTML" means a `Content-Type` of `text/html` or `application/xhtml+xml`. Outside `/api` the type does not matter, on purpose: a worker takes its policy from its own script's answer, so a worker served by a route (for example a map library's worker that fetches tiles from another host) must keep the app policy.
|
|
24
|
+
|
|
25
|
+
"Base headers" are helmet 4.6's defaults: `Strict-Transport-Security: max-age=15552000; includeSubDomains`, `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-DNS-Prefetch-Control: off`, `Expect-CT: max-age=0`, `X-Download-Options: noopen`, `X-Permitted-Cross-Domain-Policies: none`, `X-XSS-Protection: 0`. `http.helmet` changes them (for example `referrerPolicy`). helmet 4.6 sends no `Cross-Origin-*` header, and the tests pin that.
|
|
26
|
+
|
|
27
|
+
helmet's default policy is `default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests` (256 bytes). `http.helmet.contentSecurityPolicy` replaces it; with `false`, the fallback answers carry no policy, and the others still get the app policy.
|
|
28
|
+
|
|
29
|
+
How it is wired:
|
|
30
|
+
|
|
31
|
+
- `x-powered-by` is disabled on the Express app, and `helmet(http.helmet)` is mounted once, first in `start()`, so every answer of Proteum's stack starts with the base headers and helmet's default policy.
|
|
32
|
+
- Under `/api`, a policy writer is mounted before the API fast path (the short middleware stack `/api` requests take, ahead of the static files). It runs when the headers are about to be written, so it knows the final status and `Content-Type`:
|
|
33
|
+
- when the `Content-Security-Policy` value is no longer the one helmet wrote, a route chose its own: everything is left as it is;
|
|
34
|
+
- when the status is 304: the policy header is removed, so the browser keeps the policy of the copy it stored (helmet's default there would replace a page's policy);
|
|
35
|
+
- when the answer is HTML: the app policy, plus the report-only policy when one is configured and the route did not set one;
|
|
36
|
+
- otherwise: helmet's default stays.
|
|
37
|
+
- Outside `/api`, after the static files and before the router, the app policy replaces helmet's default at the start of every request, as in 2.6. When a report-only policy is configured, it is added when the headers are written, if the answer still carries the app policy.
|
|
38
|
+
- Static files are mounted before that, so they keep helmet's headers byte for byte, an `.html` or `.js` file under `/public` included.
|
|
39
|
+
|
|
40
|
+
Rule: a route's own policy wins. A route or controller that sets `Content-Security-Policy` itself (for example a strict policy with a nonce) keeps it, under `/api` or anywhere else, and no report-only header is added to it. A route that sets its own report-only header keeps it too.
|
|
41
|
+
|
|
42
|
+
The writers tell a route's own policy by its value, which leaves two edge cases no app has today: under `/api`, a route whose policy equals helmet's default byte for byte is treated as untouched, so an HTML answer gets the app policy; and with `http.helmet.contentSecurityPolicy: false`, a route under `/api` that removed the header gets the app policy back on HTML. Outside `/api`, a route whose policy equals the app policy byte for byte also gets the report-only header.
|
|
43
|
+
|
|
44
|
+
## Config
|
|
45
|
+
|
|
46
|
+
`http.csp` in the app's router config, required. All header strings are built once, at boot: a config the checks below refuse fails the boot with an `[http.csp]` error, never a broken header on a response.
|
|
47
|
+
|
|
48
|
+
The list form (`scripts` is required, an empty array is fine):
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
csp: {
|
|
52
|
+
// The four historical lists, unchanged. Proteum adds `'unsafe-inline' 'self' 'unsafe-eval'` to the scripts,
|
|
53
|
+
// and sends no `default-src` when `default` is empty.
|
|
54
|
+
scripts: ['cdn.example.com'],
|
|
55
|
+
styles: ["'self'", "'unsafe-inline'"],
|
|
56
|
+
images: ["'self'", 'data:', 'https:'],
|
|
57
|
+
default: [],
|
|
58
|
+
|
|
59
|
+
// More enforced directives, by their real names, appended after the lists.
|
|
60
|
+
directives: {
|
|
61
|
+
'frame-ancestors': ["'self'"],
|
|
62
|
+
'object-src': ["'none'"],
|
|
63
|
+
'base-uri': ["'self'"],
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// A second complete policy, sent as Content-Security-Policy-Report-Only wherever the app policy goes.
|
|
67
|
+
reportOnly: {
|
|
68
|
+
policy: { 'default-src': ["'self'"], 'connect-src': ["'self'", 'api.example.com'] },
|
|
69
|
+
reportUri: '/csp-report',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or a complete enforced policy, written in full by the app (Proteum adds nothing, so no silent `'unsafe-eval'`):
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
csp: {
|
|
78
|
+
policy: {
|
|
79
|
+
'default-src': ["'self'"],
|
|
80
|
+
'script-src': ["'self'", "'unsafe-inline'"],
|
|
81
|
+
'style-src': ["'self'", "'unsafe-inline'"],
|
|
82
|
+
'frame-ancestors': ["'self'"],
|
|
83
|
+
},
|
|
84
|
+
reportOnly: { policy: { 'default-src': ["'self'"] }, reportUri: '/csp-report' },
|
|
85
|
+
},
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`reportUri` is appended to the report-only policy only, as `report-uri <value>`. The enforced policy gets a report address only from a `report-uri` entry in `directives` or `policy`.
|
|
89
|
+
|
|
90
|
+
In dev, Proteum's client build uses eval source maps and its reload channel listens on another port: when you write `policy`, or a `default-src` or `connect-src`, add `'unsafe-eval'` to `script-src` and the dev event address (`http://localhost:<port>`) to `connect-src` in the dev profile.
|
|
91
|
+
|
|
92
|
+
Boot errors:
|
|
93
|
+
|
|
94
|
+
- `csp` absent or not an object; the list form without `scripts`; a key `csp` or `csp.reportOnly` does not know (`reportonly`, `directive`, `reporturi`), which would otherwise boot and send nothing;
|
|
95
|
+
- `policy` together with `scripts`, `styles`, `images`, `default` or `directives`;
|
|
96
|
+
- `directives` naming `default-src`, `script-src`, `style-src` or `img-src` (the lists own them, filled or not);
|
|
97
|
+
- a directive name that is not a CSP 3 directive (`frameAncestors` and `frame-ancestor` are refused, write `'frame-ancestors'`);
|
|
98
|
+
- a directive with no source, except `upgrade-insecure-requests` and `block-all-mixed-content` (which take none and refuse any), `sandbox` and `trusted-types` (which may be empty);
|
|
99
|
+
- a source that is not a string, is empty, or holds anything but printable ASCII with no whitespace, `;` or `,` (write international hosts in punycode; Latin-1 is refused too, though Node would send it, because browsers drop it);
|
|
100
|
+
- in `directives`, `policy` and `reportOnly.policy`: a bare keyword (`self`, `none`, `unsafe-inline`, `unsafe-eval`, `unsafe-hashes`, `strict-dynamic`, `report-sample`, `wasm-unsafe-eval`) that would name a host, and a source with unbalanced single quotes;
|
|
101
|
+
- an empty `policy`, a `reportOnly` without `policy` or with an empty one, a `reportUri` that is empty or not printable ASCII, and `reportUri` together with a `report-uri` entry in `reportOnly.policy`.
|
|
102
|
+
|
|
103
|
+
The four lists keep most of their 2.6 leniency: an entry holding several sources separated by spaces is kept exactly as written (each source is still checked), so it writes the same bytes; and bare keywords are not checked there. An empty entry (`''` or only spaces) is refused: 2.6 sent it as an empty source list, which blocks everything.
|
|
104
|
+
|
|
105
|
+
## Migration From 2.6
|
|
106
|
+
|
|
107
|
+
Nothing to change to keep today's policy: for every config the checks accept, the four lists produce the same header as 2.6, byte for byte, and every router answer outside `/api` keeps it. What changes by itself:
|
|
108
|
+
|
|
109
|
+
- answers under `/api` now carry the base headers and no `X-Powered-By`; HTML there gets the app policy, other answers helmet's default policy (256 bytes on each API answer), and a 304 there carries none;
|
|
110
|
+
- connected-project answers (`/api/__proteum/connected/*`) are under `/api` and follow the same rule;
|
|
111
|
+
- in the four lists, these entries booted in 2.6 and now stop the boot: an entry that is not a string (`undefined`, `null`, a number); an empty entry; any character outside printable ASCII (a typographic quote, a non-ASCII or Latin-1 host, a tab), which either failed each response or was dropped by browsers;
|
|
112
|
+
- a key `csp` does not know now stops the boot.
|
|
113
|
+
|
|
114
|
+
To add `frame-ancestors`, `object-src` and `base-uri`, add `csp.directives`. To trial a stricter policy, add `csp.reportOnly`; to enforce it, move it to `csp.policy`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proteum",
|
|
3
3
|
"description": "LLM-first Opinionated Typescript Framework for web applications.",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.7.0",
|
|
5
5
|
"author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
|
|
6
6
|
"repository": "git://github.com/gaetanlegac/proteum.git",
|
|
7
7
|
"license": "MIT",
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"mysql2": "^2.3.0",
|
|
78
78
|
"node-cmd": "^5.0.0",
|
|
79
79
|
"null-loader": "^4.0.1",
|
|
80
|
+
"on-headers": "^1.1.0",
|
|
80
81
|
"path-to-regexp": "^6.2.0",
|
|
81
82
|
"pg": "^8.21.0",
|
|
82
83
|
"postcss-loader": "^8.2.0",
|
|
@@ -69,7 +69,10 @@ type TPublicAssetResponse = {
|
|
|
69
69
|
req?: TPublicAssetRequest;
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
// `name.<hex hash>.ext`: async chunks, and since 2.6.1 the production entry files, CSS and fonts too
|
|
73
|
+
// (cli/compiler/client/outputNames.ts), so every file the HTML names is served immutable.
|
|
74
|
+
// `<hex hash>-<width>.ext` is responsive-loader's default name for each image size: hashed as well.
|
|
75
|
+
const hashedPublicAssetPattern = /(^|[-_.])[a-f0-9]{6,}(-\d+)?(?=(\.[^.]+)+$)/i;
|
|
73
76
|
|
|
74
77
|
export const resolveHttpCacheConfig = (config?: THttpCacheConfig): TResolvedHttpCacheConfig => ({
|
|
75
78
|
html: {
|
|
@@ -56,6 +56,12 @@ import {
|
|
|
56
56
|
resolvePublicAssetCacheControl,
|
|
57
57
|
type THttpCacheConfig,
|
|
58
58
|
} from './cache';
|
|
59
|
+
import {
|
|
60
|
+
buildContentSecurityPolicyHeaders,
|
|
61
|
+
createApiContentSecurityPolicyWriter,
|
|
62
|
+
createAppContentSecurityPolicy,
|
|
63
|
+
type TCspConfig,
|
|
64
|
+
} from './securityHeaders';
|
|
59
65
|
|
|
60
66
|
/*----------------------------------
|
|
61
67
|
- CONFIG
|
|
@@ -73,7 +79,8 @@ export type Config = {
|
|
|
73
79
|
upload: {
|
|
74
80
|
maxSize: string; // Expression package bytes
|
|
75
81
|
};
|
|
76
|
-
|
|
82
|
+
/** See docs/http-security-headers.md: on every router answer outside `/api`, and on HTML under `/api`. */
|
|
83
|
+
csp: TCspConfig;
|
|
77
84
|
cache?: THttpCacheConfig;
|
|
78
85
|
cors?: CorsOptions;
|
|
79
86
|
helmet?: Parameters<typeof helmet>[0];
|
|
@@ -82,9 +89,8 @@ export type Config = {
|
|
|
82
89
|
export type Hooks = {};
|
|
83
90
|
|
|
84
91
|
export { resolveHttpCacheConfig, type THttpCacheConfig } from './cache';
|
|
92
|
+
export type { TCspConfig, TCspDirectives, TCspReportOnly } from './securityHeaders';
|
|
85
93
|
|
|
86
|
-
type TContentSecurityPolicyOptions = NonNullable<Parameters<typeof helmet.contentSecurityPolicy>[0]>;
|
|
87
|
-
type TContentSecurityPolicyDirectives = NonNullable<TContentSecurityPolicyOptions['directives']>;
|
|
88
94
|
type TDevSessionAuthService = {
|
|
89
95
|
config: {
|
|
90
96
|
jwt: {
|
|
@@ -105,24 +111,6 @@ type TDevSessionResult =
|
|
|
105
111
|
statusCode: number;
|
|
106
112
|
};
|
|
107
113
|
|
|
108
|
-
const createContentSecurityPolicy = (config: Config['csp']): TContentSecurityPolicyOptions => {
|
|
109
|
-
const directives: TContentSecurityPolicyDirectives = {
|
|
110
|
-
defaultSrc:
|
|
111
|
-
config.default && config.default.length > 0
|
|
112
|
-
? [...config.default]
|
|
113
|
-
: helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc,
|
|
114
|
-
scriptSrc: ["'unsafe-inline'", "'self'", "'unsafe-eval'", ...config.scripts],
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
if (config.styles && config.styles.length > 0) directives.styleSrc = [...config.styles];
|
|
118
|
-
if (config.images && config.images.length > 0) directives.imgSrc = [...config.images];
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
useDefaults: false,
|
|
122
|
-
directives,
|
|
123
|
-
};
|
|
124
|
-
};
|
|
125
|
-
|
|
126
114
|
const connectedProjectBootRetryCount = 10;
|
|
127
115
|
const connectedProjectBootRetryDelayMs = 5_000;
|
|
128
116
|
/**
|
|
@@ -452,10 +440,21 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
452
440
|
|
|
453
441
|
// Config
|
|
454
442
|
routes.set('trust proxy', 1); // Indique qu'on est sous le proxy apache
|
|
443
|
+
routes.disable('x-powered-by');
|
|
444
|
+
|
|
445
|
+
// Built once: a malformed policy fails the boot, never a response.
|
|
446
|
+
const contentSecurityPolicyHeaders = buildContentSecurityPolicyHeaders(this.config.csp);
|
|
447
|
+
|
|
448
|
+
// Base headers on every answer, `/api` included (docs/http-security-headers.md).
|
|
449
|
+
routes.use(helmet(this.config.helmet));
|
|
450
|
+
|
|
455
451
|
/*----------------------------------
|
|
456
452
|
- FAST PATH: API
|
|
457
453
|
----------------------------------*/
|
|
458
454
|
|
|
455
|
+
// HTML under /api (pages, their 404) gets the app policy; other /api answers keep helmet's default.
|
|
456
|
+
routes.use(apiOnly(createApiContentSecurityPolicyWriter(contentSecurityPolicyHeaders)));
|
|
457
|
+
|
|
459
458
|
// Keep /api requests off the heavier page middleware stack below.
|
|
460
459
|
routes.use(apiOnly(hpp()));
|
|
461
460
|
routes.use(apiOnly(cookieParser()));
|
|
@@ -473,9 +472,6 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
473
472
|
this.registerConnectedProjectRoutes(routes);
|
|
474
473
|
routes.use(apiOnly(routeRequest));
|
|
475
474
|
|
|
476
|
-
// Diverses protections (dont le disable x-powered-by)
|
|
477
|
-
routes.use(helmet(this.config.helmet));
|
|
478
|
-
|
|
479
475
|
/*----------------------------------
|
|
480
476
|
- FICHIERS STATIQUES
|
|
481
477
|
----------------------------------*/
|
|
@@ -513,6 +509,10 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
513
509
|
},
|
|
514
510
|
}),
|
|
515
511
|
(req, res) => {
|
|
512
|
+
// Never cacheable: during a deploy an instance of the previous build can answer for a
|
|
513
|
+
// file the new build serves, and a CDN that kept that 404 broke every page naming the
|
|
514
|
+
// file until its TTL ran out (21 Oct 2026, a Cloudflare HIT on a chunk 404).
|
|
515
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
516
516
|
res.status(404).send();
|
|
517
517
|
},
|
|
518
518
|
);
|
|
@@ -570,7 +570,8 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
570
570
|
|
|
571
571
|
if (this.config.cors !== undefined) routes.use(cors(this.config.cors));
|
|
572
572
|
|
|
573
|
-
|
|
573
|
+
// Every router answer outside /api keeps the app policy, as in 2.6; static files above keep helmet's.
|
|
574
|
+
routes.use(createAppContentSecurityPolicy(contentSecurityPolicyHeaders));
|
|
574
575
|
|
|
575
576
|
this.registerDevTraceRoutes(routes);
|
|
576
577
|
routes.use(routeRequest);
|