@jsenv/server 17.1.1 → 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.
@@ -0,0 +1,77 @@
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
+ if (key === "path") {
19
+ options.path = val;
20
+ continue;
21
+ }
22
+ if (key === "domain") {
23
+ options.domain = val;
24
+ continue;
25
+ }
26
+ if (key === "secure") {
27
+ options.secure = true;
28
+ continue;
29
+ }
30
+ if (key === "httponly") {
31
+ options.httpOnly = true;
32
+ continue;
33
+ }
34
+ }
35
+
36
+ return { name, value, options };
37
+ },
38
+ stringify: ({ name, value, options }) => {
39
+ let str = `${name}=${value}`;
40
+ if (options.expires) {
41
+ str += `; Expires=${options.expires.toUTCString()}`;
42
+ }
43
+ if (options.maxAge !== undefined) {
44
+ str += `; Max-Age=${options.maxAge}`;
45
+ }
46
+ if (options.path) {
47
+ str += `; Path=${options.path}`;
48
+ }
49
+ if (options.domain) {
50
+ str += `; Domain=${options.domain}`;
51
+ }
52
+ if (options.sameSite) {
53
+ str += `; SameSite=${options.sameSite}`;
54
+ }
55
+ if (options.secure) {
56
+ str += "; Secure";
57
+ }
58
+ if (options.httpOnly) {
59
+ str += "; HttpOnly";
60
+ }
61
+ return str;
62
+ },
63
+ toSetCookieHeaders: (cookies) => {
64
+ const headers = {};
65
+ for (const cookie of cookies) {
66
+ const value = COOKIE.stringify(cookie);
67
+ if (headers["set-cookie"] === undefined) {
68
+ headers["set-cookie"] = value;
69
+ } else if (Array.isArray(headers["set-cookie"])) {
70
+ headers["set-cookie"].push(value);
71
+ } else {
72
+ headers["set-cookie"] = [headers["set-cookie"], value];
73
+ }
74
+ }
75
+ return headers;
76
+ },
77
+ };
@@ -0,0 +1,30 @@
1
+ import { COOKIE } from "./cookie.js";
2
+
3
+ export const serverPluginResponseCookies = () => {
4
+ return {
5
+ name: "jsenv:response_cookies",
6
+ augmentRouteFetchSecondArg: (request, fetchSecondArg) => {
7
+ const responseCookies = {
8
+ set: (name, value, options = {}) => {
9
+ fetchSecondArg.injectResponseHeader(
10
+ "set-cookie",
11
+ COOKIE.stringify(COOKIE.from(name, value, options)),
12
+ );
13
+ },
14
+ delete: (name, options = {}) => {
15
+ fetchSecondArg.injectResponseHeader(
16
+ "set-cookie",
17
+ COOKIE.stringify(
18
+ COOKIE.from(name, "", {
19
+ ...options,
20
+ expires: new Date(0),
21
+ }),
22
+ ),
23
+ );
24
+ },
25
+ };
26
+
27
+ return { responseCookies };
28
+ },
29
+ };
30
+ };
@@ -113,7 +113,9 @@
113
113
  }
114
114
  }
115
115
 
116
- customElements.define("clipboard-copy", ClipboardCopy);
116
+ if (!customElements.get("clipboard-copy")) {
117
+ customElements.define("clipboard-copy", ClipboardCopy);
118
+ }
117
119
 
118
120
  const addToClipboard = async (text) => {
119
121
  const type = "text/plain";
@@ -42,6 +42,13 @@
42
42
  --border-color: #e0e0e0;
43
43
  --primary-color: #1976d2;
44
44
  --secondary-color: #f5f5f5;
45
+ --forbidden-bg-color: #fff8f0;
46
+ --forbidden-border-color: #cc8800;
47
+ --forbidden-badge-bg: #fff3cc;
48
+ --forbidden-badge-color: #7a5000;
49
+ --admin-badge-bg: #f0f4ff;
50
+ --admin-badge-border-color: #5c7cfa;
51
+ --admin-badge-color: #2a4bbf;
45
52
  }
46
53
  :root[data-theme="dark"] {
47
54
  --background-color: #121212;
@@ -49,6 +56,13 @@
49
56
  --border-color: #333;
50
57
  --primary-color: #4f9eff;
51
58
  --secondary-color: #1e1e1e;
59
+ --forbidden-bg-color: #1e1500;
60
+ --forbidden-border-color: #8a6000;
61
+ --forbidden-badge-bg: #3a2800;
62
+ --forbidden-badge-color: #f0c060;
63
+ --admin-badge-bg: #0d1a3a;
64
+ --admin-badge-border-color: #3b5bdb;
65
+ --admin-badge-color: #91a7ff;
52
66
  }
53
67
 
54
68
  :root[data-theme="dark"] a {
@@ -467,6 +481,42 @@
467
481
  --method-color: var(--method-delete-color);
468
482
  }
469
483
 
