create-questpie 2.0.4 → 2.1.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/dist/index.mjs +362 -119
- package/package.json +2 -3
- package/templates/elysia/AGENTS.md +56 -0
- package/templates/elysia/CLAUDE.md +39 -0
- package/templates/elysia/Dockerfile +24 -0
- package/templates/elysia/README.md +148 -0
- package/templates/elysia/docker/init-extensions.sql +11 -0
- package/templates/elysia/docker-compose.yml +21 -0
- package/templates/elysia/env.example +16 -0
- package/templates/elysia/gitignore +6 -0
- package/templates/elysia/package.json +47 -0
- package/templates/elysia/questpie.config.ts +12 -0
- package/templates/elysia/src/index.ts +21 -0
- package/templates/elysia/src/lib/auth-client.ts +32 -0
- package/templates/elysia/src/lib/client.ts +13 -0
- package/templates/elysia/src/lib/env.ts +24 -0
- package/templates/elysia/src/lib/query-client.ts +18 -0
- package/templates/elysia/src/lib/query.ts +18 -0
- package/templates/elysia/src/questpie/server/.generated/context.gen.ts +200 -0
- package/templates/elysia/src/questpie/server/.generated/entities.gen.ts +84 -0
- package/templates/elysia/src/questpie/server/.generated/factories.ts +65 -0
- package/templates/elysia/src/questpie/server/.generated/index.ts +131 -0
- package/templates/elysia/src/questpie/server/.generated/names.gen.ts +25 -0
- package/templates/elysia/src/questpie/server/app.ts +10 -0
- package/templates/elysia/src/questpie/server/collections/index.ts +1 -0
- package/templates/elysia/src/questpie/server/collections/posts.collection.ts +10 -0
- package/templates/elysia/src/questpie/server/config/auth.ts +8 -0
- package/templates/elysia/src/questpie/server/config/openapi.ts +10 -0
- package/templates/elysia/src/questpie/server/globals/index.ts +1 -0
- package/templates/elysia/src/questpie/server/globals/site-settings.global.ts +10 -0
- package/templates/elysia/src/questpie/server/modules.ts +8 -0
- package/templates/elysia/src/questpie/server/questpie.config.ts +21 -0
- package/templates/elysia/tsconfig.json +28 -0
- package/templates/hono/AGENTS.md +56 -0
- package/templates/hono/CLAUDE.md +39 -0
- package/templates/hono/Dockerfile +24 -0
- package/templates/hono/README.md +148 -0
- package/templates/hono/docker/init-extensions.sql +11 -0
- package/templates/hono/docker-compose.yml +21 -0
- package/templates/hono/env.example +16 -0
- package/templates/hono/gitignore +6 -0
- package/templates/hono/package.json +47 -0
- package/templates/hono/questpie.config.ts +12 -0
- package/templates/hono/src/index.ts +30 -0
- package/templates/hono/src/lib/auth-client.ts +32 -0
- package/templates/hono/src/lib/client.ts +13 -0
- package/templates/hono/src/lib/env.ts +24 -0
- package/templates/hono/src/lib/query-client.ts +18 -0
- package/templates/hono/src/lib/query.ts +18 -0
- package/templates/hono/src/questpie/server/.generated/context.gen.ts +200 -0
- package/templates/hono/src/questpie/server/.generated/entities.gen.ts +84 -0
- package/templates/hono/src/questpie/server/.generated/factories.ts +65 -0
- package/templates/hono/src/questpie/server/.generated/index.ts +131 -0
- package/templates/hono/src/questpie/server/.generated/names.gen.ts +25 -0
- package/templates/hono/src/questpie/server/app.ts +10 -0
- package/templates/hono/src/questpie/server/collections/index.ts +1 -0
- package/templates/hono/src/questpie/server/collections/posts.collection.ts +10 -0
- package/templates/hono/src/questpie/server/config/auth.ts +8 -0
- package/templates/hono/src/questpie/server/config/openapi.ts +10 -0
- package/templates/hono/src/questpie/server/globals/index.ts +1 -0
- package/templates/hono/src/questpie/server/globals/site-settings.global.ts +10 -0
- package/templates/hono/src/questpie/server/modules.ts +8 -0
- package/templates/hono/src/questpie/server/questpie.config.ts +21 -0
- package/templates/hono/tsconfig.json +28 -0
- package/templates/next/AGENTS.md +55 -0
- package/templates/next/CLAUDE.md +39 -0
- package/templates/next/Dockerfile +25 -0
- package/templates/next/README.md +148 -0
- package/templates/next/components.json +22 -0
- package/templates/next/docker/init-extensions.sql +11 -0
- package/templates/next/docker-compose.yml +21 -0
- package/templates/next/env.example +16 -0
- package/templates/next/gitignore +10 -0
- package/templates/next/next-env.d.ts +5 -0
- package/templates/next/next.config.ts +20 -0
- package/templates/next/package.json +54 -0
- package/templates/next/postcss.config.mjs +8 -0
- package/templates/next/public/.gitkeep +0 -0
- package/templates/next/questpie.config.ts +12 -0
- package/templates/next/src/app/admin/[[...all]]/page.tsx +34 -0
- package/templates/next/src/app/admin/admin.css +4 -0
- package/templates/next/src/app/admin/layout.tsx +63 -0
- package/templates/next/src/app/api/[...all]/route.ts +24 -0
- package/templates/next/src/app/layout.tsx +24 -0
- package/templates/next/src/app/not-found.tsx +18 -0
- package/templates/next/src/app/page.tsx +74 -0
- package/templates/next/src/app/providers.tsx +11 -0
- package/templates/next/src/lib/auth-client.ts +12 -0
- package/templates/next/src/lib/client.ts +13 -0
- package/templates/next/src/lib/env.ts +24 -0
- package/templates/next/src/lib/query-client.ts +18 -0
- package/templates/next/src/lib/query.ts +18 -0
- package/templates/next/src/questpie/admin/.generated/client.ts +13 -0
- package/templates/next/src/questpie/admin/admin.ts +9 -0
- package/templates/next/src/questpie/admin/modules.ts +3 -0
- package/templates/next/src/questpie/server/.generated/context.gen.ts +204 -0
- package/templates/next/src/questpie/server/.generated/entities.gen.ts +100 -0
- package/templates/next/src/questpie/server/.generated/factories.ts +204 -0
- package/templates/next/src/questpie/server/.generated/index.ts +139 -0
- package/templates/next/src/questpie/server/.generated/names.gen.ts +31 -0
- package/templates/next/src/questpie/server/app.ts +10 -0
- package/templates/next/src/questpie/server/collections/index.ts +1 -0
- package/templates/next/src/questpie/server/collections/posts.collection.ts +58 -0
- package/templates/next/src/questpie/server/config/admin.ts +80 -0
- package/templates/next/src/questpie/server/config/auth.ts +8 -0
- package/templates/next/src/questpie/server/config/openapi.ts +10 -0
- package/templates/next/src/questpie/server/globals/index.ts +1 -0
- package/templates/next/src/questpie/server/globals/site-settings.global.ts +19 -0
- package/templates/next/src/questpie/server/modules.ts +9 -0
- package/templates/next/src/questpie/server/questpie.config.ts +21 -0
- package/templates/next/src/styles.css +125 -0
- package/templates/next/tsconfig.json +37 -0
- package/templates/tanstack-start/AGENTS.md +35 -607
- package/templates/tanstack-start/CLAUDE.md +26 -134
- package/templates/tanstack-start/README.md +13 -1
- package/templates/tanstack-start/docker/init-extensions.sql +11 -0
- package/templates/tanstack-start/docker-compose.yml +1 -0
- package/templates/tanstack-start/src/lib/auth-client.ts +1 -1
- package/templates/tanstack-start/src/lib/client.ts +1 -1
- package/templates/tanstack-start/src/lib/query.ts +18 -0
- package/templates/tanstack-start/src/questpie/server/collections/index.ts +1 -1
- package/templates/tanstack-start/src/questpie/server/globals/index.ts +1 -1
- package/templates/tanstack-start/src/questpie/server/questpie.config.ts +1 -1
- package/templates/tanstack-start/src/routes/__root.tsx +31 -1
- package/templates/tanstack-start/src/routes/api/$.ts +1 -1
- package/templates/tanstack-start/src/routes/index.tsx +97 -0
- package/skills/questpie/AGENTS.md +0 -2871
- package/skills/questpie/SKILL.md +0 -293
- package/skills/questpie/coverage.json +0 -213
- package/skills/questpie/references/auth.md +0 -236
- package/skills/questpie/references/business-logic.md +0 -620
- package/skills/questpie/references/codegen-plugin-api.md +0 -382
- package/skills/questpie/references/crud-api.md +0 -580
- package/skills/questpie/references/data-modeling.md +0 -509
- package/skills/questpie/references/extend.md +0 -584
- package/skills/questpie/references/field-types.md +0 -398
- package/skills/questpie/references/infrastructure-adapters.md +0 -720
- package/skills/questpie/references/mcp.md +0 -147
- package/skills/questpie/references/multi-tenancy.md +0 -363
- package/skills/questpie/references/production.md +0 -640
- package/skills/questpie/references/query-operators.md +0 -125
- package/skills/questpie/references/quickstart.md +0 -562
- package/skills/questpie/references/rules.md +0 -454
- package/skills/questpie/references/sandbox.md +0 -110
- package/skills/questpie/references/tanstack-query.md +0 -543
- package/skills/questpie/references/type-inference.md +0 -167
- package/skills/questpie/references/workflows.md +0 -155
- package/skills/questpie-admin/AGENTS.md +0 -1515
- package/skills/questpie-admin/SKILL.md +0 -443
- package/skills/questpie-admin/references/blocks.md +0 -331
- package/skills/questpie-admin/references/custom-ui.md +0 -305
- package/skills/questpie-admin/references/views.md +0 -449
|
@@ -1,454 +0,0 @@
|
|
|
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
|
-
Every operation resolves through the same chain, with no hidden framework grants above your config:
|
|
20
|
-
|
|
21
|
-
1. Collection/global `.access()` rule for that operation
|
|
22
|
-
2. App-level `defaultAccess` (`appConfig({ access })` in `config/app.ts`)
|
|
23
|
-
3. Framework fallback: require session
|
|
24
|
-
|
|
25
|
-
A deny-all `defaultAccess` (`{ read: false, create: false, update: false, delete: false }`) closes the entire REST surface — including upload-row listing and schema/meta introspection — until collections opt in.
|
|
26
|
-
|
|
27
|
-
### Collection Access
|
|
28
|
-
|
|
29
|
-
```ts
|
|
30
|
-
// collections/posts.collection.ts
|
|
31
|
-
import { collection } from "#questpie/factories";
|
|
32
|
-
|
|
33
|
-
export default collection("posts")
|
|
34
|
-
.fields(({ f }) => ({
|
|
35
|
-
title: f.text().label("Title").required(),
|
|
36
|
-
content: f.richText().label("Content"),
|
|
37
|
-
author: f.relation("user"),
|
|
38
|
-
}))
|
|
39
|
-
.access({
|
|
40
|
-
read: true, // Public read
|
|
41
|
-
create: ({ session }) => !!session, // Authenticated
|
|
42
|
-
update: ({ session }) => session?.user?.role === "admin", // Admin only
|
|
43
|
-
delete: ({ session }) => session?.user?.role === "admin",
|
|
44
|
-
});
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
### Operations
|
|
48
|
-
|
|
49
|
-
| Operation | When checked |
|
|
50
|
-
| ------------ | ---------------------------------------------------------------- |
|
|
51
|
-
| `read` | Listing and fetching records |
|
|
52
|
-
| `create` | Creating new records |
|
|
53
|
-
| `update` | Updating existing records |
|
|
54
|
-
| `delete` | Deleting records |
|
|
55
|
-
| `transition` | Workflow stage transitions (falls back to `update`) |
|
|
56
|
-
| `serve` | Upload file bytes by key (`GET /:collection/files/:key`) |
|
|
57
|
-
| `introspect` | Schema/meta routes (`GET /:collection/{schema,meta}`) |
|
|
58
|
-
|
|
59
|
-
Two operations have specialized chains:
|
|
60
|
-
|
|
61
|
-
- **`serve`** (upload collections): `serve` → explicit collection `read`
|
|
62
|
-
(row-aware, `ctx.data` is the upload row) → `defaultAccess.serve` → allow.
|
|
63
|
-
`defaultAccess.read` is deliberately NOT consulted — listing rows and
|
|
64
|
-
fetching bytes by key are distinct permissions. `visibility: "public"`
|
|
65
|
-
means bytes are servable by key; `"private"` files always require the
|
|
66
|
-
signed token in addition to any serve rule.
|
|
67
|
-
- **`introspect`**: `introspect` → `defaultAccess.introspect` → visible iff
|
|
68
|
-
at least one CRUD operation is allowed for the current user. Create-only
|
|
69
|
-
public collections keep their validation schema readable; deny-all apps
|
|
70
|
-
expose no schemas. Denied requests get 401 (anonymous) or 403
|
|
71
|
-
(authenticated).
|
|
72
|
-
|
|
73
|
-
`f.upload()` fields populate through the PARENT row's read decision — a
|
|
74
|
-
publicly readable gallery shows its assets (with `url`) to anonymous readers
|
|
75
|
-
even when the assets collection itself is unlistable. Field-level read rules
|
|
76
|
-
on the upload collection still apply inside population.
|
|
77
|
-
|
|
78
|
-
### Global Access
|
|
79
|
-
|
|
80
|
-
Globals support `read` and `update` only (singletons have no create/delete):
|
|
81
|
-
|
|
82
|
-
```ts
|
|
83
|
-
// globals/site-settings.global.ts
|
|
84
|
-
import { global } from "#questpie/factories";
|
|
85
|
-
|
|
86
|
-
export default global("siteSettings")
|
|
87
|
-
.fields(({ f }) => ({
|
|
88
|
-
siteName: f.text().label("Site Name").required(),
|
|
89
|
-
logo: f.upload().label("Logo"),
|
|
90
|
-
}))
|
|
91
|
-
.access({
|
|
92
|
-
read: true,
|
|
93
|
-
update: ({ session }) => session?.user?.role === "admin",
|
|
94
|
-
});
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
### Row-Level Access (AccessWhere)
|
|
98
|
-
|
|
99
|
-
Return a where clause object instead of a boolean to restrict operations to matching rows:
|
|
100
|
-
|
|
101
|
-
```ts
|
|
102
|
-
.access({
|
|
103
|
-
read: true,
|
|
104
|
-
update: ({ session }) => {
|
|
105
|
-
if (!session) return false;
|
|
106
|
-
// Only allow updating own records
|
|
107
|
-
return { author: session.user.id };
|
|
108
|
-
},
|
|
109
|
-
})
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
### Access Function Context
|
|
113
|
-
|
|
114
|
-
Access functions receive `AppContext` with these properties:
|
|
115
|
-
|
|
116
|
-
| Property | Description |
|
|
117
|
-
| ------------- | ------------------------------------------------------------ |
|
|
118
|
-
| `session` | Current auth session (null if unauthed) |
|
|
119
|
-
| `db` | Database instance |
|
|
120
|
-
| `collections` | Typed collection API |
|
|
121
|
-
| `request` | Current HTTP `Request` (headers, URL) |
|
|
122
|
-
| `data` | The existing row — typed, non-optional in `update`/`delete` rules |
|
|
123
|
-
| `input` | Typed insert shape in `create` rules; typed patch in `update` rules |
|
|
124
|
-
| _extensions_ | Keys returned by `appConfig({ context })`, flat (see below) |
|
|
125
|
-
|
|
126
|
-
`data`/`input` are typed **per operation** by the builder — no casts, no annotations inside the defining collection. For shared rule helpers and every other "I need type X" case, see `references/type-inference.md`.
|
|
127
|
-
|
|
128
|
-
### Derived Request Context in Rules
|
|
129
|
-
|
|
130
|
-
`appConfig({ context })` runs once per HTTP request; its result arrives **flat** on every access rule ctx (collections, globals, routes, field access, transitions), typed by inference:
|
|
131
|
-
|
|
132
|
-
```ts
|
|
133
|
-
// config/app.ts
|
|
134
|
-
export default appConfig({
|
|
135
|
-
context: async ({ request }) => ({
|
|
136
|
-
workspaceId: request.headers.get("x-workspace") || null,
|
|
137
|
-
}),
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// collections/projects.ts — destructure flat, narrow before use
|
|
141
|
-
.access({
|
|
142
|
-
read: ({ workspaceId }) =>
|
|
143
|
-
workspaceId ? { workspace: workspaceId } : false,
|
|
144
|
-
})
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
Extensions are typed `Partial<…>` — absent for non-HTTP contexts (jobs, seeds, system scripts), so rules must handle `undefined`. See `references/multi-tenancy.md` for the full pattern (membership validation, closure memoization, scope UI).
|
|
148
|
-
|
|
149
|
-
Access functions may be async. Use `request` for request-scoped checks such as headers, tenant scope, CAPTCHA tokens, or signed public form tokens:
|
|
150
|
-
|
|
151
|
-
```ts
|
|
152
|
-
import type { AccessContext } from "questpie";
|
|
153
|
-
import { ApiError } from "questpie/errors";
|
|
154
|
-
import { isAdminRequest } from "@questpie/admin/shared";
|
|
155
|
-
|
|
156
|
-
// AccessContext is the sanctioned shared-helper param — never hand-roll a
|
|
157
|
-
// structural ctx type (see references/type-inference.md)
|
|
158
|
-
async function canCreatePublicSubmission({ request, session }: AccessContext) {
|
|
159
|
-
if (session?.user) return true;
|
|
160
|
-
if (request && isAdminRequest(request)) {
|
|
161
|
-
throw ApiError.unauthorized();
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const token = request?.headers.get("x-captcha-token");
|
|
165
|
-
const valid = token ? await verifyCaptchaToken(token) : false;
|
|
166
|
-
if (valid) return true;
|
|
167
|
-
|
|
168
|
-
throw ApiError.forbidden({
|
|
169
|
-
operation: "create",
|
|
170
|
-
resource: "public_submissions",
|
|
171
|
-
reason: "CAPTCHA verification failed",
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export default collection("public_submissions")
|
|
176
|
-
.fields(({ f }) => ({
|
|
177
|
-
message: f.textarea().required(),
|
|
178
|
-
}))
|
|
179
|
-
.access({
|
|
180
|
-
read: false,
|
|
181
|
-
create: canCreatePublicSubmission,
|
|
182
|
-
});
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
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.
|
|
186
|
-
|
|
187
|
-
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.
|
|
188
|
-
|
|
189
|
-
### System Access Mode
|
|
190
|
-
|
|
191
|
-
Server-side code can bypass all access checks:
|
|
192
|
-
|
|
193
|
-
```ts
|
|
194
|
-
const ctx = await app.createContext({ accessMode: "system" });
|
|
195
|
-
const allPosts = await app.collections.posts.find({}, ctx);
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
HTTP requests always use session-based access. System mode is for background jobs, seeds, and internal server logic only.
|
|
199
|
-
|
|
200
|
-
## Hooks
|
|
201
|
-
|
|
202
|
-
Hooks run logic at specific points in the collection lifecycle. They receive the full typed `AppContext` through context injection.
|
|
203
|
-
|
|
204
|
-
### Lifecycle Order
|
|
205
|
-
|
|
206
|
-
For create/update:
|
|
207
|
-
|
|
208
|
-
```text
|
|
209
|
-
API Request
|
|
210
|
-
|
|
|
211
|
-
beforeValidate -- Modify/validate data before schema validation
|
|
212
|
-
|
|
|
213
|
-
Schema Validation -- Zod validation from field definitions
|
|
214
|
-
|
|
|
215
|
-
beforeChange -- Transform data before database write
|
|
216
|
-
|
|
|
217
|
-
Database Write -- Insert or update
|
|
218
|
-
|
|
|
219
|
-
afterChange -- Side effects after successful write
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
For delete:
|
|
223
|
-
|
|
224
|
-
```text
|
|
225
|
-
beforeDelete --> Database Delete --> afterDelete
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
### Defining Hooks
|
|
229
|
-
|
|
230
|
-
```ts
|
|
231
|
-
// collections/appointments.collection.ts
|
|
232
|
-
import { collection } from "#questpie/factories";
|
|
233
|
-
|
|
234
|
-
export default collection("appointments")
|
|
235
|
-
.fields(({ f }) => ({
|
|
236
|
-
customer: f.relation("user"),
|
|
237
|
-
barber: f.relation("barbers"),
|
|
238
|
-
service: f.relation("services"),
|
|
239
|
-
scheduledAt: f.datetime().required(),
|
|
240
|
-
status: f.select([
|
|
241
|
-
{ value: "pending", label: "Pending" },
|
|
242
|
-
{ value: "confirmed", label: "Confirmed" },
|
|
243
|
-
{ value: "cancelled", label: "Cancelled" },
|
|
244
|
-
]),
|
|
245
|
-
slug: f.text().required().inputOptional(),
|
|
246
|
-
name: f.text().required(),
|
|
247
|
-
}))
|
|
248
|
-
.hooks({
|
|
249
|
-
beforeValidate: async (ctx) => {
|
|
250
|
-
if (ctx.data.name && !ctx.data.slug) {
|
|
251
|
-
ctx.data.slug = slugify(ctx.data.name);
|
|
252
|
-
}
|
|
253
|
-
},
|
|
254
|
-
|
|
255
|
-
beforeChange: async ({ data, operation, original }) => {
|
|
256
|
-
if (operation === "create") {
|
|
257
|
-
// Set defaults on create
|
|
258
|
-
}
|
|
259
|
-
if (operation === "update" && original) {
|
|
260
|
-
// Compare with original data
|
|
261
|
-
}
|
|
262
|
-
},
|
|
263
|
-
|
|
264
|
-
afterChange: async ({ data, operation, original, queue }) => {
|
|
265
|
-
if (operation === "create") {
|
|
266
|
-
await queue.sendAppointmentConfirmation.publish({
|
|
267
|
-
appointmentId: data.id,
|
|
268
|
-
customerId: data.customer,
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
|
-
if (operation === "update" && data.status === "cancelled") {
|
|
272
|
-
await queue.sendAppointmentCancellation.publish({
|
|
273
|
-
appointmentId: data.id,
|
|
274
|
-
customerId: data.customer,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
},
|
|
278
|
-
|
|
279
|
-
beforeDelete: async ({ id }) => {
|
|
280
|
-
// Prevent deletion or clean up
|
|
281
|
-
},
|
|
282
|
-
|
|
283
|
-
afterDelete: async ({ id }) => {
|
|
284
|
-
// Clean up related data
|
|
285
|
-
},
|
|
286
|
-
});
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
### Hook Context Properties
|
|
290
|
-
|
|
291
|
-
| Property | Available in | Description |
|
|
292
|
-
| ------------- | ----------------------------------------- | -------------------------------- |
|
|
293
|
-
| `data` | beforeValidate, beforeChange, afterChange | The record data being written |
|
|
294
|
-
| `operation` | beforeChange, afterChange | `"create"` or `"update"` |
|
|
295
|
-
| `original` | beforeChange, afterChange (update) | Previous record state |
|
|
296
|
-
| `id` | beforeDelete, afterDelete | ID of record being deleted |
|
|
297
|
-
| `collections` | All hooks | Typed collection API |
|
|
298
|
-
| `globals` | All hooks | Typed globals API |
|
|
299
|
-
| `queue` | All hooks | Queue client for publishing jobs |
|
|
300
|
-
| `email` | All hooks | Email service |
|
|
301
|
-
| `db` | All hooks | Database instance |
|
|
302
|
-
| `session` | All hooks | Current auth session |
|
|
303
|
-
| `services` | All hooks | Custom services from `services/` |
|
|
304
|
-
| _extensions_ | All hooks | `appConfig({ context })` result, flat (HTTP requests only) |
|
|
305
|
-
|
|
306
|
-
Derived request context also reaches hooks and any nested code via `getContext<App>()` — including CRUD calls a hook triggers (AsyncLocalStorage carries it):
|
|
307
|
-
|
|
308
|
-
```ts
|
|
309
|
-
.hooks({
|
|
310
|
-
beforeChange: async ({ data, operation, workspaceId }) => {
|
|
311
|
-
if (operation === "create" && workspaceId) data.workspace = workspaceId;
|
|
312
|
-
},
|
|
313
|
-
})
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
### Context-First Pattern
|
|
317
|
-
|
|
318
|
-
All dependencies come through destructuring. No need to import the app instance:
|
|
319
|
-
|
|
320
|
-
```ts
|
|
321
|
-
.hooks({
|
|
322
|
-
beforeChange: async ({ data, services }) => {
|
|
323
|
-
const { blog } = services;
|
|
324
|
-
data.slug = blog.generateSlug(data.title);
|
|
325
|
-
data.readingTime = blog.computeReadingTime(data.content);
|
|
326
|
-
},
|
|
327
|
-
|
|
328
|
-
afterChange: async ({ data, operation, original, queue }) => {
|
|
329
|
-
if (
|
|
330
|
-
operation === "update" &&
|
|
331
|
-
original?.status !== "published" &&
|
|
332
|
-
data.status === "published"
|
|
333
|
-
) {
|
|
334
|
-
await queue.notifyBlogSubscribers.publish({
|
|
335
|
-
postId: data.id,
|
|
336
|
-
title: data.title,
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
},
|
|
340
|
-
})
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
## Validation
|
|
344
|
-
|
|
345
|
-
QUESTPIE validates at three levels: field constraints, auto-generated Zod schemas, and custom hooks.
|
|
346
|
-
|
|
347
|
-
### Field-Level Constraints
|
|
348
|
-
|
|
349
|
-
Built-in constraints on field definitions generate Zod schemas automatically:
|
|
350
|
-
|
|
351
|
-
```ts
|
|
352
|
-
.fields(({ f }) => ({
|
|
353
|
-
name: f.text(255).required(),
|
|
354
|
-
email: f.email().required(),
|
|
355
|
-
website: f.url(),
|
|
356
|
-
rating: f.number().min(1).max(5),
|
|
357
|
-
tags: f.text().array().maxItems(10),
|
|
358
|
-
}))
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
| Constraint | Fields | Description |
|
|
362
|
-
| ----------- | ------------------ | ----------------------- |
|
|
363
|
-
| `required` | All | Field must have a value |
|
|
364
|
-
| `maxLength` | `text`, `textarea` | Maximum string length |
|
|
365
|
-
| `min`/`max` | `number` | Numeric range |
|
|
366
|
-
| `maxItems` | `array` | Maximum array length |
|
|
367
|
-
| `mimeTypes` | `upload` | Allowed file types |
|
|
368
|
-
| `maxSize` | `upload` | Max file size in bytes |
|
|
369
|
-
|
|
370
|
-
### Input Modifier
|
|
371
|
-
|
|
372
|
-
The `input` option controls API input behavior for fields computed by hooks:
|
|
373
|
-
|
|
374
|
-
```ts
|
|
375
|
-
slug: f.text().required().inputOptional(),
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
### Custom Validation via Hooks
|
|
379
|
-
|
|
380
|
-
Use `beforeValidate` to transform data or reject operations:
|
|
381
|
-
|
|
382
|
-
```ts
|
|
383
|
-
.hooks({
|
|
384
|
-
beforeValidate: async ({ data, operation }) => {
|
|
385
|
-
// Transform data before validation
|
|
386
|
-
if (operation === "create" && !data.slug) {
|
|
387
|
-
data.slug = slugify(data.name);
|
|
388
|
-
}
|
|
389
|
-
},
|
|
390
|
-
})
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
To reject an operation, throw an error:
|
|
394
|
-
|
|
395
|
-
```ts
|
|
396
|
-
.hooks({
|
|
397
|
-
beforeValidate: async ({ data }) => {
|
|
398
|
-
if (data.scheduledAt && new Date(data.scheduledAt) < new Date()) {
|
|
399
|
-
throw new Error("Cannot schedule appointments in the past");
|
|
400
|
-
}
|
|
401
|
-
},
|
|
402
|
-
})
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
## Common Mistakes
|
|
406
|
-
|
|
407
|
-
1. **HIGH: Forgetting default access is `!!session`.**
|
|
408
|
-
Collections without `.access()` require authentication for all operations. For public read access, explicitly set `read: true`.
|
|
409
|
-
|
|
410
|
-
2. **HIGH: Using `accessMode: "system"` in HTTP handlers.**
|
|
411
|
-
System mode bypasses all access checks. Only use it for background jobs, seeds, and internal server scripts — never in request handlers.
|
|
412
|
-
|
|
413
|
-
3. **MEDIUM: Mutating `data` in `afterChange` hooks.**
|
|
414
|
-
Changes to `data` in `afterChange` are NOT persisted to the database. Only mutations in `beforeValidate` and `beforeChange` are saved.
|
|
415
|
-
|
|
416
|
-
4. **MEDIUM: Not awaiting async access control functions.**
|
|
417
|
-
Access functions can be async and must return `boolean` or a where clause object (`AccessWhere`).
|
|
418
|
-
|
|
419
|
-
5. **HIGH: Wrong context usage in access rules.**
|
|
420
|
-
Use the destructured `session` parameter from `AppContext`, not a standalone import. Access functions receive `({ session, db, collections })`.
|
|
421
|
-
|
|
422
|
-
## Access Control for Preview Sessions
|
|
423
|
-
|
|
424
|
-
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.
|
|
425
|
-
|
|
426
|
-
### Key Points
|
|
427
|
-
|
|
428
|
-
- Preview tokens are scoped to a specific collection and record — they do not grant broad access.
|
|
429
|
-
- Preview does **not** bypass access rules. The token resolves to a session with the same permissions as the user who initiated the preview.
|
|
430
|
-
- Access rules (`.access()`) still apply to all data fetched during preview, including prefetched relations and block data.
|
|
431
|
-
- Row-level access (AccessWhere) filters are enforced even in preview context — a user cannot preview records they cannot read.
|
|
432
|
-
|
|
433
|
-
### Workflow Published Reads
|
|
434
|
-
|
|
435
|
-
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:
|
|
436
|
-
|
|
437
|
-
```ts
|
|
438
|
-
.access({
|
|
439
|
-
read: ({ session, input }) => {
|
|
440
|
-
if (session?.user) return true;
|
|
441
|
-
return input?.stage === "published";
|
|
442
|
-
},
|
|
443
|
-
create: ({ session }) => !!session?.user,
|
|
444
|
-
update: ({ session }) => !!session?.user,
|
|
445
|
-
delete: ({ session }) => !!session?.user,
|
|
446
|
-
transition: ({ session }) => !!session?.user,
|
|
447
|
-
})
|
|
448
|
-
```
|
|
449
|
-
|
|
450
|
-
Public frontend code must pass `stage: "published"`. Preview/draft-mode reads may omit `stage` only when the request has an authorized editor session.
|
|
451
|
-
|
|
452
|
-
### System Access and Preview
|
|
453
|
-
|
|
454
|
-
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.
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
# Sandboxed Code Execution (ctx.executor)
|
|
2
|
-
|
|
3
|
-
Use the executor when an app must run **untrusted or dynamically-authored code** — agent-written scripts, user plugins, knowledge mini-apps — under a default-deny capability model. `ctx.executor.run()` is the primitive (top-level on AppContext; there is no `ctx.sandbox`).
|
|
4
|
-
|
|
5
|
-
Unconfigured = disabled: without an `executor` key in `questpie.config.ts`, `ctx.executor.run` throws a clear "not configured" error. An app that never runs dynamic code simply does not configure it.
|
|
6
|
-
|
|
7
|
-
## Two Isolation Modes
|
|
8
|
-
|
|
9
|
-
| Mode | Runs in | For |
|
|
10
|
-
| --- | --- | --- |
|
|
11
|
-
| `"sandboxed"` (default) | fresh, hardened **Deno** subprocess per request (scoped net/import, fs/env/run/ffi denied, memory bound) | untrusted code (user/AI mini-apps) |
|
|
12
|
-
| `"trusted"` | in-process (Bun) with a soft timeout | code you already own (code-mode agents, scheduled scripts) |
|
|
13
|
-
|
|
14
|
-
Untrusted-by-default: omitting `isolation` means `"sandboxed"`; trusted callers opt in explicitly.
|
|
15
|
-
|
|
16
|
-
## Install And Configure
|
|
17
|
-
|
|
18
|
-
The sandboxed adapter comes from the opt-in `@questpie/sandbox` package; the engine is a standalone Deno service your app reaches over HTTP (the app ships no Deno):
|
|
19
|
-
|
|
20
|
-
```bash
|
|
21
|
-
bun add @questpie/sandbox
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
```ts
|
|
25
|
-
// questpie.config.ts
|
|
26
|
-
import { httpSandboxAdapter } from "@questpie/sandbox/adapter";
|
|
27
|
-
import { runtimeConfig } from "questpie/app";
|
|
28
|
-
|
|
29
|
-
export default runtimeConfig({
|
|
30
|
-
executor: {
|
|
31
|
-
sandboxed: httpSandboxAdapter({
|
|
32
|
-
url: process.env.SANDBOX_URL ?? "http://127.0.0.1:8787",
|
|
33
|
-
}),
|
|
34
|
-
// TRUSTED internal URL of this app's own broker endpoint — required only
|
|
35
|
-
// for the untrusted app-bindings path. NEVER derive from request Host.
|
|
36
|
-
brokerUrl: process.env.SANDBOX_BROKER_URL,
|
|
37
|
-
// defaultTimeoutMs: 10_000,
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
`executor.trusted` defaults to the built-in in-process adapter — override only to customize.
|
|
43
|
-
|
|
44
|
-
## Running Code
|
|
45
|
-
|
|
46
|
-
The guest source must `export default` a function of `input`:
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
const result = await ctx.executor.run({
|
|
50
|
-
source: `export default async function (input) {
|
|
51
|
-
const res = await fetch("https://api.example.com/data?since=" + input.since);
|
|
52
|
-
const data = await res.json();
|
|
53
|
-
return { count: data.length };
|
|
54
|
-
}`,
|
|
55
|
-
capabilities: {
|
|
56
|
-
net: ["api.example.com"], // fetch() egress allowlist
|
|
57
|
-
timeoutMs: 5_000,
|
|
58
|
-
memoryMb: 128,
|
|
59
|
-
},
|
|
60
|
-
input: { since: "2026-01-01" },
|
|
61
|
-
});
|
|
62
|
-
// → { ok: true, output: { count: 42 }, logs: [...], ms: 312 }
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Result shape: `{ ok, output?, logs, error?, timedOut?, ms? }`.
|
|
66
|
-
|
|
67
|
-
## The Capability Model
|
|
68
|
-
|
|
69
|
-
Every run declares a manifest; anything not granted is denied (default-deny):
|
|
70
|
-
|
|
71
|
-
| Axis | Grants | Enforced by |
|
|
72
|
-
| --- | --- | --- |
|
|
73
|
-
| `net` | `fetch()` host allowlist (`host[:port]`) | sandbox engine |
|
|
74
|
-
| `import` | remote module-import host allowlist (independent of `net`) | sandbox engine |
|
|
75
|
-
| `timeoutMs` / `memoryMb` | hard wall-clock / per-guest memory bounds | sandbox engine |
|
|
76
|
-
| `files` | read/write path globs into the file store | bindings broker |
|
|
77
|
-
| `data.collections` | per-collection verbs (`read`/`create`/`update`/`delete`) | bindings broker |
|
|
78
|
-
| `data.globals` / `data.stores` | per-global and per-`document_store`-namespace verbs | bindings broker |
|
|
79
|
-
| `services` / `jobs` / `workflows` | allowed service names / enqueueable jobs / triggerable workflows | bindings broker |
|
|
80
|
-
|
|
81
|
-
`secrets: Record<string, string>` injects secrets into the guest without embedding them in source.
|
|
82
|
-
|
|
83
|
-
## App Bindings (the `questpie` Proxy)
|
|
84
|
-
|
|
85
|
-
A plain `ctx.executor.run` is **compute-only** (plus granted `net`). To let the guest reach app data, the caller passes an `appBindings` target plus `brokerUrl` — the service mints a per-run scoped token, and the guest's `globalThis.questpie` proxy RPCs through a host **broker** that enforces the capability manifest per call and dispatches under a non-privileged principal (never `system`):
|
|
86
|
-
|
|
87
|
-
```ts
|
|
88
|
-
// inside the guest source — only the granted surface resolves
|
|
89
|
-
const posts = await questpie.collections.posts.find({ limit: 10 });
|
|
90
|
-
const file = await questpie.files.read({ path: "company/data/report.json" });
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
The broker endpoint is a route the host app mounts (product layers like Autopilot's mini-app runner do this); the guest never imports your app. For trusted in-process runs, `bindings` injects host globals directly instead.
|
|
94
|
-
|
|
95
|
-
## Deployment
|
|
96
|
-
|
|
97
|
-
The sandbox engine runs as its own service/container reachable at `SANDBOX_URL`; `brokerUrl` must point at the app's own loopback/internal address (the supervisor is trusted) — never at anything request-derived:
|
|
98
|
-
|
|
99
|
-
```bash
|
|
100
|
-
deno run --allow-net --allow-read packages/sandbox/src/sandbox-server.ts
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
## Rules
|
|
104
|
-
|
|
105
|
-
- Do not use the executor for trusted first-party logic — routes, jobs, and services are the right tools.
|
|
106
|
-
- Never grant `net`/`import` hosts or data verbs a run does not need; capabilities are per-run, not global.
|
|
107
|
-
- Never pass `isolation: "trusted"` for code you did not author — there is no sandbox in that mode.
|
|
108
|
-
- Source `brokerUrl` from config/env only; a request-derived broker URL lets a spoofed Host exfiltrate the per-run token.
|
|
109
|
-
|
|
110
|
-
Full reference: docs page `backend/business-logic/code-execution`.
|