@bakery-framework/plugin-analytics 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-analytics",
3
- "version": "1.2.3",
3
+ "version": "2.0.0-alpha.11",
4
4
  "description": "Bakery analytics plugin.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -25,6 +25,7 @@
25
25
  "main": "./src/index.ts",
26
26
  "exports": {
27
27
  ".": "./src/index.ts",
28
+ "./setup": "./src/setup.ts",
28
29
  "./stats": "./src/endpoints/stats.ts",
29
30
  "./package.json": "./package.json"
30
31
  },
@@ -34,9 +35,9 @@
34
35
  "!src/tests"
35
36
  ],
36
37
  "dependencies": {
37
- "@bakery-framework/core": "^1.0.0"
38
+ "@bakery-framework/core": "^2.0.0-alpha.11"
38
39
  },
39
40
  "engines": {
40
- "bun": ">=1.3.14"
41
+ "bun": ">=1.4.0"
41
42
  }
42
43
  }
@@ -1,7 +1,11 @@
1
1
  import { Bakery } from '@bakery-framework/core/core/bakery'
2
- import { DASHPASS_SESSION_KEY, Session } from '@bakery-framework/core/session'
2
+ import { Session } from '@bakery-framework/core/session'
3
3
  import type { JsonResponseData } from '@bakery-framework/core/utils/common'
4
- import { response } from '@bakery-framework/core/utils/http'
4
+ import {
5
+ type AuthorizeFn,
6
+ requestHasCredential,
7
+ response,
8
+ } from '@bakery-framework/core/utils/http'
5
9
  import * as core from '../core'
6
10
  import { saveAnalyticsData } from '../storage-sqlite'
7
11
  import { timescaleToMs } from '../timescale'
