@bakery-framework/plugin-analytics 1.2.3 → 2.0.0-alpha.2

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.2",
4
4
  "description": "Bakery analytics plugin.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -34,7 +34,7 @@
34
34
  "!src/tests"
35
35
  ],
36
36
  "dependencies": {
37
- "@bakery-framework/core": "^1.0.0"
37
+ "@bakery-framework/core": "^1.2.3"
38
38
  },
39
39
  "engines": {
40
40
  "bun": ">=1.3.14"
@@ -1,7 +1,10 @@
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
+ requestHasCredential,
6
+ response,
7
+ } from '@bakery-framework/core/utils/http'
5
8
  import * as core from '../core'
6
9
  import { saveAnalyticsData } from '../storage-sqlite'
7
10
  import { timescaleToMs } from '../timescale'
@@ -77,14 +80,45 @@ export function computeStats(
77
80
  export type AnalyticsStats = ReturnType<typeof computeStats>
78
81
 
79
82
  /**
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.
83
+ * A request predicate, for apps that gate by their own roles rather than (or
84
+ * as well as) a shared key. Returning `true` admits; throwing or returning
85
+ * anything else denies (convention 2).
84
86
  */
85
- export function isAnalyticsAuthorized(req: Request): boolean {
86
- if (!process.env.DASHPASS) return false
87
- return Boolean(req.session?.get(DASHPASS_SESSION_KEY))
87
+ export type AuthorizeFn = (req: Request) => boolean | Promise<boolean>
88
+
89
+ /**
90
+ * Analytics owns the auth for its endpoints, and the dashboard delegates to
91
+ * it (`isAnalyticsAuthorized`) — the analytics key *is* the dashboard key.
92
+ * Two doors, both fail closed and both off until configured:
93
+ *
94
+ * - the shared `credential` (`x-analytics-key`, Bearer, or `?analytics-key=`)
95
+ * - an optional `authorize(req)` predicate for role-based access
96
+ *
97
+ * Neither configured means analytics is closed to everyone, which is the safe
98
+ * default. Note `isAnalyticsAuthorized` is sync-fast on the credential path
99
+ * and only awaits when a predicate is present — the websocket `canHandle`
100
+ * needs a boolean, so a predicate makes the check async there too.
101
+ */
102
+ let credential: string | undefined
103
+ let authorizeFn: AuthorizeFn | undefined
104
+
105
+ export function setAnalyticsCredential(value: string | undefined): void {
106
+ credential = value
107
+ }
108
+
109
+ export function setAnalyticsAuthorize(fn: AuthorizeFn | undefined): void {
110
+ authorizeFn = fn
111
+ }
112
+
113
+ export async function isAnalyticsAuthorized(req: Request): Promise<boolean> {
114
+ if (requestHasCredential(req, credential, 'analytics-key')) return true
115
+ if (!authorizeFn) return false
116
+ try {
117
+ return (await authorizeFn(req)) === true
118
+ } catch {
119
+ // A predicate that throws is indeterminate, and indeterminate is denied.
120
+ return false
121
+ }
88
122
  }
89
123
 
90
124
  /**
@@ -95,9 +129,13 @@ export function isAnalyticsAuthorized(req: Request): boolean {
95
129
  * the envelope carries no `data`, which is what makes it assignable into every
96
130
  * caller's own payload type.
97
131
  */
98
- function checkDashpassAuth(req: Request): JsonResponseData<undefined> | null {
99
- if (isAnalyticsAuthorized(req)) return null
100
- return process.env.DASHPASS
132
+ async function checkAnalyticsAuth(
133
+ req: Request,
134
+ ): Promise<JsonResponseData<undefined> | null> {
135
+ if (await isAnalyticsAuthorized(req)) return null
136
+ // Armed-but-unauthorised is a 401; nothing configured is a 404 that does
137
+ // not advertise the endpoint at all.
138
+ return credential || authorizeFn
101
139
  ? response.json.error<undefined>(401, 'Unauthorized')
102
140
  : response.json.error<undefined>(404, 'Not Found')
103
141
  }
@@ -105,7 +143,7 @@ function checkDashpassAuth(req: Request): JsonResponseData<undefined> | null {
105
143
  export async function handleResetRequest(
106
144
  req: Request,
107
145
  ): Promise<JsonResponseData<undefined>> {
108
- const authError = checkDashpassAuth(req)
146
+ const authError = await checkAnalyticsAuth(req)
109
147
  if (authError) return authError
110
148
 
111
149
  core.history1m.length = 0
@@ -124,7 +162,7 @@ export async function handleStatsRequest(
124
162
  req: Request,
125
163
  url: URL,
126
164
  ): Promise<JsonResponseData<AnalyticsStats | undefined>> {
127
- const authError = checkDashpassAuth(req)
165
+ const authError = await checkAnalyticsAuth(req)
128
166
  if (authError) return authError
129
167
 
130
168
  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
@@ -15,12 +15,41 @@ export {
15
15
  recordRouteHit,
16
16
  } from './core'
17
17
 
18
- export default function analyticsPlugin() {
18
+ export type { AuthorizeFn } from './endpoints/stats'
19
+
20
+ export interface AnalyticsPluginOptions {
21
+ /**
22
+ * A shared access key for the analytics endpoints and websocket, typically
23
+ * from the environment:
24
+ *
25
+ * ```ts
26
+ * analyticsPlugin({ credential: import.meta.env.ANALYTICS_KEY })
27
+ * ```
28
+ *
29
+ * Presented as `Authorization: Bearer`, an `x-analytics-key` header, or an
30
+ * `?analytics-key=` query. Checked in constant time; unset or empty means
31
+ * this door is closed. The same key gates the dashboard, which delegates
32
+ * its auth here.
33
+ */
34
+ credential?: string
35
+
36
+ /**
37
+ * A request predicate for role-based access, as an alternative or addition
38
+ * to the shared key: `authorize: req => req.session.get('role') === 'admin'`.
39
+ * Either door admits; both fail closed. With neither, analytics is off.
40
+ */
41
+ authorize?: import('./endpoints/stats').AuthorizeFn
42
+ }
43
+
44
+ export default function analyticsPlugin(options: AnalyticsPluginOptions = {}) {
19
45
  return definePlugin({
20
46
  name: 'analytics',
21
47
  async setup() {
22
48
  const { setupAnalytics } = await import('./setup')
23
- setupAnalytics()
49
+ setupAnalytics({
50
+ credential: options.credential,
51
+ authorize: options.authorize,
52
+ })
24
53
  },
25
54
  onRoute(req) {
26
55
  const url: URL = (req as any).__parsedUrl || new URL(req.url)
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,24 @@ export function handleAnalyticsRequest(
179
185
  return analyticsRoutes(req)
180
186
  }
181
187
 
182
- export function setupAnalytics() {
188
+ let registered = false
189
+
190
+ export function setupAnalytics(
191
+ options: { credential?: string; authorize?: AuthorizeFn } = {},
192
+ ) {
193
+ // Auth is (re)applied every call — last config wins — so the dashboard
194
+ // bringing analytics up with the shared key overrides a bare
195
+ // `analyticsPlugin()`, whichever order they registered in.
196
+ setAnalyticsCredential(options.credential)
197
+ setAnalyticsAuthorize(options.authorize)
198
+
199
+ // The rest runs once. Analytics is now a hard dependency of the dashboard,
200
+ // so both may set it up in one process; the handler registrations are
201
+ // idempotent but the shutdown hook and data load are not, and a doubled
202
+ // load would race two reads of the same file.
203
+ if (registered) return
204
+ registered = true
205
+
183
206
  Bakery.handlers.fetch.set(AnalyticsHandler, 110)
184
207
  Bakery.handlers.websocket.set(AnalyticsWSHandler)
185
208
  void loadAnalyticsData()