484
+ .route_details[data-forbidden] {
485
+ background: var(--forbidden-bg-color);
486
+ border-color: var(--forbidden-border-color);
487
+ opacity: 0.85;
488
+ }
489
+ .route_details[data-forbidden] .route_method {
490
+ opacity: 0.7;
491
+ filter: grayscale(60%);
492
+ }
493
+ .route_forbidden_badge {
494
+ margin-right: 6px;
495
+ padding: 3px 8px;
496
+ color: var(--forbidden-badge-color);
497
+ font-weight: 600;
498
+ font-size: 12px;
499
+ white-space: nowrap;
500
+ background: var(--forbidden-badge-bg);
501
+ border: 1px solid var(--forbidden-border-color);
502
+ border-radius: 4px;
503
+ }
504
+ .route_admin_badge {
505
+ margin-right: 6px;
506
+ padding: 3px 8px;
507
+ color: var(--admin-badge-color);
508
+ font-weight: 600;
509
+ font-size: 12px;
510
+ white-space: nowrap;
511
+ background: var(--admin-badge-bg);
512
+ border: 1px solid var(--admin-badge-border-color);
513
+ border-radius: 4px;
514
+ }
515
+ .route_forbidden_content {
516
+ color: var(--forbidden-badge-color);
517
+ font-style: italic;
518
+ }
519
+
470
520
  .route_owner_name {
471
521
  padding: 4px 8px;
472
522
  color: #666;
@@ -1388,6 +1438,8 @@
1388
1438
  websocket,
1389
1439
  clientCodeExample,
1390
1440
  declarationSource,
1441
+ forbidden,
1442
+ visibleBecauseAdmin,
1391
1443
  }) => {
1392
1444
  const [selectedMethod] = useUrlStringParam("method");
1393
1445
  const [selectedPackageName] = usePackageUrlStringParam();
@@ -1400,11 +1452,16 @@
1400
1452
  return null;
1401
1453
  }
1402
1454
 
1403
- return html`<details class="route_details" data-method="${method}">
1455
+ return html`<details
1456
+ class="route_details"
1457
+ data-method="${method}"
1458
+ data-forbidden="${forbidden || undefined}"
1459
+ data-admin-visible="${visibleBecauseAdmin || undefined}"
1460
+ >
1404
1461
  <summary class="route_summary_container">
1405
1462
  <span class="route_method">${method}</span>
1406
1463
  <p class="route_resource">${resource}</p>
