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.
Files changed (40) hide show
  1. package/README.md +10 -6
  2. package/dist/index.mjs +139 -24
  3. package/package.json +5 -3
  4. package/skills/questpie/AGENTS.md +2670 -0
  5. package/skills/questpie/SKILL.md +260 -0
  6. package/skills/questpie/references/auth.md +121 -0
  7. package/skills/questpie/references/business-logic.md +550 -0
  8. package/skills/questpie/references/codegen-plugin-api.md +382 -0
  9. package/skills/questpie/references/crud-api.md +378 -0
  10. package/skills/questpie/references/data-modeling.md +493 -0
  11. package/skills/questpie/references/extend.md +557 -0
  12. package/skills/questpie/references/field-types.md +386 -0
  13. package/skills/questpie/references/infrastructure-adapters.md +545 -0
  14. package/skills/questpie/references/multi-tenancy.md +364 -0
  15. package/skills/questpie/references/production.md +475 -0
  16. package/skills/questpie/references/query-operators.md +125 -0
  17. package/skills/questpie/references/quickstart.md +564 -0
  18. package/skills/questpie/references/rules.md +389 -0
  19. package/skills/questpie/references/tanstack-query.md +520 -0
  20. package/skills/questpie-admin/AGENTS.md +1508 -0
  21. package/skills/questpie-admin/SKILL.md +436 -0
  22. package/skills/questpie-admin/references/blocks.md +331 -0
  23. package/skills/questpie-admin/references/custom-ui.md +305 -0
  24. package/skills/questpie-admin/references/views.md +449 -0
  25. package/templates/tanstack-start/AGENTS.md +17 -13
  26. package/templates/tanstack-start/CLAUDE.md +15 -12
  27. package/templates/tanstack-start/README.md +19 -13
  28. package/templates/tanstack-start/env.example +1 -1
  29. package/templates/tanstack-start/package.json +20 -6
  30. package/templates/tanstack-start/src/lib/env.ts +1 -1
  31. package/templates/tanstack-start/src/lib/query-client.ts +10 -1
  32. package/templates/tanstack-start/src/questpie/server/config/admin.ts +27 -30
  33. package/templates/tanstack-start/src/routeTree.gen.ts +138 -0
  34. package/templates/tanstack-start/src/routes/__root.tsx +0 -2
  35. package/templates/tanstack-start/src/routes/admin/$.tsx +12 -1
  36. package/templates/tanstack-start/src/routes/admin/index.tsx +12 -5
  37. package/templates/tanstack-start/src/routes/admin.tsx +8 -1
  38. package/templates/tanstack-start/src/tanstack-start.d.ts +1 -0
  39. package/templates/tanstack-start/src/vite-env.d.ts +1 -0
  40. package/templates/tanstack-start/vite.config.ts +1 -3
