@connextar/house 0.12.0 → 0.13.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 (54) hide show
  1. package/README.md +419 -0
  2. package/dist/auth/index.d.ts +3 -1
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/index.js +3 -1
  5. package/dist/auth/index.js.map +1 -1
  6. package/dist/auth/second-factor-index.d.ts +7 -0
  7. package/dist/auth/second-factor-index.d.ts.map +1 -0
  8. package/dist/auth/second-factor-index.js +7 -0
  9. package/dist/auth/second-factor-index.js.map +1 -0
  10. package/dist/auth/second-factor-next.d.ts +39 -0
  11. package/dist/auth/second-factor-next.d.ts.map +1 -0
  12. package/dist/auth/second-factor-next.js +131 -0
  13. package/dist/auth/second-factor-next.js.map +1 -0
  14. package/dist/auth/second-factor-routes.d.ts +29 -0
  15. package/dist/auth/second-factor-routes.d.ts.map +1 -0
  16. package/dist/auth/second-factor-routes.js +35 -0
  17. package/dist/auth/second-factor-routes.js.map +1 -0
  18. package/dist/auth/second-factor.d.ts +117 -0
  19. package/dist/auth/second-factor.d.ts.map +1 -0
  20. package/dist/auth/second-factor.js +127 -0
  21. package/dist/auth/second-factor.js.map +1 -0
  22. package/dist/auth/two-factor-policy.d.ts +30 -1
  23. package/dist/auth/two-factor-policy.d.ts.map +1 -1
  24. package/dist/auth/two-factor-policy.js +39 -1
  25. package/dist/auth/two-factor-policy.js.map +1 -1
  26. package/dist/password/common.d.ts +2 -0
  27. package/dist/password/common.d.ts.map +1 -0
  28. package/dist/password/common.js +58 -0
  29. package/dist/password/common.js.map +1 -0
  30. package/dist/password/estimate-index.d.ts +10 -0
  31. package/dist/password/estimate-index.d.ts.map +1 -0
  32. package/dist/password/estimate-index.js +10 -0
  33. package/dist/password/estimate-index.js.map +1 -0
  34. package/dist/password/estimate.d.ts +65 -0
  35. package/dist/password/estimate.d.ts.map +1 -0
  36. package/dist/password/estimate.js +612 -0
  37. package/dist/password/estimate.js.map +1 -0
  38. package/dist/password/index.d.ts +22 -0
  39. package/dist/password/index.d.ts.map +1 -0
  40. package/dist/password/index.js +22 -0
  41. package/dist/password/index.js.map +1 -0
  42. package/dist/password/password-input.d.ts +55 -0
  43. package/dist/password/password-input.d.ts.map +1 -0
  44. package/dist/password/password-input.js +54 -0
  45. package/dist/password/password-input.js.map +1 -0
  46. package/dist/password/password-strength.d.ts +59 -0
  47. package/dist/password/password-strength.d.ts.map +1 -0
  48. package/dist/password/password-strength.js +74 -0
  49. package/dist/password/password-strength.js.map +1 -0
  50. package/dist/password/reveal.d.ts +56 -0
  51. package/dist/password/reveal.d.ts.map +1 -0
  52. package/dist/password/reveal.js +109 -0
  53. package/dist/password/reveal.js.map +1 -0
  54. package/package.json +19 -1
package/README.md CHANGED
@@ -72,6 +72,14 @@ to owners and operators both — Quickie's operators could not re-run a failed
72
72
  overnight sweep of a per-organisation job — and prunes the ledger without ever
73
73
  deleting a row health is read from.
74
74
 
75
+ `password`, in 0.13.0, is the other case in the rule: something ten apps were
76
+ each about to write. None of them can show a password being typed, and the one
77
+ strength meter in the portfolio is a checklist "Password1!" passes. A reveal
78
+ toggle is easy to write and easy to get quietly wrong — a revealed password
79
+ submitted from a text field lands in the browser's autofill history — and a
80
+ strength estimate that differs between apps is worse than none. So there is one
81
+ toggle and one estimate, and the estimate runs on the server too.
82
+
75
83
  ## Modules
76
84
 
77
85
  ### `@connextar/house/errors`
@@ -134,6 +142,178 @@ possession of a device and is phishing-resistant; a social login delegates to a
134
142
  provider enforcing its own second factor. Neither is weaker than password plus
135
143
  TOTP. A link in an inbox is.
136
144
 
