@bakery-framework/plugin-dashboard 2.0.0-alpha.4 → 2.0.0-alpha.6
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/client/dashboard.ts +2 -65
- package/src/client/parts/effects.ts +2 -2
- package/src/client/parts/utils.ts +13 -40
- package/src/components/DBBrowser.tsx +36 -352
- package/src/endpoints/database.ts +15 -179
- package/src/index.ts +45 -3
- package/src/setup.ts +70 -38
- package/src/authorize.ts +0 -76
- package/src/client/parts/database.ts +0 -1176
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { getElapsed } from '@bakery-framework/core/logger'
|
|
2
|
-
import { processBody } from '@bakery-framework/core/utils'
|
|
3
1
|
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
4
|
-
import {
|
|
2
|
+
import { Try } from '@bakery-framework/core/utils/common'
|
|
5
3
|
import { response } from '@bakery-framework/core/utils/http'
|
|
6
4
|
import { connection } from '@bakery-framework/orm/connection'
|
|
7
5
|
|
|
6
|
+
/**
|
|
7
|
+
* What is left of the console's database surface: two read-only endpoints.
|
|
8
|
+
*
|
|
9
|
+
* The grid editor and the SQL prompt that used to live here are retired —
|
|
10
|
+
* `@bakery-framework/plugin-db-explorer` does the same work with an access
|
|
11
|
+
* model instead of an environment flag, and the console's Database tab is now
|
|
12
|
+
* a link to it. Gone with them: `handleQuery`, `handleExecuteAction`, the
|
|
13
|
+
* statement classifier in `sql-classify.ts`, and `DASHBOARD_ALLOW_WRITES`.
|
|
14
|
+
*
|
|
15
|
+
* The flag is not deprecated, it is *absent*. Nothing here reads it, so
|
|
16
|
+
* setting it has no effect at all — which is the honest state for a console
|
|
17
|
+
* that can no longer write.
|
|
18
|
+
*/
|
|
19
|
+
|
|
8
20
|
export async function handleSchema(): Promise<JsonResponseData<unknown>> {
|
|
9
21
|
return await Try.return(
|
|
10
22
|
async () => response.json.success('success', await connection.getSchema()),
|
|
@@ -33,179 +45,3 @@ export async function handleTableData(
|
|
|
33
45
|
(error: any) => response.json.error(400, error.message),
|
|
34
46
|
)
|
|
35
47
|
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Statements that reach outside the database itself. SQLite's `ATTACH` plus
|
|
39
|
-
* `VACUUM INTO` is an arbitrary file write, which turns any dashboard session
|
|
40
|
-
* (or any XSS in the dashboard origin) into host filesystem access.
|
|
41
|
-
*/
|
|
42
|
-
const RX_OUT_OF_BAND_SQL = /\b(attach|detach|vacuum\s+into)\b/i
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Writes from the dashboard need an explicit opt-in.
|
|
46
|
-
*
|
|
47
|
-
* This gate used to sit only in `handleQuery`, so `DELETE FROM t` typed into
|
|
48
|
-
* the SQL box was refused while the grid's Delete and Truncate buttons — which
|
|
49
|
-
* reach the same rows through `execute-action`, with no SQL to inspect — were
|
|
50
|
-
* not. Half a control is worse than none: the production and security
|
|
51
|
-
* checklists both tell operators that leaving `DASHBOARD_ALLOW_WRITES` unset
|
|
52
|
-
* keeps the console read-only, and that was only ever true of one of its two
|
|
53
|
-
* write paths.
|
|
54
|
-
*
|
|
55
|
-
* Read at call time, not at module load: tests set and restore it, and the
|
|
56
|
-
* flag is a deployment decision rather than a build one.
|
|
57
|
-
*/
|
|
58
|
-
function writesEnabled(): boolean {
|
|
59
|
-
return process.env.DASHBOARD_ALLOW_WRITES === '1'
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export async function handleQuery(
|
|
63
|
-
req: Request,
|
|
64
|
-
): Promise<JsonResponseData<unknown>> {
|
|
65
|
-
const body = await processBody(req)
|
|
66
|
-
if (!is.string(body?.sql))
|
|
67
|
-
return response.json.error(400, 'Invalid SQL query')
|
|
68
|
-
|
|
69
|
-
if (RX_OUT_OF_BAND_SQL.test(body.sql)) {
|
|
70
|
-
return response.json.error(
|
|
71
|
-
403,
|
|
72
|
-
'ATTACH / DETACH / VACUUM INTO are not permitted from the dashboard.',
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const sqlLower = body.sql.trim().toLowerCase()
|
|
77
|
-
const isSelect = /^(select|with|show|describe|pragma|explain)/.test(sqlLower)
|
|
78
|
-
|
|
79
|
-
// Writes require an explicit opt-in; the browser console should not be a
|
|
80
|
-
// one-keystroke path to DROP TABLE on a production database.
|
|
81
|
-
if (!isSelect && !writesEnabled()) {
|
|
82
|
-
return response.json.error(
|
|
83
|
-
403,
|
|
84
|
-
'Write statements are disabled. Set DASHBOARD_ALLOW_WRITES=1 to enable them.',
|
|
85
|
-
)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const start = Bun.nanoseconds()
|
|
89
|
-
|
|
90
|
-
return await Try.return(
|
|
91
|
-
async () => {
|
|
92
|
-
const result = isSelect
|
|
93
|
-
? { rows: await connection.query(body.sql).all(), isSelect: true }
|
|
94
|
-
: {
|
|
95
|
-
rows: [
|
|
96
|
-
(({ lastInsertRowid, changes }) => ({
|
|
97
|
-
lastInsertRowid,
|
|
98
|
-
changes,
|
|
99
|
-
}))(await connection.query(body.sql).run()),
|
|
100
|
-
],
|
|
101
|
-
isSelect: false,
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return response.json.success('success', {
|
|
105
|
-
...result,
|
|
106
|
-
time: getElapsed(start),
|
|
107
|
-
})
|
|
108
|
-
},
|
|
109
|
-
(error: any) =>
|
|
110
|
-
response.json.error(400, error.message, { time: getElapsed(start) }),
|
|
111
|
-
)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Every branch answers with the JSON envelope — never a `Response` — so the
|
|
116
|
-
* table is typed as such. `data` stays `unknown`: these payloads are arbitrary
|
|
117
|
-
* database rows, and `unknown` says that honestly where `any` would have let
|
|
118
|
-
* the lie back in.
|
|
119
|
-
*/
|
|
120
|
-
const executeActionHandlers: Record<
|
|
121
|
-
string,
|
|
122
|
-
(body: any, connection: any) => Promise<JsonResponseData<unknown>>
|
|
123
|
-
> = {
|
|
124
|
-
'delete-row': async (body, connection) => {
|
|
125
|
-
if (body.rowid == null) return response.json.error(400, 'Invalid row ID')
|
|
126
|
-
return await Try.return(
|
|
127
|
-
async () => {
|
|
128
|
-
await connection.remove(body.tableName, body.rowid)
|
|
129
|
-
return response.json.success('Row deleted')
|
|
130
|
-
},
|
|
131
|
-
(e: any) => response.json.error(400, e.message),
|
|
132
|
-
)
|
|
133
|
-
},
|
|
134
|
-
truncate: async (body, connection) => {
|
|
135
|
-
return await Try.return(
|
|
136
|
-
async () => {
|
|
137
|
-
await connection.truncate(body.tableName)
|
|
138
|
-
return response.json.success('Table truncated')
|
|
139
|
-
},
|
|
140
|
-
(e: any) => response.json.error(400, e.message),
|
|
141
|
-
)
|
|
142
|
-
},
|
|
143
|
-
'insert-row': async (body, connection) => {
|
|
144
|
-
if (!body.row || typeof body.row !== 'object')
|
|
145
|
-
return response.json.error(400, 'Invalid row data')
|
|
146
|
-
return await Try.return(
|
|
147
|
-
async () => {
|
|
148
|
-
await connection.insert(body.tableName, body.row)
|
|
149
|
-
return response.json.success('Row inserted')
|
|
150
|
-
},
|
|
151
|
-
(e: any) => response.json.error(400, e.message),
|
|
152
|
-
)
|
|
153
|
-
},
|
|
154
|
-
'update-row': async (body, connection) => {
|
|
155
|
-
if (
|
|
156
|
-
!body.row ||
|
|
157
|
-
!is.object(body.row) ||
|
|
158
|
-
Array.isArray(body.row) ||
|
|
159
|
-
body.rowid == null
|
|
160
|
-
) {
|
|
161
|
-
return response.json.error(400, 'Invalid data or row ID')
|
|
162
|
-
}
|
|
163
|
-
return await Try.return(
|
|
164
|
-
async () => {
|
|
165
|
-
await connection.update(body.tableName, body.rowid, body.row)
|
|
166
|
-
return response.json.success('Row updated')
|
|
167
|
-
},
|
|
168
|
-
(e: any) => response.json.error(400, e.message),
|
|
169
|
-
)
|
|
170
|
-
},
|
|
171
|
-
'import-csv': async (body, connection) => {
|
|
172
|
-
if (typeof body.csvContent !== 'string')
|
|
173
|
-
return response.json.error(400, 'Invalid CSV')
|
|
174
|
-
return await Try.return(
|
|
175
|
-
async () => {
|
|
176
|
-
const info = await connection.importCSV(body.tableName, body.csvContent)
|
|
177
|
-
return response.json.success(`Imported ${info.changes} rows`, {
|
|
178
|
-
info,
|
|
179
|
-
})
|
|
180
|
-
},
|
|
181
|
-
(e: any) => response.json.error(400, e.message),
|
|
182
|
-
)
|
|
183
|
-
},
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export async function handleExecuteAction(
|
|
187
|
-
req: Request,
|
|
188
|
-
): Promise<JsonResponseData<unknown>> {
|
|
189
|
-
// Every entry in `executeActionHandlers` writes — truncate and delete-row
|
|
190
|
-
// destroy data outright — so the gate covers the endpoint rather than being
|
|
191
|
-
// repeated per action. Checked before the body is even read.
|
|
192
|
-
if (!writesEnabled()) {
|
|
193
|
-
return response.json.error(
|
|
194
|
-
403,
|
|
195
|
-
'Dashboard writes are disabled. Set DASHBOARD_ALLOW_WRITES=1 to enable them.',
|
|
196
|
-
)
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const body = await processBody(req)
|
|
200
|
-
const { action, tableName } = body || {}
|
|
201
|
-
|
|
202
|
-
if (!is.string(tableName) || !/^[a-zA-Z0-9_]+$/.test(tableName)) {
|
|
203
|
-
return response.json.error(400, 'Invalid table name')
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const handler = executeActionHandlers[action]
|
|
207
|
-
if (handler) {
|
|
208
|
-
return await handler(body, connection)
|
|
209
|
-
}
|
|
210
|
-
return response.json.error(400, 'Unknown action')
|
|
211
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { definePlugin } from '@bakery-framework/core/plugins'
|
|
2
|
-
import type { AuthorizeFn } from '
|
|
2
|
+
import type { AuthorizeFn } from '@bakery-framework/core/utils/http'
|
|
3
3
|
|
|
4
|
-
|
|
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({
|
|
78
|
+
await setupDashboard({
|
|
79
|
+
authorize: options.authorize,
|
|
80
|
+
credential: options.credential,
|
|
81
|
+
})
|
|
40
82
|
},
|
|
41
83
|
})
|
|
42
84
|
}
|
package/src/setup.ts
CHANGED
|
@@ -16,22 +16,17 @@ 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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
} from './authorize'
|
|
29
|
-
import {
|
|
30
|
-
handleExecuteAction,
|
|
31
|
-
handleQuery,
|
|
32
|
-
handleSchema,
|
|
33
|
-
handleTableData,
|
|
34
|
-
} from './endpoints/database'
|
|
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
|
+
import { handleSchema, handleTableData } from './endpoints/database'
|
|
35
30
|
import {
|
|
36
31
|
handleDeleteSession,
|
|
37
32
|
handleGetSessions,
|
|
@@ -87,28 +82,29 @@ export class DashboardHandler extends Handler {
|
|
|
87
82
|
}
|
|
88
83
|
}
|
|
89
84
|
|
|
90
|
-
let authorize: AuthorizeFn = defaultAuthorize
|
|
91
|
-
|
|
92
85
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
86
|
+
* The console holds no authorization state of its own — there is no
|
|
87
|
+
* `__setTestAuthorize` seam here any more, because there is nothing local to
|
|
88
|
+
* seam. Both options land in analytics, and a test drives the door with
|
|
89
|
+
* `setAnalyticsAuthorize` / `setAnalyticsCredential` from
|
|
90
|
+
* `@bakery-framework/plugin-analytics/stats`, which is the same state the
|
|
91
|
+
* request guard reads.
|
|
95
92
|
*
|
|
96
|
-
* `
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* `
|
|
93
|
+
* `resolveAuthorize` still runs here rather than in analytics, and that
|
|
94
|
+
* asymmetry is deliberate. Analytics on its own is closed until configured;
|
|
95
|
+
* the console keeps its documented default of loopback-in-development,
|
|
96
|
+
* because `bun create bakery` scaffolds a bare `dashboardPlugin()` and a
|
|
97
|
+
* console that 404s on the first `bun run dev` is the wrong first impression.
|
|
98
|
+
* Forwarding `defaultAuthorize` can only ever narrow — it denies in
|
|
99
|
+
* production and admits nothing but this machine in development.
|
|
101
100
|
*/
|
|
102
|
-
export function
|
|
103
|
-
authorize =
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function setupDashboard(options: { authorize?: AuthorizeFn } = {}) {
|
|
111
|
-
authorize = resolveAuthorize(options.authorize)
|
|
101
|
+
export function setupDashboard(
|
|
102
|
+
options: { authorize?: AuthorizeFn; credential?: string } = {},
|
|
103
|
+
) {
|
|
104
|
+
setupAnalytics({
|
|
105
|
+
authorize: resolveAuthorize(options.authorize),
|
|
106
|
+
credential: options.credential,
|
|
107
|
+
})
|
|
112
108
|
|
|
113
109
|
setLogCallback(entry => {
|
|
114
110
|
// Broadcast to the registry LiveReloadHandler actually populates. This used
|
|
@@ -173,12 +169,26 @@ async function handleJsAsset() {
|
|
|
173
169
|
return response.type(bundleResult.content, 'text/javascript')
|
|
174
170
|
}
|
|
175
171
|
|
|
172
|
+
/**
|
|
173
|
+
* One door, and it is analytics'. `isAnalyticsAuthorized` reads the same
|
|
174
|
+
* credential and the same predicate that gate `/api/_analytics/stats` and the
|
|
175
|
+
* `/_analytics_ws` socket — which the console's own client calls, so a console
|
|
176
|
+
* admitted by a different key than the data it renders would be half-open by
|
|
177
|
+
* construction.
|
|
178
|
+
*
|
|
179
|
+
* The 401/404 split is the console's and stays here: an API path says
|
|
180
|
+
* "Unauthorized" because a caller with a key needs to know its key was
|
|
181
|
+
* refused, while a page says "Not Found" because a 401 on the shell confirms
|
|
182
|
+
* to anyone probing that a console is mounted at this path. Analytics makes
|
|
183
|
+
* the same distinction along a different axis (armed → 401, unconfigured →
|
|
184
|
+
* 404); neither is a substitute for the other.
|
|
185
|
+
*/
|
|
176
186
|
async function checkAuthMiddleware(req: Request, path: string) {
|
|
177
187
|
// Styling and script for the console are not secrets, and letting them
|
|
178
188
|
// through keeps an unauthorised response from rendering unstyled.
|
|
179
189
|
if (/\.(css|js)$/.test(path)) return null
|
|
180
190
|
|
|
181
|
-
if (await
|
|
191
|
+
if (await isAnalyticsAuthorized(req)) return null
|
|
182
192
|
|
|
183
193
|
return path.startsWith('/api/')
|
|
184
194
|
? response.error('Unauthorized', 401)
|
|
@@ -194,9 +204,14 @@ async function checkAuthMiddleware(req: Request, path: string) {
|
|
|
194
204
|
*
|
|
195
205
|
* Necessary but **not sufficient on its own**. `SAFE_METHODS` exempts GET, and
|
|
196
206
|
* `processBody` reads a GET's query string as the body, so a plain
|
|
197
|
-
* `<img src="/api/_dashboard/
|
|
198
|
-
*
|
|
199
|
-
*
|
|
207
|
+
* `<img src="/api/_dashboard/sessions/delete?id=victim">` sails past this
|
|
208
|
+
* guard. The mutating keys in the table below are method-qualified for exactly
|
|
209
|
+
* that reason; neither half closes the hole alone.
|
|
210
|
+
*
|
|
211
|
+
* The example used to be `execute-action?action=truncate`, which was the worst
|
|
212
|
+
* of them — a link that emptied a table. That endpoint is gone with the grid
|
|
213
|
+
* editor, and the two session routes are what is left to protect. The hazard
|
|
214
|
+
* is unchanged; only the blast radius shrank.
|
|
200
215
|
*/
|
|
201
216
|
function checkCsrfMiddleware(req: Request, url: URL): DashboardResponse | null {
|
|
202
217
|
const reason = checkCsrf(req, url)
|
|
@@ -228,8 +243,6 @@ const dashboardRoutes = {
|
|
|
228
243
|
'POST /api/_dashboard/sessions/update': req => handleUpdateSession(req),
|
|
229
244
|
'/api/_dashboard/schema': () => handleSchema(),
|
|
230
245
|
'/api/_dashboard/table-data': (_req, url) => handleTableData(url),
|
|
231
|
-
'POST /api/_dashboard/query': req => handleQuery(req),
|
|
232
|
-
'POST /api/_dashboard/execute-action': req => handleExecuteAction(req),
|
|
233
246
|
} satisfies PluginRouteTable
|
|
234
247
|
|
|
235
248
|
const dispatchDashboardRoute = routeTable(dashboardRoutes)
|
|
@@ -264,5 +277,24 @@ export async function handleDashboardRequest(
|
|
|
264
277
|
const csrfBlock = checkCsrfMiddleware(req, url)
|
|
265
278
|
if (csrfBlock) return csrfBlock
|
|
266
279
|
|
|
267
|
-
|
|
280
|
+
const result = await dispatchDashboardRoute(req, url)
|
|
281
|
+
if (result) return result
|
|
282
|
+
|
|
283
|
+
// `dispatch` answers `null` for a path no key matched, and a handler
|
|
284
|
+
// returning `null` means "not mine" — which core turns into **204 No
|
|
285
|
+
// Content**. Every unmatched request under this namespace therefore answered
|
|
286
|
+
// 204: a status that reads as success and carries nothing.
|
|
287
|
+
//
|
|
288
|
+
// Retiring the write endpoints is what made that matter. A script still
|
|
289
|
+
// posting to `/api/_dashboard/execute-action` was told 204, took it for
|
|
290
|
+
// success, and silently changed nothing. For a route that has been deleted
|
|
291
|
+
// that is the worst available answer.
|
|
292
|
+
//
|
|
293
|
+
// **Only under `/api/`, and that boundary is load-bearing.** The stylesheet
|
|
294
|
+
// is served by `mountRoutes`, not by a key in the table above, so `null` is
|
|
295
|
+
// precisely how `/_dashboard/style.css` *falls through* to the static
|
|
296
|
+
// pipeline. A blanket 404 here claims it and the console loads unstyled —
|
|
297
|
+
// which is what happened when this was first written without the condition.
|
|
298
|
+
if (path.startsWith('/api/')) return response.error('Not Found', 404)
|
|
299
|
+
return null
|
|
268
300
|
}
|
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
|
-
}
|