@bakery-framework/plugin-dashboard 2.0.0-alpha.3 → 2.0.0-alpha.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-dashboard",
3
- "version": "2.0.0-alpha.3",
3
+ "version": "2.0.0-alpha.5",
4
4
  "description": "Bakery dashboard plugin.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -33,8 +33,9 @@
33
33
  "!src/tests"
34
34
  ],
35
35
  "dependencies": {
36
- "@bakery-framework/core": "^2.0.0-alpha.3",
37
- "@bakery-framework/orm": "^2.0.0-alpha.3"
36
+ "@bakery-framework/core": "^2.0.0-alpha.5",
37
+ "@bakery-framework/orm": "^2.0.0-alpha.5",
38
+ "@bakery-framework/plugin-analytics": "^2.0.0-alpha.5"
38
39
  },
39
40
  "engines": {
40
41
  "bun": ">=1.3.14"
package/src/index.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { definePlugin } from '@bakery-framework/core/plugins'
2
- import type { AuthorizeFn } from './authorize'
2
+ import type { AuthorizeFn } from '@bakery-framework/core/utils/http'
3
3
 
4
- export type { AuthorizeFn } from './authorize'
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({ authorize: options.authorize })
78
+ await setupDashboard({
79
+ authorize: options.authorize,
80
+ credential: options.credential,
81
+ })
40
82
  },
41
83
  })
42
84
  }
package/src/setup.ts CHANGED
@@ -16,16 +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
- import {
24
- type AuthorizeFn,
25
- defaultAuthorize,
26
- isAuthorized,
27
- resolveAuthorize,
28
- } from './authorize'
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'
29
29
  import {
30
30
  handleExecuteAction,
31
31
  handleQuery,
@@ -87,28 +87,29 @@ export class DashboardHandler extends Handler {
87
87
  }
88
88
  }
89
89
 
90
- let authorize: AuthorizeFn = defaultAuthorize
91
-
92
90
  /**
93
- * Test seam for the module-level predicate, symmetric with `__setTestDb` in
94
- * `@bakery-framework/orm` and `__setTestConfig` in core.
91
+ * The console holds no authorization state of its own — there is no
92
+ * `__setTestAuthorize` seam here any more, because there is nothing local to
93
+ * seam. Both options land in analytics, and a test drives the door with
94
+ * `setAnalyticsAuthorize` / `setAnalyticsCredential` from
95
+ * `@bakery-framework/plugin-analytics/stats`, which is the same state the
96
+ * request guard reads.
95
97
  *
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()`.
98
+ * `resolveAuthorize` still runs here rather than in analytics, and that
99
+ * asymmetry is deliberate. Analytics on its own is closed until configured;
100
+ * the console keeps its documented default of loopback-in-development,
101
+ * because `bun create bakery` scaffolds a bare `dashboardPlugin()` and a
102
+ * console that 404s on the first `bun run dev` is the wrong first impression.
103
+ * Forwarding `defaultAuthorize` can only ever narrow — it denies in
104
+ * production and admits nothing but this machine in development.
101
105
  */
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)
106
+ export function setupDashboard(
107
+ options: { authorize?: AuthorizeFn; credential?: string } = {},
108
+ ) {
109
+ setupAnalytics({
110
+ authorize: resolveAuthorize(options.authorize),
111
+ credential: options.credential,
112
+ })
112
113
 
113
114
  setLogCallback(entry => {
114
115
  // Broadcast to the registry LiveReloadHandler actually populates. This used
@@ -173,12 +174,26 @@ async function handleJsAsset() {
173
174
  return response.type(bundleResult.content, 'text/javascript')
174
175
  }
175
176
 
177
+ /**
178
+ * One door, and it is analytics'. `isAnalyticsAuthorized` reads the same
179
+ * credential and the same predicate that gate `/api/_analytics/stats` and the
180
+ * `/_analytics_ws` socket — which the console's own client calls, so a console
181
+ * admitted by a different key than the data it renders would be half-open by
182
+ * construction.
183
+ *
184
+ * The 401/404 split is the console's and stays here: an API path says
185
+ * "Unauthorized" because a caller with a key needs to know its key was
186
+ * refused, while a page says "Not Found" because a 401 on the shell confirms
187
+ * to anyone probing that a console is mounted at this path. Analytics makes
188
+ * the same distinction along a different axis (armed → 401, unconfigured →
189
+ * 404); neither is a substitute for the other.
190
+ */
176
191
  async function checkAuthMiddleware(req: Request, path: string) {
177
192
  // Styling and script for the console are not secrets, and letting them
178
193
  // through keeps an unauthorised response from rendering unstyled.
179
194
  if (/\.(css|js)$/.test(path)) return null
180
195
 
181
- if (await isAuthorized(authorize, req)) return null
196
+ if (await isAnalyticsAuthorized(req)) return null
182
197
 
183
198
  return path.startsWith('/api/')
184
199
  ? response.error('Unauthorized', 401)
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
- }