@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,418 @@
1
+ ---
2
+ title: Two-Factor Authentication
3
+ description: Add TOTP-based two-factor authentication so logins require a one-time code from an authenticator app.
4
+ ---
5
+
6
+ # Two-Factor Authentication
7
+
8
+ `@zerotal/auth` ships TOTP-based two-factor authentication (2FA) — generate a
9
+ secret, show a QR code, verify six-digit codes, and gate routes until the user
10
+ completes a challenge. It is RFC 6238 (TOTP) implemented natively for Bun with no
11
+ external dependencies.
12
+
13
+ > **Note** — Looking for roles & permissions? RBAC lives in its own relational
14
+ > model — the `Roles` and `Permissions` mixins composed via
15
+ > `Model.using(Authenticatable, Permissions, Roles)`. See
16
+ > [Authorization](/docs/authorization).
17
+
18
+ ## Getting Started
19
+
20
+ ```bash
21
+ # in your project root
22
+ bun add @zerotal/auth
23
+ ```
24
+
25
+ ## Register the provider
26
+
27
+ 2FA is part of `@zerotal/auth`, so registering `AuthProvider` in
28
+ `bootstrap/providers.ts` is all the wiring it needs:
29
+
30
+ ```ts
31
+ // bootstrap/providers.ts
32
+ import { AuthProvider } from "@zerotal/auth";
33
+
34
+ const providers = [
35
+ // …your other providers
36
+ AuthProvider,
37
+ ];
38
+
39
+ export default providers;
40
+ ```
41
+
42
+ Registering the provider switches on the following (2FA-relevant hooks):
43
+
44
+ - `onRegister` — binds the `two_factor` singleton (`TwoFactorService`), built from
45
+ your `auth.twoFactor` config.
46
+ - `onBooting` — pre-resolves the `two_factor` singleton so the `TwoFactor` facade
47
+ is ready in any provider's `onBooted`.
48
+
49
+ The `TwoFactor` facade resolves the `two_factor` container binding.
50
+
51
+ ## Configuration
52
+
53
+ 2FA is tuned inside the `auth` config under the `twoFactor` key. Use the
54
+ `AuthConfig()` helper so every field stays type-checked:
55
+
56
+ ```ts
57
+ // config/auth.ts
58
+ import { AuthConfig } from "@zerotal/auth";
59
+
60
+ export default AuthConfig({
61
+ algorithm: "argon2id",
62
+ twoFactor: {
63
+ issuer: "My App", // shown in authenticator apps
64
+ window: 1, // ±1 TOTP period (30s) clock tolerance
65
+ recoveryCodeCount: 8,
66
+ },
67
+ });
68
+ ```
69
+
70
+ | Field | Required | Default | Description |
71
+ | ------------------- | -------- | ----------- | ------------------------------------------------------------- |
72
+ | `issuer` | no | `"Zerotal"` | Issuer label shown in authenticator apps (your app name). |
73
+ | `window` | no | `1` | TOTP periods checked on each side of now (each period = 30s). |
74
+ | `recoveryCodeCount` | no | `8` | Number of one-time recovery codes generated. |
75
+
76
+ > **Tip** — Prefer the `AuthConfig()` helper over `satisfies AuthConfigShape`: it
77
+ > fills defaults, so you only specify the fields you want to override.
78
+
79
+ ## Data model
80
+
81
+ 2FA stores three columns on the user. Add a migration:
82
+
83
+ ```typescript
84
+ // database/migrations/003_add_two_factor_to_users.ts
85
+ export default class AddTwoFactorToUsers extends Migration {
86
+ async up(schema: Schema): Promise<void> {
87
+ await schema.table("users", (table) => {
88
+ table.string("two_factor_secret").nullable();
89
+ table.text("two_factor_recovery_codes").nullable(); // JSON array of hashed codes
90
+ table.timestamp("two_factor_confirmed_at").nullable();
91
+ });
92
+ }
93
+ async down(schema: Schema): Promise<void> {
94
+ await schema.table("users", (table) => {
95
+ table.dropColumn("two_factor_secret");
96
+ table.dropColumn("two_factor_recovery_codes");
97
+ table.dropColumn("two_factor_confirmed_at");
98
+ });
99
+ }
100
+ }
101
+ ```
102
+
103
+ Then expose them on the model:
104
+
105
+ ```typescript
106
+ // app/models/User.ts
107
+ @(table("users").withTimestamps())
108
+ export class User extends AuthUser {
109
+ // ... existing columns ...
110
+
111
+ @column() twoFactorSecret?: string | null;
112
+ @column("json") twoFactorRecoveryCodes?: string[] | null; // stored hashed
113
+ @column("datetime") twoFactorConfirmedAt?: Carbon | null;
114
+ }
115
+ ```
116
+
117
+ > **Danger** — `twoFactorRecoveryCodes` holds SHA-256 hashes of the recovery
118
+ > codes, never the plaintext. Show the plaintext to the user exactly once at
119
+ > generation time — there is no way to recover it afterwards.
120
+
121
+ ## Enabling 2FA
122
+
123
+ Enrolment is a two-step flow: generate a secret and show its QR code, then verify
124
+ the user's first code before persisting. `TwoFactor.verifyCode()` and
125
+ `TwoFactor.generateRecoveryCodes()` are synchronous.
126
+
127
+ ```typescript
128
+ // app/controllers/TwoFactorController.ts
129
+ import { TwoFactor } from "@zerotal/auth";
130
+
131
+ export class TwoFactorController extends Controller {
132
+ // GET /user/two-factor — show setup page with QR code
133
+ async setup(ctx: HttpContext) {
134
+ const secret = TwoFactor.generateSecret();
135
+ ctx.session.put("two_factor_pending_secret", secret);
136
+
137
+ // The scannable code, plus the URI as a link and the secret as text for
138
+ // anyone enrolling on the phone that holds the authenticator.
139
+ const qr = TwoFactor.getQrCodeSvg(ctx.user!.email, secret, { size: 220 });
140
+ const uri = TwoFactor.getQrCodeUrl(ctx.user!.email, secret);
141
+ return this.render("two-factor/setup", { qr, uri, secret });
142
+ }
143
+
144
+ // POST /user/two-factor — confirm and save
145
+ async confirm(ctx: HttpContext) {
146
+ const secret = ctx.session.get("two_factor_pending_secret") as string;
147
+ const code = ctx.input("code") as string;
148
+
149
+ if (!TwoFactor.verifyCode(secret, code)) {
150
+ return this.back().withErrors({ code: "Invalid code." });
151
+ }
152
+
153
+ const { plain, hashed } = TwoFactor.generateRecoveryCodes();
154
+
155
+ await ctx
156
+ .user!.fill({
157
+ twoFactorSecret: secret,
158
+ twoFactorRecoveryCodes: JSON.stringify(hashed),
159
+ twoFactorConfirmedAt: Carbon.now(),
160
+ })
161
+ .save();
162
+
163
+ ctx.session.forget("two_factor_pending_secret");
164
+
165
+ // Show recovery codes once:
166
+ return this.render("two-factor/recovery-codes", { codes: plain });
167
+ }
168
+
169
+ // DELETE /user/two-factor — disable
170
+ async disable(ctx: HttpContext) {
171
+ await ctx
172
+ .user!.fill({
173
+ twoFactorSecret: null,
174
+ twoFactorRecoveryCodes: null,
175
+ twoFactorConfirmedAt: null,
176
+ })
177
+ .save();
178
+ return this.redirect("/profile");
179
+ }
180
+ }
181
+ ```
182
+
183
+ > **Warning** — Setting `twoFactorConfirmedAt` is what makes
184
+ > `TwoFactorMiddleware` start challenging the user. Persist the secret and a
185
+ > non-null `twoFactorConfirmedAt` together, only after a successful
186
+ > `verifyCode()`, so a half-finished enrolment never locks anyone out.
187
+
188
+ ## The challenge flow
189
+
190
+ After login, the challenge controller verifies a TOTP code (falling back to a
191
+ recovery code) and marks the session as confirmed. `verifyRecoveryCode()` returns
192
+ the remaining codes synchronously so you can persist the consumed set:
193
+
194
+ ```typescript
195
+ // app/controllers/TwoFactorChallengeController.ts
196
+ import { TwoFactor, TWO_FACTOR_SESSION_KEY } from "@zerotal/auth";
197
+
198
+ export class TwoFactorChallengeController extends Controller {
199
+ // GET /two-factor/challenge
200
+ async show(ctx: HttpContext) {
201
+ return this.render("two-factor/challenge");
202
+ }
203
+
204
+ // POST /two-factor/challenge
205
+ async verify(ctx: HttpContext) {
206
+ const user = ctx.user! as User;
207
+ const code = ctx.input("code") as string;
208
+ const secret = user.twoFactorSecret!;
209
+
210
+ // Try TOTP code first, then recovery code
211
+ const totpOk = TwoFactor.verifyCode(secret, code);
212
+
213
+ if (!totpOk) {
214
+ const storedHashed = JSON.parse(user.twoFactorRecoveryCodes ?? "[]") as string[];
215
+ const recoveryResult = TwoFactor.verifyRecoveryCode(storedHashed, code);
216
+
217
+ if (!recoveryResult.valid) {
218
+ return this.back().withErrors({ code: "Invalid code." });
219
+ }
220
+
221
+ // Consume the recovery code
222
+ await user
223
+ .fill({
224
+ twoFactorRecoveryCodes: JSON.stringify(recoveryResult.remaining),
225
+ })
226
+ .save();
227
+ }
228
+
229
+ // Mark 2FA as confirmed for this session
230
+ ctx.session.put(TWO_FACTOR_SESSION_KEY, true);
231
+ return this.redirect(ctx.session.get("url.intended", "/") as string);
232
+ }
233
+ }
234
+ ```
235
+
236
+ > **Tip** — `TWO_FACTOR_SESSION_KEY` (the string `"two_factor_confirmed"`) is
237
+ > exported from `@zerotal/auth`. Use the constant rather than re-typing the key so
238
+ > your controller and the middleware always agree.
239
+
240
+ ## Protecting routes
241
+
242
+ Add `TwoFactorMiddleware` **after** `AuthMiddleware` in any group that should
243
+ require 2FA:
244
+
245
+ ```typescript
246
+ // routes/web.ts
247
+ import { AuthMiddleware, TwoFactorMiddleware } from "@zerotal/auth";
248
+
249
+ Router.group({ middleware: [AuthMiddleware, TwoFactorMiddleware] }, () => {
250
+ Router.get("/dashboard", DashboardController, "index");
251
+ Router.get("/settings", SettingsController, "index");
252
+ });
253
+ ```
254
+
255
+ `TwoFactorMiddleware` passes the request through if:
256
+
257
+ - The request is authenticated but the user has no `twoFactorSecret`, or
258
+ - `twoFactorConfirmedAt` is null (enrolment never confirmed), or
259
+ - The session already contains `two_factor_confirmed === true`.
260
+
261
+ If the user has a confirmed secret but this session has not passed the challenge,
262
+ it redirects to `TwoFactorMiddleware.challengeRoute` (default
263
+ `/two-factor/challenge`). An unauthenticated request throws `UnauthorizedError`.
264
+
265
+ Override the redirect target globally by setting the static property:
266
+
267
+ ```typescript
268
+ // bootstrap/app.ts
269
+ import { TwoFactorMiddleware } from "@zerotal/auth";
270
+
271
+ TwoFactorMiddleware.challengeRoute = "/auth/2fa";
272
+ ```
273
+
274
+ ## Displaying the QR code
275
+
276
+ `getQrCodeSvg()` returns the scannable code as an inline `<svg>`, drawn in your
277
+ process. Inline it — do not fetch it, and do not log it:
278
+
279
+ ```tsx
280
+ // in a Flow page
281
+ const qr = TwoFactor.getQrCodeSvg(user.email, secret, { size: 220 });
282
+
283
+ return <div dangerouslySetInnerHTML={{ __html: qr }} />;
284
+ ```
285
+
286
+ > **Danger** — never hand the `otpauth://` URI to a QR image service. It carries
287
+ > the TOTP secret, so a request to `api.qrserver.com` or any similar endpoint
288
+ > posts the second factor to a third party and leaves it in their logs. Serving
289
+ > the image from a route of your own has the same shape of problem: it turns the
290
+ > secret into something requestable, proxy-loggable and browser-cacheable. That
291
+ > is why this renders in-process and returns markup rather than a URL.
292
+
293
+ Options: `issuer` overrides the configured one for this code; `size` sets width
294
+ and height in pixels (omit it and the symbol scales to its container);
295
+ `dark`/`light` set the colours, with `light: null` for a transparent background;
296
+ `quietZone` is the light margin in modules, which defaults to the 4 the spec
297
+ requires — drop it to `0` only when the surrounding element already provides a
298
+ light margin, because scanners use that margin to find the symbol's edges;
299
+ `alt` and `class` set the accessible name and the CSS class.
300
+
301
+ **Offer the secret as text too.** A phone cannot photograph its own screen, so
302
+ anyone enrolling on the device that holds the authenticator needs another way in
303
+ — the secret in readable blocks to type, or the `otpauth://` URI from
304
+ `getQrCodeUrl()` as a link, which opens the authenticator app directly:
305
+
306
+ ```tsx
307
+ <a href={TwoFactor.getQrCodeUrl(user.email, secret)}>Open in my authenticator</a>
308
+ ```
309
+
310
+ To draw the code yourself — to a canvas, a PNG, or your own markup — `encodeQr()`
311
+ returns the module matrix and `qrSvg()` renders one:
312
+
313
+ ```ts
314
+ import { encodeQr } from "@zerotal/auth";
315
+
316
+ const matrix = encodeQr(TwoFactor.getQrCodeUrl(user.email, secret));
317
+ for (let row = 0; row < matrix.size; row++) {
318
+ for (let col = 0; col < matrix.size; col++) {
319
+ if (matrix.isDark(row, col)) ctx.fillRect(col * 4, row * 4, 4, 4);
320
+ }
321
+ }
322
+ ```
323
+
324
+ The encoder covers byte mode at error-correction level M, versions 1 through 20
325
+ — `maxPayloadBytes()` bytes in all. A typical `otpauth://` URI is about 130
326
+ bytes; past the ceiling, `QrError` is thrown rather than a truncated symbol
327
+ produced, and the fix is a shorter issuer or account label.
328
+
329
+ Authenticator apps that support TOTP: Google Authenticator, Authy, 1Password,
330
+ Bitwarden, Microsoft Authenticator.
331
+
332
+ ## Testing
333
+
334
+ Set your suite up once as described in [Testing](/docs/testing) — everything
335
+ below assumes `createApp()` from your `tests/helpers.ts`.
336
+
337
+ Most of the two-factor surface is pure, so it tests without an application, a
338
+ database, or a clock:
339
+
340
+ ```typescript
341
+ // tests/auth/two-factor.test.ts
342
+ import { test, expect } from "bun:test";
343
+ import { TwoFactorService } from "@zerotal/auth";
344
+
345
+ test("a recovery code verifies once and is then consumed", () => {
346
+ const tf = new TwoFactorService();
347
+ const { plain, hashed } = tf.generateRecoveryCodes();
348
+
349
+ const first = tf.verifyRecoveryCode(hashed, plain[0]!);
350
+ expect(first.valid).toBe(true);
351
+ expect(first.remaining).toHaveLength(hashed.length - 1);
352
+
353
+ // The same code must not work twice.
354
+ expect(tf.verifyRecoveryCode(first.remaining, plain[0]!).valid).toBe(false);
355
+ });
356
+ ```
357
+
358
+ **Drive the challenge with a real code.** `generateCode(secret)` produces exactly
359
+ what an authenticator app would show, so the test exercises the TOTP path rather
360
+ than working around it:
361
+
362
+ ```typescript
363
+ // tests/http/two-factor.test.ts
364
+ import { test } from "bun:test";
365
+ import { createApp } from "../helpers.ts";
366
+ import { TwoFactorService } from "@zerotal/auth";
367
+ import { User } from "../../app/models/User.ts";
368
+
369
+ test("a valid code clears the challenge", async () => {
370
+ const app = await createApp();
371
+ const tf = new TwoFactorService();
372
+ const secret = tf.generateSecret();
373
+
374
+ const user = await User.create({
375
+ email: "jane@example.com",
376
+ twoFactorSecret: secret,
377
+ twoFactorConfirmedAt: new Date(),
378
+ });
379
+
380
+ const res = await app
381
+ .actingAs(user)
382
+ .post("/two-factor/challenge", { code: tf.generateCode(secret) });
383
+
384
+ res.assertRedirect("/dashboard");
385
+ await app.close();
386
+ });
387
+ ```
388
+
389
+ `generateCode(secret, -1)` returns the previous slot's code, which is how you
390
+ assert that your replay guard rejects a code that has already been used.
391
+
392
+ A recovery code works the same way and needs no clock at all — pass one of the
393
+ `plain` values from `generateRecoveryCodes()` to the same endpoint.
394
+
395
+ To prove a route is _protected_, assert the challenge redirect rather than trying
396
+ to get past it — `assertRedirect("/two-factor/challenge")` is the whole test.
397
+
398
+ ## References
399
+
400
+ The `TwoFactor` facade proxies the `two_factor` singleton (a `TwoFactorService`).
401
+ All methods are synchronous.
402
+
403
+ | Method | Signature | Description |
404
+ | ---------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
405
+ | `generateSecret()` | `() => string` | Random 20-byte base-32 secret for a new enrolment. |
406
+ | `getQrCodeUrl(label, secret, issuer?)` | `(label: string, secret: string, issuer?: string) => string` | `otpauth://totp/...` URI for an authenticator app. |
407
+ | `getQrCodeSvg(label, secret, options?)` | `(label: string, secret: string, options?: TwoFactorQrOptions) => string` | The same URI as an inline `<svg>`, drawn in-process. Inline it; never fetch it. |
408
+ | `generateCode(secret, offset?)` | `(secret: string, offset?: number) => string` | Produce the code an authenticator would show. For tests — never send it to a user. |
409
+ | `verifyCode(secret, token)` | `(secret: string, token: string) => boolean` | Verify a 6-digit TOTP code within the time window. |
410
+ | `generateRecoveryCodes()` | `() => { plain: string[]; hashed: string[] }` | Fresh one-time recovery codes (plaintext + hashes). |
411
+ | `verifyRecoveryCode(storedHashed, code)` | `(storedHashed: string[], code: string) => { valid: boolean; remaining: string[] }` | Consume a recovery code, returning the unused ones. |
412
+
413
+ ## Next steps
414
+
415
+ - [Authentication](/docs/authentication) — login, the auth middleware, and the session flow 2FA builds on.
416
+ - [Authorization](/docs/authorization) — gates, policies, roles, and permissions.
417
+ - [Session](/docs/session) — how the per-request session that stores `two_factor_confirmed` works.
418
+ - [Encryption & Hashing](/docs/encryption) — how passwords and other secrets are hashed.