@zerotal/arch 1.7.4 → 1.8.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.
- package/CHANGELOG.md +86 -2
- package/README.md +4 -2
- package/api-surface.md +2 -0
- package/docs/about.md +16 -16
- package/docs/admin/actions.md +9 -9
- package/docs/admin/auth.md +2 -2
- package/docs/admin/dashboard.md +5 -5
- package/docs/admin/extending-ui.md +12 -12
- package/docs/admin/extending.md +16 -7
- package/docs/admin/forms.md +4 -4
- package/docs/admin/index.md +2 -2
- package/docs/admin/operations.md +9 -9
- package/docs/admin/resources.md +3 -3
- package/docs/admin/structure.md +6 -6
- package/docs/admin/tables.md +8 -8
- package/docs/admin/testing.md +4 -4
- package/docs/ai.md +118 -11
- package/docs/application.md +12 -12
- package/docs/arch.md +25 -3
- package/docs/assets.md +11 -11
- package/docs/audit.md +13 -13
- package/docs/authentication.md +39 -39
- package/docs/authorization.md +13 -13
- package/docs/broadcasting/channels.md +2 -2
- package/docs/broadcasting/client.md +2 -2
- package/docs/broadcasting/events.md +9 -10
- package/docs/broadcasting/index.md +1 -1
- package/docs/broadcasting/testing.md +5 -5
- package/docs/cache.md +15 -13
- package/docs/carbon.md +35 -35
- package/docs/changelog.md +268 -1
- package/docs/client/index.md +13 -13
- package/docs/commands.md +36 -4
- package/docs/components.md +116 -115
- package/docs/config-system.md +4 -4
- package/docs/container.md +27 -27
- package/docs/context.md +27 -27
- package/docs/contributing.md +28 -0
- package/docs/controllers.md +10 -10
- package/docs/conventions.md +23 -15
- package/docs/cookies.md +3 -3
- package/docs/csrf.md +6 -6
- package/docs/database.md +22 -17
- package/docs/deployment.md +99 -2
- package/docs/devtools.md +16 -16
- package/docs/email-verification.md +8 -8
- package/docs/encryption.md +7 -7
- package/docs/errors.md +6 -6
- package/docs/events.md +12 -11
- package/docs/flow/components.md +32 -32
- package/docs/flow/decorators.md +18 -18
- package/docs/flow/events.md +16 -16
- package/docs/flow/forms.md +17 -17
- package/docs/flow/icons.md +5 -5
- package/docs/flow/index.md +21 -21
- package/docs/flow/layouts.md +99 -35
- package/docs/flow/lifecycle.md +16 -16
- package/docs/flow/models.md +9 -9
- package/docs/flow/pagination.md +8 -8
- package/docs/flow/performance.md +7 -7
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +26 -26
- package/docs/flow/testing.md +20 -20
- package/docs/getting-started.md +18 -13
- package/docs/health.md +17 -8
- package/docs/helpers.md +17 -17
- package/docs/i18n.md +10 -10
- package/docs/inertia/devtools.md +4 -4
- package/docs/inertia/index.md +6 -6
- package/docs/inertia/props.md +21 -21
- package/docs/inertia/rendering.md +9 -9
- package/docs/inertia/ssr.md +2 -2
- package/docs/lifecycle.md +2 -2
- package/docs/lock.md +11 -11
- package/docs/logger.md +12 -12
- package/docs/media.md +22 -22
- package/docs/middleware.md +17 -17
- package/docs/migrations.md +13 -13
- package/docs/monitor.md +6 -6
- package/docs/notifications.md +24 -24
- package/docs/orm/casts.md +14 -14
- package/docs/orm/factories.md +8 -8
- package/docs/orm/index.md +22 -20
- package/docs/orm/lifecycle.md +13 -13
- package/docs/orm/queries.md +28 -28
- package/docs/orm/relationships.md +16 -16
- package/docs/orm/serialization.md +11 -11
- package/docs/package-development.md +10 -10
- package/docs/pagination.md +14 -14
- package/docs/password-reset.md +6 -6
- package/docs/providers.md +11 -11
- package/docs/query-builder.md +18 -18
- package/docs/queue.md +14 -12
- package/docs/rate-limiting.md +6 -6
- package/docs/responses.md +14 -14
- package/docs/roles-and-2fa.md +9 -9
- package/docs/routing.md +44 -44
- package/docs/scaffolding.md +1 -1
- package/docs/scheduler.md +10 -10
- package/docs/seeding.md +6 -6
- package/docs/session.md +16 -14
- package/docs/social.md +14 -14
- package/docs/storage.md +25 -13
- package/docs/structure.md +2 -2
- package/docs/support-policy.md +23 -10
- package/docs/telemetry.md +12 -12
- package/docs/tenancy.md +34 -40
- package/docs/testing/browser.md +6 -6
- package/docs/testing/console.md +3 -3
- package/docs/testing/database.md +10 -10
- package/docs/testing/flow-browser.md +6 -6
- package/docs/testing/http.md +12 -12
- package/docs/testing/index.md +29 -3
- package/docs/testing/mocking.md +8 -8
- package/docs/upgrade.md +1 -1
- package/docs/validator.md +19 -19
- package/docs/view.md +14 -14
- package/package.json +4 -4
- package/src/config.ts +8 -0
- package/src/index.ts +25 -1
- package/src/install/ArchInstallCommand.ts +46 -3
- package/src/install/guidelines.ts +84 -0
- package/src/install/shape.ts +177 -0
- package/src/install/skills.ts +271 -0
- package/src/provider/ArchProvider.ts +148 -0
- package/src/tools/searchDocs.ts +7 -0
package/docs/assets.md
CHANGED
|
@@ -159,14 +159,14 @@ sufficient for most apps.
|
|
|
159
159
|
|
|
160
160
|
Add `Router.static` to serve `public/` at the root:
|
|
161
161
|
|
|
162
|
-
```typescript
|
|
162
|
+
```typescript fragment
|
|
163
163
|
// routes/index.ts
|
|
164
164
|
Router.static("/", "public");
|
|
165
165
|
```
|
|
166
166
|
|
|
167
167
|
Or target `public/css/` specifically:
|
|
168
168
|
|
|
169
|
-
```typescript
|
|
169
|
+
```typescript fragment
|
|
170
170
|
// routes/index.ts
|
|
171
171
|
Router.static("/css", "public/css");
|
|
172
172
|
```
|
|
@@ -242,7 +242,7 @@ public/
|
|
|
242
242
|
runs as part of the JS build) and boots the Inertia client, resolving each page from
|
|
243
243
|
the generated registry:
|
|
244
244
|
|
|
245
|
-
```typescript
|
|
245
|
+
```typescript fragment
|
|
246
246
|
// resources/js/app.tsx (React)
|
|
247
247
|
import { createInertiaApp, type ResolvedComponent } from "@inertiajs/react";
|
|
248
248
|
import { createRoot } from "react-dom/client";
|
|
@@ -261,7 +261,7 @@ createInertiaApp({
|
|
|
261
261
|
});
|
|
262
262
|
```
|
|
263
263
|
|
|
264
|
-
```typescript
|
|
264
|
+
```typescript fragment
|
|
265
265
|
// resources/js/app.tsx (Vue)
|
|
266
266
|
import { createInertiaApp } from "@inertiajs/vue3";
|
|
267
267
|
import { createApp, h, type DefineComponent } from "vue";
|
|
@@ -393,7 +393,7 @@ the connections that need it most.
|
|
|
393
393
|
|
|
394
394
|
Set a per-extension loader to emit them as separate files instead:
|
|
395
395
|
|
|
396
|
-
```typescript
|
|
396
|
+
```typescript fragment
|
|
397
397
|
// config/app.ts
|
|
398
398
|
export default AppConfig({
|
|
399
399
|
assets: {
|
|
@@ -523,7 +523,7 @@ Bun's bundler understands CSS Modules out of the box. Import a `.module.css` fil
|
|
|
523
523
|
use the generated class names — styles are scoped to the component, so names never
|
|
524
524
|
collide across files:
|
|
525
525
|
|
|
526
|
-
```tsx
|
|
526
|
+
```tsx fragment
|
|
527
527
|
// app/components/Button.tsx
|
|
528
528
|
import styles from "./Button.module.css";
|
|
529
529
|
|
|
@@ -603,7 +603,7 @@ the asset is served, and that a deploy invalidates the old one.
|
|
|
603
603
|
**Assert the page references the asset**, which catches a build config that
|
|
604
604
|
silently stopped emitting it:
|
|
605
605
|
|
|
606
|
-
```typescript
|
|
606
|
+
```typescript fragment
|
|
607
607
|
// tests/http/assets.test.ts
|
|
608
608
|
import { test } from "bun:test";
|
|
609
609
|
import { createApp } from "../helpers.ts";
|
|
@@ -621,7 +621,7 @@ test("the layout links the built stylesheet", async () => {
|
|
|
621
621
|
**Assert it is actually served**, because a reference to a missing file is a
|
|
622
622
|
`404` the page will not tell you about:
|
|
623
623
|
|
|
624
|
-
```typescript
|
|
624
|
+
```typescript fragment
|
|
625
625
|
// tests/http/assets.test.ts
|
|
626
626
|
const css = await app.get("/assets/app.css");
|
|
627
627
|
|
|
@@ -637,7 +637,7 @@ testing only one leaves the failure that actually reaches users uncovered.
|
|
|
637
637
|
carries a `?v=` token that changes on rebuild; in production `ASSET_VERSION`
|
|
638
638
|
drives the Inertia version header:
|
|
639
639
|
|
|
640
|
-
```typescript
|
|
640
|
+
```typescript fragment
|
|
641
641
|
// tests/http/assets.test.ts
|
|
642
642
|
test("asset URLs carry a version in dev", async () => {
|
|
643
643
|
const res = await app.get("/");
|
|
@@ -662,7 +662,7 @@ one obvious job rather than scattering unrelated failures through the whole suit
|
|
|
662
662
|
pinned down without a request at all. This is the cheapest place to catch a
|
|
663
663
|
misconfigured CDN prefix:
|
|
664
664
|
|
|
665
|
-
```typescript
|
|
665
|
+
```typescript fragment
|
|
666
666
|
// tests/unit/asset.test.ts
|
|
667
667
|
import { asset } from "zerotal/assets";
|
|
668
668
|
|
|
@@ -679,7 +679,7 @@ Inertia apps carry an asset version on every page object, and a request whose
|
|
|
679
679
|
bundle do a full reload after a deploy. That flow only fires when the version
|
|
680
680
|
actually changes between builds, so it is worth one test:
|
|
681
681
|
|
|
682
|
-
```typescript
|
|
682
|
+
```typescript fragment
|
|
683
683
|
// tests/http/assets.test.ts
|
|
684
684
|
test("a stale bundle is told to reload", async () => {
|
|
685
685
|
const res = await app.get("/dashboard", {
|
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
|
|
367
|
+
- [Testing](/docs/testing) — swap in the null driver for isolated tests.
|
package/docs/authentication.md
CHANGED
|
@@ -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");
|
package/docs/authorization.md
CHANGED
|
@@ -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;
|