@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 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
+ }
@@ -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()
@@ -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
+ }