@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,529 @@
1
+ ---
2
+ title: Authorization (Roles & Permissions)
3
+ description: Authorize users against database-backed roles and permissions composed onto your models.
4
+ ---
5
+
6
+ # Authorization (Roles & Permissions)
7
+
8
+ Authorize users against database-backed roles and permissions. You compose two mixins onto a model, then check abilities synchronously with `can()` and `hasRole()`.
9
+
10
+ Zerotal's authorization is **relational and DB-backed**, composed from two
11
+ independent mixins you apply to a model:
12
+
13
+ - **`Roles`** — gives the model roles (a polymorphic `model_roles` pivot).
14
+ Roles carry their own permissions, so this alone supports `hasRole()` and
15
+ permission checks granted _through_ roles.
16
+ - **`Permissions`** — gives the model permissions granted **directly**
17
+ (a polymorphic `model_permissions` pivot).
18
+
19
+ Apply either on its own, or both together — in any order. When both are present,
20
+ `can()` resolves over the **union** of direct and role-derived permissions.
21
+
22
+ ```typescript
23
+ // app/models/User.ts
24
+ import { AuthUser, Roles, Permissions } from "@zerotal/auth";
25
+ import { column, table } from "@zerotal/orm";
26
+
27
+ @(table("users").withTimestamps())
28
+ export class User extends Roles(Permissions(AuthUser)) {
29
+ @column() name!: string;
30
+ @column() email!: string;
31
+ // note: do NOT also declare a json `roles` column — the relation is named `roles`.
32
+ }
33
+ ```
34
+
35
+ ## Getting Started
36
+
37
+ Roles and permissions ship inside `@zerotal/auth` — if you have followed
38
+ [Authentication](/docs/authentication) there is nothing further to install or
39
+ register.
40
+
41
+ ```bash
42
+ # in your project root
43
+ bun add @zerotal/auth
44
+ ```
45
+
46
+ `AuthProvider` registers the `Gate` and the RBAC relations, so adding it to
47
+ `bootstrap/providers.ts` is all the setup authorization needs. The pivot tables
48
+ come from the package's migrations — see [Schema](#schema).
49
+
50
+ ## Composing the mixins
51
+
52
+ The mixins come from `@zerotal/auth`; `AuthUser` is the authenticatable base
53
+ (`Authenticatable(Model)`). Compose them in either the nested form or the
54
+ flat `Model.using` form — both are equivalent:
55
+
56
+ ```typescript
57
+ // app/models/User.ts — nested form
58
+ import { AuthUser, Roles, Permissions } from "@zerotal/auth";
59
+
60
+ class User extends Roles(Permissions(AuthUser)) {}
61
+ ```
62
+
63
+ ```typescript
64
+ // app/models/User.ts — flat form (no wrapper nesting)
65
+ import { Model } from "@zerotal/orm";
66
+ import { Authenticatable, Roles, Permissions } from "@zerotal/auth";
67
+
68
+ class User extends Model.using(Authenticatable, Permissions, Roles) {}
69
+ ```
70
+
71
+ ### Which mixins do I need?
72
+
73
+ - **Roles + direct permissions** (`Roles(Permissions(AuthUser))`) — the common
74
+ case for app users.
75
+ - **Roles only** (`Roles(AuthUser)`) — permissions still work, granted through
76
+ roles. Good for coarse models like a `Team`.
77
+ - **Direct permissions only** (`Permissions(AuthUser)`) — no roles, e.g. API
78
+ keys or service accounts.
79
+
80
+ ```typescript
81
+ // app/models/*.ts
82
+ class User extends Roles(Permissions(AuthUser)) {} // roles + direct permissions
83
+ class Team extends AuthUser.using(Roles) {} // roles only (permissions via roles)
84
+ class ApiKey extends AuthUser.using(Permissions) {} // direct permissions only
85
+ ```
86
+
87
+ Each mixin exposes a per-model static flag to toggle its eager loading:
88
+
89
+ ```typescript
90
+ // app/models/User.ts
91
+ class User extends Roles(Permissions(AuthUser)) {
92
+ static withRoles = true; // default — eager-load roles (+ their permissions)
93
+ static withPermissions = true; // default — eager-load direct permissions
94
+ }
95
+ ```
96
+
97
+ ## Schema
98
+
99
+ The `roles` and `permissions` tables are ORM models, so they're created by
100
+ schema sync / your generated migrations. The three **polymorphic pivots** aren't
101
+ models — `AuthProvider` provisions them on boot (idempotently) the moment a
102
+ registered model composes `Roles` or `Permissions`, so RBAC works with no extra
103
+ migration:
104
+
105
+ ```text
106
+ roles id · name · guard · label · timestamps (ORM model)
107
+ permissions id · name · guard · label · timestamps (ORM model)
108
+ role_permissions role_id · permission_id (auto-provisioned pivot)
109
+ model_roles role_id · model_type · model_id (auto-provisioned pivot)
110
+ model_permissions permission_id · model_type · model_id (auto-provisioned pivot)
111
+ ```
112
+
113
+ The pivots are **polymorphic** (`model_type` + `model_id`), so any model — not
114
+ just `User` — can hold roles and permissions.
115
+
116
+ > **Note** — Pivot provisioning runs in the `web` and `test` environments and
117
+ > swallows DDL errors, so a DB-less runtime still boots. Generate real migrations
118
+ > for production if you'd rather control the schema explicitly.
119
+
120
+ ## Eager loading is on by default
121
+
122
+ Each mixin registers a global scope (`rolesEagerLoad`, `permissionsEagerLoad`) so
123
+ every query **auto-eager-loads** its relations — roles (and each role's
124
+ permissions) and direct permissions. That means `can()` / `hasRole()` resolve from
125
+ memory with **no extra queries**, even across a whole collection (no N+1):
126
+
127
+ ```typescript
128
+ // in a controller
129
+ const users = await User.query().get(); // roles + permissions already loaded
130
+ for (const u of users) u.can("post.publish"); // synchronous, zero further queries
131
+ ```
132
+
133
+ Turn it off per model with the static flags (`static withRoles = false`), or per
134
+ query with `User.query().withoutGlobalScope("rolesEagerLoad")` (and/or
135
+ `"permissionsEagerLoad"`). When the relations aren't loaded, call
136
+ `await user.loadAuthorization()` once to warm the memo (or eager-load explicitly
137
+ with `User.query().with("roles.permissions", "permissions")`) — the synchronous
138
+ checks then read from memory.
139
+
140
+ ## Assigning roles & permissions
141
+
142
+ Names are auto-created the first time you use them. Writes are `await`-ed and
143
+ refresh the in-memory memo automatically:
144
+
145
+ ```typescript
146
+ // in a controller / seeder
147
+ import { Role } from "@zerotal/auth";
148
+
149
+ // Roles (Roles mixin)
150
+ await user.assignRole("editor", "viewer"); // one or many
151
+ await user.removeRole("viewer");
152
+ await user.syncRoles(["admin"]); // replace the whole set
153
+
154
+ // Permissions on a role
155
+ const editor = await Role.resolve("editor");
156
+ await editor.givePermissionTo("post.create", "post.update");
157
+ await editor.revokePermissionTo("post.update");
158
+ await editor.syncPermissions(["post.create", "post.publish"]);
159
+
160
+ // Direct permissions on a user (bypassing roles) (Permissions mixin)
161
+ await user.givePermissionTo("billing.refund");
162
+ ```
163
+
164
+ ## Checking
165
+
166
+ Checks are **synchronous** — they read from the eager-loaded relations (on by
167
+ default) or the per-instance memo:
168
+
169
+ ```typescript
170
+ // in a controller
171
+ user.hasRole("editor"); // boolean (Roles mixin)
172
+ user.hasAnyRole(["editor", "admin"]);
173
+ user.hasAllRoles(["editor", "author"]);
174
+
175
+ user.hasPermissionTo("post.create"); // direct OR via a role
176
+ user.can("post.update"); // alias of hasPermissionTo
177
+ user.getRoleNames(); // string[] (Roles mixin)
178
+ user.getAllPermissions(); // string[] — effective set (direct ∪ via-role)
179
+ ```
180
+
181
+ > **Warning** — Checks throw if the model wasn't eager-loaded and `loadAuthorization()` hasn't run. Fetch via a query (eager loading is on by default) or call `await user.loadAuthorization()` first. Writes auto-refresh the data, so a check immediately after `await user.assignRole(...)` works.
182
+
183
+ ### From the Auth facade
184
+
185
+ The same checks are available on the [`Auth`](/docs/authentication) facade for the
186
+ **current** request's user (and return `false`/`[]` for guests, so you never need
187
+ a null check):
188
+
189
+ ```typescript
190
+ // in a controller
191
+ import { Auth } from "@zerotal/auth";
192
+
193
+ Auth.hasRole("editor");
194
+ Auth.hasAnyRole(["editor", "admin"]);
195
+ Auth.can("post.update");
196
+ Auth.hasPermission("post.publish"); // alias of can()
197
+ Auth.roles(); // string[]
198
+ ```
199
+
200
+ These delegate to whatever the user model implements — so `hasRole`/`roles`
201
+ require the `Roles` mixin, while `can`/`hasPermission` work with either mixin.
202
+
203
+ ### Wildcards
204
+
205
+ A permission can be a wildcard. `*` grants everything; `post.*` grants every
206
+ `post.…` ability:
207
+
208
+ ```typescript
209
+ // in a seeder
210
+ import { Role } from "@zerotal/auth";
211
+
212
+ const admin = await Role.resolve("admin");
213
+ await admin.givePermissionTo("post.*"); // post.create, post.delete, …
214
+ const root = await Role.resolve("super-admin");
215
+ await root.givePermissionTo("*"); // everything
216
+ ```
217
+
218
+ ## Guarding routes
219
+
220
+ Two route-guard middleware enforce abilities and roles before the controller
221
+ runs. Both throw `UnauthorizedError` (401) when the request is unauthenticated
222
+ and `ForbiddenError` (403) when the user is authenticated but lacks access:
223
+
224
+ ```typescript
225
+ // routes/web.ts
226
+ import { RequirePermissionMiddleware, RequireRoleMiddleware } from "@zerotal/auth";
227
+
228
+ Router.post("/posts/:id/publish", PostController, "publish", [
229
+ RequirePermissionMiddleware.for("post.publish"), // 401 if guest, 403 if denied
230
+ ]);
231
+
232
+ Router.get("/admin", AdminController, "index", [
233
+ RequireRoleMiddleware.for("admin"), // 403 unless the user hasRole('admin')
234
+ ]);
235
+ ```
236
+
237
+ `RequirePermissionMiddleware` reads `user.can()` (so the model needs `Roles`
238
+ or `Permissions`); `RequireRoleMiddleware` reads `user.hasRole()` (needs
239
+ `Roles`). Either denies with `ForbiddenError` if the model doesn't expose the
240
+ method. Both `.for(...)` factories accept several names with **OR** semantics.
241
+
242
+ > **Tip** — Check **permissions** in your code (`can('post.update')`), not roles. Roles are just bundles of permissions, and what a role grants should be free to change without editing code. Reserve `hasRole` for coarse, role-shaped decisions (e.g. "is this an admin area?").
243
+
244
+ ## Gate & policies
245
+
246
+ Permissions flow through the existing [`Gate`](/docs/authentication) automatically —
247
+ any ability name not handled by a closure or a model policy falls back to the
248
+ user's `can()` (wildcard aware), so you don't have to register anything:
249
+
250
+ ```typescript
251
+ // in a controller
252
+ import { Gate } from "@zerotal/auth";
253
+
254
+ Gate.allows("post.publish"); // true if the user has the permission
255
+ Gate.authorize("post.publish"); // throws ForbiddenError if not
256
+ ```
257
+
258
+ In a model policy, defer to permissions:
259
+
260
+ ```typescript
261
+ // app/policies/PostPolicy.ts
262
+ import { Policy } from "@zerotal/auth";
263
+
264
+ class PostPolicy extends Policy<Post> {
265
+ update(user: User, post: Post) {
266
+ return user.id === post.authorId || user.can("post.update");
267
+ }
268
+ }
269
+ Gate.registerPolicy(Post, PostPolicy);
270
+ Gate.allows("update", post);
271
+ ```
272
+
273
+ > **Tip** — Policies are auto-discovered. Drop `PostPolicy extends Policy<Post>` in `app/policies/` and it's registered for `Post` automatically (the `Policy` suffix is matched to the model; override with `static model = Post`). No `Gate.registerPolicy(...)` call needed — that explicit form stays available for policies you'd rather wire by hand. See [Conventions](/docs/conventions).
274
+
275
+ Give a role unconditional access (super admin) with one line — it registers a
276
+ before-hook that short-circuits every Gate check, including policies:
277
+
278
+ ```typescript
279
+ // app/providers/AppProvider.ts (boot)
280
+ Gate.superAdmin(); // users with the 'super-admin' role bypass all checks
281
+ Gate.superAdmin("owner"); // or name your own bypass role
282
+ ```
283
+
284
+ And on the `Auth` facade, `authorize` throws for the current user:
285
+
286
+ ```typescript
287
+ // in a controller
288
+ Auth.authorize("post.publish"); // ForbiddenError if the current user can't
289
+ ```
290
+
291
+ ## Declaring permissions in code
292
+
293
+ Declare the permission names your app uses (reviewable in source, present on every
294
+ deploy), then sync them to the database:
295
+
296
+ ```typescript
297
+ // app/providers/AppProvider.ts (boot)
298
+ import { definePermission } from "@zerotal/auth";
299
+
300
+ definePermission("post.create", "post.update", "post.publish", "user.manage");
301
+ ```
302
+
303
+ ```bash
304
+ # in your project root
305
+ bun zt auth:sync-permissions # idempotent — creates any missing permissions
306
+ ```
307
+
308
+ `auth:sync-permissions` accepts a `--guard` flag (default `web`).
309
+ `Role.resolve` / `Permission.resolve` keep a process-level name→id cache, so bulk
310
+ assignment and seeding don't re-query for the same names.
311
+
312
+ ## Seeding
313
+
314
+ ```typescript
315
+ // database/seeders/RbacSeeder.ts
316
+ import { Seeder } from "@zerotal/orm";
317
+ import { Role, Permission } from "@zerotal/auth";
318
+
319
+ export class RbacSeeder extends Seeder {
320
+ async run(): Promise<void> {
321
+ await Permission.resolve("post.create");
322
+ await Permission.resolve("post.publish");
323
+
324
+ const editor = await Role.resolve("editor");
325
+ await editor.givePermissionTo("post.create");
326
+
327
+ const admin = await Role.resolve("admin");
328
+ await admin.givePermissionTo("post.*");
329
+ }
330
+ }
331
+ ```
332
+
333
+ ## Admin UI
334
+
335
+ When [`@zerotal/admin`](/docs/admin) is installed, **Roles** and **Permissions**
336
+ appear under an **Auth** group automatically, so you can manage them at runtime.
337
+ Many-to-many relations (a user's roles, a role's permissions) render as a
338
+ **multi-select** in the create/edit form — so you can assign roles to a user or
339
+ permissions to a role right in the admin. Relations you mark `readonly` (e.g.
340
+ roles gated to super admins) are read-only there too.
341
+
342
+ ## Migrating from JSON roles
343
+
344
+ If you started with a simple `roles: string[]` column, move to relational by:
345
+
346
+ 1. Provision the RBAC tables (`roles`/`permissions` via schema sync or a
347
+ migration; the pivots are auto-created on boot).
348
+ 2. Switch the model to `extends Roles(Permissions(AuthUser))` and **remove** the
349
+ json `roles` column.
350
+ 3. Backfill, reading the old values before you drop the column:
351
+
352
+ ```typescript
353
+ // database/seeders/BackfillRolesSeeder.ts
354
+ for (const u of await User.query().get()) {
355
+ const legacy = (u as any).roles ?? (u as any).role;
356
+ const names = Array.isArray(legacy) ? legacy : legacy ? [legacy] : [];
357
+ if (names.length) await u.assignRole(...names);
358
+ }
359
+ ```
360
+
361
+ ## Troubleshooting
362
+
363
+ **"Authorization is not loaded on this instance."** A synchronous check ran on a
364
+ model that wasn't eager-loaded and whose memo wasn't warmed — e.g. a freshly
365
+ `new`-ed instance, or one fetched with `withRoles = false`. Fetch it via a query
366
+ (eager loading is on by default), or `await user.loadAuthorization()` first.
367
+ Writes refresh automatically, so a check right after `await user.assignRole(...)`
368
+ is fine.
369
+
370
+ **A permission check always returns `false`.** The permission row may not exist
371
+ yet. Names are auto-created by `assignRole`/`givePermissionTo`, but if you check
372
+ before granting, declare it with `definePermission(...)` and run
373
+ `auth:sync-permissions`. Also confirm the **guard** matches (default `web`).
374
+
375
+ **`can()` ignores a role's new permission.** Effective permissions are memoised
376
+ per instance. After changing a _role's_ permissions, re-fetch the user (or call
377
+ `await user.loadAuthorization()`) so the memo rebuilds — assigning/revoking on the
378
+ user itself refreshes automatically.
379
+
380
+ **`hasRole`/`assignRole` is undefined.** That model wasn't composed with
381
+ `Roles` (likewise `givePermissionTo` needs `Permissions`). Add the mixin:
382
+ `class User extends Roles(Permissions(AuthUser))`.
383
+
384
+ **Don't add a json `roles` column.** A `Roles` model already has a `roles`
385
+ relation; a same-named json column would collide.
386
+
387
+ ## Testing
388
+
389
+ Set your suite up once as described in [Testing](/docs/testing) — everything
390
+ below assumes `createApp()` from your `tests/helpers.ts`.
391
+
392
+ **A policy is a plain class**, so the cheapest and most valuable test needs no
393
+ application at all:
394
+
395
+ ```typescript
396
+ // tests/policies/PostPolicy.test.ts
397
+ import { test, expect } from "bun:test";
398
+ import { PostPolicy } from "../../app/policies/PostPolicy.ts";
399
+
400
+ test("only the author may update a post", () => {
401
+ const policy = new PostPolicy();
402
+ const post = { id: 1, userId: 7 };
403
+
404
+ expect(policy.update({ id: 7 }, post)).toBe(true);
405
+ expect(policy.update({ id: 8 }, post)).toBe(false);
406
+ });
407
+ ```
408
+
409
+ **Roles and permissions are database rows**, so grant them in the test and check
410
+ them the way a controller would:
411
+
412
+ ```typescript
413
+ // tests/authorization/roles.test.ts
414
+ import { test, expect } from "bun:test";
415
+ import { createApp } from "../helpers.ts";
416
+ import { User } from "../../app/models/User.ts";
417
+
418
+ test("an editor inherits the role's permissions", async () => {
419
+ const app = await createApp();
420
+ const user = await User.create({ email: "editor@example.com" });
421
+ await user.assignRole("editor"); // the editor role grants posts.publish
422
+
423
+ expect(await user.can("posts.publish")).toBe(true);
424
+ expect(await user.can("users.delete")).toBe(false);
425
+
426
+ await app.close();
427
+ });
428
+ ```
429
+
430
+ **A guarded route is checked through `actingAs()`** — `assertForbidden()` is the
431
+ assertion that proves the gate is wired, not just defined:
432
+
433
+ ```typescript
434
+ // tests/http/posts.test.ts
435
+ const res = await app.actingAs(stranger).delete("/posts/1");
436
+
437
+ res.assertForbidden();
438
+ ```
439
+
440
+ > **Warning** — Assert with the async forms (`allowsAsync`, `authorizeAsync`,
441
+ > `can`) whenever an ability touches the database. The synchronous forms treat a
442
+ > returned Promise as truthy, so a test written against them **passes even when
443
+ > the ability should deny** — the one failure mode in authorization that a green
444
+ > suite will hide from you.
445
+
446
+ ## References
447
+
448
+ Synchronous reads (`hasRole`, `can`, …) require the relations to be loaded; the
449
+ async writes refresh the memo for you.
450
+
451
+ ### On a model — `Roles` mixin
452
+
453
+ | Method | Signature | Description |
454
+ | -------------- | --------------------------------------------------------- | ---------------------------------------------- |
455
+ | `assignRole` | `(...roles: (string \| number \| Role)[]): Promise<this>` | Assign one or more roles (created if missing). |
456
+ | `removeRole` | `(...roles: (string \| number \| Role)[]): Promise<this>` | Remove one or more roles. |
457
+ | `syncRoles` | `(roles: (string \| number \| Role)[]): Promise<this>` | Replace the whole role set. |
458
+ | `hasRole` | `(role: string): boolean` | True if the model holds the role. |
459
+ | `hasAnyRole` | `(roles: string[]): boolean` | True if it holds at least one of the roles. |
460
+ | `hasAllRoles` | `(roles: string[]): boolean` | True if it holds every one of the roles. |
461
+ | `getRoleNames` | `(): string[]` | The model's role names. |
462
+ | `loadRoles` | `(): Promise<this>` | Warm the role slice of the memo from the DB. |
463
+
464
+ ### On a model — `Permissions` mixin
465
+
466
+ | Method | Signature | Description |
467
+ | -------------------- | --------------------------------------------------------------- | ---------------------------------------------- |
468
+ | `givePermissionTo` | `(...perms: (string \| number \| Permission)[]): Promise<this>` | Grant direct permissions (created if missing). |
469
+ | `revokePermissionTo` | `(...perms: (string \| number \| Permission)[]): Promise<this>` | Revoke direct permissions. |
470
+ | `syncPermissions` | `(perms: (string \| number \| Permission)[]): Promise<this>` | Replace the direct-permission set. |
471
+ | `loadPermissions` | `(): Promise<this>` | Warm the direct-permission memo from the DB. |
472
+
473
+ ### On a model — effective checks (either mixin)
474
+
475
+ | Method | Signature | Description |
476
+ | ------------------- | ---------------------------- | -------------------------------------------------------- |
477
+ | `can` | `(ability: string): boolean` | Alias of `hasPermissionTo` — the canonical check. |
478
+ | `hasPermissionTo` | `(ability: string): boolean` | True if granted directly or via a role (wildcard-aware). |
479
+ | `getAllPermissions` | `(): string[]` | Effective permission set (direct ∪ via-role). |
480
+ | `loadAuthorization` | `(): Promise<this>` | Warm every slice the model composed in. |
481
+
482
+ ### `Auth` facade (current user)
483
+
484
+ | Method | Signature | Description |
485
+ | -------------------- | ---------------------------- | ----------------------------------------------- |
486
+ | `Auth.hasRole` | `(role: string): boolean` | Current user has the role (`false` for guests). |
487
+ | `Auth.hasAnyRole` | `(roles: string[]): boolean` | Current user has any of the roles. |
488
+ | `Auth.hasAllRoles` | `(roles: string[]): boolean` | Current user has all of the roles. |
489
+ | `Auth.can` | `(ability: string): boolean` | Current user has the ability. |
490
+ | `Auth.hasPermission` | `(ability: string): boolean` | Alias of `Auth.can`. |
491
+ | `Auth.authorize` | `(ability: string): void` | Throws `ForbiddenError` unless allowed. |
492
+ | `Auth.roles` | `(): string[]` | Current user's role names (`[]` for guests). |
493
+
494
+ ### `Role` (model)
495
+
496
+ | Method | Signature | Description |
497
+ | -------------------- | --------------------------------------------------------------- | ----------------------------------------------- |
498
+ | `Role.resolve` | `(name: string, guard?: string): Promise<Role>` | Find-or-create a role by name (+ guard). |
499
+ | `givePermissionTo` | `(...perms: (string \| number \| Permission)[]): Promise<this>` | Grant permissions to the role. |
500
+ | `revokePermissionTo` | `(...perms: (string \| number \| Permission)[]): Promise<this>` | Revoke permissions from the role. |
501
+ | `syncPermissions` | `(perms: (string \| number \| Permission)[]): Promise<this>` | Replace the role's permissions. |
502
+ | `permissionNames` | `(): Promise<string[]>` | Names of the permissions on the role. |
503
+ | `hasPermission` | `(name: string): Promise<boolean>` | Exact-match check for a permission on the role. |
504
+
505
+ ### `Permission` (model)
506
+
507
+ | Method | Signature | Description |
508
+ | -------------------- | ----------------------------------------------------- | ------------------------------------ |
509
+ | `Permission.resolve` | `(name: string, guard?: string): Promise<Permission>` | Find-or-create a permission by name. |
510
+ | `definePermission` | `(...names: (string \| string[])[]): void` | Declare permission names in code. |
511
+
512
+ ### `Gate` facade
513
+
514
+ | Method | Signature | Description |
515
+ | --------------------- | -------------------------------------------- | ----------------------------------------------------- |
516
+ | `Gate.allows` | `(ability: string, model?: object): boolean` | True if the current user may perform the ability. |
517
+ | `Gate.authorize` | `(ability: string, model?: object): void` | Throws `ForbiddenError` if denied. |
518
+ | `Gate.registerPolicy` | `(ModelClass, PolicyClass): void` | Register a policy for a model. |
519
+ | `Gate.superAdmin` | `(role?: string): this` | Bypass all checks for a role (default `super-admin`). |
520
+ | `Gate.defineAbility` | `(ability: string, cb): this` | Define a closure-based ability. |
521
+ | `Gate.before` | `(hook): this` | Add a before-hook that can short-circuit checks. |
522
+ | `Gate.via` | `(PolicyClass): { allows, authorize }` | Check against an explicit policy class. |
523
+
524
+ ## Next steps
525
+
526
+ - [Authentication](/docs/authentication) — sign users in before authorizing them.
527
+ - [Roles and 2FA](/docs/roles-and-2fa) — deeper coverage of role workflows.
528
+ - [Middleware](/docs/middleware) — how the route-guard middleware fits the pipeline.
529
+ - [Conventions](/docs/conventions) — auto-discovery rules for policies.