adonisjs-server-stats 1.14.1 → 1.16.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.
Files changed (33) hide show
  1. package/README.md +115 -1
  2. package/dist/core/debug/types.d.ts +14 -0
  3. package/dist/core/types.d.ts +140 -4
  4. package/dist/src/dashboard/dashboard_controller.d.ts +7 -0
  5. package/dist/src/dashboard/dashboard_controller.js +10 -3
  6. package/dist/src/debug/debug_store.d.ts +15 -1
  7. package/dist/src/debug/debug_store.js +32 -4
  8. package/dist/src/debug/types.d.ts +14 -0
  9. package/dist/src/define_config.js +49 -1
  10. package/dist/src/middleware/request_tracking_middleware.d.ts +2 -1
  11. package/dist/src/middleware/request_tracking_middleware.js +16 -1
  12. package/dist/src/provider/boot_helpers.d.ts +19 -1
  13. package/dist/src/provider/boot_helpers.js +51 -2
  14. package/dist/src/provider/dashboard_init.js +5 -1
  15. package/dist/src/provider/dashboard_setup.d.ts +10 -1
  16. package/dist/src/provider/dashboard_setup.js +47 -2
  17. package/dist/src/provider/server_stats_provider.d.ts +7 -0
  18. package/dist/src/provider/server_stats_provider.js +26 -8
  19. package/dist/src/provider/toolbar_setup.js +5 -1
  20. package/dist/src/routes/access_middleware.d.ts +7 -1
  21. package/dist/src/routes/access_middleware.js +6 -1
  22. package/dist/src/routes/dashboard_routes.d.ts +1 -0
  23. package/dist/src/routes/dashboard_routes.js +5 -3
  24. package/dist/src/routes/debug_routes.d.ts +1 -0
  25. package/dist/src/routes/debug_routes.js +5 -3
  26. package/dist/src/routes/register_routes.d.ts +18 -3
  27. package/dist/src/routes/register_routes.js +24 -2
  28. package/dist/src/routes/router_types.d.ts +15 -5
  29. package/dist/src/routes/stats_routes.d.ts +10 -1
  30. package/dist/src/routes/stats_routes.js +9 -2
  31. package/dist/src/stubs/config.stub +8 -0
  32. package/dist/src/types.d.ts +140 -4
  33. package/package.json +1 -1
@@ -13,9 +13,27 @@ export declare function computeDashboardPath(devToolbar?: {
13
13
  dashboard?: boolean;
14
14
  dashboardPath?: string;
15
15
  }, depsAvailable?: boolean): string | undefined;
16
- export declare function collectRegisteredPaths(statsEndpoint: string | false, debugEndpoint?: string, dashboardPath?: string): string[];
16
+ export declare function collectRegisteredPaths(statsEndpoint: string | false, debugEndpoint?: string, dashboardPath?: string, domain?: string): string[];
17
17
  export declare function checkDashboardDeps(appImport: (name: string) => Promise<unknown>): Promise<string[]>;
18
18
  export declare function logMissingDeps(missing: string[]): void;
19
19
  export declare function warnAboutAuthMiddleware(config: ResolvedServerStatsConfig, makePath: (dir: string, file: string) => string): void;
20
+ /**
21
+ * Warn when `domain` is combined with the Edge toolbar.
22
+ *
23
+ * The `@serverStats()` tag emits *relative* URLs (`/admin/api/server-stats`
24
+ * etc.), so a toolbar rendered on any other host polls its own origin and gets
25
+ * a 404 on every tick. Nothing breaks visibly — the bar just stays empty — so
26
+ * say it out loud at boot.
27
+ */
28
+ export declare function warnAboutDomainWithToolbar(config: ResolvedServerStatsConfig): void;
29
+ /**
30
+ * Announce that the dashboard is live in production.
31
+ *
32
+ * Deliberately `log.warn` rather than the `log.block` its neighbours use:
33
+ * `log.block` is suppressed unless `verbose` is on, and this is the one message
34
+ * that must reach every operator who enables production mode. It only fires
35
+ * after routes were really registered, so it never over-promises.
36
+ */
37
+ export declare function announceProductionMode(config: ResolvedServerStatsConfig, paths: string[]): void;
20
38
  export declare function warnAboutSessionMiddleware(makePath: (dir: string, file: string) => string): void;
