@jsenv/server 17.1.1 → 17.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.
- package/dist/client/route_inspector/route_inspector.html +118 -35
- package/dist/jsenv_server.js +435 -4
- 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/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 +43 -0
- package/src/router/router.js +144 -2
- package/src/start_server.js +107 -0
- package/src/cookies/cookie.js +0 -42
- package/src/cookies/cookie_map.js +0 -18
|
@@ -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.
|
|
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
|
|
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
|
+
>🔒 admin only</span
|
|
1477
|
+
>`
|
|
1478
|
+
: null}
|
|
1479
|
+
${forbidden && !visibleBecauseAdmin
|
|
1480
|
+
? html`<span class="route_forbidden_badge"
|
|
1481
|
+
>🔒 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
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
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 =
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
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 =
|
|
1593
|
-
|
|
1594
|
-
|
|
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,12 +297,55 @@ 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,
|
|
303
339
|
getLastPluginUsed: () => lastPluginUsed,
|
|
304
340
|
getCurrentPlugin: () => currentPlugin,
|
|
305
341
|
getCurrentHookName: () => currentHookName,
|
|
342
|
+
destroyAllPlugins: async () => {
|
|
343
|
+
for (const plugin of activePlugins) {
|
|
344
|
+
if (plugin.destroy) {
|
|
345
|
+
await plugin.destroy();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
},
|
|
306
349
|
};
|
|
307
350
|
};
|
|
308
351
|
|
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) {
|