@bakery-framework/plugin-db-explorer 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 +42 -0
- package/src/authorize.ts +82 -0
- package/src/client.ts +193 -0
- package/src/endpoints.ts +48 -0
- package/src/index.ts +68 -0
- package/src/setup.ts +162 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bakery-framework/plugin-db-explorer",
|
|
3
|
+
"version": "2.0.0-alpha.2",
|
|
4
|
+
"description": "Bakery database explorer plugin — read-only browsing of the app database.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bakery",
|
|
7
|
+
"bun",
|
|
8
|
+
"database",
|
|
9
|
+
"explorer",
|
|
10
|
+
"plugin"
|
|
11
|
+
],
|
|
12
|
+
"author": "obillekyle",
|
|
13
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/obillekyle/bakery.git",
|
|
17
|
+
"directory": "packages/plugins/db-explorer"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://bakery.okyle.dev",
|
|
20
|
+
"bugs": "https://github.com/obillekyle/bakery/issues",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./src/index.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/index.ts",
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"!src/**/*.test.ts",
|
|
33
|
+
"!src/tests"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@bakery-framework/core": "^2.0.0-alpha.2",
|
|
37
|
+
"@bakery-framework/orm": "^2.0.0-alpha.2"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"bun": ">=1.3.14"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/authorize.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getClientIp,
|
|
3
|
+
requestHasCredential,
|
|
4
|
+
} from '@bakery-framework/core/utils/http'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decides whether a request may use the explorer.
|
|
8
|
+
*
|
|
9
|
+
* Same design as the dashboard's guard, for the same reason: the explorer
|
|
10
|
+
* authenticates nobody itself. The host application, which already knows who
|
|
11
|
+
* its users are, supplies a predicate; without one, access is loopback-only
|
|
12
|
+
* in development and denied outright in production, so an unconfigured
|
|
13
|
+
* explorer is never exposed.
|
|
14
|
+
*/
|
|
15
|
+
export type AuthorizeFn = (req: Request) => boolean | Promise<boolean>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Addresses only, never hostnames: a peer address is not the string
|
|
19
|
+
* `localhost`, and matching the request's hostname would trust a header the
|
|
20
|
+
* client chooses (`Host: localhost` from anywhere on the LAN, with the dev
|
|
21
|
+
* server listening on 0.0.0.0).
|
|
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
|
+
// getClientIp reads config and the live server, either of which may be
|
|
29
|
+
// absent (tests, early boot). An address that cannot be determined is
|
|
30
|
+
// indeterminate, and per convention 2 an indeterminate answer is a denial —
|
|
31
|
+
// not a reason to consult something the requester controls.
|
|
32
|
+
let ip = ''
|
|
33
|
+
try {
|
|
34
|
+
ip = getClientIp(req)
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
return LOOPBACK.has(ip)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The default: loopback in dev, nothing in prod. `import.meta.env.PROD` read
|
|
43
|
+
* at call time — the flag is process state, and tests flip it.
|
|
44
|
+
*/
|
|
45
|
+
export function defaultAuthorize(req: Request): boolean {
|
|
46
|
+
if (import.meta.env.PROD) return false
|
|
47
|
+
return isLoopback(req)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveAuthorize(fn?: AuthorizeFn): AuthorizeFn {
|
|
51
|
+
return fn ?? defaultAuthorize
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Guard semantics per convention 2: the *authorizer* may throw or hang-fail;
|
|
56
|
+
* the answer to any indeterminate state is denial.
|
|
57
|
+
*/
|
|
58
|
+
export async function isAuthorized(
|
|
59
|
+
authorize: AuthorizeFn,
|
|
60
|
+
req: Request,
|
|
61
|
+
): Promise<boolean> {
|
|
62
|
+
try {
|
|
63
|
+
return (await authorize(req)) === true
|
|
64
|
+
} catch {
|
|
65
|
+
// A predicate that throws is indeterminate, and indeterminate is denied.
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Shared-credential access: `dbExplorerPlugin({ credential: import.meta.env
|
|
72
|
+
* .DB_EXPLORER_KEY })`, presented as `x-db-key`, a Bearer token, or a
|
|
73
|
+
* one-time `?db-key=` query the client strips from the URL. The comparison
|
|
74
|
+
* lives in core (`requestHasCredential`) — one copy, shared with analytics;
|
|
75
|
+
* this only names the key, `db-key`.
|
|
76
|
+
*/
|
|
77
|
+
export function credentialMatches(
|
|
78
|
+
credential: string | undefined,
|
|
79
|
+
req: Request,
|
|
80
|
+
): boolean {
|
|
81
|
+
return requestHasCredential(req, credential, 'db-key')
|
|
82
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The explorer UI: a table list, a row grid with paging and sorting, nothing
|
|
3
|
+
* else. Compiled per request in dev and cached like the dashboard's client;
|
|
4
|
+
* kept deliberately small — this file is the entire browser side.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type SchemaTable = { name: string; rowCount?: number }
|
|
8
|
+
|
|
9
|
+
type TablePage = {
|
|
10
|
+
rows: Record<string, unknown>[]
|
|
11
|
+
totalRows?: number
|
|
12
|
+
totalPages?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const app = document.getElementById('app')!
|
|
16
|
+
|
|
17
|
+
let currentTable = ''
|
|
18
|
+
let currentPage = 1
|
|
19
|
+
let sortBy: string | null = null
|
|
20
|
+
let sortOrder: 'ASC' | 'DESC' = 'ASC'
|
|
21
|
+
const PAGE_SIZE = 50
|
|
22
|
+
|
|
23
|
+
// A ?key= opened in the browser is kept for API calls and scrubbed from the
|
|
24
|
+
// URL (and so from history) immediately.
|
|
25
|
+
const urlKey = new URLSearchParams(location.search).get('db-key')
|
|
26
|
+
if (urlKey) {
|
|
27
|
+
sessionStorage.setItem('__db_key', urlKey)
|
|
28
|
+
const clean = new URL(location.href)
|
|
29
|
+
clean.searchParams.delete('db-key')
|
|
30
|
+
history.replaceState(null, '', clean)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function keyHeaders(): Record<string, string> {
|
|
34
|
+
const key = sessionStorage.getItem('__db_key')
|
|
35
|
+
return key ? { 'x-db-key': key } : {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function api<T>(path: string): Promise<T> {
|
|
39
|
+
const res = await fetch(`/api/_db/${path}`, { headers: keyHeaders() })
|
|
40
|
+
const json = await res.json()
|
|
41
|
+
if (json.status < 200 || json.status >= 300) {
|
|
42
|
+
throw new Error(json.message || `Request failed (${json.status})`)
|
|
43
|
+
}
|
|
44
|
+
return json.data as T
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function el(tag: string, cls?: string, text?: string): HTMLElement {
|
|
48
|
+
const node = document.createElement(tag)
|
|
49
|
+
if (cls) node.className = cls
|
|
50
|
+
if (text !== undefined) node.textContent = text
|
|
51
|
+
return node
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderShell(tables: string[]) {
|
|
55
|
+
app.replaceChildren()
|
|
56
|
+
|
|
57
|
+
const side = el('nav', 'side')
|
|
58
|
+
side.appendChild(el('h1', 'brand', 'db explorer'))
|
|
59
|
+
side.appendChild(el('p', 'note', 'read-only'))
|
|
60
|
+
|
|
61
|
+
for (const name of tables.sort()) {
|
|
62
|
+
const btn = el('button', 'table-btn', name)
|
|
63
|
+
btn.addEventListener('click', () => {
|
|
64
|
+
currentTable = name
|
|
65
|
+
currentPage = 1
|
|
66
|
+
sortBy = null
|
|
67
|
+
void renderTable()
|
|
68
|
+
side
|
|
69
|
+
.querySelectorAll('.table-btn')
|
|
70
|
+
.forEach(b => b.classList.toggle('active', b.textContent === name))
|
|
71
|
+
})
|
|
72
|
+
side.appendChild(btn)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const main = el('main', 'main')
|
|
76
|
+
main.id = 'main'
|
|
77
|
+
main.appendChild(el('p', 'note', 'Pick a table.'))
|
|
78
|
+
|
|
79
|
+
app.append(side, main)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function renderTable() {
|
|
83
|
+
const main = document.getElementById('main')!
|
|
84
|
+
main.replaceChildren(el('p', 'note', `loading ${currentTable}…`))
|
|
85
|
+
|
|
86
|
+
const params = new URLSearchParams({
|
|
87
|
+
tableName: currentTable,
|
|
88
|
+
page: String(currentPage),
|
|
89
|
+
pageSize: String(PAGE_SIZE),
|
|
90
|
+
sortOrder,
|
|
91
|
+
})
|
|
92
|
+
if (sortBy) params.set('sortBy', sortBy)
|
|
93
|
+
|
|
94
|
+
let data: TablePage
|
|
95
|
+
try {
|
|
96
|
+
data = await api<TablePage>(`table-data?${params}`)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
main.replaceChildren(el('p', 'error', String(error)))
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const rows = data.rows ?? []
|
|
103
|
+
main.replaceChildren()
|
|
104
|
+
|
|
105
|
+
const head = el('header', 'table-head')
|
|
106
|
+
head.appendChild(el('h2', undefined, currentTable))
|
|
107
|
+
const meta = el('span', 'note')
|
|
108
|
+
meta.textContent =
|
|
109
|
+
data.totalRows !== undefined
|
|
110
|
+
? `${data.totalRows} rows · page ${currentPage}${data.totalPages ? ` / ${data.totalPages}` : ''}`
|
|
111
|
+
: `page ${currentPage}`
|
|
112
|
+
head.appendChild(meta)
|
|
113
|
+
main.appendChild(head)
|
|
114
|
+
|
|
115
|
+
if (!rows.length) {
|
|
116
|
+
main.appendChild(el('p', 'note', 'No rows on this page.'))
|
|
117
|
+
} else {
|
|
118
|
+
const cols = Object.keys(rows[0])
|
|
119
|
+
const table = el('table', 'grid')
|
|
120
|
+
const thead = el('thead')
|
|
121
|
+
const headRow = el('tr')
|
|
122
|
+
for (const col of cols) {
|
|
123
|
+
const th = el('th', undefined, col)
|
|
124
|
+
if (col === sortBy) th.textContent += sortOrder === 'ASC' ? ' ↑' : ' ↓'
|
|
125
|
+
// rowid is in the payload but not in the sortable allow-list server-side;
|
|
126
|
+
// offering a header click that silently no-ops reads as a bug.
|
|
127
|
+
if (col === 'rowid') {
|
|
128
|
+
headRow.appendChild(th)
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
th.addEventListener('click', () => {
|
|
132
|
+
sortOrder = sortBy === col && sortOrder === 'ASC' ? 'DESC' : 'ASC'
|
|
133
|
+
sortBy = col
|
|
134
|
+
void renderTable()
|
|
135
|
+
})
|
|
136
|
+
headRow.appendChild(th)
|
|
137
|
+
}
|
|
138
|
+
thead.appendChild(headRow)
|
|
139
|
+
table.appendChild(thead)
|
|
140
|
+
|
|
141
|
+
const tbody = el('tbody')
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
const tr = el('tr')
|
|
144
|
+
for (const col of cols) {
|
|
145
|
+
const value = row[col]
|
|
146
|
+
tr.appendChild(
|
|
147
|
+
el(
|
|
148
|
+
'td',
|
|
149
|
+
value === null ? 'null' : undefined,
|
|
150
|
+
value === null ? 'NULL' : String(value),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
tbody.appendChild(tr)
|
|
155
|
+
}
|
|
156
|
+
table.appendChild(tbody)
|
|
157
|
+
|
|
158
|
+
const scroller = el('div', 'scroll')
|
|
159
|
+
scroller.appendChild(table)
|
|
160
|
+
main.appendChild(scroller)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pager = el('footer', 'pager')
|
|
164
|
+
const prev = el('button', undefined, '← prev') as HTMLButtonElement
|
|
165
|
+
prev.disabled = currentPage <= 1
|
|
166
|
+
prev.addEventListener('click', () => {
|
|
167
|
+
currentPage--
|
|
168
|
+
void renderTable()
|
|
169
|
+
})
|
|
170
|
+
const next = el('button', undefined, 'next →') as HTMLButtonElement
|
|
171
|
+
next.disabled =
|
|
172
|
+
data.totalPages !== undefined
|
|
173
|
+
? currentPage >= data.totalPages
|
|
174
|
+
: rows.length < PAGE_SIZE
|
|
175
|
+
next.addEventListener('click', () => {
|
|
176
|
+
currentPage++
|
|
177
|
+
void renderTable()
|
|
178
|
+
})
|
|
179
|
+
pager.append(prev, next)
|
|
180
|
+
main.appendChild(pager)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function boot() {
|
|
184
|
+
try {
|
|
185
|
+
// `getSchema` answers a list of table descriptors, not a keyed object.
|
|
186
|
+
const schema = await api<SchemaTable[]>('schema')
|
|
187
|
+
renderShell((schema ?? []).map(t => t.name).filter(Boolean))
|
|
188
|
+
} catch (error) {
|
|
189
|
+
app.replaceChildren(el('p', 'error', String(error)))
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
void boot()
|
package/src/endpoints.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
2
|
+
import { Try } from '@bakery-framework/core/utils/common'
|
|
3
|
+
import { response } from '@bakery-framework/core/utils/http'
|
|
4
|
+
import { connection } from '@bakery-framework/orm/connection'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The explorer's whole write surface, enumerated: there is none.
|
|
8
|
+
*
|
|
9
|
+
* Both endpoints are reads, there is no raw-SQL endpoint, and no row
|
|
10
|
+
* mutations — that is the plugin's contract, not a configuration. The
|
|
11
|
+
* dashboard's `DASHBOARD_ALLOW_WRITES` gate exists because the dashboard
|
|
12
|
+
* *has* write paths to gate; the explorer removes the paths instead of
|
|
13
|
+
* gating them, so there is no flag to leave set by accident and no second
|
|
14
|
+
* write path for a gate to miss.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export async function handleSchema(): Promise<JsonResponseData<unknown>> {
|
|
18
|
+
return await Try.return(
|
|
19
|
+
async () => response.json.success('success', await connection.getSchema()),
|
|
20
|
+
() => response.json.error(500, 'Failed to retrieve schema details'),
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Table names the way the ORM writes them: identifier characters only. */
|
|
25
|
+
const RX_TABLE_NAME = /^[a-zA-Z0-9_]+$/
|
|
26
|
+
|
|
27
|
+
export async function handleTableData(
|
|
28
|
+
url: URL,
|
|
29
|
+
): Promise<JsonResponseData<unknown>> {
|
|
30
|
+
const tableName = url.searchParams.get('tableName')
|
|
31
|
+
if (!tableName || !RX_TABLE_NAME.test(tableName)) {
|
|
32
|
+
return response.json.error(400, 'Invalid table name')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return await Try.return(
|
|
36
|
+
async () => {
|
|
37
|
+
const data = await connection.getData(tableName, {
|
|
38
|
+
page: Number.parseInt(url.searchParams.get('page') || '1', 10),
|
|
39
|
+
pageSize: Number.parseInt(url.searchParams.get('pageSize') || '50', 10),
|
|
40
|
+
sortBy: url.searchParams.get('sortBy'),
|
|
41
|
+
sortOrder: url.searchParams.get('sortOrder') || 'ASC',
|
|
42
|
+
filters: JSON.parse(url.searchParams.get('filters') || '{}'),
|
|
43
|
+
})
|
|
44
|
+
return response.json.success('success', data)
|
|
45
|
+
},
|
|
46
|
+
(error: any) => response.json.error(400, error.message),
|
|
47
|
+
)
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { definePlugin } from '@bakery-framework/core/plugins'
|
|
2
|
+
import type { AuthorizeFn } from './authorize'
|
|
3
|
+
|
|
4
|
+
export type { AuthorizeFn } from './authorize'
|
|
5
|
+
|
|
6
|
+
export interface DbExplorerPluginOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Register the explorer. Defaults to true; set false to keep it out of a
|
|
9
|
+
* build entirely.
|
|
10
|
+
*/
|
|
11
|
+
enabled?: boolean
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Decide whether a request may browse the database. Return true to allow.
|
|
15
|
+
*
|
|
16
|
+
* The explorer authenticates nobody itself — the application does, because
|
|
17
|
+
* it already knows who its users are:
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* dbExplorerPlugin({
|
|
21
|
+
* authorize: req => req.session.get('role') === 'admin',
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Omitted, access is loopback-only in development and denied in
|
|
26
|
+
* production, so an unconfigured explorer is never exposed.
|
|
27
|
+
*/
|
|
28
|
+
authorize?: AuthorizeFn
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A shared access key, typically from the environment:
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* dbExplorerPlugin({ credential: import.meta.env.DB_EXPLORER_KEY })
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* Presented as `Authorization: Bearer`, an `x-db-key` header, or a
|
|
38
|
+
* one-time `?key=` query for a browser (stored client-side, stripped from
|
|
39
|
+
* the URL). Checked in constant time. Unset or empty means this path is
|
|
40
|
+
* off — it never means open. Composes with `authorize`: either admits.
|
|
41
|
+
*/
|
|
42
|
+
credential?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A read-only database browser at `/_db`: table list, rows, paging, sorting.
|
|
47
|
+
*
|
|
48
|
+
* Read-only is the contract, not a mode. There is no raw-SQL endpoint, no
|
|
49
|
+
* row mutation, and no DDL — where the dashboard gates its write paths
|
|
50
|
+
* behind `DASHBOARD_ALLOW_WRITES`, the explorer has no write paths to gate.
|
|
51
|
+
*/
|
|
52
|
+
export default function dbExplorerPlugin(
|
|
53
|
+
options: DbExplorerPluginOptions = {},
|
|
54
|
+
) {
|
|
55
|
+
const enabled = options.enabled ?? true
|
|
56
|
+
|
|
57
|
+
return definePlugin({
|
|
58
|
+
name: 'db-explorer',
|
|
59
|
+
async setup() {
|
|
60
|
+
if (!enabled) return
|
|
61
|
+
const { setupExplorer } = await import('./setup')
|
|
62
|
+
setupExplorer({
|
|
63
|
+
authorize: options.authorize,
|
|
64
|
+
credential: options.credential,
|
|
65
|
+
})
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { bundleModule } from '@bakery-framework/core/compiler'
|
|
2
|
+
import { Bakery } from '@bakery-framework/core/core/bakery'
|
|
3
|
+
import { Handler } from '@bakery-framework/core/handlers'
|
|
4
|
+
import { errorMsg } from '@bakery-framework/core/logger'
|
|
5
|
+
import type { PluginRouteTable } from '@bakery-framework/core/plugins'
|
|
6
|
+
import { routeTable } from '@bakery-framework/core/plugins'
|
|
7
|
+
import { fs } from '@bakery-framework/core/utils'
|
|
8
|
+
import { response } from '@bakery-framework/core/utils/http'
|
|
9
|
+
import {
|
|
10
|
+
type AuthorizeFn,
|
|
11
|
+
credentialMatches,
|
|
12
|
+
defaultAuthorize,
|
|
13
|
+
isAuthorized,
|
|
14
|
+
resolveAuthorize,
|
|
15
|
+
} from './authorize'
|
|
16
|
+
import { handleSchema, handleTableData } from './endpoints'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where this plugin's own files live — each package that ships files anchors
|
|
20
|
+
* to its own location, never to core's (see the dashboard's `paths.ts` for
|
|
21
|
+
* the 404s that lesson cost).
|
|
22
|
+
*/
|
|
23
|
+
const pluginRoot: string = fs.resolve(import.meta.dir)
|
|
24
|
+
|
|
25
|
+
let authorize: AuthorizeFn = defaultAuthorize
|
|
26
|
+
let credential: string | undefined
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Test seam, same shape as the dashboard's: `setupExplorer` mutates process
|
|
30
|
+
* globals that cannot be restored, so tests that only need the request
|
|
31
|
+
* pipeline set the predicate directly. Always pair with the reset.
|
|
32
|
+
*/
|
|
33
|
+
export function __setTestAuthorize(fn: AuthorizeFn): void {
|
|
34
|
+
authorize = fn
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function __resetTestAuthorize(): void {
|
|
38
|
+
authorize = defaultAuthorize
|
|
39
|
+
credential = undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Test seam for the credential path; reset with __resetTestAuthorize. */
|
|
43
|
+
export function __setTestCredential(value: string | undefined): void {
|
|
44
|
+
credential = value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SHELL = `<!DOCTYPE html>
|
|
48
|
+
<html lang="en">
|
|
49
|
+
<head>
|
|
50
|
+
<meta charset="UTF-8">
|
|
51
|
+
<title>Database explorer</title>
|
|
52
|
+
<style>
|
|
53
|
+
:root { color-scheme: dark; }
|
|
54
|
+
* { box-sizing: border-box; }
|
|
55
|
+
body { margin: 0; font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; background: #0f1115; color: #e6e8ee; }
|
|
56
|
+
#app { display: flex; min-height: 100vh; }
|
|
57
|
+
.side { width: 220px; padding: 1rem; border-right: 1px solid #262b36; flex-shrink: 0; }
|
|
58
|
+
.brand { font-size: 1rem; margin: 0; }
|
|
59
|
+
.note { color: #9aa3b2; font-size: 0.8rem; }
|
|
60
|
+
.error { color: #ff8ba0; padding: 1rem; }
|
|
61
|
+
.table-btn { display: block; width: 100%; text-align: left; background: none; border: 0; color: #cfd6e4; padding: 0.35rem 0.5rem; border-radius: 6px; cursor: pointer; font: inherit; }
|
|
62
|
+
.table-btn:hover { background: #1a1f2b; }
|
|
63
|
+
.table-btn.active { background: #16233d; color: #cfe0ff; }
|
|
64
|
+
.main { flex: 1; padding: 1rem 1.5rem; min-width: 0; }
|
|
65
|
+
.table-head { display: flex; align-items: baseline; gap: 1rem; }
|
|
66
|
+
.table-head h2 { margin: 0.2rem 0 0.8rem; font-family: ui-monospace, monospace; font-size: 1rem; }
|
|
67
|
+
.scroll { overflow-x: auto; border: 1px solid #262b36; border-radius: 8px; }
|
|
68
|
+
.grid { border-collapse: collapse; width: 100%; font-size: 0.82rem; }
|
|
69
|
+
.grid th { text-align: left; padding: 0.45rem 0.7rem; background: #151922; cursor: pointer; white-space: nowrap; position: sticky; top: 0; }
|
|
70
|
+
.grid td { padding: 0.35rem 0.7rem; border-top: 1px solid #1e2430; font-family: ui-monospace, monospace; white-space: nowrap; max-width: 26rem; overflow: hidden; text-overflow: ellipsis; }
|
|
71
|
+
.grid td.null { color: #5b6472; font-style: italic; }
|
|
72
|
+
.pager { margin-top: 0.8rem; display: flex; gap: 0.5rem; }
|
|
73
|
+
.pager button { font: inherit; padding: 0.3rem 0.8rem; border-radius: 6px; border: 1px solid #2f6feb; background: #16233d; color: #cfe0ff; cursor: pointer; }
|
|
74
|
+
.pager button:disabled { opacity: 0.4; cursor: default; }
|
|
75
|
+
</style>
|
|
76
|
+
</head>
|
|
77
|
+
<body>
|
|
78
|
+
<div id="app"><p class="note" style="padding:1rem">loading…</p></div>
|
|
79
|
+
<script type="module" src="/_db/app.js"></script>
|
|
80
|
+
</body>
|
|
81
|
+
</html>`
|
|
82
|
+
|
|
83
|
+
let cachedClientJs: string | null = null
|
|
84
|
+
|
|
85
|
+
async function handleClientJs() {
|
|
86
|
+
// PROD caches the bundle in memory; dev recompiles so edits show up.
|
|
87
|
+
if (cachedClientJs && import.meta.env.PROD) {
|
|
88
|
+
return response.type(cachedClientJs, 'text/javascript; charset=utf-8')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const built = await bundleModule(
|
|
92
|
+
fs.resolve(pluginRoot, 'client.ts') as fs.AbsolutePath,
|
|
93
|
+
)
|
|
94
|
+
if (!built.success || !built.content) {
|
|
95
|
+
return response.error(
|
|
96
|
+
`Failed to bundle explorer client: ${errorMsg(built.errors?.join('\n'))}`,
|
|
97
|
+
500,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
cachedClientJs = built.content
|
|
102
|
+
return response.type(built.content, 'text/javascript; charset=utf-8')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read-only by construction: the two data endpoints call only `getSchema`
|
|
107
|
+
* and `getData`. No raw SQL, no row mutations, no DDL — the write paths do
|
|
108
|
+
* not exist, which is a stronger property than any gate over them. The keys
|
|
109
|
+
* are method-unqualified because a bare key matches any method and every
|
|
110
|
+
* handler here is a read; there is nothing a smuggled POST could mutate,
|
|
111
|
+
* which is also why this table carries no CSRF middleware where the
|
|
112
|
+
* dashboard's must.
|
|
113
|
+
*/
|
|
114
|
+
const explorerRoutes = {
|
|
115
|
+
'/_db': () => response.html(SHELL),
|
|
116
|
+
'/_db/app.js': () => handleClientJs(),
|
|
117
|
+
'/api/_db/schema': () => handleSchema(),
|
|
118
|
+
'/api/_db/table-data': (_req, url) => handleTableData(url),
|
|
119
|
+
} satisfies PluginRouteTable
|
|
120
|
+
|
|
121
|
+
const dispatchExplorerRoute = routeTable(explorerRoutes)
|
|
122
|
+
|
|
123
|
+
export class DbExplorerHandler extends Handler {
|
|
124
|
+
static canHandle(path: string) {
|
|
125
|
+
return (
|
|
126
|
+
path === '/_db' ||
|
|
127
|
+
path.startsWith('/_db/') ||
|
|
128
|
+
path === '/api/_db' ||
|
|
129
|
+
path.startsWith('/api/_db/')
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
static async handle(path: string, req: Request) {
|
|
134
|
+
// Styling and script are not secrets, and letting them through keeps an
|
|
135
|
+
// unauthorised response from rendering unstyled — same split as the
|
|
136
|
+
// dashboard. Everything else fails closed. Either door admits: the
|
|
137
|
+
// shared credential (constant-time, off when unset) or the predicate.
|
|
138
|
+
const admitted =
|
|
139
|
+
credentialMatches(credential, req) || (await isAuthorized(authorize, req))
|
|
140
|
+
if (!/\.(css|js)$/.test(path) && !admitted) {
|
|
141
|
+
return path.startsWith('/api/')
|
|
142
|
+
? response.error('Unauthorized', 401)
|
|
143
|
+
: response.error('Not Found', 404)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Dispatch keys on `url.pathname` from the request itself — the `path`
|
|
147
|
+
// argument only steers the auth split above.
|
|
148
|
+
const result = await dispatchExplorerRoute(req)
|
|
149
|
+
return result ?? response.error('Not Found', 404)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function setupExplorer(
|
|
154
|
+
options: { authorize?: AuthorizeFn; credential?: string } = {},
|
|
155
|
+
) {
|
|
156
|
+
authorize = resolveAuthorize(options.authorize)
|
|
157
|
+
credential = options.credential
|
|
158
|
+
// Above the content handlers, below nothing that matters: the /_db and
|
|
159
|
+
// /api/_db namespaces are reserved for framework routes (convention 10),
|
|
160
|
+
// so priority only needs to beat ApiHandler (70) for the /api half.
|
|
161
|
+
Bakery.handlers.fetch.set(DbExplorerHandler, 115)
|
|
162
|
+
}
|