@pilotiq/pilotiq 0.21.0 → 0.22.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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +88 -0
- package/dist/Pilotiq.d.ts +72 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +145 -0
- package/dist/Pilotiq.js.map +1 -1
- package/dist/PilotiqServiceProvider.d.ts +2 -0
- package/dist/PilotiqServiceProvider.d.ts.map +1 -1
- package/dist/PilotiqServiceProvider.js +60 -12
- package/dist/PilotiqServiceProvider.js.map +1 -1
- package/dist/actions/importFactory.d.ts +5 -0
- package/dist/actions/importFactory.d.ts.map +1 -1
- package/dist/actions/importFactory.js +20 -10
- package/dist/actions/importFactory.js.map +1 -1
- package/dist/orm/modelDefaults.d.ts +10 -1
- package/dist/orm/modelDefaults.d.ts.map +1 -1
- package/dist/orm/modelDefaults.js +7 -2
- package/dist/orm/modelDefaults.js.map +1 -1
- package/dist/pageData/forms.js +3 -3
- package/dist/pageData/forms.js.map +1 -1
- package/dist/pageData/misc.js +5 -5
- package/dist/pageData/misc.js.map +1 -1
- package/dist/pageData/navigation.d.ts.map +1 -1
- package/dist/pageData/navigation.js +11 -9
- package/dist/pageData/navigation.js.map +1 -1
- package/dist/pageData/relationPages.d.ts.map +1 -1
- package/dist/pageData/relationPages.js +7 -4
- package/dist/pageData/relationPages.js.map +1 -1
- package/dist/pageData/resourcePages.js +6 -6
- package/dist/pageData/resourcePages.js.map +1 -1
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +1 -0
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/themeEditor.d.ts +20 -1
- package/dist/plugins/themeEditor.d.ts.map +1 -1
- package/dist/plugins/themeEditor.js +3 -1
- package/dist/plugins/themeEditor.js.map +1 -1
- package/dist/react/CollabRoomContext.d.ts +12 -0
- package/dist/react/CollabRoomContext.d.ts.map +1 -1
- package/dist/react/CollabRoomContext.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/useCollabSeed.d.ts +23 -0
- package/dist/react/useCollabSeed.d.ts.map +1 -0
- package/dist/react/useCollabSeed.js +67 -0
- package/dist/react/useCollabSeed.js.map +1 -0
- package/dist/routes/globals.d.ts.map +1 -1
- package/dist/routes/globals.js +8 -22
- package/dist/routes/globals.js.map +1 -1
- package/dist/routes/helpers.d.ts +13 -0
- package/dist/routes/helpers.d.ts.map +1 -1
- package/dist/routes/helpers.js +25 -8
- package/dist/routes/helpers.js.map +1 -1
- package/dist/routes/resources.d.ts.map +1 -1
- package/dist/routes/resources.js +12 -34
- package/dist/routes/resources.js.map +1 -1
- package/dist/routes/theme.d.ts +4 -2
- package/dist/routes/theme.d.ts.map +1 -1
- package/dist/routes/theme.js +27 -26
- package/dist/routes/theme.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +65 -37
- package/dist/routes.js.map +1 -1
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/storage.d.ts +86 -0
- package/dist/theme/storage.d.ts.map +1 -0
- package/dist/theme/storage.js +52 -0
- package/dist/theme/storage.js.map +1 -0
- package/package.json +1 -1
- package/src/Pilotiq.perf.test.ts +252 -0
- package/src/Pilotiq.test.ts +4 -0
- package/src/Pilotiq.ts +166 -0
- package/src/PilotiqServiceProvider.ts +63 -11
- package/src/actions/importFactory.ts +31 -10
- package/src/orm/modelDefaults.ts +15 -2
- package/src/pageData/forms.ts +3 -3
- package/src/pageData/misc.ts +5 -5
- package/src/pageData/navigation.ts +11 -9
- package/src/pageData/relationPages.ts +5 -3
- package/src/pageData/resourcePages.ts +6 -6
- package/src/plugins/index.ts +7 -0
- package/src/plugins/themeEditor.test.ts +36 -0
- package/src/plugins/themeEditor.ts +22 -1
- package/src/react/CollabRoomContext.ts +12 -0
- package/src/react/index.ts +1 -0
- package/src/react/useCollabSeed.ts +73 -0
- package/src/routes/globals.ts +8 -16
- package/src/routes/guard.test.ts +325 -0
- package/src/routes/helpers.ts +30 -8
- package/src/routes/resources.ts +12 -22
- package/src/routes/theme.ts +26 -44
- package/src/routes.ts +65 -36
- package/src/theme/index.ts +6 -0
- package/src/theme/storage.test.ts +126 -0
- package/src/theme/storage.ts +106 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Pilotiq.guard()` panel-wide 401 layer.
|
|
3
|
+
*
|
|
4
|
+
* Documented since Plan #10 as the unauthenticated-request gate; until
|
|
5
|
+
* 2026-05-21 only `_uploads` consulted it. Every other route relied on
|
|
6
|
+
* `cfg.user` returning null + `R.canX(user, …)` defaulting to true, so
|
|
7
|
+
* an app that wired `guard(req => Auth.check())` but shipped any
|
|
8
|
+
* Resource without `canAccess` overrides ended up with an
|
|
9
|
+
* unauthenticated, fully-readable admin panel.
|
|
10
|
+
*
|
|
11
|
+
* Fix: wrap every core panel route in one `router.group(...)` with the
|
|
12
|
+
* guard as group middleware. This file is the regression test —
|
|
13
|
+
* intentionally exhaustive so any future route that escapes the group
|
|
14
|
+
* (e.g. registered outside the wrap callback by accident) fails loudly.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it } from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import { Router } from '@rudderjs/router'
|
|
19
|
+
import type { RouteDefinition, MiddlewareHandler } from '@rudderjs/contracts'
|
|
20
|
+
|
|
21
|
+
import { Pilotiq } from '../Pilotiq.js'
|
|
22
|
+
import { Resource } from '../Resource.js'
|
|
23
|
+
import { Global } from '../Global.js'
|
|
24
|
+
import { Page } from '../Page.js'
|
|
25
|
+
import { Cluster } from '../Cluster.js'
|
|
26
|
+
import { RelationManager } from '../RelationManager.js'
|
|
27
|
+
import { registerPilotiqRoutes } from '../routes.js'
|
|
28
|
+
import { themeEditor } from '../plugins/themeEditor.js'
|
|
29
|
+
|
|
30
|
+
// ─── Test fixtures ────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
class Comment extends RelationManager {
|
|
33
|
+
static override relationship = 'comments'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class Article extends Resource {
|
|
37
|
+
static override label = 'Articles'
|
|
38
|
+
static override labelSingular = 'Article'
|
|
39
|
+
static override slug = 'articles'
|
|
40
|
+
static override relations() { return [Comment] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class Settings extends Global {
|
|
44
|
+
static override label = 'Settings'
|
|
45
|
+
static override slug = 'settings'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class Analytics extends Page {
|
|
49
|
+
static override slug = 'analytics'
|
|
50
|
+
static override label = 'Analytics'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class ReportsCluster extends Cluster {
|
|
54
|
+
static override slug = 'reports'
|
|
55
|
+
static override label = 'Reports'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class ClusteredPage extends Page {
|
|
59
|
+
static override slug = 'overview'
|
|
60
|
+
static override label = 'Overview'
|
|
61
|
+
static override cluster = ReportsCluster
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Fake req/res ─────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
interface FakeRes {
|
|
67
|
+
statusCode: number
|
|
68
|
+
sentBody?: unknown
|
|
69
|
+
status(code: number): FakeRes
|
|
70
|
+
send(body: unknown): FakeRes
|
|
71
|
+
json(body: unknown): FakeRes
|
|
72
|
+
redirect(url: string, code?: number): FakeRes
|
|
73
|
+
header(name: string, value: string): FakeRes
|
|
74
|
+
}
|
|
75
|
+
function fakeRes(): FakeRes {
|
|
76
|
+
const r: FakeRes = {
|
|
77
|
+
statusCode: 200,
|
|
78
|
+
status(code) { this.statusCode = code; return this },
|
|
79
|
+
send(body) { this.sentBody = body; return this },
|
|
80
|
+
json(body) { this.sentBody = body; return this },
|
|
81
|
+
redirect() { return this },
|
|
82
|
+
header() { return this },
|
|
83
|
+
}
|
|
84
|
+
return r
|
|
85
|
+
}
|
|
86
|
+
function fakeReq(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
87
|
+
return {
|
|
88
|
+
params: {},
|
|
89
|
+
body: null,
|
|
90
|
+
query: {},
|
|
91
|
+
raw: {},
|
|
92
|
+
headers: {},
|
|
93
|
+
...overrides,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run a route's middleware chain *without* its handler. Returns whether
|
|
99
|
+
* the chain reached the handler slot (`reached: true`) or short-circuited
|
|
100
|
+
* (`reached: false`), plus the response captured during the chain. This
|
|
101
|
+
* is the tightest unit shape for "did the guard fire" — invoking the
|
|
102
|
+
* handler would require fully-mocked record loaders / form pipelines for
|
|
103
|
+
* the 30+ documented routes, which isn't what this test is for.
|
|
104
|
+
*/
|
|
105
|
+
async function runMiddleware(
|
|
106
|
+
route: RouteDefinition,
|
|
107
|
+
req: Record<string, unknown>,
|
|
108
|
+
): Promise<{ reached: boolean; res: FakeRes }> {
|
|
109
|
+
const res = fakeRes()
|
|
110
|
+
let reached = true
|
|
111
|
+
for (const mw of route.middleware as MiddlewareHandler[]) {
|
|
112
|
+
let didCallNext = false
|
|
113
|
+
const next = async () => { didCallNext = true }
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
115
|
+
await mw(req as any, res as any, next)
|
|
116
|
+
if (!didCallNext) {
|
|
117
|
+
reached = false
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { reached, res }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Build a panel that touches every register* branch — resources (with
|
|
125
|
+
// relations + soft-deletes), globals, custom pages, clusters, theme
|
|
126
|
+
// editor, database notifications. The test then iterates router.list()
|
|
127
|
+
// so coverage stays exhaustive even if new routes ship.
|
|
128
|
+
function buildFullPanel(guard?: (req: unknown) => boolean | Promise<boolean>): Pilotiq {
|
|
129
|
+
let p = Pilotiq.make('admin')
|
|
130
|
+
.path('/admin')
|
|
131
|
+
.resources([Article])
|
|
132
|
+
.globals([Settings])
|
|
133
|
+
.pages([Analytics, ClusteredPage])
|
|
134
|
+
.clusters([ReportsCluster])
|
|
135
|
+
.databaseNotifications()
|
|
136
|
+
.use(themeEditor())
|
|
137
|
+
if (guard) p = p.guard(guard)
|
|
138
|
+
return p
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Tests ────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
describe('Pilotiq.guard() — router.group() middleware', () => {
|
|
144
|
+
it('attaches the guard middleware to every core panel route', () => {
|
|
145
|
+
const router = new Router()
|
|
146
|
+
registerPilotiqRoutes(router, buildFullPanel(() => false))
|
|
147
|
+
const routes = router.list()
|
|
148
|
+
// Sanity: the panel above is rich enough to register many routes.
|
|
149
|
+
// Hard-fail under 20 routes to catch a future split that skips one
|
|
150
|
+
// of the register* branches by accident.
|
|
151
|
+
assert.ok(routes.length > 20, `expected >20 routes, got ${routes.length}`)
|
|
152
|
+
for (const r of routes) {
|
|
153
|
+
assert.ok(
|
|
154
|
+
r.middleware.length >= 1,
|
|
155
|
+
`route ${r.method} ${r.path} has no middleware — guard is missing`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('every core route 401s when guard returns false', async () => {
|
|
161
|
+
const router = new Router()
|
|
162
|
+
registerPilotiqRoutes(router, buildFullPanel(() => false))
|
|
163
|
+
const routes = router.list()
|
|
164
|
+
for (const r of routes) {
|
|
165
|
+
const { reached, res } = await runMiddleware(r, fakeReq())
|
|
166
|
+
assert.equal(
|
|
167
|
+
reached, false,
|
|
168
|
+
`route ${r.method} ${r.path} reached its handler despite guard=false`,
|
|
169
|
+
)
|
|
170
|
+
assert.equal(
|
|
171
|
+
res.statusCode, 401,
|
|
172
|
+
`route ${r.method} ${r.path} returned ${res.statusCode}, expected 401`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('every core route reaches its handler when guard returns true', async () => {
|
|
178
|
+
const router = new Router()
|
|
179
|
+
registerPilotiqRoutes(router, buildFullPanel(() => true))
|
|
180
|
+
const routes = router.list()
|
|
181
|
+
for (const r of routes) {
|
|
182
|
+
const { reached, res } = await runMiddleware(r, fakeReq())
|
|
183
|
+
assert.equal(
|
|
184
|
+
reached, true,
|
|
185
|
+
`route ${r.method} ${r.path} did not reach its handler when guard=true ` +
|
|
186
|
+
`(short-circuited with status ${res.statusCode})`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('skips the guard check entirely when no Pilotiq.guard() is configured', async () => {
|
|
192
|
+
const router = new Router()
|
|
193
|
+
registerPilotiqRoutes(router, buildFullPanel(undefined))
|
|
194
|
+
const routes = router.list()
|
|
195
|
+
// The middleware is still attached (the group runs unconditionally),
|
|
196
|
+
// but the guard branch inside it short-circuits when cfg.guard is
|
|
197
|
+
// undefined — every chain still calls next().
|
|
198
|
+
for (const r of routes) {
|
|
199
|
+
const { reached } = await runMiddleware(r, fakeReq())
|
|
200
|
+
assert.equal(
|
|
201
|
+
reached, true,
|
|
202
|
+
`route ${r.method} ${r.path} short-circuited even though no guard was configured`,
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('returns JSON 401 envelope when client sends Accept: application/json', async () => {
|
|
208
|
+
const router = new Router()
|
|
209
|
+
registerPilotiqRoutes(router, buildFullPanel(() => false))
|
|
210
|
+
const route = router.list().find(r => r.path === '/admin')
|
|
211
|
+
assert.ok(route, 'dashboard route missing')
|
|
212
|
+
const { res } = await runMiddleware(route, fakeReq({ headers: { accept: 'application/json' } }))
|
|
213
|
+
assert.equal(res.statusCode, 401)
|
|
214
|
+
assert.deepEqual(res.sentBody, { ok: false, error: 'Unauthorized' })
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('returns plain 401 body when no JSON Accept header', async () => {
|
|
218
|
+
const router = new Router()
|
|
219
|
+
registerPilotiqRoutes(router, buildFullPanel(() => false))
|
|
220
|
+
const route = router.list().find(r => r.path === '/admin')
|
|
221
|
+
assert.ok(route, 'dashboard route missing')
|
|
222
|
+
const { res } = await runMiddleware(route, fakeReq())
|
|
223
|
+
assert.equal(res.statusCode, 401)
|
|
224
|
+
assert.equal(res.sentBody, 'Unauthorized')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('awaits async guard predicates', async () => {
|
|
228
|
+
const router = new Router()
|
|
229
|
+
registerPilotiqRoutes(router, buildFullPanel(async (_req) => {
|
|
230
|
+
await new Promise(r => setTimeout(r, 1))
|
|
231
|
+
return false
|
|
232
|
+
}))
|
|
233
|
+
const route = router.list().find(r => r.path === '/admin/articles')
|
|
234
|
+
assert.ok(route, 'articles list route missing')
|
|
235
|
+
const { reached, res } = await runMiddleware(route, fakeReq())
|
|
236
|
+
assert.equal(reached, false)
|
|
237
|
+
assert.equal(res.statusCode, 401)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('forwards the request object to the guard predicate', async () => {
|
|
241
|
+
let seen: unknown = undefined
|
|
242
|
+
const router = new Router()
|
|
243
|
+
registerPilotiqRoutes(router, buildFullPanel((req) => { seen = req; return true }))
|
|
244
|
+
const route = router.list().find(r => r.path === '/admin/articles')
|
|
245
|
+
assert.ok(route, 'articles list route missing')
|
|
246
|
+
const req = fakeReq({ tag: 'abc' })
|
|
247
|
+
await runMiddleware(route, req)
|
|
248
|
+
assert.equal((seen as { tag?: string }).tag, 'abc')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('covers _uploads — the original inline-guard site', async () => {
|
|
252
|
+
const router = new Router()
|
|
253
|
+
registerPilotiqRoutes(router, buildFullPanel(() => false))
|
|
254
|
+
const route = router.list().find(r => r.path === '/admin/_uploads')
|
|
255
|
+
assert.ok(route, '_uploads route missing')
|
|
256
|
+
const { reached, res } = await runMiddleware(route, fakeReq())
|
|
257
|
+
// The inline `if (cfg.guard && !await cfg.guard(req))` block inside
|
|
258
|
+
// handleUploadRequest was removed once the group middleware took
|
|
259
|
+
// over — this guarantees the route is now actually 401'd by the
|
|
260
|
+
// group middleware, not the inline check.
|
|
261
|
+
assert.equal(reached, false)
|
|
262
|
+
assert.equal(res.statusCode, 401)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('covers relation manager + nested relation routes', async () => {
|
|
266
|
+
const router = new Router()
|
|
267
|
+
registerPilotiqRoutes(router, buildFullPanel(() => false))
|
|
268
|
+
const routes = router.list()
|
|
269
|
+
// Relation list: GET /admin/articles/:id/comments
|
|
270
|
+
const relList = routes.find(r => r.path === '/admin/articles/:id/comments' && r.method === 'GET')
|
|
271
|
+
assert.ok(relList, 'relation list route missing')
|
|
272
|
+
const { reached, res } = await runMiddleware(relList, fakeReq())
|
|
273
|
+
assert.equal(reached, false)
|
|
274
|
+
assert.equal(res.statusCode, 401)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('covers cluster-prefixed routes', async () => {
|
|
278
|
+
const router = new Router()
|
|
279
|
+
registerPilotiqRoutes(router, buildFullPanel(() => false))
|
|
280
|
+
const routes = router.list()
|
|
281
|
+
const clustered = routes.find(r => r.path === '/admin/reports/overview' && r.method === 'GET')
|
|
282
|
+
assert.ok(clustered, 'clustered custom page route missing')
|
|
283
|
+
const { reached, res } = await runMiddleware(clustered, fakeReq())
|
|
284
|
+
assert.equal(reached, false)
|
|
285
|
+
assert.equal(res.statusCode, 401)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('covers theme editor routes', async () => {
|
|
289
|
+
const router = new Router()
|
|
290
|
+
registerPilotiqRoutes(router, buildFullPanel(() => false))
|
|
291
|
+
const routes = router.list()
|
|
292
|
+
const themePages = [
|
|
293
|
+
['GET', '/admin/theme'],
|
|
294
|
+
['GET', '/admin/api/_theme'],
|
|
295
|
+
['PUT', '/admin/api/_theme'],
|
|
296
|
+
['DELETE', '/admin/api/_theme'],
|
|
297
|
+
] as const
|
|
298
|
+
for (const [method, path] of themePages) {
|
|
299
|
+
const r = routes.find(x => x.method === method && x.path === path)
|
|
300
|
+
assert.ok(r, `theme route ${method} ${path} missing`)
|
|
301
|
+
const { reached, res } = await runMiddleware(r, fakeReq())
|
|
302
|
+
assert.equal(reached, false, `${method} ${path}`)
|
|
303
|
+
assert.equal(res.statusCode, 401, `${method} ${path}`)
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('covers _notifications endpoints', async () => {
|
|
308
|
+
const router = new Router()
|
|
309
|
+
registerPilotiqRoutes(router, buildFullPanel(() => false))
|
|
310
|
+
const routes = router.list()
|
|
311
|
+
const notif = [
|
|
312
|
+
['GET', '/admin/_notifications'],
|
|
313
|
+
['POST', '/admin/_notifications/:id/read'],
|
|
314
|
+
['POST', '/admin/_notifications/:id/unread'],
|
|
315
|
+
['POST', '/admin/_notifications/read-all'],
|
|
316
|
+
] as const
|
|
317
|
+
for (const [method, path] of notif) {
|
|
318
|
+
const r = routes.find(x => x.method === method && x.path === path)
|
|
319
|
+
assert.ok(r, `notifications route ${method} ${path} missing`)
|
|
320
|
+
const { reached, res } = await runMiddleware(r, fakeReq())
|
|
321
|
+
assert.equal(reached, false, `${method} ${path}`)
|
|
322
|
+
assert.equal(res.statusCode, 401, `${method} ${path}`)
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
})
|
package/src/routes/helpers.ts
CHANGED
|
@@ -256,6 +256,30 @@ export async function policyAccess(
|
|
|
256
256
|
return ownerOk && clusterOk
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Two-predicate policy gate. Runs `policyAccess(owner, user)` in
|
|
261
|
+
* parallel with `checkPolicy(predicate)` and returns true only when
|
|
262
|
+
* both resolve truthy. Use this when the predicate does NOT depend on a
|
|
263
|
+
* record loaded between the two checks — record-dependent gates (e.g.
|
|
264
|
+
* `canEdit(user, record)` where `record` is loaded mid-handler) must
|
|
265
|
+
* stay sequential.
|
|
266
|
+
*
|
|
267
|
+
* Both checks fail-closed (throw → false). The pair runs whenever the
|
|
268
|
+
* route handler enters; replacing the serial pair halves the wait when
|
|
269
|
+
* either predicate makes a network round-trip.
|
|
270
|
+
*/
|
|
271
|
+
export async function policyGate(
|
|
272
|
+
owner: Parameters<typeof policyAccess>[0],
|
|
273
|
+
user: unknown,
|
|
274
|
+
predicate: () => boolean | Promise<boolean>,
|
|
275
|
+
): Promise<boolean> {
|
|
276
|
+
const [accessOk, predOk] = await Promise.all([
|
|
277
|
+
policyAccess(owner, user),
|
|
278
|
+
checkPolicy(predicate),
|
|
279
|
+
])
|
|
280
|
+
return accessOk && predOk
|
|
281
|
+
}
|
|
282
|
+
|
|
259
283
|
/** Run `policyAccess(R, user)` and `findRecord(R, recordId, { user })`
|
|
260
284
|
* in parallel. Both depend only on `user`, so the two round-trips
|
|
261
285
|
* overlap instead of waiting on each other. When `R.model` isn't set
|
|
@@ -588,14 +612,12 @@ export async function handleUploadRequest(
|
|
|
588
612
|
return res.json({ ok: false, error: 'No upload adapter configured' })
|
|
589
613
|
}
|
|
590
614
|
|
|
591
|
-
// Auth:
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
return res.json({ ok: false, error: 'Unauthorized' })
|
|
598
|
-
}
|
|
615
|
+
// Auth: `Pilotiq.guard()` runs as a `router.group(...)` middleware in
|
|
616
|
+
// `routes.ts`, so by the time we land here the panel-wide gate has
|
|
617
|
+
// already passed. We don't enforce per-resource canEdit because the
|
|
618
|
+
// field doesn't know which resource it belongs to — apps that need
|
|
619
|
+
// it should hook into their adapter's `put()` and consult their own
|
|
620
|
+
// auth there.
|
|
599
621
|
|
|
600
622
|
// Parse multipart body. Hono's parseBody returns `Record<string, File | string>`.
|
|
601
623
|
const raw = req.raw as { req?: { parseBody?: (opts?: { all?: boolean }) => Promise<Record<string, unknown>> } } | undefined
|
package/src/routes/resources.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
cellHookErrorMessage,
|
|
36
36
|
checkPolicy,
|
|
37
37
|
policyAccess,
|
|
38
|
+
policyGate,
|
|
38
39
|
resolveDispatchTarget,
|
|
39
40
|
handleFormState,
|
|
40
41
|
handleFormWizard,
|
|
@@ -87,8 +88,7 @@ export function registerResourceRoutes(
|
|
|
87
88
|
const indexUrl = resourceBase
|
|
88
89
|
router.get(indexUrl, async (req, res) => {
|
|
89
90
|
const user = await pilotiq.resolveUser(req)
|
|
90
|
-
if (!await
|
|
91
|
-
if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, wantsJson(req))
|
|
91
|
+
if (!await policyGate(R, user, () => R.canViewAny(user))) return forbidden(res, wantsJson(req))
|
|
92
92
|
|
|
93
93
|
if (R.persistFiltersInSession) {
|
|
94
94
|
const query = (req.query as Record<string, unknown> | undefined) ?? {}
|
|
@@ -113,16 +113,14 @@ export function registerResourceRoutes(
|
|
|
113
113
|
|
|
114
114
|
router.post(`${indexUrl}/_widget/:id`, async (req, res) => {
|
|
115
115
|
const user = await pilotiq.resolveUser(req)
|
|
116
|
-
if (!await
|
|
117
|
-
if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, true)
|
|
116
|
+
if (!await policyGate(R, user, () => R.canViewAny(user))) return forbidden(res, true)
|
|
118
117
|
return handleWidgetData(req, res, pilotiq, { kind: 'resource', slug }, req.params['id']!)
|
|
119
118
|
})
|
|
120
119
|
|
|
121
120
|
if (R.deferLoading) {
|
|
122
121
|
router.get(`${indexUrl}/_table`, async (req, res) => {
|
|
123
122
|
const user = await pilotiq.resolveUser(req)
|
|
124
|
-
if (!await
|
|
125
|
-
if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, true)
|
|
123
|
+
if (!await policyGate(R, user, () => R.canViewAny(user))) return forbidden(res, true)
|
|
126
124
|
const data = await resourceTableData(pilotiq, slug, req.query as Record<string, string>, req)
|
|
127
125
|
if (!data) { res.status(404); return res.json({ ok: false, error: 'Resource not found' }) }
|
|
128
126
|
return res.json({ ok: true, ...data })
|
|
@@ -169,12 +167,11 @@ export function registerResourceRoutes(
|
|
|
169
167
|
if (options.reorderable) {
|
|
170
168
|
router.post(`${indexUrl}/_reorder`, async (req, res) => {
|
|
171
169
|
const user = await pilotiq.resolveUser(req)
|
|
172
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
173
170
|
// List-level edit gate. The drop affects many rows at once;
|
|
174
171
|
// there's no single record to authorize against, so we pass
|
|
175
172
|
// `undefined` and let user-supplied `canEdit` overrides branch
|
|
176
173
|
// on `record === undefined` if they want row-level granularity.
|
|
177
|
-
if (!await
|
|
174
|
+
if (!await policyGate(R, user, () => R.canEdit(user, undefined))) return forbidden(res, true)
|
|
178
175
|
|
|
179
176
|
const body = await readFormBody(req)
|
|
180
177
|
const raw = (body as { ids?: unknown }).ids
|
|
@@ -302,8 +299,7 @@ export function registerResourceRoutes(
|
|
|
302
299
|
if (pages.create) {
|
|
303
300
|
router.post(`${resourceBase}/_form/:formId/state`, async (req, res) => {
|
|
304
301
|
const user = await pilotiq.resolveUser(req)
|
|
305
|
-
if (!await
|
|
306
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
|
|
302
|
+
if (!await policyGate(R, user, () => R.canCreate(user))) return forbidden(res, true)
|
|
307
303
|
const formId = req.params['formId']!
|
|
308
304
|
return handleFormState(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
|
|
309
305
|
})
|
|
@@ -311,8 +307,7 @@ export function registerResourceRoutes(
|
|
|
311
307
|
// Plan #8 — wizard step-validate endpoint for create-mode forms.
|
|
312
308
|
router.post(`${resourceBase}/_form/:formId/wizard`, async (req, res) => {
|
|
313
309
|
const user = await pilotiq.resolveUser(req)
|
|
314
|
-
if (!await
|
|
315
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
|
|
310
|
+
if (!await policyGate(R, user, () => R.canCreate(user))) return forbidden(res, true)
|
|
316
311
|
const formId = req.params['formId']!
|
|
317
312
|
return handleFormWizard(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
|
|
318
313
|
})
|
|
@@ -320,8 +315,7 @@ export function registerResourceRoutes(
|
|
|
320
315
|
// Async-mention endpoint for create-mode forms.
|
|
321
316
|
router.post(`${resourceBase}/_form/:formId/mentions`, async (req, res) => {
|
|
322
317
|
const user = await pilotiq.resolveUser(req)
|
|
323
|
-
if (!await
|
|
324
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
|
|
318
|
+
if (!await policyGate(R, user, () => R.canCreate(user))) return forbidden(res, true)
|
|
325
319
|
const formId = req.params['formId']!
|
|
326
320
|
return handleFormMentions(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
|
|
327
321
|
})
|
|
@@ -329,8 +323,7 @@ export function registerResourceRoutes(
|
|
|
329
323
|
// SelectField inline-create modal endpoint for create-mode forms.
|
|
330
324
|
router.post(`${resourceBase}/_form/:formId/create-option/:fieldName`, async (req, res) => {
|
|
331
325
|
const user = await pilotiq.resolveUser(req)
|
|
332
|
-
if (!await
|
|
333
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
|
|
326
|
+
if (!await policyGate(R, user, () => R.canCreate(user))) return forbidden(res, true)
|
|
334
327
|
const formId = req.params['formId']!
|
|
335
328
|
const fieldName = req.params['fieldName']!
|
|
336
329
|
return handleFormCreateOption(req, res, pilotiq, { kind: 'resource-create', slug }, formId, fieldName)
|
|
@@ -392,8 +385,7 @@ export function registerResourceRoutes(
|
|
|
392
385
|
|
|
393
386
|
router.get(createUrl, async (req, res) => {
|
|
394
387
|
const user = await pilotiq.resolveUser(req)
|
|
395
|
-
if (!await
|
|
396
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
|
|
388
|
+
if (!await policyGate(R, user, () => R.canCreate(user))) return forbidden(res, wantsJson(req))
|
|
397
389
|
const data = await resourceCreateData(pilotiq, slug, undefined, req)
|
|
398
390
|
return view('pilotiq.resource-create', data ?? {})
|
|
399
391
|
})
|
|
@@ -401,8 +393,7 @@ export function registerResourceRoutes(
|
|
|
401
393
|
// Create — POST ${resourceBase}/create
|
|
402
394
|
router.post(createUrl, async (req, res) => {
|
|
403
395
|
const user = await pilotiq.resolveUser(req)
|
|
404
|
-
if (!await
|
|
405
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
|
|
396
|
+
if (!await policyGate(R, user, () => R.canCreate(user))) return forbidden(res, wantsJson(req))
|
|
406
397
|
|
|
407
398
|
const body = await readFormBody(req)
|
|
408
399
|
const { values, formId, continueCreate } = splitMeta(body)
|
|
@@ -464,8 +455,7 @@ export function registerResourceRoutes(
|
|
|
464
455
|
// coerced values.
|
|
465
456
|
router.post(`${createUrl}/_action/:actionName`, async (req, res) => {
|
|
466
457
|
const user = await pilotiq.resolveUser(req)
|
|
467
|
-
if (!await
|
|
468
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
|
|
458
|
+
if (!await policyGate(R, user, () => R.canCreate(user))) return forbidden(res, wantsJson(req))
|
|
469
459
|
|
|
470
460
|
const actionName = req.params['actionName']!
|
|
471
461
|
const json = wantsJson(req)
|
package/src/routes/theme.ts
CHANGED
|
@@ -9,29 +9,15 @@ import { migrateThemeOverrides } from '../theme/migrate.js'
|
|
|
9
9
|
import { radiusMap } from '../theme/radius.js'
|
|
10
10
|
import { panelInfo } from '../pageData.js'
|
|
11
11
|
|
|
12
|
-
/** Minimal Prisma surface used by the theme editor — narrow enough to
|
|
13
|
-
* keep the DI lookup type-safe without dragging in `PrismaClient`,
|
|
14
|
-
* which would couple the package to a concrete schema. */
|
|
15
|
-
type PanelGlobalRow = { data: string | object | null }
|
|
16
|
-
type PanelGlobalDelegate = {
|
|
17
|
-
panelGlobal: {
|
|
18
|
-
findUnique(args: { where: { slug: string } }): Promise<PanelGlobalRow | null>
|
|
19
|
-
upsert(args: {
|
|
20
|
-
where: { slug: string }
|
|
21
|
-
update: { data: string }
|
|
22
|
-
create: { slug: string; data: string }
|
|
23
|
-
}): Promise<unknown>
|
|
24
|
-
delete(args: { where: { slug: string } }): Promise<unknown>
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
12
|
/**
|
|
29
13
|
* Register the theme editor routes — the `${base}/theme` editor page
|
|
30
14
|
* plus the `${base}/api/_theme` GET / PUT / DELETE persistence endpoints.
|
|
31
15
|
* Only mounted when `cfg.themeEditor` is set (caller checks first).
|
|
32
16
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
17
|
+
* Storage persists through `panel.getThemeStorage()` — the adapter
|
|
18
|
+
* resolved by the service provider's boot pass (explicit when
|
|
19
|
+
* `themeEditor({ storage })` was set, otherwise the implicit Prisma
|
|
20
|
+
* fallback while that back-compat branch is still active).
|
|
35
21
|
*/
|
|
36
22
|
export function registerThemeRoutes(
|
|
37
23
|
router: Router,
|
|
@@ -51,16 +37,15 @@ export function registerThemeRoutes(
|
|
|
51
37
|
|
|
52
38
|
router.get(`${base}/api/_theme`, async (_req, res) => {
|
|
53
39
|
let overrides: Partial<ThemeConfig> | null = null
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
overrides = migrateThemeOverrides(raw)
|
|
40
|
+
const storage = pilotiq.getThemeStorage()
|
|
41
|
+
if (storage) {
|
|
42
|
+
try {
|
|
43
|
+
const raw = await storage.load()
|
|
44
|
+
if (raw) overrides = migrateThemeOverrides(raw)
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.warn('[pilotiq] failed to load theme overrides:', e)
|
|
62
47
|
}
|
|
63
|
-
}
|
|
48
|
+
}
|
|
64
49
|
|
|
65
50
|
return res.json({
|
|
66
51
|
config: cfg.theme ?? {},
|
|
@@ -77,18 +62,13 @@ export function registerThemeRoutes(
|
|
|
77
62
|
})
|
|
78
63
|
|
|
79
64
|
router.put(`${base}/api/_theme`, async (req, res) => {
|
|
65
|
+
const storage = pilotiq.getThemeStorage()
|
|
66
|
+
if (!storage) {
|
|
67
|
+
return res.status(500).json({ message: 'No theme storage adapter configured.' })
|
|
68
|
+
}
|
|
80
69
|
try {
|
|
81
70
|
const overrides = req.body as Partial<ThemeConfig>
|
|
82
|
-
|
|
83
|
-
const prisma = app().make('prisma') as PanelGlobalDelegate
|
|
84
|
-
const slug = `${cfg.name}__theme`
|
|
85
|
-
|
|
86
|
-
await prisma.panelGlobal.upsert({
|
|
87
|
-
where: { slug },
|
|
88
|
-
update: { data: JSON.stringify(overrides) },
|
|
89
|
-
create: { slug, data: JSON.stringify(overrides) },
|
|
90
|
-
})
|
|
91
|
-
|
|
71
|
+
await storage.save(overrides)
|
|
92
72
|
pilotiq.setThemeOverrides(overrides)
|
|
93
73
|
return res.json({ ok: true })
|
|
94
74
|
} catch (e) {
|
|
@@ -97,13 +77,15 @@ export function registerThemeRoutes(
|
|
|
97
77
|
})
|
|
98
78
|
|
|
99
79
|
router.delete(`${base}/api/_theme`, async (_req, res) => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
80
|
+
const storage = pilotiq.getThemeStorage()
|
|
81
|
+
if (storage) {
|
|
82
|
+
try {
|
|
83
|
+
await storage.clear()
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.warn('[pilotiq] failed to clear theme overrides:', e)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
pilotiq.setThemeOverrides(undefined)
|
|
107
89
|
return res.json({ ok: true })
|
|
108
90
|
})
|
|
109
91
|
}
|