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,147 +0,0 @@
|
|
|
1
|
-
# MCP Integration
|
|
2
|
-
|
|
3
|
-
Use `@questpie/mcp` when a QUESTPIE app should expose collections, globals, annotated JSON routes, schemas, or custom tools to Model Context Protocol clients.
|
|
4
|
-
|
|
5
|
-
## Static Module Pattern
|
|
6
|
-
|
|
7
|
-
MCP is codegen-aware. Keep `modules.ts` static and put options in `config/mcp.ts`.
|
|
8
|
-
|
|
9
|
-
```ts title="modules.ts"
|
|
10
|
-
import mcpModule from "@questpie/mcp";
|
|
11
|
-
|
|
12
|
-
export default [mcpModule] as const;
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
```ts title="config/mcp.ts"
|
|
16
|
-
import { mcpConfig } from "@questpie/mcp";
|
|
17
|
-
|
|
18
|
-
export default mcpConfig({
|
|
19
|
-
crud: {
|
|
20
|
-
defaults: {
|
|
21
|
-
collections: { read: true, write: false, delete: false },
|
|
22
|
-
globals: { read: true, write: false },
|
|
23
|
-
},
|
|
24
|
-
collections: {
|
|
25
|
-
posts: { read: true, write: true },
|
|
26
|
-
users: false,
|
|
27
|
-
},
|
|
28
|
-
globals: {
|
|
29
|
-
siteSettings: { read: true, write: true },
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
routes: {
|
|
33
|
-
exposeAnnotated: true,
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
Do not use `mcpModule(options)`. Runtime options belong in the plugin-discovered config file.
|
|
39
|
-
|
|
40
|
-
`mcpModule` carries its codegen plugin. Do not also add `mcpPlugin()` to `questpie.config.ts` unless you are doing a custom setup that deliberately omits `mcpModule` — double registration duplicates the plugin.
|
|
41
|
-
|
|
42
|
-
## CRUD Policy
|
|
43
|
-
|
|
44
|
-
Generated collection tools:
|
|
45
|
-
|
|
46
|
-
- `collections.{name}.list`
|
|
47
|
-
- `collections.{name}.count`
|
|
48
|
-
- `collections.{name}.get`
|
|
49
|
-
- `collections.{name}.create`
|
|
50
|
-
- `collections.{name}.update`
|
|
51
|
-
- `collections.{name}.delete`
|
|
52
|
-
|
|
53
|
-
Generated global tools:
|
|
54
|
-
|
|
55
|
-
- `globals.{name}.get`
|
|
56
|
-
- `globals.{name}.update`
|
|
57
|
-
|
|
58
|
-
Policy order:
|
|
59
|
-
|
|
60
|
-
1. Transport defaults.
|
|
61
|
-
2. CRUD defaults.
|
|
62
|
-
3. Per-entity override.
|
|
63
|
-
4. QUESTPIE access rules execute last and can still deny.
|
|
64
|
-
|
|
65
|
-
HTTP is user mode and read-oriented by default. HTTP cannot be made system mode with config or options. Stdio defaults to trusted system mode unless explicitly lowered to user mode.
|
|
66
|
-
|
|
67
|
-
Use `fields.include` / `fields.exclude` for top-level filtering. It applies to create/update input, CRUD outputs, list docs, global results, and schema resources. Nested relation projection is out of scope for v1.
|
|
68
|
-
|
|
69
|
-
## Route Tools
|
|
70
|
-
|
|
71
|
-
Only simple JSON routes are auto-converted:
|
|
72
|
-
|
|
73
|
-
- Route has `.schema(...)`.
|
|
74
|
-
- Route is not `.raw()`.
|
|
75
|
-
- Route has `meta.mcp.expose === true`.
|
|
76
|
-
- `routes.exposeAnnotated` is not `false`.
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
route()
|
|
80
|
-
.post()
|
|
81
|
-
.schema(inputSchema)
|
|
82
|
-
.outputSchema(outputSchema)
|
|
83
|
-
.meta({
|
|
84
|
-
title: "Generate report",
|
|
85
|
-
mcp: {
|
|
86
|
-
expose: true,
|
|
87
|
-
name: "reports.generate",
|
|
88
|
-
annotations: { readOnlyHint: true },
|
|
89
|
-
},
|
|
90
|
-
})
|
|
91
|
-
.handler(async ({ input }) => ({ ok: true }));
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
Routes without path params use the route input schema directly. Routes with params use `{ params, input }`. Route policy keys use the route key, not the overridden tool name.
|
|
95
|
-
|
|
96
|
-
## Resources
|
|
97
|
-
|
|
98
|
-
Built-in resources:
|
|
99
|
-
|
|
100
|
-
- `questpie://schema/collections`
|
|
101
|
-
- `questpie://schema/collections/{name}`
|
|
102
|
-
- `questpie://schema/globals`
|
|
103
|
-
- `questpie://schema/globals/{name}`
|
|
104
|
-
- `questpie://schema/routes`
|
|
105
|
-
- `questpie://schema/routes/{key}`
|
|
106
|
-
|
|
107
|
-
Resources honor MCP policy and QUESTPIE access visibility. Route resources include input/output JSON Schema when the route has Zod schemas.
|
|
108
|
-
|
|
109
|
-
## Custom Tools
|
|
110
|
-
|
|
111
|
-
Custom tools live in `mcp-tools/` and are discovered by codegen.
|
|
112
|
-
|
|
113
|
-
```ts
|
|
114
|
-
import { mcpTool } from "@questpie/mcp";
|
|
115
|
-
import { z } from "zod";
|
|
116
|
-
|
|
117
|
-
export default mcpTool("generate-report", {
|
|
118
|
-
description: "Generate a report.",
|
|
119
|
-
inputSchema: z.object({ period: z.string() }),
|
|
120
|
-
access: ({ session }) => !!session,
|
|
121
|
-
}).handler(async ({ input, ctx }) => ({
|
|
122
|
-
structuredContent: await ctx.services.reports.generate(input),
|
|
123
|
-
}));
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
Custom tool access is checked during `tools/list` and again during `tools/call`.
|
|
127
|
-
|
|
128
|
-
## Programmatic Servers
|
|
129
|
-
|
|
130
|
-
Use `createMcpServer(app, { transport: "http", request })` for programmatic HTTP setup. If no `ctx` is passed, the request is preserved through `app.createContext()`.
|
|
131
|
-
|
|
132
|
-
Use `startStdioServer(app)` for trusted stdio integrations:
|
|
133
|
-
|
|
134
|
-
```ts
|
|
135
|
-
import { app } from "#questpie";
|
|
136
|
-
import { startStdioServer } from "@questpie/mcp/stdio";
|
|
137
|
-
|
|
138
|
-
await startStdioServer(app);
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
## Gotchas
|
|
142
|
-
|
|
143
|
-
- Add `mcpModule` to static `modules.ts`, then run codegen.
|
|
144
|
-
- HTTP system mode is intentionally impossible until a future trusted-token design exists.
|
|
145
|
-
- Field filtering is top-level only.
|
|
146
|
-
- Raw routes and unannotated routes are not tools.
|
|
147
|
-
- Custom tool results should use `structuredContent` for machine-readable output.
|
|
@@ -1,363 +0,0 @@
|
|
|
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/services";
|
|
282
|
-
export default service({
|
|
283
|
-
lifecycle: "request",
|
|
284
|
-
deps: ["db", "session"] as const,
|
|
285
|
-
create: ({ db, session }) => {
|
|
286
|
-
return createScopedDb(db, session?.user?.tenantId);
|
|
287
|
-
},
|
|
288
|
-
dispose: (scopedDb) => scopedDb.release(),
|
|
289
|
-
});
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
## Full Request Flow
|
|
293
|
-
|
|
294
|
-
```text
|
|
295
|
-
1. User selects "Acme Corp" in ScopePicker
|
|
296
|
-
2. ScopeProvider stores scopeId = "ws_123" in state + localStorage
|
|
297
|
-
3. useScopedFetch() creates fetch that adds header: x-selected-workspace: ws_123
|
|
298
|
-
4. Client makes API call → POST /api/collections/projects/find
|
|
299
|
-
5. Server: createAdapterContext() receives Request
|
|
300
|
-
6. Server: context.ts resolver extracts workspaceId = "ws_123" from header
|
|
301
|
-
7. Server: RequestContext created with { workspaceId: "ws_123", session, locale, ... }
|
|
302
|
-
8. Server: runWithContext() stores in AsyncLocalStorage
|
|
303
|
-
9. Server: Access rules evaluate → return { workspace: "ws_123" }
|
|
304
|
-
10. Server: Query filtered to workspace = "ws_123"
|
|
305
|
-
11. Response: Only Acme Corp's projects returned
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
## Common Mistakes
|
|
309
|
-
|
|
310
|
-
### HIGH: Forgetting module augmentation
|
|
311
|
-
|
|
312
|
-
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`.
|
|
313
|
-
|
|
314
|
-
### HIGH: Not filtering in access rules
|
|
315
|
-
|
|
316
|
-
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.
|
|
317
|
-
|
|
318
|
-
### MEDIUM: Hardcoding header names
|
|
319
|
-
|
|
320
|
-
Use the same header name in `ScopeProvider.headerName` and `context.ts`. A mismatch means the server never sees the scope ID.
|
|
321
|
-
|
|
322
|
-
```ts
|
|
323
|
-
// These MUST match:
|
|
324
|
-
// Client:
|
|
325
|
-
<ScopeProvider headerName="x-selected-workspace" />
|
|
326
|
-
|
|
327
|
-
// Server (context.ts):
|
|
328
|
-
request.headers.get("x-selected-workspace")
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
### MEDIUM: Not validating scope access
|
|
332
|
-
|
|
333
|
-
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.
|
|
334
|
-
|
|
335
|
-
```ts
|
|
336
|
-
export default context(async ({ request, session, db }) => {
|
|
337
|
-
const workspaceId = request.headers.get("x-selected-workspace");
|
|
338
|
-
|
|
339
|
-
if (workspaceId && session?.user) {
|
|
340
|
-
const isMember = await db.query.workspaceMembers.findFirst({
|
|
341
|
-
where: and(
|
|
342
|
-
eq(workspaceMembers.workspaceId, workspaceId),
|
|
343
|
-
eq(workspaceMembers.userId, session.user.id),
|
|
344
|
-
),
|
|
345
|
-
});
|
|
346
|
-
if (!isMember) {
|
|
347
|
-
throw new Error("Unauthorized access to workspace");
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
return { workspaceId: workspaceId || null };
|
|
352
|
-
});
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
## Reference Example
|
|
356
|
-
|
|
357
|
-
See the **city-portal** example for a complete working implementation:
|
|
358
|
-
|
|
359
|
-
```text
|
|
360
|
-
examples/city-portal/
|
|
361
|
-
src/questpie/server/context.ts # Context resolver (x-selected-city header)
|
|
362
|
-
src/routes/admin/$.tsx # Admin with ScopeProvider + ScopePicker
|
|
363
|
-
```
|