@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.
- package/dist/client/route_inspector/route_inspector.html +118 -35
- package/dist/jsenv_server.js +427 -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 +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/dist/jsenv_server.js
CHANGED
|
@@ -1321,6 +1321,7 @@ const fromNodeRequest = (
|
|
|
1321
1321
|
});
|
|
1322
1322
|
|
|
1323
1323
|
const headers = headersFromObject(nodeRequest.headers);
|
|
1324
|
+
const cookies = parseRequestCookieHeader(headers["cookie"]);
|
|
1324
1325
|
// pause the request body stream to let a chance for other parts of the code to subscribe to the stream
|
|
1325
1326
|
// Without this the request body readable stream
|
|
1326
1327
|
// might be closed when we'll try to attach "data" and "end" listeners to it
|
|
@@ -1458,6 +1459,7 @@ const fromNodeRequest = (
|
|
|
1458
1459
|
}),
|
|
1459
1460
|
method: nodeRequest.method,
|
|
1460
1461
|
headers,
|
|
1462
|
+
cookies,
|
|
1461
1463
|
body,
|
|
1462
1464
|
buffer,
|
|
1463
1465
|
formData,
|
|
@@ -1648,6 +1650,23 @@ const readBody = (body, { as }) => {
|
|
|
1648
1650
|
});
|
|
1649
1651
|
};
|
|
1650
1652
|
|
|
1653
|
+
const parseRequestCookieHeader = (cookieHeader) => {
|
|
1654
|
+
const map = new Map();
|
|
1655
|
+
if (!cookieHeader) {
|
|
1656
|
+
return map;
|
|
1657
|
+
}
|
|
1658
|
+
for (const pair of cookieHeader.split(";")) {
|
|
1659
|
+
const eqIndex = pair.indexOf("=");
|
|
1660
|
+
if (eqIndex === -1) {
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
const name = pair.slice(0, eqIndex).trim();
|
|
1664
|
+
const value = decodeURIComponent(pair.slice(eqIndex + 1).trim());
|
|
1665
|
+
map.set(name, value);
|
|
1666
|
+
}
|
|
1667
|
+
return map;
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1651
1670
|
const applyRedirectionToRequest = (
|
|
1652
1671
|
request,
|
|
1653
1672
|
{ resource, pathname, ...rest },
|
|
@@ -2307,6 +2326,18 @@ const HEADER_NAMES_COMPOSITION = {
|
|
|
2307
2326
|
"server-timing": composeTwoCommaSeparatedValues,
|
|
2308
2327
|
// 'content-type', // https://github.com/ninenines/cowboy/issues/1230
|
|
2309
2328
|
"vary": composeTwoCommaSeparatedValues,
|
|
2329
|
+
// set-cookie is special: multiple values must be kept as an array
|
|
2330
|
+
"set-cookie": (leftValue, rightValue) => {
|
|
2331
|
+
if (!leftValue) {
|
|
2332
|
+
return rightValue;
|
|
2333
|
+
}
|
|
2334
|
+
if (!rightValue) {
|
|
2335
|
+
return leftValue;
|
|
2336
|
+
}
|
|
2337
|
+
const left = Array.isArray(leftValue) ? leftValue : [leftValue];
|
|
2338
|
+
const right = Array.isArray(rightValue) ? rightValue : [rightValue];
|
|
2339
|
+
return [...left, ...right];
|
|
2340
|
+
},
|
|
2310
2341
|
};
|
|
2311
2342
|
|
|
2312
2343
|
const listen = async ({
|
|
@@ -3380,6 +3411,113 @@ This endpoint exists mostly to demo eventsource as there is already the websocke
|
|
|
3380
3411
|
};
|
|
3381
3412
|
};
|
|
3382
3413
|
|
|
3414
|
+
// https://bun.sh/docs/api/cookie
|
|
3415
|
+
|
|
3416
|
+
const COOKIE = {
|
|
3417
|
+
from: (name, value, options = {}) => {
|
|
3418
|
+
return { name, value, options };
|
|
3419
|
+
},
|
|
3420
|
+
parse: (cookieString) => {
|
|
3421
|
+
const parts = cookieString.split("; ");
|
|
3422
|
+
const [nameValue, ...attributes] = parts;
|
|
3423
|
+
const [name, value] = nameValue.split("=");
|
|
3424
|
+
const options = {};
|
|
3425
|
+
for (const attr of attributes) {
|
|
3426
|
+
const [key, val] = attr.split("=");
|
|
3427
|
+
if (key === "expires") {
|
|
3428
|
+
options.expires = new Date(val);
|
|
3429
|
+
continue;
|
|
3430
|
+
}
|
|
3431
|
+
if (key === "path") {
|
|
3432
|
+
options.path = val;
|
|
3433
|
+
continue;
|
|
3434
|
+
}
|
|
3435
|
+
if (key === "domain") {
|
|
3436
|
+
options.domain = val;
|
|
3437
|
+
continue;
|
|
3438
|
+
}
|
|
3439
|
+
if (key === "secure") {
|
|
3440
|
+
options.secure = true;
|
|
3441
|
+
continue;
|
|
3442
|
+
}
|
|
3443
|
+
if (key === "httponly") {
|
|
3444
|
+
options.httpOnly = true;
|
|
3445
|
+
continue;
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
return { name, value, options };
|
|
3450
|
+
},
|
|
3451
|
+
stringify: ({ name, value, options }) => {
|
|
3452
|
+
let str = `${name}=${value}`;
|
|
3453
|
+
if (options.expires) {
|
|
3454
|
+
str += `; Expires=${options.expires.toUTCString()}`;
|
|
3455
|
+
}
|
|
3456
|
+
if (options.maxAge !== undefined) {
|
|
3457
|
+
str += `; Max-Age=${options.maxAge}`;
|
|
3458
|
+
}
|
|
3459
|
+
if (options.path) {
|
|
3460
|
+
str += `; Path=${options.path}`;
|
|
3461
|
+
}
|
|
3462
|
+
if (options.domain) {
|
|
3463
|
+
str += `; Domain=${options.domain}`;
|
|
3464
|
+
}
|
|
3465
|
+
if (options.sameSite) {
|
|
3466
|
+
str += `; SameSite=${options.sameSite}`;
|
|
3467
|
+
}
|
|
3468
|
+
if (options.secure) {
|
|
3469
|
+
str += "; Secure";
|
|
3470
|
+
}
|
|
3471
|
+
if (options.httpOnly) {
|
|
3472
|
+
str += "; HttpOnly";
|
|
3473
|
+
}
|
|
3474
|
+
return str;
|
|
3475
|
+
},
|
|
3476
|
+
toSetCookieHeaders: (cookies) => {
|
|
3477
|
+
const headers = {};
|
|
3478
|
+
for (const cookie of cookies) {
|
|
3479
|
+
const value = COOKIE.stringify(cookie);
|
|
3480
|
+
if (headers["set-cookie"] === undefined) {
|
|
3481
|
+
headers["set-cookie"] = value;
|
|
3482
|
+
} else if (Array.isArray(headers["set-cookie"])) {
|
|
3483
|
+
headers["set-cookie"].push(value);
|
|
3484
|
+
} else {
|
|
3485
|
+
headers["set-cookie"] = [headers["set-cookie"], value];
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
return headers;
|
|
3489
|
+
},
|
|
3490
|
+
};
|
|
3491
|
+
|
|
3492
|
+
const serverPluginResponseCookies = () => {
|
|
3493
|
+
return {
|
|
3494
|
+
name: "jsenv:response_cookies",
|
|
3495
|
+
augmentRouteFetchSecondArg: (request, fetchSecondArg) => {
|
|
3496
|
+
const responseCookies = {
|
|
3497
|
+
set: (name, value, options = {}) => {
|
|
3498
|
+
fetchSecondArg.injectResponseHeader(
|
|
3499
|
+
"set-cookie",
|
|
3500
|
+
COOKIE.stringify(COOKIE.from(name, value, options)),
|
|
3501
|
+
);
|
|
3502
|
+
},
|
|
3503
|
+
delete: (name, options = {}) => {
|
|
3504
|
+
fetchSecondArg.injectResponseHeader(
|
|
3505
|
+
"set-cookie",
|
|
3506
|
+
COOKIE.stringify(
|
|
3507
|
+
COOKIE.from(name, "", {
|
|
3508
|
+
...options,
|
|
3509
|
+
expires: new Date(0),
|
|
3510
|
+
}),
|
|
3511
|
+
),
|
|
3512
|
+
);
|
|
3513
|
+
},
|
|
3514
|
+
};
|
|
3515
|
+
|
|
3516
|
+
return { responseCookies };
|
|
3517
|
+
},
|
|
3518
|
+
};
|
|
3519
|
+
};
|
|
3520
|
+
|
|
3383
3521
|
const pickAcceptedContent = ({
|
|
3384
3522
|
availables,
|
|
3385
3523
|
accepteds,
|
|
@@ -3888,9 +4026,10 @@ const serverPluginRouteInspector = ({ canExposeSensitiveData }) => {
|
|
|
3888
4026
|
availableMediaTypes: ["application/json"],
|
|
3889
4027
|
description: "Get the routes available on this server in JSON.",
|
|
3890
4028
|
declarationSource: import.meta.url,
|
|
3891
|
-
fetch: (request, helpers) => {
|
|
3892
|
-
const routeJSON = helpers.router.inspect({
|
|
4029
|
+
fetch: async (request, helpers) => {
|
|
4030
|
+
const routeJSON = await helpers.router.inspect({
|
|
3893
4031
|
canExposeSensitiveData,
|
|
4032
|
+
fetchSecondArg: helpers,
|
|
3894
4033
|
});
|
|
3895
4034
|
return Response.json(routeJSON);
|
|
3896
4035
|
},
|
|
@@ -4196,6 +4335,42 @@ const createPluginsController = async ({
|
|
|
4196
4335
|
callHooksUntil,
|
|
4197
4336
|
callAsyncHooks,
|
|
4198
4337
|
callAsyncHooksUntil,
|
|
4338
|
+
// callAsyncHooksWhile(hookName, info, callback)
|
|
4339
|
+
// Calls hooks one by one, passing each return value to callback.
|
|
4340
|
+
// Stops as soon as callback returns false.
|
|
4341
|
+
callAsyncHooksWhile: async (hookName, info, callback) => {
|
|
4342
|
+
const hookSet = hookSetMap.get(hookName);
|
|
4343
|
+
if (!hookSet) {
|
|
4344
|
+
return;
|
|
4345
|
+
}
|
|
4346
|
+
for (const hook of hookSet) {
|
|
4347
|
+
const returnValue = await callAsyncHook(hook, info);
|
|
4348
|
+
const shouldContinue = await callback(returnValue);
|
|
4349
|
+
if (!shouldContinue) {
|
|
4350
|
+
return;
|
|
4351
|
+
}
|
|
4352
|
+
}
|
|
4353
|
+
},
|
|
4354
|
+
|
|
4355
|
+
// createAsyncHookIterator(hookName, info)
|
|
4356
|
+
// Returns an async function that calls the next hook on each invocation.
|
|
4357
|
+
// Returns { done: true } when all hooks have been called.
|
|
4358
|
+
// Allows callers to maintain a shared cursor across multiple consumers.
|
|
4359
|
+
createAsyncHookIterator: (hookName, info) => {
|
|
4360
|
+
const hookSet = hookSetMap.get(hookName);
|
|
4361
|
+
if (!hookSet) {
|
|
4362
|
+
return async () => ({ done: true, value: undefined });
|
|
4363
|
+
}
|
|
4364
|
+
const iterator = hookSet.values();
|
|
4365
|
+
return async () => {
|
|
4366
|
+
const { done, value: hook } = iterator.next();
|
|
4367
|
+
if (done) {
|
|
4368
|
+
return { done: true, value: undefined };
|
|
4369
|
+
}
|
|
4370
|
+
const value = await callAsyncHook(hook, info);
|
|
4371
|
+
return { done: false, value };
|
|
4372
|
+
};
|
|
4373
|
+
},
|
|
4199
4374
|
|
|
4200
4375
|
getPluginMeta: (pluginName) => meta[pluginName],
|
|
4201
4376
|
getMeta: () => meta,
|
|
@@ -4248,6 +4423,7 @@ const createServerPluginsController = async (serverPlugins) => {
|
|
|
4248
4423
|
serverListening: { type: "hook" },
|
|
4249
4424
|
redirectRequest: { type: "hook" },
|
|
4250
4425
|
augmentRouteFetchSecondArg: { type: "hook" },
|
|
4426
|
+
grantPermissions: { type: "hook" },
|
|
4251
4427
|
handleError: { type: "hook" },
|
|
4252
4428
|
onResponsePush: { type: "hook" },
|
|
4253
4429
|
injectResponseProperties: { type: "hook" },
|
|
@@ -5675,6 +5851,7 @@ const createRouter = (
|
|
|
5675
5851
|
) => {
|
|
5676
5852
|
const logger = createLogger({ logLevel });
|
|
5677
5853
|
const routeSet = new Set();
|
|
5854
|
+
let someRouteHasPermissions = false;
|
|
5678
5855
|
|
|
5679
5856
|
const constructAvailableEndpoints = () => {
|
|
5680
5857
|
// TODO: memoize
|
|
@@ -5816,6 +5993,12 @@ const createRouter = (
|
|
|
5816
5993
|
for (const routeDescription of routeDescriptionArray) {
|
|
5817
5994
|
const route = createRoute(routeDescription);
|
|
5818
5995
|
routeSet.add(route);
|
|
5996
|
+
if (
|
|
5997
|
+
route.permissionsRequired !== undefined ||
|
|
5998
|
+
route.permissionsToSee !== undefined
|
|
5999
|
+
) {
|
|
6000
|
+
someRouteHasPermissions = true;
|
|
6001
|
+
}
|
|
5819
6002
|
}
|
|
5820
6003
|
|
|
5821
6004
|
const match = async (request, fetchSecondArg) => {
|
|
@@ -5983,6 +6166,26 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
5983
6166
|
if (!resourceMatchResult) {
|
|
5984
6167
|
continue;
|
|
5985
6168
|
}
|
|
6169
|
+
// permissions check — done before method/media-type/upgrade checks so that
|
|
6170
|
+
// a hidden route does not leak its existence via 405/415/426 responses.
|
|
6171
|
+
// If the route's method would not have matched anyway we skip silently (no
|
|
6172
|
+
// wouldHaveMatched contribution). If the method would have matched we return
|
|
6173
|
+
// the denied response so the caller gets 404 or 403.
|
|
6174
|
+
if (someRouteHasPermissions) {
|
|
6175
|
+
const { permissionsRequired, permissionsToSee } = route;
|
|
6176
|
+
const denied = await checkRouteAccess(
|
|
6177
|
+
permissionsRequired,
|
|
6178
|
+
permissionsToSee,
|
|
6179
|
+
request,
|
|
6180
|
+
fetchSecondArg,
|
|
6181
|
+
);
|
|
6182
|
+
if (denied) {
|
|
6183
|
+
if (route.matchMethod(request.method)) {
|
|
6184
|
+
return denied;
|
|
6185
|
+
}
|
|
6186
|
+
continue;
|
|
6187
|
+
}
|
|
6188
|
+
}
|
|
5986
6189
|
if (!route.matchMethod(request.method)) {
|
|
5987
6190
|
if (!route.isFallback) {
|
|
5988
6191
|
wouldHaveMatched.methodSet.add(route.method);
|
|
@@ -6165,10 +6368,68 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6165
6368
|
|
|
6166
6369
|
return matchRoutes(request);
|
|
6167
6370
|
};
|
|
6168
|
-
const inspect = ({ canExposeSensitiveData }) => {
|
|
6169
|
-
// I want all the info I can gather about the routes
|
|
6371
|
+
const inspect = async ({ canExposeSensitiveData, fetchSecondArg }) => {
|
|
6170
6372
|
const data = [];
|
|
6171
6373
|
for (const route of routeSet) {
|
|
6374
|
+
if (someRouteHasPermissions) {
|
|
6375
|
+
const { permissionsRequired, permissionsToSee } = route;
|
|
6376
|
+
// route has no permission config → hidden from inspector
|
|
6377
|
+
// (unless canExposeSensitiveData is set, in which case we show it as admin-only visible)
|
|
6378
|
+
if (
|
|
6379
|
+
permissionsRequired === undefined &&
|
|
6380
|
+
permissionsToSee === undefined
|
|
6381
|
+
) {
|
|
6382
|
+
if (!canExposeSensitiveData) {
|
|
6383
|
+
continue;
|
|
6384
|
+
}
|
|
6385
|
+
const routeData = route.toJSON({ canExposeSensitiveData });
|
|
6386
|
+
routeData.visibleBecauseAdmin = true;
|
|
6387
|
+
data.push(routeData);
|
|
6388
|
+
continue;
|
|
6389
|
+
}
|
|
6390
|
+
// anyone can access → always visible
|
|
6391
|
+
if (
|
|
6392
|
+
permissionsRequired !== undefined &&
|
|
6393
|
+
permissionsRequired.length > 0
|
|
6394
|
+
) {
|
|
6395
|
+
if (fetchSecondArg) {
|
|
6396
|
+
const granted =
|
|
6397
|
+
await fetchSecondArg.hasPermissions(permissionsRequired);
|
|
6398
|
+
if (!granted) {
|
|
6399
|
+
// determine if route is normally visible (permissionsToSee satisfied)
|
|
6400
|
+
let normallyCanSee;
|
|
6401
|
+
if (permissionsToSee === undefined) {
|
|
6402
|
+
normallyCanSee = false;
|
|
6403
|
+
} else {
|
|
6404
|
+
const permsSet = await fetchSecondArg.getAllPermissions();
|
|
6405
|
+
normallyCanSee =
|
|
6406
|
+
permissionsToSee.length === 0 ||
|
|
6407
|
+
permissionsSatisfy$1(permsSet, permissionsToSee);
|
|
6408
|
+
}
|
|
6409
|
+
if (!normallyCanSee && !canExposeSensitiveData) {
|
|
6410
|
+
continue;
|
|
6411
|
+
}
|
|
6412
|
+
const routeData = route.toJSON({ canExposeSensitiveData });
|
|
6413
|
+
routeData.forbidden = true;
|
|
6414
|
+
if (!normallyCanSee) {
|
|
6415
|
+
routeData.visibleBecauseAdmin = true;
|
|
6416
|
+
}
|
|
6417
|
+
data.push(routeData);
|
|
6418
|
+
continue;
|
|
6419
|
+
}
|
|
6420
|
+
} else if (permissionsToSee === undefined) {
|
|
6421
|
+
if (!canExposeSensitiveData) {
|
|
6422
|
+
// no request context and route is not publicly visible
|
|
6423
|
+
continue;
|
|
6424
|
+
}
|
|
6425
|
+
const routeData = route.toJSON({ canExposeSensitiveData });
|
|
6426
|
+
routeData.forbidden = true;
|
|
6427
|
+
routeData.visibleBecauseAdmin = true;
|
|
6428
|
+
data.push(routeData);
|
|
6429
|
+
continue;
|
|
6430
|
+
}
|
|
6431
|
+
}
|
|
6432
|
+
}
|
|
6172
6433
|
data.push(route.toJSON({ canExposeSensitiveData }));
|
|
6173
6434
|
}
|
|
6174
6435
|
return data;
|
|
@@ -6221,6 +6482,8 @@ const createRoute = ({
|
|
|
6221
6482
|
headers,
|
|
6222
6483
|
service,
|
|
6223
6484
|
serverPlugin,
|
|
6485
|
+
permissionsRequired,
|
|
6486
|
+
permissionsToSee,
|
|
6224
6487
|
availableMediaTypes = [],
|
|
6225
6488
|
availableLanguages = [],
|
|
6226
6489
|
availableVersions = [],
|
|
@@ -6271,6 +6534,8 @@ const createRoute = ({
|
|
|
6271
6534
|
description,
|
|
6272
6535
|
service,
|
|
6273
6536
|
serverPlugin,
|
|
6537
|
+
permissionsRequired,
|
|
6538
|
+
permissionsToSee,
|
|
6274
6539
|
availableMediaTypes,
|
|
6275
6540
|
availableLanguages,
|
|
6276
6541
|
availableVersions,
|
|
@@ -6358,6 +6623,59 @@ const createRoute = ({
|
|
|
6358
6623
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
6359
6624
|
const isRedirectStatus = (status) => REDIRECT_STATUSES.has(status);
|
|
6360
6625
|
|
|
6626
|
+
// Returns a denied response if the request does not satisfy route access/visible config.
|
|
6627
|
+
// Returns null if access is granted.
|
|
6628
|
+
const checkRouteAccess = async (
|
|
6629
|
+
permissionsRequired,
|
|
6630
|
+
permissionsToSee,
|
|
6631
|
+
request,
|
|
6632
|
+
fetchSecondArg,
|
|
6633
|
+
) => {
|
|
6634
|
+
// No permissionsRequired declared → route is hidden by default
|
|
6635
|
+
if (permissionsRequired === undefined) {
|
|
6636
|
+
return createRouteNotFoundResponse(request);
|
|
6637
|
+
}
|
|
6638
|
+
// permissionsRequired: [] → anyone can access
|
|
6639
|
+
if (permissionsRequired.length === 0) {
|
|
6640
|
+
return null;
|
|
6641
|
+
}
|
|
6642
|
+
// early-exit permission check (stops as soon as rule is satisfied)
|
|
6643
|
+
const granted = await fetchSecondArg.hasPermissions(permissionsRequired);
|
|
6644
|
+
if (granted) {
|
|
6645
|
+
return null;
|
|
6646
|
+
}
|
|
6647
|
+
// access denied — decide between 404 and 403
|
|
6648
|
+
if (permissionsToSee === undefined) {
|
|
6649
|
+
return createRouteNotFoundResponse(request);
|
|
6650
|
+
}
|
|
6651
|
+
// permissionsToSee check uses full permissions (already accumulated from hasPermissions call above)
|
|
6652
|
+
const permsSet = await fetchSecondArg.getAllPermissions();
|
|
6653
|
+
if (
|
|
6654
|
+
permissionsToSee.length === 0 ||
|
|
6655
|
+
permissionsSatisfy$1(permsSet, permissionsToSee)
|
|
6656
|
+
) {
|
|
6657
|
+
return createForbiddenResponse();
|
|
6658
|
+
}
|
|
6659
|
+
return createRouteNotFoundResponse(request);
|
|
6660
|
+
};
|
|
6661
|
+
|
|
6662
|
+
const permissionsSatisfy$1 = (permissionsSet, permissionsRequired) => {
|
|
6663
|
+
for (const p of permissionsRequired) {
|
|
6664
|
+
if (!permissionsSet.has(p)) {
|
|
6665
|
+
return false;
|
|
6666
|
+
}
|
|
6667
|
+
}
|
|
6668
|
+
return true;
|
|
6669
|
+
};
|
|
6670
|
+
|
|
6671
|
+
const createForbiddenResponse = () => {
|
|
6672
|
+
return {
|
|
6673
|
+
status: 403,
|
|
6674
|
+
statusText: "Forbidden",
|
|
6675
|
+
headers: {},
|
|
6676
|
+
};
|
|
6677
|
+
};
|
|
6678
|
+
|
|
6361
6679
|
const isRequestBodyMediaTypeSupported = (request, { acceptedMediaTypes }) => {
|
|
6362
6680
|
const requestBodyContentType = request.headers["content-type"];
|
|
6363
6681
|
if (!requestBodyContentType) {
|
|
@@ -6770,6 +7088,68 @@ const TIMING_NOOP = () => {
|
|
|
6770
7088
|
return { end: () => {} };
|
|
6771
7089
|
};
|
|
6772
7090
|
|
|
7091
|
+
const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
|
|
7092
|
+
for (const p of permissionsRequired) {
|
|
7093
|
+
if (!permissionsSet.has(p)) {
|
|
7094
|
+
return false;
|
|
7095
|
+
}
|
|
7096
|
+
}
|
|
7097
|
+
return true;
|
|
7098
|
+
};
|
|
7099
|
+
|
|
7100
|
+
/**
|
|
7101
|
+
* Starts an HTTP (or HTTPS/HTTP2) server.
|
|
7102
|
+
*
|
|
7103
|
+
* @param {Object} params
|
|
7104
|
+
*
|
|
7105
|
+
* @param {Array<Object>} [params.routes=[]] - Route definitions for the server.
|
|
7106
|
+
* Each route is an object with the following properties:
|
|
7107
|
+
* - `endpoint` {string} — Required. A string like `"GET /users/:id"` combining the HTTP method
|
|
7108
|
+
* and the resource path. Use `"*"` as the method to match all methods, and URL patterns
|
|
7109
|
+
* such as `:param` for named segments or `*` for wildcards.
|
|
7110
|
+
* - `fetch` {Function} — Required. `(request, helpers) => response`. Must return a `Response`,
|
|
7111
|
+
* a response-like object `{ status, headers, body }`, or `null`/`undefined` to skip the route.
|
|
7112
|
+
* - `description` {string} — Optional human-readable description shown in the route inspector.
|
|
7113
|
+
* - `permissionsRequired` {Array<string>} — Optional. Permissions the client must hold to
|
|
7114
|
+
* access the route. An empty array means anyone can access. When omitted the route is hidden
|
|
7115
|
+
* by default (404 for everyone).
|
|
7116
|
+
* - `permissionsToSee` {Array<string>} — Optional. Permissions needed to know the route exists
|
|
7117
|
+
* (403 instead of 404 when access is denied). An empty array means the route is visible to
|
|
7118
|
+
* everyone even when access is denied.
|
|
7119
|
+
* - `availableMediaTypes` {Array<string>} — Content-types this route can produce (drives
|
|
7120
|
+
* `Accept` content negotiation).
|
|
7121
|
+
* - `availableLanguages` {Array<string>} — Languages this route can respond with.
|
|
7122
|
+
* - `availableEncodings` {Array<string>} — Encodings this route supports.
|
|
7123
|
+
* - `acceptedMediaTypes` {Array<string>} — Content-types accepted for request bodies
|
|
7124
|
+
* (POST/PATCH/PUT).
|
|
7125
|
+
* - `clientCodeExample` {string|Function} — Optional code snippet displayed in the route
|
|
7126
|
+
* inspector as a usage example.
|
|
7127
|
+
* - `declarationSource` {string} — Optional file URL of where the route is declared, shown
|
|
7128
|
+
* in the route inspector when `canExposeSensitiveData` is enabled.
|
|
7129
|
+
* - `headers` {Object} — Optional header pattern that must match for the route to be selected.
|
|
7130
|
+
*
|
|
7131
|
+
* @param {Array<Object>} [params.plugins=[]] - Server plugins that extend behaviour (hooks such
|
|
7132
|
+
* as `grantPermissions`, `redirectRequest`, `handleError`, etc.). See plugin documentation for
|
|
7133
|
+
* the exact shape; plugins are not described here in detail.
|
|
7134
|
+
*
|
|
7135
|
+
* @param {number} [params.port=0] - Port to listen on. Defaults to `0` which lets the OS assign
|
|
7136
|
+
* a random available port — useful in tests to avoid port conflicts. A fixed port such as
|
|
7137
|
+
* `3000` can be passed for a predictable address.
|
|
7138
|
+
*
|
|
7139
|
+
* @param {boolean} [params.acceptAnyIp=false] - When `true` the server binds to all network
|
|
7140
|
+
* interfaces (`0.0.0.0` / `::`), making it reachable from other machines on the network.
|
|
7141
|
+
* When `false` (default) it only listens on the configured `hostname`.
|
|
7142
|
+
*
|
|
7143
|
+
* @param {boolean} [params.canExposeSensitiveData=false] - Unlocks developer-facing features that
|
|
7144
|
+
* should never be enabled in production:
|
|
7145
|
+
* - Declaration source links in the route inspector (shows where each route is defined).
|
|
7146
|
+
* - The `/.internal/open_file` endpoint that can open files on the server machine.
|
|
7147
|
+
* - Auto-reload client notification when the server restarts.
|
|
7148
|
+
* - All routes are visible in the route inspector regardless of permission configuration.
|
|
7149
|
+
* Set to `true` during local development for a better DX.
|
|
7150
|
+
*
|
|
7151
|
+
* @returns {Promise<Object>} Resolves to the running server object with `{ origin, port, stop, … }`.
|
|
7152
|
+
*/
|
|
6773
7153
|
const startServer = async ({
|
|
6774
7154
|
signal = new AbortController().signal,
|
|
6775
7155
|
logLevel,
|
|
@@ -6880,6 +7260,7 @@ const startServer = async ({
|
|
|
6880
7260
|
|
|
6881
7261
|
plugins = [
|
|
6882
7262
|
...(canExposeSensitiveData ? [serverPluginOpenFile()] : []),
|
|
7263
|
+
serverPluginResponseCookies(),
|
|
6883
7264
|
serverPluginDefaultBody4xx5xx(),
|
|
6884
7265
|
serverPluginRouteInspector({ canExposeSensitiveData }),
|
|
6885
7266
|
...(canExposeSensitiveData ? [serverPluginInternalClientFiles()] : []),
|
|
@@ -7291,6 +7672,48 @@ const startServer = async ({
|
|
|
7291
7672
|
}
|
|
7292
7673
|
},
|
|
7293
7674
|
);
|
|
7675
|
+
// Build permission helpers, lazily per request.
|
|
7676
|
+
// A single shared iterator ensures each plugin is called at most once.
|
|
7677
|
+
// hasPermissions stops early once the rule is satisfied;
|
|
7678
|
+
// getAllPermissions drains all remaining plugins.
|
|
7679
|
+
const permissionsSet = new Set();
|
|
7680
|
+
const nextPermissionsHook =
|
|
7681
|
+
serverPluginsController.createAsyncHookIterator(
|
|
7682
|
+
"grantPermissions",
|
|
7683
|
+
request,
|
|
7684
|
+
);
|
|
7685
|
+
const drainPermissionsUntil = async (permissionsRequired) => {
|
|
7686
|
+
for (;;) {
|
|
7687
|
+
if (
|
|
7688
|
+
permissionsRequired !== undefined &&
|
|
7689
|
+
permissionsSatisfy(permissionsSet, permissionsRequired)
|
|
7690
|
+
) {
|
|
7691
|
+
return true;
|
|
7692
|
+
}
|
|
7693
|
+
const { done, value } = await nextPermissionsHook();
|
|
7694
|
+
if (done) {
|
|
7695
|
+
break;
|
|
7696
|
+
}
|
|
7697
|
+
if (Array.isArray(value)) {
|
|
7698
|
+
for (const p of value) {
|
|
7699
|
+
permissionsSet.add(p);
|
|
7700
|
+
}
|
|
7701
|
+
}
|
|
7702
|
+
}
|
|
7703
|
+
return false;
|
|
7704
|
+
};
|
|
7705
|
+
const getAllPermissions = async () => {
|
|
7706
|
+
await drainPermissionsUntil(undefined);
|
|
7707
|
+
return permissionsSet;
|
|
7708
|
+
};
|
|
7709
|
+
const hasPermissions = async (permissionsRequired) => {
|
|
7710
|
+
if (permissionsRequired.length === 0) {
|
|
7711
|
+
return true;
|
|
7712
|
+
}
|
|
7713
|
+
return drainPermissionsUntil(permissionsRequired);
|
|
7714
|
+
};
|
|
7715
|
+
fetchSecondArg.getAllPermissions = getAllPermissions;
|
|
7716
|
+
fetchSecondArg.hasPermissions = hasPermissions;
|
|
7294
7717
|
const routerResponseProperties = await router.match(
|
|
7295
7718
|
request,
|
|
7296
7719
|
fetchSecondArg,
|
package/package.json
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/server",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.2.0",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"description": "A modern Node.js HTTP server with declarative routing, content negotiation, and WebSocket support",
|
|
5
6
|
"repository": {
|
|
6
7
|
"type": "git",
|
|
7
8
|
"url": "https://github.com/jsenv/core",
|
|
8
9
|
"directory": "packages/backend/server"
|
|
9
10
|
},
|
|
10
|
-
"license": "MIT",
|
|
11
11
|
"author": {
|
|
12
12
|
"name": "dmail",
|
|
13
13
|
"email": "dmaillard06@gmail.com"
|
|
14
14
|
},
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22.13.1"
|
|
18
|
+
},
|
|
19
|
+
"main": "./index.js",
|
|
19
20
|
"exports": {
|
|
20
21
|
".": {
|
|
21
22
|
"import": {
|
|
@@ -25,11 +26,10 @@
|
|
|
25
26
|
},
|
|
26
27
|
"./*": "./*"
|
|
27
28
|
},
|
|
28
|
-
"main": "./index.js",
|
|
29
29
|
"files": [
|
|
30
30
|
"/dist/",
|
|
31
|
-
"/
|
|
32
|
-
"/
|
|
31
|
+
"/index.js",
|
|
32
|
+
"/src/"
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "node --experimental-import-meta-resolve ./scripts/build.mjs",
|
|
@@ -60,10 +60,10 @@
|
|
|
60
60
|
"undici": "7.24.2",
|
|
61
61
|
"ws": "8.20.0"
|
|
62
62
|
},
|
|
63
|
-
"engines": {
|
|
64
|
-
"node": ">=22.13.1"
|
|
65
|
-
},
|
|
66
63
|
"publishConfig": {
|
|
67
64
|
"access": "public"
|
|
68
|
-
}
|
|
65
|
+
},
|
|
66
|
+
"sideEffects": [
|
|
67
|
+
"./dist/jsenv_server.js"
|
|
68
|
+
]
|
|
69
69
|
}
|
|
@@ -47,6 +47,7 @@ export const fromNodeRequest = (
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
const headers = headersFromObject(nodeRequest.headers);
|
|
50
|
+
const cookies = parseRequestCookieHeader(headers["cookie"]);
|
|
50
51
|
// pause the request body stream to let a chance for other parts of the code to subscribe to the stream
|
|
51
52
|
// Without this the request body readable stream
|
|
52
53
|
// might be closed when we'll try to attach "data" and "end" listeners to it
|
|
@@ -184,6 +185,7 @@ export const fromNodeRequest = (
|
|
|
184
185
|
}),
|
|
185
186
|
method: nodeRequest.method,
|
|
186
187
|
headers,
|
|
188
|
+
cookies,
|
|
187
189
|
body,
|
|
188
190
|
buffer,
|
|
189
191
|
formData,
|
|
@@ -387,6 +389,23 @@ export const readRequestBody = (request, { as }) => {
|
|
|
387
389
|
throw new Error(`unsupported ${as}`);
|
|
388
390
|
};
|
|
389
391
|
|
|
392
|
+
const parseRequestCookieHeader = (cookieHeader) => {
|
|
393
|
+
const map = new Map();
|
|
394
|
+
if (!cookieHeader) {
|
|
395
|
+
return map;
|
|
396
|
+
}
|
|
397
|
+
for (const pair of cookieHeader.split(";")) {
|
|
398
|
+
const eqIndex = pair.indexOf("=");
|
|
399
|
+
if (eqIndex === -1) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const name = pair.slice(0, eqIndex).trim();
|
|
403
|
+
const value = decodeURIComponent(pair.slice(eqIndex + 1).trim());
|
|
404
|
+
map.set(name, value);
|
|
405
|
+
}
|
|
406
|
+
return map;
|
|
407
|
+
};
|
|
408
|
+
|
|
390
409
|
export const applyRedirectionToRequest = (
|
|
391
410
|
request,
|
|
392
411
|
{ resource, pathname, ...rest },
|
|
@@ -56,4 +56,16 @@ const HEADER_NAMES_COMPOSITION = {
|
|
|
56
56
|
"server-timing": composeTwoCommaSeparatedValues,
|
|
57
57
|
// 'content-type', // https://github.com/ninenines/cowboy/issues/1230
|
|
58
58
|
"vary": composeTwoCommaSeparatedValues,
|
|
59
|
+
// set-cookie is special: multiple values must be kept as an array
|
|
60
|
+
"set-cookie": (leftValue, rightValue) => {
|
|
61
|
+
if (!leftValue) {
|
|
62
|
+
return rightValue;
|
|
63
|
+
}
|
|
64
|
+
if (!rightValue) {
|
|
65
|
+
return leftValue;
|
|
66
|
+
}
|
|
67
|
+
const left = Array.isArray(leftValue) ? leftValue : [leftValue];
|
|
68
|
+
const right = Array.isArray(rightValue) ? rightValue : [rightValue];
|
|
69
|
+
return [...left, ...right];
|
|
70
|
+
},
|
|
59
71
|
};
|