create-questpie 2.0.1 → 2.0.3
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/README.md +10 -6
- package/dist/index.mjs +139 -24
- package/package.json +5 -3
- package/skills/questpie/AGENTS.md +2670 -0
- package/skills/questpie/SKILL.md +260 -0
- package/skills/questpie/references/auth.md +121 -0
- package/skills/questpie/references/business-logic.md +550 -0
- package/skills/questpie/references/codegen-plugin-api.md +382 -0
- package/skills/questpie/references/crud-api.md +378 -0
- package/skills/questpie/references/data-modeling.md +493 -0
- package/skills/questpie/references/extend.md +557 -0
- package/skills/questpie/references/field-types.md +386 -0
- package/skills/questpie/references/infrastructure-adapters.md +545 -0
- package/skills/questpie/references/multi-tenancy.md +364 -0
- package/skills/questpie/references/production.md +475 -0
- package/skills/questpie/references/query-operators.md +125 -0
- package/skills/questpie/references/quickstart.md +564 -0
- package/skills/questpie/references/rules.md +389 -0
- package/skills/questpie/references/tanstack-query.md +520 -0
- package/skills/questpie-admin/AGENTS.md +1508 -0
- package/skills/questpie-admin/SKILL.md +436 -0
- package/skills/questpie-admin/references/blocks.md +331 -0
- package/skills/questpie-admin/references/custom-ui.md +305 -0
- package/skills/questpie-admin/references/views.md +449 -0
- package/templates/tanstack-start/AGENTS.md +17 -13
- package/templates/tanstack-start/CLAUDE.md +15 -12
- package/templates/tanstack-start/README.md +19 -13
- package/templates/tanstack-start/env.example +1 -1
- package/templates/tanstack-start/package.json +20 -6
- package/templates/tanstack-start/src/lib/env.ts +1 -1
- package/templates/tanstack-start/src/lib/query-client.ts +10 -1
- package/templates/tanstack-start/src/questpie/server/config/admin.ts +27 -30
- package/templates/tanstack-start/src/routeTree.gen.ts +138 -0
- package/templates/tanstack-start/src/routes/__root.tsx +0 -2
- package/templates/tanstack-start/src/routes/admin/$.tsx +12 -1
- package/templates/tanstack-start/src/routes/admin/index.tsx +12 -5
- package/templates/tanstack-start/src/routes/admin.tsx +8 -1
- package/templates/tanstack-start/src/tanstack-start.d.ts +1 -0
- package/templates/tanstack-start/src/vite-env.d.ts +1 -0
- package/templates/tanstack-start/vite.config.ts +1 -3
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: questpie-core-rules
|
|
3
|
+
description: QUESTPIE access control hooks validation lifecycle beforeValidate beforeChange afterChange beforeDelete afterDelete access rules field-level row-level secure-by-default Zod schema refinements collection global
|
|
4
|
+
- questpie-core
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# QUESTPIE Rules — Access Control, Hooks, Validation
|
|
8
|
+
|
|
9
|
+
This skill builds on questpie-core. It covers collection/global access control, lifecycle hooks, and validation — the three rule layers that govern data flow.
|
|
10
|
+
|
|
11
|
+
## Access Control
|
|
12
|
+
|
|
13
|
+
Access rules are defined per-collection via `.access()`. Each operation accepts a static `boolean` or a function receiving `AppContext` that returns `boolean` or a where clause (row-level filtering).
|
|
14
|
+
|
|
15
|
+
### Default Behavior
|
|
16
|
+
|
|
17
|
+
When no `.access()` is defined, all operations default to `({ session }) => !!session` — **authenticated users only**. You must explicitly set `read: true` for public collections.
|
|
18
|
+
|
|
19
|
+
### Collection Access
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// collections/posts.collection.ts
|
|
23
|
+
import { collection } from "#questpie/factories";
|
|
24
|
+
|
|
25
|
+
export default collection("posts")
|
|
26
|
+
.fields(({ f }) => ({
|
|
27
|
+
title: f.text().label("Title").required(),
|
|
28
|
+
content: f.richText().label("Content"),
|
|
29
|
+
author: f.relation("users"),
|
|
30
|
+
}))
|
|
31
|
+
.access({
|
|
32
|
+
read: true, // Public read
|
|
33
|
+
create: ({ session }) => !!session, // Authenticated
|
|
34
|
+
update: ({ session }) => session?.user?.role === "admin", // Admin only
|
|
35
|
+
delete: ({ session }) => session?.user?.role === "admin",
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Operations
|
|
40
|
+
|
|
41
|
+
| Operation | When checked |
|
|
42
|
+
| --------- | ---------------------------- |
|
|
43
|
+
| `read` | Listing and fetching records |
|
|
44
|
+
| `create` | Creating new records |
|
|
45
|
+
| `update` | Updating existing records |
|
|
46
|
+
| `delete` | Deleting records |
|
|
47
|
+
|
|
48
|
+
### Global Access
|
|
49
|
+
|
|
50
|
+
Globals support `read` and `update` only (singletons have no create/delete):
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// globals/site-settings.global.ts
|
|
54
|
+
import { global } from "#questpie/factories";
|
|
55
|
+
|
|
56
|
+
export default global("siteSettings")
|
|
57
|
+
.fields(({ f }) => ({
|
|
58
|
+
siteName: f.text().label("Site Name").required(),
|
|
59
|
+
logo: f.upload().label("Logo"),
|
|
60
|
+
}))
|
|
61
|
+
.access({
|
|
62
|
+
read: true,
|
|
63
|
+
update: ({ session }) => session?.user?.role === "admin",
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Row-Level Access (AccessWhere)
|
|
68
|
+
|
|
69
|
+
Return a where clause object instead of a boolean to restrict operations to matching rows:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
.access({
|
|
73
|
+
read: true,
|
|
74
|
+
update: ({ session }) => {
|
|
75
|
+
if (!session) return false;
|
|
76
|
+
// Only allow updating own records
|
|
77
|
+
return { author: session.user.id };
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Access Function Context
|
|
83
|
+
|
|
84
|
+
Access functions receive `AppContext` with these properties:
|
|
85
|
+
|
|
86
|
+
| Property | Description |
|
|
87
|
+
| ------------- | --------------------------------------- |
|
|
88
|
+
| `session` | Current auth session (null if unauthed) |
|
|
89
|
+
| `db` | Database instance |
|
|
90
|
+
| `collections` | Typed collection API |
|
|
91
|
+
| `request` | Current HTTP `Request` (headers, URL) |
|
|
92
|
+
|
|
93
|
+
Access functions may be async. Use `request` for request-scoped checks such as headers, tenant scope, CAPTCHA tokens, or signed public form tokens:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { ApiError } from "questpie";
|
|
97
|
+
import { isAdminRequest } from "@questpie/admin/shared";
|
|
98
|
+
|
|
99
|
+
type AccessCtx = {
|
|
100
|
+
request?: Request | null;
|
|
101
|
+
session?: { user?: unknown | null } | null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
async function canCreatePublicSubmission({ request, session }: AccessCtx) {
|
|
105
|
+
if (session?.user) return true;
|
|
106
|
+
if (request && isAdminRequest(request)) {
|
|
107
|
+
throw ApiError.unauthorized();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const token = request?.headers.get("x-captcha-token");
|
|
111
|
+
const valid = token ? await verifyCaptchaToken(token) : false;
|
|
112
|
+
if (valid) return true;
|
|
113
|
+
|
|
114
|
+
throw ApiError.forbidden({
|
|
115
|
+
operation: "create",
|
|
116
|
+
resource: "public_submissions",
|
|
117
|
+
reason: "CAPTCHA verification failed",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default collection("public_submissions")
|
|
122
|
+
.fields(({ f }) => ({
|
|
123
|
+
message: f.textarea().required(),
|
|
124
|
+
}))
|
|
125
|
+
.access({
|
|
126
|
+
read: false,
|
|
127
|
+
create: canCreatePublicSubmission,
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
For public anti-abuse checks, bypass already authenticated users before requiring a CAPTCHA token. Admin-origin requests should not be asked for CAPTCHA either, but remember that `isAdminRequest()` is a caller-intent signal, not authentication; if an admin-origin request reaches this rule without a session, fail it as unauthorized instead of accepting it.
|
|
132
|
+
|
|
133
|
+
Prefer throwing `ApiError.*` from access rules when callers need a specific structured error response. Returning `false` is fine for generic denial, but it produces the default forbidden message.
|
|
134
|
+
|
|
135
|
+
### System Access Mode
|
|
136
|
+
|
|
137
|
+
Server-side code can bypass all access checks:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const ctx = await app.createContext({ accessMode: "system" });
|
|
141
|
+
const allPosts = await app.collections.posts.find({}, ctx);
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
HTTP requests always use session-based access. System mode is for background jobs, seeds, and internal server logic only.
|
|
145
|
+
|
|
146
|
+
## Hooks
|
|
147
|
+
|
|
148
|
+
Hooks run logic at specific points in the collection lifecycle. They receive the full typed `AppContext` through context injection.
|
|
149
|
+
|
|
150
|
+
### Lifecycle Order
|
|
151
|
+
|
|
152
|
+
For create/update:
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
API Request
|
|
156
|
+
|
|
|
157
|
+
beforeValidate -- Modify/validate data before schema validation
|
|
158
|
+
|
|
|
159
|
+
Schema Validation -- Zod validation from field definitions
|
|
160
|
+
|
|
|
161
|
+
beforeChange -- Transform data before database write
|
|
162
|
+
|
|
|
163
|
+
Database Write -- Insert or update
|
|
164
|
+
|
|
|
165
|
+
afterChange -- Side effects after successful write
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
For delete:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
beforeDelete --> Database Delete --> afterDelete
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Defining Hooks
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
// collections/appointments.collection.ts
|
|
178
|
+
import { collection } from "#questpie/factories";
|
|
179
|
+
|
|
180
|
+
export default collection("appointments")
|
|
181
|
+
.fields(({ f }) => ({
|
|
182
|
+
customer: f.relation("users"),
|
|
183
|
+
barber: f.relation("barbers"),
|
|
184
|
+
service: f.relation("services"),
|
|
185
|
+
scheduledAt: f.datetime().required(),
|
|
186
|
+
status: f.select([
|
|
187
|
+
{ value: "pending", label: "Pending" },
|
|
188
|
+
{ value: "confirmed", label: "Confirmed" },
|
|
189
|
+
{ value: "cancelled", label: "Cancelled" },
|
|
190
|
+
]),
|
|
191
|
+
slug: f.text().required().inputOptional(),
|
|
192
|
+
name: f.text().required(),
|
|
193
|
+
}))
|
|
194
|
+
.hooks({
|
|
195
|
+
beforeValidate: async (ctx) => {
|
|
196
|
+
if (ctx.data.name && !ctx.data.slug) {
|
|
197
|
+
ctx.data.slug = slugify(ctx.data.name);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
beforeChange: async ({ data, operation, original }) => {
|
|
202
|
+
if (operation === "create") {
|
|
203
|
+
// Set defaults on create
|
|
204
|
+
}
|
|
205
|
+
if (operation === "update" && original) {
|
|
206
|
+
// Compare with original data
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
afterChange: async ({ data, operation, original, queue }) => {
|
|
211
|
+
if (operation === "create") {
|
|
212
|
+
await queue.sendAppointmentConfirmation.publish({
|
|
213
|
+
appointmentId: data.id,
|
|
214
|
+
customerId: data.customer,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (operation === "update" && data.status === "cancelled") {
|
|
218
|
+
await queue.sendAppointmentCancellation.publish({
|
|
219
|
+
appointmentId: data.id,
|
|
220
|
+
customerId: data.customer,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
beforeDelete: async ({ id }) => {
|
|
226
|
+
// Prevent deletion or clean up
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
afterDelete: async ({ id }) => {
|
|
230
|
+
// Clean up related data
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Hook Context Properties
|
|
236
|
+
|
|
237
|
+
| Property | Available in | Description |
|
|
238
|
+
| ------------- | ----------------------------------------- | -------------------------------- |
|
|
239
|
+
| `data` | beforeValidate, beforeChange, afterChange | The record data being written |
|
|
240
|
+
| `operation` | beforeChange, afterChange | `"create"` or `"update"` |
|
|
241
|
+
| `original` | beforeChange, afterChange (update) | Previous record state |
|
|
242
|
+
| `id` | beforeDelete, afterDelete | ID of record being deleted |
|
|
243
|
+
| `collections` | All hooks | Typed collection API |
|
|
244
|
+
| `globals` | All hooks | Typed globals API |
|
|
245
|
+
| `queue` | All hooks | Queue client for publishing jobs |
|
|
246
|
+
| `email` | All hooks | Email service |
|
|
247
|
+
| `db` | All hooks | Database instance |
|
|
248
|
+
| `session` | All hooks | Current auth session |
|
|
249
|
+
| `services` | All hooks | Custom services from `services/` |
|
|
250
|
+
|
|
251
|
+
### Context-First Pattern
|
|
252
|
+
|
|
253
|
+
All dependencies come through destructuring. No need to import the app instance:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
.hooks({
|
|
257
|
+
beforeChange: async ({ data, services }) => {
|
|
258
|
+
const { blog } = services;
|
|
259
|
+
data.slug = blog.generateSlug(data.title);
|
|
260
|
+
data.readingTime = blog.computeReadingTime(data.content);
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
afterChange: async ({ data, operation, original, queue }) => {
|
|
264
|
+
if (
|
|
265
|
+
operation === "update" &&
|
|
266
|
+
original?.status !== "published" &&
|
|
267
|
+
data.status === "published"
|
|
268
|
+
) {
|
|
269
|
+
await queue.notifyBlogSubscribers.publish({
|
|
270
|
+
postId: data.id,
|
|
271
|
+
title: data.title,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Validation
|
|
279
|
+
|
|
280
|
+
QUESTPIE validates at three levels: field constraints, auto-generated Zod schemas, and custom hooks.
|
|
281
|
+
|
|
282
|
+
### Field-Level Constraints
|
|
283
|
+
|
|
284
|
+
Built-in constraints on field definitions generate Zod schemas automatically:
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
.fields(({ f }) => ({
|
|
288
|
+
name: f.text(255).required(),
|
|
289
|
+
email: f.email().required(),
|
|
290
|
+
website: f.url(),
|
|
291
|
+
rating: f.number().min(1).max(5),
|
|
292
|
+
tags: f.text().array().maxItems(10),
|
|
293
|
+
}))
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
| Constraint | Fields | Description |
|
|
297
|
+
| ----------- | ------------------ | ----------------------- |
|
|
298
|
+
| `required` | All | Field must have a value |
|
|
299
|
+
| `maxLength` | `text`, `textarea` | Maximum string length |
|
|
300
|
+
| `min`/`max` | `number` | Numeric range |
|
|
301
|
+
| `maxItems` | `array` | Maximum array length |
|
|
302
|
+
| `mimeTypes` | `upload` | Allowed file types |
|
|
303
|
+
| `maxSize` | `upload` | Max file size in bytes |
|
|
304
|
+
|
|
305
|
+
### Input Modifier
|
|
306
|
+
|
|
307
|
+
The `input` option controls API input behavior for fields computed by hooks:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
slug: f.text().required().inputOptional(),
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Custom Validation via Hooks
|
|
314
|
+
|
|
315
|
+
Use `beforeValidate` to transform data or reject operations:
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
.hooks({
|
|
319
|
+
beforeValidate: async ({ data, operation }) => {
|
|
320
|
+
// Transform data before validation
|
|
321
|
+
if (operation === "create" && !data.slug) {
|
|
322
|
+
data.slug = slugify(data.name);
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
})
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
To reject an operation, throw an error:
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
.hooks({
|
|
332
|
+
beforeValidate: async ({ data }) => {
|
|
333
|
+
if (data.scheduledAt && new Date(data.scheduledAt) < new Date()) {
|
|
334
|
+
throw new Error("Cannot schedule appointments in the past");
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Common Mistakes
|
|
341
|
+
|
|
342
|
+
1. **HIGH: Forgetting default access is `!!session`.**
|
|
343
|
+
Collections without `.access()` require authentication for all operations. For public read access, explicitly set `read: true`.
|
|
344
|
+
|
|
345
|
+
2. **HIGH: Using `accessMode: "system"` in HTTP handlers.**
|
|
346
|
+
System mode bypasses all access checks. Only use it for background jobs, seeds, and internal server scripts — never in request handlers.
|
|
347
|
+
|
|
348
|
+
3. **MEDIUM: Mutating `data` in `afterChange` hooks.**
|
|
349
|
+
Changes to `data` in `afterChange` are NOT persisted to the database. Only mutations in `beforeValidate` and `beforeChange` are saved.
|
|
350
|
+
|
|
351
|
+
4. **MEDIUM: Not awaiting async access control functions.**
|
|
352
|
+
Access functions can be async and must return `boolean` or a where clause object (`AccessWhere`).
|
|
353
|
+
|
|
354
|
+
5. **HIGH: Wrong context usage in access rules.**
|
|
355
|
+
Use the destructured `session` parameter from `AppContext`, not a standalone import. Access functions receive `({ session, db, collections })`.
|
|
356
|
+
|
|
357
|
+
## Access Control for Preview Sessions
|
|
358
|
+
|
|
359
|
+
Live preview sessions use token-based authentication. When a preview iframe loads, it receives a short-lived preview token that authorizes read access to the collection being previewed.
|
|
360
|
+
|
|
361
|
+
### Key Points
|
|
362
|
+
|
|
363
|
+
- Preview tokens are scoped to a specific collection and record — they do not grant broad access.
|
|
364
|
+
- Preview does **not** bypass access rules. The token resolves to a session with the same permissions as the user who initiated the preview.
|
|
365
|
+
- Access rules (`.access()`) still apply to all data fetched during preview, including prefetched relations and block data.
|
|
366
|
+
- Row-level access (AccessWhere) filters are enforced even in preview context — a user cannot preview records they cannot read.
|
|
367
|
+
|
|
368
|
+
### Workflow Published Reads
|
|
369
|
+
|
|
370
|
+
For publishable collections that use workflow stages, do not use `read: true` when public client or HTTP access is available. Gate anonymous reads to the published stage:
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
.access({
|
|
374
|
+
read: ({ session, input }) => {
|
|
375
|
+
if (session?.user) return true;
|
|
376
|
+
return input?.stage === "published";
|
|
377
|
+
},
|
|
378
|
+
create: ({ session }) => !!session?.user,
|
|
379
|
+
update: ({ session }) => !!session?.user,
|
|
380
|
+
delete: ({ session }) => !!session?.user,
|
|
381
|
+
transition: ({ session }) => !!session?.user,
|
|
382
|
+
})
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Public frontend code must pass `stage: "published"`. Preview/draft-mode reads may omit `stage` only when the request has an authorized editor session.
|
|
386
|
+
|
|
387
|
+
### System Access and Preview
|
|
388
|
+
|
|
389
|
+
Do not use `accessMode: "system"` to serve preview data. Preview requests should go through normal session-based access, with the preview token resolving to the editor's session. This ensures previewed content respects the same visibility rules as the final published page.
|