История коммитов

.
fix(auth): make password recovery links unguessable and single-use
The recovery code was md5(random_int(1000, 9999)) — nine thousand possible
values, stored in the clear in users.rest_code. Anyone who knew an account id
could walk the whole space in seconds and take the account over, and a database
dump handed out working links directly.

Recovery links are now 32 random bytes, kept only as a SHA-256 digest in the new
password_reset_tokens table. The identifier still in the URL is a convenience:
the token decides whose account it is, and a mismatch is rejected.

Checking the link and spending it are separate steps, because the flow has two:
opening the form only asks whether the link is still good, submitting it is what
consumes the token. So an opened-but-abandoned link keeps working while a
submitted one is dead, and a resubmitted form is a no-op instead of a second
password reset.

Asking for a new link drops the outstanding one, so only the newest letter works
instead of every letter staying valid for its hour. The one-per-day limit now
counts letters sent rather than links followed: spending a token no longer opens
the door to asking for another one straight away.

The table definition lives in AuthSchema, which the installer, the new
auth:upgrade-schema command and the tests all call — a copy in any of them would
drift and the drift would only show on somebody else's site. users.rest_code and
users.rest_time are left in place for now; they are dropped with the rest of the
legacy columns.

Recovery links already in flight stop working when this is deployed.
.
feat(auth): add the authentication and authorization skeleton
Introduces Johncms\Auth: the layer the new sign-in and permission system is
built on. Nothing calls it yet, so behaviour is unchanged.

Authentication is a chain: every AuthenticatorInterface tagged
johncms.auth.authenticator is asked in turn and the first one to recognise the
request wins; a request nobody recognises belongs to a guest. CurrentUser
resolves that once per request, lazily, and is reset between requests.

Authorization is a chain of voters: any Deny refuses, a single Allow is enough,
and a check nobody voted on is refused. The asymmetry is what later lets a ban,
an impersonation limit or the abilities of an API token take away what a role
grants. Refusing by default means a permission whose voter was forgotten closes
the door instead of opening it.

Identity carries ids, role slugs and permission keys and nothing that needs a
query, so authorization is testable without a container or a schema —
tests/Support/IdentityFactory is what that buys.

The can() helper and its Twig function are not here: with no voters registered
they would answer false to everything, and a silent denial is the worst kind of
placeholder. They arrive with the role voter.
.
chore(mail): drop the unused smtp name option
A leftover of laminas-mail, where it was the HELO hostname. MailFactory builds the DSN from host, port, username, password, encryption and auth_mode and never reads this key, so it only misled whoever configured SMTP.
.
docs: update the documentation submodule
.
docs: update the documentation submodule
.
docs: describe the CSRF middleware and the validator replacement in the changelog
.
refactor: remove laminas-validator
The legacy validator, its rules and its message map are gone, and with the package go laminas-servicemanager, laminas-stdlib and laminas-escaper. No laminas package is left in the dependency tree.

Not one msgid was lost: the strings the rules need are registered by literal d__() calls in their constructors and factories, so twenty catalogs survive the migration untouched. What the .pot loses is the detailed laminas hostname diagnostics and the CSRF message, none of which the code produces any more.

.agents/validation.md documents the API, the null policy, how to add a rule and where its messages live.
.
refactor(install): move the installer to the new validator
The last caller of the legacy validator and the last reference to Laminas\Validator\Hostname. The email stays mandatory: it was only rejected as empty because the previous EmailAddress failed on an empty value, and the new rule requires a value outright.
.
chore: drop the orphaned legacy validator imports
Left behind when the CSRF-only calls were removed.
.
refactor(profile,registration): move the last forms to the new validator
The optional profile fields now say so: everything a visitor may leave blank carries allowEmpty, including the email, which the previous ruleset left optional only as a side effect of ModelNotExists finding nothing for an empty value.

The MX check of the address survives as the MxRecord rule, and the consent message again belongs to the rule that needs it rather than to every Identical of the form.