diff --git a/astro.config.mjs b/astro.config.mjs
index 3afb1e8..c2f49e4 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -68,6 +68,7 @@ export default defineConfig({
{ label: 'Versioning', slug: 'core-concepts/versioning' },
{ label: 'Dependency Injection', slug: 'core-concepts/dependency-injection' },
{ label: 'Providers', slug: 'core-concepts/providers' },
+ { label: 'Macroable', slug: 'core-concepts/macroable' },
{ label: 'Events', slug: 'core-concepts/events' },
{ label: 'Lifecycle Hooks', slug: 'core-concepts/lifecycle-hooks' },
{ label: 'Configuration', slug: 'core-concepts/configuration' },
@@ -80,8 +81,12 @@ export default defineConfig({
{ label: 'Validation', slug: 'guides/validation' },
{ label: 'Guards', slug: 'guides/guards' },
{ label: 'Middleware', slug: 'guides/middleware' },
+ { label: 'Rate Limiting', slug: 'guides/rate-limiting' },
{ label: 'Error Handling', slug: 'guides/error-handling' },
{ label: 'Environment Typing', slug: 'guides/environment-typing' },
+ { label: 'Domain Routing', slug: 'guides/domain-routing' },
+ { label: 'Signed URLs', slug: 'guides/signed-urls' },
+ { label: 'Streaming Responses', slug: 'guides/streaming' },
],
},
{
@@ -90,6 +95,7 @@ export default defineConfig({
{ label: 'Queues', slug: 'integrations/queues' },
{ label: 'Cron Jobs', slug: 'integrations/cron-jobs' },
{ label: 'Caching', slug: 'integrations/caching' },
+ { label: 'Feature Flags', slug: 'integrations/feature-flags' },
{ label: 'Storage', slug: 'integrations/storage' },
{ label: 'Email', slug: 'integrations/email' },
{ label: 'Internationalization', slug: 'integrations/i18n' },
@@ -121,10 +127,26 @@ export default defineConfig({
{ label: 'Seeders', slug: 'framework/seeders' },
{ label: 'Factories', slug: 'framework/factories' },
{ label: 'Auth', slug: 'framework/auth' },
- { label: 'RBAC', slug: 'framework/rbac' },
+ { label: 'Access Control', slug: 'framework/access-control' },
{ label: 'Auth Guard', slug: 'framework/auth-guard' },
],
},
+ {
+ label: '@stratal/inertia',
+ items: [
+ { label: 'Overview & Setup', slug: 'inertia/overview' },
+ { label: 'Pages & Rendering', slug: 'inertia/pages-and-rendering' },
+ { label: 'Shared Data & Props', slug: 'inertia/shared-data-and-props' },
+ { label: 'Flash Messages', slug: 'inertia/flash-messages' },
+ { label: 'SSR', slug: 'inertia/ssr' },
+ { label: 'Forms & Validation', slug: 'inertia/forms-and-validation' },
+ { label: 'Modals', slug: 'inertia/modals' },
+ { label: 'React Hooks', slug: 'inertia/react-hooks' },
+ { label: 'Vite Plugin', slug: 'inertia/vite-plugin' },
+ { label: 'Testing', slug: 'inertia/testing' },
+ { label: 'CLI Commands', slug: 'inertia/cli-commands' },
+ ],
+ },
{
label: 'API Reference',
attrs: { target: '_blank' },
diff --git a/src/content/docs/core-concepts/configuration.mdx b/src/content/docs/core-concepts/configuration.mdx
index f9e5acc..5cdead5 100644
--- a/src/content/docs/core-concepts/configuration.mdx
+++ b/src/content/docs/core-concepts/configuration.mdx
@@ -3,9 +3,9 @@ title: Configuration
description: Typed config namespaces, dot-notation access, schema validation, and runtime overrides with registerAs and ConfigService.
---
-import { Aside } from '@astrojs/starlight/components';
+import { Aside, LinkCard } from '@astrojs/starlight/components';
-The configuration system gives you a structured, type-safe way to manage application settings in Stratal. It is environment-agnostic — the framework never reads environment variables directly. Instead, your application defines **config namespaces** that extract values from the Cloudflare `Env` object and expose them through a centralized `ConfigService`.
+The configuration system gives you a structured, type-safe way to manage application settings in Stratal. It is environment-agnostic - the framework never reads environment variables directly. Instead, your application defines **config namespaces** that extract values from the Cloudflare `Env` object and expose them through a centralized `ConfigService`.
Key capabilities:
@@ -14,13 +14,38 @@ Key capabilities:
- Optional Zod schema validation at startup
- Runtime overrides via `config.set()` for request-scoped values
+## Application config versus namespaced config
+
+Two layers of configuration coexist, and they serve different purposes:
+
+- **Application config** is the object you pass to the `Stratal` constructor. It tunes framework behaviour at boot: `versioning`, `trailingSlash`, `logging`, and the exception handler. The `trailingSlash` key accepts either a bare mode (`'ignore' | 'always' | 'never'`) or a `{ mode, exclude }` object that exempts specific paths from canonicalization.
+
+ ```typescript
+ export default new Stratal({
+ module: AppModule,
+ trailingSlash: { mode: 'always', exclude: ['/auth/oauth2'] },
+ })
+ ```
+
+- **Namespaced config** is everything described on the rest of this page: your application's own settings, derived from the Cloudflare `Env` object through `registerAs()` and read back through `ConfigService`.
+
+
+
+
+
## How it works
Configuration flows through three steps:
-1. **Define** — create config namespaces with `registerAs()`, each receiving the `Env` object and returning a typed config object.
-2. **Register** — pass all namespaces to `ConfigModule.forRoot()` in your root or shared module. The module resolves the Cloudflare `Env`, calls each factory, optionally validates the merged result, and initializes `ConfigService`.
-3. **Use** — inject `ConfigService` anywhere and access values with dot-notation paths.
+1. **Define** - create config namespaces with `registerAs()`, each receiving the `Env` object and returning a typed config object.
+2. **Register** - pass all namespaces to `ConfigModule.forRoot()` in your root or shared module. The module resolves the Cloudflare `Env`, calls each factory, optionally validates the merged result, and initializes `ConfigService`.
+3. **Use** - inject `ConfigService` anywhere and access values with dot-notation paths.
```mermaid
flowchart LR
@@ -101,7 +126,7 @@ export class CoreModule {}
| `validateSchema` | `ZodSchema` | No | A Zod schema to validate the merged config at startup |
## Injecting and using ConfigService
@@ -166,7 +191,7 @@ declare module 'stratal' {
}
```
-After this augmentation, `config.get('database.url')` is fully typed — your editor will autocomplete valid paths and flag invalid ones at compile time.
+After this augmentation, `config.get('database.url')` is fully typed - your editor will autocomplete valid paths and flag invalid ones at compile time.
## Schema validation
@@ -215,13 +240,13 @@ ConfigValidationError: Configuration validation failed
Use `config.set()` to override config values during a request. This is useful for middleware that adjusts settings based on request context:
```typescript
-// In middleware — override for this request
+// In middleware - override for this request
async handle(ctx: RouterContext, next: () => Promise) {
this.config.set('email.from.name', 'Custom Name')
await next()
}
-// In a downstream service — reflects the override
+// In a downstream service - reflects the override
async sendEmail() {
const fromName = this.config.get('email.from.name') // 'Custom Name'
}
@@ -351,7 +376,7 @@ To add a new config namespace to your application:
})
```
-4. **Set environment variables** — add to `.dev.vars.example` for local development and set in production via `wrangler secret put` (sensitive) or `wrangler.jsonc` (non-sensitive).
+4. **Set environment variables** - add to `.dev.vars.example` for local development and set in production via `wrangler secret put` (sensitive) or `wrangler.jsonc` (non-sensitive).
## Testing
diff --git a/src/content/docs/core-concepts/controllers-and-routing.mdx b/src/content/docs/core-concepts/controllers-and-routing.mdx
index 8aacce3..ec0b0b5 100644
--- a/src/content/docs/core-concepts/controllers-and-routing.mdx
+++ b/src/content/docs/core-concepts/controllers-and-routing.mdx
@@ -30,8 +30,8 @@ export class UsersController implements IController {
The `@Controller` decorator takes two arguments:
-1. **`route`** (required) — the base path for all routes in this controller.
-2. **`options`** (optional) — an object with:
+1. **`route`** (required) - the base path for all routes in this controller.
+2. **`options`** (optional) - an object with:
| Option | Type | Description |
| --------------- | ------------ | ----------------------------------------------------- |
@@ -39,6 +39,8 @@ The `@Controller` decorator takes two arguments:
| `security` | `SecurityScheme[]` | Security schemes applied to every route |
| `hideFromDocs` | `boolean` | Exclude all routes from the OpenAPI specification |
| `version` | `string \| string[] \| typeof VERSION_NEUTRAL` | API version(s) for this controller (see [Versioning](/core-concepts/versioning/)) |
+| `name` | `string` | Name prefix for routes in this controller (used for [URL generation](#named-routes-and-url-generation)) |
+| `domain` | `string` | Domain pattern to restrict this controller to (e.g., `'{tenant}.myapp.com'`). See [Domain Routing](/guides/domain-routing/). |
```typescript
@Controller('/api/admin', {
@@ -179,7 +181,7 @@ return ctx.redirect('/login', 302)
`RouterContext` provides three methods for streaming data to the client:
-#### `stream(callback, onError?)` — Binary / generic streaming
+#### `stream(callback, onError?)` - Binary / generic streaming
```typescript
@Get('/download', { response: { schema: z.any(), contentType: 'application/octet-stream' } })
@@ -190,7 +192,7 @@ download(ctx: RouterContext) {
}
```
-#### `streamText(callback, onError?)` — Text streaming
+#### `streamText(callback, onError?)` - Text streaming
```typescript
@Get('/text', { response: { schema: z.any(), contentType: 'text/plain' } })
@@ -202,7 +204,7 @@ text(ctx: RouterContext) {
}
```
-#### `streamSSE(callback, onError?)` — Server-Sent Events
+#### `streamSSE(callback, onError?)` - Server-Sent Events
```typescript
@Get('/events', { response: { schema: z.any(), contentType: 'text/event-stream' } })
@@ -269,7 +271,8 @@ export class HealthController implements IController {
Guards control whether a request is allowed to proceed. Apply them at the controller level or on individual methods using the `@UseGuards` decorator:
```typescript
-import { Controller, IController, UseGuards, Route, RouterContext } from 'stratal/router'
+import { Controller, IController, Route, RouterContext } from 'stratal/router'
+import { UseGuards } from 'stratal/guards'
import { AuthGuard } from './auth.guard'
import { RolesGuard } from './roles.guard'
@@ -357,6 +360,203 @@ The response displays the status, headers, and formatted body.
Running `npx quarry api` without a route argument shows the route list, equivalent to `route:list`.
+## Route groups and the fluent router API
+
+Modules can implement the `RouteConfigurable` interface to configure routing with a fluent builder API. This gives you control over route prefixes, domain patterns, middleware, versioning, and grouping - all scoped to the module's controllers.
+
+```typescript
+import { Module } from 'stratal/module'
+import type { RouteConfigurable, Router } from 'stratal/router'
+import { AuthMiddleware } from './auth.middleware'
+
+@Module({
+ controllers: [UsersController, AdminController],
+})
+export class ApiModule implements RouteConfigurable {
+ configureRoutes(router: Router) {
+ router
+ .name('api.')
+ .middleware(AuthMiddleware)
+ .group([UsersController], (r) => {
+ r.prefix('/users').name('users.')
+ })
+ .group([AdminController], (r) => {
+ r.prefix('/admin').name('admin.').hideFromDocs()
+ })
+ }
+}
+```
+
+### Fluent methods
+
+| Method | Description |
+| --- | --- |
+| `prefix(path, params?)` | Set a path prefix for all routes. Optionally pass a Zod schema to validate path parameters. |
+| `domain(pattern)` | Restrict routes to a domain pattern (e.g., `'{tenant}.myapp.com'`). See [Domain Routing](/guides/domain-routing/). |
+| `name(prefix)` | Add a name prefix to all routes (e.g., `'api.'` makes `index` become `'api.index'`). |
+| `middleware(...classes)` | Apply middleware to all routes in scope. |
+| `version(v)` | Set the API version (string or string array). |
+| `hideFromDocs(hide?)` | Hide routes from the OpenAPI spec. |
+| `use(...classes)` | Register global middleware (root router only). |
+| `group(controllers, callback)` | Create a sub-group with its own prefix, name, middleware, etc. |
+
+### Named routes and URL generation
+
+When routes have names (from `router.name()` or `@Route({ name: '...' })`), you can generate URLs using `ctx.route()`:
+
+```typescript
+@Controller('/users')
+export class UsersController implements IController {
+ @Route({ name: 'users.show' })
+ show(ctx: RouterContext) {
+ const id = ctx.param('id')
+ return ctx.json({ id })
+ }
+
+ @Route()
+ index(ctx: RouterContext) {
+ // Generate a URL to the show route
+ const url = ctx.route('users.show', { id: '42' })
+ // → '/users/42'
+ return ctx.json({ users: [], links: { detail: url } })
+ }
+}
+```
+
+Pass `{ absolute: true }` to generate a full URL (scheme + host) using the current request's origin:
+
+```typescript
+const url = ctx.route('users.show', { id: '42' }, { absolute: true })
+// → 'https://example.com/users/42'
+```
+
+For type-safe route names, generate types with the CLI:
+
+```bash
+npx quarry route:types
+```
+
+This creates a `src/stratal.d.ts` file that provides autocomplete for route names and validates parameters.
+
+#### Outside a request context
+
+Use the standalone `route()` function or inject the `Uri` service when you need URL generation from a queue consumer, cron job, or service that doesn't receive a `RouterContext`:
+
+```typescript
+import { route } from 'stratal/router'
+
+const url = route('users.show', { id: '42' })
+```
+
+The `Uri` service exposes the same primitives plus request-aware helpers like `current()`, `full()`, `previous()`, `to()`, `query()`, and sticky parameter `defaults()` - useful when you want to share a default like `locale` across many `route()` calls in a single request. Resolve it from the request container:
+
+```typescript
+import { ROUTER_TOKENS, type Uri } from 'stratal/router'
+
+const uri = ctx.getContainer().resolve(ROUTER_TOKENS.Uri)
+uri.defaults({ locale: 'en' })
+uri.route('posts.index') // auto-fills :locale param
+```
+
+
+
+### Locale-aware URLs
+
+When a route carries a `:locale` path segment, passing a `locale` param prefixes the generated URL automatically. This is the building block for SEO needs like canonical links, locale switchers, and hreflang alternates:
+
+```typescript
+// Route path: '/{locale}/posts'
+uri.route('posts.index', { locale: 'fr' })
+// → '/fr/posts'
+```
+
+Whether the default locale is prefixed follows your i18n path configuration. Set a sticky `locale` default once and every later `route()` call fills the segment for you:
+
+```typescript
+uri.defaults({ locale: ctx.getLocale() })
+uri.route('posts.index') // → '/fr/posts'
+uri.route('posts.show', { id: '42' }) // → '/fr/posts/42'
+```
+
+To compute locale path variants directly (for emitting hreflang `` tags or a canonical URL across every supported locale), inject the `LocaleUrlService`:
+
+```typescript
+import { ROUTER_TOKENS, type LocaleUrlService } from 'stratal/router'
+
+const localeUrl = ctx.getContainer().resolve(ROUTER_TOKENS.LocaleUrlService)
+
+localeUrl.applyPrefix('/posts', 'fr') // → '/fr/posts' (respects prefixDefaultLocale)
+localeUrl.stripPrefix('/fr/posts') // → '/posts'
+localeUrl.shouldPrefix('en') // false when 'en' is the unprefixed default
+```
+
+A typical hreflang block iterates supported locales, applying the prefix to the current path and pairing each with an absolute URL:
+
+```typescript
+const path = uri.current()
+const alternates = locales.map((locale) => ({
+ hreflang: locale,
+ href: uri.to(localeUrl.applyPrefix(localeUrl.stripPrefix(path), locale), undefined, { absolute: true }),
+}))
+```
+
+
+
+## Trailing slashes
+
+By default Stratal accepts both `/users` and `/users/` for the same route. To enforce a canonical form, pass a `trailingSlash` option to the `Stratal` constructor:
+
+```typescript
+import { Stratal } from 'stratal'
+import { AppModule } from './app.module'
+
+export default new Stratal({
+ module: AppModule,
+ trailingSlash: 'always', // or 'never' | 'ignore'
+})
+```
+
+| Mode | Behaviour |
+| --- | --- |
+| `'ignore'` (default) | Both `/users` and `/users/` match. URL generation leaves paths as-is. |
+| `'always'` | Requests without a trailing slash redirect (308) to the trailing-slash form. URL generation appends `/`. |
+| `'never'` | Requests with a trailing slash redirect (308) to the non-trailing form. URL generation strips `/`. |
+
+The redirect uses HTTP 308 so request bodies survive on `POST`, `PUT`, and `PATCH`. The `Location` header is path-relative (no scheme or host), which avoids mixed-content blocks behind an HTTPS-terminating proxy. Paths whose final segment contains a dot (e.g. `/openapi.json`) and the root `/` are always passed through unchanged.
+
+`ctx.route()`, the standalone `route()` function, and the `Uri` service all apply the configured mode automatically, so generated URLs stay consistent with the redirect behaviour.
+
+### Excluding paths from canonicalization
+
+Some paths have a canonical form dictated by an external party: an OAuth redirect URI registered with an identity provider is matched byte for byte, so neither slash form may be rewritten. Pass `{ mode, exclude }` instead of a bare mode to exempt those paths:
+
+```typescript
+export default new Stratal({
+ module: AppModule,
+ trailingSlash: {
+ mode: 'always',
+ exclude: ['/auth/oauth2', /^\/webhooks\//],
+ },
+})
+```
+
+Excluded paths are never redirected (no 308) and never rewritten by URL generation. Both slash forms are served exactly as requested.
+
+| Pattern type | Matching behaviour |
+| --- | --- |
+| `string` | Segment-aware prefix. `'/auth/oauth2'` exempts `/auth/oauth2` and `/auth/oauth2/callback/x`, but not `/auth/oauth2-other`. |
+| `RegExp` | Tested against both forms of the pathname (with and without the trailing slash), so anchoring to either form exempts both. |
+
+Exclusions match in route space. When path-based locale detection is active, a leading locale segment is stripped before matching, so `'/callback'` also exempts `/fr/callback`.
+
+
+
## Next steps
- [HTTP Method Decorators](/core-concepts/http-method-decorators/) for explicit `@Get`, `@Post`, etc. when you need custom paths or non-CRUD endpoints.
@@ -364,4 +564,7 @@ The response displays the status, headers, and formatted body.
- [Modules](/core-concepts/modules/) to learn how controllers are registered in modules.
- [Dependency Injection](/core-concepts/dependency-injection/) to inject services into your controllers.
- [Guards guide](/guides/guards/) for writing custom guards.
+- [Domain Routing](/guides/domain-routing/) for routing by subdomain patterns.
+- [Signed URLs](/guides/signed-urls/) for generating tamper-proof links.
+- [Streaming Responses](/guides/streaming/) for streaming data to clients.
- [OpenAPI overview](/openapi/overview/) to see how `@Route` schemas generate API documentation.
diff --git a/src/content/docs/core-concepts/dependency-injection.mdx b/src/content/docs/core-concepts/dependency-injection.mdx
index e3d102e..243c336 100644
--- a/src/content/docs/core-concepts/dependency-injection.mdx
+++ b/src/content/docs/core-concepts/dependency-injection.mdx
@@ -70,18 +70,28 @@ Scopes control how often a new instance is created when a dependency is resolved
| `Singleton` | One shared instance for the entire application | Configuration, caches, connection pools |
| `Request` | One instance per HTTP request | Services that hold per-request state |
-Set the scope when registering a provider:
+Set the scope with the decorator on the class:
+
+```typescript
+import { Singleton, Request, Transient } from 'stratal/di'
+
+@Singleton()
+export class ConfigService {}
+
+@Request()
+export class RequestLogger {}
+
+@Transient() // Transient is the default
+export class UsersService {}
+```
+
+Modules then register the classes without repeating the scope:
```typescript
import { Module } from 'stratal/module'
-import { Scope } from 'stratal/di'
@Module({
- providers: [
- { provide: ConfigService, useClass: ConfigService, scope: Scope.Singleton },
- { provide: RequestLogger, useClass: RequestLogger, scope: Scope.Request },
- UsersService, // defaults to Transient
- ],
+ providers: [ConfigService, RequestLogger, UsersService],
})
export class AppModule {}
```
@@ -92,7 +102,7 @@ export class AppModule {}
## Injection tokens
-When you inject a concrete class, TypeScript's type metadata is enough for the container to find the right provider. But when you depend on an abstraction (an interface or a value), you need a **token** to identify it.
+Every constructor parameter is resolved through the token you pass to `@inject()`. When you depend on a concrete class, the class itself is the token, as in `@inject(UsersService)`. When you depend on an abstraction such as an interface or a value, there is no class to point at, so you create a **token** to identify it.
Create tokens using Symbols:
@@ -140,7 +150,9 @@ Commonly used tokens include:
| `DI_TOKENS.ExecutionContext` | The Cloudflare `ExecutionContext` |
| `DI_TOKENS.Container` | The DI container itself |
| `DI_TOKENS.Application` | The Stratal application instance |
-| `DI_TOKENS.ErrorHandler` | The registered error handler |
+| `DI_TOKENS.ModuleRegistry` | The module registry |
+| `DI_TOKENS.LazyModuleLoader` | The lazy module loader |
+| `DI_TOKENS.ExceptionHandler` | The registered exception handler |
| `DI_TOKENS.Database` | The database service (if configured) |
| `DI_TOKENS.Queue` | The queue manager |
| `DI_TOKENS.ConsumerRegistry` | The queue consumer registry |
@@ -152,8 +164,8 @@ Commonly used tokens include:
Stratal uses a two-tier container architecture:
-1. **Global container** — created once when the application boots. It holds singleton providers and serves as the parent for all request containers.
-2. **Request container** — a child container created for each incoming HTTP request. It inherits all registrations from the global container and adds request-scoped instances.
+1. **Global container**: created once when the application boots. It holds singleton providers and serves as the parent for all request containers.
+2. **Request container**: a child container created for each incoming HTTP request. It inherits all registrations from the global container and adds request-scoped instances.
When you resolve a transient or singleton provider, the global container handles it. When you resolve a request-scoped provider, the request container creates a fresh instance that lives only for the duration of that request.
@@ -256,7 +268,36 @@ export class BackgroundTaskService {
}
```
+## Break circular dependencies with lazy()
+
+When two services depend on each other, the container cannot decide which to construct first. Wrap one side of the cycle with `lazy()` so its token is resolved on first access rather than at construction time:
+
+```typescript
+import { Transient, inject, lazy } from 'stratal/di'
+
+@Transient()
+export class OrdersService {
+ constructor(
+ @inject(lazy(() => InvoicesService)) private readonly invoices: InvoicesService,
+ ) {}
+}
+
+@Transient()
+export class InvoicesService {
+ constructor(
+ @inject(OrdersService) private readonly orders: OrdersService,
+ ) {}
+}
+```
+
+`lazy()` takes a factory that returns the constructor. The container defers resolving the wrapped token until the dependency is actually used, which lets both classes finish constructing.
+
+
+
## Next steps
- [Providers](/core-concepts/providers/) for advanced registration patterns like factories, aliases, and conditional bindings.
- [Modules](/core-concepts/modules/) to see how providers are organized across modules.
+- [Lifecycle Hooks](/core-concepts/lifecycle-hooks/) for disposing container-managed resources on shutdown.
diff --git a/src/content/docs/core-concepts/events.mdx b/src/content/docs/core-concepts/events.mdx
index 113bffc..86df671 100644
--- a/src/content/docs/core-concepts/events.mdx
+++ b/src/content/docs/core-concepts/events.mdx
@@ -102,9 +102,9 @@ async lowPriority(ctx: EventContext<'after.User.create'>) {}
Blocking controls whether `emit()` waits for the handler to complete:
-- **`before.*` events** — always blocking (`true`). The emitter waits for all handlers before proceeding.
-- **`after.*` events** — non-blocking (`false`). Handlers run in the background via `waitUntil`.
-- **Custom events** — blocking (`true`) by default.
+- **`before.*` events** - always blocking (`true`). The emitter waits for all handlers before proceeding.
+- **`after.*` events** - non-blocking (`false`). Handlers run in the background via `waitUntil`.
+- **Custom events** - blocking (`true`) by default.
You can override the default with the `blocking` option:
@@ -193,6 +193,6 @@ This outputs a table with the following columns:
## Next steps
-- [Database Events](/framework/database-events/) for events emitted automatically by database operations.
+- [Database Events](/framework/database-events/) for events emitted automatically by database operations, including entity mutation events that carry full before and after entity snapshots.
- [Lifecycle Hooks](/core-concepts/lifecycle-hooks/) for module-level initialization and shutdown events.
- [Providers](/core-concepts/providers/) for registering listeners as providers.
diff --git a/src/content/docs/core-concepts/http-method-decorators.mdx b/src/content/docs/core-concepts/http-method-decorators.mdx
index 4f9e148..314026f 100644
--- a/src/content/docs/core-concepts/http-method-decorators.mdx
+++ b/src/content/docs/core-concepts/http-method-decorators.mdx
@@ -82,7 +82,7 @@ export class UsersController {
}
```
-Notice that method names are arbitrary — there is no `IController` interface to implement. The HTTP method and path are determined entirely by the decorator.
+Notice that method names are arbitrary - there is no `IController` interface to implement. The HTTP method and path are determined entirely by the decorator.
## Decorator reference
@@ -100,8 +100,8 @@ Each decorator takes a `path` and an optional `config` object:
Key points:
- **`path`** is relative to the controller's base path. `@Get('/:id')` on a controller at `/api/users` registers `GET /api/users/:id`.
-- **Method names are arbitrary.** Name your methods whatever makes sense — there is no `IController` interface to implement.
-- **`config`** accepts the same `RouteConfig` properties as `@Route` (body, params, query, response, tags, security, summary, description, hideFromDocs) plus `statusCode`. The `body` property accepts a `RouteBody` — either a bare Zod schema (defaults to `application/json`) or a `{ schema, contentType? }` object for custom content types. Similarly, `response` accepts a `RouteResponse` — a bare Zod schema or a `{ schema, description?, contentType? }` object.
+- **Method names are arbitrary.** Name your methods whatever makes sense - there is no `IController` interface to implement.
+- **`config`** accepts the same `RouteConfig` properties as `@Route` (body, params, query, response, tags, security, summary, description, hideFromDocs) plus `statusCode`. The `body` property accepts a `RouteBody` - either a bare Zod schema (defaults to `application/json`) or a `{ schema, contentType? }` object for custom content types. Similarly, `response` accepts a `RouteResponse` - a bare Zod schema or a `{ schema, description?, contentType? }` object.
+## Lazy module loading
+
+Most modules are wired up eagerly at bootstrap when Stratal walks the import tree. A heavy or rarely used module can instead be loaded on demand, keeping it out of the cold start path until the first time a request actually needs it.
+
+Inject `DI_TOKENS.LazyModuleLoader` and call `load()` with a function that dynamically imports the module:
+
+```typescript
+import { Controller, IController, Route, RouterContext } from 'stratal/router'
+import { inject, DI_TOKENS } from 'stratal/di'
+import { LazyModuleLoader } from 'stratal/module'
+
+@Controller('/api/reports')
+export class ReportsController implements IController {
+ constructor(
+ @inject(DI_TOKENS.LazyModuleLoader) private readonly loader: LazyModuleLoader,
+ ) {}
+
+ @Route({ response: reportSchema })
+ async generate(ctx: RouterContext) {
+ const ref = await this.loader.load(() =>
+ import('./reports/reports.module').then((m) => m.ReportsModule),
+ )
+ const reports = ref.get(ReportsService)
+ return ctx.json(await reports.build())
+ }
+}
+```
+
+`load()` returns a `ModuleRef` that resolves providers from the loaded module:
+
+| Method | Description |
+| ----------------- | ----------------------------------------------------- |
+| `ref.get(token)` | Resolve a provider synchronously |
+| `ref.resolve(token)` | Async variant that returns a `Promise` of the provider |
+
+Here is what happens when a module is loaded lazily:
+
+
+
+1. The module's nested `imports` and `providers` are registered into the global container.
+
+2. The module's `onInitialize` hook runs exactly once.
+
+3. A `ModuleRef` is returned so you can resolve any of the module's providers.
+
+
+
+Loading the same module again returns the cached `ModuleRef` without re-registering or re-running `onInitialize`. Singletons resolve to a single shared instance no matter how many times the module is loaded.
+
+
+
+
+
## Middleware configuration
-Modules can configure middleware by implementing the `MiddlewareConfigurable` interface. Define a `configure()` method that receives a `MiddlewareConsumer`:
+Modules can configure middleware by implementing the `RouteConfigurable` interface. Define a `configureRoutes()` method that receives a `Router`:
```typescript
import { Module } from 'stratal/module'
-import { MiddlewareConfigurable, MiddlewareConsumer } from 'stratal/middleware'
+import type { RouteConfigurable } from 'stratal/router'
+import { Router } from 'stratal/router'
import { LoggingMiddleware } from './logging.middleware'
@Module({
controllers: [UsersController],
+ providers: [LoggingMiddleware],
})
-export class UsersModule implements MiddlewareConfigurable {
- configure(consumer: MiddlewareConsumer) {
- consumer.apply(LoggingMiddleware).forRoutes('/api/users/*')
+export class UsersModule implements RouteConfigurable {
+ configureRoutes(router: Router): void {
+ router.middleware(LoggingMiddleware)
}
}
```
-For a complete guide on writing and applying middleware, see the [Middleware guide](/guides/middleware/).
+`router.middleware()` scopes middleware to this module's controllers, `router.use()` registers it globally, and `router.group()` scopes it to specific controllers. For a complete guide on writing and applying middleware, see the [Middleware guide](/guides/middleware/).
## Next steps
diff --git a/src/content/docs/core-concepts/providers.mdx b/src/content/docs/core-concepts/providers.mdx
index 40506d5..607580e 100644
--- a/src/content/docs/core-concepts/providers.mdx
+++ b/src/content/docs/core-concepts/providers.mdx
@@ -25,18 +25,16 @@ This is shorthand for `{ provide: UsersService, useClass: UsersService }`. The c
## ClassProvider
-A `ClassProvider` maps a token to a class. Use it when the token differs from the class, or when you need to set a specific scope:
+A `ClassProvider` maps a token to a class. Use it when the token differs from the class:
```typescript
import { Module } from 'stratal/module'
-import { Scope } from 'stratal/di'
@Module({
providers: [
{
provide: USER_REPOSITORY,
useClass: PostgresUserRepository,
- scope: Scope.Singleton,
},
],
})
@@ -47,7 +45,8 @@ export class UsersModule {}
| ---------- | ----------------- | -------------------------------------------------- |
| `provide` | `InjectionToken` | The token to register under |
| `useClass` | `Constructor` | The class to instantiate |
-| `scope` | `Scope` | Optional. Defaults to `Transient` if not specified |
+
+The provider's scope comes from the scope decorator on the class itself (`@Singleton()`, `@Request()`, or `@Transient()`), so `PostgresUserRepository` carries its own scope.
## ValueProvider
diff --git a/src/content/docs/core-concepts/quarry-cli.mdx b/src/content/docs/core-concepts/quarry-cli.mdx
index f8d18f9..a31d44e 100644
--- a/src/content/docs/core-concepts/quarry-cli.mdx
+++ b/src/content/docs/core-concepts/quarry-cli.mdx
@@ -5,7 +5,25 @@ description: Build and run custom CLI commands for your Stratal application usin
import { Aside } from '@astrojs/starlight/components';
-Quarry is Stratal's built-in CLI framework, inspired by [Laravel Artisan](https://laravel.com/docs/artisan). It lets you define custom commands that run against your full application — with access to dependency injection, services, and Cloudflare bindings. Commands extend the `Command` base class from `stratal/quarry`.
+Quarry is Stratal's built-in CLI framework, inspired by [Laravel Artisan](https://laravel.com/docs/artisan). It lets you define custom commands that run against your full application, with access to dependency injection, services, and Cloudflare bindings. Commands extend the `Command` base class from `stratal/quarry`.
+
+## The command entry point
+
+Quarry loads your application through a dedicated entry file at `src/quarry.ts`, kept separate from your Worker entry (`src/index.ts`). Create it once and export a `QuarryRunner`:
+
+```typescript
+// src/quarry.ts
+import { QuarryRunner } from 'stratal/quarry/runner'
+import { AppModule } from './app.module'
+
+export default QuarryRunner.run({
+ imports: [AppModule],
+})
+```
+
+
## Running commands
@@ -13,19 +31,35 @@ Quarry is Stratal's built-in CLI framework, inspired by [Laravel Artisan](https:
npx quarry [arguments] [options]
```
-By default, Quarry uses `./src/index.ts` as the application entry point. To use a different entry file, pass it as the first argument:
+By default, Quarry uses `./src/quarry.ts` as the application entry point. To use a different entry file, pass it as the first argument:
```bash
npx quarry ./src/other-entry.ts [arguments] [options]
```
+### Targeting a Wrangler environment
+
+Use `--env ` (or `-e `) to target a `wrangler.jsonc` environment such as `env.staging` or `env.production`. Quarry loads that environment's bindings and vars before running the command. The flag is position-tolerant, so it works before or after the entry path:
+
+```bash
+npx quarry --env staging route:list
+npx quarry -e production db:seed
+npx quarry ./custom/entry.ts --env staging route:list
+```
+
+Omit the flag to use the top-level (default) Wrangler config.
+
+
+
## Creating a command
Extend `Command`, define a static `command` signature and `description`, then implement `handle()`:
```typescript
import { Command } from 'stratal/quarry'
-import { inject } from 'tsyringe'
+import { inject } from 'stratal/di'
import { TaskService } from '../services/task.service'
export class AddTaskCommand extends Command {
@@ -114,7 +148,7 @@ Use these methods inside `handle()` to write output:
```typescript
import { Command } from 'stratal/quarry'
-import { inject } from 'tsyringe'
+import { inject } from 'stratal/di'
import { TaskService } from '../services/task.service'
export class ListTasksCommand extends Command {
@@ -213,11 +247,31 @@ Stratal ships with built-in Quarry commands. Each command is documented in detai
| Command | Description | Docs |
| --- | --- | --- |
-| `help` | Show help or list all commands | — |
+| `help` | Show help or list all commands | - |
| `route:list` | List all registered routes | [Controllers and Routing](/core-concepts/controllers-and-routing/#cli-testing-routes) |
+| `route:types` | Generate TypeScript types for named routes | [Controllers and Routing](/core-concepts/controllers-and-routing/#cli-testing-routes) |
| `event:list` | List all registered event listeners | [Events](/core-concepts/events/#inspecting-listeners) |
| `schedule:list` | List all registered cron jobs | [Cron Jobs](/integrations/cron-jobs/#inspecting-cron-jobs) |
+
+### Queues
+
+| Command | Description | Docs |
+| --- | --- | --- |
| `queue:list` | List all registered queue consumers | [Queues](/integrations/queues/#inspecting-consumers) |
+| `queue:failed` | List failed queue jobs | [Queues](/integrations/queues/) |
+| `queue:retry` | Retry failed queue jobs | [Queues](/integrations/queues/) |
+| `queue:purge` | Delete failed queue jobs without retrying | [Queues](/integrations/queues/) |
+
+### Internationalization
+
+| Command | Description | Docs |
+| --- | --- | --- |
+| `i18n:check` | Audit locales for missing or extra keys | [I18n](/integrations/i18n/) |
+| `i18n:stats` | Show translation coverage per locale | [I18n](/integrations/i18n/) |
+| `i18n:list` | List message keys with per-locale coverage | [I18n](/integrations/i18n/) |
+| `i18n:search` | Search message keys or values | [I18n](/integrations/i18n/) |
+| `i18n:namespaces` | List namespaces with key counts | [I18n](/integrations/i18n/) |
+| `i18n:duplicates` | Find keys sharing identical values | [I18n](/integrations/i18n/) |
### API and MCP
@@ -250,6 +304,134 @@ Stratal ships with built-in Quarry commands. Each command is documented in detai
Run `npx quarry help` to see all available commands, or `npx quarry help ` for usage details on a specific command.
+## Managing queues
+
+Quarry includes commands to inspect queue consumers and work with the failed-job store.
+
+`queue:list` prints every registered consumer alongside the message types it handles:
+
+```bash
+npx quarry queue:list
+```
+
+`queue:failed` lists jobs that exhausted their retries and landed in the failed-job store. It accepts two options:
+
+| Option | Description |
+| --- | --- |
+| `--queue=` | Filter results to a single queue |
+| `--limit=` | Maximum number of jobs to return (default `50`) |
+
+```bash
+npx quarry queue:failed
+npx quarry queue:failed --queue=NOTIFICATIONS_QUEUE
+npx quarry queue:failed --limit=100
+```
+
+The output table includes each job's ID, queue, message type, consumer, attempt count, and the time it failed. The job ID is what you pass to `queue:retry` and `queue:purge`.
+
+`queue:retry` re-sends failed jobs back onto their original queue. Pass a single message ID, or use `--all` to retry every failed job. You must provide one or the other:
+
+| Argument or option | Description |
+| --- | --- |
+| `id` | Optional message ID to retry |
+| `--all` | Retry all failed jobs |
+| `--queue=` | When used with `--all`, only retry jobs from this queue |
+
+```bash
+npx quarry queue:retry 0c1d2e3f-... # retry one job by ID
+npx quarry queue:retry --all # retry every failed job
+npx quarry queue:retry --all --queue=EMAIL_QUEUE
+```
+
+`queue:purge` deletes failed jobs from the store without re-sending them. It mirrors the arguments of `queue:retry`:
+
+| Argument or option | Description |
+| --- | --- |
+| `id` | Optional message ID to purge |
+| `--all` | Purge all failed jobs |
+| `--queue=` | When used with `--all`, only purge jobs from this queue |
+
+```bash
+npx quarry queue:purge 0c1d2e3f-... # delete one job by ID
+npx quarry queue:purge --all # delete every failed job
+npx quarry queue:purge --all --queue=EMAIL_QUEUE
+```
+
+
+
+## Inspecting schedules
+
+`schedule:list` prints every registered cron job grouped by its schedule expression, so you can confirm your jobs match the triggers declared in `wrangler.jsonc`:
+
+```bash
+npx quarry schedule:list
+```
+
+The output is a table of schedule expression and job class. If no cron jobs are registered, it reports `No cron jobs found`.
+
+## Serving an MCP server
+
+`mcp:serve` starts a [Model Context Protocol](https://modelcontextprotocol.io/) server over stdio that exposes your API routes as tools for AI agents. Each OpenAPI route becomes a tool, and the full OpenAPI spec is registered as an MCP resource.
+
+```bash
+npx quarry mcp:serve
+```
+
+By default, tool calls are dispatched in-process through your app. The following options tune what is exposed and where requests go:
+
+| Option | Description |
+| --- | --- |
+| `--url=` | Dispatch requests to an external URL instead of the in-process app |
+| `--header=` | Add a header to dispatched requests (repeatable) |
+| `--tag=` | Only expose routes carrying these OpenAPI tags (repeatable) |
+| `--path=` | Only expose routes matching this path prefix |
+
+```bash
+# Expose only routes tagged "Notes"
+npx quarry mcp:serve --tag=Notes
+
+# Expose routes under /api/v1 and dispatch to a running server
+npx quarry mcp:serve --path=/api/v1 --url=http://localhost:8787
+```
+
+To preview which routes would be exposed without starting the server, use `mcp:tools`, which accepts the same `--tag` and `--path` filters:
+
+```bash
+npx quarry mcp:tools
+npx quarry mcp:tools --tag=Notes --path=/api/v1
+```
+
+See [AI Integration](/getting-started/ai/) for the full MCP workflow.
+
+## Auditing translations
+
+Quarry ships a set of `i18n:*` commands for inspecting translation coverage. The base locale is always `en`.
+
+```bash
+# Audit non-en locales for missing or extra keys (exit code 1 on issues, CI-friendly)
+npx quarry i18n:check
+npx quarry i18n:check --locale=fr --prefix=common
+
+# Coverage dashboard per locale
+npx quarry i18n:stats
+
+# List keys with per-locale coverage; --values shows translated strings
+npx quarry i18n:list --locale=fr --values
+
+# Search keys or values by substring; --keys-only skips value matching
+npx quarry i18n:search email --keys-only
+
+# Show namespaces with key counts; --depth drills into sub-namespaces
+npx quarry i18n:namespaces --depth=2
+
+# Find keys that share identical translation values
+npx quarry i18n:duplicates
+```
+
+See [I18n](/integrations/i18n/) for the translation system these commands operate on.
+
## How it works
When you run `npx quarry`, the CLI:
@@ -259,7 +441,7 @@ When you run `npx quarry`, the CLI:
3. Discovers all `Command` subclasses registered as providers
4. Parses command signatures and delegates to the matched command's `handle()` method
-This means your commands have full access to dependency injection, services, and Cloudflare bindings — just like your HTTP handlers.
+This means your commands have full access to dependency injection, services, and Cloudflare bindings, just like your HTTP handlers.
## Testing commands
diff --git a/src/content/docs/core-concepts/versioning.mdx b/src/content/docs/core-concepts/versioning.mdx
index 9baf9c8..cc40531 100644
--- a/src/content/docs/core-concepts/versioning.mdx
+++ b/src/content/docs/core-concepts/versioning.mdx
@@ -12,7 +12,6 @@ Stratal supports URI-based API versioning. When enabled, controller routes are a
Pass a `versioning` option to the `Stratal` constructor:
```typescript
-import 'reflect-metadata'
import { Stratal } from 'stratal'
import { AppModule } from './app.module'
@@ -129,33 +128,29 @@ A controller at `/users` with version `'1'` now registers at `/api/v1/users` ins
## Middleware with versioning
-When targeting specific routes in middleware configuration, you can use the `version` field in route info objects to match versioned paths:
+To scope middleware to a specific version, group the relevant controllers and set the version on the group with `version()`:
```typescript
import { Module } from 'stratal/module'
-import { MiddlewareConfigurable, MiddlewareConsumer } from 'stratal/middleware'
+import type { RouteConfigurable } from 'stratal/router'
+import { Router } from 'stratal/router'
import { AuthMiddleware } from './auth.middleware'
@Module({
controllers: [UsersV1Controller, UsersV2Controller],
providers: [AuthMiddleware],
})
-export class UsersModule implements MiddlewareConfigurable {
- configure(consumer: MiddlewareConsumer) {
- // Apply only to v1 users routes
- consumer
- .apply(AuthMiddleware)
- .forRoutes({ path: '/api/users', version: '1' })
-
- // Apply to multiple versions
- consumer
- .apply(AuthMiddleware)
- .forRoutes({ path: '/api/users', version: ['1', '2'] })
+export class UsersModule implements RouteConfigurable {
+ configureRoutes(router: Router): void {
+ // Apply only to the v1 users controller
+ router.group([UsersV1Controller], (v1) => {
+ v1.version('1').middleware(AuthMiddleware)
+ })
}
}
```
-Stratal resolves the version and prefix automatically — `{ path: '/api/users', version: '1' }` expands to match `/v1/api/users` and its sub-paths.
+Stratal resolves the version and prefix automatically, so `version('1')` matches `/v1/api/users` and its sub-paths.
## Reference
@@ -166,7 +161,7 @@ Passed to `ApplicationConfig.versioning`:
| Option | Type | Default | Description |
| ---------------- | ---------------------- | ------- | ------------------------------------------------------------ |
| `prefix` | `string` | `'v'` | Prefix for version segments (e.g., `'v'` → `/v1`, `'api/v'` → `/api/v1`) |
-| `defaultVersion` | `string \| string[]` | — | Version applied to controllers without an explicit `version` |
+| `defaultVersion` | `string \| string[]` | - | Version applied to controllers without an explicit `version` |
### ControllerOptions.version
@@ -174,9 +169,11 @@ Passed to `ApplicationConfig.versioning`:
| -------------------------------------------- | ------------------------------------------------------ |
| `string` | Single version (e.g., `'1'` → `/v1/...`) |
| `string[]` | Multiple versions (e.g., `['1', '2']`) |
-| `typeof VERSION_NEUTRAL` | Opt out of versioning — no prefix applied |
+| `typeof VERSION_NEUTRAL` | Opt out of versioning - no prefix applied |
-### RouteInfo.version (middleware)
+### Router.version() (middleware)
+
+Set on a `Router` scope or a `group()` to target versioned routes when configuring middleware:
| Type | Description |
| ---------------------- | ---------------------------------------------------------------- |
diff --git a/src/content/docs/framework/access-control.mdx b/src/content/docs/framework/access-control.mdx
new file mode 100644
index 0000000..6bc0cd5
--- /dev/null
+++ b/src/content/docs/framework/access-control.mdx
@@ -0,0 +1,200 @@
+---
+title: Access Control
+description: Role-based permissions for authenticated users - define resources and roles in one place, then enforce them with guards or service-level checks.
+---
+
+import { Aside } from '@astrojs/starlight/components';
+
+`@stratal/framework` ships an opt-in role-based access control system built on top of [Better Auth](https://www.better-auth.com/)'s admin plugin. You declare your resources and roles once with `createAccessControl()`, wire the result into `AuthModule`, and check permissions either through the `AuthGuard` or by injecting `AccessService` into your providers.
+
+## Defining roles and permissions
+
+Use `createAccessControl()` to declare every resource your app cares about and the actions each role can perform on it. Mark `resources` as `as const` so role permissions are type-checked against the declared action lists.
+
+```typescript
+// src/permissions.ts
+import { createAccessControl } from '@stratal/framework/access-control'
+
+export const permissions = createAccessControl({
+ resources: {
+ posts: ['create', 'read', 'update', 'delete'],
+ users: ['list', 'ban'],
+ admin: ['access'],
+ } as const,
+ roles: {
+ admin: {
+ posts: ['create', 'read', 'update', 'delete'],
+ users: ['list', 'ban'],
+ admin: ['access'],
+ },
+ editor: {
+ posts: ['create', 'read', 'update'],
+ },
+ user: {
+ posts: ['read'],
+ },
+ },
+})
+```
+
+The return value is `{ ac, roles }` - a Better Auth access-control descriptor that you can spread into both Stratal's `AuthModule` and Better Auth's `admin` plugin.
+
+## Wiring it into AuthModule
+
+Pass the descriptor as the `accessControl` option to `AuthModule.forRootAsync()`. Spread the same value into the Better Auth `admin` plugin so server-side admin endpoints share the same role definitions.
+
+```typescript
+import { Module } from 'stratal/module'
+import { DI_TOKENS } from 'stratal/di'
+import { AuthModule } from '@stratal/framework/auth'
+import { admin } from 'better-auth/plugins'
+import { permissions } from './permissions'
+
+@Module({
+ imports: [
+ AuthModule.forRootAsync({
+ inject: [DI_TOKENS.Database],
+ useFactory: (db) => ({
+ database: db,
+ plugins: [admin({ ...permissions })],
+ }),
+ accessControl: permissions,
+ }),
+ ],
+})
+export class AppModule {}
+```
+
+When `accessControl` is provided, `AuthModule` registers `AccessService` and adds the Stratal access-control plugin to Better Auth automatically.
+
+## Composing roles
+
+There is no implicit role hierarchy. To build a role on top of another, use `extendRole()` - it returns a new role with permissions from the parent merged with any additional permissions you specify.
+
+```typescript
+import { extendRole } from '@stratal/framework/access-control'
+import { permissions } from './permissions'
+
+const superAdminRole = extendRole(permissions.ac, permissions.roles.admin, {
+ users: ['list', 'ban', 'delete'] as const,
+})
+
+const finalPermissions = {
+ ...permissions,
+ roles: { ...permissions.roles, super_admin: superAdminRole },
+}
+```
+
+Pass `finalPermissions` to `AuthModule` instead of `permissions`. Overlapping resource keys are unioned - `extendRole` never overwrites a parent's actions.
+
+## Checking permissions in routes
+
+The simplest way to enforce a permission is to apply `AuthGuard` with a `permissions` option. Each entry is a `'resource:action'` string; arrays require the user to have *all* listed permissions.
+
+```typescript
+import { Controller, Route, type RouterContext } from 'stratal/router'
+import { UseGuards } from 'stratal/guards'
+import { AuthGuard } from '@stratal/framework/guards'
+
+@Controller('/posts')
+export class PostsController {
+ @Route({ name: 'posts.update' })
+ @UseGuards(AuthGuard({ permissions: 'posts:update' }))
+ update(ctx: RouterContext) {
+ return ctx.json({ ok: true })
+ }
+
+ @Route({ name: 'posts.destroy' })
+ @UseGuards(AuthGuard({ permissions: ['posts:delete', 'admin:access'] }))
+ destroy(ctx: RouterContext) {
+ return ctx.json({ ok: true })
+ }
+}
+```
+
+Apply the guard at the controller level when every route in a controller needs the same permission:
+
+```typescript
+@Controller('/admin')
+@UseGuards(AuthGuard({ permissions: 'admin:access' }))
+export class AdminController {
+ // ...
+}
+```
+
+See [Auth Guard](/framework/auth-guard/) for the full guard reference.
+
+## AccessService
+
+For checks inside a service or to assign roles programmatically, inject `AccessService` from the access-control package. It is request-scoped, so `currentUser*` methods read from the in-memory `AuthContext` and avoid extra database round-trips.
+
+```typescript
+import { Transient, inject } from 'stratal/di'
+import { AC_TOKENS, type AccessService } from '@stratal/framework/access-control'
+
+@Transient()
+export class PostPolicy {
+ constructor(
+ @inject(AC_TOKENS.AccessService) private readonly access: AccessService,
+ ) {}
+
+ canUpdate(): boolean {
+ return this.access.currentUserHasPermission({ posts: ['update'] })
+ }
+
+ async grantEditor(userId: string) {
+ await this.access.setUserRole(userId, ['editor'])
+ }
+}
+```
+
+### API
+
+| Method | Returns | Description |
+| --- | --- | --- |
+| `getCurrentUserRoles()` | `string[]` | Roles for the current request's user. Reads from `AuthContext`. |
+| `getCurrentUserPermissions()` | `Record` | Merged permission map across the current user's roles. |
+| `currentUserHasPermission(perms)` | `boolean` | Synchronous permission check for the current user. |
+| `getUserRoles(userId)` | `Promise` | Roles for any user. Falls back to the database when the user isn't the current request's user. |
+| `getPermissionsForUser(userId)` | `Promise>` | Merged permission map for a given user. |
+| `hasPermission(userId, perms)` | `Promise` | Permission check for an arbitrary user. |
+| `setUserRole(userId, role)` | `Promise` | Assign one role (`'admin'`) or many (`['editor', 'reviewer']`). Stored as a comma-separated string in `user.role`. |
+
+### Permission shape
+
+Permission objects map a resource name to the actions you require on that resource:
+
+```typescript
+{ posts: ['update', 'delete'] }
+```
+
+Use `'*'` to require *any* action on a resource - useful when you only care that the user has some level of access:
+
+```typescript
+access.currentUserHasPermission({ posts: ['*'] })
+```
+
+A user is granted access if **any** of their roles satisfies the requested permissions (OR across roles, AND across resources within a single role).
+
+## Multiple roles per user
+
+`setUserRole` accepts a single role or an array. Multiple roles are stored as a comma-separated string in the `user.role` column and evaluated independently - the user is granted access if any role permits the action.
+
+```typescript
+await access.setUserRole(userId, ['editor', 'reviewer'])
+// stored as 'editor,reviewer'
+```
+
+## Errors
+
+| Error class | HTTP status | When |
+| --- | --- | --- |
+| `InsufficientPermissionsError` | 403 | Authenticated user lacks the required permissions |
+
+Stratal's global error handler maps `InsufficientPermissionsError` to a localised JSON response. You can catch it explicitly when handling permission checks inside services.
+
+## Next steps
+
+- [Auth Guard](/framework/auth-guard/) for the guard-based enforcement reference.
+- [Auth](/framework/auth/) for configuring the underlying authentication layer.
+- [Guards](/guides/guards/) for writing your own guards.
diff --git a/src/content/docs/framework/auth-guard.mdx b/src/content/docs/framework/auth-guard.mdx
index da5cb43..a48718a 100644
--- a/src/content/docs/framework/auth-guard.mdx
+++ b/src/content/docs/framework/auth-guard.mdx
@@ -5,7 +5,7 @@ description: Protect routes with authentication and permission checks using the
import { Aside } from '@astrojs/starlight/components';
-The `AuthGuard` factory from `@stratal/framework/guards` creates guards that check authentication and optionally verify permissions. It integrates with `AuthContext` and `CasbinService` to provide both authentication-only and scope-based authorization.
+The `AuthGuard` factory from `@stratal/framework/guards` creates guards that check authentication and, optionally, permission. It works with `AuthContext` for authentication and with `AccessService` for permission checks, both supplied automatically when their respective modules are configured.
## Authentication only
@@ -13,7 +13,7 @@ Apply `AuthGuard()` without options to require authentication on a controller or
```typescript
import { Controller, IController, Route, RouterContext } from 'stratal/router'
-import { UseGuards } from 'stratal/router'
+import { UseGuards } from 'stratal/guards'
import { AuthGuard } from '@stratal/framework/guards'
@Controller('/api/profile')
@@ -27,47 +27,49 @@ export class ProfileController implements IController {
}
```
-When no scopes are provided, `AuthGuard` checks `AuthContext.isAuthenticated()`. If the user is not authenticated, it throws a `UserNotAuthenticatedError` (HTTP 401).
+When no permissions are provided, `AuthGuard` checks `AuthContext.isAuthenticated()`. If the user is not authenticated, it throws a `UserNotAuthenticatedError` (HTTP 401).
-## With permission scopes
+## With permission checks
-Pass a `scopes` array to check specific permissions via `CasbinService`:
+Pass a `permissions` option to enforce access-control checks alongside authentication. Each entry is a `'resource:action'` string:
```typescript
@Controller('/api/admin/users')
-@UseGuards(AuthGuard({ scopes: ['users:write'] }))
+@UseGuards(AuthGuard({ permissions: 'users:ban' }))
export class AdminUsersController implements IController {
@Route({ response: usersSchema })
index(ctx: RouterContext) {
- // only users with 'users:write' permission reach this handler
+ // only authenticated users with 'users:ban' permission reach this handler
return ctx.json({ users: [] })
}
}
```
-With scopes, `AuthGuard` first checks authentication (401 if not authenticated), then checks if the user has the required permission (403 if unauthorized).
+`permissions` accepts a single string or an array. When you pass an array, every listed permission must be granted:
+
+```typescript
+@UseGuards(AuthGuard({ permissions: ['posts:delete', 'admin:access'] }))
+```
+
+A bare resource without an action (e.g. `'admin'`) is treated as a wildcard: the user passes if they have any action defined for that resource.
+
+`AuthGuard` first verifies authentication (401 if not authenticated), then checks permissions through `AccessService` (403 if unauthorized). The permission check reads roles from `AuthContext`, so it doesn't trigger an extra database query.
## Per-route guards
-You can apply `AuthGuard` at the route level instead of the controller level:
+Apply `AuthGuard` at the route level instead of the controller level when only some routes need authorization:
```typescript
@Controller('/api/students')
export class StudentsController implements IController {
@Route({ response: studentsSchema })
- @UseGuards(AuthGuard({ scopes: ['students:read'] }))
+ @UseGuards(AuthGuard({ permissions: 'students:read' }))
index(ctx: RouterContext) {
// requires students:read permission
}
- @Route({ response: studentSchema })
- @UseGuards(AuthGuard({ scopes: ['students:read'] }))
- show(ctx: RouterContext) {
- // requires students:read permission
- }
-
@Route({ body: createStudentSchema, response: studentSchema })
- @UseGuards(AuthGuard({ scopes: ['students:write'] }))
+ @UseGuards(AuthGuard({ permissions: 'students:write' }))
create(ctx: RouterContext) {
// requires students:write permission
}
@@ -79,16 +81,21 @@ export class StudentsController implements IController {
| Error | HTTP Status | When |
| --- | --- | --- |
| `UserNotAuthenticatedError` | 401 | User is not authenticated |
-| `InsufficientPermissionsError` | 403 | User is authenticated but lacks required permissions |
+| `InsufficientPermissionsError` | 403 | User is authenticated but lacks the required permissions |
+
+Both errors are handled by Stratal's global error handler and produce JSON error responses with localised messages.
-Both errors are handled by Stratal's global error handler and produce appropriate JSON error responses.
+
## Prerequisites
-For authentication-only checks (no scopes), only [AuthModule](/framework/auth/) is required. When using scopes for permission checks, both [AuthModule](/framework/auth/) and [RbacModule](/framework/rbac/) must be configured.
+- [AuthModule](/framework/auth/) must be configured for any use of `AuthGuard`.
+- For permission checks, pass an `accessControl` option to `AuthModule.forRootAsync()` so that `AccessService` is registered. See [Access Control](/framework/access-control/).
## Next steps
- [Auth](/framework/auth/) for configuring authentication.
-- [RBAC](/framework/rbac/) for configuring roles and permissions.
-- [Guards](/guides/guards/) for the core guard system and custom guards.
+- [Access Control](/framework/access-control/) for declaring resources and roles.
+- [Guards](/guides/guards/) for the core guard system and writing custom guards.
diff --git a/src/content/docs/framework/auth.mdx b/src/content/docs/framework/auth.mdx
index 10847b1..0904439 100644
--- a/src/content/docs/framework/auth.mdx
+++ b/src/content/docs/framework/auth.mdx
@@ -3,7 +3,7 @@ title: Auth
description: Authentication with Better Auth, session management, and request-scoped auth context.
---
-import { Aside } from '@astrojs/starlight/components';
+import { Aside, Badge } from '@astrojs/starlight/components';
`@stratal/framework` integrates with [Better Auth](https://www.better-auth.com/) to provide authentication, session management, and a request-scoped `AuthContext` for accessing the authenticated user throughout your application.
@@ -79,8 +79,8 @@ export class ProfileService {
) {}
async getProfile() {
- const userId = this.auth.requireUserId()
- return this.usersRepository.findById(userId)
+ const user = this.auth.requireUser()
+ return this.usersRepository.findById(user.id)
}
}
```
@@ -90,11 +90,31 @@ export class ProfileService {
| Method | Return type | Description |
| --- | --- | --- |
| `isAuthenticated()` | `boolean` | Whether the current request has a valid session |
+| `getUser()` | `AuthUser \| undefined` | The authenticated user, or `undefined` |
+| `requireUser()` | `AuthUser` | The authenticated user. Throws `UserNotAuthenticatedError` if not authenticated |
| `getUserId()` | `string \| undefined` | The authenticated user's ID, or `undefined` |
| `requireUserId()` | `string` | The authenticated user's ID. Throws if not authenticated |
-| `getAuthContext()` | `AuthInfo` | The full authentication context object |
+| `getRole()` | `string \| undefined` | Raw role string from `user.role` (comma-separated for multiple roles) |
+| `getRoles()` | `string[]` | Roles parsed into an array. Returns `[]` when the user has no role |
+| `getAuthInfo()` | `AuthInfo` | The full authentication context object (`{ user }`). Throws `AuthError` if no user is authenticated |
| `clearAuthContext()` | `void` | Clears the authentication state |
+### Augmenting AuthUser
+
+`AuthUser` extends Better Auth's base user with `name` made optional. Augment it via TypeScript module declaration to match whatever `additionalFields` or plugins your Better Auth config returns:
+
+```typescript
+declare module '@stratal/framework/context' {
+ interface AuthUser {
+ firstName: string
+ lastName: string
+ role: string
+ }
+}
+```
+
+Once augmented, `auth.requireUser()` and `auth.getUser()` return the typed shape, and `auth.getRole()` is typed as `string`.
+
@@ -125,13 +145,67 @@ The `auth` property returns the configured Better Auth instance with full access
The `AuthModule` automatically registers two middleware components:
-1. **AuthContextMiddleware** — Creates an `AuthContext` instance in the request-scoped container for every request.
-2. **SessionVerificationMiddleware** — Verifies the session token from the request and populates `AuthContext` with the authenticated user's information.
+1. **AuthContextMiddleware**: Creates an `AuthContext` instance in the request-scoped container for every request.
+2. **SessionVerificationMiddleware**: Verifies the session token from the request and populates `AuthContext` with the authenticated user's information.
These middleware run before your controller methods, so `AuthContext` is always available and populated when your code executes.
+## Auth errors
+
+`AuthModule` translates Better Auth's API errors into typed `HttpException` subclasses from `@stratal/framework/auth`. Stratal's global error handler renders each one as a localised JSON response carrying the status code listed below, so you never have to inspect raw Better Auth error codes in your controllers.
+
+### Fresh-session requirement
+
+Sensitive operations (for example deleting an account, changing a password, or revoking sessions) require a session that was authenticated recently. When the current session is older than Better Auth's configured freshness window, the operation fails with a `FreshSessionRequiredError`.
+
+| Error | HTTP status |
+| --- | --- |
+| `FreshSessionRequiredError` | |
+
+
+
+### Full error reference
+
+Every error below extends `HttpException` and is thrown when the corresponding Better Auth condition occurs during a request handled by your auth controller.
+
+| Error | HTTP status | When |
+| --- | --- | --- |
+| `InvalidCredentialsError` | | Email and password combination is invalid |
+| `InvalidPasswordError` | | Supplied password is incorrect |
+| `SessionExpiredError` | | The session has expired |
+| `TokenExpiredError` | | A verification or reset token has expired |
+| `InvalidTokenError` | | A verification or reset token is invalid or has been used too many times |
+| `TokenRequiredError` | | A verification token is required but was not provided |
+| `FreshSessionRequiredError` | | The session is not fresh enough for a sensitive operation |
+| `EmailNotVerifiedError` | | The account's email address has not been verified |
+| `InvalidOriginError` | | The request origin is not allowed |
+| `UserNotFoundError` | | No user matches the request |
+| `UserEmailNotFoundError` | | The user has no email on record |
+| `AccountNotFoundError` | | No linked account matches the request |
+| `CredentialAccountNotFoundError` | | No password (credential) account exists for the user |
+| `ProviderNotFoundError` | | The requested social provider is not configured |
+| `AccountAlreadyExistsError` | | An account already exists for the email |
+| `SocialAccountLinkedError` | | The social account is already linked to a user |
+| `CannotUnlinkLastAccountError` | | The user's last remaining account cannot be unlinked |
+| `UserAlreadyHasPasswordError` | | The user already has a password set |
+| `EmailAlreadyVerifiedError` | | The email address is already verified |
+| `InvalidEmailError` | | The email address is malformed |
+| `PasswordTooShortError` | | The password is shorter than the minimum length |
+| `PasswordTooLongError` | | The password exceeds the maximum length |
+| `EmailCannotBeUpdatedError` | | The email address cannot be changed |
+| `EmailMismatchError` | | The supplied email does not match the expected one |
+| `IdTokenNotSupportedError` | | The provider does not support ID-token sign-in |
+| `InvalidCallbackUrlError` | | A callback or redirect URL is not allowed |
+| `AuthValidationFailedError` | | The request failed Better Auth's field validation |
+
+
+
## Next steps
- [Auth Guard](/framework/auth-guard/) for protecting routes with authentication checks.
-- [RBAC](/framework/rbac/) for role-based access control on top of auth.
+- [Access Control](/framework/access-control/) for role-based permissions on top of auth.
- [Database](/framework/database/) for configuring the database used by Better Auth.
diff --git a/src/content/docs/framework/database-events.mdx b/src/content/docs/framework/database-events.mdx
index e3deae2..406cb53 100644
--- a/src/content/docs/framework/database-events.mdx
+++ b/src/content/docs/framework/database-events.mdx
@@ -3,21 +3,26 @@ title: Database Events
description: Automatically emitted events for database operations with pattern matching and type-safe contexts.
---
-import { Aside } from '@astrojs/starlight/components';
+import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
-Stratal automatically emits events before and after every database operation. The `EventEmitterPlugin` is auto-registered by the `DatabaseModule` — no additional setup is required. These events integrate with the core [event system](/core-concepts/events/) and support the same pattern matching, priority, and blocking behavior.
+Stratal automatically emits events for every database operation. The `EventEmitterPlugin` is auto-registered by the `DatabaseModule`, so no additional setup is required. These events integrate with the core [event system](/core-concepts/events/) and support the same pattern matching, priority, and blocking behavior.
-## Event pattern
+Two families of events are emitted:
-Database events follow the pattern `{phase}.{Model}.{operation}`:
+- **Query events** (`before.{Model}.{operation}` / `after.{Model}.{operation}`) wrap the raw query and carry the operation arguments and result.
+- **Entity mutation events** (`entity.{Model}.{verb}`) carry full entity snapshots taken before and after the mutation.
+
+## Query event pattern
+
+Query events follow the pattern `{phase}.{Model}.{operation}`:
| Segment | Values | Example |
| --- | --- | --- |
| Phase | `before`, `after` | `before`, `after` |
| Model | Any model name from your schema | `User`, `Post` |
-| Operation | `create`, `update`, `delete`, etc. | `create` |
+| Operation | `create`, `update`, `delete`, `findMany`, `count`, etc. | `create` |
-A full event name looks like `after.User.create` or `before.Post.update`.
+A full event name looks like `after.User.create` or `before.Post.update`. These events fire for read operations too, such as `after.User.findMany`.
## Listening to database events
@@ -91,16 +96,16 @@ async onAnyAfterEvent(context: EventContext<'after'>) {
}
```
-## Event context
+## Query event context
-The event context is a discriminated union based on the event pattern:
+The query event context is a discriminated union based on the event pattern:
### Exact events (`after.User.create`)
```typescript
interface ExactEventContext {
data: UserCreateInput // before: mutable, after: readonly
- result: User // only available in after phase
+ result: User // only present in the after phase
}
```
@@ -136,7 +141,109 @@ interface PhaseWildcardContext {
```
+
+## Entity mutation events
+
+Query events carry the raw operation arguments and result, which are not always the shape you want to react to. A `delete` carries the `where` filter, an `update` carries only the changed fields, and a `createMany` carries an array. When you want to work with complete entity rows, listen for **entity mutation events** instead.
+
+Entity events follow the pattern `entity.{Model}.{verb}`:
+
+| Segment | Values | Example |
+| --- | --- | --- |
+| Model | Any model name from your schema | `User`, `Post` |
+| Verb | `created`, `updated`, `deleted` | `created` |
+
+A full event name looks like `entity.User.created` or `entity.Post.updated`. One event is emitted per affected row, so a bulk operation that touches many rows emits one entity event per row.
+
+### Entity event context
+
+Each entity event carries `model`, `action`, and a pair of full entity snapshots. Which snapshots are populated depends on the verb:
+
+```typescript
+// entity.User.created
+interface EntityCreatedContext {
+ model: 'User'
+ action: 'created'
+ before: undefined // nothing existed beforehand
+ after: User // the created row
+}
+
+// entity.User.updated
+interface EntityUpdatedContext {
+ model: 'User'
+ action: 'updated'
+ before: User // the row as it was before the update
+ after: User // the row after the update
+}
+
+// entity.User.deleted
+interface EntityDeletedContext {
+ model: 'User'
+ action: 'deleted'
+ before: User // the row as it was before deletion
+ after: undefined // nothing remains
+}
+```
+
+
+
+ ```typescript
+ @On('entity.User.created')
+ async onUserCreated(context: EventContext<'entity.User.created'>) {
+ const user = context.after
+ // index the new user, send a welcome email, etc.
+ }
+ ```
+
+
+ ```typescript
+ @On('entity.User.updated')
+ async onUserUpdated(context: EventContext<'entity.User.updated'>) {
+ const { before, after } = context
+ if (before.email !== after.email) {
+ // react to the email change with both snapshots in hand
+ }
+ }
+ ```
+
+
+ ```typescript
+ @On('entity.User.deleted')
+ async onUserDeleted(context: EventContext<'entity.User.deleted'>) {
+ const user = context.before
+ // clean up related records, archive the removed row, etc.
+ }
+ ```
+
+
+
+### Entity wildcards
+
+Entity events support the same hierarchical pattern matching as query events:
+
+| Pattern | Matches |
+| --- | --- |
+| `entity.User.created` | A specific model and verb |
+| `entity.User` | All mutations on the User model |
+| `entity.updated` | Every model that is updated |
+| `entity` | Every entity mutation across all models |
+
+```typescript
+@On('entity.User')
+async onAnyUserMutation(context: EventContext<'entity.User'>) {
+ // context.action is 'created', 'updated', or 'deleted'
+ // before / after are populated according to the action
+}
+```
+
+
+
+
## Blocking behavior
@@ -145,12 +252,13 @@ Database events follow the same blocking rules as core events:
- **`before.*`** events are always **blocking**. The database operation waits for all handlers to complete before proceeding.
- **`after.*`** events are **non-blocking** by default. Handlers run in the background via `waitUntil`.
+- **`entity.*`** events are **blocking** by default, the same as custom events.
-This means `before` handlers can validate or modify data before it reaches the database, while `after` handlers run without delaying the response.
+This means `before` handlers can validate or modify data before it reaches the database, while `after` handlers run without delaying the response. As with core events, you can override any default with the `blocking` option on `@On()`.
## Type-safe augmentation
-The framework automatically augments the core `CustomEventRegistry` with your database events based on the `StratalDatabase` schema augmentation. Once you've set up [database type augmentation](/framework/database/), your database events are automatically type-safe.
+The framework automatically augments the core `CustomEventRegistry` with both your query events and your entity mutation events, based on the `StratalDatabase` schema augmentation. Once you've set up [database type augmentation](/framework/database/), every database event name autocompletes and its context is fully typed. For entity events, `before` and `after` resolve to the full model type.
## Next steps
diff --git a/src/content/docs/framework/database.mdx b/src/content/docs/framework/database.mdx
index b65123d..6270f4f 100644
--- a/src/content/docs/framework/database.mdx
+++ b/src/content/docs/framework/database.mdx
@@ -147,7 +147,7 @@ With per-connection schemas, `DatabaseService<'main'>` only exposes models defin
### Per-connection schemas
-Each connection has its own independent `.zmodel` file — simply define each schema separately.
+Each connection has its own independent `.zmodel` file - simply define each schema separately.
**`db/main/schema.zmodel`**
@@ -193,25 +193,10 @@ model PageView {
## Plugins
-The `DatabaseModule` automatically registers the following plugins on every connection — no configuration needed:
+The `DatabaseModule` automatically registers the following plugins on every connection - no configuration needed:
-- **ErrorHandlerPlugin** — Transforms ZenStack errors into Stratal `ApplicationError` instances with appropriate HTTP status codes.
-- **EventEmitterPlugin** — Emits events before and after database operations, integrating with Stratal's [event system](/core-concepts/events/). See [Database Events](/framework/database-events/) for the full event pattern documentation.
-
-### SchemaSwitcherPlugin (opt-in)
-
-The only user-configurable plugin is `SchemaSwitcherPlugin`, which sets the PostgreSQL `search_path` for multi-tenant isolation:
-
-```typescript
-import { SchemaSwitcherPlugin } from '@stratal/framework/database'
-
-{
- name: 'main',
- schema: mainSchema,
- dialect: () => new PostgresDialect({ pool }),
- plugins: [new SchemaSwitcherPlugin()],
-}
-```
+- **ErrorHandlerPlugin** - Transforms ZenStack errors into Stratal `ApplicationError` instances with appropriate HTTP status codes.
+- **EventEmitterPlugin** - Emits events before and after database operations, integrating with Stratal's [event system](/core-concepts/events/). See [Database Events](/framework/database-events/) for the full event pattern documentation.
## Type-safe schema augmentation
@@ -232,7 +217,7 @@ declare module '@stratal/framework/database' {
}
```
-This provides full type safety — `DatabaseService` returns a client typed with the default connection's schema, while `DatabaseService<'analytics'>` returns a client typed with the analytics schema.
+This provides full type safety - `DatabaseService` returns a client typed with the default connection's schema, while `DatabaseService<'analytics'>` returns a client typed with the analytics schema.
## CLI commands
diff --git a/src/content/docs/framework/factories.mdx b/src/content/docs/framework/factories.mdx
index 6897b7f..79a243a 100644
--- a/src/content/docs/framework/factories.mdx
+++ b/src/content/docs/framework/factories.mdx
@@ -79,7 +79,7 @@ const factory = new UserFactory().admin().unverified()
## Building instances
-### make — without persistence
+### make - without persistence
`make()` returns a plain object with the factory's attributes. It does not touch the database:
@@ -98,7 +98,7 @@ const users = new UserFactory().count(5).makeMany()
// Array of 5 user attribute objects
```
-### create — with database persistence
+### create - with database persistence
`create()` inserts a record into the database and returns the created model:
diff --git a/src/content/docs/framework/overview.mdx b/src/content/docs/framework/overview.mdx
index 486972c..4205353 100644
--- a/src/content/docs/framework/overview.mdx
+++ b/src/content/docs/framework/overview.mdx
@@ -1,6 +1,6 @@
---
title: "@stratal/framework"
-description: Higher-level modules for database, auth, RBAC, factories, and guards built on the Stratal core.
+description: Higher-level modules for database, auth, access control, factories, and guards built on the Stratal core.
---
import { Aside } from '@astrojs/starlight/components';
@@ -19,10 +19,10 @@ The package is organized into sub-path exports so you only import what you need:
| Import path | Provides |
| --- | --- |
-| `@stratal/framework/database` | `DatabaseModule`, `@InjectDB`, `DatabaseSchemaRegistry`, plugins |
+| `@stratal/framework/database` | `DatabaseModule`, `@InjectDB`, `DATABASE_TOKENS`, plugins |
| `@stratal/framework/auth` | `AuthModule`, `AuthService` |
-| `@stratal/framework/context` | `AuthContext` |
-| `@stratal/framework/rbac` | `RbacModule`, `CasbinService` |
+| `@stratal/framework/context` | `AuthContext`, `AuthUser` |
+| `@stratal/framework/access-control` | `createAccessControl`, `AccessService`, `extendRole` |
| `@stratal/framework/guards` | `AuthGuard` factory |
| `@stratal/framework/factory` | `Factory` base class, `Sequence` |
@@ -32,8 +32,8 @@ The core `stratal` package provides the module system, DI container, router, eve
- **Database** uses the DI container for connection management and the event system for database events.
- **Auth** uses middleware and request-scoped providers from the core.
-- **RBAC** integrates with the DI container and request scope for per-request authorization.
-- **Guards** use the core guard system (`CanActivate`) with auth and RBAC checks.
+- **Access control** integrates with `AuthContext` so the current user's roles are available in every request without a database round-trip.
+- **Guards** use the core guard system (`CanActivate`) with auth and permission checks.
- **Factories** work with the database service for persistence.
You can use the core package without `@stratal/framework` if you don't need these features.
@@ -42,5 +42,5 @@ You can use the core package without `@stratal/framework` if you don't need thes
- [Database](/framework/database/) for ORM integration and multi-connection support.
- [Auth](/framework/auth/) for authentication with Better Auth.
-- [RBAC](/framework/rbac/) for role-based access control.
+- [Access Control](/framework/access-control/) for declaring resources, roles, and permissions.
- [Auth Guard](/framework/auth-guard/) for protecting routes with auth and permission checks.
diff --git a/src/content/docs/framework/rbac.mdx b/src/content/docs/framework/rbac.mdx
deleted file mode 100644
index 7881168..0000000
--- a/src/content/docs/framework/rbac.mdx
+++ /dev/null
@@ -1,139 +0,0 @@
----
-title: RBAC
-description: Role-based access control with Casbin, role hierarchies, permission enforcement, and database-backed policy storage.
----
-
-import { Aside } from '@astrojs/starlight/components';
-
-`@stratal/framework` provides role-based access control (RBAC) powered by [Casbin](https://casbin.org/). It supports role hierarchies, permission scopes, and database-backed policy storage via the ZenStack adapter.
-
-## Setup
-
-### Configure RbacModule
-
-```typescript
-import { Module } from 'stratal/module'
-import { RbacModule } from '@stratal/framework/rbac'
-
-@Module({
- imports: [
- RbacModule.forRoot({
- model: MY_RBAC_MODEL,
- defaultPolicies: [
- ['admin', 'users:*', '.*'],
- ['member', 'users:read', '.*'],
- ],
- roleHierarchy: [
- ['super_admin', 'admin'],
- ['admin', 'member'],
- ],
- }),
- ],
-})
-export class AppModule {}
-```
-
-### Configuration options
-
-| Option | Type | Description |
-| --- | --- | --- |
-| `model` | `string` | Casbin model definition string |
-| `defaultPolicies` | `string[][]` | Default policies seeded on initialization |
-| `roleHierarchy` | `[string, string][]` | Role inheritance pairs `[child, parent]` |
-
-## CasbinService
-
-`CasbinService` is a request-scoped service that provides the full Casbin authorization API. It automatically knows the current user from `AuthContext`.
-
-```typescript
-import { Transient, inject } from 'stratal/di'
-import { CasbinService } from '@stratal/framework/rbac'
-
-@Transient()
-export class PermissionsService {
- constructor(
- @inject(CasbinService) private readonly casbin: CasbinService,
- ) {}
-
- async canEditUsers(): Promise {
- return this.casbin.currentUserHasPermission('users:write', '.*')
- }
-}
-```
-
-### Permission checking
-
-| Method | Description |
-| --- | --- |
-| `hasPermission(userId, scope, action)` | Check if a user has a specific permission |
-| `currentUserHasPermission(scope, action)` | Check the current request's user |
-| `hasAnyPermission(userId, scopes[], action)` | Check if user has any of the listed permissions |
-| `currentUserHasAnyPermission(scopes[], action)` | Check current user for any of the permissions |
-
-### Role management
-
-| Method | Description |
-| --- | --- |
-| `addRoleForUser(userId, role)` | Assign a role to a user |
-| `deleteRoleForUser(userId, role)` | Remove a role from a user |
-| `deleteRolesForUser(userId)` | Remove all roles for a user |
-| `getRolesForUser(userId)` | Get all directly assigned roles |
-| `getImplicitRolesForUser(userId)` | Get all roles including inherited ones |
-| `hasRoleForUser(userId, role)` | Check if a user has a specific role |
-| `setRolesForUser(userId, roles[])` | Replace all roles for a user |
-| `getCurrentUserRoles()` | Get roles for the current request's user |
-| `currentUserHasRole(role)` | Check if current user has a role |
-| `getImplicitUsersForRole(role)` | Get all users with a role (direct + inherited) |
-
-### Role hierarchy
-
-| Method | Description |
-| --- | --- |
-| `addRoleInheritance(childRole, parentRole)` | Add a role inheritance relationship |
-| `deleteRoleInheritance(childRole, parentRole)` | Remove a role inheritance relationship |
-
-### Deletion
-
-| Method | Description |
-| --- | --- |
-| `deleteUser(userId)` | Remove all policies for a user |
-| `deleteRole(role)` | Remove a role and its policies |
-
-### Frontend support
-
-```typescript
-const permissions = await this.casbin.getPermissionsForUserAsCasbinJs(userId)
-// Returns permissions in a format compatible with casbin.js for frontend enforcement
-```
-
-## Role hierarchies
-
-Role hierarchies let child roles inherit all permissions of parent roles:
-
-```typescript
-RbacModule.forRoot({
- model: MY_RBAC_MODEL,
- roleHierarchy: [
- ['super_admin', 'admin'], // super_admin inherits all admin permissions
- ['admin', 'member'], // admin inherits all member permissions
- ],
-})
-```
-
-With this hierarchy, a `super_admin` has the permissions of `admin` and `member` in addition to any permissions directly assigned to `super_admin`.
-
-Use `getImplicitRolesForUser()` to see the full role chain including inherited roles.
-
-## Database-backed policies
-
-Policies are persisted in the database via a ZenStack adapter. The `defaultPolicies` and `roleHierarchy` you define in `RbacModule.forRoot()` are seeded automatically on initialization.
-
-
-
-## Next steps
-
-- [Auth Guard](/framework/auth-guard/) for protecting routes with RBAC checks.
-- [Auth](/framework/auth/) for the authentication layer that provides user identity.
-- [Guards](/guides/guards/) for the core guard system.
diff --git a/src/content/docs/framework/seeders.mdx b/src/content/docs/framework/seeders.mdx
index 72bdbb9..885d905 100644
--- a/src/content/docs/framework/seeders.mdx
+++ b/src/content/docs/framework/seeders.mdx
@@ -127,7 +127,7 @@ export class DatabaseSeeder extends Seeder {
## Auto-discovery
-Seeders are automatically discovered from the module tree. Any class that extends `Seeder` and is listed in a module's `providers` array will be found by the CLI. Only bare class providers are scanned — value, factory, and existing providers are skipped.
+Seeders are automatically discovered from the module tree. Any class that extends `Seeder` and is listed in a module's `providers` array will be found by the CLI. Bare class and `useClass` providers are scanned; value and factory providers are skipped.
## How it works
diff --git a/src/content/docs/getting-started/ai.mdx b/src/content/docs/getting-started/ai.mdx
index 0cf22ea..c79c090 100644
--- a/src/content/docs/getting-started/ai.mdx
+++ b/src/content/docs/getting-started/ai.mdx
@@ -71,9 +71,9 @@ This starts an MCP server using the stdio transport. The server registers each A
Each OpenAPI operation is converted into an MCP tool:
-- **Tool name** — uses the route's `operationId` if set, otherwise generates one from the method and path (e.g., `get_api_users`)
-- **Description** — combines the route's `summary` and `description`
-- **Input schema** — built from path parameters, query parameters, and request body:
+- **Tool name** - uses the route's `operationId` if set, otherwise generates one from the method and path (e.g., `get_api_users`)
+- **Description** - combines the route's `summary` and `description`
+- **Input schema** - built from path parameters, query parameters, and request body:
| Source | Input key format | Example |
| --- | --- | --- |
@@ -100,7 +100,7 @@ The MCP server exposes the full OpenAPI specification as a resource at `openapi:
## Client installation
-Configure your AI agent to connect to the Stratal MCP server. Each example assumes your project is at `/path/to/your/project` — replace this with the actual path to your Stratal application.
+Configure your AI agent to connect to the Stratal MCP server. Each example assumes your project is at `/path/to/your/project` - replace this with the actual path to your Stratal application.
@@ -218,14 +218,14 @@ Add to your project's `opencode.json`:
## Stratal skills
-Stratal provides AI skills that teach your AI agent the framework's conventions, patterns, and APIs. When installed, your AI agent automatically applies Stratal best practices — correct decorators, import paths, module structure, and more.
+Stratal provides AI skills that teach your AI agent the framework's conventions, patterns, and APIs. When installed, your AI agent automatically applies Stratal best practices - correct decorators, import paths, module structure, and more.
### What skills provide
-- **Framework rules** — patterns that prevent runtime failures (DI decorators, import paths, ESM requirements)
-- **Reference guides** — modules, routing, DI, events, queues, cron, database, auth, RBAC, testing, and more
-- **Project scaffold** — templates for bootstrapping new Stratal projects
-- **Gotchas** — Cloudflare Workers constraints and common pitfalls
+- **Framework rules** - patterns that prevent runtime failures (DI decorators, import paths, ESM requirements)
+- **Reference guides** - modules, routing, DI, events, queues, cron, database, auth, access control, testing, and more
+- **Project scaffold** - templates for bootstrapping new Stratal projects
+- **Gotchas** - Cloudflare Workers constraints and common pitfalls
### Installing skills
diff --git a/src/content/docs/getting-started/incremental-adoption.mdx b/src/content/docs/getting-started/incremental-adoption.mdx
index 0fd9c93..c9f9332 100644
--- a/src/content/docs/getting-started/incremental-adoption.mdx
+++ b/src/content/docs/getting-started/incremental-adoption.mdx
@@ -22,9 +22,9 @@ With the skill installed, your AI agent can:
- Analyze your existing Hono routes and identify candidates for migration
- Generate Stratal modules, controllers, and services that match your current route structure
- Wire up dependency injection, queue consumers, and cron jobs correctly
-- Handle the subtleties — async initialization, DI container scope, middleware ordering, and error handling boundaries
+- Handle the subtleties - async initialization, DI container scope, middleware ordering, and error handling boundaries
-As you migrate routes into Stratal, they're automatically included in the OpenAPI spec and can be exposed as [MCP](https://modelcontextprotocol.io/) tools via `npx quarry mcp:serve` — giving AI agents direct access to your newly migrated API endpoints.
+As you migrate routes into Stratal, they're automatically included in the OpenAPI spec and can be exposed as [MCP](https://modelcontextprotocol.io/) tools via `npx quarry mcp:serve` - giving AI agents direct access to your newly migrated API endpoints.
See [AI Integration](/getting-started/ai/) for the full setup, including MCP client configuration and filtering options.
@@ -77,7 +77,7 @@ All routes defined in your Stratal modules will now be available under the `/api
## Wiring up queues and cron jobs
-When you mount Stratal as a sub-app with `app.route()`, only HTTP routing is connected. Queue consumers and scheduled (cron) handlers are **not** part of the Hono routing layer — they must be explicitly forwarded from your worker's export.
+When you mount Stratal as a sub-app with `app.route()`, only HTTP routing is connected. Queue consumers and scheduled (cron) handlers are **not** part of the Hono routing layer - they must be explicitly forwarded from your worker's export.
The code example above shows the full pattern: export an object with `fetch`, `queue`, and `scheduled` handlers instead of exporting `app` directly.
@@ -96,7 +96,7 @@ export default {
If you skip the `queue` or `scheduled` exports, any queue consumers or cron jobs defined in your Stratal modules will silently do nothing.
@@ -105,11 +105,11 @@ If you skip the `queue` or `scheduled` exports, any queue consumers or cron jobs
A practical migration strategy is to move one feature at a time into Stratal modules while keeping everything else in your existing Hono app:
-1. **Pick a feature** — Start with a self-contained feature like user management or billing.
-2. **Create a Stratal module** — Move the routes, services, and logic into a Stratal module with controllers and providers.
-3. **Mount under a prefix** — Use `app.route()` to mount Stratal at the same path your existing routes used.
-4. **Remove old routes** — Delete the original Hono handlers for the migrated feature.
-5. **Repeat** — Continue moving features until your entire app runs on Stratal.
+1. **Pick a feature** - Start with a self-contained feature like user management or billing.
+2. **Create a Stratal module** - Move the routes, services, and logic into a Stratal module with controllers and providers.
+3. **Mount under a prefix** - Use `app.route()` to mount Stratal at the same path your existing routes used.
+4. **Remove old routes** - Delete the original Hono handlers for the migrated feature.
+5. **Repeat** - Continue moving features until your entire app runs on Stratal.
```typescript
import { Hono } from 'hono'
@@ -142,7 +142,7 @@ Over time, you can shrink the legacy section and expand the Stratal-managed rout
### Async initialization
-The `stratal.hono` getter returns a `Promise` because Stratal bootstraps asynchronously — it resolves modules, builds the DI container, and registers routes during initialization. You must `await` it before mounting.
+The `stratal.hono` getter returns a `Promise` because Stratal bootstraps asynchronously - it resolves modules, builds the DI container, and registers routes during initialization. You must `await` it before mounting.