@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,320 @@
1
+ ---
2
+ title: Email Verification
3
+ description: Confirm a user controls their email address with signed, expiring verification links — no token table required.
4
+ ---
5
+
6
+ # Email Verification
7
+
8
+ Email verification confirms that a user controls the address they signed up with.
9
+ Zerotal builds the whole flow from two pieces you already have — the `Url` facade
10
+ (from `zerotal`) signs an expiring link, and `ValidateSignatureMiddleware`
11
+ (from `@zerotal/auth`) rejects tampered or expired clicks — so there's no separate
12
+ table and no plain-text tokens to store.
13
+
14
+ ## Getting Started
15
+
16
+ Email verification is assembled from pieces you already have — the `Url` facade
17
+ in `@zerotal/core` signs the link, and `ValidateSignatureMiddleware` in
18
+ `@zerotal/auth` checks it. There is no extra package and no extra table:
19
+
20
+ ```typescript
21
+ import { Url } from "zerotal";
22
+ import { ValidateSignatureMiddleware } from "@zerotal/auth";
23
+ ```
24
+
25
+ ## How it works
26
+
27
+ ```
28
+ register ──▶ Url.sign(/auth/verify?id&email, ttl) ──▶ email link
29
+ │ user clicks
30
+
31
+ ValidateSignatureMiddleware ──(valid)──▶ controller stamps
32
+ (rejects tampered / expired) email_verified_at
33
+
34
+
35
+ VerifiedMiddleware gates "verified-only" routes
36
+ ```
37
+
38
+ 1. After registration, generate a signed verification URL with `Url.sign()` and
39
+ email it to the user.
40
+ 2. `ValidateSignatureMiddleware` on the verify route rejects links that are
41
+ tampered or expired — your controller only runs for valid clicks.
42
+ 3. On success, stamp `email_verified_at` on the user record.
43
+ 4. `VerifiedMiddleware` (you write once, shown below) gates routes that require a
44
+ verified email.
45
+
46
+ > **Note** — The signed link's secret is your `APP_KEY` (config `app.key`).
47
+ > `ValidateSignatureMiddleware` fails closed: if no key is set it throws a config
48
+ > error rather than accepting forged links.
49
+
50
+ ## Migration
51
+
52
+ Add a nullable `email_verified_at` column to your users table:
53
+
54
+ ```typescript
55
+ // database/migrations/002_add_email_verified_at.ts
56
+ export default class AddEmailVerifiedAt extends Migration {
57
+ async up(schema: Schema) {
58
+ await schema.table("users", (table) => {
59
+ table.timestamp("email_verified_at").nullable();
60
+ });
61
+ }
62
+ async down(schema: Schema) {
63
+ await schema.table("users", (table) => {
64
+ table.dropColumn("email_verified_at");
65
+ });
66
+ }
67
+ }
68
+ ```
69
+
70
+ ## User model
71
+
72
+ Expose the column and a convenience getter the middleware and controller can read:
73
+
74
+ ```typescript
75
+ // app/models/User.ts
76
+ import { column, table } from "@zerotal/orm";
77
+ import { AuthUser } from "@zerotal/auth";
78
+ import { Carbon } from "zerotal/carbon";
79
+
80
+ @(table("users").withTimestamps())
81
+ export class User extends AuthUser {
82
+ @column() name!: string;
83
+ @column() email!: string;
84
+ @column() password!: string;
85
+ @column("datetime") emailVerifiedAt?: Carbon | null;
86
+
87
+ get hasVerifiedEmail(): boolean {
88
+ return this.emailVerifiedAt != null;
89
+ }
90
+ }
91
+ ```
92
+
93
+ ## The verification mailer
94
+
95
+ `Url.sign(base, params, expiresInMinutes?, secret?)` returns the base URL with the
96
+ query params plus a `signature` and `expires` appended. Build it inside a mail
97
+ `Notification` (mail lives in `@zerotal/notifications`):
98
+
99
+ ```typescript
100
+ // app/notifications/VerifyEmailNotification.ts
101
+ import { Notification, MailMessage, type Notifiable } from "@zerotal/notifications";
102
+ import { env } from "zerotal";
103
+ import { Url } from "zerotal/http";
104
+
105
+ export class VerifyEmailNotification extends Notification {
106
+ constructor(
107
+ private readonly userId: number,
108
+ private readonly email: string,
109
+ ) {
110
+ super();
111
+ }
112
+
113
+ channels(): string[] {
114
+ return ["mail"];
115
+ }
116
+
117
+ toMail(_notifiable: Notifiable): MailMessage {
118
+ const url = Url.sign(
119
+ `${env("APP_URL", "http://localhost:3000")}/auth/verify`,
120
+ { id: String(this.userId), email: this.email },
121
+ 60, // link expires in 60 minutes
122
+ );
123
+
124
+ // The recipient defaults to the notifiable's `email` — no need to call to().
125
+ return new MailMessage().subject("Verify your email address").html(`
126
+ <p>Thanks for signing up! Please click the link below to verify your email address.</p>
127
+ <p><a href="${url}">${url}</a></p>
128
+ <p>This link expires in 60 minutes. If you did not create an account, no action is needed.</p>
129
+ `);
130
+ }
131
+ }
132
+ ```
133
+
134
+ > **Tip** — Keep the expiry short (15–60 minutes). A stale link is harmless: the
135
+ > user just requests a fresh one from the notice page (see [`resend`](#controller)).
136
+
137
+ ## Controller
138
+
139
+ The verify route is already guarded by `ValidateSignatureMiddleware`, so the
140
+ controller can trust that the URL is intact — it only checks that the `id`/`email`
141
+ pair still resolves to a real user:
142
+
143
+ ```typescript
144
+ // app/controllers/VerificationController.ts
145
+ import { Auth } from "@zerotal/auth";
146
+ import { Notify } from "@zerotal/notifications";
147
+ import { Carbon } from "zerotal/carbon";
148
+ import { User } from "#app/models/User.ts";
149
+ import { VerifyEmailNotification } from "#app/notifications/VerifyEmailNotification.ts";
150
+ import type { HttpContext } from "zerotal";
151
+
152
+ export class VerificationController {
153
+ // GET /auth/verify (protected by ValidateSignatureMiddleware)
154
+ async verify(ctx: HttpContext) {
155
+ const id = Number(ctx.query("id"));
156
+ const email = ctx.query("email") ?? "";
157
+
158
+ const user = await User.find(id);
159
+
160
+ if (!user || user.email !== email) {
161
+ ctx.flash("errors", { link: ["Verification link is invalid."] });
162
+ ctx.redirect("/login", 303);
163
+ return;
164
+ }
165
+
166
+ if (!user.hasVerifiedEmail) {
167
+ await user.fill({ emailVerifiedAt: Carbon.now() }).save();
168
+ }
169
+
170
+ ctx.flash("success", "Email verified! You are now logged in.");
171
+ if (!Auth.check()) {
172
+ ctx.session.regenerate();
173
+ await Auth.login(user);
174
+ }
175
+ ctx.redirect("/dashboard", 303);
176
+ }
177
+
178
+ // POST /auth/verify/resend (requires auth)
179
+ async resend(ctx: HttpContext) {
180
+ const user = Auth.user() as User;
181
+
182
+ if (user.hasVerifiedEmail) {
183
+ ctx.redirect("/dashboard", 303);
184
+ return;
185
+ }
186
+
187
+ await Notify.queue(user, new VerifyEmailNotification(user.id, user.email));
188
+
189
+ ctx.flash("success", "A fresh verification link has been sent to your email.");
190
+ ctx.redirect("/auth/verify/notice", 303);
191
+ }
192
+
193
+ // GET /auth/verify/notice
194
+ notice(ctx: HttpContext) {
195
+ ctx.view(VerifyEmailNoticePage({ email: (Auth.user() as User).email }));
196
+ }
197
+ }
198
+ ```
199
+
200
+ ## Routes
201
+
202
+ ```typescript
203
+ // routes/web.ts
204
+ import { ValidateSignatureMiddleware } from "@zerotal/auth";
205
+ import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
206
+ import { VerificationController } from "#app/controllers/VerificationController.ts";
207
+
208
+ // The verify route is guarded by the signature middleware — no controller-level check needed
209
+ Router.get("/auth/verify", VerificationController, "verify", [ValidateSignatureMiddleware]);
210
+ Router.get("/auth/verify/notice", VerificationController, "notice", [RequireAuthMiddleware]);
211
+ Router.post("/auth/verify/resend", VerificationController, "resend", [RequireAuthMiddleware]);
212
+ ```
213
+
214
+ > **Warning** — Do not add a controller-level signature check on `/auth/verify`;
215
+ > `ValidateSignatureMiddleware` already rejects invalid links with a 403 before the
216
+ > controller runs. Double-checking just risks the two paths drifting apart.
217
+
218
+ ## Send the verification email on registration
219
+
220
+ Call `Notify.queue()` at the end of your registration handler so the HTTP response
221
+ returns immediately while delivery happens in the background:
222
+
223
+ ```typescript
224
+ // app/controllers/AuthController.ts
225
+ import { Notify } from "@zerotal/notifications";
226
+ import { VerifyEmailNotification } from "#app/notifications/VerifyEmailNotification.ts";
227
+
228
+ async register(ctx: HttpContext) {
229
+ // … validate, create user …
230
+
231
+ await Notify.queue(user, new VerifyEmailNotification(user.id, user.email));
232
+
233
+ ctx.session.regenerate();
234
+ await Auth.login(user);
235
+ ctx.redirect("/auth/verify/notice", 303); // send to the "check your email" page
236
+ }
237
+ ```
238
+
239
+ ## Protecting routes that require a verified email
240
+
241
+ Write a `VerifiedMiddleware` once and apply it to routes that must only be
242
+ accessible to verified users. It extends `BaseMiddleware` (from `zerotal`)
243
+ and reads the current user via `Auth.userOrNull()`:
244
+
245
+ ```typescript
246
+ // app/middleware/Verified.ts
247
+ import { BaseMiddleware } from "zerotal";
248
+ import type { HttpContext, NextFn } from "zerotal";
249
+ import { Auth } from "@zerotal/auth";
250
+ import { User } from "#app/models/User.ts";
251
+
252
+ export class VerifiedMiddleware extends BaseMiddleware {
253
+ protected options = {};
254
+
255
+ async handle(ctx: HttpContext, next: NextFn): Promise<Response | void> {
256
+ const user = Auth.userOrNull() as User | undefined;
257
+
258
+ if (!user) {
259
+ ctx.redirect("/login", 302);
260
+ return ctx.response;
261
+ }
262
+
263
+ if (!user.hasVerifiedEmail) {
264
+ ctx.redirect("/auth/verify/notice", 302);
265
+ return ctx.response;
266
+ }
267
+
268
+ return next();
269
+ }
270
+ }
271
+ ```
272
+
273
+ Apply it alongside `RequireAuthMiddleware` so unverified users are bounced to the
274
+ notice page:
275
+
276
+ ```typescript
277
+ // routes/web.ts
278
+ Router.group({ middleware: [RequireAuthMiddleware, VerifiedMiddleware] }, () => {
279
+ Router.get("/dashboard", DashboardController, "index");
280
+ Router.get("/settings", SettingsController, "index");
281
+ Router.post("/posts", PostController, "store");
282
+ });
283
+ ```
284
+
285
+ ## Testing
286
+
287
+ ```typescript
288
+ // tests/email-verification.test.ts
289
+ it("unverified user is redirected to notice page", async () => {
290
+ const user = await UserFactory.create({ emailVerifiedAt: null });
291
+ const res = await testApp.actingAs(user).get("/dashboard");
292
+ res.assertRedirect("/auth/verify/notice");
293
+ });
294
+
295
+ it("verified user can access dashboard", async () => {
296
+ const user = await UserFactory.create({ emailVerifiedAt: new Date() });
297
+ const res = await testApp.actingAs(user).get("/dashboard");
298
+ res.assertOk();
299
+ });
300
+ ```
301
+
302
+ ## References
303
+
304
+ | Member | Signature | Description |
305
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
306
+ | `Url.sign` | `sign(base: string, params?: Record<string, string>, expiresInMinutes?: number, secret?: string): string` | Append signed `signature`/`expires` params to a URL. Defaults to a 60-minute expiry and `app.key` as the secret. |
307
+ | `Url.verify` | `verify(signedUrl: string, secret?: string): boolean` | True when the signature matches and the link has not expired. |
308
+ | `ValidateSignatureMiddleware` | `class extends BaseMiddleware` | Rejects requests whose signature is missing, tampered, or expired with a 403. |
309
+ | `Auth.check` | `check(): boolean` | True when the current request has an authenticated user. |
310
+ | `Auth.login` | `login(user: UserModel, options?: LoginOptions): Promise<void>` | Write the user to the session and populate the request context. |
311
+ | `Auth.user` | `user(): UserModel` | The authenticated user; throws when there is none. |
312
+ | `Auth.userOrNull` | `userOrNull(): UserModel \| undefined` | The authenticated user, or `undefined` for guests. |
313
+ | `Notify.queue` | `queue(notifiable: Notifiable, notification: Notification): Promise<void>` | Serialise the notification and dispatch a background delivery job. |
314
+
315
+ ## Next steps
316
+
317
+ - [Authentication](/docs/authentication) — sign users in before verifying their address.
318
+ - [Password reset](/docs/password-reset) — another signed-URL flow built the same way.
319
+ - [Notifications](/docs/notifications) — configure the transport that delivers the verification email.
320
+ - [Middleware](/docs/middleware) — how `VerifiedMiddleware` plugs into the request pipeline.