@bakery-framework/plugin-dashboard 1.0.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/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { definePlugin } from '@bakery-framework/core/plugins'
2
+ import type { AuthorizeFn } from './authorize'
3
+
4
+ export type { AuthorizeFn } from './authorize'
5
+
6
+ export interface DashboardPluginOptions {
7
+ /**
8
+ * Register the dashboard. Defaults to true. Set to false to keep it out of a
9
+ * build entirely — the documented way to disable it in production.
10
+ */
11
+ enabled?: boolean
12
+
13
+ /**
14
+ * Decide whether a request may use the console. Return true to allow.
15
+ *
16
+ * The dashboard does not authenticate anyone itself; the application does,
17
+ * because it is the thing that already knows who its users are:
18
+ *
19
+ * ```ts
20
+ * dashboardPlugin({
21
+ * authorize: req => req.session.get('role') === 'admin',
22
+ * })
23
+ * ```
24
+ *
25
+ * Omitted, access is limited to loopback in development and denied in
26
+ * production, so an unconfigured console is never exposed.
27
+ */
28
+ authorize?: AuthorizeFn
29
+ }
30
+
31
+ export default function dashboardPlugin(options: DashboardPluginOptions = {}) {
32
+ const enabled = options.enabled ?? true
33
+
34
+ return definePlugin({
35
+ name: 'dashboard',
36
+ async setup() {
37
+ if (!enabled) return
38
+ const { setupDashboard } = await import('./setup')
39
+ await setupDashboard({ authorize: options.authorize })
40
+ },
41
+ })
42
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { fs } from '@bakery-framework/core/utils'
2
+
3
+ /**
4
+ * Where this plugin's own files live.
5
+ *
6
+ * Not `frameworkPath()` — that anchors to `@bakery-framework/core`, so once the dashboard
7
+ * became its own package every asset lookup pointed into core and 404'd. Each
8
+ * package that ships files needs its own anchor, derived from its own location.
9
+ */
10
+ export const pluginRoot: string = fs.resolve(import.meta.dir)
11
+
12
+ export function pluginPath(...segments: string[]): string {
13
+ return fs.resolve(pluginRoot, ...segments)
14
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,268 @@
1
+ import { bundleModule } from '@bakery-framework/core/compiler'
2
+ import { Bakery } from '@bakery-framework/core/core/bakery'
3
+ import { isDevWorker } from '@bakery-framework/core/core/init'
4
+ import { Handler, mountRoutes } from '@bakery-framework/core/handlers'
5
+ // LiveReloadHandler owns this registry — it adds and removes sockets from it.
6
+ import {
7
+ connectedLoggers,
8
+ errorMsg,
9
+ pluginLog,
10
+ setLogCallback,
11
+ } from '@bakery-framework/core/logger'
12
+ import {
13
+ type PluginRouteTable,
14
+ routeTable,
15
+ } from '@bakery-framework/core/plugins'
16
+ import type { JsonResponseData } from '@bakery-framework/core/utils/common'
17
+ import { Try } from '@bakery-framework/core/utils/common'
18
+ import {
19
+ checkCsrf,
20
+ injectIfHtml,
21
+ response,
22
+ } from '@bakery-framework/core/utils/http'
23
+ import {
24
+ type AuthorizeFn,
25
+ defaultAuthorize,
26
+ isAuthorized,
27
+ resolveAuthorize,
28
+ } from './authorize'
29
+ import {
30
+ handleExecuteAction,
31
+ handleQuery,
32
+ handleSchema,
33
+ handleTableData,
34
+ } from './endpoints/database'
35
+ import {
36
+ handleDeleteSession,
37
+ handleGetSessions,
38
+ handleUpdateSession,
39
+ } from './endpoints/sessions'
40
+ import { pluginPath } from './paths'
41
+ import renderDashboardShell from './shell'
42
+
43
+ export { connectedLoggers }
44
+
45
+ let cachedDashboardJsPath: string | null = null
46
+
47
+ /**
48
+ * `/_dashboard` and `/api/_dashboard` are namespace *roots*, not prefixes.
49
+ *
50
+ * A bare `startsWith` also matches `/api/_dashboard-anything`, and this handler
51
+ * outranks every content handler at priority 120 — so the prefix form silently
52
+ * claimed, and then 404'd, any application route whose path merely began with
53
+ * those characters. Match the root itself or a segment below it, nothing else.
54
+ */
55
+ function inNamespace(path: string, root: string): boolean {
56
+ return path === root || path.startsWith(`${root}/`)
57
+ }
58
+
59
+ function isDashboardPath(path: string): boolean {
60
+ return (
61
+ inNamespace(path, '/_dashboard') || inNamespace(path, '/api/_dashboard')
62
+ )
63
+ }
64
+
65
+ export class DashboardHandler extends Handler {
66
+ static canHandle(path: string) {
67
+ // Deliberately narrow. This handler outranks every content handler, so
68
+ // anything it claims can never reach the route mount below — assets must
69
+ // fall through.
70
+ if (inNamespace(path, '/api/_dashboard')) return true
71
+ return path === '/_dashboard' || path === '/_dashboard/dashboard.js'
72
+ }
73
+
74
+ static resolveRoute(path: string): Handler.Route.Info | null {
75
+ if (isDashboardPath(path)) {
76
+ return new Handler.Route.Info(pluginPath('setup.tsx'), path)
77
+ }
78
+ return null
79
+ }
80
+
81
+ static async handle(
82
+ _path: string,
83
+ req: Request,
84
+ ): Promise<DashboardResponse | undefined> {
85
+ const res = await handleDashboardRequest(req)
86
+ return res || undefined
87
+ }
88
+ }
89
+
90
+ let authorize: AuthorizeFn = defaultAuthorize
91
+
92
+ /**
93
+ * Test seam for the module-level predicate, symmetric with `__setTestDb` in
94
+ * `@bakery-framework/orm` and `__setTestConfig` in core.
95
+ *
96
+ * `setupDashboard` is the only other way to set it, and it also mounts routes,
97
+ * registers the handler at priority 120 and installs a global log callback —
98
+ * three process-global mutations, none of them restorable. A test that only
99
+ * needs the request pipeline sets the predicate directly and puts it back in
100
+ * `afterAll`. Always pair with `__resetTestAuthorize()`.
101
+ */
102
+ export function __setTestAuthorize(fn: AuthorizeFn): void {
103
+ authorize = fn
104
+ }
105
+
106
+ export function __resetTestAuthorize(): void {
107
+ authorize = defaultAuthorize
108
+ }
109
+
110
+ export function setupDashboard(options: { authorize?: AuthorizeFn } = {}) {
111
+ authorize = resolveAuthorize(options.authorize)
112
+
113
+ setLogCallback(entry => {
114
+ // Broadcast to the registry LiveReloadHandler actually populates. This used
115
+ // to target a Set nothing ever added to, so the Logs tab was always empty
116
+ // and every log line paid for a JSON.stringify that went nowhere.
117
+ if (!connectedLoggers.size) return
118
+
119
+ const message = JSON.stringify({
120
+ type: 'server_log',
121
+ level: entry.level,
122
+ by: entry.by,
123
+ payload: entry.msg,
124
+ timestamp: Date.now(),
125
+ })
126
+ connectedLoggers.forEach(loggerWs => {
127
+ Try(() => loggerWs.send(message))
128
+ })
129
+ })
130
+
131
+ // The stylesheet is served by the normal static pipeline from this
132
+ // plugin's own directory — no bespoke asset route, no hand-rolled caching.
133
+ mountRoutes('/_dashboard', pluginPath('../public'))
134
+
135
+ Bakery.handlers.fetch.set(DashboardHandler, 120)
136
+ }
137
+
138
+ async function handleDashboardView() {
139
+ const htmlContent = await renderDashboardShell()
140
+ const injected = await injectIfHtml(htmlContent)
141
+
142
+ return injected ? injected : response.html(htmlContent)
143
+ }
144
+
145
+ async function handleJsAsset() {
146
+ if (cachedDashboardJsPath && !isDevWorker) {
147
+ const cachedFile = Bun.file(cachedDashboardJsPath)
148
+ if (cachedFile.size > 0) return response.type(cachedFile, 'text/javascript')
149
+ }
150
+
151
+ const bundleResult = await bundleModule(pluginPath('client/dashboard.ts'))
152
+
153
+ if (!bundleResult.success || !bundleResult.content) {
154
+ pluginLog.DASHBOARD_BUNDLE_ERR({
155
+ error: errorMsg(bundleResult.errors?.join('\n')),
156
+ })
157
+ return response.error(
158
+ `Failed to bundle dashboard.js: ${bundleResult.errors?.join('\n')}`,
159
+ 500,
160
+ )
161
+ }
162
+
163
+ if (!isDevWorker) {
164
+ const tmpPath = `${Bakery.cacheDir}/_dashboard.js`
165
+ await Bun.write(tmpPath, bundleResult.content)
166
+ const writtenFile = Bun.file(tmpPath)
167
+ if (writtenFile.size > 0) {
168
+ cachedDashboardJsPath = tmpPath
169
+ return writtenFile
170
+ }
171
+ }
172
+
173
+ return response.type(bundleResult.content, 'text/javascript')
174
+ }
175
+
176
+ async function checkAuthMiddleware(req: Request, path: string) {
177
+ // Styling and script for the console are not secrets, and letting them
178
+ // through keeps an unauthorised response from rendering unstyled.
179
+ if (/\.(css|js)$/.test(path)) return null
180
+
181
+ if (await isAuthorized(authorize, req)) return null
182
+
183
+ return path.startsWith('/api/')
184
+ ? response.error('Unauthorized', 401)
185
+ : response.error('Not Found', 404)
186
+ }
187
+
188
+ /**
189
+ * `checkCsrf` has exactly one other call site — inside `ApiHandler`, at
190
+ * priority 70. This handler sits at 120 and claims the whole `/api/_dashboard`
191
+ * namespace, so `ApiHandler.handle` never ran for these paths and the check
192
+ * never happened: a cross-origin `<form method=post>` arrived with the
193
+ * operator's cookies attached and reached the database.
194
+ *
195
+ * Necessary but **not sufficient on its own**. `SAFE_METHODS` exempts GET, and
196
+ * `processBody` reads a GET's query string as the body, so a plain
197
+ * `<img src="/api/_dashboard/execute-action?action=truncate&tableName=…">`
198
+ * sails past this guard. The mutating keys in the table below are
199
+ * method-qualified for exactly that reason; neither half closes the hole alone.
200
+ */
201
+ function checkCsrfMiddleware(req: Request, url: URL): DashboardResponse | null {
202
+ const reason = checkCsrf(req, url)
203
+ if (!reason) return null
204
+
205
+ return url.pathname.startsWith('/api/')
206
+ ? response.json.error(403, reason)
207
+ : response.error(reason, 403)
208
+ }
209
+
210
+ /**
211
+ * Authenticated routes only — the logout/login cases below run before the auth
212
+ * check and are deliberately kept out of this table.
213
+ *
214
+ * Every mutating endpoint is method-qualified. A bare key matches **any**
215
+ * method, which made each of these reachable by GET — and a GET is a safe
216
+ * method as far as `checkCsrf` is concerned, so the CSRF guard above cannot
217
+ * see it. `POST /api/_analytics/reset` is the same shape.
218
+ *
219
+ * `satisfies` rather than an annotation: an annotation erases the literal type,
220
+ * which would collapse every endpoint's return type back into the wide
221
+ * `ValidResponses` (and its `object` member) before `routeTable` ever sees it.
222
+ */
223
+ const dashboardRoutes = {
224
+ '/_dashboard': () => handleDashboardView(),
225
+ '/_dashboard/dashboard.js': () => handleJsAsset(),
226
+ '/api/_dashboard/sessions': (_req, url) => handleGetSessions(url),
227
+ 'POST /api/_dashboard/sessions/delete': req => handleDeleteSession(req),
228
+ 'POST /api/_dashboard/sessions/update': req => handleUpdateSession(req),
229
+ '/api/_dashboard/schema': () => handleSchema(),
230
+ '/api/_dashboard/table-data': (_req, url) => handleTableData(url),
231
+ 'POST /api/_dashboard/query': req => handleQuery(req),
232
+ 'POST /api/_dashboard/execute-action': req => handleExecuteAction(req),
233
+ } satisfies PluginRouteTable
234
+
235
+ const dispatchDashboardRoute = routeTable(dashboardRoutes)
236
+
237
+ /**
238
+ * Three genuinely different shapes, so a union rather than a single type: the
239
+ * shell is a `Response`, `dashboard.js` is served straight off disk as a
240
+ * `Bun.BunFile` once it has been bundled and cached, and every `/api/` endpoint
241
+ * answers with the JSON envelope. `processResponse` in `router.ts` serialises
242
+ * all three — `Response` passes through, a `Blob` goes to `ETag.sendFile`, and
243
+ * a `JsonResponseData` is stamped with the elapsed time and JSON-encoded.
244
+ */
245
+ export type DashboardResponse =
246
+ | Response
247
+ | Bun.BunFile
248
+ | JsonResponseData<unknown>
249
+
250
+ export async function handleDashboardRequest(
251
+ req: Request,
252
+ ): Promise<DashboardResponse | null> {
253
+ const url = new URL(req.url)
254
+ const path = url.pathname
255
+
256
+ if (!isDashboardPath(path)) return null
257
+
258
+ // Auth first, so a cross-origin probe from an unauthenticated peer gets the
259
+ // same 404/401 as any other unauthenticated request rather than a 403 that
260
+ // confirms the console is mounted here.
261
+ const authBlock = await checkAuthMiddleware(req, path)
262
+ if (authBlock) return authBlock
263
+
264
+ const csrfBlock = checkCsrfMiddleware(req, url)
265
+ if (csrfBlock) return csrfBlock
266
+
267
+ return dispatchDashboardRoute(req, url)
268
+ }
package/src/shell.tsx ADDED
@@ -0,0 +1,137 @@
1
+ import { renderDatabaseBrowser } from './components/DBBrowser'
2
+ import { renderLogsPanel } from './components/LogsPanel'
3
+ import { renderSessionsPanel } from './components/SessionsPanel'
4
+ import { renderStatsPanel } from './components/StatsPanel'
5
+ import { renderTopPagesPanel } from './components/TopPagesPanel'
6
+
7
+ /**
8
+ * Nav items drive the existing client-side `switchTab`, which finds buttons by
9
+ * the `.tab-btn` class and reads the target id out of the onclick attribute.
10
+ * Keeping that contract lets the chrome be replaced without touching the ~3k
11
+ * lines of panel client code.
12
+ */
13
+ const NAV = [
14
+ {
15
+ group: 'Observability',
16
+ items: [
17
+ { id: 'stats', label: 'Overview' },
18
+ { id: 'top-pages', label: 'Traffic' },
19
+ { id: 'logs', label: 'Logs' },
20
+ ],
21
+ },
22
+ {
23
+ group: 'Data',
24
+ items: [
25
+ { id: 'database', label: 'Database' },
26
+ { id: 'sessions', label: 'Sessions' },
27
+ ],
28
+ },
29
+ ]
30
+
31
+ function NavItem({ id, label }: { id: string; label: string }) {
32
+ return (
33
+ <button
34
+ type="button"
35
+ class={id === 'stats' ? 'tab-btn active' : 'tab-btn'}
36
+ onclick={`switchTab('${id}')`}>
37
+ <span class="nav-dot"></span>
38
+ <span>{label}</span>
39
+ </button>
40
+ )
41
+ }
42
+
43
+ export default function Dashboard() {
44
+ return (
45
+ <html lang="en">
46
+ <head>
47
+ <meta charSet="UTF-8" />
48
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
49
+ <meta name="color-scheme" content="light dark" />
50
+ <title>Bakery Console</title>
51
+ <link rel="stylesheet" href="/_dashboard/style.css" />
52
+ </head>
53
+ <body>
54
+ <div class="console">
55
+ <aside class="rail" id="rail">
56
+ <div class="rail-brand">
57
+ <div class="rail-mark">B</div>
58
+ <span class="rail-name">Bakery Console</span>
59
+ </div>
60
+
61
+ {NAV.map(section => (
62
+ <nav class="rail-group">
63
+ <div class="rail-group-label">{section.group}</div>
64
+ {section.items.map(item => (
65
+ <NavItem id={item.id} label={item.label} />
66
+ ))}
67
+ </nav>
68
+ ))}
69
+
70
+ <div class="rail-foot">
71
+ <span>Bakery</span>
72
+ <span id="rail-version">v3</span>
73
+ </div>
74
+ </aside>
75
+
76
+ <header class="bar">
77
+ <div class="crumbs">
78
+ <button
79
+ type="button"
80
+ class="btn rail-toggle"
81
+ onclick="document.getElementById('rail').classList.toggle('open')"
82
+ aria-label="Toggle navigation">
83
+
84
+ </button>
85
+ <span>Console</span>
86
+ <span class="sep">/</span>
87
+ <strong id="crumb-current">Overview</strong>
88
+ </div>
89
+
90
+ <div class="bar-actions">
91
+ <div class="status-indicator" id="server-status-indicator">
92
+ <span class="status-dot" id="server-status-dot"></span>
93
+ <span id="server-status-text">Connecting…</span>
94
+ </div>
95
+
96
+ <div
97
+ class="profile-dropdown-wrapper"
98
+ id="profile-dropdown-wrapper">
99
+ <button
100
+ type="button"
101
+ class="profile-trigger-btn"
102
+ onclick="toggleProfileDropdown(event)"
103
+ aria-label="Account menu">
104
+ <span class="profile-avatar">A</span>
105
+ <span aria-hidden="true">▾</span>
106
+ </button>
107
+
108
+ <div class="profile-menu" id="profile-menu">
109
+ <div class="profile-header-info">
110
+ <span class="profile-admin-name">Administrator</span>
111
+ </div>
112
+ {/* No sign-out: the console does not own sessions. The host
113
+ application authenticates, via the authorize predicate. */}
114
+ <button
115
+ type="button"
116
+ onclick="resetAnalytics(); toggleProfileDropdown(event);">
117
+ <span>Reset analytics</span>
118
+ </button>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </header>
123
+
124
+ <main>
125
+ {renderStatsPanel()}
126
+ {renderTopPagesPanel()}
127
+ {renderSessionsPanel()}
128
+ {renderDatabaseBrowser()}
129
+ {renderLogsPanel()}
130
+ </main>
131
+ </div>
132
+
133
+ <script src="/_dashboard/dashboard.js"></script>
134
+ </body>
135
+ </html>
136
+ )
137
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Fixtures shared by this plugin's test files.
3
+ *
4
+ * Deliberately not named `*.test.ts`: nothing here runs on its own, and Bun
5
+ * would collect it as an empty suite.
6
+ */
7
+
8
+ /**
9
+ * A database stand-in that records what it was asked to do and does none of it.
10
+ *
11
+ * The assertion that matters in both suites is that the recorded list stays
12
+ * *empty* — a rejection that arrives after the truncate is not a rejection — so
13
+ * the stub must reach no real database and must log every method the endpoints
14
+ * can call. `setup.test.ts` (routing and CSRF) and `endpoints/database.test.ts`
15
+ * (the write gate) each kept their own copy, and the copies had already
16
+ * diverged: one was missing `insert` and `query` entirely.
17
+ *
18
+ * A factory rather than a shared singleton. Bun loads every test file into one
19
+ * process, so a module-level array would be shared state between two suites
20
+ * that reset it on different hooks; each caller gets its own.
21
+ */
22
+ export function createStubDb() {
23
+ const calls: string[] = []
24
+
25
+ const db = {
26
+ truncate: async (table: string) => {
27
+ calls.push(`truncate:${table}`)
28
+ },
29
+ remove: async (table: string, rowid: unknown) => {
30
+ calls.push(`remove:${table}:${rowid}`)
31
+ },
32
+ insert: async (table: string) => {
33
+ calls.push(`insert:${table}`)
34
+ },
35
+ query: (sql: string) => ({
36
+ all: async () => {
37
+ calls.push(`all:${sql}`)
38
+ return []
39
+ },
40
+ run: async () => {
41
+ calls.push(`run:${sql}`)
42
+ return { lastInsertRowid: 1, changes: 1 }
43
+ },
44
+ }),
45
+ }
46
+
47
+ return {
48
+ db,
49
+ calls,
50
+ /** Emptied in place, so the array identity a suite captured stays valid. */
51
+ reset: () => {
52
+ calls.length = 0
53
+ },
54
+ }
55
+ }