@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.
@@ -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) {
@@ -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,
@@ -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
- };