@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,340 @@
1
+ ---
2
+ title: Pagination
3
+ description: Split large query results into pages with the metadata and URL helpers a UI needs.
4
+ ---
5
+
6
+ # Pagination
7
+
8
+ The [query builder](/docs/query-builder) and [ORM](/docs/orm/queries) share four
9
+ paginators, each a terminal method on any query. Each returns a result object
10
+ carrying the rows plus the metadata, cursors, and URL helpers a UI needs.
11
+
12
+ | Method | Strategy | Runs `COUNT`? | Best for |
13
+ | --------------------------------------- | ---------------------- | ------------- | --------------------------------------- |
14
+ | `paginate(perPage, page)` | Offset + total | Yes | Numbered page UIs (1, 2, 3, …) |
15
+ | `simplePaginate(perPage, page)` | Offset, next/prev only | No | "Previous / Next" without page numbers |
16
+ | `cursorPaginate({ cursor, limit })` | Keyset on `id` | No | Infinite scroll, very large tables |
17
+ | `keysetPaginate({ column, direction })` | Keyset on any column | No | Cursor paging on a sort other than `id` |
18
+
19
+ > **Note** — This is **data-layer** pagination. For Flow's reactive,
20
+ > server-driven paginated components, see
21
+ > [Flow › Pagination](/docs/flow/pagination).
22
+
23
+ ## Getting Started
24
+
25
+ Pagination ships with `@zerotal/orm` as query-builder methods — nothing to
26
+ install or register. Call them at the end of any query:
27
+
28
+ ```typescript
29
+ const page = await Post.query().latest().paginate();
30
+ ```
31
+
32
+ ## paginate — full numbered pages
33
+
34
+ ```typescript
35
+ // in a controller
36
+ const page = await Post.query()
37
+ .where("status", "published")
38
+ .orderBy("created_at", "desc")
39
+ .paginate(15); // reads ?page= from the request
40
+
41
+ page.data; // Post[] for this page
42
+ page.total; // total matching rows
43
+ page.lastPage; // total number of pages
44
+ ```
45
+
46
+ It runs a `COUNT` (ignoring limit/offset) then fetches the page, so you get a
47
+ complete picture — at the cost of the extra count query. `perPage` defaults to 15;
48
+ omit `page` and it reads the request's current page (`?page=`, or the page a Flow
49
+ component registered).
50
+
51
+ ```typescript
52
+ async paginate<T>(perPage?: number, page?: number): Promise<PaginateResult<T>>
53
+ ```
54
+
55
+ ### Result shape
56
+
57
+ The object you get back carries the rows under `data` plus everything a UI needs to
58
+ draw a pager — the current page, the total, the last page, and the 1-based `from`/`to`
59
+ indices for "showing 1–15 of 240" labels. The nested `meta` repeats the same numbers in
60
+ the shape API clients usually expect, so you can return it straight from a JSON
61
+ endpoint:
62
+
63
+ ```typescript
64
+ // type: PaginateResult<T>
65
+ interface PaginateResult<T> {
66
+ data: T[];
67
+ total: number;
68
+ page: number;
69
+ perPage: number;
70
+ lastPage: number;
71
+ from: number | null; // 1-based index of the first row (null if empty)
72
+ to: number | null; // 1-based index of the last row (null if empty)
73
+ meta: { from; to; currentPage; lastPage; perPage; total; path };
74
+ // URL + state helpers ↓
75
+ }
76
+ ```
77
+
78
+ ### URL & state helpers
79
+
80
+ ```typescript
81
+ // using a PaginateResult `page`
82
+ page.hasMorePages; // boolean
83
+ page.onFirstPage; // boolean
84
+ page.onLastPage; // boolean
85
+
86
+ page.nextPageUrl(); // '?page=2' | null on the last page
87
+ page.previousPageUrl(); // '?page=1' | null on the first page
88
+ page.url(3); // '?page=3'
89
+ page.url(3, "/posts", { q: "bun" }); // '/posts?q=bun&page=3'
90
+
91
+ // Render a numbered pager — extra query params are merged onto every link
92
+ page.links("/posts", { q: "bun" });
93
+ // → [{ page: 1, url: '/posts?q=bun&page=1', active: false }, …]
94
+ ```
95
+
96
+ `nextPageUrl`, `previousPageUrl`, `url`, and `links` all accept an optional base URL
97
+ and extra query params, which are preserved across links so filters survive
98
+
99
+ ## simplePaginate — next / prev only
100
+
101
+ Skips the `COUNT` entirely by fetching `perPage + 1` rows to detect whether
102
+ another page follows. Use it when you don't need a total or page numbers:
103
+
104
+ ```typescript
105
+ // in a controller
106
+ const page = await Post.query().latest().simplePaginate(20);
107
+
108
+ page.data; // up to 20 rows
109
+ page.hasMorePages; // true if a further page exists
110
+ page.nextPageUrl();
111
+ page.onFirstPage;
112
+ ```
113
+
114
+ ```typescript
115
+ async simplePaginate<T>(perPage?: number, page?: number): Promise<SimplePaginateResult<T>>
116
+ ```
117
+
118
+ > **Note** — Argument order is `simplePaginate(perPage, page)` — **`perPage`
119
+ > first**, matching `paginate(perPage, page)`. `perPage` defaults to 15; omit
120
+ > `page` to read the request's current page.
121
+
122
+ There is no `total` or `lastPage` — that's the trade-off for dropping the count.
123
+
124
+ ## cursorPaginate — keyset pagination on id
125
+
126
+ Cursor (keyset) pagination walks the table by `id` with `WHERE id > cursor ORDER
127
+ BY id ASC`, fetching `limit + 1` rows to detect a next page. It has **no
128
+ `COUNT`** and stays fast no matter how deep you scroll — ideal for
129
+ infinite-scroll feeds and very large tables:
130
+
131
+ ```typescript
132
+ // in a controller
133
+ let result = await Post.query().cursorPaginate({ limit: 20 });
134
+
135
+ result.data; // first 20 posts
136
+ result.nextCursor; // pass to the next call; null on the last page
137
+ result.prevCursor; // cursor that produced the previous page; null on the first
138
+ result.hasMore; // boolean
139
+
140
+ // Next page:
141
+ const more = await Post.query().cursorPaginate({ cursor: result.nextCursor!, limit: 20 });
142
+ ```
143
+
144
+ ```typescript
145
+ async cursorPaginate<T>(options?: { cursor?: number; limit?: number }): Promise<CursorPaginateResult<T>>
146
+ ```
147
+
148
+ Defaults: `{ cursor: 0, limit: 15 }`. Because it orders by `id` ascending, the
149
+ cursor is stable even as rows are inserted. The trade-off vs. `paginate()` is no
150
+ random page access and no total count.
151
+
152
+ ## keysetPaginate — keyset pagination on any column
153
+
154
+ `keysetPaginate()` generalises `cursorPaginate()`: it accepts **any sort
155
+ column**, supports `'asc'` and `'desc'`, and returns an **opaque base64 cursor**
156
+ that encodes the last row's sort value so clients cannot interpret or tamper with
157
+ it. A secondary `id ASC` tiebreaker keeps page boundaries stable when the sort
158
+ column isn't unique.
159
+
160
+ ```typescript
161
+ // in a controller
162
+ // First page, newest first
163
+ const p1 = await Post.query().keysetPaginate({ column: "created_at", direction: "desc" });
164
+
165
+ p1.data; // first page of rows
166
+ p1.nextCursor; // opaque base64 string | null on the last page
167
+
168
+ // Next page — pass the opaque cursor straight back
169
+ const p2 = await Post.query().keysetPaginate({
170
+ column: "created_at",
171
+ direction: "desc",
172
+ cursor: p1.nextCursor!,
173
+ });
174
+ ```
175
+
176
+ ```typescript
177
+ async keysetPaginate<T>(options?: KeysetOptions): Promise<KeysetPaginateResult<T>>
178
+
179
+ interface KeysetOptions {
180
+ cursor?: string | null; // opaque cursor from the previous page; null = first page
181
+ column?: string; // sort column, must be a safe identifier (default 'id')
182
+ direction?: "asc" | "desc"; // default 'asc'
183
+ limit?: number; // default 15
184
+ }
185
+ ```
186
+
187
+ > **Danger** — The `column` must be a safe SQL identifier; `keysetPaginate()`
188
+ > rejects anything else (e.g. `name; DROP TABLE users--`) by throwing. Never
189
+ > build the column name from raw, unvalidated user input.
190
+
191
+ ## Choosing a paginator
192
+
193
+ - **Numbered admin tables / search results** → `paginate()` — you want page
194
+ numbers and a total.
195
+ - **Lightweight "Load more" / Prev-Next** → `simplePaginate()` — skip the count,
196
+ keep it cheap.
197
+ - **Infinite scroll keyed on `id`** → `cursorPaginate()` — constant-time paging,
198
+ no count, simplest cursor.
199
+ - **Cursor paging on a sort other than `id`** (e.g. `created_at`, `score`) →
200
+ `keysetPaginate()` — any column, opaque tamper-proof cursors.
201
+
202
+ All four work identically on a raw `DB.table(...)` query (returning plain rows)
203
+ and on a `Model.query()` (returning hydrated models with eager-loaded
204
+ relations).
205
+
206
+ ## Putting it together
207
+
208
+ Two patterns cover most real use.
209
+
210
+ ### A filtered, numbered results page
211
+
212
+ Paginate _after_ applying filters and sorting, then pass the current filters to
213
+ `links()` so they ride along on every page URL — without that, clicking "page 2" would
214
+ silently drop the user's search:
215
+
216
+ ```typescript
217
+ // in a controller — GET /search?q=bun&page=2
218
+ const q = http.query("q", "");
219
+
220
+ const page = await Post.query()
221
+ .when(q, (query, term) => query.whereLike("title", `%${term}%`))
222
+ .latest()
223
+ .paginate(15, Number(http.query("page", "1")));
224
+
225
+ return view("search", {
226
+ results: page.data,
227
+ total: page.total,
228
+ // every link keeps ?q=… so the filter survives paging
229
+ pager: page.links("/search", { q }),
230
+ });
231
+ ```
232
+
233
+ ### An infinite-scroll API endpoint
234
+
235
+ For a feed the client scrolls forever, return the rows plus the next cursor and nothing
236
+ else — no count, no page numbers. The client sends the cursor back to fetch more:
237
+
238
+ ```typescript
239
+ // in a controller — GET /api/posts?cursor=128
240
+ const result = await Post.query()
241
+ .with("author")
242
+ .cursorPaginate({ cursor: Number(http.query("cursor", "0")), limit: 20 });
243
+
244
+ return {
245
+ posts: result.data,
246
+ nextCursor: result.nextCursor, // null when there's nothing left to load
247
+ };
248
+ ```
249
+
250
+ When the sort isn't `id` — a "newest first" feed, say — reach for `keysetPaginate()`
251
+ instead and hand the opaque `nextCursor` string back the same way.
252
+
253
+ ## Testing
254
+
255
+ Set your suite up once as described in [Testing](/docs/testing). Pagination bugs
256
+ live at the edges, so test the boundaries rather than the happy page.
257
+
258
+ A `paginate()` result carries `data` plus the counts, so one call proves several
259
+ things at once:
260
+
261
+ ```typescript
262
+ // tests/pagination/Posts.test.ts
263
+ import { test, expect } from "bun:test";
264
+ import { PostFactory } from "../../database/factories/PostFactory.ts";
265
+ import { Post } from "../../app/models/Post.ts";
266
+
267
+ test("reports the right totals on the last page", async () => {
268
+ await PostFactory.count(25).create();
269
+
270
+ const page = await Post.query().orderBy("id").paginate(10, 3); // 10 per page, page 3
271
+
272
+ expect(page.data).toHaveLength(5); // the remainder
273
+ expect(page.total).toBe(25);
274
+ expect(page.lastPage).toBe(3);
275
+ expect(page.from).toBe(21);
276
+ expect(page.to).toBe(25);
277
+ });
278
+ ```
279
+
280
+ **The three cases worth pinning down** are the ones that produce a broken UI
281
+ rather than an exception:
282
+
283
+ ```typescript
284
+ // tests/pagination/Posts.test.ts
285
+ test("an empty result reports null bounds, not zero", async () => {
286
+ const page = await Post.query().where("status", "nothing").paginate();
287
+
288
+ expect(page.data).toEqual([]);
289
+ expect(page.total).toBe(0);
290
+ expect(page.from).toBeNull(); // not 0 — a pager rendering "0–0 of 0" is a bug
291
+ expect(page.to).toBeNull();
292
+ });
293
+
294
+ test("a page past the end is empty rather than an error", async () => {
295
+ await PostFactory.count(5).create();
296
+
297
+ const page = await Post.query().orderBy("id").paginate(10, 99);
298
+
299
+ expect(page.data).toEqual([]);
300
+ expect(page.page).toBe(99);
301
+ });
302
+ ```
303
+
304
+ > **Warning** — Paginating without an `orderBy` gives the database licence to
305
+ > return rows in any order, so the same row can appear on two pages and another
306
+ > on none. A test that seeds five rows and reads one page will not catch it —
307
+ > order explicitly, and the bug never exists.
308
+
309
+ ## References
310
+
311
+ ### Query methods
312
+
313
+ | Method | Signature | Description |
314
+ | ---------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------- |
315
+ | `paginate` | `paginate(perPage?, page?): Promise<PaginateResult<T>>` | Offset pagination with a `COUNT` total and `lastPage`. |
316
+ | `simplePaginate` | `simplePaginate(perPage?, page?): Promise<SimplePaginateResult<T>>` | Offset next/prev pagination, no count. |
317
+ | `cursorPaginate` | `cursorPaginate({ cursor?, limit?, column? }): Promise<CursorPaginateResult<T>>` | Keyset pagination on `column` (default `id`, numeric cursor). |
318
+ | `keysetPaginate` | `keysetPaginate(options?): Promise<KeysetPaginateResult<T>>` | Keyset pagination on any column (opaque base64 cursor). |
319
+
320
+ ### `PaginateResult<T>` helpers
321
+
322
+ | Member | Signature | Description |
323
+ | ----------------- | -------------------------------------------------- | ------------------------------------------------- |
324
+ | `hasMorePages` | `boolean` | True when a page follows the current one. |
325
+ | `onFirstPage` | `boolean` | True on the first page. |
326
+ | `onLastPage` | `(): boolean` | True on the last page. |
327
+ | `nextPageUrl` | `(baseUrl?, query?): string \| null` | URL of the next page, or `null` on the last. |
328
+ | `previousPageUrl` | `(baseUrl?, query?): string \| null` | URL of the previous page, or `null` on the first. |
329
+ | `elements` | `(each?): (number \| "...")[]` | Page-number window with `"..."` gaps for a pager. |
330
+ | `url` | `(page, baseUrl?, query?): string` | URL for any page number. |
331
+ | `links` | `(baseUrl?, query?): Array<{ page; url; active }>` | One link per page for rendering a numbered pager. |
332
+
333
+ > **Tip** — `SimplePaginateResult<T>` exposes the same URL helpers and
334
+ > `hasMorePages`, but has no `links()`, `onLastPage`, `total`, or `lastPage`.
335
+
336
+ ## Next steps
337
+
338
+ - [Query Builder](/docs/query-builder) — building the query you paginate.
339
+ - [ORM Queries](/docs/orm/queries) — model queries, scopes, and eager loading.
340
+ - [Flow › Pagination](/docs/flow/pagination) — reactive paginated components.
@@ -0,0 +1,335 @@
1
+ ---
2
+ title: Password Reset
3
+ description: Generate, email, and verify hashed reset tokens so users can securely set a new password.
4
+ ---
5
+
6
+ # Password Reset
7
+
8
+ `PasswordBroker` handles the full password reset flow: generating and storing a
9
+ hashed token, emailing the reset link, verifying the token on submission, and
10
+ calling your password-update logic. It is database-agnostic — you supply the query
11
+ functions.
12
+
13
+ The broker never stores or emails the raw token. It generates a random plain token,
14
+ **SHA-256 hashes it** before handing it to `storeToken`, and emails the plain token
15
+ to the user. On `reset()` it re-hashes the submitted token and compares — so a leaked
16
+ database row can't be turned back into a working reset link.
17
+
18
+ > **Danger** — Store only the hashed token, exactly as the broker hands it to `storeToken`. Persisting the plain token would let anyone with database access forge a working reset link.
19
+
20
+ ## Getting Started
21
+
22
+ `PasswordBroker` ships in the auth package:
23
+
24
+ ```bash
25
+ # in your project root
26
+ bun add @zerotal/auth
27
+ ```
28
+
29
+ There is no provider or `config/` file to register — you construct the broker
30
+ yourself and supply the query functions, so it works with any storage backend.
31
+
32
+ ## Migration
33
+
34
+ The broker reads and writes through your query functions, so it needs a table to
35
+ back them. The default examples below use a `password_reset_tokens` table keyed by
36
+ email:
37
+
38
+ ```typescript
39
+ // database/migrations/xxxx_create_password_reset_tokens.ts
40
+ await Schema.create("password_reset_tokens", (table) => {
41
+ table.string("email").primary();
42
+ table.string("token");
43
+ table.timestamp("expires_at").nullable();
44
+ table.timestamp("created_at").nullable();
45
+ });
46
+ ```
47
+
48
+ > **Note** — The broker decides validity from `createdAt + expireMinutes`, so the
49
+ > table must carry `created_at` and `findToken` must return it.
50
+
51
+ ## Wire up the broker
52
+
53
+ Create the broker once and export it so controllers can import it. Pass an object
54
+ implementing `PasswordBrokerOptions` — each query function maps the broker onto your
55
+ storage:
56
+
57
+ ```typescript
58
+ // app/auth/passwords.ts
59
+ import { PasswordBroker, Hash } from "@zerotal/auth";
60
+ import { Notify } from "@zerotal/notifications";
61
+ import { DB } from "@zerotal/orm";
62
+ import { User } from "#app/models/User.ts";
63
+ import { PasswordResetNotification } from "#app/notifications/PasswordResetNotification.ts";
64
+
65
+ export const broker = new PasswordBroker({
66
+ expireMinutes: 60,
67
+
68
+ // The broker decides validity from `createdAt + expireMinutes`, so findToken
69
+ // MUST return createdAt and storeToken MUST persist created_at.
70
+ findToken: async (email) => {
71
+ const row = await DB.table("password_reset_tokens")
72
+ .where("email", email)
73
+ .first<{ token: string; created_at: string }>();
74
+ return row ? { token: row.token, createdAt: new Date(row.created_at) } : null;
75
+ },
76
+
77
+ storeToken: (email, hash, expiresAt) =>
78
+ DB.table("password_reset_tokens").upsert(
79
+ { email },
80
+ { token: hash, created_at: new Date(), expires_at: expiresAt },
81
+ ),
82
+
83
+ deleteToken: (email) => DB.table("password_reset_tokens").where("email", email).delete(),
84
+
85
+ pruneTokens: (cutoff) =>
86
+ DB.table("password_reset_tokens").where("expires_at", "<", cutoff).delete(),
87
+
88
+ // PasswordResetNotification implements toMail(); { email } is the on-demand recipient.
89
+ sendResetLink: (email, token) =>
90
+ Notify.queue({ email }, new PasswordResetNotification(token, email)),
91
+
92
+ resetPassword: async (email, newPassword) => {
93
+ const user = await User.where("email", email).firstOrFail();
94
+ await user.fill({ password: await Hash.make(newPassword) }).save();
95
+ },
96
+ });
97
+ ```
98
+
99
+ > **Warning** — `findToken` receives the email and `sendResetLink` receives the
100
+ > **plain** token; `storeToken` receives the **hashed** token. Don't swap them — the
101
+ > broker hashes the plain token before `storeToken` and re-hashes the submitted
102
+ > token on `reset()` to compare.
103
+
104
+ ### The notification
105
+
106
+ ```typescript
107
+ // app/notifications/PasswordResetNotification.ts
108
+ import { Notification, MailMessage, type Notifiable } from "@zerotal/notifications";
109
+
110
+ export class PasswordResetNotification extends Notification {
111
+ constructor(
112
+ private readonly token: string,
113
+ private readonly email: string,
114
+ ) {
115
+ super();
116
+ }
117
+
118
+ channels(): string[] {
119
+ return ["mail"];
120
+ }
121
+
122
+ toMail(_notifiable: Notifiable): MailMessage {
123
+ const url = `https://myapp.com/reset-password?token=${this.token}&email=${encodeURIComponent(this.email)}`;
124
+ return new MailMessage().subject("Reset your password").html(`
125
+ <p>Click the link below to reset your password. This link expires in 60 minutes.</p>
126
+ <p><a href="${url}">${url}</a></p>
127
+ <p>If you did not request a password reset, no action is needed.</p>
128
+ `);
129
+ }
130
+ }
131
+ ```
132
+
133
+ ## Controller
134
+
135
+ The controller wires the broker into request handlers: one pair for requesting a
136
+ link, one pair for submitting the new password. Compare the broker result against the
137
+ [`PASSWORDS` constants](#passwords-constants) rather than raw strings.
138
+
139
+ ```typescript
140
+ // app/controllers/PasswordResetController.ts
141
+ import { broker } from "#app/auth/passwords.ts";
142
+ import { Auth, PASSWORDS } from "@zerotal/auth";
143
+ import { User } from "#app/models/User.ts";
144
+ import type { HttpContext } from "zerotal";
145
+
146
+ export class PasswordResetController {
147
+ showForm(ctx: HttpContext) {
148
+ ctx.view(ForgotPasswordPage({ errors: ctx.flashed("errors") }));
149
+ }
150
+
151
+ async sendLink(ctx: HttpContext) {
152
+ const { email } = await ctx.body<{ email: string }>();
153
+ await broker.sendResetLink(email);
154
+ // Same response regardless of whether the email exists — don't reveal account presence
155
+ ctx.flash("success", "If that address is registered, a reset link is on its way.");
156
+ ctx.redirect("/login", 303);
157
+ }
158
+
159
+ showReset(ctx: HttpContext) {
160
+ ctx.view(
161
+ ResetPasswordPage({
162
+ token: ctx.query("token") ?? "",
163
+ email: ctx.query("email") ?? "",
164
+ errors: ctx.flashed("errors"),
165
+ }),
166
+ );
167
+ }
168
+
169
+ async reset(ctx: HttpContext) {
170
+ const { token, email, password } = await ctx.body<{
171
+ token: string;
172
+ email: string;
173
+ password: string;
174
+ }>();
175
+
176
+ const result = await broker.reset(token, email, password);
177
+
178
+ if (result === PASSWORDS.TOKEN) {
179
+ ctx.flash("errors", { token: ["This reset link is invalid or has expired."] });
180
+ ctx.redirect(`/reset-password?token=${token}&email=${encodeURIComponent(email)}`, 303);
181
+ return;
182
+ }
183
+
184
+ // Log the user in immediately after a successful reset
185
+ const user = await User.where("email", email).first();
186
+ if (user) {
187
+ ctx.session.regenerate();
188
+ await Auth.login(user);
189
+ }
190
+
191
+ ctx.flash("success", "Your password has been reset.");
192
+ ctx.redirect("/dashboard", 303);
193
+ }
194
+ }
195
+ ```
196
+
197
+ > **Danger** — Always respond identically whether or not the email exists, as
198
+ > `sendLink` does above. Branching the response leaks which addresses have accounts.
199
+
200
+ ## Routes
201
+
202
+ ```typescript
203
+ // routes/web.ts
204
+ import { GuestMiddleware } from "@zerotal/auth";
205
+ import { PasswordResetController } from "#app/controllers/PasswordResetController.ts";
206
+
207
+ Router.get("/forgot-password", PasswordResetController, "showForm", [GuestMiddleware]);
208
+ Router.post("/forgot-password", PasswordResetController, "sendLink", [GuestMiddleware]);
209
+ Router.get("/reset-password", PasswordResetController, "showReset", [GuestMiddleware]);
210
+ Router.post("/reset-password", PasswordResetController, "reset", [GuestMiddleware]);
211
+ ```
212
+
213
+ `GuestMiddleware` keeps already-authenticated users out of the reset flow.
214
+
215
+ > **Warning** — Add a [rate limiter](/docs/rate-limiting) to `POST /forgot-password` so the endpoint can't be used to spray reset emails or probe which addresses exist.
216
+
217
+ ## Pruning expired tokens
218
+
219
+ Expired rows accumulate because `reset()` only deletes a token when it's used or
220
+ found expired on lookup. Call `broker.prune()` on a schedule to clear the rest — it
221
+ delegates to your `pruneTokens` function with the cutoff date:
222
+
223
+ ```typescript
224
+ // app/schedules/PrunePasswordTokens.ts
225
+ import { Schedule } from "@zerotal/scheduler";
226
+ import { broker } from "#app/auth/passwords.ts";
227
+
228
+ export class PrunePasswordTokens extends Schedule {
229
+ cron = "0 * * * *"; // hourly
230
+ async handle() {
231
+ await broker.prune();
232
+ }
233
+ }
234
+ ```
235
+
236
+ See [Scheduler](/docs/scheduler) for the worker setup.
237
+
238
+ ## PASSWORDS constants
239
+
240
+ Always compare against the constants rather than the raw strings — the values are
241
+ namespaced and may change.
242
+
243
+ | Constant | Value | Returned by |
244
+ | ----------------- | ------------------- | ------------------------------------------------------ |
245
+ | `PASSWORDS.SENT` | `'passwords.sent'` | `sendResetLink()` once the link is dispatched |
246
+ | `PASSWORDS.TOKEN` | `'passwords.token'` | `reset()` when the token is missing, wrong, or expired |
247
+ | `PASSWORDS.RESET` | `'passwords.reset'` | `reset()` after the password is updated |
248
+
249
+ ## Testing
250
+
251
+ Set your suite up once as described in [Testing](/docs/testing) — everything
252
+ below assumes `createApp()` from your `tests/helpers.ts`.
253
+
254
+ The broker needs no application at all, because **your options own the
255
+ delivery**. `sendResetLink(email, token)` is a callback you wrote, so a test
256
+ captures the token instead of sending mail:
257
+
258
+ ```typescript
259
+ // tests/auth/password-reset.test.ts
260
+ import { test, expect } from "bun:test";
261
+ import { PasswordBroker } from "@zerotal/auth";
262
+
263
+ test("a reset token changes the password, once", async () => {
264
+ const store = new Map<string, { token: string; createdAt: Date }>();
265
+ let sent: string | undefined;
266
+ let password: string | undefined;
267
+
268
+ const broker = new PasswordBroker({
269
+ findToken: async (email) => store.get(email) ?? null,
270
+ storeToken: async (email, hash) =>
271
+ void store.set(email, { token: hash, createdAt: new Date() }),
272
+ deleteToken: async (email) => void store.delete(email),
273
+ pruneTokens: async () => {},
274
+ sendResetLink: async (_email, token) => void (sent = token),
275
+ resetPassword: async (_email, next) => void (password = next),
276
+ });
277
+
278
+ expect(await broker.sendResetLink("jane@example.com")).toBe("passwords.sent");
279
+
280
+ expect(await broker.reset(sent!, "jane@example.com", "new-secret")).toBe("passwords.reset");
281
+ expect(password).toBe("new-secret");
282
+
283
+ // Single-use: replaying the same token must fail.
284
+ expect(await broker.reset(sent!, "jane@example.com", "other")).toBe("passwords.token");
285
+ });
286
+ ```
287
+
288
+ **Every failure returns `"passwords.token"` rather than throwing**, so assert on
289
+ the return value — a `expect(...).toThrow()` here will never fire. Three cases
290
+ earn a test: a wrong token, an expired one, and the replay above.
291
+
292
+ On the HTTP side, the case worth pinning down is that a miss is indistinguishable
293
+ from a hit:
294
+
295
+ ```typescript
296
+ // tests/http/password-reset.test.ts
297
+ const res = await app.post("/forgot-password", { email: "nobody@example.com" });
298
+
299
+ // Deliberately identical to the registered-address case — the response must not
300
+ // reveal whether the account exists.
301
+ res.assertRedirect("/forgot-password");
302
+ ```
303
+
304
+ ## References
305
+
306
+ The constructor takes `PasswordBrokerOptions`; `expireMinutes` is the only optional
307
+ field (default `60`), the rest are query functions you must supply.
308
+
309
+ ### Constructor options
310
+
311
+ | Option | Signature | Description |
312
+ | ------------------------------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------- |
313
+ | `expireMinutes` | `number` (optional, default `60`) | Minutes until a reset token expires. |
314
+ | `findToken(email)` | `(email: string) => Promise<{ token: string; createdAt: Date } \| null>` | Look up the stored hashed token and its creation time. |
315
+ | `storeToken(email, hash, expiresAt)` | `(email: string, hash: string, expiresAt: Date) => Promise<void>` | Persist the **hashed** token and its expiry. |
316
+ | `deleteToken(email)` | `(email: string) => Promise<void>` | Remove a single user's token after use or expiry. |
317
+ | `pruneTokens(cutoff)` | `(cutoff: Date) => Promise<void>` | Delete all tokens created before `cutoff`. |
318
+ | `sendResetLink(email, token)` | `(email: string, token: string) => Promise<void>` | Deliver the **plain** token to the user (usually via mail). |
319
+ | `resetPassword(email, newPassword)` | `(email: string, newPassword: string) => Promise<void>` | Apply the new password to the user record. |
320
+
321
+ ### Broker methods
322
+
323
+ | Method | Signature | Description |
324
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
325
+ | `sendResetLink(email)` | `(email: string) => Promise<'passwords.sent'>` | Generate + store a hashed token and send the link. |
326
+ | `reset(token, email, password)` | `(token: string, email: string, password: string) => Promise<'passwords.token' \| 'passwords.reset'>` | Verify the token and update the password. |
327
+ | `prune()` | `() => Promise<void>` | Delete tokens older than `expireMinutes`. |
328
+
329
+ ## Next steps
330
+
331
+ - [Encryption & Hashing](/docs/encryption) — the `Hash` facade used in `resetPassword`.
332
+ - [Authentication](/docs/authentication) — logging the user in after a reset.
333
+ - [Notifications](/docs/notifications) — building and queueing the `PasswordResetNotification`.
334
+ - [Rate Limiting](/docs/rate-limiting) — throttle the forgot-password endpoint.
335
+ - [Scheduler](/docs/scheduler) — run `prune()` on a recurring schedule.