@connextar/house 0.11.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 +581 -19
- 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/jobs/health.d.ts +17 -4
- package/dist/jobs/health.d.ts.map +1 -1
- package/dist/jobs/health.js +10 -7
- package/dist/jobs/health.js.map +1 -1
- package/dist/jobs/index.d.ts +4 -3
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js +3 -2
- package/dist/jobs/index.js.map +1 -1
- package/dist/jobs/job-health-list.d.ts +15 -3
- package/dist/jobs/job-health-list.d.ts.map +1 -1
- package/dist/jobs/job-health-list.js +11 -7
- package/dist/jobs/job-health-list.js.map +1 -1
- package/dist/jobs/prune.d.ts +45 -0
- package/dist/jobs/prune.d.ts.map +1 -0
- package/dist/jobs/prune.js +58 -0
- package/dist/jobs/prune.js.map +1 -0
- package/dist/jobs/registry.d.ts +19 -4
- package/dist/jobs/registry.d.ts.map +1 -1
- package/dist/jobs/registry.js +16 -1
- package/dist/jobs/registry.js.map +1 -1
- package/dist/jobs/scheduler.d.ts +12 -1
- package/dist/jobs/scheduler.d.ts.map +1 -1
- package/dist/jobs/scheduler.js +24 -9
- package/dist/jobs/scheduler.js.map +1 -1
- package/dist/jobs/store.d.ts +26 -1
- package/dist/jobs/store.d.ts.map +1 -1
- package/dist/jobs/store.js +35 -4
- package/dist/jobs/store.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
|
@@ -67,7 +67,18 @@ caller's own organisation — and both printed a job's result as its variable
|
|
|
67
67
|
names, so production read "0 chargesRaised · 0 totalOutstanding", with an amount
|
|
68
68
|
of money shown as a number of things. The module is their union, with that
|
|
69
69
|
made impossible rather than fixed: a count reaches the screen only through a
|
|
70
|
-
declaration that says its words and its kind.
|
|
70
|
+
declaration that says its words and its kind. 0.12.0 lets a job offer Run now
|
|
71
|
+
to owners and operators both — Quickie's operators could not re-run a failed
|
|
72
|
+
overnight sweep of a per-organisation job — and prunes the ledger without ever
|
|
73
|
+
deleting a row health is read from.
|
|
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.
|
|
71
82
|
|
|
72
83
|
## Modules
|
|
73
84
|
|
|
@@ -131,6 +142,178 @@ possession of a device and is phishing-resistant; a social login delegates to a
|
|
|
131
142
|
provider enforcing its own second factor. Neither is weaker than password plus
|
|
132
143
|
TOTP. A link in an inbox is.
|
|
133
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
|
+
|
|
134
317
|
### `@connextar/house/account`
|
|
135
318
|
|
|
136
319
|
The contract for "your data" and "delete my account" — types, the export notice,
|
|
@@ -445,6 +628,245 @@ independent sections are a form with three headings. A flow whose steps are
|
|
|
445
628
|
separate saves, each landing before the next opens, is not one form and must not
|
|
446
629
|
pretend to be: this wizard keeps one register and submits once.
|
|
447
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
|
+
|
|
448
870
|
### `@connextar/house/setup-guide`
|
|
449
871
|
|
|
450
872
|
The checklist a new account works through, and what finishing a step earns.
|
|
@@ -763,7 +1185,7 @@ got wrong:
|
|
|
763
1185
|
| A run in progress | Shown as **Errored** (`ok` is false until it finishes); one cut off stays so, with no reason | The same | Running inside its timeout; past it, Failed — "never finished, probably cut off" |
|
|
764
1186
|
| Staleness | Last OK older than `maxAgeMs` (strictly) | The same — and a tenant's Run now reset it for everyone | Last successful _whole_ run older than the threshold; healthy exactly at it |
|
|
765
1187
|
| Pause / resume | Per job, who and when, audited; harvest's switch shared with its own screen | None | Per job, who and when; platform authority; audit in the adapter's transaction; tick says it skipped |
|
|
766
|
-
| Run now | `runJobNow` exists but has no caller, and every job is `global`, so it would refuse them all | Owner-only, scoped to the caller's organisation; no cool-down, and `force` skips reminder throttles | `tenant
|
|
1188
|
+
| Run now | `runJobNow` exists but has no caller, and every job is `global`, so it would refuse them all | Owner-only, scoped to the caller's organisation; no cool-down, and `force` skips reminder throttles | `tenant`, `platform` or both; refused outside it, while paused, while running, in its cool-down |
|
|
767
1189
|
| Jobs on their own clock | Harvest (10 min) and imports (1 min): switchable, but record no runs, so no health | — | `schedule: "own"` + `runOwn`: the switch and the ledger, with or without a window |
|
|
768
1190
|
| Errors | Message stored; shown to administrators | Message stored; shown to **every** organisation's owner, whoever's data it came from | Message stored; a tenant viewer sees whether a sweep failed, not its words or its numbers |
|
|
769
1191
|
| `CRON_SECRET` | Bearer or `?secret=`; `timingSafeEqual` after a length check | Bearer (prefix optional) or `?secret=`; SHA-256 digests; dev secret refused in production | Bearer required; digests; dev secret refused; `?secret=` only when a route asks |
|
|
@@ -805,9 +1227,11 @@ export const jobs = defineJobs([
|
|
|
805
1227
|
cadence: "Daily, from 09:00",
|
|
806
1228
|
window: daily({ from: 9 }),
|
|
807
1229
|
staleAfterMs: 26 * HOUR,
|
|
808
|
-
|
|
1230
|
+
// An owner sweeps their own organisation, and nobody else's; an operator re-runs the sweep over all of them.
|
|
1231
|
+
// One scope — `{ scope: "tenant" }` — is declared as before.
|
|
1232
|
+
runNow: { scope: ["tenant", "platform"] },
|
|
809
1233
|
counts: { remindersSent: { label: { one: "reminder sent", other: "reminders sent" } } },
|
|
810
|
-
// `tenantId` is null for the schedule's sweep and always set for an owner's
|
|
1234
|
+
// `tenantId` is null for the schedule's sweep and a platform Run now, and always set for an owner's.
|
|
811
1235
|
run: async ({ now, tenantId }) => {
|
|
812
1236
|
const { remindersSent } = await sendPaymentReminders(now, { organizationId: tenantId ?? undefined });
|
|
813
1237
|
return { remindersSent };
|
|
@@ -854,6 +1278,7 @@ const outcome = await scheduler.runNow(body.job, {
|
|
|
854
1278
|
actor: { id: user.id, name: user.name },
|
|
855
1279
|
platform: user.platformAdmin,
|
|
856
1280
|
tenantId: user.organizationId,
|
|
1281
|
+
scope: body.scope, // the button pressed, on a job that offers both; optional
|
|
857
1282
|
});
|
|
858
1283
|
if (outcome.status === "refused") return apiError(outcome.message, refusalStatus(outcome.reason));
|
|
859
1284
|
if (outcome.status === "failed") return apiError(outcome.message, 500);
|
|
@@ -884,7 +1309,8 @@ import { JobHealthList } from "@connextar/house/jobs/react";
|
|
|
884
1309
|
onSwitch={async (job, on) => {
|
|
885
1310
|
await commitApi("/api/admin/cron", { job, action: on ? "resume" : "pause" });
|
|
886
1311
|
}}
|
|
887
|
-
onRunNow={(job) => commitApi("/api/cron/run", { job })}
|
|
1312
|
+
onRunNow={(job, scope) => commitApi("/api/cron/run", { job, scope })}
|
|
1313
|
+
runNowWords={{ tenant: "your organisation", platform: "every organisation" }}
|
|
888
1314
|
onSettled={router.refresh}
|
|
889
1315
|
extra={(job) => (job.id === "harvest" ? <Link href={routes.harvest}>What it searches next</Link> : null)}
|
|
890
1316
|
/>;
|
|
@@ -897,10 +1323,14 @@ refusal puts both back and reports the reason through the
|
|
|
897
1323
|
`ActionFeedbackProvider`. No other row is disabled while one saves. Run now is
|
|
898
1324
|
`useKeyedAction`: that row's button is busy (`data-busy`), the route's message is
|
|
899
1325
|
said, and the screen refreshes whether the run worked or not, since a failure is
|
|
900
|
-
on the ledger too.
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1326
|
+
on the ledger too. A row draws one button per scope the view offers
|
|
1327
|
+
(`runNowScopes`) and hands `onRunNow` the one pressed; nearly every viewer holds
|
|
1328
|
+
one — an operator the platform run, an owner their own — and sees "Run now". A
|
|
1329
|
+
viewer who holds both sees two, "Run for every account" and "Run for your
|
|
1330
|
+
account", in the app's words through `runNowWords`. Dates are drawn in
|
|
1331
|
+
`timeZone` (default `Europe/London`), never the runtime's own — the server
|
|
1332
|
+
draws the list in UTC and the browser again in BST, and two times in one
|
|
1333
|
+
element is a hydration error. `classNames` replaces any part's look.
|
|
904
1334
|
|
|
905
1335
|
The rules it keeps:
|
|
906
1336
|
|
|
@@ -933,9 +1363,21 @@ The rules it keeps:
|
|
|
933
1363
|
needs one; `platform` sweeps everything and needs platform authority. It is
|
|
934
1364
|
refused while the job is off, while a run it would overlap is in flight (the
|
|
935
1365
|
schedule's sweep covers the caller's tenant too), and within its cool-down
|
|
936
|
-
(default ten minutes, per job and scope
|
|
937
|
-
second press would otherwise send the whole
|
|
938
|
-
job's hour gates; a per-recipient throttle
|
|
1366
|
+
(default ten minutes, per job and scope, and per tenant for tenant runs) — a
|
|
1367
|
+
manual run sets `force`, so a second press would otherwise send the whole
|
|
1368
|
+
batch again. `force` is for a job's hour gates; a per-recipient throttle
|
|
1369
|
+
should stay on.
|
|
1370
|
+
- **A job may offer both** — `runNow: { scope: ["tenant", "platform"] }` —
|
|
1371
|
+
and a single scope is declared as it always was. The caller's `scope` says
|
|
1372
|
+
which was pressed; left out, it is the caller's tenant when they have one and
|
|
1373
|
+
every tenant when they do not, and it never grants anything `platform` and
|
|
1374
|
+
`tenantId` do not. The scopes are fenced from each other: separate
|
|
1375
|
+
cool-downs, and neither starts while the other is in flight — a tenant run
|
|
1376
|
+
waits for a platform run, and a platform run waits for any tenant's
|
|
1377
|
+
(`JobStore.latestUnfinishedRun`, which `createScheduler` requires the moment
|
|
1378
|
+
one job offers both). A platform Run now is a run over every tenant, so it
|
|
1379
|
+
resets the job's health for everyone, as the schedule's does; a tenant's never
|
|
1380
|
+
does. `outcome.scope` says which ran, for the app's audit entry.
|
|
939
1381
|
- **A tenant sees their own.** To a tenant viewer, a sweep over every tenant
|
|
940
1382
|
shows whether it ran and when, but not its numbers (which are everyone's) or
|
|
941
1383
|
its error (which may name another organisation's data). Their own Run now
|
|
@@ -953,7 +1395,11 @@ The rules it keeps:
|
|
|
953
1395
|
`allowQuery: true`, because an address lands in request logs.
|
|
954
1396
|
|
|
955
1397
|
**Storage.** The package owns no schema. The app implements `JobStore` — claim,
|
|
956
|
-
finish, latest run, read switches, write a switch
|
|
1398
|
+
finish, latest run, read switches, write a switch, and, since 0.12.0, the latest
|
|
1399
|
+
unfinished run over any tenant (for a job with both Run now scopes) and pruning
|
|
1400
|
+
— over its own tables. The two new methods are optional in the type, so an
|
|
1401
|
+
0.11.0 adapter still compiles; `createScheduler` and `pruneJobRuns` say by name
|
|
1402
|
+
what is missing when a feature needs one. This is
|
|
957
1403
|
the reference model; both apps' existing `cron_run` tables are it minus two
|
|
958
1404
|
nullable columns, so every row they hold maps across as it is:
|
|
959
1405
|
|
|
@@ -1030,10 +1476,38 @@ export const jobStore: JobStore = {
|
|
|
1030
1476
|
...(ok ? { ok: true } : {}),
|
|
1031
1477
|
...(manualOnly ? { windowKey: { startsWith: "manual:" } } : {}),
|
|
1032
1478
|
},
|
|
1033
|
-
|
|
1479
|
+
// `id` breaks a tie the way `pruneRuns` does, so the row read as latest is the row kept as latest.
|
|
1480
|
+
orderBy: [{ startedAt: "desc" }, { id: "desc" }],
|
|
1034
1481
|
});
|
|
1035
1482
|
return row && { ...row, by: row.triggeredBy };
|
|
1036
1483
|
},
|
|
1484
|
+
// Since 0.12.0 — needed once a job's Run now offers both scopes.
|
|
1485
|
+
async latestUnfinishedRun(job) {
|
|
1486
|
+
const row = await prisma.cronRun.findFirst({
|
|
1487
|
+
where: { job, finishedAt: null }, // any tenant: a platform run must wait for every tenant's
|
|
1488
|
+
orderBy: [{ startedAt: "desc" }, { id: "desc" }],
|
|
1489
|
+
});
|
|
1490
|
+
return row && { ...row, by: row.triggeredBy };
|
|
1491
|
+
},
|
|
1492
|
+
// Since 0.12.0 — `pruneJobRuns` calls it from the housekeeping job. One statement, at most `limit` rows, oldest
|
|
1493
|
+
// first. `PARTITION BY` groups every NULL tenant together, which is exactly "the runs over every tenant".
|
|
1494
|
+
async pruneRuns({ startedBefore, limit }) {
|
|
1495
|
+
return prisma.$executeRaw`
|
|
1496
|
+
DELETE FROM "cron_run" WHERE "id" IN (
|
|
1497
|
+
SELECT "id" FROM (
|
|
1498
|
+
SELECT "id", "ok", "started_at", "finished_at",
|
|
1499
|
+
ROW_NUMBER() OVER (PARTITION BY "job", "tenant_id" ORDER BY "started_at" DESC, "id" DESC) AS "nth",
|
|
1500
|
+
ROW_NUMBER() OVER (PARTITION BY "job", "tenant_id", "ok" ORDER BY "started_at" DESC, "id" DESC) AS "nth_of_kind"
|
|
1501
|
+
FROM "cron_run"
|
|
1502
|
+
) AS "ranked"
|
|
1503
|
+
WHERE "started_at" < ${startedBefore}
|
|
1504
|
+
AND "finished_at" IS NOT NULL -- never a run still under way
|
|
1505
|
+
AND "nth" > 1 -- never the latest run of a job for its tenant
|
|
1506
|
+
AND NOT ("ok" AND "nth_of_kind" = 1) -- nor its latest success
|
|
1507
|
+
ORDER BY "started_at", "id"
|
|
1508
|
+
LIMIT ${limit}
|
|
1509
|
+
)`;
|
|
1510
|
+
},
|
|
1037
1511
|
async readSwitches() {
|
|
1038
1512
|
const rows = await prisma.cronSwitch.findMany();
|
|
1039
1513
|
return new Map(rows.map((r) => [r.job, { paused: r.paused, at: r.switchedAt, by: r.switchedByName }]));
|
|
@@ -1060,11 +1534,49 @@ export const jobStore: JobStore = {
|
|
|
1060
1534
|
};
|
|
1061
1535
|
```
|
|
1062
1536
|
|
|
1063
|
-
`createMemoryJobStore()` is the same contract in memory, unique index
|
|
1064
|
-
for the app's own tests of its binding.
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1537
|
+
`createMemoryJobStore()` is the same contract in memory, unique index and
|
|
1538
|
+
pruning rules included, for the app's own tests of its binding.
|
|
1539
|
+
|
|
1540
|
+
**Pruning the ledger.** Every tick and every own-clock call writes a row —
|
|
1541
|
+
AltEd about 150 a day — and a Run now row keeps the name of whoever pressed it.
|
|
1542
|
+
`pruneJobRuns(store, { now, retentionDays, batchLimit })` deletes the old ones
|
|
1543
|
+
through `JobStore.pruneRuns`, from the app's **own housekeeping job**, and
|
|
1544
|
+
reports what it removed as a declared count:
|
|
1545
|
+
|
|
1546
|
+
```ts
|
|
1547
|
+
import { JOB_RUN_PRUNE_COUNTS, pruneJobRuns } from "@connextar/house/jobs";
|
|
1548
|
+
|
|
1549
|
+
defineJob({
|
|
1550
|
+
id: "rate-limit-prune",
|
|
1551
|
+
label: "Housekeeping",
|
|
1552
|
+
// …
|
|
1553
|
+
counts: { windowsPruned: { label: "…" }, ...JOB_RUN_PRUNE_COUNTS }, // "12 old job runs removed"
|
|
1554
|
+
run: async ({ now }) => ({ windowsPruned: await pruneRateLimits(), ...(await pruneJobRuns(jobStore, { now })) }),
|
|
1555
|
+
});
|
|
1556
|
+
```
|
|
1557
|
+
|
|
1558
|
+
- **Retention** is 90 days by default, in days (at least 1), so a number cannot
|
|
1559
|
+
be read as milliseconds and empty the ledger. A run exactly at the boundary is
|
|
1560
|
+
kept.
|
|
1561
|
+
- **Always kept, however old:** per job, the latest run and the latest
|
|
1562
|
+
successful run — once for the runs over every tenant (the schedule's and
|
|
1563
|
+
platform Run nows), and once for each tenant's own. Health, the Last run and
|
|
1564
|
+
Last success lines and an owner's own view are read from exactly these, so a
|
|
1565
|
+
monthly job that last ran 120 days ago still says so rather than "Never run",
|
|
1566
|
+
and a job whose last run failed still shows when it last worked.
|
|
1567
|
+
- **Never a run still under way** — an unfinished row is never deleted. (A run
|
|
1568
|
+
cut off by the platform stays as the evidence; there are few.)
|
|
1569
|
+
- **Bounded:** at most `batchLimit` rows a call (default 1,000), oldest first, in
|
|
1570
|
+
one statement, so a housekeeping run never holds the table. What is left goes
|
|
1571
|
+
the next day; calling it again straight away deletes only that. A long
|
|
1572
|
+
history is caught up a batch a day — or raise `batchLimit` for the first run.
|
|
1573
|
+
|
|
1574
|
+
Choosing the retention: keep it longer than the longest window (a monthly job's
|
|
1575
|
+
33 days), so the ledger holds a whole period of every job to investigate; and
|
|
1576
|
+
where the privacy policy names a period for operator activity, use that — the
|
|
1577
|
+
`triggered_by` names are why it matters. The rows always kept outlive it, name
|
|
1578
|
+
included; a quiet job's single last run is the price of its health staying
|
|
1579
|
+
true.
|
|
1068
1580
|
|
|
1069
1581
|
**What should not be a scheduled job.**
|
|
1070
1582
|
|
|
@@ -1177,6 +1689,56 @@ older row is history, not state.
|
|
|
1177
1689
|
8. **The guide** (`docs/guides/automation.md`): Errored is now Failed, a run in
|
|
1178
1690
|
progress says Running, and the counts read as words.
|
|
1179
1691
|
|
|
1692
|
+
**Upgrading to 0.12.0 — both Run now scopes, and pruning.** A job declared
|
|
1693
|
+
with one scope behaves exactly as in 0.11.0, and an 0.11.0 adapter compiles
|
|
1694
|
+
unchanged. Each app then adds pruning to its housekeeping, and Quickie makes
|
|
1695
|
+
its per-organisation jobs that are safe to re-sweep dual.
|
|
1696
|
+
|
|
1697
|
+
- **AltEd** — no dual jobs: it is single-tenant, and every job that offers Run
|
|
1698
|
+
now already sweeps everything (`platform`). In `lib/jobs/store.ts` add
|
|
1699
|
+
`pruneRuns` from the adapter above (`latestUnfinishedRun` is not needed
|
|
1700
|
+
until a job offers both), and add `{ id: "desc" }` to `latestRun`'s
|
|
1701
|
+
`orderBy`. In `lib/jobs/index.ts`, Housekeeping (`rate-limit-prune`) takes
|
|
1702
|
+
`...JOB_RUN_PRUNE_COUNTS` in its counts and
|
|
1703
|
+
`...(await pruneJobRuns(jobStore, { now }))` in its result. The harvest's
|
|
1704
|
+
144 rows a day are most of the ledger; whatever history has built up beyond
|
|
1705
|
+
90 days is caught up at a thousand rows a day. Test the SQL in
|
|
1706
|
+
`store.db.test.ts` with runs dated before 2000 under the test's own job ids
|
|
1707
|
+
and `startedBefore` in 2000, so no real row is old enough to go.
|
|
1708
|
+
- **Quickie** — make these dual, `runNow: { scope: ["tenant", "platform"] }`,
|
|
1709
|
+
because a second sweep sends nobody anything twice: **payment reminders**
|
|
1710
|
+
(the per-invoice throttle, which Run now does not lift), **booking
|
|
1711
|
+
reminders** (a booking's `REMINDER` message), **review requests** (`PENDING`
|
|
1712
|
+
becomes `SENT`), **bank consent check** (`expiryNoticeAt`) and **budget
|
|
1713
|
+
alerts** (`BudgetAlert`, one per budget, category and month). Keep these
|
|
1714
|
+
tenant-only: the **follow-up** and **weekly finance digests** —
|
|
1715
|
+
`ignoreHour: force` would email every opted-in organisation at once, and
|
|
1716
|
+
nothing stops a second digest the same day; the **monthly report** — nothing
|
|
1717
|
+
records that an organisation's report went, so re-running a sweep that failed
|
|
1718
|
+
half-way emails the first half again (make it dual once it does); and the
|
|
1719
|
+
**bank feed sync** — `force` lifts each connection's six-hour hold, so a
|
|
1720
|
+
platform run would call every bank for every organisation, and the next
|
|
1721
|
+
window recovers anyway (if it is ever made dual, pass
|
|
1722
|
+
`force && tenantId !== null`). In `lib/repos/cron.repo.ts` add
|
|
1723
|
+
`latestUnfinishedRun` — `createScheduler` refuses a dual job without it — and
|
|
1724
|
+
`pruneRuns`. No route changes: `/api/admin/cron` already calls `runNow` with
|
|
1725
|
+
`platform: true, tenantId: null`, which on a dual job is the platform run,
|
|
1726
|
+
and `/api/cron/run` with `platform: false` and the organisation, which is the
|
|
1727
|
+
tenant run; the admin panel now shows Run now on the dual jobs, and owners see
|
|
1728
|
+
what they saw. Reword the admin route's and panel's "only for the jobs that
|
|
1729
|
+
sweep shared tables", and the automation guide, to say an operator can re-run
|
|
1730
|
+
a failed sweep. Housekeeping (`rate-limit-prune`, `lib/services/cron.service.ts`)
|
|
1731
|
+
prunes through `cronJobStore`. The old `unrecorded` Run now rows age out like
|
|
1732
|
+
any tenant's, the latest per job kept; no screen reads them.
|
|
1733
|
+
- **senco** — no dual jobs: retention and housekeeping are sweeps over every
|
|
1734
|
+
school, and a school's Run now is refused on purpose (`lib/jobs/index.ts`).
|
|
1735
|
+
In `lib/jobs/store.ts` add `pruneRuns` and `{ id: "desc" }` in `latestRun`'s
|
|
1736
|
+
`orderBy`; Housekeeping (`rate-limit-prune`, `lib/jobs/index.ts`) takes the
|
|
1737
|
+
counts and `pruneJobRuns(jobStore, { now })`. `CronSwitchChange` is the
|
|
1738
|
+
switches' audit record, not ledger, and is not pruned. Retention enforcement
|
|
1739
|
+
is a promise to schools about how long evidence is kept; say the ledger's 90
|
|
1740
|
+
days in the same place.
|
|
1741
|
+
|
|
1180
1742
|
### Styles — `@connextar/house/house.css`
|
|
1181
1743
|
|
|
1182
1744
|
What the components need that utility classes cannot say: rich text, the editor's
|