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,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
|
+
```
|