@http-client-toolkit/dashboard 1.0.1 → 2.0.1

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/lib/index.d.ts CHANGED
@@ -67,6 +67,7 @@ declare const DashboardOptionsSchema: z.ZodEffects<
67
67
  >;
68
68
  basePath: z.ZodDefault<z.ZodString>;
69
69
  pollIntervalMs: z.ZodDefault<z.ZodNumber>;
70
+ readonly: z.ZodDefault<z.ZodBoolean>;
70
71
  },
71
72
  'strip',
72
73
  z.ZodTypeAny,
@@ -77,6 +78,7 @@ declare const DashboardOptionsSchema: z.ZodEffects<
77
78
  }[];
78
79
  basePath: string;
79
80
  pollIntervalMs: number;
81
+ readonly: boolean;
80
82
  },
81
83
  {
82
84
  clients: {
@@ -85,6 +87,7 @@ declare const DashboardOptionsSchema: z.ZodEffects<
85
87
  }[];
86
88
  basePath?: string | undefined;
87
89
  pollIntervalMs?: number | undefined;
90
+ readonly?: boolean | undefined;
88
91
  }
89
92
  >,
90
93
  {
@@ -94,6 +97,7 @@ declare const DashboardOptionsSchema: z.ZodEffects<
94
97
  }[];
95
98
  basePath: string;
96
99
  pollIntervalMs: number;
100
+ readonly: boolean;
97
101
  },
98
102
  {
99
103
  clients: {
@@ -102,6 +106,7 @@ declare const DashboardOptionsSchema: z.ZodEffects<
102
106
  }[];
103
107
  basePath?: string | undefined;
104
108
  pollIntervalMs?: number | undefined;
109
+ readonly?: boolean | undefined;
105
110
  }
106
111
  >;
107
112
  declare const StandaloneDashboardOptionsSchema: z.ZodIntersection<
@@ -139,6 +144,7 @@ declare const StandaloneDashboardOptionsSchema: z.ZodIntersection<
139
144
  >;
140
145
  basePath: z.ZodDefault<z.ZodString>;
141
146
  pollIntervalMs: z.ZodDefault<z.ZodNumber>;
147
+ readonly: z.ZodDefault<z.ZodBoolean>;
142
148
  },
143
149
  'strip',
144
150
  z.ZodTypeAny,
@@ -149,6 +155,7 @@ declare const StandaloneDashboardOptionsSchema: z.ZodIntersection<
149
155
  }[];
150
156
  basePath: string;
151
157
  pollIntervalMs: number;
158
+ readonly: boolean;
152
159
  },
153
160
  {
154
161
  clients: {
@@ -157,6 +164,7 @@ declare const StandaloneDashboardOptionsSchema: z.ZodIntersection<
157
164
  }[];
158
165
  basePath?: string | undefined;
159
166
  pollIntervalMs?: number | undefined;
167
+ readonly?: boolean | undefined;
160
168
  }
161
169
  >,
162
170
  {
@@ -166,6 +174,7 @@ declare const StandaloneDashboardOptionsSchema: z.ZodIntersection<
166
174
  }[];
167
175
  basePath: string;
168
176
  pollIntervalMs: number;
177
+ readonly: boolean;
169
178
  },
170
179
  {
171
180
  clients: {
@@ -174,6 +183,7 @@ declare const StandaloneDashboardOptionsSchema: z.ZodIntersection<
174
183
  }[];
175
184
  basePath?: string | undefined;
176
185
  pollIntervalMs?: number | undefined;
186
+ readonly?: boolean | undefined;
177
187
  }
178
188
  >,
179
189
  z.ZodObject<
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { existsSync, readFileSync } from 'fs';
3
- import { join, extname, dirname } from 'path';
3
+ import { resolve, join, extname, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { createServer } from 'http';
6
6
 
@@ -377,6 +377,7 @@ var DashboardOptionsSchema = z
377
377
  .min(1, 'At least one client is required'),
378
378
  basePath: z.string().default('/'),
379
379
  pollIntervalMs: z.number().int().positive().default(5e3),
380
+ readonly: z.boolean().default(false),
380
381
  })
