@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.
Files changed (101) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +88 -0
  3. package/dist/Pilotiq.d.ts +72 -0
  4. package/dist/Pilotiq.d.ts.map +1 -1
  5. package/dist/Pilotiq.js +145 -0
  6. package/dist/Pilotiq.js.map +1 -1
  7. package/dist/PilotiqServiceProvider.d.ts +2 -0
  8. package/dist/PilotiqServiceProvider.d.ts.map +1 -1
  9. package/dist/PilotiqServiceProvider.js +60 -12
  10. package/dist/PilotiqServiceProvider.js.map +1 -1
  11. package/dist/actions/importFactory.d.ts +5 -0
  12. package/dist/actions/importFactory.d.ts.map +1 -1
  13. package/dist/actions/importFactory.js +20 -10
  14. package/dist/actions/importFactory.js.map +1 -1
  15. package/dist/orm/modelDefaults.d.ts +10 -1
  16. package/dist/orm/modelDefaults.d.ts.map +1 -1
  17. package/dist/orm/modelDefaults.js +7 -2
  18. package/dist/orm/modelDefaults.js.map +1 -1
  19. package/dist/pageData/forms.js +3 -3
  20. package/dist/pageData/forms.js.map +1 -1
  21. package/dist/pageData/misc.js +5 -5
  22. package/dist/pageData/misc.js.map +1 -1
  23. package/dist/pageData/navigation.d.ts.map +1 -1
  24. package/dist/pageData/navigation.js +11 -9
  25. package/dist/pageData/navigation.js.map +1 -1
  26. package/dist/pageData/relationPages.d.ts.map +1 -1
  27. package/dist/pageData/relationPages.js +7 -4
  28. package/dist/pageData/relationPages.js.map +1 -1
  29. package/dist/pageData/resourcePages.js +6 -6
  30. package/dist/pageData/resourcePages.js.map +1 -1
  31. package/dist/plugins/index.d.ts +3 -0
  32. package/dist/plugins/index.d.ts.map +1 -1
  33. package/dist/plugins/index.js +1 -0
  34. package/dist/plugins/index.js.map +1 -1
  35. package/dist/plugins/themeEditor.d.ts +20 -1
  36. package/dist/plugins/themeEditor.d.ts.map +1 -1
  37. package/dist/plugins/themeEditor.js +3 -1
  38. package/dist/plugins/themeEditor.js.map +1 -1
  39. package/dist/react/CollabRoomContext.d.ts +12 -0
  40. package/dist/react/CollabRoomContext.d.ts.map +1 -1
  41. package/dist/react/CollabRoomContext.js.map +1 -1
  42. package/dist/react/index.d.ts +1 -0
  43. package/dist/react/index.d.ts.map +1 -1
  44. package/dist/react/index.js +1 -0
  45. package/dist/react/index.js.map +1 -1
  46. package/dist/react/useCollabSeed.d.ts +23 -0
  47. package/dist/react/useCollabSeed.d.ts.map +1 -0
  48. package/dist/react/useCollabSeed.js +67 -0
  49. package/dist/react/useCollabSeed.js.map +1 -0
  50. package/dist/routes/globals.d.ts.map +1 -1
  51. package/dist/routes/globals.js +8 -22
  52. package/dist/routes/globals.js.map +1 -1
  53. package/dist/routes/helpers.d.ts +13 -0
  54. package/dist/routes/helpers.d.ts.map +1 -1
  55. package/dist/routes/helpers.js +25 -8
  56. package/dist/routes/helpers.js.map +1 -1
  57. package/dist/routes/resources.d.ts.map +1 -1
  58. package/dist/routes/resources.js +12 -34
  59. package/dist/routes/resources.js.map +1 -1
  60. package/dist/routes/theme.d.ts +4 -2
  61. package/dist/routes/theme.d.ts.map +1 -1
  62. package/dist/routes/theme.js +27 -26
  63. package/dist/routes/theme.js.map +1 -1
  64. package/dist/routes.d.ts.map +1 -1
  65. package/dist/routes.js +65 -37
  66. package/dist/routes.js.map +1 -1
  67. package/dist/theme/index.d.ts +2 -0
  68. package/dist/theme/index.d.ts.map +1 -1
  69. package/dist/theme/index.js +1 -0
  70. package/dist/theme/index.js.map +1 -1
  71. package/dist/theme/storage.d.ts +86 -0
  72. package/dist/theme/storage.d.ts.map +1 -0
  73. package/dist/theme/storage.js +52 -0
  74. package/dist/theme/storage.js.map +1 -0
  75. package/package.json +1 -1
  76. package/src/Pilotiq.perf.test.ts +252 -0
  77. package/src/Pilotiq.test.ts +4 -0
  78. package/src/Pilotiq.ts +166 -0
  79. package/src/PilotiqServiceProvider.ts +63 -11
  80. package/src/actions/importFactory.ts +31 -10
  81. package/src/orm/modelDefaults.ts +15 -2
  82. package/src/pageData/forms.ts +3 -3
  83. package/src/pageData/misc.ts +5 -5
  84. package/src/pageData/navigation.ts +11 -9
  85. package/src/pageData/relationPages.ts +5 -3
  86. package/src/pageData/resourcePages.ts +6 -6
  87. package/src/plugins/index.ts +7 -0
  88. package/src/plugins/themeEditor.test.ts +36 -0
  89. package/src/plugins/themeEditor.ts +22 -1
  90. package/src/react/CollabRoomContext.ts +12 -0
  91. package/src/react/index.ts +1 -0
  92. package/src/react/useCollabSeed.ts +73 -0
  93. package/src/routes/globals.ts +8 -16
  94. package/src/routes/guard.test.ts +325 -0
  95. package/src/routes/helpers.ts +30 -8
  96. package/src/routes/resources.ts +12 -22
  97. package/src/routes/theme.ts +26 -44
  98. package/src/routes.ts +65 -36
  99. package/src/theme/index.ts +6 -0
  100. package/src/theme/storage.test.ts +126 -0
  101. 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