1407
- ${method === "GET"
1464
+ ${method === "GET" && !forbidden
1408
1465
  ? html`<a
1409
1466
  style="display:inline-flex; margin-left: 5px;"
1410
1467
  href="${resource}"
@@ -1412,25 +1469,45 @@
1412
1469
  /></a>`
1413
1470
  : null}
1414
1471
  <span style="flex:1"></span>
1472
+ ${visibleBecauseAdmin
1473
+ ? html`<span
1474
+ class="route_admin_badge"
1475
+ title="This route is only visible because the server was started with canExposeSensitiveData"
1476
+ >&#128274; admin only</span
1477
+ >`
1478
+ : null}
1479
+ ${forbidden && !visibleBecauseAdmin
1480
+ ? html`<span class="route_forbidden_badge"
1481
+ >&#128274; access denied</span
1482
+ >`
1483
+ : null}
1415
1484
  <${RouteBadge} meta="${meta}" />
1416
1485
  <span class="summary_marker">
1417
1486
  <${ArrowDown} />
1418
1487
  </span>
1419
1488
  </summary>
1420
- <${RouteDetailsContent}
1421
- method="${method}"
1422
- resource="${resource}"
1423
- description="${description}"
1424
- availableMediaTypes="${availableMediaTypes}"
1425
- availableLanguages="${availableLanguages}"
1426
- availableVersions="${availableVersions}"
1427
- availableEncodings="${availableEncodings}"
1428
- acceptedMediaTypes="${acceptedMediaTypes}"
1429
- websocket="${websocket}"
1430
- clientCodeExample="${clientCodeExample}"
1431
- declarationSource="${declarationSource}"
1432
- meta="${meta}"
1433
- />
1489
+ ${!forbidden
1490
+ ? html`<${RouteDetailsContent}
1491
+ method="${method}"
1492
+ resource="${resource}"
1493
+ description="${description}"
1494
+ availableMediaTypes="${availableMediaTypes}"
1495
+ availableLanguages="${availableLanguages}"
1496
+ availableVersions="${availableVersions}"
1497
+ availableEncodings="${availableEncodings}"
1498
+ acceptedMediaTypes="${acceptedMediaTypes}"
1499
+ websocket="${websocket}"
1500
+ clientCodeExample="${clientCodeExample}"
1501
+ declarationSource="${declarationSource}"
1502
+ meta="${meta}"
1503
+ />`
1504
+ : html`<div class="route_details_content route_forbidden_content">
1505
+ <p>
1506
+ ${visibleBecauseAdmin
1507
+ ? "This route is only visible because the server was started with canExposeSensitiveData. Access is denied with the current permissions."
1508
+ : "You do not have the required permissions to access this route."}
1509
+ </p>
1510
+ </div>`}
1434
1511
  </details>`;
1435
1512
  };
1436
1513
 
@@ -1561,26 +1638,26 @@
1561
1638
  const websocketScheme =
1562
1639
  self.location.protocol === "https:" ? "wss" : "ws";
1563
1640
  const websocketOrigin = `${websocketScheme}://${self.location.host}`;
1564
- jsSource = /*js */ `
1565
- const websocket = new WebSocket("${websocketOrigin}${resource}");
1566
- websocket.onerror = () => {
1567
- console.error("Failed to connect to websocket endpoint");
1568
- };
1569
- websocket.onopen = () => {
1570
- console.info("Connected to websocket endpoint")
1571
- };
1572
- websocket.onmessage = (event) => {
1573
- console.log("Received message", event.data);
1574
- };
1575
- `;
1641
+ jsSource = `
1642
+ const websocket = new WebSocket("${websocketOrigin}${resource}");
1643
+ websocket.onerror = () => {
1644
+ console.error("Failed to connect to websocket endpoint");
1645
+ };
1646
+ websocket.onopen = () => {
1647
+ console.info("Connected to websocket endpoint");
1648
+ };
1649
+ websocket.onmessage = (event) => {
1650
+ console.log("Received message", event.data);
1651
+ };
1652
+ `;
1576
1653
  } else {
1577
1654
  let fetchLine;
1578
1655
  if (method === "GET") {
1579
1656
  fetchLine = `const response = await window.fetch("${resource}");`;
1580
1657
  } else {
1581
1658
  fetchLine = `const response = await window.fetch("${resource}", {
1582
- method: "${method}",
1583
- }`;
1659
+ method: "${method}",
1660
+ }`;
1584
1661
  }
1585
1662
  let readResponseLine;
1586
1663
  if (availableMediaTypes.includes("application/json")) {
@@ -1589,10 +1666,10 @@
1589
1666
  readResponseLine = `const text = await response.text();`;
1590
1667
  }
1591
1668
 
1592
- jsSource = /*js */ `
1593
- ${fetchLine}
1594
- ${readResponseLine}
1595
- `;
1669
+ jsSource = `
1670
+ ${fetchLine};
1671
+ ${readResponseLine};
1672
+ `;
1596
1673
  }
1597
1674
 
1598
1675
  return html`<code-block lang="js" data-escaped data-copy-button
@@ -28,9 +28,10 @@ export const serverPluginRouteInspector = ({ canExposeSensitiveData }) => {
28
28
  availableMediaTypes: ["application/json"],
29
29
  description: "Get the routes available on this server in JSON.",
30
30
  declarationSource: import.meta.url,
31
- fetch: (request, helpers) => {
32
- const routeJSON = helpers.router.inspect({
31
+ fetch: async (request, helpers) => {
32
+ const routeJSON = await helpers.router.inspect({
33
33
  canExposeSensitiveData,
34
+ fetchSecondArg: helpers,
34
35
  });
35
36
  return Response.json(routeJSON);
36
37
  },
@@ -9,6 +9,7 @@ export const createServerPluginsController = async (serverPlugins) => {
9
9
  serverListening: { type: "hook" },
10
10
  redirectRequest: { type: "hook" },
11
11
  augmentRouteFetchSecondArg: { type: "hook" },
12
+ grantPermissions: { type: "hook" },
12
13
  handleError: { type: "hook" },
13
14
  onResponsePush: { type: "hook" },
14
15
  injectResponseProperties: { type: "hook" },
@@ -297,6 +297,42 @@ export const createPluginsController = async ({
297
297
  callHooksUntil,
298
298
  callAsyncHooks,
299
299
  callAsyncHooksUntil,
300
+ // callAsyncHooksWhile(hookName, info, callback)
301
+ // Calls hooks one by one, passing each return value to callback.
302
+ // Stops as soon as callback returns false.
303
+ callAsyncHooksWhile: async (hookName, info, callback) => {
304
+ const hookSet = hookSetMap.get(hookName);
305
+ if (!hookSet) {
306
+ return;
307
+ }
308
+ for (const hook of hookSet) {
309
+ const returnValue = await callAsyncHook(hook, info);
310
+ const shouldContinue = await callback(returnValue);
311
+ if (!shouldContinue) {
312
+ return;
313
+ }
314
+ }
315
+ },
316
+
317
+ // createAsyncHookIterator(hookName, info)
318
+ // Returns an async function that calls the next hook on each invocation.
319
+ // Returns { done: true } when all hooks have been called.
320
+ // Allows callers to maintain a shared cursor across multiple consumers.
321
+ createAsyncHookIterator: (hookName, info) => {
322
+ const hookSet = hookSetMap.get(hookName);
323
+ if (!hookSet) {
324
+ return async () => ({ done: true, value: undefined });
325
+ }
326
+ const iterator = hookSet.values();
327
+ return async () => {
328
+ const { done, value: hook } = iterator.next();
329
+ if (done) {
330
+ return { done: true, value: undefined };
331
+ }
332
+ const value = await callAsyncHook(hook, info);
333
+ return { done: false, value };
334
+ };
335
+ },
300
336
 
301
337
  getPluginMeta: (pluginName) => meta[pluginName],
302
338
  getMeta: () => meta,
@@ -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) {