381
382
  .refine(
382
383
  (data) => {
@@ -413,7 +414,11 @@ function parseUrl(req, basePath) {
413
414
  const raw = req.url ?? '/';
414
415
  const url = new URL(raw, 'http://localhost');
415
416
  let pathname = url.pathname;
416
- if (basePath !== '/' && pathname.startsWith(basePath)) {
417
+ if (
418
+ basePath !== '/' &&
419
+ pathname.startsWith(basePath) &&
420
+ (pathname.length === basePath.length || pathname[basePath.length] === '/')
421
+ ) {
417
422
  pathname = pathname.slice(basePath.length) || '/';
418
423
  }
419
424
  return { pathname, query: url.searchParams };
@@ -431,15 +436,23 @@ function extractParam(pathname, pattern) {
431
436
  if (paramIndex === -1) return void 0;
432
437
  return pathParts[paramIndex];
433
438
  }
439
+ var MAX_BODY_SIZE = 1024 * 1024;
434
440
  async function readJsonBody(req) {
435
- return new Promise((resolve, reject) => {
441
+ return new Promise((resolve3, reject) => {
436
442
  let body = '';
443
+ let size = 0;
437
444
  req.on('data', (chunk) => {
445
+ size += chunk.length;
446
+ if (size > MAX_BODY_SIZE) {
447
+ req.destroy();
448
+ reject(new Error('Request body too large'));
449
+ return;
450
+ }
438
451
  body += chunk.toString();
439
452
  });
440
453
  req.on('end', () => {
441
454
  try {
442
- resolve(JSON.parse(body));
455
+ resolve3(JSON.parse(body));
443
456
  } catch {
444
457
  reject(new Error('Invalid JSON body'));
445
458
  }
@@ -467,6 +480,9 @@ function sendNotFound(res) {
467
480
  function sendMethodNotAllowed(res) {
468
481
  sendError(res, 'Method not allowed', 405);
469
482
  }
483
+ function sendForbidden(res) {
484
+ sendError(res, 'Dashboard is in readonly mode', 403);
485
+ }
470
486
 
471
487
  // src/server/handlers/cache.ts
472
488
  async function handleCacheStats(res, adapter) {
@@ -697,7 +713,15 @@ function createApiRouter(ctx) {
697
713
  sendError(res, `Unknown client: ${clientName}`, 404);
698
714
  return true;
699
715
  }
700
- return routeClientApi(req, res, client, subPath, method, query);
716
+ return routeClientApi(
717
+ req,
718
+ res,
719
+ client,
720
+ subPath,
721
+ method,
722
+ query,
723
+ ctx.readonly,
724
+ );
701
725
  }
702
726
  if (pathname.startsWith('/api/')) {
703
727
  sendNotFound(res);
@@ -706,7 +730,22 @@ function createApiRouter(ctx) {
706
730
  return false;
707
731
  };
708
732
  }
709
- async function routeClientApi(req, res, client, subPath, method, query) {
733
+ function isMutatingMethod(method) {
734
+ return method === 'DELETE' || method === 'PUT' || method === 'POST';
735
+ }
736
+ async function routeClientApi(
737
+ req,
738
+ res,
739
+ client,
740
+ subPath,
741
+ method,
742
+ query,
743
+ readonly,
744
+ ) {
745
+ if (readonly && isMutatingMethod(method)) {
746
+ sendForbidden(res);
747
+ return true;
748
+ }
710
749
  if (subPath.startsWith('/cache')) {
711
750
  if (!client.cache) {
712
751
  sendError(res, 'Cache store not configured', 404);
@@ -822,7 +861,7 @@ function getCurrentDir() {
822
861
  function getClientDir() {
823
862
  if (clientDir) return clientDir;
824
863
  const currentDir = getCurrentDir();
825
- clientDir = join(currentDir, '..', 'dist', 'client');
864
+ clientDir = resolve(currentDir, '..', 'dist', 'client');
826
865
  return clientDir;
827
866
  }
828
867
  function getIndexHtml() {
@@ -848,7 +887,12 @@ function getIndexHtml() {
848
887
  function serveStatic(res, pathname) {
849
888
  const dir = getClientDir();
850
889
  if (pathname !== '/' && pathname !== '/index.html') {
851
- const filePath = join(dir, pathname);
890
+ const filePath = resolve(join(dir, pathname));
891
+ if (!filePath.startsWith(dir + '/')) {
892
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
893
+ res.end('Bad request');
894
+ return true;
895
+ }
852
896
  if (existsSync(filePath)) {
853
897
  try {
854
898
  const content = readFileSync(filePath);
@@ -898,6 +942,7 @@ function createDashboard(options) {
898
942
  const ctx = {
899
943
  clients,
900
944
  pollIntervalMs: opts.pollIntervalMs,
945
+ readonly: opts.readonly,
901
946
  };
902
947
  const apiRouter = createApiRouter(ctx);
903
948
  return (req, res, _next) => {
@@ -947,7 +992,7 @@ function getCurrentDir2() {
947
992
  function getClientDir2() {
948
993
  if (clientDir2) return clientDir2;
949
994
  const currentDir = getCurrentDir2();
950
- clientDir2 = join(currentDir, '..', 'dist', 'client');
995
+ clientDir2 = resolve(currentDir, '..', 'dist', 'client');
951
996
  return clientDir2;
952
997
  }
953
998
  function getIndexHtml2() {
@@ -973,7 +1018,10 @@ function getIndexHtml2() {
973
1018
  function serveStaticWeb(pathname) {
974
1019
  const dir = getClientDir2();
975
1020
  if (pathname !== '/' && pathname !== '/index.html') {
976
- const filePath = join(dir, pathname);
1021
+ const filePath = resolve(join(dir, pathname));
1022
+ if (!filePath.startsWith(dir + '/')) {
1023
+ return new Response('Bad request', { status: 400 });
1024
+ }
977
1025
  if (existsSync(filePath)) {
978
1026
  try {
979
1027
  const content = readFileSync(filePath);
@@ -1046,14 +1094,34 @@ async function routeApi(request, pathname, query, ctx) {
1046
1094
  if (!client) {
1047
1095
  return errorResponse(`Unknown client: ${clientName}`, 404);
1048
1096
  }
1049
- return routeClientApi2(request, subPath, method, client, query);
1097
+ return routeClientApi2(
1098
+ request,
1099
+ subPath,
1100
+ method,
1101
+ client,
1102
+ query,
1103
+ ctx.readonly,
1104
+ );
1050
1105
  }
1051
1106
  if (pathname.startsWith('/api/')) {
1052
1107
  return errorResponse('Not found', 404);
1053
1108
  }
1054
1109
  return null;
1055
1110
  }
1056
- async function routeClientApi2(request, subPath, method, client, query) {
1111
+ function isMutatingMethod2(method) {
1112
+ return method === 'DELETE' || method === 'PUT' || method === 'POST';
1113
+ }
1114
+ async function routeClientApi2(
1115
+ request,
1116
+ subPath,
1117
+ method,
1118
+ client,
1119
+ query,
1120
+ readonly,
1121
+ ) {
1122
+ if (readonly && isMutatingMethod2(method)) {
1123
+ return errorResponse('Dashboard is in readonly mode', 403);
1124
+ }
1057
1125
  if (subPath.startsWith('/cache')) {
1058
1126
  if (!client.cache) return errorResponse('Cache store not configured', 404);
1059
1127
  return routeCache(subPath, method, client.cache, query);
@@ -1197,12 +1265,18 @@ function createDashboardHandler(options) {
1197
1265
  const ctx = {
1198
1266
  clients,
1199
1267
  pollIntervalMs: opts.pollIntervalMs,
1268
+ readonly: opts.readonly,
1200
1269
  };
1201
1270
  return async (request) => {
1202
1271
  try {
1203
1272
  const url = new URL(request.url);
1204
1273
  let pathname = url.pathname;
1205
- if (opts.basePath !== '/' && pathname.startsWith(opts.basePath)) {
1274
+ if (
1275
+ opts.basePath !== '/' &&
1276
+ pathname.startsWith(opts.basePath) &&
1277
+ (pathname.length === opts.basePath.length ||
1278
+ pathname[opts.basePath.length] === '/')
1279
+ ) {
1206
1280
  pathname = pathname.slice(opts.basePath.length) || '/';
1207
1281
  }
1208
1282
  const apiResponse = await routeApi(
@@ -1224,14 +1298,14 @@ async function startDashboard(options) {
1224
1298
  const server = createServer((req, res) => {
1225
1299
  middleware(req, res);
1226
1300
  });
1227
- return new Promise((resolve, reject) => {
1301
+ return new Promise((resolve3, reject) => {
1228
1302
  server.on('error', reject);
1229
1303
  server.listen(opts.port, opts.host, () => {
1230
1304
  const addr = server.address();
1231
1305
  const url =
1232
1306
  typeof addr === 'string' ? addr : `http://${opts.host}:${opts.port}`;
1233
1307
  console.log(`Dashboard running at ${url}`);
1234
- resolve({
1308
+ resolve3({
1235
1309
  server,
1236
1310
  async close() {
1237
1311
  return new Promise((res, rej) => {