+ })
@@ -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: panel-wide `guard` and per-request `user`. We don't enforce
592
- // per-resource canEdit here because the field doesn't know which
593
- // resource it belongs to apps that need it should hook into
594
- // their adapter's `put()` and consult their own auth there.
595
- if (cfg.guard && !await cfg.guard(req)) {
596
- res.status(401)
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
@@ -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 policyAccess(R, user)) return forbidden(res, wantsJson(req))
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 policyAccess(R, user)) return forbidden(res, true)
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 policyAccess(R, user)) return forbidden(res, true)
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 checkPolicy(() => R.canEdit(user, undefined))) return forbidden(res, true)
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 policyAccess(R, user)) return forbidden(res, true)
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 policyAccess(R, user)) return forbidden(res, true)
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 policyAccess(R, user)) return forbidden(res, true)
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 policyAccess(R, user)) return forbidden(res, true)
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 policyAccess(R, user)) return forbidden(res, wantsJson(req))
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 policyAccess(R, user)) return forbidden(res, wantsJson(req))
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 policyAccess(R, user)) return forbidden(res, wantsJson(req))
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)
@@ -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
- * Pulled out of `registerPilotiqRoutes` in 2026-05-12 (Phase 3 of the
34
- * routes.ts split).
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
- try {
55
- const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
56
- const prisma = app().make('prisma') as PanelGlobalDelegate
57
- const slug = `${cfg.name}__theme`
58
- const row = await prisma.panelGlobal.findUnique({ where: { slug } })
59
- if (row?.data) {
60
- const raw = typeof row.data === 'string' ? JSON.parse(row.data as string) : row.data
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
- } catch { /* no DB or no table — that's fine */ }
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
- const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
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
- try {
101
- const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
102
- const prisma = app().make('prisma') as PanelGlobalDelegate
103
- const slug = `${cfg.name}__theme`
104
- await prisma.panelGlobal.delete({ where: { slug } }).catch(() => {})
105
- pilotiq.setThemeOverrides(undefined)
106
- } catch { /* ignore */ }
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
  }