@bakery-framework/plugin-dashboard 1.2.3 → 2.0.0-alpha.11
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/package.json +5 -4
- package/src/client/dashboard.ts +2 -65
- package/src/client/parts/effects.ts +2 -2
- package/src/client/parts/stats.ts +105 -23
- package/src/client/parts/utils.ts +13 -40
- package/src/components/DBBrowser.tsx +41 -352
- package/src/index.ts +45 -3
- package/src/setup.ts +71 -40
- package/src/shell.tsx +85 -21
- package/src/authorize.ts +0 -76
- package/src/client/parts/database.ts +0 -1176
- package/src/endpoints/database.ts +0 -211
|
@@ -1,362 +1,51 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The Database tab is a signpost, not a browser.
|
|
3
|
+
*
|
|
4
|
+
* This panel used to be the grid editor and a raw SQL prompt, backed by
|
|
5
|
+
* `POST /api/_dashboard/query` and `POST /api/_dashboard/execute-action` and
|
|
6
|
+
* gated by a single environment flag. Both endpoints are gone.
|
|
7
|
+
* `@bakery-framework/plugin-db-explorer` does the same work at `/_db` with an
|
|
8
|
+
* access level per caller instead, so the console points at it rather than
|
|
9
|
+
* shipping a second, weaker copy.
|
|
10
|
+
*
|
|
11
|
+
* Rendered **only when nothing serves `/_db`**. With the explorer mounted the
|
|
12
|
+
* nav entry links straight to it and this panel never appears — a tab whose
|
|
13
|
+
* whole content is "go there" is a click of ceremony in front of going there.
|
|
14
|
+
* Without it, this is what explains where the editor went.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately static: no fetch, no client module, nothing to escape. It is
|
|
17
|
+
* plain markup so that a tab which now only links somewhere cannot grow a
|
|
18
|
+
* data path back by accident.
|
|
19
|
+
*/
|
|
9
20
|
export function renderDatabaseBrowser() {
|
|
10
|
-
const driverName = driverNames[connection.driver] || 'Database'
|
|
11
21
|
return (
|
|
12
22
|
<div id="panel-database" class="panel">
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`}</style>
|
|
21
|
-
<div class="db-mobile-toggle">
|
|
22
|
-
<h2 class="db-browser-mobile-title">
|
|
23
|
-
<iconify-icon icon="lucide:database"></iconify-icon>
|
|
24
|
-
Database Explorer
|
|
25
|
-
</h2>
|
|
26
|
-
<button
|
|
27
|
-
type="button"
|
|
28
|
-
class="btn btn-secondary"
|
|
29
|
-
onclick="document.getElementById('db-sidebar').classList.toggle('mobile-open')">
|
|
30
|
-
<iconify-icon icon="lucide:menu"></iconify-icon>
|
|
31
|
-
<span>Tables Menu</span>
|
|
32
|
-
</button>
|
|
33
|
-
</div>
|
|
34
|
-
<div class="db-container">
|
|
35
|
-
{/* Left Sidebar: Discovered Tables & Schema Details */}
|
|
36
|
-
<div id="db-sidebar" class="sidebar glass-effect">
|
|
37
|
-
<div class="sidebar-scroll-container">
|
|
38
|
-
<div class="sidebar-search">
|
|
39
|
-
<input
|
|
40
|
-
type="text"
|
|
41
|
-
id="db-table-search"
|
|
42
|
-
class="search-input"
|
|
43
|
-
placeholder="Search tables..."
|
|
44
|
-
oninput="filterTablesList()"
|
|
45
|
-
/>
|
|
46
|
-
</div>
|
|
47
|
-
<div class="sidebar-title">
|
|
48
|
-
<span>DISCOVERED TABLES</span>
|
|
49
|
-
<span id="tables-count">(0)</span>
|
|
50
|
-
</div>
|
|
51
|
-
<ul class="table-list" id="tables-list">
|
|
52
|
-
<li class="results-empty">Scanning schema...</li>
|
|
53
|
-
</ul>
|
|
23
|
+
<div class="browser-card glass-effect">
|
|
24
|
+
<div class="browser-header">
|
|
25
|
+
<div class="table-info">
|
|
26
|
+
<h2>
|
|
27
|
+
<iconify-icon icon="lucide:database"></iconify-icon>
|
|
28
|
+
<span>Database</span>
|
|
29
|
+
</h2>
|
|
54
30
|
</div>
|
|
55
31
|
</div>
|
|
56
32
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
0 rows
|
|
69
|
-
</span>
|
|
70
|
-
</div>
|
|
71
|
-
<div class="actions-group">
|
|
72
|
-
<button
|
|
73
|
-
type="button"
|
|
74
|
-
class="btn btn-primary"
|
|
75
|
-
onclick="openInsertModal()">
|
|
76
|
-
<iconify-icon icon="lucide:plus"></iconify-icon>
|
|
77
|
-
<span>Add Row</span>
|
|
78
|
-
</button>
|
|
79
|
-
<button
|
|
80
|
-
type="button"
|
|
81
|
-
class="btn btn-secondary"
|
|
82
|
-
onclick="openImportModal()">
|
|
83
|
-
<iconify-icon icon="lucide:file-up"></iconify-icon>
|
|
84
|
-
<span>Import CSV</span>
|
|
85
|
-
</button>
|
|
86
|
-
<div class="export-dropdown-wrapper">
|
|
87
|
-
<button
|
|
88
|
-
type="button"
|
|
89
|
-
class="btn btn-secondary"
|
|
90
|
-
onclick="toggleExportMenu()">
|
|
91
|
-
<iconify-icon icon="lucide:download"></iconify-icon>
|
|
92
|
-
<span>Export ▾</span>
|
|
93
|
-
</button>
|
|
94
|
-
<div id="export-menu" class="export-menu">
|
|
95
|
-
<button type="button" onclick="exportToCSV()">
|
|
96
|
-
Export to CSV
|
|
97
|
-
</button>
|
|
98
|
-
<button type="button" onclick="exportToJSON()">
|
|
99
|
-
Export to JSON
|
|
100
|
-
</button>
|
|
101
|
-
</div>
|
|
102
|
-
</div>
|
|
103
|
-
<button
|
|
104
|
-
type="button"
|
|
105
|
-
class="btn btn-secondary btn-danger"
|
|
106
|
-
onclick="truncateCurrentTable()">
|
|
107
|
-
<iconify-icon icon="lucide:alert-triangle"></iconify-icon>
|
|
108
|
-
<span>Truncate</span>
|
|
109
|
-
</button>
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
112
|
-
|
|
113
|
-
{/* Filter Query Builder Bar */}
|
|
114
|
-
<div class="filter-builder-card glass-effect">
|
|
115
|
-
<div class="filter-builder-row">
|
|
116
|
-
<span class="filter-label">
|
|
117
|
-
<iconify-icon icon="lucide:filter"></iconify-icon>
|
|
118
|
-
<span>Filters</span>
|
|
119
|
-
</span>
|
|
120
|
-
<select id="filter-col-select" class="filter-select">
|
|
121
|
-
<option value="">-- Choose Column --</option>
|
|
122
|
-
</select>
|
|
123
|
-
<select id="filter-op-select" class="filter-select">
|
|
124
|
-
<option value="like">contains</option>
|
|
125
|
-
<option value="=">equals</option>
|
|
126
|
-
<option value=">">></option>
|
|
127
|
-
<option value="<"><</option>
|
|
128
|
-
<option value="is_null">is null</option>
|
|
129
|
-
<option value="is_not_null">is not null</option>
|
|
130
|
-
</select>
|
|
131
|
-
<input
|
|
132
|
-
type="text"
|
|
133
|
-
id="filter-val-input"
|
|
134
|
-
class="filter-input"
|
|
135
|
-
placeholder="Filter value..."
|
|
136
|
-
/>
|
|
137
|
-
<button
|
|
138
|
-
type="button"
|
|
139
|
-
class="btn btn-secondary"
|
|
140
|
-
onclick="addActiveFilter()">
|
|
141
|
-
Apply
|
|
142
|
-
</button>
|
|
143
|
-
<button
|
|
144
|
-
type="button"
|
|
145
|
-
class="btn btn-secondary"
|
|
146
|
-
onclick="clearActiveFilters()">
|
|
147
|
-
Clear All
|
|
148
|
-
</button>
|
|
149
|
-
</div>
|
|
150
|
-
<div id="active-filters-list" class="active-filters-list">
|
|
151
|
-
{/* Dynamically generated filter chips */}
|
|
152
|
-
</div>
|
|
153
|
-
</div>
|
|
154
|
-
|
|
155
|
-
{/* Pagination Controls */}
|
|
156
|
-
<div class="pagination-bar">
|
|
157
|
-
<div class="page-size-selector">
|
|
158
|
-
<span>Show</span>
|
|
159
|
-
<select
|
|
160
|
-
id="db-page-size"
|
|
161
|
-
class="filter-select"
|
|
162
|
-
onchange="changePageSize()">
|
|
163
|
-
<option value="10">10 rows</option>
|
|
164
|
-
<option value="25">25 rows</option>
|
|
165
|
-
<option value="50" selected>
|
|
166
|
-
50 rows
|
|
167
|
-
</option>
|
|
168
|
-
<option value="100">100 rows</option>
|
|
169
|
-
<option value="500">500 rows</option>
|
|
170
|
-
</select>
|
|
171
|
-
</div>
|
|
172
|
-
<div class="page-nav">
|
|
173
|
-
<button
|
|
174
|
-
type="button"
|
|
175
|
-
id="btn-page-prev"
|
|
176
|
-
class="btn btn-secondary"
|
|
177
|
-
onclick="prevPage()">
|
|
178
|
-
<iconify-icon icon="lucide:chevron-left"></iconify-icon>
|
|
179
|
-
<span>Prev</span>
|
|
180
|
-
</button>
|
|
181
|
-
<span id="db-page-info">Page 1 of 1</span>
|
|
182
|
-
<button
|
|
183
|
-
type="button"
|
|
184
|
-
id="btn-page-next"
|
|
185
|
-
class="btn btn-secondary"
|
|
186
|
-
onclick="nextPage()">
|
|
187
|
-
<span>Next</span>
|
|
188
|
-
<iconify-icon icon="lucide:chevron-right"></iconify-icon>
|
|
189
|
-
</button>
|
|
190
|
-
</div>
|
|
191
|
-
<div class="rows-meta">
|
|
192
|
-
<span id="db-rows-meta">0 rows matching filters</span>
|
|
193
|
-
</div>
|
|
194
|
-
</div>
|
|
195
|
-
|
|
196
|
-
{/* Interactive Data Table Grid */}
|
|
197
|
-
<div class="results-card glass-effect">
|
|
198
|
-
<div id="browser-grid-body">
|
|
199
|
-
<div class="results-empty">
|
|
200
|
-
<span>Loading table data...</span>
|
|
201
|
-
</div>
|
|
202
|
-
</div>
|
|
203
|
-
</div>
|
|
204
|
-
</div>
|
|
205
|
-
|
|
206
|
-
{/* SQL Terminal Console */}
|
|
207
|
-
<div class="editor-card glass-effect">
|
|
208
|
-
<div class="sql-terminal-header">
|
|
209
|
-
<h2>{driverName} SQL Terminal</h2>
|
|
210
|
-
<span class="console-mode-badge">Read/Write Mode Enabled</span>
|
|
211
|
-
</div>
|
|
212
|
-
<textarea
|
|
213
|
-
class="sql-textarea"
|
|
214
|
-
id="sql-query"
|
|
215
|
-
placeholder="SELECT * FROM users LIMIT 10;"
|
|
216
|
-
/>
|
|
217
|
-
<div class="actions-row">
|
|
218
|
-
<span class="sql-terminal-help">
|
|
219
|
-
Supports SELECT, UPDATE, INSERT, DELETE, and administrative
|
|
220
|
-
statements.
|
|
221
|
-
</span>
|
|
222
|
-
<button
|
|
223
|
-
type="button"
|
|
224
|
-
class="btn sql-terminal-run"
|
|
225
|
-
onclick="runQuery()">
|
|
226
|
-
<iconify-icon icon="lucide:play"></iconify-icon>
|
|
227
|
-
<span>Run SQL Command</span>
|
|
228
|
-
</button>
|
|
229
|
-
</div>
|
|
230
|
-
</div>
|
|
231
|
-
|
|
232
|
-
{/* SQL Terminal Output */}
|
|
233
|
-
<div class="results-card glass-effect" id="sql-console-results-card">
|
|
234
|
-
<div class="results-header">
|
|
235
|
-
<span>SQL TERMINAL OUTPUT</span>
|
|
236
|
-
<span id="results-meta"></span>
|
|
237
|
-
</div>
|
|
238
|
-
<div id="results-body">
|
|
239
|
-
<div class="results-empty">
|
|
240
|
-
<span>
|
|
241
|
-
No SQL query executed yet. Write a query above and click "Run
|
|
242
|
-
SQL Command".
|
|
243
|
-
</span>
|
|
244
|
-
</div>
|
|
245
|
-
</div>
|
|
246
|
-
</div>
|
|
247
|
-
</div>
|
|
248
|
-
</div>
|
|
249
|
-
|
|
250
|
-
{/* Dynamic Insert Modal */}
|
|
251
|
-
<div id="modal-insert" class="modal-overlay">
|
|
252
|
-
<div class="modal-card">
|
|
253
|
-
<div class="modal-header">
|
|
254
|
-
<h3>Add New Row</h3>
|
|
255
|
-
<button
|
|
256
|
-
type="button"
|
|
257
|
-
class="modal-close"
|
|
258
|
-
onclick="closeInsertModal()">
|
|
259
|
-
×
|
|
260
|
-
</button>
|
|
261
|
-
</div>
|
|
262
|
-
<form id="insert-row-form" onsubmit="submitInsertRow(event)">
|
|
263
|
-
<div id="insert-fields-container" class="modal-fields-container">
|
|
264
|
-
{/* Dynamically generated form fields */}
|
|
265
|
-
</div>
|
|
266
|
-
<div class="modal-actions">
|
|
267
|
-
<button
|
|
268
|
-
type="button"
|
|
269
|
-
class="btn btn-secondary"
|
|
270
|
-
onclick="closeInsertModal()">
|
|
271
|
-
Cancel
|
|
272
|
-
</button>
|
|
273
|
-
<button type="submit" class="btn btn-primary">
|
|
274
|
-
Add Row
|
|
275
|
-
</button>
|
|
276
|
-
</div>
|
|
277
|
-
</form>
|
|
278
|
-
</div>
|
|
279
|
-
</div>
|
|
280
|
-
|
|
281
|
-
{/* Dynamic Edit Modal */}
|
|
282
|
-
<div id="modal-edit" class="modal-overlay">
|
|
283
|
-
<div class="modal-card">
|
|
284
|
-
<div class="modal-header">
|
|
285
|
-
<h3>Edit Row Details</h3>
|
|
286
|
-
<button
|
|
287
|
-
type="button"
|
|
288
|
-
class="modal-close"
|
|
289
|
-
onclick="closeEditModal()">
|
|
290
|
-
×
|
|
291
|
-
</button>
|
|
292
|
-
</div>
|
|
293
|
-
<form id="edit-row-form" onsubmit="submitEditRow(event)">
|
|
294
|
-
<input type="hidden" id="edit-row-rowid" />
|
|
295
|
-
<div id="edit-fields-container" class="modal-fields-container">
|
|
296
|
-
{/* Dynamically generated form fields */}
|
|
297
|
-
</div>
|
|
298
|
-
<div class="modal-actions">
|
|
299
|
-
<button
|
|
300
|
-
type="button"
|
|
301
|
-
class="btn btn-secondary"
|
|
302
|
-
onclick="closeEditModal()">
|
|
303
|
-
Cancel
|
|
304
|
-
</button>
|
|
305
|
-
<button type="submit" class="btn btn-primary">
|
|
306
|
-
Save Changes
|
|
307
|
-
</button>
|
|
308
|
-
</div>
|
|
309
|
-
</form>
|
|
310
|
-
</div>
|
|
311
|
-
</div>
|
|
312
|
-
|
|
313
|
-
{/* CSV Import Modal */}
|
|
314
|
-
<div id="modal-import" class="modal-overlay">
|
|
315
|
-
<div class="modal-card">
|
|
316
|
-
<div class="modal-header">
|
|
317
|
-
<h3>
|
|
318
|
-
<iconify-icon icon="lucide:file-up"></iconify-icon>
|
|
319
|
-
<span>Bulk Import CSV Data</span>
|
|
320
|
-
</h3>
|
|
321
|
-
<button
|
|
322
|
-
type="button"
|
|
323
|
-
class="modal-close"
|
|
324
|
-
onclick="closeImportModal()">
|
|
325
|
-
×
|
|
326
|
-
</button>
|
|
327
|
-
</div>
|
|
328
|
-
<div class="modal-fields-container">
|
|
329
|
-
<p>
|
|
330
|
-
Paste CSV data below. The first row must contain column headers
|
|
331
|
-
matching the table schema. Data values are automatically parsed.
|
|
332
|
-
</p>
|
|
333
|
-
<textarea
|
|
334
|
-
id="csv-import-textarea"
|
|
335
|
-
class="sql-textarea"
|
|
336
|
-
placeholder="username,email alice,alice@example.com bob,bob@example.com"></textarea>
|
|
337
|
-
<div class="import-upload-block">
|
|
338
|
-
<span>Or upload a .csv file:</span>
|
|
339
|
-
<input
|
|
340
|
-
type="file"
|
|
341
|
-
id="csv-file-input"
|
|
342
|
-
accept=".csv"
|
|
343
|
-
onchange="handleCsvFileSelect(event)"
|
|
344
|
-
/>
|
|
345
|
-
</div>
|
|
346
|
-
</div>
|
|
33
|
+
<div class="modal-fields-container">
|
|
34
|
+
<p>
|
|
35
|
+
The database browser and editor moved to the Database Explorer
|
|
36
|
+
plugin, served at <code>/_db</code>. It browses tables, filters and
|
|
37
|
+
edits rows, and does not accept raw SQL.
|
|
38
|
+
</p>
|
|
39
|
+
<p>
|
|
40
|
+
This console no longer reads or writes table data. If{' '}
|
|
41
|
+
<code>/_db</code> is not reachable, the explorer plugin is not
|
|
42
|
+
registered in your <code>server.config.ts</code>.
|
|
43
|
+
</p>
|
|
347
44
|
<div class="modal-actions">
|
|
348
|
-
<
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
Cancel
|
|
353
|
-
</button>
|
|
354
|
-
<button
|
|
355
|
-
type="button"
|
|
356
|
-
class="btn btn-primary"
|
|
357
|
-
onclick="submitImportCsv()">
|
|
358
|
-
Import Rows
|
|
359
|
-
</button>
|
|
45
|
+
<a class="btn btn-primary" href="/_db">
|
|
46
|
+
<iconify-icon icon="lucide:external-link"></iconify-icon>
|
|
47
|
+
<span>Open Database Explorer</span>
|
|
48
|
+
</a>
|
|
360
49
|
</div>
|
|
361
50
|
</div>
|
|
362
51
|
</div>
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { definePlugin } from '@bakery-framework/core/plugins'
|
|
2
|
-
import type { AuthorizeFn } from '
|
|
2
|
+
import type { AuthorizeFn } from '@bakery-framework/core/utils/http'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Re-exported so the plugin's public surface is unchanged by the guard moving
|
|
6
|
+
* into core. It is the same type either way — an app that imported it from here
|
|
7
|
+
* keeps working, and one that reaches for `@bakery-framework/core/utils/http`
|
|
8
|
+
* directly gets the identical declaration rather than a structural twin.
|
|
9
|
+
*/
|
|
10
|
+
export type { AuthorizeFn } from '@bakery-framework/core/utils/http'
|
|
5
11
|
|
|
6
12
|
export interface DashboardPluginOptions {
|
|
7
13
|
/**
|
|
@@ -24,10 +30,43 @@ export interface DashboardPluginOptions {
|
|
|
24
30
|
*
|
|
25
31
|
* Omitted, access is limited to loopback in development and denied in
|
|
26
32
|
* production, so an unconfigured console is never exposed.
|
|
33
|
+
*
|
|
34
|
+
* Handed straight to `@bakery-framework/plugin-analytics`, which owns the
|
|
35
|
+
* door for both surfaces — so this predicate also admits to
|
|
36
|
+
* `/api/_analytics/stats` and the `/_analytics_ws` socket the console reads.
|
|
27
37
|
*/
|
|
28
38
|
authorize?: AuthorizeFn
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A shared access key, typically from the environment:
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* dashboardPlugin({ credential: import.meta.env.ANALYTICS_KEY })
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* Presented as `Authorization: Bearer`, an `x-analytics-key` header, or an
|
|
48
|
+
* `?analytics-key=` query. Checked in constant time. Unset or empty means
|
|
49
|
+
* this path is off — it never means open. Composes with `authorize`: either
|
|
50
|
+
* admits.
|
|
51
|
+
*
|
|
52
|
+
* It is the *analytics* key, not a second one: the console delegates its
|
|
53
|
+
* authorization to `@bakery-framework/plugin-analytics`, so configuring it
|
|
54
|
+
* here and configuring it on `analyticsPlugin` are the same act. Set it on
|
|
55
|
+
* either plugin — a bare call never clears what the other one set.
|
|
56
|
+
*/
|
|
57
|
+
credential?: string
|
|
29
58
|
}
|
|
30
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The operator console at `/_dashboard`.
|
|
62
|
+
*
|
|
63
|
+
* `@bakery-framework/plugin-analytics` is a hard dependency, not an optional
|
|
64
|
+
* companion: the console renders analytics, its client calls
|
|
65
|
+
* `/api/_analytics/reset` and opens `/_analytics_ws`, and registering the
|
|
66
|
+
* dashboard brings analytics' handlers up so those endpoints exist. It follows
|
|
67
|
+
* that they share one door rather than two — see `authorize` and `credential`
|
|
68
|
+
* above.
|
|
69
|
+
*/
|
|
31
70
|
export default function dashboardPlugin(options: DashboardPluginOptions = {}) {
|
|
32
71
|
const enabled = options.enabled ?? true
|
|
33
72
|
|
|
@@ -36,7 +75,10 @@ export default function dashboardPlugin(options: DashboardPluginOptions = {}) {
|
|
|
36
75
|
async setup() {
|
|
37
76
|
if (!enabled) return
|
|
38
77
|
const { setupDashboard } = await import('./setup')
|
|
39
|
-
await setupDashboard({
|
|
78
|
+
await setupDashboard({
|
|
79
|
+
authorize: options.authorize,
|
|
80
|
+
credential: options.credential,
|
|
81
|
+
})
|
|
40
82
|
},
|
|
41
83
|
})
|
|
42
84
|
}
|
package/src/setup.ts
CHANGED
|
@@ -16,22 +16,16 @@ import {
|
|
|
16
16
|
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
17
17
|
import { Try } from '@bakery-framework/core/utils/common'
|
|
18
18
|
import {
|
|
19
|
+
type AuthorizeFn,
|
|
19
20
|
checkCsrf,
|
|
20
21
|
injectIfHtml,
|
|
22
|
+
resolveAuthorize,
|
|
21
23
|
response,
|
|
22
24
|
} from '@bakery-framework/core/utils/http'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
resolveAuthorize,
|
|
28
|
-
} from './authorize'
|
|
29
|
-
import {
|
|
30
|
-
handleExecuteAction,
|
|
31
|
-
handleQuery,
|
|
32
|
-
handleSchema,
|
|
33
|
-
handleTableData,
|
|
34
|
-
} from './endpoints/database'
|
|
25
|
+
// The console's door is analytics'. This is the one plugin-to-plugin edge in
|
|
26
|
+
// the tree, allow-listed by name in `tests/conventions.test.ts`.
|
|
27
|
+
import { setupAnalytics } from '@bakery-framework/plugin-analytics/setup'
|
|
28
|
+
import { isAnalyticsAuthorized } from '@bakery-framework/plugin-analytics/stats'
|
|
35
29
|
import {
|
|
36
30
|
handleDeleteSession,
|
|
37
31
|
handleGetSessions,
|
|
@@ -63,6 +57,8 @@ function isDashboardPath(path: string): boolean {
|
|
|
63
57
|
}
|
|
64
58
|
|
|
65
59
|
export class DashboardHandler extends Handler {
|
|
60
|
+
static override namespace = '/_dashboard'
|
|
61
|
+
|
|
66
62
|
static canHandle(path: string) {
|
|
67
63
|
// Deliberately narrow. This handler outranks every content handler, so
|
|
68
64
|
// anything it claims can never reach the route mount below — assets must
|
|
@@ -87,28 +83,29 @@ export class DashboardHandler extends Handler {
|
|
|
87
83
|
}
|
|
88
84
|
}
|
|
89
85
|
|
|
90
|
-
let authorize: AuthorizeFn = defaultAuthorize
|
|
91
|
-
|
|
92
86
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
87
|
+
* The console holds no authorization state of its own — there is no
|
|
88
|
+
* `__setTestAuthorize` seam here any more, because there is nothing local to
|
|
89
|
+
* seam. Both options land in analytics, and a test drives the door with
|
|
90
|
+
* `setAnalyticsAuthorize` / `setAnalyticsCredential` from
|
|
91
|
+
* `@bakery-framework/plugin-analytics/stats`, which is the same state the
|
|
92
|
+
* request guard reads.
|
|
95
93
|
*
|
|
96
|
-
* `
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* `
|
|
94
|
+
* `resolveAuthorize` still runs here rather than in analytics, and that
|
|
95
|
+
* asymmetry is deliberate. Analytics on its own is closed until configured;
|
|
96
|
+
* the console keeps its documented default of loopback-in-development,
|
|
97
|
+
* because `bun create bakery` scaffolds a bare `dashboardPlugin()` and a
|
|
98
|
+
* console that 404s on the first `bun run dev` is the wrong first impression.
|
|
99
|
+
* Forwarding `defaultAuthorize` can only ever narrow — it denies in
|
|
100
|
+
* production and admits nothing but this machine in development.
|
|
101
101
|
*/
|
|
102
|
-
export function
|
|
103
|
-
authorize =
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function setupDashboard(options: { authorize?: AuthorizeFn } = {}) {
|
|
111
|
-
authorize = resolveAuthorize(options.authorize)
|
|
102
|
+
export function setupDashboard(
|
|
103
|
+
options: { authorize?: AuthorizeFn; credential?: string } = {},
|
|
104
|
+
) {
|
|
105
|
+
setupAnalytics({
|
|
106
|
+
authorize: resolveAuthorize(options.authorize),
|
|
107
|
+
credential: options.credential,
|
|
108
|
+
})
|
|
112
109
|
|
|
113
110
|
setLogCallback(entry => {
|
|
114
111
|
// Broadcast to the registry LiveReloadHandler actually populates. This used
|
|
@@ -173,12 +170,26 @@ async function handleJsAsset() {
|
|
|
173
170
|
return response.type(bundleResult.content, 'text/javascript')
|
|
174
171
|
}
|
|
175
172
|
|
|
173
|
+
/**
|
|
174
|
+
* One door, and it is analytics'. `isAnalyticsAuthorized` reads the same
|
|
175
|
+
* credential and the same predicate that gate `/api/_analytics/stats` and the
|
|
176
|
+
* `/_analytics_ws` socket — which the console's own client calls, so a console
|
|
177
|
+
* admitted by a different key than the data it renders would be half-open by
|
|
178
|
+
* construction.
|
|
179
|
+
*
|
|
180
|
+
* The 401/404 split is the console's and stays here: an API path says
|
|
181
|
+
* "Unauthorized" because a caller with a key needs to know its key was
|
|
182
|
+
* refused, while a page says "Not Found" because a 401 on the shell confirms
|
|
183
|
+
* to anyone probing that a console is mounted at this path. Analytics makes
|
|
184
|
+
* the same distinction along a different axis (armed → 401, unconfigured →
|
|
185
|
+
* 404); neither is a substitute for the other.
|
|
186
|
+
*/
|
|
176
187
|
async function checkAuthMiddleware(req: Request, path: string) {
|
|
177
188
|
// Styling and script for the console are not secrets, and letting them
|
|
178
189
|
// through keeps an unauthorised response from rendering unstyled.
|
|
179
190
|
if (/\.(css|js)$/.test(path)) return null
|
|
180
191
|
|
|
181
|
-
if (await
|
|
192
|
+
if (await isAnalyticsAuthorized(req)) return null
|
|
182
193
|
|
|
183
194
|
return path.startsWith('/api/')
|
|
184
195
|
? response.error('Unauthorized', 401)
|
|
@@ -194,9 +205,14 @@ async function checkAuthMiddleware(req: Request, path: string) {
|
|
|
194
205
|
*
|
|
195
206
|
* Necessary but **not sufficient on its own**. `SAFE_METHODS` exempts GET, and
|
|
196
207
|
* `processBody` reads a GET's query string as the body, so a plain
|
|
197
|
-
* `<img src="/api/_dashboard/
|
|
198
|
-
*
|
|
199
|
-
*
|
|
208
|
+
* `<img src="/api/_dashboard/sessions/delete?id=victim">` sails past this
|
|
209
|
+
* guard. The mutating keys in the table below are method-qualified for exactly
|
|
210
|
+
* that reason; neither half closes the hole alone.
|
|
211
|
+
*
|
|
212
|
+
* The example used to be `execute-action?action=truncate`, which was the worst
|
|
213
|
+
* of them — a link that emptied a table. That endpoint is gone with the grid
|
|
214
|
+
* editor, and the two session routes are what is left to protect. The hazard
|
|
215
|
+
* is unchanged; only the blast radius shrank.
|
|
200
216
|
*/
|
|
201
217
|
function checkCsrfMiddleware(req: Request, url: URL): DashboardResponse | null {
|
|
202
218
|
const reason = checkCsrf(req, url)
|
|
@@ -226,10 +242,6 @@ const dashboardRoutes = {
|
|
|
226
242
|
'/api/_dashboard/sessions': (_req, url) => handleGetSessions(url),
|
|
227
243
|
'POST /api/_dashboard/sessions/delete': req => handleDeleteSession(req),
|
|
228
244
|
'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
245
|
} satisfies PluginRouteTable
|
|
234
246
|
|
|
235
247
|
const dispatchDashboardRoute = routeTable(dashboardRoutes)
|
|
@@ -264,5 +276,24 @@ export async function handleDashboardRequest(
|
|
|
264
276
|
const csrfBlock = checkCsrfMiddleware(req, url)
|
|
265
277
|
if (csrfBlock) return csrfBlock
|
|
266
278
|
|
|
267
|
-
|
|
279
|
+
const result = await dispatchDashboardRoute(req, url)
|
|
280
|
+
if (result) return result
|
|
281
|
+
|
|
282
|
+
// `dispatch` answers `null` for a path no key matched, and a handler
|
|
283
|
+
// returning `null` means "not mine" — which core turns into **204 No
|
|
284
|
+
// Content**. Every unmatched request under this namespace therefore answered
|
|
285
|
+
// 204: a status that reads as success and carries nothing.
|
|
286
|
+
//
|
|
287
|
+
// Retiring the write endpoints is what made that matter. A script still
|
|
288
|
+
// posting to `/api/_dashboard/execute-action` was told 204, took it for
|
|
289
|
+
// success, and silently changed nothing. For a route that has been deleted
|
|
290
|
+
// that is the worst available answer.
|
|
291
|
+
//
|
|
292
|
+
// **Only under `/api/`, and that boundary is load-bearing.** The stylesheet
|
|
293
|
+
// is served by `mountRoutes`, not by a key in the table above, so `null` is
|
|
294
|
+
// precisely how `/_dashboard/style.css` *falls through* to the static
|
|
295
|
+
// pipeline. A blanket 404 here claims it and the console loads unstyled —
|
|
296
|
+
// which is what happened when this was first written without the condition.
|
|
297
|
+
if (path.startsWith('/api/')) return response.error('Not Found', 404)
|
|
298
|
+
return null
|
|
268
299
|
}
|