Skip to content
View in the app

A better way to browse. Learn more.

ernestdefoe.online

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
ernestdefoe.online

Extensions, themes & support for Flarum and Invision Community

Vibe coding for the community web. Report a bug, request a feature, or dig into the source — this is where the tools you use get built, in the open.

We do custom Bespoke Invision Community apps. If you have an idea for something you want then use the contact form to get in touch with us.
Knowledge base

Things that cost me a day, so they cost you none

Working notes from building Invision Community and Flarum applications. Mostly the failures that give no error at all — the ones where everything installs cleanly and quietly does the wrong thing.

92 articles

Invision Community 5

86 articles

Extensions and contracts

39

What each extension point is for, what it must declare, and what happens when it is wrong — which is usually nothing visible.

Languages and text

5

The string table, translation, and the places where text does not appear where you expected it to.

Theming, templates and forms

9

Theme hooks, CSS that survives both colour schemes, and building forms that do not throw on render.

Background work and scheduled tasks

5

The queue system, work that has to happen after the response, and jobs that finish without doing anything.

Data, settings and storage

11

The database layer, settings, tags, file storage, and backing up a live site.

AI features and expectations

5

What these features do, what they cost, and what buyers reasonably but wrongly assume they do.

Application structure and releases

11

The JSON files an application is made of, versioning and upgrade steps, and testing from the command line.

Realtime, chat and calls

1

WebSocket gateways, relays and the server-side pieces live features depend on — where "it works when I test it" and "it works for your members" are different claims.

Nothing matches that.

The core/MFAArea extension in Invision Community 5

The core/MFAArea extension adds one tick box to a single ACP form. That form is the ACP screen registered in applications/core/data/acpmenu.json as settings → mfa and labelled "Two Factor Authentication" — app=core&module=settings&controller=mfa — and the field is "Require two factor authentication when". Each MFAArea extension in the suite contributes one option to that list — "Logging into AdminCP", "Changing email address", "Managing stored cards", and so on. When an administrator ticks your option, the key is written into the security_questions_areas setting, and from that moment a member who reaches the part of your application you have protected is shown a two-factor challenge before they are allowed to continue.

The extension itself does not perform the challenge, does not know which page it protects, and is never asked whether the member should be challenged. It is a registration list for the ACP checkbox, nothing more. The challenge is triggered by a separate call to IPS\MFA\MFAHandler::accessToArea() that you have to write yourself, in your own controller. An MFAArea extension with no matching accessToArea() call protects nothing, and an accessToArea() call with no matching extension can never be switched on. Both halves fail silently on their own, which is where nearly everything in this article comes from.

The contract

IPS\Extensions\MFAAreaAbstract, at system/Extensions/MFAAreaAbstract.php, is one of the two smallest extension abstracts in the suite (tied with GroupLimitsAbstract.php). It is twenty-nine lines, most of them a licence header and the SUITE_UNIQUE_KEY guard, and declares exactly one member:

namespace IPS\Extensions;

abstract class MFAAreaAbstract
{
    /**
     * Is this area available and should show in the ACP configuration?
     *
     * @return	bool
     */
    abstract public function isEnabled(): bool;
}

That is the whole class body — the rest of the file is the copyright docblock and the SUITE_UNIQUE_KEY guard. No properties, no constructor, no optional methods. isEnabled() is abstract, so you must declare it or the class will not compile.

Read the next sentence carefully, because the docblock above is misleading. In 5.0.19 nothing in the suite calls isEnabled() on an MFAArea extension. The return value is never read. Returning FALSE does not hide the area from the ACP configuration form. The method is required by the compiler and ignored by the runtime. Why that is true is explained under "isEnabled() returns FALSE and the option is still on the form" below.

The file belongs at applications/<app>/extensions/core/MFAArea/<Class>.php, in namespace IPS\<app>\extensions\core\MFAArea, and must be listed in applications/<app>/data/extensions.json:

{
    "core": {
        "MFAArea": {
            "MyArea": "IPS\\myapp\\extensions\\core\\MFAArea\\MyArea"
        }
    }
}

