1.5.6
Released 2026-07-14
Changed
Translation cleanup now closes the mobile and contextual admin-help ratchets. All 14,904 missing mobile locale keys are filled across the six maintained non-English mobile locales, with unambiguous web translations reused before machine translation and a new CI check enforcing mobile interpolation-variable parity. The 653 English-only contextual admin-help entries now load from a dedicated
admin_helpnamespace across all ten web locales instead of a bundled TypeScript registry, while preserving lazy loading and route structure. Translation files pass exact key and interpolation parity; these machine-assisted translations remain explicitly queued for native-speaker review.Maintained React translations now have zero actionable fallback gaps across all ten non-English locales. The remaining 1,991 English fallbacks were machine-translated with key and interpolation parity preserved, while the gap audit now distinguishes explicit language-invariant protocol, product, currency, and keyboard labels from untranslated prose and can print exact residual keys for review.
The primary React frontend now reports CSP violations instead of silently enforcing its static policy. Every production and fallback nginx SPA response advertises the bounded API reporting endpoint through both
report-uriandreport-to/Reporting-Endpoints; regression coverage requires the canonical policy and reporting header to remain paired across all maintained React vhosts.Voice-message erasure now preserves both migration compatibility and private-storage locking guarantees. GDPR deletion safely resolves historical tenant-scoped public recording pointers as well as current private media, retains failed-delete pointers for retry, and the MariaDB concurrency regression exercises the private storage contract so a send cannot commit after account erasure. The production migration also recognizes the original absolute attachment paths and tenant-slug voice paths, moving those files into tenant-private storage instead of leaving historical raw media in the web root.
The platform audit now enforces private sensitive-media, native-release, and infrastructure boundaries. Insurance is metadata-only (type, provider, expiry and reminders) across React, API, administration, and the accessible frontend; raw documents and sensitive policy fields are rejected, safe-column reads have regression coverage, and existing stored values/files are purged by migration. Direct-message attachments and voice recordings move outside the web root and are delivered only through tenant- and participant-authorized, no-store endpoints, including authenticated React/mobile playback and erasure coverage. Android now generates a certificate-pinned, cleartext-disabled network policy, restricts deep links and authenticated downloads, binds OTA updates to versioned staging/production channels, and has an authoritative native release gate. Horizon receives a five-queue, 2 GiB, hourly-recycling baseline; legacy raw-SQL migrations require a pre-mutation snapshot; the prerender cron installer validates intervals with a blocking runtime test; and dependency update coverage spans Composer, npm, Actions, and Docker.
Release and abuse controls now cover every maintained delivery surface. The API has tenant/actor-scoped minute and sustained global envelopes, trusted browser devices use Secure HttpOnly cookies while native clients retain an explicit stateless path, CSP violations report to a bounded sanitized endpoint, and federated OIDC sessions may satisfy local MFA only with explicit validated
amrevidence or an allow-listed ACR. CI adds blocking Android and accessible-frontend jobs plus one aggregate Release Gate, while the insurance workflow and its safeguards are translated across all maintained locales.Authentication sessions now use one short, server-enforced lifetime and revocation policy across maintained clients and sign-in methods. Password, TOTP, passkey, social OAuth/SSO, React, and the accessible frontend now issue 15-minute access JWTs plus 30-day absolute refresh-token families regardless of caller-controlled platform headers. Refresh credentials rotate on every use and persist only hashed identifiers; unique token/direct-successor constraints plus a composite user/tenant foreign key prevent branching and cross-tenant rows. A duplicate within five seconds is tolerated only when its active direct successor exists and receives a credential-free conflict, while older, branching, revoked, or expired replay revokes the family. Refresh and credential issuance serialize with logout-all and user-locked password change/reset/admin mutations, which roll back if session revocation fails; API and accessible account-deletion requests revoke every session before confirmation. Pending password/TOTP and passkey ceremonies carry their authentication start and atomically consume one challenge before issuance. Social OAuth applies tenant admission/orchestration and signed tenant state, OAuth/SSO callbacks recheck account gates under the shared issuance lock and fail closed for untrusted local TOTP, and OIDC discovery/token/JWKS requests use pinned public-IP, no-redirect clients. Tenant OIDC domain authorization, email linking, and auto-provisioning now require a signed literal-boolean
email_verifiedassertion; existing tenant-bound subjects remain usable without a domain gate, but unverified email metadata cannot rebind them. Every social login/register/link and tenant OIDC SSO start also creates a per-tab high-entropy verifier retained only insessionStorage; only its SHA-256 challenge enters signed/cache state, callback codes bind that challenge, and final exchange verifies the verifier atomically under the callback-code lock before returning credentials. A wrong or missing verifier cannot consume the valid code, closing login-CSRF/session swapping, and maintained OAuth/SSO frontend requests use the configuredAPI_BASE. The accessible frontend rotates through Secure-in-production HttpOnly cookies; React holds a Web Lock for the full refresh request, rechecks token generation and tenant context after waiting, and aborts refresh on timeout or tenant change. Personal-access bearer sessions, pre-v2 or overlong access tokens, and pre-rotation refresh tokens are deliberately rejected, so existing sessions must sign in again at rollout.Groups configuration now distinguishes tenant policy from day-to-day administration. The Module Configuration dialog exposes only settings with real runtime enforcement, adds the previously omitted maximum description length, links directly to the full Groups administration area for approvals, moderation, types, organisation, recommendations, ranking, analytics, and group records, and uses a responsive management card on mobile and desktop. The Groups configuration API now rejects unknown keys, malformed booleans, invalid visibility values, out-of-range limits, and contradictory description bounds instead of silently coercing or ignoring them.
Events enterprise CI now exercises the authoritative notification outbox. The destructive lifecycle journey drains the disposable CI app's Events outbox before verifying recipient-visible cancellation notifications and targets HeroUI v3 data grids by their actual accessible role.
Podcasts now enforce one tenant-safe, fail-closed contract across React, the accessible frontend, distribution feeds, moderation, storage, search, and privacy workflows. Public RSS/transcript/chapter and hosted-media delivery use explicit tenant identity and capability signatures; unsafe or unprocessed media cannot publish, stream, or leak through feed cards; creator edits reset moderation when material; report resolution is per report; hosted artwork is tenant-owned; listen analytics record real progress without trusting client completion; creator/admin studios expose full metadata and operational review controls; global search, feed lifecycle, GDPR export/erasure, OpenAPI coverage, and accessible authoring now match the canonical Laravel API. Locale parity, legacy cache handling, durable failed-file cleanup, and focused tenant/authorization/media regressions are included.
Marketplace checkout and money movement are now server-authoritative, tenant-safe, and retry-safe. Orders bind a durable idempotency key to their canonical listing, quantity, shipping, coupon, loyalty, and payment-method inputs; inventory, offers, coupon use, loyalty reservations, free orders, time-credit settlement, pickup reservations, and cancellation/expiry recovery are atomic and reversible. Stripe Connect now distinguishes destination charges from delayed separate transfers, holds eligible payouts until delivery, reconciles partial/full refunds and external refunds exactly once, handles active/won/lost chargebacks (including seller transfer reversal and reimbursement), blocks payout while disputes are active, preserves reduced partial-refund balances, and reports seller balances by currency instead of summing incompatible currencies. Delivered non-escrow orders now auto-complete on schedule, and migrations add the uniqueness, ledger, dispute, enforcement, and browse indexes required by these guarantees.
Marketplace privacy, trust, discovery, and moderation now fail closed. Upload extensions come from detected MIME, public originals are re-encoded to remove EXIF/GPS metadata, Apache blocks executable files below public storage, and public coordinates are rounded while owners retain precise edit coordinates. Category/collection/discovery queries reject cross-tenant references; public reads hide pending, removed, suspended, and expired inventory; material listing edits return to moderation; save counters are idempotent; paid promotion products are hidden until a real payment lifecycle exists while explicitly free promotions remain supported. User reports, seller notices, appeals, admin evidence review, dispute resolution, enforcement snapshots, and enforcement-specific restoration are complete and recipient-locale aware, with transactional decision claims preventing competing admin resolutions.
Marketplace checkout and case-management parity now spans React, native mobile, and the accessible frontend. All three clients support cash, free, time-credit, and explicit hybrid payment-method choice; use server-priced shipping; recover safely from pickup/payment failures; and expose user report/appeal flows. React adds translated user report pages and an evidence-rich admin report/dispute workspace, mobile uses stable list keys and idempotent checkout recovery, and the accessible frontend provides HTML-first purchase parity. Marketplace API/OpenAPI contracts, the checked-in MariaDB schema, all 11 PHP/React locales, all 7 mobile locales, focused financial/tenant/privacy regressions, and a Chromium browser journey are refreshed together.
Marketplace policy, publication, and concurrency controls now fail closed end to end. Tenant switches for shipping, free items, business sellers, hybrid pricing, community delivery, card payments, promotions, and moderation are enforced in services and rechecked against locked listings at checkout; configuration read failures no longer auto-approve content. Suspended/inactive sellers are excluded from every maintained public discovery path, per-seller listing quotas serialize, accepted offers expire and release reservations safely, cancelled offer checkout can restart without reusing financial ledgers, and shipping/delivery transitions cannot overwrite a concurrent dispute or refund. Stripe account creation is idempotent, provider-bound fee economics survive later config changes, webhook refunds/disputes share the payout mutex, offer notifications are translated, and marketplace migrations are idempotent and ownership-safe under partial MariaDB DDL.
Page-builder image uploads now use polished, responsive spacing by default. Images inserted or replaced through the GrapesJS page builder receive a dedicated Nexus presentation class with responsive sizing, rounded corners, and consistent separation from following content; a narrowly scoped compatibility rule also repairs existing custom pages where a bare uploaded image directly touches the following Nexus card grid.
All existing numeric route throttles now use explicit tenant-, actor-, and endpoint-scoped policies across the maintained API and accessible frontend. Legacy numeric middleware declarations have been replaced without changing their request ceilings, unrelated routes no longer consume one another's endpoint-specific allowance, and every policy tier retains a broader IP abuse envelope. Database-backed email login buckets are now tenant-scoped, case-normalised, and stored as digests rather than raw addresses; regression gates prevent numeric throttles from returning.
Platform-audit enforcement now covers the surfaces that previously escaped release gates. Hardcoded-string checks include React administration and native mobile runtime code, known admin-help and mobile locale gaps are ratcheted, Expo permission copy is localised, and web-push/mobile network errors use translations. The test-skip budget is lower, the mobile Jest baseline no longer depends on expired dates, removed settings, or uncommitted generated native files, dependency licence classification distinguishes
UNLICENSEDmetadata and documents the reviewed getID3 MPL election, E2E workflow permissions are read-only, vulnerable transitive packages are overridden, production licence inventories are refreshed, and private root env/secret inputs are excluded from the Docker build context. The frontend manifest and lockfile declare the Node 22/npm 10 CI toolchain so clean installs retain HeroUI CLI's optional peer dependency. Performance-sensitive Events and Marketplace modules use focused HeroUI imports, while OpenStreetMap rendering now uses Leaflet directly instead of the use-restricted React wrapper dependency.PHP test isolation now takes effect before Laravel providers boot. The PHPUnit cache driver forcibly uses the intended in-memory store even inside the Docker image, preserving named route-limiter registrations while preventing rate-limit counters from leaking across tests or runs. Accessible blog SEO coverage now verifies that its JSON-LD nonce matches the response CSP, proxy tests preserve the hardened Cloudflare-chain requirement and reject spoofed forwarding headers, and the message-service mock matches Eloquent's real collection contract.
Fixed
Tenant Hub capability is now managed from one guarded control. Edit forms direct administrators to Hub Settings instead of submitting the protected capability field, Hub deactivation is blocked until all child tenants are moved or deleted, disabling an empty Hub requires explicit confirmation and explains the tenant-super-admin impact, maximum hierarchy depth is described consistently, and the shared HeroUI Switch wrapper now follows the v3 clickable-content anatomy while explicitly reflecting controlled on/off state in its visible track and thumb.
Security scanning now distinguishes Symfony components instead of treating the framework as one monolithic CPE. OWASP Dependency-Check no longer assigns component-specific Symfony advisories to unrelated packages such as Clock, UID, Translation, or VarDumper; the suppression is constrained to the exact false-positive package/CVE pairs while Composer Audit, the PHP security checker, and Trivy remain blocking package-aware gates.
Partner Timebanks settings now keep the save action beside the editable controls. The settings page no longer hides its only Save button in the page header above the long guidance content; all three partnership toggles and the partnership limit now share a clearly visible save action at the bottom of the settings card, with regression coverage verifying that every toggle is included in the persisted API payload.
Partner Directory requests now reflect existing relationships. Communities with a pending or active partnership no longer appear to accept a duplicate request, and rejected request attempts display the API's specific reason instead of an unhelpful generic failure.
Super-admin tenant edits can be saved again without unrelated 422 validation failures. The tenant form now sends a partial update containing only changed fields, so unchanged legacy contact values and protected platform routing hosts do not block ordinary edits; the backend also accepts an already-assigned protected host while continuing to reject new assignments and avoids treating unchanged routing values as routing changes.
Events load correctly on custom tenant domains and tenant PWA manifests remain valid, tenant-scoped JSON. CORS now permits and exposes the negotiated
X-Events-Contractheader across Laravel's primary and fallback response paths, while both React nginx configurations proxy same-origin/api/*resources to Laravel instead of returning the SPA HTML shell and preserve the browser-facing host for manifest tenant resolution; regression coverage locks both boundaries.Blue-green deployments now terminate Horizon through the root-owned container process and expose IndexNow verification on every tenant host. The deploy script detects Horizon's soft signal failure and falls back immediately instead of waiting through the former five-minute shutdown path, while both maintained React nginx configurations serve the shipped verification key as exact plain text ahead of the SPA catch-all; focused regressions lock both production contracts.
Open message conversations now reflect a member's withdrawn safeguarding contact preference promptly, including empty threads. A blocked conversation independently rechecks the server-side contact policy every five seconds while visible instead of relying on message-cursor polling, which never started when the two members had no existing messages. The member-side control now says “Request review of my vetting” so it cannot be mistaken for changing the other participant's status. The revoke API remains synchronous and a regression proves the next conversation preflight and direct-message write are immediately allowed.
Safeguarding Options no longer exposes internal codes or untranslated trigger keys. Broker-facing option cards now translate preset sources such as
england_wales, show localized labels for every behavioural trigger, reuse localized trigger explanations in the editor, and hide inactive options' internal storage keys such ashas_vetting.Safeguarding policy setup no longer replays existing member selections as new onboarding disclosures. Configuring a tenant contact policy, refreshing a country preset, maintaining an option, or revoking one preference still invalidates caches and recomputes every enforcement trigger, but only a fresh member preference save can dispatch the onboarding safeguarding alert. This prevents an England-and-Wales policy transition from emailing brokers about every historical active selection while preserving policy-review notices and the dedicated consent-withdrawal notification.
The live accessible frontend now enforces its CSP and HTML boundaries consistently. GOV.UK bootstrap and JSON-LD scripts carry the per-request nonce, the event credential print action uses the compiled progressive-enhancement handler instead of blocked inline JavaScript, and legal, knowledge-base, and blog CMS bodies are sanitised at render time. Static regression coverage scans every accessible Blade template for nonce and inline-handler regressions.
Production origin, proxy trust, and browser policies now match the platform's security controls. PHP and React origin ports bind to loopback, the PHP image derives its remote address from Apache's right-appended
X-Forwarded-Forchain instead of trusting caller-selectedCF-Connecting-IPacross a Docker hop, and application fallback logic accepts Cloudflare identity only when that chain proves a Cloudflare edge. The PHP image no longer adds a second static CSP that overrode Laravel's per-request nonce; Laravel is the single nonce-bearing CSP authority for API and accessible responses. Both React Nginx configurations now share one canonical SPA policy with the maintained media, map, worker, document, payment, and embed origins, preventing route-specific policy drift.Voice transcription and merchant coupon QR display no longer depend on stale or external paths. Both voice-upload controllers transcribe the server-owned file after it has moved into tenant storage and never re-fetch a public URL or expose the local path. React and native mobile generate redemption QR codes locally, so opaque coupon tokens are no longer sent to
api.qrserver.comor exposed through a fallback link.The audited frontend content and scanner sink findings now fail closed at their browser boundaries. Page-builder HTML uses DOMPurify's parser-backed fragment output, CSS and URL allowlists reject container escapes, active schemes, global selectors, and unsafe declarations, and image previews accept only normalised HTTP(S) or explicitly opted-in browser blob URLs. Podcast listen sessions require cryptographic randomness, dynamic test URLs escape complete regular-expression syntax, Markdown audit output escapes backslashes, and uploaded voice-file cleanup proves canonical tenant-directory containment before touching the filesystem.
Direct-message reactions no longer expand into rows of numbered controls. The API now returns an allowlisted emoji-to-count map from canonical reaction rows, safely falls back to legacy JSON, and never exposes the legacy per-user map or reactor IDs (including from the batch endpoint); reaction reads and toggles are tenant-scoped throughout, while the React message bubble also rejects malformed strings, arrays, metadata, and invalid counts during staggered deployments.
Safeguarding checkbox responses now distinguish an explicit “No” from an active protection. Checkbox values are normalised to
1/0, and only affirmative, non-revoked selections can activate cached, bulk, or transaction-locked vetting and messaging gates. Negative responses remain in consent and GDPR history but are excluded consistently from React and accessible member settings, administrator lists and counts, staff summaries, annual reviews, policy-change reviews, and member safeguarding flags.Legacy personal safeguarding preferences can no longer authorise broker access to message content. The copy engine rejects and repairs the exact historical onboarding-monitoring marker, an idempotent migration clears existing rows without weakening independent messaging restrictions or audit fields, and the generic review notice is now controlled only by tenant-wide broker-visibility policy without exposing either participant’s monitoring status.
Sensitive administrator actions and logout now preserve their privilege and session boundaries under concurrency. Email, role, status, approval, suspension, ban, reactivation, deletion, password replacement, password-reset dispatch, 2FA reset, and impersonation enforce one actor-above-target hierarchy that includes legacy
is_adminaccounts; sensitive mutations recheck that hierarchy while actor and target rows are locked, and administrator password replacement also consumes the target tenant's outstanding reset links transactionally. WebAuthn password/TOTP/backup-code confirmation now holds the tenant-user issuance lock through factor verification and proof creation, so a concurrent password reset cannot mint a fresh passkey-enrolment proof from an old factor. Refreshed access JWTs are bound to their refresh family, making family logout invalidate delayed SPA or accessible-cookie responses, while React marks and Web-Lock-serializes logout so an in-flight refresh cannot repopulate browser storage.Social sign-in and identity linking now require explicit tenant, intent, browser, and provider proof at every mutation boundary. Login intent can no longer provision a member, and missing or malformed tenant provider opt-in fails closed. Google automatic email ownership is limited to Gmail or a matching signed Workspace hosted-domain assertion; Facebook email metadata cannot automatically link or provision an account; and Apple remains unavailable until a vetted driver exists. Verified-email and SSO identity links are deferred until the initiating browser proves its verifier and current account gates are rechecked, callback processing rechecks the provider kill switch, and unlinking a provider revokes every session in the same transaction or rolls the identity removal back.
Passkey enrolment, password reset links, backup codes, and two-factor removal now share the account's locked revocation boundary. Passkey registration revalidates the current security confirmation under the tenant user lock immediately before credential persistence, rejecting ceremonies invalidated by a password change or logout-all. An authenticated password change consumes every reset token for that locked tenant/email inside the same password/history/session transaction. Backup-code use is a tenant/user-scoped conditional update with exactly one successful consumer, and disabling TOTP re-reads the password under the user lock, revokes every session with the factor removal, and restores the factor if revocation cannot persist.
Two-factor login can no longer lose its tenant, lifetime, or single-use boundary during cross-community administration. Login and setup challenges bind the account's home tenant and password-authentication start; session and stateless completion atomically consume the same cache-backed challenge with one absolute five-minute deadline and a bounded attempt count before credential issuance, which is serialized against password changes and logout-all. Verification reads only the bound tenant's TOTP secret, backup codes, trusted devices, and attempt history; tenant administrators can authenticate only through their own community, while platform-level administrators retain cross-tenant access without bypassing an enabled second factor. Forced first-time administrator setup remains default-disabled and must receive the same authentication-start revocation check before it is enabled.
Voice-message cleanup can no longer follow an untrusted database or API path outside the owning tenant's recording directory. Generic message sends reject client-supplied voice pointers, maintained clients persist audio only through the dedicated server upload flow with 128-bit random filenames, and GDPR erasure canonicalizes every tenant message pointer before unlinking. Traversal, remote, missing, symlink-escape, and cross-tenant paths are scrubbed without being followed; recordings referenced by another sender are preserved, failed unlinks retain their retry pointer and keep the erasure request open, and rotating refresh-session records are removed with the erased account. API and accessible account-deletion requests also revoke all active sessions before confirming the request; private storage and authenticated delivery of sensitive media remain separate conditional work.
Account erasure and message creation now share one tenant-user serialization boundary. Erasure locks the account before establishing its database snapshot, and message persistence locks and rechecks that the sender is exactly active and not deleted immediately before insertion. A request authenticated before deletion therefore cannot commit a text, attachment, or voice message after the successful erasure scan; real two-process MariaDB coverage exercises the race.
Events People bulk operations no longer share a numeric rate-limit bucket with unrelated API traffic. The existing 30-per-minute allowance now uses a named limiter scoped to the tenant and authenticated actor (with an IP fallback), so ordinary smoke-test or application traffic cannot starve the organizer bulk workflow while its own limit still returns 429 correctly.
Pending podcast media no longer leaks a cloud CDN URL. Hosted uploads persist the fail-closed in-app media proxy until processing and scanning complete; legacy cloud rows are also normalized to that proxy whenever unready media is projected. Ready public episodes may then use the configured CDN while restricted episodes receive signed proxy capabilities.
The full backend CI suite now matches the hardened commerce and event lifecycles. Pickup fixtures declare pickup delivery and paid QR-scan state, loyalty edge cases apply pending reservations to an order before reversal assertions, podcast upload tests isolate the synchronous media processor, and the Ed25519 tamper test always changes significant signature bits instead of occasionally changing only Base64URL padding bits.
Marketplace OpenAPI descriptions now remain structurally valid when they contain commas. Flow-style YAML descriptions for checkout validation, time-credit refunds, and historical dispute refunds are quoted so the synchronized JSON contracts no longer emit sentence fragments as illegal schema properties and the Redocly documentation gate passes.
Fresh schema imports no longer retain a production-only MariaDB trigger definer. Schema refreshes strip account-specific
DEFINERclauses so CI, tests, and new installations create triggers under their own import account instead of failing writes with error 1449 whennexus@%does not exist.
Added
Events now has a real enterprise tenant-configuration workspace instead of placeholder “Configure” options. The dedicated, translated admin page separates tenant policy from per-Event and member-owned settings, reports rollout/schema readiness for registration forms, invitations, ticketing, agendas, offline sync, broadcasts, safety, analytics and federation, previews active-record impact, and exposes versioned audit history. Tenant-scoped creation, moderation, capacity, registration, guests, waitlists/timed offers, recurrence creation, reminders, broadcasts, offline check-in, calendars, federation, safety and notification-delivery policies are enforced in canonical services and projections with safe withdrawal/revocation paths. Changes require a reason and optimistic version, disruptive disables require server-confirmed impact review, section/all restoration removes only selected overrides, reminder shutdown cancels pending rows, and federation shutdown queues ordered tombstones for existing shares. The Events module card now navigates directly to this workspace; desktop/mobile browser coverage verifies configuration, confirmation, restoration, module navigation and the organizer People workspace.
Events clients can now discover recurrence capabilities from an authoritative runtime contract. Authenticated clients inside the tenant Events feature boundary can call
GET /api/v2/events/recurrence-capabilitiesto select the legacy or V2 engine, supported frequencies/end types and bounded occurrence cap without guessing from a deployment version. Rolling-never, effective-revision and definition-blueprint support fail closed when rollout flags, configuration or required schema are unavailable; the exact non-sensitive resource, tenant/auth boundary, OpenAPI schema and Events test-harness membership are regression-tested.Events is now a full enterprise operations module across React, the accessible frontend, and native mobile. Events now use an authenticated, tenant-feature-gated canonical contract with separate publication/operational lifecycle, moderation history, capability-scoped staff, server-paginated People operations, race-safe registration/waitlist offers, encrypted versioned forms and answers, invitations/campaigns, guests and retention, timezone-safe RFC 5545 recurrence and calendars, preview/commit recurrence revisions, immutable definition-only future-occurrence blueprints, attendee/session agenda registration, independent reminders and notification preferences, scheduled audience-frozen broadcasts, signed offline check-in with device/manifest/conflict workflows, safety and guardian-consent controls, versioned templates, bounded free/time-credit ticket definitions with free-only entitlement ledgers and atomic release on registration exit, privacy-thresholded analytics, and reliable federation upserts/tombstones. Notification delivery is outbox-authoritative by default for fresh installs, rechecks current event/category/global consent immediately before deferred sends, renders per recipient locale, and retains audited retry evidence. Privacy, tenant, lifecycle, capacity, idempotency, migration rollback and maintained-client parity are covered by the isolated Events test harness. Destructive RSVP and waitlist exits now require explicit confirmation, and the checked-in schema baseline is refreshed through migration
000071with a verified no-pending-migration round trip. Attendance remains separate from finance: automatic attendance credits, cash processing, and unapproved time-credit settlement stay fail-closed.Module Configuration now includes lockout-safe two-factor authentication and passkey controls. Super-admins can configure trusted-device availability and duration, future recovery-code counts, passive passkey autofill, passkey enrolment, and the maximum credentials per member. The Passkey / biometric login switch is now a real tenant-wide authentication kill switch: disabling it requires an impact confirmation that reports affected and passkey-only members, removes passkey login from tenant bootstrap/login UI, and blocks every public passkey challenge/verification route; an environment-level emergency switch provides the same fail-closed platform response. New-enrolment policy remains independent so it can be paused without disabling existing sign-in. The full passkey path now requires user verification and discoverable credentials, binds ceremonies to the exact tenant origin, RP ID, user handle, credential allow-list, and account state, consumes challenges atomically, pads account-bound challenge results, stores complete case-sensitive credential/RP/authenticator metadata, and revokes stale credentials on tenant moves. Authenticator registration, rename, and removal require short-lived password/TOTP/backup-code or recent passkey/federated step-up; removal revokes every active session, while final-sign-in-method guards count only usable passwords and enabled OAuth/SSO providers. The React and accessible settings flows enforce the same sensitive-change boundary, and focused abuse, replay, tenant-isolation, schema, migration, and successful-cryptographic-path tests replace the former 500-accepting coverage.
Safeguarding vetting now supports one United Kingdom policy package across England, Wales, Scotland and Northern Ireland. An authorised broker can record one community contact clearance backed by any controlled combination of Enhanced DBS, PVG and AccessNI, together with an encrypted scope summary, encrypted private operational notes, a mandatory community review date, and the PVG membership expiry date when applicable. The expandable broker detail view shows exactly what the community certified; review or authority expiry closes safeguarded contact until renewal. Active brokers and administrators receive translated email and in-app reminders at 90, 30 and 7 days, on the due date, and after expiry. Certificates, reference numbers, disclosure results, identity documents and criminal-record information remain prohibited, and a contact clearance is never reused as volunteering, employment or regulated-role clearance. One central fail-closed policy continues to guard maintained messaging, connection, exchange, matching, caring, volunteering and job-contact paths before writes or notifications, while cancel/withdraw/decline exits remain available. React and the accessible frontend retain private status, empty-body broker-review requests, policy-review acknowledgement, coordinator help and live message rechecks; safeguarding preferences do not grant brokers message-content monitoring. Dry-run-first commands still cover narrowly proven legacy metadata import, inferred-jurisdiction correction, unsupported listing flags and content-blind DPO-authorised evidence cleanup.
The protected-contact boundary now covers every maintained alternate interaction path found in the remediation sweep. Write-time checks now include directed comments and mentions, reactions/shares/votes/favourites/support, group membership plus discussion/announcement/Q&A/file/media/team content, events and waitlists, marketplace orders/community delivery, linked or sub-account relationships, legacy scheduled matching, volunteering emergency/waitlist transitions, and podcast reactions; removal, withdrawal, decline, cancellation, unreact and unfavourite exits remain available. Live protective options cannot be silently disabled, deleted, replaced, or revoked by an admin policy transition: incompatible/custom/unconfigured transitions preserve the member selection and return policy unavailable pending review. Broker decisions serialize with policy rotation; definitive direct-message decisions use a common tenant/policy/preference/option/attestation lock order, bypass shared caches, and serialize preference reactivation before persistence. DPO-authorised cleanup now also redacts prohibited legacy certificate references, dates, notes, rejection text and evidence-derived role flags, setting a content-free marker that permanently excludes the row from automatic import. Automated credential cleanup is limited to explicit vetting aliases and cleanup tombstones, while unknown/custom historical types remain private, non-authoritative, and available for manual review/member deletion. Legacy-decision apply requires explicit tenant/all-tenant scope, an exact acknowledgement, active authorised verifier provenance, and an unredacted legacy row.
The pre-render admin now has an authoritative “Reset and rebuild all” workflow. Platform super-admins get a typed-confirmation danger action that preflights every active tenant's complete static + sitemap route plan, fences older queue work, and schedules one high-priority fresh rebuild while retaining the last known-good snapshots if rendering or validation fails. The control panel now reports actual tenant-specific coverage, unexpected snapshots, read/write cache health, route-plan failures, inventory truncation, authenticated metrics/CSV downloads, complete missing-route lists, safer destructive confirmations, request-race-safe inspection, and honest live-page links. Destructive pattern and unexpected-snapshot purges require a short-lived server-issued preview token and delete only the exact cache paths in that preview; changed/expired previews fail closed. The reset, inventory, coverage, tenant-safety, sitemap, audit, and freshness copy is translated across all 11 frontend locales.
The accessible (GOV.UK) frontend now renders styled error pages instead of bare Laravel defaults. Aborting inside the accessible route group (403 module gates, 404s, expired form sessions, rate limits) previously fell through to the unstyled framework error pages: no GOV.UK layout, no skip link, no way back, and — a hard project requirement — no AGPL Section 7(b) attribution (2026-07-10 accessible-frontend audit, crawler finding W2). A new exception renderable (
App\Support\AccessibleErrorPage, registered first inbootstrap/app.phpso accessible requests are skinned before the API JSON renderables claim them) renders a standaloneaccessible-frontend::errorview for 403/404/419/429/503 with translated title/body (govuk_alpha.error_pages.*, all 11 locales), a link back to the tenant's accessible home, and the attribution footer. The view deliberately does not extend the main layout so a second failure while rendering the error page is impossible. Also fixed from the same crawl:govuk_alpha.actions.back/actions.cancelwere referenced by the AI-chat and listing-report pages but existed in no locale, rendering as literal key names (crawler finding W1) — added to all 11 locales.The Explore / Discover page is now a gateable per-tenant feature that admins can turn on or off in Module Configuration. Previously the
/explorecurated discovery page was hardcoded-visible: its navbar link (desktop, mobile drawer, and collapsed overflow menu) rendered unconditionally and the route had no gate, so a tenant that didn't want it had no way to hide it.exploreis now a first-class tenant feature (TenantFeatureConfig::FEATURE_DEFAULTS+TenantFeatures/defaultFeatures, defaulting ON so existing tenants are unaffected) with a card in the admin Module Configuration panel (moduleRegistryFEATURE_MODULES). When an admin toggles it off, the navbar Explore link disappears in all three surfaces (Navbardesktop link + overflow megamenu gated onhasFeature('explore'),MobileDraweritem carriesfeature: 'explore') and the route is wrapped in<FeatureGate feature="explore" redirect="/">so direct-URL access redirects home — matching how sibling features (events, groups, volunteering) are gated frontend-side. No new user-facing strings (reuses existingnav.explore/nav_desc.explorekeys); the public/api/v2/exploreendpoints keep the codebase-wide convention of frontend-only feature gating.The master tenant (tenant 1) home page is now a searchable "Choose your community" directory instead of a normal-looking landing page. The master tenant is the platform root, not a working community — but its home rendered the same
LandingPageRendereras every real tenant, so a visitor who got redirected there by an error saw a page indistinguishable from their own community with no obvious way back.HomePagenow renders a newMasterTenantChooserwhenuseTenant().tenant?.id === 1: a card grid of every active community (from the existing public, Redis-cachedGET /v2/tenants, which already excludes master) with a client-side search box, each card a full-document<a href>— the tenant's custom domain when it has one, else/{slug}— so the app cleanly re-bootstraps into the chosen community (the same reasonTenantShell's "Community not found" screen uses<a href>). Mirrors the accessible frontend'stenant-chooser(AlphaController::tenantChooser+tenant-chooser.blade.php). Strictly gated to id 1 — every other tenant renders the unchanged landing page — and touches no tenant-resolution or routing code, so it cannot affect any other tenant. Newcommunity_chooser.*keys added topublic.jsonacross all 11 locales.
Changed
- The accessible frontend's public URL prefix is now
/{tenantSlug}/accessible/...— the/alphaslug is retired. The track has been Beta for a while, but the URL still said alpha. The route prefix, the custom-domain slug-stripping middleware, the React "WCAG 2.2 AA Version" utility-bar/mobile-drawer link builder, the accessible-frontend dev-server redirects, the a11y E2E page list, ~1,577 test URLs across 52 test files, and the docs all move to/accessible. Old URLs keep working:/{slug}/alpha/{path}permanently redirects (301 for GET/HEAD, method-preserving 308 otherwise, query string preserved), and on dedicated accessible custom domains legacy/alpha/{path}deep links redirect to the bare slug-less path. That custom-domain redirect also fixes a real pre-existing bug:buildAccessibleFrontendUrlemittedhttps://{custom-domain}/alpha/{path}deep links, which always 404'd because custom accessible domains serve bare slug-less routes — the builder now emits bare paths (and/{slug}/accessibleon the shared domain). Internal code-path names (GovukAlphacontrollers,govuk_alphatranslations,govuk-alpha.*route names,routes/govuk-alpha*.php) deliberately keep their names until a separate namespace migration; AGENTS.md/READMEs updated to say so. - Merged the
tenant_adminrole intoadmin; tenant super-admin is now purely theis_tenant_super_adminflag, granted via the dedicated toggle. An audit foundrole='tenant_admin'androle='admin'were treated identically by every authorization gate — the real tenant-super-admin power lives in the separateis_tenant_super_admincolumn, which was set independently of the role. So atenant_adminwithout the flag was actually weaker than anadmin(it couldn't assign elevated roles — the "only super admins" toast), andAdminUsersController::setSuperAdminrevoke left arole='tenant_admin'with the flag cleared — a powerless "zombie tenant_admin".tenant_adminis removed as an assignable role from all five role pickers (UserEdit,UserCreate, super-panelTenantShowadd-admin,SuperUserListfilter, andSuperUserFormcreate/edit) and the four backend allow-lists (AdminUsersController::update/store,AdminSuperController::userCreate/userUpdate) — attempting to assign it now returns 422 pointing to the super-admin toggle. The three grant paths (setSuperAdmin,AdminSuperController::promoteAndMove+ bulk move) now writerole='admin'alongsideis_tenant_super_admin=1, so revoking the flag can never strand atenant_adminrole again (fixes the zombie bug). Data migration2026_07_09_000002_merge_tenant_admin_into_adminconverts existingtenant_adminusers toadminwhile preservingis_tenant_super_admin(real tenant super-admins keep every power via the flag; flagless zombies become the plain admins they already were).tenant_adminis deliberately retained in the authorization accept-lists (requireAdmin/callerIsAdminTier/EnsureIsAdmin, frontendisAdminTier/hasAdminPanelAccess) as an inert legacy alias, so any pre-migration row still resolves to admin access; the frontendmin_rolemenu hierarchy now treatstenant_adminas equal toadmin(so a nav item gatedmin_role: 'tenant_admin'stays visible to migrated tenant super-admins rather than vanishing); inertrole_tenant_adminlabels are left in place. Net: the redundant, mislabeledtenant_adminrole is gone from the UI — assignable roles are Member/Broker/Admin, and tenant super-admin is an explicit flag toggle.
Removed
- Deleted three dead
NewsletterServicemethods (getABTestResults,selectABWinner,resendToNonOpeners). They had no callers — the live admin resend/A-B workflow is implemented tenant-scoped inAdminNewsletterController— and they mutated newsletters by bare id with no tenant filter, a cross-tenant IDOR waiting to happen the moment one was wired to a route (2026-07-09 platform audit P3). - Removed the two non-functional placeholder user roles, "Moderator" and "Newsletter Admin", from the admin user role selector. Both appeared in the Create/Edit User role dropdowns and were storable on
users.role, but neither was ever wired to a backend capability:moderatoronly granted cosmetic client-side entry to/admin/*routes (whose API calls still 403'd) plus inclusion in two notification/assignee queries, andnewsletter_admingranted nothing at all (the newsletter endpoints require a trueadmin). They are now gone from both selectors (UserCreate,UserEdit), the two backend allow-lists (AdminUsersController::update/store— assigning either now returnsVALIDATION_ERROR), the deadmoderatorclauses inProtectedRoute,roles.ts::canModerateContent, theUserPermissionsbadge-colour map and theUserListsearch-hint map, and the tworole IN (...)staff queries (MunicipalImpactReportService,AdminCrmController). A data migration (2026_07_09_000001_normalize_retired_user_roles) downgrades any existingmoderator/newsletter_adminusers tomember— the role they already behaved as — so no orphaned values remain. TheUser['role']TypeScript union and Zod enums intentionally keepmoderator: it is also a distinct group-member role (GroupMember extends User) and can still appear in pre-migration legacy data, so the type layer stays a superset while the assignment paths do the enforcing. The now-unusedrole_moderator/role_newsletter_adminlocale label keys are left in place (inert). The four real platform roles — Member, Broker, Admin, Tenant Admin — are unchanged.
Fixed
Mobile bottom navigation and radio controls now remain clear in constrained phone layouts. All five tabs receive equal flexible widths and share one fixed label baseline, the raised Create action no longer pushes its label toward or beyond the viewport edge in landscape, and left/right safe-area insets keep navigation clear of notches and system controls. The shared HeroUI v3 radio wrapper now restores a contrast-safe one-pixel boundary for unselected controls in dark and light themes while preserving the accent-filled selected state; focused component regressions cover both contracts.
Profile settings now fail safely and save consistently. Discarding edits restores the last server-backed values instead of retaining hidden changes, privacy and notification controls remain unavailable until their real values load, browser history stays synchronized with the selected tab, and tab-specific APIs load only when needed. Notification, match, and digest preferences now save through one validated database transaction with canonical values returned to React. The same hardening closes arbitrary identity-column writes through legacy notification preferences and ensures GDPR consent/request operations use the request tenant rather than a service constructed before tenant resolution. Focused settings coverage now exercises rollback, lazy loading, URL navigation, failed-load protection, identity verification, skills, passkeys, availability, linked accounts, and all maintained settings tabs.
Events lifecycle compatibility resolution now applies the blank-status normalization consistently. Canonical-axis consistency checks use the same legacy-active interpretation as the publication and operational enum mappers, allowing historical empty-string rows to complete guarded lifecycle backfill without weakening rejection of other unknown values.
Events lifecycle rollout now preserves blank legacy statuses as active compatibility rows. Historical Events installations can contain empty-string
events.statusvalues with the same meaning as the existing nullable compatibility state; the dual-axis lifecycle migration now maps both forms to published/scheduled while continuing to fail closed on every other unknown status.Accessible Event cover-upload cleanup now loads its tenant-safe image helper in production. The accessible Events controller imports the shared uploader used by failed-create and failed-edit cleanup paths, preserving the upload lifecycle fix under PHP static analysis and at runtime.
Accessible Event creation now preserves cover uploads across immediate moderation freeze and renders recurrence limits from the live capability contract. Cover files are ingested into the creation payload before a new Event can enter pending review, failed creations clean up their tenant-scoped upload, and edit-time upload failures no longer leave orphaned files. The GOV.UK form and regression coverage now keep rolling-never hidden while its guarded writers are disabled and use the server-advertised maximum occurrence count instead of assuming the V2 ceiling.
CI schema regressions now exercise the authoritative Events lifecycle and stable WebAuthn dump semantics. The moderation regression fixture supplies a pending-review Event and an authorised admin before asserting the legacy
activecompatibility mirror, while the WebAuthn identity test accepts mysqldump's optional comma spacing without weakening its composite tenant foreign-key invariant.Accessible Events moderation now exposes its subtype-preserving response helper to PHPStan correctly. The generic response annotation uses a standard multiline contract, preventing CI from treating the helper's template type as unreferenced while retaining precise
ResponseandRedirectResponseinference.Non-Events CI regressions now match the hardened authentication, federation, Groups, privacy, and queue contracts. Authentication fixtures use valid password-backed recovery methods; outbound federation fixtures provide explicit HMAC platform identifiers; Groups feed and course fixtures respect member-only child-resource visibility; accessible federation message counts stay scoped to the active conversation; authenticated responses emit one complete
Vary: Authorization, Cookiefield; and Horizon consumes the declaredwebhooksqueue.Event detail pages no longer flash a false “Event Not Found” state or wait for the People roster before rendering a valid event. Overlapping detail loads (including development StrictMode and fast route transitions) aborted the older request correctly, but that stale request's unconditional
finallycleared the shared loading flags while its replacement was still in flight; the empty terminal branch then rendered for several seconds before the successful response replaced it. Detail and roster loads now use independent request-generation, controller-ownership, loading and error lifecycles, so only the current non-aborted request can publish its result and the authoritative event renders as soon as its detail contract resolves. Genuine unsuccessful responses still render the established terminal error state, while deferred replacement-detail, slow-roster and stale-roster regressions pin the races.React modals now stay above fixed application navigation and inside safe viewport insets. The shared HeroUI v3 wrapper previously inherited the library's
z-50backdrop even though the platform header uses the--z-fixedlayer at 300, allowing the Events organizer message title and close control (and sibling dialogs) to sit underneath the header on shorter desktop viewports. Shared modal backdrops and containers now use the existing--z-modal-backdropand--z-modaltokens plus safe-area-aware HeroUI container insets; full-screen dialogs retain their edge-to-edge contract, and shared/Events composer regressions pin the layer and viewport guarantees.Event management tabs remain usable on narrow mobile viewports. The installed HeroUI v3 tab styles gave both the horizontal list and every tab
w-full; combined with the management strip's intrinsic minimum width, each control expanded to the width of the entire strip and pushed later sections thousands of pixels off-screen at 390px. Every permission- and capability-gated tab now uses intrinsic non-growing sizing, the tablist is the single horizontal scroll owner, and a deep-linked section is revealed from its stable controlled key once the loading workspace has mounted and scrollable layout has settled instead of depending on React Aria's lateraria-selectedtiming. A 390px regression holds the staff request through the early scheduling window, delays both ARIA and scrollable layout after mount, proves horizontal movement, and covers the complete organizer strip including the rolling-recurrence Future setup tab.Accessible event registration now renders every attendee and organiser workflow through the maintained GOV.UK layout. The registration overview, form editor, and private answer-review pages no longer reference a nonexistent nested layout, and their complete registration/error vocabulary is available in all 11 PHP locales. Arabic passkey impact messages also retain their required count interpolation, while focused regression coverage keeps both mocked and database-backed group exchange creation paths.
Group detail navigation is compact again instead of rendering every module as an oversized scrolling tab. Desktop now keeps the six core group sections in a bounded 36px tab row and places collaboration/admin sections in a single translated More menu; mobile retains its one-control section selector. Keyboard navigation, tenant tab configuration, feature gates, member/admin visibility, and selected-panel semantics remain covered by focused regression tests.
CI coverage now matches the privacy and safeguarding contracts introduced by the latest hardening release. Member-identity controller tests authenticate before exercising protected payloads, denied direct-message attempts emit the staff safeguarding event at the write preflight, external federation transfers fail closed until a partner trust contract exists, locale files stay structurally aligned, and PWA offline checks target only the identity-free public shell. Service/unit fixtures now cover the definitive safeguarding queries and metadata-only GDPR cleanup, while the root dependency lock updates
immutableto 5.1.9 andtarto 7.5.19 to clear the current OWASP findings.Passkey/WebAuthn authentication now fails closed across account, tenant, domain, and session boundaries. Inactive or unverified members can no longer bypass normal login gates; assertion signatures are verified before account-policy responses; and signed-out login always uses discoverable, account-agnostic credentials so email-bound descriptor padding cannot become a response or timing oracle. Authenticator backup flags and offered COSE algorithms are enforced, registration limits are race-safe, production loopback origins are rejected, and RP/domain or hierarchy changes that would strand current, legacy, or inheriting-child credentials return a recoverable impact report. Registration challenges carry a fresh routing fingerprint, while every routing editor and enrolment share the same tenant/user locks and re-check impact inside the mutation transaction; real concurrent-registration coverage verifies the boundary. Credential removal and tenant moves atomically revoke JWT and Sanctum sessions, same-second revocation cutoffs advance monotonically without invalidating a freshly issued replacement token, passkey-only user moves return a recoverable 409 until password recovery exists, and unused low-level hard deletion is disabled in favour of the audited purge workflow. The hardening migration aborts before DDL instead of deleting ambiguous ownership data. The settings UI now prevents duplicate sensitive actions, translates default device names, exposes credential-domain mismatches and configured limits, flags legacy or unknown-discoverability credentials for re-enrolment, restores session-expiry warnings after conditional passkey login, reports unknown disable impact as unsafe, and exposes step-up selection to assistive technology; signed-out recovery guidance directs affected legacy users through password reset without restoring the email enumeration oracle.
Groups authorization, lifecycle, membership, and challenge rewards are now fail-closed and tenant-safe. One canonical lifecycle state controls discovery, child resources, recommendations, jobs, admin transitions, and the compatibility mirror; disabled Groups routes now fail closed across every maintained legacy and current endpoint. Invitations are tenant/email-bound and atomic, join/leave/approval/role/removal paths preserve owners and member counts, and challenge completion/cancellation uses bounded rewards with an idempotent ledger. Adjacent-ID, cross-tenant, private-group, upload, rate-limit, and audit-secret boundaries are covered explicitly.
Groups collaboration, storage, exports, and automation now use durable bounded contracts. Members, events, discussions, announcements, Q&A, wiki, analytics, files, media, channels, tasks, and subgroups use deterministic cursor pagination and parent-group access rules. Private file/media migration is dry-run-first with checksum verification and storage compensation; exports are queued and authenticated; scheduled posts and webhooks use idempotent claim/outbox flows. Destructive, role, lifecycle, image, pin, and economy mutations write redacted audit events in the same transaction, while unsafe synchronous export, unfinished custom fields, ownership transfer, clone, merge, and misleading type-scoped policies are no longer reachable.
Groups navigation and administration have received an extensive responsive/accessibility pass.
?tab=is canonical for deep links and browser history, stale requests cannot cross group boundaries, desktop uses semantic HeroUI v3 Tabs, and mobile uses the official single-selection Dropdown below the real header offset. The sticky desktop tab bar now clears the fixed header reliably, analytics uses the translated Files label, and the language preference API accepts the same Arabic locale that the application middleware supports. Destructive actions use typed HeroUI confirmations; challenge cancellation, server-side member search, event continuation, notifications, RTL, forced colours, reduced motion, keyboard paths, and 320–1280 px layouts are covered. Admin Groups now uses canonical lifecycle actions, translated audit filtering/paging, safe type/tag/rule/configuration workflows, and reversible fixture-backed browser journeys; module configuration no longer requests an empty translation key and its card actions, responsive grid, and search semantics remain usable at narrow desktop widths. Deterministic Groups browser coverage now exercises Chromium, Firefox, mobile Chrome, mobile Safari, and the Chromium admin surface with run-scoped fixtures and cleanup verification.Member identities are no longer exposed through signed-out community-content pages, APIs, accessible HTML, indexing, or caches. Explore, groups and rosters, events and attendees, listings, jobs, courses, podcasts/RSS/media, marketplace sellers, volunteering and organisations, resources/knowledge base, ideation, certificates, and municipality surveys now require a valid same-tenant member session before identity-bearing reads; React and the accessible frontend redirect to tenant-aware login before mounting or querying those surfaces. The blog remains public, indexable, sitemap-discoverable, and prerenderable for SEO, but its anonymous API, React, accessible HTML, and structured data use an author-free editorial projection with the tenant/community as publisher instead of exposing the linked member account; blog comments, reactions, authoring, and administration remain authenticated. Explore now enforces publication, schedule, audience, member-privacy, and private-group rules; event detail no longer embeds RSVP users and event/group rosters require a relevant relationship or role; marketplace and ideation detail lookups cannot enumerate unpublished records. Protected URLs are removed from public sitemaps and both backend/build-time prerender plans, authenticated API responses are
private, no-store, the PWA never caches private APIs or protected navigations and purges legacy HTML/thumbnail caches, and the public CMS fails closed on legacy member-grid blocks, account profile links, and account avatars. Static project contributors, editorial testimonials, organisation-only directories/calendars, aggregate statistics, and identity-free job/listen feeds remain intentional public content.Production nginx now serves runtime translation catalogs as JSON instead of the SPA shell. The hardened pre-render fallback treated
/locales/<language>/<namespace>.jsonlike an unknown client-side route and returned_spa.htmlwithtext/html; i18next retried the catalogs indefinitely, leaving React's root Suspense boundary blank and causing the candidate journey gate to fail every rendered page before cutover./locales/is now an exact static namespace with a fail-closed 404 fallback and bounded caching, covered by both nginx contract and real-container HTTP tests.Pre-render reset fencing and maintenance cutover are now rolling-deploy safe. Authoritative reset intent is stored durably in additive
fence_state/fence_ready_atcolumns without modifying the live job-status enum: old workers see a non-claimable row, new workers exposepending_fence, verify the database defaults required by old writers, recover the fence before honoring a tripped circuit breaker, and defer lock-contended activation quickly instead of holding the HTTP request for up to two minutes. The accepted intent and its success audit (including the actual job ID) now commit atomically; an audit storage failure rolls back the intent and returns 503. Once activated, the replacement cannot be canceled after it has superseded older jobs. Snapshot ownership now has a checksummed_tenant.jsonsidecar and a transactional.tenant-identity-v1rollout marker; nginx serves the live SPA until the first fully verified identity generation exists. Maintenance rendering uses a rotated root-only Basic credential accepted only for the exact tenant origin, revokes and rolls back credentials transactionally, preserves truthful 503 status, and rebuilds before reopening. Shared-volume ACLs are normalized for root, PHP, and nginx without allowing PHP container startup to cross the nested pre-render mount or expose the private credential.The pre-render engine is tenant-aware and fail-closed end to end. The route planner now includes each tenant's real feature/module gates, custom pages, blog entries, custom-domain or parent-domain path, and landing-page configuration instead of applying one generic route set; malformed, empty, unsafe, or capped plans are rejected rather than silently degrading to static routes. Each render uses an isolated browser context and verifies the exact tenant id/slug, final URL, canonical, readiness, status, visible content, API errors, assets, and public-network destination before publication. Landing-page, SEO, feature/module, blog, custom-page, menu, resource-library, marketplace listing/category, and listing-image writes now invalidate or reconcile the correct tenant; disabled-feature, retired-host, deleted-tenant, and wrong-tenant leftovers are removed by exact-plan drift reconciliation. Queue claiming/finalisation is claim-token fenced, long jobs have a real heartbeat lease plus runtime/resource limits, blue/green workers resolve the actual active color, stale-job reaping is compare-and-swap safe, and drift detection bypasses stale sitemap response caches and yields to active authoritative global work. Also fixed the broken shell JSON ingestion, custom-domain root-prefix loss, read-only cache mount, parent/child cache attribution, stale status/checksum sidecars, misleading >100% coverage, sitemap date precision, inaccessible external invalidation webhook, cron self-deadlock, and silent partial/truncated maintenance passes.
Authoritative pre-render rebuilds now publish validated, rollback-safe host-tree batches. A complete reset replaces each host tree only after every tenant route has rendered and validated, journals old/new ownership, rolls back on publication errors, and restores interrupted mutations when the next successful render reaches publication; custom-domain and shared-host tenants therefore cannot be left persistently on route-level mixed generations. Lock takeover is fenced by
flock, PID start time, a random owner token, and a matching Docker label, so PID reuse or a foreign container can never be killed by name alone. Tenant domain, slug, hierarchy, activation, and deletion changes schedule a global authoritative generation to remove former host paths, while ordinary tenant branding/configuration changes refresh only that tenant. Healthy long jobs survive queue repair through heartbeat-aware leases, cancelled claims cannot start, cached redirect documents are rejected instead of being served incorrectly as HTTP 200, and the Playwright container now drops Linux capabilities and forbids privilege escalation. Startup recovery and a request-atomic immutable-generation serving pointer remain the next architecture steps and are documented in the audit report.Pre-render publication, maintenance status, and cancellation are now fail-closed across blue/green operation. Route aliases that redirect (
/development-status, tenant-only hOUR pages, and the build-dependent marketplace map) are no longer planned for the wrong tenants; malformed or partially rejected sitemap locations stop an authoritative reset. Root and query-string maintenance snapshots retain HTTP 503 withRetry-After, the status map is persisted beside snapshots on the shared volume and verified before color cutover, targeted jobs cannot change status-bearing snapshots, rejected authoritative output reports zero published pages, and owner-qualified lease files let reset/reaper revoke a publisher before waiting on the mutation barrier. Path traversal, symlink escape, unsafe manifest/output, and spreadsheet-formula export cases are rejected.Courses now participate fully in tenant pre-rendering. Feature-gated course list/detail routes are included in sitemaps and complete rebuilds, with course/section/lesson observers and drift timestamps keeping snapshots current. Podcast routes were subsequently removed from public sitemaps and pre-rendering when same-tenant authentication became mandatory, preventing identity-bearing member content from entering anonymous caches.
Render-affecting tenant settings now commit with durable rebuild intent. General settings validate every field before the first write; reserved or duplicate slugs and malformed/duplicate domains are rejected through the shared hierarchy validator. Branding/content settings—including tenant landing-page layouts, feature/module gates, SEO metadata, header colours/logos, partner marks, and powered-by images—commit with a tenant rebuild job, while slug/domain/maintenance/hierarchy/activation/deletion changes commit with an authoritative rebuild job in the same database transaction. If the queue intent cannot be written, the setting or routing mutation rolls back instead of returning success with stale snapshots. Tenant routing identity is now platform-super-admin-only and core platform hosts cannot be claimed as tenant domains. Request-time
CREATE TABLE IF NOT EXISTSwas removed because MySQL's implicit DDL commit could previously defeat that rollback; missing settings schema now fails explicitly and must be repaired through migrations.The E2E smoke suite no longer fails on a login rate-limit. Its
primeApiAuthhelper logged in per test (an admin and a user each), firing ~40POST /api/auth/loginrequests within a minute from the CI runner's single IP and tripping the login limiter (routethrottle:30,1plus theApp\Core\RateLimiterbrute-force check) — roughly 11 succeeded and the rest 429'd withrate_limited, failing the smoke deploy gate. The helper now caches tokens per role (one login per role per worker, reused across every test) and honours the throttle'sretry_afterwith a bounded retry as a backstop. Test-harness only — no production rate limits were changed.The in-app "Report a problem" button no longer crashes the error-boundary fallback.
ReportProblemButton(and its floating wrapper) calleduseAuth(), which throws when rendered with noAuthProviderancestor — but the top-levelErrorBoundary(App.tsx) sits above the per-routeAuthProvider(provided insideTenantShell), so any recovery fallback that surfaced the report button re-crashed and escalated to the bare root boundary, defeating the recovery UI exactly when it was needed. A new non-throwinguseAuthOptional()hook returnsnulloutside a provider instead of throwing; the button now readsuseAuthOptional()?.isAuthenticated ?? false. Regular feature code keeps usinguseAuth(). Covered by component and context tests.Restored the
react-frontendnpm lockfile to a statenpm ciaccepts. A hand-edit toreact-frontend/package-lock.jsonhad dropped the nestedheroui-cli/node_modules/rxjs@7.8.2entry (and inconsistently bumped two transitive deps) while changing no real dependencies — thepackage.jsondiff was scripts-only — so everynpm cistep failed withMissing: rxjs@7.8.2 from lock file, turning the CI Pipeline React build, Lighthouse CI, and the Security Scan OWASP audit red. Restored the byte-exact lockfile from the last commit whosenpm cipassed in CI; verified the committed file carries all fourrxjsentries.Fixed the accessible (GOV.UK) volunteering parity test after the
govuk-tabs→ semantic<nav>change. The audit-polish batch correctly replaced the volunteering section switcher'sgovuk-tabsmarkup with an aria-labelled<nav>of links (govuk-tabsis reserved for in-page JS panel switching), but the inheritedGovukAlphaFrontendTest::test_volunteering_pages_render_opportunity_detail_and_application_flowstill assertedclass="govuk-tabs ", so it failed across all six subclasses that inherit it and turned the PHPUnit suite red. The assertion now targets the new sub-nav (tabs_titlearia-label + tab label); the/alpha → /accessibleslug rename and routes were already correct.React frontend audit remediation: closed the HeroUI v3 accessibility, nested-interactive, locale-formatting, admin-i18n, responsive-header, PWA, performance, and semantic design-token findings. Added blocking contracts for nested controls, browser-locale formatting, admin literals, and raw brand palettes; the authenticated live accessibility matrix now passes 8/8 with zero Axe violations.
Frontend localization and state reliability: completed all ten non-English locale catalogs with interpolation parity, translated genuine admin copy, preserved technical-literal suppressions narrowly, and replaced fabricated analytics zeroes with explicit loading/error/empty/stale states. Machine-assisted secondary translations remain flagged for native-speaker review.
Canonical platform legal pages now state their language policy: Platform Terms, Privacy, and Disclaimer retain authoritative English legal bodies while a translated notice explains that policy and says the English text controls if translations differ. No substantive legal text was machine-translated.
Accessible (GOV.UK) frontend audit pass — 46 polish and correctness fixes across ~40 pages (2026-07-10 full audit: 574-request crawl of all 287 routes, two blade-by-blade reviews of all 265 templates). The audit found zero 5xx errors, zero broken route references, and near-perfect i18n discipline; the fixes close the gaps it did find. Highlights: the premium page's monthly/yearly choice was JS-only — without JavaScript (this frontend's core audience) every subscribe silently posted monthly; each tier now renders its own no-JS interval radios and the inline script is gone, prices gain a currency symbol from the tenant currency, and the regression test asserts the radios and the script's absence. Organisation-jobs JSON-LD was HTML-escaped inside its
<script>tag (invalid JSON, invisible to search engines) — now emitted withJSON_HEX_*flags. A nonexistentgovuk-warning-text__assistiveclass (v6 removed it) rendered the "Warning" prefix visibly on the notifications and feed pages — replaced withgovuk-visually-hidden. Every remaining one-click destructive action gained the no-JS<details>+warning confirmation pattern (job alert/posting deletes — which previously relied on a JS-onlyconfirm(), listing delete, poll deletes, connection remove/cancel ×4, group-member removal, passkey removal), and credit-moving forms (wallet transfer, wallet-manage, member-profile transfer) now require a native-requiredconfirmation checkbox. Help-centre FAQ answers are sanitized at render (HtmlSanitizer::sanitizeCms) as defence-in-depth instead of trusting write-time sanitization alone. Ratings no longer pre-select 5 stars (profile, reviews) and the exchange rating select starts on a "Choose a rating" placeholder; message delete defaults to "Delete for you" instead of "Delete for everyone"; RSVP radios no longer pre-select "Going". The listings "Load more" link preserves theneardistance filter; login failures now mark the email field with a proper inline GOV.UK error; course enrolment no longer renders two<h1>s; group and group-exchange detail pages gained back links; the profile-delete error summary moved above the heading; wallet and volunteering cross-page "tabs" became real<nav>sub-navigation; session device types and community-project statuses are translated (newgovuk_alpha.ux.*keys, all 11 locales); the jobs bias-audit back link pointed at a route that never existed (/alpha/admin) and now returns to the jobs index; plus table captions,@phpblock-form conversions per the project's Blade hazard rule, dead-code removal, poll-percentage rounding, dashboard feed permalinks, and screen-reader context for rating values and goal progress bars.The later Settings tabs are reachable on mobile again — Security, Skills, Availability, Linked/Connected Accounts, Safeguarding and Translation were all trapped off-screen on a phone. The Settings page has 10 tabs but only ~3 fit a phone width; the strip was meant to scroll sideways, but its
overflow-x-auto/scrollbar-hideclasses were applied to HeroUI's inner.tabs__list(which ismin-w-maxand never scrolls itself) instead of the real scroller,.tabs__list-container— and with no visible scrollbar and only a faint edge gradient, a touch swipe on the 32px strip was routinely lost to vertical page scroll. The user report was concrete: a member could not scroll past Profile/Notifications/Privacy to reach the Safeguarding tab and revoke a safeguarding preference (e.g. "only be contacted by DBS/vetted members"). The sharedTabswrapper gains an opt-inscrollAffordanceprop, enabled on Settings: edge ‹/› chevron buttons that appear only when there is more to scroll (and hide at each end), plus the selected tab is auto-scrolled into view so deep links like?tab=safeguardingland visibly. The buttons arearia-hiddenand non-focusable — keyboard users already get React Aria's arrow-key navigation with scroll-into-view, so they add no duplicate tab stops and no new translatable strings. Fixing it surfaced a second latent bug caught in a real browser: the new scroll wrapper is a flex item of HeroUI's column.tabsflexbox, so withoutmin-w-0itsmin-width: autoexpanded to the full tab-strip content width and defeated the inner overflow scroller entirely (the un-wrapped container had dodged this only because its ownoverflowgives itmin-width: 0). Verified at 375px — all 10 tabs reachable, the last tab fully visible at the end, chevrons toggling correctly at start/middle/end — with 6 new regression tests on the sharedTabscomponent (including a guard on the load-bearingmin-w-0).Passkey (biometric) login and setup now work on tenant custom domains. The WebAuthn RP ID was a single platform-wide value (
WEBAUTHN_RP_ID=project-nexus.ie), but five production tenants serve the React app from their own domains (e.g.hour-timebank.ie) — and WebAuthn requires the RP ID to be a registrable suffix of the page's domain, so on every custom-domain tenant the browser rejected the ceremony before it started (The RP ID "project-nexus.ie" is invalid for this domain) for both passkey registration and login; theHTTP_HOSTcode fallback could never help because production API calls are cross-origin (api.project-nexus.ie).WebAuthnController::getRpId()now derives the RP ID from the request'sOriginheader validated against the tenant's registered domains (tenants.domain,tenants.accessible_domain) plus the configured platform default — a forged Origin cannot mint tokens because lbuchs/WebAuthn checks the browser-signedclientDataJSON.originagainst the same RP ID at verify time, and credentials stay scoped to the RP ID they were registered under. Compounding fixes from the same audit: the login page silently swallowed every passkey failure except "cancelled"/"not found" (an RP ID failure looked like the button doing nothing) — unexpected failures now surface a translated toast; the settings page toasted the raw untranslated browser exception (the exact toast in the user report) — known failure classes now map to translated messages (newpasskey_error_domain/passkey_login_failed/passkey_cancelledkeys, all 11 locales) with the raw error logged for diagnostics; frontend error classification uses SimpleWebAuthn's structured error codes (ERROR_INVALID_RP_IDetc.) instead of fragile message substrings; WebAuthn challenge tenant-binding now actually engages for the stateless React flows (the store read$_SESSION['tenant_id'], which is unset there, so the cross-tenant replay guard silently never fired — it now usesTenantContext::getId()); the per-user registration-challenge rate limit of 3 per 10 minutes (a user retrying a failing setup locked themselves out after three clicks) is raised to 10; and the stale AGENTS.md claim that production compose derives the RP ID from an HTTP_ORIGIN fallback is corrected. Slug-only sub-tenants served at a parent's custom domain (e.g.stratfordatuk.timebank.global/stratford— no domain of their own,parent_idset) inherit the parent tenant's domains as valid RP IDs, mirroringTenantContext::getFrontendUrl()'s parent lookup; credentials remain tenant-scoped inwebauthn_credentials, so a shared RP ID cannot cross-authenticate tenants. Regression tests pin per-tenant RP ID derivation (custom domain, multi-label custom domain likeuk.timebank.global, sub-tenant inheriting the parent domain, platform domain, unrecognised origin falls back to the platform default) and the frontend error classification. Note: passkeys are inherently scoped per domain — one registered onapp.project-nexus.ieis a separate credential from one registered on a tenant's custom domain (custom-domain users had no working passkeys before, so nothing existing breaks).The "Set Your Availability" grid in profile/settings is clickable again — every cell had collapsed to a 2px-wide sliver. Each day/time cell is a HeroUI
Button, and HeroUI v3's base.buttonstyle appliesw-fit(width: fit-content). The cells have no text content andp-0, sofit-contentshrank each one to ~2px (just its 1px borders) — an unhittable click target, which rendered the whole grid as faint vertical lines instead of a grid of boxes (customer report: "cannot click on any of the times or days"). The appended utilities (h-6,p-0,min-w-0) already override HeroUI's default height/padding, but nothing overrode the width, sow-fitsurvived the HeroUI v3 button migration. Fix is a single class: the cell button now carriesw-full, so it fills its1frgrid track (~80px) and presents a full-size click target. Affects both the editable settings grid and the read-only profile display (which showed the same slivers). Verified in a real browser via coordinate-based hit-testing — pre-fix the cell centre resolved to no element and toggling never fired; post-fix a real pointer click flips the slot, turns it green, and reveals Save. No new strings; SPDX/i18n unaffected.Internal federated credits are now visible in the receiver's wallet. A cross-tenant transfer (
FederationV2Controller::sendTransaction) writes its canonical ledger row in the SENDER's tenant (transactions.tenant_id= sender tenant,is_federated=1), and the receiver's wallet reads through theTransactionmodel's tenant scope — so the receiver's balance rose with no ledger line to see, audit or dispute (2026-07-10 federation audit B1). Rather than dual-writing afederation_transactionsrow (which would double-count every internal transfer inFederationInternalLedgerService's admin totals, since that service deliberately sums both tables),WalletServicegains a receiver-scoped read overlay: the transaction list's page-1 federation overlay, the balance summary'stotal_earned/count, and the single-transaction detail view now also surface internal federated inbound rows (scoped byreceiver_id+receiver_tenant_id= viewer, recorded in another tenant,deleted_for_receiverrespected), rendered with the sender's name and community like external inbound credits. Regression test pins that the receiver sees exactly one credit row (list, detail and total_earned) and the sender sees exactly one debit row with no duplicate overlay.Federation browse endpoints no longer 500 on array-valued query params, and federated transfers reject fractional amounts instead of silently truncating them.
?partner_id[]=1reachedstr_starts_with()as an array before the endpoints' try/catch (uncaughtTypeError→ HTTP 500 on members/listings/events/groups); a newqueryScalar()coercion treats non-scalarpartner_id/q/skills/service_reach/typeinput as "no filter".sendTransactiondid(int) $amountbefore the range check, so"5.9"transferred 5 — rounding in the sender's favor; non-integer amounts are now rejected with a validation error (reusing the existing "whole hours" message). The receiver-creditUPDATEinside the transfer is now rowcount-guarded like the sender debit, rolling back if the receiver row vanished mid-transaction instead of debiting the sender with no matching credit. The mutating federation endpoints that had no rate limiting (opt-in/opt-out/setup, settings update, message mark-read single/batch, connection accept/reject/remove) now carrythrottlemiddleware matching the style of the already-throttled send endpoints. ThememberReviewshardcoded English'Anonymous'is now__('api.fed_review_anonymous')(translated across all 11 locales), andFederationUserService::getTrustScore()now scopes reviews byreceiver_tenant_id(with the legacy null fallback and status filter) exactly like the member profile's review list and reputation aggregate — previously the same profile could show disagreeingtrust_scoreandreputation_scorebecause the two paths selected different review rows (2026-07-10 federation audit B2–B7). Regression tests cover the array-param 200s, the fractional rejection, and the receiver-wallet visibility.Credit agreements between communities now require genuine two-sided consent and follow a real state machine. The admin credit-agreement action endpoint mapped
approve|reject|suspend|activate|reactivate|terminatestraight to a rawUPDATE ... SET statuswith no current-state check — any state could jump to any state (including resurrecting a terminated agreement), and the tenant that CREATED an agreement could immediately "approve" it toactivealone, unlocking the v1 federated-transfer gate without the counterparty ever consenting (2026-07-10 federation audit C1). The dual-consent logic already existed inFederationCreditService::approveAgreement()(per-partyapproved_by_from/approved_by_tocolumns, TOCTOU guards, row-locked activation) — the controller just never called it.action('approve')now routes through that service (so approval only records the acting tenant's consent and the agreement activates only when BOTH sides have approved), requires an active partnership between the two tenants before activation, and every other action passes a legal-transition guard (pending→terminatedvia reject,active⇄suspended,→terminatedfrom any live state, terminated is final) returning 409INVALID_TRANSITIONotherwise. Also from the same audit tranche: partnership permission edits (updatePermissions) are now rejected on non-active partnerships, unknown permission keys fail loudly instead of being silently ignored, and no flag can be switched on beyond what the partnership'sfederation_levelgrants by default (a level-1 discovery partnership can no longer be handedtransactions_enabled); the federation data-import only accepts partnership rows where the importing tenant is the initiating side, closing the path where a super-admin could import a fabricated "pending request from" another tenant and self-approve it to active (C3); the directory profiledescriptionis length-clamped like the sibling fields (C4); the platform-wide federation handlers (system controls, emergency lockdown, global whitelist, suspend/reactivate/terminate any partnership) now callrequirePlatformSuperAdmin()in-controller as defense-in-depth beyond the route middleware (C5); and counter-proposed partnership levels are clamped to the system-wide max federation level (C6). Sixteen new regression tests pin the state machine, dual consent, partnership requirement, permission guards, and import scoping.Federation React i18n pass: the admin Aggregates page, reviews panel, toasts and level labels now actually translate. The entire Federation Aggregates admin page referenced a
federation_aggregates.*key block that existed in no locale — every label, chip, button, modal and toast rendered as raw keys likefederation_aggregates.consent.title; the full block now exists inadmin_federation.jsonacross all 11 locales.FederationReviewsPanelreferenced missingreviews.empty/reviews.load_error/reviews.verifiedkeys (the empty state hits every federated profile with zero reviews) — added to all locales — and its 404 detection now tests the api client'scodefield (NOT_FOUND/HTTP_404) instead of astatusfield the failure envelope never carries plus a locale-fragile English regex. The settings toggle success toast interpolated hardcoded English'enabled'/'disabled'into translated sentences (newsettings.action_enabled/_disabledkeys, all locales); the messages compose modal's'Recipient'/`User #${id}`and the events card's'Organizer'fallbacks are now translated keys; the Analytics "Recent Errors" chip rendered mojibake—(double-encoded em-dash, also fixed in two Verein federation cells); and partner federation-level chips across the hub, partners list/modal and partner detail now prefer the translatedpartners.level_*keys over the backend's always-Englishfederation_level_name(external partners get the existing translatedexternalkey). Three pages read the federation opt-in status from fields the/v2/federation/statusendpoint never returns (user_opted_in,status.user_optin) — a new sharedlib/federationStatus.tshelper reads the realfederation_optin/enabledfields and is used by the onboarding redirect, messages page and member profile (2026-07-10 federation audit A1–A8).Federation React UX pass: opted-out users now get an opt-in CTA instead of fake empty states, plus a batch of small fixes. The members/listings/events/groups browse endpoints 403 (
FEDERATION_NOT_ENABLED) for users who haven't opted into federation, but the four pages rendered "nothing found" (members: an un-retryable error) — compounded by the partner filter dropdown populating anyway, since/partnersdoesn't require opt-in. All four now detect the code and render a sharedFederationOptInNoticelinking to the onboarding wizard, exactly like the messages page already did (RF8). Also: the hub's three data fetches now honour itsAbortController(the signal was created but never passed) and the effect aborts on unmount (RF1); the members grid hides "Send message" for members whosemessaging_enabledis false instead of letting the backend 403 after composing (RF2); an inbound realtime message for the currently-open thread is now marked read immediately instead of sticking as unread until the next thread switch (RF3); the hub quick-links gain the routed-but-unreachable Groups and Connections pages (index-keyed labels shifted so Settings keeps its translations, new labels in all 11 locales) (RF6); the reviews panel no longer double-fetches when the i18n namespace loads (tRefpattern) (RF7); the super-admin FederationWhitelist page's loading state gainsrole="status"/aria-busyand a load failure now shows an error toast instead of silently rendering an empty whitelist (newfailed_to_loadkey, all locales) (RF5); and four federation admin files' SPDX headers moved from below the imports to the top of the file (RF4).Accessible (GOV.UK) frontend federation pass: honest hub stats, safe status params, and paginated connections. The hub's "Federated messages" stat counted both copies of the dual-insert (outbound sender copy + inbound receiver copy match the same sender/receiver columns), showing ~2× the React figure — it now applies the same direction filter as the React API (a member with 5 sent + 3 received sees 8, not 16; regression test) (B8). The
?status=query param was interpolated raw into__()on four pages (messages, conversation, transfer, connections), echoing arbitrary input back as a literal translation key inside a GOV.UK error summary — all four now whitelist the known status values exactly like the member page already did (AF2). The federated listing detail image now gets the same URL-scheme guard as the member avatar (AF3). And the connections list — previously a flat 100-row cap that silently dropped a member's older connections — now has GOV.UK previous/next pagination (50/page, wallet-history pattern,fed2.connections.pagination_*keys reusing each locale's existing pagination translations) (AF4).Federation "Browse Members" from a partner community now filters to that community. The partner-detail page's "Browse Members" button links to
/federation/members?partner_id=X, butFederationMembersPagenever readuseSearchParams, so thepartner_id(and anyq/skills/service_reach) in the URL was ignored and the page always listed members from every partner community — while the sibling "Browse Listings" worked becauseFederationListingsPagedoes initialise its filter from the URL (2026-07-10 federation audit). The members page now initialises its partner/search/skills/service-reach filters from the query string and syncs them back (so back/refresh preserves them), matching the listings page. No backend change was needed — themembers/listings/events/groupsendpoints already honourpartner_idand gate it behind an active partnership, so the filter can only narrow within the partnership, never reach a non-partner tenant.Federation partner-detail permission chips, level labels and the onboarding service-reach summary no longer render raw i18n keys.
FederationPartnerDetailPagereferencedpartner_detail.permission_*/partner_detail.level_*keys that don't exist (the real keys live under thepartners.*block, used correctly by the partners list) andFederationOnboardingPagereferencedonboarding.reach_label_*(the keys areonboarding.reach_*), so the six permission chips, the level fallback label, and the onboarding confirmation summary showed literal strings likepartner_detail.permission_profiles(2026-07-10 federation audit). All three now point at the existing keys — a code-only fix with no locale-file or translation-parity changes. The/v2/federation/ingest/*endpoints authenticate afederation_api_keysrow, but the acting external-partner identity — and with it the per-partnerallow_*permission flags — was chosen from the client-suppliedX-Federation-Partner-IDheader, constrained only to the same tenant. A key issued for partner A (e.g. volunteering disallowed) could claim partner B's id and write, overwrite, or mass-retract B's federated opportunities/listings/events under B's permissions (2026-07-10 volunteering audit M3; cross-tenant isolation was never affected). The partner identity is now a server-side binding: a newfederation_api_keys.external_partner_idcolumn links each key to its partner row, the ingest controller resolves the acting partner from the authenticated key only and ignores the header entirely, and an unlinked key resolves no partner so every permission flag fails closed. Admins can set the binding when creating a key (external_partner_idonPOST /v2/admin/federation/api-keys, validated tenant-scoped) and see it in the key listing. Regression tests pin that a bound key acts only as its own partner regardless of the header and that an unlinked key claiming a partner via header is rejected with no shadow write.Suspended volunteer organisations can no longer receive wallet deposits via the accessible frontend. The hard-freeze on non-approved orgs lived only in the React API controller, so the accessible (GOV.UK) frontend — which calls
VolOrgWalletService::depositFromUser()directly after its owner/admin gate — let members keep moving time credits into a suspended or still-pending org's wallet (2026-07-10 volunteering audit M2). The freeze now lives insidedepositFromUser()itself, checked on the locked org row before any balance moves, so every current and future caller inherits it; reuses the existingapi.volunteer_org_not_activemessage. Regression test pins that deposits into suspended and pending orgs are rejected with no balance change and no transaction row.Partially refunded Stripe donations can no longer over-claim Gift Aid or overstate giving-day totals.
charge.refundedfires for partial refunds too, but the webhook handler ignored them entirely: the donation stayedcompletedat its full amount, the giving day'sraised_amountstayed overstated, and areadyGift Aid row was exported to HMRC at the full amount — claiming tax relief on money already returned to the donor (2026-07-10 volunteering audit M1). A newvol_donations.amount_refundedcolumn (migration backfills fully refunded rows) now records Stripe's cumulative refund total, delta-applied under a row lock so replayed webhooks and successive partial refunds decrement the giving day exactly once per refunded slice; the donation deliberately stayscompleted(it is still partly a live gift). The Gift Aid export and the ops overview'sready_centsnow claim only the retained amount (amount − amount_refunded, fully netted rows excluded), a partial refund of an already-claimed donation flagsrefund_after_claimfor the next HMRC adjustment exactly like full refunds, the full-refund path decrements only the still-unaccounted remainder after earlier partial refunds, and annual receipt rows expose the refunded amount. Regression tests cover cumulative delta-idempotency, the netted export/overview, and the claimed-then-partially-refunded flag.A minor volunteer can no longer grant their own guardian consent (safeguarding). Two coupled defects from the 2026-07-10 volunteering audit (C1 + H1): the minor's own consent list,
GET /v2/volunteering/guardian-consents, did a bare->get()and returned every column includingconsent_token— the very secretrequestConsent()was hardened never to hand the minor — and the public verify endpoint performed the one-waypending → activegrant on an unauthenticated GET, so anyone holding the URL (the minor, or a mail-scanner prefetching the guardian's email link) could record legal consent. Together they let a minor request consent with any email, read the token from their own list, hit the verify URL, and pass everycheckConsent()safeguarding gate with zero guardian involvement. Fixes:getConsentsForMinor()now selects an explicit column list excludingconsent_token/consent_ip(mirroringgetConsentsForAdmin()); the grant is now POST-only (POST /v2/volunteering/guardian-consents/verify/{token}), with the GET on the same URL demoted to a read-only status lookup that never mutates; the ReactGuardianConsentVerifyPagekeeps its explicit human "Confirm my approval" tap, now issuing the POST. No new locale keys (reusesapi.vol_consent_invalid_token). Regression tests pin that the minor's create response and consent list contain no token, that GET verify leaves the rowpendingeven with the real token, and that POST verify still grants.Feed post saves and bookmarks work again after the 2026-07-09 cross-tenant fix mapped
'post'to the wrong table. That fix (2e28315e5) added an item-existence check toBookmarkService::toggle()andSavedCollectionService::saveItem(), but both type→table maps pointed'post'at the legacy blogpoststable instead offeed_posts. Every real feed save/bookmark — the React FeedCard bookmark button and the accessible frontend'sstoreFeedPostSave, which both send afeed_postsid — ran the check against the wrong table and failed with “Item not found”, which surfaced as the GOV.UK feed-save parity tests going red across four PHP shards in CI. Both maps now resolve'post'→feed_posts(preview/title from the post content); a regression test pins that a same-tenant feed post is bookmarkable.Reviews can no longer be fabricated against transactions the reviewer wasn't part of.
ReviewService::create()validated only thatreceiver_idandtransaction_ideach exist in the tenant — never that they were related or that the reviewer took part — so any member could attach a review of anyone to any tenant transaction id, and rotate through ids to bypass the 24-hour anti-spam window (review-bombing) (2026-07-10 accessible-frontend audit P2; shared with the React API — this fixes both). Creating a review with atransaction_idnow requires the reviewer and receiver to be the two parties (sender_id/receiver_id) of that transaction.A paid marketplace order can no longer be silently cancelled without a refund.
MarketplaceOrderService::cancel()blocked shipped/delivered/completed/refunded orders but allowed apaidorder to be cancelled — which voided the order and restored inventory while never returning the buyer's captured Stripe payment, leaving them charged with no goods and no refund (2026-07-10 accessible-frontend audit P3, money; shared — the React API cancel endpoint calls the same service, so both surfaces are fixed). Cancellation is now allowed only before payment; the accessible-frontend order pages no longer offer "cancel" on a paid order. Refunding a captured payment on cancellation is a separate payments feature (the refund engineMarketplacePaymentService::processRefundexists but is not yet wired to any user action and needs a staging Stripe test before launch). Unit tests pin that shipped/completed/refunded and now paid orders are rejected.Ranked-choice poll ballots are now validated on submit.
PollRankingService::submitRanking()inserted whatever option ids were posted, with no check that the poll was ranked-type, still open, or that the options belonged to it — so junk option ids could be inserted and inflate the voter count and per-option tallies in the results (2026-07-10 accessible-frontend audit P3; shared with the ReactPollsController::rank). It now rejects the ballot unless the poll is ranked-type and every submitted option belongs to that poll.Accessible frontend: a drip-scheduled course lesson can no longer be completed before it unlocks.
commerceCompleteLessonmarked any lesson complete without the drip-availability check the React API enforces, letting a learner skip ahead of the schedule and drive course completion early (2026-07-10 accessible-frontend audit P3). It now mirrorsCourseEnrollmentController— a locked lesson is refused with a "not yet available" notice instead of being completed.Accessible frontend: federated member reviews now respect the reviewer's cross-tenant opt-out. The two federated-review queries (member review list + reputation average) matched every review targeting the viewing tenant, dropping the
review_type = 'federated'andshow_cross_tenant = 1conditions the canonicalReview::scopeWithFederatedrequires — so a reviewer who opted out of cross-tenant display still appeared, and counted, in a partner community's view (2026-07-10 accessible-frontend audit P3; parity with the same gap inFederationV2Controller). Both queries now carry the opt-out conditions.Accessible (GOV.UK) frontend: group join/leave, goal create/progress/complete and organisation registration no longer 403 when a tenant disables the group-exchanges feature. These six accessible-frontend actions used
geGuard()— a helper that also assertshasFeature('group_exchanges')— as their auth guard, then applied their real gate (groups/goals/volunteering) on the next line, so a tenant with group-exchanges off but those modules on got a 403 while the pages still rendered the forms (2026-07-10 accessible-frontend audit P2; latent because the feature defaults on). A new feature-agnosticalphaAuthGuard()(slug assertion +currentUserId()+ login redirect) now guards the six handlers;geGuard()stays reserved for the genuine group-exchange handlers.Accessible frontend: resource admin controls (reorder, cross-member delete) now work for tenant admins.
resourcesUserIsAdmin()checkedAuth::user(), but the accessible frontend authenticates with a statelessauth_tokencookie and never populates a Laravel guard, so it returned false for every user including admins — reorder always 403'd and the admin delete fallback never applied (2026-07-10 accessible-frontend audit P2). It now resolves the role viacurrentUserId()and a tenant-scopeduserslookup, mirroring the workingideationIsAdmin().Accessible frontend: the buyer marketplace "Active" orders tab is no longer always empty, and order tabs no longer hide
delivered/refundedorders. Tab names were passed straight through asmarketplace_orders.statusvalues, butactive/completed/cancelledare UI groupings, not enum statuses — the buyer Active tab matched zero rows, and Completed omitteddeliveredwhile Cancelled omittedrefunded(2026-07-10 accessible-frontend audit P2/P3). A sharedcommerceOrderStatusFilter()now maps tabs to the status sets used by the React BuyerOrdersPage/SellerOrdersPage (active→paid,shipped;completed→completed,delivered;cancelled→cancelled,refunded), and the seller dashboard gains a Cancelled tab so cancelled/refunded sales are visible outside "All" (reuses existingorders.tab_cancelledkey).Accessible frontend: editing a listing no longer silently re-activates a sold, expired or removed listing. The shared listing-input builder sets
status='active'(correct for create), but the edit path passed the same payload toMarketplaceListingService::update, so any edit of a sold listing flipped it back toactiveand re-purchasable, and resurrected expired/removed listings without the renew flow'sexpires_atextension (2026-07-10 accessible-frontend audit P3, money-adjacent). The update path now stripsstatusfrom the payload; lifecycle status changes only through the explicit renew flow.Accessible frontend: hardened
currentUserId()so a stale session identity can't act cross-tenant. The tenant-membership + active + approval re-check (validatedTenantUserId()) ran only on the token auth branches; theAuth::user()and native$_SESSION['user_id']branches returned the id unchecked. The live alpha path (token cookie) was already validated so there was no reachable exploit, but if either branch were ever populated on the shared host a tenant-A identity could act under tenant-B's slug and a suspended user with a live session wouldn't be re-blocked (2026-07-10 accessible-frontend audit P3, defense-in-depth). All three branches now route throughvalidatedTenantUserId().~40 admin and member API route blocks now enforce auth at the middleware layer, not just inside controllers. The FADP compliance, residency-verification, ad-campaign, push-campaign, KI-agent, AI-module-docs, AI-trace-metrics, regional-analytics, pilot-inquiry and api-partner route blocks carried no route-level
auth:sanctum/adminmiddleware — every controller self-checked identity/role, so there was no live bypass, but those helpers don't re-check account status or token↔tenant binding, meaning a suspended or banned user with a live session wasn't blocked, and any future forgotten controller guard would have been world-reachable (2026-07-09 platform audit P2). All/v2/admin/*blocks are now wrapped in['auth:sanctum','admin']and their/v2/me/*counterparts inauth:sanctum, with controller checks kept as a second layer. Deliberately-public endpoints (ad serving + impression/click beacons, survey listing, the throttled pilot-inquiry lead form) are annotated and untouched, as are the two blocks that must stay auth-only because non-admin roles legitimately use them (municipality announcers for surveys; safeguarding officers/coordinators/brokers for safeguarding). A 29-test regression suite pins 401 for anonymous and 403 for plain members on a representative route from every hardened block, and that the public endpoints stay public.Members can no longer probe or drain the tenant's AI provider budget via
POST /ai/test-provider. The endpoint only required a logged-in user and had no rate limit, so any member could enumerate which AI providers a tenant has configured and loop connection tests that spend real provider API credit (2026-07-09 platform audit P2). It now requires an admin (bothrequireAdmin()in the controller andadminmiddleware on the route) and is throttled to 10 requests/minute. Regression tests pin 401/403/200 for anonymous/member/admin.Event categories can no longer cross tenants.
EventService::resolveCategoryId()looked up a user-suppliedcategory_namewith no tenant filter (first match from any tenant won) and stored a caller-suppliedcategory_idwith no ownership check, so a foreign tenant's category name/colour could surface in this tenant's event joins (2026-07-09 platform audit P2). Both branches are now tenant-scoped — a foreign or nonexistent id/name resolves tonull(uncategorized) instead of attaching foreign data — and the update path validates raw ids the same way. Regression tests cover foreign id, foreign name, same-name-in-both-tenants, and nonexistent id.Creating, editing, deleting and renewing listings no longer fake success when the API rejects the request. The api client resolves 4xx as
{success:false}without throwing, and these four call sites ignored the envelope: a 422/403/409/429 on create or edit still showed "created/updated successfully" and navigated away (silently losing the user's input), delete showed "deleted" while the listing still existed, and a failed renew just stopped the spinner with no feedback (2026-07-09 platform audit P2/P3). All four now checkresponse.success, surface the API's error detail in an error toast, keep the form state, and stay on the page. Ten Vitest regression tests cover the failure and success paths.Bookmark collections are no longer leaked between users on a shared browser.
useBookmarkCollectionscached the user's private collection names/counts in a module-global variable that survived logout, so after a user switch the next user saw the previous user's collections — and adding a bookmark could write into the previous user's collection id (2026-07-09 platform audit P2). The cache is now keyed by tenant+user, cleared on logout, and refetched when the authenticated user changes; the hook's public API is unchanged. Thirteen hook tests cover the user-switch, logout and cache-hit paths.The organisation detail page no longer crashes when an opportunity has a category. It rendered
{opp.category}directly, but the API returns categorized opportunities' category as an object{id,name,color}— React throws "Objects are not valid as a React child" and the error boundary replaced the whole page (2026-07-09 platform audit P2). A sharedgetOpportunityCategoryName()helper now unwraps string-or-object categories in OrganisationDetailPage, VolunteeringPage and OpportunityDetailPage (the latter had the same latent crash), with unit tests.Seven pages no longer fail silently or show misleading empty states when the API errors. All shared the same root cause (4xx resolves
{success:false}without throwing): Group detail's "generate invite link" did nothing on 403 (now shows the error toast); the Appreciation Wall, Blocked Users (privacy-critical — a failed load claimed "No blocked users", as if blocks had been cleared) and Matches pages rendered their empty states on failure (all three now render an explicit error state with a retry button, never the empty state); Match Preferences left the form editable with defaults after a failed load so saving silently overwrote the member's real preferences (the error screen now replaces the form entirely and saving is blocked until a load succeeds); Goals' "Load more" had no in-flight state so a double-click appended the same page twice with colliding keys (now guarded + spinner); and the Security tab labelled its working sessions endpoint "coming soon" and masked real API errors behind the same copy (now renders real data, a real error + retry, and an honest empty state — the misleadingsessions_coming_soonkey is gone from all 11 locales). Eleven new Vitest regression tests across the batch (2026-07-09 platform audit P3).Event notification emails now render dates and location lines in the recipient's language. Every event reminder/cancelled/updated/created email formatted dates with PHP
date()— English weekday/month names for every locale, even though the surrounding copy was correctly translated — and the reminder body glued hardcoded' (Online)'/' at {location}'connectors into translated strings (2026-07-09 platform audit P3). All five date sites now use CarbonisoFormatin the active locale, and the connectors are translation keys (notifications.event_reminder_location_online/_at) across all 11 locales. A regression test asserts a German-locale reminder renders a German weekday.Three more hardcoded-English surfaces are now translatable: the wallet transfer category picker's label/placeholder, the composer template picker's template titles and bodies (post/listing/event/goal/poll), and the Regional Points history's transaction types, which rendered raw machine codes like
earned_for_hours(now mapped through locale keys with a humanized fallback for unknown codes). Keys added across all 11 locales (2026-07-09 platform audit P3).Approving under an hour of volunteering is now honest in the email channel too. The earlier VOL-BE-008 fix made the bell/push notification say "no time credit was added", but the email still used the celebratory generic "Hours Approved — thank you!" template. Sub-hour approvals now send the same honest no-credit note in the email body (
emails_notifications.volunteering.hours_approved_no_credit_note, all 11 locales), with a regression test on both variants (2026-07-09 platform audit P4).The "new group created" admin email renders its fallback in each admin's language. When the group creator couldn't be resolved, the fanout baked the English string "A member" into every admin's email regardless of their
preferred_language; the translated fallback is now resolved per-recipient inside the locale-wrapped send (2026-07-09 platform audit P4).Group deletion, gamification aggregates and group recommendations now carry tenant filters on their raw queries. Defense-in-depth from the audit's tenant pass: the group-deletion cascade deleted child rows by
group_idalone (parent load was tenant-checked, so not exploitable — but the query shapes were unsafe to copy), gamification XP/badge aggregates counted likes/transactions across all tenants for users belonging to two communities (blending their stats), and two recommendation-engine reads were unscoped intermediates. All now filter bytenant_id; deliberately cross-tenant cron maintenance (CronJobRunner cleanups, identity-verification session expiry/purge, group-challenge expiry) is explicitly annotated as such so the pattern is never copied into request paths (2026-07-09 platform audit P3).AI-chat source links now navigate in-app instead of reloading the whole SPA, the tool-result card's hardcoded "Result" fallback, the share sheet's "Email" label, and the link-preview card's screen-reader alt texts are now translated (all 11 locales), and the Smart Nudges admin chart uses the shared theme-aware chart colours instead of hardcoded light-mode greys that fell below AA contrast in dark mode (2026-07-09 platform audit P4).
Volunteering "Load more" buttons show a spinner and can't double-fire; My Collections validates blank names and distinguishes load errors from "no collections"; the Verein dues "Pay now" button is guarded against double-click (which could surface a spurious Stripe error toast mid-payment); and the Reviews page's dead unreachable error block was replaced by its live per-tab error states (2026-07-09 platform audit P4).
The marketplace seller "Reviews" tab and the admin user-permissions page no longer promise "coming soon" for things that aren't being built. The seller tab (which sat next to a rating chip advertising a review count) now shows honest neutral copy that reviews aren't available and points at the aggregate rating; the admin permissions page now says permissions are read-only there and directs to role management. Old
*_coming_soonkeys removed, new keys added across all 11 locales (2026-07-09 platform audit P4).useApilatent hazards fixed: a null endpoint no longer leavesisLoadingstucktrue, andusePaginatedApino longer jumpscurrentPagetototal_pageswhen the response meta omitscurrent_page(which killed pagination after page 1). No live consumers were affected; hook tests added (2026-07-09 platform audit P4).69 source files' copyright headers normalized to the mandated
© 2024–2026form — they carried ASCII(c)variants that passed the SPDX CI check because it only greps the license line (2026-07-09 platform audit P4).Saved collections and bookmarks can no longer read another tenant's content.
SavedCollectionService::attachPreviews()andBookmarkService::attachTitles()hydrated saved-item previews/titles with a bareWHERE id IN (…)— notenant_idfilter — against tenant-scoped tables (posts, listings, events, groups, pages, marketplace_listings, job_vacancies, resources, group_discussions), and neithersaveItem()nortoggle()validated that the id being saved existed in the caller's tenant. Any authenticated user could therefore save an arbitrary foreign-tenant id and read the hydratedpreview.titleacross the tenant boundary — for posts the preview selectscontent as title, so that's the full post content, drafts included (2026-07-09 platform audit P1). Both layers are now fixed in both services: the hydration queries are tenant-scoped (AND tenant_id = ?, so even a poisoned pre-existing row hydratesnullinstead of foreign content), and save/bookmark creation rejects any(item_type, item_id)that doesn't exist in the caller's tenant with a translated validation error (api.saved_item_not_found, added across all 11 locales). Removal paths (unsave/un-toggle) deliberately skip the existence check so stale bookmarks pointing at since-deleted items can still be cleaned up. Regression tests cover the cross-tenant save/bookmark rejection, the nonexistent-id rejection, the poisoned-rownullpreview/title, and that same-tenant previews/titles still hydrate.Custom-split group exchanges can no longer mint (or destroy) time credits. A
split_type='custom'exchange stored each participant's hours verbatim with no cross-role reconciliation:complete()credited every provider and debited every receiver, so the net ledger change was Σ(provider hours) − Σ(receiver hours) — an organizer could set provider=100h / receiver=1h and mint +99 credits from nothing on completion, repeatably (2026-07-09 platform audit P1).GroupExchangeServicenow enforces the conservation invariant at both gates:start()(early feedback to the organizer, before participants are asked to confirm) andcomplete()(the authoritative gate, since the split can still change between start and complete —update()allowssplit_typeedits). The credits the split would give providers must equal the debits it would take from receivers, computed exactly ascomplete()applies them (rounded to 2dp, ≤0 entries skipped) so a negative-hours row can't disguise an imbalance a plain SUM would miss. Unbalanced splits are rejected with a translated error naming both totals (api.group_exchange_split_unbalanced, added across all 11 locales); equal/weighted splits already conserved by construction and are untouched. Also fixed the adjacent completion-notification bug: per-participant hours were(int)-cast before the bell/push/email, so a 0.5h share moved real money with no notification at all and 1.5h read as "1" — hours now stay float and render as trimmed 2dp ("0.5", "1.5", "6"), so sub-hour shares notify. Regression tests cover the audit exploit at both gates, the negative-hours disguise, a balanced custom split still starting/completing, and the 0.5h notification.Group Exchanges is a working module now — it was broken end-to-end at every step. The whole feature (create a multi-participant time exchange, add providers/receivers, split hours, confirm, complete → wallet settlement) had never been exercised end-to-end; its tests mocked an imagined contract the backend never returned, so they stayed green while production was dead. Fixed, in order of the user journey: (1) the reported bug — in the create wizard's "Add participants" step the search results rendered in an
absolute-positioned dropdown, so when the search box sat low in the viewport the list fell below the fold with no way to scroll to it (only the tops of the green/amber role buttons peeked out — TimeBanking UK's CEO hit exactly this). Results now render inline in normal document flow, so the card grows and the page scrolls, with a capped-height internal scroll for long lists and a "no members found" state. (2) The list was always empty:GroupExchangeController::index()double-nested the envelope ({data:{data:[…]}}); the client unwraps one level, soArray.isArray(response.data)failed and every user saw the empty state even with exchanges — it now returns the collection as the top-leveldataarray withhas_moreinmeta. (3) "Start Exchange" was a silent no-op: itPUT {status}, but the service update allow-list dropsstatusby design, so the exchange was stuck indraftforever and could never be confirmed or completed — added a dedicatedPOST /v2/group-exchanges/{id}/startaction (organizer-only, requires ≥1 provider and ≥1 receiver) that transitionsdraft/pending_participants→pending_confirmation, and wired the button to it. (4) Blank names/avatars:show()/listForUser()omittedorganizer_name/organizer_avatarand returned participants asname/avatar_urlwhile the React pages readorganizer_name/user_name/user_avatar/user_email— the service now joins the organizer and returns both the legacy and the frontend field names, plusparticipant_count. (5) The detail "Hour Split" table rendered garbage: it expected a nested provider→receiver map butcalculated_splitis a flat per-participant list, soObject.entriesproduced nonsense rows — it now renders an honest per-participant credit (+) / debit (−) breakdown, which is exactly what the wallet ledger does on completion. (6) Participant search ignored the query: both pages sent?search=, but the member directory filters on?q=, so it returned an unfiltered member list regardless of what was typed — now sendsq. Adds twoapi.phpkeys (group_exchange_cannot_start,group_exchange_start_needs_participants) and twogroup_exchanges.jsontoast keys (exchange_started,start_failed) across all 11 locales, a full HTTP end-to-end feature test (create → list → show contract → start → confirm → complete → wallet settlement, plus the start authorization/validation paths), and repairs the module's three Vitest suites (their@/lib/helpersmocks were missingresolveThumbnailUrl/cn, so the realAvatar/Modalcrashed and the tests couldn't run). Starting an exchange now notifies every participant (bell + push, in each recipient'spreferred_languageviaLocaleContext, deduped per user, fail-safe) that it's live and awaiting their confirmation — previously it silently changed status and relied on participants noticing the "Needs Confirmation" tab. Addssvc_notifications.group_exchange.needs_confirmationacross all 11 locales and agroup_exchangeicon in the notifications UI; the E2E test asserts each participant is notified and the organizer is not. Both the start prompt and the completion credit/debit now also send an email (in addition to the bell + push) — rendered in the recipient'spreferred_language, sent directly as a force-instant transactional message (bypassing the digest queue, which defaults to off, but still honouring an explicitemail_transactionsopt-out), and fail-safe so a mail error can never unwind the committed status/balance change. The completion email was previously bell + push only. Reuses the existingsvc_notificationsbodies +emails.common/emails.footer/emails.notification.view_walletkeys and adds a 5-keyemails.group_exchangesection (subjects/titles/CTA) across all 11 locales; email HTML + i18n resolution verified inenandfr.Approving under an hour of volunteering no longer implies a credit was added. Time credits are whole hours, so approving a sub-hour log (e.g. 45 minutes) floors to 0 credits — nothing is minted and the org wallet isn't debited — yet the volunteer still got the generic "Your hours were approved!" notification, implying success. That case now sends an honest message ("…that is under an hour so no time credit was added"). Adds
notifications.vol_hours_approved_no_credit_bodyacross all 11 locales (English pending the translation pass). Regression test asserts the sub-hour approval mints nothing, records no transaction, and sends the honest message. (Audit VOL-BE-008.)The webhook "Retry" button is now honestly labelled as a test event. On a failed volunteering webhook, the button (and its success toast) said "Retry", but it actually POSTs a synthetic test event — not a redelivery of the failed events — which could mislead operators into thinking failed deliveries had been re-sent. It now uses the "test event" label and toast; the real failed events are already retried automatically by the
*/5cron (WebhookDispatchService::retryFailed). No new locale keys (reusestest_webhook/webhook_test_sent/webhook_test_failed). (Audit VOL-RX-008.)Nine broken
t()translation lookups across the app now resolve instead of silently falling back to the raw key or hardcoded English. The exhaustive i18n coverage gate (scripts/check-i18n-coverage.mjs, baseline 0) was red onmain: ninet('…')calls referenced keys that existed in no locale JSON — the session-timeout "Log Out" button, the accent-colour swatch aria-label, the code-snippet copy button, the ideation tag-filter aria-label, three ad/marketplace loading aria-labels, the marketplace filter-sidebar aria-label, and the marketplace category-search placeholder. Seven were genuinely-missing keys and are now added to all 11 locales with real translations (common:auth.log_out, top-levelcopy_code,common.loading;marketplace:common.loading,aria.filter_panel,search.category_search;utility: top-levelloading). Two were code mis-references and are fixed at the call site:IdeationPageusedt('tags')wheretagsis an object, nowt('tags.label');ThemePickerusedt('appearance_prefs.select_color', { ns: 'settings' })with thensoption on a separate line — invisible to the line-based checker and (correctly) resolving in thesettingsnamespace — now the equivalentt('settings:appearance_prefs.select_color', …)colon-prefix form. Coverage now reports 0 missing keys and the translation-drift check confirms all 11 locales share an identical key set, somainis green on i18n again. Regeneratedreact-frontend/src/resources.d.ts.The "Community not found" page's "Go home" and "Find a community" buttons work now — they never had since day one. Both were SPA
<Link>s (to="/"andto="/login").TenantShellkeeps a sticky tenant slug for the lifetime of the document (stickySlugRef, added to stopsetSearchParamson tenant pages from momentarily flipping to the master tenant). A client-side<Link>navigation away from an unknown-tenant URL is not a fresh document load, so the bad slug stayed sticky:effectiveSlugfell back to it,TenantProviderkept bootstrapping the missing slug,notFoundSlugnever cleared, and the page simply re-rendered itself — the URL bar changed but the "Community not found" card stayed, so the buttons looked dead. They are now full-document<a href>navigations (/and/login), which is the fresh document load the sticky-slug design already assumed for any tenant/master switch — the sticky ref resets and the tenant re-resolves from the URL/Host. Verified live in-browser:Go home→ master-tenant home,Find a community→ the login page's community chooser. Regression test asserts both render as real anchors with the correcthref.Whole app modules (Courses, Podcasts, Premium, OAuth sign-in, invite links, and more) are reachable again wherever the first path segment is the route. When the tenant is identified by the domain/host — a custom domain like
hour-timebank.ie, or localhost/shared-host path-based dev — the first path segment is the SPA route, butdetectTenantFromUrl()(TRS-001) treats an unreserved first segment as a candidate tenant slug and callstenant/bootstrap?slug=<segment>, which 404s and renders "Community not found". Many real top-level routes had never been added to the shared reserved-path lists, sohour-timebank.ie/coursesandlocalhost:5173/premium— plus/podcasts,/coupons,/clubs,/me/...,/municipality-calendar,/advertise/...,/donations/...,/users/...,/join/<code>invite links, the/auth/oauth/callbackOAuth redirect,/verify-identity-optional, and public/pilot-apply,/developers,/trust-and-safety,/regional-analytics,/partner-analytics,/pricing— were all silently unreachable. Both reserved-path lists —RESERVED_PATHSinreact-frontend/src/lib/tenant-routing.tsandTenantContext::getReservedPaths()in PHP — now include every top-level route name (reserving a route name is always safe; a route name must never be a tenant slug), and a router-derived completeness test now parses the route files and fails CI if any future top-level route ships unreserved — so this class of bug (previously patched one route at a time:/saved, then/courses, then/premium) cannot regress again. A genuine parent-domain child (timebanking.uk/cardiff) still resolves. The bootstrap 404s were only ever recorded as performance transactions, never as errors, which is why no Sentry issue was raised.Volunteer expenses now record the tenant's currency and enforce the monthly cap atomically. Expense submission stored
currencyfrom the client defaulting to a hardcoded'EUR'(and the approval/paid emails fell back to?? 'EUR'), and it read the monthly-cap running total then inserted outside any lock — so a first-time claimant on a non-euro tenant had their reimbursement mislabelled "EUR", and two concurrent submissions each individually withinpolicy.max_monthlycould jointly exceed it (check-then-insert TOCTOU). Submission now persistsTenantContext::getCurrency()(never a euro literal; the email fallbacks resolve the tenant currency too) and serialises the cap read + insert under a per-volunteer/org/monthCache::lock, mirroring the dedupe guard inVolunteerService::logHours. Regression tests cover the tenant-currency default and the lock serialisation. (Audit VOL-BE-001, VOL-BE-002.)Un-sharing a federated volunteer opportunity now retracts it from partners. Turning off "share to the federation network" (
federated_visibilitylisted→none) dispatched an update event carrying the already-unshared row; the push listener's opt-in gate returned before the retraction branch, so noaction='deleted'was ever sent and partner sites kept displaying the withdrawn opportunity indefinitely. TheVolunteerOpportunityUpdatedevent now carries the priorfederated_visibility, and the listener treats alisted→ un-shared transition as a retraction (pushesaction='deleted'keyed on the opportunity id). The existing delete/close retraction (driven byis_active=0) is unchanged. Regression tests cover the un-share retraction and that a never-shared opportunity is not spuriously retracted. (Audit VOL-BE-004.)Recovered volunteers no longer keep a stale "at-risk" wellbeing alert forever. The daily burnout assessment only ever wrote alerts (risk score ≥ 30) and had no path to clear one, so a volunteer flagged months ago who has since re-engaged kept an
activecritical alert carrying the old score/indicators indefinitely, polluting the coordinator safety panel.runTenantAssessmentnow auto-resolves a user's system-raisedactivealert once their recomputed score drops below the threshold (coordinator-managed acknowledged/dismissed states are left untouched). Regression tests cover resolution on recovery and retention while still at-risk. (Audit VOL-BE-005.)Admin donation refunds can no longer double-decrement a giving-day total.
createRefund()checked the donation status on an unlocked read taken before the Stripe call, then its transaction unconditionally setrefundedand decrementedraised_amount— so it could race thecharge.refundedwebhook it triggers and decrement the giving day twice (understating it, possibly negative). The refund-ledger step is now a single shared, row-locked, idempotent helper used by both the admin refund and the webhook: it re-reads the locked row, bails if alreadyrefunded, and decrements only a still-completedrow, so whichever path commits first the total moves exactly once. Regression test drives both paths on the same donation and asserts a single decrement. (Audit VOL-BE-003.)Guardian-consent and check-in token pages now announce their result to screen readers. Both pages swap between confirm / loading / success / error content inside a static card with no live region and no focus move, so a blind guardian or shift coordinator got no announcement of whether their consent approval or check-in/out succeeded (WCAG 2.1 AA 4.1.3). The result region is now an
aria-livecontainer (role="alert"assertive for errors,role="status"polite otherwise) that also receives focus when the async action resolves. Adds the first Vitest coverage forCheckInVerifyPageand asserts the alert region on both pages. (Audit VOL-RX-001.)Right-to-erasure now clears
gift_aid_countryon unclaimed donations. The GDPR Article-17 scrub nulled every gift-aid declaration/address field on unclaimed rows exceptgift_aid_country, leaving e.g. 'GB' behind — a field the data-export code already classifies as the subject's personal data.gift_aid_countryis now included in the scrub (claimed / refund-after-claim rows still retain it under the HMRC record-keeping carve-out), and the erasure regression test asserts it is nulled on unclaimed rows and retained on claimed ones. (Audit VOL-BE-014, VOL-XC-002.)The volunteering bulk-config validation message is now translated.
updateVolunteeringConfigBulkreturned a hardcoded English "Settings array is required" (invisible to the i18n checker) when thesettingsbody was missing or not an array; it now uses the existing translatedapi.missing_required_fieldkey. Regression test asserts the 422 message resolves from the key, not the literal. (Audit VOL-BE-010.)Volunteer notification dates now render in the recipient's language. Shift-reminder emails, donation receipts, admin donation alerts, the emergency-alert bell, and the shift-signup confirmation embedded the date via locale-insensitive
date()/Carbon->format(), so inside an otherwise recipient-locale-translated notification the weekday/month/am-pm was always English (a French volunteer's reminder read "Mon, 14 Jul 2026"). Every such date now renders throughCarbon::parse(...)->locale(app()->getLocale())->isoFormat(...); for the emergency-alert bell and the shift-signup confirmation the formatting was moved inside the per-recipientLocaleContextclosure (it was previously computed once in the worker's default locale). No new locale keys. (Audit VOL-BE-012, VOL-XC-001.)Volunteering data-contract and feature-gating cleanups. Six "my-organisations" / applications call sites hand-rolled a
raw.data?.items ?? raw.items ?? Array.isArray(...)unwrap — several with permanently-dead branches and a comment falsely claiming a{data:{items}}envelope when the endpoint returns a bare array — and now use the testedextractCollectionItemshelper (removing the silent-empty risk and the misleading comments); a new unit test covers the helper's array /{items}/{data:{items}}/ fallback shapes. Separately, the guardian-consent verify route — the only volunteering route wrapped inErrorBoundaryalone — now sits behind<FeatureGate feature="volunteering" redirect="/">like its siblings (still public / token-based). (Audit VOL-RX-002, VOL-RX-003, VOL-RX-005.)Volunteering backend hardening: creator-id disclosure, monthly recurrence, waitlist-expiry scoping. (1) The public opportunities list serialised the creator's internal
users.id; it is now stripped from the response (name + avatar remain), matching the org directory's owner-id stripping. (2) Monthly recurring-shift patterns anchored on day 29/30/31 used an exact day-of-month match, so a day-31 pattern never generated in Feb/Apr/Jun/Sep/Nov; the anchor day is now clamped to the last valid day of each month (matchingEventService). (3)ShiftWaitlistService::expireStaleNotificationsnow scopes its statusUPDATEbytenant_id(defence-in-depth). Regression tests cover the creator-id stripping and the September clamp. (Audit VOL-BE-011, VOL-BE-018, VOL-BE-019.)Donation receipt and check-in QR failures now recover gracefully. A transient receipt-fetch failure dropped into a passive
<p>with norole="alert"and no way to retry (only a full reload); it now renders an alert with a "Try again" button that re-fetches. And when the client-sideqrcodechunk fails to load (stale deploy / offline),QrCodeImageleft anaria-busyplaceholder spinning forever with no fallback — it now renders a usable link to the check-in URL instead. Adds the first Vitest coverage forQrCodeImage. (Audit VOL-RX-006, VOL-RX-007.)Declining a single volunteer application now asks for confirmation. The per-row Decline button in the admin approvals table fired
declineApplicationimmediately on click — a misclick irreversibly rejected the applicant — unlike the bulk decline and the single-decline flows in Hours Audit / Swaps, which confirm first. It now routes through the sameConfirmModal. Regression test asserts the decline only POSTs after confirmation. (Audit VOL-RX-010.)OrgHoursReviewTabno longer mistypes its array endpoint as an object. The pending-hours endpoint returns{ data: [...], meta }(api.get unwraps one level, soresponse.datais the array and cursor/has_more live onresponse.meta), but the code typedapi.get<PendingHoursResponse>with an{ items, cursor, has_more }interface that contradicted its own runtime — a footgun whereresponse.data.itemswould type-check yet beundefined. The generic is nowPendingHourEntry[], the read goes throughextractCollectionItems, and the misleading interface is removed (validated bytsc+ the existing runtime test). (Audit VOL-RX-004.)VolOrgWalletService::payVolunteer()no longer phantom-succeeds on sub-1.0 amounts.users.balanceis whole hours, so a fractional amount below 1 (e.g. 0.5) floored to a 0-hour payment that moved nothing yet still recorded a zero-amountvol_org_transactionsrow, emailed "0 hours paid", and returned success. The method now rejectsfloor(amount) < 1(reusing the existing amount-required message). It has no production caller today — real payouts run throughverifyHours— so this hardens it before any future wiring. Regression test asserts a 0.5 payment is rejected with no ledger row. (Audit VOL-BE-009.)Stripe donation currency-mismatch error and anonymous-receipt label are now translated.
StripeDonationService::createPaymentIntentthrew a hardcoded English "Donation currency must match the community currency." (surfaced to the React form on a mismatched-but-valid currency), and the receipt built an untranslated "Anonymous" donor label — unlike the siblingVolunteerDonationServicewhich uses__(). Both now resolve from existing translated keys (api.vol_donation_currency_mismatchwith the tenant currency,api.vol_activity_donor_anonymous); no new locale keys. (Audit VOL-BE-013.)The accessible safeguarding error summary now links to the offending field. The GOV.UK error summary rendered errors as a bare
<p>with no jump link, unlike the sibling accessible forms — a keyboard/screen-reader user could not jump from the summary to the control. It now renders agovuk-error-summary__listwith an<a href="#field">for each field-level status (training type/name/date, incident title/description); generic/server statuses stay as plain text. No new locale keys. Regression test asserts the summary links to#training_type. (Audit VOL-AF-002.)Accessible warning text no longer announces "There is a problem" for advisory warnings. The
govuk-warning-textvisually-hidden prefix on the emergency-alert accept warning and the group-signup cancel warning reusedshared.error_title("There is a problem"), so screen-reader users heard an advisory warning framed as a validation error. Both now use the existing translatedgovuk_alpha.states.warning_prefix("Warning"). No new locale keys. (Audit VOL-AF-003.)The accessible donations page no longer hardcodes euro. The amount input showed a fixed
€prefix (aria-hidden, so screen-reader users got no currency cue), and the amount/currency hints read "in euro" / "Leave blank to use euro" — misleading for every non-eurozone tenant (donations are actually recorded in the tenant currency). The prefix now shows the tenant's configured currency code and is exposed to assistive tech, and the two hints are currency-neutral ("…for example 25 or 25.50" / "…leave blank to use the community currency") across all 11 locales (the keys were untranslated English placeholders, so the neutral value is applied uniformly). No new locale keys. Regression test asserts the donations page carries no€/"in euro". (Audit VOL-AF-001.)The admin reminder-settings save no longer hides partial failures.
handleSavefired independent per-reminder PUTs withPromise.alland collapsed all outcomes into one boolean, so a partial failure persisted some settings and not others while showing only a generic error and leaving the form on stale optimistic values. It now usesPromise.allSettled, names the reminder key(s) that failed in the error toast, and always reloads from the server afterwards so the form reflects the true saved state. (Audit VOL-RX-009.)Per-tenant reminder sweeps no longer scan every tenant's rows. In the
runAll/forEachTenantcron path,sendPreShiftReminders($tenantId)(and the post-shift, lapsed-nudge, and credential/training-expiry sweeps) plucked the reminder settings and shift/record candidates for ALL tenants, then discarded all but the current one — an O(tenants) full scan per tenant per cycle. Each collection query is now scoped to$onlyTenantIdwhen provided, so a per-tenant call reads only that tenant's rows (behaviour-preserving;restrictToTenantyields the same result). Regression test asserts the pre-shift settings scan carries the target tenant binding. (Audit VOL-BE-015.)Pre-shift reminder loop no longer re-queries per shift and per recipient. The sweep selected only
s.*from its shift↔opportunity join and then re-fetched the opportunity once per shift, and ran anexists()dedup query once per confirmed volunteer. It now selects the opportunity title/location from the join (removing the per-shift re-fetch) and loads the already-reminded user set once per shift (an in-memory check instead of a per-recipient query). Behaviour-preserving — same shifts, same dedup — verified by the reminder integration + unit suites. (Audit VOL-BE-016; the per-recipient user-fetch is left as a further optional optimization.)Admin "Organisation Wallets" dashboard now shows real data instead of empty rows. The
/admin/timebanking/org-walletsoverview queried the abandoned community-org tables (org_wallets/org_transactions/org_type='community'), which nothing writes to, so it always rendered empty. It now reads the live volunteer-org wallet —vol_organizations.balance, thevol_org_transactionsledger (deposits and positive adjustments as money-in, payments and negative adjustments as money-out), and activeorg_type='volunteer'member counts. Regression test seeds an org with a balance, a deposit, a payment, and two members and asserts the dashboard reports them.Removed the dead "community organisation" wallet controller.
OrgWalletControllerserved an abandoned second org subsystem that nothing writes to. Itsbalance()/transactions()/transfer()methods were unrouted and queried columns/tables that do not exist (org_wallets.org_id,org_wallets.currency,org_wallet_transactions) — a money-transfer method that would throw SQL errors the moment it was wired up — while its two routed endpoints (/organizations/{id}/members,/organizations/{id}/wallet/balance) always returned empty (no community members/wallets are ever created) and the React frontend never called them. The controller, its two routes, and its test are removed;route:liststill loads cleanly. The live volunteer-org wallet under/v2/volunteering/...and/v2/admin/volunteering/...is unaffected.Accessible federation onboarding wizard is now reachable from the hub. The 4-step guided onboarding wizard existed and was tested but nothing linked to it — the opted-out hub CTA pointed at the flat single-page opt-in form, so accessible-frontend members never got the guided, GDPR-explaining flow React users get. The hub's "join the network" button now points at
govuk-alpha.federation.onboarding. Regression test asserts the opted-out hub links to the wizard.Removed an unused, incomplete federation page barrel.
react-frontend/src/pages/federation/index.tsre-exported only 10 of the 12 federation pages (omittingFederationGroupsPageandFederationMemberProfilePage) and was imported nowhere — routes lazy-import each page directly. Deleting it removes a misleading, incomplete export surface that would hand anundefinedcomponent to anyone who imported from it.Suspending a volunteer organisation is now a hard freeze on value movement. A suspended org was hidden from public listings, but the owner could still deposit into its wallet and admins could still approve pending hours — minting new time credits into a suspended org.
orgWalletDepositand hour approval (verifyHoursfor theapproveaction) now reject when the org is not in an active/approved status (ORG_NOT_ACTIVE, HTTP 403). Declining pending hours stays allowed (no value movement) so admins can clear the queue. Addsapi.volunteer_org_not_activeacross all locales; regression test covers approve-blocked / decline-allowed.Federation emergency lockdown / global disable now truly halts inbound webhooks and native ingest. The outbound push listeners gated on
FederationFeatureService, but the inbound webhook receiver and native-ingest controller never did — so during an emergency lockdown (or global disable, or after de-whitelisting a tenant) an active partner could keep POSTingtransaction.completed/transaction.requestedwebhooks that mint local time-credit balances while operators believed federation was stopped. A single kill-switch check now sits at the top of the sharedhandleEvent()(covering both inbound paths): if the partner's tenant lacks thefederationfeature orisTenantFederationEnabled()is false (emergency lockdown, global disable, or not whitelisted), the event is rejected with HTTP 503 — so a conforming partner retries after re-enable — and nothing is persisted or credited. Regression test asserts a lockdown transaction webhook returns 503 and credits no balance.Uploaded images now render from their original stored files unless thumbnails are explicitly enabled. The responsive media helper no longer routes avatars, listing images, or other uploaded content through
/v2/media/thumbnailby default, because a thumbnail-source/path failure could make freshly uploaded originals appear broken across the member UI. The thumbnail proxy remains available behindVITE_ENABLE_MEDIA_THUMBNAILS=truefor a safer re-enable after production verification.HeroUI/Tailwind visual polish restored on sensitive frontend surfaces. Auth SSO/OAuth buttons, full-page loading, app/feature error fallbacks, the auth utility footer/language controls, and the admin page header are back on shared HeroUI-backed components instead of native fallback markup, while the admin header remains opaque so scrolled content cannot bleed through. A visual-contract smoke guard now fails if these high-sensitivity surfaces are simplified away from HeroUI primitives again.
Federation credential decryption now fails loud instead of silently using ciphertext.
FederationExternalApiClient::decryptCredentialand the webhook receiver'sdecryptSecretcaughtDecryptExceptionand returned the raw stored value, so after anAPP_KEYrotation every encrypted partner secret silently became a garbage signing key/API key — outbound calls signed with ciphertext and inbound HMAC compared against ciphertext, masquerading as a generic "partner down". Both now pass through genuine legacy plaintext (non-eyJpdiI6values) but, when a ciphertext value will not decrypt, log a distinct APP_KEY/APP_PREVIOUS_KEYS error and treat the credential as unavailable (the client throws so the send path surfaces an auth error; the webhook returns null so the partner fails auth) rather than authenticating against the blob. Regression tests cover both.Inbound federated transaction amounts now convert units deterministically per protocol.
handleTransactionRequestedguessed seconds-vs-hours with anamount > 100heuristic whilehandleTransactionCompletedcredited the amount verbatim, so a TimeOverflow partner (which sends seconds) over-credited by up to ~3600× and sub-100-second transfers were minted as whole hours. Unit normalization moved intoTimeOverflowAdapter::normalizeWebhookPayload(seconds→hours for transaction events); both inbound handlers now consume already-normalized hours and the magnitude heuristic is removed. Regression tests assert a 60-second transfer credits 0.02h.Organisation website validation now rejects non-http(s) schemes at every create/update sink. The public org page renders the website as an
<a href>, but onlyUpdateOrganisationRequestrestricted the scheme —CreateOrganisationRequestused a bareurlrule and bothVolunteerService::createOrganization(the shared sink for the React member form, both GOV.UK register paths, and admin create) andAdminVolunteerController::updateOrganizationvalidated withfilter_var(FILTER_VALIDATE_URL), which acceptsjavascript:/data:URLs — a stored link-injection vector. All sinks now enforce anhttp/httpsscheme allow-list. Regression tests cover rejection of ajavascript:URL and acceptance of a validhttps:URL.Cross-Verein invitations now require the inviter to belong to the source club.
VereinFederationService::sendCrossInvitationvalidated the invitee's membership but never the inviter's, so any authenticated user in a caring-community tenant could send invitations "from" a Verein they had no relationship with — fanning out in-app notifications and emails to that club's members — and use the invitee-membership check as a membership-disclosure oracle. The inviter is now verified as an activevolunteermember of the source Verein before the invitation is created (and before the invitee lookup, which closes the oracle). Adds aninviter_not_membermessage across all locales; regression test added.Federation partner logs now redact confidential credential keys and whole sensitive subtrees.
FederationLogRedactorusedarray_walk_recursive, which only visited scalar leaves, so a secret nested as an object under a sensitive key (e.g.{"credential": {...}}) was persisted in full; its allow-list also omittedclient_secret,private_key, andcredential. Redaction now walks the payload manually and replaces the entire value under any sensitive key (scalar or nested), the allow-list adds the missing keys, and a substring heuristic redacts prefixed variants likepartner_client_secret/webhook_token. Free-text redaction also now matchesclient_secret/private_key/credential.Federation review/connection push listeners now tenant-scope their federated-identity lookups.
PushReviewToFederatedPartnerandPushConnectionAcceptedToFederatedPartnerqueried the deliberately non-auto-scopedFederatedIdentitymodel without atenant_idfilter, so a same-local_user_ididentity row belonging to another tenant could receive the wrong network's review/connection push. Both listeners now filtertenant_id, closing the documented-invariant gap left when the sibling listeners were fixed in60a1237(defense-in-depth — currently mitigated by globally-unique user ids). Regression tests assert a foreign-tenant identity is never pushed to.Avatar uploads now fail earlier and recover from stale Docker upload-volume permissions. Settings and onboarding avatar pickers now only offer the formats accepted by the backend image pipeline (JPG, PNG, GIF, and WebP), share one client-side validator for MIME type, extension, and 5 MB size checks, and reject HEIC/AVIF/SVG-style images with the existing translated invalid-file toast instead of sending them to
/api/v2/users/me/avatarand surfacing a generic 400 upload failure. The PHP Docker images also pre-create and repair the tenant upload tree at startup so old root-owned named volumes cannot block Master Tenant profile avatar directories.Saved-items navigation no longer resolves as an unknown community. The React and PHP tenant reserved-route lists now include
/saved, solocalhost:5173/savedand shared-host/savedURLs render the saved-items route instead of tryingtenant/bootstrap?slug=saved; the public route registry also uses already-loaded navigation translations for blog, listings, courses, and podcasts feature labels to avoid noisy i18next missing-namespace warnings during startup.Federation audit fixes now fail closed across ingest, reads, and logs. External webhook and native-ingest handlers enforce per-event partner capability flags, native ingest resolves real external partner permissions instead of permissive synthetic defaults, duplicate webhook signing-secret ambiguity is rejected unless a signed discriminator identifies one partner, and inbound/outbound federation logs now redact sensitive payload fields. Federation browse/read endpoints now require caller opt-in while keeping partner discovery explicit, public aggregate access is locked as public-but-consent-gated, member-review fetches require active permitted partners, shadow upserts and profile-update identity lookups are tenant-scoped, settings boolean normalization handles string booleans, accessible federation data/action screens redirect non-opted-in members to opt-in, and React federation toasts no longer display raw backend error text.
Secondary image surfaces now use responsive uploaded-media thumbnails. Explore rails, blog cards/detail heroes, event covers, group cards/headers/media grids, profile listing cards, federation listing/event cards, and selected admin preview/table surfaces now use the shared responsive thumbnail helper so uploaded content can render smaller browser-selected variants outside the primary listings/marketplace/feed hot paths. Branding/logo previews remain on original assets by design.
Database hot-path indexes now cover the remaining justified audit findings. The DB/index EXPLAIN pass added guarded composite indexes for event RSVP count aggregation, marketplace listing primary-image hydration, search-log trending range scans, and listing category slug resolution while leaving feed, message inbox, notification, and existing public listing/marketplace cursor paths unchanged because their current composite indexes already match the audited query plans.
Non-English locales caught up on ~10,800 untranslated interface strings. Recent admin feature batches (federation, content, system, podcasts, resources, and others) had added English strings to the
enlocale without translating them into the 10 other languages, leaving large swathes of the admin and member UI showing English to non-English users and turning the i18n gap-regression gate red (12,619 gaps vs a 1,843 baseline). All 10 locales (de, fr, it, pt, es, nl, pl, ja, ar, ga) were machine-translated for those gaps — 10,845 strings filled — with{{placeholder}}and markup integrity verified (0 variable mismatches across 1,214 files) and every locale file confirmed to parse. The remaining gaps are strings that are legitimately identical across languages (proper nouns, product terms like "Webhooks", short tokens). The gap baseline was refreshed to the new post-translation level. Machine translations are a first pass and the admin strings warrant a native-speaker review over time.Platform audit follow-ups tightened accessible frontend, admin routing, and editor bundles. Accessible GOV.UK POST tests now exercise CSRF tokens for cookie/support/report flows, the accessible build no longer imports GOV.UK footer identity CSS or the unused crest asset, stale
/admin-legacyredirects and maintenance bypasses now target the maintained React admin surface, and newsletter/page design editors lazy-load the asset library while GrapesJS and CodeMirror live in deferred vendor chunks. The test-skip ratchet now reflects the current schema-driven skip budget.Viewing the volunteer wellbeing dashboard no longer writes to the database. Burnout detection (
VolunteerWellbeingService::detectBurnoutRisk) used to upsert an activevol_wellbeing_alertsrow as a side effect of every wellbeing dashboard / my-status GET, so a read mutated data. Detection now takes a$persistflag (default off) — the read endpoints compute and return live risk without writing, and the alert upsert moved to a new scheduled command,volunteering:assess-wellbeing(daily, per-tenant crash isolation), which the admin wellbeing panel's alerts are sourced from. Idempotent upsert (one active row per user) means the daily job refreshes rather than duplicates.Admin navigation no longer corrupts the member header/footer CSS after returning to site. Admin, broker, caring, partner-timebank, and super-admin code still lazy-load as route chunks, but their Tailwind utilities are generated by the main stylesheet instead of late-loaded panel CSS files. This removes the cross-route cascade leak that could hide member header/footer labels after clicking Back to site while preserving responsive pairs such as
hidden md:blockandhidden sm:inlineinside the admin shell. The desktop footer also no longer carries a conflicting base column span that pushed Legal onto a second row after admin CSS loaded.The user-facing Timebanking navbar dropdown has its structured menu layout again. The desktop Timebanking menu now renders its icon, label, and description rows with explicit spacing, padding, and active/hover states, so the menu does not lose its formatting after the recent performance CSS loading changes.
Admin and panel headers now keep their left-side navigation controls visible. The main Tailwind bundle now safelists the shared responsive display and offset utilities used by lazy-loaded admin, broker, caring-community, partner-timebank, and super-admin shells, so
Back to site,Back to admin, tenant names, mobile toggles, and desktop header links are no longer hidden by the lower-priority route utility layer.Returning from admin to the member site no longer hides or distorts the header and footer. Lazy-loaded admin, broker, caring, partner-timebank, and super-admin Tailwind utility sheets now sit in a lower-priority cascade layer, so their late-loaded
.hidden, spacing, and responsive utilities cannot override the main app navbar after client-side navigation back to/dashboard; the desktop footer also no longer carries a conflicting base column span that pushed Legal onto a second row after admin CSS loaded.CI now recognises the responsive uploaded-image pipeline release note. The performance/media hotfix is recorded in the pushed changelog delta and the in-app changelog copy is refreshed so the release-note guard passes on
main.Volunteering performance indexes now pass the blue/green migration safety gate. The additive index migration no longer carries raw rollback
DROP INDEXSQL in the pending migration file, so emergency deploys can proceed without maintenance-mode override.Uploaded media now has a stronger responsive-image pipeline. Image uploads enforce a pixel-dimension cap, generate named card/square/detail variants with WebP and AVIF derivatives when the server supports them, and return structured
variants/srcsetsmetadata. The media thumbnail endpoint now accepts an explicit image format while preserving cache headers, and high-traffic listing, marketplace, search, and feed image renderers use responsivesrcSetprops so browsers can choose smaller derivatives instead of downloading one oversized card/detail image everywhere. Bundle-budget guardrails now pin those hot uploaded-media surfaces to the responsive thumbnail helper.Header and footer logos now render from their original branding assets again. Header tenant logos, footer partner logos, and the powered-by footer mark no longer pass through the uploaded-media thumbnail endpoint, preserving transparent PNG/JPEG/WebP light/dark variants and avoiding the API-host rewrite that broke static
/images/...powered-by assets. The legacy PNG/JPEG public image filenames were restored as compatibility fallbacks for cached clients and older references.Gated-page redirects to login no longer render stale application routes without their providers. TenantShell now tags the lazy-loaded route registry as auth, public, app, or provided, and refuses to render a registry from the previous surface during navigation. This prevents protected-page redirects into
/loginfrom briefly rendering the full app layout outside the menu/auth provider stack, which causeduseMenuContext must be used within a MenuProviderand follow-onuseAuth must be used within an AuthProvidercrashes.Emergency deploy gate fixes now keep donation and admin surfaces stable. Stripe donation currency conversion now declares its zero-/three-decimal currency tables before the static-analysis deploy gate runs, and the admin header uses an opaque token-backed surface so page content cannot bleed through during light/dark mode scrolling.
Vitest no longer hangs at 100% CPU on tests that mock
@/components/ui. The sharedsrc/test/uiMock.tsxproxy returned a truthy stub for every property access — includingthen— which made the mocked ES-module namespace look like a thenable. When Vitest linked the mocked barrel for any component doingimport { X } from '@/components/ui'(e.g.PublicPageHero,PublicEmptyState,VerificationBadge), the module-resolution interop awaitednamespace.then(...), which never settled, and the worker spun forever (uninterruptible bytestTimeout). The mock'sget/hastraps now reportthenas absent, so the namespace is not mistaken for a promise. This unblocks the whole class of barrel-importing component suites that were hanging CI.Organisations page tests no longer fail/hang on
PageMetaandBreadcrumbs.OrganisationDetailPage.testandOrganisationsPage.testnow stub the deep@/components/seo/PageMetaimport (the global@/components/seobarrel mock insrc/test/setup.tsdoes not intercept the deep path, so the realPageMeta/BreadcrumbsreacheduseTenant()from@/contexts/TenantContextand threw "must be used within a TenantProvider"), matching the pattern already used across 130+ other page tests.Caring-community hour approvals now always mint time credits, matching the core volunteering invariant. Two caring-community services (
CaringCommunityWorkflowServicereview decisions andCaringSupportRelationshipServiceauto-approved hour logging) still gated organisation payment on the org'sauto_pay_enabledflag and on the org wallet having sufficient balance — so a carer's hours could be committed as approved while never being credited (the org wallet is a reconciliation figure, not a spending switch, and the verify paths only ever reprocess pending logs, so those credits were lost permanently). Both services now debit the org wallet unconditionally, allow it to go negative, and keep the fractional remainder in the org wallet — mirroringVolunteerService::applyVolunteerAutoPayment. Regional points remain an additive, opt-in reward and are unaffected. New regression tests cover both services at the helper level (mint into a negative balance) and through the full approval paths (auto_pay_enabled = 0still mints).Public route startup now defers tenant menu fetching until after first paint. The navigation components now depend on a lightweight menu-context core, while public tenant routes lazy-load the full menu provider after a short idle delay. This keeps custom public menus available without putting the menu API/provider code on the initial public page path.
Common public tenant pages now avoid the full protected/admin route registry. TenantShell loads a dedicated public route registry for public listings, events, groups, jobs, marketplace, volunteering, resources, organisations, ideation, blog, legal/static, and similar browse pages. Protected member, admin, panel, seller-tool, and editor routes still fall back to the full registry only when those routes are actually needed.
Tailwind no longer scans generated TypeScript declaration files for production CSS. The main stylesheet now excludes
*.d.tsfiles, including the 2 MB generated translation resource declaration, from Tailwind's source scan so translation/type metadata cannot accidentally contribute utility candidates or build-time scan cost.Public marketplace browse routes now stay on the public route registry while seller tools remain protected. The route classifier now treats seller profiles, public marketplace listing lists, and public offer lists as public browse routes, while coupon management, onboarding, order, pickup, shipping, and edit routes still load the full protected registry/provider stack.
Browse pages now defer map/filter-only UI until it is requested. Listings and members no longer include the generic map-view wrapper on the default grid/list path. Listings load the proximity filter only when the advanced filters panel is opened, while events and volunteering keep the proximity filter in a small lazy chunk outside their main route bundles. The volunteering landing page also lazy-loads signed-in-only tabs and guardian-consent UI, cutting its public opportunities route chunk by more than half.
Feed cards now split optional rich-content renderers out of the hot path. Plain feed items no longer carry the image carousel, media grid, video player, link-preview card, quote-embed renderer, or share modal workflows in the core feed-card path; those chunks load only when a visible feed item actually contains that content or the user opens the relevant action.
Admin shell hotfixes. The main admin header now uses an opaque theme surface so scrolled page content cannot show through behind the fixed top bar, and protected routes now mount the full app provider stack even while auth is settling so returning from admin to the main site hydrates header navigation/menu context correctly.
Documented the header/footer logo asset rule. Future frontend work must keep tenant brand logos in the header/footer on uploaded raster assets, preferably transparent PNGs, rather than converting them to inline SVGs; SVGs remain acceptable for icons and non-brand illustrations elsewhere.
Volunteering & organisations module audit — credit-flow integrity. Auto-approved volunteer hours now always mint time credits even when the organisation's
auto_pay_enabledflag is off (the schema default), fixing a case where approved hours were committed but never credited and never recoverable (VolunteerService::logHours). The admin hours-verification endpoint now blocks admins from approving their own volunteer hours (separation of duties), matching the guard already present on the member/org path.Volunteering module audit — service hardening. Opportunity suggestions now enforce the same public-visibility gates as the main listing (open/active opportunities, approved/active organisations) instead of surfacing closed or unvetted records; emergency alerts now honour their
expires_atso lapsed alerts can no longer be accepted or listed; volunteering configuration defaults now match the values enforced at each call site (cancellation deadline, per-shift hours cap, certificate minimum), so the admin UI advertises what is actually applied; expense monthly caps use full-month datetime bounds (last-day submissions were excluded) and admin expense screens now load the volunteer's email; QR check-in token generation is serialised to prevent duplicate tokens.Volunteering module audit — data & i18n. Removed a dead, schema-incompatible
OrgWalletService; repaired corrupted (mojibake) copyright headers on five volunteering models; added the missingarraycast onVolCustomField.field_options; removedtenant_idfrom volunteering models' mass-assignment allow-list as defence-in-depth; and localised the admin activity feed, which previously concatenated English fragments in SQL.Volunteering module audit — frontend. User-facing organisations/volunteering pages now decode list responses through one shared helper with correct types (preventing silent empty lists on envelope changes), and the organisation detail page gained request-abort cleanup and accessible star-rating roles. Admin volunteering modules gained confirmation dialogs and in-flight guards on destructive actions (bulk approve/decline, org suspend, campaign deactivate, hours decline), replaced a native
window.prompt()reject flow with a HeroUI modal, and closed several hardcoded-string / wrong-namespace i18n gaps (16 new keys across all 11 locales).Volunteering deep audit (round 2) — safeguarding. The minor/guardian-consent gate lived only in the React API controller, so it was bypassable via the accessible (GOV.UK) frontend and by adding a minor to a group-shift reservation. The gate is now enforced in the service layer (
VolunteerService::apply/signUpForShift,ShiftGroupReservationService::addMember), and re-checked at shift signup so lapsed/withdrawn consent is caught.Volunteering deep audit (round 2) — data integrity. Fixed a dead delete branch that hard-deleted opportunities and orphaned their applications (now soft-deletes); recurring-shift deletion now cancels the emergency alerts that pointed at the removed shifts (previously orphaned and uncancellable); pre-shift reminders and "my shifts" now exclude cancelled opportunities; and admin application approval counts group-reservation slots against capacity so admins can no longer approve past a full shift.
Volunteering deep audit (round 2) — privacy. Approved volunteer hours no longer broadcast the volunteer's free-text description to the community feed and now honour the volunteer's
show_on_leaderboardopt-out; public organisation endpoints no longer leaktenant_id, the owner's user id, or the Designated/Deputy Safeguarding Lead user ids.Volunteering deep audit (round 2) — GDPR erasure. Admin "delete user" now routes through GDPR erasure (anonymise-in-place + full cross-module PII/file cleanup) instead of a raw DELETE that orphaned safeguarding/wellbeing/consent rows and left credential/receipt files on disk. Erasure now also deletes expense-receipt files, scopes the custom-field-value deletion by
entity_type(was destroying colliding rows), and scrubs an organisation's contact email + the user's org memberships.Donations — Stripe money-path hardening. Donations are restricted to the community's configured currency and converted with Stripe's zero-/three-decimal currency table (fixing a 100× overcharge on zero-decimal currencies and mixed-currency giving-day inflation); webhook handlers re-read under lock with conditional state transitions so out-of-order/replayed events can't resurrect refunded donations, double-count totals, or drive them negative; partial refunds are no longer treated as full refunds; and the gift-aid CSV export is sanitised against formula injection.
Volunteering deep audit (round 2) — performance. Added composite indexes for the unbounded-growth hot paths (
vol_logs/vol_applicationsby tenant+status+created,vol_shifts.end_time,vol_donationsby tenant+created), deduped the nightly lapsed-volunteer sweep in SQL, and cached the per-requestis_federatedschema probe.Donations — Gift Aid claim lifecycle. The HMRC export now stamps exported declarations as claimed (with
?preview=1for a dry run) and excludes them from subsequent exports, closing a double-claim risk; refunding an already-claimed donation flags it for adjustment on the next claim and the admin overview surfaces the count.Wallet — credit-donation guardrails. The member time-credit donation endpoints gained rate limiting, a 1000-credit cap, and a double-submit lock (a double-clicked donate button no longer transfers twice).
GDPR — volunteering data lifecycle completed. The Article 15 export now includes the member's volunteer-payment ledger, safeguarding incidents where they are the subject/involved party (facts only — third-party narrative withheld), and the stored gift-aid declaration address. Four opt-in retention policies were added for volunteering special-category data (mood check-ins, wellbeing alerts, safeguarding incidents, guardian consents) with status guards so open cases are never purged, per-type recommended windows, and admin UI labels in all 11 locales.
GDPR — volunteering erasure & export gaps closed (round 3 audit). Article 17 erasure now scrubs the gift-aid declaration name, full home address, and donor message from
vol_donations(previously onlydonor_name/donor_emailwere cleared, leaving directly-identifying data behind), with a documented HMRC record-keeping carve-out that retains the declaration fields on claimed rows while still scrubbing name/email/message. The Article 15 export and erasure now also cover community-project supporter messages and the reservation notes on group shifts a member leads, and swap-request erasure retains the counterparty's record (scrubbing only the erased member's message) instead of deleting both parties' rows. Guardian-consent retention now also purges withdrawn and abandoned no-expiry rows (guardian name/email/phone/IP) once past the window.Volunteering audit (round 3) — organisation profile validation. The member organisation-update endpoint now validates input through a dedicated request (name length/uniqueness, valid contact email, and an http/https-only website rule), closing a hole where an organisation manager could persist a
javascript:URL that rendered as a clickable link on the public organisations page, blank an organisation's name, or trigger a 500 with an over-length name.Volunteering audit (round 3) — credit & wallet correctness. The three hour-approval payout paths now lock the member row before the organisation row, matching the wallet deposit path and removing a lock-order inversion that could deadlock an approval against a concurrent deposit. Organisation wallet deposits now reject fractional amounts with a clear error instead of silently flooring them (the deposit form no longer advertises quarter-credit precision the ledger cannot hold). Application approve/decline now guards on
status = 'pending', so a concurrent or double-submitted decision is a no-op that fires no second notification instead of flipping an existing decision and sending a contradictory email.Volunteering audit (round 3) — check-in, wellbeing & reminders. QR check-in tokens now expire (rejected once past the shift end plus a four-hour grace, or 24h after start when no end time), closing a replay window where a stale code could fabricate attendance weeks later. The wellbeing-alert lifecycle is now reachable: burnout-risk alerts that were written on every dashboard load but had no coordinator surface can be listed and acknowledged/resolved/dismissed through new admin endpoints and an admin panel. Volunteer expense totals are now computed across all of a member's records rather than only the current page, the nightly reminder sweep runs once per tenant instead of once-per-tenant-squared, certificate verification codes use 16 uppercase alphanumerics (the lookup column collates case-insensitively, so the previous uppercased mixed-case value added no entropy), emergency-alert expiry is clamped to a sane range, credential expiry dates are validated, and the federated-opportunity push no longer leaks internal ids.
Volunteering audit (round 3) — donations currency & Gift Aid eligibility. Offline/manual donations and the accessible-frontend donation path now record the community's configured currency and reject an explicit foreign-currency mismatch (previously client-controlled, and hard-coded to EUR on the accessible path — both routes to inflating a single-currency giving-day total). Gift Aid — a UK/HMRC scheme — is now gated to GBP donations across declaration eligibility, the claim export, and claim stamping, and the previously-ignored community-project donation filter is now applied.
Volunteering audit (round 3) — public data exposure. Public organisation endpoints no longer emit the owner's internal user id (the eager-loaded owner relation re-added the id that the field-stripping helper explicitly removes); the shift-listing endpoint now applies the same public-visibility gate as opportunity detail; admin joins carry tenant guards; and member-facing search escapes SQL
LIKEwildcards.Volunteering audit (round 3) — member & admin frontend. The hours-review and donations tabs now render an error-with-retry state on a non-thrown API failure instead of a misleading "all reviewed / nothing here" empty state. The organisation dashboard background-refreshes on balance changes instead of blanking the page and resetting the active tab, resolves the organisation from the member's managed-orgs list so owners of pending/declined organisations reach it (with the correct status chips) instead of an access-denied screen, and the organisations directory search now guards against out-of-order responses. "Hours by month" labels and the log-hours default date now use local time (they previously shifted a day/month in negative-UTC timezones); hours, expense amount, currency, and group-slot inputs gained client-side validation; admin CSV exports now fetch all pages before writing; and a batch of accessibility labels, SPDX header placements, and hardcoded-string/
tenantPathgaps were closed across the member tabs and admin modules.