145
+ ### `@connextar/house/auth/second-factor`
146
+
147
+ The code screen after a magic link and after a social sign-in, so every sign-in
148
+ path treats two-factor the same way.
149
+
150
+ Better Auth's `twoFactor` plugin only intercepts the password routes
151
+ (`/sign-in/email`, `/sign-in/username`, `/sign-in/phone-number`). A magic link
152
+ (`/magic-link/verify`) and a Google, Microsoft or Apple callback (`/callback/:id`)
153
+ hand out a full session with no code. Until now the house apps refused magic
154
+ links for two-factor accounts, and let social sign-in skip the code.
155
+ `secondFactorEverywhere()` fixes both. For an account with two-factor on, a magic
156
+ link or a social sign-in now ends up exactly where a password sign-in does:
157
+
158
+ - **No session survives.** The session the route just created is deleted, and
159
+ its cookies (including the cached `session_data`) are expired.
160
+ - **The pending state is Better Auth's own.** It uses the same signed
161
+ `two_factor` cookie and verification row, so `/two-factor/verify-totp` and
162
+ `/two-factor/verify-backup-code` finish the sign-in unchanged. Attempt limits,
163
+ account lockout and "trust this device" all keep working.
164
+ - **A trusted device skips the code** under the plugin's own rule, and its trust
165
+ cookie is rotated.
166
+ - **The browser goes to the code screen** instead of `callbackURL`. The original
167
+ destination comes along as `?next=`, but only when it is a path on this site.
168
+ Another origin (even one Better Auth trusts), `//host`, `/\host` and
169
+ `javascript:` are all dropped.
170
+ - **A JSON caller**, such as a magic-link verify without `callbackURL` or
171
+ `/sign-in/social` with an `idToken`, gets the plugin's own
172
+ `{ twoFactorRedirect: true }`. `twoFactorClient`'s `onTwoFactorRedirect`
173
+ already handles that.
174
+
175
+ Passkeys stay a single step. A passkey proves possession of a device and resists
176
+ phishing, so it is excluded and cannot be added to the list.
177
+
178
+ **How it works.** This module copies none of Better Auth's pending-state
179
+ internals: no cookie names, no signing, no identifiers. On these routes it runs
180
+ the two-factor plugin's own password-sign-in after-hook, then turns the plugin's
181
+ `{ twoFactorRedirect: true }` into a redirect to the code screen. It depends on
182
+ three things, and `second-factor.test.ts` pins each of them against the installed
183
+ Better Auth:
184
+
185
+ - the plugin exposes that hook;
186
+ - the hook still covers only the password routes;
187
+ - the hook answers `twoFactorRedirect` when it takes the session away.
188
+
189
+ `init` also refuses to start if it cannot find the hook. The tests run a real
190
+ Better Auth instance (memory adapter, the real `twoFactor` and `magicLink`
191
+ plugins, a stub OAuth provider, requests through `auth.handler` with a cookie
192
+ jar) against both lines the apps use. The `house` Vitest project runs them
193
+ against **1.7.2**. The `better-auth-1.6` project runs them against **1.6.33**
194
+ (`better-auth-1-6`, an npm alias, re-pointed with a Vitest alias). The supported
195
+ peer range is `>=1.6.33 <1.8.0`. Before you widen it, update the dev dependency
196
+ and read the new two-factor plugin.
197
+
198
+ #### Server
199
+
200
+ ```ts
201
+ import { secondFactorEverywhere } from "@connextar/house/auth/second-factor";
202
+
203
+ plugins: [
204
+ twoFactor({ … }),
205
+ passkey({ … }),
206
+ secondFactorSessions(), // the app's own proof recorder: unchanged
207
+ secondFactorEverywhere(), // after twoFactor(), before nextCookies()
208
+ magicLink({ … }),
209
+ ],
210
+ ```
211
+
212
+ | Option | Default | What it does |
213
+ | ---------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
214
+ | `codeScreenPath` | `/auth/two-factor` | Where the code screen is. Every house app already uses this path. |
215
+ | `nextParam` | `next` | The query parameter that carries the destination. |
216
+ | `appOrigin` | origin of Better Auth's `baseURL` | The origin of the code screen, and the only origin a `next` may point to. |
217
+ | `routes` | all of `SECOND_FACTOR_CHALLENGE_ROUTES` | `/magic-link/verify`, `/callback/:id`, `/oauth2/callback/:providerId`, `/sign-in/social` (idToken), `/one-tap/callback`, `/sign-in/email-otp`. A route that is not installed never matches. |
218
+ | `providers` | `"all"` | Limits the social routes to these provider ids, e.g. `["google", "microsoft"]`. |
219
+ | `enabled` | `true` | Set it to `false` to opt this app out and keep the plugin listed. The old magic-link refusal comes back (see below). |
220
+
221
+ **Composing with `second-factor-session`.** Your existing plugin needs no change.
222
+ The session a magic link or a social callback creates is deleted before the
223
+ response goes out. The session that counts is created by
224
+ `/two-factor/verify-totp` or `/two-factor/verify-backup-code`, and your plugin
225
+ already records those as proven. The tests check this with a stand-in recorder.
226
+
227
+ One optional refinement: on a trusted device, a magic-link or social session
228
+ survives, just as `/sign-in/email` does today. Your plugin does not count that
229
+ session as proven, so an administrator is asked to sign in again. That fails
230
+ safe. If you want those sessions to count the way `/sign-in/email` does, extend
231
+ your `provesSecondFactor` rule with care: `providers` can leave some callbacks
232
+ unchallenged.
233
+
234
+ #### The code screen
235
+
236
+ When the code or backup code is accepted, go to the safe `next`:
237
+
238
+ ```ts
239
+ import { secondFactorNext } from "@connextar/house/auth";
240
+
241
+ const { error } = await authClient.twoFactor.verifyTotp({ code, trustDevice });
242
+ if (!error) router.replace(secondFactorNext(searchParams)); // "/" if next is missing or unsafe
243
+ ```
244
+
245
+ `@connextar/house/auth` does not import Better Auth, so client components can use
246
+ it. `safeNextPath(candidate, fallback)` is the rule underneath.
247
+
248
+ #### Retiring the magic-link refusal
249
+
250
+ Pass the hook's `ctx.context` as a fourth argument to `shouldBlockForTwoFactor`:
251
+
252
+ ```ts
253
+ if (await shouldBlockForTwoFactor(ctx.path, email, lookup, ctx.context)) throw new APIError("BAD_REQUEST", { … });
254
+ ```
255
+
256
+ While `secondFactorEverywhere()` is installed, enabled and covering
257
+ `/magic-link/verify`, the refusal steps aside and skips the lookup
258
+ (`magicLinkRefusalRetired(ctx.context)`). If the challenge is ever switched off,
259
+ the refusal comes back by itself, so turning the challenge off cannot quietly
260
+ reopen the magic-link hole. You can delete the refusal outright once every
261
+ environment runs the plugin. Keeping the fourth argument is the safe middle
262
+ ground.
263
+
264
+ #### Email link scanners
265
+
266
+ Link scanners such as Microsoft Defender Safe Links, Mimecast and Proofpoint open
267
+ the link before the person does. A magic link works only once, so the scanner
268
+ uses it up. With two-factor on, the scanner gets only the pending code state
269
+ (never a session), and the person's own click lands on `?error=INVALID_TOKEN`.
270
+ Without two-factor it was already this way, except that the scanner got a full
271
+ session. A test pins this behaviour.
272
+
273
+ **Mitigation, optional and simple:** put a confirm page between the email and the
274
+ verify URL. Scanners fetch pages; they do not press buttons.
275
+
276
+ ```ts
277
+ // sendMagicLink: email the confirm page instead of Better Auth's URL
278
+ import { magicLinkConfirmHref } from "@connextar/house/auth";
279
+ sendMagicLink: async ({ email, url }) => send(email, magicLinkConfirmHref(url, "/auth/confirm-link")),
280
+
281
+ // app/auth/confirm-link/page.tsx: a "Continue signing in" button
282
+ import { magicLinkVerifyHref } from "@connextar/house/auth";
283
+ const href = magicLinkVerifyHref(searchParams, "/api/auth/magic-link/verify"); // null → "link incomplete"
284
+ <a href={href}>Continue signing in</a>
285
+ ```
286
+
287
+ `magicLinkVerifyHref` rebuilds the verify URL from the app's own fixed path,
288
+ never from the query. It keeps only the token and same-site redirect targets.
289
+ Better Auth still origin-checks those targets itself.
290
+
291
+ #### Adopting it in an app
292
+
293
+ This applies to AltEd, Quickie, PM Tool, senco, KRP, FB Web (1.7.2) and
294
+ PharmaLine (1.6.33). All of them already use `/auth/two-factor` and `?next=`.
295
+
296
+ 1. Bump `@connextar/house`.
297
+ 2. In `auth.ts`, add `secondFactorEverywhere()` after `twoFactor()` and before
298
+ `nextCookies()`, if the app has it.
299
+ 3. In the `hooks.before` refusal, pass `ctx.context` to `shouldBlockForTwoFactor`,
300
+ or delete the refusal and the app's `lib/auth/two-factor.ts` binding. Do not
301
+ change the deleted-account or suspended-account checks.
302
+ 4. On the code screen, send the person to `secondFactorNext(searchParams)` after
303
+ a successful code, or keep the app's own safe-next helper if it already
304
+ validates `next`.
305
+ 5. Update the copy. Remove the settings dialog line "email sign-in links stop
306
+ working on this account… Passkeys and Google sign-in keep working" and replace
307
+ it with something like "you'll be asked for your code after an email link or
308
+ Google sign-in too; passkeys stay one step".
309
+ 6. Update comments that say social or magic-link sessions skip the second factor
310
+ (`second-factor-session.ts`, AltEd `admin-second-factor.ts`). The admin proof
311
+ rule itself does not change.
312
+ 7. Add an app test: a magic link for a two-factor account redirects to
313
+ `/auth/two-factor?next=…` with no session, and the code then signs in with
314
+ proof.
315
+ 8. Optional: the confirm page for link scanners.
316
+
137
317
  ### `@connextar/house/account`