The Developer Center's template for a new extension, at applications/core/data/defaults/extensions/MFAArea.txt, generates a class whose body is return TRUE;. Six of core's seven implementations keep that body verbatim; DeviceManagement.php returns Settings::i()->device_management instead. Of Nexus's six, three return TRUE (Addresses, Alternatives, BillingInfo) and three return a real condition (AccountCredit, BillingAgreements, Cards). As the next section explains, none of those conditions currently has any effect.

The two identifiers you must keep in step

Your area is identified everywhere by the string <appDirectory>_<extensionKey>. The app directory is the directory your extension lives in, not the core that appears in the path and namespace. The extension key is the key in extensions.json, which matches the file name. So applications/nexus/extensions/core/MFAArea/Cards.php is the area nexus_Cards.

That string is built in IPS\Application::allExtensions() at system/Application/Application.php:419:

$appExtensions[ $application->directory . '_' . $key ] = $class;

The same string is what accessToArea() looks for in the setting, at system/MFA/MFAHandler.php:117. If the two arguments you pass to accessToArea() do not combine into the same string, your call silently does nothing, forever.

Language keys

The label on the tick box is the language key MFA_<appDirectory>_<extensionKey>. An optional second key, MFA_<appDirectory>_<extensionKey>_desc, is shown as smaller descriptive text under the label if it exists; IPS\Helpers\Form\CheckboxSet::html() tests for it with checkKeyExists() at system/Helpers/Form/CheckboxSet.php:71-75, so leaving it out is legitimate.

The main key is not optional in practice. If it is missing, an administrator sees a tick box labelled MFA_myapp_MyArea. (addToStack() itself returns an md5 placeholder; the substitution happens later in Lang::replaceWords(), which falls back to $replacement = $values['key'] — the raw key — when the word cannot be loaded, at system/Lang/Lang.php:2131-2134. No exception, no log.) The Developer Center knows about this: applications/core/modules/admin/developer/details.php:577 lists 'MFAArea' => 'MFA_{app}_{key}' in its language-scan table, so a missing key is reported under "MFAArea Extensions" on the Developer Center's language check.

Core's keys are worth reading as examples of the tone expected — they are phrased as the thing the member is doing, not as the name of the area. From applications/core/dev/lang/login.php:373-382:

'MFA_core_AuthenticateAdmin'      => "Logging into AdminCP",
'MFA_core_EmailChange'            => "Changing email address",
'MFA_core_EmailChange_desc'       => "The user will also need to re-enter their password.",

Who calls it

One method, in one file. IPS\MFA\MFAHandler::areas(), at system/MFA/MFAHandler.php:52-60:

public static function areas(): array
{
    $return = array();
    foreach ( Application::allExtensions( 'core', 'MFAArea', FALSE, 'core', NULL, FALSE ) as $k => $v )
    {
        $return[ $k ] = "MFA_{$k}";
    }
    return $return;
}

Note the arguments against the signature at system/Application/Application.php:355. $checkAccess is FALSE, so no application access check is performed and your extension is listed even for an app that is restricted to certain groups. $firstApp is 'core', so core's seven areas are always listed before anyone else's. The final FALSE is $constructthe extension objects are never instantiated. areas() receives class names, uses only the array keys, and throws the class names away.

areas() in turn has exactly one caller, the ACP form at applications/core/modules/admin/settings/mfa.php:251:

$form->add( new CheckboxSet( 'security_questions_areas', Settings::i()->security_questions_areas ? explode( ',', Settings::i()->security_questions_areas ) : array_keys( MFAHandler::areas() ), FALSE, array( 'options' => MFAHandler::areas() ), NULL, NULL, NULL, 'security_questions_areas' ) );

That is the entire consumption of this extension point. The saved value is a comma-separated string, joined at mfa.php:282.

The other half: triggering the challenge

The runtime side never touches the extension. MFAHandler::accessToArea(), at system/MFA/MFAHandler.php:106, takes the app and area as plain strings and compares them against the setting:

public static function accessToArea( string $app, string $area, Url $url, ?Member $member = NULL ): ?string
/* If MFA is not enabled for this area, do nothing */
if ( !Settings::i()->security_questions_areas or !in_array( "{$app}_{$area}", explode( ',', Settings::i()->security_questions_areas ) ) )
{
    return NULL;
}

It returns NULL when the member may proceed, or a string of HTML — the challenge form, the setup form, or the lockout screen — which the caller must render instead of the page. Nexus shows the standard pattern, at applications/nexus/modules/front/clients/cards.php:61-65:

