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.
- package/README.md +115 -1
- package/dist/core/debug/types.d.ts +14 -0
- package/dist/core/types.d.ts +140 -4
- package/dist/src/dashboard/dashboard_controller.d.ts +7 -0
- package/dist/src/dashboard/dashboard_controller.js +10 -3
- package/dist/src/debug/debug_store.d.ts +15 -1
- package/dist/src/debug/debug_store.js +32 -4
- package/dist/src/debug/types.d.ts +14 -0
- package/dist/src/define_config.js +49 -1
- package/dist/src/middleware/request_tracking_middleware.d.ts +2 -1
- package/dist/src/middleware/request_tracking_middleware.js +16 -1
- package/dist/src/provider/boot_helpers.d.ts +19 -1
- package/dist/src/provider/boot_helpers.js +51 -2
- package/dist/src/provider/dashboard_init.js +5 -1
- package/dist/src/provider/dashboard_setup.d.ts +10 -1
- package/dist/src/provider/dashboard_setup.js +47 -2
- package/dist/src/provider/server_stats_provider.d.ts +7 -0
- package/dist/src/provider/server_stats_provider.js +26 -8
- package/dist/src/provider/toolbar_setup.js +5 -1
- package/dist/src/routes/access_middleware.d.ts +7 -1
- package/dist/src/routes/access_middleware.js +6 -1
- package/dist/src/routes/dashboard_routes.d.ts +1 -0
- package/dist/src/routes/dashboard_routes.js +5 -3
- package/dist/src/routes/debug_routes.d.ts +1 -0
- package/dist/src/routes/debug_routes.js +5 -3
- package/dist/src/routes/register_routes.d.ts +18 -3
- package/dist/src/routes/register_routes.js +24 -2
- package/dist/src/routes/router_types.d.ts +15 -5
- package/dist/src/routes/stats_routes.d.ts +10 -1
- package/dist/src/routes/stats_routes.js +9 -2
- package/dist/src/stubs/config.stub +8 -0
- package/dist/src/types.d.ts +140 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 &&
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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?:
|
|
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):
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
})
|