Repository navigation
v0.1.1 — Events named by their class, and a sign-in that outlives its session
Events are the classes they carry, three more of them exist, and a sign-in can outlive its session.
Events are named by their class
// before
event()->dispatch('nafinity.changed', new Change(…));
event()->listen('nafinity.changed', fn(Change $c) => …);
// now
event()->dispatch(new Change(…));
event()->listen(Change::class, fn(Change $c) => …);No class was written for this. Change, GrantsChanged and ExportLine already existed and were being announced under a string that repeated their name less precisely. ExportService::LINE is gone with them: a constant holding a second way to name one thing is a second way to get it wrong.
A misspelled event is now an error where it is written, rather than a listener that never runs and never says so — and finding every listener of an event is a question an IDE can answer.
Upgrading. A listener on 'nafinity.changed', 'rbac.granted' or 'export.line' stops hearing it. The two are separate keys and nothing warns you. Change them to Change::class, GrantsChanged::class and ExportLine::class in the same deployment.
Two spellings of rbac.granted deliberately did not move: the stored change type in the audit log and the activity vocabulary. Those are strings in rows that already exist.
Three events where a plugin could not stand
SignIn fires from the one method that decides either outcome, for the password provider and the directory one alike. Signing in writes no row, so the change event structurally could not carry it, and a plugin wanting to notice a sign-in from somewhere new, lock an account after a run of failures, or keep its own record had nowhere to stand.
Both outcomes travel on it, because the interesting one is usually the failure, and it carries the address as typed even when no account answers to it. It is announced after its transaction rather than inside it: by then the session is published and the person is in, and a listener rolling that back would leave them signed in with no record of it.
ExportStarted and ExportFinished frame the records. A package writing into somebody else's system opens something before the first and closes it after the last, and the count on the closing event is a number the export already knows.
The log knows who came in, and who tried
It knew when somebody changed their password and when they moved their address. It did not know that anybody had signed in — which in a system whose promise is accountability is the entry people come looking for first, and the one an intrusion is noticed by.
A refused attempt is recorded with no actor, and that is not a gap to be filled: naming somebody would mean asserting who they were on the strength of a password that did not match. The address as typed is kept instead, whether or not any account answers to it.
Both kinds sit behind audit.personal, beside the password and address entries they belong with. Change::$actorId is nullable to allow it; the column it is written to always was.
Staying signed in
A session cookie ends when the browser does, which for a board people live in all week is the wrong promise. A remember-me cookie is kept for ninety days — configurable through nafinity:remember_days — and is worth nothing the moment anything says it should be.
A selector to find the row by, a validator to prove it with, and only the validator's hash at rest, so a leak of that table is a list of names rather than a key ring. Every use rotates the validator and keeps the selector, and that asymmetry is the whole of the theft detection: a stale cookie still arrives at its row and fails there. When it does, every token of the account goes, because which of the two holders is the owner cannot be told from here.
And the promise this would otherwise have broken quietly: the account's security version is copied in at issue and compared at use, so changing a password still signs you out everywhere — including out of a cookie issued before it.
The box on the sign-in form is unchecked by default. Staying signed in is a choice made on the device somebody is sitting at.
Smaller things
The seven helpers in src/functions.php lose their if (!function_exists()) guards. Plugin files are loaded with require_once, so nothing could reach them twice.
The extension reference gains the Events chapter it never had, including what was true and written down nowhere: a listener on Change runs inside the transaction, so throwing refuses the write. That is how a rule no permission can express gets to stop something.
Requires
naf/framework ^0.2.6 for the object form of dispatch(), and naf/rbac ^0.1.1, which announces its own event the same way.
Verification
110 unit, 209 database on MariaDB and PostgreSQL, 3 worker, 158 HTTP — including the round trip a browser makes: sign in, lose the session cookie, come back with the other one — 49 JavaScript, 130 across four extension-host configurations.