if ( $output = MFAHandler::accessToArea( 'nexus', 'Cards', Url::internal( 'app=nexus&module=clients&controller=cards', 'front', 'clientscards' ) ) )
{
    Output::i()->output = Theme::i()->getTemplate('clients')->cards( array() ) . $output;
    return;
}

The URL you pass is the URL the member is sent back to after passing the challenge, so it must be the page you are protecting. The $member argument defaults to Member::loggedIn(); the login flows pass an explicit member because the member is not logged in yet.

isEnabled() returns FALSE and the option is still on the form

This is the trap most likely to catch a third-party developer, because the docblock in the abstract, in every core implementation and in the Developer Center's own template all say "Is this area available and should show in the ACP configuration?".

Grep the 5.0.19 source for MFAArea and exactly one line of executable code asks for the extension type at all: MFAHandler.php:55, inside areas(). The other matches are the abstract itself, two docblock mentions in MFAHandler.php (lines 100 and 101), the language-scan table at details.php:577 — which reads extensions.json as a file and never loads a class — the Developer Center template, the extensions.json registrations, and the ext__MFAArea/devscan__strings_mfaarea language strings. Nothing else. And areas() passes $construct=FALSE, so Application::constructExtensionClass() (declared at Application.php:469, called from Application.php:451 only when $construct is true) is never reached and no MFAArea object is ever created. A method that is never called on an object that is never built cannot influence anything.

Both core and Nexus write real logic there in good faith — applications/core/extensions/core/MFAArea/DeviceManagement.php returns Settings::i()->device_management, applications/nexus/extensions/core/MFAArea/Cards.php returns Settings::i()->card_storage_gateways, and AccountCredit.php returns a check on nexus_min_topup and nexus_payout — and on a 5.0.19 site those checks have no effect: "Managing stored cards" appears on the MFA settings form whether or not any gateway supports card storage.

What this means for you. Write isEnabled() because you must, put your real condition in it because a future version may start honouring it, and do not rely on it for anything. If your area must disappear when a feature is switched off, the place to enforce that is your own controller: skip the accessToArea() call, or do not reach that code path at all. This is a behaviour that could change in a later 5.x release, so treat the observation as true of 5.0.19 specifically.

The option appears, an admin ticks it, and nobody is ever challenged

No error, no log entry. The tick box works, the setting saves, and the site behaves exactly as before.

The extension only creates the option. Nothing in core scans your application and inserts a challenge for you. If you never call MFAHandler::accessToArea() from the controller you meant to protect, the ticked box is inert. Every protected area in the suite has a hand-written call site — there are twenty-two of them across core and Nexus, in system/Dispatcher/Dispatcher.php, system/Login/Success.php, applications/core/modules/admin/system/login.php, applications/core/modules/front/system/settings.php (eight on its own), login.php, register.php, lostpass.php, the six Nexus client-area controllers and applications/nexus/modules/front/checkout/checkout.php.

The same silence applies to ignoring the return value. On an ordinary page load accessToArea() has no side effect that stops execution — it returns a string and leaves it to you. (It is not completely inert: on requests that carry _mfa=optout or mfa_auth it calls Session::i()->csrfCheck(), at MFAHandler.php:137 and 229 and 267, which will call Output::i()->error() and halt if the CSRF key is wrong. That only happens on a submission of its own form.) If you call it and discard the string, the member is shown your normal page and the protection is gone.

accessToArea() always returns NULL and I cannot see why

Four separate causes, all silent, in rough order of likelihood.

  • The area string does not match. Your extension is applications/myapp/extensions/core/MFAArea/MyArea.php, so the stored key is myapp_MyArea, but you copied a core call site and wrote accessToArea( 'core', 'MyArea', $url ). The comparison is a plain in_array() on "{$app}_{$area}"; a miss just returns NULL. The first argument is your application's directory.
  • The setting is empty. security_questions_areas ships with a default of "" (applications/core/data/build.xml:407), and the first condition in accessToArea() is !Settings::i()->security_questions_areas. On a site where the MFA settings form has never been saved, no area is protected, including core's own, even though the form shows every box ticked. The form pre-ticks everything only as a display default when the stored value is empty — see the ternary at mfa.php:251. Nothing is actually enabled until an administrator presses Save.
  • No handler is usable. After the area check, accessToArea() builds the list of handlers where isEnabled() and memberCanUseHandler( $member ) are both true, and returns NULL if that list is empty (MFAHandler.php:150-162). On a site with no two-factor method configured at all, every area returns NULL. Enable Google Authenticator or security questions before testing.
  • The recovery constant is set. DISABLE_MFA, defined as FALSE at init.php:176 and overridable in constants.php, short-circuits accessToArea() to NULL before anything else. There is a matching DEV_FORCE_MFA (init.php:454) that bypasses the "already authenticated this session" check, which is what you want while developing.

