@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 +4 -3
- package/src/endpoints/stats.ts +57 -14
- package/src/endpoints/websocket.ts +4 -1
- package/src/index.ts +33 -3
- package/src/setup.ts +54 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/plugin-analytics",
|
|
3
|
-
"version": "
|
|
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": "^
|
|
38
|
+
"@bakery-framework/core": "^2.0.0-alpha.11"
|
|
38
39
|
},
|
|
39
40
|
"engines": {
|
|
40
|
-
"bun": ">=1.
|
|
41
|
+
"bun": ">=1.4.0"
|
|
41
42
|
}
|
|
42
43
|
}
|
package/src/endpoints/stats.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { Bakery } from '@bakery-framework/core/core/bakery'
|
|
2
|
-
import {
|
|
2
|
+
import { Session } from '@bakery-framework/core/session'
|
|
3
3
|
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
4
|
-
import {
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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()
|