@bakery-framework/plugin-analytics 1.2.3 → 2.0.0-alpha.1
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 +2 -2
- package/src/endpoints/stats.ts +52 -14
- package/src/endpoints/websocket.ts +4 -1
- package/src/index.ts +31 -2
- package/src/setup.ts +25 -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.1",
|
|
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.
|
|
37
|
+
"@bakery-framework/core": "^1.2.3"
|
|
38
38
|
},
|
|
39
39
|
"engines": {
|
|
40
40
|
"bun": ">=1.3.14"
|
package/src/endpoints/stats.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
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
|
+
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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()
|