Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .agents/skills/btst-client-plugin-dev/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyApiRouter>({ baseURL: apiBaseURL, basePath: apiBasePath })
const { api } = useStack()
const { headers } = usePluginOverrides("my-plugin")
const client = createApiClient<MyApiRouter>({ baseURL: api?.baseURL, basePath: api?.basePath })
const queries = createMyQueryKeys(client, headers)

const { data, refetch, error, isFetching } = useSuspenseQuery({
Expand Down Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions .agents/skills/btst-client-plugin-dev/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyApiRouter>({ baseURL: apiBaseURL, basePath: apiBasePath })
Expand Down
26 changes: 18 additions & 8 deletions .agents/skills/btst-client-plugin-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<StackProvider
basePath="/pages"
router={nextRouter()}
api={{ baseURL, basePath: "/api/data" }}
auth={{ getIdentity, loginPath: "/login" }}
overrides={{ myPlugin: { uploadImage, localization } }}
>
{children}
</StackProvider>
```

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<LinkProps>
Image?: ComponentType<ImageProps>
uploadImage?: (file: File) => Promise<string>
headers?: HeadersInit
localization?: Partial<Localization>
Expand All @@ -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.
Expand Down
38 changes: 14 additions & 24 deletions .agents/skills/btst-integration/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,39 +253,31 @@ 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
// add one entry per plugin
}

export default function PagesLayout({ children }: { children: React.ReactNode }) {
const router = useRouter()
const [queryClient] = useState(() => getOrCreateQueryClient())
const baseURL = getBaseURL()

return (
<QueryClientProvider client={queryClient}>
<StackProvider<PluginOverrides>
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 }) => <Link href={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,
},
}}
>
Expand All @@ -301,28 +293,25 @@ 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 `<Link>` component wrapper |
| `Image` | Optional framework `<Image>` 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)

| Hook | When |
|---|---|
| `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 }`.

Expand All @@ -346,8 +335,9 @@ export default function PagesLayout({ children }: { children: React.ReactNode })
- `searchUsers(query): Promise<User[]>` — assignee search
- `taskDetailBottomSlot: (task) => ReactNode` — inject below task detail (e.g. comments)

**comments** (standalone, not via StackProvider — use `<CommentThread />` directly)
- `currentUserId`, `resourceId`, `resourceType`, `apiBaseURL`, `apiBasePath`, `loginHref`
**comments**
- `resourceLinks` and comment display/editing defaults live in the plugin override.
- `<CommentThread />` receives `resourceId` and `resourceType`; it reads API, identity, and login path from `StackProvider`.

**media**
- `queryClient` — pass the current QueryClient explicitly
Expand Down
7 changes: 5 additions & 2 deletions .agents/skills/btst-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 79 additions & 1 deletion docs/content/docs/breaking-changes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<StackProvider
basePath="/pages"
+ router={nextRouter()}
+ api={{ baseURL, basePath: "/api/data" }}
+ auth={{ getIdentity, loginPath: "/login", can }}
overrides={{
blog: {
- apiBaseURL: baseURL,
- apiBasePath: "/api/data",
- navigate: (path) => 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
<CommentThread
resourceId={post.slug}
resourceType="blog-post"
- apiBaseURL={baseURL}
- apiBasePath="/api/data"
- currentUserId={session?.user?.id}
- loginHref="/login"
- headers={headers}
/>
```

### 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).

<Callout type="info">
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.
</Callout>

### `pageComponents` overrides now receive the route context
Expand Down
3 changes: 1 addition & 2 deletions docs/content/docs/how-it-works.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

5 changes: 2 additions & 3 deletions docs/content/docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -806,8 +806,8 @@ In order to use BTST, your application must meet the following requirements:

<Callout type="info">
**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`)
</Callout>
</Step>
Expand Down Expand Up @@ -1143,4 +1143,3 @@ In order to use BTST, your application must meet the following requirements:

</Step>
</Steps>

Loading