diff --git a/.agents/skills/btst-backend-plugin-dev/REFERENCE.md b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md index 983e9cae6..f4f1e70e8 100644 --- a/.agents/skills/btst-backend-plugin-dev/REFERENCE.md +++ b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md @@ -3,85 +3,45 @@ ## defineBackendPlugin shape (api/plugin.ts) ```typescript -import type { DBAdapter as Adapter } from "@btst/db" -import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api" -import { z } from "zod" -import { dbSchema } from "./db-schema" -import { listItems, getItemById } from "./getters" -import { createItem, updateItem, deleteItem } from "./mutations" - -const ItemQuerySchema = z.object({ limit: z.coerce.number().optional() }) -const CreateItemSchema = z.object({ name: z.string() }) - -export const myBackendPlugin = defineBackendPlugin({ - name: "my-plugin", - dbPlugin: dbSchema, - - // api factory — bound to shared adapter, no HTTP context - api: (adapter: Adapter) => ({ - listItems: () => listItems(adapter), - getItemById: (id: string) => getItemById(adapter, id), - createItem: (data: CreateItemInput) => createItem(adapter, data), - }), - - // routes factory — HTTP endpoints built with createEndpoint - routes: (adapter: Adapter) => { - const listItemsEndpoint = createEndpoint( - "/items", - { method: "GET", query: ItemQuerySchema }, - async (ctx) => { - return await listItems(adapter) - }, - ) - - const createItemEndpoint = createEndpoint( - "/items", - { method: "POST", body: CreateItemSchema }, - async (ctx) => { - return await createItem(adapter, ctx.body) - }, - ) - - const getItemEndpoint = createEndpoint( - "/items/:id", - { method: "GET" }, - async (ctx) => { - const item = await getItemById(adapter, ctx.params.id) - if (!item) throw ctx.error(404, { message: "Item not found" }) - return item - }, - ) - - return { listItems: listItemsEndpoint, createItem: createItemEndpoint, getItem: getItemEndpoint } as const - }, -}) - -// Router type for client consumption -export type MyApiRouter = ReturnType["routes"]> -``` - -### ctx object inside createEndpoint handlers +/** Configuration accepted by `myBackendPlugin`. */ +export interface MyBackendPluginOptions { + /** Lifecycle callbacks composed around plugin operations. */ + hooks?: MyBackendHooks +} -| Property | Description | -|---|---| -| `ctx.query` | Validated query params (when `query:` schema provided) | -| `ctx.body` | Validated request body (when `body:` schema provided) | -| `ctx.params` | URL path params (e.g. `:id` → `ctx.params.id`) | -| `ctx.headers` | Request `Headers` object | -| `ctx.request` | Raw `Request` object | -| `ctx.error(status, { message })` | Create an HTTP error — always `throw` the result | +export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => + defineBackendPlugin({ + id: "myPlugin", + dbPlugin: dbSchema, + operations: (adapter) => createMyOperations(adapter, options.hooks), + raw: (adapter) => ({ + prefetchForRoute: createItemPrefetchForRoute(adapter), + }), + routes: (_adapter, _context, operations) => { + const createItem = createEndpoint( + "/items", + { method: "POST", body: CreateItemSchema, requireRequest: true }, + operations.createItem.route((ctx) => ctx.body), + ) + return { createItem } as const + }, + }) ---- +/** Inferred router contract imported by the client plugin. */ +export type MyApiRouter = ReturnType< + ReturnType["routes"] +> +``` ## getters.ts -Pure DB functions — no HTTP context, no lifecycle hooks, always accept `adapter` as first arg: +Lower-level DB functions — no HTTP context or lifecycle composition, always accept `adapter` as first arg: ```typescript import type { DBAdapter as Adapter } from "@btst/db" import type { Item } from "./types" -// Authorization hooks are NOT called — callers are responsible for access control +// Lower-level primitive: callers own validation and lifecycle composition. export async function listItems(adapter: Adapter): Promise { return adapter.findMany({ model: "item" }) } @@ -95,7 +55,7 @@ export async function getItemById(adapter: Adapter, id: string): Promise { return adapter.create({ @@ -114,7 +74,7 @@ export async function createItem(adapter: Adapter, data: CreateItemInput): Promi /** * Update an existing item. - * Authorization hooks are NOT called — caller is responsible for access control. + * Lower-level primitive: caller owns validation and lifecycle composition. */ export async function updateItem( adapter: Adapter, @@ -126,7 +86,7 @@ export async function updateItem( /** * Delete an item. - * Authorization hooks are NOT called — caller is responsible for access control. + * Lower-level primitive: caller owns validation and lifecycle composition. */ export async function deleteItem(adapter: Adapter, id: string): Promise { await adapter.delete({ model: "item", where: { id } }) @@ -140,10 +100,10 @@ export async function deleteItem(adapter: Adapter, id: string): Promise { Re-export getters and mutations for direct server-side import (SSG, scripts, AI tools): ```typescript -// Getters — read-only, no auth hooks +// Getters — read-only lower-level primitives export { listItems, getItemById } from "./getters" -// Mutations — write ops, no auth hooks +// Mutations — write lower-level primitives export { createItem, updateItem, deleteItem } from "./mutations" // Types for consumers @@ -154,52 +114,26 @@ export { serializeItem } from "./serializers" --- -## Lifecycle hook implementation in routes +## Lifecycle hooks in operations -```typescript -routes: (adapter: Adapter) => { - const createItemEndpoint = createEndpoint( - "/items", - { method: "POST", body: CreateItemSchema }, - async (ctx) => { - const context = { body: ctx.body, headers: ctx.headers } - - // before hook — throw to deny - await ctx.hooks?.onBeforeItemCreated?.(ctx.body, context) - - const item = await createItem(adapter, ctx.body) - - // after hook — fire and forget or await - await ctx.hooks?.onAfterItemCreated?.(item, context) - - return item - }, - ) - - return { createItem: createItemEndpoint } as const -}, -``` - -Hook naming always follows: `onBefore{Entity}{Action}`, `onAfter{Entity}{Action}`, `on{Entity}{Action}Error`. - ---- +Invoke domain hooks from the operation lifecycle after authorization. Hooks can enforce domain invariants, publish side effects, and observe errors; they are not the authorization policy. -## Plugin stack() wiring (in stack.ts) +## Plugin `createBackendStack()` wiring (in stack.ts) ```typescript -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { myBackendPlugin } from "./src/plugins/my-plugin/api/plugin" -export const myStack = stack({ +export const myStack = createBackendStack({ basePath: "/api/data", plugins: { - myPlugin: myBackendPlugin, + myPlugin: myBackendPlugin(), }, adapter: (db) => createDrizzleAdapter(schema, db, {}), }) export const { handler, dbSchema } = myStack -// Direct server-side access (bypasses auth hooks): -const items = await myStack.api.myPlugin.listItems() +// Explicitly trusted business access keeps validation and lifecycle hooks: +const item = await myStack.trusted.myPlugin.createItem({ name: "Scheduled item" }) ``` diff --git a/.agents/skills/btst-backend-plugin-dev/SKILL.md b/.agents/skills/btst-backend-plugin-dev/SKILL.md index d3df52646..5d3fd57f3 100644 --- a/.agents/skills/btst-backend-plugin-dev/SKILL.md +++ b/.agents/skills/btst-backend-plugin-dev/SKILL.md @@ -1,6 +1,6 @@ --- name: btst-backend-plugin-dev -description: Patterns for writing BTST backend plugins inside the monorepo, including defineBackendPlugin structure, getters.ts/mutations.ts separation, the api factory, lifecycle hook naming conventions, and accessing the adapter in AI tool execute functions. Use when creating or modifying a backend plugin, adding DB getters or mutations, wiring the api factory, or implementing lifecycle hooks in src/plugins/{name}/api/. +description: Patterns for writing BTST backend plugins inside the monorepo, including operation-first authorization, getters.ts/mutations.ts separation, lifecycle hooks, and narrow SSG raw factories. Use when creating or modifying a backend plugin, adding DB getters or mutations, operations, permission descriptors, or lifecycle hooks in src/plugins/{name}/api/. --- # BTST Backend Plugin Development @@ -12,7 +12,8 @@ src/plugins/{name}/ api/ plugin.ts ← defineBackendPlugin entry getters.ts ← read-only DB functions (no HTTP context) - mutations.ts ← write DB functions (no auth hooks) + mutations.ts ← lower-level write DB functions + operations.ts ← validated operations, permission facts, lifecycle index.ts ← re-exports getters + mutations + types query-keys.ts ← React Query key factory ``` @@ -20,11 +21,12 @@ src/plugins/{name}/ ## Rules - **`getters.ts`** — pure async DB functions only. No HTTP context, no lifecycle hooks. Always takes `adapter` as first arg. -- **`mutations.ts`** — write operations (create/update/delete). No auth hooks, no HTTP context. Add JSDoc: "Authorization hooks are NOT called." +- **`mutations.ts`** — lower-level write primitives. No HTTP context or lifecycle composition. Document that callers own validation and lifecycle. - **`api/index.ts`** — re-export everything from getters + mutations for direct server-side import. -- The `api` factory and `routes` factory share the same adapter instance — bind getters inside the factory, don't pass adapter at call site. -- If the plugin has a one-time init step (e.g. `syncContentTypes`), call it inside each getter/mutation wrapper — not only inside `routes`. -- **Never** use `myStack.api.*` as a substitute for authenticated HTTP endpoints — auth hooks are not called. +- **`operations.ts`** — define the one maintained business inventory with input validation, exact permission descriptors, authoritative facts, domain execution, and lifecycle hooks. +- Bind HTTP routes to same-key operations. Use `operationRouteMap` only for real route-name mismatches. +- The optional `raw` factory is narrow: first-party plugins expose only `prefetchForRoute` for SSG. Do not duplicate business getters or mutations on `createBackendStack().raw`. +- Use `myStack.forRequest(request).operations.*` for request work and `myStack.trusted.*` for explicitly trusted jobs. ## Key patterns @@ -38,30 +40,37 @@ src/plugins/{name}/ ## Lifecycle hook naming -Pattern: `onBefore{Entity}{Action}`, `onAfter{Entity}{Action}`, `on{Entity}{Action}Error` +Pattern: `onBefore{Action}{Entity}`, `onAfter{Action}{Entity}`, `onError{Action}{Entity}` ```typescript -// Examples from existing plugins: -onBeforeListPosts, onPostsRead, onListPostsError -onBeforeCreatePost, onPostCreated, onCreatePostError -onBeforeUpdatePost, onPostUpdated, onUpdatePostError -onBeforeDeletePost, onPostDeleted, onDeletePostError -onBeforePost, onAfterPost // comments plugin (create comment) -onBeforeEdit, onAfterEdit // comments plugin (edit comment) -onBeforeDelete, onAfterDelete // comments plugin (delete comment) -onBeforeStatusChange, onAfterApprove +onBeforeListPosts, onAfterListPosts, onErrorListPosts +onBeforeCreatePost, onAfterCreatePost, onErrorCreatePost +onBeforeUpdatePost, onAfterUpdatePost, onErrorUpdatePost +onBeforeDeletePost, onAfterDeletePost, onErrorDeletePost + +// Preserve meaningful domain events instead of inventing CRUD phases. +onBeforeChat, onAfterChat, onErrorChat +onBeforeActivateTools ``` -## Adapter in AI tool execute functions +Normalize names without adding lifecycle phases that the plugin does not +already support. + +## Trusted operations in AI tool execute functions `myStack` is a module-level const. The `execute` closure runs lazily (only on HTTP request), so `myStack` is always initialised by then: ```typescript -export const myStack = stack({ ... }) +import { createBackendStack } from "@btst/stack/api" + +export const myStack = createBackendStack({ ... }) const myTool = tool({ execute: async (params) => { - await createKanbanTask(myStack.adapter, { title: params.title, columnId: "col-id" }) + await myStack.trusted.kanban.createTask({ + title: params.title, + columnId: "col-id", + }) return { success: true } } }) @@ -73,8 +82,8 @@ const myTool = tool({ - **Wrong adapter type** — use `import type { DBAdapter as Adapter } from "@btst/db"` in getters/mutations/plugin files. - **`"GET /path"` string keys** — routes use `createEndpoint()`, not string-keyed method/path objects. - **`ctx.json()`** — does not exist; return data directly from route handlers. -- **`stack().api` bypasses auth hooks** — never use for authenticated data access; enforce auth at the call site. -- **Plugin init not called via `api`** — if `routes` factory runs a setup (e.g. `syncContentTypes`), also await it inside each `api` getter wrapper. +- **Business methods on `createBackendStack().raw`** — do not add them. Keep the composed lifecycle explicit through `forRequest(request).operations` or `trusted`, and reserve `raw` for narrow lower-level/SSG helpers. +- **Authorization in lifecycle hooks** — routine access control belongs in operation descriptors and the one shared rule. Hooks receive already-authorized context. - **Write ops in `getters.ts`** — write functions belong in `mutations.ts`, not `getters.ts`. ## Full code patterns diff --git a/.agents/skills/btst-build-config/SKILL.md b/.agents/skills/btst-build-config/SKILL.md index 1ea77943f..b255984a4 100644 --- a/.agents/skills/btst-build-config/SKILL.md +++ b/.agents/skills/btst-build-config/SKILL.md @@ -61,12 +61,15 @@ The `postbuild.cjs` script auto-discovers and copies them — no manual registra ## Updating all three codegen projects -When adding a new plugin or changing plugin config, update ALL three: +When adding a plugin, changing plugin config, or changing a framework entry +point, update ALL three generated projects: **Next.js** (`codegen-projects/nextjs/`) - `lib/stack.ts` — backend plugin registration - `lib/stack-client.tsx` — client plugin registration -- `app/pages/layout.tsx` — override configuration +- `app/pages/client-layout.tsx` — shared client provider and overrides +- `app/(request)/pages/layout.tsx` — trusted request-origin hydration +- `app/(static)/pages/layout.tsx` — header-free SSG/ISR provider wrapper - `app/globals.css` — `@import "@btst/stack/plugins/{name}/css";` **React Router** (`codegen-projects/react-router/`) @@ -81,17 +84,47 @@ When adding a new plugin or changing plugin config, update ALL three: - `src/routes/pages/route.tsx` - `src/styles.css` -### Override type registration (in each layout) +Keep generated framework routes on the v3 entry factories: + +| Framework | API route | Page route | Provider router | +|---|---|---|---| +| Next.js | `toNextRouteHandlers` | `createNextPage` | `nextRouter()` | +| React Router | `toReactRouterHandlers` | `createReactRouterPage` | `reactRouter()` | +| TanStack Start | `toTanStackHandlers` | `createTanStackPageOptions` | `tanstackRouter()` | + +Do not generate hand-written route resolution, loader/meta ordering, +dehydration, or 404 plumbing. The entry factories own that behavior. + +Generated layouts pass the resolved client stack to `StackProvider`: + +```tsx + + {children} + +``` -```typescript -import type { YourPluginOverrides } from "@btst/stack/plugins/{name}/client" +Only plugin-specific values belong in `overrides`. Never generate `Link`, +`Image`, `navigate`, `refresh`, API paths, identity, or login values inside a +built-in plugin override. Configure `api`, `site`, and `queryClient` once in +`createClientStack()`. Resolved client definitions such as Blog and AI Chat +accept only plugin-specific options; their loaders and metadata receive shared +runtime through the stack resolver. -type PluginOverrides = { - blog: BlogPluginOverrides, - "ai-chat": AiChatPluginOverrides, - "{name}": YourPluginOverrides, // add here -} -``` +### Override type inference + +Resolved definitions contribute their public override type automatically under +their canonical programmatic ID (`blog`, `aiChat`). Register the definition in +`createClientStack({ plugins })` and pass that resolved stack to +`StackProvider`; never recreate a manual application override map or provider +generic. Do not restore removed framework, API, guard, or identity fields in +local intersection types. ## Adding shared UI components (@workspace/ui) @@ -122,3 +155,8 @@ pnpm turbo clean && pnpm build - **Build cache** — run `pnpm turbo clean` if changes aren't reflected in codegen projects after `pnpm build`. - **CSS not loading** — ensure `"./plugins/{name}/css"` entry exists in `package.json` exports; `postbuild.cjs` handles the rest automatically. - **`@workspace/ui` sub-path components** — if a new component imports from a directory (not a single file), add it to `EXTERNAL_REGISTRY_COMPONENTS` in `build-registry.ts`. +- **Stale v2 templates** — generated routes must use framework entry factories. + Generated layouts configure `api`, `site`, and `queryClient` once in + `createClientStack()`, then pass the resolved stack to `StackProvider`. + Only provider-owned services such as `router` and `auth` remain alongside the + `stack` prop. diff --git a/.agents/skills/btst-client-plugin-dev/EXAMPLES.md b/.agents/skills/btst-client-plugin-dev/EXAMPLES.md index a420acf03..85b9a58fc 100644 --- a/.agents/skills/btst-client-plugin-dev/EXAMPLES.md +++ b/.agents/skills/btst-client-plugin-dev/EXAMPLES.md @@ -34,28 +34,20 @@ export function MyPageComponent({ id }: { id: string }) { ### my-page.internal.tsx (actual UI) ```typescript -import { useSuspenseQuery } from "@tanstack/react-query" -import { usePluginOverrides } 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" +import { createResource } from "@btst/stack/plugins/client/hooks" +import { myResources } from "../../query-keys" +import { MY_PLUGIN_ID } from "../../constants" -function useMyItem(id: string) { - const { apiBaseURL, apiBasePath, headers, queryClient } = usePluginOverrides("my-plugin") - const client = createApiClient({ baseURL: apiBaseURL, basePath: apiBasePath }) - const queries = createMyQueryKeys(client, headers) - - const { data, refetch, error, isFetching } = useSuspenseQuery({ - ...queries.items.detail(id), - staleTime: 60_000, - retry: false, - }) +// Reuse the definition's id so hooks cannot drift from the registered runtime. +// The resolved stack supplies its browser-safe endpoint and QueryClient. +const my = createResource({ + plugin: MY_PLUGIN_ID, + resources: myResources, +}) - // useSuspenseQuery only throws on initial fetch — manually re-throw for refetch errors - if (error && !isFetching) throw error - - return { data: data as MyItem, refetch } +function useMyItem(id: string) { + const { data, refetch } = my.items.detail.useSuspense([id]) + return { data, refetch } } export function MyPage({ id }: { id: string }) { @@ -76,17 +68,17 @@ export function MyPage({ id }: { id: string }) { ## Client hooks (lifecycle) example ```typescript -// In defineClientPlugin config: +// In the config passed to myClientPlugin(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 analytics.track("item_viewed", { id }) }, - onLoadError: async (error, ctx) => { + onErrorLoad: async (error, ctx) => { Sentry.captureException(error, { extra: { path: ctx.path } }) }, } diff --git a/.agents/skills/btst-client-plugin-dev/REFERENCE.md b/.agents/skills/btst-client-plugin-dev/REFERENCE.md index 4f827c18f..9e3977a57 100644 --- a/.agents/skills/btst-client-plugin-dev/REFERENCE.md +++ b/.agents/skills/btst-client-plugin-dev/REFERENCE.md @@ -5,7 +5,7 @@ ```typescript import { isConnectionError } from "@btst/stack/plugins/client" -function createMyLoader(id: string, config: MyClientConfig) { +function createMyLoader(id: string, config: ResolvedMyClientConfig) { return async () => { if (typeof window === "undefined") { const { queryClient, apiBasePath, apiBaseURL, hooks, headers } = config @@ -21,11 +21,15 @@ 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 }) + const client = createApiClient({ + baseURL: apiBaseURL, + basePath: apiBasePath, + headers, + credentials: config.credentials, + }) const queries = createMyQueryKeys(client, headers) await queryClient.prefetchQuery(queries.items.detail(id)) @@ -36,18 +40,18 @@ function createMyLoader(id: string, config: MyClientConfig) { } const queryState = queryClient.getQueryState(queries.items.detail(id).queryKey) - if (queryState?.error && hooks?.onLoadError) { + if (queryState?.error && hooks?.onErrorLoad) { const error = queryState.error instanceof Error ? queryState.error : new Error(String(queryState.error)) - await hooks.onLoadError(error, context) + await hooks.onErrorLoad(error, context) } } catch (error) { if (isConnectionError(error)) { - console.warn("[btst/my-plugin] route.loader() failed — no server at build time. Use myStack.api.myPlugin.prefetchForRoute() for SSG.") + console.warn("[btst/my-plugin] route.loader() failed — no server at build time. Use myStack.raw.myPlugin.prefetchForRoute() for SSG.") } - if (hooks?.onLoadError) { - await hooks.onLoadError(error as Error, context) + if (hooks?.onErrorLoad) { + await hooks.onErrorLoad(error as Error, context) } // Never re-throw — let React Query store errors for ErrorBoundary } @@ -61,7 +65,7 @@ function createMyLoader(id: string, config: MyClientConfig) { ## Meta generator (createMyMeta) ```typescript -function createMyMeta(id: string, config: MyClientConfig) { +function createMyMeta(id: string, config: ResolvedMyClientConfig) { return () => { const { queryClient, apiBaseURL, apiBasePath, siteBaseURL, siteBasePath, seo } = config @@ -92,36 +96,89 @@ function createMyMeta(id: string, config: MyClientConfig) { --- -## Query Keys Factory (query-keys.ts) +## Resource declaration and query keys (query-keys.ts) ```typescript -import { mergeQueryKeys, createQueryKeys } from "@lukemorales/query-key-factory" -import { createApiClient } from "@btst/stack/client" -import type { MyApiRouter } from "./api/plugin" - -export function createMyQueryKeys(client: ReturnType>, headers?: HeadersInit) { - return mergeQueryKeys( - createQueryKeys("myPlugin", { - list: () => ({ - queryKey: ["list"], - queryFn: async () => client.items.list({ headers }), - }), - detail: (id: string) => ({ - queryKey: [id], - queryFn: async () => client.items.get(id, { headers }), - }), - }) - ) +import { + createResourceQueryKeys, + type ResourceClient, + type ResourcesDeclaration, +} from "@btst/stack/plugins/client" + +export const myResources = { + items: { + queries: { + list: { + path: "/items", + select: (data: any) => data?.items ?? [], + }, + detail: { + path: "/items", + query: (id: string) => ({ id }), + key: (id: string) => [id], + select: (data: any) => data?.item ?? null, + }, + }, + }, +} satisfies ResourcesDeclaration + +export function createMyQueryKeys(client: ResourceClient, headers?: HeadersInit) { + return createResourceQueryKeys(client, myResources, headers) } ``` --- +## Programmatic id (client/constants.ts) + +```typescript +export const MY_PLUGIN_ID = "myPlugin" as const +``` + ## defineClientPlugin shape (client/plugin.tsx) ```typescript -import { defineClientPlugin, createRoute } from "@btst/stack/plugins" +import { + defineClientPlugin, + defineRoute, + defineRoutes, +} from "@btst/stack/plugins/client" +import type { ResolvedClientPluginRuntime } from "@btst/stack/plugins/client" +import type { QueryClient } from "@tanstack/react-query" import { lazy } from "react" +import { MY_PLUGIN_ID } from "./constants" + +export interface MyClientConfig { + hooks?: MyClientHooks + seo?: MySeoConfig +} + +interface ResolvedMyClientConfig extends MyClientConfig { + queryClient: QueryClient + apiBaseURL: string + apiBasePath: string + siteBaseURL: string + siteBasePath: string + headers?: Headers + credentials?: RequestCredentials +} + +function resolveMyClientConfig( + config: MyClientConfig, + runtime: ResolvedClientPluginRuntime, +): ResolvedMyClientConfig { + return { + hooks: config.hooks, + seo: config.seo, + queryClient: runtime.queryClient, + apiBaseURL: runtime.api.baseURL, + apiBasePath: runtime.api.basePath, + siteBaseURL: runtime.site.baseURL, + siteBasePath: runtime.site.basePath, + headers: runtime.api.headers, + credentials: runtime.api.credentials, + } +} const ListPage = lazy(() => import("./components/pages/list-page").then(m => ({ default: m.ListPageComponent })) @@ -130,29 +187,35 @@ const DetailPage = lazy(() => import("./components/pages/detail-page").then(m => ({ default: m.DetailPageComponent })) ) -export const myClientPlugin = defineClientPlugin({ - name: "my-plugin", - config: (overrides) => ({ - queryClient: overrides.queryClient, - apiBaseURL: overrides.apiBaseURL, - apiBasePath: overrides.apiBasePath, - siteBaseURL: overrides.siteBaseURL, - siteBasePath: overrides.siteBasePath, - hooks: overrides.hooks, - headers: overrides.headers, - seo: overrides.seo, - }), - routes: (config) => ({ - list: createRoute("/my-plugin", () => ({ - PageComponent: () => , - loader: createListLoader(config), - meta: createListMeta(config), - })), - detail: createRoute("/my-plugin/:id", ({ params }) => ({ - PageComponent: () => , - loader: createDetailLoader(params.id, config), - meta: createDetailMeta(params.id, config), - })), - }), -}) +function createResolvedMyPlugin(config: ResolvedMyClientConfig) { + return { + routes: () => + defineRoutes({ + list: defineRoute("/my-plugin", { + page: ListPage, + loader: createListLoader(config), + meta: createListMeta(config), + }), + detail: defineRoute("/my-plugin/:id", { + page: ({ params }) => , + loader: ({ params }) => createDetailLoader(params.id, config)(), + meta: ({ params }) => createDetailMeta(params.id, config)(), + }), + }), + } +} + +export const myClientPlugin = (config: MyClientConfig = {}) => + defineClientPlugin()({ + id: MY_PLUGIN_ID, + resolve: (runtime) => + createResolvedMyPlugin(resolveMyClientConfig(config, runtime)), + }) ``` + +`MyClientConfig` contains only plugin-specific choices. API, site, QueryClient, +headers, and credentials arrive through `resolve(runtime)` from the enclosing +client stack. Construct the resolved config from an explicit allowlist so +removed transport fields cannot survive through JavaScript or `any` callers. +Use the same exported literal id in the definition and every `createResource()` +call so runtime lookup cannot drift from registration. diff --git a/.agents/skills/btst-client-plugin-dev/SKILL.md b/.agents/skills/btst-client-plugin-dev/SKILL.md index 5e10f7b99..7b9dd2a7e 100644 --- a/.agents/skills/btst-client-plugin-dev/SKILL.md +++ b/.agents/skills/btst-client-plugin-dev/SKILL.md @@ -10,19 +10,85 @@ description: Patterns for writing BTST client plugins inside the monorepo, inclu ``` src/plugins/{name}/ client/ + constants.ts ← one literal programmatic plugin id plugin.tsx ← defineClientPlugin entry hooks.ts ← "use client" React hooks only components/ pages/ my-page.tsx ← wrapper: ComposedRoute + lazy import my-page.internal.tsx ← actual UI: useSuspenseQuery - query-keys.ts ← React Query key factory + query-keys.ts ← resource declaration + query key factory ``` +## Data hooks: use `createResource` (new plugins) + +Don't hand-write the useQuery/useMutation + `isErrorResponse`/`toError` plumbing. +Declare the plugin's resources once in `query-keys.ts` and generate everything: + +```typescript +// query-keys.ts (server-safe — no React) +import { createResourceQueryKeys, type ResourcesDeclaration } from "@btst/stack/plugins/client"; + +export const myResources = { + posts: { + queries: { + list: { path: "/posts", query: (p?: ListParams) => ({...}), key: (p?: ListParams) => [discriminator(p)], + select: (d: any): Item[] => d?.items ?? [], infinite: true, pageSize: (p?: ListParams) => p?.limit ?? 10 }, + detail: { path: "/posts", query: (slug: string) => ({ slug, limit: 1 }), key: (slug: string) => [slug], + select: (d: any): Item | null => d?.items?.[0] ?? null, skip: (slug: string) => !slug }, + }, + mutations: { + create: { path: "@post/posts", method: "POST", input: (vars: CreateInput) => ({ body: vars }), + select: (d: any) => d as Item | null, invalidates: ["posts.list"], + setData: { query: "detail", args: (r: Item | null) => (r?.slug ? [r.slug] : null) } }, + }, + }, +} satisfies ResourcesDeclaration; + +// SSR loaders keep using the same factory (keys match @lukemorales shapes) +export function createMyQueryKeys(client, headers?: HeadersInit) { + return createResourceQueryKeys(client, myResources, headers); +} +``` + +```typescript +// client/hooks.ts ("use client") +import { createResource } from "@btst/stack/plugins/client/hooks"; +import { myResources } from "../query-keys"; +import { MY_PLUGIN_ID } from "./constants"; + +const my = createResource({ plugin: MY_PLUGIN_ID, resources: myResources }); + +export const usePosts = (params?: ListParams) => my.posts.list.useInfinite([params]); +export const useSuspensePost = (slug: string) => my.posts.detail.useSuspense([slug]); +export const useCreatePost = () => my.posts.create.use(); +``` + +Generated per query: `use(args, { enabled? })`, `useSuspense(args)` (plain) or +`useInfinite`/`useSuspenseInfinite` (when `infinite: true`). Suspense variants re-throw +refetch errors automatically. Mutations get `use()` with declarative `invalidates` +(`"resource"` or `"resource.query"` prefixes), optional `setData` cache seeding, and an +awaited `refresh()` after invalidation. Errors are normalized to `StackError` +(`statusCode`, field-level `errors` from Zod issues). + +Each resource also exposes: + +- `my.posts.useForm({ action, id?, record?, defaults?, toCreateVars?, toUpdateVars?, successMessage?, errorMessage?, redirect?, onSuccess? })` + — create/edit lifecycle: fetches the record for edit, runs the right mutation, + notifies via `useNotify()`, redirects via the router adapter, and exposes + `fieldErrors` (map server Zod issues onto react-hook-form with `setError`). +- `my.posts.useSelect({ searchArgs, getOptionValue, getOptionLabel, value?, preload? })` + — debounced server-side search + current-value preloading for relation pickers. + +`SHARED_QUERY_CONFIG`, `isErrorResponse`, `toError`, and `StackError` live in +`@btst/stack/plugins/client` — never copy them into a plugin. The blog plugin +(`src/plugins/blog/query-keys.ts`, `client/hooks/blog-hooks.tsx`) is the reference +consumer. + ## Server/client module boundary `client/plugin.tsx` must stay import-safe on the server. Next.js (including SSG build) -can execute `createStackClient()` on the server, which calls each `*ClientPlugin()` +can execute `createClientStack()` on the server, which resolves each `*ClientPlugin()` factory. If that module is marked `"use client"` or imports a client-only module, build can fail with "Attempted to call ... from the server". @@ -32,20 +98,24 @@ Rules: - Keep `client/plugin.tsx` free of React hooks (`useState`, `useEffect`, etc.). - Put hook utilities in a separate client-only module (`client/hooks.ts`) with `"use client"`, and re-export them from `client/index.ts`. +- `createResource` comes from the client-only entry `@btst/stack/plugins/client/hooks`; + the server-safe `@btst/stack/plugins/client` entry only exposes + `createResourceQueryKeys` + declaration types for `query-keys.ts` and loaders. - UI components can remain client components as needed; only the plugin factory entry must stay server-import-safe. ## Route anatomy -Each route returns exactly three things: +Use `defineRoute` / `defineRoutes` for new routes. The `page`, `loader`, and +`meta` handlers on a parameterized route each receive the route context: ```typescript -routes: (config) => ({ - myRoute: createRoute("/path/:id", ({ params }) => ({ - PageComponent: () => , - loader: createMyLoader(params.id, config), // SSR only - meta: createMyMeta(params.id, config), // SEO tags - })), +routes: () => defineRoutes({ + myRoute: defineRoute("/path/:id", { + page: ({ params }) => , + loader: ({ params }) => createMyLoader(params.id, config)(), // SSR only + meta: ({ params }) => createMyMeta(params.id, config)(), // SEO tags + }), }) ``` @@ -64,7 +134,7 @@ const MyPage = lazy(() => - Only execute inside `if (typeof window === "undefined")` guard - **Never throw** — store errors in React Query, let ErrorBoundary catch during render -- Call `beforeLoad` / `afterLoad` / `onLoadError` hooks +- Call `beforeLoad` / `afterLoad` / `onErrorLoad` hooks - Use `queryClient.prefetchQuery()` to seed data - Import `isConnectionError` from `@btst/stack/plugins/client` and warn on build-time failure @@ -104,25 +174,43 @@ export function useMyData(id: string) { } ``` -## Client overrides shape +## Provider wiring and client overrides + +Shared API, site, and QueryClient values belong on the resolved client stack, +not inside plugin options or provider 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 } ``` +The plugin factory receives only plugin-specific choices such as SEO and +loader hooks. Its `resolve(runtime)` callback receives API, site, QueryClient, +headers, and credentials from `createClientStack()`. Keep factory options +independent from `StackProvider` overrides; never add a `config(overrides)` +adapter that copies provider fields back into the plugin. + ## Gotchas -- **Missing `usePluginOverrides()` config** — client components crash if overrides aren't set in layout. +- **Framework config in plugin overrides** — `Link`, `Image`, navigation, and refresh come from `StackProvider`; API paths come from the resolved client stack. +- **Building plugin config from overrides** — plugin factory config is created + in `getStackClient(queryClient)`; provider overrides are browser-runtime + customization only. - **`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 b13c45b5a..4404a2905 100644 --- a/.agents/skills/btst-integration/REFERENCE.md +++ b/.agents/skills/btst-integration/REFERENCE.md @@ -3,41 +3,48 @@ ## lib/stack.ts shape ```ts -import { stack } from "@btst/stack" -import { createDrizzleAdapter } from "@btst/adapter-drizzle" // or prisma / kysely / mongodb / memory +import { createBackendStack } from "@btst/stack/api" +import { createMemoryAdapter } from "@btst/adapter-memory" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" +import { openai } from "@ai-sdk/openai" +import { serverAuth } from "./authorization.server" // import more plugins… -// Memory adapter + Next.js: pin to globalThis to share one instance across API and page bundles -const g = global as typeof global & { __btst__?: ReturnType } +function createAppStack() { + return createBackendStack({ + basePath: "/api/data", + plugins: { + blog: blogBackendPlugin({ + hooks: { + // optional domain hooks + onAfterCreatePost: async (post) => { /* revalidate, notify */ }, + }, + }), + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + systemPrompt: "You are a helpful assistant.", + access: "authorized", + }), + // add more plugins… + }, + adapter: (db) => createMemoryAdapter(db)({}), + auth: serverAuth, + }) +} -export const myStack = g.__btst__ ??= stack({ - basePath: "/api/data", - plugins: { - blog: blogBackendPlugin({ - // optional hooks — throw to deny - onBeforeCreatePost: async (data) => { /* auth check */ }, - onPostCreated: async (post) => { /* revalidate, notify */ }, - }), - aiChat: aiChatBackendPlugin({ - model: openai("gpt-4o"), - systemPrompt: "You are a helpful assistant.", - mode: "authenticated", - getUserId: async (ctx) => ctx.headers?.get("x-user-id") ?? null, - }), - // add more plugins… - }, - adapter: (db) => createDrizzleAdapter(schema, db, {}), - // For memory adapter: adapter: (db) => createMemoryAdapter(db)({}) -}) +// Memory adapter + Next.js: pin the exact app type across API and page bundles. +type AppStack = ReturnType +const g = globalThis as typeof globalThis & { __btst__?: AppStack } +export const myStack = g.__btst__ ??= createAppStack() export const { handler, dbSchema } = myStack ``` **Rules:** -- For any real DB adapter (Drizzle, Prisma, Kysely, MongoDB), just call `stack()` at module level — no `globalThis` needed. +- For any real DB adapter (Drizzle, Prisma, Kysely, MongoDB), call the typed `createBackendStack()` factory at module level — no `globalThis` needed. - Only pin to `globalThis` when using `@btst/adapter-memory` in Next.js. +- `access: "authorized"` requires a bound `serverAuth`. Omitting `createBackendStack({ auth })` intentionally preserves permissive compatibility and does not protect operations. --- @@ -70,222 +77,408 @@ export function getOrCreateQueryClient() { **Next.js** (`app/api/data/[[...all]]/route.ts`): ```ts -import { myStack } from "@/lib/stack" +import { toNextRouteHandlers } from "@btst/stack/next" +import { handler } from "@/lib/stack" -export const { GET, POST, PUT, PATCH, DELETE } = myStack.handler +export const { GET, POST, PUT, PATCH, DELETE } = + toNextRouteHandlers(handler) ``` -**React Router v7** (`app/routes/api.data.$.ts`): +**React Router v7** (`app/routes/api/data/$.ts`): ```ts -import { myStack } from "~/lib/stack" -import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router" +import { toReactRouterHandlers } from "@btst/stack/react-router" +import { handler } from "~/lib/stack" -export async function loader({ request }: LoaderFunctionArgs) { - return myStack.handler(request) -} -export async function action({ request }: ActionFunctionArgs) { - return myStack.handler(request) -} +const handlers = toReactRouterHandlers(handler) +export const loader = handlers.loader +export const action = handlers.action ``` **TanStack Start** (`src/routes/api/data/$.ts`): ```ts import { createFileRoute } from "@tanstack/react-router" -import { handler } from "~/lib/stack" +import { toTanStackHandlers } from "@btst/stack/tanstack" +import { handler } from "@/lib/stack" export const Route = createFileRoute("/api/data/$")({ - server: { - handlers: { - GET: async ({ request }) => handler(request), - POST: async ({ request }) => handler(request), - PUT: async ({ request }) => handler(request), - PATCH: async ({ request }) => handler(request), - DELETE: async ({ request }) => handler(request), - }, - }, + server: { handlers: toTanStackHandlers(handler) }, }) ``` - --- ## Pages catch-all route -**Next.js** (`app/pages/[[...all]]/page.tsx`): +**Next.js** (`app/(request)/pages/[[...all]]/page.tsx`): ```tsx -import { notFound } from "next/navigation" +import { createNextPage } from "@btst/stack/next" import { headers } from "next/headers" -import { HydrationBoundary, dehydrate } from "@tanstack/react-query" -import { normalizePath } from "@btst/stack/client" import { getOrCreateQueryClient } from "@/lib/query-client" -import { getStackClient } from "@/lib/stack-client" - -export default async function Page({ params }: { params: Promise<{ all?: string[] }> }) { - const headersList = await headers() - const headersObj = new Headers() - headersList.forEach((value, key) => headersObj.set(key, value)) +import { getStackClientForRequest } from "@/lib/stack-client.server" - const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient, { headers: headersObj }) - const route = stackClient.router.getRoute(normalizePath((await params).all)) - - if (!route) notFound() - if (route.loader) await route.loader() - - return ( - - - - ) -} +export const dynamic = "force-dynamic" +const page = createNextPage({ + getStackClient: async (queryClient) => + getStackClientForRequest(queryClient, { + headers: new Headers(await headers()), + }), + getQueryClient: getOrCreateQueryClient, +}) +export default page.Page +export const generateMetadata = page.generateMetadata ``` ---- +**React Router v7** (`app/routes/pages/$.tsx`): -## getBaseURL helper +```tsx +import { createReactRouterPage } from "@btst/stack/react-router" +import { getOrCreateQueryClient } from "~/lib/query-client" +import { getStackClient } from "~/lib/stack-client" +import { getStackClientForRequest } from "~/lib/stack-client.server" + +const page = createReactRouterPage({ + getStackClient, + getQueryClient: getOrCreateQueryClient, +}) +export const loader = page.createLoader((queryClient, { request }) => + getStackClientForRequest(queryClient, { + headers: request.headers, + requestOrigin: new URL(request.url).origin, + }), +) +export const meta = page.meta +export const ErrorBoundary = page.ErrorBoundary +export default page.Component +``` -A server/client-safe URL helper — required for `apiBaseURL` in every plugin config and override. +**TanStack Start** (`src/routes/pages/$.tsx`): -```ts -// Next.js -const getBaseURL = () => - typeof window !== "undefined" - ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:3000") - -// Vite (React Router / TanStack) -const getBaseURL = () => - typeof window !== "undefined" - ? (import.meta.env.VITE_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:5173") +```tsx +import { createFileRoute } from "@tanstack/react-router" +import { createTanStackPageOptions } from "@btst/stack/tanstack" +import type { QueryClient } from "@tanstack/react-query" +import { createIsomorphicFn } from "@tanstack/react-start" +import { getRequest } from "@tanstack/react-start/server" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { getStackClientForRequest } from "@/lib/stack-client.server" +import { getTrustedClientOrigins } from "@/lib/stack-client.origins" + +const getLoaderRequestContext = createIsomorphicFn() + .server(() => { + const request = getRequest() + return { + headers: request.headers, + requestOrigin: new URL(request.url).origin, + } + }) + .client(() => undefined) + +const getNavigationClientStack = async (queryClient: QueryClient) => + getStackClient(queryClient, await getTrustedClientOrigins()) + +export const Route = createFileRoute("/pages/$")( + createTanStackPageOptions({ + getStackClient, + getLoaderStackClient: async (queryClient) => { + const requestContext = await getLoaderRequestContext() + return requestContext + ? getStackClientForRequest(queryClient, requestContext) + : getNavigationClientStack(queryClient) + }, + getQueryClient: getOrCreateQueryClient, + }), +) ``` +The entry factories own route matching, loader-before-meta ordering, +dehydration, and framework 404 behavior. Do not duplicate that plumbing in +consumer routes. + --- ## lib/stack-client.tsx shape ```tsx -import { createStackClient } from "@btst/stack/client" +import { + createClientStack, + type ClientPluginEndpointOverride, +} from "@btst/stack/client" import { blogClientPlugin } from "@btst/stack/plugins/blog/client" -import { QueryClient } from "@tanstack/react-query" - -const getBaseURL = () => /* see above */ +import type { QueryClient } from "@tanstack/react-query" + +/** Browser-safe origins resolved once by the server layout. */ +export interface StackClientOptions { + /** Trusted destination for browser API requests. */ + apiOrigin?: string + /** Trusted public origin used to build application links. */ + siteOrigin?: string +} -export const getStackClient = ( +export function createAppClientStack( queryClient: QueryClient, - options?: { headers?: Headers } -) => { - const baseURL = getBaseURL() - return createStackClient({ + options?: StackClientOptions & { headers?: HeadersInit }, +) { + const siteOrigin = getSiteOrigin(options?.siteOrigin) + const apiOrigin = getApiOrigin(options?.apiOrigin, siteOrigin) + const crossOriginBlogEndpoint = getCrossOriginBlogEndpoint( + apiOrigin, + siteOrigin, + ) + + return createClientStack({ + api: { + baseURL: apiOrigin, + basePath: "/api/data", + ...(options?.headers ? { headers: options.headers } : {}), + }, + site: { baseURL: siteOrigin, basePath: "/pages" }, + queryClient, plugins: { blog: blogClientPlugin({ - apiBaseURL: baseURL, - apiBasePath: "/api/data", - siteBaseURL: baseURL, - siteBasePath: "/pages", - queryClient, - headers: options?.headers, // pass for SSR auth seo: { siteName: "My App" }, // optional hooks: { // optional client-side loader hooks beforeLoadPost: async (slug, ctx) => { /* ... */ }, afterLoadPost: async (post, slug, ctx) => { /* ... */ }, - onLoadError: async (error, ctx) => { /* ... */ }, + onErrorLoad: async (error, ctx) => { /* ... */ }, }, }), // add more plugins… }, + ...(crossOriginBlogEndpoint + ? { endpoints: { blog: crossOriginBlogEndpoint } } + : {}), }) } + +export function getStackClient( + queryClient: QueryClient, + options?: StackClientOptions, +) { + return createAppClientStack(queryClient, options) +} + +function getSiteOrigin(serverOrigin?: string) { + if (serverOrigin) return serverOrigin + if (typeof window !== "undefined") { + return ( + process.env.NEXT_PUBLIC_SITE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + window.location.origin + ) + } + return ( + process.env.BTST_SITE_URL || + process.env.NEXT_PUBLIC_SITE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + process.env.BASE_URL || + "http://localhost:3000" + ) +} + +function getApiOrigin(serverOrigin: string | undefined, siteOrigin: string) { + if (serverOrigin) return serverOrigin + if (typeof window !== "undefined") { + return ( + process.env.NEXT_PUBLIC_API_URL || + process.env.NEXT_PUBLIC_BASE_URL || + siteOrigin + ) + } + return ( + process.env.BTST_API_URL || + process.env.NEXT_PUBLIC_API_URL || + process.env.NEXT_PUBLIC_BASE_URL || + process.env.BASE_URL || + siteOrigin + ) +} + +function getCrossOriginBlogEndpoint(apiOrigin: string, siteOrigin: string) { + if (apiOrigin === siteOrigin) return undefined + return { + api: { + baseURL: apiOrigin, + basePath: "/api/data", + credentials: "include", + }, + } satisfies ClientPluginEndpointOverride +} ``` -**Common client plugin config fields** (all plugins): +The CLI emits the equivalent Vite helper with +`VITE_PUBLIC_SITE_URL`/`VITE_PUBLIC_API_URL`. The server companion calls +`resolveTrustedClientOrigins()` and fails closed in production when it cannot +resolve a configured site/API origin. `BTST_API_URL` may point to a managed or +custom backend; the browser receives that same trusted snapshot instead of +reconstructing it from `window.location`. + +## lib/stack-client.server.ts shape + +Keep credential forwarding in a server-only helper. The configured API origin +wins even when a reverse proxy or custom frontend domain has a different +request origin. Missing production configuration fails closed; request-derived +fallbacks are accepted only for HTTP(S) loopback development. + +```ts +import { + filterCredentialForwardingHeaders, + resolveTrustedClientOrigins, +} from "@btst/stack/client/server" +import type { QueryClient } from "@tanstack/react-query" +import { createAppClientStack } from "./stack-client" + +function configuredApiOrigin() { + return ( + process.env.BTST_API_URL || + process.env.NEXT_PUBLIC_API_URL || + process.env.NEXT_PUBLIC_BASE_URL || + process.env.BASE_URL + ) +} + +function configuredSiteOrigin() { + return ( + process.env.BTST_SITE_URL || + process.env.NEXT_PUBLIC_SITE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + process.env.BASE_URL + ) +} + +function requestOriginFromHeaders(headers: Headers) { + const host = (headers.get("x-forwarded-host") || headers.get("host")) + ?.split(",")[0] + ?.trim() + if (!host) return undefined + const protocol = + headers.get("x-forwarded-proto")?.split(",")[0]?.trim() || "http" + return `${protocol}://${host}` +} + +export function getServerClientOrigins(requestOrigin?: string) { + return resolveTrustedClientOrigins({ + configuredApiOrigin: configuredApiOrigin(), + configuredSiteOrigin: configuredSiteOrigin(), + requestOrigin, + isProduction: process.env.NODE_ENV === "production", + apiLabel: "BTST_API_URL, NEXT_PUBLIC_API_URL, NEXT_PUBLIC_BASE_URL, or BASE_URL", + siteLabel: "BTST_SITE_URL, NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_BASE_URL, or BASE_URL", + }) +} + +export function getServerClientOriginsFromHeaders(headers: HeadersInit) { + const requestHeaders = new Headers(headers) + return getServerClientOrigins(requestOriginFromHeaders(requestHeaders)) +} + +export function getStackClientForRequest( + queryClient: QueryClient, + options: { headers: HeadersInit; requestOrigin?: string }, +) { + const requestHeaders = new Headers(options.headers) + const origins = options.requestOrigin + ? getServerClientOrigins(options.requestOrigin) + : getServerClientOriginsFromHeaders(requestHeaders) + const headers = filterCredentialForwardingHeaders(requestHeaders) + return createAppClientStack(queryClient, { ...origins, headers }) +} +``` + +Generated Vite helpers use the corresponding `VITE_PUBLIC_*` variables. Never +serialize request headers or a resolved server stack into a provider. +`NEXT_PUBLIC_BASE_URL` (or `VITE_BASE_URL`) remains a narrow +migration-compatible same-origin fallback; new deployments should prefer the +separate site/API variables above. + +**Shared client stack fields:** | Field | Required | Description | |---|---|---| -| `apiBaseURL` | Yes | Base URL for API calls (absolute) | -| `apiBasePath` | Yes | API route prefix, e.g. `/api/data` | -| `siteBaseURL` | Yes | Base URL for generated page links | -| `siteBasePath` | Yes | Pages route prefix, e.g. `/pages` | +| `api` | Yes | API base URL/path and optional per-request headers/credentials | +| `site` | Yes | Site base URL and pages path | | `queryClient` | Yes | The QueryClient for this request | -| `headers` | No | Pass incoming request headers for SSR auth | -| `seo` | No | `{ siteName, description, author, twitterHandle, … }` | -| `hooks` | No | Client-side loader hooks (see per-plugin docs) | ---- +Client definitions receive only plugin-specific options such as `seo`, +`hooks`, and `pageComponents`. Shared API/site/QueryClient runtime belongs only +on `createClientStack()`. -## SSR headers forwarding (Next.js) +--- -Pass request cookies/auth headers into the stack client during SSR so plugins can perform authenticated prefetches: +## Auth wiring -```ts -// app/pages/[[...all]]/page.tsx -import { headers } from "next/headers" +Identity and client permissions belong on the top-level provider: -export default async function Page({ params }) { - const headersList = await headers() - const headersObj = new Headers() - headersList.forEach((value, key) => headersObj.set(key, value)) +```tsx +import { createClientAuth } from "@btst/stack/authorization/client" +import { authorization } from "./authorization" - const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient, { headers: headersObj }) - const route = stackClient.router.getRoute(normalizePath((await params).all)) +const clientAuth = createClientAuth({ + authorization, + getIdentity: async () => (await getSession())?.user ?? null, + loginPath: "/login", +}) - if (route?.loader) await route.loader() - return ( - - {route?.PageComponent ? : notFound()} - - ) -} + + {children} + ``` +Create the backend adapter with `createServerAuth({ authorization, getIdentity })` +and pass it to `createBackendStack({ auth: serverAuth })`. Operations derive trusted facts and +evaluate exact permission descriptors before lifecycle hooks. Do not use hooks +for routine authorization. Do not pass `currentUserId`, +`loginHref`, request headers, API paths, or navigation functions to public +plugin components. + +Per-request headers belong on `createClientStack({ api: { headers } })`; they +are not plugin options, provider overrides, or component identity props. + --- -## StackProvider — pages layout +## StackProvider — shared client layout -The pages layout must be `"use client"` and wrap `QueryClientProvider` then `StackProvider`. +The shared provider must be `"use client"` and wrap `QueryClientProvider` then +`StackProvider`. It receives only the trusted, serializable client origins from +the server wrapper. ```tsx -// Next.js: app/pages/layout.tsx (or app/pages/[[...all]]/layout.tsx) +// Next.js: app/pages/client-layout.tsx "use client" -import { useState } from "react" +import { useMemo } 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" +import { getStackClient, type StackClientOptions } from "@/lib/stack-client" -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() +export default function PagesClientLayout({ children, clientOrigins }: { + children: React.ReactNode + clientOrigins: StackClientOptions +}) { + const queryClient = getOrCreateQueryClient() + const clientStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) return ( - - basePath="/pages" + 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, }, }} > @@ -296,25 +489,87 @@ export default function PagesLayout({ children }: { children: React.ReactNode }) } ``` +The request wrapper at `app/(request)/pages/layout.tsx` calls +`getServerClientOriginsFromHeaders(await headers())`. The header-free wrapper at +`app/(static)/pages/layout.tsx` calls `getServerClientOrigins()` for SSG/ISR. +Both groups publish the same `/pages/*` URLs; never serialize the resolved +request stack or request headers into the client layout. + +React Router serializes the same snapshot from its layout loader and reuses it +for the provider: + +```tsx +import { StackProvider } from "@btst/stack/context" +import { reactRouter } from "@btst/stack/react-router" +import { QueryClientProvider } from "@tanstack/react-query" +import { useMemo } from "react" +import { + Outlet, + useLoaderData, + type LoaderFunctionArgs, +} from "react-router" +import { getOrCreateQueryClient } from "~/lib/query-client" +import { getStackClient } from "~/lib/stack-client" +import { getServerClientOrigins } from "~/lib/stack-client.server" + +export function loader({ request }: LoaderFunctionArgs) { + return getServerClientOrigins(new URL(request.url).origin) +} + +export default function BtstPagesLayout() { + const queryClient = getOrCreateQueryClient() + const { apiOrigin, siteOrigin } = useLoaderData() + const clientStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) + return ( + + + + + + ) +} +``` + +TanStack Start exposes that server snapshot through a server function so both +initial hydration and later client navigation keep a managed API origin: + +```ts +// src/lib/stack-client.origins.ts +import { createServerFn } from "@tanstack/react-start" +import { getRequest } from "@tanstack/react-start/server" +import { getServerClientOrigins } from "./stack-client.server" + +export const getTrustedClientOrigins = createServerFn({ method: "GET" }) + .handler(() => { + const request = getRequest() + return getServerClientOrigins(new URL(request.url).origin) + }) +``` + +The `/pages` route loader returns `getTrustedClientOrigins()` and its provider +calls `getStackClient(queryClient, { apiOrigin, siteOrigin })`, exactly like the +React Router layout above. The TanStack catch-all route also calls the server +function during client navigation, as shown in the route example earlier. + ### StackProvider props | Prop | Required | Description | |---|---|---| -| `basePath` | Yes | Must match your `/pages/*` catch-all route prefix | -| `overrides` | Yes | Per-plugin override objects, keyed by plugin name | +| `stack` | Yes | Resolved client stack; projects API, site, QueryClient, and inferred override types | +| `router` | No | Framework router preset 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 | +| `stack` | Resolved API, site, QueryClient, routes, and plugin registrations | +| `router` | Framework `Link`, `Image`, `navigate`, and `refresh` implementation | +| `auth` | Identity, login path, and authorization checks | ### Lifecycle hooks (available on most plugins) @@ -322,7 +577,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 }`. @@ -333,7 +587,9 @@ export default function PagesLayout({ children }: { children: React.ReactNode }) - `imagePicker`, `imageInputField` — custom image picker components **ai-chat** -- `mode: "authenticated" | "public"` — conversation persistence mode +- `aiChatClientPlugin({ mode: "authenticated" | "public" })` — the single + conversation persistence-mode configuration; do not repeat it in provider + overrides or component props - `uploadFile(file): Promise` — for chat file attachments - `chatSuggestions: string[]` — pre-filled prompt suggestions - **Root layout requirement**: wrap the root layout (above all `StackProvider` instances) with `PageAIContextProvider` from `@btst/stack/plugins/ai-chat/client/context`. Individual pages then call `useRegisterPageAIContext()` — see the `btst-ai-context` skill. @@ -346,8 +602,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 @@ -375,18 +632,21 @@ No CSS import is needed for: `media`, `open-api`. ## Backend plugin hooks reference -Backend plugins accept a hooks object as their factory argument. Common hooks: +Backend plugins accept lifecycle callbacks under their factory's `hooks` +option. Common hooks: **blog** ```ts blogBackendPlugin({ - onBeforeCreatePost: async (data) => { /* throw to deny */ }, - onBeforeUpdatePost: async (postId) => { /* throw to deny */ }, - onBeforeDeletePost: async (postId) => { /* throw to deny */ }, - onBeforeListPosts: async (filter) => { /* throw to deny drafts to unauthed users */ }, - onPostCreated: async (post) => { revalidatePath("/pages/blog") }, - onPostUpdated: async (post) => { /* … */ }, - onPostDeleted: async (postId) => { /* … */ }, + hooks: { + onBeforeCreatePost: async (data) => { /* domain validation */ }, + onBeforeUpdatePost: async (postId) => { /* domain validation */ }, + onBeforeDeletePost: async (postId) => { /* audit */ }, + onBeforeListPosts: async (filter) => { /* telemetry */ }, + onAfterCreatePost: async (post) => { revalidatePath("/pages/blog") }, + onAfterUpdatePost: async (post) => { /* … */ }, + onAfterDeletePost: async (postId) => { /* … */ }, + }, }) ``` @@ -395,10 +655,11 @@ blogBackendPlugin({ commentsBackendPlugin({ autoApprove: false, resolveUser: async (authorId) => ({ name: "…" }), - resolveCurrentUserId: async (ctx) => ctx?.headers?.get("x-user-id") ?? null, - onBeforePost: async (input, ctx) => ({ authorId: "from-session" }), - onBeforeEdit: async (commentId, update, ctx) => { /* auth check */ }, - onBeforeStatusChange: async (commentId, status, ctx) => { /* admin check */ }, + hooks: { + onBeforeCreateComment: async (input, ctx) => { /* domain validation */ }, + onBeforeUpdateComment: async (commentId, update, ctx) => { /* domain validation */ }, + onBeforeModerateComment: async (commentId, status, ctx) => { /* audit */ }, + }, }) ``` @@ -407,14 +668,13 @@ commentsBackendPlugin({ aiChatBackendPlugin({ model: openai("gpt-4o"), systemPrompt: "…", - mode: "authenticated", + access: "authorized", tools: { myTool }, enablePageTools: true, - getUserId: async (ctx) => ctx.headers?.get("x-user-id") ?? null, hooks: { - onConversationCreated: async (convo) => { /* … */ }, + onAfterCreateConversation: async (convo) => { /* … */ }, onAfterChat: async (conversationId, messages) => { /* … */ }, - onBeforeToolsActivated: async (toolNames, routeName, ctx) => toolNames, + onBeforeActivateTools: async (toolNames, routeName, ctx) => toolNames, }, }) ``` diff --git a/.agents/skills/btst-integration/SKILL.md b/.agents/skills/btst-integration/SKILL.md index e8b75e649..572be84a3 100644 --- a/.agents/skills/btst-integration/SKILL.md +++ b/.agents/skills/btst-integration/SKILL.md @@ -10,9 +10,10 @@ description: Guides developers and AI agents through manual BTST library consump 1. Install `@btst/stack`, `@tanstack/react-query`, and one `@btst/adapter-*`. 2. Install and register each plugin's backend and client halves. 3. Create `lib/stack.ts` → export `{ handler, dbSchema }`. -4. Mount a catch-all API route at `/api/data/*` forwarding all methods to `handler`. +4. Mount `handler` with the framework API entry factory at `/api/data/*`. 5. Add `@import "@btst/stack/plugins/{plugin}/css"` per plugin in your global CSS. -6. Create `lib/stack-client.tsx`, `lib/query-client.ts`, and the `/pages/*` catch-all route. +6. Create `lib/stack-client.tsx`, `lib/query-client.ts`, and the `/pages/*` + catch-all route with the framework page entry factory. 7. Create the pages **layout** file with `QueryClientProvider` + `StackProvider`. 8. Run `@btst/cli generate` (and `migrate` for Kysely). @@ -31,20 +32,27 @@ See [REFERENCE.md](REFERENCE.md) for full code shapes for every file. ### 2) Register plugins (backend + client) - **Backend** (`lib/stack.ts`): import `{plugin}BackendPlugin` from `@btst/stack/plugins/{plugin}/api`. - - Pass hooks and config to the backend plugin factory (e.g. `blogBackendPlugin(hooks)`). + - Pass hooks and config in the backend plugin options object (e.g. `blogBackendPlugin({ hooks })`). - Use camelCase keys for plugins that have compound names: `aiChat`, `formBuilder`, `uiBuilder`. - **Client** (`lib/stack-client.tsx`): import `{plugin}ClientPlugin` from `@btst/stack/plugins/{plugin}/client`. - - Each client plugin factory receives: `{ apiBaseURL, apiBasePath, siteBaseURL, siteBasePath, queryClient, headers?, seo?, hooks? }`. - - Pass `headers` from the incoming request for SSR authentication. + - Client definitions receive only plugin-specific options such as `seo`, hooks, or page components. Configure `api`, `site`, and `queryClient` once on `createClientStack()`; its resolver binds that runtime to every definition. + - Pass incoming SSR request headers through the top-level `createClientStack({ api: { headers } })` configuration. + - Resolve one trusted API/site origin snapshot on the server and hydrate it into the provider. A managed backend may use a distinct `BTST_API_URL`; never reconstruct it from `window.location` after hydration. ### 3) Configure backend stack -- Call `stack({ basePath: "/api/data", plugins: { ... }, adapter: (db) => createXxxAdapter(..., db, {}) })`. +- Call `createBackendStack({ basePath: "/api/data", plugins: { ... }, adapter: (db) => createXxxAdapter(..., db, {}), auth: serverAuth })` when application authorization is enabled. +- Create `serverAuth` with `createServerAuth({ authorization, getIdentity })`. Omitting `auth` intentionally preserves permissive no-authorization compatibility; plugin labels such as AI Chat's `access: "authorized"` are not an enforcement boundary by themselves. - Export `handler` and `dbSchema`. - **Memory adapter + Next.js**: pin to `globalThis` to avoid two instances in the same process: ```ts - const g = global as typeof global & { __btst__?: ReturnType } - export const myStack = g.__btst__ ??= stack({ ... }) + function createAppStack() { + return createBackendStack({ ... }) + } + + type AppStack = ReturnType + const g = globalThis as typeof globalThis & { __btst__?: AppStack } + export const myStack = g.__btst__ ??= createAppStack() export const { handler, dbSchema } = myStack ``` @@ -61,13 +69,19 @@ Do not duplicate — the patcher and manual edits must both be idempotent. ### 5) Wire framework routes and client runtime -- **API route**: catch-all at `/api/data/*`, forward GET/POST/PUT/PATCH/DELETE to `handler`. -- **Pages route**: catch-all at `/pages/*` — resolve via `stackClient.router.getRoute(path)`, run `route.loader?.()` server-side, wrap in `HydrationBoundary`. +- **API route**: use `toNextRouteHandlers`, + `toReactRouterHandlers`, or `toTanStackHandlers` from the framework + entry point. +- **Pages route**: use `createNextPage`, + `createReactRouterPage`, or `createTanStackPageOptions`. The factory + owns route matching, loader ordering, hydration, metadata, and 404 handling. - **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 } }}` - - 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. + - Pass the resolved client stack with `stack={clientStack}`. Its top-level `site.basePath` must match your pages catch-all prefix. + - `router={nextRouter()}` / `reactRouter()` / `tanstackRouter()` for framework-wide links, images, navigation, and refresh. + - `auth={createClientAuth({ authorization, getIdentity, loginPath })}` when plugins need identity or permission presentation gates. + - `overrides={{ pluginKey: { uploadImage?, ...pluginSpecificValues } }}` only for plugin-specific customization. + - Let the resolved plugin map infer override keys and values; do not duplicate a manual provider map. + - 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" @@ -90,10 +104,12 @@ Do not duplicate — the patcher and manual edits must both be idempotent. ## Validation checklist - `stack.ts` exports both `handler` and `dbSchema`. +- Protected applications pass `createServerAuth(...)` to `createBackendStack({ auth })`; omitted auth is intentionally permissive. - Every plugin is registered on both backend and client sides. -- API `basePath` and `stack({ basePath })` match exactly. +- API `basePath` and `createBackendStack({ basePath })` match exactly. +- API and page catch-all routes use the framework entry factories. - Pages layout is `"use client"` and wraps `QueryClientProvider` then `StackProvider`. -- `StackProvider` `basePath` matches the `/pages` catch-all route prefix. +- The resolved client stack's `site.basePath` matches the `/pages` catch-all route prefix. - Global CSS has one `@import` line per selected plugin. - `/pages/*` routes render expected plugin pages. - CLI commands run with required env vars in scope. @@ -107,4 +123,6 @@ Do not duplicate — the patcher and manual edits must both be idempotent. - **Memory adapter + Next.js** — always pin to `globalThis` to share one in-memory store across API and page bundles. - **Path aliases in CLI** — `@btst/cli` executes your config file directly; use relative imports in `lib/stack.ts` and its dependencies. - **Kysely generate needs DB** — pass `DATABASE_URL` or `--database-url`; use `dotenv-cli` for `.env.local`. -- **SSR headers for auth** — forward `await headers()` (Next.js) into `getStackClient(queryClient, { headers })` so plugins can read cookies/auth tokens during SSR. +- **Provider services copied into plugin overrides** — never add `Link`, + `Image`, navigation, refresh, API paths, identity, or login values to + built-in plugin overrides. Use top-level `router`, `api`, and `auth`. diff --git a/.agents/skills/btst-plugin-ssg/REFERENCE.md b/.agents/skills/btst-plugin-ssg/REFERENCE.md index 76ba0b9ae..b0c27ef15 100644 --- a/.agents/skills/btst-plugin-ssg/REFERENCE.md +++ b/.agents/skills/btst-plugin-ssg/REFERENCE.md @@ -78,10 +78,8 @@ function createMyPluginPrefetchForRoute(adapter: Adapter): MyPluginPrefetchForRo } as MyPluginPrefetchForRoute } -// Wire into the api factory in defineBackendPlugin: -api: (adapter) => ({ - listItems: () => listItems(adapter), - getItemById: (id: string) => getItemById(adapter, id), +// Wire into the raw factory in defineBackendPlugin: +raw: (adapter) => ({ prefetchForRoute: createMyPluginPrefetchForRoute(adapter), }) ``` @@ -93,7 +91,7 @@ api: (adapter) => ({ Static page that bypasses `route.loader()` and seeds the cache directly: ```tsx -// app/pages/my-plugin/page.tsx +// app/(static)/pages/my-plugin/page.tsx import { notFound } from "next/navigation" import { HydrationBoundary, dehydrate } from "@tanstack/react-query" import type { Metadata } from "next" @@ -113,7 +111,7 @@ export async function generateMetadata(): Promise { const route = stackClient.router.getRoute(normalizePath(["my-plugin"])) if (!route) return { title: "Fallback" } - await myStack.api.myPlugin.prefetchForRoute("list", queryClient) + await myStack.raw.myPlugin.prefetchForRoute("list", queryClient) return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata } @@ -123,7 +121,7 @@ export default async function Page() { const route = stackClient.router.getRoute(normalizePath(["my-plugin"])) if (!route) notFound() - await myStack.api.myPlugin.prefetchForRoute("list", queryClient) + await myStack.raw.myPlugin.prefetchForRoute("list", queryClient) return ( @@ -132,6 +130,11 @@ export default async function Page() { } ``` +Keep the request catch-all in `app/(request)/pages/[[...all]]/page.tsx` and +static pages such as this one in `app/(static)/pages`. Both route groups retain +the `/pages/*` URL. Each group layout should wrap the shared client provider in +`app/pages/client-layout.tsx`; only the request layout may read request headers. + --- ## query-keys.ts — import from query-key-defs.ts diff --git a/.agents/skills/btst-plugin-ssg/SKILL.md b/.agents/skills/btst-plugin-ssg/SKILL.md index 8f2b149d5..c3d0b1360 100644 --- a/.agents/skills/btst-plugin-ssg/SKILL.md +++ b/.agents/skills/btst-plugin-ssg/SKILL.md @@ -1,13 +1,13 @@ --- name: btst-plugin-ssg -description: Patterns for adding SSG (static site generation) support to BTST plugins using prefetchForRoute, including query-key-defs.ts, serializers.ts, typed prefetchForRoute overloads, and the SSG page.tsx pattern for Next.js. Use when a plugin needs static generation support, when route.loader() silently fails at next build, when adding prefetchForRoute to the api factory, or when fixing infinite query shape/date serialization errors during SSG. +description: Patterns for adding SSG (static site generation) support to BTST plugins using prefetchForRoute, including query-key-defs.ts, serializers.ts, typed prefetchForRoute overloads, and the SSG page.tsx pattern for Next.js. Use when a plugin needs static generation support, when route.loader() silently fails at next build, when adding prefetchForRoute to the raw factory, or when fixing infinite query shape/date serialization errors during SSG. --- # BTST Plugin SSG Support ## Why route.loader() fails at build time -`route.loader()` makes HTTP requests. No server exists during `next build`, so fetches fail silently — static pages render empty. Solution: expose `prefetchForRoute` on the `api` factory to seed React Query directly from the DB. +`route.loader()` makes HTTP requests. No server exists during `next build`, so fetches fail silently — static pages render empty. Solution: expose `prefetchForRoute` on the `raw` factory to seed React Query directly from the DB. ## Required files per plugin @@ -16,7 +16,7 @@ description: Patterns for adding SSG (static site generation) support to BTST pl | `api/query-key-defs.ts` | Shared key shapes — import into both `query-keys.ts` and `prefetchForRoute` | | `api/serializers.ts` | Convert `Date` fields → ISO strings before `setQueryData` | | `api/getters.ts` | Add any ID-based getters `prefetchForRoute` needs | -| `api/plugin.ts` | `RouteKey` type + typed overloads + wire `prefetchForRoute` into `api` factory | +| `api/plugin.ts` | `RouteKey` type + typed overloads + wire `prefetchForRoute` into `raw` factory | | `api/index.ts` | Re-export `RouteKey`, serializers, `PLUGIN_QUERY_KEYS` | | `query-keys.ts` | Import discriminator fn from `api/query-key-defs.ts` | | `client/plugin.tsx` | `isConnectionError` warn in each loader `catch` block | @@ -27,7 +27,8 @@ description: Patterns for adding SSG (static site generation) support to BTST pl - **`useInfiniteQuery` lists** require `{ pages: [...], pageParams: [...] }` shape in `setQueryData`. Flat arrays break hydration. - **Share key builders** via `api/query-key-defs.ts` — never hardcode key shapes in two places. - **One-time init steps** (e.g. CMS `ensureSynced`) — call once at the top of `prefetchForRoute`; it's idempotent and safe for concurrent SSG. -- Place shared `StackProvider` layout at `app/pages/layout.tsx` (not inside `[[...all]]/`) so it applies to both SSG pages and the catch-all. +- In Next.js, keep request-aware routes under `app/(request)/pages` and SSG/ISR routes under `app/(static)/pages`. Both groups still publish `/pages/*` URLs. +- Put the reusable client `StackProvider` shell in `app/pages/client-layout.tsx`. Wrap it from a request layout that resolves origins from trusted request headers and a header-free static layout so SSG does not become dynamic. ## Plugins with SSG support @@ -46,7 +47,7 @@ import { isConnectionError } from "@btst/stack/plugins/client" // in each loader catch block: if (isConnectionError(error)) { - console.warn("[btst/my-plugin] route.loader() failed — no server at build time. Use myStack.api.myPlugin.prefetchForRoute() for SSG.") + console.warn("[btst/my-plugin] route.loader() failed — no server at build time. Use myStack.raw.myPlugin.prefetchForRoute() for SSG.") } ``` diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..574523242 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,71 @@ +FROM archlinux:latest + +# Sync package database and install base tooling +# docker CLI (no daemon) lets `docker compose` talk to the host Podman socket +RUN pacman -Syu --noconfirm \ + && pacman -S --noconfirm --needed \ + base-devel \ + bash \ + ca-certificates \ + curl \ + docker \ + docker-compose \ + git \ + github-cli \ + make \ + openssh \ + procps-ng \ + sudo \ + unzip \ + && pacman -Scc --noconfirm + +# Chromium runtime libraries for Playwright e2e tests. `playwright install-deps` +# only targets Debian/Ubuntu, so on Arch we install the shared libs Chromium +# links against ourselves (derived from `ldd chrome | grep "not found"`). +RUN pacman -Sy --noconfirm --needed \ + alsa-lib \ + at-spi2-core \ + atk \ + cairo \ + cups \ + libdrm \ + libx11 \ + libxcb \ + libxcomposite \ + libxdamage \ + libxext \ + libxfixes \ + libxkbcommon \ + libxrandr \ + mesa \ + nspr \ + nss \ + pango \ + && pacman -Scc --noconfirm + +# Create a non-root user matching the host UID/GID (1000/1000 on SteamOS) +ARG USERNAME=dev +ARG USER_UID=1000 +ARG USER_GID=1000 +RUN groupadd --gid $USER_GID $USERNAME \ + && useradd --uid $USER_UID --gid $USER_GID -m -s /bin/bash $USERNAME \ + && echo "$USERNAME ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME + +USER $USERNAME +WORKDIR /home/$USERNAME + +# Install mise (runtime version manager) +RUN curl https://mise.run | sh +ENV PATH="/home/$USERNAME/.local/bin:$PATH" + +# Node 22.18.0 matches the repo's .nvmrc; pnpm 10.17.1 matches the +# "packageManager" field in package.json (activated via corepack). +RUN mise use --global node@22.18.0 \ + && mise exec -- corepack enable \ + && mise exec -- corepack prepare pnpm@10.17.1 --activate + +RUN echo 'eval "$(mise activate bash)"' >> ~/.bashrc \ + && echo 'eval "$(mise activate bash)"' >> ~/.profile + +ENV PATH="/home/$USERNAME/.local/share/mise/shims:$PATH" diff --git a/.devcontainer/linux-podman/devcontainer.json b/.devcontainer/linux-podman/devcontainer.json new file mode 100644 index 000000000..09343061a --- /dev/null +++ b/.devcontainer/linux-podman/devcontainer.json @@ -0,0 +1,89 @@ +{ + // ─── To reuse this in another project ──────────────────────────────────────── + // 1. Copy .devcontainer/ into the new repo + // 2. Update "name" and "postCreateCommand" + // 3. Everything else (Dockerfile, mounts, runArgs) is project-agnostic + // ───────────────────────────────────────────────────────────────────────────── + "name": "better-stack", + "build": { + "dockerfile": "../Dockerfile", + "args": { + "USERNAME": "dev", + "USER_UID": "1000", + "USER_GID": "1000" + } + }, + + // Podman-specific flags only — no volume paths here (those are in "mounts") + // --userns=keep-id: rootless Podman maps your UID into the container so file ownership matches + // --network=host: shares host network stack (localhost = host) + "runArgs": ["--userns=keep-id", "--network=host"], + + // ${localEnv:HOME} → /home/ on any Linux machine + // ${localEnv:XDG_RUNTIME_DIR} → /run/user/, where the Podman socket lives + "mounts": [ + // Podman socket → docker CLI inside the container talks to host Podman (no daemon needed). + // Mounted at the SAME path as on the host so docker-out-of-docker path forwarding works. + { + "source": "${localEnv:XDG_RUNTIME_DIR}/podman/podman.sock", + "target": "${localEnv:XDG_RUNTIME_DIR}/podman/podman.sock", + "type": "bind" + }, + // pnpm content-addressable store → packages survive image rebuilds + { + "source": "${localEnv:HOME}/.local/share/pnpm", + "target": "/home/dev/.local/share/pnpm", + "type": "bind" + }, + // Playwright browser cache → Chromium download survives rebuilds + { + "source": "${localEnv:HOME}/.cache/ms-playwright", + "target": "/home/dev/.cache/ms-playwright", + "type": "bind" + }, + // SSH keys (read-only) → git push/pull over SSH works inside the container + { + "source": "${localEnv:HOME}/.ssh", + "target": "/home/dev/.ssh", + "type": "bind", + "readonly": true + }, + // gh CLI auth token → survives container rebuilds without re-authenticating + { + "source": "${localEnv:HOME}/.config/gh", + "target": "/home/dev/.config/gh", + "type": "bind" + } + ], + + "containerEnv": { + // Point at the socket's real host path (mounted 1:1 above) so any path the + // CLI forwards to sibling containers resolves identically on the host. + "DOCKER_HOST": "unix://${localEnv:XDG_RUNTIME_DIR}/podman/podman.sock" + }, + + // Mount the workspace at its REAL host path (not /workspaces/...) so any + // project-relative paths forwarded to sibling containers resolve on the HOST. + "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind,consistency=cached", + "workspaceFolder": "${localWorkspaceFolder}", + + // reshim: regenerates mise shims to point to this container's mise binary + // store-dir: pins pnpm store so it never falls back to a project-local .pnpm-store/ + "postCreateCommand": "mise reshim && pnpm config set store-dir /home/dev/.local/share/pnpm/store && pnpm install", + + // NOTE: No "forwardPorts" here on purpose. This container runs with + // --network=host (see runArgs), so it already shares the host's network + // namespace and every dev server (docs site, codegen project on 3006, etc.) + // is directly reachable on the host. + + "customizations": { + "vscode": { + "extensions": ["biomejs.biome", "ms-playwright.playwright"], + "settings": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome", + "terminal.integrated.defaultProfile.linux": "bash" + } + } + } +} diff --git a/.github/workflows/init.yml b/.github/workflows/init.yml index 12bc3172a..bd46dc5d4 100644 --- a/.github/workflows/init.yml +++ b/.github/workflows/init.yml @@ -7,6 +7,8 @@ on: - 'packages/cli/**' - 'docs/content/docs/cli.mdx' - 'docs/content/docs/installation.mdx' + - 'docs/content/docs/breaking-changes.mdx' + - 'docs/content/docs/plugins/better-auth-ui.mdx' - '.github/workflows/init.yml' concurrency: @@ -21,6 +23,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 @@ -40,6 +44,9 @@ jobs: - name: Build @btst/codegen run: pnpm --filter @btst/codegen build + - name: Verify historical Next.js migration hashes + run: pnpm --filter @btst/codegen legacy-next-hashes:check + - name: Run init harness working-directory: packages/cli run: bash scripts/test-init.sh @@ -54,3 +61,38 @@ jobs: name: btst-init-fixtures path: /tmp/test-btst-init-*/ retention-days: 3 + + better-auth-ui-fixtures: + name: Better Auth UI packed fixtures + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + + - name: Setup Node.js 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run packed framework fixtures + run: pnpm --filter @btst/codegen test:better-auth-ui-fixtures + env: + BTST_KEEP_FIXTURES: 1 + CI: true + + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: better-auth-ui-fixtures + path: /tmp/btst-better-auth-ui-*/ + retention-days: 3 diff --git a/.github/workflows/packed-consumers.yml b/.github/workflows/packed-consumers.yml new file mode 100644 index 000000000..02354f132 --- /dev/null +++ b/.github/workflows/packed-consumers.yml @@ -0,0 +1,106 @@ +name: Packed consumers + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'packages/stack/**' + - 'scripts/packed-consumer/**' + - 'scripts/packed-consumer-smoke.mjs' + - 'package.json' + - 'pnpm-lock.yaml' + - '.github/workflows/packed-consumers.yml' + +concurrency: + group: packed-consumers-${{ github.ref }} + cancel-in-progress: true + +jobs: + core: + name: Core (${{ matrix.package-manager }}) + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + package-manager: [npm, pnpm] + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + + - name: Setup Node.js 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Test harness + run: pnpm test:packed-consumers + + - name: Pack and validate core consumer + run: >- + pnpm smoke:packed-consumer -- + --fixture core + --package-manager ${{ matrix.package-manager }} + + auth: + name: Auth + account (${{ matrix.package-manager }}) + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + package-manager: [npm, pnpm] + + steps: + - name: Checkout Better Stack + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Checkout Better Auth UI + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: better-stack-ai/better-auth-ui + path: .packed-consumer/better-auth-ui + + - name: Setup pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + + - name: Setup Node.js 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + + - name: Install Better Stack workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Install and build Better Auth UI + working-directory: .packed-consumer/better-auth-ui + run: | + corepack pnpm@10.26.2 install --ignore-workspace --frozen-lockfile + corepack pnpm@10.26.2 build + + - name: Pack Better Auth UI + id: pack-auth-ui + working-directory: .packed-consumer/better-auth-ui + run: | + mkdir -p "$RUNNER_TEMP/better-auth-ui" + tarball="$(npm pack --quiet --pack-destination "$RUNNER_TEMP/better-auth-ui")" + echo "tarball=$RUNNER_TEMP/better-auth-ui/$tarball" >> "$GITHUB_OUTPUT" + + - name: Test harness + run: pnpm test:packed-consumers + + - name: Pack and validate auth consumer + run: >- + pnpm smoke:packed-consumer -- + --fixture auth + --package-manager ${{ matrix.package-manager }} + --better-auth-ui ${{ steps.pack-auth-ui.outputs.tarball }} diff --git a/.github/workflows/registry.yml b/.github/workflows/registry.yml index a45e6be2b..737bc16ce 100644 --- a/.github/workflows/registry.yml +++ b/.github/workflows/registry.yml @@ -8,6 +8,7 @@ on: - 'packages/stack/scripts/build-registry.ts' - 'packages/stack/scripts/schema.ts' - 'packages/stack/scripts/test-registry.sh' + - 'packages/stack/scripts/fixtures/registry/**' - 'packages/ui/src/components/**' - 'packages/ui/src/hooks/**' - 'packages/ui/src/lib/**' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e7a42f29..9190eb9a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,9 +3,20 @@ name: BTST Release on: release: types: [published] + workflow_dispatch: + inputs: + release_tag: + description: Existing Git tag to publish; select the same tag as the workflow ref + required: true + type: string + prerelease: + description: Publish with the npm next dist-tag + required: true + default: false + type: boolean permissions: - contents: write + contents: read id-token: write jobs: @@ -14,7 +25,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ github.event.release.tag_name || inputs.release_tag }} fetch-depth: 0 - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 @@ -26,10 +37,10 @@ jobs: node-version: 22.22.1 registry-url: 'https://registry.npmjs.org' - - name: Update npm - run: npm install -g npm@latest + - name: Install npm with trusted publishing support + run: npm install -g npm@11.17.0 - - run: pnpm install + - run: pnpm install --frozen-lockfile - name: Copy README files to published packages run: | @@ -50,40 +61,138 @@ jobs: - name: Build @btst/codegen run: pnpm --filter "@btst/codegen" build --force - - name: Verify tag matches package version - working-directory: packages/stack + - name: Validate release metadata + id: release_metadata + env: + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + GITHUB_PRERELEASE: ${{ github.event.release.prerelease || inputs.prerelease }} run: | - PKG_VERSION=$(node -p "require('./package.json').version") - if [ -n "${{ github.event.release.tag_name }}" ]; then - RAW_TAG="${{ github.event.release.tag_name }}" - else - RAW_TAG="${GITHUB_REF#refs/tags/}" + set -euo pipefail + + STACK_VERSION=$(node -p "require('./packages/stack/package.json').version") + CODEGEN_VERSION=$(node -p "require('./packages/cli/package.json').version") + TAG_VERSION="${RELEASE_TAG#v}" + + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$GITHUB_REF" != "refs/tags/$RELEASE_TAG" ]; then + echo "Manual release retries must run from tag $RELEASE_TAG; current ref is $GITHUB_REF" + exit 1 fi - TAG_VERSION="${RAW_TAG#v}" - if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then - echo "Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" + + if [ "$STACK_VERSION" != "$TAG_VERSION" ]; then + echo "Tag version ($TAG_VERSION) does not match @btst/stack version ($STACK_VERSION)" exit 1 fi - - name: Publish to npm + NPM_DIST_TAG=latest + if [[ "$STACK_VERSION" == *-* || "$CODEGEN_VERSION" == *-* || "$GITHUB_PRERELEASE" == "true" ]]; then + NPM_DIST_TAG=next + fi + + echo "stack_version=$STACK_VERSION" >> "$GITHUB_OUTPUT" + echo "codegen_version=$CODEGEN_VERSION" >> "$GITHUB_OUTPUT" + echo "npm_dist_tag=$NPM_DIST_TAG" >> "$GITHUB_OUTPUT" + echo "Publishing @btst/stack@$STACK_VERSION and @btst/codegen@$CODEGEN_VERSION with dist-tag '$NPM_DIST_TAG'" + + - name: Publish @btst/stack to npm working-directory: packages/stack - run: npm publish --access public --provenance + env: + NPM_DIST_TAG: ${{ steps.release_metadata.outputs.npm_dist_tag }} + PKG_VERSION: ${{ steps.release_metadata.outputs.stack_version }} + run: | + set -euo pipefail + + if npm view "@btst/stack@$PKG_VERSION" version >/dev/null 2>&1; then + echo "@btst/stack@$PKG_VERSION is already published; skipping." + exit 0 + fi + npm publish --access public --provenance --tag "$NPM_DIST_TAG" - name: Publish @btst/codegen to npm working-directory: packages/cli + env: + NPM_DIST_TAG: ${{ steps.release_metadata.outputs.npm_dist_tag }} + PKG_VERSION: ${{ steps.release_metadata.outputs.codegen_version }} run: | - PKG_VERSION=$(node -p "require('./package.json').version") + set -euo pipefail + if npm view "@btst/codegen@$PKG_VERSION" version >/dev/null 2>&1; then echo "@btst/codegen@$PKG_VERSION is already published; skipping." exit 0 fi - npm publish --access public --provenance + npm publish --access public --provenance --tag "$NPM_DIST_TAG" + + - name: Verify published packages + env: + NPM_DIST_TAG: ${{ steps.release_metadata.outputs.npm_dist_tag }} + STACK_VERSION: ${{ steps.release_metadata.outputs.stack_version }} + CODEGEN_VERSION: ${{ steps.release_metadata.outputs.codegen_version }} + run: | + set -euo pipefail + + read_expected_version() { + local package_spec="$1" + local expected_version="$2" + local published_version="" + + for attempt in {1..12}; do + published_version=$(npm view "$package_spec" version 2>/dev/null || true) + if [ "$published_version" = "$expected_version" ]; then + printf '%s\n' "$published_version" + return 0 + fi + echo "Waiting for $package_spec to resolve to $expected_version (attempt $attempt/12)" >&2 + sleep 10 + done + + echo "$package_spec resolves to ${published_version:-nothing}, expected $expected_version" >&2 + return 1 + } + + PUBLISHED_STACK_VERSION=$(read_expected_version "@btst/stack@$NPM_DIST_TAG" "$STACK_VERSION") + PUBLISHED_CODEGEN_VERSION=$(read_expected_version "@btst/codegen@$NPM_DIST_TAG" "$CODEGEN_VERSION") + STACK_INTEGRITY=$(npm view "@btst/stack@$STACK_VERSION" dist.integrity) + CODEGEN_INTEGRITY=$(npm view "@btst/codegen@$CODEGEN_VERSION" dist.integrity) + + if [ "$PUBLISHED_STACK_VERSION" != "$STACK_VERSION" ]; then + echo "@btst/stack@$NPM_DIST_TAG resolves to $PUBLISHED_STACK_VERSION, expected $STACK_VERSION" + exit 1 + fi + if [ "$PUBLISHED_CODEGEN_VERSION" != "$CODEGEN_VERSION" ]; then + echo "@btst/codegen@$NPM_DIST_TAG resolves to $PUBLISHED_CODEGEN_VERSION, expected $CODEGEN_VERSION" + exit 1 + fi + if [ -z "$STACK_INTEGRITY" ] || [ -z "$CODEGEN_INTEGRITY" ]; then + echo "Published package integrity metadata is missing" + exit 1 + fi + + LATEST_SUMMARY="" + if [ "$NPM_DIST_TAG" = "next" ]; then + STACK_LATEST_VERSION=$(npm view "@btst/stack@latest" version) + if [ "$STACK_LATEST_VERSION" = "$STACK_VERSION" ] || [[ "$STACK_LATEST_VERSION" == *-* ]]; then + echo "Prerelease publication must leave @btst/stack@latest on a stable version; found $STACK_LATEST_VERSION" + exit 1 + fi + LATEST_SUMMARY="@btst/stack@latest remains at $STACK_LATEST_VERSION" + fi + + { + echo "### npm release" + echo "" + echo "| Package | Version | Dist-tag | Integrity |" + echo "| --- | --- | --- | --- |" + echo "| @btst/stack | $STACK_VERSION | $NPM_DIST_TAG | \`$STACK_INTEGRITY\` |" + echo "| @btst/codegen | $CODEGEN_VERSION | $NPM_DIST_TAG | \`$CODEGEN_INTEGRITY\` |" + if [ -n "$LATEST_SUMMARY" ]; then + echo "" + echo "$LATEST_SUMMARY" + fi + } >> "$GITHUB_STEP_SUMMARY" - name: Upload npm logs on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: npm-debug-logs path: /home/runner/.npm/_logs/ retention-days: 7 - diff --git a/AGENTS.md b/AGENTS.md index f23fb7c71..f54896af1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Detailed patterns and reference material are in the following skills. Read the r | Skill | Domain | Trigger | |---|---|---| -| [`btst-backend-plugin-dev`](.agents/skills/btst-backend-plugin-dev/SKILL.md) | Backend plugin authoring | `defineBackendPlugin`, `getters.ts`, `mutations.ts`, lifecycle hooks, api factory | +| [`btst-backend-plugin-dev`](.agents/skills/btst-backend-plugin-dev/SKILL.md) | Backend plugin authoring | `defineBackendPlugin`, `getters.ts`, `mutations.ts`, lifecycle hooks, raw factory | | [`btst-client-plugin-dev`](.agents/skills/btst-client-plugin-dev/SKILL.md) | Client plugin authoring | `defineClientPlugin`, routes, SSR loaders, meta, `ComposedRoute`, `useSuspenseQuery` | | [`btst-plugin-ssg`](.agents/skills/btst-plugin-ssg/SKILL.md) | SSG support | `prefetchForRoute`, `query-key-defs.ts`, serializers, `next build` silent failures | | [`btst-build-config`](.agents/skills/btst-build-config/SKILL.md) | Build & exports | New entry points, `build.config.ts`, `exports`/`typesVersions`, example app updates | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dd69d44b..d3a80e1a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,78 +102,111 @@ If you want to publish a plugin as a standalone npm package (not merged into thi ### Plugin anatomy -A plugin has two halves that must be kept in sync: +A plugin may expose either or both of these independent halves: | Half | Entry point | Factory function | Import path | |------|-------------|------------------|-------------| | Backend | `api/plugin.ts` | `defineBackendPlugin` | `@btst/stack/plugins/api` | | Client | `client/plugin.tsx` | `defineClientPlugin` | `@btst/stack/plugins/client` | +Do not add a placeholder half for symmetry. OpenAPI is backend-only, Route Docs +is client-only, and UI Builder is client-only over CMS. When both halves exist, +their camelCase programmatic ID and registration key must agree; package and +URL slugs may remain kebab-case. + **Minimum backend shape:** ```typescript -import { defineBackendPlugin, createDbPlugin, createEndpoint, type Adapter } from "@btst/stack/plugins/api" - -export const myBackendPlugin = defineBackendPlugin({ - name: "my-plugin", // unique key — must match the key used in stack({ plugins: { ... } }) - dbPlugin: mySchema, // from createDbPlugin(...) - routes: (adapter: Adapter) => { - const listItems = createEndpoint("/items", { method: "GET" }, async () => { - return adapter.findMany({ model: "item" }) - }) - return { listItems } as const - }, - // Optional: server-side API surface (no HTTP roundtrip — used for SSG, scripts, Server Components) - api: (adapter: Adapter) => ({ - listItems: () => adapter.findMany({ model: "item" }), - }), -}) +import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api" -// Export the inferred router type — the client plugin imports this for end-to-end type safety -export type MyApiRouter = ReturnType +/** Configuration accepted by `myBackendPlugin`. */ +export interface MyBackendPluginOptions { + /** Lifecycle callbacks composed around plugin operations. */ + hooks?: MyBackendHooks +} + +export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => + defineBackendPlugin({ + id: "myPlugin", // camelCase programmatic ID; package and URL slugs may stay kebab-case + dbPlugin: mySchema, + operations: (adapter) => createMyOperations(adapter, options.hooks), + routes: (_adapter, _context, operations) => { + const listItems = createEndpoint( + "/items", + { method: "GET", requireRequest: true }, + operations.listItems.route(() => ({})), + ) + return { listItems } as const + }, + }) + +/** Inferred router contract imported by the client plugin for end-to-end type safety. */ +export type MyApiRouter = ReturnType["routes"]> ``` **Minimum client shape:** ```typescript -import { defineClientPlugin, createRoute, createApiClient } from "@btst/stack/plugins/client" +import { + defineClientPlugin, + defineRoute, + type ResolvedClientPluginRuntime, +} from "@btst/stack/plugins/client" import { lazy } from "react" -import type { QueryClient } from "@tanstack/react-query" -import type { MyApiRouter } from "../api/plugin" + +export const MY_PLUGIN_ID = "myPlugin" as const export interface MyClientConfig { - queryClient: QueryClient - apiBaseURL: string - apiBasePath: string - siteBaseURL: string - siteBasePath: string + title?: string } -export const myClientPlugin = (config: MyClientConfig) => - defineClientPlugin({ - name: "my-plugin", +const ListPage = lazy(() => import("./components/list-page")) + +function createResolvedPlugin( + config: MyClientConfig, + runtime: ResolvedClientPluginRuntime, +) { + const resolvedConfig = { + title: config.title ?? "My Plugin", + queryClient: runtime.queryClient, + apiBaseURL: runtime.api.baseURL, + apiBasePath: runtime.api.basePath, + siteBaseURL: runtime.site.baseURL, + siteBasePath: runtime.site.basePath, + headers: runtime.api.headers, + credentials: runtime.api.credentials, + } + return { routes: () => ({ - list: createRoute("/my-plugin", () => { - const ListPage = lazy(() => import("./components/list-page")) - return { - PageComponent: ListPage, - loader: myLoader(config), - meta: myMeta(config), - } + list: defineRoute("/my-plugin", { + page: ListPage, + loader: myLoader(resolvedConfig), + meta: myMeta(resolvedConfig), }), }), + } +} + +export const myClientPlugin = (config: MyClientConfig = {}) => + defineClientPlugin()({ + id: MY_PLUGIN_ID, + resolve: (runtime) => createResolvedPlugin(config, runtime), }) ``` +API, site, QueryClient, headers, and credentials are configured once on +`createClientStack()`. Client plugin options contain only plugin-specific +choices; `resolve(runtime)` binds the shared runtime. + **Backend hook naming conventions:** ```typescript -// Authorization hooks (throw to deny) -onBeforeCreate, onBeforeUpdate, onBeforeDelete, onBeforeList +// Pre-execution lifecycle hooks (throw to stop execution after authorization) +onBeforeCreateItem, onBeforeUpdateItem, onBeforeDeleteItem, onBeforeListItems // Lifecycle hooks (called after success) -onAfterCreate, onAfterUpdate, onAfterDelete, onAfterList +onAfterCreateItem, onAfterUpdateItem, onAfterDeleteItem, onAfterListItems // Error hooks -onCreateError, onUpdateError, onDeleteError, onListError +onErrorCreateItem, onErrorUpdateItem, onErrorDeleteItem, onErrorListItems ``` --- @@ -187,6 +220,7 @@ packages/stack/src/plugins/your-plugin/ ├── db.ts # createDbPlugin(...) — database schema definition ├── types.ts # Shared TypeScript types (no framework dependencies) ├── schemas.ts # Zod validation schemas for request bodies +├── permissions.ts # Shared authorization descriptor catalog ├── query-keys.ts # React Query key factory (imports from api/query-key-defs.ts) ├── client.css # Plugin CSS (Tailwind source directives, component styles) ├── style.css # Full styles including Tailwind @source directives @@ -194,6 +228,7 @@ packages/stack/src/plugins/your-plugin/ │ ├── plugin.ts # defineBackendPlugin, RouteKey type, prefetchForRoute factory │ ├── getters.ts # Pure DB read functions — no hooks, no HTTP context │ ├── mutations.ts # Server-side write functions — no hooks, no HTTP context +│ ├── operations.ts # Validation, authorization, lifecycle, and execution │ ├── query-key-defs.ts # Shared query key shapes (prevents SSG/SSR key drift) │ ├── serializers.ts # Convert Date fields → ISO strings before setQueryData │ └── index.ts # Barrel re-export of all public backend surface @@ -209,7 +244,10 @@ packages/stack/src/plugins/your-plugin/ └── list-page.internal.tsx # Actual page content (useSuspenseQuery inside) ``` -Not every file is required for a minimal plugin. Start with `db.ts`, `types.ts`, `api/plugin.ts`, and `client/plugin.tsx`. Add the rest as the plugin grows. +Not every file is required for a minimal plugin. A backend plugin starts with +`db.ts`, `types.ts`, `permissions.ts`, `api/operations.ts`, and `api/plugin.ts`; +a client half starts with `client/plugin.tsx`. Add the optional query, style, +and component files as the plugin grows. --- @@ -221,7 +259,7 @@ Define your data models using `createDbPlugin`. Field types: `string`, `boolean` // packages/stack/src/plugins/your-plugin/db.ts import { createDbPlugin } from "@btst/stack/plugins/api" -export const mySchema = createDbPlugin("your-plugin", { +export const mySchema = createDbPlugin("yourPlugin", { item: { modelName: "item", fields: { @@ -261,18 +299,37 @@ export const createItemSchema = z.object({ export const updateItemSchema = createItemSchema.partial() ``` +```typescript +// packages/stack/src/plugins/your-plugin/permissions.ts +import { definePermissions, permission } from "@btst/stack/authorization" + +export const myPermissions = definePermissions("yourPlugin", { + item: { + list: permission(), + create: permission(), + update: permission(), + delete: permission(), + }, +}) +``` + --- ### 3. Backend plugin -**`api/getters.ts`** — pure DB reads, safe for SSG and scripts. Authorization hooks are **not** called here — callers are responsible for access control. +**`api/getters.ts`** — pure DB reads, safe for SSG and scripts. Operation +validation, authorization, and lifecycle hooks are **not** called here; callers +own those concerns. ```typescript // packages/stack/src/plugins/your-plugin/api/getters.ts -import type { Adapter } from "@btst/stack/plugins/api" +import type { DBAdapter as Adapter } from "@btst/db" import type { Item } from "../types" -/** Returns all items sorted newest-first. Authorization hooks are NOT called. */ +/** + * Returns all items sorted newest-first. + * Operation validation, authorization, and lifecycle hooks are NOT called. + */ export async function listItems(adapter: Adapter): Promise { return adapter.findMany({ model: "item", @@ -280,7 +337,10 @@ export async function listItems(adapter: Adapter): Promise { }) as Promise } -/** Returns a single item by ID, or null. Authorization hooks are NOT called. */ +/** + * Returns a single item by ID, or null. + * Operation validation, authorization, and lifecycle hooks are NOT called. + */ export async function getItemById(adapter: Adapter, id: string): Promise { return adapter.findOne({ model: "item", @@ -293,22 +353,172 @@ export async function getItemById(adapter: Adapter, id: string): Promise { return adapter.create({ model: "item", - data: { ...input, published: false, createdAt: new Date(), updatedAt: new Date() }, + data: { + ...input, + published: input.published ?? false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) +} + +/** + * @remarks Operation validation, authorization, and lifecycle hooks are NOT + * called. The caller owns those concerns. + */ +export async function updateItem( + adapter: Adapter, + id: string, + input: UpdateItemInput, +): Promise { + return adapter.update({ + model: "item", + where: [{ field: "id", value: id }], + update: { ...input, updatedAt: new Date() }, + }) +} + +/** + * @remarks Operation validation, authorization, and lifecycle hooks are NOT + * called. The caller owns those concerns. + */ +export async function deleteItem(adapter: Adapter, id: string): Promise { + await adapter.delete({ + model: "item", + where: [{ field: "id", value: id }], + }) +} +``` + +**`api/operations.ts`** — the single validated, authorized operation inventory +used by HTTP routes, request-scoped server calls, and trusted jobs: + +```typescript +// packages/stack/src/plugins/your-plugin/api/operations.ts +import type { DBAdapter as Adapter } from "@btst/db" +import { defineOperation } from "@btst/stack/plugins/api" +import { z } from "zod" +import { myPermissions } from "../permissions" +import { createItemSchema, updateItemSchema } from "../schemas" +import type { Item } from "../types" +import { listItems } from "./getters" +import { + createItem, + deleteItem, + updateItem, +} from "./mutations" + +/** Lifecycle callbacks composed around the item operations. */ +export interface MyBackendHooks { + /** Runs before an item is created. */ + onBeforeCreateItem?: (data: unknown, ctx: { headers?: Headers }) => Promise | void + /** Runs after an item is created. */ + onAfterCreateItem?: (item: unknown, ctx: { headers?: Headers }) => Promise | void + /** Runs when item creation fails. */ + onErrorCreateItem?: (error: Error, ctx: { headers?: Headers }) => Promise | void +} + +const updateOperationSchema = z.object({ + id: z.string(), + data: updateItemSchema, +}) +const itemIdSchema = z.object({ id: z.string() }) + +function serializeItem(item: Item) { + return { + ...item, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + } +} + +const hookContext = (request?: Request) => ({ + ...(request ? { headers: request.headers } : {}), +}) + +export function createMyOperations( + adapter: Adapter, + hooks?: MyBackendHooks, +) { + const listItemsOperation = defineOperation({ + input: z.object({}), + permission: myPermissions.item.list, + facts: () => undefined, + execute: async () => (await listItems(adapter)).map(serializeItem), + }) + + const createItemOperation = defineOperation({ + input: createItemSchema, + permission: myPermissions.item.create, + facts: () => undefined, + before: async ({ input, request }) => { + await hooks?.onBeforeCreateItem?.(input, hookContext(request)) + }, + execute: async ({ input }) => serializeItem(await createItem(adapter, input)), + after: async ({ result, request }) => { + await hooks?.onAfterCreateItem?.(result, hookContext(request)) + }, + onError: async ({ error, request }) => { + const cause = error instanceof Error ? error : new Error("Create item failed") + await hooks?.onErrorCreateItem?.(cause, hookContext(request)) + }, + }) + + const updateItemOperation = defineOperation({ + input: updateOperationSchema, + permission: myPermissions.item.update, + facts: () => undefined, + execute: async ({ input }) => { + const item = await updateItem(adapter, input.id, input.data) + if (!item) throw new Error("Item not found") + return serializeItem(item) + }, + }) + + const deleteItemOperation = defineOperation({ + input: itemIdSchema, + permission: myPermissions.item.delete, + facts: () => undefined, + execute: async ({ input }) => { + await deleteItem(adapter, input.id) + return { success: true } as const + }, }) + + return { + listItems: listItemsOperation, + createItem: createItemOperation, + updateItem: updateItemOperation, + deleteItem: deleteItemOperation, + } as const } ``` @@ -316,69 +526,44 @@ export async function createItem(adapter: Adapter, input: CreateItemInput): Prom ```typescript // packages/stack/src/plugins/your-plugin/api/plugin.ts -import { defineBackendPlugin, createEndpoint, type Adapter } from "@btst/stack/plugins/api" +import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api" import { mySchema } from "../db" import { createItemSchema, updateItemSchema } from "../schemas" -import { listItems, getItemById } from "./getters" +import { createMyOperations, type MyBackendHooks } from "./operations" -export interface MyBackendHooks { - onBeforeCreate?: (data: unknown, ctx: { headers: Headers }) => Promise | void - onAfterCreate?: (item: unknown, ctx: { headers: Headers }) => Promise | void - onCreateError?: (error: Error, ctx: { headers: Headers }) => Promise | void +/** Configuration accepted by `myBackendPlugin`. */ +export interface MyBackendPluginOptions { + /** Lifecycle callbacks composed around plugin operations. */ + hooks?: MyBackendHooks } -export const myBackendPlugin = (hooks?: MyBackendHooks) => +export const myBackendPlugin = (options: MyBackendPluginOptions = {}) => defineBackendPlugin({ - name: "your-plugin", + id: "yourPlugin", dbPlugin: mySchema, - - api: (adapter) => ({ - listItems: () => listItems(adapter), - getItemById: (id: string) => getItemById(adapter, id), - }), - - routes: (adapter: Adapter) => { - const listItemsEndpoint = createEndpoint("/items", { method: "GET" }, async () => { - return listItems(adapter) - }) - - const createItemEndpoint = createEndpoint( + operations: (adapter) => createMyOperations(adapter, options.hooks), + routes: (_adapter, _context, operations) => { + const listItems = createEndpoint( "/items", - { method: "POST", body: createItemSchema }, - async (ctx) => { - if (hooks?.onBeforeCreate) { - try { - await hooks.onBeforeCreate(ctx.body, { headers: ctx.headers }) - } catch (e) { - throw ctx.error(403, { message: e instanceof Error ? e.message : "Unauthorized" }) - } - } - const item = await adapter.create({ model: "item", data: { ...ctx.body, createdAt: new Date(), updatedAt: new Date() } }) - await hooks?.onAfterCreate?.(item, { headers: ctx.headers }) - return item - }, + { method: "GET", requireRequest: true }, + operations.listItems.route(() => ({})), ) - - const updateItemEndpoint = createEndpoint( + const createItem = createEndpoint( + "/items", + { method: "POST", body: createItemSchema, requireRequest: true }, + operations.createItem.route((ctx) => ctx.body), + ) + const updateItem = createEndpoint( "/items/:id", - { method: "PUT", body: updateItemSchema }, - async (ctx) => { - const updated = await adapter.update({ - model: "item", - where: [{ field: "id", value: ctx.params.id }], - update: { ...ctx.body, updatedAt: new Date() }, - }) - if (!updated) throw ctx.error(404, { message: "Item not found" }) - return updated - }, + { method: "PUT", body: updateItemSchema, requireRequest: true }, + operations.updateItem.route((ctx) => ({ id: ctx.params.id, data: ctx.body })), ) - - const deleteItemEndpoint = createEndpoint("/items/:id", { method: "DELETE" }, async (ctx) => { - await adapter.delete({ model: "item", where: [{ field: "id", value: ctx.params.id }] }) - return { success: true } - }) - - return { listItemsEndpoint, createItemEndpoint, updateItemEndpoint, deleteItemEndpoint } as const + const deleteItem = createEndpoint( + "/items/:id", + { method: "DELETE", requireRequest: true }, + operations.deleteItem.route((ctx) => ({ id: ctx.params.id })), + ) + return { listItems, createItem, updateItem, deleteItem } as const }, }) @@ -390,8 +575,16 @@ export type MyApiRouter = ReturnType["routes" ```typescript // packages/stack/src/plugins/your-plugin/api/index.ts export * from "./plugin" +export { createMyOperations, type MyBackendHooks } from "./operations" export { listItems, getItemById } from "./getters" -export { createItem, type CreateItemInput } from "./mutations" +export { + createItem, + deleteItem, + updateItem, + type CreateItemInput, + type UpdateItemInput, +} from "./mutations" +export { myPermissions } from "../permissions" ``` --- @@ -402,25 +595,63 @@ export { createItem, type CreateItemInput } from "./mutations" ```typescript // packages/stack/src/plugins/your-plugin/client/plugin.tsx -import { defineClientPlugin, createRoute, createApiClient, isConnectionError } from "@btst/stack/plugins/client" +import { + createApiClient, + defineClientPlugin, + defineRoute, + isConnectionError, + type ResolvedClientPluginRuntime, +} from "@btst/stack/plugins/client" import { lazy } from "react" import type { QueryClient } from "@tanstack/react-query" import type { MyApiRouter } from "../api/plugin" +export const MY_PLUGIN_ID = "yourPlugin" as const + export interface MyClientConfig { + title?: string +} + +interface ResolvedMyClientConfig { + title: string queryClient: QueryClient apiBaseURL: string apiBasePath: string siteBaseURL: string siteBasePath: string + headers?: Headers + credentials?: RequestCredentials } -function myLoader(config: MyClientConfig) { +function resolveMyClientConfig( + config: MyClientConfig, + runtime: ResolvedClientPluginRuntime, +): ResolvedMyClientConfig { + return { + title: config.title ?? "My Plugin", + queryClient: runtime.queryClient, + apiBaseURL: runtime.api.baseURL, + apiBasePath: runtime.api.basePath, + siteBaseURL: runtime.site.baseURL, + siteBasePath: runtime.site.basePath, + ...(runtime.api.headers ? { headers: runtime.api.headers } : {}), + ...(runtime.api.credentials + ? { credentials: runtime.api.credentials } + : {}), + } +} + +function myLoader(config: ResolvedMyClientConfig) { return async () => { if (typeof window === "undefined") { - const { queryClient, apiBaseURL, apiBasePath } = config + const { queryClient, apiBaseURL, apiBasePath, headers, credentials } = config try { - const client = createApiClient({ baseURL: apiBaseURL, basePath: apiBasePath }) + const client = createApiClient({ + baseURL: apiBaseURL, + basePath: apiBasePath, + headers, + credentials, + }) await queryClient.prefetchQuery({ queryKey: ["your-plugin", "items"], queryFn: async () => (await client("/items", { method: "GET" })).data, @@ -429,7 +660,7 @@ function myLoader(config: MyClientConfig) { if (isConnectionError(error)) { console.warn( "[btst/your-plugin] route.loader() failed — no server at build time. " + - "Use myStack.api['your-plugin'].prefetchForRoute() for SSG.", + "Use myStack.raw.yourPlugin.prefetchForRoute() for SSG.", ) } // Do not re-throw — let React Query store errors and Error Boundaries handle them during render @@ -438,38 +669,48 @@ function myLoader(config: MyClientConfig) { } } -function myMeta(config: MyClientConfig) { +function myMeta(config: ResolvedMyClientConfig) { return () => { const { siteBaseURL, siteBasePath } = config return [ - { title: "My Plugin" }, + { title: config.title }, { name: "description", content: "My plugin description." }, { property: "og:url", content: `${siteBaseURL}${siteBasePath}/your-plugin` }, ] } } -export const myClientPlugin = (config: MyClientConfig) => - defineClientPlugin({ - name: "your-plugin", +const ListPage = lazy(() => + import("./components/pages/list-page").then((m) => ({ default: m.ListPageComponent })), +) + +function createResolvedMyPlugin(config: ResolvedMyClientConfig) { + return { routes: () => ({ - list: createRoute("/your-plugin", () => { - const ListPage = lazy(() => - import("./components/pages/list-page").then((m) => ({ default: m.ListPageComponent })), - ) - return { - PageComponent: ListPage, - loader: myLoader(config), - meta: myMeta(config), - } + list: defineRoute("/your-plugin", { + page: ListPage, + loader: myLoader(config), + meta: myMeta(config), }), }), sitemap: async () => [ { url: `${config.siteBaseURL}${config.siteBasePath}/your-plugin`, lastModified: new Date(), priority: 0.7 }, ], + } +} + +export const myClientPlugin = (config: MyClientConfig = {}) => + defineClientPlugin()({ + id: MY_PLUGIN_ID, + resolve: (runtime) => + createResolvedMyPlugin(resolveMyClientConfig(config, runtime)), }) ``` +The public factory accepts only plugin-specific options. Consumers configure +the shared API, site, QueryClient, and optional request headers once on +`createClientStack()`. + **Page component wrapper** (`list-page.tsx`) — wraps with `ComposedRoute` for Suspense + ErrorBoundary: ```typescript @@ -624,7 +865,7 @@ Then register it in the codegen project overlay files: **`scripts/codegen/files/nextjs/lib/stack-client.tsx`** — add the client plugin registration. -**`scripts/codegen/files/nextjs/app/pages/layout.tsx`** — add the `StackProvider` override entry. +**`scripts/codegen/files/nextjs/app/pages/client-layout.tsx`** — add the shared client `StackProvider` override entry. Keep trusted request-origin hydration in `app/(request)/pages/layout.tsx` and the header-free SSG/ISR wrapper in `app/(static)/pages/layout.tsx`. Add the plugin CSS to `app/globals.css` if it ships styles: @@ -759,7 +1000,7 @@ npm install @btst/stack ## Hooks - + ``` Preview locally: @@ -821,11 +1062,11 @@ Before opening a pull request for a new plugin, verify every item: **Plugin implementation** -- [ ] Backend plugin: `name`, `dbPlugin`, and `routes` are all present -- [ ] Client plugin: `name` and `routes` are present +- [ ] Backend plugin: camelCase `id`, `dbPlugin`, `operations`, and operation-bound `routes` are present +- [ ] Client plugin: the matching camelCase `id` and `resolve(runtime)` returning `routes` are present - [ ] `api/getters.ts` contains only pure DB reads — no HTTP context, no lifecycle hooks -- [ ] `api/getters.ts` has JSDoc noting "Authorization hooks are NOT called" -- [ ] `api/mutations.ts` (if present) has JSDoc noting "Authorization hooks are NOT called" +- [ ] `api/getters.ts` has JSDoc noting operation validation, authorization, and lifecycle hooks are not called +- [ ] `api/mutations.ts` (if present) has the same JSDoc and says the caller owns those concerns - [ ] `api/index.ts` re-exports all public backend surface (getters, mutations, types, router type) - [ ] `api/query-key-defs.ts` defines shared key shapes imported by both `query-keys.ts` and `prefetchForRoute` - [ ] `api/serializers.ts` converts `Date` fields to ISO strings before `setQueryData` @@ -851,7 +1092,7 @@ Before opening a pull request for a new plugin, verify every item: - [ ] `packages/cli/src/utils/constants.ts` — `PLUGINS` array updated with new plugin entry - [ ] `scripts/codegen/files/nextjs/lib/stack.ts` — backend plugin registered - [ ] `scripts/codegen/files/nextjs/lib/stack-client.tsx` — client plugin registered -- [ ] `scripts/codegen/files/nextjs/app/pages/layout.tsx` — StackProvider overrides added +- [ ] `scripts/codegen/files/nextjs/app/pages/client-layout.tsx` — StackProvider overrides added; request/static wrappers remain origin-safe - [ ] Codegen project rebuilt and E2E passes: `bash scripts/codegen/setup-nextjs.sh && pnpm -F e2e codegen:e2e:nextjs` **Tests** diff --git a/README.md b/README.md index 7a1ab7dcf..6662d170b 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,12 @@ Enable the features you need and keep building your product. | **Media** | Media library with uploads, folders, picker UI, URL registration, and reusable image inputs | | **OpenAPI** | Auto-generated API documentation with interactive Scalar UI | | **Route Docs** | Auto-generated client route documentation with interactive navigation | -| **Better Auth UI** | Beautiful shadcn/ui authentication components for better-auth | | **Comments** | Commenting system with moderation, likes, and nested replies | -Each plugin ships **frontend + backend together**: -routes, APIs, database models, React components, SSR, and SEO — already wired. +Full-stack plugins ship separate frontend and backend definitions: routes, +APIs, database models, React components, SSR, and SEO—already wired. Intentional +one-sided plugins stay one-sided: OpenAPI is backend-only, Route Docs is +client-only, and UI Builder is client-only over CMS. **Want a specific plugin?** [Open an issue](https://github.com/better-stack-ai/better-stack/issues/new) and let us know! @@ -62,55 +63,169 @@ You keep your codebase, database, and deployment. --- -## Minimal usage +## Minimal setup (Next.js) ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" import { createMemoryAdapter } from "@btst/adapter-memory" -export const { handler, dbSchema } = stack({ - basePath: "/api/data", - plugins: { - blog: blogBackendPlugin() - }, - adapter: (db) => createMemoryAdapter(db)({}) -}) +function createAppStack() { + return createBackendStack({ + basePath: "/api/data", + plugins: { + blog: blogBackendPlugin() + }, + adapter: (db) => createMemoryAdapter(db)({}) + }) +} + +type AppStack = ReturnType +const globalForStack = globalThis as typeof globalThis & { + __btst_stack__?: AppStack +} +export const myStack = globalForStack.__btst_stack__ ??= createAppStack() +export const { handler, dbSchema } = myStack ``` ```tsx title="lib/stack-client.tsx" -import { createStackClient } from "@btst/stack/client" +import { + createClientStack, + type ClientPluginEndpointOverride, +} from "@btst/stack/client" import { blogClientPlugin } from "@btst/stack/plugins/blog/client" -import { QueryClient } from "@tanstack/react-query" - -export const getStackClient = (queryClient: QueryClient) => - createStackClient({ +import type { QueryClient } from "@tanstack/react-query" + +export interface StackClientOptions { + apiOrigin: string + siteOrigin: string +} + +export function createAppClientStack( + queryClient: QueryClient, + options: StackClientOptions & { headers?: HeadersInit }, +) { + const { apiOrigin, siteOrigin } = options + const crossOriginBlogEndpoint = apiOrigin === siteOrigin + ? undefined + : { + api: { + baseURL: apiOrigin, + basePath: "/api/data", + credentials: "include", + }, + } satisfies ClientPluginEndpointOverride + + return createClientStack({ + api: { + baseURL: apiOrigin, + basePath: "/api/data", + ...(options.headers ? { headers: options.headers } : {}), + }, + site: { baseURL: siteOrigin, basePath: "/pages" }, + queryClient, plugins: { - blog: blogClientPlugin({ - apiBaseURL: "http://localhost:3000", - apiBasePath: "/api/data", - siteBaseURL: "http://localhost:3000", - siteBasePath: "/pages", - queryClient, - }) - } + blog: blogClientPlugin() + }, + ...(crossOriginBlogEndpoint + ? { endpoints: { blog: crossOriginBlogEndpoint } } + : {}), }) +} + +export function getStackClient( + queryClient: QueryClient, + options: StackClientOptions, +) { + return createAppClientStack(queryClient, options) +} ``` -Now you have a working blog with API, pages, SSR, and SEO. See the [full installation guide](https://www.better-stack.ai/docs/installation) for database adapters, auth hooks, and framework-specific setup. +The generated `lib/stack-client.server.ts` resolves these origins from trusted +deployment configuration (`BTST_API_URL` and `BTST_SITE_URL`), defaults the API +to the trusted site origin, and forwards filtered credentials only to that API. +It fails closed in production if no trusted origin is available. +Existing same-origin Next.js installs may keep `NEXT_PUBLIC_BASE_URL` while +migrating; new deployments should prefer the separate site/API variables. -## Database schemas & migrations +Use the v3 framework entry factories for the two catch-all routes: -Optional CLI to generate schemas and run migrations from enabled plugins: +```ts title="app/api/data/[[...all]]/route.ts" +import { toNextRouteHandlers } from "@btst/stack/next" +import { handler } from "@/lib/stack" -```bash -npm install -D @btst/cli +export const { GET, POST, PUT, PATCH, DELETE } = + toNextRouteHandlers(handler) +``` + +```tsx title="app/(request)/pages/[[...all]]/page.tsx" +import { createNextPage } from "@btst/stack/next" +import { headers } from "next/headers" +import { getStackClientForRequest } from "@/lib/stack-client.server" +import { getOrCreateQueryClient } from "@/lib/query-client" + +export const dynamic = "force-dynamic" + +const page = createNextPage({ + getStackClient: async (queryClient) => + getStackClientForRequest(queryClient, { + headers: new Headers(await headers()), + }), + getQueryClient: getOrCreateQueryClient, +}) +export default page.Page +export const generateMetadata = page.generateMetadata +``` + +The request layout at `app/(request)/pages/layout.tsx` hydrates trusted client +origins into the shared provider in `app/pages/client-layout.tsx`. Put SSG/ISR +routes under `app/(static)/pages` with a header-free layout; route groups do not +change the public `/pages/*` URLs. + +Wrap the pages subtree with one `StackProvider`: + +```tsx +// app/pages/client-layout.tsx +"use client" + +function PagesClientLayout({ children, clientOrigins }: { + children: React.ReactNode + clientOrigins: StackClientOptions +}) { + const queryClient = getOrCreateQueryClient() + const clientStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) + + return ( + + + {children} + + + ) +} ``` -Generate drizzle schema: +API, site, and QueryClient runtime belong on the resolved client stack; router +and auth services belong on the provider. Plugin overrides contain only +plugin-specific customization. See the [full installation guide](https://www.better-stack.ai/docs/installation) +for QueryClient wiring, database adapters, all three frameworks, and auth. + +## Database schemas & migrations + +Generate schemas and run migrations through the v3 codegen CLI. It runs the +aligned Better DB CLI in isolation, so its dependencies and `btst` binary do +not enter your application graph: ```bash -npx @btst/cli generate --orm drizzle --config lib/stack.ts --output db/schema.ts +npx @btst/codegen@0.2.0 generate --orm drizzle --config lib/stack.ts --output db/schema.ts ``` Supports Prisma, Drizzle, MongoDB and Kysely SQL dialects. diff --git a/biome.json b/biome.json index dfa490efa..bd2eeb002 100644 --- a/biome.json +++ b/biome.json @@ -64,6 +64,8 @@ "!**/.btst-stack-src/**", "!**/.btst-stack-ui/**", "!**/packages/stack/registry/**", + "!**/packages/stack/scripts/fixtures/registry/**", + "!**/packages/cli/scripts/fixtures/legacy-next/**", "!**/codegen-projects/**", "!**/playwright-report-codegen/trace/**" ] diff --git a/docs/assets/better-auth-ui-demo.webp b/docs/assets/better-auth-ui-demo.webp deleted file mode 100644 index 637d34458..000000000 Binary files a/docs/assets/better-auth-ui-demo.webp and /dev/null differ diff --git a/docs/content/docs/api-reference.mdx b/docs/content/docs/api-reference.mdx index db6096c74..90746de7e 100644 --- a/docs/content/docs/api-reference.mdx +++ b/docs/content/docs/api-reference.mdx @@ -5,21 +5,21 @@ description: Autogenerated API reference for BTST ## Backend (`@btst/stack/api`) -### stack +### createBackendStack - + ### BackendPlugin -### BackendLibConfig +### BackendStackConfig - + -### BackendLib +### BackendStack - + ### toNodeHandler @@ -27,21 +27,71 @@ Re-exported from `better-call/node`. Converts a BTST handler to a Node.js compat ## Client (`@btst/stack/client`) -### createStackClient +### createClientStack - +Configure the shared API location, site location, React Query client, and optional server request headers once. Runtime-independent client plugin definitions receive the resolved values when the stack is created. + +```ts +const clientStack = createClientStack({ + api: { + baseURL: "https://app.example.com", + basePath: "/api/data", + headers: requestHeaders, // server/request stacks only + }, + site: { + baseURL: "https://app.example.com", + basePath: "/pages", + }, + queryClient, + plugins: { + example: exampleClientPlugin(), + }, +}) +``` + +Use `endpoints..api` or `endpoints..site` only when one plugin implements its BTST contract at another endpoint. A path-only API replacement inherits the top-level origin and server request headers. Supplying another origin requires its own `basePath` and does not inherit request headers. An empty plugin endpoint block inherits both top-level locations unchanged. + +Headers deliberately exposed to browser code use the explicit `browserHeaders` field and appear under the same name in the provider projection. Cookie, authorization, proxy-authorization, and set-cookie values are rejected there; keep request credentials in server-only `api.headers`. Cross-origin browser `credentials` must also be explicit. + +The returned `provider` value contains API/site/query information for browser consumers, but never top-level server request headers. Browser-created stacks reject `api.headers` rather than silently serializing them. + +### ResolvedClientStackConfig + + + +### ResolvedClientPluginRuntime + + + +### ClientPluginDefinition + + + +A definition may declare `providerConfig` when its browser components need a +small subset of plugin factory values. The resolved provider projection exposes +the exact inferred shape as `plugins..config`. These values must be safe to +expose in the browser: do not include secrets, request headers, server-only +objects, or the shared API/site/QueryClient runtime. + +### ClientProviderPluginRuntime + + + +`config` is present only for definitions that project browser-safe factory +values through `providerConfig`; it is not a provider override and applications +do not configure it on `StackProvider`. ### ClientPlugin -### ClientLib +### ClientStack - + -### ClientLibConfig +### ResolvedClientStack - + ### SitemapEntry @@ -103,6 +153,64 @@ type Sitemap = Array; +### usePluginSiteNavigation + + + +Use this client hook for links and programmatic navigation owned by a plugin. Pass the +same literal plugin ID used in `createClientStack({ plugins })`; the hook reads that +plugin's resolved site endpoint, including any `endpoints..site` override. + +`resolve(...segments)` returns the canonical `path`, the rendered `href`, and whether +the destination is cross-origin. Same-origin links use the configured router and a +path-only `href`. Cross-origin links use an absolute `href` that is stable during SSR +and hydration, and `navigate()` performs a full-page browser navigation. Without a +router adapter, `navigate()` also falls back to full-page navigation. Root-mounted +sites are normalized to one leading slash. + +```tsx +"use client" + +import { usePluginSiteNavigation } from "@btst/stack/context" + +const TODOS_PLUGIN_ID = "todos" as const + +export function AddTodoLink() { + const { Link, resolve } = usePluginSiteNavigation(TODOS_PLUGIN_ID) + return Add Todo +} +``` + ### useBasePath + +### joinBasePath + + + +Join the resolved site base path to a plugin route before passing it to a link or router navigation function. This produces one path separator even when the configured base path is `/`. + +Prefer `usePluginSiteNavigation(pluginId)` inside plugin browser components so +per-plugin site endpoint overrides are honored. Keep `joinBasePath` for non-hook or +server-side path composition when you already have the correct resolved base path. + +### useNotify + + + +Notifications are configured via the `notify` prop on `StackProvider`. Without an override, `useNotify()` routes to sonner toasts. + +### useTranslate + + + +Without an `i18n` provider, `useTranslate()` returns the English default with `{{param}}` interpolation. + +### useListState + +URL-synced list state (filters, tabs, pagination) via the router's `getSearchParams` / `setSearchParams` contract. Import `useListState` from the client-only `@btst/stack/client/hooks` entry. Server-safe parsing and serialization helpers remain available from `@btst/stack/client`. + +For SSR loaders, read initial state with `parseListStateFromSearchParams(namespace, schema, requestSearchParams)`. + + diff --git a/docs/content/docs/auth.mdx b/docs/content/docs/auth.mdx new file mode 100644 index 000000000..249db75ca --- /dev/null +++ b/docs/content/docs/auth.mdx @@ -0,0 +1,766 @@ +--- +title: Authorization +description: Define schema-backed permissions once and enforce the same rule in the browser and backend +--- + +BTST's one-rule authorization path keeps a rule-free permission contract separate from the browser-safe local rule set. Client and server adapters resolve identity independently. Browser checks only control presentation; backend operations derive trusted facts and enforce the same rule before mutation. + +Authorization is opt-in. Omitting server authorization preserves permissive v2 behavior; once `serverAuth` is configured, protected operations deny missing rules and anonymous or denied requests fail closed. + +## Define the shared contract + +Plugins publish stable, schema-backed permissions. The complete Blog operation catalog is available from its browser-safe entry point: + +```ts title="lib/authorization-contract.ts" +import { defineAuthorizationContract } from "@btst/stack/authorization"; +import { blogPermissions } from "@btst/stack/plugins/blog/permissions"; +import { z } from "zod"; + +export const authorizationContract = defineAuthorizationContract({ + identity: z.object({ + id: z.string(), + role: z.enum(["user", "admin"]), + }), + permissions: [blogPermissions] as const, +}); +``` + +Keep the local rules in a separate browser-safe module: + +```ts title="lib/authorization.ts" +import { defineAuthorization } from "@btst/stack/authorization"; +import { authorizationContract } from "./authorization-contract"; + +export const authorization = defineAuthorization({ + contract: authorizationContract, + rules: ({ blog }) => [ + blog.post.read.when(({ identity, facts }) => { + if (facts.scope === "published") return true; + if (facts.scope === "post" && (!facts.exists || facts.published)) return true; + return identity?.role === "admin" || + (facts.scope === "post" && identity?.id === facts.authorId); + }), + blog.post.create.when(({ identity, facts }) => + identity !== null && + (facts.publish === "draft" || identity.role === "admin") + ), + blog.post.update.when(({ identity, facts }) => + identity !== null && + (identity.role === "admin" || + (identity.id === facts.authorId && facts.publish === "unchanged")) + ), + blog.post.delete.when(({ identity, facts }) => + identity !== null && + (identity.role === "admin" || identity.id === facts.authorId) + ), + blog.tag.read.allow(), + ], +}); +``` + +The identity and permission facts are inferred from their schemas. The contract contains only those schemas, stable permission ids, and an automatically derived `version`; it does not contain the rules. It can therefore live in a small shared package without backend code, database imports, secrets, or React. `authorization.contract` exposes the same rule-free object when starting from a local authorization instance. + +Portable contract schemas must be fully representable as JSON Schema. BTST rejects custom refinements, transforms, and other opaque schema behavior at contract definition time so an automatically derived version can never silently omit validation logic. Prefer explicit Zod constraints such as enums, string formats, ranges, and object shapes. + +A missing rule denies access once authorization is enabled. Use `.allow()` for an explicit unconditional rule. The example makes public published-post and tag reads intentional while protecting draft collections, draft details, mutation, and publish transitions. + +## Bind the browser adapter + +Keep client identity resolution in a client module. Permission evaluation is synchronous and local: it does not make an authorization request or install a permission cache. + +```tsx title="lib/authorization.client.ts" +"use client"; + +import { createClientAuth } from "@btst/stack/authorization/client"; +import { authorization } from "./authorization"; + +export const clientAuth = createClientAuth({ + authorization, + getIdentity: () => session?.user ?? null, + loginPath: "/auth/sign-in", +}); +``` + +Install that exact adapter on `StackProvider`, then use its bound hooks and component. Invalid facts and permissions outside the registered catalogs fail at typecheck time. + +```tsx +import { StackProvider } from "@btst/stack/context"; +import { blogPermissions } from "@btst/stack/plugins/blog/permissions"; +import { clientAuth } from "@/lib/authorization.client"; + +function UpdateControl({ post }) { + const { CanAccess } = clientAuth; + + return ( + + + + ); +} + + + {children} +; +``` + +`clientAuth.useIdentity()` preserves the exact inferred identity type. `clientAuth.useCan(permission)` and `clientAuth.CanAccess` are bound to the same registered permissions. + +## Use a managed or separate backend + +Publish only `authorizationContract` and the permission descriptors to the frontend. A remote evaluator keeps the same bound hook API while the managed backend remains authoritative: + +```tsx title="lib/authorization.client.ts" +"use client"; + +import { createClientAuth } from "@btst/stack/authorization/client"; +import { createRemoteAuthorizationEvaluator } from "@btst/stack/authorization/remote"; +import { authorizationContract } from "@acme/backend-contract"; + +const evaluator = createRemoteAuthorizationEvaluator({ + contract: authorizationContract, + transport: async (request) => { + const response = await fetch("/api/authorization/evaluate", { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); + return response.json(); + }, +}); + +export const clientAuth = createClientAuth({ + evaluator, + getIdentity: () => session?.user ?? null, +}); +``` + +The wire request contains the contract version, stable permission id, and schema-validated facts. Facts must also be JSON-safe data; the evaluator rejects values such as `bigint`, functions, non-finite numbers, class instances, and cycles before calling the transport. It intentionally does not send the browser identity as trusted input. The backend strictly validates the request against its contract and resolves identity from its own session: + +```ts title="authorization-handler.ts" +import { parseRemoteAuthorizationRequest } from "@btst/stack/authorization/remote"; +import { blogPermissions } from "@btst/stack/plugins/blog/permissions"; +import { authorizationContract } from "./authorization-contract"; +import { authorization } from "./authorization"; + +export async function evaluateAuthorization(request: Request) { + const body = await request.json(); + const parsed = parseRemoteAuthorizationRequest(authorizationContract, body); + const identity = await getIdentityFromSession(request); + + let trustedPermission; + switch (parsed.permission.id) { + case blogPermissions.post.read.id: { + const facts = parsed.permission.facts; + if (facts.scope !== "post") { + trustedPermission = blogPermissions.post.read(facts); + break; + } + const post = await database.posts.findBySlug(facts.slug); + trustedPermission = blogPermissions.post.read({ + scope: "post", + slug: facts.slug, + exists: post !== null, + ...(post ? { + id: post.id, + authorId: post.authorId, + published: post.published, + } : { published: false }), + }); + break; + } + case blogPermissions.post.create.id: + // `publish` is the validated action the UI is asking about. The create + // operation independently derives it again from its validated input. + trustedPermission = blogPermissions.post.create({ + publish: parsed.permission.facts.publish, + }); + break; + case blogPermissions.post.update.id: { + const post = await database.posts.findById(parsed.permission.facts.id); + trustedPermission = blogPermissions.post.update({ + id: parsed.permission.facts.id, + authorId: post?.authorId, + publish: parsed.permission.facts.publish, + }); + break; + } + case blogPermissions.post.delete.id: { + const post = await database.posts.findById(parsed.permission.facts.id); + trustedPermission = blogPermissions.post.delete({ + id: parsed.permission.facts.id, + authorId: post?.authorId, + }); + break; + } + case blogPermissions.tag.read.id: + trustedPermission = blogPermissions.tag.read(); + break; + default: + throw new TypeError("Unsupported permission id"); + } + + const allowed = authorization.can(trustedPermission, identity); + + return Response.json({ + version: authorizationContract.version, + allowed, + }); +} +``` + +Contract version mismatches and malformed responses throw typed protocol errors; they are never converted into ordinary denials. Remote decisions are stored only in each mounted `useCan()` hook. BTST does not install a reusable authorization cache, so installation needs no framework-specific cache wiring. + +## Bind the server adapter + +Keep server identity dependencies behind the server entry point. In Next.js, put this adapter in a `server-only` module so client imports cannot pull session or database code into the browser bundle. + +```ts title="lib/authorization.server.ts" +import "server-only"; + +import { createServerAuth } from "@btst/stack/authorization/server"; +import { authorization } from "./authorization"; +import { auth } from "./auth"; + +export const serverAuth = createServerAuth({ + authorization, + getIdentityFromHeaders: async ({ headers }) => { + const session = await auth.api.getSession({ headers }); + return session?.user ?? null; + }, +}); +``` + +Use `getIdentityFromHeaders` when a framework layout must hydrate identity +without constructing a synthetic `Request`. Existing backend-only adapters can +keep the request-aware `getIdentity({ request, headers })` callback, but those +adapters are not accepted by headers-only layout helpers until adapted. + +Pass it to `createBackendStack({ auth: serverAuth })`. Every Blog HTTP route and its matching `myStack.forRequest(request).operations.blog.*` method use the same operation and rule. Operations load authoritative post, author, and current publish facts before authorization, so client-supplied ownership or visibility facts are never trusted. Trusted jobs can call `myStack.trusted.blog.*`; trusted calls skip user authorization but retain input validation, trusted fact derivation, domain behavior, and lifecycle hooks. + +Ordinary denials become HTTP 401 for anonymous identities and 403 for authenticated identities. Identity validation and rule failures remain errors instead of being converted into denials. + +`createBackendStack()` also checks the operation catalog at typecheck time. A server adapter +that registers a different permission catalog—or reuses the same stable id +with an incompatible fact schema—cannot be composed with Blog operations. + +## Hydrate identity at the layout boundary + +Resolve identity once from the incoming request and hydrate it into the +`StackProvider` that covers the complete pages subtree. The server render and +first browser render then evaluate local rules from the same identity without +an immediate duplicate session request. + +`initialIdentity` is intentionally tri-state: + +| Value | First browser state | Browser resolver | +| --- | --- | --- | +| `undefined` or omitted | Pending | Runs immediately | +| `null` | Settled anonymous | Skipped initially | +| Validated identity | Settled authenticated | Skipped initially | + +The supplied snapshot is parsed again through the client authorization +contract. Schema and resolver failures remain observable errors. A later +`clientAuth.useIdentity().refetch()` still runs the browser resolver after +login, logout, or account switching. + +The framework helpers also parse through the server adapter's portable +`contract` and reject non-JSON-safe output before framework serialization. +Custom server identity adapters can participate by exposing `contract` plus +`getIdentity(request)` for request-aware loaders, or +`getIdentityFromHeaders({ headers })` for the Next.js layout helper. + +### Next.js + +Keep the provider and browser auth in a client boundary, then create the server +layout through the server-only framework entry: + +```tsx title="app/pages/client-layout.tsx" +"use client"; + +import { StackProvider } from "@btst/stack/context"; +import { clientAuth } from "@/lib/authorization.client"; +import { getOrCreateQueryClient } from "@/lib/query-client"; +import { getStackClient, type StackClientOrigins } from "@/lib/stack-client"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; + +export function BtstPagesClientLayout({ children, clientOrigins, initialIdentity }: { + children?: ReactNode; + clientOrigins?: StackClientOrigins; + initialIdentity?: Awaited>; +}) { + const queryClient = getOrCreateQueryClient(); + const stack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins?.apiOrigin, clientOrigins?.siteOrigin, queryClient], + ); + return ( + + {children} + + ); +} +``` + +```tsx title="app/(request)/pages/layout.tsx" +import { createNextLayout } from "@btst/stack/next/server"; +import { serverAuth } from "@/lib/authorization.server"; +import { BtstPagesClientLayout } from "@/app/pages/client-layout"; +import { getRequestClientOrigins } from "@/lib/stack-client.server"; + +export const dynamic = "force-dynamic"; + +const layout = createNextLayout({ + auth: serverAuth, + ClientLayout: BtstPagesClientLayout, + resolveClientOrigins: getRequestClientOrigins, +}); + +export default layout.Layout; +``` + +Only the schema-validated identity and deployment-trusted API/site origins +cross the Server Component boundary. +`next/headers`, the session provider, and database dependencies stay in the +server graph. Because the layout reads request headers, Next.js must render its +subtree per request. If the application also has SSG/ISR pages, put them in a +separate route group with a client layout that omits `initialIdentity`. Static +pages then keep full-route caching and resolve identity in the browser, while +the request-aware group keeps server identity hydration for its entire provider +subtree. + +```tsx title="app/(static)/pages/layout.tsx" +import { BtstPagesClientLayout } from "@/app/pages/client-layout"; +import { getServerClientOrigins } from "@/lib/stack-client.server"; +import type { ReactNode } from "react"; + +export default function StaticPagesLayout({ children }: { children?: ReactNode }) { + // Identity stays undefined; trusted origins are embedded in static output. + return ( + + {children} + + ); +} +``` + +Route-group folders do not change URLs: both subtrees still render under +`/pages`. True static output cannot contain a per-request identity; choosing the +static group intentionally chooses the `undefined` branch of the tri-state +contract. Both route groups still hydrate the same server-resolved API/site +snapshot, including when `BTST_API_URL` points at a managed backend. + +This integration targets conventional Next.js route-segment caching with +`cacheComponents` disabled. Cache Components ignores `dynamic` segment config +and requires an application-owned Suspense/PPR composition for request APIs; +`createNextLayout` does not install that caching architecture in v3. + +### React Router + +Use the parent layout loader so its snapshot covers the complete `` +subtree: + +```tsx title="app/routes/pages/_layout.tsx" +import { StackProvider } from "@btst/stack/context"; +import { createReactRouterLayout } from "@btst/stack/react-router"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { Outlet, useLoaderData, type LoaderFunctionArgs } from "react-router"; +import { clientAuth } from "~/lib/authorization.ui"; +import { serverAuth } from "~/lib/authorization.server"; +import { getOrCreateQueryClient } from "~/lib/query-client"; +import { getStackClient } from "~/lib/stack-client"; +import { getRequestClientOrigins } from "~/lib/stack-client.server"; + +const layout = createReactRouterLayout({ auth: serverAuth }); +export async function loader(args: LoaderFunctionArgs) { + return { + ...(await layout.loader(args)), + ...getRequestClientOrigins(args.request), + }; +} + +export default function BtstPagesLayout() { + const { apiOrigin, initialIdentity, siteOrigin } = useLoaderData(); + const queryClient = getOrCreateQueryClient(); + const stack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ); + return ( + + + + + + ); +} +``` + +### TanStack Start + +TanStack loaders are isomorphic, so resolve the request through a server +function and use the generated loader on the parent route. The server helper +produces the validated snapshot envelope required by the layout helper: + +```ts title="src/lib/authorization.identity.ts" +import { createServerFn } from "@tanstack/react-start"; +import { getRequest } from "@tanstack/react-start/server"; +import { resolveTanStackInitialIdentity } from "@btst/stack/tanstack/server"; +import { serverAuth } from "./authorization.server"; +import { getRequestClientOrigins } from "./stack-client.server"; + +export const getInitialIdentity = createServerFn({ method: "GET" }).handler( + async () => { + const request = getRequest(); + return { + ...(await resolveTanStackInitialIdentity({ + auth: serverAuth, + request, + })), + ...getRequestClientOrigins(request), + }; + }, +); +``` + +```tsx title="src/routes/pages/route.tsx" +import { StackProvider } from "@btst/stack/context"; +import { createTanStackLayout } from "@btst/stack/tanstack"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { clientAuth } from "../../lib/authorization.ui"; +import { getInitialIdentity } from "../../lib/authorization.identity"; +import { getStackClient } from "../../lib/stack-client"; + +const layout = createTanStackLayout({ getInitialIdentity }); + +export const Route = createFileRoute("/pages")({ + loader: layout.loader, + component: BtstPagesLayout, +}); + +function BtstPagesLayout() { + const { queryClient } = Route.useRouteContext(); + const { apiOrigin, initialIdentity, siteOrigin } = Route.useLoaderData(); + const stack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ); + return ( + + + + + + ); +} +``` + +Only the validated identity and deployment-trusted API/site origins cross the +server boundary. Raw `Host`/forwarding values, request headers, and the resolved +server stack stay server-only. Configure `BTST_API_URL` and `BTST_SITE_URL`, or +use `BASE_URL` when both are same-origin; production fails closed without a +trusted site origin. BTST does not cache authorization results or require +framework middleware. + +TanStack catch-all loaders should also call `getInitialIdentity()` from their +client navigation branch and pass its `apiOrigin`/`siteOrigin` to +`getStackClient`. That server function keeps later navigations on the same +trusted managed API selected during SSR. + +## Operation lifecycle ordering + +An operation validates input, derives trusted primary facts, resolves identity, +and authorizes its primary permission. Only after that succeeds does it derive +and authorize any compound secondary permissions, then enter plugin lifecycle +hooks. A primary denial therefore cannot trigger secondary reads or be replaced +by a secondary-derivation error. Validation, fact, identity, and rule +failures—including 401 and 403 denials—do not call `before`, `after`, or +operation error hooks. Once authorization succeeds, lifecycle contexts carry +the validated `input`, trusted `facts`, resolved `identity`, and `request`; +post-execution contexts also carry `result`. + +Operation lifecycle data uses primitives, plain objects, and arrays. Validated +input, trusted facts, resolved identity, and results are deeply readonly in +TypeScript and frozen at runtime. A `before` hook therefore cannot change the +record or claims that were authorized before `execute` uses them. Mutable +built-ins such as `Date`, `Map`, `Set`, and typed arrays do not cross this +boundary; serialize them to plain data first. + +Plugin-authored hooks know the plugin's exact input, facts, and result types. +Because a reusable plugin is authored before an application chooses its +identity schema, its identity field uses the honest `StackIdentity | null` +base type. Applications can narrow provider-specific claims when needed. + +Operations have no public `run({ internal: true })` escape hatch. Use only the +transport-bound APIs: + +```ts +await myStack.forRequest(request).operations.blog.updatePost({ id: postId, data }); +await myStack.trusted.blog.updatePost({ id: postId, data }); +``` + +## Client gates + +Use the adapter returned by `createClientAuth()`. Its hooks and component are bound to the registered catalog, so misspelled ids and invalid facts fail at compile time. + +```tsx +const { identity, isPending, error, refetch } = clientAuth.useIdentity(); +const edit = clientAuth.useCan( + blogPermissions.post.update({ + id: post.id, + authorId: post.authorId ?? undefined, + publish: "unchanged", + }), +); + +return ( + + + +); +``` + +Plugin components use the catalog-agnostic `PermissionAccess` and `PermissionCheck` primitives internally, but applications should prefer the bound adapter when they want exact catalog inference. Browser checks are synchronous for a local rule and affect presentation only. The backend remains authoritative. + +When client auth is omitted, browser gates render permissively to preserve no-auth applications. This does not weaken a configured backend. + +## Route and operation semantics + +Protected routes receive one exact `permission` descriptor. Public behavior is declared on the server operation with `access: "public"`; there is no parallel string permission or route-level public bridge. + +```ts +const listPublished = defineOperation({ + input: listInput, + permission: blogPermissions.post.read, + access: "public", + facts: ({ input }) => ({ scope: "published" as const }), + execute: ({ input }) => listPosts(input), +}); +``` + +Server transports share the same operation: + +- `app.handler(request)` for HTTP +- `app.forRequest(request).operations.blog.updatePost(...)` for an authenticated request in server code +- `app.trusted.blog.updatePost(...)` for an explicitly trusted job + +The request surface validates input, derives authoritative facts, resolves identity, evaluates the rule, then runs domain behavior and lifecycle hooks. The `trusted` surface skips user authorization but keeps validation, fact derivation, domain behavior, and hooks. First-party `app.raw` namespaces expose only narrow SSG `prefetchForRoute` helpers; raw business getters and mutations are not duplicated there. + +When server authorization is configured, stack composition rejects every HTTP +route that is not bound to a same-key operation or explicitly declared as a +public infrastructure route with a rationale. This prevents route-only custom +plugins from silently bypassing the configured boundary. Route-only plugins +remain supported when server authorization is omitted. + +When server authorization is omitted, protected request operations remain permissive for compatibility. Once it is configured: + +- missing or malformed credentials produce `401` +- an authenticated denial or missing rule produces `403` +- identity resolver, fact derivation, and rule exceptions remain errors rather than ordinary denials +- explicit public operations bypass identity and rule evaluation + +There is no authorization-result cache. Rules are boolean decisions, not row or tenant filters; derive tenant scope in the operation and adapter query. + +## Lifecycle hooks are domain hooks + +Lifecycle hooks observe already-validated, deeply readonly operation context. They can enforce domain invariants, publish side effects, and record telemetry—not perform routine authorization or transform operation input. Use descriptor rules for access control. Request and trusted operations both run the documented hook sequence; raw adapter primitives are lower-level implementation tools and do not promise that lifecycle. + +## Managed and separate backends + +The frontend can share only a versioned, rule-free contract with a backend implemented elsewhere. Bind `createClientAuth` to `createRemoteAuthorizationEvaluator`; the remote service parses the same permission id, fact schema, identity schema, and contract version. Malformed JSON, invalid payloads, and version mismatches throw typed protocol errors instead of becoming denials. + +The managed backend does not need BTST or TypeScript. It only needs to honor the published protocol. Keep its authorization authoritative; the browser evaluator remains presentation logic. + +## v2 and v3 RC migration + +Remove the compatibility code instead of hiding it behind aliases. + +### Client provider and gates + +Before: + +```tsx +const auth = { + getIdentity: () => session.user, + can: ({ resource, action, params }) => resource === "blog:post" && action === "delete", +}; + +const { can } = useCan({ resource: "blog:post", action: "delete", params: { id } }); + +``` + +After: + +```tsx +const clientAuth = createClientAuth({ authorization, getIdentity: () => session.user }); +const { can } = clientAuth.useCan(blogPermissions.post.delete({ id })); + +``` + +`StackAuthProvider`, structural `can` callbacks, global string `useCan`, and string `CanAccess` props are removed. + +### Route gates + +Before: + +```tsx + +``` + +After: + +```tsx + +``` + +For a truly public route, bind it to an operation declared with `access: "public"`. `legacyPermission` and `legacyPublic` are removed. + +### Server provider and operation metadata + +Before: + +```ts +const auth: StackServerAuthProvider = { getIdentity, can }; +defineOperation({ + legacyAuthorization: { resource: "blog:post", action: "update" }, + legacyAdditionalAuthorization: (...), +}); +const identity = await getRequestIdentity(headers); +``` + +After: + +```ts +const serverAuth = createServerAuth({ authorization, getIdentity }); +defineOperation({ + permission: blogPermissions.post.update, + facts: async ({ input }) => authoritativePostFacts(input.id), + additionalPermissions: async ({ input }) => relatedDescriptors(input), + execute: ({ identity, facts, input }) => updatePost(input, identity, facts), +}); +``` + +`StackServerAuthProvider`, global request identity lookup, legacy operation mappings, and hook-based authorization are removed. Identity is available in operation and lifecycle context after the server adapter resolves it. + +### Trusted server calls + +{/* canonical-dx-guard: migration:start reason="removed server namespace example" */} + +Before: + +```ts +await app.api.cms.createContentItem("article", body); +await app.api.blog.getAllPosts(); +``` + +{/* canonical-dx-guard: migration:end */} + +After: + +```ts +await app.forRequest(request).operations.cms.createContentItem({ typeSlug: "article", body }); +await app.trusted.blog.listPosts({}); +await app.raw.blog.prefetchForRoute("posts", queryClient); +``` + +Use `forRequest(request).operations` for user-driven server work, `trusted` for explicitly trusted work, and `app.raw.*.prefetchForRoute` only for SSG prefetch. Lower-level exported adapter getters remain implementation primitives, not an authorization boundary. + +### AI Chat backend access + +Before: + +```ts +aiChatBackendPlugin({ mode: "authenticated", getUserId }); +``` + +After: + +```ts +aiChatBackendPlugin({ access: "authorized" }); +createBackendStack({ auth: serverAuth, plugins: { aiChat } }); +``` + +The backend `mode` alias and `getUserId` callback are removed. The client plugin's `mode` still controls its real conversation UI/persistence mode; it is not a security boundary. + +### Lifecycle context and Comments authorship + +{/* canonical-dx-guard: migration:start reason="removed lifecycle and identity bridge example" */} + +Before: + +```ts +commentsBackendPlugin({ + resolveCurrentUserId: (context: CommentsApiContext) => readSession(context.headers), + onBeforePost: (_input, context: CommentsApiContext) => ({ + authorId: readAuthor(context), + }), +}); + +const onBeforeCreatePost = (_input: unknown, context: BlogApiContext) => { + audit(context); +}; +``` + +{/* canonical-dx-guard: migration:end */} + +After: + +```ts +const serverAuth = createServerAuth({ authorization, getIdentity: readIdentity }); + +commentsBackendPlugin({ + hooks: { + onBeforeCreateComment: async (_input, context: CommentsCreateOperationContext) => { + await audit(context.identity, context.facts); + }, + }, +}); + +const blogHooks: BlogBackendHooks = { + onBeforeCreatePost: (_input, context: BlogCreateOperationContext) => + audit(context.identity, context.facts), +}; + +await app.trusted.comments.createComment({ + resourceId, + resourceType: "article", + body: "Automated note", + authorId: "system-job", +}); +``` + +The generic Blog and Comments lifecycle context aliases and the Comments +identity resolver option are removed. Use each hook's operation-specific, +deeply readonly context. Request authorship comes from `createServerAuth` +identity; hook return values never choose an author. Explicitly trusted +`trusted` and no-auth calls may provide `authorId` in the validated input. diff --git a/docs/content/docs/breaking-changes.mdx b/docs/content/docs/breaking-changes.mdx index 0a0b2fadc..686c77927 100644 --- a/docs/content/docs/breaking-changes.mdx +++ b/docs/content/docs/breaking-changes.mdx @@ -10,6 +10,1257 @@ This page documents breaking changes between major versions and provides migrati --- +## v2 or release candidate → stable v3: production playbook + +Use this section as the canonical migration order for a production application. +The RC2→RC3 and v2 framework sections later on this page remain detailed +references for individual mechanical changes; they are not separate upgrade +paths. + + + Do not upgrade one package at a time in a deployed application. Pin the exact + verified cohort, migrate on a branch with a database backup, and keep the old + application artifact and lockfile available until production verification is + complete. + + +### 0. Pin the verified cohort and a rollback point + +Use this exact stable v3 cohort: + +| Role | Exact version | +| --- | --- | +| Core | `@btst/stack@3.0.0` | +| Scaffold and command wrapper | `@btst/codegen@0.2.0` | +| Optional Better Auth UI companion | `@btst/better-auth-ui@2.0.0` | +| Better DB and the selected BTST adapter | `2.2.3` | +| Database CLI for generation/migration | `@btst/cli@2.2.4`, delegated by Codegen 0.2.0 from the consumer project directory | +| Better Auth and Core | `better-auth@1.6.16`, `@better-auth/core@1.6.16` | +| Better Auth utilities and transport | `@better-auth/utils@0.4.1`, `@better-fetch/fetch@1.2.2`, `better-call@1.3.6` | +| API-key and passkey declarations, when the companion is installed | `@better-auth/api-key@1.6.16`, `@better-auth/passkey@1.6.16` | + +Retain the Better Auth `1.6.16` and Better DB/adapter `2.2.3` cohorts unless a +later migration guide explicitly changes them. Do not move this release to +Better Auth `1.7.x`, and do not use a floating `latest`, `next`, caret, or +workspace range in a production migration. + +For example, a Drizzle application without the optional auth companion can pin +the stable cohort with: + +```bash +pnpm add --save-exact @btst/stack@3.0.0 @btst/adapter-drizzle@2.2.3 +pnpm add --save-dev --save-exact @btst/codegen@0.2.0 +pnpm install --frozen-lockfile +``` + +If the application selects Better Auth UI, add the complete aligned auth +cohort shown in [Better Auth UI companion](#8-migrate-the-better-auth-ui-companion) +rather than asking the package manager to repair peers opportunistically. + +Before changing code or data: + +- record the currently deployed commit, package-manager version, runtime + version, build command, start command, and environment names; +- commit the manifest and lockfile, export the generated schema, and take a + restorable database backup or provider snapshot; +- inventory every BTST plugin, embedded component, direct hook, provider root, + custom route, lifecycle hook, auth rule, and trusted/background call site; +- capture a production-like smoke baseline, including representative existing + records and anonymous, regular-user, and privileged-user behavior; and +- create a migration branch and keep the previous application artifact + deployable. Never treat a down migration as the only rollback for data that + the new application has already written. + +### 1. Apply the ownership changes in this order + +{/* canonical-dx-guard: migration:start reason="stable v3 before-and-after inventory" */} + +| Concern | Removed or intermediate shape | Stable-v3 shape | +| --- | --- | --- | +| Backend constructor | `stack(...)` | `createBackendStack(...)` from `@btst/stack/api` | +| Client constructor | `createStackClient(...)`, `stackClient(...)` | `createClientStack(...)` from `@btst/stack/client` | +| Client runtime | API, site, query client, and headers repeated in plugins/provider | one resolved `createClientStack({ api, site, queryClient, plugins })` | +| Plugin IDs | kebab-case programmatic keys such as `ai-chat` and `form-builder` | canonical camelCase keys such as `aiChat` and `formBuilder`; package paths and URL slugs stay kebab-case | +| Backend factories | positional arguments or top-level hook callbacks | zero or one options object with callbacks under `hooks` and required domain dependencies explicit | +| Lifecycle | mixed read/create/error spellings and boolean hook denials | `onBefore` / `onAfter` / `onError` and thrown domain failures | +| Provider | API/base-path fields and a manual override generic | browser-safe `stack`, framework `router`, optional `auth`, `initialIdentity`, and genuine application services | +| Overrides | empty blocks used to activate plugins or duplicated runtime/auth fields | optional inferred keys containing only plugin-specific browser or presentation customization | +| Request calls | ambiguous `api` namespace | `forRequest(request).operations` | +| Trusted calls | `internal` or a boolean bypass | `trusted`, which skips user authorization but retains validation, trusted facts, domain behavior, transactions, and lifecycle | +| Low-level calls | ordinary app code reaching exported getters/mutations | narrow `raw` prefetch escape hatches; standalone primitives remain caller-composed and are not the ordinary app API | + +{/* canonical-dx-guard: migration:end */} + +The canonical browser composition is: + +```tsx title="lib/stack-client.tsx" +const clientStack = createClientStack({ + api: { baseURL, basePath: "/api/data" }, + site: { baseURL, basePath: "/pages" }, + queryClient, + plugins: { + blog: blogClientPlugin(), + comments: commentsClientPlugin(), + }, +}) + + + {children} + +``` + +Omit `overrides` when there is no customization. An empty override does not +register or activate a plugin. The registered resolved definitions infer the +allowed keys and exact value types; do not restore a provider generic or a +manual application override map. + +Build one request-specific client stack for server loaders and metadata, with +filtered headers under `api.headers`, and a separate stable browser stack +without request headers. Only schema-validated identity and trusted API/site +origins may cross the server/client boundary. Never serialize a backend stack, +cookies, authorization headers, proxy headers, secrets, or a request-specific +client stack. + +Keep resolved client plugin definitions server-import-safe. Put React state, +browser auth clients, upload callbacks, navigation, and the provider itself in a +client-only module. A path-only per-plugin endpoint replacement inherits the +top-level origin and filtered request headers; a replacement origin is a new +transport boundary and must provide its own path and deliberately selected +credentials. Preserve that boundary instead of forwarding server credentials +to another origin. + +### 2. Register plugins, factories, and lifecycle hooks + +Register both halves of a full-stack plugin under the same canonical key. +OpenAPI is backend-only; Route Docs is client-only; UI Builder is client-only +and composes the registered CMS contract. Do not invent a matching half for a +one-sided plugin. + +Move each backend plugin to one options object and each callback to `hooks`: + +```ts +createBackendStack({ + basePath: "/api/data", + adapter, + auth: serverAuth, + plugins: { + blog: blogBackendPlugin({ hooks: { onAfterCreatePost } }), + comments: commentsBackendPlugin({ + allowEditing: false, + resolveUser, + hooks: { onBeforeCreateComment }, + }), + }, +}) +``` + +The stable lifecycle grammar is `onBefore`, +`onAfter`, and `onError`. The complete rename +inventory is in [Rename every backend lifecycle callback](#6-rename-every-backend-lifecycle-callback). +Update every used plugin, including callbacks referenced indirectly from shared +hook objects. A hook runs only after validation, authoritative fact derivation, +identity resolution, and authorization have succeeded; it is not a replacement +for permission enforcement. Hook denials throw—returning `false` is no longer a +denial. + +### 3. Configure atomic writes explicitly + +AI Chat, Form Builder, Kanban, and Media contain operations whose authorization +facts and writes must share one isolated transaction. Configure a supported +Prisma, Drizzle, or Kysely adapter with `transaction: true`; do not rely on the +sequential fallback. Form Builder does not support generated memory or MongoDB +configuration, and Media does not support generated MongoDB configuration, +because those combinations cannot provide the required isolation. + +See [Database adapters](/databases/adapters#isolated-transactions-for-atomic-plugin-writes) +for copyable adapter examples and the fail-closed behavior. + +### 4. Migrate authorization as one application-owned rule + +Plugins publish schema-backed descriptors and the minimum facts required for an +operation. The application owns its identity schema, local rules, and both +identity resolvers. Authentication discovers identity; authorization decides +whether that identity may perform a typed operation. + +Keep the rule module browser-safe: + +```ts title="lib/authorization.ts" +import { defineAuthorization } from "@btst/stack/authorization" +import { blogPermissions } from "@btst/stack/plugins/blog/permissions" +import { z } from "zod" + +export const authorization = defineAuthorization({ + identity: z.object({ id: z.string(), role: z.enum(["user", "admin"]) }), + permissions: [blogPermissions] as const, + rules: ({ blog }) => [ + blog.post.delete.when(({ identity, facts }) => + identity !== null && + (identity.role === "admin" || identity.id === facts.authorId), + ), + ], +}) +``` + +Bind it separately on each side. These modules are client-only and server-only, +respectively: + +```tsx title="lib/authorization.client.ts" +"use client" + +export const clientAuth = createClientAuth({ + authorization, + getIdentity: () => session?.user ?? null, + loginPath: "/sign-in", +}) + +const { CanAccess } = clientAuth +const control = ( + + + +) +``` + +```ts title="lib/authorization.server.ts" +import "server-only" + +export const serverAuth = createServerAuth({ + authorization, + getIdentityFromHeaders: async ({ headers }) => { + const session = await auth.api.getSession({ headers }) + return session?.user ?? null + }, +}) +``` + +The client check is a synchronous presentation decision. It makes no +permission request and creates no shared authorization-result cache. The +backend validates input, derives trusted facts from server data, resolves the +request identity, evaluates the descriptor, and only then enters domain and +lifecycle execution. A representative plugin operation binds that ordering +once: + +```ts title="plugins/posts/api.ts" +const deletePost = defineOperation({ + input: z.object({ id: z.string() }), + permission: blogPermissions.post.delete, + facts: async ({ input }) => { + const post = await adapter.findOne({ + model: "post", + where: [{ field: "id", value: input.id }], + }) + return { id: input.id, ...(post?.authorId ? { authorId: post.authorId } : {}) } + }, + execute: async ({ input }) => { + await adapter.delete({ + model: "post", + where: [{ field: "id", value: input.id }], + }) + return { success: true } as const + }, +}) +``` + +Do not accept `authorId`, role, tenant ownership, record visibility, or other +authoritative facts from the browser merely because the same shapes are used +for a local UI preview. Row and tenant query scoping is a separate server-only +data concern, not a boolean authorization check. + +Once server authorization is enabled, a missing rule denies. Ordinary anonymous +and authenticated denials become 401 and 403, respectively. Invalid identity, +schema, transport, fact derivation, and policy execution remain observable +errors; never convert them to `false`. Omitting server authorization preserves +the documented permissive compatibility behavior while the migration is staged, +but it should be an explicit temporary choice. + +Audit every call site against its trust contract: + +```ts +await backend.forRequest(request).operations.blog.deletePost({ id }) +await backend.trusted.blog.deletePost({ id }) +await backend.raw.blog.prefetchForRoute("post", queryClient, { slug }) +``` + +The first path is request-authorized. The second is for a trusted job or server +workflow and skips only user authorization. The third is a narrow composition +escape hatch, not an alternate business API. + +For a managed or separately deployed backend, publish only the rule-free, +versioned contract and descriptors: + +```ts title="packages/backend-contract/authorization.ts" +export const authorizationContract = defineAuthorizationContract({ + identity: z.object({ id: z.string(), role: z.enum(["user", "admin"]) }), + permissions: [blogPermissions] as const, +}) +``` + +The browser may bind that contract to `createRemoteAuthorizationEvaluator`, +but the remote service must parse the contract version and facts, resolve its +own identity, re-read authoritative records, and evaluate server-owned rules. +It never trusts browser identity or ownership facts. See +[Authorization](/auth#use-a-managed-or-separate-backend) for the transport +example. Core intentionally exports no provider-specific auth adapter and no +global open-string `useCan` or `CanAccess` API. + +### 5. Adopt framework entries and tri-state hydration + +Use the framework entry factories instead of copied route resolution, loader, +metadata, dehydration, or 404 logic: + +| Framework | API route | Page route | Provider router | Identity layout | +| --- | --- | --- | --- | --- | +| Next.js | `toNextRouteHandlers` | `createNextPage` | `nextRouter()` | `createNextLayout` from `@btst/stack/next/server` | +| React Router | `toReactRouterHandlers` | `createReactRouterPage` | `reactRouter()` | `createReactRouterLayout` on the parent route | +| TanStack Start | `toTanStackHandlers` | `createTanStackPageOptions` | `tanstackRouter()` | `createTanStackLayout` plus a server function | + +Hydrate identity at the layout or parent-route boundary that owns the complete +provider subtree: + +```tsx + + {children} + +``` + +`initialIdentity` has three deliberate states: + +| Value | Meaning | Initial client behavior | +| --- | --- | --- | +| `undefined` or omitted | no server snapshot | resolve identity in the browser | +| `null` | settled anonymous snapshot | do not duplicate the initial request | +| validated identity | settled authenticated snapshot | use it without a duplicate initial request | + +Next.js request-aware pages and layouts must construct their server client from +the current request and keep static/ISR routes in a separate header-free route +group. React Router resolves the snapshot in the parent layout loader so it +covers the full ``. TanStack Start resolves it in a server function +used by the parent route loader and later client navigations. The complete, +copyable implementations are in +[Authorization: hydrate identity at the layout boundary](/auth#hydrate-identity-at-the-layout-boundary). + +When an application-owned route renders one plugin page directly instead of +using the catch-all, keep a dedicated wrapper and pass the page component its +declarative `{ params }` route context. Supply synthetic params only when that +wrapper intentionally fixes a resource; do not call route internals or restore +the removed named-prop adapters. The exact parameter mappings are listed in +[Update parameterized page-component overrides](#5-update-parameterized-page-component-overrides). + +After login, logout, or account switching, refresh at the application/framework +seam: Next.js `router.refresh()`, React Router `revalidator.revalidate()`, +TanStack `router.invalidate()`, or `clientAuth.useIdentity().refetch()`. + +### 6. Migrate embedded surfaces and every provider root + +The catch-all pages layout is not automatically an ancestor of UI embedded in +the rest of the application. Inventory and migrate `CommentThread`, +`CommentCount`, `FormRenderer`, direct plugin hooks, and cards such as +`PostCard` or `TaskCard`. Each rendered surface must be below a `StackProvider` +whose resolved stack registers that plugin and below the same QueryClient +provider used to create the stack. + +If a modal, parallel route, portal host, microfrontend, or independently mounted +widget has a separate React root, give that root its own stable browser stack +and provider using the same trusted origin snapshot and auth contract. Context +does not cross sibling roots. Hydrate identity per root or intentionally leave +it `undefined`; never copy a server stack or request headers into the new root. + +Remove `apiBaseURL`, `apiBasePath`, `headers`, and current-user props from +embedded components. They read transport and identity from the nearest +provider. Keep `CommentThread.loginHref` only when the resource needs a +specific sign-in return URL; it overrides the provider's general `loginPath`. +Test embedded mutations and counts as well as their first render—a static card +that looks correct can still be bound to the wrong endpoint or identity cache. + +### 7. Regenerate or merge the framework scaffold + +`@btst/codegen` owns application scaffolding. The focused +`@btst/codegen@0.2.0` delegates `generate` and `migrate` to +`@btst/cli@2.2.4` from the consumer project directory. That behavior loads the +application's TypeScript/JavaScript aliases and standard Next.js environment +files, resolves its Prisma or Drizzle adapter and ORM peers, and ignores only a +bare `import "server-only"` marker while evaluating the server config. + +Use the scaffold as a reference diff for an existing application rather than +blindly overwriting owned files: + +```bash +npx @btst/codegen@0.2.0 init --framework=nextjs --adapter=drizzle \ + --plugins=blog,comments --cwd=. --skip-install +npx @btst/codegen@0.2.0 generate \ + --orm=drizzle --config=lib/stack.ts --output=src/db/schema.ts +``` + +Select `react-router` or `tanstack` for those frameworks. Review every planned +write and TODO, preserve application-owned auth/domain dependencies, then run +the generated framework's typecheck and production build. See [CLI](/cli) for +the supported flags and direct failure fallback. + +### 8. Migrate the Better Auth UI companion + +Better Auth remains application-configured and is a prerequisite. The optional +companion reads its own Better Auth session and uses native Better Auth account, +organization, and permission APIs; it exports no Better Auth-to-BTST client or +server auth factory. Map the session into `createClientAuth` and +`createServerAuth` yourself when business plugins should authorize the same +person. Role and tenant fields remain application-owned. + +Auth plus account is the minimal runtime integration. Pin the stable cohort +exactly when it is selected: + +```bash +pnpm add --save-exact @btst/better-auth-ui@2.0.0 \ + better-auth@1.6.16 @better-auth/core@1.6.16 \ + @better-auth/api-key@1.6.16 @better-auth/passkey@1.6.16 \ + @better-auth/utils@0.4.1 @better-fetch/fetch@1.2.2 better-call@1.3.6 +``` + +`@btst/codegen@0.2.0` includes the explicit `better-auth-ui` scaffold +selection and generates the minimal auth-and-account path described in the +focused guide. It does not generate or replace the application's Better Auth +backend, database, schema, providers, secrets, or optional runtime plugins. + +API-key and passkey are required declaration peers in the stable package +because its complete `AuthClient` type exposes those surfaces. Installing them +satisfies the strict type/dependency graph; it does not enable either feature. +Enable organization, API-key, passkey, or multi-session only when the matching +Better Auth server and browser plugins are configured. Optional `tanstack`, +`instantdb`, and `triplit` companion subpaths have additional peers; the base +auth/account integration does not require those adapter peers. + +Configure `authClient` once under the `auth` override and avatar behavior only +under `account`. Account and organization overrides intentionally reject +auth-only fields. Companion route bases derive from the resolved +`createClientStack({ site })` runtime; do not repeat them in overrides. Use the +explicit framework session refresh described above. The focused setup and peer +table live in [Better Auth UI Companion](/plugins/better-auth-ui). + +### 9. Verify data, production behavior, and cleanup + +Generate the schema from the migrated stack, diff it against the recorded +baseline, and review the ORM migration before applying it. Test against a +restored production-like snapshot first. Prefer additive migrations during the +deployment window, deploy schema changes before code that needs them, and do +not remove old columns or compatibility reads until the rollback window closes. +Verify existing rows, relationships, cascades, tenant boundaries, and plugin +records—not only newly created fixtures. + +Run this checklist against the exact packed/published artifacts and the +optimized production server: + +- [ ] Clean install succeeds from the exact selected cohort with no + undocumented peer repair, duplicate Better Auth type universe, or application + dependency on `@btst/cli`. +- [ ] Typecheck, lint, unit/integration tests, schema generation, reviewed + migration, optimized build, and production start pass. +- [ ] Server and client bundles remain separated; no backend stack, request + headers, cookies, secrets, or server auth are serialized. +- [ ] Direct navigation, hard refresh, back/forward, 404 and error boundaries, + console/server errors, and hydration warnings are clean. +- [ ] SSR, authenticated SSR, SSG/ISR, metadata, sitemap, and browser refetch + use the same resolved endpoints. +- [ ] Anonymous, regular-user, and privileged-user controls match authoritative + backend results. +- [ ] An allowed operation succeeds; anonymous denial is 401; authenticated + denial is 403; a missing rule denies; identity/fact/policy failures remain + errors; spoofed browser facts do not grant access; trusted internal execution + retains validation, domain behavior, transactions, and lifecycle. +- [ ] Login, logout, account switching, explicit session refresh, and all three + `initialIdentity` states behave without duplicate initial requests. +- [ ] Embedded components outside the catch-all layout, every independent + provider root, resource-specific sign-in return URLs, counts/cards/direct + hooks, and representative plugin mutations work. +- [ ] Better Auth account, profile, avatar, and only the optional features the + application configured work on the retained `1.6.16` cohort. +- [ ] Existing database records remain readable and writable, failed atomic + operations roll back, and database plus remote test assets are removed. + +Release maintainers prove the clean-room and snippet contracts from the +repository root before publishing: + +```bash +pnpm test:packed-consumers +BTST_ARTIFACT_DIR="$(mktemp -d)" +npm pack @btst/better-auth-ui@2.0.0 --pack-destination "$BTST_ARTIFACT_DIR" +BTST_AUTH_UI_TARBALL="$BTST_ARTIFACT_DIR/btst-better-auth-ui-2.0.0.tgz" +pnpm smoke:packed-consumer -- --fixture core --package-manager npm +pnpm smoke:packed-consumer -- --fixture core --package-manager pnpm +pnpm smoke:packed-consumer -- --fixture auth --package-manager npm \ + --better-auth-ui "$BTST_AUTH_UI_TARBALL" +pnpm smoke:packed-consumer -- --fixture auth --package-manager pnpm \ + --better-auth-ui "$BTST_AUTH_UI_TARBALL" +pnpm --filter @btst/codegen test:better-auth-ui-fixtures +pnpm typecheck +``` + +The first command verifies the harness itself; the four smoke commands then +install only packed tarballs with npm and pnpm, under strict peers, and exercise +core plus the auth cohort. The Better Auth UI gate generates untouched Next.js, +React Router, and TanStack Start applications and builds and typechecks each +one. The root typecheck includes the constructor, authorization, +managed-contract, and hydration consumer fixtures used by this guide. A failure +in any gate blocks publication and must be corrected in the guide or +implementation rather than repaired by an undocumented fixture edit. + +Finally remove migration-only compatibility code: old constructors, positional +factory arguments, top-level or retired lifecycle names, kebab-case +programmatic IDs, duplicated API/site/query/header wiring, manual override maps, +empty activation blocks, render guards, open-string authorization calls, +provider-specific core auth bridges, ambiguous `api`/`internal` calls, and +temporary dual-read or dual-write paths after the rollback window. Commit the +final lockfile and deployment evidence with the migration. + +--- + +## Migration reference: v3 RC2 → RC3 canonical stack and plugin DX + +RC3 removes the remaining duplicate runtime configuration and historical naming +seams. The migration is mechanical: rename the constructors, move shared client +runtime to one client stack, nest backend hooks, update programmatic IDs and +lifecycle names, and select the server surface whose trust contract matches the +caller. + +### 1. Rename both stack constructors + +{/* canonical-dx-guard: migration:start reason="removed constructor example" */} + +Before: + +```ts +import { stack } from "@btst/stack" +import { createStackClient } from "@btst/stack/client" + +const backend = stack({ /* ... */ }) +const client = createStackClient({ /* ... */ }) +``` + +{/* canonical-dx-guard: migration:end */} + +After: + +```ts +import { createBackendStack } from "@btst/stack/api" +import { createClientStack } from "@btst/stack/client" + +const backend = createBackendStack({ /* ... */ }) +const client = createClientStack({ /* ... */ }) +``` + +{/* canonical-dx-guard: migration:start reason="removed constructor names in migration prose" */} + +Use only `createBackendStack` and `createClientStack` in maintained code. The +earlier `stack` and `createStackClient` names are migration inputs, not parallel +constructor stories. + +{/* canonical-dx-guard: migration:end */} + +### 2. Move shared client runtime to one resolved stack + +{/* canonical-dx-guard: migration:start reason="duplicated client runtime example" */} + +Before, every client plugin and the provider repeated transport, site, cache, +and request values: + +```tsx +const blog = blogClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient, + headers: requestHeaders, +}) + + + {children} + +``` + +{/* canonical-dx-guard: migration:end */} + +After, create one request-specific stack for loaders and metadata by supplying +`api.headers`, and a separate stable browser stack without request headers: + +```tsx +const clientStack = createClientStack({ + api: { baseURL, basePath: "/api/data" }, + site: { baseURL, basePath: "/pages" }, + queryClient, + plugins: { + blog: blogClientPlugin(), + }, +}) + + + {children} + +``` + +`createClientStack` is the only owner of API location, site location, +QueryClient, request headers, registered plugin definitions, and endpoint +replacement. `StackProvider` owns browser/framework services (`router`, +`auth`, `notify`, and `i18n`) plus genuine plugin browser customization. + +Never serialize the request-specific stack into the browser. Construct the SSR +stack with filtered request headers, then construct the browser stack from +browser-safe origins and the hydrated QueryClient. + +### 3. Delete manual provider generics and override maps + +{/* canonical-dx-guard: migration:start reason="removed provider generic example" */} + +Before: + +```tsx +type AppPluginOverrides = { + "ai-chat": AiChatPluginOverrides + blog: BlogPluginOverrides +} + + + basePath="/pages" + overrides={overrides} +> + {children} + +``` + +{/* canonical-dx-guard: migration:end */} + +After, the resolved definitions registered in `createClientStack({ plugins })` +infer the valid override keys and each value shape: + +```tsx + + {children} + +``` + +Omit `overrides` entirely when no plugin needs customization. +`usePluginOverrides()` supplies a safe empty value for an omitted optional +block; genuinely required plugin customization remains required by the +registered definition's inferred type. + +### 4. Rename programmatic plugin IDs to camelCase + +Package paths and URL slugs remain kebab-case. Only programmatic registration +IDs, provider keys, resource namespaces, and diagnostics change: + +| Package or URL slug | Removed programmatic ID | Canonical programmatic ID | +| --- | --- | --- | +| `ai-chat` | `ai-chat` | `aiChat` | +| `blog` | `blog` | `blog` | +| `cms` | `cms` | `cms` | +| `comments` | `comments` | `comments` | +| `form-builder` | `form-builder` | `formBuilder` | +| `kanban` | `kanban` | `kanban` | +| `media` | `media` | `media` | +| `open-api` | `open-api` | `openApi` | +| `route-docs` | `route-docs` | `routeDocs` | +| `ui-builder` | `ui-builder` | `uiBuilder` | + +{/* canonical-dx-guard: migration:start reason="removed plugin ID example" */} + +```diff +plugins: { +- "ai-chat": aiChatClientPlugin(), +- "form-builder": formBuilderClientPlugin(), +- "route-docs": routeDocsClientPlugin(), +- "ui-builder": uiBuilderClientPlugin(), ++ aiChat: aiChatClientPlugin(), ++ formBuilder: formBuilderClientPlugin(), ++ routeDocs: routeDocsClientPlugin(), ++ uiBuilder: uiBuilderClientPlugin(), +} +``` + +{/* canonical-dx-guard: migration:end */} + +### 5. Use one backend options object with nested hooks + +{/* canonical-dx-guard: migration:start reason="removed backend factory forms" */} + +Before: + +```ts +blogBackendPlugin(blogHooks) +commentsBackendPlugin({ allowEditing: false, onAfterPost }) +kanbanBackendPlugin(resolveUser, searchUsers, kanbanHooks) +``` + +{/* canonical-dx-guard: migration:end */} + +After: + +```ts +import type { BlogBackendHooks } from "@btst/stack/plugins/blog/api" +import type { KanbanBackendHooks } from "@btst/stack/plugins/kanban/api" + +const blogHooks: BlogBackendHooks = { + // Use the canonical Blog hook names from the tables below. +} +const kanbanHooks: KanbanBackendHooks = { + // Use the canonical Kanban hook names from the tables below. +} + +blogBackendPlugin({ hooks: blogHooks }) +commentsBackendPlugin({ + allowEditing: false, + resolveUser, + hooks: { onAfterCreateComment }, +}) +kanbanBackendPlugin({ hooks: kanbanHooks }) + + + {children} + +``` + +Every backend plugin is a factory receiving at most one options object. +Optional-only factories allow `plugin()`. Required plugin configuration stays +required on its owning surface: AI Chat backend options keep its model, tools, +and access mode; CMS keeps content types; Comments keeps behavior and user +resolution; Media keeps storage, tenant, and upload configuration; OpenAPI +keeps its presentation/schema options. Kanban user resolution and search are +browser services and move to the inferred `StackProvider` override shown above. + +OpenAPI is intentionally backend-only. Route Docs is intentionally client-only. +UI Builder is intentionally client-only and composes over the registered CMS +backend/client contract; do not invent matching halves for symmetry. + +### 6. Rename every backend lifecycle callback + +The tables below come from the structured `*_LIFECYCLE_HOOK_MIGRATIONS` +inventories shipped by each plugin. Names absent from these tables did not +change. Keep all callbacks under the plugin factory's `hooks` field. + +{/* canonical-dx-guard: migration:start reason="complete removed lifecycle inventory" */} + +#### AI Chat + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeToolsActivated` | `onBeforeActivateTools` | +| `onConversationsRead` | `onAfterListConversations` | +| `onConversationRead` | `onAfterGetConversation` | +| `onConversationCreated` | `onAfterCreateConversation` | +| `onConversationUpdated` | `onAfterUpdateConversation` | +| `onConversationDeleted` | `onAfterDeleteConversation` | +| `onChatError` | `onErrorChat` | +| `onListConversationsError` | `onErrorListConversations` | +| `onGetConversationError` | `onErrorGetConversation` | +| `onCreateConversationError` | `onErrorCreateConversation` | +| `onUpdateConversationError` | `onErrorUpdateConversation` | +| `onDeleteConversationError` | `onErrorDeleteConversation` | + +#### Blog + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeNextPreviousPosts` | `onBeforeGetNextPreviousPosts` | +| `onPostsRead` | `onAfterListPosts` | +| `onPostCreated` | `onAfterCreatePost` | +| `onPostUpdated` | `onAfterUpdatePost` | +| `onPostDeleted` | `onAfterDeletePost` | +| `onNextPreviousPostsRead` | `onAfterGetNextPreviousPosts` | +| `onListPostsError` | `onErrorListPosts` | +| `onNextPreviousPostsError` | `onErrorGetNextPreviousPosts` | +| `onCreatePostError` | `onErrorCreatePost` | +| `onUpdatePostError` | `onErrorUpdatePost` | +| `onDeletePostError` | `onErrorDeletePost` | + +#### CMS + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeCreate` | `onBeforeCreateContent` | +| `onAfterCreate` | `onAfterCreateContent` | +| `onBeforeUpdate` | `onBeforeUpdateContent` | +| `onAfterUpdate` | `onAfterUpdateContent` | +| `onBeforeDelete` | `onBeforeDeleteContent` | +| `onAfterDelete` | `onAfterDeleteContent` | +| `onError` | `onErrorExecuteContentOperation` | + +#### Comments + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeList` | `onBeforeListComments` | +| `onBeforeCount` | `onBeforeCountComments` | +| `onBeforeListByAuthor` | `onBeforeListCommentsByAuthor` | +| `onBeforePost` | `onBeforeCreateComment` | +| `onAfterPost` | `onAfterCreateComment` | +| `onBeforeEdit` | `onBeforeUpdateComment` | +| `onAfterEdit` | `onAfterUpdateComment` | +| `onBeforeLike` | `onBeforeToggleCommentReaction` | +| `onBeforeStatusChange` | `onBeforeModerateComment` | +| `onAfterApprove` | `onAfterApproveComment` | +| `onBeforeDelete` | `onBeforeDeleteComment` | +| `onAfterDelete` | `onAfterDeleteComment` | + +#### Form Builder + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeFormCreated` | `onBeforeCreateForm` | +| `onAfterFormCreated` | `onAfterCreateForm` | +| `onBeforeFormUpdated` | `onBeforeUpdateForm` | +| `onAfterFormUpdated` | `onAfterUpdateForm` | +| `onBeforeFormDeleted` | `onBeforeDeleteForm` | +| `onAfterFormDeleted` | `onAfterDeleteForm` | +| `onSubmissionError` | `onErrorSubmission` | +| `onBeforeSubmissionDeleted` | `onBeforeDeleteSubmission` | +| `onAfterSubmissionDeleted` | `onAfterDeleteSubmission` | + +#### Kanban + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeReadBoard` | `onBeforeGetBoard` | +| `onBoardsRead` | `onAfterListBoards` | +| `onBoardRead` | `onAfterGetBoard` | +| `onBoardCreated` | `onAfterCreateBoard` | +| `onBoardUpdated` | `onAfterUpdateBoard` | +| `onBoardDeleted` | `onAfterDeleteBoard` | +| `onListBoardsError` | `onErrorListBoards` | +| `onReadBoardError` | `onErrorGetBoard` | +| `onCreateBoardError` | `onErrorCreateBoard` | +| `onUpdateBoardError` | `onErrorUpdateBoard` | +| `onDeleteBoardError` | `onErrorDeleteBoard` | +| `onColumnCreated` | `onAfterCreateColumn` | +| `onColumnUpdated` | `onAfterUpdateColumn` | +| `onColumnDeleted` | `onAfterDeleteColumn` | +| `onTaskCreated` | `onAfterCreateTask` | +| `onTaskUpdated` | `onAfterUpdateTask` | +| `onTaskDeleted` | `onAfterDeleteTask` | + +#### Media + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeDelete` | `onBeforeDeleteAsset` | +| `onAfterDelete` | `onAfterDeleteAsset` | +| `onOperationError` | `onError` | + +{/* canonical-dx-guard: migration:end */} + +The lifecycle grammar is `onBefore`, +`onAfter`, and `onError`. Meaningful domain +events such as chat, submission receipt, moderation approval, and upload +finalization retain their domain vocabulary. Media storage-adapter callbacks +are transport contracts and are not part of this mapping. + +### 7. Select an explicit server trust surface + +{/* canonical-dx-guard: migration:start reason="removed ambiguous server namespaces" */} + +Before: + +```ts +await app.api.blog.updatePost(input) +await app.forRequest(request).api.blog.updatePost(input) +await app.internal.blog.updatePost(input) +``` + +{/* canonical-dx-guard: migration:end */} + +After: + +```ts +await app.forRequest(request).operations.blog.updatePost(input) +await app.trusted.blog.updatePost(input) +await app.raw.blog.prefetchForRoute("post", queryClient, { slug }) +``` + +`forRequest(request).operations` runs validation, trusted-fact derivation, +configured server authorization, domain behavior, and lifecycle hooks. +`trusted` skips only user authorization and keeps the rest of that operation +pipeline. `raw` is the explicit lower-level escape hatch and first-party +plugins expose only narrow SSG prefetch helpers there. Standalone exported +getters and mutations are also lower-level primitives whose caller owns +validation, authorization, and lifecycle composition. + +Omitting `createBackendStack({ auth })` preserves permissive compatibility. +Once server auth is configured, its schema-bound rules are authoritative and +missing or denied rules fail closed. Browser checks remain presentation only; +derive authorization facts from server data rather than client input. + +### 8. Apply endpoint and identity boundaries + +A path-only `endpoints..api` replacement inherits the top-level API +origin and request headers. A replacement `baseURL` must include a replacement +`basePath` and establishes a new transport boundary: server cookies, +authorization, proxy-authorization, and other request headers are not +inherited. Add only explicitly browser-safe `browserHeaders` or an explicit +Fetch `credentials` mode, and only when the destination implements that +plugin's BTST HTTP contract. Site endpoints follow the same path-only versus +complete-replacement rule independently. + +`initialIdentity` is tri-state: `undefined` means no server snapshot was +supplied and the client resolver may run immediately; `null` is an explicitly +hydrated anonymous snapshot; an identity object is an explicitly hydrated +authenticated snapshot. Serialize only schema-validated identity and trusted +deployment origins, never a server stack or request headers. + +BTST core is authentication-provider agnostic. Better Auth and Better Auth UI +are not core dependencies or hidden identity bridges; adapt the provider your +application already uses through `createClientAuth` and `createServerAuth`. +Applications that already run Better Auth can separately opt into the migrated +Better Auth UI companion for auth and account pages. + +--- + +## Migration reference: v2 → v3 framework entries and resolved client runtime + +BTST v3 has one supported framework-wiring path: framework entry factories +own the catch-all routes, `createClientStack()` owns shared API, site, and +QueryClient runtime, and `StackProvider` consumes that resolved stack alongside +browser-side router and auth services. Plugin factories still own +plugin-specific loader and metadata choices; plugin overrides contain only +plugin-specific browser customization. + + + v3 removes the v2 compatibility fallbacks. Complete every step in this + section before upgrading. + + +### 1. Replace hand-written catch-all routes with entry factories + +Replace copied API-handler and page-rendering glue with the matching framework +entry point. For Next.js, the migration is: + +```diff ++ import { toNextRouteHandlers } from "@btst/stack/next" + import { handler } from "@/lib/stack" + +- export const GET = handler +- export const POST = handler +- export const PUT = handler +- export const PATCH = handler +- export const DELETE = handler ++ export const { GET, POST, PUT, PATCH, DELETE } = ++ toNextRouteHandlers(handler) +``` + +```diff ++ import { createNextPage } from "@btst/stack/next" + import { getStackClient } from "@/lib/stack-client" + import { getOrCreateQueryClient } from "@/lib/query-client" + +- export default async function Page({ params }) { +- // normalize the path, resolve the route, run its loader, +- // dehydrate React Query, render the page, and handle 404 +- } +- export async function generateMetadata({ params }) { +- // resolve the route, run its loader, and convert metadata +- } ++ const page = createNextPage({ ++ getStackClient, ++ getQueryClient: getOrCreateQueryClient, ++ }) ++ export default page.Page ++ export const generateMetadata = page.generateMetadata +``` + +Use the equivalent pair for your framework: + +| Framework | API factory | Page factory | Router preset | +| --- | --- | --- | --- | +| Next.js | `toNextRouteHandlers` | `createNextPage` | `nextRouter` | +| React Router | `toReactRouterHandlers` | `createReactRouterPage` | `reactRouter` | +| TanStack Router | `toTanStackHandlers` | `createTanStackPageOptions` | `tanstackRouter` | + +The [installation guide](/installation) has complete route files for all three +frameworks. + +All three page factories also support request-aware client creation. Next.js +awaits `getStackClient` with the current page props, React Router exposes +`page.createLoader()` with its request and router context, and TanStack accepts +an isomorphic `getLoaderStackClient` resolver with its loader context. Existing +synchronous factories remain valid; see the installation guide's +request-aware examples when SSR authorization needs headers or session state. + +### 2. Move shared runtime to the client stack + +Every maintained client plugin now uses the resolved runtime definition. +Remove shared runtime fields from each plugin factory and configure API, site, +and QueryClient once in a shared factory: + +```ts title="lib/stack-client.ts" +export const getStackClient = ( + queryClient: QueryClient, + options: { apiOrigin: string; siteOrigin: string }, +) => createClientStack({ + api: { baseURL: options.apiOrigin, basePath: "/api/data" }, + site: { baseURL: options.siteOrigin, basePath: "/pages" }, + queryClient, + plugins: { blog: blogClientPlugin() }, +}) +``` + +Create a request-specific stack for server loaders and metadata: + +```ts title="app/(request)/pages/[[...all]]/page.tsx" +import { headers } from "next/headers" +import { getStackClientForRequest } from "@/lib/stack-client.server" + +const page = createNextPage({ + getQueryClient: getOrCreateQueryClient, + getStackClient: async (queryClient) => + getStackClientForRequest(queryClient, { + headers: new Headers(await headers()), + }), +}) +``` + +Create a separate, stable browser stack inside the Client Component that owns +the provider. Do not pass or serialize the request stack; it contains functions +and server-only request headers. + +```tsx title="app/pages/client-layout.tsx" +"use client" + +export default function PagesClientLayout({ children, clientOrigins }) { + const [queryClient] = useState(() => getOrCreateQueryClient()) + const clientStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) + + return ( + + + {children} + + + ) +} +``` + +Wrap this client provider from `app/(request)/pages/layout.tsx` using +`getServerClientOriginsFromHeaders(await headers())`. Put SSG/ISR routes under +`app/(static)/pages` and use header-free `getServerClientOrigins()` there. Both +route groups keep the `/pages/*` URL. + +`apiBaseURL`, `apiBasePath`, site fields, `queryClient`, and request headers are +no longer built-in plugin options. SSR loaders, metadata, browser hooks, and +mutations use the same resolved runtime for every maintained client plugin. + +### 3. Replace render guards with `StackProvider.auth` + +All `onBefore*PageRendered` override callbacks were removed. Define exact +permission descriptors and bind the shared authorization rule to the browser: + +```diff ++ import { createClientAuth } from "@btst/stack/authorization/client" ++ import { authorization } from "@/lib/authorization" ++ ++ const clientAuth = createClientAuth({ ++ authorization, ++ getIdentity: () => session.user, ++ loginPath: "/login", ++ }) ++ + Boolean(currentUser), +- onBeforeNewPostPageRendered: () => currentUser?.role === "admin", + uploadImage, + }, + }} + > +``` + +Built-in routes declare schema-backed permission descriptors. The browser uses +the same synchronous rule for presentation gates; `createServerAuth()` is the +authoritative request boundary. Lifecycle hooks run afterward for domain +validation, side effects, and telemetry—not routine authorization. + +### 4. Remove manual API and identity component props + +`CommentThread` and `CommentCount` now read API and identity services from the +nearest provider: + +```diff + +``` + +Keep request `headers` on the top-level `createClientStack({ api })` runtime; +they are no longer plugin options or public component identity props. +The optional `loginHref` prop remains available for threads that need a +resource-specific sign-in return URL and takes precedence over the provider's +`loginPath`. + +### 5. Update parameterized page-component overrides + +Parameterized `pageComponents` now receive the declarative route context +`{ params }` instead of named props: + +```diff +blogClientPlugin({ + // ...Blog-specific SEO, hooks, and page choices + pageComponents: { + posts: MyCustomPostsPage, +- post: ({ slug }) => , ++ post: ({ params }) => , +- tag: ({ tagSlug }) => , ++ tag: ({ params }) => , + }, +}) +``` + +| Plugin | Override | v2 props | v3 props | +| --- | --- | --- | --- | +| Blog | `post`, `editPost` | `{ slug }` | `{ params: { slug } }` | +| Blog | `tag` | `{ tagSlug }` | `{ params: { tagSlug } }` | +| CMS | `contentList`, `newContent` | `{ typeSlug }` | `{ params: { typeSlug } }` | +| CMS | `editContent` | `{ typeSlug, id }` | `{ params: { typeSlug, id } }` | +| Form Builder | `editForm` | `{ id }` | `{ params: { id } }` | +| Form Builder | `submissions` | `{ formId }` | `{ params: { id } }` | +| UI Builder | `editPage` | `{ id }` | `{ params: { id } }` | +| Kanban | `board` | `{ boardId }` | `{ params: { boardId } }` | +| AI Chat | `chatConversation` | `{ conversationId }` | `{ params: { id } }` | + +Routes without parameters keep their existing component contract. Custom +plugins should use `defineRoute` / `defineRoutes` from +`@btst/stack/plugins/client`. + +### 6. Deny backend hooks by throwing + +The boolean-return compatibility shim was removed. Returning `false` no longer +denies a request: + +```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 +} +``` + +### 7. Rename normalized backend lifecycle hooks + +Use the complete seven-plugin mapping in +[Rename every backend lifecycle callback](#6-rename-every-backend-lifecycle-callback) +above. JavaScript applications must update removed keys too; removed callback +names are not invoked at runtime. Keep hook denials exception-based while +renaming them. + +### 8. Replace the v2 provider-specific auth bridge + +The v3 CLI no longer generates the old Better Auth-to-BTST authorization +provider. BTST authorization is provider-agnostic: adapt your application +session through the generic client and server identity resolvers. + +The stable-v3 CLI does offer an optional `better-auth-ui` companion scaffold +for applications that already own a Better Auth server. That selection creates +only the Better Auth browser client plus auth and account UI routes; it does not +generate the server, database, schema, migrations, providers, secrets, +organization plugin, or a BTST identity bridge. + +Remove the old bridge: + +```bash +npx @btst/codegen init --plugins blog,better-auth-ui +``` + +```tsx +import { createBetterAuthProvider } from "@btst/better-auth-ui" + + + {children} + +``` + +Keep authorization application-owned: + +```ts title="authorization.client.ts" +export const clientAuth = createClientAuth({ + authorization, + getIdentity: () => session?.user ?? null, + loginPath: "/sign-in", +}) +``` + +```ts title="authorization.server.ts" +export const serverAuth = createServerAuth({ + authorization, + getIdentity: ({ headers }) => myAuthProvider.getIdentity(headers), +}) +``` + +Pass `clientAuth` to `StackProvider`, pass `serverAuth` to +`createBackendStack({ auth: serverAuth })`, and keep +authorization independent from the optional Better Auth UI routes. See +[Better Auth UI Companion](/plugins/better-auth-ui) for the supported scaffold +and exact dependency cohort. + +### Migration checklist + +- Replace API and page catch-all glue with the framework entry factories. +- Configure API, site, and QueryClient once on `createClientStack()`, then pass + its browser-resolved stack to `StackProvider.stack`. +- Add one framework router preset to `StackProvider`. +- Add `StackProvider.auth` when routes or controls require identity or permissions. +- Delete shared router/API fields and `onBefore*PageRendered` callbacks from plugin overrides. +- Remove API, header, and identity props from Comments components; keep + `loginHref` only when a thread needs a per-instance sign-in URL. +- Update parameterized `pageComponents` adapters to read `params`. +- Change backend hook denials from boolean returns to thrown errors. +- Replace every retired Form Builder, Kanban, and Media lifecycle spelling + using the RC3 mapping tables. +- Keep the Better Auth backend and identity resolvers application-owned. When + selected, use the optional companion scaffold for browser auth/account routes + and connect it to that existing backend. +- Run your framework build, TypeScript checks, and tests. + +--- ## v1 → v2: Rebranding to BTST BTST v2 introduces a rebranding from "Better Stack" to "BTST". This guide covers all the changes you need to make to upgrade your project. @@ -20,6 +1271,8 @@ BTST v2 introduces a rebranding from "Better Stack" to "BTST". This guide covers ### Summary of Changes +{/* canonical-dx-guard: migration:start reason="historical v2 constructor summary" */} + | v1 (Better Stack) | v2 (BTST) | |-------------------|-----------| | `betterStack()` | `stack()` | @@ -31,6 +1284,8 @@ BTST v2 introduces a rebranding from "Better Stack" to "BTST". This guide covers | `better-stack-client.tsx` | `stack-client.tsx` | | `"Powered by Better Stack"` | `"Powered by BTST"` | +{/* canonical-dx-guard: migration:end */} + ### Migration Steps @@ -52,6 +1307,8 @@ pnpm add @btst/stack@latest Update your backend configuration file (commonly `lib/better-stack.ts` → `lib/stack.ts`): +{/* canonical-dx-guard: migration:start reason="historical v2 constructor example" */} + ```diff - import { betterStack } from "@btst/stack"; + import { stack } from "@btst/stack"; @@ -61,6 +1318,8 @@ Update your backend configuration file (commonly `lib/better-stack.ts` → `lib/ // ... your configuration }); ``` + +{/* canonical-dx-guard: migration:end */} @@ -168,6 +1427,8 @@ mv lib/better-stack-auth.ts lib/stack-auth.ts You can use these find-and-replace patterns to quickly update your codebase: +{/* canonical-dx-guard: migration:start reason="historical v2 constructor replacement" */} + | Find | Replace | |------|---------| | `betterStack(` | `stack(` | @@ -181,6 +1442,8 @@ You can use these find-and-replace patterns to quickly update your codebase: | `from "./better-stack"` | `from "./stack"` | | `from "./better-stack-client"` | `from "./stack-client"` | +{/* canonical-dx-guard: migration:end */} + ### TypeScript Types The following types have been renamed: diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 6f6138619..322b6d243 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -12,7 +12,11 @@ BTST has two CLI packages: - `@btst/codegen` owns `init` scaffolding (`npx @btst/codegen init`) - `@btst/cli` owns low-level DB schema generation and migrations -`@btst/codegen generate` and `@btst/codegen migrate` are passthrough entrypoints to the existing `@btst/cli` flow. +`@btst/codegen generate` and `@btst/codegen migrate` run the aligned +`@btst/cli@2.2.4` release in isolation. This avoids adding its dependency graph +or competing `btst` binary to the application. The delegated CLI still loads +the configuration, environment files, aliases, Better Auth adapter, and ORM +peers from the application directory. ## Init (Codegen) @@ -28,7 +32,7 @@ Common flags: |------|-------------| | `--framework` | `nextjs`, `react-router`, or `tanstack` | | `--adapter` | `memory`, `prisma`, `drizzle`, `kysely`, or `mongodb` | -| `--plugins` | Comma-separated plugin keys: `blog`, `ai-chat`, `cms`, `form-builder`, `ui-builder`, `kanban`, `comments`, `media`, `route-docs`, `open-api` (or `all`) | +| `--plugins` | Comma-separated plugin keys: `blog`, `ai-chat`, `cms`, `form-builder`, `ui-builder`, `kanban`, `comments`, `media`, `route-docs`, `open-api`, `better-auth-ui` (or `all`) | | `--cwd` | Target directory | | `--skip-install` | Skip package installation step | | `--yes` | Non-interactive defaults (useful in CI) | @@ -38,14 +42,25 @@ Common flags: - `lib/stack.ts` (or framework equivalent) - `lib/stack-client.tsx` - `lib/query-client.ts` -- API catch-all route and pages catch-all route +- API and pages catch-all routes using the framework entry factories - Global CSS imports (including plugin CSS) -- Root layout with `QueryClientProvider` where possible +- Pages layout with `QueryClientProvider`, one resolved client stack, and the + framework router passed to `StackProvider` If root layout patching is not safe for your file shape, the command prints manual patch instructions instead of applying a destructive rewrite. Generated plugin config entries in `lib/stack.ts` and `lib/stack-client.tsx` use camelCase config keys (for example `aiChat`, `uiBuilder`, `formBuilder`) even though plugin selection flags use kebab-case names. +Generated v3 layouts never repeat framework router, API, or identity wiring in +plugin overrides. Replace only the plugin-specific TODO values (for example an +upload function or user resolver). + +`better-auth-ui` is an optional companion selection. It generates the auth and +account client plugins, a browser client for an existing `/api/auth` endpoint, +and the framework-native session refresh callback. It never generates a Better +Auth server, database schema, migrations, providers, secrets, or BTST identity +adapter. See [Better Auth UI Companion](/plugins/better-auth-ui). + ## Generate and Migrate via Codegen If you prefer one command surface, these delegate to `@btst/cli`: @@ -55,7 +70,8 @@ npx @btst/codegen generate --orm=prisma --config=lib/stack.ts --output=schema.pr npx @btst/codegen migrate --config=lib/stack.ts --database-url=postgres://... ``` -When a delegated command fails, fix the underlying issue and run the equivalent `npx @btst/cli ...` command directly. +When a delegated command fails, fix the underlying issue and run the equivalent +`npx @btst/cli@2.2.4 ...` command directly. ## About Better DB @@ -63,27 +79,11 @@ BTST uses [Better DB (`@btst/db`)](https://github.com/better-stack-ai/better-aut The CLI works with the `dbSchema` exported from your BTST configuration, which is built using Better DB's schema definition API. All plugin schemas are automatically merged into a unified schema that the CLI can process. -Install the CLI as a dev dependency: - - - - ```bash - npm install -D @btst/cli - ``` - - - - ```bash - pnpm add -D @btst/cli - ``` - - - - ```bash - yarn add -D @btst/cli - ``` - - +For v3 applications, prefer the codegen passthrough commands above. If a v2 +application lists `@btst/cli` in its dependencies, remove it during migration; +the pinned one-off CLI keeps Better DB dependencies from polluting the consumer +graph. You can still invoke the low-level CLI directly with +`npx @btst/cli@2.2.4`. ## Parameters @@ -101,13 +101,13 @@ Generate database schemas for your ORM from your BTST `dbSchema`: ```bash - npx @btst/cli generate --config=lib/stack.ts --orm=prisma --output=schema.prisma + npx @btst/cli@2.2.4 generate --config=lib/stack.ts --orm=prisma --output=schema.prisma ``` ```bash - npx @btst/cli generate --config=lib/stack.ts --orm=drizzle --output=src/db/schema.ts + npx @btst/cli@2.2.4 generate --config=lib/stack.ts --orm=drizzle --output=src/db/schema.ts ``` @@ -117,17 +117,17 @@ Generate database schemas for your ORM from your BTST `dbSchema`: **Using DATABASE_URL environment variable:** ```bash - DATABASE_URL=sqlite:./dev.db npx @btst/cli generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql + DATABASE_URL=sqlite:./dev.db npx @btst/cli@2.2.4 generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql ``` **Or using --database-url flag:** ```bash - npx @btst/cli generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql --database-url=sqlite:./dev.db + npx @btst/cli@2.2.4 generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql --database-url=sqlite:./dev.db ``` ```bash - npx @btst/cli generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql --database-url=postgres://user:pass@localhost:5432/db + npx @btst/cli@2.2.4 generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql --database-url=postgres://user:pass@localhost:5432/db ``` @@ -140,17 +140,17 @@ Migrate your database schema directly (Kysely only). For Prisma and Drizzle, use **Using DATABASE_URL environment variable:** ```bash -DATABASE_URL=sqlite:./dev.db npx @btst/cli migrate --config=lib/stack.ts +DATABASE_URL=sqlite:./dev.db npx @btst/cli@2.2.4 migrate --config=lib/stack.ts ``` **Or using --database-url flag:** ```bash -npx @btst/cli migrate --config=lib/stack.ts --database-url=sqlite:./dev.db +npx @btst/cli@2.2.4 migrate --config=lib/stack.ts --database-url=sqlite:./dev.db ``` ```bash -npx @btst/cli migrate --config=lib/stack.ts --database-url=postgres://user:pass@localhost:5432/db +npx @btst/cli@2.2.4 migrate --config=lib/stack.ts --database-url=postgres://user:pass@localhost:5432/db ``` ### Generate SQL to File @@ -158,23 +158,26 @@ npx @btst/cli migrate --config=lib/stack.ts --database-url=postgres://user:pass@ Instead of running migrations directly, generate SQL to a file: ```bash -npx @btst/cli migrate --config=lib/stack.ts --output=migrations.sql --database-url=sqlite:./dev.db +npx @btst/cli@2.2.4 migrate --config=lib/stack.ts --output=migrations.sql --database-url=sqlite:./dev.db ``` -## Gotchas +## Project config loading -Because the CLI executes your config file to extract the `dbSchema`, there are a few limitations to be aware of: +The CLI executes your config file to extract the `dbSchema`, using the same +project context as the application: -- **Path aliases don't work**: Path aliases (like `@/` or `~/`) configured in your TypeScript config won't work for any imports used in your `stack.ts` file or any files it imports. Use relative paths instead. +- TypeScript path aliases such as `@/` or `~/` are loaded from the project's + `tsconfig.json` or `jsconfig.json`. +- Standard Next.js environment files are loaded in precedence order. Existing + process environment values continue to win. +- A bare `import "server-only"` marker is ignored while evaluating the server + config for schema generation; the remainder of the server import graph still + executes normally. +- Prisma and Drizzle adapter modules, including their ORM peers, resolve from + the application. Keep `better-auth` and the selected ORM installed there. -- **Environment variables**: If your config file or its imports have conditional checks for available environment variables (e.g., checking if `process.env.SOME_VAR` exists), you should also pass those environment variables when running CLI commands: +You can still override a value for one command: ```bash -SOME_VAR=value npx @btst/cli generate --config=lib/stack.ts --orm=prisma --output=schema.prisma +SOME_VAR=value npx @btst/cli@2.2.4 generate --config=lib/stack.ts --orm=prisma --output=schema.prisma ``` - -or using dotenv-cli: - -```bash -npx dotenv-cli -e .env.local -- npx @btst/cli generate --orm drizzle --config lib/stack.ts --output db/btst-schema.ts -``` \ No newline at end of file diff --git a/docs/content/docs/databases/adapters.mdx b/docs/content/docs/databases/adapters.mdx index 47615db80..0962a5c00 100644 --- a/docs/content/docs/databases/adapters.mdx +++ b/docs/content/docs/databases/adapters.mdx @@ -14,7 +14,7 @@ BTST consists of separate npm packages under the `@btst` namespace: - **`@btst/stack`** - Core package (install this first) - **`@btst/adapter-*`** - Database adapters (install one based on your ORM) -- **`@btst/cli`** - CLI tools for schema generation (dev dependency) +- **`@btst/cli`** - schema tooling invoked in isolation through `@btst/codegen` - **`@btst/db`** - Internal database abstraction layer (installed as a dependency of other packages) See the [Installation guide](/installation) for setup instructions. @@ -43,28 +43,78 @@ npm install @btst/adapter-prisma See the [Installation guide](/installation#install-database-adapter) for detailed adapter setup instructions. +## Isolated transactions for atomic plugin writes + +Some plugin operations authorize against a database snapshot and then update +that same state. In production, the fact read, domain hooks, compare-and-set, +and write must commit as one isolated transaction. Configure +`transaction: true` when the installed plugin set includes **AI Chat, Form +Builder, Kanban, or Media**. + + + + ```ts + adapter: (db) => createPrismaAdapter(prisma, db, { + provider: "postgresql", + transaction: true, + })({}) + ``` + + + + ```ts + adapter: (db) => createDrizzleAdapter(drizzleDb, db, { + provider: "pg", + transaction: true, + })({}) + ``` + + + + ```ts + adapter: (db) => createKyselyAdapter(kyselyDb, db, { + transaction: true, + })({}) + ``` + + + +Choose the provider value that matches the real ORM connection. The flag opts +the adapter into its native transaction implementation; it is not a promise +that a sequential callback fallback is safe. Owner-sensitive and persistent +operations fail closed with `ATOMIC_TRANSACTION_REQUIRED` when isolation is +missing, before lifecycle hooks or writes run. + +The memory adapter remains useful for local, single-process development where +the plugin documents serialized access. It is not a production isolation +substitute. The generated scaffold rejects Form Builder with memory or MongoDB +and Media with MongoDB; use Prisma, Drizzle, or Kysely for those production +configurations. `@btst/codegen@0.2.0 init` adds `transaction: true` +automatically for all four atomic-write plugins: AI Chat, Form Builder, Kanban, +and Media. + ## Usage -When you configure BTST, the `stack()` function collects all plugin database schemas and merges them into a unified schema. The adapter function receives this merged schema and returns an adapter that translates BTST's database operations to your ORM. +When you configure BTST, the `createBackendStack()` function collects all plugin database schemas and merges them into a unified schema. The adapter function receives this merged schema and returns an adapter that translates BTST's database operations to your ORM. ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createPrismaAdapter } from "@btst/adapter-prisma" import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here }, // The adapter receives the merged db schema from all plugins - adapter: (db) => createPrismaAdapter(prisma, db, { + adapter: (db) => createPrismaAdapter(prisma, db, { provider: "postgresql" // or "mysql", "sqlite", "cockroachdb", "mongodb" - }) + })({}) }) export { handler, dbSchema } @@ -73,7 +123,7 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createDrizzleAdapter } from "@btst/adapter-drizzle" import { drizzle } from "drizzle-orm/postgres-js" // or "drizzle-orm/mysql2", "drizzle-orm/better-sqlite3", etc. import postgres from "postgres" @@ -81,12 +131,14 @@ When you configure BTST, the `stack()` function collects all plugin database sch const client = postgres(process.env.DATABASE_URL!) const drizzleDb = drizzle(client) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here }, - adapter: (db) => createDrizzleAdapter(drizzleDb, db, {}) + adapter: (db) => createDrizzleAdapter(drizzleDb, db, { + provider: "pg" // or "mysql", "sqlite" + })({}) }) export { handler, dbSchema } @@ -95,7 +147,7 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createKyselyAdapter } from "@btst/adapter-kysely" import { Kysely, PostgresDialect } from "kysely" import { Pool } from "pg" @@ -106,12 +158,12 @@ When you configure BTST, the `stack()` function collects all plugin database sch }) }) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here }, - adapter: (db) => createKyselyAdapter(kyselyDb, db, {}) + adapter: (db) => createKyselyAdapter(kyselyDb, db, {})({}) }) export { handler, dbSchema } @@ -120,14 +172,14 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMongodbAdapter } from "@btst/adapter-mongodb" import { MongoClient } from "mongodb" const client = new MongoClient(process.env.MONGODB_URI!) const mongoDb = client.db() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -142,10 +194,10 @@ When you configure BTST, the `stack()` function collects all plugin database sch ```ts title="lib/stack.ts" // IMPORTANT: Memory adapter is used for development and testing only - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMemoryAdapter } from "@btst/adapter-memory" - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Your plugins here @@ -168,7 +220,7 @@ The `adapter` function receives the merged database schema (`db`) containing all The adapter pattern follows this flow: -1. `stack()` merges all plugin schemas into a unified `db` object +1. `createBackendStack()` merges all plugin schemas into a unified `db` object 2. Your `adapter` function receives this `db` object 3. The adapter creator function (e.g., `createPrismaAdapter`, `createDrizzleAdapter`) returns an adapter instance 4. This adapter instance implements the common interface (create, update, findOne, etc.) diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index ccc0910b7..d287cb89d 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -11,12 +11,12 @@ Here's a high-level overview of how BTST works: The server handles database operations, API endpoints, data prefetching, routing, and server-side rendering. -**`stack`** manages the backend layer: +**`createBackendStack`** manages the backend layer: - **API Router**: Routes incoming requests to the appropriate plugin handlers. Returns a handler function that you mount at your API path. - **DB Adapter**: Translates BTST's database operations to your ORM (Prisma, Drizzle, Kysely, MongoDB). Plugins define schemas that get merged and passed to the adapter. -**`stackClient`** manages the rendering layer: +**`createClientStack`** manages the rendering layer: - **Data Fetching**: Plugins can prefetch data server-side into React Query cache before rendering, enabling instant page loads with hydrated state. - **Page Router**: Matches URLs to plugin routes and returns the appropriate page component, loader, and metadata. @@ -32,7 +32,9 @@ Server-rendered HTML is hydrated with client-side React. The React Query cache **SPA Navigation (If using in an SPA)** -After the initial page load, `stackClient`'s router handles client-side navigation. Clicking links doesn't trigger full page reloads—React Query fetches data in the background while the UI updates immediately. +After the initial page load, the framework router preset on `StackProvider` +handles client-side navigation. React Query fetches data in the background +while the UI updates. **State Management** @@ -46,18 +48,24 @@ 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 with a resolved client stack plus +browser-only router and auth services. API, site, QueryClient, and plugin +endpoint values come from `createClientStack()`; plugin components read that +projection and router services such as `Link`, `Image`, and `navigate` through +`useStack()`. `usePluginOverrides()` is reserved for genuinely plugin-specific +customization. ## Plugins -Plugins are the building blocks of BTST. Each feature (like Blog) ships as **two separate plugins**—one for the backend, one for the client—that you register independently: +Plugins are the building blocks of BTST. Full-stack features such as Blog ship +separate backend and client definitions that you register independently: ```ts // Backend: lib/stack.ts -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" -const { handler } = stack({ +const { handler } = createBackendStack({ plugins: { blog: blogBackendPlugin({ /* config */ }) }, @@ -65,22 +73,27 @@ const { handler } = stack({ }) // Client: lib/stack-client.tsx -import { createStackClient } from "@btst/stack/client" +import { createClientStack } from "@btst/stack/client" import { blogClientPlugin } from "@btst/stack/plugins/blog/client" +import { QueryClient } from "@tanstack/react-query" -const stackClient = createStackClient({ +const queryClient = new QueryClient() +const stackClient = createClientStack({ + api: { baseURL: "https://example.com", basePath: "/api/data" }, + site: { baseURL: "https://example.com", basePath: "/pages" }, + queryClient, plugins: { blog: blogClientPlugin({ /* config */ }) } }) ``` -**Backend plugins** (registered in `stack`): +**Backend plugins** (registered in `createBackendStack`): - Define database schemas (tables, columns, relations) - Register API route handlers for CRUD operations -- Provide hooks for authorization and custom logic +- Provide post-authorization domain lifecycle hooks -**Client plugins** (registered in `stackClient`): +**Client plugins** (registered in `createClientStack`): - Define page routes and components - Provide loaders for server-side data prefetching - Export components, hooks, and utilities for state management @@ -88,12 +101,44 @@ const stackClient = createStackClient({ This separation keeps server-only code (database schemas, API handlers) out of your client bundle, and allows each plugin to be configured independently for its context. -## Overrides +One-sided plugins are intentional: OpenAPI is backend-only, Route Docs is +client-only, and UI Builder is client-only over the CMS backend/client +contract. Register only the side that exists; do not add a placeholder half. -Framework-specific components injected into plugins at runtime: +## Resolved Client Runtime and Provider Services -- **Link**: Use Next.js `Link`, React Router `Link`, or TanStack `Link` for optimized navigation -- **Image**: Use Next.js `Image` for automatic optimization -- **navigate**: Programmatic navigation function for your framework -- **apiBaseURL/apiBasePath**: Configure where your API is mounted +Configure shared runtime once and pass the resolved result to the provider: +```tsx +const clientStack = createClientStack({ + api, + site, + queryClient, + plugins: { blog: blogClientPlugin() }, +}) + + + {children} + +``` + +- **`stack`**: Supplies the API and site locations, QueryClient, browser-safe + per-plugin endpoints, and exact plugin map. That map also infers valid + `overrides` keys and values. + +- **`router`**: Use `nextRouter()`, `reactRouter()`, or + `tanstackRouter()` for links, images, navigation, refresh, and URL search + state. +- **`auth`**: Resolve identity, provide a login path, and evaluate exact + schema-backed permission descriptors. + +The `overrides` object is only for plugin-specific customization such as +upload functions, component slots, localization, and route analytics. SSR +loader hooks, page choices, and metadata customization remain plugin-specific +factory options; their shared transport and cache runtime arrives through the +resolved stack. diff --git a/docs/content/docs/i18n.mdx b/docs/content/docs/i18n.mdx new file mode 100644 index 000000000..0fa9df379 --- /dev/null +++ b/docs/content/docs/i18n.mdx @@ -0,0 +1,189 @@ +--- +title: i18n Keys +description: Translation key conventions and the reference catalog of plugin i18n keys +--- + +Plugins render every user-visible string through `useTranslate()` from `@btst/stack/context`: + +```tsx +const t = useTranslate(); + +t("blog.forms.titleLabel", "Title"); +t("blog.list.tagPageTitle", "{{tag}} Posts", { tag: tag.name }); +``` + +Without an `i18n` provider on `StackProvider`, `t()` returns the English default (with `{{param}}` interpolation), so apps with no provider behave exactly as before. With a provider, your `translate(key, defaultValue, params)` implementation decides what to render. + +## Key conventions + +- Keys are namespaced `..`, e.g. `blog.forms.titleLabel`. Areas group related UI (`common`, `list`, `card`, `post`, `forms`, `search`, ...). +- The second argument is always a **string literal** English default, so the key catalog can be regenerated mechanically: + +```bash +pnpm exec tsx packages/stack/scripts/extract-i18n-keys.ts +``` + +- Parameters use `{{param}}` interpolation in the default value. +- Plugins that support a legacy per-plugin `localization` override object resolve it with higher precedence than `t()`: + +```tsx +localization?.BLOG_FORMS_TITLE_LABEL ?? t("blog.forms.titleLabel", "Title"); +``` + +An app-provided `localization` override wins byte-for-byte; otherwise the string goes through the i18n provider. + +## Blog + +| Key | Default | +| --- | --- | +| `blog.card.draftBadge` | Draft | +| `blog.common.genericErrorMessage` | An unexpected error occurred. | +| `blog.common.genericErrorTitle` | Something went wrong | +| `blog.common.pageNotFoundDescription` | The page you are looking for does not exist. | +| `blog.common.pageNotFoundTitle` | Not Found | +| `blog.common.tagsShowAll` | Show all tags | +| `blog.common.tagsShowLess` | Show fewer tags | +| `blog.forms.cancelButton` | Cancel | +| `blog.forms.contentLabel` | Content | +| `blog.forms.deleteButton` | Delete Post | +| `blog.forms.deleteDialogCancel` | Cancel | +| `blog.forms.deleteDialogConfirm` | Delete | +| `blog.forms.deleteDialogDescription` | Are you sure you want to delete this post? This action cannot be undone. | +| `blog.forms.deleteDialogTitle` | Delete Post | +| `blog.forms.deletePending` | Deleting... | +| `blog.forms.editorPlaceholder` | Write something... | +| `blog.forms.excerptLabel` | Excerpt | +| `blog.forms.excerptPlaceholder` | Brief summary of your post... | +| `blog.forms.featuredImageErrorNotImage` | Please select an image file | +| `blog.forms.featuredImageErrorTooLarge` | Image size must be less than 4MB | +| `blog.forms.featuredImageInputPlaceholder` | Image URL or upload below... | +| `blog.forms.featuredImageLabel` | Image | +| `blog.forms.featuredImagePreviewAlt` | Featured image preview | +| `blog.forms.featuredImageRequiredAsterisk` |  * | +| `blog.forms.featuredImageToastFailure` | Failed to upload image | +| `blog.forms.featuredImageToastSuccess` | Image uploaded successfully | +| `blog.forms.featuredImageUploadButton` | Upload | +| `blog.forms.featuredImageUploadingButton` | Uploading... | +| `blog.forms.featuredImageUploadingText` | Uploading image... | +| `blog.forms.publishedDescription` | Toggle to publish immediately | +| `blog.forms.publishedLabel` | Published | +| `blog.forms.requiredAsterisk` |  * | +| `blog.forms.slugLabel` | Slug | +| `blog.forms.slugPlaceholder` | url-friendly-slug | +| `blog.forms.submitCreateIdle` | Create Post | +| `blog.forms.submitCreatePending` | Creating... | +| `blog.forms.submitUpdateIdle` | Update Post | +| `blog.forms.submitUpdatePending` | Updating... | +| `blog.forms.tagsLabel` | Tags | +| `blog.forms.tagsPlaceholder` | Enter your post tags... | +| `blog.forms.tagsSearchPlaceholder` | Search or create tags... | +| `blog.forms.titleLabel` | Title | +| `blog.forms.titlePlaceholder` | Enter your post title... | +| `blog.forms.toastCreateSuccess` | Post created successfully | +| `blog.forms.toastDeleteFailure` | Failed to delete post | +| `blog.forms.toastDeleteSuccess` | Post deleted successfully | +| `blog.forms.toastUpdateSuccess` | Post updated successfully | +| `blog.forms.validation.contentRequired` | Content is required | +| `blog.forms.validation.excerptRequired` | Excerpt is required | +| `blog.forms.validation.slugRequired` | Slug is required | +| `blog.forms.validation.titleRequired` | Title is required | +| `blog.list.draftsTitle` | Draft Posts | +| `blog.list.empty` | There are no posts here yet. | +| `blog.list.loadMore` | Load more posts | +| `blog.list.loadingMore` | Loading more... | +| `blog.list.searchButton` | Search Posts | +| `blog.list.searchEmpty` | No blog posts found. | +| `blog.list.searchPlaceholder` | Search Blog Posts... | +| `blog.list.tagNotFound` | Tag not found | +| `blog.list.tagNotFoundDescription` | The tag you are looking for does not exist. | +| `blog.list.tagPageDescription` | Browse all posts with this tag | +| `blog.list.tagPageTitle` | \{\{tag\}\} Posts | +| `blog.list.title` | Blog Posts | +| `blog.post.addDescription` | Create a new blog post. | +| `blog.post.addTitle` | Add New Post | +| `blog.post.editDescription` | Update your blog post. | +| `blog.post.editTitle` | Edit Post | +| `blog.post.keepReading` | Keep Reading | +| `blog.post.next` | Next | +| `blog.post.onThisPage` | In This Post | +| `blog.post.previous` | Previous | +| `blog.post.viewAll` | View all | +| `blog.search.button` | Search | +| `blog.search.empty` | No results found. | +| `blog.search.placeholder` | Type to search... | +| `blog.search.searching` | Searching... | + +## AI Chat + +Legacy `AiChatLocalization` values override these catalog entries when both are configured. + +| Key | Default | +| --- | --- | +| `aiChat.a11y.assistantMessage` | AI response | +| `aiChat.a11y.clearChat` | Clear chat | +| `aiChat.a11y.closeChat` | Close chat | +| `aiChat.a11y.closeSidebar` | Close sidebar | +| `aiChat.a11y.conversationActions` | Conversation actions | +| `aiChat.a11y.openChat` | Open chat | +| `aiChat.a11y.openMenu` | Open menu | +| `aiChat.a11y.openSidebar` | Open sidebar | +| `aiChat.a11y.title` | AI Chat | +| `aiChat.a11y.userMessage` | Your message | +| `aiChat.chat.emptyState` | Start a conversation... | +| `aiChat.chat.error` | Something went wrong. Please try again. | +| `aiChat.chat.loading` | Thinking... | +| `aiChat.chat.placeholder` | Type a message... | +| `aiChat.chat.send` | Send | +| `aiChat.conversation.delete` | Delete | +| `aiChat.conversation.deleteCancel` | Cancel | +| `aiChat.conversation.deleteConfirmButton` | Delete | +| `aiChat.conversation.deleteConfirmDescription` | Are you sure you want to delete this conversation? This action cannot be undone. | +| `aiChat.conversation.deleteConfirmTitle` | Delete conversation | +| `aiChat.conversation.rename` | Rename | +| `aiChat.conversation.renameCancel` | Cancel | +| `aiChat.conversation.renameDescription` | Enter a new title for this conversation. | +| `aiChat.conversation.renamePlaceholder` | Enter conversation name | +| `aiChat.conversation.renameSave` | Save | +| `aiChat.conversation.titleRequired` | Title is required | +| `aiChat.errors.genericMessage` | An error occurred while loading the chat. Please try again. | +| `aiChat.errors.genericTitle` | Something went wrong | +| `aiChat.errors.missingConversation` | Conversation is required | +| `aiChat.errors.notFoundDescription` | The conversation you're looking for doesn't exist or has been deleted. | +| `aiChat.errors.notFoundTitle` | Chat not found | +| `aiChat.files.attach` | Attach file | +| `aiChat.files.fallbackName` | File | +| `aiChat.files.remove` | Remove file | +| `aiChat.files.tooLarge` | File must be less than 10MB | +| `aiChat.files.uploadFailure` | Failed to attach file | +| `aiChat.files.uploadSuccess` | File attached | +| `aiChat.images.attachedAlt` | Attached image \{\{count\}\} | +| `aiChat.images.generatedAlt` | Image \{\{count\}\} | +| `aiChat.messages.cancel` | Cancel | +| `aiChat.messages.copied` | Copied! | +| `aiChat.messages.copy` | Copy message | +| `aiChat.messages.edit` | Edit message | +| `aiChat.messages.retry` | Retry | +| `aiChat.messages.save` | Save | +| `aiChat.sidebar.empty` | No conversations yet | +| `aiChat.sidebar.newChat` | New chat | +| `aiChat.time.daysAgo` | \{\{count\}\} days ago | +| `aiChat.time.hoursAgo` | \{\{count\}\} hours ago | +| `aiChat.time.justNow` | Just now | +| `aiChat.time.minutesAgo` | \{\{count\}\} minutes ago | +| `aiChat.time.yesterday` | Yesterday | +| `aiChat.toasts.deleteFailure` | Failed to delete conversation | +| `aiChat.toasts.deleteSuccess` | Conversation deleted | +| `aiChat.toasts.renameFailure` | Failed to rename conversation | +| `aiChat.toasts.renameSuccess` | Conversation renamed | +| `aiChat.tools.executionFailed` | Tool execution failed | +| `aiChat.tools.handlerMissing` | No client-side handler registered for tool "\{\{toolName\}\}". The page context may have changed while the response was streaming. | +| `aiChat.tools.id` | ID: \{\{id\}\} | +| `aiChat.tools.input` | Input | +| `aiChat.tools.output` | Output | +| `aiChat.tools.status.complete` | Complete | +| `aiChat.tools.status.error` | Error | +| `aiChat.tools.status.executing` | Executing... | +| `aiChat.tools.status.pending` | Pending | +| `aiChat.tools.status.running` | Running... | + +Other plugins adopt the same convention as their phase-2 sweeps land. diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index f80cd665f..383302f4f 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -196,13 +196,13 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createPrismaAdapter } from "@btst/adapter-prisma" import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -218,7 +218,7 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createDrizzleAdapter } from "@btst/adapter-drizzle" import { drizzle } from "drizzle-orm/postgres-js" // or "drizzle-orm/mysql2", "drizzle-orm/better-sqlite3", etc. import postgres from "postgres" @@ -226,7 +226,7 @@ In order to use BTST, your application must meet the following requirements: const client = postgres(process.env.DATABASE_URL!) const drizzleDb = drizzle(client) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -240,7 +240,7 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createKyselyAdapter } from "@btst/adapter-kysely" import { Kysely, PostgresDialect } from "kysely" import { Pool } from "pg" @@ -251,7 +251,7 @@ In order to use BTST, your application must meet the following requirements: }) }) - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -265,14 +265,14 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMongodbAdapter } from "@btst/adapter-mongodb" import { MongoClient } from "mongodb" const client = new MongoClient(process.env.MONGODB_URI!) const mongoDb = client.db() - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -288,10 +288,10 @@ In order to use BTST, your application must meet the following requirements: ```ts title="lib/stack.ts" // IMPORTANT: Memory adapter is used for development and testing only - import { stack } from "@btst/stack" + import { createBackendStack } from "@btst/stack/api" import { createMemoryAdapter } from "@btst/adapter-memory" - const { handler, dbSchema } = stack({ + const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { // Add your backend plugins here @@ -306,7 +306,7 @@ In order to use BTST, your application must meet the following requirements: **What happens here:** - - `stack()` collects all plugin database schemas and merges them into a unified `dbSchema` + - `createBackendStack()` collects all plugin database schemas and merges them into a unified `dbSchema` - The `basePath` determines where your API is mounted (e.g., `/api/data/*`) - The `adapter` function receives this merged schema (`db`) and returns an adapter that translates BTST's database operations to your ORM - The `handler` is a request handler function `(request: Request) => Promise` that processes all API calls @@ -358,61 +358,39 @@ In order to use BTST, your application must meet the following requirements: ### Create API Route - Create a catch-all API route to handle BTST requests. The route will handle requests for the path `/api/data/*`. If you use a different path make sure to update the `basePath` in the `stack` config to match your chosen path. + Create a catch-all API route to handle BTST requests. The `toNextRouteHandlers` / `toReactRouterHandlers` / `toTanStackHandlers` helpers from the framework entry points wire your stack `handler` to every HTTP method the route needs. The route will handle requests for the path `/api/data/*`. If you use a different path make sure to update the `basePath` in the `stack` config to match your chosen path. ```ts title="app/api/data/[[...all]]/route.ts" + import { toNextRouteHandlers } from "@btst/stack/next" import { handler } from "@/lib/stack" - export const GET = handler - export const POST = handler - export const PUT = handler - export const PATCH = handler - export const DELETE = handler + export const { GET, POST, PUT, PATCH, DELETE } = toNextRouteHandlers(handler) ``` - ```ts title="app/routes/api/data/route.ts" + ```ts title="app/routes/api/data/$.ts" + import { toReactRouterHandlers } from "@btst/stack/react-router" import { handler } from "~/lib/stack" - import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node" - export async function loader({ request }: LoaderFunctionArgs) { - return handler(request) - } - - export async function action({ request }: ActionFunctionArgs) { - return handler(request) - } + // React Router's build can't strip destructured exports from route + // modules, so assign loader/action individually. + const handlers = toReactRouterHandlers(handler) + export const loader = handlers.loader + export const action = handlers.action ``` ```ts title="src/routes/api/data/$.ts" - import { createFileRoute } from '@tanstack/react-router' - import { handler } from '@/lib/stack' + import { createFileRoute } from "@tanstack/react-router" + import { toTanStackHandlers } from "@btst/stack/tanstack" + import { handler } from "@/lib/stack" - export const Route = createFileRoute('/api/data/$')({ - server: { - handlers: { - GET: async ({ request }) => { - return handler(request) - }, - POST: async ({ request }) => { - return handler(request) - }, - PUT: async ({ request }) => { - return handler(request) - }, - PATCH: async ({ request }) => { - return handler(request) - }, - DELETE: async ({ request }) => { - return handler(request) - }, - }, - }, + export const Route = createFileRoute("/api/data/$")({ + server: { handlers: toTanStackHandlers(handler) }, }) ``` @@ -455,6 +433,10 @@ In order to use BTST, your application must meet the following requirements: ``` + + Keep the API path in this route, `createBackendStack({ basePath })`, and + `createClientStack({ api: { basePath } })` identical. `StackProvider` + receives that browser-safe projection through its `stack` prop. @@ -477,15 +459,56 @@ In order to use BTST, your application must meet the following requirements: Create a client instance that routes requests to plugin pages, prefetches their data on the server, and renders them with instant hydration on the client: - ```ts title="lib/stack-client.tsx" - import { createStackClient } from "@btst/stack/client" - import { QueryClient } from "@tanstack/react-query" + ```tsx title="lib/stack-client.tsx" + import { + createClientStack, + type ClientPluginEndpointOverride, + } from "@btst/stack/client" + import { blogClientPlugin } from "@btst/stack/plugins/blog/client" + import type { QueryClient } from "@tanstack/react-query" + + interface StackClientOptions { + apiOrigin?: string + siteOrigin?: string + } + + export const getStackClient = ( + queryClient: QueryClient, + options?: StackClientOptions, + ) => { + const siteOrigin = + options?.siteOrigin || + process.env.NEXT_PUBLIC_SITE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + (typeof window === "undefined" + ? process.env.BASE_URL || "http://localhost:3000" + : window.location.origin) + const apiOrigin = + options?.apiOrigin || + process.env.NEXT_PUBLIC_API_URL || + process.env.NEXT_PUBLIC_BASE_URL || + siteOrigin + const crossOriginApiEndpoint = + apiOrigin === siteOrigin + ? undefined + : ({ + api: { + baseURL: apiOrigin, + basePath: "/api/data", + credentials: "include", + }, + } satisfies ClientPluginEndpointOverride) - export const getStackClient = (queryClient: QueryClient) => { - return createStackClient({ + return createClientStack({ + api: { baseURL: apiOrigin, basePath: "/api/data" }, + site: { baseURL: siteOrigin, basePath: "/pages" }, + queryClient, plugins: { - // Add your client plugins here - } + blog: blogClientPlugin(), + }, + ...(crossOriginApiEndpoint + ? { endpoints: { blog: crossOriginApiEndpoint } } + : {}), }) } ``` @@ -494,14 +517,35 @@ In order to use BTST, your application must meet the following requirements: **Why a function?** `getStackClient` takes a `QueryClient` because different contexts use different instances: - **Server (SSR)**: Each request gets its own QueryClient (or cached per-request) - **Client**: A singleton QueryClient is shared across navigations - - **Additional options**: You can pass additional options to the `createStackClient` function, such as `headers` for SSR authentication if plugins expose lifecycle hooks. + API, site, QueryClient, and request-header configuration belongs on the + client stack so SSR loaders, metadata, and browser hooks resolve the same + runtime. Do not copy those services into plugin configuration or provider + overrides. + - This pattern allows you to pass the appropriate QueryClient and other options for each context. + + Credentialed SSR stacks must resolve their API destination from + deployment configuration, never from `Host`, forwarding headers, or + `request.url`. Set `BTST_API_URL` for a managed/custom API and + `BTST_SITE_URL` for the public site. For a same-origin deployment, + `BASE_URL` can provide both. Generated `stack-client.server.ts` uses + `resolveTrustedClientOrigins` from `@btst/stack/client/server`, fails + closed when production configuration is missing, and removes routing and + hop-by-hop headers before forwarding the remaining request credentials. + The browser stack above separately opts each API-owning plugin into + `credentials: "include"` only when the trusted API and site origins differ. + A managed backend that uses cookies must allow the public site origin and + credentialed requests in its CORS policy. + + Existing Next.js scaffolds may keep `NEXT_PUBLIC_BASE_URL` as a narrow + same-origin migration fallback when rerunning `btst init`. Prefer + `NEXT_PUBLIC_SITE_URL` plus `NEXT_PUBLIC_API_URL` (or server-only + `BTST_SITE_URL`/`BTST_API_URL`) for new deployment configuration. - ### Set Up Query Client Provider + ### Create the Query Client If you don't already have a query client utility, create one to ensure proper SSR hydration: @@ -546,89 +590,8 @@ In order to use BTST, your application must meet the following requirements: } ``` - Then configure `QueryClientProvider` in your your app: - - - - ```tsx title="app/layout.tsx" - import { QueryClientProvider } from "@tanstack/react-query" - import { getOrCreateQueryClient } from "@/lib/query-client" - - export default function RootLayout({ children }) { - const queryClient = getOrCreateQueryClient() - - return ( - - - - {children} - - - - ) - } - ``` - - - - ```tsx title="app/root.tsx" - import { QueryClientProvider } from "@tanstack/react-query" - import { getOrCreateQueryClient } from "~/lib/query-client" - import { Outlet } from "react-router" - - export default function App() { - const queryClient = getOrCreateQueryClient() - - return ( - - - - ) - } - ``` - - - - ```tsx title="src/router.tsx" - import { createRouter } from '@tanstack/react-router' - import { routeTree } from './routeTree.gen' - import { QueryClient } from '@tanstack/react-query' - import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' - import { getOrCreateQueryClient } from '@/lib/query-client' - - export interface MyRouterContext { - queryClient: QueryClient - } - - export function getRouter() { - const queryClient = getOrCreateQueryClient() - - const router = createRouter({ - routeTree, - scrollRestoration: true, - defaultPreload: false, - context: { - queryClient, - }, - notFoundMode: "root", - }) - - setupRouterSsrQueryIntegration({ - router, - queryClient, - }) - - return router - } - - declare module '@tanstack/react-router' { - interface Register { - router: ReturnType - } - } - ``` - - + The framework layouts in the next step install `QueryClientProvider` + alongside `StackProvider`, so BTST pages have one provider boundary. The `getOrCreateQueryClient()` utility ensures: @@ -642,81 +605,106 @@ In order to use BTST, your application must meet the following requirements: - ### Set Up Layout Provider + ### Set Up the Provider Layout - Wrap your BTST pages with the `StackProvider` to enable framework-specific overrides: + Put React Query and BTST's framework services around the `/pages/*` + subtree. The resolved stack projects API, site, and QueryClient services; + the provider adds the framework router and optional auth. The `overrides` + object contains only plugin-specific UI or behavior such as Blog's upload + function. - ```tsx title="app/pages/[[...all]]/layout.tsx" + ```tsx title="app/pages/client-layout.tsx" + "use client" + + import { useMemo, useState } from "react" + import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" - import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client" - import Link from "next/link" - import Image from "next/image" - import { useRouter } from "next/navigation" - - // Define the shape of all plugin overrides for type safety - type PluginOverrides = { - example: ExamplePluginOverrides - // Add other plugins here + import { nextRouter } from "@btst/stack/next" + import { getOrCreateQueryClient } from "@/lib/query-client" + import { getStackClient, type StackClientOptions } from "@/lib/stack-client" + import { uploadImage } from "@/lib/uploads" + + export function PagesClientLayout({ children, clientOrigins }: { + children: React.ReactNode + clientOrigins: StackClientOptions + }) { + const [queryClient] = useState(() => getOrCreateQueryClient()) + const clientStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) + + return ( + + + {children} + + + ) } + ``` - export default function Layout({ children }) { - const router = useRouter() - + ```tsx title="app/(request)/pages/layout.tsx" + import { headers } from "next/headers" + import { PagesClientLayout } from "../../pages/client-layout" + import { getServerClientOriginsFromHeaders } from "@/lib/stack-client.server" + + export default async function PagesLayout({ children }) { + const clientOrigins = getServerClientOriginsFromHeaders(await headers()) return ( - - basePath="/pages" - overrides={{ - example: { - Link: (props) => , - Image: (props) => , - navigate: (path) => router.push(path), - // Add other plugin overrides here - } - // Add other plugins here - }} - > + {children} - + ) } ``` + + Put SSG/ISR routes under `app/(static)/pages` and give that group a + header-free layout using `getServerClientOrigins()`. Both route groups + keep the `/pages/*` URL; the split prevents request headers from making + static routes dynamic. ```tsx title="app/routes/pages/_layout.tsx" - import { Outlet, Link, useNavigate } from "react-router" + import { useMemo, useState } from "react" + import { Outlet, useLoaderData, type LoaderFunctionArgs } from "react-router" + import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" - import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client" + import { reactRouter } from "@btst/stack/react-router" + import { getOrCreateQueryClient } from "~/lib/query-client" + import { getStackClient } from "~/lib/stack-client" + import { getServerClientOrigins } from "~/lib/stack-client.server" + import { uploadImage } from "~/lib/uploads" - // Define the shape of all plugin overrides - type PluginOverrides = { - example: ExamplePluginOverrides - // Add other plugins here + export function loader({ request }: LoaderFunctionArgs) { + return getServerClientOrigins(new URL(request.url).origin) } - export default function Layout() { - const navigate = useNavigate() - + export default function PagesLayout() { + const [queryClient] = useState(() => getOrCreateQueryClient()) + const { apiOrigin, siteOrigin } = useLoaderData() + const clientStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) + return ( - - basePath="/pages" - overrides={{ - example: { - navigate: (href) => navigate(href), - Link: ({ href, children, className, ...props }) => ( - - {children} - - ) - // Add other plugin overrides here - } - // Add other plugins here - }} - > - - + + + + + ) } ``` @@ -724,41 +712,34 @@ In order to use BTST, your application must meet the following requirements: ```tsx title="src/routes/pages/route.tsx" - import { StackProvider } from "@btst/stack/context" + import { createFileRoute, Outlet } from "@tanstack/react-router" import { QueryClientProvider } from "@tanstack/react-query" - import type { ExamplePluginOverrides } from "@btst/stack/plugins/example/client" - import { Link, useRouter, Outlet, createFileRoute } from "@tanstack/react-router" - - // Define the shape of all plugin overrides - type PluginOverrides = { - example: ExamplePluginOverrides - // Add other plugins here - } + import { StackProvider } from "@btst/stack/context" + import { tanstackRouter } from "@btst/stack/tanstack" + import { useMemo } from "react" + import { getStackClient } from "@/lib/stack-client" + import { getTrustedClientOrigins } from "@/lib/stack-client.origins" + import { uploadImage } from "@/lib/uploads" - export const Route = createFileRoute('/pages')({ - component: Layout + export const Route = createFileRoute("/pages")({ + loader: async () => getTrustedClientOrigins(), + component: PagesLayout, }) - function Layout() { - const router = useRouter() - const context = Route.useRouteContext() + function PagesLayout() { + const { queryClient } = Route.useRouteContext() + const { apiOrigin, siteOrigin } = Route.useLoaderData() + const clientStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) return ( - - - basePath="/pages" - overrides={{ - example: { - navigate: (href) => router.navigate({ href }), - Link: ({ href, children, className, ...props }) => ( - - {children} - - ) - // Add other plugin overrides here - } - // Add other plugins here - }} + + @@ -769,170 +750,178 @@ 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. - - **Type Safety**: Each plugin exports its override type (e.g., `ExamplePluginOverrides`) - + Add `auth`, `notify`, or `i18n` beside `stack` and `router` when your + application needs them. See the [auth provider guide](/auth). Never copy + framework routing, API paths, or identity props into a plugin override. On + Vite SSR, serialize only the trusted API/site origins into the parent loader + so the server provider and hydrated browser stack resolve the same endpoints. + TanStack client-navigation loaders should call the same generated + `getTrustedClientOrigins()` server function. Keep request headers and server + stack instances out of loader data. ### Set Up Page Handler - Create a catch-all route to handle BTST pages defined in your plugins. This enables server-side rendering, metadata generation and automatic route handling. + Create a catch-all route to handle BTST pages defined in your plugins. The page factories from the framework entry points own the invariant plumbing once: server-side prefetching via `route.loader()`, React Query dehydration (including failed queries, so the client doesn't refetch on errors), loader-before-meta ordering for SEO metadata, and 404 handling via your framework's mechanism. - ```tsx title="app/pages/[[...all]]/page.tsx" - import { dehydrate, HydrationBoundary } from "@tanstack/react-query" - import { notFound } from "next/navigation" + ```tsx title="app/(request)/pages/[[...all]]/page.tsx" + import { createNextPage } from "@btst/stack/next" + import { headers } from "next/headers" import { getOrCreateQueryClient } from "@/lib/query-client" - import { getStackClient } from "@/lib/stack-client" - import { metaElementsToObject, normalizePath } from "@btst/stack/client" - import { Metadata } from "next" - - export default async function Page({ params }: { params: Promise<{ all: string[] }> }) { - const pathParams = await params - const path = normalizePath(pathParams?.all) - - const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(path) - - // Prefetch data server-side if the route has a loader - if (route?.loader) await route.loader() - - // Serialize React Query cache for client hydration - const dehydratedState = dehydrate(queryClient) - - return ( - - {route && route.PageComponent ? : notFound()} - - ) - } + import { getStackClientForRequest } from "@/lib/stack-client.server" - export async function generateMetadata({ params }: { params: Promise<{ all: string[] }> }) { - const pathParams = await params - const path = normalizePath(pathParams?.all) - - const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(path) - - if (!route) return notFound() - if (route?.loader) await route.loader() - - // Convert plugin meta elements to Next.js Metadata format - return route.meta ? metaElementsToObject(route.meta()) satisfies Metadata : { title: "No meta" } - } + export const dynamic = "force-dynamic" + + const page = createNextPage({ + getStackClient: async (queryClient) => + getStackClientForRequest(queryClient, { + headers: new Headers(await headers()), + }), + getQueryClient: getOrCreateQueryClient, + }) + export default page.Page + export const generateMetadata = page.generateMetadata ``` - ```tsx title="app/routes/pages/index.tsx" - import type { Route } from "./+types/index" - import { useLoaderData } from "react-router" - import { dehydrate, HydrationBoundary, QueryClient, useQueryClient } from "@tanstack/react-query" + ```tsx title="app/routes/pages/$.tsx" + import { createReactRouterPage } from "@btst/stack/react-router" + import { getOrCreateQueryClient } from "~/lib/query-client" import { getStackClient } from "~/lib/stack-client" - import { normalizePath } from "@btst/stack/client" - - export async function loader({ params }: Route.LoaderArgs) { - const path = normalizePath(params["*"]) - - // Create QueryClient for this request with consistent config - const queryClient = new QueryClient({ - defaultOptions: { queries: { staleTime: 1000 * 60 * 5, refetchOnMount: false, retry: false } } - }) - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(path) - - if (route?.loader) await route.loader() - - // Include errors so client doesn't refetch on error - const dehydratedState = dehydrate(queryClient) - - return { path, dehydratedState, meta: route?.meta?.() } - } - export function meta({ loaderData }: Route.MetaArgs) { - return loaderData.meta - } - - export default function PagesIndex() { - const { path, dehydratedState } = useLoaderData() - const queryClient = useQueryClient() - const route = getStackClient(queryClient).router.getRoute(path) - const Page = route && route.PageComponent ? :
Route not found
- - return dehydratedState ? ( - {Page} - ) : Page - } + const page = createReactRouterPage({ getStackClient, getQueryClient: getOrCreateQueryClient }) + export const loader = page.loader + export const meta = page.meta + export const ErrorBoundary = page.ErrorBoundary + export default page.Component ```
```tsx title="src/routes/pages/$.tsx" - import { createFileRoute, notFound } from "@tanstack/react-router" + import { createFileRoute } from "@tanstack/react-router" + import { createTanStackPageOptions } from "@btst/stack/tanstack" import { getStackClient } from "@/lib/stack-client" - import { normalizePath } from "@btst/stack/client" - - export const Route = createFileRoute("/pages/$")({ - ssr: true, - component: Page, - loader: async ({ params, context }) => { - const routePath = normalizePath(params._splat) - const stackClient = getStackClient(context.queryClient) - const route = stackClient.router.getRoute(routePath) - - if (!route) throw notFound() - if (route?.loader) await route.loader() - - return { meta: await route?.meta?.() } - }, - head: ({ loaderData }) => { - return loaderData?.meta && Array.isArray(loaderData.meta) - ? { meta: loaderData.meta } - : { meta: [{ title: "No Meta" }], title: "No Meta" } - }, - notFoundComponent: () =>

This page doesn't exist!

- }) - function Page() { - const context = Route.useRouteContext() - const { _splat } = Route.useParams() - const routePath = normalizePath(_splat) - const route = getStackClient(context.queryClient).router.getRoute(routePath) - - return route && route.PageComponent ? :
Route not found
- } + export const Route = createFileRoute("/pages/$")( + createTanStackPageOptions({ getStackClient }), + ) ```
- - **How it works:** - - `stackClient.router.getRoute(path)` matches the URL to a plugin route and returns a route object: - - ```typescript - route = { - PageComponent: React.ComponentType, // The page to render - loader?: () => Promise, // Prefetches React Query data - meta?: () => MetadataElements, // Returns SEO metadata - ErrorComponent?: React.ComponentType, // Standalone error components - LoadingComponent?: React.ComponentType // Standalone loading components - } - ``` - - **Key steps:** - - **Server-side data loading**: Call `route.loader()` before rendering to prefetch data into React Query cache - - **Hydration**: Use `dehydrate()` to serialize prefetched data for the client (not required with TanStack Start) - - **Error handling**: Configure your query client with `shouldDehydrateQuery` to include failed queries in dehydration, preventing client-side refetching on errors - - **Metadata generation**: Use `route.meta()` with framework-specific meta functions for SEO - - **404 handling**: Return `notFound()` or your framework's equivalent function when routes don't exist + **How it works:** + + The factory matches the URL to a plugin route via `stackClient.router.getRoute(path)`, prefetches data server-side with `route.loader()`, renders the route's `PageComponent` with instant hydration on the client, and generates SEO metadata from `route.meta()` (running the loader first, so meta can read prefetched data). + + **Factory options:** + - `createNextPage` accepts an async `getStackClient`, plus `notFound`, `wrapPage`, and `dehydrateOptions`. + - `createReactRouterPage` exposes `createLoader()` for async request-aware stack clients and accepts `NotFound`, `ErrorBoundary`, `wrapPage`, and `dehydrateOptions`. + - `createTanStackPageOptions` accepts `getLoaderStackClient` for async context-aware loaders and `getQueryClient` when the QueryClient is not available from router context. + + Use these options to customize the entry factory without reimplementing + route matching, loader ordering, hydration, metadata, or 404 handling. + + + + **Request-aware stack clients** + + Keep session and authorization policy in your application. The entry + factories pass each framework's native lifecycle context to an async + resolver while preserving a synchronous client for browser rendering. + + + + Next.js page and metadata functions run on the server, so the main + resolver can await request headers and session state directly: + + ```tsx + import { headers } from "next/headers" + import { createNextPage } from "@btst/stack/next" + import { getOrCreateQueryClient } from "@/lib/query-client" + import { getStackClientForRequest } from "@/lib/stack-client.server" + + const page = createNextPage({ + getQueryClient: getOrCreateQueryClient, + getStackClient: async (queryClient, pageProps) => { + const requestHeaders = await headers() + return getStackClientForRequest(queryClient, { + headers: new Headers(requestHeaders), + pageProps, + }) + }, + }) + + export default page.Page + export const generateMetadata = page.generateMetadata + ``` + + + + React Router framework loaders are server-only, but the route + component also renders in the browser. Keep the normal client + synchronous and create a request-aware loader separately: + + ```tsx + import { createReactRouterPage } from "@btst/stack/react-router" + import { getOrCreateQueryClient } from "~/lib/query-client" + import { getStackClient } from "~/lib/stack-client" + import { getStackClientForRequest } from "~/lib/stack-client.server" + + const page = createReactRouterPage({ + getStackClient, + getQueryClient: getOrCreateQueryClient, + }) + + export const loader = page.createLoader( + async (queryClient, { request, context, params }) => + getStackClientForRequest(queryClient, { + headers: request.headers, + requestOrigin: new URL(request.url).origin, + context, + params, + }), + ) + export const meta = page.meta + export default page.Component + ``` + + + + TanStack loaders run during SSR and browser navigation. Supply an + isomorphic resolver and put request-derived session data in router + context before the loader runs: + + ```tsx + import type { QueryClient } from "@tanstack/react-query" + import { createFileRoute } from "@tanstack/react-router" + import { createTanStackPageOptions } from "@btst/stack/tanstack" + import type { AppSession } from "@/lib/auth" + import { getStackClient, getStackClientForLoad } from "@/lib/stack-client" + + type AppRouterContext = { + queryClient: QueryClient + session: AppSession | null + } + + export const Route = createFileRoute("/pages/$")( + createTanStackPageOptions({ + getStackClient, + getLoaderStackClient: (queryClient, { context, params }) => + getStackClientForLoad(queryClient, { + session: context.session, + params, + }), + }), + ) + ``` + +
@@ -1029,7 +1018,8 @@ In order to use BTST, your application must meet the following requirements: - ✅ Database adapter that connects plugins to your database - ✅ Client-side router with SSR support - ✅ React Query integration for data fetching - - ✅ Framework-specific overrides + - ✅ API, site, and QueryClient configured once on the resolved client stack + - ✅ Framework router and optional auth services configured once on the provider **Next steps:** @@ -1056,4 +1046,3 @@ In order to use BTST, your application must meet the following requirements: - diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index e602b2225..212b7165f 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -26,6 +26,8 @@ "---[Database]Databases---", "databases/adapters", "---[BookOpenCheck]Concepts---", + "auth", + "i18n", "cli", "api-reference", "standalone-components", @@ -34,4 +36,3 @@ "[Try the Playground](https://www.better-stack.ai/playground)" ] } - \ No newline at end of file diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx index 9ef656617..5f4f8e269 100644 --- a/docs/content/docs/plugins/ai-chat.mdx +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -34,24 +34,71 @@ Follow these steps to add the AI Chat plugin to your BTST setup. Import and register the AI Chat backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" +import { defineAuthorization } from "@btst/stack/authorization" +import { createServerAuth } from "@btst/stack/authorization/server" import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" +import { aiChatPermissions } from "@btst/stack/plugins/ai-chat/permissions" import { openai } from "@ai-sdk/openai" +import { z } from "zod" // ... your adapter imports -const { handler, dbSchema } = stack({ +// Browser-safe: put this in a shared authorization.ts module when the +// frontend and backend live in the same codebase. +export const authorization = defineAuthorization({ + identity: z.object({ + id: z.string(), + role: z.enum(["user", "admin"]), + }), + permissions: [aiChatPermissions] as const, + rules: ({ aiChat }) => { + const owns = ( + identity: { id: string; role: "user" | "admin" } | null, + ownerId?: string, + ) => identity !== null && (identity.role === "admin" || identity.id === ownerId) + const canStart = ( + identity: { id: string; role: "user" | "admin" } | null, + ownerId?: string, + ) => identity !== null && (ownerId === undefined || owns(identity, ownerId)) + + return [ + aiChat.conversation.read.when(({ identity, facts }) => + facts.scope === "collection" ? identity !== null : owns(identity, facts.ownerId), + ), + aiChat.conversation.create.when(({ identity }) => identity !== null), + aiChat.conversation.update.when(({ identity, facts }) => owns(identity, facts.ownerId)), + aiChat.conversation.delete.when(({ identity, facts }) => owns(identity, facts.ownerId)), + aiChat.message.send.when(({ identity, facts }) => + facts.createsConversation ? identity !== null : owns(identity, facts.ownerId), + ), + aiChat.message.edit.when(({ identity, facts }) => owns(identity, facts.ownerId)), + aiChat.message.retry.when(({ identity, facts }) => owns(identity, facts.ownerId)), + aiChat.attachment.send.when(({ identity, facts }) => canStart(identity, facts.ownerId)), + aiChat.tool.activate.when(({ identity, facts }) => canStart(identity, facts.ownerId)), + aiChat.stream.start.when(({ identity, facts }) => + facts.createsConversation ? identity !== null : owns(identity, facts.ownerId), + ), + ] + }, +}) + +const serverAuth = createServerAuth({ + authorization, + getIdentity: async ({ headers }) => { + const token = headers.get("authorization") + if (!token) return null + const user = await verifyToken(token) + return user ? { id: user.id, role: user.role } : null + }, +}) + +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", + auth: serverAuth, plugins: { aiChat: aiChatBackendPlugin({ model: openai("gpt-4o"), // Or any LanguageModel from AI SDK - mode: "authenticated", // "authenticated" (default) or "public" - // Extract userId from request headers to scope conversations per user - getUserId: async (ctx) => { - const token = ctx.headers?.get("authorization") - if (!token) return null // Deny access if no auth - const user = await verifyToken(token) // Your auth logic - return user?.id ?? null - }, + access: "authorized", // Default; use "public" only intentionally systemPrompt: "You are a helpful assistant.", // Optional tools: {}, // Optional: AI SDK v5 tools }) @@ -62,7 +109,18 @@ const { handler, dbSchema } = stack({ export { handler, dbSchema } ``` -The `aiChatBackendPlugin()` accepts optional hooks for customizing behavior (authorization, logging, etc.). +The `aiChatBackendPlugin()` accepts optional lifecycle hooks for domain behavior, logging, rate limits, and tool safety. Identity/role/ownership policy belongs in the typed rules above. + + +Authorized AI Chat streams persist conversation and message history. Production +database adapters must therefore provide real isolated transactions so the +final authorization/CAS check, lifecycle hooks, and persistence commit as one +unit. Set `transaction: true` on supported Prisma, Drizzle, and Kysely adapters; +otherwise authorized streaming and owner-sensitive history mutations fail +closed with `ATOMIC_TRANSACTION_REQUIRED`. The CLI enables this option when AI +Chat is selected. The memory adapter remains available for local, +single-process development, and explicit public mode does not persist history. + **Model Configuration:** You can use any model from the AI SDK, including OpenAI, Anthropic, Google, and more. Make sure to install the corresponding provider package (e.g., `@ai-sdk/openai`) and set up your API keys in environment variables. @@ -73,7 +131,7 @@ The `aiChatBackendPlugin()` accepts optional hooks for customizing behavior (aut Register the AI Chat client plugin in your `stack-client.tsx` file: ```tsx title="lib/stack-client.tsx" -import { createStackClient } from "@btst/stack/client" +import { createClientStack } from "@btst/stack/client" import { aiChatClientPlugin } from "@btst/stack/plugins/ai-chat/client" import { QueryClient } from "@tanstack/react-query" @@ -82,19 +140,23 @@ const getBaseURL = () => ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) : (process.env.BASE_URL || "http://localhost:3000") -export const getStackClient = (queryClient: QueryClient, options?: { headers?: Headers }) => { +export const getStackClient = ( + queryClient: QueryClient, + options?: { headers?: Headers; identity?: { id: string; role: "user" | "admin" } }, +) => { const baseURL = getBaseURL() - return createStackClient({ + return createClientStack({ + api: { + baseURL, + basePath: "/api/data", + ...(options?.headers ? { headers: options.headers } : {}), + }, + site: { baseURL, basePath: "/pages" }, + queryClient, plugins: { aiChat: aiChatClientPlugin({ - // Required configuration - apiBaseURL: baseURL, - apiBasePath: "/api/data", - siteBaseURL: baseURL, - siteBasePath: "/pages", - queryClient: queryClient, - headers: options?.headers, - // Mode should match backend config + identityPartition: options?.identity, + // Client conversation UI/persistence mode mode: "authenticated", // "authenticated" (default) or "public" // Optional: SEO configuration seo: { @@ -107,15 +169,26 @@ export const getStackClient = (queryClient: QueryClient, options?: { headers?: H } ``` -**Required configuration:** -- `apiBaseURL`: Base URL for API calls during SSR data prefetching (use environment variables for flexibility) -- `apiBasePath`: Path where your API is mounted (e.g., `/api/data`) -- `siteBaseURL`: Base URL of your site -- `siteBasePath`: Path where your pages are mounted (e.g., `/pages`) -- `queryClient`: React Query client instance +The stack owns the shared API location, site location, request headers, and +`QueryClient`. `aiChatClientPlugin()` accepts only AI Chat choices such as mode, +SEO, loader hooks, page overrides, and the optional SSR identity partition. -**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. +Server request headers belong only on `createClientStack({ api: { headers } })`. +For an intentionally public cross-origin AI Chat endpoint, use +`endpoints.aiChat.api` with explicit `browserHeaders` and `credentials`; sensitive +headers such as `authorization` and `cookie` are rejected from the browser projection. + + + +**Migrating to RC3:** move `apiBaseURL`, `apiBasePath`, `siteBaseURL`, +`siteBasePath`, `queryClient`, and `headers` out of `aiChatClientPlugin()` and +into the top-level client stack shown above. Rename the provider override key +from `"ai-chat"` to `aiChat`, pass the resolved stack to `StackProvider`, remove +the manual provider generic/override map, and rename the loader error hook from +`onLoadError` to `onErrorLoad`. Configure `mode` only in +`aiChatClientPlugin()`; the resolved stack carries it to browser components. +The package path and `/chat` URL do not change. ### 3. Import Plugin CSS @@ -128,54 +201,44 @@ 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`: +Pass the resolved client stack to `StackProvider`; it supplies the API/site +runtime and infers the exact `aiChat` override type. Keep only framework routing +and AI Chat-specific values on the provider: - ```tsx title="app/pages/layout.tsx" + ```tsx title="app/pages/client-layout.tsx" "use client" - import { useState } from "react" + import { useMemo } 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 = () => - typeof window !== 'undefined' - ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:3000") - - type PluginOverrides = { - "ai-chat": AiChatPluginOverrides - } - - export default function Layout({ children }) { - const router = useRouter() - const [queryClient] = useState(() => getOrCreateQueryClient()) - const baseURL = getBaseURL() + import { getStackClient, type StackClientOptions } from "@/lib/stack-client" + + export default function Layout({ children, clientOrigins }: { + children: React.ReactNode + clientOrigins: StackClientOptions + }) { + const queryClient = getOrCreateQueryClient() + const stack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) return ( - - basePath="/pages" + router.push(path), - refresh: () => router.refresh(), + aiChat: { uploadFile: async (file) => { // Implement your file upload logic return "https://example.com/uploads/file.pdf" }, - Link: ({ href, ...props }) => , - Image: (props) => , } }} > @@ -190,43 +253,26 @@ 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" - - const getBaseURL = () => - typeof window !== 'undefined' - ? (import.meta.env.VITE_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:5173") - - type PluginOverrides = { - "ai-chat": AiChatPluginOverrides - } + import { getStackClient } from "~/lib/stack-client" export default function Layout() { - const navigate = useNavigate() const [queryClient] = useState(() => new QueryClient()) - const baseURL = getBaseURL() + const stack = getStackClient(queryClient) return ( - - basePath="/pages" + navigate(href), + aiChat: { uploadFile: async (file) => { return "https://example.com/uploads/file.pdf" }, - Link: ({ href, children, className, ...props }) => ( - - {children} - - ), } }} > @@ -242,42 +288,25 @@ 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" - - const getBaseURL = () => - typeof window !== 'undefined' - ? (import.meta.env.VITE_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:3000") - - type PluginOverrides = { - "ai-chat": AiChatPluginOverrides - } + import { Outlet } from "@tanstack/react-router" + import { getStackClient } from "@/lib/stack-client" function Layout() { - const router = useRouter() const [queryClient] = useState(() => new QueryClient()) - const baseURL = getBaseURL() + const stack = getStackClient(queryClient) return ( - - basePath="/pages" + router.navigate({ href }), + aiChat: { uploadFile: async (file) => { return "https://example.com/uploads/file.pdf" }, - Link: ({ href, children, className, ...props }) => ( - - {children} - - ), } }} > @@ -290,25 +319,11 @@ 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 @@ -326,13 +341,13 @@ For more details on the CLI and all available options, see the [CLI documentatio Your AI Chat plugin is now fully configured and ready to use! Here's a quick reference of what's available: -### Plugin Modes +### Access modes The AI Chat plugin supports two distinct modes: -**Authenticated Mode (Default)** +**Authorized access (default)** - Conversation persistence in database -- User-scoped data via `getUserId` +- Identity-scoped data from the request's validated server identity - Full UI with sidebar and conversation history - Routes: `/chat` (new/list) and `/chat/:id` (existing conversation) @@ -344,18 +359,26 @@ The AI Chat plugin supports two distinct modes: ### API Endpoints -The AI Chat plugin provides the following API endpoints (mounted at your configured `apiBasePath`): +The AI Chat plugin provides the following API endpoints, mounted at the resolved +AI Chat API location. Configure that location with +`createClientStack({ endpoints: { aiChat: { api: { ... } } } })`; otherwise it +inherits the stack's top-level `api` location. - **POST** `/chat` - Send a message and receive streaming response -- **GET** `/conversations` - List all conversations (authenticated mode only) -- **GET** `/conversations/:id` - Get a conversation with messages -- **POST** `/conversations` - Create a new conversation -- **PUT** `/conversations/:id` - Update (rename) a conversation -- **DELETE** `/conversations/:id` - Delete a conversation +- **GET** `/chat/conversations` - List all conversations (authenticated mode only) +- **GET** `/chat/conversations/:id` - Get a conversation with messages +- **POST** `/chat/conversations` - Create a new conversation +- **PUT** `/chat/conversations/:id` - Rename a conversation; the title is trimmed and must not be empty +- **DELETE** `/chat/conversations/:id` - Delete a conversation + +In authorized mode, the streaming response includes `X-Conversation-Id` as soon as the backend has resolved or created the authoritative conversation. The built-in client uses that header to bind streamed message controls to persisted IDs even when the history-list refresh is delayed or fails. A separately deployed backend that implements the BTST contract must preserve this header; cross-origin deployments must also expose it through CORS. ### Page Routes -The AI Chat plugin automatically creates the following pages (mounted at your configured `siteBasePath`): +The AI Chat plugin automatically creates the following pages, mounted at the +resolved AI Chat site location. Configure that location with +`createClientStack({ endpoints: { aiChat: { site: { ... } } } })`; otherwise it +inherits the stack's top-level `site` location. **Authenticated mode:** - `/chat` - Start a new conversation (with sidebar showing history) @@ -373,12 +396,12 @@ The AI Chat plugin automatically creates the following pages (mounted at your co - **File Uploads**: Attach images, PDFs, and text files to messages - **Tools Support**: Use AI SDK v5 tools for function calling - **Customizable Models**: Use any LanguageModel from the AI SDK -- **Authorization Hooks**: Add custom authentication and authorization logic +- **Typed Authorization**: Reuse the same schema-backed rules for browser hints and authoritative server checks - **Localization**: Customize all UI strings ### Page Component Overrides -You can replace any built-in page with your own React component using the optional `pageComponents` field in `aiChatClientPlugin(config)`. The built-in component is used as the fallback whenever an override is not provided, so this is fully backward-compatible. +You can replace any built-in page with your own React component using the optional `pageComponents` field in `aiChatClientPlugin(config)`. The built-in component is used as the fallback whenever an override is not provided. Overrides for parameterized routes receive the route context (`{ params }`) as props. ```tsx aiChatClientPlugin({ @@ -387,9 +410,9 @@ aiChatClientPlugin({ // Replace the chat home page chat: MyCustomChatPage, // Replace the conversation page (authenticated mode only) - // receives conversationId as a prop - chatConversation: ({ conversationId }) => ( - + // receives the route context as props + chatConversation: ({ params }) => ( + ), }, }) @@ -397,7 +420,44 @@ aiChatClientPlugin({ ### Adding Authorization -To add authorization rules and customize behavior, you can use the lifecycle hooks defined in the API Reference section below. These hooks allow you to control access to API endpoints, add logging, and customize the plugin's behavior to fit your application's needs. +AI Chat publishes its browser-safe catalog from `@btst/stack/plugins/ai-chat/permissions`. Bind the same `authorization` definition to `createClientAuth()` for presentation and `createServerAuth()` for enforcement: + +```tsx +import { createClientAuth } from "@btst/stack/authorization/client" +import { authorization } from "./authorization" + +const clientAuth = createClientAuth({ + authorization, + getIdentity: () => session?.user ?? null, + loginPath: "/sign-in", +}) + + + {children} + +``` + +The built-in route, new-chat, rename, delete, send, edit, retry, attachment, and tool controls construct exact descriptors from rendered conversation/message data. Those values are presentation hints only. Every backend operation reloads the authoritative conversation owner and message state before evaluating the same rule. + +The streaming operation checks `stream.start` plus the exact semantic intent (`message.send`, `message.edit`, `message.retry`, attachment, tool, and conversation creation when applicable) before hooks, persistence, or provider work. A completed client-tool continuation requires both send and retry permission for its server-resolved user message, so a forged assistant transcript cannot bypass a denied retry rule. A coarse stream rule cannot bypass a denied sub-operation rule. + +Attachment controls evaluate the selected file's real MIME type before calling the configured upload transport. The backend independently validates and authorizes the submitted file parts again before starting the provider. + +Missing rules deny in authorized mode. Anonymous denials return 401 and identified denials return 403; identity, rule, schema, and fact-loading failures remain errors. BTST does not install an authorization-result cache. + +The operation catalog also powers both server call styles: + +```ts +await app.forRequest(request).operations.aiChat.deleteConversation({ id }) // authorized +await app.trusted.aiChat.deleteConversation({ id }) // trusted +``` + +`trusted` skips only user authorization. Input validation, authoritative reads, lifecycle hooks, persistence, and provider/tool behavior remain active. The raw getters documented below deliberately bypass authorization and lifecycle composition. ## API Reference @@ -409,49 +469,61 @@ To add authorization rules and customize behavior, you can use the lifecycle hoo #### AiChatBackendConfig -The backend plugin accepts a configuration object with the model, mode, and optional hooks: +The backend plugin accepts a configuration object with the model, explicit access policy, and optional hooks: #### AiChatBackendHooks -Customize backend behavior with optional lifecycle hooks. All hooks are optional and allow you to add authorization, logging, and custom behavior: +Customize post-authorization domain behavior with optional lifecycle hooks. Keep ordinary identity, role, owner, message, attachment, and tool policy in `aiChatPermissions` rules. +AI Chat lifecycle names use the action-first `onBefore`, `onAfter`, and `onError` grammar. Chat completion remains the domain event `onAfterChat`. + +{/* canonical-dx-guard: migration:start reason="plugin lifecycle migration table" */} + +| Before RC3 | RC3 | +|---|---| +| `onBeforeToolsActivated` | `onBeforeActivateTools` | +| `onConversationsRead` | `onAfterListConversations` | +| `onConversationRead` | `onAfterGetConversation` | +| `onConversationCreated` | `onAfterCreateConversation` | +| `onConversationUpdated` | `onAfterUpdateConversation` | +| `onConversationDeleted` | `onAfterDeleteConversation` | +| `onChatError` | `onErrorChat` | +| `onListConversationsError` | `onErrorListConversations` | +| `onGetConversationError` | `onErrorGetConversation` | +| `onCreateConversationError` | `onErrorCreateConversation` | +| `onUpdateConversationError` | `onErrorUpdateConversation` | +| `onDeleteConversationError` | `onErrorDeleteConversation` | + +{/* canonical-dx-guard: migration:end */} + **Example usage:** ```ts title="lib/stack.ts" import { aiChatBackendPlugin, type AiChatBackendHooks } from "@btst/stack/plugins/ai-chat/api" const chatHooks: AiChatBackendHooks = { - // Authorization hooks — throw to deny access + // Domain/abuse controls run only after typed authorization succeeds. onBeforeChat(messages, context) { - const authHeader = context.headers?.get("authorization") - if (!authHeader) throw new Error("Unauthorized") - }, - async onBeforeListConversations(context) { - if (!await isAuthenticated(context.headers as Headers)) - throw new Error("Unauthorized") - }, - async onBeforeDeleteConversation(conversationId, context) { - if (!await isAuthenticated(context.headers as Headers)) - throw new Error("Unauthorized") + enforceRateLimit(context.headers) }, // Lifecycle hooks - onConversationCreated(conversation, context) { + onAfterCreateConversation(conversation, context) { console.log("Conversation created:", conversation.id) }, onAfterChat(conversationId, messages, context) { console.log("Chat completed:", conversationId, "messages:", messages.length) }, // Error hooks - onChatError(error, context) { + onErrorChat(error, context) { console.error("Chat error:", error.message) }, } -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ plugins: { aiChat: aiChatBackendPlugin({ model: openai("gpt-4o"), @@ -474,7 +546,17 @@ const { handler, dbSchema } = stack({ #### AiChatClientConfig -The client plugin accepts a configuration object with required fields and optional SEO settings: +The client plugin accepts AI Chat-specific mode, SEO, loader hook, identity-partition, and page-component options. Shared runtime fields come from `createClientStack()`: + +`mode` has one source of truth: `aiChatClientPlugin()`. The resolved client +stack carries it to both built-in routes and standalone AI Chat components, so +do not repeat it in `StackProvider.overrides`. + +Route-aware tool configuration stays at its established ownership seams: +enable page tools and register tool schemas on `aiChatBackendPlugin()`, then +register the active page's browser handlers with +`useRegisterPageAIContext({ clientTools })`. Neither `clientTools` nor a +`pageTools` option belongs on `aiChatClientPlugin()`. @@ -482,13 +564,6 @@ The client plugin accepts a configuration object with required fields and option ```tsx title="lib/stack-client.tsx" aiChat: aiChatClientPlugin({ - // Required configuration - apiBaseURL: baseURL, - apiBasePath: "/api/data", - siteBaseURL: baseURL, - siteBasePath: "/pages", - queryClient: queryClient, - headers: options?.headers, // Mode configuration mode: "authenticated", // Optional SEO configuration @@ -503,7 +578,11 @@ aiChat: aiChatClientPlugin({ #### AiChatClientHooks -Customize client-side behavior with lifecycle hooks. These hooks are called during data fetching (both SSR and CSR): +Customize server-loader behavior with lifecycle hooks. The route loaders call +these hooks while preloading data for SSR. Browser queries and mutations do not +run them. `onErrorLoad` is a contained reporting hook: use it for logging or +telemetry, not redirects, and do not rely on errors it throws escaping the +loader. @@ -511,21 +590,16 @@ Customize client-side behavior with lifecycle hooks. These hooks are called duri ```tsx title="lib/stack-client.tsx" aiChat: aiChatClientPlugin({ - // ... rest of the config - headers: options?.headers, hooks: { beforeLoadConversations: async (context) => { - // Check if user is authenticated before loading - if (!await isAuthenticated(context.headers)) - throw new Error("Unauthorized") + console.log("Loading conversations for", context.path) }, afterLoadConversation: async (conversation, id, context) => { // Log access for analytics console.log("User accessed conversation:", id) }, - onLoadError(error, context) { - // Handle error - redirect to login - redirect("/auth/sign-in") + onErrorLoad(error, context) { + reportLoaderError(error, { path: context.path }) }, } }) @@ -541,7 +615,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: @@ -549,13 +623,8 @@ 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), + aiChat: { // Optional overrides - mode: "authenticated", uploadFile: async (file) => { const formData = new FormData() formData.append("file", file) @@ -575,15 +644,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") - }, } }} ``` @@ -592,6 +652,9 @@ overrides={{ The `ChatLayout` component provides a ready-to-use chat interface. It can be used directly for custom integrations or public mode with persistence: +`ChatLayout` always uses the mode registered by `aiChatClientPlugin()`; it does +not accept a second component-level mode. + ```tsx import { ChatLayout, type ChatLayoutProps, type UIMessage } from "@btst/stack/plugins/ai-chat/client" ``` @@ -606,8 +669,6 @@ The default widget mode manages its own open/close state and renders a floating ```tsx @@ -620,8 +681,6 @@ Use `defaultOpen` and `showTrigger={false}` when your own UI handles opening and ```tsx {/* Rendered inside a modal/dialog that you control */} - typeof window !== "undefined" - ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:3000"); - export default function ChatModal() { const router = useRouter(); - const baseURL = getBaseURL(); return ( {/* Backdrop */}
router.back()}> {/* Modal card */}
e.stopPropagation()}> - - {/* Panel is pre-opened; no trigger button rendered */} - - + {/* This route remains below the app's existing StackProvider. */} +
); @@ -696,8 +744,6 @@ export default function ChatModal() { ```tsx +### UseRenameConversationFormOptions + + + +`useRenameConversationForm()` trims the submitted title, maps server validation issues to `fieldErrors.title`, sends success and non-field failures through the `StackProvider` `notify` provider, and preserves the conversation detail cache while refreshing the list. + **Example usage:** ```tsx @@ -745,6 +798,7 @@ import { useConversation, useCreateConversation, useRenameConversation, + useRenameConversationForm, useDeleteConversation, } from "@btst/stack/plugins/ai-chat/client/hooks" @@ -777,6 +831,35 @@ function ConversationsList() { } ``` +For a custom rename dialog, prefer the form lifecycle over calling the raw mutation directly: + +```tsx +const renameForm = useRenameConversationForm({ + conversation, + onSuccess: () => setOpen(false), +}) + +await renameForm.submit({ title }) + +return renameForm.fieldErrors.title ? ( +

{renameForm.fieldErrors.title}

+) : null +``` + +### Query keys and resource declaration + +The server-safe query-key entry point exposes both the factory and the underlying declaration: + +```ts +import { + aiChatResources, + createAiChatQueryKeys, + type AiChatQueryKeys, +} from "@btst/stack/plugins/ai-chat/query-keys" +``` + +The stable prefixes remain `['conversations', 'list', 'all']` and `['conversations', 'detail', id]`. Protected keys append the validated identity id plus an opaque fingerprint (or an explicit anonymous marker), never the full identity object. Structural claims are fingerprinted deterministically; non-JSON claims use conservative reference partitions because they cannot cross an SSR boundary. This keeps authorization-relevant identity changes in separate cache partitions without serializing those claims into dehydrated caches. Pending/error identity generations use separate disabled keys, mutations refresh only the partition that started them, and an account switch clears drafts, attachments, edit state, messages, and any active stream. Server loaders must pass `identityPartition` (the framework codegen does this automatically) so dehydrated data lands in the same browser partition. + ## Model & Tools Configuration ### Using Different Models @@ -846,8 +929,8 @@ The default `ToolCallDisplay` component shows: Provide custom UI components for specific tools using the `toolRenderers` override. Each key should match the tool name from your backend configuration: -```tsx title="app/pages/layout.tsx" -import type { AiChatPluginOverrides, ToolCallProps } from "@btst/stack/plugins/ai-chat/client" +```tsx title="app/pages/client-layout.tsx" +import type { ToolCallProps } from "@btst/stack/plugins/ai-chat/client" // Custom weather card component function WeatherCard({ input, output, isLoading }: ToolCallProps<{ location: string }, { temperature: number; condition: string }>) { @@ -871,13 +954,11 @@ function WeatherCard({ input, output, isLoading }: ToolCallProps<{ location: str } // In your layout - - basePath="/pages" + router.push(path), + aiChat: { // Custom tool renderers toolRenderers: { getWeather: WeatherCard, @@ -929,19 +1010,19 @@ For public chatbots without user authentication: ### Backend Setup ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" import { openai } from "@ai-sdk/openai" // Example rate limiter (implement your own) const rateLimiter = new Map() -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { aiChat: aiChatBackendPlugin({ model: openai("gpt-4o"), - mode: "public", // Stateless mode - no persistence + access: "public", // Explicit, stateless streaming access systemPrompt: "You are a helpful customer support bot.", hooks: { onBeforeChat: async (messages, ctx) => { @@ -964,31 +1045,23 @@ const { handler, dbSchema } = stack({ ```tsx title="lib/stack-client.tsx" aiChat: aiChatClientPlugin({ - apiBaseURL: baseURL, - apiBasePath: "/api/data", - siteBaseURL: baseURL, - siteBasePath: "/pages", - queryClient: queryClient, - mode: "public", // Must match backend + mode: "public", // Stateless public conversation UI }) ``` -### 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} + ``` -In public mode, the sidebar is hidden, conversation history is not saved to the database, and only the `/chat` route is available. +Public access is explicit operation metadata, not a missing-rule fallback. It makes only the maintained streaming intents public: stream start, send/edit/retry semantics, validated attachments, and configured tools. Validation, rate/abuse hooks, provider limits, route/tool allowlists, and attachment safety still run. Conversation history and CRUD endpoints remain unavailable (404), no conversation is persisted, and the setting does not make any other plugin or application operation public. ### Local Storage Persistence @@ -1001,8 +1074,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", @@ -1011,8 +1082,6 @@ export default function PublicChat() { return ( .` keys. The legacy `localization` override remains supported and takes precedence when both are configured: ```tsx overrides={{ - "ai-chat": { + aiChat: { // ... other overrides localization: { CHAT_PLACEHOLDER: "Ask me anything...", @@ -1059,45 +1128,24 @@ overrides={{ -## Server-side Data Access - -The AI Chat plugin exposes standalone getter functions for server-side use cases, giving you direct access to conversation history without going through HTTP. - -### Two patterns - -**Pattern 1 — via `stack().api`** - -```ts title="app/lib/stack.ts" -import { myStack } from "./stack"; - -// List all conversations (optionally scoped to a user) -const all = await myStack.api["ai-chat"].getAllConversations(); -const userConvs = await myStack.api["ai-chat"].getAllConversations("user-123"); - -// Get a conversation with its full message history -const conv = await myStack.api["ai-chat"].getConversationById("conv-456"); -if (conv) { - console.log(conv.messages); // Message[] -} -``` +Rename, delete, and file-upload feedback uses the shared `notify` provider. Field validation and streaming errors remain inline: -**Pattern 2 — direct import** - -```ts -import { - getAllConversations, - getConversationById, -} from "@btst/stack/plugins/ai-chat/api"; - -const conv = await getConversationById(myAdapter, conversationId); +```tsx + myToast.success(message), + error: (message) => myToast.error(message), + }} + // ... +> + {children} + ``` -### Available getters +## Server-side Data Access -| Function | Description | -|---|---| -| `getAllConversations(adapter, userId?)` | Returns all conversations, optionally filtered by userId | -| `getConversationById(adapter, id)` | Returns a conversation with messages, or `null` | +AI Chat has no raw `stack.raw.aiChat` business namespace. Use `app.forRequest(request).operations.aiChat` for request-driven work and `app.trusted.aiChat` for explicitly trusted jobs. Standalone conversation getters remain lower-level adapter primitives for plugin internals and migrations. ## Route-Aware AI Context @@ -1247,11 +1295,11 @@ useRegisterPageAIContext({ |---|---|---|---| | `enablePageTools` | `boolean` | `false` | Activate page tool support | | `clientToolSchemas` | `Record` | — | Custom tool schemas for non-BTST pages | -| `hooks.onBeforeToolsActivated` | `(toolNames, routeName, context) => string[]` | — | Filter active tools per request; throw to abort with 403 | +| `hooks.onBeforeActivateTools` | `(toolNames, routeName, context) => string[]` | — | Apply a final domain/safety filter after typed tool authorization | ### Tool Authorization Hook -`onBeforeToolsActivated` runs server-side after the structural `routeName` allowlist check. Use it to add user-level authorization — for example, restricting which tools are available based on the authenticated user's role or subscription tier. +`onBeforeActivateTools` runs server-side after the structural `routeName` allowlist and the exact `aiChat.tool.activate` rule have succeeded. Use the typed rule for identity, role, subscription, or ownership policy. Use this hook only for exceptional server-side domain/safety narrowing. ```ts title="lib/stack.ts" import type { AiChatBackendHooks } from "@btst/stack/plugins/ai-chat/api" @@ -1259,13 +1307,9 @@ import type { AiChatBackendHooks } from "@btst/stack/plugins/ai-chat/api" aiChatBackendPlugin({ enablePageTools: true, hooks: { - onBeforeToolsActivated: async (toolNames, routeName, context) => { - const role = await getUserRole(context.headers); - // Viewers cannot use any interactive tools - if (role === "viewer") return []; - // Non-editors cannot fill the blog form - if (role !== "editor") return toolNames.filter(t => t !== "fillBlogForm"); - return toolNames; + onBeforeActivateTools: async (toolNames, routeName, context) => { + const disabled = await loadEmergencyToolDisableList() + return toolNames.filter((name) => !disabled.has(name)) }, }, }) @@ -1277,10 +1321,10 @@ aiChatBackendPlugin({ | `routeName` | `string \| undefined` | Claimed route name from the request | | `context` | `ChatApiContext` | Full request context (headers, body, etc.) | -Return a subset of `toolNames` to allow, or `[]` to suppress all page tools. Throw an `Error` to abort the entire chat request — the endpoint catches it and returns a **403** response. +Return a subset of `toolNames` to allow, or `[]` to suppress all page tools. Throwing aborts the stream through the ordinary post-authorization operation error path. -This hook runs **after** the structural `routeName` allowlist check (which validates that each built-in tool is only requested from its intended page). `onBeforeToolsActivated` is the right place to add user-specific logic — the two layers are complementary. +The server authorizes the complete configured tool-name set before this hook or provider execution. Filtering cannot be used to smuggle an unapproved tool into the model, and client-supplied `routeName`/tool names never override the server allowlist. ## Shadcn Registry @@ -1319,15 +1363,14 @@ After installing, wire your custom components into the plugin via the `pageCompo import { aiChatClientPlugin } from "@btst/stack/plugins/ai-chat/client" // Import your ejected (and customized) page components import { ChatPageComponent } from "@/components/btst/ai-chat/client/components/pages/chat-page" -import { ChatConversationPageComponent } from "@/components/btst/ai-chat/client/components/pages/chat-conversation-page" aiChatClientPlugin({ - apiBaseURL: "...", - apiBasePath: "/api/data", - queryClient, pageComponents: { - chat: ChatPageComponent, // replaces the chat home page - chatConversation: ChatConversationPageComponent, // replaces the conversation page + chat: ChatPageComponent, // replaces the chat home page + // Param routes receive the route context ({ params }) as props + chatConversation: ({ params }) => ( + + ), }, }) ``` diff --git a/docs/content/docs/plugins/better-auth-ui.mdx b/docs/content/docs/plugins/better-auth-ui.mdx index 1aba119ba..4b7592864 100644 --- a/docs/content/docs/plugins/better-auth-ui.mdx +++ b/docs/content/docs/plugins/better-auth-ui.mdx @@ -1,556 +1,175 @@ --- -title: Better Auth UI Plugin (Beta) -description: Beautiful shadcn/ui authentication components for better-auth +title: Better Auth UI Companion +description: Add optional auth and account pages to an application that already runs Better Auth. --- -import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; -import { Github, BookOpen, GitFork } from "lucide-react"; -import Image from "next/image"; - -import betterAuthUiDemo from "../../../assets/better-auth-ui-demo.webp"; - -
- -The Better Auth UI plugin provides beautiful, plug-and-play authentication UI components built with [shadcn/ui](https://ui.shadcn.com/) for [better-auth](https://www.better-auth.com/). This is a fork of the popular [better-auth-ui](https://github.com/better-auth-ui/better-auth-ui) library, adapted for seamless integration with BTST. - - - -## Features - -- **Sign In / Sign Up** – Complete authentication flows with email, password, social login, and magic links -- **Account Management** – User profile settings, security settings, API keys, and team/organization memberships -- **Two-Factor Authentication** – TOTP and OTP support for enhanced security -- **Social Login** – GitHub, Google, Discord, and more OAuth providers -- **Passkeys** – WebAuthn/Passkey authentication support -- **Organizations** – Team and organization management with invitations and roles -- **Email OTP / Magic Link** – Passwordless authentication options -- **Email Verification** – Enforce email verification before access -- **Generic OAuth** – Bring your own OAuth provider -- **Fully Customizable** – Built with TailwindCSS and shadcn/ui; per-page `className`, `classNames`, and `localization` overrides +import { Tabs, Tab } from "fumadocs-ui/components/tabs"; -## Installation +[`@btst/better-auth-ui`](https://github.com/better-stack-ai/better-auth-ui) +is the separately maintained Better Auth UI companion for BTST v3. It adds +resolved auth and account routes while Better Auth UI continues to read its own +session and native permissions from your Better Auth client. - -Before starting, ensure you have: -- A Next.js project with `@btst/stack` already set up (see [Installation](/installation)) -- `better-auth` configured (server-side auth) -- A `better-auth` client (`lib/auth-client.ts`) set up -- A database adapter (e.g., Drizzle with `@btst/adapter-drizzle`) + + This integration assumes your application already owns a Better Auth server + endpoint. It does not generate a Better Auth backend, database adapter, + schema, migrations, authentication providers, secrets, or deployment + configuration. -### 1. Install the Package +## Generate the minimal integration -```bash -pnpm add @btst/better-auth-ui -``` - -Or with npm/yarn: +Select the companion explicitly; it is never part of the default scaffold. ```bash -npm install @btst/better-auth-ui -# or -yarn add @btst/better-auth-ui -``` - -### 2. Configure the Stack Client - -Import and register the auth plugins in your `stack-client.tsx` file: - -```tsx title="lib/stack-client.tsx" -import { createStackClient } from "@btst/stack/client" -import { - authClientPlugin, - accountClientPlugin, - organizationClientPlugin, -} from "@btst/better-auth-ui/client" -import { QueryClient } from "@tanstack/react-query" - -const getBaseURL = () => - typeof window !== "undefined" - ? window.location.origin - : process.env.BASE_URL || "http://localhost:3000" - -export function getStackClient(queryClient: QueryClient) { - const baseURL = getBaseURL() - - return createStackClient({ - plugins: { - // Auth plugin — sign-in, sign-up, forgot-password, magic-link, etc. - auth: authClientPlugin({ - siteBaseURL: baseURL, - siteBasePath: "/p", // prefix used in your catch-all route - }), - - // Account plugin — settings, security, API keys, teams, organizations - account: accountClientPlugin({ - siteBaseURL: baseURL, - siteBasePath: "/p", - }), - - // Organization plugin — org settings, members, teams - organization: organizationClientPlugin({ - siteBaseURL: baseURL, - siteBasePath: "/p", - }), - - // ... other BTST plugins (blog, cms, etc.) - }, - }) -} -``` - -### 3. Configure the StackProvider (Client-Side Layout) - -Configure the plugin overrides in your catch-all layout file. The `auth` overrides are shared across all three plugins using `...authConfig`. - - - - ```tsx title="app/p/[[...all]]/layout.tsx" - "use client" - - import { StackProvider } from "@btst/stack/context" - import type { - AuthPluginOverrides, - AccountPluginOverrides, - OrganizationPluginOverrides, - } from "@btst/better-auth-ui/client" - import { authClient } from "@/lib/auth-client" - import Link from "next/link" - import { useRouter } from "next/navigation" - import type { ReactNode } from "react" - - type PluginOverrides = { - auth: AuthPluginOverrides - account: AccountPluginOverrides - organization: OrganizationPluginOverrides - } - - export default function PagesLayout({ children }: { children: ReactNode }) { - const router = useRouter() - - // Shared auth configuration — spread into each plugin override - const authConfig = { - authClient, - navigate: router.push, - replace: router.replace, - onSessionChange: () => router.refresh(), - Link, - } - - return ( - - basePath="/p" - overrides={{ - auth: { - ...authConfig, - basePath: "/p/auth", // auth routes prefix - redirectTo: "/p/account/settings", // redirect after login - // social: { providers: ["github", "google"] }, - // magicLink: true, - // emailOTP: true, - // passkey: true, - // twoFactor: ["otp", "totp"], - // emailVerification: true, - // credentials: { forgotPassword: true }, - }, - account: { - ...authConfig, - basePath: "/p/account", // account routes prefix - account: { - fields: ["image", "name"], // editable profile fields - }, - // deleteUser: true, - // teams: true, - // apiKey: true, - // avatar: { - // upload: async (file) => myUploader(file), - // size: 128, - // extension: "png", - // }, - }, - organization: { - ...authConfig, - basePath: "/p/org", - organization: { - basePath: "/p/org", - // logo: true, - // customRoles: [{ role: "editor", label: "Editor" }], - // apiKey: true, - // pathMode: "slug", - }, - // teams: true, - }, - }} - > - {children} - - ) - } - ``` - - - -### 4. Import Required CSS - -Add the better-auth-ui styles to your global stylesheet: - -```css title="app/globals.css" -@import "@btst/better-auth-ui/css"; +npx @btst/codegen init --plugins better-auth-ui ``` +The generated result: -## Available Routes - -Once configured, the following routes become available under your configured `basePath`. +- registers only `authClientPlugin()` and `accountClientPlugin()`; +- creates one browser client for the existing `/api/auth` endpoint; +- mounts routes under the resolved BTST site path (`/pages/auth/*` and + `/pages/account/*` by default); +- configures API, site, and QueryClient runtime only once in + `createClientStack()`; and +- refreshes the framework explicitly after a Better Auth session change. -### Auth Routes (`/p/auth/...`) - -| Route | Description | -|-------|-------------| -| `/p/auth/sign-in` | Sign in page | -| `/p/auth/sign-up` | Sign up page | -| `/p/auth/forgot-password` | Password reset request | -| `/p/auth/reset-password` | Password reset form | -| `/p/auth/magic-link` | Magic link landing page | -| `/p/auth/email-otp` | Email OTP landing page | -| `/p/auth/two-factor` | Two-factor verification | -| `/p/auth/recover-account` | Backup code recovery | -| `/p/auth/callback` | OAuth callback handler | -| `/p/auth/sign-out` | Sign out page | -| `/p/auth/accept-invitation` | Organization invitation acceptance | -| `/p/auth/email-verification` | Email verification page | - -### Account Routes (`/p/account/...`) - -| Route | Description | -|-------|-------------| -| `/p/account/settings` | Profile & account settings | -| `/p/account/security` | Password, 2FA, passkeys, sessions | -| `/p/account/api-keys` | API key management (`apiKey: true`) | -| `/p/account/organizations` | User's organization memberships | -| `/p/account/teams` | User's team memberships (`teams: true`) | - -### Organization Routes (`/p/org/...`) - -| Route | Description | -|-------|-------------| -| `/p/org/settings` | Organization name, logo, danger zone | -| `/p/org/members` | Member management, invitations, roles | -| `/p/org/api-keys` | Organization API keys (`apiKey: true`) | -| `/p/org/teams` | Team management (`teams: true`) | +Organization, API-key, passkey, multi-session, and other Better Auth extensions +are not enabled by the generated code. Add one only after the matching Better +Auth server and client plugin are configured in your application. -Routes are prefixed with your configured `basePath`. The examples above use `/p` as the base path. -The exact sub-paths come from the view paths constants in the library and match the route keys above. + The stable package publishes API-key and passkey as required declaration + peers because its + synthetic full `AuthClient` type exposes their surfaces. The CLI therefore + installs their aligned 1.6.16 packages to keep strict dependency trees clean, + but it does not import, register, or enable either runtime feature. Activation + remains an explicit application choice and requires the matching Better Auth + server/client plugins. -## Configuration Options - -### Auth Plugin (`AuthPluginOverrides`) - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `authClient` | `AnyAuthClient` | Required | Better Auth client | -| `basePath` | `string` | `"/auth"` | Base path for auth routes | -| `baseURL` | `string` | — | Front-end base URL for OAuth callbacks | -| `redirectTo` | `string` | `"/"` | Redirect URL after login | -| `credentials` | `boolean \| CredentialsOptions` | `true` | Email/password login | -| `signUp` | `boolean \| SignUpOptions` | `true` | Sign-up flow | -| `social` | `SocialOptions` | — | Social provider config | -| `genericOAuth` | `GenericOAuthOptions` | — | Custom OAuth providers | -| `magicLink` | `boolean` | `false` | Passwordless magic link | -| `emailOTP` | `boolean` | `false` | Passwordless email OTP | -| `passkey` | `boolean` | `false` | WebAuthn passkeys | -| `oneTap` | `boolean` | `false` | Google One Tap | -| `twoFactor` | `("otp" \| "totp")[]` | — | Two-factor authentication | -| `multiSession` | `boolean` | `false` | Multiple session support | -| `emailVerification` | `boolean` | — | Require email verification | -| `changeEmail` | `boolean` | `true` | Allow email changes | -| `nameRequired` | `boolean` | `true` | Name field required on sign-up | -| `apiKey` | `boolean \| { prefix?, metadata? }` | — | API key plugin support | -| `gravatar` | `boolean \| GravatarOptions` | — | Gravatar avatars | -| `avatar` | `boolean \| AvatarOptions` | — | Avatar upload | -| `additionalFields` | `AdditionalFields` | — | Extra user fields | -| `captcha` | `CaptchaOptions` | — | CAPTCHA integration | -| `localization` | `AuthLocalization` | — | Override all UI strings | -| `viewPaths` | `Partial` | — | Custom route sub-paths | -| `freshAge` | `number` | `86400` | Session freshness in seconds | -| `persistClient` | `boolean` | `false` | Force session refresh on callback | -| `optimistic` | `boolean` | `false` | Optimistic user updates | -| `hooks` | `Partial` | — | Custom data fetching hooks | -| `mutators` | `Partial` | — | Custom mutation handlers | -| `Link` | `Link` | `` | Custom link component | -| `navigate` | `(href: string) => void` | `location.href` | Navigation function | -| `replace` | `(href: string) => void` | `navigate` | Replace navigation | -| `toast` | `RenderToast` | Sonner | Custom toast renderer | -| `onSessionChange` | `() => void` | — | Session change callback | -| `onRouteError` | `(name, error, ctx) => void` | — | Route error callback | -| `pageProps` | See [Per-Page Props](#per-page-props) | — | Per-page className/classNames/localization | - -### Account Plugin (`AccountPluginOverrides`) - -Extends `Partial` with: - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `account` | `boolean \| Partial` | `{ fields: ["image", "name"] }` | Account view config | -| `deleteUser` | `boolean \| DeleteUserOptions` | — | Account deletion | -| `teams` | `boolean \| TeamOptions` | — | Teams support | -| `pageProps` | See [Per-Page Props](#per-page-props) | — | Per-page className/classNames/localization | - -### Organization Plugin (`OrganizationPluginOverrides`) - -Extends `Partial` with: - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `organization` | `boolean \| OrganizationOptions` | — | Organization config | -| `teams` | `boolean \| TeamOptions` | — | Teams within organizations | -| `pageProps` | See [Per-Page Props](#per-page-props) | — | Per-page className/classNames/localization | - -**`OrganizationOptions`:** +## Supported release cohort -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `basePath` | `string` | `"/organization"` | Base path for org routes | -| `logo` | `boolean \| Partial` | — | Logo upload | -| `customRoles` | `{ role: string; label: string }[]` | `[]` | Extra roles beyond owner/admin/member | -| `apiKey` | `boolean` | `false` | API keys for organizations | -| `pathMode` | `"default" \| "slug"` | `"default"` | Route mode | -| `slug` | `string` | — | Active organization slug (when `pathMode: "slug"`) | -| `personalPath` | `string` | — | Redirect path when Personal Account is selected | -| `viewPaths` | `Partial` | — | Custom route sub-paths | +The stable companion release is `@btst/better-auth-ui@2.0.0`. Its +stable-v3 compatibility contract retains these exact versions: -## Per-Page Props +| Package | Version | +| --- | --- | +| `better-auth`, `@better-auth/core` | `1.6.16` | +| `@better-auth/api-key`, `@better-auth/passkey` | `1.6.16` | +| `@better-auth/utils` | `0.4.1` | +| `@better-fetch/fetch` | `1.2.2` | +| `better-call` | `1.3.6` | +| `@btst/db` and BTST database adapters | `2.2.3` | -You can customize each page individually with `className`, `classNames`, `localization`, and other view-specific props — without replacing the entire component. +Do not combine this companion release with a Better Auth 1.7 dependency graph. +The CLI installs the corrected auth cohort without changing the retained +`@btst/db@2.2.3` or adapter versions. -These are set via `pageProps` in the relevant plugin override: +For a manual installation, add the companion and exact auth cohort alongside +your existing BTST dependencies: -```tsx -overrides={{ - auth: { - ...authConfig, - basePath: "/p/auth", - pageProps: { - signIn: { - className: "my-wrapper", - classNames: { - title: "text-3xl font-bold", - description: "text-muted-foreground", - footer: "border-t pt-4", - }, - localization: { SIGN_IN: "Log in" }, // only override what you need - socialLayout: "grid", - redirectTo: "/dashboard", - }, - signUp: { - cardHeader: , // replace the card header entirely - callbackURL: "/welcome", - }, - callback: { - redirectTo: "/onboarding", // override the post-OAuth redirect - }, - signOut: { - redirectTo: "/p/auth/sign-in", - }, - }, - }, - account: { - ...authConfig, - basePath: "/p/account", - account: { fields: ["image", "name"] }, - pageProps: { - accountSettings: { - className: "max-w-2xl mx-auto", - hideNav: true, - }, - accountSecurity: { - localization: { SECURITY: "Privacy & Security" }, - }, - }, - }, - organization: { - ...authConfig, - basePath: "/p/org", - organization: { basePath: "/p/org" }, - pageProps: { - organizationSettings: { - className: "p-8", - classNames: { sidebar: { base: "w-64" } }, - }, - }, - }, -}} -``` - -### `AuthPageProps` (auth pages) - -| Prop | Type | Description | -|------|------|-------------| -| `className` | `string` | Wrapper class | -| `classNames` | `AuthViewClassNames` | Fine-grained class overrides | -| `localization` | `Partial` | Override specific strings | -| `socialLayout` | `"auto" \| "horizontal" \| "grid" \| "vertical"` | Social provider button layout | -| `callbackURL` | `string` | URL sent to OAuth providers as callback | -| `redirectTo` | `string` | Override the post-auth redirect | -| `cardHeader` | `ReactNode` | Replace the card header | -| `cardFooter` | `ReactNode` | Replace the card footer | -| `otpSeparators` | `0 \| 1 \| 2` | OTP input separator count | - -> `callback` and `signOut` only accept `{ redirectTo }`. `acceptInvitation` only accepts `{ className }`. - -### `AccountPageProps` (account pages) - -| Prop | Type | Description | -|------|------|-------------| -| `className` | `string` | Wrapper class | -| `classNames` | `{ base?, cards?, drawer?, sidebar?, card? }` | Fine-grained class overrides | -| `localization` | `Partial` | Override specific strings | -| `hideNav` | `boolean` | Hide the sidebar/drawer navigation | -| `showTeams` | `boolean` | Show teams tab on the page | - -### `OrganizationPageProps` (organization pages) - -| Prop | Type | Description | -|------|------|-------------| -| `className` | `string` | Wrapper class | -| `classNames` | `{ base?, cards?, drawer?, sidebar?, card? }` | Fine-grained class overrides | -| `localization` | `Partial` | Override specific strings | -| `hideNav` | `boolean` | Hide the sidebar/drawer navigation | -| `slug` | `string` | Override the active organization slug | - -## Common Recipes - -### Social Login - -```tsx -auth: { - ...authConfig, - social: { - providers: ["github", "google", "discord"], - }, -} +```bash +pnpm add @btst/better-auth-ui@2.0.0 \ + better-auth@1.6.16 @better-auth/core@1.6.16 \ + @better-auth/api-key@1.6.16 @better-auth/passkey@1.6.16 \ + @better-auth/utils@0.4.1 @better-fetch/fetch@1.2.2 better-call@1.3.6 ``` -### Passwordless (Magic Link + Email OTP) +The package declares its component-library peers. Resolve any peer warning +against the companion's published manifest. These optional data-adapter +subpaths add their own peers; do not install or import them unless you select +that integration: -```tsx -auth: { - ...authConfig, - magicLink: true, - emailOTP: true, -} -``` +| Optional subpath | Additional peers | +| --- | --- | +| `@btst/better-auth-ui/tanstack` | `@daveyplate/better-auth-tanstack@^1.3.6` | +| `@btst/better-auth-ui/instantdb` | `@instantdb/react@>=0.18.0` | +| `@btst/better-auth-ui/triplit` | `@triplit/client@>=1.0.0`, `@triplit/react@>=1.0.0` | -### Two-Factor Authentication +## Browser client and resolved routes -```tsx -auth: { - ...authConfig, - twoFactor: ["otp", "totp"], -} -``` +The CLI generates the following application-owned seam: -### Passkeys +```ts title="lib/auth-client.ts" +import { createAuthClient } from "better-auth/react" -```tsx -auth: { - ...authConfig, - passkey: true, +export function createAppAuthClient(baseURL?: string) { + return createAuthClient({ + ...(baseURL ? { baseURL } : {}), + basePath: "/api/auth", + }) } ``` -### Avatar Upload +Change `basePath` only when your existing Better Auth handler uses a different +path. The companion route bases are not configured here: they derive from the +site runtime passed once to `createClientStack()`. -```tsx -account: { - ...authConfig, - avatar: { - upload: async (file) => { - const result = await myStorage.upload(file) - return result.url - }, - size: 128, - extension: "png", +```tsx title="lib/stack-client.tsx" +import { accountClientPlugin, authClientPlugin } from "@btst/better-auth-ui/client" +import { createClientStack } from "@btst/stack/client" + +return createClientStack({ + api: { baseURL: apiOrigin, basePath: "/api/data" }, + site: { baseURL: siteOrigin, basePath: "/pages" }, + queryClient, + plugins: { + auth: authClientPlugin(), + account: accountClientPlugin(), }, -} +}) ``` -### Account Deletion - -```tsx -account: { - ...authConfig, - deleteUser: true, - // or with a confirmation requirement: - // deleteUser: { requirePassword: true }, -} -``` +## Provider overrides -### Organizations with Logo Upload +The resolved stack infers both override keys. Configure the Better Auth client +once under `auth`; account-specific settings remain under `account`. ```tsx -organization: { - ...authConfig, - basePath: "/p/org", - organization: { - basePath: "/p/org", - logo: { - upload: async (file) => { - const result = await myStorage.upload(file) - return result.url - }, - size: 256, - extension: "png", + + {children} + ``` -## Learn More +Use the framework-native synchronization generated for your target: -For comprehensive documentation on all configuration options, customization, and advanced features, visit: + + + ```ts + onSessionChange: () => router.refresh() + ``` + + + ```ts + onSessionChange: () => revalidator.revalidate() + ``` + + + ```ts + onSessionChange: () => router.invalidate() + ``` + + -- **[Better Auth UI Documentation](https://better-auth-ui.com/)** – Official documentation with demos and guides -- **[GitHub Repository (Fork)](https://github.com/better-stack-ai/better-auth-ui)** – Source code for the BTST fork -- **[Original Repository](https://github.com/better-auth-ui/better-auth-ui)** – Upstream repository -- **[better-auth Documentation](https://www.better-auth.com/)** – Documentation for the underlying auth library +The bridge performs no hidden BTST identity refetch. If business plugins use +BTST authorization, map the Better Auth session separately with BTST's generic +`createClientAuth` and `createServerAuth` contracts and keep server +authorization authoritative. diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index 99e0037a6..a76a80ecb 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -43,11 +43,11 @@ Follow these steps to add the Blog plugin to your BTST setup. Import and register the blog backend plugin in your `stack.ts` file: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" // ... your adapter imports -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { blog: blogBackendPlugin() @@ -60,32 +60,38 @@ const { handler, dbSchema } = stack({ export { handler, dbSchema } ``` -The `blogBackendPlugin()` accepts optional hooks for customizing behavior (authorization, logging, etc.). +The `blogBackendPlugin()` accepts optional post-authorization lifecycle hooks +for domain invariants, logging, and side effects. ### 2. Add Plugin to Client Register the blog client plugin in your `stack-client.tsx` file: ```tsx title="lib/stack-client.tsx" -import { createStackClient } from "@btst/stack/client" +import { createClientStack } from "@btst/stack/client" import { blogClientPlugin } from "@btst/stack/plugins/blog/client" import { QueryClient } from "@tanstack/react-query" -const getBaseURL = () => - (process.env.BASE_URL || "http://localhost:3000") +const getBaseURL = () => + typeof window !== "undefined" + ? window.location.origin + : process.env.BASE_URL || "http://localhost:3000" -export const getStackClient = (queryClient: QueryClient) => { +export const getStackClient = ( + queryClient: QueryClient, + options?: { headers?: HeadersInit }, +) => { const baseURL = getBaseURL() - return createStackClient({ + return createClientStack({ + api: { + baseURL, + basePath: "/api/data", + headers: options?.headers, + }, + site: { baseURL, basePath: "/pages" }, + queryClient, plugins: { blog: blogClientPlugin({ - // Required configuration - apiBaseURL: baseURL, - apiBasePath: "/api/data", - siteBaseURL: baseURL, - siteBasePath: "/pages", - queryClient: queryClient, - // Optional: SEO configuration seo: { siteName: "My Blog", author: "Your Name", @@ -99,15 +105,15 @@ export const getStackClient = (queryClient: QueryClient) => { } ``` -**Required configuration:** -- `apiBaseURL`: Base URL for API calls during SSR data prefetching (use environment variables for flexibility) -- `apiBasePath`: Path where your API is mounted (e.g., `/api/data`) -- `siteBaseURL`: Base URL of your site -- `siteBasePath`: Path where your pages are mounted (e.g., `/pages`) -- `queryClient`: React Query client instance +Shared API, site, query-client, and per-request header values belong on +`createClientStack()`. `blogClientPlugin()` accepts only Blog-specific SEO, +loader hooks, and page component choices. -**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. +**Migrating to v3:** move `apiBaseURL`, `apiBasePath`, `siteBaseURL`, +`siteBasePath`, `queryClient`, and `headers` out of `blogClientPlugin()` and into +the top-level client stack fields shown above. Rename the old `onLoadError` +loader hook to `onErrorLoad`. ### 3. Import Plugin CSS @@ -120,53 +126,56 @@ 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 + +Pass the resolved client stack to `StackProvider`, add the framework router, +and keep only Blog-specific values in `overrides`: -Configure framework-specific overrides in your `StackProvider`: +Create the provider stack inside the browser layout without request headers. +SSR page factories create a separate request stack with their request +QueryClient and headers; never serialize that function-bearing server object +through a Client Component prop. - ```tsx title="app/pages/[[...all]]/layout.tsx" - import { StackProvider } from "@btst/stack/context" - 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' - ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:3000") - - type PluginOverrides = { - blog: BlogPluginOverrides - } + ```tsx title="app/pages/client-layout.tsx" + "use client" - export default function Layout({ children }) { - const router = useRouter() - const baseURL = getBaseURL() + import { useMemo } from "react" + import { QueryClientProvider } from "@tanstack/react-query" + import { StackProvider } from "@btst/stack/context" + import { nextRouter } from "@btst/stack/next" + import { getStackClient, type StackClientOptions } from "@/lib/stack-client" + import { getOrCreateQueryClient } from "@/lib/query-client" + + export default function Layout({ children, clientOrigins }: { + children: React.ReactNode + clientOrigins: StackClientOptions + }) { + const queryClient = getOrCreateQueryClient() + const clientStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) return ( - - basePath="/pages" - 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) => , - } - }} - > - {children} - + + { + // Implement your image upload logic + // Return the URL of the uploaded image + return "https://example.com/uploads/image.jpg" + }, + } + }} + > + {children} + + ) } ``` @@ -174,45 +183,38 @@ Configure framework-specific overrides in your `StackProvider`: ```tsx title="app/routes/pages/_layout.tsx" - import { Outlet, Link, useNavigate } from "react-router" + import { useMemo, useState } from "react" + import { Outlet } from "react-router" + import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" - import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" - - const getBaseURL = () => - typeof window !== 'undefined' - ? (import.meta.env.VITE_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:5173") - - type PluginOverrides = { - blog: BlogPluginOverrides - } + import { reactRouter } from "@btst/stack/react-router" + import { getStackClient } from "~/lib/stack-client" + import { getOrCreateQueryClient } from "~/lib/query-client" export default function Layout() { - const navigate = useNavigate() - const baseURL = getBaseURL() + const [queryClient] = useState(() => getOrCreateQueryClient()) + const clientStack = useMemo( + () => getStackClient(queryClient), + [queryClient], + ) return ( - - basePath="/pages" - 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} - - ), - } - }} - > - - + + { + // Implement your image upload logic + return "https://example.com/uploads/image.jpg" + }, + } + }} + > + + + ) } ``` @@ -220,45 +222,37 @@ Configure framework-specific overrides in your `StackProvider`: ```tsx title="src/routes/pages/route.tsx" + import { useMemo } from "react" + import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" - import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" - import { Link, useRouter, Outlet } from "@tanstack/react-router" - - const getBaseURL = () => - typeof window !== 'undefined' - ? (import.meta.env.VITE_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:3000") - - type PluginOverrides = { - blog: BlogPluginOverrides - } + import { tanstackRouter } from "@btst/stack/tanstack" + import { getStackClient } from "@/lib/stack-client" + import { Outlet } from "@tanstack/react-router" function Layout() { - const router = useRouter() - const baseURL = getBaseURL() + const { queryClient } = Route.useRouteContext() + const clientStack = useMemo( + () => getStackClient(queryClient), + [queryClient], + ) return ( - - basePath="/pages" - 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} - - ), - } - }} - > - - + + { + // Implement your image upload logic + return "https://example.com/uploads/image.jpg" + }, + } + }} + > + + + ) } ``` @@ -266,22 +260,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: @@ -300,7 +284,7 @@ Your blog plugin is now fully configured and ready to use! Here's a quick refere ### API Endpoints -The blog plugin provides the following API endpoints (mounted at your configured `apiBasePath`): +The blog plugin provides the following API endpoints (mounted at the resolved Blog API path): - **GET** `/posts` - List posts with optional filtering (published status, tag, search query) - **POST** `/posts` - Create a new post @@ -311,7 +295,7 @@ The blog plugin provides the following API endpoints (mounted at your configured ### Page Routes -The blog plugin automatically creates the following pages (mounted at your configured `siteBasePath`): +The blog plugin automatically creates the following pages (mounted under the top-level site path): - `/blog` - Blog homepage with published posts - `/blog/drafts` - Draft posts page @@ -322,7 +306,7 @@ The blog plugin automatically creates the following pages (mounted at your confi ### Page Component Overrides -You can replace any built-in page with your own React component using the optional `pageComponents` field in `blogClientPlugin(config)`. The built-in component is used as the fallback whenever an override is not provided, so this is fully backward-compatible. +You can replace any built-in page with your own React component using the optional `pageComponents` field in `blogClientPlugin(config)`. The built-in component is used as the fallback whenever an override is not provided. Overrides for parameterized routes receive the route context (`{ params }`) as props. ```tsx blogClientPlugin({ @@ -330,12 +314,12 @@ blogClientPlugin({ pageComponents: { // Replace the published posts list page posts: MyCustomPostsPage, - // Replace the single post page — receives the slug as a prop - post: ({ slug }) => , - // Replace the edit post page — receives the slug as a prop - editPost: ({ slug }) => , - // Replace the tag page — receives tagSlug as a prop - tag: ({ tagSlug }) => , + // Replace the single post page — receives the route context as props + post: ({ params }) => , + // Replace the edit post page — receives the route context as props + editPost: ({ params }) => , + // Replace the tag page — receives the route context as props + tag: ({ params }) => , // Replace the drafts list page drafts: MyCustomDraftsPage, // Replace the new post page @@ -346,7 +330,71 @@ blogClientPlugin({ ### Adding Authorization -To add authorization rules and customize behavior, you can use the lifecycle hooks defined in the API Reference section below. These hooks allow you to control access to API endpoints, add logging, and customize the plugin's behavior to fit your application's needs. +The Blog plugin publishes a browser-safe, schema-backed catalog at +`@btst/stack/plugins/blog/permissions`. It covers published, draft, and +individual post reads; draft and published creation; content and publish-state +updates; deletion; and tag reads. Built-in Blog routes and controls pass these +exact descriptors to `StackProvider.auth`—they do not use resource/action +strings. + +```ts title="lib/authorization.ts" +import { defineAuthorization } from "@btst/stack/authorization"; +import { blogPermissions } from "@btst/stack/plugins/blog/permissions"; +import { z } from "zod"; + +export const authorization = defineAuthorization({ + identity: z.object({ + id: z.string(), + role: z.enum(["user", "admin"]), + }), + permissions: [blogPermissions] as const, + rules: ({ blog }) => [ + blog.post.read.when(({ identity, facts }) => { + if (facts.scope === "published") return true; + if (facts.scope === "post" && (!facts.exists || facts.published)) return true; + return identity?.role === "admin" || + (facts.scope === "post" && identity?.id === facts.authorId); + }), + blog.post.create.when(({ identity, facts }) => + identity !== null && + (facts.publish === "draft" || identity.role === "admin") + ), + blog.post.update.when(({ identity, facts }) => + identity !== null && + (identity.role === "admin" || + (identity.id === facts.authorId && facts.publish === "unchanged")) + ), + blog.post.delete.when(({ identity, facts }) => + identity !== null && + (identity.role === "admin" || identity.id === facts.authorId) + ), + blog.tag.read.allow(), + ], +}); +``` + +The explicit published-post and tag rules make public access intentional; +`allow()` is the unconditional tag rule. Once authorization is installed, a +missing Blog rule denies access. Browser facts only improve presentation: each +record-sensitive detail, update, and delete operation reloads authoritative +post, author, and publish state before evaluation. List facts come from the +validated query (and the matched post for a slug), create facts come from the +validated publish intent, and the public navigation/tag operations declare +their fixed facts explicitly. + +Post-detail reads verify that any returned row still matches the authoritative +existence, identity, author, and publish facts used for authorization. When an +update includes `published`, the write atomically requires the publish state +observed during authorization to still match. A concurrent security-relevant +change returns HTTP 409 (`POST_READ_STATE_CHANGED` or `POST_STATE_CHANGED`) +instead of using stale facts; retry the operation against the current post. + +Image upload is not a Blog backend route. `uploadImage` is an app-supplied +client override, so its upload endpoint must enforce the application's own +authorization policy. + +See the [authorization guide](/auth) to bind this definition to client and +server identity adapters. ## API Reference @@ -358,49 +406,97 @@ To add authorization rules and customize behavior, you can use the lifecycle hoo #### BlogBackendHooks -Customize backend behavior with optional lifecycle hooks. All hooks are optional and allow you to add authorization, logging, and custom behavior: +Customize backend behavior with optional lifecycle hooks. All Blog hooks run +after input validation, trusted fact derivation, identity resolution, and the +shared authorization rule. Use them for exceptional domain invariants, +logging, and side effects—not ordinary role or ownership checks. Context +identity, input, trusted facts, and result values are deeply readonly. The +context object itself is frozen; `Request` and `Headers` remain standard +platform objects. +Blog lifecycle names use the action-first `onBefore`, +`onAfter`, and `onError` grammar. + +{/* canonical-dx-guard: migration:start reason="plugin lifecycle migration table" */} + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeNextPreviousPosts` | `onBeforeGetNextPreviousPosts` | +| `onPostsRead` | `onAfterListPosts` | +| `onPostCreated` | `onAfterCreatePost` | +| `onPostUpdated` | `onAfterUpdatePost` | +| `onPostDeleted` | `onAfterDeletePost` | +| `onNextPreviousPostsRead` | `onAfterGetNextPreviousPosts` | +| `onListPostsError` | `onErrorListPosts` | +| `onNextPreviousPostsError` | `onErrorGetNextPreviousPosts` | +| `onCreatePostError` | `onErrorCreatePost` | +| `onUpdatePostError` | `onErrorUpdatePost` | +| `onDeletePostError` | `onErrorDeletePost` | + +{/* canonical-dx-guard: migration:end */} + **Example usage:** ```ts title="lib/stack.ts" import { blogBackendPlugin, type BlogBackendHooks } from "@btst/stack/plugins/blog/api" const blogHooks: BlogBackendHooks = { - // Authorization hooks — throw to deny access - async onBeforeListPosts(filter, context) { - if (filter.published === false) { - if (!await isBlogAdmin(context.headers as Headers)) - throw new Error("Admin access required to view drafts") - } - }, - async onBeforeCreatePost(data, context) { - if (!await isBlogAdmin(context.headers as Headers)) - throw new Error("Admin access required to create posts") - }, - async onBeforeUpdatePost(postId, data, context) { - if (!await isBlogAdmin(context.headers as Headers)) - throw new Error("Admin access required to update posts") - }, async onBeforeDeletePost(postId, context) { - if (!await isBlogAdmin(context.headers as Headers)) - throw new Error("Admin access required to delete posts") + if (isProtectedPost(postId)) + throw new Error("Protected posts cannot be deleted") + auditDeleteAttempt(context.identity?.id, context.facts) + }, + async onAfterUpdatePost(post, context) { + await auditPostChange({ + actorId: context.identity?.id, + postId: post.id, + publishTransition: context.facts.publish, + }) }, - // ... other hooks } -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ plugins: { - blog: blogBackendPlugin(blogHooks) + blog: blogBackendPlugin({ hooks: blogHooks }) }, // ... }) ``` -#### BlogApiContext +#### Blog lifecycle contexts + +Every Blog operation supplies validated input, server-derived facts, resolved +identity, and request directly to its lifecycle hooks. The result is available +after execution, and an error context is created only after authorization +succeeds. Identity, input, facts, and results are plain lifecycle data that is +deeply readonly and frozen before hooks can observe it. The context object is +also frozen, while its `Request` and `Headers` references retain their normal +platform behavior. Operation-specific interfaces preserve the exact facts and +result types. + + +**Migrating from the earlier v3 RC:** Blog hooks now run after authorization +and receive operation-specific readonly contexts. Result hooks receive +JSON-safe serialized posts (including string timestamps), not mutable database +`Post` values with `Date` instances. Create/update hook input timestamps are +also normalized to ISO strings by their operation schemas instead of being +passed as `Date` instances. Move ordinary role and ownership checks into the +shared authorization rule. + - + + + + + + + + + + + ### Client (`@btst/stack/plugins/blog/client`) @@ -410,27 +506,27 @@ const { handler, dbSchema } = stack({ #### BlogClientConfig -The client plugin accepts a configuration object with required fields and optional SEO settings: +The client plugin accepts optional Blog-specific SEO, loader hooks, and page components. Shared runtime configuration belongs on the client stack: **Example usage:** ```tsx title="lib/stack-client.tsx" -blog: blogClientPlugin({ - // Required configuration - apiBaseURL: baseURL, - apiBasePath: "/api/data", - siteBaseURL: baseURL, - siteBasePath: "/pages", - queryClient: queryClient, - // Optional SEO configuration - seo: { - siteName: "My Awesome Blog", - author: "John Doe", - twitterHandle: "@johndoe", - locale: "en_US", - defaultImage: `${baseURL}/og-image.png`, +createClientStack({ + api: { baseURL, basePath: "/api/data", headers: options?.headers }, + site: { baseURL, basePath: "/pages" }, + queryClient, + plugins: { + blog: blogClientPlugin({ + seo: { + siteName: "My Awesome Blog", + author: "John Doe", + twitterHandle: "@johndoe", + locale: "en_US", + defaultImage: `${baseURL}/og-image.png`, + }, + }), }, }) ``` @@ -445,29 +541,16 @@ Customize client-side behavior with lifecycle hooks. These hooks are called duri ```tsx title="lib/stack-client.tsx" blog: blogClientPlugin({ - // ... rest of the config - headers: options?.headers, hooks: { beforeLoadPosts: async (filter, context) => { - // only allow loading draft posts for admin - if (!filter.published) { - if (!await isAdmin(context.headers)) - throw new Error("Admin access required to view drafts") - } + performance.mark(`blog:list:${filter.published ? "published" : "drafts"}`) }, afterLoadPost: async (post, slug, context) => { - // only allow loading draft post for admin - const isEditRoute = context.path?.includes('/edit'); - if (post?.published === false || isEditRoute) { - if (!await isAdmin(context.headers)) - throw new Error("Admin access required") - } + analytics.track("Blog post loaded", { slug, path: context.path }) }, - onLoadError(error, context) { - //handle error during prefetching - redirect("/auth/sign-in") + onErrorLoad(error, context) { + reportError(error, { path: context.path }) }, - // ... other hooks } }) ``` @@ -482,7 +565,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 +574,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 +601,6 @@ overrides={{ ), } @@ -585,100 +657,67 @@ You can import the hooks from `"@btst/stack/plugins/blog/client/hooks"` to use i ## Server-side Data Access -The blog plugin exposes standalone getter and mutation functions for server-side use cases. These bypass the HTTP layer entirely and query the database directly — no authorization hooks are called, so the caller is responsible for any access-control checks. - -### Two patterns +The Blog plugin exposes standalone lower-level getters and mutation primitives +for build-time and administrative work. These raw functions +bypass the operation and lifecycle pipeline entirely. Keep them to SSG, +migrations, test setup, and seed scripts; use the operation API below for +request-time application behavior. -**Pattern 1 — via `stack().api` (recommended for runtime server code)** +### Authorized operations -After calling `stack()`, the returned object includes a fully-typed `api` namespace. Getters and mutations are pre-bound to the adapter: +When `createBackendStack()` receives a one-rule server adapter, use the request-scoped API +for user-facing Blog work. These calls run the same operations as the HTTP +routes, including input validation, trusted fact derivation, authorization, and +lifecycle hooks: -```ts title="app/lib/stack.ts" -import { myStack } from "./stack"; // your stack() instance - -// Getters — read-only -const result = await myStack.api.blog.getAllPosts({ published: true }); -// result.items — Post[] -// result.total — total count before pagination -// result.limit — applied limit -// result.offset — applied offset - -const post = await myStack.api.blog.getPostBySlug("hello-world"); -const tags = await myStack.api.blog.getAllTags(); +```ts +const blog = myStack.forRequest(request).operations.blog; -// Mutations — write operations (no auth hooks are called) -const newPost = await myStack.api.blog.createPost({ - title: "Hello World", - slug: "hello-world", +const published = await blog.listPosts({ published: true }); +const post = await blog.createPost({ + title: "Operation-first Blog", content: "...", excerpt: "...", + published: false, + tags: [], }); -await myStack.api.blog.updatePost(newPost.id, { published: true }); -await myStack.api.blog.deletePost(newPost.id); +await blog.updatePost({ + id: post.id, + data: { ...post, title: "Updated", tags: [] }, +}); +await blog.deletePost({ id: post.id }); ``` -**Pattern 2 — direct import (SSG, build-time, or custom adapter)** - -Import getters and mutations directly and pass any `Adapter`: +Trusted server work can bypass user authorization explicitly while preserving +validation, trusted facts, lifecycle hooks, and domain behavior: ```ts -import { getAllPosts, createPost, updatePost, deletePost } from "@btst/stack/plugins/blog/api"; - -// e.g. in Next.js generateStaticParams -export async function generateStaticParams() { - const { items } = await getAllPosts(myAdapter, { published: true }); - return items.map((p) => ({ slug: p.slug })); -} - -// e.g. seeding or scripting -const post = await createPost(myAdapter, { - title: "Seeded Post", - slug: "seeded-post", - content: "Content here", - excerpt: "Short excerpt", +await myStack.trusted.blog.updatePost({ + id: postId, + data: update, }); -await updatePost(myAdapter, post.id, { published: true }); ``` - -**No authorization hooks are called** when using `stack().api.*` or direct imports. These functions hit the database directly. Always perform your own access-control checks before calling them from user-facing code. - - -### Available getters - -| Function | Returns | Description | -|---|---|---| -| `getAllPosts(adapter, params?)` | `PostListResult` | Paginated posts matching optional filter params | -| `getPostBySlug(adapter, slug)` | `Post \| null` | Single post by slug, or `null` if not found | -| `getAllTags(adapter)` | `Tag[]` | All tags, sorted alphabetically | +`trusted` skips identity resolution and user authorization only. It is the +appropriate surface for a trusted job that still needs normal Blog behavior. -### `PostListParams` +### Trusted and lower-level data access - +Use `myStack.trusted.blog` for trusted jobs that should keep normal validation, fact derivation, domain behavior, and hooks. Use `myStack.forRequest(request).operations.blog` for user-driven server work. `myStack.raw.blog` contains only `prefetchForRoute`. -### `PostListResult` - - - -### Available mutations - -| Function | Returns | Description | -|---|---|---| -| `createPost(adapter, input)` | `Post` | Create a new post with optional tag associations | -| `updatePost(adapter, id, input)` | `Post \| null` | Update a post and reconcile its tags; `null` if not found | -| `deletePost(adapter, id)` | `void` | Delete a post by ID | - -### `CreatePostInput` - - - -### `UpdatePostInput` - - +Standalone getters and mutations from `@btst/stack/plugins/blog/api` remain lower-level adapter primitives for plugin internals or migrations whose caller owns lifecycle composition. ## Static Site Generation (SSG) -`route.loader()` makes HTTP requests to `apiBaseURL`, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering. +`route.loader()` makes HTTP requests to the resolved top-level API endpoint, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering. + + +`prefetchForRoute()` is a raw-data escape hatch. It does not resolve identity, +evaluate authorization rules, or run Blog lifecycle hooks. Static artifacts +are commonly public: use `"drafts"` or `"editPost"` only when your deployment +applies equivalent access controls to the generated output. Never publish +dehydrated protected data into a public page. + ### `prefetchForRoute(routeKey, queryClient, params?)` @@ -713,7 +752,7 @@ export async function generateMetadata(): Promise { const stackClient = getStackClient(queryClient) const route = stackClient.router.getRoute(normalizePath(["blog"])) if (!route) return { title: "Blog" } - await myStack.api.blog.prefetchForRoute("posts", queryClient) + await myStack.raw.blog.prefetchForRoute("posts", queryClient) return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata } @@ -723,7 +762,7 @@ export default async function BlogListPage() { const route = stackClient.router.getRoute(normalizePath(["blog"])) if (!route) return null // Reads directly from DB — works at build time, no HTTP server required - await myStack.api.blog.prefetchForRoute("posts", queryClient) + await myStack.raw.blog.prefetchForRoute("posts", queryClient) return ( @@ -736,7 +775,7 @@ For individual post pages, also generate the static params list: ```tsx title="app/pages/blog/[slug]/page.tsx" export async function generateStaticParams() { - const { items } = await myStack.api.blog.getAllPosts({ published: true, limit: 1000 }) + const { items } = await myStack.trusted.blog.listPosts({ published: true, limit: 1000 }) return items.map((p) => ({ slug: p.slug })) } @@ -745,7 +784,7 @@ export default async function BlogPostPage({ params }: { params: { slug: string const stackClient = getStackClient(queryClient) const route = stackClient.router.getRoute(normalizePath(["blog", params.slug])) if (!route) return null - await myStack.api.blog.prefetchForRoute("post", queryClient, { slug: params.slug }) + await myStack.raw.blog.prefetchForRoute("post", queryClient, { slug: params.slug }) return ( @@ -763,15 +802,15 @@ import { revalidatePath } from "next/cache" import type { BlogBackendHooks } from "@btst/stack/plugins/blog" const blogHooks: BlogBackendHooks = { - onPostCreated: async (post) => { + onAfterCreatePost: async (post) => { revalidatePath("/blog") revalidatePath(`/blog/${post.slug}`) }, - onPostUpdated: async (post) => { + onAfterUpdatePost: async (post) => { revalidatePath("/blog") revalidatePath(`/blog/${post.slug}`) }, - onPostDeleted: async (postId) => { + onAfterDeletePost: async (postId) => { revalidatePath("/blog") }, } @@ -824,14 +863,10 @@ import { HomePageComponent } from "@/components/btst/blog/client/components/page import { PostPageComponent } from "@/components/btst/blog/client/components/pages/post-page" blogClientPlugin({ - apiBaseURL: "...", - apiBasePath: "/api/data", - siteBaseURL: "...", - siteBasePath: "/pages", - queryClient, pageComponents: { posts: HomePageComponent, // replaces the published posts list page - post: PostPageComponent, // replaces the single post page + // Param routes receive the route context ({ params }) as props + post: ({ params }) => , // drafts, newPost, editPost, tag — omit to keep built-in defaults }, }) diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index 6b6eae00a..835d5eae1 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -43,6 +43,7 @@ Ensure you followed the general [framework installation guide](/installation) fi Create your content types as Zod schemas in a shared file. This allows you to use the schemas on both server (for validation) and client (for type-safe hooks). Use `.meta()` to add descriptions and placeholders that appear in the admin UI: ```ts title="lib/cms-schemas.ts" +import type { ContentTypeConfig } from "@btst/stack/plugins/cms/api" import { z } from "zod"; // ========== Product Schema ========== @@ -83,6 +84,22 @@ export const TestimonialSchema = z.object({ }), }); +// One application-owned declaration can configure both CMS factories. +export const contentTypes = [ + { + name: "Product", + slug: "product", + description: "Products for the store", + schema: ProductSchema, + }, + { + name: "Testimonial", + slug: "testimonial", + description: "Customer testimonials", + schema: TestimonialSchema, + }, +] satisfies ContentTypeConfig[]; + // ========== Type Exports for Client Hooks ========== /** Inferred type for Product data */ @@ -106,30 +123,14 @@ export type CMSTypes = { Register the CMS backend plugin with your content types: ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" -import { ProductSchema, TestimonialSchema } from "./cms-schemas" +import { contentTypes } from "./cms-schemas" -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", plugins: { - cms: cmsBackendPlugin({ - contentTypes: [ - { - name: "Product", - slug: "product", - description: "Products for the store", - schema: ProductSchema, - // Field types are defined in the schema via .meta({ fieldType: "..." }) - }, - { - name: "Testimonial", - slug: "testimonial", - description: "Customer testimonials", - schema: TestimonialSchema, - }, - ], - }) + cms: cmsBackendPlugin({ contentTypes }) }, adapter: (db) => createMemoryAdapter(db)({}) }) @@ -142,54 +143,65 @@ export { handler, dbSchema } Register the CMS client plugin: ```tsx title="lib/stack-client.tsx" -import { createStackClient } from "@btst/stack/client" +import { createClientStack } from "@btst/stack/client" import { cmsClientPlugin } from "@btst/stack/plugins/cms/client" import { QueryClient } from "@tanstack/react-query" +import { contentTypes } from "./cms-schemas" -const getBaseURL = () => - process.env.BASE_URL || "http://localhost:3000" +function getBaseURL(serverOrigin?: string) { + if (typeof window !== "undefined") return window.location.origin + return ( + serverOrigin || + process.env.BTST_SITE_URL || + process.env.BASE_URL || + "http://localhost:3000" + ) +} -export const getStackClient = (queryClient: QueryClient, options?: { headers?: Headers }) => { - const baseURL = getBaseURL() - return createStackClient({ +export const getStackClient = ( + queryClient: QueryClient, + options?: { headers?: Headers; origin?: string }, +) => { + const baseURL = getBaseURL(options?.origin) + return createClientStack({ + api: { + baseURL, + basePath: "/api/data", + ...(options?.headers ? { headers: options.headers } : {}), + }, + site: { baseURL, basePath: "/pages" }, + queryClient, plugins: { - cms: cmsClientPlugin({ - apiBaseURL: baseURL, - apiBasePath: "/api/data", - siteBaseURL: baseURL, - siteBasePath: "/pages", - queryClient: queryClient, - headers: options?.headers, - }) + cms: cmsClientPlugin({ contentTypes }) } }) } ``` -### 4. Configure Provider Overrides +The backend HTTP catalog remains authoritative for content data. Passing the +shared declaration to `cmsClientPlugin()` preserves your application-defined +content-type order in the admin UI. Omit it when a managed or separately +deployed backend owns the catalog. -Add CMS overrides to your layout: +### 4. Configure the Provider -```tsx title="app/pages/layout.tsx" -import type { CMSPluginOverrides } from "@btst/stack/plugins/cms/client" +Pass the resolved client stack to the provider. Override inference comes from the +registered CMS definition, so no manual override map or provider generic is needed: -type PluginOverrides = { - cms: CMSPluginOverrides, -} +```tsx title="app/pages/client-layout.tsx" +import { nextRouter } from "@btst/stack/next" - - basePath="/pages" +const stack = getStackClient(queryClient, clientOrigins) + + router.push(path), - refresh: () => router.refresh(), uploadImage: async (file) => { // Your image upload logic return "https://example.com/image.png" }, - Link: ({ href, ...props }) => , } }} > @@ -335,7 +347,7 @@ Admin routes are automatically set to `noindex` for SEO. Don't include them in y ### Page Component Overrides -You can replace any built-in admin page with your own React component using the optional `pageComponents` field in `cmsClientPlugin(config)`. The built-in component is used as the fallback whenever an override is not provided, so this is fully backward-compatible. +You can replace any built-in admin page with your own React component using the optional `pageComponents` field in `cmsClientPlugin(config)`. The built-in component is used as the fallback whenever an override is not provided. Overrides for parameterized routes receive the route context (`{ params }`) as props. ```tsx cmsClientPlugin({ @@ -343,13 +355,15 @@ cmsClientPlugin({ pageComponents: { // Replace the CMS dashboard page dashboard: MyCustomDashboard, - // Replace the content list page — receives typeSlug as a prop - contentList: ({ typeSlug }) => , - // Replace the new content page — receives typeSlug as a prop - newContent: ({ typeSlug }) => , - // Replace the edit content page — receives typeSlug and id as props - editContent: ({ typeSlug, id }) => ( - + // Replace the content list page — receives the route context as props + contentList: ({ params }) => ( + + ), + // Replace the new content page — receives the route context as props + newContent: ({ params }) => , + // Replace the edit content page — receives the route context as props + editContent: ({ params }) => ( + ), }, }) @@ -520,35 +534,57 @@ Customize CMS behavior with backend hooks: cmsBackendPlugin({ contentTypes: [...], hooks: { - onBeforeCreate: async (data, context) => { + onBeforeCreateContent: async (data, context) => { console.log("Creating item in", context.typeSlug) - // Return modified data to allow, throw to deny - return data + // `data` is validated and canonical. Throw to deny. }, - onAfterCreate: async (item, context) => { + onAfterCreateContent: async (item, context) => { console.log("Created:", item.slug) // Trigger webhooks, notifications, etc. }, - onBeforeUpdate: async (id, data, context) => { - // Return modified data to allow, throw to deny - return data + onBeforeUpdateContent: async (id, data, context) => { + // `data` is the complete merged, validated record. Throw to deny. }, - onAfterUpdate: async (item, context) => { + onAfterUpdateContent: async (item, context) => { // ... }, - onBeforeDelete: async (id, context) => { + onBeforeDeleteContent: async (id, context) => { // Throw to deny: throw new Error("Cannot delete published content") }, - onAfterDelete: async (id, context) => { + onAfterDeleteContent: async (id, context) => { // ... }, - onError: async (error, operation, context) => { + onErrorExecuteContentOperation: async (error, operation, context) => { console.error(`CMS ${operation} error:`, error.message) }, }, }) ``` +A plain error thrown by `onBeforeCreateContent`, `onBeforeUpdateContent`, or +`onBeforeDeleteContent` denies the request with HTTP 403. Create and update hooks run +before the parent or any inline related record is written, and no mutation is +committed when a before hook denies the operation. + +CMS lifecycle names use the action-first `onBefore`, +`onAfter`, and `onError` grammar. The existing +aggregate error phase remains one callback; its `operation` argument identifies +the failed create, update, delete, list, or get operation. + +{/* canonical-dx-guard: migration:start reason="plugin lifecycle migration table" */} + +| Removed name | Canonical name | +| --- | --- | +| `onBeforeCreate` | `onBeforeCreateContent` | +| `onAfterCreate` | `onAfterCreateContent` | +| `onBeforeUpdate` | `onBeforeUpdateContent` | +| `onAfterUpdate` | `onAfterUpdateContent` | +| `onBeforeDelete` | `onBeforeDeleteContent` | +| `onAfterDelete` | `onAfterDeleteContent` | +| `onError` | `onErrorExecuteContentOperation` | + +{/* canonical-dx-guard: migration:end */} + ## Type Safety The CMS plugin provides **end-to-end type safety** from schema definition to frontend rendering: @@ -631,82 +667,168 @@ The CMS plugin exposes these REST endpoints: | `/content/:typeSlug/:id` | PUT | Update item | | `/content/:typeSlug/:id` | DELETE | Delete item | | `/content/:typeSlug/:id/populated` | GET | Get item with relations populated | -| `/content/:typeSlug/populated` | GET | List items with relations populated | | `/content/:typeSlug/by-relation` | GET | Filter by relation (query: `field`, `targetId`) | +| `/content-types/:slug/inverse-relations` | GET | Inspect inverse relation definitions | +| `/content-types/:slug/inverse-relations/:sourceType` | GET | List inverse relation items | ## Authorization & Lifecycle Hooks -The CMS plugin provides two levels of hooks for authorization: +CMS owns a browser-safe, schema-backed permission catalog. Define one rule set +against that catalog, then bind it to a server identity adapter and a browser +identity adapter. The built-in CMS routes and the backend operations use the +same descriptors—there is no second list of resource/action strings to keep in +sync. + +```ts title="lib/authorization.ts" +import { z } from "zod" +import { defineAuthorization } from "@btst/stack/authorization" +import { cmsPermissions } from "@btst/stack/plugins/cms/permissions" + +export const authorization = defineAuthorization({ + identity: z.object({ + id: z.string(), + role: z.enum(["editor", "admin"]), + }), + permissions: [cmsPermissions] as const, + rules: ({ cms }) => [ + // Public record responses embed their content-type schema, so both reads + // must be declared public. Omit either rule to deny that response. + cms.record.read.allow(), + cms.contentType.read.allow(), + cms.record.create.when(({ identity }) => identity !== null), + cms.record.update.when( + ({ identity, facts }) => + identity?.role === "admin" || identity?.id === facts.authorId, + ), + cms.record.delete.when( + ({ identity, facts }) => + identity?.role === "admin" || identity?.id === facts.authorId, + ), + ], +}) +``` -### Client Hooks (SSR Authorization) +Bind the same value on the backend. HTTP routes and request-scoped calls resolve +the identity, derive trusted facts from the database, evaluate the rule, and +only then enter CMS lifecycle hooks and mutation code. -Use `hooks` in the client plugin config for **async authorization** during SSR. These run in loaders before pages render, supporting async session checks and redirects: +```ts title="lib/stack.ts" +import { createServerAuth } from "@btst/stack/authorization/server" +import { authorization } from "./authorization" + +const auth = createServerAuth({ + authorization, + getIdentity: async ({ request }) => { + const session = await getSession(request.headers) + return session?.user ?? null + }, +}) -```tsx title="lib/stack-client.tsx" -import { redirect } from "next/navigation" // or your framework's redirect +export const myStack = createBackendStack({ + basePath: "/api/data", + plugins: { cms: cmsBackendPlugin({ contentTypes }) }, + adapter: (db) => createMemoryAdapter(db)({}), + auth, +}) +``` -cms: cmsClientPlugin({ - apiBaseURL: baseURL, - apiBasePath: "/api/data", - siteBaseURL: baseURL, - siteBasePath: "/pages", - queryClient: queryClient, - headers: options?.headers, - hooks: { - beforeLoadDashboard: async (context) => { - const session = await getSession(context.headers) - return session?.user?.isAdmin === true - }, - beforeLoadContentList: async (typeSlug, context) => { - const session = await getSession(context.headers) - return session?.user?.isAdmin === true - }, - beforeLoadContentEditor: async (typeSlug, id, context) => { - const session = await getSession(context.headers) - return session?.user?.isAdmin === true - }, - onLoadError: (error, context) => { - // Redirect to login on authorization failure - redirect("/auth/sign-in") - }, - }, +The exact `cmsPermissions` descriptors are evaluated before lifecycle hooks. Compound inline-create, existing-relation, populated-record, and inverse-relation checks evaluate every server-derived target or source separately, so a secondary record cannot be exposed or mutated through a permitted primary record. Record responses embed their content-type schema, so +record list, detail, create, update, populated, and relation-list operations +also require `cms.contentType.read` for every embedded type. Relation filters +derive the target type from the configured relation field and authorize the +authoritative target record before reading junction rows. + +Bind it in the browser for immediate presentation checks. Browser facts only +control what is shown; they are never trusted by the backend. + +```tsx title="app/pages/client-layout.tsx" +import { createClientAuth } from "@btst/stack/authorization/client" +import { authorization } from "@/lib/authorization" + +const auth = createClientAuth({ + authorization, + getIdentity: () => getBrowserSession()?.user ?? null, }) + + + {children} + ``` - -**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. - +### CMS permission catalog -### Override Hooks (Client-Side) +| Descriptor | Facts | Backend operations | +|---|---|---| +| `cms.contentType.read` | `contentType?` | List content types, get a content type, inspect inverse relation definitions | +| `cms.record.read` | `contentType`, `scope: "collection" \| "record"`, `recordId?`, `authorId?` | List, get, populate, and relation reads | +| `cms.record.create` | `contentType` | Create a record | +| `cms.record.update` | `contentType`, `recordId`, `authorId?` | Update a record | +| `cms.record.delete` | `contentType`, `recordId`, `authorId?` | Delete a record | -Use lifecycle hooks in `StackProvider` overrides for **synchronous** client-side checks (SPA navigation): +The backend reloads the content type and record before it derives +`contentType`, `scope`, `recordId`, and `authorId`; client-supplied ownership, +lookup scope, or type claims cannot authorize a write. A by-slug lookup uses +`scope: "record"` even when no matching record exists, so an explicitly public +detail rule can return a not-found result without exposing the collection. +Rules are boolean operation checks. If your +application needs tenant or row filtering, scope the backend query itself +rather than treating a boolean rule as a data filter. -```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 - }, -} +The content-type catalog includes `itemCount`, so listing it also authorizes a +collection-scoped `cms.record.read` for every counted content type. If any +collection is denied, the operation fails without returning partial counts. + +### Server operation surfaces + +The same operation pipeline backs all authoritative application surfaces: + +```ts +// Identity comes from this request; authorization is enforced. +await myStack.forRequest(request).operations.cms.updateContentItem({ + typeSlug: "product", + id: productId, + body: { data: { name: "Updated" } }, +}) + +// Trusted application code skips only user authorization. Validation, +// fact derivation, execution, and lifecycle hooks still run. +await myStack.trusted.cms.deleteContentItem({ + typeSlug: "product", + id: productId, +}) ``` - -**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. - +`myStack.raw.cms` contains only the SSG `prefetchForRoute` helper. Use `forRequest(request).operations.cms` for request work and `trusted.cms` for explicitly trusted jobs; both retain validation and lifecycle behavior. + +Inline `_new` relation values are compound writes. Request-scoped create and +update operations authorize `cms.record.create` for every server-derived target +content type before hooks run, then commit the parent, related records, and +relation rows atomically. Existing `{ id }` relation values are resolved against +the configured target content type and require `cms.record.read` for the +authoritative target facts. The operation rejects wrong-type IDs and rechecks +target ownership before the transaction writes a relation. Trusted +`trusted.cms` calls skip those user checks but keep the same validation and +transaction lifecycle. + +Compound reads fail closed too. A populated-record operation authorizes every +related target record before returning it, and inverse-relation metadata +authorizes each referring source content type before exposing its name or +fields. When inverse metadata includes counts for an `itemId`, it also +authorizes that target record and each counted source collection before reading +relation rows. Listing the records for an inverse relation likewise authorizes +the source collection and target record, and rederives the requested relation +field from the current content-type schemas. + +Loader hooks may still prepare data and report failures, but they are not an +authorization boundary. `onErrorLoad` is reporting-only: callback errors are +contained and the loader never rejects, so it cannot perform throwing framework +redirects. Use `onRouteRender` and `onRouteError` for presentation lifecycle and +reporting. ## Custom Field Components @@ -1115,6 +1237,12 @@ await fetch("/api/data/content/resource", { }); ``` +`_new` is strict creation, not an upsert or an alternate way to reference an +existing record. BTST derives a slug from the inline data; if that slug already +exists for the target content type or occurs twice in the same request, the +operation returns HTTP `409` with code `RELATED_RECORD_SLUG_CONFLICT`. Pass +`{ id: "existing-record-id" }` when you intend to link an existing record. + #### belongsTo Relations (Single Object) ```ts @@ -1202,9 +1330,17 @@ export default function DirectoryPage() { -#### CMSHookContext +#### CMSCreateOperationContext + + + +#### CMSUpdateOperationContext + + + +#### CMSDeleteOperationContext - + ### Client (`@btst/stack/plugins/cms/client`) @@ -1218,7 +1354,10 @@ export default function DirectoryPage() { #### CMSClientHooks -Customize client-side behavior with lifecycle hooks. These hooks run during SSR data loading and support async authorization: +Customize framework-side data loading, analytics, and error reporting with +lifecycle hooks. The shared CMS operation rules remain the authorization +boundary. `onErrorLoad` is an observer: exceptions from the callback are +contained and never reject the loader. @@ -1226,23 +1365,18 @@ Customize client-side behavior with lifecycle hooks. These hooks run during SSR ```tsx title="lib/stack-client.tsx" cms: cmsClientPlugin({ - // ... rest of the config - headers: options?.headers, hooks: { beforeLoadDashboard: async (context) => { - const session = await getSession(context.headers) - return session?.user?.isAdmin === true + await warmDashboardDependencies(context.headers) }, beforeLoadContentList: async (typeSlug, context) => { - // Check per-content-type permissions - return isAdmin(context.headers) + await recordContentListLoad(typeSlug, context.headers) }, beforeLoadContentEditor: async (typeSlug, id, context) => { - return isAdmin(context.headers) + await recordEditorLoad(typeSlug, id, context.headers) }, - onLoadError(error, context) { - // Redirect on auth failure - redirect("/auth/sign-in") + onErrorLoad(error, context) { + reportCMSLoaderError(error, context) }, } }) @@ -1254,7 +1388,7 @@ cms: cmsClientPlugin({ #### CMSPluginOverrides -Configure framework-specific overrides and route lifecycle hooks: +Configure CMS-specific overrides and route lifecycle hooks: @@ -1318,131 +1452,27 @@ const result = zodSchema.safeParse(data) ## Server-side Data Access -The CMS plugin exposes standalone getter functions for server-side and SSG use cases. - -### Two patterns - -**Pattern 1 — via `stack().api`** - -```ts title="app/lib/stack.ts" -import { myStack } from "./stack"; - -const types = await myStack.api.cms.getAllContentTypes(); -const items = await myStack.api.cms.getAllContentItems("posts", { limit: 10 }); -const item = await myStack.api.cms.getContentItemBySlug("posts", "my-first-post"); -``` - -**Pattern 2 — direct import** +Use the operation surfaces for application business calls: ```ts -import { - getAllContentTypes, - getAllContentItems, - getContentItemBySlug, -} from "@btst/stack/plugins/cms/api"; - -export async function generateStaticParams() { - const result = await getAllContentItems(myAdapter, "posts", { limit: 100 }); - return result.items.map((item) => ({ slug: item.slug })); -} -``` - -### Available getters - -| Function | Description | -|---|---| -| `getAllContentTypes(adapter)` | Returns all registered content types, sorted by name | -| `getAllContentItems(adapter, typeSlug, params?)` | Returns paginated items for a content type | -| `getContentItemBySlug(adapter, typeSlug, slug)` | Returns a single item by slug, or `null` | - -### Server-side mutation — `createContentItem` - -In addition to read-only getters, the CMS plugin exposes a **mutation function** for creating content items directly from server-side code. This is the recommended path for seeds, imports, and scheduled jobs. - - -**`createContentItem` bypasses authorization hooks and Zod schema validation.** Hooks such as `onBeforeCreate` and `onAfterCreate` are **not** called, and the data payload is stored as-is without running the content type's schema validation. The caller is responsible for providing valid data and for any access-control checks. Inline `_new` relation creation is not supported — pre-create related items and pass their IDs. For schema validation or inline `_new` creation, use the HTTP endpoint instead. - - -**Via `myStack.api.cms`:** - -```ts -await myStack.api.cms.createContentItem("client-profile", { - slug: `intake-${Date.now()}`, - data: { - clientName: "Sarah Chen", - age: 34, - riskTolerance: "moderate", - recommendation: "Rebalance windfall 80% equity, 20% cash.", - amlFlag: false, - confidenceScore: 94, - }, +const types = await myStack.trusted.cms.listContentTypes({}) +const items = await myStack.trusted.cms.listContentItems({ + typeSlug: "posts", + query: { limit: 10 }, }) -``` - -**Direct import:** - -```ts -import { createCMSContentItem } from "@btst/stack/plugins/cms/api" - -await createCMSContentItem(myStack.adapter, "client-profile", { - slug: `intake-${Date.now()}`, - data: { clientName: "Sarah Chen", age: 34, amlFlag: false }, +await myStack.forRequest(request).operations.cms.createContentItem({ + typeSlug: "client-profile", + body: { slug, data }, }) ``` -#### `syncRelations` option - -By default, `createContentItem` only writes the item's JSON payload — it does **not** populate the `contentRelation` junction table. This is a no-op for content types without relations, but for content types with `belongsTo` / `hasMany` / `manyToMany` fields it means: +`forRequest(request).operations.cms` enforces authorization. `trusted.cms` is the explicit trusted surface and still runs validation, authoritative fact derivation, relation planning, transactions, and lifecycle hooks. `myStack.raw.cms` is reserved for `prefetchForRoute`. -- The admin UI's "Related Items" / inverse-relations panel will not discover the item. -- `useContentByRelation`, `getContentByRelation`, and `*/populated` endpoints will not return it. -- Only the JSON `{ id: "..." }` reference on the item itself is persisted. - -Pass `{ syncRelations: true }` to also persist relation fields into the junction table — the same behavior the HTTP `POST /content/:typeSlug` route provides, minus inline `_new` creation. - -```ts -await myStack.api.cms.createContentItem( - "study-reference", - { - slug: "bpc157-ref-gwyer-2019", - data: { - compoundId: { id: bpc157.id }, // belongsTo - categoryIds: [{ id: catPeptides.id }], // manyToMany - author: "Gwyer, D. et al.", - year: 2019, - title: "BPC-157 promotes angiogenesis…", - quote: "…", - relevance: "Mechanism of Action", - }, - }, - { syncRelations: true }, -) -``` - - -Enable `syncRelations: true` whenever the content type has relation fields — especially in seed scripts. Without it, seeded items appear correctly on their own detail page but are invisible to inverse-relation queries, so the admin "Related Items" panel and any `by-relation` filter will silently show zero results. - - -The same option is available on the direct import: - -```ts -import { createCMSContentItem } from "@btst/stack/plugins/cms/api" - -await createCMSContentItem( - myStack.adapter, - "study-reference", - { slug: "…", data: { compoundId: { id: bpc157.id }, /* … */ } }, - { syncRelations: true }, -) -``` - -Throws if: -- The content type slug is not found (run `ensureSynced` first if calling outside a plugin request) -- A content item with the same slug already exists in that content type +Standalone getters and `createCMSContentItem(adapter, ...)` remain lower-level adapter primitives for plugin internals and migrations whose caller intentionally owns validation and lifecycle composition. ## Static Site Generation (SSG) -`route.loader()` makes HTTP requests to `apiBaseURL`, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering. +`route.loader()` makes HTTP requests to the API resolved by `createClientStack()`, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering. ### `prefetchForRoute(routeKey, queryClient, params?)` @@ -1469,7 +1499,7 @@ import type { Metadata } from "next" // Generate one static page per content type slug export async function generateStaticParams() { - const types = await myStack.api.cms.getAllContentTypes() + const types = await myStack.trusted.cms.listContentTypes({}) return types.map((t) => ({ typeSlug: t.slug })) } @@ -1480,7 +1510,7 @@ export async function generateMetadata( const stackClient = getStackClient(queryClient) const route = stackClient.router.getRoute(normalizePath(["cms", params.typeSlug])) if (!route) return { title: "Content" } - await myStack.api.cms.prefetchForRoute("contentList", queryClient, { typeSlug: params.typeSlug }) + await myStack.raw.cms.prefetchForRoute("contentList", queryClient, { typeSlug: params.typeSlug }) return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata } @@ -1489,7 +1519,7 @@ export default async function ContentListPage({ params }: { params: { typeSlug: const stackClient = getStackClient(queryClient) const route = stackClient.router.getRoute(normalizePath(["cms", params.typeSlug])) if (!route) return null - await myStack.api.cms.prefetchForRoute("contentList", queryClient, { typeSlug: params.typeSlug }) + await myStack.raw.cms.prefetchForRoute("contentList", queryClient, { typeSlug: params.typeSlug }) return ( @@ -1509,13 +1539,13 @@ import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api" cmsBackendPlugin({ contentTypes: { ... }, hooks: { - onAfterCreate: async (item, context) => { + onAfterCreateContent: async (item, context) => { revalidatePath(`/cms/${context.typeSlug}`, "page") }, - onAfterUpdate: async (item, context) => { + onAfterUpdateContent: async (item, context) => { revalidatePath(`/cms/${context.typeSlug}`, "page") }, - onAfterDelete: async (id, context) => { + onAfterDeleteContent: async (id, context) => { revalidatePath(`/cms/${context.typeSlug}`, "page") }, }, @@ -1565,12 +1595,12 @@ import { DashboardPageComponent } from "@/components/btst/cms/client/components/ import { ContentListPageComponent } from "@/components/btst/cms/client/components/pages/content-list-page" cmsClientPlugin({ - apiBaseURL: "...", - apiBasePath: "/api/data", - queryClient, pageComponents: { dashboard: DashboardPageComponent, // replaces the CMS dashboard page - contentList: ContentListPageComponent, // replaces the content list page + // Param routes receive the route context ({ params }) as props + contentList: ({ params }) => ( + + ), // newContent, editContent — omit to keep built-in defaults }, }) diff --git a/docs/content/docs/plugins/comments.mdx b/docs/content/docs/plugins/comments.mdx index 58eaff82f..dd5e2d096 100644 --- a/docs/content/docs/plugins/comments.mdx +++ b/docs/content/docs/plugins/comments.mdx @@ -25,81 +25,31 @@ Ensure you followed the general [framework installation guide](/installation) fi ### 1. Add Plugin to Backend API -Register the comments backend plugin in your `stack.ts` file: +Register the Comments plugin and the generic server authorization adapter in +your `stack.ts` file. The adapter can wrap any session/authentication library; +Comments has no dependency on it. ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" +import { serverAuth } from "./authorization.server" -const { handler, dbSchema } = stack({ +const { handler, dbSchema } = createBackendStack({ basePath: "/api/data", + auth: serverAuth, plugins: { comments: commentsBackendPlugin({ - // Automatically approve comments (default: false — requires moderation) autoApprove: false, - - // Resolve author display name and avatar from your auth system resolveUser: async (authorId) => { const user = await db.users.findById(authorId) return user ? { name: user.displayName, avatarUrl: user.avatarUrl } : null }, - - // Lifecycle hooks — see Security section below for required configuration - onBeforeList: async (query, ctx) => { - // Restrict non-approved status filters (pending/spam) to admin sessions only - if (query.status && query.status !== "approved") { - const session = await getSession(ctx.headers) - if (!session?.user?.isAdmin) throw new Error("Admin access required") - } - }, - onBeforePost: async (comment, ctx) => { - // Required: resolve the authorId from the authenticated session - // Never use any ID supplied by the client - const session = await getSession(ctx.headers) - if (!session?.user) throw new Error("Authentication required") - return { authorId: session.user.id } - }, - onAfterPost: async (comment, ctx) => { - console.log("New comment posted:", comment.id) - }, - onBeforeEdit: async (commentId, update, ctx) => { - // Required: verify the caller owns the comment they are editing. - // Without this hook all edit requests return 403 by default. - const session = await getSession(ctx.headers) - if (!session?.user) throw new Error("Authentication required") - const comment = await db.comments.findById(commentId) - if (comment?.authorId !== session.user.id && !session.user.isAdmin) - throw new Error("Forbidden") - }, - onBeforeLike: async (commentId, authorId, ctx) => { - // Verify authorId matches the authenticated session - const session = await getSession(ctx.headers) - if (!session?.user) throw new Error("Authentication required") - if (authorId !== session.user.id) throw new Error("Forbidden") - }, - onBeforeStatusChange: async (commentId, status, ctx) => { - // Require admin/moderator role for the moderation endpoint - const session = await getSession(ctx.headers) - if (!session?.user?.isAdmin) throw new Error("Admin access required") - }, - onAfterApprove: async (comment, ctx) => { - // Send notification to comment author - await sendApprovalEmail(comment.authorId) - }, - onBeforeDelete: async (commentId, ctx) => { - // Require admin/moderator role — the Delete button is client-side only - const session = await getSession(ctx.headers) - if (!session?.user?.isAdmin) throw new Error("Admin access required") - }, - - // Required to show authors their own pending comments after posting. - // Without this hook the feature is disabled — client-supplied - // currentUserId is ignored server-side to prevent impersonation. - resolveCurrentUserId: async (ctx) => { - const session = await getSession(ctx.headers) - return session?.user?.id ?? null + hooks: { + onAfterApproveComment: async (comment, ctx) => { + await sendApprovalEmail(comment.authorId) + }, }, }) }, @@ -109,42 +59,62 @@ const { handler, dbSchema } = stack({ export { handler, dbSchema } ``` +Lifecycle hooks run after authorization. Use them for domain invariants, +logging, and side effects; the shared rule below owns ordinary authentication, +ownership, and moderation decisions. + ### 2. Add Plugin to Client Register the comments client plugin in your `stack-client.tsx` file: ```tsx title="lib/stack-client.tsx" -import { createStackClient } from "@btst/stack/client" +import { createClientStack } from "@btst/stack/client" import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { QueryClient } from "@tanstack/react-query" -const getBaseURL = () => - process.env.BASE_URL || "http://localhost:3000" +function getBaseURL(serverOrigin?: string) { + if (typeof window !== "undefined") return window.location.origin + return ( + serverOrigin || + process.env.BTST_SITE_URL || + process.env.BASE_URL || + "http://localhost:3000" + ) +} -export const getStackClient = (queryClient: QueryClient) => { - const baseURL = getBaseURL() - return createStackClient({ +export const getStackClient = ( + queryClient: QueryClient, + options?: { headers?: Headers; origin?: string }, +) => { + const baseURL = getBaseURL(options?.origin) + return createClientStack({ + api: { + baseURL, + basePath: "/api/data", + ...(options?.headers ? { headers: options.headers } : {}), + }, + site: { baseURL, basePath: "/pages" }, + queryClient, plugins: { comments: commentsClientPlugin({ - apiBaseURL: baseURL, - apiBasePath: "/api/data", - queryClient, - siteBaseURL: baseURL, - siteBasePath: "/pages", - // optional headers + lifecycle hooks: - // headers: { cookie: request.headers.get("cookie") ?? "" }, + // optional loader lifecycle hooks: // hooks: { // beforeLoadModeration: async (ctx) => { ... }, // beforeLoadUserComments: async (ctx) => { ... }, - // onLoadError: async (error, ctx) => { ... }, + // onErrorLoad: async (error, ctx) => { ... }, // }, }), }, - queryClient, }) } ``` +Create a request-specific stack with request headers for SSR and a separate +browser stack without them. `StackProvider` consumes the browser stack, so SSR +loaders, metadata, hydration, browser hooks, and mutations share one resolved +Comments endpoint and query client. Do not repeat transport or identity values +in `CommentsPluginOverrides` or component props. + ### 3. Add CSS Import @@ -165,84 +135,134 @@ export const getStackClient = (queryClient: QueryClient) => {
-### 4. Configure Overrides - -Add comments overrides to your layout file. You must also register the `CommentsPluginOverrides` type: +### 4. Configure the Provider - - -```tsx title="app/pages/layout.tsx" -import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" +Comments reads its resolved API and query client from the registered client +stack. `StackProvider` adds framework routing and authorization; its inferred +plugin override is only for Comments-specific presentation and behavior: -type PluginOverrides = { - // ... existing plugins - comments: CommentsPluginOverrides -} +```tsx title="app/pages/client-layout.tsx" +import { clientAuth } from "@/lib/authorization.client" -// 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") + `/pages/blog/${slug}`, + }, }, - } -}} + }} +> + {children} + ``` - - -```tsx title="app/routes/pages/_layout.tsx" -import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" -type PluginOverrides = { - // ... existing plugins - comments: CommentsPluginOverrides -} +The framework layout helpers can resolve `initialIdentity` on the server and +hydrate it here. That avoids a duplicate identity request and keeps the server +render and first browser render on the same rule result. See the +[authorization guide](/auth#hydrate-identity-at-the-layout-boundary). + +## Authorization + +Comments publishes a browser-safe, schema-backed catalog from +`@btst/stack/plugins/comments/permissions`. Register it once and use the same +rules in the browser and BTST backend: + +```ts title="lib/authorization.ts" +import { defineAuthorization } from "@btst/stack/authorization" +import { commentsPermissions } from "@btst/stack/plugins/comments/permissions" +import { z } from "zod" + +export const authorization = defineAuthorization({ + identity: z.object({ + id: z.string(), + role: z.enum(["user", "moderator"]), + }), + permissions: [commentsPermissions] as const, + rules: ({ comments }) => [ + comments.thread.read.when(({ identity, facts }) => { + if (facts.scope === "public") return true + if (facts.scope === "own") + return identity?.id === facts.authorId || identity?.role === "moderator" + return identity?.role === "moderator" + }), + comments.thread.createComment.when(({ identity }) => identity !== null), + comments.comment.edit.when(({ identity, facts }) => + identity?.id === facts.authorId || identity?.role === "moderator" + ), + comments.comment.delete.when(({ identity, facts }) => + identity?.id === facts.authorId || identity?.role === "moderator" + ), + comments.comment.react.when(({ identity, facts }) => + identity !== null && facts.status === "approved" + ), + comments.comment.moderate.when(({ identity, facts }) => + identity?.role === "moderator" && facts.currentStatus !== facts.nextStatus + ), + ], +}) +``` -// 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="lib/authorization.client.ts" +"use client" + +import { createClientAuth } from "@btst/stack/authorization/client" +import { authorization } from "./authorization" + +export const clientAuth = createClientAuth({ + authorization, + getIdentity: () => browserSession?.user ?? null, + loginPath: "/login", +}) ``` - - -```tsx title="src/routes/pages/route.tsx" -import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" -type PluginOverrides = { - // ... existing plugins - comments: CommentsPluginOverrides -} +```ts title="lib/authorization.server.ts" +import "server-only" -// 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") - }, - } -}} +import { createServerAuth } from "@btst/stack/authorization/server" +import { authorization } from "./authorization" + +export const serverAuth = createServerAuth({ + authorization, + getIdentityFromHeaders: async ({ headers }) => { + const session = await auth.api.getSession({ headers }) + return session?.user ?? null + }, +}) ``` - - + +The public rule is deliberate: anonymous users can read approved threads and +approved counts. Authenticated authors additionally see only their own pending +comments in those threads. The own-history and moderation scopes are protected. +Those row/status filters execute only on the server; boolean rules decide the +coarse scope and never return query filters. + +Browser facts are presentation-only. Before editing, deleting, reacting, or +moderating, the backend reloads the comment's authoritative author, status, +resource, and thread facts. Request authorship and like identity always come +from the server adapter, even if an old RC caller sends an `authorId`. A +security-relevant state change during evaluation returns HTTP 409 +(`COMMENT_STATE_CHANGED`) instead of mutating with stale facts. Edit, +reaction, moderation, and delete writes condition on the ownership, status, +and resource facts their rule authorized, so overlapping changes cannot bypass +the rule. + +Once authorization is installed, a missing Comments rule denies that action. +Omitting `createBackendStack({ auth })` preserves permissive server behavior for applications +that do not configure authorization. Use `createServerAuth()` to enable exact +descriptor enforcement and default-deny missing rules. Client gates use the +matching `createClientAuth()` adapter and exact descriptors shown above. ## Embedding Comments The `CommentThread` component can be embedded anywhere — below a blog post, inside a Kanban task dialog, or on a custom page. +Data requests use the resolved endpoint of the registered `comments` plugin; +identity and sign-in behavior use the `auth` service from the nearest +`StackProvider`. ```tsx import { CommentThread } from "@btst/stack/plugins/comments/client/components" @@ -250,10 +270,7 @@ import { CommentThread } from "@btst/stack/plugins/comments/client/components" ``` +`CommentThread` uses the published descriptors for the thread, create/reply, +edit, delete, and react controls. These local checks avoid rendering controls +the browser identity cannot use. They are not security boundaries: every HTTP +and request-scoped backend call derives trusted facts and evaluates the same +rule again. + ### Props | Prop | Type | Required | Description | |------|------|----------|-------------| | `resourceId` | `string` | ✓ | Identifier for the resource (e.g. post slug, task ID) | | `resourceType` | `string` | ✓ | Type of resource (`"blog-post"`, `"kanban-task"`, etc.) | -| `apiBaseURL` | `string` | ✓ | Base URL for API requests | -| `apiBasePath` | `string` | ✓ | Path prefix where the API is mounted | -| `currentUserId` | `string` | — | Authenticated user ID — enables edit/delete/pending badge | -| `loginHref` | `string` | — | Login page URL shown to unauthenticated users | +| `loginHref` | `string` | — | Sign-in URL for unauthenticated users in this thread. Overrides the `loginPath` from the nearest `StackProvider`, which is useful for preserving a resource-specific return URL. | | `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: `