adonisjs-server-stats 1.13.0 → 1.14.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.
Files changed (150) hide show
  1. package/README.md +3 -2
  2. package/dist/configure.js +6 -1
  3. package/dist/core/collectors/collector.d.ts +83 -0
  4. package/dist/core/core/types.d.ts +186 -0
  5. package/dist/core/debug/types.d.ts +467 -0
  6. package/dist/core/index.js +184 -169
  7. package/dist/core/types.d.ts +855 -167
  8. package/dist/react/{CacheSection-CX2duJuc.js → CacheSection-DkLtukWz.js} +1 -1
  9. package/dist/react/{CacheTab-CgT4t0oZ.js → CacheTab-DWeXnEBI.js} +51 -46
  10. package/dist/react/ConfigContent-Bwvfl_G7.js +350 -0
  11. package/dist/react/{ConfigSection-Dt0FyeaW.js → ConfigSection-DaVbFPDg.js} +2 -2
  12. package/dist/react/{ConfigTab-KtiTtsrj.js → ConfigTab-D4640WJZ.js} +2 -2
  13. package/dist/react/{CustomPaneTab-15QoEplP.js → CustomPaneTab-DglmAVMC.js} +1 -1
  14. package/dist/react/{EmailsSection-YcJYR5eA.js → EmailsSection-vYx6ExTb.js} +1 -1
  15. package/dist/react/{EmailsTab-CUjubln_.js → EmailsTab-DKDrRmRH.js} +54 -49
  16. package/dist/react/{EventsSection-GGPoYSNv.js → EventsSection-CapH5xut.js} +1 -1
  17. package/dist/react/{EventsTab-Wdwr0_ny.js → EventsTab-BNVEySAm.js} +1 -1
  18. package/dist/react/{InternalsContent-BNOnSoi9.js → InternalsContent-C9lIA92C.js} +1 -1
  19. package/dist/react/{InternalsSection-BwrTfpjA.js → InternalsSection-lOv_UcSV.js} +1 -1
  20. package/dist/react/{InternalsTab--RD-L1dX.js → InternalsTab-BygoSorC.js} +1 -1
  21. package/dist/react/{JobsSection-BqqIh_DR.js → JobsSection-ZIQsJWid.js} +1 -1
  22. package/dist/react/{JobsTab-CBrU-ryL.js → JobsTab-DXQzXrDt.js} +1 -1
  23. package/dist/react/{LogEntryRow-BOrRkhRU.js → LogEntryRow-BWkHE51-.js} +1 -1
  24. package/dist/react/{LogsSection-Cm_lphM6.js → LogsSection-ConXdBkL.js} +2 -2
  25. package/dist/react/LogsTab-CJM47LPn.js +82 -0
  26. package/dist/react/{OverviewSection-XF7bakyM.js → OverviewSection-DOMu2qvl.js} +131 -131
  27. package/dist/react/{QueriesSection-DsQBKrNK.js → QueriesSection-DFsaOSJI.js} +1 -1
  28. package/dist/react/{QueriesTab-CxCC1GVq.js → QueriesTab-CY9CG_7L.js} +1 -1
  29. package/dist/react/RequestsSection-CC-eVHsl.js +326 -0
  30. package/dist/react/{RoutesSection-D0y5JQP4.js → RoutesSection-p1DMq41y.js} +1 -1
  31. package/dist/react/{RoutesTab-Y_alJVMV.js → RoutesTab-Cnqy8UcK.js} +1 -1
  32. package/dist/react/{SplitPaneWrapper-CZl1ouIT.js → SplitPaneWrapper-XgkA0QxE.js} +1 -1
  33. package/dist/react/{TimelineTab-HyqZpfbp.js → TimelineTab-1YOERxe5.js} +3 -3
  34. package/dist/react/index-DOSlCpZ9.js +1159 -0
  35. package/dist/react/index.js +1 -1
  36. package/dist/react/useApiClient-CtEG7rHs.js +11 -0
  37. package/dist/src/collectors/queue_collector.js +22 -6
  38. package/dist/src/config/deprecation_migration.js +11 -0
  39. package/dist/src/dashboard/cache_handlers.js +22 -2
  40. package/dist/src/dashboard/flush_manager.d.ts +2 -0
  41. package/dist/src/dashboard/flush_manager.js +17 -0
  42. package/dist/src/dashboard/integrations/config_inspector.d.ts +0 -1
  43. package/dist/src/dashboard/integrations/config_inspector.js +4 -2
  44. package/dist/src/dashboard/paginate_helper.d.ts +5 -0
  45. package/dist/src/dashboard/paginate_helper.js +16 -4
  46. package/dist/src/dashboard/query_explain_handler.d.ts +6 -1
  47. package/dist/src/dashboard/query_explain_handler.js +12 -2
  48. package/dist/src/dashboard/write_queue.d.ts +13 -0
  49. package/dist/src/dashboard/write_queue.js +34 -2
  50. package/dist/src/debug/debug_store.js +8 -1
  51. package/dist/src/debug/event_collector.js +4 -0
  52. package/dist/src/debug/query_collector.js +2 -0
  53. package/dist/src/debug/ring_buffer.js +8 -1
  54. package/dist/src/debug/trace_collector.js +34 -10
  55. package/dist/src/debug/types.d.ts +2 -0
  56. package/dist/src/define_config.js +1 -0
  57. package/dist/src/edge/client/dashboard.js +2 -2
  58. package/dist/src/edge/client/debug-panel-deferred.js +1 -1
  59. package/dist/src/edge/client/stats-bar.js +1 -1
  60. package/dist/src/edge/client-vue/dashboard.js +5 -5
  61. package/dist/src/edge/client-vue/debug-panel-deferred.js +2 -2
  62. package/dist/src/engine/request_metrics.js +8 -10
  63. package/dist/src/log_stream/log_stream_service.d.ts +1 -0
  64. package/dist/src/log_stream/log_stream_service.js +7 -5
  65. package/dist/src/prometheus/prometheus_collector.d.ts +9 -0
  66. package/dist/src/prometheus/prometheus_collector.js +11 -0
  67. package/dist/src/provider/dashboard_init.js +15 -0
  68. package/dist/src/provider/dashboard_setup.js +9 -2
  69. package/dist/src/provider/email_bridge.js +6 -2
  70. package/dist/src/provider/email_helpers.d.ts +9 -1
  71. package/dist/src/provider/email_helpers.js +32 -4
  72. package/dist/src/provider/pino_hook.d.ts +8 -0
  73. package/dist/src/provider/pino_hook.js +19 -1
  74. package/dist/src/provider/provider_helpers_extra.d.ts +2 -0
  75. package/dist/src/provider/provider_helpers_extra.js +17 -2
  76. package/dist/src/provider/server_stats_provider.js +19 -1
  77. package/dist/src/provider/shutdown_helpers.js +2 -0
  78. package/dist/src/provider/toolbar_setup.js +17 -14
  79. package/dist/src/routes/dashboard_routes.js +96 -62
  80. package/dist/src/routes/debug_routes.js +45 -35
  81. package/dist/src/routes/no_session_middleware.d.ts +6 -3
  82. package/dist/src/routes/no_session_middleware.js +7 -3
  83. package/dist/src/routes/register_routes.d.ts +8 -0
  84. package/dist/src/routes/register_routes.js +19 -0
  85. package/dist/src/types.d.ts +31 -0
  86. package/dist/src/utils/app_import.js +12 -4
  87. package/dist/vue/CacheSection-CxEBVVkF.js +156 -0
  88. package/dist/vue/ConfigSection-BiRAiaHj.js +415 -0
  89. package/dist/vue/EmailsSection-Dl44qyqY.js +208 -0
  90. package/dist/vue/{EmailsTab-CwIF1fik.js → EmailsTab-Diya54CP.js} +6 -5
  91. package/dist/vue/{EventsSection-BpgkWIwM.js → EventsSection-CWjeitjU.js} +1 -1
  92. package/dist/vue/{InternalsTab-521fxYYj.js → InternalsTab-Z3c82glB.js} +15 -15
  93. package/dist/vue/{JobsSection-DrghFEKL.js → JobsSection-DOBb4LjZ.js} +1 -1
  94. package/dist/vue/LogsSection-CXx-HOWJ.js +260 -0
  95. package/dist/vue/{OverviewSection-BAgZTPjY.js → OverviewSection-CyfNQ8uV.js} +318 -302
  96. package/dist/vue/{QueriesSection-CUpwhp7u.js → QueriesSection-CXBsFp-y.js} +1 -1
  97. package/dist/vue/{RequestsSection-D8P2xpF2.js → RequestsSection-YTIaZGZd.js} +151 -145
  98. package/dist/vue/{RoutesSection-0qB81hTT.js → RoutesSection-CDKMey49.js} +1 -1
  99. package/dist/vue/composables/useApiClient.d.ts +8 -1
  100. package/dist/vue/index-14x39RI_.js +1239 -0
  101. package/dist/vue/index.js +1 -1
  102. package/dist/vue/style.css +1 -1
  103. package/package.json +13 -9
  104. package/dist/react/ConfigContent-CnsEI4j3.js +0 -397
  105. package/dist/react/LogsTab-jKwv9G7Q.js +0 -79
  106. package/dist/react/RequestsSection-D8cMbZU0.js +0 -321
  107. package/dist/react/index-CsprmgzI.js +0 -1121
  108. package/dist/react/useApiClient-BVtNCmnL.js +0 -9
  109. package/dist/vue/CacheSection-DRqV3YX2.js +0 -149
  110. package/dist/vue/ConfigSection-C6pQCHAL.js +0 -576
  111. package/dist/vue/EmailsSection-BTNw3ZU2.js +0 -206
  112. package/dist/vue/LogsSection-BDxx9Bfi.js +0 -258
  113. package/dist/vue/index-30jLw-_w.js +0 -1232
  114. /package/dist/core/{api-client.d.ts → core/api-client.d.ts} +0 -0
  115. /package/dist/core/{config-utils.d.ts → core/config-utils.d.ts} +0 -0
  116. /package/dist/core/{constants.d.ts → core/constants.d.ts} +0 -0
  117. /package/dist/core/{dashboard-api.d.ts → core/dashboard-api.d.ts} +0 -0
  118. /package/dist/core/{dashboard-data-controller.d.ts → core/dashboard-data-controller.d.ts} +0 -0
  119. /package/dist/core/{dashboard-data-helpers.d.ts → core/dashboard-data-helpers.d.ts} +0 -0
  120. /package/dist/core/{debug-data-controller.d.ts → core/debug-data-controller.d.ts} +0 -0
  121. /package/dist/core/{define-config-helpers.d.ts → core/define-config-helpers.d.ts} +0 -0
  122. /package/dist/core/{explain-utils.d.ts → core/explain-utils.d.ts} +0 -0
  123. /package/dist/core/{feature-detect-helpers.d.ts → core/feature-detect-helpers.d.ts} +0 -0
  124. /package/dist/core/{feature-detect.d.ts → core/feature-detect.d.ts} +0 -0
  125. /package/dist/core/{field-resolvers.d.ts → core/field-resolvers.d.ts} +0 -0
  126. /package/dist/core/{formatters-helpers.d.ts → core/formatters-helpers.d.ts} +0 -0
  127. /package/dist/core/{formatters.d.ts → core/formatters.d.ts} +0 -0
  128. /package/dist/core/{history-buffer.d.ts → core/history-buffer.d.ts} +0 -0
  129. /package/dist/core/{icons.d.ts → core/icons.d.ts} +0 -0
  130. /package/dist/core/{index.d.ts → core/index.d.ts} +0 -0
  131. /package/dist/core/{internals-utils.d.ts → core/internals-utils.d.ts} +0 -0
  132. /package/dist/core/{job-utils.d.ts → core/job-utils.d.ts} +0 -0
  133. /package/dist/core/{log-utils-helpers.d.ts → core/log-utils-helpers.d.ts} +0 -0
  134. /package/dist/core/{log-utils.d.ts → core/log-utils.d.ts} +0 -0
  135. /package/dist/core/{metrics.d.ts → core/metrics.d.ts} +0 -0
  136. /package/dist/core/{pagination.d.ts → core/pagination.d.ts} +0 -0
  137. /package/dist/core/{queries-columns.d.ts → core/queries-columns.d.ts} +0 -0
  138. /package/dist/core/{queries-controller.d.ts → core/queries-controller.d.ts} +0 -0
  139. /package/dist/core/{query-utils.d.ts → core/query-utils.d.ts} +0 -0
  140. /package/dist/core/{resizable-columns.d.ts → core/resizable-columns.d.ts} +0 -0
  141. /package/dist/core/{routes.d.ts → core/routes.d.ts} +0 -0
  142. /package/dist/core/{server-stats-controller.d.ts → core/server-stats-controller.d.ts} +0 -0
  143. /package/dist/core/{sparkline.d.ts → core/sparkline.d.ts} +0 -0
  144. /package/dist/core/{split-pane.d.ts → core/split-pane.d.ts} +0 -0
  145. /package/dist/core/{theme.d.ts → core/theme.d.ts} +0 -0
  146. /package/dist/core/{trace-utils.d.ts → core/trace-utils.d.ts} +0 -0
  147. /package/dist/core/{transmit-adapter.d.ts → core/transmit-adapter.d.ts} +0 -0
  148. /package/dist/core/{transmit-helpers.d.ts → core/transmit-helpers.d.ts} +0 -0
  149. /package/dist/core/{types-dashboard.d.ts → core/types-dashboard.d.ts} +0 -0
  150. /package/dist/core/{types-diagnostics.d.ts → core/types-diagnostics.d.ts} +0 -0
