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.