@zerotal/arch 1.7.4 → 1.7.5

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 (119) hide show
  1. package/CHANGELOG.md +34 -2
  2. package/README.md +4 -2
  3. package/docs/about.md +16 -16
  4. package/docs/admin/actions.md +9 -9
  5. package/docs/admin/auth.md +2 -2
  6. package/docs/admin/dashboard.md +5 -5
  7. package/docs/admin/extending-ui.md +12 -12
  8. package/docs/admin/extending.md +16 -7
  9. package/docs/admin/forms.md +4 -4
  10. package/docs/admin/index.md +2 -2
  11. package/docs/admin/operations.md +9 -9
  12. package/docs/admin/resources.md +3 -3
  13. package/docs/admin/structure.md +6 -6
  14. package/docs/admin/tables.md +8 -8
  15. package/docs/admin/testing.md +4 -4
  16. package/docs/ai.md +118 -11
  17. package/docs/application.md +12 -12
  18. package/docs/arch.md +25 -3
  19. package/docs/assets.md +11 -11
  20. package/docs/audit.md +13 -13
  21. package/docs/authentication.md +39 -39
  22. package/docs/authorization.md +13 -13
  23. package/docs/broadcasting/channels.md +2 -2
  24. package/docs/broadcasting/client.md +2 -2
  25. package/docs/broadcasting/events.md +9 -10
  26. package/docs/broadcasting/index.md +1 -1
  27. package/docs/broadcasting/testing.md +5 -5
  28. package/docs/cache.md +15 -13
  29. package/docs/carbon.md +35 -35
  30. package/docs/changelog.md +105 -1
  31. package/docs/client/index.md +13 -13
  32. package/docs/commands.md +4 -4
  33. package/docs/components.md +116 -115
  34. package/docs/config-system.md +4 -4
  35. package/docs/container.md +27 -27
  36. package/docs/context.md +27 -27
  37. package/docs/contributing.md +28 -0
  38. package/docs/controllers.md +10 -10
  39. package/docs/conventions.md +23 -15
  40. package/docs/cookies.md +3 -3
  41. package/docs/csrf.md +6 -6
  42. package/docs/database.md +22 -17
  43. package/docs/deployment.md +2 -2
  44. package/docs/devtools.md +16 -16
  45. package/docs/email-verification.md +8 -8
  46. package/docs/encryption.md +7 -7
  47. package/docs/errors.md +6 -6
  48. package/docs/events.md +12 -11
  49. package/docs/flow/components.md +32 -32
  50. package/docs/flow/decorators.md +18 -18
  51. package/docs/flow/events.md +16 -16
  52. package/docs/flow/forms.md +17 -17
  53. package/docs/flow/icons.md +5 -5
  54. package/docs/flow/index.md +20 -20
  55. package/docs/flow/layouts.md +35 -35
  56. package/docs/flow/lifecycle.md +16 -16
  57. package/docs/flow/models.md +9 -9
  58. package/docs/flow/pagination.md +8 -8
  59. package/docs/flow/performance.md +7 -7
  60. package/docs/flow/references.md +3 -3
  61. package/docs/flow/routing.md +26 -26
  62. package/docs/flow/testing.md +20 -20
  63. package/docs/getting-started.md +18 -13
  64. package/docs/health.md +17 -8
  65. package/docs/helpers.md +17 -17
  66. package/docs/i18n.md +10 -10
  67. package/docs/inertia/devtools.md +4 -4
  68. package/docs/inertia/index.md +6 -6
  69. package/docs/inertia/props.md +21 -21
  70. package/docs/inertia/rendering.md +9 -9
  71. package/docs/inertia/ssr.md +2 -2
  72. package/docs/lifecycle.md +2 -2
  73. package/docs/lock.md +11 -11
  74. package/docs/logger.md +12 -12
  75. package/docs/media.md +22 -22
  76. package/docs/middleware.md +17 -17
  77. package/docs/migrations.md +13 -13
  78. package/docs/monitor.md +6 -6
  79. package/docs/notifications.md +24 -24
  80. package/docs/orm/casts.md +14 -14
  81. package/docs/orm/factories.md +8 -8
  82. package/docs/orm/index.md +22 -20
  83. package/docs/orm/lifecycle.md +13 -13
  84. package/docs/orm/queries.md +28 -28
  85. package/docs/orm/relationships.md +16 -16
  86. package/docs/orm/serialization.md +11 -11
  87. package/docs/package-development.md +10 -10
  88. package/docs/pagination.md +14 -14
  89. package/docs/password-reset.md +6 -6
  90. package/docs/providers.md +11 -11
  91. package/docs/query-builder.md +18 -18
  92. package/docs/queue.md +14 -12
  93. package/docs/rate-limiting.md +6 -6
  94. package/docs/responses.md +14 -14
  95. package/docs/roles-and-2fa.md +9 -9
  96. package/docs/routing.md +44 -44
  97. package/docs/scaffolding.md +1 -1
  98. package/docs/scheduler.md +10 -10
  99. package/docs/seeding.md +6 -6
  100. package/docs/session.md +16 -14
  101. package/docs/social.md +14 -14
  102. package/docs/storage.md +25 -13
  103. package/docs/structure.md +2 -2
  104. package/docs/support-policy.md +23 -10
  105. package/docs/telemetry.md +12 -12
  106. package/docs/tenancy.md +34 -40
  107. package/docs/testing/browser.md +6 -6
  108. package/docs/testing/console.md +3 -3
  109. package/docs/testing/database.md +10 -10
  110. package/docs/testing/flow-browser.md +6 -6
  111. package/docs/testing/http.md +12 -12
  112. package/docs/testing/index.md +3 -3
  113. package/docs/testing/mocking.md +8 -8
  114. package/docs/upgrade.md +1 -1
  115. package/docs/validator.md +19 -19
  116. package/docs/view.md +14 -14
  117. package/package.json +4 -4
  118. package/src/index.ts +25 -1
  119. package/src/tools/searchDocs.ts +7 -0
