@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.
- package/README.md +419 -0
- package/dist/auth/index.d.ts +3 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +3 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/second-factor-index.d.ts +7 -0
- package/dist/auth/second-factor-index.d.ts.map +1 -0
- package/dist/auth/second-factor-index.js +7 -0
- package/dist/auth/second-factor-index.js.map +1 -0
- package/dist/auth/second-factor-next.d.ts +39 -0
- package/dist/auth/second-factor-next.d.ts.map +1 -0
- package/dist/auth/second-factor-next.js +131 -0
- package/dist/auth/second-factor-next.js.map +1 -0
- package/dist/auth/second-factor-routes.d.ts +29 -0
- package/dist/auth/second-factor-routes.d.ts.map +1 -0
- package/dist/auth/second-factor-routes.js +35 -0
- package/dist/auth/second-factor-routes.js.map +1 -0
- package/dist/auth/second-factor.d.ts +117 -0
- package/dist/auth/second-factor.d.ts.map +1 -0
- package/dist/auth/second-factor.js +127 -0
- package/dist/auth/second-factor.js.map +1 -0
- package/dist/auth/two-factor-policy.d.ts +30 -1
- package/dist/auth/two-factor-policy.d.ts.map +1 -1
- package/dist/auth/two-factor-policy.js +39 -1
- package/dist/auth/two-factor-policy.js.map +1 -1
- package/dist/password/common.d.ts +2 -0
- package/dist/password/common.d.ts.map +1 -0
- package/dist/password/common.js +58 -0
- package/dist/password/common.js.map +1 -0
- package/dist/password/estimate-index.d.ts +10 -0
- package/dist/password/estimate-index.d.ts.map +1 -0
- package/dist/password/estimate-index.js +10 -0
- package/dist/password/estimate-index.js.map +1 -0
- package/dist/password/estimate.d.ts +65 -0
- package/dist/password/estimate.d.ts.map +1 -0
- package/dist/password/estimate.js +612 -0
- package/dist/password/estimate.js.map +1 -0
- package/dist/password/index.d.ts +22 -0
- package/dist/password/index.d.ts.map +1 -0
- package/dist/password/index.js +22 -0
- package/dist/password/index.js.map +1 -0
- package/dist/password/password-input.d.ts +55 -0
- package/dist/password/password-input.d.ts.map +1 -0
- package/dist/password/password-input.js +54 -0
- package/dist/password/password-input.js.map +1 -0
- package/dist/password/password-strength.d.ts +59 -0
- package/dist/password/password-strength.d.ts.map +1 -0
- package/dist/password/password-strength.js +74 -0
- package/dist/password/password-strength.js.map +1 -0
- package/dist/password/reveal.d.ts +56 -0
- package/dist/password/reveal.d.ts.map +1 -0
- package/dist/password/reveal.js +109 -0
- package/dist/password/reveal.js.map +1 -0
- 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.
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -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
|
package/dist/auth/index.d.ts.map
CHANGED
|
@@ -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"}
|
package/dist/auth/index.js
CHANGED
|
@@ -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
|
package/dist/auth/index.js.map
CHANGED
|
@@ -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,
|
|
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"}
|