@stacksjs/defaults 0.70.227 → 0.70.229

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,36 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { parseRowId, resolveWritableModel } from './model-write'
3
+
4
+ /**
5
+ * `DELETE /api/dashboard/models/{slug}/{id}`.
6
+ *
7
+ * Deletes one row from the dashboard's generic model browser. Goes through
8
+ * the model rather than the table so soft deletes, observers and cascade
9
+ * behaviour all apply exactly as they would anywhere else in the app.
10
+ */
11
+ export default new Action({
12
+ name: 'Dashboard Model Destroy',
13
+ description: 'Deletes one row of a model from the dashboard model browser.',
14
+ method: 'DELETE',
15
+ apiResponse: true,
16
+ async handle(req: { getParam?: (name: string) => unknown, route?: { params?: { slug?: string, id?: string } } }) {
17
+ const resolved = await resolveWritableModel(String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? ''))
18
+ if ('error' in resolved)
19
+ return { ok: false, error: resolved.error }
20
+
21
+ const id = parseRowId(req?.getParam?.('id') ?? req?.route?.params?.id)
22
+ if (id === null)
23
+ return { ok: false, error: 'A numeric row id is required.' }
24
+
25
+ try {
26
+ const row = await resolved.Model.find(id)
27
+ if (!row)
28
+ return { ok: false, error: `${resolved.modelName} ${id} not found.` }
29
+ await row.delete()
30
+ return { ok: true, id }
31
+ }
32
+ catch (e) {
33
+ return { ok: false, error: e instanceof Error ? e.message : String(e) }
34
+ }
35
+ },
36
+ })
@@ -1,37 +1,61 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { loadModel, safeAll, safeGet } from '../../../../resources/functions/dashboard/data'
2
+ import { request } from '@stacksjs/router'
3
+ import { loadModel, safeGet } from '../../../../resources/functions/dashboard/data'
3
4
 
4
5
  /**
5
6
  * `GET /api/dashboard/models/{slug}` (stacksjs/stacks#1838).
6
7
  *
7
- * Returns the first 50 rows of any model's table for the dynamic
8
- * `views/dashboard/models/[model].stx` page. Previously the page did
9
- * this work inline in a `<script server>` block — converting it for
10
- * the #1838 sweep moved the ORM lookup + raw-SQLite fallback into an
11
- * action so the page can stay `<script client>`-only.
8
+ * The query engine behind the dashboard's generic model browser
9
+ * (`views/dashboard/models/[model].stx`) — the page every model gets for
10
+ * free, whether or not someone hand-built a dedicated view for it.
12
11
  *
13
- * Resolution order:
14
- * 1. ORM via `loadModel(name)` — covers every model the dashboard
15
- * knows about and respects scopes, casts, accessors, observers.
16
- * 2. Raw SQLite via `bun:sqlite` — last-resort fallback for slugs
17
- * that don't have a matching ORM file (rare; usually a lookup
18
- * table or userland-only schema).
12
+ * Everything is resolved server-side: paging, sorting, a global search
13
+ * across text columns, and per-column equality filters. The page used to
14
+ * receive the first 50 rows and nothing else, so its pagination controls
15
+ * were inert and a table with real volume was unusable.
19
16
  *
20
- * The response shape mirrors what the original template consumed so
21
- * the page rewrite is a 1:1 swap (no template restructuring needed).
17
+ * Model resolution order:
18
+ * 1. `globalThis[Name]` — @stacksjs/orm injects every registered model
19
+ * as a global, and that is the same object the rest of the dashboard
20
+ * queries, so scopes, casts and accessors all apply.
21
+ * 2. `loadModel(Name)` — path-map lookup, covers models the ORM has not
22
+ * registered as a global.
23
+ * 3. Raw SQLite — last resort for a table with no model file at all
24
+ * (lookup/pivot tables, or a schema added outside the ORM).
22
25
  */
23
26
 