My new area does not appear on the MFA settings form

Assuming extensions.json is correct — and it is only rewritten by Application::buildExtensionsJson(), which runs from the Developer Center's create and delete actions and nowhere else — the cause is almost always the datastore cache.

allExtensions() stores the resolved class-name map under the datastore key extensions, keyed by extension type. Once MFAArea is in there, the directory walk never runs again. That key is listed in Application's instance property $caches at system/Application/Application.php:117 (protected array $caches = array( 'updatecount_applications', 'applications', 'extensions' );), which ActiveRecord::clearCaches() unsets from save() and delete() (system/Patterns/ActiveRecord.php:553 and 577) — so it is cleared when an application record is saved — install, enable, disable, uninstall — and by ACP → Support → Clear Caches. Copying a file in and editing the JSON by hand clears nothing.

Two secondary causes. Application::extensions() skips any entry whose class fails class_exists(), with a bare continue and no log (Application.php:926-930), so a class name in the JSON that does not match the class declared in the file drops the area in total silence. And allExtensions() skips third-party applications entirely while RECOVERY_MODE is on (Application.php:396) — and then writes that filtered list to the datastore, where it survives recovery mode being turned back off.

My area is on the form but unticked after an upgrade, and members are not challenged

Expected behaviour, and worth telling your customers about in your release notes. The pre-tick-everything default at mfa.php:251 only applies when security_questions_areas is empty. On any site where the form has been saved once, the stored string is non-empty, so a newly installed area is rendered unticked. Adding an MFAArea extension in an update never enables it; an administrator has to open the form and tick it.

Fatal error: contains 1 abstract method and must therefore be declared abstract

The exact text:

Fatal error: Class IPS\myapp\extensions\core\MFAArea\MyArea contains 1 abstract
method and must therefore be declared abstract or implement the remaining methods
(IPS\Extensions\MFAAreaAbstract::isEnabled)

Because isEnabled() is abstract, omitting it is a compile-time error, raised when the autoloader loads your class during the class_exists( $classname ) call inside Application::extensions() (system/Application/Application.php:926). Nothing catches it: it is an E_ERROR at file-compile time, so neither the try/catch in constructExtensionClass() nor the set_error_handler()/set_exception_handler() pair registered at init.php:632-633 can intercept it, and IPS registers no shutdown handler.

Where this differs from most extension points is when you will see it. The MFAArea list is built lazily, only when something asks for that extension type, and the only thing that ever asks is the ACP MFA settings form. So a missing isEnabled() does not break the front end, does not break your own app, and does not break the rest of the ACP — it produces a white screen on one ACP settings page, which an administrator may not visit for months. Load app=core&module=settings&controller=mfa after adding the extension, as a smoke test.

The member passed the challenge somewhere else and walks straight into my area

Not a bug in your code. Success is recorded once, globally, in $_SESSION['MFAAuthenticated'] as a timestamp (MFAHandler.php:236 and 272), and the early-return check reads it without reference to the area (MFAHandler.php:123):

if ( !DEV_FORCE_MFA and ( isset( $_SESSION['MFAAuthenticated'] ) and ( !Settings::i()->security_questions_timer or ( ( $_SESSION['MFAAuthenticated'] + ( Settings::i()->security_questions_timer * 60 ) ) > time() ) ) ) )
{
    return NULL;
}

One challenge satisfies every area until security_questions_timer minutes have passed, and a timer of 0 means "for the whole session". A member who has just logged in with two-factor authentication will not be challenged again by your area. If your area guards something that genuinely needs a fresh check every time, MFA is not the mechanism — core re-asks for the password separately for email and password changes, which is why the language strings for those areas say "The user will also need to re-enter their password."