@@ -1,4 +1,4 @@
1
- import { B as s, D as t, c as r, J as u, M as g, d as o, d, S as D, T as S, e as h, a as B, u as T, f as b, g as l, h as m } from "./index-CsprmgzI.js";
1
+ import { B as s, D as t, c as r, J as u, M as g, d as o, d, S as D, T as S, e as h, a as B, u as T, f as b, g as l, h as m } from "./index-DOSlCpZ9.js";
2
2
  export {
3
3
  s as Badge,
4
4
  t as DashboardPage,
@@ -0,0 +1,11 @@
1
+ import { useRef as t, useEffect as u, useCallback as c } from "react";
2
+ import { ApiClient as i } from "adonisjs-server-stats/core";
3
+ function o(e = "", n) {
4
+ const r = t(null);
5
+ return u(() => {
6
+ r.current = null;
7
+ }, [e, n]), c(() => (r.current || (r.current = new i({ baseUrl: e, authToken: n })), r.current), [e, n]);
8
+ }
9
+ export {
10
+ o as u
11
+ };
@@ -7,12 +7,9 @@ const QUEUE_DEFAULTS = {
7
7
  queueFailed: 0,
8
8
  queueWorkerCount: 0,
9
9
  };
10
- /** Fetch job counts from a BullMQ queue. */
11
- async function fetchQueueCounts(queueName, connection) {
12
- const { Queue } = await import('bullmq');
13
- const queue = new Queue(queueName, { connection });
10
+ /** Fetch job counts from a long-lived BullMQ queue instance. */
11
+ async function fetchQueueCounts(queue) {
14
12
  const [counts, workers] = await Promise.all([queue.getJobCounts(), queue.getWorkers()]);
15
- await queue.close();
16
13
  return {
17
14
  queueActive: counts.active ?? 0,
18
15
  queueWaiting: counts.waiting ?? 0,
@@ -62,9 +59,28 @@ export function queueCollector(opts) {
62
59
  connectionError: false,
63
60
  missingConnection: false,
64
61
  };
62
+ // The BullMQ Queue owns a Redis connection. Create it once and reuse it
63
+ // across every poll tick so a rejected getJobCounts()/getWorkers() can never
64
+ // leak a connection (the previous per-tick `new Queue(...)` leaked one on
65
+ // every failure because close() was skipped). The queue is created lazily on
66
+ // first collect so a missing `bullmq` peer dependency degrades gracefully.
67
+ let queue = null;
68
+ async function getQueue(connection) {
69
+ if (queue)
70
+ return queue;
71
+ const { Queue } = await import('bullmq');
72
+ queue = new Queue(queueName, { connection });
73
+ return queue;
74
+ }
65
75
  return {
66
76
  name: 'queue',
67
77
  label: `queue — ${queueName} @ ${opts.connection?.host ?? '?'}:${opts.connection?.port ?? '?'}`,
78
+ async stop() {
79
+ if (queue) {
80
+ await queue.close().catch(() => { });
81
+ queue = null;
82
+ }
83
+ },
68
84
  getConfig() {
69
85
  return {
70
86
  queueName,
@@ -84,7 +100,7 @@ export function queueCollector(opts) {
84
100
  return QUEUE_DEFAULTS;
85
101
  }
86
102
  try {
87
- return await fetchQueueCounts(queueName, opts.connection);
103
+ return await fetchQueueCounts(await getQueue(opts.connection));
88
104
  }
89
105
  catch (error) {
90
106
  handleQueueError(error, queueName, opts.connection, warned);
@@ -39,6 +39,10 @@ const SIMPLE_DEPRECATIONS = [
39
39
  newName: 'authorize',
40
40
  before: () => ['shouldShow: (ctx) => ...'],
41
41
  after: () => ['authorize: (ctx) => ...'],
42
+ note: () => [
43
+ 'Your `shouldShow` guard still fully protects the dashboard — nothing breaks.',
44
+ '`authorize` is just the new name for the same access check.',
45
+ ],
42
46
  },
43
47
  {
44
48
  key: 'channelName',
@@ -171,6 +175,7 @@ export function logDeprecationWarnings(config) {
171
175
  new: dep.newName,
172
176
  before: dep.before(config),
173
177
  after: dep.after(config),
178
+ note: dep.note?.(config),
174
179
  });
175
180
  }
176
181
  }
@@ -192,6 +197,12 @@ export function logDeprecationWarnings(config) {
192
197
  for (const line of entry.after) {
193
198
  lines.push(` ${dim('after:')} ${line}`);
194
199
  }
200
+ if (entry.note) {
201
+ lines.push('');
202
+ for (const line of entry.note) {
203
+ lines.push(` ${dim(line)}`);
204
+ }
205
+ }
195
206
  lines.push('');
196
207
  }
197
208
  lines.push(` ${dim('No rush — the old names still work. They will be removed in the next major version.')}`);
@@ -1,4 +1,18 @@
1
1
  import { clamp } from '../utils/math_helpers.js';
2
+ /**
3
+ * Optional allow-list prefix for single-key cache operations (get/delete).
4
+ * Restricts which Redis keys the dashboard may read or delete so an attacker
5
+ * can't target arbitrary keys (sessions, rate-limit counters). Empty/unset
6
+ * means unrestricted (back-compat default for local dev).
7
+ */
8
+ function getCacheKeyPrefix() {
9
+ return (process.env.SERVER_STATS_CACHE_KEY_PREFIX ?? '').trim();
10
+ }
11
+ /** Check whether a cache key is permitted under the configured prefix. */
12
+ function isKeyAllowed(key) {
13
+ const prefix = getCacheKeyPrefix();
14
+ return prefix === '' || key.startsWith(prefix);
15
+ }
2
16
  /**
3
17
  * Handle GET /cache-stats — list cache keys and overall stats.
4
18
  */
@@ -26,8 +40,11 @@ export async function handleCacheKey(inspectors, { params, response }) {
26
40
  const inspector = await inspectors.getCacheInspector();
27
41
  if (!inspector)
28
42
  return response.notFound({ error: 'Cache not available' });
43
+ const key = decodeURIComponent(params.key);
44
+ if (!isKeyAllowed(key))
45
+ return response.forbidden({ error: 'Cache key not permitted' });
29
46
  try {
30
- const detail = await inspector.getKey(decodeURIComponent(params.key));
47
+ const detail = await inspector.getKey(key);
31
48
  return detail ? response.json(detail) : response.notFound({ error: 'Key not found' });
32
49
  }
33
50
  catch {
@@ -41,8 +58,11 @@ export async function handleCacheKeyDelete(inspectors, { params, response }) {
41
58
  const inspector = await inspectors.getCacheInspector();
42
59
  if (!inspector)
43
60
  return response.notFound({ error: 'Cache not available' });
61
+ const key = decodeURIComponent(params.key);
62
+ if (!isKeyAllowed(key))
63
+ return response.forbidden({ error: 'Cache key not permitted' });
44
64
  try {
45
- return (await inspector.deleteKey(decodeURIComponent(params.key)))
65
+ return (await inspector.deleteKey(key))
46
66
  ? response.json({ deleted: true })
47
67
  : response.notFound({ error: 'Key not found' });
48
68
  }
@@ -11,6 +11,7 @@ export declare class FlushManager {
11
11
  pendingEmails: EmailRecord[];
12
12
  private flushTimer;
13
13
  private flushing;
14
+ private inFlight;
14
15
  private db;
15
16
  constructor(getDb: () => Knex | null);
16
17
  persistRequest(input: PersistRequestInput, dashboardPath: string): void;
@@ -21,5 +22,6 @@ export declare class FlushManager {
21
22
  private backpressure;
22
23
  private scheduleFlush;
23
24
  flush(): Promise<void>;
25
+ private runFlush;
24
26
  private takeSnapshot;
25
27
  }
@@ -9,6 +9,7 @@ export class FlushManager {
9
9
  pendingEmails = [];
10
10
  flushTimer = null;
11
11
  flushing = false;
12
+ inFlight = null;
12
13
  db;
13
14
  constructor(getDb) {
14
15
  this.db = getDb;
@@ -43,6 +44,10 @@ export class FlushManager {
43
44
  clearTimeout(this.flushTimer);
44
45
  this.flushTimer = null;
45
46
  }
47
+ // Await any in-flight (timer-triggered) flush first, otherwise the final
48
+ // flush below early-returns and queued data is lost at shutdown.
49
+ if (this.inFlight)
50
+ await this.inFlight.catch(() => { });
46
51
  await this.flush().catch(() => { });
47
52
  }
48
53
  backpressure(q) {
@@ -67,6 +72,18 @@ export class FlushManager {
67
72
  if (this.flushing || !db)
68
73
  return;
69
74
  this.flushing = true;
75
+ // Track the in-flight flush so stop() can await it before issuing a final
76
+ // flush, instead of racing the flushing guard and dropping queued data.
77
+ const run = this.runFlush(db);
78
+ this.inFlight = run;
79
+ try {
80
+ await run;
81
+ }
82
+ finally {
83
+ this.inFlight = null;
84
+ }
85
+ }
86
+ async runFlush(db) {
70
87
  const snap = this.takeSnapshot();
71
88
  if (!snap) {
72
89
  this.flushing = false;
@@ -2,7 +2,6 @@ import type { ApplicationService } from '@adonisjs/core/types';
2
2
  export interface RedactedValue {
3
3
  __redacted: true;
4
4
  display: string;
5
- value: string;
6
5
  }
7
6
  export interface SanitizedConfig {
8
7
  /** App configuration from `app.config.all()` with secrets redacted. */
@@ -44,8 +44,10 @@ const SENSITIVE_PATTERNS = [
44
44
  /app[_-]key/i,
45
45
  ];
46
46
  const REDACTED_DISPLAY = '••••••••';
47
- function redact(value) {
48
- return { __redacted: true, display: REDACTED_DISPLAY, value };
47
+ function redact(_value) {
48
+ // Never include the plaintext value: the redacted object is serialized
49
+ // to the browser, so the secret must not leave the server.
50
+ return { __redacted: true, display: REDACTED_DISPLAY };
49
51
  }
50
52
  // ---------------------------------------------------------------------------
51
53
  // ConfigInspector
@@ -6,6 +6,11 @@
6
6
  */
7
7
  import type { PaginatedResult, PaginateOptions } from './dashboard_types.js';
8
8
  import type { Knex } from 'knex';
9
+ /**
10
+ * Clamp a raw perPage value to a safe range (1..200) to prevent DoS via
11
+ * unbounded result sets on the single sqlite connection.
12
+ */
13
+ export declare function clampPerPage(value: number): number;
9
14
  /**
10
15
  * Execute a paginated query within a transaction.
11
16
  */
@@ -4,20 +4,32 @@
4
4
  * Wraps COUNT + SELECT in a single transaction so the pool connection
5
5
  * is acquired only once instead of two separate acquire/release cycles.
6
6
  */
7
+ import { clamp } from '../utils/math_helpers.js';
8
+ /**
9
+ * Clamp a raw perPage value to a safe range (1..200) to prevent DoS via
10
+ * unbounded result sets on the single sqlite connection.
11
+ */
12
+ export function clampPerPage(value) {
13
+ const n = Number(value);
14
+ if (!Number.isFinite(n))
15
+ return 25;
16
+ return clamp(Math.trunc(n), 1, 200);
17
+ }
7
18
  /**
8
19
  * Execute a paginated query within a transaction.
9
20
  */
10
21
  export async function executePaginate(db, opts) {
22
+ const perPage = clampPerPage(opts.perPage);
11
23
  return db.transaction(async (trx) => {
12
24
  const countQuery = trx(opts.table);
13
25
  if (opts.applyFilters)
14
26
  opts.applyFilters(countQuery);
15
27
  const [{ count: totalRaw }] = await countQuery.count('* as count');
16
28
  const total = Number(totalRaw);
17
- const offset = (opts.page - 1) * opts.perPage;
29
+ const offset = (opts.page - 1) * perPage;
18
30
  const dataQuery = trx(opts.table)
19
31
  .orderBy('created_at', 'desc')
20
- .limit(opts.perPage)
32
+ .limit(perPage)
21
33
  .offset(offset);
22
34
  if (opts.applyFilters)
23
35
  opts.applyFilters(dataQuery);
@@ -26,8 +38,8 @@ export async function executePaginate(db, opts) {
26
38
  data,
27
39
  total,
28
40
  page: opts.page,
29
- perPage: opts.perPage,
30
- lastPage: Math.ceil(total / opts.perPage),
41
+ perPage,
42
+ lastPage: Math.ceil(total / perPage),
31
43
  };
32
44
  });
33
45
  }
@@ -1,12 +1,17 @@
1
1
  import type { DashboardStore } from './dashboard_store.js';
2
2
  import type { HttpContext } from '@adonisjs/core/http';
3
3
  import type { ApplicationService } from '@adonisjs/core/types';
4
+ /**
5
+ * Reject SQL containing statement separators / comment sequences that could
6
+ * enable stacked statements or comment-based injection into the EXPLAIN prefix.
7
+ */
8
+ export declare function hasUnsafeSqlTokens(sqlText: string): boolean;
4
9
  export type DbDialect = 'pg' | 'sqlite' | 'mysql' | 'mssql' | 'unknown';
5
10
  export interface AppDbClient {
6
11
  raw: (sql: string, bindings: unknown[]) => Promise<unknown>;
7
12
  dialect: DbDialect;
8
13
  }
9
- /** Get the Lucid write client for running EXPLAIN. */
14
+ /** Get the Lucid read client for running EXPLAIN. */
10
15
  export declare function getAppDbClient(app: ApplicationService, connectionName?: string): Promise<AppDbClient | null>;
11
16
  /** Build the EXPLAIN SQL for the detected dialect. */
12
17
  export declare function buildExplainSql(sql: string, dialect: DbDialect): string;
@@ -12,6 +12,13 @@ async function fetchQueryRecord(dashboardStore, id) {
12
12
  function isSelectQuery(sqlText) {
13
13
  return sqlText.trim().toUpperCase().startsWith('SELECT');
14
14
  }
15
+ /**
16
+ * Reject SQL containing statement separators / comment sequences that could
17
+ * enable stacked statements or comment-based injection into the EXPLAIN prefix.
18
+ */
19
+ export function hasUnsafeSqlTokens(sqlText) {
20
+ return /;|--|\/\*/.test(sqlText);
21
+ }
15
22
  /** Parse bindings from a stored query. */
16
23
  function parseBindings(raw) {
17
24
  if (!raw)
@@ -39,14 +46,14 @@ function detectDialect(client) {
39
46
  return 'mssql';
40
47
  return 'unknown';
41
48
  }
42
- /** Get the Lucid write client for running EXPLAIN. */
49
+ /** Get the Lucid read client for running EXPLAIN. */
43
50
  export async function getAppDbClient(app, connectionName) {
44
51
  try {
45
52
  const lucid = await app.container.make('lucid.db');
46
53
  const conn = connectionName
47
54
  ? lucid.connection(connectionName)
48
55
  : lucid.connection();
49
- const client = conn.getWriteClient();
56
+ const client = conn.getReadClient();
50
57
  const dialect = detectDialect(client);
51
58
  return {
52
59
  raw: client.raw.bind(client),
@@ -111,6 +118,9 @@ export async function handleQueryExplain(dashboardStore, app, ctx) {
111
118
  if (!isSelectQuery(query.sql_text)) {
112
119
  return response.badRequest({ error: 'EXPLAIN is only supported for SELECT queries' });
113
120
  }
121
+ if (hasUnsafeSqlTokens(query.sql_text)) {
122
+ return response.badRequest({ error: 'EXPLAIN rejected: query contains disallowed tokens' });
123
+ }
114
124
  const appDb = await getAppDbClient(app, query.connection || undefined);
115
125
  if (!appDb) {
116
126
  return response.serviceUnavailable({ error: 'App database connection not available' });
@@ -13,8 +13,21 @@ export declare function markWarned(path: string): void;
13
13
  /**
14
14
  * Normalize a SQL query by replacing literal values with `?` placeholders.
15
15
  * Used for grouping identical query patterns.
16
+ *
17
+ * Numeric replacement is restricted to value contexts (a digit run preceded
18
+ * by whitespace, a comma, a paren, or an operator) so identifiers that contain
19
+ * digits — e.g. `orders_2024` — are left intact and distinct queries are not
20
+ * merged together.
16
21
  */
17
22
  export declare function normalizeSql(sql: string): string;
23
+ /**
24
+ * Redact/truncate SQL bindings before persistence so secret-looking values
25
+ * (long tokens, hashes, keys) are not stored in cleartext.
26
+ *
27
+ * Conservative: only long strings are truncated; short values (ids, flags,
28
+ * emails, ordinary params) pass through so normal capture is unaffected.
29
+ */
30
+ export declare function sanitizeBindings(bindings: unknown): unknown;
18
31
  export interface PreparedQuery {
19
32
  sql_text: string;
20
33
  sql_normalized: string;
@@ -22,15 +22,47 @@ export function markWarned(path) {
22
22
  /**
23
23
  * Normalize a SQL query by replacing literal values with `?` placeholders.
24
24
  * Used for grouping identical query patterns.
25
+ *
26
+ * Numeric replacement is restricted to value contexts (a digit run preceded
27
+ * by whitespace, a comma, a paren, or an operator) so identifiers that contain
28
+ * digits — e.g. `orders_2024` — are left intact and distinct queries are not
29
+ * merged together.
25
30
  */
26
31
  export function normalizeSql(sql) {
27
32
  return sql
28
33
  .replace(/'[^']*'/g, '?')
29
- .replace(/\b\d+(\.\d+)?\b/g, '?')
34
+ .replace(/(^|[\s,(=<>+\-*/])\d+(\.\d+)?\b/g, '$1?')
30
35
  .replace(/\s+/g, ' ')
31
36
  .trim();
32
37
  }
33
38
  // ---------------------------------------------------------------------------
39
+ // Binding hygiene
40
+ // ---------------------------------------------------------------------------
41
+ /** Max length for a persisted string binding before it is truncated. */
42
+ const MAX_BINDING_LEN = 256;
43
+ /**
44
+ * Redact/truncate SQL bindings before persistence so secret-looking values
45
+ * (long tokens, hashes, keys) are not stored in cleartext.
46
+ *
47
+ * Conservative: only long strings are truncated; short values (ids, flags,
48
+ * emails, ordinary params) pass through so normal capture is unaffected.
49
+ */
50
+ export function sanitizeBindings(bindings) {
51
+ if (Array.isArray(bindings))
52
+ return bindings.map(sanitizeBindings);
53
+ if (typeof bindings === 'string' && bindings.length > MAX_BINDING_LEN) {
54
+ return bindings.slice(0, MAX_BINDING_LEN) + `…[truncated ${bindings.length} chars]`;
55
+ }
56
+ if (bindings && typeof bindings === 'object') {
57
+ const out = {};
58
+ for (const [k, v] of Object.entries(bindings)) {
59
+ out[k] = sanitizeBindings(v);
60
+ }
61
+ return out;
62
+ }
63
+ return bindings;
64
+ }
65
+ // ---------------------------------------------------------------------------
34
66
  // Pure data-prep functions
35
67
  // ---------------------------------------------------------------------------
36
68
  /**
@@ -46,7 +78,7 @@ export function prepareRequestRows(requests) {
46
78
  .map((q) => ({
47
79
  sql_text: q.sql,
48
80
  sql_normalized: normalizeSql(q.sql),
49
- bindings: q.bindings ? JSON.stringify(q.bindings) : null,
81
+ bindings: q.bindings ? JSON.stringify(sanitizeBindings(q.bindings)) : null,
50
82
  duration: round(q.duration),
51
83
  method: q.method,
52
84
  model: q.model,
@@ -1,5 +1,6 @@
1
1
  import { writeFile, readFile, rename, mkdir } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
+ import { redactTokenLinks } from '../provider/email_helpers.js';
3
4
  import { log, bold } from '../utils/logger.js';
4
5
  import { EmailCollector } from './email_collector.js';
5
6
  import { EventCollector } from './event_collector.js';
@@ -103,7 +104,13 @@ export class DebugStore {
103
104
  const events = this.events.getEvents();
104
105
  parts.push(`"events":${JSON.stringify(events)},`);
105
106
  await new Promise((resolve) => setImmediate(resolve));
106
- const emails = this.emails.getEmails();
107
+ // Strip token-bearing links (reset/verify/magic URLs, ?token=…) from email
108
+ // bodies before they hit disk in cleartext.
109
+ const emails = this.emails.getEmails().map((email) => ({
110
+ ...email,
111
+ html: typeof email.html === 'string' ? redactTokenLinks(email.html) : email.html,
112
+ text: typeof email.text === 'string' ? redactTokenLinks(email.text) : email.text,
113
+ }));
107
114
  parts.push(`"emails":${JSON.stringify(emails)}`);
108
115
  if (this.traces) {
109
116
  await new Promise((resolve) => setImmediate(resolve));
@@ -87,6 +87,10 @@ export class EventCollector {
87
87
  start(emitter) {
88
88
  if (!emitter || typeof emitter.emit !== 'function')
89
89
  return;
90
+ // Idempotent: a second start() must not capture the already-patched emit as
91
+ // the "original", which would corrupt emitter.emit permanently.
92
+ if (this.originalEmit)
93
+ return;
90
94
  this.emitter = emitter;
91
95
  this.originalEmit = emitter.emit.bind(emitter);
92
96
  emitter.emit = (event, data) => {
@@ -122,6 +122,8 @@ export class QueryCollector {
122
122
  }
123
123
  clear() {
124
124
  this.buffer.clear();
125
+ // Invalidate the cached summary so totals aren't stale after a clear().
126
+ this.cachedSummary = null;
125
127
  }
126
128
  /** Register a callback that fires whenever a new query is recorded. */
127
129
  onNewItem(cb) {
@@ -23,7 +23,14 @@ export class RingBuffer {
23
23
  if (this.count < this.capacity) {
24
24
  this.count++;
25
25
  }
26
- this.pushCallback?.(item);
26
+ // The write itself is infallible; a throwing subscriber must not escape and
27
+ // corrupt callers that rely on push() never failing.
28
+ try {
29
+ this.pushCallback?.(item);
30
+ }
31
+ catch (err) {
32
+ console.error('[server-stats] ring buffer push callback threw:', err);
33
+ }
27
34
  }
28
35
  /** Returns all items in insertion order (oldest first). */
29
36
  toArray() {
@@ -6,6 +6,12 @@ import { RingBuffer } from './ring_buffer.js';
6
6
  * Module-level singleton reference for the `trace()` helper.
7
7
  */
8
8
  const globalRef = { current: null };
9
+ /**
10
+ * Tracks whether console.warn is currently patched by a TraceCollector, so a
11
+ * second instance (or a skipped stop()) cannot double-wrap it and leak the
12
+ * patch permanently.
13
+ */
14
+ const consoleWarnPatch = { active: false };
9
15
  /**
10
16
  * Wrap an async function in a traced span.
11
17
  *
@@ -101,9 +107,14 @@ export class TraceCollector {
101
107
  const parentId = ctx.currentSpanId;
102
108
  const spanId = String(ctx.nextSpanId++);
103
109
  ctx.currentSpanId = spanId;
110
+ let errored = false;
104
111
  try {
105
112
  return await fn();
106
113
  }
114
+ catch (err) {
115
+ errored = true;
116
+ throw err;
117
+ }
107
118
  finally {
108
119
  const duration = performance.now() - start;
109
120
  ctx.spans.push({
@@ -113,6 +124,7 @@ export class TraceCollector {
113
124
  category,
114
125
  startOffset: round(start - ctx.requestStart),
115
126
  duration: round(duration),
127
+ error: errored ? true : undefined,
116
128
  });
117
129
  ctx.currentSpanId = parentId;
118
130
  }
@@ -145,28 +157,40 @@ export class TraceCollector {
145
157
  };
146
158
  emitter.on('db:query', this.dbHandler);
147
159
  }
148
- // Intercept console.warn to capture warnings per-request
149
- this.originalConsoleWarn = console.warn;
150
- console.warn = (...args) => {
151
- const ctx = this.als.getStore();
152
- if (ctx) {
153
- ctx.warnings.push(args.map(String).join(' '));
154
- }
155
- this.originalConsoleWarn.apply(console, args);
156
- };
160
+ // Intercept console.warn to capture warnings per-request. Guard against
161
+ // double-wrapping: if another instance (or a prior start() without a
162
+ // matching stop()) already patched console.warn, don't wrap it again —
163
+ // that would capture the patched fn as the "original" and leak the patch.
164
+ if (!consoleWarnPatch.active) {
165
+ consoleWarnPatch.active = true;
166
+ this.originalConsoleWarn = console.warn;
167
+ console.warn = (...args) => {
168
+ const ctx = this.als.getStore();
169
+ if (ctx) {
170
+ ctx.warnings.push(args.map(String).join(' '));
171
+ }
172
+ this.originalConsoleWarn.apply(console, args);
173
+ };
174
+ }
157
175
  }
158
176
  /** Unhook event listeners and restore console.warn. */
159
177
  stop() {
160
178
  if (this.emitter && this.dbHandler) {
161
179
  this.emitter.off('db:query', this.dbHandler);
162
180
  }
181
+ // Only restore console.warn if this instance was the one that patched it.
163
182
  if (this.originalConsoleWarn) {
164
183
  console.warn = this.originalConsoleWarn;
184
+ consoleWarnPatch.active = false;
165
185
  }
166
186
  this.dbHandler = null;
167
187
  this.emitter = null;
168
188
  this.originalConsoleWarn = null;
169
- globalRef.current = null;
189
+ // Only relinquish the global reference if it still points at this instance,
190
+ // so stopping an older instance can't clear a newer one's registration.
191
+ if (globalRef.current === this) {
192
+ globalRef.current = null;
193
+ }
170
194
  }
171
195
  getTraces() {
172
196
  return this.buffer.toArray().reverse();
@@ -220,6 +220,8 @@ export interface TraceSpan {
220
220
  startOffset: number;
221
221
  /** Span duration in milliseconds. */
222
222
  duration: number;
223
+ /** True when the wrapped function threw. */
224
+ error?: boolean;
223
225
  /** Optional metadata (query bindings, status code, etc.). */
224
226
  metadata?: Record<string, unknown>;
225
227
  }
@@ -140,6 +140,7 @@ export function defineConfig(config) {
140
140
  onStats: config.onStats,
141
141
  devToolbar: resolveDevToolbar(config),
142
142
  shouldShow: config.authorize ?? config.shouldShow,
143
+ unsafeAllowNoAuth: config.unsafeAllowNoAuth,
143
144
  verbose,
144
145
  };
145
146
  }