27
+ interface ColumnMeta {
28
+ name: string
29
+ label: string
30
+ /** Coarse kind used by the table to pick an alignment and a filter control. */
31
+ type: 'number' | 'boolean' | 'date' | 'json' | 'text'
32
+ }
33
+
24
34
  interface ResponseShape {
25
35
  modelName: string
26
36
  tableName: string
27
37
  rows: Array<Record<string, unknown>>
28
38
  columns: string[]
29
39
  displayColumns: string[]
40
+ columnMeta: ColumnMeta[]
41
+ searchable: string[]
30
42
  totalCount: number
43
+ page: number
44
+ perPage: number
45
+ lastPage: number
46
+ sort: string
47
+ dir: 'asc' | 'desc'
48
+ q: string
49
+ filters: Record<string, string>
31
50
  source: 'orm' | 'sqlite-fallback'
51
+ /** False for tables reached through the SQLite fallback: no model, no writes. */
52
+ writable: boolean
32
53
  error: string | null
33
54
  }
34
55
 
56
+ const DEFAULT_PER_PAGE = 25
57
+ const MAX_PER_PAGE = 200
58
+
35
59
  function slugToPascal(str: string): string {
36
60
  return str.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
37
61
  }
@@ -46,85 +70,268 @@ function pluralize(word: string): string {
46
70
  return `${word}s`
47
71
  }
48
72
 