@@ -77,14 +81,49 @@ export function computeStats(
77
81
  export type AnalyticsStats = ReturnType<typeof computeStats>
78
82
 
79
83
  /**
80
- * True when the caller may read analytics. Fails **closed** when DASHPASS is
81
- * unset, matching the dashboard's behaviour this previously returned
82
- * "authorized" in that case, so the documented "disabled" posture actually
83
- * exposed process stats and top pages to anyone.
84
+ * A request predicate, for apps that gate by their own roles rather than (or
85
+ * as well as) a shared key. Returning `true` admits; throwing or returning
86
+ * anything else denies (convention 2).
87
+ *
88
+ * Re-exported from core rather than declared here: the dashboard and
89
+ * db-explorer plugins need the same type, and three byte-identical copies of a
90
+ * security type drift the way the guards themselves already had.
84
91
  */
85
- export function isAnalyticsAuthorized(req: Request): boolean {
86
- if (!process.env.DASHPASS) return false
87
- return Boolean(req.session?.get(DASHPASS_SESSION_KEY))
92
+ export type { AuthorizeFn } from '@bakery-framework/core/utils/http'
93
+
94
+ /**
95
+ * Analytics owns the auth for its endpoints, and the dashboard delegates to
96
+ * it (`isAnalyticsAuthorized`) — the analytics key *is* the dashboard key.
97
+ * Two doors, both fail closed and both off until configured:
98
+ *
99
+ * - the shared `credential` (`x-analytics-key`, Bearer, or `?analytics-key=`)
100
+ * - an optional `authorize(req)` predicate for role-based access
101
+ *
102
+ * Neither configured means analytics is closed to everyone, which is the safe
103
+ * default. Note `isAnalyticsAuthorized` is sync-fast on the credential path
104
+ * and only awaits when a predicate is present — the websocket `canHandle`
105
+ * needs a boolean, so a predicate makes the check async there too.
106
+ */
107
+ let credential: string | undefined
108
+ let authorizeFn: AuthorizeFn | undefined
109
+
110
+ export function setAnalyticsCredential(value: string | undefined): void {
111
+ credential = value
112
+ }
113
+
114
+ export function setAnalyticsAuthorize(fn: AuthorizeFn | undefined): void {
115
+ authorizeFn = fn
116
+ }
117
+
118
+ export async function isAnalyticsAuthorized(req: Request): Promise<boolean> {
119
+ if (requestHasCredential(req, credential, 'analytics-key')) return true
120
+ if (!authorizeFn) return false
121
+ try {
122
+ return (await authorizeFn(req)) === true
123
+ } catch {
124
+ // A predicate that throws is indeterminate, and indeterminate is denied.
125
+ return false
126
+ }
88
127
  }
89
128
 
90
129
  /**
@@ -95,9 +134,13 @@ export function isAnalyticsAuthorized(req: Request): boolean {
95
134
  * the envelope carries no `data`, which is what makes it assignable into every
96
135
  * caller's own payload type.
97
136
  */
98
- function checkDashpassAuth(req: Request): JsonResponseData<undefined> | null {
99
- if (isAnalyticsAuthorized(req)) return null
100
- return process.env.DASHPASS
137
+ async function checkAnalyticsAuth(
138
+ req: Request,
139
+ ): Promise<JsonResponseData<undefined> | null> {
140
+ if (await isAnalyticsAuthorized(req)) return null
141
+ // Armed-but-unauthorised is a 401; nothing configured is a 404 that does
142
+ // not advertise the endpoint at all.
143
+ return credential || authorizeFn
101
144
  ? response.json.error<undefined>(401, 'Unauthorized')
102
145
  : response.json.error<undefined>(404, 'Not Found')
103
146
  }
@@ -105,7 +148,7 @@ function checkDashpassAuth(req: Request): JsonResponseData<undefined> | null {
105
148
  export async function handleResetRequest(
106
149
  req: Request,
107
150
  ): Promise<JsonResponseData<undefined>> {
108
- const authError = checkDashpassAuth(req)
151
+ const authError = await checkAnalyticsAuth(req)
109
152
  if (authError) return authError
110
153
 
111
154
  core.history1m.length = 0
@@ -124,7 +167,7 @@ export async function handleStatsRequest(
124
167
  req: Request,
125
168
  url: URL,
126
169
  ): Promise<JsonResponseData<AnalyticsStats | undefined>> {
127
- const authError = checkDashpassAuth(req)
170
+ const authError = await checkAnalyticsAuth(req)
128
171
  if (authError) return authError
129
172
 
130
173
  const timescale = url.searchParams.get('timescale') || '1m'
@@ -8,8 +8,11 @@ export class AnalyticsWSHandler extends WebSocketHandler {
8
8
  // The upgrade is dispatched before any plugin hook runs, so the auth check
9
9
  // has to live here. Without it this socket served the same payload the HTTP
10
10
  // stats endpoint guards — and pushed it live every second.
11
- static canHandle(path: string, req?: Request): boolean {
11
+ static async canHandle(path: string, req?: Request): Promise<boolean> {
12
12
  if (path !== '/_analytics_ws') return false
13
+ // Async because the auth may run an `authorize` predicate; the registry
14
+ // awaits a promise-returning `canHandle`. A browser cannot set a header on
15
+ // a WebSocket, so the credential arrives as `?analytics-key=`.
13
16
  return req ? isAnalyticsAuthorized(req) : false
14
17
  }
15
18
 
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { definePlugin } from '@bakery-framework/core/plugins'
2
+ import { parsedUrl } from '@bakery-framework/core/utils/http'
2
3
  import { recordErrorPageHit, recordRouteHit } from './core'
3
4
 
4
5
  export {
@@ -15,15 +16,44 @@ export {
15
16
  recordRouteHit,
16
17
  } from './core'
17
18
 
18
- export default function analyticsPlugin() {
19
+ export type { AuthorizeFn } from './endpoints/stats'
20
+
21
+ export interface AnalyticsPluginOptions {
22
+ /**
23
+ * A shared access key for the analytics endpoints and websocket, typically
24
+ * from the environment:
25
+ *
26
+ * ```ts
27
+ * analyticsPlugin({ credential: import.meta.env.ANALYTICS_KEY })
28
+ * ```
29
+ *
30
+ * Presented as `Authorization: Bearer`, an `x-analytics-key` header, or an
31
+ * `?analytics-key=` query. Checked in constant time; unset or empty means
32
+ * this door is closed. The same key gates the dashboard, which delegates
33
+ * its auth here.
34
+ */
35
+ credential?: string
36
+
37
+ /**
38
+ * A request predicate for role-based access, as an alternative or addition
39
+ * to the shared key: `authorize: req => req.session.get('role') === 'admin'`.
40
+ * Either door admits; both fail closed. With neither, analytics is off.
41
+ */
42
+ authorize?: import('./endpoints/stats').AuthorizeFn
43
+ }
44
+
45
+ export default function analyticsPlugin(options: AnalyticsPluginOptions = {}) {
19
46
  return definePlugin({
20
47
  name: 'analytics',
21
48
  async setup() {
22
49
  const { setupAnalytics } = await import('./setup')
23
- setupAnalytics()
50
+ setupAnalytics({
51
+ credential: options.credential,
52
+ authorize: options.authorize,
53
+ })
24
54
  },
25
55
  onRoute(req) {
26
- const url: URL = (req as any).__parsedUrl || new URL(req.url)
56
+ const url = parsedUrl(req)
27
57
  recordRouteHit(req.method, url.pathname, url.search)
28
58
  },
29
59
  onStart: async server => {
package/src/setup.ts CHANGED
@@ -10,7 +10,13 @@ import { response } from '@bakery-framework/core/utils/http'
10
10
  import * as core from './core'
11
11
  import { BOOT_MAX_ITEMS } from './core'
12
12
  import type { AnalyticsStats } from './endpoints/stats'
13
- import { handleResetRequest, handleStatsRequest } from './endpoints/stats'
13
+ import {
14
+ type AuthorizeFn,
15
+ handleResetRequest,
16
+ handleStatsRequest,
17
+ setAnalyticsAuthorize,
18
+ setAnalyticsCredential,
19
+ } from './endpoints/stats'
14
20
  import { AnalyticsWSHandler } from './endpoints/websocket'
15
21
  import * as storageSqlite from './storage-sqlite'
16
22
 
@@ -179,7 +185,53 @@ export function handleAnalyticsRequest(
179
185
  return analyticsRoutes(req)
180
186
  }
181
187
 
182
- export function setupAnalytics() {
188
+ let registered = false
189
+
190
+ export interface AnalyticsAuthOptions {
191
+ credential?: string
192
+ authorize?: AuthorizeFn
193
+ }
194
+
195
+ /**
196
+ * The auth half of `setupAnalytics`, split out from the once-only half.
197
+ *
198
+ * Auth is (re)applied on every call — last config wins — so the dashboard
199
+ * bringing analytics up with the shared key overrides a bare
200
+ * `analyticsPlugin()`, whichever order they registered in.
201
+ *
202
+ * "Whichever order" is what the `!== undefined` buys, and it is the whole
203
+ * reason each option is applied conditionally rather than assigned straight
204
+ * through. Both plugins forward their options here and an application
205
+ * configures whichever of the two it thinks of as the console, so under a
206
+ * plain assignment the answer would depend on registration order: in
207
+ * `apps/example`, `analyticsPlugin({ credential })` runs after
208
+ * `dashboardPlugin({ authorize })` and would have wiped the predicate,
209
+ * shutting the console it was registered to open. A call that carries a value
210
+ * wins; a bare call is a no-op against auth rather than a silent disarm.
211
+ * Turning a door off means not registering the plugin, or passing the empty
212
+ * string — never omitting the option.
213
+ *
214
+ * It is separate, and exported, because `setupAnalytics` cannot be called from
215
+ * a test: it also registers two handlers, installs a shutdown hook and kicks
216
+ * off a data load, none of them restorable (convention 9).
217
+ */
218
+ export function applyAnalyticsAuth(options: AnalyticsAuthOptions): void {
219
+ if (options.credential !== undefined) {
220
+ setAnalyticsCredential(options.credential)
221
+ }
222
+ if (options.authorize !== undefined) setAnalyticsAuthorize(options.authorize)
223
+ }
224
+
225
+ export function setupAnalytics(options: AnalyticsAuthOptions = {}) {
226
+ applyAnalyticsAuth(options)
227
+
228
+ // The rest runs once. Analytics is now a hard dependency of the dashboard,
229
+ // so both may set it up in one process; the handler registrations are
230
+ // idempotent but the shutdown hook and data load are not, and a doubled
231
+ // load would race two reads of the same file.
232
+ if (registered) return
233
+ registered = true
234
+
183
235
  Bakery.handlers.fetch.set(AnalyticsHandler, 110)
184
236
  Bakery.handlers.websocket.set(AnalyticsWSHandler)
185
237
  void loadAnalyticsData()