There is also a per-member opt-out. If mfa_required_groups is not '*' and the member is not in a listed group, a member who has previously opted out is returned NULL for every area (MFAHandler.php:129-147). Your area cannot override that.

A complete working example

Taken from Nexus, which is the closest thing in the suite to a third-party implementation. Three files plus one call site.

applications/nexus/extensions/core/MFAArea/Cards.php, in full apart from the IPS copyright docblock above the namespace line:

<?php

namespace IPS\nexus\extensions\core\MFAArea;

/* To prevent PHP errors (extending class does not exist) revealing path */

use IPS\Extensions\MFAAreaAbstract;
use IPS\Settings;
use function defined;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * Multi-Factor Authentication Area
 */
class Cards extends MFAAreaAbstract
{
	/**
	 * Is this area available and should show in the ACP configuration?
	 *
	 * @return	bool
	 */
	public function isEnabled(): bool
	{
		return Settings::i()->card_storage_gateways;
	}
}

The registration, in applications/nexus/data/extensions.json:

{
    "core": {
        "MFAArea": {
            "Cards": "IPS\\nexus\\extensions\\core\\MFAArea\\Cards"
        }
    }
}

The language string, in applications/nexus/data/lang.xml:2210:

<word key="MFA_nexus_Cards" js="0">Managing stored cards</word>

And the call site that actually enforces it — in execute(), not in the manage() method, at applications/nexus/modules/front/clients/cards.php:61. Putting it in execute() before the parent::execute() that dispatches to manage() is what makes it cover every action of the controller at once:

if ( $output = MFAHandler::accessToArea( 'nexus', 'Cards', Url::internal( 'app=nexus&module=clients&controller=cards', 'front', 'clientscards' ) ) )
{
    Output::i()->output = Theme::i()->getTemplate('clients')->cards( array() ) . $output;
    return;
}

parent::execute();

Nexus renders an empty version of the page behind the challenge so the member is not dropped onto a blank screen. applications/nexus/modules/front/clients/info.php:57-65 does the same with a form. Neither is required; you may equally assign $output to Output::i()->output on its own.

A core detail that looks like your bug but is not

Inside the "try another way to sign in" branch, MFAHandler.php:194 reads:

if ( $app === 'core' and $area === 'AuthenticateFront' and !in_array( "app_AuthenticateFrontKnown", explode( ',', Settings::i()->security_questions_areas ) ) )

Stored area keys are always <appDirectory>_<extensionKey>, so the value in this setting is core_AuthenticateFrontKnown, never app_AuthenticateFrontKnown. The literal cannot match, so the negated test is always true and the known-device recovery option is offered whenever the member has any row in core_members_known_devices. It affects only core's own front-end login area. Nothing a third-party MFAArea does can trigger or avoid it; it is noted here so you do not spend an afternoon looking for it in your own code.

Removing an area

Deleting the extension removes the tick box from the form. It does not remove the key from security_questions_areas: no code in the suite rewrites that setting except the MFA settings form itself, so a stale myapp_MyArea entry stays in the comma-separated string after your app is uninstalled. It is harmless — nothing will ever compare against it again — but it will still be sitting in core_sys_conf_settings and will be silently discarded the next time an administrator saves the form. Whether an app should clean this up in its core/Uninstall extension is a judgement call; core does not.

Verified against

Read from the source of Invision Community 5.0.19 (latest upgrade step applications/core/setup/upg_5001908). Key files: system/Extensions/MFAAreaAbstract.php, system/MFA/MFAHandler.php, system/Application/Application.php (allExtensions(), extensions(), constructExtensionClass(), $caches), applications/core/modules/admin/settings/mfa.php, applications/core/modules/admin/developer/details.php, applications/core/data/defaults/extensions/MFAArea.txt, system/Helpers/Form/CheckboxSet.php, system/Dispatcher/Dispatcher.php, system/Login/Success.php, the seven implementations under applications/core/extensions/core/MFAArea/ and the six under applications/nexus/extensions/core/MFAArea/. The claim that isEnabled() is never invoked is based on a full-source grep for MFAArea and on areas() passing $construct=FALSE; it is a statement about 5.0.19 and may not hold in later releases.


User Feedback

Recommended Comments

There are no comments to display.

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.