138
318
 
139
319
  The contract for "your data" and "delete my account" — types, the export notice,
@@ -448,6 +628,245 @@ independent sections are a form with three headings. A flow whose steps are
448
628
  separate saves, each landing before the next opens, is not one form and must not
449
629
  pretend to be: this wizard keeps one register and submits once.
450
630
 
631
+ ### `@connextar/house/password`, `/password/estimate`
632
+
633
+ The password field every form uses, and the strength meter for the ones where a
634
+ password is being chosen. Ten apps have password fields, in 38 files, and not
635
+ one of them can show what has been typed. One shows a strength meter
636
+ (Canisters, with an unused copy in FB): a checklist of length, digit, capital and
637
+ symbol that "Password1!" passes on every line.
638
+
639
+ | Export | What it is |
640
+ | --------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
641
+ | `PasswordInput` | `<input>` with a show/hide toggle inside it. Drop-in for `<Input type="password">` |
642
+ | `PasswordStrength` | Four bars and one plain sentence, under a field that sets a new password |
643
+ | `estimatePasswordStrength(password, { userInputs, minLength })` | The judgement itself — score 0–4, label, sentence, reason. Pure; server-safe |
644
+ | `usePasswordReveal(options)` | The toggle's behaviour without its markup, for fields that are not a plain input |
645
+ | `@connextar/house/password/estimate` | The estimator alone: imports no package at all, not even React |
646
+
647
+ ```tsx
648
+ import { PasswordInput, PasswordStrength } from "@connextar/house/password";
649
+
650
+ // Signing in, or confirming it's you: the toggle, no meter.
651
+ <PasswordInput id="password" purpose="current" {...register("password")} />;
652
+
653
+ // Choosing one: the toggle and the meter.
654
+ const newPassword = watch("newPassword") ?? "";
655
+ <PasswordInput id="new-password" purpose="new" aria-describedby="new-password-strength" {...register("newPassword")} />;
656
+ <PasswordStrength
657
+ id="new-password-strength"
658
+ password={newPassword}
659
+ userInputs={[session.user.email, session.user.name, "Quickie"]}
660
+ minLength={8}
661
+ />;
662
+ ```
663
+
664
+ Inside a shadcn `FormField`, `<FormControl><PasswordInput purpose="new" {...field} /></FormControl>`
665
+ — `FormControl`'s `id`, `aria-invalid` and `aria-describedby` land on the input.
666
+ With plain state, `value` and `onChange` as with any input.
667
+
668
+ **`PasswordInput`.** Every input prop and the ref pass straight through, so
669
+ `register`, a `Controller`'s `field` and plain state all work unchanged (all
670
+ three are tested). `purpose` sets `autoComplete` — `"current"` (the default)
671
+ gives `current-password`, `"new"` gives `new-password` — and an explicit
672
+ `autoComplete` wins. What the toggle takes care of:
673
+
674
+ - **It is a `<button type="button">`**, inside the field and in the tab order,
675
+ so it never submits the form. Its accessible name stays **"Show password"** and
676
+ `aria-pressed` carries the state. A toggle that also changed its name to "Hide
677
+ password" would be read as "Hide password, pressed" — the opposite of what is
678
+ on screen — which is why WAI-ARIA's button pattern says the name of a toggle
679
+ must not change. Sighted people see the eye become a struck-through eye.
680
+ - **The field keeps focus and the caret stays where it was.** A pointer press on
681
+ the toggle never takes focus; the selection is put back after the type
682
+ switches, since some browsers reset it. A keyboard user who tabbed to the
683
+ toggle stays on the toggle.
684
+ - **It is hidden again as its form submits** — synchronously, before any submit
685
+ handler or the browser reads the form. A password submitted from a text field
686
+ is a value the browser may keep in its ordinary autofill history and offer in
687
+ other text boxes. It is hidden on reset too.
688
+ - **Spellcheck, autocorrect and autocapitalise are always off.** A revealed field
689
+ is a text field, and enhanced spellcheck sends the contents of text fields to
690
+ a server.
691
+ - `revealed`/`onRevealedChange` make it controlled — one toggle state for a new
692
+ and a confirm field. Edge's own reveal button is hidden, so there is one eye.
693
+
694
+ **`PasswordStrength`** — only where a password is being chosen:
695
+
696
+ | Field | Toggle | Meter |
697
+ | ------------------------------------------------------------------------ | ------ | ----------------------------- |
698
+ | Sign-in | yes | **never** |
699
+ | Two-factor enable/disable "confirm your password", any re-authentication | yes | never |
700
+ | Change password — current | yes | never |
701
+ | Sign-up, reset, set a first password, change password — new | yes | **yes** |
702
+ | Confirm new password | yes | no — it must match, not score |
703
+ | Someone setting a password for someone else (a guardian for a child) | yes | yes |
704
+
705
+ Rating a password someone already has tells them nothing they can act on, and
706
+ tells anyone watching the screen something about it. A form that is sign-in and
707
+ sign-up in one (AltEd, Quickie, PharmaLine, FB) shows the meter only in sign-up
708
+ mode.
709
+
710
+ - **It refuses nothing.** There is no `valid` to wire a button to. Whether a
711
+ password is allowed is the app's rule — its minimum length, its schema — and
712
+ `minLength` exists only so the sentence can say "Use at least 8 characters."
713
+ while the password is shorter, in agreement with the form rather than
714
+ contradicting it.
715
+ - **Nothing shows for an empty field.** The label and sentence are ordinary text
716
+ (put `id` on it and point the field's `aria-describedby` at it); the bars are
717
+ `aria-hidden`. A visually hidden `role="status"` `aria-live="polite"` region
718
+ says the new strength once typing has paused (`announceDelay`, 1 s) and only
719
+ when it changed — never a word per keystroke.
720
+ - `userInputs` takes what the person's password should not be built from: email,
721
+ name, username, organisation, **and the app's own name**. They are matched in
722
+ pieces and through l33t, so `amir@connextar.com` also catches `Amir2026!` and
723
+ `C0nn3xtar`.
724
+ - `messages` replaces the labels and sentences (Canisters is `next-intl`);
725
+ `classNames` replaces the look part by part; the root carries `data-score` for
726
+ an app styling it from its own stylesheet.
727
+
728
+ **The estimator — zxcvbn's method, written small.** A password is read as the
729
+ cheapest sequence of patterns a cracker would try: a common password or word
730
+ (including reversed and l33t), the person's own details, a keyboard walk
731
+ (QWERTY and keypad), a sequence (`abc`, `9753`), a repeat (`aaaa`, `abcabc`), a
732
+ date or year, and plain characters for whatever is left. The score is the
733
+ number of guesses that costs, on zxcvbn's scale: under 10³ is 0, 10⁶ is 1, 10⁸
734
+ is 2, 10¹⁰ is 3, and above is 4. Two departures from zxcvbn, both making it
735
+ stricter: the person's own words, dates, and up to four digits or symbols on
736
+ either end are free pieces rather than earning zxcvbn's allowance for an
737
+ unpredictable structure — so "Summer2024!" and "amir-connextar-2026" are weak
738
+ rather than fair — and a separator after a word belongs to the word.
739
+
740
+ | Password | Score | Why |
741
+ | ----------------------------------------------- | ----- | --------------------------------------------- |
742
+ | `password`, `qwerty`, `trustno1` | 0 | among the most common passwords |
743
+ | `Password1!`, `P@ssw0rd123!`, `Summer2024!` | 0–1 | a common word dressed up |
744
+ | `xcvbnm,.`, `78963214` | 0–1 | keyboard walks |
745
+ | `aaaaaaaa`, `abcabcabc`, `abcdefgh`, `98765432` | 0 | repeats and sequences |
746
+ | `01/02/1987`, `19870201` | 1 | dates |
747
+ | `amir-connextar-2026` for amir@connextar.com | 1 | the person's own details (3+ for anyone else) |
748
+ | `kX9#vQ2$mL` | 3 | ten random characters |
749
+ | `correct horse battery staple` | 4 | a long passphrase |
750
+
751
+ **Why not zxcvbn itself.** It is the honest measure, and this is its method.
752
+ What it adds is frequency lists — tens of thousands of leaked passwords, English
753
+ words, names and surnames — and they are most of its weight: zxcvbn's README
754
+ puts the original at about 400 kB gzipped (820 kB minified), and
755
+ `@zxcvbn-ts/core` with its common and English packs is published as 0.8 MB,
756
+ 1.9 MB and 5.4 MB unpacked. Taking it as an optional peer would mean ten apps
757
+ each deciding whether to install it and each lazy-loading a chunk on their
758
+ sign-up page, and an app that did not would quietly score differently — the
759
+ divergence this package exists to prevent. The estimator here is **12.6 kB
760
+ minified, 5.9 kB gzipped**, dictionary included; the whole module with both
761
+ components is 17.0 kB / 7.7 kB, before React and two lucide icons. The cost of
762
+ the smaller dictionary is known and one-sided: a rare word nobody has leaked
763
+ ("Tr0ub4dor") scores as random characters, so the estimate is a little generous
764
+ on unusual vocabulary and never on the patterns people actually lean on. The
765
+ first 100 characters are analysed; the search takes a fraction of a
766
+ millisecond for anything typed.
767
+
768
+ **On the server**, from `@connextar/house/password/estimate`, which imports no
769
+ package at all:
770
+
771
+ ```ts
772
+ import { estimatePasswordStrength } from "@connextar/house/password/estimate";
773
+
774
+ const strength = estimatePasswordStrength(body.newPassword, { userInputs: [user.email, user.name] });
775
+ if (strength.score < 2) return problem(400, strength.sentence);
776
+ ```
777
+
778
+ Whether to refuse the weakest is each app's decision, and it is the same
779
+ judgement the meter showed. Nothing in this module refuses anything.
780
+
781
+ **Fields that are not a plain input.** `usePasswordReveal({ inputId })` returns
782
+ `inputProps` and `toggleProps` (and `inputRef`) to spread on markup of the app's
783
+ own: Tracker C's floating label sits _after_ its input and is styled with a
784
+ sibling selector that a wrapper would break; an MUI `TextField` puts the toggle
785
+ in an `InputAdornment`. Same behaviour, the app's markup. The toggle still needs
786
+ its `aria-label`.
787
+
788
+ **Look.** The input is the shadcn input on the theme tokens, with room on the
789
+ right for the eye; an app whose `Input` differs passes its classes as
790
+ `classNames.input`, and `className` is appended as with its own `Input`. KRP,
791
+ MAS and Tracker C have no shadcn tokens and pass their own classes. The meter's
792
+ amber and green are Tailwind palette classes — no theme token names them — and
793
+ all of it rides the `@source` line in [Tailwind setup](#tailwind-setup-required-for-every-component).
794
+
795
+ #### Adoption, per app
796
+
797
+ Every file with a password field on 2026-09-30. **New** marks a field that sets a
798
+ new password — toggle and meter; every other field gets the toggle only. No app
799
+ has a reveal toggle today.
800
+
801
+ | App | File | Fields |
802
+ | ---------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
803
+ | AltEd | `components/auth/sign-in-form.tsx` | sign-in; sign-up (**new**, same field in sign-up mode) |
804
+ | | `components/auth/reset-password-form.tsx` | reset **new**; confirm |
805
+ | | `components/settings/account/password-section.tsx` | set **new** + confirm; change: current, **new** + confirm (all through the file-local `PasswordField`) |
806
+ | | `components/settings/account/two-factor-section.tsx` | enable and disable: confirm password |
807
+ | | `components/learners/learner-sign-in.tsx` | guardian sets a learner's password (**new**) |
808
+ | Quickie | `components/auth/sign-in-form.tsx` | sign-in; sign-up (**new**) |
809
+ | | `components/auth/reset-password-form.tsx` | reset **new**; confirm |
810
+ | | `components/settings/account/password-section.tsx` | set **new** + confirm; change: current, **new** + confirm |
811
+ | | `components/settings/account/two-factor-section.tsx` | enable and disable: confirm password |
812
+ | PM Tool | `components/auth/sign-in-form.tsx` | sign-in (no sign-up); no `autoComplete` today |
813
+ | | `app/auth/reset-password/page.tsx` | reset **new** (no confirm); no `autoComplete` today |
814
+ | | `components/settings/account/password-section.tsx` | set **new** + confirm; change: current, **new** + confirm |
815
+ | | `components/settings/account/two-factor-section.tsx` | enable and disable: confirm password |
816
+ | senco | `components/auth/sign-in-form.tsx` | sign-in; sign-up (**new**, a separate form) |
817
+ | | `components/auth/reset-password-form.tsx` | reset **new**; confirm |
818
+ | | `components/settings/account/password-section.tsx` | set **new** + confirm; change: current, **new** + confirm |
819
+ | | `components/settings/account/two-factor-section.tsx` | enable and disable: confirm password |
820
+ | PharmaLine | `components/auth/sign-in-form.tsx` | sign-in; sign-up (**new**) |
821
+ | | `components/auth/reset-password-form.tsx` | reset **new**; confirm |
822
+ | | `components/settings/account/password-section.tsx` | set **new** + confirm; change: current, **new** + confirm |
823
+ | | `components/settings/account/two-factor-section.tsx` | enable and disable: confirm password |
824
+ | KRP | `src/components/Auth/SignInForm.tsx` | child sign-in; guardian sign-in |
825
+ | | `src/components/Auth/SignUpForm.tsx` | sign-up **new**; confirm |
826
+ | | `src/app/auth/reset-password/page.tsx` | reset **new**; confirm |
827
+ | | `src/page-components/guardian/ProfileSettings.tsx` | current (may be empty to set a first one); **new**; confirm |
828
+ | | `src/page-components/guardian/ChildrenManagement.tsx` | guardian sets a child's password: **new**; confirm |
829
+ | FB Web | `components/auth/sign-in-form.tsx` | sign-in; sign-up (**new**) |
830
+ | | `components/auth/reset-password-form.tsx` | reset **new**; confirm |
831
+ | | `components/settings/account/password-section.tsx` | set **new** + confirm; change: current, **new** + confirm |
832
+ | | `components/settings/account/two-factor-section.tsx` | enable and disable: confirm password |
833
+ | | `components/profile/password-strength.tsx` | an unused checklist meter — delete it |
834
+ | MAS | `src/app/(site)/signin/SigninForm.tsx` | sign-in |
835
+ | | `src/app/(site)/signup/SignupForm.tsx` | sign-up **new** (no confirm) |
836
+ | Tracker C | `src/app/(routes)/auth/login/page.tsx` | sign-in (password mode) — Formik, floating label: use `usePasswordReveal` |
837
+ | | `src/app/(routes)/auth/signup/page.tsx` | sign-up **new** (no confirm) |
838
+ | | `src/app/(routes)/auth/forgot-password/page.tsx` | reset **new**; confirm |
839
+ | Canisters | `components/auth/login-form.tsx` | sign-in |
840
+ | | `components/auth/register-form.tsx` | sign-up **new** — replaces its inline requirements checklist |
841
+ | | `components/profile/settings-form.tsx` | change: current; **new** — replaces `components/profile/password-strength.tsx` |
842
+
843
+ Every app is shadcn/Radix on Tailwind v4 with a `components/ui/input.tsx`, except
844
+ KRP (Tailwind v4, its own `src/components/Input.tsx`), MAS (raw inputs with
845
+ literal classes in these two forms; MUI elsewhere), Tracker C (Formik and global
846
+ `form-textbox` classes; MUI elsewhere, not in these forms) and Canisters
847
+ (shadcn on Tailwind 3.3). Forms are react-hook-form + zod, except the two-factor
848
+ dialogs everywhere and PM Tool's sign-in and reset (plain state), KRP and MAS
849
+ (plain state), and Tracker C (Formik + Yup, plain state on reset).
850
+
851
+ Found while surveying, and each app's to fix while it is there:
852
+
853
+ - **Minimums agree at 8 everywhere, maximums do not.** Better Auth is 8–128 in
854
+ all nine apps that use it (Canisters has its own auth). **Tracker C's sign-up
855
+ Yup caps at 20** (and its message says "at least"), so a passphrase the server
856
+ accepts is refused.
857
+ **Canisters has no maximum**, and bcrypt ignores everything past 72 bytes.
858
+ - **Rules disagree within an app.** AltEd, Quickie, PharmaLine and FB require a
859
+ letter and a digit on set/reset/change but not on sign-up, where only Better
860
+ Auth's 8–128 applies — and in all five apps with that schema (PM Tool and
861
+ senco without the letter-and-digit rule) the server applies it only on
862
+ `/api/auth/set-password`, never on sign-up, reset or change. Canisters' sign-up needs 8
863
+ characters, its checklist shows digit and symbol, and change-password needs
864
+ digit, capital and symbol. Composition rules are what "Password1!" is built
865
+ to pass; the meter is the better guide, and length the better rule.
866
+ - **Missing `autoComplete`**: PM Tool's sign-in and reset, and every password
867
+ field in KRP, MAS, Tracker C and Canisters. `purpose` fixes it on adoption.
868
+ - **Tracker C's reset** posts to the legacy API rather than Better Auth.
869
+
451
870
  ### `@connextar/house/setup-guide`
452
871
 
453
872
  The checklist a new account works through, and what finishing a step earns.
@@ -1,2 +1,4 @@
1
- export { TWO_FACTOR_BLOCKED_MESSAGE, TWO_FACTOR_BLOCKED_PATHS, isTwoFactorBlockedPath, shouldBlockForTwoFactor, totpSecretFromUri, type TwoFactorBlockedPath, type TwoFactorLookup, } from "./two-factor-policy.js";
1
+ export { TWO_FACTOR_BLOCKED_MESSAGE, TWO_FACTOR_BLOCKED_PATHS, isTwoFactorBlockedPath, magicLinkRefusalRetired, shouldBlockForTwoFactor, totpSecretFromUri, type AuthContextLike, type TwoFactorBlockedPath, type TwoFactorLookup, } from "./two-factor-policy.js";
2
+ export { DEFAULT_CODE_SCREEN_PATH, DEFAULT_NEXT_PARAM, SECOND_FACTOR_CHALLENGE_ROUTES, SECOND_FACTOR_EVERYWHERE_ID, type SecondFactorChallengeRoute, } from "./second-factor-routes.js";
3
+ export { magicLinkConfirmHref, magicLinkVerifyHref, safeNextPath, secondFactorNext } from "./second-factor-next.js";
2
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,0BAA0B,EAC1B,wBAAwB,EACxB,sBAAsB,EACtB,uBAAuB,EACvB,iBAAiB,EACjB,KAAK,oBAAoB,EACzB,KAAK,eAAe,GACrB,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,0BAA0B,EAC1B,wBAAwB,EACxB,sBAAsB,EACtB,uBAAuB,EACvB,uBAAuB,EACvB,iBAAiB,EACjB,KAAK,eAAe,EACpB,KAAK,oBAAoB,EACzB,KAAK,eAAe,GACrB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,8BAA8B,EAC9B,2BAA2B,EAC3B,KAAK,0BAA0B,GAChC,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC"}
@@ -1,2 +1,4 @@
1
- export { TWO_FACTOR_BLOCKED_MESSAGE, TWO_FACTOR_BLOCKED_PATHS, isTwoFactorBlockedPath, shouldBlockForTwoFactor, totpSecretFromUri, } from "./two-factor-policy.js";
1
+ export { TWO_FACTOR_BLOCKED_MESSAGE, TWO_FACTOR_BLOCKED_PATHS, isTwoFactorBlockedPath, magicLinkRefusalRetired, shouldBlockForTwoFactor, totpSecretFromUri, } from "./two-factor-policy.js";
2
+ export { DEFAULT_CODE_SCREEN_PATH, DEFAULT_NEXT_PARAM, SECOND_FACTOR_CHALLENGE_ROUTES, SECOND_FACTOR_EVERYWHERE_ID, } from "./second-factor-routes.js";
3
+ export { magicLinkConfirmHref, magicLinkVerifyHref, safeNextPath, secondFactorNext } from "./second-factor-next.js";
2
4
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,0BAA0B,EAC1B,wBAAwB,EACxB,sBAAsB,EACtB,uBAAuB,EACvB,iBAAiB,GAGlB,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,0BAA0B,EAC1B,wBAAwB,EACxB,sBAAsB,EACtB,uBAAuB,EACvB,uBAAuB,EACvB,iBAAiB,GAIlB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,8BAA8B,EAC9B,2BAA2B,GAE5B,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@connextar/house/auth/second-factor` — the server half: a Better Auth plugin. Kept off `@connextar/house/auth`
3
+ * so that entry point stays free of any Better Auth import and safe for client components.
4
+ */
5
+ export { findTwoFactorSignInHook, secondFactorEverywhere, type SecondFactorEverywhereOptions, } from "./second-factor.js";
6
+ export { DEFAULT_CODE_SCREEN_PATH, DEFAULT_NEXT_PARAM, SECOND_FACTOR_CHALLENGE_ROUTES, SECOND_FACTOR_EVERYWHERE_ID, type SecondFactorChallengeRoute, } from "./second-factor-routes.js";
7
+ //# sourceMappingURL=second-factor-index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"second-factor-index.d.ts","sourceRoot":"","sources":["../../src/auth/second-factor-index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EACL,uBAAuB,EACvB,sBAAsB,EACtB,KAAK,6BAA6B,GACnC,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,8BAA8B,EAC9B,2BAA2B,EAC3B,KAAK,0BAA0B,GAChC,MAAM,2BAA2B,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@connextar/house/auth/second-factor` — the server half: a Better Auth plugin. Kept off `@connextar/house/auth`
3
+ * so that entry point stays free of any Better Auth import and safe for client components.
4
+ */
5
+ export { findTwoFactorSignInHook, secondFactorEverywhere, } from "./second-factor.js";
6
+ export { DEFAULT_CODE_SCREEN_PATH, DEFAULT_NEXT_PARAM, SECOND_FACTOR_CHALLENGE_ROUTES, SECOND_FACTOR_EVERYWHERE_ID, } from "./second-factor-routes.js";
7
+ //# sourceMappingURL=second-factor-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"second-factor-index.js","sourceRoot":"","sources":["../../src/auth/second-factor-index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EACL,uBAAuB,EACvB,sBAAsB,GAEvB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,8BAA8B,EAC9B,2BAA2B,GAE5B,MAAM,2BAA2B,CAAC"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `candidate` if it is a same-site path, otherwise `fallback`. The result always starts with a single `/`, so it is
3
+ * safe to hand straight to `router.replace()` or `redirect()`.
4
+ */
5
+ export declare function safeNextPath(candidate: string | null | undefined, fallback?: string): string;
6
+ /**
7
+ * The code screen's `next`, made safe. Pass the page's search params (a `URLSearchParams`, Next's
8
+ * `ReadonlyURLSearchParams`, or a raw `?a=b` string); read it once the code — or a backup code — has been accepted:
9
+ *
10
+ * ```ts
11
+ * const { error } = await authClient.twoFactor.verifyTotp({ code, trustDevice });
12
+ * if (!error) router.replace(secondFactorNext(useSearchParams()));
13
+ * ```
14
+ */
15
+ export declare function secondFactorNext(search: string | {
16
+ get(name: string): string | null;
17
+ } | null | undefined, options?: {
18
+ param?: string;
19
+ fallback?: string;
20
+ }): string;
21
+ /**
22
+ * The same-site path of `location` (absolute or relative) on `origin`, or null when it points anywhere else.
23
+ * Used by the server to turn a sign-in route's `callbackURL` into the code screen's `next`.
24
+ */
25
+ export declare function sameSitePath(location: string | null | undefined, origin: string): string | null;
26
+ /**
27
+ * The code screen's URL on `origin`, carrying `next` when it is worth carrying: a safe same-site path that is not
28
+ * the site root (the default anyway) and not the code screen itself (which would loop).
29
+ */
30
+ export declare function codeScreenUrl(origin: string, codeScreenPath: string, next: string | null, param?: string): string;
31
+ export declare function magicLinkConfirmHref(verifyUrl: string, confirmPath: string): string;
32
+ /**
33
+ * The verify URL to send the "Continue" button to, or null when the page was opened without a token. `verifyPath`
34
+ * is the app's own route, e.g. `/api/auth/magic-link/verify`.
35
+ */
36
+ export declare function magicLinkVerifyHref(search: string | {
37
+ get(name: string): string | null;
38
+ }, verifyPath: string): string | null;
39
+ //# sourceMappingURL=second-factor-next.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"second-factor-next.d.ts","sourceRoot":"","sources":["../../src/auth/second-factor-next.ts"],"names":[],"mappings":"AAcA;;;GAGG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,QAAQ,SAAM,GAAG,MAAM,CAezF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,GAAG;IAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,EACxE,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClD,MAAM,CAMR;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAa/F;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE,MAAM,GAAG,IAAI,EACnB,KAAK,GAAE,MAA2B,GACjC,MAAM,CAIR;AAkBD,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAQnF;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,GAAG;IAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;CAAE,EACrD,UAAU,EAAE,MAAM,GACjB,MAAM,GAAG,IAAI,CAaf"}