@bakery-framework/plugin-dashboard 1.0.0

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.
@@ -0,0 +1,90 @@
1
+ export function renderSessionsPanel() {
2
+ return (
3
+ <div id="panel-sessions" class="panel">
4
+ <div class="session-browser-header glass-effect">
5
+ <div class="actions-row session-browser-title-row">
6
+ <div>
7
+ <h2>Active Evictable Sessions</h2>
8
+ <span class="session-browser-subtitle">
9
+ Search, sort, and page through in-memory session records.
10
+ </span>
11
+ </div>
12
+ <button
13
+ type="button"
14
+ class="btn btn-secondary"
15
+ onclick="loadSessions()">
16
+ <iconify-icon icon="lucide:refresh-cw"></iconify-icon>
17
+ <span>Refresh Sessions</span>
18
+ </button>
19
+ </div>
20
+
21
+ <div class="session-browser-controls">
22
+ <input
23
+ id="session-search-input"
24
+ class="search-input session-search-input"
25
+ type="text"
26
+ placeholder="Search by session id or value..."
27
+ oninput="queueSessionSearch()"
28
+ />
29
+ <select
30
+ id="session-sort-by"
31
+ class="filter-select"
32
+ onchange="loadSessions()">
33
+ <option value="accessed">Last Accessed</option>
34
+ <option value="id">Session ID</option>
35
+ <option value="keys">Persisted Keys</option>
36
+ </select>
37
+ <select
38
+ id="session-sort-order"
39
+ class="filter-select"
40
+ onchange="loadSessions()">
41
+ <option value="DESC">Newest First</option>
42
+ <option value="ASC">Oldest First</option>
43
+ </select>
44
+ <select
45
+ id="session-page-size"
46
+ class="filter-select"
47
+ onchange="changeSessionPageSize()">
48
+ <option value="10">10 rows</option>
49
+ <option value="25" selected>
50
+ 25 rows
51
+ </option>
52
+ <option value="50">50 rows</option>
53
+ <option value="100">100 rows</option>
54
+ <option value="500">500 rows</option>
55
+ </select>
56
+ </div>
57
+
58
+ <div class="session-browser-pagination pagination-bar compact">
59
+ <div class="page-nav">
60
+ <button
61
+ type="button"
62
+ id="session-page-prev"
63
+ class="btn btn-secondary"
64
+ onclick="prevSessionPage()">
65
+ <iconify-icon icon="lucide:chevron-left"></iconify-icon>
66
+ <span>Prev</span>
67
+ </button>
68
+ <span id="session-page-info">Page 1 of 1</span>
69
+ <button
70
+ type="button"
71
+ id="session-page-next"
72
+ class="btn btn-secondary"
73
+ onclick="nextSessionPage()">
74
+ <span>Next</span>
75
+ <iconify-icon icon="lucide:chevron-right"></iconify-icon>
76
+ </button>
77
+ </div>
78
+ <div class="rows-meta">
79
+ <span id="session-rows-meta">0 sessions matching filters</span>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ <div class="session-grid" id="session-container">
84
+ <div class="results-empty">
85
+ <span>Loading active sessions...</span>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ )
90
+ }
@@ -0,0 +1,265 @@
1
+ export function renderStatsPanel() {
2
+ return (
3
+ <div id="panel-stats" class="panel active">
4
+ <div class="grid-stats">
5
+ <div class="card glass-effect">
6
+ <span class="card-title">Process Uptime</span>
7
+ <span class="card-value" id="stat-uptime">
8
+ 0s
9
+ </span>
10
+ <span class="card-sub" id="stat-pid">
11
+ PID: -
12
+ </span>
13
+ </div>
14
+ <div class="card glass-effect">
15
+ <span class="card-title">Ping Latency</span>
16
+ <span class="card-value" id="stat-ping">
17
+ 0 ms
18
+ </span>
19
+ <span class="card-sub">Client Latency</span>
20
+ </div>
21
+ <div class="card glass-effect">
22
+ <span class="card-title">Memory Allocation</span>
23
+ <span class="card-value" id="stat-memory">
24
+ 0 MB
25
+ </span>
26
+ <span class="card-sub" id="stat-mem-total">
27
+ Total Alloc
28
+ </span>
29
+ </div>
30
+ <div class="card glass-effect">
31
+ <span class="card-title">Active Loggers</span>
32
+ <span class="card-value" id="stat-loggers">
33
+ 0
34
+ </span>
35
+ <span class="card-sub">Connections</span>
36
+ </div>
37
+ <div class="card glass-effect">
38
+ <span class="card-title">Active Sessions</span>
39
+ <span class="card-value" id="stat-sessions">
40
+ 0
41
+ </span>
42
+ <span class="card-sub">In-Memory Store</span>
43
+ </div>
44
+ <div class="card glass-effect">
45
+ <span class="card-title">Bun Runtime</span>
46
+ <span class="card-value" id="stat-bun-version">
47
+ v-
48
+ </span>
49
+ <span class="card-sub" id="stat-arch">
50
+ Architecture
51
+ </span>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="section-header">
56
+ <h2>
57
+ <iconify-icon icon="lucide:area-chart"></iconify-icon>
58
+ <span>Performance History</span>
59
+ </h2>
60
+ <div class="timescale-selector">
61
+ <button
62
+ type="button"
63
+ class="timescale-btn active"
64
+ id="timescale-1m"
65
+ onclick="changeTimescale('1m')">
66
+ 1m
67
+ </button>
68
+ <button
69
+ type="button"
70
+ class="timescale-btn"
71
+ id="timescale-1h"
72
+ onclick="changeTimescale('1h')">
73
+ 1h
74
+ </button>
75
+ <button
76
+ type="button"
77
+ class="timescale-btn"
78
+ id="timescale-1d"
79
+ onclick="changeTimescale('1d')">
80
+ 1d
81
+ </button>
82
+ <button
83
+ type="button"
84
+ class="timescale-btn"
85
+ id="timescale-7d"
86
+ onclick="changeTimescale('7d')">
87
+ 7d
88
+ </button>
89
+ <button
90
+ type="button"
91
+ class="timescale-btn"
92
+ id="timescale-30d"
93
+ onclick="changeTimescale('30d')">
94
+ 30d
95
+ </button>
96
+ </div>
97
+ </div>
98
+
99
+ <div class="grid-charts">
100
+ <div class="chart-card glass-effect">
101
+ <span class="card-title">Ping Latency History</span>
102
+ <span class="card-sub">
103
+ Client-to-server connection latency (last 1 min, 1s resolution)
104
+ </span>
105
+ <canvas id="canvas-ping" class="big-chart"></canvas>
106
+ <div class="chart-stats">
107
+ <span>
108
+ MIN: <strong id="ping-min">-</strong>
109
+ </span>
110
+ <span>
111
+ MAX: <strong id="ping-max">-</strong>
112
+ </span>
113
+ <span>
114
+ AVG: <strong id="ping-avg">-</strong>
115
+ </span>
116
+ </div>
117
+ </div>
118
+ <div class="chart-card glass-effect">
119
+ <span class="card-title">Memory History</span>
120
+ <span class="card-sub">
121
+ Heap/RSS RAM consumption (last 1 min, 1s resolution)
122
+ </span>
123
+ <canvas id="canvas-memory" class="big-chart"></canvas>
124
+ <div class="chart-stats">
125
+ <span>
126
+ MIN: <strong id="memory-min">-</strong>
127
+ </span>
128
+ <span>
129
+ MAX: <strong id="memory-max">-</strong>
130
+ </span>
131
+ <span>
132
+ AVG: <strong id="memory-avg">-</strong>
133
+ </span>
134
+ </div>
135
+ </div>
136
+ <div class="chart-card glass-effect">
137
+ <span class="card-title">WebSocket Connections</span>
138
+ <span class="card-sub">
139
+ Active client logger tunnels (last 1 min, 1s resolution)
140
+ </span>
141
+ <canvas id="canvas-loggers" class="big-chart"></canvas>
142
+ <div class="chart-stats">
143
+ <span>
144
+ MIN: <strong id="loggers-min">-</strong>
145
+ </span>
146
+ <span>
147
+ MAX: <strong id="loggers-max">-</strong>
148
+ </span>
149
+ <span>
150
+ AVG: <strong id="loggers-avg">-</strong>
151
+ </span>
152
+ </div>
153
+ </div>
154
+ <div class="chart-card glass-effect">
155
+ <span class="card-title">Active Sessions</span>
156
+ <span class="card-sub">
157
+ In-memory active user sessions (last 1 min, 1s resolution)
158
+ </span>
159
+ <canvas id="canvas-sessions" class="big-chart"></canvas>
160
+ <div class="chart-stats">
161
+ <span>
162
+ MIN: <strong id="sessions-min">-</strong>
163
+ </span>
164
+ <span>
165
+ MAX: <strong id="sessions-max">-</strong>
166
+ </span>
167
+ <span>
168
+ AVG: <strong id="sessions-avg">-</strong>
169
+ </span>
170
+ </div>
171
+ </div>
172
+ <div class="chart-card glass-effect" id="chart-route-hits">
173
+ <span class="card-title">Page Route Hits History</span>
174
+ <span class="card-sub">
175
+ Application page requests (last 1 min, 1s resolution)
176
+ </span>
177
+ <canvas id="canvas-route-hits" class="big-chart"></canvas>
178
+ <div class="chart-stats">
179
+ <span>
180
+ MIN: <strong id="pageHits-min">-</strong>
181
+ </span>
182
+ <span>
183
+ MAX: <strong id="pageHits-max">-</strong>
184
+ </span>
185
+ <span>
186
+ AVG: <strong id="pageHits-avg">-</strong>
187
+ </span>
188
+ </div>
189
+ </div>
190
+ <div class="chart-card glass-effect" id="chart-api-hits">
191
+ <span class="card-title">API Hits History</span>
192
+ <span class="card-sub">
193
+ API endpoint requests (last 1 min, 1s resolution)
194
+ </span>
195
+ <canvas id="canvas-api-hits" class="big-chart"></canvas>
196
+ <div class="chart-stats">
197
+ <span>
198
+ MIN: <strong id="apiHits-min">-</strong>
199
+ </span>
200
+ <span>
201
+ MAX: <strong id="apiHits-max">-</strong>
202
+ </span>
203
+ <span>
204
+ AVG: <strong id="apiHits-avg">-</strong>
205
+ </span>
206
+ </div>
207
+ </div>
208
+ <div class="chart-card glass-effect" id="chart-unique-requests">
209
+ <span class="card-title">Unique Requests History</span>
210
+ <span class="card-sub">
211
+ Distinct request signatures (last 1 min, 1s resolution)
212
+ </span>
213
+ <canvas id="canvas-unique-requests" class="big-chart"></canvas>
214
+ <div class="chart-stats">
215
+ <span>
216
+ MIN: <strong id="uniqueRequests-min">-</strong>
217
+ </span>
218
+ <span>
219
+ MAX: <strong id="uniqueRequests-max">-</strong>
220
+ </span>
221
+ <span>
222
+ AVG: <strong id="uniqueRequests-avg">-</strong>
223
+ </span>
224
+ </div>
225
+ </div>
226
+ <div class="chart-card glass-effect" id="chart-db-hits">
227
+ <span class="card-title">DB Hits History</span>
228
+ <span class="card-sub">
229
+ Database query executions (last 1 min, 1s resolution)
230
+ </span>
231
+ <canvas id="canvas-db-hits" class="big-chart"></canvas>
232
+ <div class="chart-stats">
233
+ <span>
234
+ MIN: <strong id="dbHits-min">-</strong>
235
+ </span>
236
+ <span>
237
+ MAX: <strong id="dbHits-max">-</strong>
238
+ </span>
239
+ <span>
240
+ AVG: <strong id="dbHits-avg">-</strong>
241
+ </span>
242
+ </div>
243
+ </div>
244
+ <div class="chart-card glass-effect" id="chart-error-page-hits">
245
+ <span class="card-title">Error Page Hits History</span>
246
+ <span class="card-sub">
247
+ Custom error page renders (last 1 min, 1s resolution)
248
+ </span>
249
+ <canvas id="canvas-error-page-hits" class="big-chart"></canvas>
250
+ <div class="chart-stats">
251
+ <span>
252
+ MIN: <strong id="errorPageHits-min">-</strong>
253
+ </span>
254
+ <span>
255
+ MAX: <strong id="errorPageHits-max">-</strong>
256
+ </span>
257
+ <span>
258
+ AVG: <strong id="errorPageHits-avg">-</strong>
259
+ </span>
260
+ </div>
261
+ </div>
262
+ </div>
263
+ </div>
264
+ )
265
+ }
@@ -0,0 +1,57 @@
1
+ export function renderTopPagesPanel() {
2
+ return (
3
+ <div id="panel-top-pages" class="panel">
4
+ <div class="section-header">
5
+ <h2>
6
+ <iconify-icon icon="lucide:file-text"></iconify-icon>
7
+ <span>Top Visited Pages</span>
8
+ </h2>
9
+ <div class="timescale-selector">
10
+ <button
11
+ type="button"
12
+ class="pages-filter-btn"
13
+ id="pages-filter-1m"
14
+ onclick="changePagesFilter('1m')">
15
+ 1m
16
+ </button>
17
+ <button
18
+ type="button"
19
+ class="pages-filter-btn"
20
+ id="pages-filter-1h"
21
+ onclick="changePagesFilter('1h')">
22
+ 1h
23
+ </button>
24
+ <button
25
+ type="button"
26
+ class="pages-filter-btn active"
27
+ id="pages-filter-1d"
28
+ onclick="changePagesFilter('1d')">
29
+ 1d
30
+ </button>
31
+ <button
32
+ type="button"
33
+ class="pages-filter-btn"
34
+ id="pages-filter-7d"
35
+ onclick="changePagesFilter('7d')">
36
+ 7d
37
+ </button>
38
+ <button
39
+ type="button"
40
+ class="pages-filter-btn"
41
+ id="pages-filter-30d"
42
+ onclick="changePagesFilter('30d')">
43
+ 30d
44
+ </button>
45
+ </div>
46
+ </div>
47
+
48
+ <div class="card glass-effect top-pages-card">
49
+ <div id="top-pages-list-container">
50
+ <div class="results-empty">
51
+ <span>Loading top pages...</span>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ )
57
+ }
@@ -0,0 +1,211 @@
1
+ import { getElapsed } from '@bakery-framework/core/logger'
2
+ import { processBody } from '@bakery-framework/core/utils'
3
+ import type { JsonResponseData } from '@bakery-framework/core/utils/common'
4
+ import { is, Try } from '@bakery-framework/core/utils/common'
5
+ import { response } from '@bakery-framework/core/utils/http'
6
+ import { connection } from '@bakery-framework/orm/connection'
7
+
8
+ export async function handleSchema(): Promise<JsonResponseData<unknown>> {
9
+ return await Try.return(
10
+ async () => response.json.success('success', await connection.getSchema()),
11
+ () => response.json.error(500, 'Failed to retrieve schema details'),
12
+ )
13
+ }
14
+
15
+ export async function handleTableData(
16
+ url: URL,
17
+ ): Promise<JsonResponseData<unknown>> {
18
+ const tableName = url.searchParams.get('tableName')
19
+ if (!tableName || !/^[a-zA-Z0-9_]+$/.test(tableName))
20
+ return response.json.error(400, 'Invalid table name')
21
+
22
+ return await Try.return(
23
+ async () => {
24
+ const data = await connection.getData(tableName, {
25
+ page: parseInt(url.searchParams.get('page') || '1', 10),
26
+ pageSize: parseInt(url.searchParams.get('pageSize') || '50', 10),
27
+ sortBy: url.searchParams.get('sortBy'),
28
+ sortOrder: url.searchParams.get('sortOrder') || 'ASC',
29
+ filters: JSON.parse(url.searchParams.get('filters') || '{}'),
30
+ })
31
+ return response.json.success('success', data)
32
+ },
33
+ (error: any) => response.json.error(400, error.message),
34
+ )
35
+ }
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
+ }
@@ -0,0 +1,59 @@
1
+ import { isReservedSessionKey, Session } from '@bakery-framework/core/session'
2
+ import { processBody } from '@bakery-framework/core/utils'
3
+ import type { JsonResponseData } from '@bakery-framework/core/utils/common'
4
+ import { response } from '@bakery-framework/core/utils/http'
5
+
6
+ export async function handleGetSessions(
7
+ url: URL,
8
+ ): Promise<JsonResponseData<unknown>> {
9
+ const search = url.searchParams.get('search')?.trim().toLowerCase() || ''
10
+ const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10))
11
+ const pageSize = Math.min(
12
+ 500,
13
+ Math.max(1, parseInt(url.searchParams.get('pageSize') || '25', 10)),
14
+ )
15
+ const sortBy = url.searchParams.get('sortBy') || 'accessed'
16
+ const sortOrder = url.searchParams.get('sortOrder') === 'ASC' ? 'ASC' : 'DESC'
17
+
18
+ const result = await Session.list({
19
+ search,
20
+ page,
21
+ pageSize,
22
+ sortBy,
23
+ sortOrder,
24
+ })
25
+
26
+ return response.json.success('success', result)
27
+ }
28
+
29
+ export async function handleDeleteSession(
30
+ req: Request,
31
+ ): Promise<JsonResponseData<unknown>> {
32
+ const body = await processBody(req)
33
+ const deleted = body?.id && Session.delete(body.id)
34
+ return deleted
35
+ ? response.json.success('Session deleted')
36
+ : response.json.error(404, 'Session not found')
37
+ }
38
+
39
+ export async function handleUpdateSession(
40
+ req: Request,
41
+ ): Promise<JsonResponseData<unknown>> {
42
+ const body = await processBody(req)
43
+ const { id, key, value, remove } = body || {}
44
+
45
+ if (typeof id !== 'string' || typeof key !== 'string')
46
+ return response.json.error(400, 'Invalid payload')
47
+
48
+ // Without this, editing a session could set the dashboard's own auth marker
49
+ // on any user's session — a permanent backdoor.
50
+ if (isReservedSessionKey(key)) {
51
+ return response.json.error(403, 'Reserved session key')
52
+ }
53
+
54
+ const session = await Session.get(id)
55
+ if (!session) return response.json.error(404, 'Session not found')
56
+
57
+ remove ? session.delete(key) : session.set(key, value)
58
+ return response.json.success('Session updated', { id, key, value, remove })
59
+ }