diff --git a/.agents/skills/btst-client-plugin-dev/EXAMPLES.md b/.agents/skills/btst-client-plugin-dev/EXAMPLES.md index a420acf0..7f2ad129 100644 --- a/.agents/skills/btst-client-plugin-dev/EXAMPLES.md +++ b/.agents/skills/btst-client-plugin-dev/EXAMPLES.md @@ -35,15 +35,16 @@ export function MyPageComponent({ id }: { id: string }) { ```typescript import { useSuspenseQuery } from "@tanstack/react-query" -import { usePluginOverrides } from "@btst/stack/context" +import { usePluginOverrides, useStack } from "@btst/stack/context" import { createMyQueryKeys } from "../../query-keys" import { createApiClient } from "@btst/stack/client" import type { MyApiRouter } from "../../api/plugin" import type { MyItem } from "../../api/types" function useMyItem(id: string) { - const { apiBaseURL, apiBasePath, headers, queryClient } = usePluginOverrides("my-plugin") - const client = createApiClient({ baseURL: apiBaseURL, basePath: apiBasePath }) + const { api } = useStack() + const { headers } = usePluginOverrides("my-plugin") + const client = createApiClient({ baseURL: api?.baseURL, basePath: api?.basePath }) const queries = createMyQueryKeys(client, headers) const { data, refetch, error, isFetching } = useSuspenseQuery({ @@ -79,8 +80,8 @@ export function MyPage({ id }: { id: string }) { // In defineClientPlugin config: hooks: { beforeLoadDetail: async (id, ctx) => { - // Return false to prevent loading (e.g. user not authorised) - return true + const session = await getSession(ctx.headers) + if (!session) throw new Error("Authentication required") }, afterLoadDetail: async (item, id, ctx) => { // item is the prefetched data diff --git a/.agents/skills/btst-client-plugin-dev/REFERENCE.md b/.agents/skills/btst-client-plugin-dev/REFERENCE.md index 4f827c18..b4e9754f 100644 --- a/.agents/skills/btst-client-plugin-dev/REFERENCE.md +++ b/.agents/skills/btst-client-plugin-dev/REFERENCE.md @@ -21,8 +21,7 @@ function createMyLoader(id: string, config: MyClientConfig) { try { if (hooks?.beforeLoad) { - const canLoad = await hooks.beforeLoad(id, context) - if (!canLoad) throw new Error("Load prevented by beforeLoad hook") + await hooks.beforeLoad(id, context) } const client = createApiClient({ baseURL: apiBaseURL, basePath: apiBasePath }) diff --git a/.agents/skills/btst-client-plugin-dev/SKILL.md b/.agents/skills/btst-client-plugin-dev/SKILL.md index e5ed85d7..2831a2fb 100644 --- a/.agents/skills/btst-client-plugin-dev/SKILL.md +++ b/.agents/skills/btst-client-plugin-dev/SKILL.md @@ -171,16 +171,26 @@ export function useMyData(id: string) { } ``` -## Client overrides shape +## Provider wiring and client overrides + +Framework-wide values belong on `StackProvider`, not inside plugin overrides: + +```tsx + + {children} + +``` + +Plugin override types contain only plugin-specific customization: ```typescript type PluginOverrides = { - apiBaseURL: string - apiBasePath: string // e.g. "/api/data" - navigate: (path: string) => void - refresh?: () => void - Link: ComponentType - Image?: ComponentType uploadImage?: (file: File) => Promise headers?: HeadersInit localization?: Partial @@ -189,7 +199,7 @@ type PluginOverrides = { ## Gotchas -- **Missing `usePluginOverrides()` config** — client components crash if overrides aren't set in layout. +- **Framework config in plugin overrides** — `Link`, `Image`, navigation, refresh, and client API paths come from the top-level `StackProvider`. - **`staleTime: Infinity`** — use for data that should not auto-refetch. - **Next.js Link href undefined** — use `href={href || "#"}` pattern. - **Suspense errors not caught** — add `if (error && !isFetching) throw error` in every suspense hook. diff --git a/.agents/skills/btst-integration/REFERENCE.md b/.agents/skills/btst-integration/REFERENCE.md index b13c45b5..7f49f307 100644 --- a/.agents/skills/btst-integration/REFERENCE.md +++ b/.agents/skills/btst-integration/REFERENCE.md @@ -253,10 +253,9 @@ The pages layout must be `"use client"` and wrap `QueryClientProvider` then `Sta import { useState } from "react" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" +import { nextRouter } from "@btst/stack/next" import { getOrCreateQueryClient } from "@/lib/query-client" import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" -import Link from "next/link" -import { useRouter } from "next/navigation" type PluginOverrides = { blog: BlogPluginOverrides @@ -264,7 +263,6 @@ type PluginOverrides = { } export default function PagesLayout({ children }: { children: React.ReactNode }) { - const router = useRouter() const [queryClient] = useState(() => getOrCreateQueryClient()) const baseURL = getBaseURL() @@ -272,20 +270,14 @@ export default function PagesLayout({ children }: { children: React.ReactNode }) basePath="/pages" + router={nextRouter()} + api={{ baseURL, basePath: "/api/data" }} overrides={{ blog: { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (path) => router.push(path), - refresh: () => router.refresh(), - Link: ({ href, ...props }) => , - Image: MyImageWrapper, // optional: Next.js Image wrapper uploadImage: myUploadFn, // optional: returns uploaded URL // lifecycle hooks (all optional): onRouteRender: async (routeName, ctx) => { /* analytics, logging */ }, onRouteError: async (routeName, err, ctx) => { /* error tracking */ }, - onBeforePostsPageRendered: (ctx) => true, // return false to block - onBeforePostPageRendered: (slug, ctx) => true, }, }} > @@ -301,20 +293,18 @@ export default function PagesLayout({ children }: { children: React.ReactNode }) | Prop | Required | Description | |---|---|---| | `basePath` | Yes | Must match your `/pages/*` catch-all route prefix | -| `overrides` | Yes | Per-plugin override objects, keyed by plugin name | +| `router` | No | Framework router preset shared by every plugin | +| `api` | No | Client-side API base URL and path shared by every plugin | +| `auth` | No | Identity, login path, and permission provider | +| `overrides` | No | Plugin-specific override objects, keyed by plugin name | -### Common override fields (all data plugins) +### Top-level provider fields | Field | Description | |---|---| -| `apiBaseURL` | Absolute base URL for API fetches | -| `apiBasePath` | API prefix, e.g. `/api/data` | -| `navigate(path)` | Framework navigation function | -| `Link` | Framework `` component wrapper | -| `Image` | Optional framework `` wrapper (important for Next.js) | -| `refresh()` | Optional router refresh (Next.js: `router.refresh()`) | -| `uploadImage(file)` | Optional — returns URL string after upload | -| `headers` | Optional headers for per-request auth | +| `router` | Framework `Link`, `Image`, `navigate`, and `refresh` implementation | +| `api` | Client API `baseURL` and `basePath` | +| `auth` | Identity, login path, and authorization checks | ### Lifecycle hooks (available on most plugins) @@ -322,7 +312,6 @@ export default function PagesLayout({ children }: { children: React.ReactNode }) |---|---| | `onRouteRender(routeName, ctx)` | After a plugin page renders (SSR or CSR) | | `onRouteError(routeName, err, ctx)` | On plugin route render error | -| `onBefore{Page}PageRendered(ctx)` | Before a specific page renders; return `false` to block | `ctx` contains `{ isSSR: boolean, path: string }`. @@ -346,8 +335,9 @@ export default function PagesLayout({ children }: { children: React.ReactNode }) - `searchUsers(query): Promise` — assignee search - `taskDetailBottomSlot: (task) => ReactNode` — inject below task detail (e.g. comments) -**comments** (standalone, not via StackProvider — use `` directly) -- `currentUserId`, `resourceId`, `resourceType`, `apiBaseURL`, `apiBasePath`, `loginHref` +**comments** +- `resourceLinks` and comment display/editing defaults live in the plugin override. +- `` receives `resourceId` and `resourceType`; it reads API, identity, and login path from `StackProvider`. **media** - `queryClient` — pass the current QueryClient explicitly diff --git a/.agents/skills/btst-integration/SKILL.md b/.agents/skills/btst-integration/SKILL.md index e8b75e64..c677cfd6 100644 --- a/.agents/skills/btst-integration/SKILL.md +++ b/.agents/skills/btst-integration/SKILL.md @@ -65,9 +65,12 @@ Do not duplicate — the patcher and manual edits must both be idempotent. - **Pages route**: catch-all at `/pages/*` — resolve via `stackClient.router.getRoute(path)`, run `route.loader?.()` server-side, wrap in `HydrationBoundary`. - **Pages layout** (`"use client"` in Next.js): wrap in `QueryClientProvider` then `StackProvider`: - `basePath="/pages"` (must match your pages catch-all prefix) - - `overrides={{ pluginKey: { apiBaseURL, apiBasePath, navigate, Link, Image?, refresh?, uploadImage?, ...hooks } }}` + - `router={nextRouter()}` / `reactRouter()` / `tanstackRouter()` for framework-wide links, images, navigation, and refresh. + - `api={{ baseURL, basePath: "/api/data" }}` for client-side API calls. + - `auth={{ getIdentity, loginPath }}` when plugins need identity or permissions. + - `overrides={{ pluginKey: { uploadImage?, ...pluginSpecificValues } }}` only for plugin-specific customization. - Define a typed `PluginOverrides` interface importing `{Plugin}Overrides` from each plugin client package. - - See [REFERENCE.md](REFERENCE.md) for the full per-plugin override shape and lifecycle hooks. + - See [REFERENCE.md](REFERENCE.md) for provider wiring and per-plugin override shapes. - **ai-chat plugin only**: wrap the **root layout** (above `StackProvider`) with `PageAIContextProvider`: ```tsx import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context" diff --git a/docs/content/docs/auth.mdx b/docs/content/docs/auth.mdx index 8832032e..448191f3 100644 --- a/docs/content/docs/auth.mdx +++ b/docs/content/docs/auth.mdx @@ -112,7 +112,7 @@ When an auth provider is configured and `can()` denies access: - **Unauthenticated** users are redirected to the provider's `loginPath` (via the top-level `router`'s `navigate`, falling back to `window.location.assign`), with the route's `LoadingComponent` shown in the meantime. - **Authenticated** users get an `Unauthorized` error thrown into the route's ErrorBoundary. -Without a provider, `permission` is ignored and the route renders as before. Existing `onBefore*PageRendered` callbacks keep working unchanged alongside the provider. +Without a provider, `permission` is ignored and the route renders without client-side gating. ## Server-side identity diff --git a/docs/content/docs/breaking-changes.mdx b/docs/content/docs/breaking-changes.mdx index fcbbe228..811be58d 100644 --- a/docs/content/docs/breaking-changes.mdx +++ b/docs/content/docs/breaking-changes.mdx @@ -10,12 +10,90 @@ This page documents breaking changes between major versions and provides migrati --- +## v2 → v3: Single provider wiring path + +BTST v3 removes the per-plugin compatibility path for framework wiring. Configure router, API, and auth once on `StackProvider`; plugin overrides now contain only plugin-specific customization. + +```diff + router.push(path), +- refresh: () => router.refresh(), +- Link, +- Image, + uploadImage, + }, + }} +> +``` + +The `Link`, `Image`, `navigate`, `refresh`, `apiBaseURL`, and `apiBasePath` fields were removed from built-in plugin override types. The `apiBaseURL` and `apiBasePath` fields on each client plugin factory remain required because SSR loaders run outside React Context. + +### Comments use provider API and identity + +`CommentThread`, `CommentCount`, moderation pages, and user-comments pages no longer accept manual API, header, identity, or login props. Render them under the configured provider: + +```diff + +``` + +### Route authorization uses `StackProvider.auth` + +The `onBefore*PageRendered` override callbacks were removed. They only controlled client rendering and were never an API authorization boundary. Built-in protected routes now declare resource/action permissions through `ComposedRoute`; implement `auth.can` once for client-side gating: + +```tsx +const authProvider: StackAuthProvider = { + getIdentity, + loginPath: "/login", + can: ({ resource, action, identity }) => { + if (!identity) return false + if (resource === "comments:comment" && action === "moderate") { + return identity.role === "moderator" || identity.role === "admin" + } + return true + }, +} +``` + +For Comments, the moderation route requests `comments:comment` / `moderate`; per-resource moderation and delete controls use the same provider permissions. The user-comments page and `CommentThread` resolve the current user through `auth.getIdentity` and show their login state when no identity is available. Keep SSR checks in client loader hooks and enforce authorization again in backend lifecycle hooks. + +### Backend hooks deny by throwing + +The compatibility shim for boolean lifecycle-hook returns was removed. Returning `false` no longer denies a request; throw an `Error` from an `onBefore*` hook instead. This includes Form Builder's `onBeforeSubmission` hook: + +```diff +onBeforeSubmission: async (_formSlug, data, ctx) => { +- if (!ctx.headers.get("x-user-id")) return false ++ if (!ctx.headers.get("x-user-id")) throw new Error("Unauthorized") + return data +} +``` + +Hooks that transform input continue to return the transformed object. + +--- + ## v2 → v3: Declarative routes and the `pageComponents` contract BTST v3 adopts the declarative `defineRoute` / `defineRoutes` helpers from `@btst/yar` 1.3+ for all plugin routes, and stabilizes page component identity across re-renders (no more subtree remounts or Suspense re-triggers on parent renders). - The only consumer-facing change is the props passed to `pageComponents` overrides on parameterized routes. Overrides for routes without params, and all other plugin config, are unchanged. + This section covers the route-definition migration. Overrides for routes without params are unchanged. ### `pageComponents` overrides now receive the route context diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index ccc0910b..b51257e9 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -46,7 +46,7 @@ Note: 3rd party plugins may use a different state management library. **Context & Overrides** -The `StackProvider` wraps your pages and injects framework-specific components via React Context. Plugin components access these overrides through `usePluginOverrides()`, allowing them to use your framework's `Link`, `Image`, and navigation without tight coupling and to avoid breaking the client/server boundary in frameworks like Next.js. +The `StackProvider` wraps your pages and exposes framework-wide router, API, and auth services through React Context. Plugin components read router services such as `Link`, `Image`, and `navigate` through `useStack()`, while `usePluginOverrides()` is reserved for genuinely plugin-specific customization. ## Plugins @@ -96,4 +96,3 @@ Framework-specific components injected into plugins at runtime: - **Image**: Use Next.js `Image` for automatic optimization - **navigate**: Programmatic navigation function for your framework - **apiBaseURL/apiBasePath**: Configure where your API is mounted - diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index d0174d04..b236b83a 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -806,8 +806,8 @@ In order to use BTST, your application must meet the following requirements: **Understanding Overrides:** - - **Purpose**: Injects framework-specific components via React Context. Plugin components access these overrides through `usePluginOverrides()` hook, allowing them to use your framework's `Link`, `Image`, and navigation without tight coupling and to avoid breaking the client/server boundary in frameworks like Next.js. - - **Resolution order**: per-plugin `overrides` → top-level `router`/`api` → plugin defaults. Per-plugin `Link`/`navigate`/`Image`/`apiBaseURL` values always take precedence over the router preset, so you can still override framework wiring for a single plugin as an escape hatch. + - **Framework wiring**: `router` supplies `Link`, `Image`, navigation, and refresh to every plugin; `api` supplies the client API location. + - **Purpose**: `overrides` contains only plugin-specific customization such as upload handlers, component slots, localization, and route analytics hooks. - **Type Safety**: Each plugin exports its override type (e.g., `ExamplePluginOverrides`) @@ -1143,4 +1143,3 @@ In order to use BTST, your application must meet the following requirements: - diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx index 978d7f4b..cba2c0d3 100644 --- a/docs/content/docs/plugins/ai-chat.mdx +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -115,7 +115,7 @@ export const getStackClient = (queryClient: QueryClient, options?: { headers?: H - `queryClient`: React Query client instance -**Why configure API paths here?** This configuration is used by **server-side loaders** that prefetch data before your pages render. These loaders run outside of React Context, so they need direct configuration. You'll also provide `apiBaseURL` and `apiBasePath` again in the Provider overrides (Section 4) for **client-side components** that run during actual rendering. +**Why configure API paths here?** This configuration is used by **server-side loaders** that run outside React Context. Client-side components use the top-level `StackProvider.api` configuration shown in Section 4. ### 3. Import Plugin CSS @@ -128,9 +128,9 @@ Add the AI Chat plugin CSS to your global stylesheet: This includes all necessary styles for the chat components and markdown rendering. -### 4. Add Context Overrides +### 4. Add the Context Provider -Configure framework-specific overrides in your `StackProvider`: +Configure framework-wide router and API wiring on `StackProvider`; keep only AI Chat-specific values in `overrides`: @@ -138,11 +138,9 @@ Configure framework-specific overrides in your `StackProvider`: "use client" import { useState } from "react" import { StackProvider } from "@btst/stack/context" + import { nextRouter } from "@btst/stack/next" import { QueryClientProvider } from "@tanstack/react-query" import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" - import Link from "next/link" - import Image from "next/image" - import { useRouter } from "next/navigation" import { getOrCreateQueryClient } from "@/lib/query-client" const getBaseURL = () => @@ -155,7 +153,6 @@ Configure framework-specific overrides in your `StackProvider`: } export default function Layout({ children }) { - const router = useRouter() const [queryClient] = useState(() => getOrCreateQueryClient()) const baseURL = getBaseURL() @@ -163,19 +160,15 @@ Configure framework-specific overrides in your `StackProvider`: basePath="/pages" + router={nextRouter()} + api={{ baseURL, basePath: "/api/data" }} overrides={{ "ai-chat": { mode: "authenticated", // Should match backend config - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (path) => router.push(path), - refresh: () => router.refresh(), uploadFile: async (file) => { // Implement your file upload logic return "https://example.com/uploads/file.pdf" }, - Link: ({ href, ...props }) => , - Image: (props) => , } }} > @@ -190,8 +183,9 @@ Configure framework-specific overrides in your `StackProvider`: ```tsx title="app/routes/pages/_layout.tsx" import { useState } from "react" - import { Outlet, Link, useNavigate } from "react-router" + import { Outlet } from "react-router" import { StackProvider } from "@btst/stack/context" + import { reactRouter } from "@btst/stack/react-router" import { QueryClientProvider, QueryClient } from "@tanstack/react-query" import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" @@ -205,7 +199,6 @@ Configure framework-specific overrides in your `StackProvider`: } export default function Layout() { - const navigate = useNavigate() const [queryClient] = useState(() => new QueryClient()) const baseURL = getBaseURL() @@ -213,20 +206,14 @@ Configure framework-specific overrides in your `StackProvider`: basePath="/pages" + router={reactRouter()} + api={{ baseURL, basePath: "/api/data" }} overrides={{ "ai-chat": { mode: "authenticated", - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (href) => navigate(href), uploadFile: async (file) => { return "https://example.com/uploads/file.pdf" }, - Link: ({ href, children, className, ...props }) => ( - - {children} - - ), } }} > @@ -242,9 +229,10 @@ Configure framework-specific overrides in your `StackProvider`: ```tsx title="src/routes/pages/route.tsx" import { useState } from "react" import { StackProvider } from "@btst/stack/context" + import { tanstackRouter } from "@btst/stack/tanstack" import { QueryClientProvider, QueryClient } from "@tanstack/react-query" import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" - import { Link, useRouter, Outlet } from "@tanstack/react-router" + import { Outlet } from "@tanstack/react-router" const getBaseURL = () => typeof window !== 'undefined' @@ -256,7 +244,6 @@ Configure framework-specific overrides in your `StackProvider`: } function Layout() { - const router = useRouter() const [queryClient] = useState(() => new QueryClient()) const baseURL = getBaseURL() @@ -264,20 +251,14 @@ Configure framework-specific overrides in your `StackProvider`: basePath="/pages" + router={tanstackRouter()} + api={{ baseURL, basePath: "/api/data" }} overrides={{ "ai-chat": { mode: "authenticated", - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (href) => router.navigate({ href }), uploadFile: async (file) => { return "https://example.com/uploads/file.pdf" }, - Link: ({ href, children, className, ...props }) => ( - - {children} - - ), } }} > @@ -290,26 +271,14 @@ Configure framework-specific overrides in your `StackProvider`: -**Required overrides:** -- `apiBaseURL`: Base URL for API calls (used by client-side components during rendering) -- `apiBasePath`: Path where your API is mounted -- `navigate`: Function for programmatic navigation - **Optional overrides:** - `mode`: Plugin mode (`"authenticated"` or `"public"`) - `uploadFile`: Function to upload files and return their URL - `allowedFileTypes`: Array of allowed file type categories (default: all types) - `chatSuggestions`: Array of suggested prompts shown in empty chat state -- `Link`: Custom Link component (defaults to `` tag) -- `Image`: Custom Image component (useful for Next.js Image optimization) -- `refresh`: Function to refresh server-side cache (useful for Next.js) - `localization`: Custom localization strings - `headers`: Headers to pass with API requests - -**Why provide API paths again?** You already configured these in Section 2, but that configuration is only available to **server-side loaders**. The overrides here provide the same values to **client-side components** (like hooks, forms, and UI) via React Context. These two contexts serve different phases: loaders prefetch data server-side before rendering, while components use data during actual rendering (both SSR and CSR). - - ### 5. Generate Database Schema After adding the plugin, generate your database schema using the CLI: @@ -564,7 +533,7 @@ aiChat: aiChatClientPlugin({ #### AiChatPluginOverrides -Configure framework-specific overrides and route lifecycle hooks. All lifecycle hooks are optional: +Configure AI Chat-specific overrides and route lifecycle hooks. All lifecycle hooks are optional: @@ -573,10 +542,6 @@ Configure framework-specific overrides and route lifecycle hooks. All lifecycle ```tsx overrides={{ "ai-chat": { - // Required overrides - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (path) => router.push(path), // Optional overrides mode: "authenticated", uploadFile: async (file) => { @@ -598,15 +563,6 @@ overrides={{ getWeather: WeatherCard, searchDocs: SearchResultsRenderer, }, - // Optional lifecycle hooks - onBeforeChatPageRendered: (context) => { - // Check if user can view chat. Useful for SPA. - // Throw to deny: throw new Error("Unauthorized") - }, - onBeforeConversationPageRendered: (id, context) => { - // Check if user can view this specific conversation. - // Throw to deny: throw new Error("Unauthorized") - }, } }} ``` @@ -629,8 +585,6 @@ The default widget mode manages its own open/close state and renders a floating ```tsx @@ -643,8 +597,6 @@ Use `defaultOpen` and `showTrigger={false}` when your own UI handles opening and ```tsx {/* Rendered inside a modal/dialog that you control */} router.back()}> {/* Modal card */}
e.stopPropagation()}> - + {/* Panel is pre-opened; no trigger button rendered */} basePath="/pages" + router={nextRouter()} + api={{ baseURL, basePath: "/api/data" }} overrides={{ "ai-chat": { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (path) => router.push(path), // Custom tool renderers toolRenderers: { getWeather: WeatherCard, @@ -1033,18 +980,17 @@ aiChat: aiChatClientPlugin({ }) ``` -### Context Overrides +### Provider Configuration ```tsx -overrides={{ - "ai-chat": { - mode: "public", - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (path) => router.push(path), - // No uploadFile needed in public mode typically - } -}} + + {children} + ``` @@ -1061,8 +1007,6 @@ By default, public mode is completely stateless - messages are lost on page refr import { ChatLayout, type UIMessage } from "@btst/stack/plugins/ai-chat/client"; import { useLocalStorage } from "@/hooks/useLocalStorage"; // Your hook -const baseURL = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"; - export default function PublicChat() { const [messages, setMessages] = useLocalStorage( "public-chat-messages", @@ -1071,8 +1015,6 @@ export default function PublicChat() { return ( { - `queryClient`: React Query client instance -**Why configure API paths here?** This configuration is used by **server-side loaders** that prefetch data before your pages render. These loaders run outside of React Context, so they need direct configuration. You'll also provide `apiBaseURL` and `apiBasePath` again in the Provider overrides (Section 4) for **client-side components** that run during actual rendering. +**Why configure API paths here?** This configuration is used by **server-side loaders** that run outside React Context. Client-side components use the top-level `StackProvider.api` configuration shown in Section 4. ### 3. Import Plugin CSS @@ -120,18 +120,16 @@ Add the blog plugin CSS to your global stylesheet: This includes all necessary styles for the blog components, markdown rendering, and editor. -### 4. Add Context Overrides +### 4. Add the Context Provider -Configure framework-specific overrides in your `StackProvider`: +Configure framework-wide router and API wiring on `StackProvider`; keep only Blog-specific values in `overrides`: ```tsx title="app/pages/[[...all]]/layout.tsx" import { StackProvider } from "@btst/stack/context" + import { nextRouter } from "@btst/stack/next" import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" - import Link from "next/link" - import Image from "next/image" - import { useRouter } from "next/navigation" const getBaseURL = () => typeof window !== 'undefined' @@ -143,25 +141,20 @@ Configure framework-specific overrides in your `StackProvider`: } export default function Layout({ children }) { - const router = useRouter() const baseURL = getBaseURL() return ( basePath="/pages" + router={nextRouter()} + api={{ baseURL, basePath: "/api/data" }} overrides={{ blog: { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (path) => router.push(path), - refresh: () => router.refresh(), uploadImage: async (file) => { // Implement your image upload logic // Return the URL of the uploaded image return "https://example.com/uploads/image.jpg" }, - Link: (props) => , - Image: (props) => , } }} > @@ -174,8 +167,9 @@ Configure framework-specific overrides in your `StackProvider`: ```tsx title="app/routes/pages/_layout.tsx" - import { Outlet, Link, useNavigate } from "react-router" + import { Outlet } from "react-router" import { StackProvider } from "@btst/stack/context" + import { reactRouter } from "@btst/stack/react-router" import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" const getBaseURL = () => @@ -188,26 +182,19 @@ Configure framework-specific overrides in your `StackProvider`: } export default function Layout() { - const navigate = useNavigate() const baseURL = getBaseURL() return ( basePath="/pages" + router={reactRouter()} + api={{ baseURL, basePath: "/api/data" }} overrides={{ blog: { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (href) => navigate(href), uploadImage: async (file) => { // Implement your image upload logic return "https://example.com/uploads/image.jpg" }, - Link: ({ href, children, className, ...props }) => ( - - {children} - - ), } }} > @@ -221,8 +208,9 @@ Configure framework-specific overrides in your `StackProvider`: ```tsx title="src/routes/pages/route.tsx" import { StackProvider } from "@btst/stack/context" + import { tanstackRouter } from "@btst/stack/tanstack" import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" - import { Link, useRouter, Outlet } from "@tanstack/react-router" + import { Outlet } from "@tanstack/react-router" const getBaseURL = () => typeof window !== 'undefined' @@ -234,26 +222,19 @@ Configure framework-specific overrides in your `StackProvider`: } function Layout() { - const router = useRouter() const baseURL = getBaseURL() return ( basePath="/pages" + router={tanstackRouter()} + api={{ baseURL, basePath: "/api/data" }} overrides={{ blog: { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (href) => router.navigate({ href }), uploadImage: async (file) => { // Implement your image upload logic return "https://example.com/uploads/image.jpg" }, - Link: ({ href, children, className, ...props }) => ( - - {children} - - ), } }} > @@ -266,22 +247,12 @@ Configure framework-specific overrides in your `StackProvider`: **Required overrides:** -- `apiBaseURL`: Base URL for API calls (used by client-side components during rendering) -- `apiBasePath`: Path where your API is mounted -- `navigate`: Function for programmatic navigation - `uploadImage`: Function to upload images and return their URL **Optional overrides:** -- `Link`: Custom Link component (defaults to `` tag) -- `Image`: Custom Image component (useful for Next.js Image optimization) -- `refresh`: Function to refresh server-side cache (useful for Next.js) - `localization`: Custom localization strings - `showAttribution`: Whether to show BTST attribution - -**Why provide API paths again?** You already configured these in Section 2, but that configuration is only available to **server-side loaders**. The overrides here provide the same values to **client-side components** (like hooks, forms, and UI) via React Context. These two contexts serve different phases: loaders prefetch data server-side before rendering, while components use data during actual rendering (both SSR and CSR). - - ### 5. Generate Database Schema After adding the plugin, generate your database schema using the CLI: @@ -482,7 +453,7 @@ blog: blogClientPlugin({ #### BlogPluginOverrides -Configure framework-specific overrides and route lifecycle hooks. All lifecycle hooks are optional: +Configure Blog-specific components, slots, localization, and route lifecycle hooks. All lifecycle hooks are optional: @@ -491,20 +462,13 @@ Configure framework-specific overrides and route lifecycle hooks. All lifecycle ```tsx overrides={{ blog: { - // Required overrides - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (path) => router.push(path), uploadImage: async (file) => { // Implement your image upload logic return "https://example.com/uploads/image.jpg" }, - // Optional lifecycle hooks - onBeforePostsPageRendered: (context) => { - // Check if user can view posts list. Helpful for SPA; not needed for SSR (check auth in the loader instead). - // Throw to deny: throw new Error("Unauthorized") + onRouteRender: (routeName, context) => { + // Track page views }, - // ... other hooks } }} ``` @@ -525,10 +489,6 @@ overrides={{ ), } diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index c6e847d0..d7fcf333 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -166,12 +166,13 @@ export const getStackClient = (queryClient: QueryClient, options?: { headers?: H } ``` -### 4. Configure Provider Overrides +### 4. Configure the Provider -Add CMS overrides to your layout: +Configure framework-wide router/API wiring at the top level and keep only CMS-specific values in its override: ```tsx title="app/pages/layout.tsx" import type { CMSPluginOverrides } from "@btst/stack/plugins/cms/client" +import { nextRouter } from "@btst/stack/next" type PluginOverrides = { cms: CMSPluginOverrides, @@ -179,17 +180,14 @@ type PluginOverrides = { basePath="/pages" + router={nextRouter()} + api={{ baseURL, basePath: "/api/data" }} overrides={{ cms: { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - navigate: (path) => router.push(path), - refresh: () => router.refresh(), uploadImage: async (file) => { // Your image upload logic return "https://example.com/image.png" }, - Link: ({ href, ...props }) => , } }} > @@ -638,7 +636,7 @@ The CMS plugin exposes these REST endpoints: ## Authorization & Lifecycle Hooks -The CMS plugin provides two levels of hooks for authorization: +Use loader hooks for SSR authorization and the top-level auth provider for client-side route permissions: ### Client Hooks (SSR Authorization) @@ -657,15 +655,18 @@ cms: cmsClientPlugin({ hooks: { beforeLoadDashboard: async (context) => { const session = await getSession(context.headers) - return session?.user?.isAdmin === true + if (session?.user?.isAdmin !== true) + throw new Error("Admin access required") }, beforeLoadContentList: async (typeSlug, context) => { const session = await getSession(context.headers) - return session?.user?.isAdmin === true + if (session?.user?.isAdmin !== true) + throw new Error("Admin access required") }, beforeLoadContentEditor: async (typeSlug, id, context) => { const session = await getSession(context.headers) - return session?.user?.isAdmin === true + if (session?.user?.isAdmin !== true) + throw new Error("Admin access required") }, onLoadError: (error, context) => { // Redirect to login on authorization failure @@ -676,38 +677,28 @@ cms: cmsClientPlugin({ ``` -**Use client hooks for SSR.** These hooks run during server-side data loading and support async operations like session checks. The `onLoadError` hook is called when any `beforeLoad*` hook returns `false`, allowing you to redirect unauthorized users. +**Use client hooks for SSR.** These hooks run during server-side data loading and support async operations like session checks. Throw from a `beforeLoad*` hook to deny access; `onLoadError` can then redirect unauthorized users. -### Override Hooks (Client-Side) +### Auth Provider (Client-Side) -Use lifecycle hooks in `StackProvider` overrides for **synchronous** client-side checks (SPA navigation): +Built-in CMS routes declare their required resource/action permission. Supply `auth.can` once on `StackProvider` to enforce those gates during client navigation: ```tsx title="app/pages/layout.tsx" -cms: { - // ...required overrides - onBeforeDashboardRendered: (context) => { - // Sync check - runs during component render - if (user?.isAdmin !== true) throw new Error("Admin access required") - }, - onBeforeListRendered: (typeSlug, context) => { - // Throw to deny: throw new Error("Unauthorized") - }, - onBeforeEditorRendered: (typeSlug, id, context) => { - // id is null for new items - // Throw to deny: throw new Error("Unauthorized") - }, - onRouteRender: (routeName, context) => { - // Track page views - }, - onRouteError: (routeName, error, context) => { - // Log errors - }, -} + session?.user ?? null, + can: ({ resource, action }) => canUser(session?.user, resource, action), + loginPath: "/auth/sign-in", + }} + // router, api, overrides, ... +> + {children} + ``` - -**Override hooks are synchronous.** They run during component render and cannot await async operations. For SSR authorization with session checks, use the client hooks above. + +Use `onRouteRender` and `onRouteError` override hooks for analytics and error reporting. Authorization belongs in loader/backend hooks and `StackProvider.auth`. ## Custom Field Components @@ -1233,14 +1224,15 @@ cms: cmsClientPlugin({ hooks: { beforeLoadDashboard: async (context) => { const session = await getSession(context.headers) - return session?.user?.isAdmin === true + if (session?.user?.isAdmin !== true) + throw new Error("Admin access required") }, beforeLoadContentList: async (typeSlug, context) => { // Check per-content-type permissions - return isAdmin(context.headers) + if (!await isAdmin(context.headers)) throw new Error("Unauthorized") }, beforeLoadContentEditor: async (typeSlug, id, context) => { - return isAdmin(context.headers) + if (!await isAdmin(context.headers)) throw new Error("Unauthorized") }, onLoadError(error, context) { // Redirect on auth failure @@ -1256,7 +1248,7 @@ cms: cmsClientPlugin({ #### CMSPluginOverrides -Configure framework-specific overrides and route lifecycle hooks: +Configure CMS-specific overrides and route lifecycle hooks: diff --git a/docs/content/docs/plugins/comments.mdx b/docs/content/docs/plugins/comments.mdx index ce9d4374..59a7836a 100644 --- a/docs/content/docs/plugins/comments.mdx +++ b/docs/content/docs/plugins/comments.mdx @@ -165,80 +165,38 @@ export const getStackClient = (queryClient: QueryClient) => { -### 4. Configure Overrides +### 4. Configure the Provider -Add comments overrides to your layout file. You must also register the `CommentsPluginOverrides` type: +Comments read API, identity, login path, and permissions from `StackProvider`. Use the plugin override only for comments-specific behavior: - - ```tsx title="app/pages/layout.tsx" import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" type PluginOverrides = { - // ... existing plugins comments: CommentsPluginOverrides } -// Inside your StackProvider overrides: -overrides={{ - comments: { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - - // Access control for admin routes - onBeforeModerationPageRendered: async (context) => { - const session = await getSession() - if (!session?.user?.isAdmin) throw new Error("Admin access required") - }, - } -}} -``` - - -```tsx title="app/routes/pages/_layout.tsx" -import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" - -type PluginOverrides = { - // ... existing plugins - comments: CommentsPluginOverrides -} - -// Inside your StackProvider overrides: -overrides={{ - comments: { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - onBeforeModerationPageRendered: async (context) => { - const session = await getSession() - if (!session?.user?.isAdmin) throw new Error("Admin access required") - }, - } -}} -``` - - -```tsx title="src/routes/pages/route.tsx" -import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" - -type PluginOverrides = { - // ... existing plugins - comments: CommentsPluginOverrides -} - -// Inside your StackProvider overrides: -overrides={{ - comments: { - apiBaseURL: baseURL, - apiBasePath: "/api/data", - onBeforeModerationPageRendered: async (context) => { - const session = await getSession() - if (!session?.user?.isAdmin) throw new Error("Admin access required") + + basePath="/pages" + router={frameworkRouter} + api={{ baseURL, basePath: "/api/data" }} + auth={{ + getIdentity: async () => (await getSession())?.user ?? null, + loginPath: "/login", + can: ({ resource, action }) => + resource !== "comments:comment" || action !== "moderate" || isAdmin(), + }} + overrides={{ + comments: { + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + }, }, - } -}} + }} +> + {children} + ``` - - ## Embedding Comments @@ -266,10 +224,6 @@ import { CommentThread } from "@btst/stack/plugins/comments/client/components" |------|------|----------|-------------| | `resourceId` | `string` | ✓ | Identifier for the resource (e.g. post slug, task ID) | | `resourceType` | `string` | ✓ | Type of resource (`"blog-post"`, `"kanban-task"`, etc.) | -| `apiBaseURL` | `string` | — | Explicit base URL override; defaults to `StackProvider.api.baseURL` | -| `apiBasePath` | `string` | — | Explicit API path override; defaults to `StackProvider.api.basePath` | -| `currentUserId` | `string` | — | Explicit identity override; defaults to `StackProvider.auth` identity | -| `loginHref` | `string` | — | Explicit login URL override; defaults to `StackProvider.auth.loginPath` | | `pageSize` | `number` | — | Comments per page. Falls back to `defaultCommentPageSize` from overrides, then 100. A "Load more" button appears when there are additional pages. | | `sort` | `"asc" \| "desc"` | — | Sort direction for top-level comments by `createdAt`. Defaults to `defaultCommentSort` from overrides, then `"desc"` (newest first). Replies inside each thread always render chronologically and are unaffected. | | `components.Input` | `ComponentType` | — | Custom input component (default: `