package/docs/audit.md CHANGED
@@ -24,7 +24,7 @@ bun add @zerotal/audit
24
24
  Add `AuditProvider` to the providers array in `bootstrap/providers.ts`, after
25
25
  `DatabaseProvider` (the audit table lives in your database):
26
26
 
27
- ```ts
27
+ ```ts fragment
28
28
  // bootstrap/providers.ts
29
29
  import { AuditProvider } from "@zerotal/audit";
30
30
 
@@ -100,7 +100,7 @@ export class User extends Model.using(Auditable) {
100
100
 
101
101
  `Auditable` composes with the auth mixins as the outermost wrapper:
102
102
 
103
- ```ts
103
+ ```ts fragment
104
104
  // app/models/User.ts
105
105
  export class User extends Auditable(WithRoles(WithPermissions(AuthUser))) {
106
106
  protected static auditExcept = ["password"];
@@ -129,7 +129,7 @@ For a model you'd rather not wrap in the mixin, register it from a provider's
129
129
  `onBooted()` (after the container has resolved the `"audit"` binding). Configure it
130
130
  with the same static fields:
131
131
 
132
- ```ts
132
+ ```ts fragment
133
133
  // in a ServiceProvider.onBooted()
134
134
  import { registerAudit } from "@zerotal/audit";
135
135
 
@@ -157,12 +157,12 @@ Log any custom event — logins, exports, settings changes — via the `Audit` f
157
157
  Pass the **model instance** the event concerns; `auditable_type` and `auditable_id`
158
158
  are derived from it, so logs are always linked to a record:
159
159
 
160
- ```ts
160
+ ```ts fragment
161
161
  function log(event: AuditEvent, model: Model, payload?: InstanceAuditPayload): Promise<void>;
162
162
  function log(event: AuditEvent, payload: Omit<AuditPayload, "event">): Promise<void>;
163
163
  ```
164
164
 
165
- ```ts
165
+ ```ts fragment
166
166
  // in a controller or service (within a request context)
167
167
  import { Audit } from "@zerotal/audit";
168
168
 
@@ -178,7 +178,7 @@ await Audit.log("report.exported", report, {
178
178
  For an event not tied to a model, pass a raw payload with `auditable_type`. Outside
179
179
  a request (queue job, CLI command) supply the actor explicitly:
180
180
 
181
- ```ts
181
+ ```ts fragment
182
182
  // in a queue job or CLI command
183
183
  await Audit.log("subscription.renewed", {
184
184
  auditable_type: "Subscription",
@@ -190,7 +190,7 @@ await Audit.log("subscription.renewed", {
190
190
 
191
191
  When a model is `Auditable`, the same call is available as an instance method:
192
192
 
193
- ```ts
193
+ ```ts fragment
194
194
  // in a controller or service
195
195
  await user.auditLog("login.success", { tags: { method: "github_oauth" } });
196
196
  ```
@@ -207,7 +207,7 @@ operating outside a request.
207
207
 
208
208
  `AuditLog` is a full `Model` with scopes and chainable queries:
209
209
 
210
- ```ts
210
+ ```ts fragment
211
211
  // in a controller or service
212
212
  import { AuditLog } from "@zerotal/audit";
213
213
 
@@ -233,7 +233,7 @@ const page = await AuditLog.query()
233
233
 
234
234
  The `Audit` facade exposes the same queries plus a convenience read:
235
235
 
236
- ```ts
236
+ ```ts fragment
237
237
  // in a controller or service
238
238
  const logs = await Audit.logs(User, user.id).orderBy("id", "desc").limit(25).get();
239
239
  const byActor = await Audit.logsByActor(user.id).get();
@@ -245,14 +245,14 @@ const recent = await Audit.historyFor("User", user.id, 25);
245
245
 
246
246
  An `Auditable` model also offers an instance shortcut:
247
247
 
248
- ```ts
248
+ ```ts fragment
249
249
  // in a controller or service
250
250
  const logs = await user.auditLogs().orderBy("id", "desc").limit(25).get();
251
251
  ```
252
252
 
253
253
  ### Inspecting a record
254
254
 
255
- ```ts
255
+ ```ts fragment
256
256
  // in a controller or service
257
257
  const entry = history[0];
258
258
 
@@ -309,7 +309,7 @@ implementation to the `"audit"` singleton.
309
309
  In tests, swap to the `NullDriver` so no database is needed. Bind a fresh
310
310
  `Auditor` over the `"audit"` key:
311
311
 
312
- ```ts
312
+ ```ts fragment
313
313
  // tests/setup.ts
314
314
  import { Application } from "zerotal";
315
315
  import { Auditor, NullDriver } from "@zerotal/audit";
@@ -364,4 +364,4 @@ app.container.singleton(
364
364
  - [ORM Lifecycle](/docs/orm/lifecycle) — the model hooks the audit system listens to.
365
365
  - [Authentication](/docs/authentication) — how the actor on each entry is resolved.
366
366
  - [Authorization](/docs/authorization) — gate read access to audit logs by role.
367
- - [Testing](/docs/testing/index) — swap in the null driver for isolated tests.
367
+ - [Testing](/docs/testing) — swap in the null driver for isolated tests.
@@ -65,7 +65,7 @@ export default AuthConfig({
65
65
 
66
66
  `AuthProvider` needs to know how to load a user from their session-stored ID. Call `AuthProvider.resolveUsing()` in `bootstrap/app.ts` **before** `Application.create()`:
67
67
 
68
- ```typescript
68
+ ```typescript fragment
69
69
  // bootstrap/app.ts
70
70
  import { Application, basePath } from "zerotal";
71
71
  import { AuthProvider } from "@zerotal/auth";
@@ -96,7 +96,7 @@ export class User extends AuthUser {
96
96
 
97
97
  `AuthUser` provides `getAuthId()` (returns `this.id`) and `getAuthPassword()` (returns `this.password`). Make `Auth.user()` return your concrete type by augmenting `UserModel` once:
98
98
 
99
- ```typescript
99
+ ```typescript fragment
100
100
  // bootstrap/app.ts (or any file imported at boot)
101
101
  import type { User } from "../app/models/User.ts";
102
102
 
@@ -119,7 +119,7 @@ On every request `PersistUserMiddleware` reads `user_id` from the session and po
119
119
 
120
120
  `Auth` reads the current user from async local storage — use it from controllers, services, or anywhere in the request tree:
121
121
 
122
- ```typescript
122
+ ```typescript fragment
123
123
  // in a controller or service
124
124
  import { Auth } from "@zerotal/auth";
125
125
 
@@ -135,11 +135,11 @@ await Auth.logout(); // clear user_id from session, unset ctx.user
135
135
 
136
136
  `Auth.attempt()` rolls credential lookup, password verification, and login into one call:
137
137
 
138
- ```typescript
138
+ ```typescript fragment
139
139
  function attempt(credentials: Credentials, remember?: boolean): Promise<boolean>;
140
140
  ```
141
141
 
142
- ```typescript
142
+ ```typescript fragment
143
143
  // in a controller
144
144
  if (await Auth.attempt({ email, password })) {
145
145
  ctx.redirect("/dashboard", 303);
@@ -157,7 +157,7 @@ ctx.redirect("/login", 303);
157
157
 
158
158
  `AuthMiddleware` is the built-in guard — the inverse of `GuestMiddleware`. It lets authenticated requests through, returns `401` JSON for API clients, and redirects HTML guests to `/login` (saving the originating URL to the session as `intended_url`):
159
159
 
160
- ```typescript
160
+ ```typescript fragment
161
161
  // routes/web.ts
162
162
  import { AuthMiddleware } from "@zerotal/auth";
163
163
  import { Router } from "zerotal";
@@ -171,7 +171,7 @@ Router.group({ prefix: "/app", middleware: [AuthMiddleware] }, () => {
171
171
 
172
172
  Override the redirect target, or also require a verified email:
173
173
 
174
- ```typescript
174
+ ```typescript fragment
175
175
  // routes/web.ts
176
176
  AuthMiddleware.with({ redirectTo: "/sign-in" });
177
177
  AuthMiddleware.with({ mustVerifyEmail: true, verifyRedirectTo: "/confirm-email" });
@@ -189,7 +189,7 @@ AuthMiddleware.with({ mustVerifyEmail: true, verifyRedirectTo: "/confirm-email"
189
189
 
190
190
  Redirects authenticated users away from login/register pages:
191
191
 
192
- ```typescript
192
+ ```typescript fragment
193
193
  // routes/web.ts
194
194
  import { GuestMiddleware } from "@zerotal/auth";
195
195
 
@@ -214,7 +214,7 @@ endpoints — are handled by the guards covered in
214
214
 
215
215
  > **Note** — Password hashing is covered in [Encryption & Hashing](/docs/encryption); password reset in [Password Reset](/docs/password-reset).
216
216
 
217
- ```typescript
217
+ ```typescript fragment
218
218
  // app/controllers/AuthController.ts
219
219
  import { Auth, Hash } from "@zerotal/auth";
220
220
  import type { HttpContext } from "zerotal";
@@ -284,7 +284,7 @@ export class AuthController {
284
284
 
285
285
  A "remember me" checkbox keeps a user signed in after their session expires. Pass `{ remember: true }` to `Auth.login()` (or as the second argument to `Auth.attempt()`) and the framework does the rest:
286
286
 
287
- ```typescript
287
+ ```typescript fragment
288
288
  await Auth.login(user, { remember: true });
289
289
  // or
290
290
  await Auth.attempt({ email, password }, remember);
@@ -296,7 +296,7 @@ Only the hash is persisted, so a leaked database row can't be replayed as a vali
296
296
 
297
297
  When a request was restored from the cookie rather than an active session, `Auth.viaRemember()` returns `true`. Use it to demand a fresh login (or password confirmation) before sensitive actions:
298
298
 
299
- ```typescript
299
+ ```typescript fragment
300
300
  if (Auth.viaRemember()) {
301
301
  return ctx.redirect("/confirm-password");
302
302
  }
@@ -306,7 +306,7 @@ if (Auth.viaRemember()) {
306
306
 
307
307
  To blunt credential-stuffing and brute-force attempts, throttle failed logins per identifier and IP. The shared `loginThrottle` limiter (5 attempts per 60 seconds by default) records misses, locks the pair out once the limit is reached, and emits a `Lockout` event you can hook for alerting:
308
308
 
309
- ```typescript
309
+ ```typescript fragment
310
310
  import { Auth, loginThrottle } from "@zerotal/auth";
311
311
 
312
312
  async login(ctx: HttpContext) {
@@ -332,7 +332,7 @@ async login(ctx: HttpContext) {
332
332
 
333
333
  Tune the window with `new LoginRateLimiter({ maxAttempts, decaySeconds })` for a dedicated limiter, or listen for the lockout:
334
334
 
335
- ```typescript
335
+ ```typescript fragment
336
336
  FrameworkEvents.on(Lockout, ({ identifier }) => {
337
337
  // notify the account owner, feed intrusion detection, etc.
338
338
  });
@@ -348,7 +348,7 @@ So that is when it happens. `Auth.attempt()` (and `attemptWhen`) compare the
348
348
  stored hash's algorithm against `auth.algorithm`, and on a mismatch re-hash the
349
349
  password the user just proved they know and persist it:
350
350
 
351
- ```typescript
351
+ ```typescript fragment
352
352
  // config/auth.ts — raise the cost, and logins migrate themselves
353
353
  export default AuthConfig({
354
354
  algorithm: "argon2id",
@@ -366,7 +366,7 @@ an outage caused by a maintenance task.
366
366
 
367
367
  To drive a migration rather than wait for it, check the hash yourself:
368
368
 
369
- ```typescript
369
+ ```typescript fragment
370
370
  // in a command or service
371
371
  import { Hash } from "zerotal/security";
372
372
 
@@ -385,7 +385,7 @@ if (Hash.needsRehash(user.password)) {
385
385
 
386
386
  Some actions — changing a password, deleting an account, viewing recovery codes — warrant re-entering the password even within an active session. Gate those routes with `ConfirmPasswordMiddleware`: it lets the request through if the user confirmed their password within the window (default 3 hours), otherwise it stores the intended URL and redirects to `/confirm-password` (or returns `423 Locked` for JSON).
387
387
 
388
- ```typescript
388
+ ```typescript fragment
389
389
  Router.group({ middleware: [AuthMiddleware, ConfirmPasswordMiddleware] }, () => {
390
390
  Router.get("/settings/security", SecurityController, "show");
391
391
  });
@@ -393,7 +393,7 @@ Router.group({ middleware: [AuthMiddleware, ConfirmPasswordMiddleware] }, () =>
393
393
 
394
394
  Your confirm-password route verifies the password and records the confirmation:
395
395
 
396
- ```typescript
396
+ ```typescript fragment
397
397
  async confirm(ctx: HttpContext) {
398
398
  const { password } = await ctx.body<{ password: string }>();
399
399
  if (await Auth.confirmPassword(password)) {
@@ -410,7 +410,7 @@ async confirm(ctx: HttpContext) {
410
410
 
411
411
  Let a user end their sessions on every _other_ device while staying signed in on the current one — typically offered after a password change. Attach `AuthenticateSessionMiddleware` to your authenticated routes; it binds each session to a snapshot of the user's password hash. Then call `Auth.logoutOtherDevices(currentPassword)`:
412
412
 
413
- ```typescript
413
+ ```typescript fragment
414
414
  Router.group({ middleware: [AuthMiddleware, AuthenticateSessionMiddleware] }, () => {
415
415
  // ...the bulk of your authenticated routes
416
416
  });
@@ -427,7 +427,7 @@ It re-hashes the same password and persists it, so every other session's snapsho
427
427
 
428
428
  `isPasswordCompromised()` checks a password against the Have I Been Pwned breach corpus using the k-anonymity range API — only the first five characters of the SHA-1 hash ever leave the process. Use it during registration or password changes:
429
429
 
430
- ```typescript
430
+ ```typescript fragment
431
431
  import { isPasswordCompromised } from "@zerotal/auth";
432
432
 
433
433
  if (await isPasswordCompromised(password)) {
@@ -458,7 +458,7 @@ the flow, which is the thing to decide first:
458
458
 
459
459
  `EmailOtpBroker` powers passwordless login by emailing a short numeric code. Like `PasswordBroker`, it's DB-agnostic via injected callbacks and stores only the code's hash.
460
460
 
461
- ```typescript
461
+ ```typescript fragment
462
462
  const otp = new EmailOtpBroker({
463
463
  findCode: (email) => LoginCode.query().where("email", email).first(),
464
464
  storeCode: (email, hash, expiresAt) => LoginCode.upsert({ email, code: hash, expiresAt }),
@@ -504,7 +504,7 @@ way to discover who has registered.
504
504
 
505
505
  `MagicLinkBroker` generates signed, time-limited login URLs and establishes a session on verify.
506
506
 
507
- ```typescript
507
+ ```typescript fragment
508
508
  // app/auth/magicLinks.ts
509
509
  import { MagicLinkBroker } from "@zerotal/auth";
510
510
  import { env } from "zerotal";
@@ -521,7 +521,7 @@ export const magicLinks = new MagicLinkBroker({
521
521
  });
522
522
  ```
523
523
 
524
- ```typescript
524
+ ```typescript fragment
525
525
  // app/controllers/MagicLinkController.ts
526
526
  import { magicLinks } from "#app/auth/magicLinks.ts";
527
527
  import { MAGIC } from "@zerotal/auth";
@@ -558,7 +558,7 @@ export class MagicLinkController {
558
558
 
559
559
  Routes:
560
560
 
561
- ```typescript
561
+ ```typescript fragment
562
562
  // routes/web.ts
563
563
  Router.post("/magic", MagicLinkController, "send");
564
564
  Router.get("/magic/verify", MagicLinkController, "verify");
@@ -594,7 +594,7 @@ JWTs when statelessness does.
594
594
 
595
595
  The top-level `Auth` facade is the default session-backed `web` guard. For separate auth schemes — most often a stateless API guard alongside the session UI — register a **request guard** with `Auth.viaRequest()` and reach it via `Auth.guard(name)`:
596
596
 
597
- ```typescript
597
+ ```typescript fragment
598
598
  import { Auth, Jwt } from "@zerotal/auth";
599
599
 
600
600
  Auth.viaRequest("api", async (req) => {
@@ -647,7 +647,7 @@ only going into a foreign key or being compared against one.
647
647
 
648
648
  For quick internal endpoints, `BasicAuthMiddleware` authenticates straight from the `Authorization: Basic` header — no login page. It's stateless (sets `ctx.user` for the request only) and challenges with `401 WWW-Authenticate` when credentials are missing or wrong.
649
649
 
650
- ```typescript
650
+ ```typescript fragment
651
651
  Router.get("/internal/metrics", MetricsController, "show", [BasicAuthMiddleware]);
652
652
  // Authenticate by a different column / realm:
653
653
  BasicAuthMiddleware.with({ field: "username", realm: "Admin" });
@@ -671,7 +671,7 @@ internal dashboard nobody wants to build a login page for.
671
671
 
672
672
  For stateless API or mobile clients, issue and verify HS256 JSON Web Tokens with the `Jwt` helper (no external dependency), and authenticate requests with `JwtGuardMiddleware`:
673
673
 
674
- ```typescript
674
+ ```typescript fragment
675
675
  import { Jwt, JwtGuardMiddleware, AuthMiddleware } from "@zerotal/auth";
676
676
 
677
677
  // Issue on login:
@@ -698,7 +698,7 @@ For SPAs and mobile apps, issue personal access tokens instead of (or alongside)
698
698
 
699
699
  #### API token migration
700
700
 
701
- ```typescript
701
+ ```typescript fragment
702
702
  // database/migrations/xxxx_create_personal_access_tokens.ts
703
703
  await Schema.create("personal_access_tokens", (table) => {
704
704
  table.increments("id");
@@ -715,7 +715,7 @@ await Schema.create("personal_access_tokens", (table) => {
715
715
 
716
716
  #### Issuing tokens
717
717
 
718
- ```typescript
718
+ ```typescript fragment
719
719
  function createToken(options: {
720
720
  tokenableId: number;
721
721
  tokenableType?: string;
@@ -727,7 +727,7 @@ function createToken(options: {
727
727
 
728
728
  > **Danger** — The plain-text token is returned to the client exactly once and is never stored — only its SHA-256 hash lives in the database. If the user loses it, issue a new one.
729
729
 
730
- ```typescript
730
+ ```typescript fragment
731
731
  // in a controller
732
732
  import { createToken } from "@zerotal/auth";
733
733
  import { DB } from "@zerotal/orm";
@@ -780,7 +780,7 @@ last-used tracking, since it adds a write to every authenticated request.
780
780
 
781
781
  Apply to API routes, and check abilities with `ctx.tokenCan()`:
782
782
 
783
- ```typescript
783
+ ```typescript fragment
784
784
  // routes/api.ts
785
785
  Router.group({ prefix: "/api", middleware: [BearerTokenMiddleware] }, () => {
786
786
  Router.get("/me", UserController, "show");
@@ -802,7 +802,7 @@ the test client encodes a session through your app's own session driver and send
802
802
  a real cookie, so the request travels the same middleware path a browser's would
803
803
  and anything reading the current user sees the one you named.
804
804
 
805
- ```typescript
805
+ ```typescript fragment
806
806
  // tests/http/auth.test.ts
807
807
  import { createTestApp } from "@zerotal/testing";
808
808
  import { UserFactory } from "../../database/factories/UserFactory.ts";
@@ -823,21 +823,21 @@ it("dashboard redirects guests to login", async () => {
823
823
  `actingAs` only needs an object carrying an `id`, so a full model is optional when
824
824
  the route reads nothing else:
825
825
 
826
- ```typescript
826
+ ```typescript fragment
827
827
  const res = await testApp.actingAs({ id: 42 }).get("/profile");
828
828
  ```
829
829
 
830
830
  The acting user persists on the test client across requests — what you want inside
831
831
  one test, and a leak across several. Clear it between tests:
832
832
 
833
- ```typescript
833
+ ```typescript fragment
834
834
  afterEach(() => testApp.actingAsGuest());
835
835
  ```
836
836
 
837
837
  `withSession(data)` seeds extra session values alongside the acting user, for
838
838
  routes that read something the real login flow would have put there:
839
839
 
840
- ```typescript
840
+ ```typescript fragment
841
841
  const res = await testApp.actingAs(user).withSession({ locale: "fr" }).get("/profile");
842
842
  ```
843
843
 
@@ -852,7 +852,7 @@ the login flow itself rather than a route it protects.
852
852
  | `assertAuthenticatedAs(user)` | That specific user is — takes a model or id |
853
853
  | `assertGuest()` | Nobody is |
854
854
 
855
- ```typescript
855
+ ```typescript fragment
856
856
  it("signs the user in on valid credentials", async () => {
857
857
  const user = await UserFactory.create({ password: await Hash.make("secret") });
858
858
 
@@ -878,7 +878,7 @@ test pass for the wrong reason:
878
878
  - **API routes** answer with a status — `assertUnauthorized()` for 401 (not signed
879
879
  in) and `assertForbidden()` for 403 (signed in, not permitted).
880
880
 
881
- ```typescript
881
+ ```typescript fragment
882
882
  it("rejects an API request with no token", async () => {
883
883
  const res = await testApp.asJson().get("/api/orders");
884
884
  res.assertUnauthorized();
@@ -893,7 +893,7 @@ it("rejects a signed-in user without the ability", async () => {
893
893
  A failed login usually redirects back carrying validation errors rather than a
894
894
  status code, so assert on the errors:
895
895
 
896
- ```typescript
896
+ ```typescript fragment
897
897
  it("login with wrong password redirects back", async () => {
898
898
  const user = await UserFactory.create({ password: await Hash.make("correct") });
899
899
 
@@ -910,7 +910,7 @@ A bearer guard reads a header rather than a cookie, so `actingAs` plays no part
910
910
  issue a token and send it the way a client would. `createToken` returns the
911
911
  plain-text value once, which is the value the header carries:
912
912
 
913
- ```typescript
913
+ ```typescript fragment
914
914
  import { createToken } from "@zerotal/auth";
915
915
  import { DB } from "@zerotal/orm";
916
916
 
@@ -933,7 +933,7 @@ it("serves the API with a valid token", async () => {
933
933
  By default the client hands back the redirect itself, which is what a login flow
934
934
  should assert on. When the page the user lands on is the point, ask for it:
935
935
 
936
- ```typescript
936
+ ```typescript fragment
937
937
  const res = await testApp.followingRedirects().post("/login", { email, password });
938
938
  res.assertOk();
939
939
  res.assertSee("Welcome back");
@@ -77,7 +77,7 @@ class User extends Model.using(Authenticatable, Permissions, Roles) {}
77
77
  - **Direct permissions only** (`Permissions(AuthUser)`) — no roles, e.g. API
78
78
  keys or service accounts.
79
79
 
80
- ```typescript
80
+ ```typescript fragment
81
81
  // app/models/*.ts
82
82
  class User extends Roles(Permissions(AuthUser)) {} // roles + direct permissions
83
83
  class Team extends AuthUser.using(Roles) {} // roles only (permissions via roles)
@@ -86,7 +86,7 @@ class ApiKey extends AuthUser.using(Permissions) {} // direct permissions only
86
86
 
87
87
  Each mixin exposes a per-model static flag to toggle its eager loading:
88
88
 
89
- ```typescript
89
+ ```typescript fragment
90
90
  // app/models/User.ts
91
91
  class User extends Roles(Permissions(AuthUser)) {
92
92
  static withRoles = true; // default — eager-load roles (+ their permissions)
@@ -124,7 +124,7 @@ every query **auto-eager-loads** its relations — roles (and each role's
124
124
  permissions) and direct permissions. That means `can()` / `hasRole()` resolve from
125
125
  memory with **no extra queries**, even across a whole collection (no N+1):
126
126
 
127
- ```typescript
127
+ ```typescript fragment
128
128
  // in a controller
129
129
  const users = await User.query().get(); // roles + permissions already loaded
130
130
  for (const u of users) u.can("post.publish"); // synchronous, zero further queries
@@ -142,7 +142,7 @@ checks then read from memory.
142
142
  Names are auto-created the first time you use them. Writes are `await`-ed and
143
143
  refresh the in-memory memo automatically:
144
144
 
145
- ```typescript
145
+ ```typescript fragment
146
146
  // in a controller / seeder
147
147
  import { Role } from "@zerotal/auth";
148
148
 
@@ -166,7 +166,7 @@ await user.givePermissionTo("billing.refund");
166
166
  Checks are **synchronous** — they read from the eager-loaded relations (on by
167
167
  default) or the per-instance memo:
168
168
 
169
- ```typescript
169
+ ```typescript fragment
170
170
  // in a controller
171
171
  user.hasRole("editor"); // boolean (Roles mixin)
172
172
  user.hasAnyRole(["editor", "admin"]);
@@ -221,7 +221,7 @@ Two route-guard middleware enforce abilities and roles before the controller
221
221
  runs. Both throw `UnauthorizedError` (401) when the request is unauthenticated
222
222
  and `ForbiddenError` (403) when the user is authenticated but lacks access:
223
223
 
224
- ```typescript
224
+ ```typescript fragment
225
225
  // routes/web.ts
226
226
  import { RequirePermissionMiddleware, RequireRoleMiddleware } from "@zerotal/auth";
227
227
 
@@ -257,7 +257,7 @@ Gate.authorize("post.publish"); // throws ForbiddenError if not
257
257
 
258
258
  In a model policy, defer to permissions:
259
259
 
260
- ```typescript
260
+ ```typescript fragment
261
261
  // app/policies/PostPolicy.ts
262
262
  import { Policy } from "@zerotal/auth";
263
263
 
@@ -275,7 +275,7 @@ Gate.allows("update", post);
275
275
  Give a role unconditional access (super admin) with one line — it registers a
276
276
  before-hook that short-circuits every Gate check, including policies:
277
277
 
278
- ```typescript
278
+ ```typescript fragment
279
279
  // app/providers/AppProvider.ts (boot)
280
280
  Gate.superAdmin(); // users with the 'super-admin' role bypass all checks
281
281
  Gate.superAdmin("owner"); // or name your own bypass role
@@ -283,7 +283,7 @@ Gate.superAdmin("owner"); // or name your own bypass role
283
283
 
284
284
  And on the `Auth` facade, `authorize` throws for the current user:
285
285
 
286
- ```typescript
286
+ ```typescript fragment
287
287
  // in a controller
288
288
  Auth.authorize("post.publish"); // ForbiddenError if the current user can't
289
289
  ```
@@ -349,7 +349,7 @@ If you started with a simple `roles: string[]` column, move to relational by:
349
349
  json `roles` column.
350
350
  3. Backfill, reading the old values before you drop the column:
351
351
 
352
- ```typescript
352
+ ```typescript fragment
353
353
  // database/seeders/BackfillRolesSeeder.ts
354
354
  for (const u of await User.query().get()) {
355
355
  const legacy = (u as any).roles ?? (u as any).role;
@@ -392,7 +392,7 @@ below assumes `createApp()` from your `tests/helpers.ts`.
392
392
  **A policy is a plain class**, so the cheapest and most valuable test needs no
393
393
  application at all:
394
394
 
395
- ```typescript
395
+ ```typescript fragment
396
396
  // tests/policies/PostPolicy.test.ts
397
397
  import { test, expect } from "bun:test";
398
398
  import { PostPolicy } from "../../app/policies/PostPolicy.ts";
@@ -409,7 +409,7 @@ test("only the author may update a post", () => {
409
409
  **Roles and permissions are database rows**, so grant them in the test and check
410
410
  them the way a controller would:
411
411
 
412
- ```typescript
412
+ ```typescript fragment
413
413
  // tests/authorization/roles.test.ts
414
414
  import { test, expect } from "bun:test";
415
415
  import { createApp } from "../helpers.ts";
@@ -430,7 +430,7 @@ test("an editor inherits the role's permissions", async () => {
430
430
  **A guarded route is checked through `actingAs()`** — `assertForbidden()` is the
431
431
  assertion that proves the gate is wired, not just defined:
432
432
 
433
- ```typescript
433
+ ```typescript fragment
434
434
  // tests/http/posts.test.ts
435
435
  const res = await app.actingAs(stranger).delete("/posts/1");
436
436
 
@@ -39,7 +39,7 @@ broadcasting analogue of `routes/web.ts`. It's loaded automatically at boot.
39
39
  Patterns use the file-routing **`[param]` placeholder syntax**. Each `[param]` matches one channel
40
40
  segment and is passed to the callback positionally after the authenticated user.
41
41
 
42
- ```ts
42
+ ```ts fragment
43
43
  // routes/channels.ts
44
44
  import { Broadcast } from "@zerotal/broadcasting";
45
45
  import { Order } from "../app/models/Order.ts";
@@ -98,7 +98,7 @@ writes an `app/events/*.ts` with `broadcastOn()`/`broadcastWith()` stubbed.
98
98
  Presence channels track who is currently subscribed, enabling "who's online" lists. The
99
99
  authorization rule for a presence channel returns the **member data** instead of a boolean:
100
100
 
101
- ```ts
101
+ ```ts fragment
102
102
  // routes/channels.ts
103
103
  Broadcast.channel("chat.[roomId]", (user: User, roomId: string) => {
104
104
  if (!user.canJoin(Number(roomId))) return null;
@@ -59,7 +59,7 @@ rules and signs the result with the app's `APP_KEY`, and the client echoes the s
59
59
  or other headers via `auth.headers`, or set `authEndpoint: false` to skip the fetch and rely on
60
60
  connection-level authorization instead:
61
61
 
62
- ```ts
62
+ ```ts fragment
63
63
  // in your client code
64
64
  const socket = new Socket({
65
65
  auth: { headers: { "X-CSRF-TOKEN": csrf } }, // sent on POST /broadcasting/auth
@@ -76,7 +76,7 @@ client.
76
76
  The server is also Pusher-protocol compatible, so the reference
77
77
  [pusher-js](https://github.com/pusher/pusher-js) client works unchanged:
78
78
 
79
- ```ts
79
+ ```ts fragment
80
80
  // in your client code
81
81
  import Pusher from "pusher-js";
82
82
 
@@ -7,7 +7,7 @@ description: Write a broadcast event, dispatch it, and broadcast model changes a
7
7
 
8
8
  You only have to implement `broadcastOn()`; the rest default sensibly.
9
9
 
10
- ```ts
10
+ ```ts fragment
11
11
  // app/events/OrderShipmentStatusUpdated.ts
12
12
  import { BroadcastingEvent, privateChannel } from "@zerotal/broadcasting";
13
13
  import type { Order } from "../models/Order.ts";
@@ -48,7 +48,7 @@ export class OrderShipmentStatusUpdated extends BroadcastingEvent {
48
48
 
49
49
  Three ways to broadcast, in order of ergonomics:
50
50
 
51
- ```ts
51
+ ```ts fragment
52
52
  // in a controller
53
53
  import { broadcast, Broadcast } from "@zerotal/broadcasting";
54
54
 
@@ -75,7 +75,7 @@ several statements; otherwise treat `broadcast(...)` as the whole call.
75
75
 
76
76
  A broadcastable event emitted on the [event bus](/docs/events) is broadcast automatically:
77
77
 
78
- ```ts
78
+ ```ts fragment
79
79
  // in a controller
80
80
  import { Events } from "zerotal";
81
81
 
@@ -84,7 +84,7 @@ Events.emit(new OrderShipmentStatusUpdated(order)); // runs listeners AND broadc
84
84
 
85
85
  Broadcast to multiple channels by returning an array from `broadcastOn()`:
86
86
 
87
- ```ts
87
+ ```ts fragment
88
88
  // in an event's broadcastOn()
89
89
  broadcastOn() {
90
90
  return [privateChannel(`orders.${this.order.id}`), privateChannel(`users.${this.order.userId}`)];
@@ -95,7 +95,7 @@ broadcastOn() {
95
95
 
96
96
  When a full event class is overkill, broadcast inline:
97
97
 
98
- ```ts
98
+ ```ts fragment
99
99
  // in a controller
100
100
  Broadcast.on(`orders.${order.id}`).as("OrderPlaced").with(order).toOthers().send();
101
101
  Broadcast.private(`orders.${order.id}`).as("OrderPlaced").with({ id: order.id }).send();
@@ -110,7 +110,7 @@ Broadcast.to("posts", "PostViewed", { id: post.id, viewedAt: Date.now() });
110
110
  Get the members currently subscribed to a presence channel (real driver only —
111
111
  returns `[]` under the fake/null driver):
112
112
 
113
- ```ts
113
+ ```ts fragment
114
114
  // in a controller
115
115
  const members = Broadcast.getMembers("presence-chat.room1");
116
116
  // → [{ id, info }, …]
@@ -122,7 +122,7 @@ Broadcast a model's lifecycle changes by mapping them to a `BroadcastingEvent` t
122
122
  `dispatchesEvents`. When the model fires the event on the [event bus](/docs/events), it is
123
123
  broadcast automatically (see [dispatching](#dispatching-events)) — no manual broadcast call.
124
124
 
125
- ```ts
125
+ ```ts fragment
126
126
  // app/events/PostCreated.ts
127
127
  import { BroadcastingEvent, privateChannel } from "@zerotal/broadcasting";
128
128
  import type { Post } from "../models/Post.ts";
@@ -140,7 +140,7 @@ export class PostCreated extends BroadcastingEvent {
140
140
  }
141
141
  ```
142
142
 
143
- ```ts
143
+ ```ts fragment
144
144
  // app/models/Post.ts
145
145
  @table("posts")
146
146
  export class Post extends Model {
@@ -164,7 +164,6 @@ import { broadcastsModelEvents, privateChannel } from "@zerotal/broadcasting";
164
164
 
165
165
  @table("orders")
166
166
  export class Order extends Model {
167
- @column() id!: number;
168
167
  @column() status!: string;
169
168
  }
170
169
 
@@ -179,7 +178,7 @@ broadcastsModelEvents(Order, {
179
178
  The wire event name defaults to `${ModelName}${Event}` (e.g. `OrderUpdated`) and the payload to
180
179
  `{ order }` (the model under its camel-cased name). On the client:
181
180
 
182
- ```ts
181
+ ```ts fragment
183
182
  // in your client code
184
183
  Socket.private(`orders.${id}`).listen("OrderUpdated", (e) => render(e.order));
185
184
  ```