@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.
Files changed (82) hide show
  1. package/README.md +581 -19
  2. package/dist/auth/index.d.ts +3 -1
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/index.js +3 -1
  5. package/dist/auth/index.js.map +1 -1
  6. package/dist/auth/second-factor-index.d.ts +7 -0
  7. package/dist/auth/second-factor-index.d.ts.map +1 -0
  8. package/dist/auth/second-factor-index.js +7 -0
  9. package/dist/auth/second-factor-index.js.map +1 -0
  10. package/dist/auth/second-factor-next.d.ts +39 -0
  11. package/dist/auth/second-factor-next.d.ts.map +1 -0
  12. package/dist/auth/second-factor-next.js +131 -0
  13. package/dist/auth/second-factor-next.js.map +1 -0
  14. package/dist/auth/second-factor-routes.d.ts +29 -0
  15. package/dist/auth/second-factor-routes.d.ts.map +1 -0
  16. package/dist/auth/second-factor-routes.js +35 -0
  17. package/dist/auth/second-factor-routes.js.map +1 -0
  18. package/dist/auth/second-factor.d.ts +117 -0
  19. package/dist/auth/second-factor.d.ts.map +1 -0
  20. package/dist/auth/second-factor.js +127 -0
  21. package/dist/auth/second-factor.js.map +1 -0
  22. package/dist/auth/two-factor-policy.d.ts +30 -1
  23. package/dist/auth/two-factor-policy.d.ts.map +1 -1
  24. package/dist/auth/two-factor-policy.js +39 -1
  25. package/dist/auth/two-factor-policy.js.map +1 -1
  26. package/dist/jobs/health.d.ts +17 -4
  27. package/dist/jobs/health.d.ts.map +1 -1
  28. package/dist/jobs/health.js +10 -7
  29. package/dist/jobs/health.js.map +1 -1
  30. package/dist/jobs/index.d.ts +4 -3
  31. package/dist/jobs/index.d.ts.map +1 -1
  32. package/dist/jobs/index.js +3 -2
  33. package/dist/jobs/index.js.map +1 -1
  34. package/dist/jobs/job-health-list.d.ts +15 -3
  35. package/dist/jobs/job-health-list.d.ts.map +1 -1
  36. package/dist/jobs/job-health-list.js +11 -7
  37. package/dist/jobs/job-health-list.js.map +1 -1
  38. package/dist/jobs/prune.d.ts +45 -0
  39. package/dist/jobs/prune.d.ts.map +1 -0
  40. package/dist/jobs/prune.js +58 -0
  41. package/dist/jobs/prune.js.map +1 -0
  42. package/dist/jobs/registry.d.ts +19 -4
  43. package/dist/jobs/registry.d.ts.map +1 -1
  44. package/dist/jobs/registry.js +16 -1
  45. package/dist/jobs/registry.js.map +1 -1
  46. package/dist/jobs/scheduler.d.ts +12 -1
  47. package/dist/jobs/scheduler.d.ts.map +1 -1
  48. package/dist/jobs/scheduler.js +24 -9
  49. package/dist/jobs/scheduler.js.map +1 -1
  50. package/dist/jobs/store.d.ts +26 -1
  51. package/dist/jobs/store.d.ts.map +1 -1
  52. package/dist/jobs/store.js +35 -4
  53. package/dist/jobs/store.js.map +1 -1
  54. package/dist/password/common.d.ts +2 -0
  55. package/dist/password/common.d.ts.map +1 -0
  56. package/dist/password/common.js +58 -0
  57. package/dist/password/common.js.map +1 -0
  58. package/dist/password/estimate-index.d.ts +10 -0
  59. package/dist/password/estimate-index.d.ts.map +1 -0
  60. package/dist/password/estimate-index.js +10 -0
  61. package/dist/password/estimate-index.js.map +1 -0
  62. package/dist/password/estimate.d.ts +65 -0
  63. package/dist/password/estimate.d.ts.map +1 -0
  64. package/dist/password/estimate.js +612 -0
  65. package/dist/password/estimate.js.map +1 -0
  66. package/dist/password/index.d.ts +22 -0
  67. package/dist/password/index.d.ts.map +1 -0
  68. package/dist/password/index.js +22 -0
  69. package/dist/password/index.js.map +1 -0
  70. package/dist/password/password-input.d.ts +55 -0
  71. package/dist/password/password-input.d.ts.map +1 -0
  72. package/dist/password/password-input.js +54 -0
  73. package/dist/password/password-input.js.map +1 -0
  74. package/dist/password/password-strength.d.ts +59 -0
  75. package/dist/password/password-strength.d.ts.map +1 -0
  76. package/dist/password/password-strength.js +74 -0
  77. package/dist/password/password-strength.js.map +1 -0
  78. package/dist/password/reveal.d.ts +56 -0
  79. package/dist/password/reveal.d.ts.map +1 -0
  80. package/dist/password/reveal.js +109 -0
  81. package/dist/password/reveal.js.map +1 -0
  82. 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` or `platform` scope, refused outside it, while paused, while running, and within a cool-down |
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
- runNow: { scope: "tenant" }, // an owner sweeps their own organisation, and nobody else's
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 Run now.
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. Dates are drawn in `timeZone` (default `Europe/London`),
901
- never the runtime's own — the server draws the list in UTC and the browser
902
- again in BST, and two times in one element is a hydration error. `classNames`
903
- replaces any part's look.
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) — a manual run sets `force`, so a
937
- second press would otherwise send the whole batch again. `force` is for a
938
- job's hour gates; a per-recipient throttle should stay on.
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 — over its own tables. This is
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
- orderBy: { startedAt: "desc" },
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 included,
1064
- for the app's own tests of its binding. Prune the ledger in the app's
1065
- housekeeping job like any other growing table, keeping more than the longest
1066
- threshold — health reads the latest run and the latest success per job, so an
1067
- older row is history, not state.
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