1.5.1
Released 2026-05-20
Fixed
Proximity regression guard tests added for listings, events, and members. Three new integration tests assert that a listing/event/member at Cork coordinates (~258 km from Dublin) is excluded from a 10 km Dublin-centred radius search. These would have caught every recurrence of the proximity filter being silently ignored. Also fixed:
ListingService::countAll()proximity subquery had a binding-order bug —mergeBindings()placed WHERE-clause values before SELECT bindings, causing MariaDB to evaluatecos(radians('active'))as the latitude. Replaced with a rawDB::selectOne()query using explicit ordered bindings.Security: cross-tenant data access hardened in ExchangeService and MarketplaceListingService.
ExchangeService::accept()anddecline()refetched exchange records for notification dispatch without atenant_idconstraint — a defence-in-depth gap that could pass cross-tenant metadata to the notification system. Both refetches now include->where('tenant_id', TenantContext::getId()), consistent withcomplete()which was already correct.MarketplaceListingService::saveListing()had no ownership check before creating a saved-listing record — a user on Tenant A could bookmark a listing from Tenant B by crafting a direct API call. A tenant guard viaHasTenantScopeis now applied before thefirstOrCreate.Events proximity pagination returns correct results on Load More.
EventService::getAll()applied distance ordering (ORDER BY distance_km ASC) when proximity was active, but still built the cursor fromlast_idand decoded it asWHERE id < cursoron subsequent requests — a keyset/sort mismatch that caused Load More to skip or repeat events. Proximity path now uses offset-based pagination (cursor formatnearby:N) consistent withListingService.getNearby().Proximity "Near me" filter broken end-to-end — five separate issues fixed across listings and events. Full audit revealed: (1)
ListingService::getAll()receivednear_lat/near_lng/radius_kmfrom the controller but never applied them — all listings returned regardless of distance (root cause of Dublin user seeing Cork listings at 2 km). Fixed by delegating to the existinggetNearby()haversine query when coordinates are present. (2)ListingService::countAll()also ignored proximity, showing a wrong total count in the results badge; fixed with a subquery-based haversine count. (3) BothgetAll()andcountAll()were missingnear_latfrom the$hasFacetedFiltersguard, so search + proximity queries incorrectly used Meilisearch totals. (4)ListingsControllerapplied the personalisation re-ranker and smart-match ranker after proximity results, destroying the nearest-first order; both ranking passes now skip when proximity is active. (5)EventService::getAll()had the identical structural bug (proximity params ignored); fixed by applying the haversine filter inline, consistent with howVolunteerServicealready correctly handles it. Also fixed in the React UI:activeFilterCountnow includes proximity so the "Filters" badge reflects it, and the "Clear filters" button now resets the proximity pill correctly using a remount key.GDPR: account deletion now scrubs the
email_logaudit trail and clears the user's row fromemail_suppression. The original email address is captured before the user record is anonymised so the platform-wide suppression cache (keyed on email, not user id) can be cleaned up. Recipient address inemail_logis anonymised in place rather than deleted, preserving tenant-level aggregate deliverability metrics.Stuck
notification_queuerows from before the daily-digest opt-in flip are now expired. Cleanup task now also marksstatus='pending'rows older than 7 days asfailedso the digest cron doesn't send a member a "what happened in March" digest after the deploy. The 30-day cleanup also now sweepsfailedrows along withsent(was onlysentbefore).Dropped dead
users.email_preferencesJSON column. Never read, never written by application code (verified by grep); removed via guardedhasColumnmigration so it's safe to re-run.DKIM / SPF / DMARC verified healthy on production —
project-nexus.nethas strict SPF (include:sendgrid.net -all), boths1._domainkeyands2._domainkeyCNAMEs validly point at SendGrid, SendGrid reports the domain asvalid=True. DMARC is atp=none(monitor-only); recommended to escalate top=quarantineafter 30 days of clean aggregate reports.
Added
Hierarchical domain inheritance for sub-tenants. Slug-only sub-tenants whose immediate parent has a custom domain are now accessible at
parent.domain/child-slug(e.g.timebanking.uk/cardiff) in addition toapp.project-nexus.ie/cardiff. The backend resolves the child tenant from the first path segment after locking on the parent's custom domain, and the bootstrap API returns aparent_domainfield so the SPA automatically uses path-prefixed routing on the parent's domain. Email and notification links for these sub-tenants now correctly emittimebanking.uk/cardiff/...URLs (including queued/background jobs via a DB parent-domain lookup fallback). The sitemap layer is fully wired:timebanking.uk/sitemap.xmlreturns a sitemap index listing both the parent's and each sub-tenant's sitemap;/sitemap-{childslug}.xmlgenerates URLs with the correctparentdomain/childslug/...base. The prerender pipeline (prerender:plan-routesandprerender-tenants.sh) prerenders sub-tenant pages attimebanking.uk/cardiff/...instead ofapp.project-nexus.ie/cardiff/....SitemapService::generateForAppDomain()excludes sub-tenants that belong under a parent domain so they don't appear with wrong canonical URLs in the shared-host sitemap. Redis bootstrap cache is invalidated automatically on hierarchy moves and domain changes. Moving a tenant in the hierarchy tree is safe at any time — domain associations update immediately with no stale cached state. No DNS or nginx changes are required beyond pointing the parent domain at the platform.SEO: organization type, geo meta tags, and structured data enhancements. Tenant super-admins can now set a
seo_organization_type(e.g.LocalBusiness,EducationalOrganization,NonprofitOrganization) via the admin tenant form, overriding the global Schema.org@typedefault. Geo meta tags (geo.region,geo.country,geo.placename, ICBM lat/long) are emitted in<head>when the tenant has location coordinates — helps Google/Bing assign geographic context and reduces multi-tenant duplicate-content risk.LocalBusinesstenants get anareaServedblock in the org schema that mapsservice_areascope to Schema.org types (City, AdministrativeArea, Country, Place). Added@idanchor to org schema for cross-referencing. Lat/lng andservice_areaare now included in the bootstrap APIcontactpayload.Admin panel: Registration Security card on
/admin/settings/registration-policy. Front-and-centre status card for the per-tenant circuit breaker. Polls every 30s. Green chip when signups are flowing normally; red border + alarm-banner + one-click "Resume signups now" button when the breaker has tripped. Includes the live signup count vs threshold so admins can see "we're at 18 of 20 this hour" before the breaker actually fires. Additive — sits above the existing registration-policy form, doesn't touch any existing components.
Added
Existing SendGrid event webhook (
POST /api/v2/webhooks/sendgrid/events) extended to populateemail_log+email_suppressionin real time. The webhook was already wired intoNewsletterBounceandEmailMonitorServicefor legacy bounce / complaint tracking; now also updates the new deliverability tables: matches the row by recipient +sg_message_idprefix, advances status todelivered/bounced/failed(never regresses a terminal state), populatesdelivered_at/bounced_at/opened_at, upsertsemail_suppressionon bounce / dropped / spamreport / unsubscribe. Adds support foropen/click/unsubscribeevent types that the legacy handler ignored. Uses the existing ECDSA verification viaSENDGRID_WEBHOOK_VERIFICATION_KEY— no new env vars or routes. The Mailer captures theX-Message-Idheader from SendGrid on send and writes it toemail_log.provider_message_idso webhook events match back to log rows.Admin email deliverability dashboard at
/admin/email-deliverability. Per-tenant headline metrics (delivered %, bounced %, status breakdown over 1/7/30/90 days), filterable email_log feed (recipient + status + date range), platform-wide suppression-list view with one-click remove (clears locally AND in SendGrid), and a per-user history endpoint. Operators can now answer "did Joe Bloggs get his welcome email?" with a click instead of a SSH session.Mobile push (FCM) fan-out from
NotificationDispatcher. Previously the dispatcher's instant path only fired web push and silently skipped the retired web-wrapper mobile app — direct messages, connection requests, volunteer-application status, mentions etc. were invisible on mobile. Dispatcher now fans out web push + FCM push in parallel, failure-isolated so one provider's outage doesn't suppress the other.FCMPushService::sendToUser/sendToUsersalso now honoursnotification_preferences.push_enabledso members can actually turn mobile push off (previously the preference existed but had no effect on FCM).emails:reconcile-transient-failuresartisan command (every 15 min). Cross-checks recentemail_logrows withstatus=failedagainst SendGrid's/v3/messagesactivity feed. If SendGrid actually accepted the send despite a transient 5xx on our side, the log row is repaired todeliveredso the audit trail reflects reality. Genuine failures stay flagged.Per-recipient email rate limit. Redis-backed rolling-hour counter (default 30/hour/recipient, configurable via
MAILER_PER_RECIPIENT_HOURLY_LIMIT, 0 disables). Catches runaway loops / buggy listeners that would otherwise flood a single member with dozens of emails. Logged asstatus=failed, error="per-recipient rate limit exceeded"so admins see what tripped the limit.expireOverdueJobs()+expireFeaturedJobs()re-implemented. Both methods were removed in an earlier refactor; the cron entries were throwingundefined methodonce per tenant per day. Now: featured jobs loseis_featuredoncefeatured_untilis past; open jobs older than 180 days with no edit in the last 60 days are auto-closed.AchievementCampaignService::processRecurringCampaigns()is also re-implemented (tickslast_run_atfor due recurring campaigns; award logic stubbed pending product decision on missed-runs).BrokerMessageVisibilityService::expireMonitoringBatch()stays no-op'd — the underlying schema (broker_monitoring table) does not exist on production.Integration test
WelcomeEmailCrossTenantTest— asserts the welcome listener still works whenTenantContextis pre-leaked from a tenant-2 job. Directly guards the original incident from regressing.Email observability:
email_logaudit table +email_suppressioncache. EveryMailer::send()now writes a row capturing tenant, user, recipient, subject, status (queued/sent/failed/suppressed/bounced/delivered), provider message id, and error. Operators can finally answer "did Joe Bloggs get his welcome email?" without grepping log files. The companionemail_suppressiontable is hydrated hourly by a newsendgrid:sync-suppressionsartisan command that pulls SendGrid's bounce / block / invalid / spam-report lists; the Mailer checks suppression before every send and refuses to mail addresses SendGrid has already told us are dead (saves quota, protects sender reputation, surfaces invalid member emails to admins).One-click unsubscribe (Gmail/Yahoo Feb-2024 bulk-sender compliance). New
NotificationUnsubscribeControllerplus/api/v2/notifications/unsubscriberoute (GET for browser visits, POST forList-Unsubscribe-Post). Token format is HMAC-signeduserId.tenantId.category.sig; categories map to notification preference keys (all/messages/connections/transactions/reviews/listings/digest/gamification/org/federation). The Mailer auto-attaches the header on every send by looking up the recipient in the current tenant — no caller changes needed for 30+ existing email-sending services. Confirmation page is locale-aware, tenant-branded, no-indexed.One-shot recovery:
php artisan emails:resend-stuck-activations. Re-sends welcome/verification emails to members who registered while the earlier TenantContext leak / Mailer bypass bugs were live and never got their activation email. Defaults to--dry-runso you can sanity-check the recipient list.--since=60days,--tenant=N,--limit=200flags for scoping. Reuses the canonical Mailer path so the email_log and suppression checks still apply.Notification settings:
caring_smart_nudgesandfederation_notifications_enabledUI toggles. Both were backend-supported but had no UI control — members had no way to opt out. Now exposed in/settings → Notifications.federation_notifications_enabledis a column (not part of the JSON), so the GET/PUT endpoints were extended to read/write it alongside the rest of the preferences.Members can opt INTO the activity digest from notification settings. New "Activity digest frequency" selector in
/settings → Notificationslets members pickoff(default) /instant/daily/weekly. Backed by a newGET /api/v2/notifications/settingsendpoint that returns the user'snotification_settings.globalrow plus per-group and per-thread overrides; the existingPOSTendpoint upserts. Critical user-facing events (direct messages, connection requests/accepts, volunteer-application status, volunteer-hours approval) are forced to'instant'regardless of this setting so disabling the digest never silences them.Inbound federation event ingestion wired up for review / connection / listing / community-event / member-updated. The federation webhook controller had been dispatching these 5 events for months with no listener registered, so the
event(...)calls were dropped on the floor (the controller's local DB persistence still ran, so no data was lost). Five new listeners:HandleFederatedReviewReceived— notifies the local reviewee with an in-app bell + email (anonymous "Someone left you a 5-star review" so we don't dox the partner-side reviewer name).HandleFederatedConnectionReceived— notifies the local user of an inbound partner connection request / accept.HandleFederatedListingReceived,HandleFederatedCommunityEventReceived,HandleFederatedMemberUpdated— observability-only structured audit logs; persistence is already complete in the controller, and these inbound bulk-content events don't map to a specific local user to notify. Future extension points kept in the listener for search-index sync.
In-app bell notifications for group chatroom messages.
GroupChatroomMessagePostedhad a Pusher broadcast for online members but no listener — members who weren't online missed the message entirely. NewNotifyGroupChatroomMessagelistener creates an in-app bell row for every active group member (excluding the sender and anyone who muted them), with a 5-minute dedup window so a burst of chat messages doesn't produce a wall of bell rows. No email — chat volume is too high to safely email every message; users who want email coverage of group chatter can opt into the daily digest.
Changed
- Daily activity digest is now OFF by default. Members reported the previous daily-email default felt like spam.
NotificationDispatcher::dispatch()andNotificationDispatcher::getFrequencySetting()andEventNotificationService::resolveFrequency()now fall back to'off'when the member has no row innotification_settings, replacing the previous'daily'fallback. The new opt-in selector in/settings → Notificationslets members turn it on if they want it. Six critical activity types still force'instant'regardless:new_message,connection_request,connection_accepted,vol_application_approved,vol_application_declined,vol_hours_approved.
Fixed
Five notification-preference enforcement bugs. An audit of every email-sending listener found preferences silently ignored in 5 paths:
NotificationDispatcher::sendReviewEmaildid not checkemail_reviews— local + federated review emails ignored the pref.NotificationDispatcher::sendReviewRequestEmailchecked the WRONG key (email_transactionsinstead ofemail_reviews).NotificationDispatcher::sendCreditEmail/sendCreditSentEmailhad no defence-in-depth pref check (only the caller did, so any future caller would bypass).HandleFederatedReviewReceiveddid not honourfederation_notifications_enabled(the per-user federation opt-out had no effect on federation review emails).CronJobRunner::processDigestsent the digest to every user with queued items regardless of theiremail_digestpreference. All five now respect the matching preference; the digest path also marks the queued rows assentwhen the pref is off so they don't pile up indefinitely.
CI guard against Laravel
Mail::facade. ExtendedEmailMailerRoutingTest::test_no_mail_facade_usage_anywhere_in_appto scan every file underapp/(excludingapp/Mail/Mailable definitions and comments) forMail::raw|Mail::to|Mail::send|Mail::queue|Mail::later|Mail::mailer. Any future regression that bypassesMailer::forCurrentTenant()fails CI instead of silently dropping production emails. The platform.envhas SendGrid configured and intentionally NO SMTP credentials — facade usage routes through Laravel's default SMTP mailer and silently drops the message.Structural defence:
SerializesModelsremoved from all 30 events. Closes the deserialization trap permanently: even if a queued job'sfinally { reset(); }is missed for any reason, no Eloquent re-fetch happens at job-pickup time, so a staleTenantContextcan no longer poison a model lookup. Event payloads are now snapshotted by PHP's default serializer with the full in-memory model state. Slightly larger queue payload for negligible savings before; effectively zero risk of the cross-tenant filter ever firing against a wrong-tenant id again.Newsletter template backfill: every tenant now has the starter newsletter templates. Re-engagement / nurture / onboarding starter templates were originally seeded for tenant 2 only via January-2026 migrations. Other tenants saw an empty admin "New newsletter" page and any code path looking up a template by category returned nothing. New idempotent migration copies every
category='starter'row from tenant 2 into every active tenant; per-tenant edits made later are preserved (skip on(tenant_id, name, category)collision).Critical: daily / weekly digest emails were silently dropped for every user across every tenant due to a TenantContext leak inside the cron loop.
CronJobRunner::processDigest()calledUser::findById($userId)BEFORE settingTenantContext::setById($user['tenant_id'])— so the EloquentHasTenantScopefilter applied aWHERE tenant_id = <previous-iteration's-tenant>clause and returned null for every user whose tenant didn't match the leaked context. Result: every user was logged as "Skipping User ID X (No email/Invalid)" — 31 skipped users on the most recent run, and 52 pending notifications stuck innotification_queuefor 7 weeks (oldest from 2026-03-30). Fixed by callingTenantContext::reset()at the start of each iteration sofindByIdruns with a clean baseline. The same pattern now also defendsrunSubTask()(resets before+after every cron sub-task) andforEachTenant()(resets between tenant iterations + final reset on exit) — closes the leak surface across the entire cron pipeline.Critical:
BalanceAlertService::checkAllBalances()andListingExpiryService::processAllTenants()were called statically every day but are instance methods. The cron threw "Non-static method ... cannot be called statically" 12 times per day for each (once per tenant), silently dropping every low-organisation-wallet alert email and every listing-expiry email across every tenant. Both now resolved viaapp(...)container resolution. Also no-op'd four other cron tasks that referenced services whose methods were removed in a refactor (JobVacancyService::expireOverdueJobs/expireFeaturedJobs,BrokerMessageVisibilityService::expireMonitoringBatch,AchievementCampaignService::processRecurringCampaigns) — these were spamming 12-48 "undefined method" errors per cron run; now print a single "skipped" line until the service replacements ship.Critical: four email-sending paths bypassed the platform
Mailerand silently failed in production. Production.envconfigures SendGrid (SENDGRID_API_KEYset, FROMnoreply@project-nexus.net) and does NOT configure SMTP (MAIL_USERNAMEandMAIL_PASSWORDare intentionally unset). Four call sites used Laravel'sMail::to(...)->send(...)orMail::raw(...)which routes throughconfig('mail.default')(=smtp) — sends were attempted via an unconfigured SMTP server and dropped silently. Rerouted through\App\Core\Mailer::forCurrentTenant()so they now use SendGrid like everything else: (1)SafeguardingService.php:630— critical safeguarding alerts were not being delivered; theSafeguardingCriticalMailMailable is now rendered to HTML and sent viaMailer. (2)AdminBillingController.php:249— billing-upgrade-request notifications to the platform owner. (3)GenerateMonthlyReports.php:127— regional analytics report-ready emails. (4)AdminEmailController::test()— admin "send test email" now usesMailer::forCurrentTenant()(matchingtestProvider()) so it respects per-tenant email_settings instead of only the platform.env.Critical follow-up: structural defence against the same trap firing during job deserialization. Per-listener
finally { reset(); }only fires afterhandle()runs — a job that throws during its payload's deserialization (e.g. whenSerializesModels::restoreModel()callsUser::findOrFail()with a staleTenantContextandModelNotFoundExceptionblows up) never reaches that finally, so the stale context persists into the next job. Registered globalQueue::before/Queue::after/Queue::failinghooks inAppServiceProvider::boot()that callTenantContext::reset()around every queued job.Queue::beforefires BEFORE deserialization, guaranteeing every job starts with a clean null context regardless of what the previous job did. Combined with the per-listener finally pattern this is true defence-in-depth. Also removedSerializesModelsfrom theUserRegisteredevent so itsUsermodel is snapshotted in-memory instead of re-fetched from the DB on dequeue — eliminates the deserialization round-trip for the most user-facing event (welcome / activation email).Critical: TenantContext static state leaked between Horizon queue jobs, silently dropping welcome emails, cron notifications, and federation pushes for all tenants except the first one processed by a worker. All 18 queued event listeners were missing
TenantContext::reset()in afinallyblock. Horizon runs up to 4 long-lived worker processes; without the reset, staticTenantContextstate from one job leaked into the next job the same worker picked up. When Laravel'sSerializesModelstrait re-fetched theUserEloquent model during job deserialization,TenantScopeapplied aWHERE tenant_id = <stale>clause — causingModelNotFoundExceptionto silently swallow the job and drop the email. Now all queued listeners callTenantContext::reset()infinally, matching the pattern already used byNotifyAdminOfNewRegistration. Affected listeners:SendWelcomeNotification,SendOnboardingCompletionEmail,NotifyJobAlertSubscribers,UpdateWalletBalance,UpdateFeedOnListingCreated,CopyMessageForBrokerReview, and all 12Push*ToFederated*federation listeners. Also fixedFederationInitialSyncJob(twosetByIdcalls, no reset) andRunAdminCronJob(delegates toCronJobRunnerwhich callssetByIdacross 7 code paths for multi-tenant cron processing).
Security
- Registration form: multi-field honeypot. Three additional decoy inputs with realistic names (
confirm_email,address_line_2,referral_code) sit alongside the existingwebsitehoneypot — all hidden via off-screen CSS positioning (catches more bots thandisplay:none). Server silent-no-ops if ANY of the four come back non-empty. React form also checks all four refs client-side. Catches sophisticated bots that filter on the legacywebsitefield name but can't tell which other inputs to skip. - Email verification is now required for every tenant (current and future) and can only be disabled by God (platform super-admin).
TenantSettingsService::requiresEmailVerification()now defaults to TRUE (fail-closed), reads the bareemail_verificationkey (plusgeneral.email_verificationfallback for legacy rows), and the login gate callsrequiresEmailVerification()consistently. A backfill migration (migrations/2026_05_16_enforce_email_verification_all_tenants.sql) writesemail_verification=trueto every existing tenant row and syncstenant_registration_policies.require_email_verify=1. New tenant seeding was corrected from the orphanedgeneral.email_verificationkey to the bareemail_verificationkey. Both the Admin Settings and Registration Policy pages now lock the toggle with a "God only" chip for non-platform-super-admins.AdminConfigController::updateSettings()andRegistrationPolicyController::updatePolicy()enforce this server-side with a 403 for non-platform-super-admins. - Admin approval and email verification toggles are now God-only in the admin UI. Both the "Require email verification" and "Admin approval required" switches on
/admin/settingsand/admin/settings/registration-policyare locked with a "God only" chip for tenant admins and tenant super-admins. The backend enforces this viarequirePlatformSuperAdmin()— only platform super-admins (rolegod/super_adminoris_super_admin=true) may change these settings. - Admin approval is now required for every tenant (current and future).
TenantSettingsService::requiresAdminApproval()now defaults to TRUE (fail-closed) so any tenant without an explicit setting still enforces the gate. A backfill migration (migrations/2026_05_16_enforce_admin_approval_all_tenants.sql) writesadmin_approval=trueto every existing tenant row so the policy is explicit in the database. Tenant seeding (TenantHierarchyService::seedTenantDefaults) now writes the bareadmin_approvalkey the reader actually checks — the previousgeneral.admin_approvalrow was orphaned (reader never looked it up), which meant new tenants silently ran with admin approval disabled. Already-approved members are unaffected; only new registrations (and accounts still instatus='pending') require an admin to approve them before login. - Registration form: per-tenant hourly circuit breaker (default 20/h). Last-line containment when everything else fails. If a single tenant gets a flood of signups in one hour, account creation is automatically paused for that tenant for an hour and the next signup attempt returns HTTP 503
REGISTRATION_TENANT_PAUSED. Auto-resumes after 1h; tenant admin can clear manually viaPOST /api/v2/admin/registration/resume-signups. Status visible atGET /api/v2/admin/registration/breaker. Worst-case outcome: a tenant loses 1 hour of legitimate signups — much better than waking up to 10,000 fake accounts. Configurable via envREGISTRATION_TENANT_HOURLY_CAP(set to 0 to disable). - Registration form: per-IP daily cap on successful signups (default 5/24h). Stacks on top of the existing 3/5min route throttle. The route throttle caps raw request volume; this caps how many accounts a single IP can actually create in a 24-hour window — closes the "patient bot grinding 1 signup every 6 minutes" hole that the short rate-window leaves wide open. Counter increments ONLY on successful registration, so a user typing wrong passwords doesn't burn quota. Configurable via env
REGISTRATION_DAILY_CAP_PER_IP(set to 0 to disable). Returns newREGISTRATION_DAILY_LIMIT(HTTP 429) with retry-after. - Registration form: MX-record check on the email domain. Rejects signups where the email domain has no MX record AND no A record — catches typos like
user@gmial.com, made-up domains bots fall back to, and freshly-registered burner domains without mail wiring. Returns newEMAIL_DOMAIN_INVALIDerror code with a "check for typos" hint that doubles as a UX win. Results cached 24h (positive) / 1h (negative); fails open on DNS errors so an outage doesn't block legitimate users. RFC-reserved.invalidTLD rejected without a DNS round-trip. - Registration form: disposable / throwaway email-domain blocklist. Rejects signups from ~200 known temp-email providers (mailinator, 10minutemail, guerrillamail, tempmail, yopmail, etc., plus their sub-domains). New
DisposableEmailServiceloads the curated list fromresources/security/disposable-email-domains.txt; refresh from the canonical upstream list viascripts/update-disposable-emails.sh. Returns the newEMAIL_DISPOSABLEerror code with a clear "use a permanent email address" message. Kills the cheapest bot-signup path — no inbox to pay for = no per-account cost. - Registration form: verified-location gate (anti-fraud). The location field on both forms is now hard-gated to require lat/lng coordinates that came back from the place-autocomplete API (Google Places or Nominatim). Free-text gibberish like "555" — the exact bypass a recent attacker used — is rejected server-side with a new
LOCATION_NOT_VERIFIEDerror code. Null Island (lat=0,lng=0) is also rejected as the obvious signature of a default-zero coordinate. The bar is now: an attacker must call a Geocoding API themselves to forge believable coordinates. Both forms show inline guidance to "pick a suggestion from the list". - Registration form: server-side enforcement closes three React-side-only bypasses. The React frontend has long sent
terms_accepted,password_confirmation, andinvite_codein the registration payload, butRegistrationService::register()ignored them — meaning a scripted submission could skip the terms checkbox, mismatch passwords, or register on aninvite_onlytenant with no code at all. All three are now enforced server-side with distinct error codes (TERMS_REQUIRED,PASSWORD_MISMATCH,INVITE_REQUIRED,INVITE_INVALID). Invite codes are validated againstInviteCodeServiceand redeemed atomically after the user row is created; if the redeem races out (concurrent registration consumed the last use), the new account is markedrejectedso the code stays the gating signal. - Min-form-time bot gate is now server-enforced. Both the React form and the new Blade form send a
form_started_attimestamp; the service silently no-ops (success-shaped response, like the honeypot) when the elapsed time is < 5 seconds. Previously the 5-second check only ran in the React UI and a scripted POST would skip it.
Fixed
Bulk user approval now sends welcome emails, in-app notifications, and grants welcome credits. Previously
bulkApprove()silently flippedis_approved=1with no further action — users approved in bulk received no welcome email, no in-app notification, and no welcome credits. Fixed: after each successfulupdateAdminFields()call the samegrantWelcomeCredits()/sendApprovalWelcomeEmail()/sendApprovalInAppNotification()helpers that single-userapprove()already calls are now called per user.grantWelcomeCredits()is idempotent, so double-approval is still safe.FederationInitialSyncJob: TenantContext always reset even when an exception is thrown. The previous fix added a bareTenantContext::reset()on the success path only, leaving the Horizon worker's static TenantContext stale if the audit-log write threw. Wrapped the entirehandle()body intry/finally { TenantContext::reset(); }so cleanup is guaranteed on every exit path.Mailer::forCurrentTenant()now logs a warning when called without a TenantContext. Calling this method with no active tenant context silently fell back to platform SMTP credentials, making cross-tenant email delivery failures invisible. ALog::warning()with a 5-frame backtrace is now emitted so these cases appear in the Laravel log.All 18 queued listeners now have
finally { TenantContext::reset(); }inhandle(). The previous listener audit commit was orphaned on a branch that diverged from main and was never merged — the fixes existed in git history but not on disk. Re-applied to all 18 listeners:CopyMessageForBrokerReview,NotifyJobAlertSubscribers,PushCommunityEventToFederatedPartners,PushConnectionAcceptedToFederatedPartner,PushFederationDataRetraction,PushGroupMembershipToFederatedPartners,PushGroupRetractionToFederatedPartners,PushGroupToFederatedPartners,PushListingToFederatedPartners,PushMemberProfileUpdateToFederatedPartners,PushMessageToFederatedPartner,PushReviewToFederatedPartner,PushTransactionToFederatedPartner,PushVolunteerOpportunityToFederatedPartners,SendOnboardingCompletionEmail,SendWelcomeNotification,UpdateFeedOnListingCreated,UpdateWalletBalance. Verified by grep: zeroShouldQueueclasses that callsetById()are now missing a reset.Module Configuration: browser autofill permanently blocked on the search input. Chrome was storing the user's previously-typed value (the admin email address) as "search history" for the
type="search"input and restoring it on every page load — including after the Refresh button, because the loading spinner was unmounting and remounting the input, triggering a fresh autofill injection cycle. Fixed by: (1) changing the input totype="text"so Chrome's search-history persistence doesn't apply, (2) settingautoComplete="new-password"which browsers actually honour (unlike"off"), and (3) rendering the loading spinner inline instead of replacing the whole page so the input is never unmounted.
Changed
- Accessible (GovUK Alpha) registration form: feature parity with the React form. Added profile-type radios (individual / organisation), conditional organisation-name field, conditional invite-code field (shown only when the tenant's effective registration policy is
invite_only), password-confirmation field with live-match indicator, mandatory terms-of-service + privacy-policy checkbox, and Google Places autocomplete on the location field (progressive enhancement — form works without JS). The newsletter checkbox is preserved. Newregister-enhancements.jshandles all client-side interactions; the existingpassword-strength.jscontinues to provide live NIST-aligned length + HIBP breach feedback. - GovUK Alpha
storeRegistercontroller maps six new service error codes to distinct Blade page statuses so users see specific messages ("you must accept the terms" / "this invite code is invalid") instead of the generic "check the form and try again" fallback. - React
RegisterPagenow sendsform_started_atto the server so the min-form-time gate enforces on this path too.
Removed
- Cloudflare Turnstile removed from login, password-reset, and registration forms (2026-05-16). Both the React SPA and the GovUK Alpha accessible Blade frontend. Member feedback found the widget too confusing and the false-positive rate unacceptable on account-recovery and sign-in flows. Turnstile is retained on contact forms where the cost of a small amount of user friction is acceptable as spam defence.
- Bot/brute-force defence on auth endpoints is now: the DB-backed per-email + per-IP brute-force limiter, route-level throttle (login 30/min, password-reset 5/15min, register 3/5min), the registration honeypot, the registration admin-approval gate, and the email-enumeration safety on the password-reset response.
- Removed
TurnstileServiceinjection fromAuthController,PasswordResetController, andRegistrationService. - Removed
useTurnstile()and widget JSX fromLoginPage,ForgotPasswordPage,RegisterPage(desktop + mobile mounts). - Removed
cf-turnstiledivs and api.js loader fromaccessible-frontend/views/login.blade.phpandregister.blade.php. - Dead
turnstile_tokenrequest types and deadregister-turnstile-failed/turnstile-failedBlade status branches dropped.
Fixed
- Cloudflare Turnstile rollout UX + silent-failure regressions (emergency). Same-day hotfix to today's Turnstile/bot-defence rollout. Two valuable members reported real problems: one found the visible "Verify you are human" widget confusing and suspicious, another could not get a password reset email no matter how many times he tried.
- Widget is now invisible for legitimate users. Switched the Turnstile widget to
appearance: 'interaction-only'(Cloudflare's silent-pass mode). The widget only renders visibly when Cloudflare actually decides a human challenge is needed — roughly 1% of legitimate sessions. The other 99% never see a widget at all. - Forgot-password no longer silently swallows errors. The page previously caught every error and showed a fake "we've sent you an email" success message — including when a Turnstile failure or rate limit blocked the request. It now distinguishes Turnstile failures, rate-limit hits, and generic errors with distinct messages so users know to retry.
- Per-email reset rate limit raised from 3/hr to 10/hr. Legitimate users hitting the 3/hr ceiling silently got "we sent you an email" with no email ever sent. The cap now matches realistic usage; bots are still blocked by per-IP throttle (5/min) + Turnstile. The endpoint now returns a real 429 instead of fake success.
- Single-use Turnstile tokens are reset on every failed submit across login, register, forgot-password, and contact pages. Previously a failed validation locked the form because the consumed token couldn't be re-used until full page reload.
- Backend uses a dedicated
TURNSTILE_FAILEDerror code (was wrongly reusingVALIDATION_REQUIRED_FIELD/VALIDATION_INVALID_FORMAT). All four API call sites updated. - Registration controller now passes specific error codes through so the React UI can show "this password appears in known breaches" vs "an account already exists" vs "the security check failed" — instead of a single catch-all message.
- GovUK Alpha Blade flows get the same treatment.
storeLoginandstoreRegisternow map API error codes to distinct page statuses (turnstile-failed,rate-limited,email-not-verified,account-suspended,register-duplicate,register-password-pwned, etc.) so the accessible frontend shows useful messages too. - Diagnostic logging added to the password-reset flow: per-email rate hits, unknown-email reset requests, and successful email dispatches are now logged with masked email + IP. Distinguishes "wrong email" from "mailer broken" when investigating future complaints.
- New optional
useTurnstile().status+useTurnstile().reset()for callers that need to react to widget load failures or reset after a failed submit.
- Widget is now invisible for legitimate users. Switched the Turnstile widget to
Added
Prerender engine — Round 4: tests + retry + sitemap explorer.
- 11 new tests covering the Round 2+3 logic: circuit breaker trip + claim-suppression, per-tenant concurrency cap, route validation rejecting shell metacharacters, audit secret-redaction, health check transitions, snapshot integrity (
ok/mismatch/missing), TTL-pattern specificity resolution, safeCachePath accepting route special characters, observer-storm coalescing to a tenant-wide row. Without these, one refactor breaks the safety net. - Job retry button. Failed / partial / cancelled jobs now have a "Retry" button on the Jobs tab that clones their parameters into a new queued row. Original job is preserved for history. New audit row links the two via
retried_from_job_id. - Sitemap explorer. New Overview card lets you punch in a tenant slug and see the exact route list the engine plans to render — static floor (feature/module gated) + dynamic URLs from
SitemapService(capped at 1,000). Answers "what does the engine think this tenant has?" without grepping logs. react-frontend/CLAUDE.mdupdated with the full Round 2+3+4 architecture so future contributors don't have to re-derive it from the code.
- 11 new tests covering the Round 2+3 logic: circuit breaker trip + claim-suppression, per-tenant concurrency cap, route validation rejecting shell metacharacters, audit secret-redaction, health check transitions, snapshot integrity (
Prerender engine — Round 3: defense in depth + operator superpowers.
- Scheduler liveness tracking. Every prerender scheduled task (
detect-drift,auto-recache,reap-stale) now stamps a cache key on success. The health endpoint checks the age of each stamp against 2×/3× the expected interval and surfaces a yellow/red check if the Laravel scheduler has stopped firing — catches the "supervisord nexus-scheduler died" failure mode that would otherwise be silent. - Webhook nonce one-time-use. HMAC
/invalidatealready had a 5-min timestamp window; now each(timestamp, signature)pair can only be used once. The nonce is keyed bysha256(ts:sig)and persisted for 600s. Replay attempts are bounced AND audited withoutcome=denied, reason=webhook_replayfor forensics. - Snapshot integrity verification. The Playwright worker now writes a
.sha256sidecar next to everyindex.htmlit renders. The Inspect drawer shows anintegrity: ok|missing|mismatch|unreadablechip — mismatch is highlighted in danger color and the tooltip shows the expected vs actual prefixes. Catches filesystem corruption, bit rot, and hand-edits that would otherwise look like a valid snapshot. - CSV export for the three operator-facing tables:
GET /api/v2/admin/prerender/export/{audit,inventory,jobs}.csv. Streamed, capped at 5,000 rows. "Export CSV" button on the History tab; the same URLs work for cron-scraped exports. - TTL inspector card on the Overview tab. Type a route, see which
config/prerender.phppattern owns it, what TTL it gets, and what other patterns also match (with their specificities). No more grepping config to understand the freshness policy.
- Scheduler liveness tracking. Every prerender scheduled task (
Prerender engine — Round 2: self-healing, audit, observability artefacts. Building on the P0/P1/P2 audit, the engine now self-recovers from worker outages and ships first-class ops artefacts.
- Per-tenant concurrency cap.
claimNextJobnow skips rows whose tenant already has a job in flight. Stops a single slow tenant homepage from starving the queue. - Circuit breaker. Five failed jobs inside a 10-minute window auto-pauses the queue for 15 minutes. Saves CPU on a wedged host and gives operators time to investigate. Closes automatically on cooldown; can be reset manually via
POST /api/v2/admin/prerender/reset-breakeror the new admin UI button. - Health endpoint.
GET /api/v2/admin/prerender/healthreturns a traffic-light JSON (green/yellow/red) with per-check details (cache filesystem, breaker, queue age, failure rate, stuck rows) and an actionableactionstring on every failing check. Rendered into a banner at the top of the admin module. - Emergency "Reset stuck queue" button in the health banner. Requeues every
claimed/runningrow older than 30 min AND clears the breaker — one click. Rate-limited (2/5min per user) and audited. - Audit log. New
prerender_audit_logtable persists every mutating action (enqueue, cancel, purge, invalidate, auto_recache, detect_drift, purge_unexpected, reset_breaker, reset_queue) with actor, IP, UA, outcome, sanitised details. New History tab in the admin UI surfaces it with an action filter. Secrets are scrubbed before persistence (password/token/secret/api_keykeys redacted). - Per-user per-action rate limiting on every mutating endpoint. Denied attempts are themselves audited so abuse leaves a trail.
- New Prometheus metrics:
nexus_prerender_breaker_tripped,nexus_prerender_breaker_until_seconds,nexus_prerender_queue_oldest_age_seconds,nexus_prerender_health_status(0/1/2 enum). - Grafana dashboard committed at
docs-public/observability/prerender-grafana-dashboard.json— health + breaker + coverage + queue age + outcomes + per-tenant missing-route bargauge. - Prometheus alerting rules committed at
docs-public/observability/prerender-alerts.yml— 7 alerts (4 critical, 3 warning) covering RED health, breaker, cache, queue jam, coverage, recent failures, asset invalidation. - Operator runbook at
docs-public/observability/prerender-runbook.md— alert-by-alert response steps + emergency procedures + forensics index. - Jobs tab gains a PRIORITY column showing HIGH/NORMAL/LOW with a tooltip explaining the numeric value. The lifecycle was already priority-aware (claim order is
priority ASC, queued_at ASC); now you can see it at a glance.
- Per-tenant concurrency cap.
Fixed
- Prerender engine — admin module audit, full P0→P2 sweep. Following the new admin panel's introduction, prerender jobs were piling up in
queuedstate forever and tenant admins reported all action buttons greyed out. Full audit + 13 fixes:- 🔴 P0 — Host cron for the job processor was never installed.
scripts/prerender-job-processor.shdocumented a* * * * *cron entry in its header but nothing in the repo actually wrote it to/etc/cron.d/. The in-container Laravel scheduler can runprerender:detect-driftandprerender:auto-recache, but the processor MUST run on the host because it callsdocker exec. Result: every job — observer-triggered, drift-triggered, TTL, manual — sat queued forever, and observer-deleted snapshots were never regenerated. New phase:scripts/deploy/phases/install-prerender-cron.shwrites/etc/cron.d/nexus-prerender-processoridempotently on every deploy. - 🔴 P0 — No stale-job reaper. If the worker was OOM-killed, a deploy SIGTERMed it mid-flight, or the host rebooted, the row stayed
claimed/runningforever, blocking dashboards and distorting metrics. Newprerender:reap-staleartisan command (also installed in the host cron, runs every 5 minutes) plus scheduler registration inbootstrap/app.php. - 🔴 P0 — Frontend "buttons greyed out" for tenant admins.
PrerenderAdmin.tsxgated buttons onis_super_admin || is_god || role==='super_admin'while the backend'srequireSuperAdminalso acceptedis_tenant_super_admin. A tenant super-admin saw disabled buttons but could have called the API directly via curl — worst of both worlds, AND a cross-tenant operation surface a tenant admin shouldn't reach. Fixed by tightening the controller torequirePlatformSuperAdminon every mutating endpoint (enqueue, purge, cancel, invalidate, auto-recache, detect-drift, purge-unexpected), hiding the sidebar entry from non-platform-super-admins, and adding an explicit read-only banner so anyone landing on the page understands why actions are disabled. Sign in as platform super-admin to drive the engine. - P1 — Race in
enqueueJobdedup. SELECT-then-INSERT outside a transaction let concurrent observer callbacks both insert. Wrapped inDB::transactionwithlockForUpdateso MariaDB serializes them. Routes now also validated against the canonical regex insideenqueueJob— defence in depth for the host shellevalconsumer. - P1 — HMAC replay protection on
/invalidate. Captured signatures were replayable indefinitely. Now requiresX-Nexus-Timestampheader within ±300 s and signs"<ts>.<body>". - P1 —
safeCachePathregex too narrow. Omitted: @ ~ ( ) + , ; = ! $ *so inspecting any snapshot whose route contained those characters silently 404'd the drawer. Widened to match the canonical route regex;..block +/index.htmlsuffix check preserved. - P1 — Observer storm backpressure. Bulk imports (e.g. seeding 5k blog posts) would enqueue 5k distinct queued rows because each post has a unique
routesvalue. Per-tenant burst counter in a 60s cache window — over 50 invalidations/min collapses subsequent enqueues onto a single tenant-wide row. - P2 — Overview tab double-fetched when realtime worked (Pusher reload + 30s poll). Poll now disabled when
live === true. - P2 — KPI grid layout was ragged on desktop (11 cards in
grid-cols-2 md:grid-cols-4). Rebreakpointedgrid-cols-2 sm:grid-cols-3 md:grid-cols-3 xl:grid-cols-4. - P2 —
inventory()unbounded scan. A misbehaving Playwright could write thousands of files into one host directory and hang the admin summary. Hard cap at 50k rows with a__truncatedsentinel surfaced to the UI. - P2 — URL state sync for the prerender admin tab + tenant filter. Refresh / back / forward now preserve view state (
?tab=coverage&tenant=hour-timebank). - P2 —
.bot-access.jsonllogrotate. Newinstall-prerender-logrotate.shdeploy phase writes/etc/logrotate.d/nexus-prerender-bot-access(daily, 14 days, compressed, copytruncate) so the bot-only access log doesn't grow unbounded.
- 🔴 P0 — Host cron for the job processor was never installed.
- Cross-tenant login bug —
app.project-nexus.ie/no longer silently boots into a stale tenant. Logging into one community and arriving on another is fully resolved.- Root cause.
TenantContexthad astoredSlugfallback that readnexus_tenant_slugfromlocalStoragewhenever a user had auth tokens. Onapp.project-nexus.ie/(the platform root, no slug in the URL), this silently booted the SPA into whichever tenant the user had last visited — e.g. Agoris. The login page then saw a "resolved" tenant slug and hid the community chooser, letting users authenticate against the wrong community. - Fix.
TenantContext.tsx— removed thestoredSlugfallback entirely. Effective tenant slug is nowtenantSlugprop (fromTenantShell, URL-derived) ORdetectTenantFromUrl()only. This matches the 2026-05-08 policy already documented inTenantShell.tsx: URL is respected as typed; master tenant renders at/, tenant-scoped pages require the slug in the URL. - Defence in depth.
AuthContext.logout()now clearsnexus_tenant_idandnexus_tenant_slugfromlocalStorage(previously preserved as a UX nicety, which contributed to the leak). Cross-tab logout already did this; the same-tab logout path now matches.
- Root cause.
Changed
- Sales site (
project-nexus.ie) — GA messaging and audit-driven fixes.- Hero badge updated from "V1.5 Now Open Source — AGPL-3.0" to "V1.5 Generally Available · Open Source · AGPL-3.0" so the public marketing site matches the actual v1.5 GA status promoted in CHANGELOG.md.
- Broken Documentation link fixed. The Get Started panel linked to
github.com/jasperfordesq-ai/nexus-v1/tree/main/docs(the repository's name at the time), which 404s — that path doesn't exist (the repo hasdocs-public/, notdocs/). Repointed to the repo README anchor (#readme) with a sublabel referencingdocs-public/. - WCAG claim softened. "WCAG 2.1 AA — full accessibility compliance" was an unsupported blanket claim. Now reads "built to WCAG 2.1 AA targets with ongoing audit."
- Prerender.io reference removed from the SEO feature card. The platform is fully self-hosted on Playwright-rendered snapshots; the old "Prerender.io fallback" wording was stale. New copy describes the actual three-layer freshness model (observer + sitemap-drift + TTL) and HTTP status propagation.
- Sitemap
lastmodbumped to 2026-05-14.
- README — v1.5 status promoted to Generally Available. The top-of-file blurb and the "Project Status" section both said "Release Candidate / in active production use while undergoing final pre-release validation." Updated both to "Generally Available, in active production use" with a pointer to the in-app
/featurespage and CHANGELOG for per-module maturity. Historical RC entries in CHANGELOG.md and thev1.5.0-rc.1release marker in.github/RELEASE_PROCESS.mdare left untouched (historical record). - Sales-site nginx — security headers hardened. Added
Content-Security-Policy(allowing only Google Fonts and Ahrefs analytics, which are the only third-party origins the page actually loads),Strict-Transport-Security(max-age=31536000; includeSubDomains; preload), andPermissions-Policy(deny accelerometer/camera/geo/gyro/mic/payment/usb). Dropped the now-deprecatedX-XSS-Protectionheader — modern browsers ignore it and CSP supersedes it. Headers repeated in the static-asset and HTMLlocationblocks because nginxadd_headeris replace-not-merge.
Fixed
- Admin panel — raw translation keys no longer leak. The Algorithm Settings and AI Settings pages (and 19 other admin pages, mostly in Caring Community) were rendering raw
t()keys likealgo.feed_label,advanced.provider_openai,admin.providers.titlebecause their translation keys were never added to the locale files.- Algorithm Settings and AI Settings — stripped
useTranslation/t()entirely and inlined literal English (per the admin-is-English-only convention). - 236 missing keys added to
en/admin.jsonunderadmin.*,panel.*,billing.*,tenant_features.*,federation.*,groups.*,moderation.*,resources.*,super.*, andvolunteering.*. Covers Care Providers, Loyalty Program, Warmth Pass, Hour Transfers, Municipality Feedback, Trust Tier, and the volunteer admin tooling. - All 10 non-English locale files filled with English fallbacks;
node scripts/check-i18n-drift.mjsnow passes with 0 drift. - All 2,552 admin-side
t()calls now resolve.
- Algorithm Settings and AI Settings — stripped
Changed
- Prerender engine — Round 5 (the full polish, "better than the big names"). Closes every remaining gap from both audits and adds three things no competitor ships.
- Three-layer freshness defence (the headline change). Stale public pages now have three independent mechanisms trying to keep them fresh:
- Observer hook (millisecond layer). Eloquent model observers for every public content type —
Post,Listing,Event,JobVacancy,Group,MarketplaceListing,MarketplaceCategory,VolOpportunity,IdeationChallenge,Page(CMS),ResourceItem. On save/delete, the affected snapshot is deleted and a NORMAL-priority recache enqueued. Failures are logged, never thrown. - Sitemap drift detector (minute layer). New
prerender:detect-driftcron walks every tenant's sitemap, parses<lastmod>, compares against snapshot mtimes, enqueues HIGH-priority recaches for any drift. Catches code paths that bypass Eloquent (raw DB writes, migrations, queue jobs). 2-minute cadence; bounded fan-out. - TTL auto-recache (hour/day floor). Existing Phase 2 cron, still the backstop for content that doesn't appear in either sitemap or model events.
- Observer hook (millisecond layer). Eloquent model observers for every public content type —
- External invalidation webhook.
POST /api/v2/admin/prerender/invalidatewith Bearer token or HMAC signature. Lets headless CMS, marketing automation, or external integrations invalidate routes directly. SetsPRERENDER_WEBHOOK_TOKENenv var to enable. - AI-friendly Markdown rendering. Worker now extracts a clean Markdown body (
index.md) alongside the HTML snapshot. nginx detects AI crawlers (GPTBot, ClaudeBot, Perplexity, ByteSpider, Common Crawl, Google-Extended, Amazonbot, etc.) and serves the.mdvariant first viatry_files. Falls back to HTML if markdown isn't available. No competitor (Prerender.io, Netlify, Cloudflare Pages) ships this — DataJelly was the only player doing it. - Admin UI overhaul — six tabs, full polish.
- Overview — new Freshness automation card (one-click auto-recache + drift detect with dry-run / apply); new Wildcard cache purge form with pattern, tenant scope, dry-run, and auto-recache toggle.
- Inventory — adds HTTP status column, search/filter, status-code filter, bulk selection checkboxes, and bulk-recache button (groups selections by tenant, dispatches via the invalidate API).
- Inspect drawer — front-and-centre SEO score card (0-100 + A–F grade) with must-fix issues list and tips list. HTTP status chip. Reflects the new
seofield on the inspect response. - Coverage — new "Refresh all stale (N)" bulk button that enqueues per-tenant recaches for everything missing / stale / asset-broken in one click.
- Analytics — new tab. Bot traffic over 1d / 7d / 30d windows: KPIs (total hits, IP-verified %, spoofed count, unique URIs), hits-by-crawler + hits-by-status breakdowns, top-50 URIs table, recent-activity feed.
- Tests. New
PrerenderServiceTestcases forpurgePattern(glob single segment,**recursive, host scoping, actual deletion),ttlForRoute(specificity + default fallback),seoScore(high / low grade scenarios),_statussidecar reading, priority promotion on duplicate enqueue, priority-ordered claim, and the JSONL crawler analytics aggregator. - Docs.
react-frontend/CLAUDE.md"Prerender Pipeline" section rewritten to describe the three-layer freshness model, priority lanes, status-code propagation, AI Markdown variant, and the six admin tabs.
- Three-layer freshness defence (the headline change). Stale public pages now have three independent mechanisms trying to keep them fresh:
- Prerender engine — Phase 4 (hardening).
- Crawler IP-range verification. New
scripts/refresh-bot-ip-ranges.shpulls Google/Bing/DuckDuckGo/Apple's published IP-range JSON feeds and ships them into the nginx container as ageoinclude.$nexus_bot_ip_verifiedis logged on every bot hit; analytics surfaceverified_hitsandspoofed_by_crawlerso admins can spot User-Agent spoofing without blocking (alternative crawlers / IPv6 transitions cause false positives if you block on verification alone). Designed for a weekly cron. - Bot User-Agent refresh helper.
scripts/refresh-bot-ua-list.shdiffs Matomo's actively-maintained bot regex list against the names we already cover and writes candidates tologs/bot-ua-suggestions.txtfor human review. Stops the curated regex innginx.bluegreen.conffrom drifting into obsolescence. - Viewport variant flag. Worker honours
PRERENDER_VIEWPORT=mobile(414×896 + iPhone Safari UA) for tenants/routes that need a mobile-specific snapshot. nginx routing for the variant is a deferred follow-up — current platform is single-DOM responsive so the desktop snapshot serves both audiences correctly.
- Crawler IP-range verification. New
- Prerender engine — Phase 3 (visibility & SEO scoring).
- SEO score per snapshot (0–100, A–F grade). Synthesised from existing
inspect()flags — title length, meta description length, canonical, OG completeness, h1 count, JSON-LD validity, asset issues, noscript fallback, body text volume. Surfaced asseoon the inspect API response withissues(must-fix) andtips(suggestions) arrays. - Crawler analytics. nginx now writes a bot-only JSONL access log (
$status, prerender override status, crawler label, verified flag, UA, IP, referer, bytes, request time) to the shared prerender volume.GET /api/v2/admin/prerender/analytics?since=ISO&limit=200aggregates hits by status, crawler, host, top URIs, recent rows. Default window: 7 days. - Manual auto-recache trigger.
POST /api/v2/admin/prerender/auto-recache { apply: bool }runs one immediate pass of the freshness loop (dry-run by default) for operators who don't want to wait for the cron tick. - Inventory/Coverage filter, bulk recache from Coverage tab, admin UI polish: backend supports
?tenant=filtering on inventory + the new analytics endpoint; frontendPrerenderAdmin.tsxpolish deferred to a focused UI change.
- SEO score per snapshot (0–100, A–F grade). Synthesised from existing
- Prerender engine — Phase 2 (freshness automation). Snapshots now refresh themselves; deploy-time renders are no longer the only freshness mechanism.
- TTL rules per route pattern. New
config/prerender.phpmaps route globs to max snapshot ages (homepage 6h, content index 6–24h, individual items 1–7d, static pages 30d).PrerenderService::ttlForRoute()resolves the most-specific pattern. - Auto-recache cron. New
prerender:auto-recacheartisan command walks the deep inventory, identifies TTL-expired and content-drifted snapshots, and enqueues low-priority recache jobs grouped by tenant. Bounded bymax_tenants_per_run/max_routes_per_tenantso a single tick can't flood the queue. Designed for a 15–30 min cron cadence. - Content-change hooks. Model observers (
Post,Listing,Event) now invalidate the affected snapshots (/blog,/blog/{slug},/listings,/listings/{id},/events,/events/{id}) on save/delete and auto-enqueue a low-priority recache. Failures are logged, never thrown — model writes never block on the prerender side-channel. The basePrerenderInvalidationObservermakes it a few lines to wire up additional content types. window.prerenderReadysignal. Worker now waits forwindow.prerenderReady === truebefore snapshotting; falls back to the DOM-content heuristic when the signal is never set.initPrerenderReady()inmain.tsxensures the variable always exists;usePrerenderReady(isLoaded)is a one-line hook for data-driven routes to control snapshot timing.
- TTL rules per route pattern. New
- Prerender engine — Phase 1 (coverage & correctness). Lifts the engine from "render hardcoded routes on deploy" to "render every public URL Google can discover, with correct HTTP status codes." Addresses the highest-impact gaps from both prerender audits.
- Sitemap-driven URL discovery. New
prerender:plan-routesartisan command unions the static-page floor (/,/about, …) with every URLSitemapServicepublishes — blog posts, listings, events, jobs, KB articles, marketplace listings/categories, CMS pages, organisations, ideation challenges.scripts/prerender-tenants.shconsumes the per-tenant plan; the hardcodedPUBLIC_ROUTESlist remains as a fallback when the PHP container is unavailable.--no-sitemapflag andNEXUS_PRERENDER_NO_SITEMAP=1env var disable it for emergencies. Closes the long-tail coverage gap flagged by both audits. - HTTP status code propagation. Worker now extracts
<meta name="prerender-status-code">from rendered DOM and writes a_statussidecar next toindex.html. Bash aggregates non-200 routes into/etc/nginx/prerender-status-overrides.list; nginx uses amap+error_page/returnflow to serve 404/410/503 with the prerendered body. Soft-404s on community-not-found, deleted listings, and maintenance mode now emit the right status to crawlers. Validated withnginx -tbefore reload; reverts atomically if the new map is malformed. Inspect API and Inventory rows now exposehttp_status. - Job priority lane. New
priority TINYINTcolumn onprerender_jobs(3 = high, 5 = normal, 7 = low). Claim ordering is(priority, queued_at, id)so auto-recache jobs can't starve urgent user-initiated runs. Enqueue API accepts an optionalpriorityfield; duplicate enqueues at a higher priority promote the existing queued row. - Wildcard cache purge.
POST /api/v2/admin/prerender/purge { pattern: "/blog/*" }removes matching snapshots (and_statussidecars). Supports*(single segment),**(recursive),?(single char), optionaltenant_slugscoping,dry_run, and an optionalrecacheflag that auto-enqueues a low-priority re-render. - Dashboard summary now truthful.
summary()was reportingcontent_stale_countandasset_invalid_countfrom a shallow inventory pass (deep=false), so the overview tab silently under-reported drift. Now uses the deep inventory under a 60-second cache.
- Sitemap-driven URL discovery. New
- Partner Communities moved to the left column of the "More" mega menu, sitting directly under the Tools section. Previously placed beneath Impact in the right column, the federation submenu is now more discoverable to reflect its importance.
Added
- In-app
/changelogpage rendering this file viareact-markdown. The markdown source is copied from the repo root intoreact-frontend/public/changelog.mdat prebuild/predev time byscripts/copy-changelog.mjs, so the in-app changelog is always in sync with the file in git. Footer Changelog link is now internal. Featureslink in the public Navbar and Mobile drawer (About section, alongside About / Blog / FAQ).nav.featuresandnav_desc.featurestranslation keys in all 11 languages.
Removed
- Dead
dev_banner.*anddev_status.*translation keys swept from all 11 locale files (22 key blocks total). All code references were already gone when the platform moved to GA. - "Dev Notice" amber button in the MobileDrawer bottom bar — redundant post-GA; Features is now reachable via the About accordion.
FlaskConicalicon import removed.
Fixed
Trust & Safety "Garda vetting" section made jurisdiction-neutral. This is a multi-tenant global platform; the Ireland-specific "Garda vetting" wording was inappropriate for tenants outside Ireland. Section retitled to "Background checks and vetting" and the body rewritten to cover background checks generally, mentioning Garda vetting (Ireland) and DBS (UK) as examples rather than the canonical regime. Applied across all 11 locale files.
🔴 Trust & Safety "Insurance and liability" section rewritten to match the actual platform-provider position in the Terms. Aligns the Trust & Safety page wording with the corrected Terms of Service Section 13 (see
database/migrations/2026_04_15_000002_fix_terms_insurance_section.php, 2026-04-15): the organisation is a connection platform, not a service provider; members exchange services entirely at their own risk; members are solely responsible for ensuring they hold appropriate cover for any activities they undertake. Updatedtrust_safety.insurance_itemsacross all 11 locale files and added a pointer to the Terms for the full liability and indemnity language.{{name}}literal placeholder rendered on the public Trust & Safety page.TrustSafetyPage.tsxwas callingt(section.introKey)andt(\${section.itemsKey}.${i}`)without the{ name: branding.name }interpolation context, so strings like"By using {{name}} you agree to:"rendered with the raw{{name}}` placeholder visible. Both intros and list items now pass the tenant brand name. Title interpolation also added defensively.Build commit hash visible in the public footer. The footer was rendering
__BUILD_COMMIT__as a monospace string at the bottom of every page (and bleeding into Google snippets). The commit + build time are now exposed only asdata-*attributes on a hidden element so the same diagnostics remain available via DOM inspection / Sentry tags without being part of the indexable page text.Blog post dates rendered with locale-dependent and ambiguous formatting.
BlogPage.tsxwas using baretoLocaleDateString()(no locale), so the same post showed as12/9/2025to some visitors and9/12/2025to others — unreadable for an Irish/UK audience.BlogPostPage.tsxwas usingtoLocaleDateString(undefined, …)with the same issue. Both now pin toen-GB(12 September 2025).American "neighbors" in public marketing copy. Standardised to
neighboursin the Stay Local landing card (public.json+CoreValuesSection.tsxfallback) and the "Local Hubs" mega-menu description inNavigationConfig.php, for consistency with the rest of the Irish/UK English copy.UnexpectedValueException: chmod(): Operation not permittedon every request that triggers a Laravel log write (Sentry NEXUS-PHP-7). Thedailylog channel inconfig/logging.phpset'permission' => 0664, which made Monolog callchmod()on the file on every write. When the existing day's log file is owned by a different user — e.g. left behind on a mounted volume from a prior container run — thechmod()fails and bubbles up as a 500. Removed the explicit permission so Monolog skips the chmod step entirely; new files are created with the default0644and existing files are left untouched.CHANGELOG.md cleaned up. Removed a block of fabricated legacy entries (a fake
[2.0.0] - 2024-02-13, a duplicate[1.5.0] - 2024-02-12, and[1.4.0]through[1.0.0]with 2023–2024 dates) that were left over from a template — Project NEXUS development only began in mid-December 2025, so none of those releases ever existed. Also removed an incorrect "Hour Timebank (Crewkerne)" attribution (Crewkerne is an unrelated UK timebank) and the changelog's own contributors list, which conflicted with the canonical CONTRIBUTORS.md. Footer compare links pruned to the versions that actually exist (v1.5.0,v1.5.0-rc.1).
Added
Goals module: accountability and check-ins enhanced. New
GoalInsightsPanelcomponent surfaces trend analysis, streak tracking, milestone progress, and next-step recommendations on the goal detail page.GoalCheckinModalextended with cadence-aware prompts and partner-accountability nudges. Backend:GoalCheckinServiceandGoalProgressServicerewritten to compute velocity, predict completion, and surface at-risk goals; newgoal_insightsandgoal_accountability_partnerscolumns added via migration. NewGET /api/v2/goals/{id}/insightsendpoint. Unit tests cover the insights panel.Security:
users:purge-undeliverableartisan command. Retroactive cleanup for accounts registered with undeliverable email addresses (e.g.testing@example.comaccounts created during the May 2026 cyber-attack). Re-runs the sameDisposableEmailService+MxRecordValidatorvalidators the registration form uses, restricted toemail_verified_at IS NULLnon-admin users. Defaults to--dry-run;--softsetsdeleted_at,--hardissues a real DELETE. Scoped with--since=90days,--tenant=N,--limit=200.Registration: reserved-domain MX gap closed.
MxRecordValidatorpreviously only rejected.invalidTLD (RFC 6761) but passedexample.com,example.net,example.org,*.test,*.example,*.localhost— all have real DNS records but are guaranteed undeliverable (RFC 2606/6761). Now rejects the full reserved-domain and reserved-TLD lists before any DNS round-trip.SocialInteractionPanelshared component. Likes, comments, shares, reactions, and poll voting are now handled by a singleSocialInteractionPanelcomponent used across the Feed, Blog, Events, Goal detail, and Group Discussion tab — replacing five separate per-page implementations. Backed by a newPOST /api/v2/social/interactionsendpoint. Tests cover the panel in all five contexts.Per-category From addresses on platform SendGrid. When the platform
SENDGRID_API_KEYdriver is active, outgoing emails now use purpose-specific From addresses onproject-nexus.netinstead of the single generic address:notifications@(member alerts),newsletters@(digests),messages@(DMs, cross-community invites),noreply@(password reset, verification, security),admin@(moderation, ban, vetting),events@(event reminders),safeguarding@(staff alerts),billing@(payments, marketplace, subscriptions). Mapping is derived from the existingEmailDispatchServiceaudit category strings — no call-site changes needed. A default Reply-To of the platform owner address is attached to all platform SendGrid emails when the caller supplies none. Tenant-specific SMTP/Gmail/SendGrid accounts are unaffected.Password change email notifications. Users now receive a security notification at their current email address whenever their password is changed — whether self-initiated or by an admin. Wired through
Mailer::forCurrentTenant()so it honours tenant email settings, suppression lists, and theemail_logaudit trail.Admin email deliverability dashboard polished. The
/admin/email-deliverabilitydashboard (introduced in the email observability rollout) received a focused UX pass: clearer metric card labels, a time-range selector that persists in URL state, improved empty-state messaging, and a per-recipient search that highlights suppressed addresses. No new API endpoints — data comes from the existingemail_logandemail_suppressionendpoints.
Fixed
Email delivery reliability — exhaustive multi-pass audit. Following the email observability rollout, a systematic audit of every email-sending path across the codebase identified and fixed ~80 reliability gaps spanning four themes:
- Tenant context leaks: ~20 service classes and listeners were not resetting or explicitly binding
TenantContextbefore dispatch, so cron / queue jobs sent emails in the wrong tenant's locale or from the wrong sender. Affected paths: newsletter cron batches, federation notification listeners, Verein webhook handler, volunteering reminders, marketplace dispute handler, async notification queue. - Missing send evidence guards: ~15 paths updated a state machine (registration token, activation token, billing reminder sent-at, digest status) before confirming the email was actually delivered, so a transient failure left the record in an "already sent" state with no email sent. Tokens are now only updated / marked as sent after
Mailer::send()returns a provider message id. - Deduplication gaps: duplicate event reminder bell rows, duplicate federated connection notifications, duplicate review notifications, duplicate event cancellation recipient lookups, and re-delivered newsletter queue rows. Each fixed with
Cache::add()idempotency guards, unique index constraints, or atomic claiming. - Broken delivery paths: federated message / connection / transaction delivery was silently no-op'd (missing tenant resolution on inbound payloads). Event reminders were blocked by a stale
status=failedguard that prevented retries. Marketplace dispute notifications used the wrong tenant scope. Stripe webhook handler did not restore tenant context after processing. All repaired.
- Tenant context leaks: ~20 service classes and listeners were not resetting or explicitly binding
All member-facing email links are now tenant- and domain-aware. Nine files were using
config('app.frontend_url')orgetFrontendUrl(path)with a silent path-discard bug, producing links that always pointed to the shared platform host or to the tenant homepage rather than the specific resource:AdminUsersController— admin-initiated password reset was also broken at the token layer: the controller was storing tokens hashed withbcryptbutPasswordResetControllervalidates withhash('sha256'). Fixed both the hash algorithm and addedtenant_idto the INSERT/DELETE so the token passes the ownership check.JobAlertEmailServiceandJobExpiryNotificationService—getFrontendUrl(path)silently discarded the path argument (method signature takes no params). Every job link pointed to the tenant homepage.GuardianConsentService— produced double-slug URLs likeapp.project-nexus.ie/hour-timebank/hour-timebank/...for path-based tenants.NewsletterService— unsubscribe and manage-preferences links always used the platform host.AppreciationReceived,VereinCrossInvitationReceived,CivicDigestMail— sameconfig()pattern.SafeguardingService— admin alert linked toapp.project-nexus.ie/admin/...even for custom-domain tenants. All nine now useTenantContext::getFrontendUrl() . TenantContext::getSlugPrefix().
Browser geolocation obliterated — all proximity uses profile location.
useProximityanduseGeolocationhooks (both callednavigator.geolocation) removed. No browser geolocation popup appears anywhere on the platform. TheProximityFiltershared component (Listings, Events, Volunteering, Marketplace map) now reads lat/lng from the authenticated user's profile viauseAuth(), matching the pattern already used by the Members page.Listings location locked to user profile with automatic sync. Both listing creation forms (feed compose + full create/edit page) now show a disabled, pre-populated location field sourced from the user's profile. Users can no longer enter a custom listing location — the coordinates always reflect their profile.
UserService::updateProfile()now propagates lat/lng changes to all non-deleted listings owned by the user. A backfill migration fills missing coordinates on existing listings from the owning user's profile.Proximity filter dropdown replaces pill buttons. The shared
ProximityFiltercomponent now offers a dropdown of 5 / 10 / 25 / 50 / 100 km radii (up from the 1 / 2 / 5 / 10 km pills), consistent with the Members page.Explore: trending posts sorted by velocity-weighted score (not raw engagement count). The
trendingScore(velocity × 60% + volume × 40%) was already computed but then discarded — two sequentialusort()calls left the final order sorted by raw engagement, so the same high-engagement posts always appeared regardless of recency. Replaced with a single sort ontrendingScoreso recently-active posts surface correctly.Explore: trending posts and popular listings windows widened from 90 → 365 days. The tight 90-day window returned near-empty results for early-stage communities. 365 days is appropriate because engagement-weighted ordering already surfaces the best content without an artificial hard cutoff.
Explore: popular listings — collation crash fixed. A
utf8mb4_unicode_civsutf8mb4_general_cimismatch betweenlistings.titleandcategories.namecausedMariaDB ERROR 1267on the title/category filter; thetry/catchswallowed it silently, returning an empty list. Fixed by normalising the category name tounicode_civiaCONVERT.Docker: storage/logs permission denied on cross-user appends fixed (Sentry NEXUS-PHP-5, -6, -16). When
artisancommands ran asroot(image CMD,docker exec) beforeapache(www-data) did its first write,rootcreated the day's log file with0644owner root — subsequent www-data writes threwUnexpectedValueException: Permission denied. Fixed inDockerfile.bluegreenandDockerfile.prod: container boot now setsumask 0002, appliessetgidon all storage directories so new files inherit groupwww-datawith group-write, and re-chowns afterartisan optimizeas defence-in-depth.Boot:
URL::forceScheme('https')deferred to avoid null Request in console (NEXUS-PHP-17). CallingURL::forceSchemeeagerly inAppServiceProvider::boot()resolved theurlcontainer binding immediately, which injected a null$requestin non-HTTP contexts (queue workers, scheduler, artisan), throwingTypeErroron every cron tick. Fixed with aresolving('url', ...)callback soforceSchemeonly runs when the url service is actually constructed inside an HTTP request.UI: transparent modals and popovers replaced with opaque surface token. 20 components including dropdowns, hover cards, command palettes, and sheet panels were using
--glass-bg(rgba(255,255,255,0.05)) as their background — nearly invisible in dark mode, allowing background content to bleed through. All switched to--surface-dropdown(#16162adark /#fffffflight), the correct opaque surface token for floating UI.UserHoverCardalso has an!importantoverride to win against HeroUI defaults.Listings: comments open inline on detail page. Previously, the comments section on a listing detail page required a separate tap/click to expand. Comments now render immediately below the listing content, consistent with blog posts and events.
Feed: disable sharing for content you own. The share button on feed posts, listings, events, and blog posts is now hidden when the current user is the author — sharing your own content to your own feed was a no-op that cluttered the share count.
Social: comment parity gaps closed. Several content types (Goals, Marketplace listings, Volunteer opportunities) were missing comment threading, nested replies, or the delete-own-comment permission. Brought to parity with Feed posts.
Tenant: five correctness + one polish fix. (1)
PrerenderPlanRoutesnow excludes master tenant (id=1) from the parent-domain map so a misconfigured platform root can never pollute child routing. (2)prerender-tenants.shre-validatesFILTER_TENANTinsideget_tenants()at the SQL use site. (3)moveTenant()fails safely on NULL path fields instead of using a fallback that could allow circular hierarchy moves. (4)TenantContext::getReservedPaths()adds'platform'to sync with the TypeScriptRESERVED_PATHSset. (5)SitemapController::tenant()replaces two sequential DB queries with a single JOIN. (6)tenant-routing.tsdocuments that slugs are lowercased at the DB layer.Deploy: post-deploy smoke tests hardened. WebAuthn / passkey smoke check now passes a real tenant slug (rather than hitting the platform root), matching how passkeys are actually challenged in production. Passkey smoke checks are also allowed during maintenance-mode windows so blue-green health checks don't fail because the platform is in maintenance. Local development health checks allowed through the post-deploy gate.
Goals: modal polish and history label fixes. Check-in modal updated with clearer cadence copy. History panel label keys corrected (were displaying raw translation keys). Floating modal positioning fixed on small screens.
Frontend: remaining TypeScript type errors and stale query patterns closed. Type-only pass over React pages — no behaviour changes.
Changed
Activity digest default changed from weekly to monthly. Feedback from early members found weekly digests too frequent for communities with moderate activity. Monthly is now the opt-in default for new users; existing preferences are unchanged. Critical instant-category events (DMs, connection requests, application status) are unaffected.
Admin sidebar: navigation refined. Email Deliverability moved into the Settings group. Registration Security moved adjacent to Registration Policy. Prerender Engine entry restricted to platform super-admins only (was visible to tenant super-admins, who couldn't operate it). Ordering of secondary items tidied.
SEO: detail page metadata enriched. Listing, event, job, blog post, and marketplace listing detail pages now emit
og:updated_time,article:modified_time, anddateModifiedin JSON-LD. Listing and event pages also emitgeo.placenamewhen coordinates are present.SEO: account and settings pages marked noindex.
/settings/*,/wallet/*,/notifications,/profile/edit, and similar authenticated-only pages now emit<meta name="robots" content="noindex">. Prevents personal account pages from appearing in search results.SEO: public SEO route coverage improved. Organisation profiles, ideation challenges, and caring-community hubs added to the prerender route plan and sitemap.
SEO: crawl metadata coverage improved.
<link rel="canonical">andog:urlnow use the tenant's canonical domain (custom domain or slug-prefixed) rather than alwaysapp.project-nexus.ie. Duplicate-content risk reduced for tenants on custom domains.i18n: complete frontend translation fallback audit. All 52 React locale namespaces verified across all 11 languages (
en,ga,de,fr,it,pt,es,nl,pl,ja,ar). ~400 keys that existed only inen/were filled with English fallbacks in all other language files.node scripts/check-i18n-drift.mjsnow reports zero drift. This was the source of several recurring CI failures.Admin: icon-only buttons given accessible labels. Toolbar icon buttons in the admin panel that had no visible text now carry
aria-labelattributes. Screen-reader and keyboard users can identify all admin actions.Newsletter heatmap contrast improved. Day/time engagement heatmap in the newsletter admin now uses a higher-contrast colour ramp for the top quartile, making peak send-time cells distinguishable in both light and dark mode.