@zerotal/arch 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,1008 @@
1
+ ---
2
+ title: Routing
3
+ description: Explicit routes, groups, file-based routes, model binding, domains, and testing — the whole routing surface on one page.
4
+ ---
5
+
6
+ # Routing
7
+
8
+ The router maps incoming HTTP requests to the controller, view, or closure that
9
+ should handle them, and is where you declare your application's URLs. Zerotal
10
+ offers two complementary styles — **explicit** routes declared in route files and
11
+ **file-based** routes discovered automatically from a directory tree. Both
12
+ register into the same `Router` singleton and can be mixed freely in one app.
13
+
14
+ Routing ships inside `@zerotal/core`, so there is no package to install or
15
+ provider to register — the `Router` is available as soon as your app boots.
16
+
17
+ ## Getting Started
18
+
19
+ ```typescript
20
+ // routes/index.ts
21
+ import { Router, route } from "zerotal";
22
+ ```
23
+
24
+ ## Basic usage
25
+
26
+ Register a `GET` route to a controller action, or to an inline closure:
27
+
28
+ ```typescript
29
+ // routes/index.ts
30
+ import { Router } from "zerotal";
31
+
32
+ Router.get("/users", UserController, "index");
33
+ Router.get("/", (ctx) => ctx.html`<h1>Home</h1>`);
34
+ ```
35
+
36
+ A `GET /users` request now runs `UserController.index`, and `GET /` returns the
37
+ inline HTML.
38
+
39
+ ## Which routing style should I use?
40
+
41
+ - **Explicit routes** (`routes/index.ts`) — best when you want every URL visible
42
+ in one place, fine-grained control over names and ordering, or resource/group
43
+ helpers. Start here.
44
+ - **File-based routes** (a directory tree) — best for large apps where the URL
45
+ map mirrors the filesystem, or for page-heavy frontends. Opt in per app.
46
+
47
+ The two compose: file routes register first during boot, then explicit routes run
48
+ and take precedence on duplicate `METHOD + path` keys. See
49
+ [route registration order](#route-registration-order) for the full precedence
50
+ rules.
51
+
52
+ ## Explicit routes
53
+
54
+ ### HTTP verbs
55
+
56
+ ```typescript
57
+ // routes/index.ts
58
+ Router.get("/users", UserController, "index");
59
+ Router.post("/users", UserController, "store");
60
+ Router.put("/users/:id", UserController, "update");
61
+ Router.patch("/users/:id", UserController, "update");
62
+ Router.delete("/users/:id", UserController, "destroy");
63
+ ```
64
+
65
+ Each call returns a `RouteRegistration` you can chain:
66
+
67
+ ```typescript
68
+ // routes/index.ts
69
+ Router.get("/posts/:slug", PostController, "show").name("posts.show").bind("post", Post);
70
+ ```
71
+
72
+ #### Per-route middleware
73
+
74
+ Pass middleware classes as the fourth argument:
75
+
76
+ ```typescript
77
+ // routes/index.ts
78
+ Router.get("/dashboard", DashboardController, "index", [AuthMiddleware]);
79
+ Router.post("/posts", PostController, "store", [AuthMiddleware, ThrottleMiddleware]);
80
+ ```
81
+
82
+ #### Inline closure handlers
83
+
84
+ Every verb method also accepts a closure instead of a `Controller`/action pair —
85
+ handy for small endpoints that don't warrant a controller. The handler receives
86
+ the same request `HttpContext` a controller action does; raw route params and
87
+ resolved model bindings live on `ctx.params`:
88
+
89
+ ```typescript
90
+ // routes/index.ts
91
+ import { Router, type HttpContext } from "zerotal";
92
+
93
+ Router.get("/", (ctx) => ctx.html`<h1>Home</h1>`);
94
+ Router.get("/health", (ctx) => ctx.json({ ok: true }));
95
+
96
+ // Raw route params arrive on ctx.params — type them with HttpContext<{ ... }>:
97
+ Router.get("/posts/:slug", (ctx: HttpContext<{ slug: string }>) =>
98
+ ctx.json({ slug: ctx.params.slug }),
99
+ );
100
+ ```
101
+
102
+ Middleware is passed as the **third** argument in closure form (there's no action
103
+ name), and the returned `RouteRegistration` still chains `.name()` / `.bind()`:
104
+
105
+ ```typescript
106
+ // routes/index.ts
107
+ Router.get("/admin", (ctx) => ctx.json({ ok: true }), [AuthMiddleware]);
108
+ Router.get("/posts/:post", (ctx: HttpContext<{ post: Post }>) =>
109
+ ctx.json({ post: ctx.params.post }),
110
+ ).bind("post", Post);
111
+ ```
112
+
113
+ > **Note** — Zerotal distinguishes the two forms by the third argument: a string is
114
+ > treated as a controller action; anything else (a middleware array or nothing)
115
+ > means the second argument is a closure handler.
116
+
117
+ ### Resource routes
118
+
119
+ `Router.resource()` registers all seven RESTful actions in one call:
120
+
121
+ ```typescript
122
+ // routes/index.ts
123
+ Router.resource("posts", PostController);
124
+ ```
125
+
126
+ | Method | Path | Action |
127
+ | -------- | ----------------- | --------- |
128
+ | `GET` | `/posts` | `index` |
129
+ | `GET` | `/posts/create` | `create` |
130
+ | `POST` | `/posts` | `store` |
131
+ | `GET` | `/posts/:id` | `show` |
132
+ | `GET` | `/posts/:id/edit` | `edit` |
133
+ | `PUT` | `/posts/:id` | `update` |
134
+ | `DELETE` | `/posts/:id` | `destroy` |
135
+
136
+ `PATCH /:id` is also registered and maps to `update`, so both `PUT` and `PATCH`
137
+ are accepted.
138
+
139
+ #### Filtering actions
140
+
141
+ ```typescript
142
+ // routes/index.ts
143
+ // Register only these actions
144
+ Router.resource("photos", PhotoController).only(["index", "show"]);
145
+
146
+ // Register everything except these
147
+ Router.resource("tags", TagController).except(["create", "edit"]);
148
+ ```
149
+
150
+ #### Resource middleware
151
+
152
+ ```typescript
153
+ // routes/index.ts
154
+ Router.resource("comments", CommentController, [AuthMiddleware]);
155
+ ```
156
+
157
+ ### View routes
158
+
159
+ Register a GET route that renders a `@zerotal/core` JSX component directly —
160
+ no controller class needed for simple pages:
161
+
162
+ ```typescript
163
+ // routes/index.ts
164
+ import { AboutPage } from "../resources/views/AboutPage.tsx";
165
+ import { DashboardPage } from "../resources/views/DashboardPage.tsx";
166
+
167
+ // Static props — evaluated once at registration
168
+ Router.view("/about", AboutPage, { title: "About Us" });
169
+
170
+ // Dynamic props — factory is called on each request
171
+ Router.view("/dashboard", DashboardPage, (ctx) => ({
172
+ user: ctx.user,
173
+ greeting: `Hello, ${ctx.user?.name ?? "guest"}`,
174
+ }));
175
+
176
+ // No props
177
+ Router.view("/privacy", PrivacyPage);
178
+
179
+ // With middleware
180
+ Router.view("/settings", SettingsPage, (ctx) => ({ user: ctx.user }), [AuthMiddleware]);
181
+ ```
182
+
183
+ The route chains `.name()` and `.withLayout()`:
184
+
185
+ ```typescript
186
+ // routes/index.ts
187
+ Router.view("/about", AboutPage).name("about").withLayout(AppLayout);
188
+ ```
189
+
190
+ ### Static file serving
191
+
192
+ Serve a local directory under a URL prefix:
193
+
194
+ ```typescript
195
+ // routes/index.ts
196
+ Router.static("/assets", "./public/assets");
197
+ Router.static("/uploads", "public/uploads");
198
+
199
+ // With cache headers
200
+ Router.static("/assets", "./public", {
201
+ headers: { "Cache-Control": "public, max-age=31536000, immutable" },
202
+ });
203
+ ```
204
+
205
+ Any `GET /assets/logo.svg` resolves to `./public/assets/logo.svg`, served with
206
+ Bun's native file streaming. Returns 404 for missing files. By default every file
207
+ is pre-registered at boot as a static `Response` (zero JS per request); pass
208
+ `eager: false` to fall back to a per-request lookup.
209
+
210
+ ### Markdown file serving
211
+
212
+ Serve a directory of `.md` files as rendered HTML pages:
213
+
214
+ ```typescript
215
+ // routes/index.ts
216
+ Router.markdown("/docs", "./docs");
217
+
218
+ // With parser options and a fallback title
219
+ Router.markdown("/docs", "./docs", {
220
+ title: "Zerotal Docs",
221
+ headings: { ids: true },
222
+ });
223
+ ```
224
+
225
+ URL mapping: `GET /docs/orm` → `./docs/orm.md` (also tries `./docs/orm/index.md`
226
+ for bare directory paths). Uses `Bun.markdown.html()` with GFM extensions
227
+ (tables, strikethrough, tasklists, autolinks) enabled by default.
228
+
229
+ ### Raw routes
230
+
231
+ Register a handler that receives the raw `Request` and bypasses the entire global
232
+ middleware pipeline — no `HttpContext`, no session, no auth. Useful for internal
233
+ health checks or asset endpoints:
234
+
235
+ ```typescript
236
+ // routes/index.ts
237
+ Router.raw("GET", "/__ping", () => new Response("pong"));
238
+ Router.raw("GET", "/health", async () => {
239
+ const ok = await db
240
+ .query("SELECT 1")
241
+ .then(() => true)
242
+ .catch(() => false);
243
+ return Response.json({ ok });
244
+ });
245
+ ```
246
+
247
+ > **Warning** — Raw routes skip every middleware (session, CSRF, auth). Only use
248
+ > them for endpoints that must not run the pipeline. They are compiled last and
249
+ > take precedence over same-path pipeline routes.
250
+
251
+ ### Flow routes
252
+
253
+ Added by the `@zerotal/flow` package:
254
+
255
+ ```typescript
256
+ // routes/index.ts
257
+ import { Router } from "zerotal";
258
+
259
+ Router.flow("/dashboard", DashboardPage);
260
+ Router.flow("/chat", ChatPage, [AuthMiddleware]);
261
+ ```
262
+
263
+ See the [Flow](/docs/flow) guide for full details.
264
+
265
+ ## Route groups
266
+
267
+ Groups apply a shared prefix and/or middleware stack to a set of routes. Groups
268
+ nest — prefix and middleware accumulate.
269
+
270
+ ```typescript
271
+ // routes/index.ts
272
+ Router.group({ prefix: "/api/v1" }, () => {
273
+ Router.get("/users", UserController, "index");
274
+ Router.post("/users", UserController, "store");
275
+ // → GET /api/v1/users, POST /api/v1/users
276
+ });
277
+ ```
278
+
279
+ ```typescript
280
+ // routes/index.ts
281
+ Router.group({ prefix: "/api/v1", middleware: AuthMiddleware }, () => {
282
+ Router.resource("posts", PostController);
283
+ });
284
+ ```
285
+
286
+ A group is bookkeeping applied while its callback runs, not a runtime wrapper.
287
+ Routes register themselves with the accumulated prefix and middleware already
288
+ baked in, so grouping costs nothing per request. The surrounding state is restored
289
+ even when the callback throws, so one broken group cannot leak its prefix into the
290
+ routes declared after it.
291
+
292
+ ### Named middleware groups
293
+
294
+ Define a group of middleware classes under a string key, then reference it by
295
+ name:
296
+
297
+ ```typescript
298
+ // in a ServiceProvider or bootstrap
299
+ Router.middlewareGroup("api", [ThrottleMiddleware, JsonMiddleware]);
300
+ Router.middlewareGroup("web", [SessionMiddleware, CsrfMiddleware]);
301
+
302
+ // Reference by name
303
+ Router.group({ prefix: "/api", middleware: "api" }, () => {
304
+ Router.resource("posts", PostController);
305
+ });
306
+
307
+ // Mix names and classes
308
+ Router.group({ middleware: ["web", AuthMiddleware] }, () => {
309
+ Router.get("/dashboard", DashboardController, "index");
310
+ });
311
+ ```
312
+
313
+ Naming a stack once and referring to it keeps the definition in one place, which
314
+ matters most when the stack changes: adding a middleware to the `web` group applies
315
+ it everywhere that group is used instead of requiring an edit at every call site.
316
+
317
+ ### Nested groups
318
+
319
+ ```typescript
320
+ // routes/index.ts
321
+ Router.group({ prefix: "/admin" }, () => {
322
+ Router.group({ middleware: [AuthMiddleware, AdminMiddleware] }, () => {
323
+ Router.get("/dashboard", AdminController, "dashboard").name("admin.dashboard");
324
+ Router.resource("users", AdminUserController);
325
+ });
326
+ });
327
+ ```
328
+
329
+ The three options compose differently as groups nest, which is worth knowing before
330
+ relying on it:
331
+
332
+ | Option | Nesting behaviour |
333
+ | ------------ | -------------------------------------------------------------------- |
334
+ | `prefix` | Concatenates outer then inner — `/admin` + `/users` → `/admin/users` |
335
+ | `middleware` | Appends, outermost first, so the outer stack runs earlier |
336
+ | `domain` | Replaces — an inner `domain` overrides the outer one entirely |
337
+
338
+ Because middleware order follows nesting depth, an outer `AuthMiddleware` runs
339
+ before an inner `AdminMiddleware`. Put the checks that should fail fastest — or
340
+ that the inner ones depend on, such as resolving the current user — in the outer
341
+ group.
342
+
343
+ ### Domain & subdomain routing
344
+
345
+ The `domain` option scopes a group of routes to a specific host. Dynamic `:label`
346
+ segments are captured and exposed on the context via `ctx.subdomains`.
347
+
348
+ ```typescript
349
+ // routes/index.ts
350
+ import { Router } from "zerotal";
351
+
352
+ // Static host
353
+ Router.group({ domain: "admin.app.com" }, () => {
354
+ Router.get("/", AdminController, "dashboard");
355
+ });
356
+
357
+ // Dynamic subdomain — captured as ctx.subdomains.tenant
358
+ Router.group({ domain: ":tenant.app.com" }, () => {
359
+ Router.get("/", DashboardController, "index");
360
+ });
361
+ ```
362
+
363
+ ```typescript
364
+ // app/controllers/DashboardController.ts
365
+ class DashboardController {
366
+ index(ctx: HttpContext) {
367
+ ctx.subdomains; // { tenant: 'acme' } for acme.app.com
368
+ ctx.subdomain("tenant"); // 'acme' (or null)
369
+ }
370
+ }
371
+ ```
372
+
373
+ Routes that share a path across different hosts are dispatched by host at request
374
+ time.
375
+
376
+ > **Warning** — Register specific domains before wildcards — the first matching
377
+ > host wins, just like route ordering. A domain-only path returns `404` for a
378
+ > host that doesn't match and has no plain (domain-less) fallback. `domain`
379
+ > composes with `prefix` and `middleware` in the same group.
380
+
381
+ #### With multi-tenancy
382
+
383
+ `@zerotal/tenancy`'s `SubdomainResolver` resolves the tenant _model_ from the same
384
+ subdomain a domain group captures, so the two compose directly:
385
+
386
+ ```typescript
387
+ // routes/index.ts
388
+ Router.group({ domain: ":tenant.app.com" }, () => {
389
+ Router.get("/dashboard", DashboardController, "index");
390
+ // ctx.subdomains.tenant === the resolved tenant slug; TenantContext holds the model
391
+ });
392
+ ```
393
+
394
+ Configuring the resolver is covered in [Multi-tenancy](/docs/tenancy).
395
+
396
+ ## Named routes
397
+
398
+ Assign a name and generate URLs from it:
399
+
400
+ ```typescript
401
+ // routes/index.ts
402
+ Router.get("/posts/:slug", PostController, "show").name("posts.show");
403
+
404
+ // Generate the URL with route()
405
+ route("posts.show", { slug: "hello-world" }); // → '/posts/hello-world'
406
+ route("search", {}, { q: "zerotal", page: 2 }); // → '/search?q=zerotal&page=2'
407
+ ```
408
+
409
+ `route(name, params, query)` takes three arguments, and each one means one thing:
410
+ params fill `:segments`, query values become the query string. A key in `params`
411
+ that matches no segment is an error, not a query param.
412
+
413
+ Naming is what lets a path change without a sweep through templates: the URL lives
414
+ in one place and every link asks for it by name. A dotted convention —
415
+ `posts.show`, `admin.users.edit` — keeps names sorted and readable as the table
416
+ grows, and mirrors the grouping the routes already sit in.
417
+
418
+ > **Warning** — `route()` throws if the route name is unknown, a required param
419
+ > is missing, or a param matches no `:segment`.
420
+
421
+ Throwing rather than returning a broken string is deliberate: a typo surfaces the
422
+ first time the code runs instead of shipping a link to a 404. Run
423
+ [`bun zt route:list`](#the-routelist-command) to see every registered name.
424
+
425
+ A catch-all route (`/docs/*`, or `[...slug].ts` under file-based routing) reaches
426
+ the router as `*` — the segment's name is gone by then — so its value is passed
427
+ under the `"*"` key, as a path or as segments:
428
+
429
+ ```typescript
430
+ route("docs.show", { "*": "guides/intro" }); // → '/docs/guides/intro'
431
+ route("docs.show", { "*": ["guides", "intro"] }); // → '/docs/guides/intro'
432
+ ```
433
+
434
+ ### Typed route names
435
+
436
+ Run `bun zt route:types` and the names above stop being strings the compiler has
437
+ to take on faith:
438
+
439
+ ```typescript
440
+ route("psots.show", { slug }); // ✗ not assignable to RouteName
441
+ route("posts.show"); // ✗ Expected 2 arguments, but got 1
442
+ route("posts.show", {}); // ✗ Property 'slug' is missing
443
+ route("posts.show", { slugg: "x" }); // ✗ Did you mean to write 'slug'?
444
+ ```
445
+
446
+ The command boots the app, reads the routes it registered, and writes
447
+ `types/routes.generated.ts` — a name → pattern map plus a one-line augmentation
448
+ that `route()` reads:
449
+
450
+ ```typescript
451
+ export const ROUTES = {
452
+ home: "/",
453
+ "posts.show": "/posts/:slug",
454
+ } as const;
455
+ ```
456
+
457
+ It boots rather than scanning the routes directory because a route name comes
458
+ from three places and only one of them is a file path: the file-router's
459
+ convention, a route file's `export const meta = { GET: { name } }`, and
460
+ programmatic registrations — including those a package's provider makes. A
461
+ scanner would see the first and quietly miss the other two. Params are derived
462
+ from the pattern, so adding a segment changes one string and every call site
463
+ updates with it.
464
+
465
+ **Commit the generated file.** `zt dev` rewrites it on every restart, so it stays
466
+ current while you work, but editors and CI need it without booting the app. In CI:
467
+
468
+ ```bash
469
+ bun zt route:types --check # fails when the file no longer matches the routes
470
+ ```
471
+
472
+ Until you run the command, the registry is empty and `route()` behaves exactly as
473
+ it always did — every name accepted, nothing checked.
474
+
475
+ **When the name is not known at compile time** — read from config, chosen by a
476
+ package — use the escape hatch, which does the same work with no checking:
477
+
478
+ ```typescript
479
+ route.dynamic(config("app.home_route"), { id });
480
+ ```
481
+
482
+ It is a separate function rather than an overload on `route()` for a reason: an
483
+ overload that accepts every string is matched by every string, and would make
484
+ the checked signature above decorative.
485
+
486
+ Typed names flow through the helpers built on `route()` too — `redirect().to()`,
487
+ `Url.route()`, `Uri.route()`, and Flow's `redirectRoute()`.
488
+
489
+ ### route() in the browser
490
+
491
+ The server's `route()` reads the live router, which only exists in the server
492
+ process. In a browser bundle, import it from `zerotal/routes` instead and
493
+ hand it the generated table once, at your entry point:
494
+
495
+ ```typescript
496
+ // resources/js/app.js
497
+ import { defineRoutes } from "zerotal/routes";
498
+ import { ROUTES } from "../../types/routes.generated";
499
+
500
+ defineRoutes(ROUTES);
501
+ ```
502
+
503
+ From there the call is the one you already know:
504
+
505
+ ```typescript
506
+ import { route } from "zerotal/routes";
507
+
508
+ route("posts.show", { slug }); // → '/posts/hello'
509
+ route("posts.index", {}, { page: 2 }); // → '/posts?page=2'
510
+ ```
511
+
512
+ Same names, same params, same errors. Both helpers are typed as one
513
+ `RouteBuilder` interface and share one URL builder, so a link rendered on the
514
+ server and the same call made in a component cannot disagree about encoding — and
515
+ because `types/routes.generated.ts` augments the one registry, a name that
516
+ type-checks in a controller type-checks in a component.
517
+
518
+ `defineRoutes()` takes the generated `ROUTES` object or any `RouteTable`
519
+ (a name → pattern map). Calling it again replaces the table, which is what makes
520
+ hot reload work. `resetRoutes()` clears it again, for tests that assert on the
521
+ unconfigured error.
522
+
523
+ If your app renders through SSR, call `defineRoutes()` in the SSR entry too — the
524
+ page components run in both processes.
525
+
526
+ For a link that only exists when some package is installed, ask first rather than
527
+ catching a throw:
528
+
529
+ ```typescript
530
+ import { hasRoute, route } from "zerotal/routes";
531
+
532
+ {hasRoute("admin.index") && <a href={route("admin.index")}>Admin</a>}
533
+ ```
534
+
535
+ **In Flow**, the table arrives on its own. `/__flow/runtime.js` is built by the
536
+ framework rather than by your app, so it cannot import your generated file;
537
+ Flow serialises the table onto the runtime it serves instead, and exposes the
538
+ helper to Alpine expressions as `$route`:
539
+
540
+ ```html
541
+ <a :href="$route('posts.show', { slug: post.slug })">Read</a>
542
+ ```
543
+
544
+ Nothing to install, and the names are the same ones the server rendered with.
545
+
546
+ ## File-based routing
547
+
548
+ Map a directory tree to routes: each file under the routes directory becomes an
549
+ endpoint, with `_middleware.ts` for shared middleware.
550
+
551
+ Opt in with `app.fileBasedRouting()` in `bootstrap/app.ts`, passing a map of
552
+ named route groups (each `web`/`api` group brings its own default prefix and
553
+ middleware) to directories:
554
+
555
+ ```typescript
556
+ // bootstrap/app.ts
557
+ import { Application, basePath } from "zerotal";
558
+ import providers from "./providers";
559
+
560
+ const app = Application.create({ providers }).fileBasedRouting({
561
+ web: basePath("app/routes"),
562
+ });
563
+ ```
564
+
565
+ The scanner walks the directory and converts file paths to URL paths
566
+ automatically.
567
+
568
+ ### File → URL mapping
569
+
570
+ | File path | URL |
571
+ | ------------------------- | ---------------------- |
572
+ | `index.ts` | `/` |
573
+ | `about.ts` | `/about` |
574
+ | `users/index.ts` | `/users` |
575
+ | `users/[id].ts` | `/users/:id` |
576
+ | `(admin)/users/index.ts` | `/users` |
577
+ | `api/users/[id]/posts.ts` | `/api/users/:id/posts` |
578
+ | `blog/[...slug].ts` | `/blog/*` |
579
+
580
+ Rules in order: `(group)` directory segments are stripped (no URL contribution);
581
+ `[...slug]` becomes `*` (catch-all); `[param]` becomes `:param` (dynamic
582
+ segment); `index` becomes the directory URL.
583
+
584
+ ### Handler exports
585
+
586
+ A file may export handlers for one or more HTTP verbs:
587
+
588
+ ```typescript
589
+ // app/routes/users/[id].ts
590
+ import type { HttpContext } from "zerotal";
591
+
592
+ export async function GET(ctx: HttpContext): Promise<void> {
593
+ const user = await User.findOrFail(Number(ctx.params.id));
594
+ ctx.json(user);
595
+ }
596
+
597
+ export async function DELETE(ctx: HttpContext): Promise<void> {
598
+ const user = await User.findOrFail(Number(ctx.params.id));
599
+ await user.delete();
600
+ ctx.response = new Response(null, { status: 204 });
601
+ }
602
+ ```
603
+
604
+ `export default` is a GET alias when no explicit `GET` export exists:
605
+
606
+ ```typescript
607
+ // app/routes/api/v2/status.ts
608
+ import type { HttpContext } from "zerotal";
609
+
610
+ export default async function (ctx: HttpContext): Promise<void> {
611
+ ctx.json({ status: "ok", uptime: Math.floor(process.uptime()) });
612
+ }
613
+ ```
614
+
615
+ ### Auto-generated route names
616
+
617
+ File routes are automatically named following the RESTful convention:
618
+
619
+ | URL | Method | Auto-name |
620
+ | ---------------- | -------- | ------------------- |
621
+ | `/` | `GET` | `home` |
622
+ | `/about` | `GET` | `about` |
623
+ | `/about` | `POST` | `about.store` |
624
+ | `/api/users` | `GET` | `api.users.index` |
625
+ | `/api/users` | `POST` | `api.users.store` |
626
+ | `/api/users/:id` | `GET` | `api.users.show` |
627
+ | `/api/users/:id` | `PUT` | `api.users.update` |
628
+ | `/api/users/:id` | `DELETE` | `api.users.destroy` |
629
+
630
+ Override with the `meta` export:
631
+
632
+ ```typescript
633
+ // app/routes/api/v2/posts/[id].ts
634
+ export const meta = {
635
+ GET: { name: "api.v2.posts.show" },
636
+ DELETE: { name: "api.v2.posts.destroy" },
637
+ };
638
+ ```
639
+
640
+ ### Directory middleware
641
+
642
+ Place a `_middleware.ts` file in any directory to protect all routes under it.
643
+ The scanner walks from the root down to the file's directory and stacks
644
+ middleware outermost-first:
645
+
646
+ ```typescript
647
+ // app/routes/api/v2/me/_middleware.ts
648
+ import { RequireAuthMiddleware } from "../../../middleware/RequireAuthMiddleware.ts";
649
+
650
+ export const middleware = [RequireAuthMiddleware];
651
+ ```
652
+
653
+ For `app/routes/api/v2/me/posts.ts` the stack would be:
654
+
655
+ 1. `app/routes/_middleware.ts` (if present)
656
+ 2. `app/routes/api/_middleware.ts` (if present)
657
+ 3. `app/routes/api/v2/_middleware.ts` (if present)
658
+ 4. `app/routes/api/v2/me/_middleware.ts` ← adds `RequireAuthMiddleware`
659
+
660
+ #### Route groups in file trees
661
+
662
+ Use `(group)` directory names to apply shared `_middleware.ts` without affecting
663
+ URLs:
664
+
665
+ ```text
666
+ app/flow/pages/
667
+ (auth)/ ← GuestMiddleware
668
+ login.tsx → /login
669
+ register.tsx → /register
670
+ (protected)/ ← AuthMiddleware
671
+ dashboard.tsx → /dashboard
672
+ profile.tsx → /profile
673
+ index.tsx → /
674
+ ```
675
+
676
+ ### Flow component files
677
+
678
+ When `@zerotal/flow` is installed, the file scanner recognises Flow
679
+ `Component` class exports and registers them automatically without any extra
680
+ configuration. Verb handlers (`POST`, `DELETE`, etc.) in the same file still
681
+ register normally alongside the page:
682
+
683
+ ```typescript
684
+ // app/flow/pages/(protected)/dashboard.tsx
685
+ import type { HttpContext } from "zerotal";
686
+
687
+ export default class DashboardPage extends Component {
688
+ /* ... */
689
+ }
690
+
691
+ // Optional: form handler lives in the same file
692
+ export async function POST(ctx: HttpContext): Promise<void> {
693
+ /* ... */
694
+ }
695
+ ```
696
+
697
+ ## Route-model binding
698
+
699
+ Resolve a route parameter straight to a model instance before the controller
700
+ runs, with an automatic 404 when the record is missing.
701
+
702
+ ### Implicit binding
703
+
704
+ Because every model under `app/models/` is
705
+ [auto-registered](/docs/conventions#models-appmodels), binding is automatic: a
706
+ route param whose name matches a model resolves to a loaded instance with no
707
+ configuration. `:user` resolves via `User`, `:post` via `Post`, `:blogPost` via
708
+ `BlogPost` (and a plural `:users` resolves to `User` too, via singularization).
709
+
710
+ ```typescript
711
+ // routes/index.ts — nothing to declare:
712
+ Router.get("/users/:user", UserController, "show");
713
+
714
+ // app/controllers/UserController.ts — :user is already a loaded User
715
+ // (or a 404 was thrown first):
716
+ async show(ctx: HttpContext<{ user: User }>) {
717
+ return ctx.json({ user: ctx.params.user });
718
+ }
719
+ ```
720
+
721
+ Resolution uses `Model.findOrFail(value)` on the primary key; a missing record
722
+ throws `ModelNotFoundError` (404) before the controller runs. Implicit binding
723
+ only fires for params whose name maps to a model — `:id`, `:slug`, `:page`, and
724
+ other non-model params stay raw strings.
725
+
726
+ #### The model owns its lookup
727
+
728
+ A model that resolves by something other than its primary key says so once, on the
729
+ model, rather than at every route that mentions it. Declare
730
+ `static resolveRouteBinding` and it is used wherever that model binds:
731
+
732
+ ```typescript
733
+ // app/models/User.ts
734
+ @table("users")
735
+ export class User extends Model {
736
+ static override async resolveRouteBinding(value: string, ctx: HttpContext, param: string) {
737
+ // One model can answer for several segments — branch on the param, not the URL.
738
+ if (param === "username") return this.where("username", value).firstOrFail();
739
+ return this.findOrFail(value);
740
+ }
741
+ }
742
+ ```
743
+
744
+ It receives the matched value, the request context, and the **name of the segment**
745
+ that matched. Branching on `param` is what lets `/users/:user` and
746
+ `/users/:username/posts` resolve differently without the model knowing any route's
747
+ shape; `ctx` is there for lookups that depend on the request, such as scoping to the
748
+ current tenant.
749
+
750
+ Because it is a static, a subclass inherits it, and everything that binds a model —
751
+ controllers, [Flow pages](/docs/flow/routing), file routes — goes through it. Return
752
+ anything you like; throwing `ModelNotFoundError` (as `firstOrFail`/`findOrFail` do)
753
+ is what produces the 404.
754
+
755
+ ### When bindings resolve
756
+
757
+ Bindings resolve **after every middleware on the route, immediately before the
758
+ controller**. That ordering is a guarantee, not an implementation detail:
759
+
760
+ - A middleware that short-circuits — an auth guard returning 401, a tenant
761
+ scope returning 403 — does so **before any binding query runs**. A protected
762
+ route therefore answers the same way whether or not the record exists, rather
763
+ than leaking that difference as 404-vs-401 to an unauthenticated caller.
764
+ - Middleware runs with the raw string still on `ctx.params`. If a middleware
765
+ needs the record itself, load it there — it cannot rely on the binding.
766
+ - A `ModelNotFoundError` unwinds back out through the middleware, so their
767
+ `finally` blocks (session persistence, for example) still run.
768
+
769
+ #### Opting out and customising
770
+
771
+ Two static properties on the model control implicit binding:
772
+
773
+ ```typescript
774
+ // app/models/User.ts
775
+ @table("users")
776
+ export class User extends Model {
777
+ // Never bind this model implicitly (the :user param stays a raw string):
778
+ static implicitBinding = false;
779
+
780
+ // Or claim a different param name than the class name:
781
+ static implicitBindingKey = "author"; // now :author resolves via User, and :user does not
782
+ }
783
+ ```
784
+
785
+ `implicitBindingKey` **replaces** the name convention — a model that claims
786
+ `:author` no longer answers to `:user`.
787
+
788
+ > **Tip** — Need to resolve by something other than the primary key (e.g. a
789
+ > slug)? Prefer [`static resolveRouteBinding`](#the-model-owns-its-lookup) — it
790
+ > applies everywhere the model binds. Reach for an explicit
791
+ > [per-route binding](#per-route-binding) when the override belongs to one route
792
+ > rather than to the model; explicit bindings always win over implicit ones.
793
+
794
+ ### Explicit binding
795
+
796
+ There are exactly two ways to change how a param resolves, and they differ only in
797
+ scope: put it on the **model** when it is how that model always resolves, or on the
798
+ **route** when it belongs to that one route.
799
+
800
+ ```typescript
801
+ // in a controller — either way, it is already resolved
802
+ async show(ctx: HttpContext) {
803
+ const user = ctx.model<User>('user'); // no DB call needed here
804
+ return ctx.json({ user });
805
+ }
806
+ ```
807
+
808
+ If the record is not found, a `ModelNotFoundError` (404) is thrown before the
809
+ controller runs.
810
+
811
+ #### Per-route binding
812
+
813
+ Overrides the model's own resolution for a single route:
814
+
815
+ ```typescript
816
+ // routes/index.ts
817
+ Router.get("/posts/:post", PostController, "show").name("posts.show").bind("post", Post);
818
+
819
+ // Custom per-route resolver
820
+ Router.get("/articles/:article", ArticleController, "show").bind("article", (value) =>
821
+ Article.where("slug", value).firstOrFail(),
822
+ );
823
+ ```
824
+
825
+ #### Receiving bindings in controllers
826
+
827
+ The resolved instance is available two ways — via `ctx.model()`, or on
828
+ `ctx.params` under the param's name:
829
+
830
+ ```typescript
831
+ // in a controller — via ctx.model()
832
+ async show(ctx: HttpContext) {
833
+ const post = ctx.model<Post>('post');
834
+ ctx.json(post);
835
+ }
836
+
837
+ // via ctx.params
838
+ async show(ctx: HttpContext<{ post: Post }>) {
839
+ ctx.json(ctx.params.post);
840
+ }
841
+
842
+ // mixed — model + raw route param
843
+ async comments(ctx: HttpContext<{ post: Post; tab: string }>) {
844
+ const { post, tab } = ctx.params;
845
+ ctx.json({ post, tab, comments: await post.comments().all() });
846
+ }
847
+ ```
848
+
849
+ `ctx.params` is one part of the request context every handler receives — reading
850
+ input, sending responses, headers, flash, and after-response hooks all live on the
851
+ same object. See [HttpContext](/docs/context) for that surface.
852
+
853
+ ## Route registration order
854
+
855
+ 1. `app.fileBasedRouting()` directories are scanned and registered during boot.
856
+ 2. Explicit route files run and register their routes.
857
+ 3. `Router.raw()` routes are compiled last and take precedence over same-path
858
+ pipeline routes.
859
+
860
+ Within explicit routes, last registration wins for duplicate `METHOD + path`
861
+ keys — registering the same path twice overwrites the first.
862
+
863
+ For resource routes, register literal paths **before** dynamic ones when there is
864
+ a naming conflict:
865
+
866
+ ```typescript
867
+ // routes/index.ts
868
+ // Correct — /posts/create is matched before /posts/:slug
869
+ Router.get("/posts/create", PostController, "showCreate");
870
+ Router.get("/posts/:slug", PostController, "show");
871
+ ```
872
+
873
+ ## Route inspection
874
+
875
+ ### The route:list command
876
+
877
+ Print a table of all registered routes:
878
+
879
+ ```bash
880
+ # in your project root
881
+ bun zt route:list
882
+
883
+ # Filter by method
884
+ bun zt route:list --method GET
885
+ bun zt route:list -m DELETE
886
+
887
+ # Filter by path substring
888
+ bun zt route:list --path /api
889
+ bun zt route:list -p /users
890
+
891
+ # Show only named routes
892
+ bun zt route:list --name
893
+
894
+ # Include middleware column
895
+ bun zt route:list --verbose
896
+ bun zt route:list -v
897
+ ```
898
+
899
+ ### The route:types command
900
+
901
+ Write the generated name → pattern map that makes [`route()` typed](#typed-route-names):
902
+
903
+ ```bash
904
+ # writes types/routes.generated.ts — commit it
905
+ bun zt route:types
906
+
907
+ # CI: fail when the committed file no longer matches the routes
908
+ bun zt route:types --check
909
+ ```
910
+
911
+ Both forms boot the app, so a route a provider registers is included.
912
+
913
+ ### Programmatic inspection
914
+
915
+ ```typescript
916
+ // anywhere after boot
917
+ Router.routes; // ReadonlyMap<string, RouteDefinition>
918
+ Router.namedRoutes; // ReadonlyMap<string, string> (name → path)
919
+
920
+ // Middleware attached to a specific route
921
+ Router.middlewareFor("GET", "/dashboard"); // MiddlewareClass[]
922
+ ```
923
+
924
+ ## Testing
925
+
926
+ Use `HttpContext.fake()` to unit-test controllers and route handlers without a
927
+ running server:
928
+
929
+ ```typescript
930
+ // src/tests/PostController.test.ts
931
+ import { HttpContext } from "zerotal";
932
+
933
+ const ctx = HttpContext.fake("http://localhost/posts/42", { method: "GET" });
934
+ ctx.params = { id: "42" };
935
+
936
+ await new PostController().show(ctx);
937
+ // assert ctx.response
938
+ ```
939
+
940
+ For full integration tests, boot the real app with `createTestApp()` from
941
+ `@zerotal/testing` and exercise it over real requests:
942
+
943
+ ```typescript
944
+ // src/tests/PostTest.ts
945
+ import { beforeAll, afterAll, it } from "bun:test";
946
+ import { createTestApp, type TestApp } from "@zerotal/testing";
947
+ import { app } from "../bootstrap/app.ts";
948
+
949
+ let testApp: TestApp;
950
+ beforeAll(async () => (testApp = await createTestApp(() => app)));
951
+ afterAll(() => testApp.close());
952
+
953
+ it("returns the post", async () => {
954
+ const post = await Post.create({ title: "Hello" });
955
+ const res = await testApp.get(`/posts/${post.id}`);
956
+ res.assertStatus(200);
957
+ res.assertJson({ title: "Hello" });
958
+ });
959
+ ```
960
+
961
+ See [HTTP Tests](/docs/testing/http) for the full client and assertion surface.
962
+
963
+ ## References
964
+
965
+ ### Router methods
966
+
967
+ | Method | Signature | Description |
968
+ | -------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
969
+ | `Router.get` / `post` / `put` / `patch` / `delete` | `(path, Controller, action, mw?)` or `(path, handler, mw?)` | Register a route for an HTTP verb (controller action or closure handler). Returns a `RouteRegistration`. |
970
+ | `Router.resource` | `(name, Controller, mw?): ResourceRouteBuilder` | Register the seven RESTful routes; chain `.only()` / `.except()`. |
971
+ | `Router.view` | `(path, Component, props?, mw?): ViewRegistration` | Render a JSX view with no controller. |
972
+ | `Router.static` | `(prefix, dir, options?)` | Serve a directory of static files. |
973
+ | `Router.markdown` | `(prefix, dir, options?)` | Serve `.md` files as rendered HTML. |
974
+ | `Router.raw` | `(method, path, handler)` | Handle the raw `Request`, bypassing the middleware pipeline. |
975
+ | `Router.flow` | `(path, Page, mw?)` | Register a Flow page (added by `@zerotal/flow`). |
976
+ | `Router.group` | `(options, fn)` | Share a `prefix`, `middleware`, and/or `domain` across routes. |
977
+ | `Router.middlewareGroup` | `(name, [...])` | Define a reusable, named middleware stack. |
978
+
979
+ ### Registration chaining
980
+
981
+ Every route registration returns a chainable handle:
982
+
983
+ | Chain | Signature | Effect |
984
+ | --------------------------------- | --------------------------------------------- | ---------------------------------------------------------- |
985
+ | `.name(name)` | `(name): RouteRegistration` | Name the route for `route()` URL generation. |
986
+ | `.bind(param, target)` | `(param, modelOrResolver): RouteRegistration` | Per-route model binding; overrides the global one. |
987
+ | `.withLayout(layout)` | `(layout): ViewRegistration` | Wrap a view route's output in a layout (view routes only). |
988
+ | `.only([...])` / `.except([...])` | `(actions): this` | Limit which resource actions are registered. |
989
+
990
+ ### URLs, inspection and CLI
991
+
992
+ | API | Signature | Description |
993
+ | -------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------- |
994
+ | `route(name, params?, query?)` | `(name, params?, query?): string` | Build a URL from a named route. Params are exact; query values go last. |
995
+ | `route.dynamic(name, params?, query?)` | `(name, params?, query?): string` | The same, for a name only known at runtime — no compile-time checking. |
996
+ | `Router.routes` / `Router.namedRoutes` | `ReadonlyMap` | Read the registered route and name maps. |
997
+ | `Router.middlewareFor(method, path)` | `(method, path): MiddlewareClass[]` | List the middleware attached to a route. |
998
+ | `HttpContext.fake(url?, init?)` | `(url?, init?): HttpContext` | Build a fake context for unit tests. |
999
+ | `bun zt route:list` | — | Print every route (`-m` method, `-p` path, `--name`, `-v` middleware). |
1000
+ | `bun zt route:types` | `--check` | Write `types/routes.generated.ts`; `--check` fails when it is stale. |
1001
+
1002
+ ## Next steps
1003
+
1004
+ - [Controllers](/docs/controllers) — move route logic out of closures.
1005
+ - [Middleware](/docs/middleware) — protect and transform requests.
1006
+ - [HttpContext](/docs/context) — the request/response object actions receive.
1007
+ - [Multi-tenancy](/docs/tenancy) — resolve a tenant from the subdomain a group scopes to.
1008
+ - [HTTP Tests](/docs/testing/http) — boot the real app and assert over requests.