49
- // Hide credential / token columns + private fields by default.
50
- const HIDDEN_COLUMNS = new Set(['password', 'remember_token', 'api_token', 'access_token', 'refresh_token', 'secret'])
73
+ function humanize(column: string): string {
74
+ return column
75
+ .replace(/_/g, ' ')
76
+ .replace(/\b\w/g, c => c.toUpperCase())
77
+ .replace(/\bId\b/g, 'ID')
78
+ .replace(/\bUrl\b/g, 'URL')
79
+ }
80
+
81
+ // Hide credential / token columns + private fields by default. These are
82
+ // dropped from the response body AND from the sort/filter allowlist: a
83
+ // column you cannot see is still an enumeration oracle if you can sort or
84
+ // filter by it.
85
+ const HIDDEN_COLUMNS = new Set(['password', 'remember_token', 'api_token', 'access_token', 'refresh_token', 'secret', 'two_factor_secret'])
86
+
87
+ /**
88
+ * Column identifiers reach the query builder as raw, unquoted SQL, so only
89
+ * word-shaped names from the table's own column list may ever be used.
90
+ */
91
+ function isSafeColumn(name: string, allowed: Set<string>): boolean {
92
+ return /^\w+$/.test(name) && allowed.has(name)
93
+ }
94
+
95
+ function inferType(values: unknown[]): ColumnMeta['type'] {
96
+ const sample = values.find(v => v !== null && v !== undefined)
97
+ if (sample === undefined) return 'text'
98
+ if (typeof sample === 'boolean') return 'boolean'
99
+ if (typeof sample === 'number') return 'number'
100
+ if (typeof sample === 'object') return 'json'
101
+ return 'text'
102
+ }
103
+
104
+ function typeForColumn(column: string, values: unknown[]): ColumnMeta['type'] {
105
+ if (column === 'id' || column.endsWith('_id')) return 'number'
106
+ if (column.endsWith('_at') || column.endsWith('_date')) return 'date'
107
+ if (column.startsWith('is_') || column.startsWith('has_')) return 'boolean'
108
+ return inferType(values)
109
+ }
110
+
111
+ function queryParams(): URLSearchParams {
112
+ const query = ((request as any).query || {}) as Record<string, string | string[] | undefined>
113
+ const params = new URLSearchParams()
114
+ for (const [key, value] of Object.entries(query)) {
115
+ if (value === undefined) continue
116
+ params.set(key, Array.isArray(value) ? String(value[0]) : String(value))
117
+ }
118
+ return params
119
+ }
120
+
121
+ /** `filters` arrives as a JSON object so column names stay unambiguous. */
122
+ function parseFilters(raw: string | null): Record<string, string> {
123
+ if (!raw) return {}
124
+ try {
125
+ const parsed = JSON.parse(raw) as Record<string, unknown>
126
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
127
+ const out: Record<string, string> = {}
128
+ for (const [key, value] of Object.entries(parsed)) {
129
+ if (value === null || value === undefined || value === '') continue
130
+ out[key] = String(value)
131
+ }
132
+ return out
133
+ }
134
+ catch {
135
+ return {}
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Signals that a free-text search spans several columns, which the query
141
+ * builder cannot express (`whereLike` is single-column and `orWhere` only
142
+ * takes equality pairs). The SQLite path builds that OR with bound
143
+ * parameters instead. Distinct from a real failure so it never surfaces
144
+ * to the page as an error.
145
+ */
146
+ class SearchUnsupported extends Error {}
147
+
148
+ function resolveOrmModel(modelName: string): any {
149
+ const injected = (globalThis as Record<string, any>)[modelName]
150
+ // The ORM injects Proxies that answer `undefined` for every property
151
+ // until their deferred load lands, so probe for a real query method
152
+ // rather than trusting the name to be bound.
153
+ if (injected && typeof injected.where === 'function') return injected
154
+ return null
155
+ }
51
156
 
52
157
  export default new Action({
53
158
  name: 'Dashboard Model Show',
54
- description: 'Returns the first N rows of a single model by URL slug.',
159
+ description: 'Queries a single model by URL slug with paging, sorting, search and column filters.',
55
160
  method: 'GET',
56
161
  apiResponse: true,
57
- async handle(request: { route?: { params?: { slug?: string } } }) {
58
- const slug = request?.route?.params?.slug || ''
162
+ async handle(req: { getParam?: (name: string) => unknown, route?: { params?: { slug?: string } } }) {
163
+ const slug = String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? '')
59
164
  const modelName = slugToPascal(slug)
60
165
  const tableName = pluralize(pascalToSnake(modelName))
61
166
 
167
+ const params = queryParams()
168
+ const page = Math.max(1, Number.parseInt(params.get('page') || '1', 10) || 1)
169
+ const perPage = Math.min(MAX_PER_PAGE, Math.max(1, Number.parseInt(params.get('per_page') || String(DEFAULT_PER_PAGE), 10) || DEFAULT_PER_PAGE))
170
+ const requestedSort = (params.get('sort') || '').trim()
171
+ const dir: 'asc' | 'desc' = params.get('dir') === 'asc' ? 'asc' : 'desc'
172
+ const q = (params.get('q') || '').trim()
173
+ const filters = parseFilters(params.get('filters'))
174
+
62
175
  const response: ResponseShape = {
63
176
  modelName,
64
177
  tableName,
65
178
  rows: [],
66
179
  columns: [],
67
180
  displayColumns: [],
181
+ columnMeta: [],
182
+ searchable: [],
68
183
  totalCount: 0,
184
+ page,
185
+ perPage,
186
+ lastPage: 1,
187
+ sort: '',
188
+ dir,
189
+ q,
190
+ filters,
69
191
  source: 'orm',
192
+ writable: false,
70
193
  error: null,
71
194
  }
72
195
 
73
- // ORM path.
74
- try {
75
- const Model = await loadModel(modelName)
76
- if (Model && !Model._isStub) {
77
- const all = await safeAll(Model) as Array<Record<string, unknown>>
78
- const rows = all.slice(0, 50)
79
- response.totalCount = all.length
80
-
81
- if (rows.length > 0) {
82
- const sample = rows[0] as Record<string, unknown>
83
- const attrs = sample && typeof sample === 'object' && !Array.isArray(sample)
84
- ? ((sample as { attributes?: Record<string, unknown> }).attributes ?? sample)
85
- : sample
86
- response.columns = Object.keys(attrs ?? {}).filter(k => typeof k === 'string' && !k.startsWith('_'))
196
+ const Model = resolveOrmModel(modelName) ?? await loadModel(modelName)
197
+ const hasOrm = Boolean(Model) && !Model._isStub && typeof Model.where === 'function'
198
+
199
+ if (hasOrm) {
200
+ try {
201
+ // The column list has to come from a real row: the ORM does not
202
+ // expose a schema reflection API, and the filter/sort allowlist
203
+ // must be built from actual columns rather than user input.
204
+ const probe = await Model.orderByDesc('id').take(1).get() as Array<Record<string, unknown>>
205
+ const first = Array.isArray(probe) ? probe[0] : undefined
206
+ const shape = first && typeof first === 'object'
207
+ ? ((first as { attributes?: Record<string, unknown> }).attributes ?? first)
208
+ : {}
209
+ response.columns = Object.keys(shape).filter(k => typeof k === 'string' && !k.startsWith('_'))
210
+
211
+ const allowed = new Set(response.columns.filter(c => !HIDDEN_COLUMNS.has(c)))
212
+ const sort = isSafeColumn(requestedSort, allowed)
213
+ ? requestedSort
214
+ : (allowed.has('id') ? 'id' : (response.columns[0] ?? ''))
215
+ response.sort = sort
216
+
217
+ // Text columns are decided from the probe row, before any query is
218
+ // built, so the search clause does not depend on response metadata
219
+ // that is only assembled after the rows come back.
220
+ const searchColumns = [...allowed].filter(c => typeof safeGet(first, c, null) === 'string')
221
+
222
+ const build = () => {
223
+ let chain = Model as any
224
+ for (const [column, value] of Object.entries(filters)) {
225
+ if (!isSafeColumn(column, allowed)) continue
226
+ chain = chain.where(column, value)
227
+ }
228
+ // Global search is a LIKE across every text column. The builder's
229
+ // `whereLike` covers one column and `orWhere` only takes equality
230
+ // pairs, so a multi-column OR-LIKE has no ORM expression; that
231
+ // case is served by the SQLite path below instead, which builds
232
+ // the OR with bound parameters. Writes stay enabled either way,
233
+ // because the model still exists.
234
+ if (q && searchColumns.length === 1)
235
+ chain = chain.whereLike(searchColumns[0], `%${q}%`)
236
+ return chain
87
237
  }
88
238
 
89
- // Flatten each row to a plain JSON-safe object — proxy models or
90
- // accessor-rich rows don't serialise cleanly across the wire.
91
- response.rows = rows.map((row) => {
239
+ // A multi-column search has no ORM expression, so hand that one
240
+ // query to the SQLite path — without recording an error, because it
241
+ // is a gap in the builder's vocabulary, not a failure.
242
+ if (q && searchColumns.length !== 1)
243
+ throw new SearchUnsupported()
244
+
245
+ response.totalCount = await build().count()
246
+ const rows = await build()
247
+ .orderBy(sort, dir)
248
+ .skip((page - 1) * perPage)
249
+ .take(perPage)
250
+ .get() as Array<Record<string, unknown>>
251
+
252
+ // Flatten to plain JSON-safe objects — proxy models and
253
+ // accessor-rich rows do not serialise cleanly across the wire.
254
+ response.rows = (Array.isArray(rows) ? rows : []).map((row) => {
92
255
  const flat: Record<string, unknown> = {}
93
256
  for (const col of response.columns)
94
257
  flat[col] = safeGet(row, col, null)
95
258
  return flat
96
259
  })
97
260
  }
98
- else {
261
+ catch (e) {
99
262
  response.source = 'sqlite-fallback'
263
+ if (!(e instanceof SearchUnsupported))
264
+ response.error = e instanceof Error ? e.message : String(e)
100
265
  }
101
266
  }
102
- catch (e) {
267
+ else {
103
268
  response.source = 'sqlite-fallback'
104
- response.error = e instanceof Error ? e.message : String(e)
105
269
  }
106
270
 
107
- // SQLite fallback path.
108
- if (response.source === 'sqlite-fallback' && response.rows.length === 0) {
271
+ if (response.source === 'sqlite-fallback') {
109
272
  try {
110
273
  const { Database } = await import('bun:sqlite')
111
274
  const db = new Database('database/stacks.sqlite')
112
- const tableInfo = db.query(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>
113
- response.columns = tableInfo.map(c => c.name)
114
- if (response.columns.length > 0) {
115
- const countResult = db.query(`SELECT COUNT(*) as count FROM ${tableName}`).get() as { count?: number } | null
116
- response.totalCount = countResult?.count ?? 0
117
- response.rows = db.query(`SELECT * FROM ${tableName} ORDER BY id DESC LIMIT 50`).all() as Array<Record<string, unknown>>
118
- response.error = null
275
+ try {
276
+ const tableInfo = db.query(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string, type: string }>
277
+ response.columns = tableInfo.map(c => c.name)
278
+
279
+ if (response.columns.length > 0) {
280
+ const allowed = new Set(response.columns.filter(c => !HIDDEN_COLUMNS.has(c)))
281
+ const sort = isSafeColumn(requestedSort, allowed)
282
+ ? requestedSort
283
+ : (allowed.has('id') ? 'id' : response.columns[0])
284
+ response.sort = sort
285
+
286
+ // Every value is bound; only identifiers are interpolated, and
287
+ // those come from the allowlist built out of PRAGMA above.
288
+ const wheres: string[] = []
289
+ const values: unknown[] = []
290
+ for (const [column, value] of Object.entries(filters)) {
291
+ if (!isSafeColumn(column, allowed)) continue
292
+ wheres.push(`${column} = ?`)
293
+ values.push(value)
294
+ }
295
+ if (q) {
296
+ const textColumns = tableInfo
297
+ .filter(c => allowed.has(c.name) && /char|text|clob/i.test(c.type || ''))
298
+ .map(c => c.name)
299
+ if (textColumns.length > 0) {
300
+ wheres.push(`(${textColumns.map(c => `${c} LIKE ?`).join(' OR ')})`)
301
+ for (const _ of textColumns) values.push(`%${q}%`)
302
+ }
303
+ }
304
+ const whereSql = wheres.length > 0 ? ` WHERE ${wheres.join(' AND ')}` : ''
305
+
306
+ const countRow = db.query(`SELECT COUNT(*) as count FROM ${tableName}${whereSql}`).get(...values as any[]) as { count?: number } | null
307
+ response.totalCount = countRow?.count ?? 0
308
+ response.rows = db
309
+ .query(`SELECT * FROM ${tableName}${whereSql} ORDER BY ${sort} ${dir.toUpperCase()} LIMIT ? OFFSET ?`)
310
+ .all(...values as any[], perPage, (page - 1) * perPage) as Array<Record<string, unknown>>
311
+ response.error = null
312
+ }
313
+ }
314
+ finally {
315
+ db.close()
119
316
  }
120
- db.close()
121
317
  }
122
318
  catch (e) {
123
319
  response.error = e instanceof Error ? e.message : String(e)
124
320
  }
125
321
  }
126
322
 
323
+ // Writability follows the existence of a model, not which engine
324
+ // answered this particular query: a multi-column search is served by
325
+ // SQLite even for a model that is perfectly writable.
326
+ response.writable = hasOrm
127
327
  response.displayColumns = response.columns.filter(col => !HIDDEN_COLUMNS.has(col) && !col.startsWith('_'))
328
+ response.columnMeta = response.displayColumns.map(name => ({
329
+ name,
330
+ label: humanize(name),
331
+ type: typeForColumn(name, response.rows.map(r => r[name])),
332
+ }))
333
+ response.searchable = response.columnMeta.filter(c => c.type === 'text').map(c => c.name)
334
+ response.lastPage = Math.max(1, Math.ceil(response.totalCount / perPage))
128
335
 
129
336
  return response
130
337
  },
@@ -0,0 +1,59 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { request } from '@stacksjs/router'
3
+ import { parseRowId, PROTECTED_COLUMNS, resolveWritableModel } from './model-write'
4
+
5
+ /**
6
+ * `PATCH /api/dashboard/models/{slug}/{id}`.
7
+ *
8
+ * Edits one row from the dashboard's generic model browser. The page's
9
+ * edit control used to be decorative — a button with no handler — which is
10
+ * worse than having none, because the row reads as manageable and silently
11
+ * is not.
12
+ */
13
+ export default new Action({
14
+ name: 'Dashboard Model Update',
15
+ description: 'Updates one row of a model from the dashboard model browser.',
16
+ method: 'PATCH',
17
+ apiResponse: true,
18
+ async handle(req: {
19
+ getParam?: (name: string) => unknown
20
+ all?: () => Record<string, unknown>
21
+ route?: { params?: { slug?: string, id?: string } }
22
+ }) {
23
+ const resolved = await resolveWritableModel(String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? ''))
24
+ if ('error' in resolved)
25
+ return { ok: false, error: resolved.error }
26
+
27
+ const id = parseRowId(req?.getParam?.('id') ?? req?.route?.params?.id)
28
+ if (id === null)
29
+ return { ok: false, error: 'A numeric row id is required.' }
30
+
31
+ // Edits arrive nested under `fields`. The router's input bag merges the
32
+ // body with route params and query, so a flat payload would silently
33
+ // pick up the `slug` and `id` from the URL and try to write them as
34
+ // columns — and `slug` is a real column on plenty of models, so it
35
+ // cannot simply be blocklisted. One level of nesting keeps the two
36
+ // namespaces apart.
37
+ const input = (req.all?.() ?? (request as any).all?.() ?? {}) as Record<string, unknown>
38
+ const raw = input.fields
39
+ const body = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? raw as Record<string, unknown> : {}
40
+ const changes: Record<string, unknown> = {}
41
+ for (const [key, value] of Object.entries(body)) {
42
+ if (!/^\w+$/.test(key) || PROTECTED_COLUMNS.has(key)) continue
43
+ changes[key] = value
44
+ }
45
+ if (Object.keys(changes).length === 0)
46
+ return { ok: false, error: 'No writable fields in the request body.' }
47
+
48
+ try {
49
+ const row = await resolved.Model.find(id)
50
+ if (!row)
51
+ return { ok: false, error: `${resolved.modelName} ${id} not found.` }
52
+ await row.update(changes)
53
+ return { ok: true, id, changed: Object.keys(changes) }
54
+ }
55
+ catch (e) {
56
+ return { ok: false, error: e instanceof Error ? e.message : String(e) }
57
+ }
58
+ },
59
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared plumbing for the dashboard model browser's write endpoints.
3
+ *
4
+ * Only the ORM path is writable. A table reachable solely through the
5
+ * read-side SQLite fallback has no model, so it has no fillable list, no
6
+ * casts and no observers; writing to it from a generic admin screen would
7
+ * bypass every guarantee the ORM makes. Those rows stay read-only.
8
+ */
9
+ import { loadModel } from '../../../../resources/functions/dashboard/data'
10
+
11
+ /** Never writable from a generic admin table, whatever the model declares. */
12
+ export const PROTECTED_COLUMNS: ReadonlySet<string> = new Set([
13
+ 'id',
14
+ 'uuid',
15
+ 'created_at',
16
+ 'updated_at',
17
+ 'password',
18
+ 'remember_token',
19
+ 'api_token',
20
+ 'access_token',
21
+ 'refresh_token',
22
+ 'secret',
23
+ 'two_factor_secret',
24
+ ])
25
+
26
+ export function slugToPascal(str: string): string {
27
+ return str.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
28
+ }
29
+
30
+ export interface ResolvedModel {
31
+ Model: any
32
+ modelName: string
33
+ }
34
+
35
+ /**
36
+ * Resolve a URL slug to a writable model, preferring the global the ORM
37
+ * injects (the same object the rest of the dashboard queries, so scopes,
38
+ * casts and observers all apply) over the path-map lookup.
39
+ */
40
+ export async function resolveWritableModel(slug: string): Promise<ResolvedModel | { error: string }> {
41
+ const modelName = slugToPascal(slug)
42
+ const injected = (globalThis as Record<string, any>)[modelName]
43
+ const Model = (injected && typeof injected.where === 'function') ? injected : await loadModel(modelName)
44
+ if (!Model || Model._isStub || typeof Model.find !== 'function')
45
+ return { error: `No ORM model named ${modelName}. Rows from tables without a model are read-only.` }
46
+ return { Model, modelName }
47
+ }
48
+
49
+ export function parseRowId(raw: unknown): number | null {
50
+ const id = Number.parseInt(String(raw ?? ''), 10)
51
+ return Number.isFinite(id) && id > 0 ? id : null
52
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Dashboard API client.
3
+ *
4
+ * Every mutating dashboard endpoint sits behind a stateless double-submit
5
+ * CSRF cookie: the server seeds a readable `X-CSRF-Token` cookie on safe
6
+ * requests, and a POST/PATCH/DELETE has to echo it back in a header. Pages
7
+ * were each re-implementing that cookie read inline, which put bare
8
+ * `document.*` access into template scripts and meant a change to the
9
+ * scheme had to be made in every page that writes.
10
+ */
11
+
12
+ export interface ApiRequestOptions {
13
+ method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
14
+ /** JSON request body. Omitted entirely for GET. */
15
+ body?: unknown
16
+ signal?: AbortSignal
17
+ }
18
+
19
+ /** Read the CSRF cookie the server seeds on safe requests. */
20
+ function csrfToken(): string | null {
21
+ if (typeof document === 'undefined')
22
+ return null
23
+ const match = document.cookie.match(/(?:^|;\s*)X-CSRF-Token=([^;]*)/)
24
+ return match ? decodeURIComponent(match[1]) : null
25
+ }
26
+
27
+ /**
28
+ * Call a dashboard API endpoint and return its parsed JSON.
29
+ *
30
+ * Throws on a non-2xx response, using the server's `message` / `error`
31
+ * field when there is one so callers can surface something better than a
32
+ * bare status code.
33
+ */
34
+ export async function dashboardApi<T = unknown>(path: string, options: ApiRequestOptions = {}): Promise<T> {
35
+ const method = options.method ?? 'GET'
36
+ const headers: Record<string, string> = { accept: 'application/json' }
37
+
38
+ if (options.body !== undefined)
39
+ headers['content-type'] = 'application/json'
40
+
41
+ if (method !== 'GET' && method !== 'HEAD') {
42
+ const token = csrfToken()
43
+ if (token)
44
+ headers['X-CSRF-Token'] = token
45
+ }
46
+
47
+ const res = await fetch(path, {
48
+ method,
49
+ headers,
50
+ signal: options.signal,
51
+ ...(options.body !== undefined && { body: JSON.stringify(options.body) }),
52
+ })
53
+
54
+ const text = await res.text()
55
+ let payload: any = null
56
+ try {
57
+ payload = text ? JSON.parse(text) : null
58
+ }
59
+ catch {
60
+ payload = null
61
+ }
62
+
63
+ if (!res.ok) {
64
+ const detail = payload?.message || payload?.error || `HTTP ${res.status}`
65
+ throw new Error(String(detail))
66
+ }
67
+
68
+ return payload as T
69
+ }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.70.227",
5
+ "version": "0.70.229",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.227",
5
+ "version": "0.70.229",
6
6
  "description": "The complete managed Stacks application scaffold, including runtime defaults, AI guidance, editor metadata, and npm-backed project support files.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -203,11 +203,18 @@ route.group({ prefix: '/api/dashboard', apiResponse: true }, () => {
203
203
  // `views/dashboard/models/index.stx` page (stacksjs/stacks#1838).
204
204
  guard(route.get('/models', 'Actions/Dashboard/Models/ModelsIndexAction'))
205
205
 
206
- // Per-model row view — first 50 rows + column list, for the
207
- // dynamic `views/dashboard/models/[model].stx` page. ORM path
208
- // first, raw SQLite fallback if no model file matches the slug.
206
+ // Per-model row query for the dynamic `views/dashboard/models/[model].stx`
207
+ // page: paging, sorting, search and column filters, all resolved
208
+ // server-side. ORM path first, raw SQLite fallback if no model file
209
+ // matches the slug.
209
210
  guard(route.get('/models/{slug}', 'Actions/Dashboard/Models/ModelShowAction'))
210
211
 
212
+ // Row writes from the same page. Guarded like every other mutating
213
+ // dashboard endpoint; models with no ORM file stay read-only, which the
214
+ // actions enforce rather than the route.
215
+ guard(route.patch('/models/{slug}/{id}', 'Actions/Dashboard/Models/ModelUpdateAction'))
216
+ guard(route.delete('/models/{slug}/{id}', 'Actions/Dashboard/Models/ModelDestroyAction'))
217
+
211
218
  // Markdown blog admin — the write side of the BunPress blog that /blog
212
219
  // renders from `content/blog/*.md` (storage/framework/core/actions/src/blog.ts).
213
220
  // Backs `views/dashboard/content/blog/index.stx`.