21
39
  export declare function logDashboardError(category: 'missing-dep' | 'timeout' | 'unknown', err: unknown): void;
@@ -12,7 +12,7 @@ export function computeDashboardPath(devToolbar, depsAvailable) {
12
12
  return undefined;
13
13
  return devToolbar.dashboardPath ?? '/__stats';
14
14
  }
15
- export function collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath) {
15
+ export function collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath, domain) {
16
16
  const paths = [];
17
17
  if (typeof statsEndpoint === 'string')
18
18
  paths.push(statsEndpoint);
@@ -20,7 +20,9 @@ export function collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPa
20
20
  paths.push(debugEndpoint + '/*');
21
21
  if (dashboardPath)
22
22
  paths.push(dashboardPath + '/*');
23
- return paths;
23
+ // Without the host prefix the logged paths are misleading when the routes are
24
+ // domain-restricted — they only resolve on that host.
25
+ return domain ? paths.map((p) => domain + p) : paths;
24
26
  }
25
27
  export async function checkDashboardDeps(appImport) {
26
28
  const missing = [];
@@ -57,6 +59,53 @@ export function warnAboutAuthMiddleware(config, makePath) {
57
59
  return;
58
60
  log.block(bold('found global auth middleware that will run on every poll:'), buildAuthMiddlewareWarning(found, dim, bold));
59
61
  }
62
+ /**
63
+ * Warn when `domain` is combined with the Edge toolbar.
64
+ *
65
+ * The `@serverStats()` tag emits *relative* URLs (`/admin/api/server-stats`
66
+ * etc.), so a toolbar rendered on any other host polls its own origin and gets
67
+ * a 404 on every tick. Nothing breaks visibly — the bar just stays empty — so
68
+ * say it out loud at boot.
69
+ */
70
+ export function warnAboutDomainWithToolbar(config) {
71
+ if (!config.domain || !config.devToolbar?.enabled)
72
+ return;
73
+ log.block(bold(`routes are restricted to ${config.domain}:`), [
74
+ dim('The @serverStats() toolbar uses relative URLs, so it only works on pages'),
75
+ dim(`served from ${config.domain}. On any other host the bar will stay empty.`),
76
+ ]);
77
+ }
78
+ /** Human-readable list of the capture subsystems that are switched on. */
79
+ function describeCapture(config) {
80
+ const requested = config.production?.capture;
81
+ if (!requested)
82
+ return 'nothing (request metadata only)';
83
+ const on = ['queries', 'events', 'emails', 'traces', 'logs'].filter((key) => requested[key] === true);
84
+ return on.length > 0 ? on.join(', ') : 'nothing (request metadata only)';
85
+ }
86
+ /**
87
+ * Announce that the dashboard is live in production.
88
+ *
89
+ * Deliberately `log.warn` rather than the `log.block` its neighbours use:
90
+ * `log.block` is suppressed unless `verbose` is on, and this is the one message
91
+ * that must reach every operator who enables production mode. It only fires
92
+ * after routes were really registered, so it never over-promises.
93
+ */
94
+ export function announceProductionMode(config, paths) {
95
+ if (!config.production?.enabled)
96
+ return;
97
+ const retention = config.production.retentionDays ?? 3;
98
+ const dbPath = config.devToolbar?.dbPath ?? '.adonisjs/server-stats/dashboard.sqlite3';
99
+ const lines = [
100
+ ` reachable at: ${paths.join(', ')}`,
101
+ ` guard: ${config.shouldShow ? 'authorize() configured' : 'NONE'}`,
102
+ ` capturing: ${describeCapture(config)}`,
103
+ ];
104
+ if (config.devToolbar?.dashboard) {
105
+ lines.push(` data at: ${dbPath} (retention: ${retention} days)`);
106
+ }
107
+ log.warn('DASHBOARD IS LIVE IN PRODUCTION\n' + lines.join('\n'));
108
+ }
60
109
  export function warnAboutSessionMiddleware(makePath) {
61
110
  const found = detectGlobalSessionMiddleware(makePath);
62
111
  if (found.length === 0)
@@ -42,7 +42,11 @@ export async function initDashboardStore(opts) {
42
42
  setDashboardPath(tc.dashboardPath);
43
43
  const DCC = (await import('../dashboard/dashboard_controller.js')).default;
44
44
  const dashboardController = new DCC(dashboardStore, app);
45
- const dashboardLogStream = pipeDashLogs(pinoHookActive, dashboardStore, app.makePath.bind(app));
45
+ // Log capture is a separate opt-in: it is high-volume and log lines routinely
46
+ // carry request payloads. With it off the dashboard's other panes still work.
47
+ const dashboardLogStream = tc.capture?.logs !== false
48
+ ? pipeDashLogs(pinoHookActive, dashboardStore, app.makePath.bind(app))
49
+ : null;
46
50
  pipeDashRequests(debugStore, dashboardStore);
47
51
  const dashboardBroadcastTimer = await setupDashBroadcast({
48
52
  container,
@@ -2,6 +2,7 @@
2
2
  * Pure helper functions for dashboard setup and configuration.
3
3
  */
4
4
  import type { DevToolbarConfig } from '../debug/types.js';
5
+ import type { ProductionConfig } from '../types.js';
5
6
  /**
6
7
  * Classify a dashboard start() error into a category.
7
8
  */
@@ -17,9 +18,17 @@ export declare function buildExcludedPrefixes(toolbarConfig: {
17
18
  debugEndpoint?: string;
18
19
  excludeFromTracing?: string[];
19
20
  }, statsEndpoint: string | false): string[];
21
+ /** Environment context needed to resolve production-sensitive defaults. */
22
+ export interface ProductionContext {
23
+ inProduction: boolean;
24
+ production?: ProductionConfig;
25
+ }
20
26
  /**
21
27
  * Resolve a partial DevToolbarConfig by filling in all defaults.
28
+ *
29
+ * Pass `ctx` to apply production-sensitive defaults (capture off, shorter
30
+ * retention). Omitting it resolves as a non-production environment.
22
31
  */
23
32
  export declare function resolveToolbarConfig(partial: Partial<DevToolbarConfig> & {
24
33
  enabled: boolean;
25
- }): DevToolbarConfig;
34
+ }, ctx?: ProductionContext): DevToolbarConfig;
@@ -73,13 +73,58 @@ function stripUndefined(obj) {
73
73
  }
74
74
  return result;
75
75
  }
76
+ /** Retention default when running in production — shorter than the usual 7 days. */
77
+ const PRODUCTION_RETENTION_DAYS = 3;
76
78
  /**
77
- * Resolve a partial DevToolbarConfig by filling in all defaults.
79
+ * Resolve which capture subsystems subscribe.
80
+ *
81
+ * Outside production everything captures, as it always has. In production every
82
+ * subsystem is off until asked for by name, because each one is the expensive,
83
+ * secret-adjacent half of this package: query bindings, mail bodies, log lines.
84
+ *
85
+ * `tracing: false` still wins over `capture.traces` — it is the pre-existing
86
+ * documented kill switch and must not be quietly re-enabled.
78
87
  */
79
- export function resolveToolbarConfig(partial) {
88
+ function resolveCapture(ctx, tracing) {
89
+ const inProduction = ctx?.inProduction === true;
90
+ const requested = (inProduction ? ctx?.production?.capture : undefined) ?? {};
91
+ const isOn = (key) => requested[key] ?? !inProduction;
80
92
  return {
93
+ queries: isOn('queries'),
94
+ events: isOn('events'),
95
+ emails: isOn('emails'),
96
+ traces: tracing && isOn('traces'),
97
+ logs: isOn('logs'),
98
+ };
99
+ }
100
+ /**
101
+ * Resolve retention, preferring an explicit value from any source over the
102
+ * production default. Order: `production.retentionDays`, then whatever
103
+ * `dashboard`/`advanced` set, then 3 days in production, then 7.
104
+ */
105
+ function resolveRetentionDays(ctx, explicit) {
106
+ const fromProduction = ctx?.inProduction ? ctx.production?.retentionDays : undefined;
107
+ if (fromProduction !== undefined)
108
+ return fromProduction;
109
+ if (explicit !== undefined)
110
+ return explicit;
111
+ return ctx?.inProduction ? PRODUCTION_RETENTION_DAYS : TOOLBAR_DEFAULTS.retentionDays;
112
+ }
113
+ /**
114
+ * Resolve a partial DevToolbarConfig by filling in all defaults.
115
+ *
116
+ * Pass `ctx` to apply production-sensitive defaults (capture off, shorter
117
+ * retention). Omitting it resolves as a non-production environment.
118
+ */
119
+ export function resolveToolbarConfig(partial, ctx) {
120
+ const merged = {
81
121
  ...TOOLBAR_DEFAULTS,
82
122
  ...stripUndefined(partial),
83
123
  enabled: partial.enabled,
84
124
  };
125
+ return {
126
+ ...merged,
127
+ retentionDays: resolveRetentionDays(ctx, partial.retentionDays),
128
+ capture: resolveCapture(ctx, merged.tracing),
129
+ };
85
130
  }
@@ -43,6 +43,13 @@ export default class ServerStatsProvider {
43
43
  whenReady(): Promise<void>;
44
44
  boot(): Promise<void>;
45
45
  private initBoot;
46
+ /**
47
+ * Whether this package should do anything in the current environment.
48
+ *
49
+ * Everything is on outside production. In production nothing is registered or
50
+ * built unless `production.enabled` opts in.
51
+ */
52
+ private isEnabledHere;
46
53
  private registerRoutes;
47
54
  ready(): Promise<void>;
48
55
  private initStats;
@@ -2,7 +2,7 @@ import { StatsEngine } from '../engine/stats_engine.js';
2
2
  import { setShouldShow, setExcludedPrefixes } from '../middleware/request_tracking_middleware.js';
3
3
  import { registerAllRoutes } from '../routes/register_routes.js';
4
4
  import { log, dim, setVerbose } from '../utils/logger.js';
5
- import { deriveEndpointPaths, computeDashboardPath, collectRegisteredPaths, warnAboutAuthMiddleware, warnAboutSessionMiddleware, } from './boot_helpers.js';
5
+ import { deriveEndpointPaths, computeDashboardPath, collectRegisteredPaths, warnAboutAuthMiddleware, warnAboutSessionMiddleware, warnAboutDomainWithToolbar, announceProductionMode, } from './boot_helpers.js';
6
6
  import { resolveToolbarConfig, buildExcludedPrefixes } from './dashboard_setup.js';
7
7
  import { buildDiagnostics } from './diagnostics.js';
8
8
  import { hookPinoToLogStream, setupLogStreamBroadcast, setupStatsIntervalHelper, checkDashboardDepsHelper, registerEdgePluginHelper, setupNonWebBridgeHelper, setupDevToolbarCore, applyToolbarResult, } from './provider_helpers_extra.js';
@@ -73,16 +73,29 @@ export default class ServerStatsProvider {
73
73
  if (config.shouldShow)
74
74
  setShouldShow(config.shouldShow);
75
75
  await this.registerRoutes(config);
76
- this.edgePluginActive = await registerEdgePluginHelper(this.app, config);
76
+ // Only register the Edge tag when the routes it polls actually exist —
77
+ // otherwise the bar renders in production and 404s on every tick.
78
+ this.edgePluginActive = this.isEnabledHere(config)
79
+ ? await registerEdgePluginHelper(this.app, config)
80
+ : false;
81
+ }
82
+ /**
83
+ * Whether this package should do anything in the current environment.
84
+ *
85
+ * Everything is on outside production. In production nothing is registered or
86
+ * built unless `production.enabled` opts in.
87
+ */
88
+ isEnabledHere(config) {
89
+ return !this.app.inProduction || config.production?.enabled === true;
77
90
  }
78
91
  async registerRoutes(config) {
79
92
  const router = await this.resolve('router');
80
- if (!router || this.app.inProduction)
93
+ if (!router || !this.isEnabledHere(config))
81
94
  return;
82
95
  this.dashboardDepsAvailable = await checkDashboardDepsHelper(config, this.app);
83
96
  const { statsEndpoint, debugEndpoint } = deriveEndpointPaths(config.endpoint, config.devToolbar);
84
97
  const dashboardPath = computeDashboardPath(config.devToolbar, this.dashboardDepsAvailable);
85
- registerAllRoutes({
98
+ const registered = registerAllRoutes({
86
99
  router: router,
87
100
  getApiController: () => this.apiController,
88
101
  getStatsController: () => this.statsController,
@@ -95,14 +108,19 @@ export default class ServerStatsProvider {
95
108
  dashboardPath,
96
109
  shouldShow: config.shouldShow,
97
110
  unsafeAllowNoAuth: config.unsafeAllowNoAuth,
111
+ inProduction: this.app.inProduction,
98
112
  whenReady: () => this.whenReady(),
113
+ domain: config.domain,
99
114
  });
100
- const paths = collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath);
101
- if (paths.length === 0)
115
+ const paths = collectRegisteredPaths(statsEndpoint, debugEndpoint, dashboardPath, config.domain);
116
+ if (!registered || paths.length === 0)
102
117
  return;
103
118
  log.list('routes auto-registered (no manual setup needed):', paths);
104
119
  warnAboutAuthMiddleware(config, this.app.makePath.bind(this.app));
105
120
  warnAboutSessionMiddleware(this.app.makePath.bind(this.app));
121
+ warnAboutDomainWithToolbar(config);
122
+ if (this.app.inProduction)
123
+ announceProductionMode(config, paths);
106
124
  }
107
125
  async ready() {
108
126
  const config = this.app.config.get('server_stats');
@@ -130,7 +148,7 @@ export default class ServerStatsProvider {
130
148
  this.pinoHookActive = hookPinoToLogStream(await this.resolve('logger'));
131
149
  const SC = (await import('../controller/server_stats_controller.js')).default;
132
150
  this.statsController = new SC(this.engine);
133
- if (config.devToolbar?.enabled && !this.app.inProduction) {
151
+ if (config.devToolbar?.enabled && this.isEnabledHere(config)) {
134
152
  this.checkLucidDebugFlag();
135
153
  await this.setupDevToolbar(config);
136
154
  }
@@ -156,7 +174,7 @@ export default class ServerStatsProvider {
156
174
  return r.collectors;
157
175
  }
158
176
  async setupDevToolbar(config) {
159
- const tc = resolveToolbarConfig({ enabled: true, ...config.devToolbar });
177
+ const tc = resolveToolbarConfig({ enabled: true, ...config.devToolbar }, { inProduction: this.app.inProduction, production: config.production });
160
178
  try {
161
179
  const result = await setupDevToolbarCore({
162
180
  tc,
@@ -20,7 +20,11 @@ export async function setupDevToolbarCore(opts) {
20
20
  await debugStore.start(em, await resolve('router'));
21
21
  const emailBridgeRedis = await setupBridgeInternal(em, debugStore, app);
22
22
  const debugController = await createDebugController(debugStore, config, getDiagnostics, app);
23
- if (debugStore.traces)
23
+ // Also gated on capture: installing the collector is what makes the middleware
24
+ // wrap every request in AsyncLocalStorage and write a trace row per request.
25
+ // Gating only the emitter subscription would leave that cost in place and
26
+ // persist empty traces.
27
+ if (debugStore.capture.traces && debugStore.traces)
24
28
  setTraceCollector(debugStore.traces);
25
29
  const flushTimer = persistPath ? createFlushTimer(debugStore, persistPath) : null;
26
30
  const broadcast = await setupDebugBroadcastInternal(debugStore, resolve);
@@ -1,8 +1,14 @@
1
+ import type { AccessGuard } from '../types.js';
1
2
  import type { HttpContext } from '@adonisjs/core/http';
2
3
  /**
3
4
  * Create a middleware function that gates access using the shouldShow callback.
4
5
  * Returns 403 if the callback returns false.
5
6
  *
7
+ * The guard is awaited, so an async callback (the usual shape when it has to
8
+ * consult `ctx.auth` or the database) is resolved before the decision is made.
9
+ * Returning the promise unawaited would make every async guard pass, since a
10
+ * pending promise is truthy.
11
+ *
6
12
  * Shared by stats, debug, and dashboard route registrars.
7
13
  */
8
- export declare function createAccessMiddleware(shouldShow: (ctx: HttpContext) => boolean): (ctx: HttpContext, next: () => Promise<void>) => Promise<void>;
14
+ export declare function createAccessMiddleware(shouldShow: AccessGuard): (ctx: HttpContext, next: () => Promise<void>) => Promise<void>;
@@ -4,12 +4,17 @@ let warnedShouldShow = false;
4
4
  * Create a middleware function that gates access using the shouldShow callback.
5
5
  * Returns 403 if the callback returns false.
6
6
  *
7
+ * The guard is awaited, so an async callback (the usual shape when it has to
8
+ * consult `ctx.auth` or the database) is resolved before the decision is made.
9
+ * Returning the promise unawaited would make every async guard pass, since a
10
+ * pending promise is truthy.
11
+ *
7
12
  * Shared by stats, debug, and dashboard route registrars.
8
13
  */
9
14
  export function createAccessMiddleware(shouldShow) {
10
15
  return async (ctx, next) => {
11
16
  try {
12
- if (!shouldShow(ctx)) {
17
+ if (!(await shouldShow(ctx))) {
13
18
  return ctx.response.forbidden({ error: 'Access denied' });
14
19
  }
15
20
  }
@@ -9,6 +9,7 @@ interface DashboardRoutesOpts {
9
9
  getApiController: () => ApiController | null;
10
10
  middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>;
11
11
  whenReady?: () => Promise<void>;
12
+ domain?: string;
12
13
  }
13
14
  /** Register dashboard routes. */
14
15
  export declare function registerDashboardRoutes(opts: DashboardRoutesOpts): void;
@@ -230,7 +230,7 @@ export function registerDashboardRoutes(opts) {
230
230
  const { router, dashboardPath, getDashboardController, getApiController, middleware } = opts;
231
231
  const whenReady = opts.whenReady;
232
232
  const base = dashboardPath.replace(/\/+$/, '');
233
- router
233
+ const group = router
234
234
  .group(() => {
235
235
  registerDashboardPageRoutes(router, getDashboardController, whenReady);
236
236
  registerQueryRoutes(router, getApiController, whenReady);
@@ -244,6 +244,8 @@ export function registerDashboardRoutes(opts) {
244
244
  registerJobRoutes(router, getDashboardController, whenReady);
245
245
  registerConfigAndFilterRoutes(router, getDashboardController, whenReady);
246
246
  })
247
- .prefix(base)
248
- .use(middleware);
247
+ .prefix(base);
248
+ if (opts.domain)
249
+ group.domain(opts.domain);
250
+ group.use(middleware);
249
251
  }
@@ -13,6 +13,7 @@ interface DebugRoutesOpts {
13
13
  getApp?: () => ApplicationService | null;
14
14
  middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>;
15
15
  whenReady?: () => Promise<void>;
16
+ domain?: string;
16
17
  }
17
18
  /** Register debug panel API routes. */
18
19
  export declare function registerDebugRoutes(opts: DebugRoutesOpts): void;
@@ -149,7 +149,7 @@ export function registerDebugRoutes(opts) {
149
149
  const { router, debugEndpoint, getDebugController, getApiController, middleware } = opts;
150
150
  const whenReady = opts.whenReady;
151
151
  const base = debugEndpoint.replace(/\/+$/, '');
152
- router
152
+ const group = router
153
153
  .group(() => {
154
154
  registerDebugConfigRoutes(router, getDebugController, whenReady);
155
155
  registerDebugQueryAndEventRoutes(router, getApiController, whenReady);
@@ -158,6 +158,8 @@ export function registerDebugRoutes(opts) {
158
158
  registerDebugEmailRoutes(router, getApiController, whenReady);
159
159
  registerDebugTraceRoutes(router, getApiController, whenReady);
160
160
  })
161
- .prefix(base)
162
- .use(middleware);
161
+ .prefix(base);
162
+ if (opts.domain)
163
+ group.domain(opts.domain);
164
+ group.use(middleware);
163
165
  }
@@ -3,8 +3,8 @@ import type DebugController from '../controller/debug_controller.js';
3
3
  import type { DebugStore } from '../debug/debug_store.js';
4
4
  import type ServerStatsController from '../controller/server_stats_controller.js';
5
5
  import type DashboardController from '../dashboard/dashboard_controller.js';
6
+ import type { AccessGuard } from '../types.js';
6
7
  import type { AdonisRouter } from './router_types.js';
7
- import type { HttpContext } from '@adonisjs/core/http';
8
8
  import type { ApplicationService } from '@adonisjs/core/types';
9
9
  /**
10
10
  * Options for the unified route registration function.
@@ -20,7 +20,7 @@ export interface RegisterRoutesOptions {
20
20
  statsEndpoint?: string | false;
21
21
  debugEndpoint?: string;
22
22
  dashboardPath?: string;
23
- shouldShow?: (ctx: HttpContext) => boolean;
23
+ shouldShow?: AccessGuard;
24
24
  /**
25
25
  * Escape hatch to register the sensitive dashboard/debug/stats routes WITHOUT
26
26
  * any access guard when no `shouldShow` callback is provided. Off by default —
@@ -29,10 +29,25 @@ export interface RegisterRoutesOptions {
29
29
  * email bodies, and SQL) to anyone who can reach it. Local dev only.
30
30
  */
31
31
  unsafeAllowNoAuth?: boolean;
32
+ /**
33
+ * Whether the app is running in production. When true, `unsafeAllowNoAuth` is
34
+ * ignored — an unauthenticated dashboard in production is never correct, so
35
+ * the only way in is a real `shouldShow` guard.
36
+ */
37
+ inProduction?: boolean;
32
38
  /** Optional promise that resolves when controllers are initialized. */
33
39
  whenReady?: () => Promise<void>;
40
+ /**
41
+ * Restrict every registered route to this host, via `router.group().domain()`.
42
+ * Supports dynamic segments (`':tenant.example.com'`). Unset = no restriction.
43
+ */
44
+ domain?: string;
34
45
  }
35
46
  /**
36
47
  * Register all server-stats routes in a single call.
48
+ *
49
+ * Returns whether the routes were actually registered — false means the
50
+ * fail-closed guard check rejected the configuration, which callers need to
51
+ * know before announcing that anything is reachable.
37
52
  */
38
- export declare function registerAllRoutes(options: RegisterRoutesOptions): void;
53
+ export declare function registerAllRoutes(options: RegisterRoutesOptions): boolean;
@@ -8,16 +8,29 @@ import { log } from '../utils/logger.js';
8
8
  let _warnedNoAuth = false;
9
9
  /**
10
10
  * Register all server-stats routes in a single call.
11
+ *
12
+ * Returns whether the routes were actually registered — false means the
13
+ * fail-closed guard check rejected the configuration, which callers need to
14
+ * know before announcing that anything is reachable.
11
15
  */
12
16
  export function registerAllRoutes(options) {
13
17
  // Fail closed: without an access guard (`shouldShow`) the sensitive routes must
14
18
  // not be registered unless the caller explicitly opts in via `unsafeAllowNoAuth`.
15
19
  if (!options.shouldShow) {
20
+ // In production the escape hatch does not apply — there is no legitimate
21
+ // reason to serve secrets, email bodies, and SQL to unauthenticated callers
22
+ // on a production host, so the guard is the only way through.
23
+ if (options.inProduction) {
24
+ log.warn('server-stats: production mode is enabled but no `authorize`/`shouldShow` guard is ' +
25
+ 'configured — sensitive routes (dashboard, debug API, stats) will NOT be registered. ' +
26
+ '`unsafeAllowNoAuth` is ignored in production. Provide an authorize callback.');
27
+ return false;
28
+ }
16
29
  if (!options.unsafeAllowNoAuth) {
17
30
  log.warn('server-stats: no `authorize`/`shouldShow` guard configured — sensitive routes ' +
18
31
  '(dashboard, debug API, stats) will NOT be registered. Provide an authorize callback, ' +
19
32
  'or set `unsafeAllowNoAuth: true` to expose them without auth (local dev only).');
20
- return;
33
+ return false;
21
34
  }
22
35
  if (!_warnedNoAuth) {
23
36
  _warnedNoAuth = true;
@@ -31,7 +44,13 @@ export function registerAllRoutes(options) {
31
44
  ...(options.shouldShow ? [createAccessMiddleware(options.shouldShow)] : []),
32
45
  ];
33
46
  if (typeof options.statsEndpoint === 'string') {
34
- registerStatsRoute(options.router, options.statsEndpoint, options.getStatsController, middleware);
47
+ registerStatsRoute({
48
+ router: options.router,
49
+ endpoint: options.statsEndpoint,
50
+ getController: options.getStatsController,
51
+ middleware,
52
+ domain: options.domain,
53
+ });
35
54
  }
36
55
  if (options.debugEndpoint) {
37
56
  registerDebugRoutes({
@@ -43,6 +62,7 @@ export function registerAllRoutes(options) {
43
62
  getApp: options.getApp,
44
63
  middleware,
45
64
  whenReady: options.whenReady,
65
+ domain: options.domain,
46
66
  });
47
67
  }
48
68
  if (options.dashboardPath) {
@@ -53,6 +73,8 @@ export function registerAllRoutes(options) {
53
73
  getApiController: options.getApiController,
54
74
  middleware,
55
75
  whenReady: options.whenReady,
76
+ domain: options.domain,
56
77
  });
57
78
  }
79
+ return true;
58
80
  }
@@ -10,6 +10,20 @@ export interface AdonisRoute {
10
10
  where(key: string, matcher: RegExp): AdonisRoute;
11
11
  use(middleware: unknown[]): void;
12
12
  }
13
+ /**
14
+ * Return type of `router.group()` supporting the chaining patterns
15
+ * used by server-stats route registration.
16
+ *
17
+ * AdonisJS allows chaining `.prefix()`, `.domain()`, and `.use()` in
18
+ * any order on a route group. This interface covers the combinations
19
+ * we use: `.prefix().use()`, `.prefix().domain().use()`, and
20
+ * `.domain().prefix().use()`.
21
+ */
22
+ export interface AdonisRouteGroup {
23
+ prefix(path: string): AdonisRouteGroup;
24
+ domain(host: string): AdonisRouteGroup;
25
+ use(middleware: unknown[]): AdonisRouteGroup;
26
+ }
13
27
  /**
14
28
  * Minimal interface for the AdonisJS router used in route registration.
15
29
  *
@@ -20,9 +34,5 @@ export interface AdonisRouter {
20
34
  get(pattern: string, handler: (ctx: HttpContext) => unknown): AdonisRoute;
21
35
  post(pattern: string, handler: (ctx: HttpContext) => unknown): AdonisRoute;
22
36
  delete(pattern: string, handler: (ctx: HttpContext) => unknown): AdonisRoute;
23
- group(callback: () => void): {
24
- prefix(path: string): {
25
- use(middleware: unknown[]): void;
26
- };
27
- };
37
+ group(callback: () => void): AdonisRouteGroup;
28
38
  }
@@ -1,5 +1,14 @@
1
1
  import type ServerStatsController from '../controller/server_stats_controller.js';
2
2
  import type { AdonisRouter } from './router_types.js';
3
3
  import type { HttpContext } from '@adonisjs/core/http';
4
+ interface StatsRouteOpts {
5
+ router: AdonisRouter;
6
+ endpoint: string;
7
+ getController: () => ServerStatsController | null;
8
+ middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>;
9
+ /** Optional domain restriction — see `ServerStatsConfig['domain']`. */
10
+ domain?: string;
11
+ }
4
12
  /** Register the stats polling endpoint. */
5
- export declare function registerStatsRoute(router: AdonisRouter, endpoint: string, getController: () => ServerStatsController | null, middleware: Array<(ctx: HttpContext, next: () => Promise<void>) => Promise<void>>): void;
13
+ export declare function registerStatsRoute(opts: StatsRouteOpts): void;
14
+ export {};
@@ -1,6 +1,7 @@
1
1
  /** Register the stats polling endpoint. */
2
- export function registerStatsRoute(router, endpoint, getController, middleware) {
3
- router
2
+ export function registerStatsRoute(opts) {
3
+ const { router, endpoint, getController, middleware, domain } = opts;
4
+ const register = () => router
4
5
  .get(endpoint, async (ctx) => {
5
6
  const controller = getController();
6
7
  if (!controller)
@@ -11,4 +12,10 @@ export function registerStatsRoute(router, endpoint, getController, middleware)
11
12
  })
12
13
  .as('server-stats.api')
13
14
  .use(middleware);
15
+ // A domain restriction needs a group to hang `.domain()` on; without one we
16
+ // register the route directly so the route tree stays exactly as before.
17
+ if (domain)
18
+ router.group(register).domain(domain);
19
+ else
20
+ register();
14
21
  }
@@ -19,4 +19,12 @@ export default defineConfig({
19
19
 
20
20
  // Log detailed initialization steps (SQLite, migrations, routes, etc.)
21
21
  // verbose: true,
22
+
23
+ // Nothing is registered in production unless you opt in here. Requires an
24
+ // `authorize` guard, and data capture stays off until you name a subsystem.
25
+ // production: {
26
+ // enabled: true,
27
+ // capture: { queries: true },
28
+ // retentionDays: 3,
29
+ // },
22
30
  })