@@ -0,0 +1,364 @@
1
+ ---
2
+ name: questpie-core-multi-tenancy
3
+ description: QUESTPIE multi-tenant scope context resolver header-based tenant isolation ScopeProvider ScopePicker request-scoped services data filtering access control workspace organization property
4
+ - questpie-core
5
+ - questpie-core-rules
6
+ - questpie-core-business-logic
7
+ ---
8
+
9
+ # QUESTPIE Multi-Tenancy
10
+
11
+ QUESTPIE supports multi-tenant applications through a **scope-based** architecture. A "scope" can represent anything: organizations, workspaces, properties, cities, brands — any entity that partitions data.
12
+
13
+ The pattern is simple: **HTTP header carries a scope ID, server extracts it into typed context, access rules filter data**.
14
+
15
+ ## Architecture Overview
16
+
17
+ ```text
18
+ Client Server
19
+ ────── ──────
20
+ ScopeProvider context.ts (file convention)
21
+ ↓ stores scopeId ↓ extracts header → typed context
22
+ ScopePicker (UI) appConfig({ context }) (types)
23
+ ↓ user selects scope ↓ available in every handler
24
+ useScopedFetch() access rules / hooks
25
+ ↓ injects HTTP header ↓ filter data by scope
26
+ fetch("x-selected-city: id") AsyncLocalStorage → getContext()
27
+ ```
28
+
29
+ ## Step 1: Define the Scope Collection
30
+
31
+ Create a collection that represents your tenant entity:
32
+
33
+ ```ts
34
+ // collections/workspaces.ts
35
+ import { collection } from "#questpie/factories";
36
+
37
+ export default collection("workspaces").fields(({ f }) => ({
38
+ name: f.text().label("Name").required(),
39
+ slug: f.text().label("Slug").inputOptional(),
40
+ owner: f.relation("user").label("Owner"),
41
+ }));
42
+ ```
43
+
44
+ Other collections reference the scope via a relation:
45
+
46
+ ```ts
47
+ // collections/projects.ts
48
+ import { collection } from "#questpie/factories";
49
+
50
+ export default collection("projects").fields(({ f }) => ({
51
+ title: f.text().label("Title").required(),
52
+ workspace: f.relation("workspaces").label("Workspace").required(),
53
+ }));
54
+ ```
55
+
56
+ ## Step 2: Create the Context Resolver
57
+
58
+ The `context.ts` file convention is a singleton that extracts custom properties from each incoming request. Codegen discovers it automatically.
59
+
60
+ ```ts
61
+ // src/questpie/server/context.ts
62
+ import { context } from "#questpie";
63
+
64
+ export default context(async ({ request, session, db }) => {
65
+ const workspaceId = request.headers.get("x-selected-workspace");
66
+
67
+ // Optional: validate that the user has access to this workspace
68
+ // if (workspaceId && session?.user) {
69
+ // const membership = await db.query.workspaceMembers.findFirst({
70
+ // where: and(
71
+ // eq(workspaceMembers.workspaceId, workspaceId),
72
+ // eq(workspaceMembers.userId, session.user.id),
73
+ // ),
74
+ // });
75
+ // if (!membership) throw new Error("No access to this workspace");
76
+ // }
77
+
78
+ return {
79
+ workspaceId: workspaceId || null,
80
+ };
81
+ });
82
+ ```
83
+
84
+ ### Context Resolver Parameters
85
+
86
+ | Parameter | Type | Description |
87
+ | --------- | --------------------------- | ----------------------------------------------- |
88
+ | `request` | `Request` | The incoming HTTP request (Web API) |
89
+ | `session` | `{ user, session } \| null` | Resolved auth session (null if unauthenticated) |
90
+ | `db` | `Database` | Database client for validation queries |
91
+
92
+ The object you return is merged into the request context and becomes available in **every** handler, hook, and access rule.
93
+
94
+ ## Step 3: Filter Data with Access Rules
95
+
96
+ Use the typed context in access rules and hooks to enforce data isolation:
97
+
98
+ ```ts
99
+ // collections/projects.ts
100
+ import { collection } from "#questpie/factories";
101
+
102
+ export default collection("projects")
103
+ .fields(({ f }) => ({
104
+ title: f.text().label("Title").required(),
105
+ workspace: f.relation("workspaces").label("Workspace").required(),
106
+ }))
107
+ .access({
108
+ // Only allow reads when a workspace is selected
109
+ read: ({ ctx }) => {
110
+ if (!ctx.workspaceId) return false;
111
+ return { workspace: ctx.workspaceId };
112
+ },
113
+ create: ({ ctx }) => !!ctx.workspaceId,
114
+ update: ({ ctx }) => {
115
+ if (!ctx.workspaceId) return false;
116
+ return { workspace: ctx.workspaceId };
117
+ },
118
+ delete: ({ ctx }) => {
119
+ if (!ctx.workspaceId) return false;
120
+ return { workspace: ctx.workspaceId };
121
+ },
122
+ })
123
+ .hooks({
124
+ // Auto-assign workspace on create
125
+ beforeChange: async ({ data, operation, ctx }) => {
126
+ if (operation === "create" && ctx.workspaceId) {
127
+ data.workspace = ctx.workspaceId;
128
+ }
129
+ return data;
130
+ },
131
+ });
132
+ ```
133
+
134
+ ### Access Rule Return Values
135
+
136
+ | Return | Meaning |
137
+ | ------------------------------ | ---------------------------------------- |
138
+ | `true` | Allow all records |
139
+ | `false` | Deny all records |
140
+ | `{ field: value }` | Where-clause filter (row-level security) |
141
+
142
+ ## Step 5: Set Up the Admin UI
143
+
144
+ ### ScopeProvider
145
+
146
+ Wrap your admin with `ScopeProvider` to enable scope selection. It manages the selected scope ID and persists it to localStorage.
147
+
148
+ ```tsx
149
+ // routes/admin/$.tsx
150
+ import {
151
+ AdminLayout,
152
+ AdminRouter,
153
+ ScopePicker,
154
+ ScopeProvider,
155
+ } from "@questpie/admin/client";
156
+
157
+ function AdminPage() {
158
+ return (
159
+ <ScopeProvider
160
+ headerName="x-selected-workspace"
161
+ storageKey="admin-selected-workspace"
162
+ >
163
+ <AdminContent />
164
+ </ScopeProvider>
165
+ );
166
+ }
167
+ ```
168
+
169
+ #### ScopeProvider Props
170
+
171
+ | Prop | Type | Required | Description |
172
+ | -------------- | ---------------- | -------- | --------------------------------- |
173
+ | `headerName` | `string` | Yes | HTTP header name for the scope ID |
174
+ | `storageKey` | `string` | No | localStorage key for persistence |
175
+ | `defaultScope` | `string \| null` | No | Default scope if none stored |
176
+
177
+ ### ScopePicker
178
+
179
+ A dropdown for selecting the current scope. Place it in the sidebar:
180
+
181
+ ```tsx
182
+ function AdminContent() {
183
+ return (
184
+ <AdminLayout
185
+ admin={admin}
186
+ basePath="/admin"
187
+ slots={{
188
+ afterBrand: (
189
+ <div className="px-3 py-2 border-b">
190
+ <ScopePicker
191
+ collection="workspaces"
192
+ labelField="name"
193
+ placeholder="Select workspace..."
194
+ allowClear
195
+ clearText="All Workspaces"
196
+ compact
197
+ />
198
+ </div>
199
+ ),
200
+ }}
201
+ >
202
+ <AdminRouter basePath="/admin" />
203
+ </AdminLayout>
204
+ );
205
+ }
206
+ ```
207
+
208
+ #### ScopePicker Props
209
+
210
+ | Prop | Type | Default | Description |
211
+ | ------------- | ------------------------------ | ------------- | ------------------------------------------ |
212
+ | `collection` | `string` | — | Collection to fetch options from |
213
+ | `labelField` | `string` | `"name"` | Field to display as label |
214
+ | `valueField` | `string` | `"id"` | Field to use as value |
215
+ | `options` | `ScopeOption[]` | — | Static options (alternative to collection) |
216
+ | `loadOptions` | `() => Promise<ScopeOption[]>` | — | Async options loader |
217
+ | `placeholder` | `string` | `"Select..."` | Placeholder text |
218
+ | `allowClear` | `boolean` | `false` | Show "All" option to clear scope |
219
+ | `clearText` | `string` | `"All"` | Label for the clear option |
220
+ | `compact` | `boolean` | `false` | Render smaller (no label) |
221
+
222
+ ### Three Data Sources
223
+
224
+ ```tsx
225
+ // 1. From a collection
226
+ <ScopePicker collection="workspaces" labelField="name" />
227
+
228
+ // 2. Static options
229
+ <ScopePicker options={[
230
+ { value: "ws_1", label: "Workspace 1" },
231
+ { value: "ws_2", label: "Workspace 2" },
232
+ ]} />
233
+
234
+ // 3. Async loader
235
+ <ScopePicker loadOptions={async () => {
236
+ const res = await fetch("/api/my-workspaces");
237
+ return res.json();
238
+ }} />
239
+ ```
240
+
241
+ ### useScopedFetch
242
+
243
+ When you need to create the API client, use `useScopedFetch()` to automatically inject the scope header into all requests:
244
+
245
+ ```tsx
246
+ import { useScopedFetch } from "@questpie/admin/client";
247
+
248
+ function AdminContent() {
249
+ const scopedFetch = useScopedFetch();
250
+
251
+ const client = useMemo(
252
+ () => createClient<typeof app>({ baseURL: "/api", fetch: scopedFetch }),
253
+ [scopedFetch],
254
+ );
255
+
256
+ return <AdminProvider client={client} />;
257
+ }
258
+ ```
259
+
260
+ ### createScopedFetch (Non-React)
261
+
262
+ For use outside React components:
263
+
264
+ ```ts
265
+ import { createScopedFetch } from "@questpie/admin/client";
266
+
267
+ let currentScopeId: string | null = null;
268
+
269
+ const scopedFetch = createScopedFetch(
270
+ "x-selected-workspace",
271
+ () => currentScopeId,
272
+ );
273
+ ```
274
+
275
+ ## Request-Scoped Services
276
+
277
+ For advanced cases, create a request-scoped service that provides a tenant-aware database connection:
278
+
279
+ ```ts
280
+ // services/scoped-db.ts
281
+ import { service } from "questpie";
282
+
283
+ export default service({
284
+ lifecycle: "request",
285
+ deps: ["db", "session"] as const,
286
+ create: ({ db, session }) => {
287
+ return createScopedDb(db, session?.user?.tenantId);
288
+ },
289
+ dispose: (scopedDb) => scopedDb.release(),
290
+ });
291
+ ```
292
+
293
+ ## Full Request Flow
294
+
295
+ ```text
296
+ 1. User selects "Acme Corp" in ScopePicker
297
+ 2. ScopeProvider stores scopeId = "ws_123" in state + localStorage
298
+ 3. useScopedFetch() creates fetch that adds header: x-selected-workspace: ws_123
299
+ 4. Client makes API call → POST /api/collections/projects/find
300
+ 5. Server: createAdapterContext() receives Request
301
+ 6. Server: context.ts resolver extracts workspaceId = "ws_123" from header
302
+ 7. Server: RequestContext created with { workspaceId: "ws_123", session, locale, ... }
303
+ 8. Server: runWithContext() stores in AsyncLocalStorage
304
+ 9. Server: Access rules evaluate → return { workspace: "ws_123" }
305
+ 10. Server: Query filtered to workspace = "ws_123"
306
+ 11. Response: Only Acme Corp's projects returned
307
+ ```
308
+
309
+ ## Common Mistakes
310
+
311
+ ### HIGH: Forgetting module augmentation
312
+
313
+ Without a `context` function in `appConfig()`, your custom context properties won't be available in handlers. Make sure to define `context` in your `config/app.ts`.
314
+
315
+ ### HIGH: Not filtering in access rules
316
+
317
+ The context resolver only **extracts** the scope. You must still enforce isolation in `.access()` rules or `.hooks()`. Without access rules, all data is returned regardless of scope.
318
+
319
+ ### MEDIUM: Hardcoding header names
320
+
321
+ Use the same header name in `ScopeProvider.headerName` and `context.ts`. A mismatch means the server never sees the scope ID.
322
+
323
+ ```ts
324
+ // These MUST match:
325
+ // Client:
326
+ <ScopeProvider headerName="x-selected-workspace" />
327
+
328
+ // Server (context.ts):
329
+ request.headers.get("x-selected-workspace")
330
+ ```
331
+
332
+ ### MEDIUM: Not validating scope access
333
+
334
+ In production, validate that the authenticated user actually belongs to the selected scope. Otherwise any user can access any scope by sending the header manually.
335
+
336
+ ```ts
337
+ export default context(async ({ request, session, db }) => {
338
+ const workspaceId = request.headers.get("x-selected-workspace");
339
+
340
+ if (workspaceId && session?.user) {
341
+ const isMember = await db.query.workspaceMembers.findFirst({
342
+ where: and(
343
+ eq(workspaceMembers.workspaceId, workspaceId),
344
+ eq(workspaceMembers.userId, session.user.id),
345
+ ),
346
+ });
347
+ if (!isMember) {
348
+ throw new Error("Unauthorized access to workspace");
349
+ }
350
+ }
351
+
352
+ return { workspaceId: workspaceId || null };
353
+ });
354
+ ```
355
+
356
+ ## Reference Example
357
+
358
+ See the **city-portal** example for a complete working implementation:
359
+
360
+ ```text
361
+ examples/city-portal/
362
+ src/questpie/server/context.ts # Context resolver (x-selected-city header)
363
+ src/routes/admin/$.tsx # Admin with ScopeProvider + ScopePicker
364
+ ```