Havenlytics authentication governs who may enter the Agent Workspace — the front-end SPA that agents use in place of wp-admin. This guide documents the model as it ships in Havenlytics 3.3.1: a single authorization choke point that evaluates two independent conditions, a pluggable identity-verification framework, the AJAX endpoints that back login and email verification, and the identity link that ties a WordPress user to an Agent CPT. It is written for plugin, theme, and integration developers extending the workspace — not for end users or agents.
Note: This article covers the Agent Workspace authorization layer under the REST namespace
hvnly/v1. For the workspace architecture as a whole (delivery, services, React SPA) see the Agent Workspace developer guide; for the REST surface see the REST API reference.
The mental model: authentication vs. authorization
Havenlytics keeps these two concerns strictly separate, and you should too when you extend it:
- Authentication (“are you signed in, and who are you?”) is handled by WordPress itself (
wp_signon(), the auth cookie) plus the identity link that resolves the current user to an Agent CPT. The relevant class isAgentIdentityService. - Authorization (“may this signed-in user use the workspace, and what may they do?”) is computed by
PortalAuthorization— a pure boolean gate that returnstrue/falseand never redirects or emits HTML.
Every capability the workspace grants — managing listings, editing a profile, viewing analytics, handling inquiries — is derived from one method. If that method says no, every downstream permission says no.
The single choke point: compute_can_access_workspace()
All workspace access flows through PortalAuthorization::compute_can_access_workspace() (in includes/Workspace/Auth/PortalAuthorization.php). Public methods such as can_manage_own_properties(), can_edit_own_profile(), can_view_analytics(), can_create_listings(), can_publish_listings() and can_manage_inquiries() all short-circuit on it: if a user cannot access the workspace, they cannot do anything inside it.
The gate evaluates in this exact order:
- Administrator bypass. A user with the
manage_optionscapability is allowed unconditionally and skips both conditions below. This guarantees site owners can never lock themselves out. - Condition 1 — registration status (“what may they do?”).
- Condition 2 — identity verification (“who are they, and are they verified?”).
- WordPress capability check. Only if both conditions pass does the gate check the
hvnly_portal_accesscapability (or grant soft access by resolved role).
The two conditions are independent. A user can be fully approved yet unverified (Condition 1 passes, Condition 2 fails), or verified yet still pending approval (the reverse). Both must pass.
Condition 1 — registration status
The lifecycle SSOT is WorkspaceRegistrationStatus, backed by the user meta key _hvnly_ws_registration_status (mirrored to the linked Agent CPT meta). A user’s status is one of:
| State | Grants workspace access? | Grants login? |
|---|---|---|
pending | No | No |
approved | Yes | Yes |
active | Yes | Yes |
suspended | No | Yes |
blocked | No | No |
archived | No | No |
Condition 1 passes only when WorkspaceRegistrationStatus::allows_workspace_access() returns true — that is, when the status is approved or active. A separate check, allows_login(), also permits suspended users to sign in (so they can see a “suspended” message) without granting workspace access. Transitions are validated by allowed_transitions() / can_transition(), and every change fires the hvnly_workspace_registration_status_changed action.
<?php
use HvnlyNab\Workspace\Auth\WorkspaceRegistrationStatus;
// Read a user's current registration status.
$status = WorkspaceRegistrationStatus::get_for_user( $user_id );
// React whenever an agent's status changes (approve, suspend, etc.).
add_action(
'hvnly_workspace_registration_status_changed',
function ( $user_id, $new_status, $old_status ) {
if ( 'active' === $new_status ) {
// e.g. sync the agent into your CRM as an active seat.
}
},
10,
3
);
Note: Registration status is deliberately decoupled from the Agent CPT’s
post_status. An Agent post can be published while its owner is stillpending. Never infer workspace access from post status — always read_hvnly_ws_registration_statusviaWorkspaceRegistrationStatus.
Condition 2 — identity verification
Condition 2 asks IdentityVerificationService::instance()->required_factors_satisfied( $user_id ). This is the crucial design decision: the authorization layer never names “email”. It composes an arbitrary set of verification factors and passes only when every required factor is satisfied.
Out of the box exactly one factor is registered: EmailVerificationFactor (slug email). It is satisfied when the user meta _hvnly_email_verified equals the string '1'. Because the gate is generic, adding phone confirmation, 2FA, or KYC later requires zero changes to PortalAuthorization — you register another factor (see below).
The email factor also encodes several safety valves so a new requirement never locks out an existing site:
- Administrators are exempt — they were already short-circuited by the
manage_optionsbypass, so the factor never blocks them. - Legacy accounts are grandfathered. Accounts that predate the feature (no
_hvnly_email_verifiedmeta) are treated as satisfied until a one-time back-fill runs (guarded by the optionhvnly_email_verification_migrated). New registrations are explicitly set to'0'. - Emergency kill switch. The option
hvnly_email_verification_disabledand the filterhvnly_email_verification_enforcedlet you disable enforcement entirely without a deploy.
<?php
use HvnlyNab\Workspace\Identity\IdentityVerificationService;
// Is every REQUIRED factor satisfied for this user?
$ok = IdentityVerificationService::instance()->required_factors_satisfied( $user_id );
// Full serializable state (per-factor) for a Security Center / admin view.
$state = IdentityVerificationService::instance()->state_for_user( $user_id );
The identity-factor framework
A factor answers exactly one yes/no question about a user’s identity. Every factor implements HvnlyNab\Workspace\Identity\VerificationFactorInterface:
| Method | Returns | Purpose |
|---|---|---|
slug() | string | Stable machine slug, e.g. 'email', 'phone', '2fa'. |
label() | string | Translation-ready human label for UI. |
is_required_for_access( int $user_id ) | bool | Must this factor pass before workspace access? Return false for an informational-only factor. |
is_satisfied( int $user_id ) | bool | Does the user currently satisfy it? |
state( int $user_id ) | array | Serializable state for /me, the Security Center, and admin views. |
The service seeds its registry with the email factor, then applies the hvnly_identity_factors filter so third parties can add their own. This filter is the primary extension seam for authentication.
Example: register a custom “phone confirmed” factor
The following registers a required factor that is satisfied only when a user has confirmed their phone number (stored in a meta key your integration writes). Because it returns true from is_required_for_access(), Condition 2 will now also demand a confirmed phone — automatically, with no edit to the authorization code.
<?php
use HvnlyNab\Workspace\Identity\VerificationFactorInterface;
/**
* A verification factor that requires a confirmed phone number.
*/
final class My_Phone_Verification_Factor implements VerificationFactorInterface {
const META_CONFIRMED = '_my_phone_confirmed';
public function slug(): string {
return 'phone';
}
public function label(): string {
return __( 'Phone number', 'my-textdomain' );
}
public function is_required_for_access( int $user_id ): bool {
// Optional: exempt administrators, honor an emergency toggle, etc.
if ( user_can( $user_id, 'manage_options' ) ) {
return false;
}
return true;
}
public function is_satisfied( int $user_id ): bool {
return '1' === (string) get_user_meta( $user_id, self::META_CONFIRMED, true );
}
public function state( int $user_id ): array {
return array(
'slug' => $this->slug(),
'label' => $this->label(),
'required' => $this->is_required_for_access( $user_id ),
'satisfied' => $this->is_satisfied( $user_id ),
);
}
}
// Register it. $factors is keyed by slug.
add_filter( 'hvnly_identity_factors', function ( array $factors ) {
$factor = new My_Phone_Verification_Factor();
$factors[ $factor->slug() ] = $factor;
return $factors;
} );
Warning: A required factor that no real user can satisfy will lock every non-admin agent out of the workspace. Always ship a way to satisfy the factor (a UI flow, an admin action, or a back-fill for existing users) before making
is_required_for_access()returntruein production. Test with a non-admin account first.
Tip: Related filters let you tune verification without a custom factor:
hvnly_identity_verification_satisfied(final override of the composed result), and for the built-in email factorhvnly_email_verification_enforcedandhvnly_email_verification_ttl.
Authentication AJAX actions
All interactive authentication runs over admin-ajax.php through SessionAuthController (includes/Workspace/Auth/SessionAuthController.php). Each action is registered for both logged-in (wp_ajax_) and logged-out (wp_ajax_nopriv_) visitors, and each carries its own nonce action — nonces are never shared across operations.
| Purpose | AJAX action | Nonce action | Backing method |
|---|---|---|---|
| Login | hvnly_ws_login | hvnly_ws_auth_login | login() (calls wp_signon()) |
| Register | hvnly_ws_register | hvnly_ws_auth_register | register_user() |
| Lost password | hvnly_ws_lostpassword | hvnly_ws_auth_lostpassword | lost_password() |
| Reset password | hvnly_ws_resetpassword | hvnly_ws_auth_resetpassword | reset_password() |
| Logout | hvnly_ws_logout | hvnly_ws_auth_logout | logout() |
| Refresh nonces | hvnly_ws_auth_nonces | — | refresh_nonces() |
| Verify email | hvnly_ws_verify_email | hvnly_ws_auth_verify_email | verify_email() |
| Resend verification | hvnly_ws_resend_verification | hvnly_ws_auth_resend_verification | resend_verification() |
Two behaviours are worth highlighting for integrators:
- Login re-derives status.
login()does not merely authenticate the credentials; it re-evaluates the registration status so a suspended or blocked account is handled correctly at the login boundary, not just at the REST boundary. - Verification endpoints are nopriv and rate-limited. Email verification must work for a logged-out visitor clicking a link, so
verify_emailandresend_verificationare reachable without a session — which is exactly why they are aggressively throttled (below) and return generic failures to prevent account enumeration.
Rate limiting
VerificationRateLimiter protects the public verification endpoints. It is transient-backed (no custom table). Identifiers such as IP addresses are never stored as raw option names — each key is hashed with hash_hmac('sha256', $id, wp_salt('auth')) and truncated. The client IP is read from REMOTE_ADDR only; proxy headers are not trusted (override via the hvnly_verification_client_ip filter if you terminate TLS at a trusted proxy).
| Endpoint | Limits applied |
|---|---|
verify_email | 20 attempts / 10 min per IP; 10 attempts / hour per token. |
resend_verification | 10 / hour per IP; 60-second cooldown per account; 5 / hour per account; 10 / day per account. |
The email verification token
EmailVerificationFactor issues single-use, expiring tokens using a selector/verifier scheme so that a database read never reveals a usable secret:
- Selector —
bin2hex(random_bytes(8)), a plaintext lookup index stored in meta_hvnly_email_verification_selector. - Verifier —
bin2hex(random_bytes(32)). Only its HMAC —hash_hmac('sha256', $verifier, wp_salt('auth'))— is persisted (meta_hvnly_email_verification_token). The raw verifier is never stored or logged. - The token handed to the user is
selector.verifier. verify()strips non-hex characters, looks up by selector, recomputes the HMAC, and compares withhash_equals()(constant-time). It enforces expiry (meta_hvnly_email_verification_expires) and single use (the token meta is deleted on success or expiry). On failure it fireshvnly_email_verification_failedand resolves to user ID0.- TTL defaults to
DAY_IN_SECONDS, is filterable viahvnly_email_verification_ttl, and is clamped to a 1-hour–48-hour window.
Security: This is the model to copy for any secret you mint in an extension — store only an HMAC keyed by
wp_salt('auth'), compare withhash_equals(), make tokens single-use and time-boxed, and return generic errors from public endpoints. Never store or log the raw verifier.
Identity provisioning & the 1:1 link
Authentication is meaningless without a stable answer to “which Agent is this user?” Havenlytics maintains a strict 1:1 link between a WordPress user and an Agent CPT, recorded in the Agent post meta _hvnly_agent_linked_user_id.
AgentProvisioner is the sole authorized writer of that meta. A meta write guard (guard_linked_user_metadata, hooked at priority 0 on update_post_metadata/add_post_metadata/delete_post_metadata) blocks any other code from mutating the link unless the write happens inside the provisioner’s authorized context. This prevents a stray update_post_meta() elsewhere from silently corrupting identity.
| Direction | Method | What it does | Fires |
|---|---|---|---|
| User → Agent | ensure_agent_for_user() | Front-end registration path: creates a published Agent CPT for the user. | hvnly_workspace_agent_provisioned |
| Agent → User | ensure_user_for_agent() | Admin path: creates a WP user (role hvnly_agent, status pending) for an existing Agent. | hvnly_workspace_user_provisioned_for_agent |
Resolution at request time is AgentIdentityService‘s job: it is the SSOT that maps the current WP user to their linked Agent, request-cached so it resolves once per request. It exposes get_user_id(), get_agent_id(), get_agent(), get_role(), get_capabilities() and is_logged_in(). The resolved persona role is one of administrator, agency_owner, agency_manager, agent, buyer, unlinked or guest.
<?php
use HvnlyNab\Workspace\Auth\AgentIdentityService;
$identity = AgentIdentityService::instance();
if ( $identity->is_logged_in() ) {
$agent_id = $identity->get_agent_id(); // linked hvnly_agent post ID, or 0
$role = $identity->get_role(); // 'agent', 'agency_owner', …
}
// React to a fresh front-end registration.
add_action( 'hvnly_workspace_agent_provisioned', function ( $user_id, $agent_id ) {
// e.g. enqueue an onboarding email or CRM record.
}, 10, 2 );
Common mistake: Writing
_hvnly_agent_linked_user_iddirectly withupdate_post_meta(). The write guard will reject it. Always go throughAgentProvisioner(set_linked_user(),ensure_agent_for_user(),ensure_user_for_agent()) so the 1:1 invariant and its transient lock are honoured.
Enforcement layers
Authorization is not enforced in one place — it is re-checked at every boundary, so a bypass at one layer cannot leak through another. This is defense in depth:
| Layer | Where | What it enforces |
|---|---|---|
| REST | WorkspaceAvailability::filter_rest_pre_dispatch (priority 5); each controller’s permission_* callback | Kills hvnly/v1 when the workspace is disabled; every controller re-checks enabled + logged in + can access. PropertyAccessGate adds per-resource ownership. |
| Login | SessionAuthController::login() | Re-derives registration status at the authentication boundary. |
| Asset / SPA | WorkspaceAssets; WorkspaceApp.js | The SPA script is never enqueued when the workspace is disabled, and the React app refuses to mount if HvnlyWorkspaceData.enabled is falsy. |
| Front-end page | WorkspaceAvailability::guard_front_end() (priority 0) | Guards the dashboard page itself. |
Security: Because each REST controller re-checks authorization in its own permission callback, you must do the same in any REST controller you add. Never assume the SPA already gated the request — a client can call
hvnly/v1directly. Gate per-object with an ownership check (asPropertyAccessGatedoes), not with a blanket capability.
Best practices
- Read access, never re-implement it. Call
PortalAuthorization‘s public methods (or the/mepayload) to decide what to show. Do not re-derive access from raw meta. - Add requirements as factors. New identity requirements belong in a
VerificationFactorInterfaceimplementation registered onhvnly_identity_factors— not in edits toPortalAuthorization. - Respect the two conditions. If you build an approval workflow, drive it through
WorkspaceRegistrationStatustransitions so thehvnly_workspace_registration_status_changedaction and notifiers fire correctly. - Never trust client-supplied identity. Controllers resolve identity server-side via
AgentIdentityService; client-supplied user or agent IDs are ignored.
Performance recommendations
AgentIdentityServiceandPortalAuthorizationare request-cached; calling them repeatedly in one request is cheap. Avoid re-querying_hvnly_ws_registration_statusyourself.- Keep custom factors’
is_satisfied()andstate()lightweight — they run inside the authorization gate and on the boot identity payload. Avoid remote calls there; cache results in user meta or a transient. - The verification rate limiter uses transients rather than a table, so it stays fast under load and self-expires.
Common mistakes
- Confusing nonce actions with AJAX actions. The login AJAX
actionishvnly_ws_login; its nonce action ishvnly_ws_auth_login. They are different strings. - Inferring access from Agent post status. Post status and registration status are decoupled.
- Making a factor required before users can satisfy it. This locks out all non-admins. Ship the satisfaction path first.
- Storing raw tokens. Persist only the HMAC, compare with
hash_equals().