@jsenv/server 17.1.0 → 17.2.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/dist/client/route_inspector/route_inspector.html +118 -35
- package/dist/jsenv_server.js +453 -17
- package/package.json +13 -13
- package/src/interfacing_with_node/from_node_request.js +19 -0
- package/src/internal/headers_composition.js +12 -0
- package/src/plugins/cookies/cookie.js +77 -0
- package/src/plugins/cookies/server_plugin_response_cookies.js +30 -0
- package/src/plugins/cors/server_plugin_cors.js +26 -13
- package/src/plugins/route_inspector/client/code_block.js +3 -1
- package/src/plugins/route_inspector/client/route_inspector.html +111 -34
- package/src/plugins/route_inspector/server_plugin_route_inspector.js +3 -2
- package/src/plugins/server_plugins_controller.js +1 -0
- package/src/plugins_controller.js +36 -0
- package/src/router/router.js +144 -2
- package/src/start_server.js +106 -0
- package/src/cookies/cookie.js +0 -42
- package/src/cookies/cookie_map.js +0 -18
package/src/router/router.js
CHANGED
|
@@ -32,6 +32,7 @@ export const createRouter = (
|
|
|
32
32
|
) => {
|
|
33
33
|
const logger = createLogger({ logLevel });
|
|
34
34
|
const routeSet = new Set();
|
|
35
|
+
let someRouteHasPermissions = false;
|
|
35
36
|
|
|
36
37
|
const constructAvailableEndpoints = () => {
|
|
37
38
|
// TODO: memoize
|
|
@@ -173,6 +174,12 @@ export const createRouter = (
|
|
|
173
174
|
for (const routeDescription of routeDescriptionArray) {
|
|
174
175
|
const route = createRoute(routeDescription);
|
|
175
176
|
routeSet.add(route);
|
|
177
|
+
if (
|
|
178
|
+
route.permissionsRequired !== undefined ||
|
|
179
|
+
route.permissionsToSee !== undefined
|
|
180
|
+
) {
|
|
181
|
+
someRouteHasPermissions = true;
|
|
182
|
+
}
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
const match = async (request, fetchSecondArg) => {
|
|
@@ -340,6 +347,26 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
340
347
|
if (!resourceMatchResult) {
|
|
341
348
|
continue;
|
|
342
349
|
}
|
|
350
|
+
// permissions check — done before method/media-type/upgrade checks so that
|
|
351
|
+
// a hidden route does not leak its existence via 405/415/426 responses.
|
|
352
|
+
// If the route's method would not have matched anyway we skip silently (no
|
|
353
|
+
// wouldHaveMatched contribution). If the method would have matched we return
|
|
354
|
+
// the denied response so the caller gets 404 or 403.
|
|
355
|
+
if (someRouteHasPermissions) {
|
|
356
|
+
const { permissionsRequired, permissionsToSee } = route;
|
|
357
|
+
const denied = await checkRouteAccess(
|
|
358
|
+
permissionsRequired,
|
|
359
|
+
permissionsToSee,
|
|
360
|
+
request,
|
|
361
|
+
fetchSecondArg,
|
|
362
|
+
);
|
|
363
|
+
if (denied) {
|
|
364
|
+
if (route.matchMethod(request.method)) {
|
|
365
|
+
return denied;
|
|
366
|
+
}
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
343
370
|
if (!route.matchMethod(request.method)) {
|
|
344
371
|
if (!route.isFallback) {
|
|
345
372
|
wouldHaveMatched.methodSet.add(route.method);
|
|
@@ -525,10 +552,68 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
525
552
|
|
|
526
553
|
return matchRoutes(request);
|
|
527
554
|
};
|
|
528
|
-
const inspect = ({ canExposeSensitiveData }) => {
|
|
529
|
-
// I want all the info I can gather about the routes
|
|
555
|
+
const inspect = async ({ canExposeSensitiveData, fetchSecondArg }) => {
|
|
530
556
|
const data = [];
|
|
531
557
|
for (const route of routeSet) {
|
|
558
|
+
if (someRouteHasPermissions) {
|
|
559
|
+
const { permissionsRequired, permissionsToSee } = route;
|
|
560
|
+
// route has no permission config → hidden from inspector
|
|
561
|
+
// (unless canExposeSensitiveData is set, in which case we show it as admin-only visible)
|
|
562
|
+
if (
|
|
563
|
+
permissionsRequired === undefined &&
|
|
564
|
+
permissionsToSee === undefined
|
|
565
|
+
) {
|
|
566
|
+
if (!canExposeSensitiveData) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
const routeData = route.toJSON({ canExposeSensitiveData });
|
|
570
|
+
routeData.visibleBecauseAdmin = true;
|
|
571
|
+
data.push(routeData);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
// anyone can access → always visible
|
|
575
|
+
if (
|
|
576
|
+
permissionsRequired !== undefined &&
|
|
577
|
+
permissionsRequired.length > 0
|
|
578
|
+
) {
|
|
579
|
+
if (fetchSecondArg) {
|
|
580
|
+
const granted =
|
|
581
|
+
await fetchSecondArg.hasPermissions(permissionsRequired);
|
|
582
|
+
if (!granted) {
|
|
583
|
+
// determine if route is normally visible (permissionsToSee satisfied)
|
|
584
|
+
let normallyCanSee;
|
|
585
|
+
if (permissionsToSee === undefined) {
|
|
586
|
+
normallyCanSee = false;
|
|
587
|
+
} else {
|
|
588
|
+
const permsSet = await fetchSecondArg.getAllPermissions();
|
|
589
|
+
normallyCanSee =
|
|
590
|
+
permissionsToSee.length === 0 ||
|
|
591
|
+
permissionsSatisfy(permsSet, permissionsToSee);
|
|
592
|
+
}
|
|
593
|
+
if (!normallyCanSee && !canExposeSensitiveData) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const routeData = route.toJSON({ canExposeSensitiveData });
|
|
597
|
+
routeData.forbidden = true;
|
|
598
|
+
if (!normallyCanSee) {
|
|
599
|
+
routeData.visibleBecauseAdmin = true;
|
|
600
|
+
}
|
|
601
|
+
data.push(routeData);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
} else if (permissionsToSee === undefined) {
|
|
605
|
+
if (!canExposeSensitiveData) {
|
|
606
|
+
// no request context and route is not publicly visible
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
const routeData = route.toJSON({ canExposeSensitiveData });
|
|
610
|
+
routeData.forbidden = true;
|
|
611
|
+
routeData.visibleBecauseAdmin = true;
|
|
612
|
+
data.push(routeData);
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
532
617
|
data.push(route.toJSON({ canExposeSensitiveData }));
|
|
533
618
|
}
|
|
534
619
|
return data;
|
|
@@ -581,6 +666,8 @@ const createRoute = ({
|
|
|
581
666
|
headers,
|
|
582
667
|
service,
|
|
583
668
|
serverPlugin,
|
|
669
|
+
permissionsRequired,
|
|
670
|
+
permissionsToSee,
|
|
584
671
|
availableMediaTypes = [],
|
|
585
672
|
availableLanguages = [],
|
|
586
673
|
availableVersions = [],
|
|
@@ -631,6 +718,8 @@ const createRoute = ({
|
|
|
631
718
|
description,
|
|
632
719
|
service,
|
|
633
720
|
serverPlugin,
|
|
721
|
+
permissionsRequired,
|
|
722
|
+
permissionsToSee,
|
|
634
723
|
availableMediaTypes,
|
|
635
724
|
availableLanguages,
|
|
636
725
|
availableVersions,
|
|
@@ -718,6 +807,59 @@ const createRoute = ({
|
|
|
718
807
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
719
808
|
const isRedirectStatus = (status) => REDIRECT_STATUSES.has(status);
|
|
720
809
|
|
|
810
|
+
// Returns a denied response if the request does not satisfy route access/visible config.
|
|
811
|
+
// Returns null if access is granted.
|
|
812
|
+
const checkRouteAccess = async (
|
|
813
|
+
permissionsRequired,
|
|
814
|
+
permissionsToSee,
|
|
815
|
+
request,
|
|
816
|
+
fetchSecondArg,
|
|
817
|
+
) => {
|
|
818
|
+
// No permissionsRequired declared → route is hidden by default
|
|
819
|
+
if (permissionsRequired === undefined) {
|
|
820
|
+
return createRouteNotFoundResponse(request);
|
|
821
|
+
}
|
|
822
|
+
// permissionsRequired: [] → anyone can access
|
|
823
|
+
if (permissionsRequired.length === 0) {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
// early-exit permission check (stops as soon as rule is satisfied)
|
|
827
|
+
const granted = await fetchSecondArg.hasPermissions(permissionsRequired);
|
|
828
|
+
if (granted) {
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
// access denied — decide between 404 and 403
|
|
832
|
+
if (permissionsToSee === undefined) {
|
|
833
|
+
return createRouteNotFoundResponse(request);
|
|
834
|
+
}
|
|
835
|
+
// permissionsToSee check uses full permissions (already accumulated from hasPermissions call above)
|
|
836
|
+
const permsSet = await fetchSecondArg.getAllPermissions();
|
|
837
|
+
if (
|
|
838
|
+
permissionsToSee.length === 0 ||
|
|
839
|
+
permissionsSatisfy(permsSet, permissionsToSee)
|
|
840
|
+
) {
|
|
841
|
+
return createForbiddenResponse(request);
|
|
842
|
+
}
|
|
843
|
+
return createRouteNotFoundResponse(request);
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
|
|
847
|
+
for (const p of permissionsRequired) {
|
|
848
|
+
if (!permissionsSet.has(p)) {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return true;
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
const createForbiddenResponse = () => {
|
|
856
|
+
return {
|
|
857
|
+
status: 403,
|
|
858
|
+
statusText: "Forbidden",
|
|
859
|
+
headers: {},
|
|
860
|
+
};
|
|
861
|
+
};
|
|
862
|
+
|
|
721
863
|
const isRequestBodyMediaTypeSupported = (request, { acceptedMediaTypes }) => {
|
|
722
864
|
const requestBodyContentType = request.headers["content-type"];
|
|
723
865
|
if (!requestBodyContentType) {
|
package/src/start_server.js
CHANGED
|
@@ -24,6 +24,7 @@ import { createPolyglotServer } from "./internal/server-polyglot.js";
|
|
|
24
24
|
import { trackServerPendingConnections } from "./internal/trackServerPendingConnections.js";
|
|
25
25
|
import { trackServerPendingRequests } from "./internal/trackServerPendingRequests.js";
|
|
26
26
|
import { serverPluginAutoreloadOnRestart } from "./plugins/autoreload_on_server_restart/server_plugin_autoreload_on_server_restart.js";
|
|
27
|
+
import { serverPluginResponseCookies } from "./plugins/cookies/server_plugin_response_cookies.js";
|
|
27
28
|
import { serverPluginDefaultBody4xx5xx } from "./plugins/default_body_4xx_5xx/server_plugin_default_body_4xx_5xx.js";
|
|
28
29
|
import { serverPluginInternalClientFiles } from "./plugins/internal_client_files/server_plugin_internal_client_files.js";
|
|
29
30
|
import { serverPluginOpenFile } from "./plugins/open_file/server_plugin_open_file.js";
|
|
@@ -50,6 +51,68 @@ const TIMING_NOOP = () => {
|
|
|
50
51
|
return { end: () => {} };
|
|
51
52
|
};
|
|
52
53
|
|
|
54
|
+
const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
|
|
55
|
+
for (const p of permissionsRequired) {
|
|
56
|
+
if (!permissionsSet.has(p)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Starts an HTTP (or HTTPS/HTTP2) server.
|
|
65
|
+
*
|
|
66
|
+
* @param {Object} params
|
|
67
|
+
*
|
|
68
|
+
* @param {Array<Object>} [params.routes=[]] - Route definitions for the server.
|
|
69
|
+
* Each route is an object with the following properties:
|
|
70
|
+
* - `endpoint` {string} — Required. A string like `"GET /users/:id"` combining the HTTP method
|
|
71
|
+
* and the resource path. Use `"*"` as the method to match all methods, and URL patterns
|
|
72
|
+
* such as `:param` for named segments or `*` for wildcards.
|
|
73
|
+
* - `fetch` {Function} — Required. `(request, helpers) => response`. Must return a `Response`,
|
|
74
|
+
* a response-like object `{ status, headers, body }`, or `null`/`undefined` to skip the route.
|
|
75
|
+
* - `description` {string} — Optional human-readable description shown in the route inspector.
|
|
76
|
+
* - `permissionsRequired` {Array<string>} — Optional. Permissions the client must hold to
|
|
77
|
+
* access the route. An empty array means anyone can access. When omitted the route is hidden
|
|
78
|
+
* by default (404 for everyone).
|
|
79
|
+
* - `permissionsToSee` {Array<string>} — Optional. Permissions needed to know the route exists
|
|
80
|
+
* (403 instead of 404 when access is denied). An empty array means the route is visible to
|
|
81
|
+
* everyone even when access is denied.
|
|
82
|
+
* - `availableMediaTypes` {Array<string>} — Content-types this route can produce (drives
|
|
83
|
+
* `Accept` content negotiation).
|
|
84
|
+
* - `availableLanguages` {Array<string>} — Languages this route can respond with.
|
|
85
|
+
* - `availableEncodings` {Array<string>} — Encodings this route supports.
|
|
86
|
+
* - `acceptedMediaTypes` {Array<string>} — Content-types accepted for request bodies
|
|
87
|
+
* (POST/PATCH/PUT).
|
|
88
|
+
* - `clientCodeExample` {string|Function} — Optional code snippet displayed in the route
|
|
89
|
+
* inspector as a usage example.
|
|
90
|
+
* - `declarationSource` {string} — Optional file URL of where the route is declared, shown
|
|
91
|
+
* in the route inspector when `canExposeSensitiveData` is enabled.
|
|
92
|
+
* - `headers` {Object} — Optional header pattern that must match for the route to be selected.
|
|
93
|
+
*
|
|
94
|
+
* @param {Array<Object>} [params.plugins=[]] - Server plugins that extend behaviour (hooks such
|
|
95
|
+
* as `grantPermissions`, `redirectRequest`, `handleError`, etc.). See plugin documentation for
|
|
96
|
+
* the exact shape; plugins are not described here in detail.
|
|
97
|
+
*
|
|
98
|
+
* @param {number} [params.port=0] - Port to listen on. Defaults to `0` which lets the OS assign
|
|
99
|
+
* a random available port — useful in tests to avoid port conflicts. A fixed port such as
|
|
100
|
+
* `3000` can be passed for a predictable address.
|
|
101
|
+
*
|
|
102
|
+
* @param {boolean} [params.acceptAnyIp=false] - When `true` the server binds to all network
|
|
103
|
+
* interfaces (`0.0.0.0` / `::`), making it reachable from other machines on the network.
|
|
104
|
+
* When `false` (default) it only listens on the configured `hostname`.
|
|
105
|
+
*
|
|
106
|
+
* @param {boolean} [params.canExposeSensitiveData=false] - Unlocks developer-facing features that
|
|
107
|
+
* should never be enabled in production:
|
|
108
|
+
* - Declaration source links in the route inspector (shows where each route is defined).
|
|
109
|
+
* - The `/.internal/open_file` endpoint that can open files on the server machine.
|
|
110
|
+
* - Auto-reload client notification when the server restarts.
|
|
111
|
+
* - All routes are visible in the route inspector regardless of permission configuration.
|
|
112
|
+
* Set to `true` during local development for a better DX.
|
|
113
|
+
*
|
|
114
|
+
* @returns {Promise<Object>} Resolves to the running server object with `{ origin, port, stop, … }`.
|
|
115
|
+
*/
|
|
53
116
|
export const startServer = async ({
|
|
54
117
|
signal = new AbortController().signal,
|
|
55
118
|
logLevel,
|
|
@@ -160,6 +223,7 @@ export const startServer = async ({
|
|
|
160
223
|
|
|
161
224
|
plugins = [
|
|
162
225
|
...(canExposeSensitiveData ? [serverPluginOpenFile()] : []),
|
|
226
|
+
serverPluginResponseCookies(),
|
|
163
227
|
serverPluginDefaultBody4xx5xx(),
|
|
164
228
|
serverPluginRouteInspector({ canExposeSensitiveData }),
|
|
165
229
|
...(canExposeSensitiveData ? [serverPluginInternalClientFiles()] : []),
|
|
@@ -571,6 +635,48 @@ export const startServer = async ({
|
|
|
571
635
|
}
|
|
572
636
|
},
|
|
573
637
|
);
|
|
638
|
+
// Build permission helpers, lazily per request.
|
|
639
|
+
// A single shared iterator ensures each plugin is called at most once.
|
|
640
|
+
// hasPermissions stops early once the rule is satisfied;
|
|
641
|
+
// getAllPermissions drains all remaining plugins.
|
|
642
|
+
const permissionsSet = new Set();
|
|
643
|
+
const nextPermissionsHook =
|
|
644
|
+
serverPluginsController.createAsyncHookIterator(
|
|
645
|
+
"grantPermissions",
|
|
646
|
+
request,
|
|
647
|
+
);
|
|
648
|
+
const drainPermissionsUntil = async (permissionsRequired) => {
|
|
649
|
+
for (;;) {
|
|
650
|
+
if (
|
|
651
|
+
permissionsRequired !== undefined &&
|
|
652
|
+
permissionsSatisfy(permissionsSet, permissionsRequired)
|
|
653
|
+
) {
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
656
|
+
const { done, value } = await nextPermissionsHook();
|
|
657
|
+
if (done) {
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
if (Array.isArray(value)) {
|
|
661
|
+
for (const p of value) {
|
|
662
|
+
permissionsSet.add(p);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return false;
|
|
667
|
+
};
|
|
668
|
+
const getAllPermissions = async () => {
|
|
669
|
+
await drainPermissionsUntil(undefined);
|
|
670
|
+
return permissionsSet;
|
|
671
|
+
};
|
|
672
|
+
const hasPermissions = async (permissionsRequired) => {
|
|
673
|
+
if (permissionsRequired.length === 0) {
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
return drainPermissionsUntil(permissionsRequired);
|
|
677
|
+
};
|
|
678
|
+
fetchSecondArg.getAllPermissions = getAllPermissions;
|
|
679
|
+
fetchSecondArg.hasPermissions = hasPermissions;
|
|
574
680
|
const routerResponseProperties = await router.match(
|
|
575
681
|
request,
|
|
576
682
|
fetchSecondArg,
|
package/src/cookies/cookie.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// https://bun.sh/docs/api/cookie
|
|
2
|
-
|
|
3
|
-
export const COOKIE = {
|
|
4
|
-
from: (name, value, options = {}) => {
|
|
5
|
-
return { name, value, options };
|
|
6
|
-
},
|
|
7
|
-
parse: (cookieString) => {
|
|
8
|
-
const parts = cookieString.split("; ");
|
|
9
|
-
const [nameValue, ...attributes] = parts;
|
|
10
|
-
const [name, value] = nameValue.split("=");
|
|
11
|
-
const options = {};
|
|
12
|
-
for (const attr of attributes) {
|
|
13
|
-
const [key, val] = attr.split("=");
|
|
14
|
-
if (key === "expires") {
|
|
15
|
-
options.expires = new Date(val);
|
|
16
|
-
continue;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (key === "path") {
|
|
20
|
-
options.path = val;
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
if (key === "domain") {
|
|
24
|
-
options.domain = val;
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
if (key === "secure") {
|
|
28
|
-
options.secure = true;
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
if (key === "httponly") {
|
|
32
|
-
options.httpOnly = true;
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return { name, value, options };
|
|
38
|
-
},
|
|
39
|
-
stringify: ({ name, value, options }) => {
|
|
40
|
-
return `${name}=${value}${options.expires ? `; Expires=${options.expires.toUTCString()}` : ""}${options.path ? `; Path=${options.path}` : ""}${options.domain ? `; Domain=${options.domain}` : ""}${options.secure ? "; Secure" : ""}${options.httpOnly ? "; HttpOnly" : ""}`;
|
|
41
|
-
},
|
|
42
|
-
};
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { COOKIE } from "./cookie.js";
|
|
2
|
-
|
|
3
|
-
export const createCookieMap = (setCookieString) => {
|
|
4
|
-
const map = new Map();
|
|
5
|
-
|
|
6
|
-
if (setCookieString) {
|
|
7
|
-
const cookies = setCookieString.split(", ");
|
|
8
|
-
for (const cookie of cookies) {
|
|
9
|
-
const parsedCookie = COOKIE.parse(cookie);
|
|
10
|
-
map.set(parsedCookie.name, parsedCookie);
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
return {
|
|
15
|
-
map,
|
|
16
|
-
toSetCookieHeaders: () => {},
|
|
17
|
-
};
|
|
18
|
-
};
|