@bakery-framework/plugin-dashboard 2.0.0-alpha.1 → 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
package/src/shell.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getFrameworkVersion } from '@bakery-framework/core'
|
|
2
|
+
import { Bakery } from '@bakery-framework/core/core/bakery'
|
|
1
3
|
import { renderDatabaseBrowser } from './components/DBBrowser'
|
|
2
4
|
import { renderLogsPanel } from './components/LogsPanel'
|
|
3
5
|
import { renderSessionsPanel } from './components/SessionsPanel'
|
|
@@ -10,25 +12,78 @@ import { renderTopPagesPanel } from './components/TopPagesPanel'
|
|
|
10
12
|
* Keeping that contract lets the chrome be replaced without touching the ~3k
|
|
11
13
|
* lines of panel client code.
|
|
12
14
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Is anything serving `/_db`?
|
|
17
|
+
*
|
|
18
|
+
* Asked of the registry's *declarations*, never by importing db-explorer: a
|
|
19
|
+
* plugin-to-plugin import is a package-graph edge, and
|
|
20
|
+
* `tests/conventions.test.ts` allows exactly one of those (this package →
|
|
21
|
+
* analytics). `Handler.namespace` is the declaration — any handler that owns
|
|
22
|
+
* `/_db` as a surface gets the link, including an application serving its own
|
|
23
|
+
* explorer there.
|
|
24
|
+
*
|
|
25
|
+
* This replaces a behavioural probe that called every handler's
|
|
26
|
+
* `canHandle('/_db')` with a control path to exclude the priority-0 catch-all.
|
|
27
|
+
* The probe worked and its `as any` was the tell: the registry could not say
|
|
28
|
+
* what a handler serves, so the question had to be asked by experiment. Now it
|
|
29
|
+
* can — `list()` is typed `typeof Handler[]`, so this reads with no cast, and
|
|
30
|
+
* a second plugin wanting a console entry declares a namespace rather than
|
|
31
|
+
* copying a probe.
|
|
32
|
+
*/
|
|
33
|
+
function explorerIsMounted(): boolean {
|
|
34
|
+
return Bakery.handlers.fetch.list().some(h => h.namespace === '/_db')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface NavEntry {
|
|
38
|
+
id: string
|
|
39
|
+
label: string
|
|
40
|
+
/** Present when this entry leaves the console rather than switching a tab. */
|
|
41
|
+
href?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function navSections(explorerMounted: boolean): {
|
|
45
|
+
group: string
|
|
46
|
+
items: NavEntry[]
|
|
47
|
+
}[] {
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
group: 'Observability',
|
|
51
|
+
items: [
|
|
52
|
+
{ id: 'stats', label: 'Overview' },
|
|
53
|
+
{ id: 'top-pages', label: 'Traffic' },
|
|
54
|
+
{ id: 'logs', label: 'Logs' },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
group: 'Data',
|
|
59
|
+
items: [
|
|
60
|
+
// The console does not browse the database any more, so Database is a
|
|
61
|
+
// way *out* of it when the explorer is mounted — a link, not a tab.
|
|
62
|
+
// Without it the entry stays a tab showing the panel that explains
|
|
63
|
+
// where the editor went and how to get it back; an entry that silently
|
|
64
|
+
// navigates to a 404 would be worse than either.
|
|
65
|
+
explorerMounted
|
|
66
|
+
? { id: 'database', label: 'Database', href: '/_db' }
|
|
67
|
+
: { id: 'database', label: 'Database' },
|
|
68
|
+
{ id: 'sessions', label: 'Sessions' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function NavItem({ id, label, href }: NavEntry) {
|
|
75
|
+
if (href) {
|
|
76
|
+
return (
|
|
77
|
+
<a class="tab-btn" href={href}>
|
|
78
|
+
<span class="nav-dot"></span>
|
|
79
|
+
<span>{label}</span>
|
|
80
|
+
<span class="nav-external" aria-hidden="true">
|
|
81
|
+
↗
|
|
82
|
+
</span>
|
|
83
|
+
</a>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
30
86
|
|
|
31
|
-
function NavItem({ id, label }: { id: string; label: string }) {
|
|
32
87
|
return (
|
|
33
88
|
<button
|
|
34
89
|
type="button"
|
|
@@ -41,6 +96,9 @@ function NavItem({ id, label }: { id: string; label: string }) {
|
|
|
41
96
|
}
|
|
42
97
|
|
|
43
98
|
export default function Dashboard() {
|
|
99
|
+
const explorerMounted = explorerIsMounted()
|
|
100
|
+
const NAV = navSections(explorerMounted)
|
|
101
|
+
|
|
44
102
|
return (
|
|
45
103
|
<html lang="en">
|
|
46
104
|
<head>
|
|
@@ -62,14 +120,20 @@ export default function Dashboard() {
|
|
|
62
120
|
<nav class="rail-group">
|
|
63
121
|
<div class="rail-group-label">{section.group}</div>
|
|
64
122
|
{section.items.map(item => (
|
|
65
|
-
<NavItem id={item.id} label={item.label} />
|
|
123
|
+
<NavItem id={item.id} label={item.label} href={item.href} />
|
|
66
124
|
))}
|
|
67
125
|
</nav>
|
|
68
126
|
))}
|
|
69
127
|
|
|
70
128
|
<div class="rail-foot">
|
|
71
129
|
<span>Bakery</span>
|
|
72
|
-
|
|
130
|
+
{/* Was the literal `v3`, which was never any version of anything
|
|
131
|
+
— nothing filled the id, and the framework was on 1.x when it
|
|
132
|
+
was written. `getFrameworkVersion()` reads core's own
|
|
133
|
+
manifest, which is the number this label claims to be; the
|
|
134
|
+
app's version is a different question and `BAKERY_VERSION`
|
|
135
|
+
answers that one despite its name. */}
|
|
136
|
+
<span id="rail-version">v{getFrameworkVersion()}</span>
|
|
73
137
|
</div>
|
|
74
138
|
</aside>
|
|
75
139
|
|
|
@@ -125,7 +189,7 @@ export default function Dashboard() {
|
|
|
125
189
|
{renderStatsPanel()}
|
|
126
190
|
{renderTopPagesPanel()}
|
|
127
191
|
{renderSessionsPanel()}
|
|
128
|
-
{renderDatabaseBrowser()}
|
|
192
|
+
{explorerMounted ? null : renderDatabaseBrowser()}
|
|
129
193
|
{renderLogsPanel()}
|
|
130
194
|
</main>
|
|
131
195
|
</div>
|
package/src/authorize.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { getClientIp } from '@bakery-framework/core/utils/http'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Decides whether a request may use the console.
|
|
5
|
-
*
|
|
6
|
-
* The dashboard used to run its own identity system: a shared `DASHPASS`
|
|
7
|
-
* secret, a login form, a session flag, a constant-time compare and a
|
|
8
|
-
* failed-attempt backoff map. That is a lot of security-sensitive surface for
|
|
9
|
-
* a framework to own, and it composed with nothing — an app with real users
|
|
10
|
-
* and roles still had to hand out a second, shared password.
|
|
11
|
-
*
|
|
12
|
-
* So the dashboard no longer authenticates anyone. The host application, which
|
|
13
|
-
* already knows who its users are, supplies a predicate.
|
|
14
|
-
*/
|
|
15
|
-
export type AuthorizeFn = (req: Request) => boolean | Promise<boolean>
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Addresses only. `'localhost'` used to be a member because the request's
|
|
19
|
-
* *hostname* was compared against this set as well — see below for why that is
|
|
20
|
-
* gone. A peer address is never the string `localhost`, and accepting it would
|
|
21
|
-
* mean an `X-Forwarded-For: localhost` counted as loopback under `trustProxy`.
|
|
22
|
-
*/
|
|
23
|
-
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
24
|
-
|
|
25
|
-
/** True when the request came from this machine. */
|
|
26
|
-
export function isLoopback(req: Request): boolean {
|
|
27
|
-
// The peer address is the only evidence here the client does not choose.
|
|
28
|
-
// This used to fall back to `new URL(req.url).hostname`, which Bun builds
|
|
29
|
-
// from the client's own `Host` header — and `DEFAULT_HOST` is 0.0.0.0, so a
|
|
30
|
-
// dev server listens on every interface. Any peer on the LAN could send
|
|
31
|
-
// `Host: localhost` and be handed the database browser.
|
|
32
|
-
//
|
|
33
|
-
// getClientIp reads config and the live server, either of which may be
|
|
34
|
-
// absent (tests, early boot). An address that cannot be determined is
|
|
35
|
-
// indeterminate, and an indeterminate answer is a denial — not a reason to
|
|
36
|
-
// consult something the requester controls.
|
|
37
|
-
let ip = ''
|
|
38
|
-
try {
|
|
39
|
-
ip = getClientIp(req)
|
|
40
|
-
} catch {
|
|
41
|
-
// See above: no server and no config means no evidence, which is a denial.
|
|
42
|
-
ip = ''
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return LOOPBACK.has(ip)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Fail closed. With no predicate configured the console is reachable only from
|
|
50
|
-
* loopback in development, and from nowhere in production — so forgetting to
|
|
51
|
-
* configure it cannot expose a database browser to the internet.
|
|
52
|
-
*/
|
|
53
|
-
export function defaultAuthorize(req: Request): boolean {
|
|
54
|
-
if (!import.meta.env.DEV) return false
|
|
55
|
-
return isLoopback(req)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function resolveAuthorize(authorize?: AuthorizeFn): AuthorizeFn {
|
|
59
|
-
return authorize ?? defaultAuthorize
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Run a predicate without letting a throwing one grant access.
|
|
64
|
-
*/
|
|
65
|
-
export async function isAuthorized(
|
|
66
|
-
authorize: AuthorizeFn,
|
|
67
|
-
req: Request,
|
|
68
|
-
): Promise<boolean> {
|
|
69
|
-
try {
|
|
70
|
-
return Boolean(await authorize(req))
|
|
71
|
-
} catch {
|
|
72
|
-
// An authorization check that errors is indeterminate, and an
|
|
73
|
-
// indeterminate answer is a denial.
|
|
74
|
-
return false
|
|
75
|
-
}
|
|
76
|
-
}
|