Unreleased
Changes that have landed since the last release.
Documentation
- Help Centre safeguarding guides match the vetting-based rules (26 October): ten admin, broker and member articles now say that new members are asked only the vulnerable-adult question; that coordinators and members with recorded vetting can contact a vulnerable member, both ways, and only coordinators until vetting is recorded; that groups keep working; and how Offer to new members closes an option. All eleven languages; Irish hand-written.
- ASP.NET audit independently verified (25 October): a second reviewer re-traced every Codex finding in source. None was refuted; corrections are recorded, for example the legal-documents service already exists behind the wrong route, and the 33 route collisions share one root cause in the alias convention. The pass also found 16 further gaps, including no first-community or first-admin bootstrap for a new install, admin approval that does not allow sign-in, email verification that cannot complete, no starting balance, no exchange notifications, React group creation and image posts rejected, module switches with no effect, and an ASP.NET RSVP action that trusts a body
user_id(not deployed). It also records that web-uk's insurance upload conflicts with live Laravel. Repaired run-together numbers and the 52/53 package mismatch in the Codex-written pages, and pointed to the detailed method text that pass removed.aspnet-backend/docs/CURRENT_ASPNET_CONTRACT_STATUS.md,ROADMAP.md. Development remains paused. - Audit documentation CI follow-up: corrected Markdown spacing in the maintained architecture, monorepo, dual-backend and documentation-index pages; no application behavior changed.
- Dual-backend restart scope (25 October): clarified independent new installations with both web frontends unchanged; prepared an owned implementation/verification breakdown, effort scenarios, first-stage checklist and acceptance requirements. Existing-community data migration is excluded; delivery remains paused.
- ASP.NET deeper readiness review (25 October): expanded workflow/lifecycle review, reproduced routing ambiguity, distinguished migration source counts from EF discovery, and refreshed restart sequence, handoff, coverage and acceptance guidance. Implementation remains paused.
- ASP.NET readiness audit (25 October): reviewed current backend code against Laravel and both unchanged web frontends; recorded confirmed contract and persistence gaps, build/selected-test evidence, and verification limits. Reconciled maintained readiness and handoff guidance. Development remains paused; no compatibility certification or deployment is claimed.
Added
Accessible site takes the same card payments as the website — the identity-verification fee, volunteering donations and club dues, on Stripe-hosted payment pages (owner, 26 Oct 2026: parity between the two frontends). The website keeps its embedded card form; the accessible site works without JavaScript, so it sends the member to a Stripe Checkout page and Stripe sends them back. New
App\Services\Payments\HostedCheckoutService(modelled on the marketplace's hosted checkout): card only, amount and currency from our own record, an open page reused instead of a second one opened, 35-minute expiry. The page puts the SAME metadata on its payment as the card form does, so the existing, testedpayment_intent.succeededhandlers complete it; identity fees now also find their record bynexus_identity_session_idwhen that event arrives before the session one (only a hosted-page attempt with no intent yet, and only when the record's own community and member match).checkout.session.completedonly links the payment id;checkout.session.expiredcloses an unpaid attempt; a declined card on an open page no longer fails the record (the member can retry on the same page). One record cannot be paid twice: an unpaid card-form intent is cancelled before a page opens (a succeeded one counts as paid; one still processing refuses with 409PAYMENT_IN_PROGRESS), and the card form closes an open page first; a cancelled intent behind the card form's stable idempotency key is replaced rather than handed back. New endpointsPOST /v2/identity/checkout,POST /v2/donations/checkout,GET /v2/donations/{id}/checkout-status,POST /v2/me/verein-dues/{id}/checkout,GET /v2/me/verein-dues/{id}/checkout-status(the status reads ask Stripe when the webhook is late, and complete a payment only when Stripe reports it paid, through the webhook's own idempotent handler);POST /v2/identity/startacceptsreturn_urlso Stripe's ID-check page returns to the accessible site. Migration2026_10_26_120000_add_stripe_checkout_session_id_to_card_payment_recordsadds a nullable, indexedstripe_checkout_session_idtoidentity_verification_sessions,vol_donationsandverein_member_dues(instant add). Onweb-uk: a new identity-verification journey at/verify-identity-optional(date of birth, fee, ID check, result — the accessible site had none), a Card option on the donate form, Pay now on a member's dues, return pages that say "we're confirming your payment" until the server confirms, a server-side single-use token so a double submit cannot open two payments, and redirects followed only tocheckout.stripe.com. Not tested against Stripe itself: there are no Stripe test keys locally, so every Stripe call is exercised through the real Stripe SDK against an in-memory fake. Production read on 26 Oct: 11 communities charge a €5 identity fee (11 paid so far); card donations are rare; club dues are used nowhere. Tests:tests/Laravel/Feature/Payments/HostedCheckoutTest.php(21),web-uk/tests/card-payments-hosted-checkout.test.js(34),web-uk/tests/payment-redirect-allowlist.test.js(10).Accessible site catches up with the website — every member-facing gap from the 26 Oct React/accessible drift audit that does not need a card-payment or sign-in decision (owner: "address everything on the list"). New on
web-uk, each as plain GOV.UK pages that work without JavaScript, gated exactly as React gates them, wording in all eleven languages: Help Centre — the 208 member guides plus the broker and admin guides at/help/{audience}/{section}/{article}, generated from React's own guide registries andhelp_*catalogues byweb-uk/scripts/build-help-guides.js(build:help/check:help; a test fails when the copy is stale, and.github/ci-paths.ymlnow wakes the Web UK jobs when React's guides change), with the community FAQ kept on/help. Events — organisers assign event staff and create, change, pause and archive ticket types; "Create event" is hidden, and the form explained, where the community restricts who may create. Groups — filter the directory by group type with a type label on each card; redeem a group invitation link at/groups/invite/{token}(a signed-out visitor signs in and comes back —auth.jsaccepts only that exact return shape,lib/login-return.js), and organisers now see the link they minted. Comments and likes on events and volunteering opportunities. Volunteering — organisation safeguarding reports show an open/total count, what each status means, the confidentiality and "never contact the reporter" guidance, and no update form on a closed report; donations name the organisation and campaign, separate live/coming/past campaigns, offer cash and a payment reference, drop a donor total that counted people twice, and gain a receipt page; My hours pages through every entry and lists the shifts a member holds. Marketplace — browse borrow-and-lend items, follow and appeal reports, manage shipping options, add/replace/remove listing photos (lib/api.jsnow sendsimages[], so several photos are no longer reduced to the last one), and paging on every capped list, including the accepted-offer purchase that failed for a buyer with more than 50 sent offers. Partner communities — a profile now says up front when that community's safeguarding policy will refuse contact, and offers no form the server would refuse; the matches pages explain when vetting checks are unavailable. Clubs — invitations, a member's dues (amount and status; paying by card is not offered because the dues API is PaymentIntent-only), and the organiser's dues overview, reminders, waivers, fee settings, yearly generation and CSV member import. Also: regional points (balance, history, sending), the municipality calendar, a member's ad campaigns, the emailed supported-decision confirmation page, community pages in the footer menu (menu_pages), the community's own date and number format (settings.region), podcast studio guide and copyable share links, and Beta labels on Courses and Podcasts. Route matrix 854 -> 906; API ledger 776 -> 816 contracts, every helper with a direct contract assertion. Most journeys were walked on the disposable e2e environment; the rest are covered by tests, as recorded per item in the audit report. Tests: about 30 newweb-uk/tests/*files; full web-uk suite 4,235 passing.Accessible site — members can block a member of a partner community (F-284 parity, 26 Oct 2026). The website gained this on 29 Sep; the accessible site had no control, so a member there could not stop a partner-community member's messages, connection requests or credit transfers. A partner member's profile (
/federation/members/{id}) now offers Block this member, and once blocked says so, offers Unblock member and offers no contact. It uses the existing member block endpoint and the profile's existing translated wording (no new strings). Only members of internal partner communities can be blocked: an external partner's member id belongs to another installation, so it is never offered and a forged form is refused before the API. New routesPOST /federation/members/{id}/blockand/unblock. Walked end to end on the disposable e2e environment (block stored, the partner's federated message then refused withBLOCKED, unblock restores contact). Tests:web-uk/tests/federation-partner-block.test.js(9).Safeguarding — "I consider myself a vulnerable adult" switches on the community's vetting system (owner decisions, 25 Oct 2026, after the Timebanking UK chief executive confirmed that dealing with vulnerable adults requires vetting, in Ireland and the UK alike). It is the one safeguarding question new members are asked (with "none of these apply"). The option now carries
requires_vetted_interactionwith the jurisdiction's own check (garda_vetting,dbs_enhanced,pvg_scotland,access_ni,uk_safeguarding_clearance): coordinators of the member's community (any SafeguardingStaff role, newSafeguardingCoordinatorDirectory) and members whose vetting the community has recorded can contact the member; nobody else. It works both ways: the member can contact coordinators and vetted members only (codeSAFEGUARDING_CONTACT_RESTRICTEDwithrestricted_by_sender, wordingsafeguarding.errors.contact_via_coordinator*), instead of being able to send a message nobody was allowed to answer. Coordinators can always contact any member with a contact-limiting choice, including the older "coordinator arranges all contact" and "only vetted members" choices. Coordinator alerts name the right member when it is the protected member who tried to make contact (contact_attempt_via_body,coordination_request_via_body, reasonSAFEGUARDING_CONTACT_VIA_COORDINATOR). Migration2026_10_25_210000_vulnerable_adults_use_vettingapplies this to existing communities; where a member already holds the option it keeps its coordinator-only block as well, so nobody becomes reachable by more people without choosing that. A coordinator-approved-contacts design was built earlier the same day and withdrawn before release, because an approval could have let an unvetted member contact a vulnerable adult. Wording hand-written in Irish, machine-translated in the other nine languages and spot-checked. Tests:VulnerableMemberContactTest(8); 15 existing test files updated for the new methods.Safeguarding questions say what they do, and a member cut off by "vetted members only" can get out of it in one step (owner, 25 Oct 2026). Production check that day: on TimeBank Ireland (tenant 2) five members held "I would prefer to only interact with members who have been appropriately vetted", the community had recorded Garda Vetting for no one, so
SafeguardingInteractionPolicyrefused every message, reply, credit transfer, exchange, review, mention and group-chat post to them from anyone; tenants 5 and 11 had one such member each. The option was worded as a preference and promised "coordinators will ensure you are only matched with vetted members"; nothing did that. NewSafeguardingEffectsServiceturns an option's enforced triggers into plain sentences in the member's language (including the live count of members with vetting recorded, e.g. "No members have Garda vetting recorded here at the moment, so nobody can contact you while this is on"); they are returned aseffectsbyGET /v2/onboarding/safeguarding-options,GET /v2/safeguarding/my-preferencesandGET /v2/admin/safeguarding/options(which also gainsholder_count), and shown under every option in the website wizard, its post-save summary, Settings → Safeguarding, the broker/admin options page, the accessible site and the phone app. New optioncoordinator_introductions("I would like a coordinator to keep an eye on my exchanges and help introduce me to members": broker approval of exchanges, match review, coordinator told; members can still message) in every preset and added to existing communities by migration2026_10_25_200000_overhaul_safeguarding_questions. Newtenant_safeguarding_options.accepts_new_selections: a closed option is hidden from members who do not hold it and refused by the API for them, while it keeps protecting, and stays visible to, its holders (closing never touches a live protection, so the F-433 guard is unaffected); staff can switch it from the options page. New member endpointPOST /v2/safeguarding/switch-to-coordinator-introductions(selects the new option and revokes vetted-only in one audited step; 409 when not held), with a button in Settings carrying server-translated copy. Emails (App\Services\Safeguarding\SafeguardingChoicesEmail, new tablesafeguarding_member_emails): an automatic "Your safety choices" summary ten minutes after a member saves or changes their choices (SendSafeguardingChoicesSummary, unique per member; listing only protections that are on; not repeated when nothing changed; none for "none of these apply"), and a one-off apology/explanation to vetted-only holders. Both are sent per member in their own language; neither is scheduled for existing members —php artisan safeguarding:send-choice-summaries [--notice] [--tenant=] --dry-runsends them when an operator decides.safeguarding:check-policy-healthnow also alerts (own Sentry fingerprintsafeguarding_vetted_contact_unreachable) when members require vetted contact in a community that has recorded no vetting — tenant 2's state, which the check passed until now. Wording in all eleven languages; Irish hand-written. Tests:SafeguardingQuestionsOverhaulTest(9),SafeguardingChoicesEmailTest(8),SafeguardingPolicyHealthCheckTest(+1, one renamed),SafeguardingStep.test.tsx,SafeguardingTab.test.tsx(+3).The offers & requests round-up email is back (owner, 25 Oct 2026). The original weekly email of new offers and requests was lost in the Laravel move:
src/Services/DigestService.phpwas deleted on 20 Mar 2026 (df9906ee7), its Laravel replacement was an empty stub (deleted 21 Mar,aa27af479), and the design,views/emails/weekly_digest.php, went on 11 May as an "orphan email" (2d7ce86d8); nothing has sent it since. NewApp\Services\ListingsDigestService+ListingsDigest\ListingsDigestEmail(a new owner-approved design: hero with counts, a spotlight listing, Offers and "Could you help?" card sections, up to three upcoming public events, a post-an-offer prompt; brand colour from the community, darkened until white text reads at 4.5:1; stacked cards on phones, dark-mode aware, right-to-left for Arabic). Automatic: a daily 10:00 cron (listings-digest) emails each member whose own interval has passed — monthly by default, weekly selectable; a community admin sets the community default (listings_digest.default_frequency: monthly, weekly or off) and each member can choose their own (notification_preferences.listings_digest_frequency,email_listings_digest). A member is only ever sent listings they have not been sent before (new tablelistings_digest_sends), never their own, nothing from anyone they blocked or who blocked them, and no email when nothing is new; listings followListingService::applyPublicVisibility()plus not expired and an active poster; events are published, scheduled, not in a group. The poster is shown by first name only (or organisation name). On demand: Admin → Listings → Offers & requests email (/admin/listings-digest) shows a live preview, the default frequency and the number of members it would reach, and Send now emails every current offer and request to everyone who has not turned it off, after a confirmation; it runs on theemailsqueue in batches of 40 (App\Jobs\SendListingsDigestNow, not retried) and is audit-logged. Newsletters: a new{{recent_listings}}merge tag drops the newest offers and requests into any newsletter as the same cards (the old newsletter editor's "Listings" button, also lost in March). Endpoints:GET/PUT /v2/users/me/listings-digest,GET/PUT /v2/admin/listings-digest,GET /v2/admin/listings-digest/preview,POST /v2/admin/listings-digest/send. The member setting is in Settings → Notifications on the website and the accessible site; the email's one-click unsubscribe (categorylistings_digest) turns off only this email. Mail categorylistings_digestuses the newsletter sender and stream. "Post an offer" / "Ask for help" open the listing form with the type chosen (/listings/create?type=request). Wording hand-translated in all eleven languages (emails_listings.listings_digest.*,settings.listings_digest.*,admin_newsletters.listings_digest.*,govuk_alpha.profile_settings.notifications.round_up_*). Tests:ListingsDigestServiceTest(13),ListingsDigestControllerTest(7),ListingsDigestAdmin.test.tsx,ListingsDigestSetting.test.tsx,web-uk/tests/shared-accessible-shell.test.js.Accessible site — the podcast studio and episode page now do what the website's do (owner, 25 Oct 2026; the remaining gaps from the same day's podcast audit). Studio (
/podcasts/studio/{id}): one label per show and episode naming why listeners cannot hear it yet (draft, archived, awaiting review, not approved, scheduled for a time in UTC, audio still being checked, audio could not be used) or "Published"; moderator feedback shown under the show and each episode; Publish offered only when the API will accept it (a draft or archived episode whose audio is ready — it was offered for every non-published episode, so an episode still being virus-checked failed with "could not be published"); Archive for a show and for an episode, each behind a confirmation page, and publishing an archived one brings it back; deleting a whole show now goes through a confirmation page as one episode already did (it was one button press); subscriber count, and when listen analytics are on, listens, different listeners, the share listened to the end and the most listened-to episodes for the last 30 days (GET /v2/podcasts/{id}/stats); a podcast feed check page (GET /v2/podcasts/{id}/validate-feed) listing in plain words what must and should be fixed before podcast apps can read the feed. Every refusal now also shows the API's own reason, in the member's language (rememberApiReason(), kept for one page view) — they all collapsed into one generic message. Chapters are typed as plain lines ("0:00 Introduction", "1:02:30 Questions"), as on the website, instead of raw JSON (JSON is still accepted, for a chapter with a link), with their own error message. Scheduled times say they are UTC and are sent with the zone (…:00Z), so the API cannot read them in its own default zone. Episode page: Like this episode / Remove your like with a count, when the community has likes on (POST /v2/podcasts/episodes/{id}/reaction, sending the state wanted, not a toggle); Report this episode with a reason and details (…/report); the chapter list; and Download the transcript as a text file (/podcasts/{show}/episodes/{id}/transcript.txt, from this site because the API's distribution route serves public episodes only). Listening is not counted from this site: that needs the page's player script, which the accessible site does not have. 10 new routes, 4 new API contracts; wording hand-translated in all eleven languages (govuk_alpha.podcasts.*,govuk_alpha_commerce.podcast_studio.*; the chapter, chapter-error and schedule hints reworded). Tests:web-uk/tests/podcast-studio-gaps.test.js(17),podcast-episode-multipart-clears.test.js.TimeBank Ireland's accessible site is live at
accessible.hour-timebank.ie, and it is monitored (owner, 24 Oct 2026; tenant 2'saccessible_domain). No new vhost was needed:hour-timebank.ieis a Plesk domain alias ofproject-nexus.ie, so theaccessible.project-nexus.iesubdomain already answered asaccessible.hour-timebank.ieand proxied it to web-uk. It returned Cloudflare 526 only because that vhost carriedLets Encrypt accessible.project-nexus.ie, which names one host. Let's Encrypt could not add the new name (the HTTP challenge is redirected to HTTPS, which is the part that was failing), so the vhost now usescloudflare-origin-nexus-20260506— the certificateapp.project-nexus.iealready servesapp.hour-timebank.iewith; it covers*.project-nexus.ieand*.hour-timebank.ieand runs to 2041. The Let's Encrypt certificate stays in the repository (valid to 3 Dec 2026) as the rollback. Verified live:/versionon both hosts returnsnexus-webuk, the new host serves TimeBank Ireland slug-less (no community chooser,/listings200), the shared host still shows the chooser and/hour-timebank/accessiblepages, both redirect HTTP to HTTPS. 🔴 The certificate binding lives only on the server. Both URLs (root, and/versionexpectingnexus-webuk) are added toscripts/uptime-targets.json, and the two deploy warnings that list the accessible hostnames name it.Phone app and accessible site — profile stat cards open your own records, with My Listings / My Groups / My Events (owner, 24 Oct 2026; follows the website change below). Phone app (
mobile/): on your own profileStatCardbecomes a pressable (role button, label + hint) — Hours Given / Hours Received / Total hours → Wallet, Active Listings → new(modals)/my-listings(GET /v2/listings/mine, the website's five status groups with counts, paging, empty/error states; a "My listings" button on the Listings tab), Groups → groups with a new "My Groups" filter (user_id=<me>,?scope=joined), Events → Events tab with a new "My Events" toggle (scope=mine,?scope=mine). Gated by the same module/feature checks; plain on others' and federated profiles. Deep links/listings/mine,/groups?scope=joined,/events?scope=mineresolve natively;parity-map.jsonmarkslistings/minenative. 7 app locales. Tests:member-profile(+5),my-listings(7), groups (+3), events (+2), exchanges (+1),native-intent(+1);deepLinkParamsresolver now follows@/app/re-exports. Accessible site (web-uk/): own-profile activity figures link (inside the<dt>, visually hidden destination suffix) to/wallet, new/listings/mine(GET /v2/listings/mine, status tabs with counts, type filter, load more, sign-in required),/groups?filter=joined(existing) and/events?scope=mine(new; heading/intro change, scope kept through filters and paging, signed-out visitors sent to sign in); Groups and Events figures added when those features are on. "My listings", "My groups", "My events" buttons on the three index pages. 25 newgovuk_alpha.own_records.*strings hand-translated in all eleven languages; route matrix and API ledger regenerated. Tests:own-records-links.test.js(10),api.test.js.Profile stat cards are clickable, and Events has a "My Events" view (owner, 24 Oct 2026). On your own profile the five cards now open the matching page: Hours Given and Hours Received →
/wallet, Active Listings →/listings/mine, Groups →/groups?scope=joined(the existing "My Groups" filter), Events →/events?scope=mine. On someone else's profile the cards stay plain, since those pages show your own records. The link stretches over the whole card so the<dl>keeps validdt/ddmarkup. NewGET /v2/events?scope=mine(EventService::whereMine()) lists only events you organise or have RSVP'd "going" to; the recurring-series collapse applies the same rule to sibling occurrences, so the occurrence you are going to is not hidden behind one you are not. A guest asking for it gets nothing. The Events page gets a "My Events" toggle (hero and phone row,aria-pressed, kept in the URL); arriving with?scope=minestarts on "All events" so past attended events match the profile count. My Listings and My Groups already existed and are unchanged. Newevents.my_events.*keys, hand-translated in all eleven languages. Tests:EventDiscoveryContractTest(2 new),ProfilePage.test.tsx(2 new).Admin — the SEO page no longer warns that Google verification is missing on sites verified another way.
/admin/seoshowed "Google Search Console Verification — Not configured — recommended for search insights" as a warning whenever its verification box was empty, although Google offers several methods and most communities use a DNS TXT record (the only one for a Domain property; hour-timebank.ie was verified that way through Cloudflare), so a correctly verified site looked faulty. NewGET /v2/admin/config/seo-verification-dns(admin;App\Services\SearchVerificationDns) looks up the community's own domain and its parent domains for agoogle-site-verification=TXT record (cached 6 h when found, 10 min when not; a resolver failure reads as not found and is logged). The health check now passes with "A Google verification record was found in the DNS settings for {domain}" when one exists, and otherwise shows a neutral note (not counted in the score) saying it can be ignored if the site was verified another way and that nothing is broken; a community on a shared address (<app host>/<slug>), whose DNS is the platform's, is not looked up and is pointed at the box instead. The verification card says both boxes are optional and that Bing can copy verification from Google Search Console. Bing's DNS method (a CNAME named after the account's own code) cannot be looked up and is not checked. A record shows someone verified the domain, not which Google account or that it is still current; the wording says only what was found. The old warning string leaves all eleven languages; the new wording is hand-translated in all eleven. Tests:SeoVerificationDnsTest(7),SeoOverview.test.tsx(6 new)."Site for search engines" — a community can make its accessible site the one search engines index (
seo_primary_siteon /admin/seo:standard, the default, oraccessible; 2026-10-23). For communities such as councils that use only the accessible site. Withaccessible: the main site's sitemap answers 404 (SitemapService::notListedReason()→accessible_primary) and the accessible site publishes its own;llms.txtdescribes the community with its accessible addresses (/timebanking-guide→/guide, coupons left out); on the main site every page with an accessible equivalent names it as canonical andog:url, and a page with none getsnoindex, followand no canonical (src/components/seo/primarySite.ts,accessibleFrontendEquivalentFor()); breadcrumb data is not emitted, and SeoHead's Organization and WebSiteurl/@idand the x-default hreflang use the accessible site. On the accessible site (web-uk) every public page becomes self-canonical in this mode, and there is now a sitemap in both modes (src/lib/accessible-sitemap.js:/sitemap.xmlon a community's accessible domain,/{slug}/accessible/sitemap.xmlon the shared host). In standard mode it lists only the pages that are canonical to themselves (changelog releases, single legal versions); in accessible mode every public signed-out page, blog posts and upcoming public events included, with the module and feature gates applied. It answers 404 for hidden communities or when the automatic sitemap is off, 503 uncached when the API fails, androbots.txtnames it when published.seo:probechecks the canonicals point at the accessible site and that its sitemap has addresses.standardoutput is unchanged. The value is validated (422api.seo_primary_site_invalid, 11 locales) and published in the tenant bootstrap. Tests:SeoFieldParityTest,SitemapControllerTest,LlmsControllerTest,SeoProbeTest,PageMeta.test.tsx,SeoHead.test.tsx,SeoOverview.test.tsx,web-uk/tests/seo-accessible-primary-sitemap.test.js.Help Centre guides in every community's sitemap (owner, 23 Oct 2026: every community indexes its own).
SitemapService::getHelpGuideUrls()lists/help/brokers,/help/adminsand every section and article the community actually has, with the Help Centre's own gates (registry.ts isGateOpen: feature and module gates need the switch on, setting gates count as open as for a signed-out visitor);/help/membersis the same page as/helpand is not listed. Until now this returned[], so every guide reached crawlers as the bare shell with noindex. The guide list is the React registry, copied toresources/help/*.registry.jsonbecause the API image ships noreact-frontend/;HelpRegistryCopyTestfails if the copy drifts./help/**gets a 30-day snapshot TTL. About 350 guide pages per community: the first render after deploy is a one-time several-hour job, after which only changed guides re-render. Tests:SitemapServiceTest(guides listed and gated; older substring checks ignore help addresses),HelpRegistryCopyTest.seo:probe, a nightly check of what search engines receive (06:15,runInBackground, guarded byPagerCommandsRunInBackgroundTest). For each community it fetches/and/aboutas Googlebot (one<title>, canonical = own address, noindex only when the community is hidden), its sitemap (200 with addresses, or 404 when hidden or switched off), its host'srobots.txt(names this host's sitemap) andllms.txt(404 when hidden). Problems are logged, sent to Sentry once (fingerprintseo_probe) and to the SLO Slack webhook, and the command exits non-zero, asslo:checkdoes. Until now nothing on a schedule looked at the live site as a crawler. Tests:tests/Laravel/Feature/Console/SeoProbeTest.php.Phone app — "Trust this device" at the two-factor step. The app had no such option, so every sign-in on a phone asked for the authenticator code. When the community allows it, the code screen now offers "Trust this device for N days"; the token the server issues (
trusted_device_token, already returned to native clients) is kept in secure storage, one per account (community + email), and sent asX-Trusted-Deviceat the next sign-in for that account. Signing out keeps it, as on the website. Wording in the app's seven languages. Ships to phones by over-the-air update after the server change is deployed (no native change). Tests:lib/trustedDevices.test.ts,lib/api/auth.test.ts(token stored and kept out of the session, never requested when not allowed, sent at sign-in),MfaSignIn.test.tsx(offered with the day limit, off unless ticked, hidden when not allowed).CI — no migration may overwrite or remove content a community wrote (BLOCKING). Deploys run pending migrations automatically, so a migration that changes community-written content replaces what the community chose on every server that has not run it yet;
migrations/2026_05_13_normalize_hour_timebank_branding.sqldid exactly that to hour-timebank's name, meta title, title suffix and footer.scripts/check-tenant-content-writes.mjs(npm run check:tenant-content-writes, in the Migration Safety Gate, which runs on every push, and inpreflight.mjs) fails when a file indatabase/migrations/ormigrations/overwrites or deletes: thetenantsfields a community edits (SEO tab, General, Contact, Social: meta title and description, hub heading and intro, sharing image, robots, organisation type, name, tagline, description, contact details, social links); its branding intenants.configuration(footer text, logos, favicon, colour); thelanding_page.config,seo_*andfooter_textsettings; or existing rows of the content tables (custom pages and their blocks and versions, menus and menu items, legal documents and versions, help articles and FAQs, knowledge base, blog, newsletter templates). Schema changes, NEW rows and insert-if-missing defaults are allowed. A migration that genuinely must do it carriestenant-content-overwrite: approved — <who, why>. Eleven older files that did this (all already applied; applied migrations never re-run) are recorded in.github/tenant-content-writes-baseline.json, which only shrinks. Pinned both ways byscripts/test/test-tenant-content-writes.sh(eight overwrites refused; six schema, new-row, default, approved and operational writes allowed).Admin — the social sharing image can be uploaded, and the field explains itself. On
/admin/seo("Social sharing image") and the super-admin tenant form's SEO tab, the bare "OG Image URL" box is replaced bySharingImageField: a plain explanation (the picture shown when a link to the community is shared on Facebook, WhatsApp, LinkedIn, X and most messaging apps; pages without their own picture use it), the size to use (1200 × 630 px, JPG or PNG, under 1 MB, nothing important near the edges), an Upload image button that sends the file to the tenant image library (POST /v2/upload, admin-only, 10 MB cap) and fills the address in, a Replace / Remove pair, the address box for pasting an image that is already online, a preview that says when the address shows no image, and a reminder that the page's Save button keeps it. Wording hand-translated in all eleven languages. Tests:SharingImageField.test.tsx(explanation, upload fills the address, upload failure leaves it alone, remove, paste).Accessible site — the people who run a shift can check a volunteer in and out.
/volunteering/checkin/{code}, the same address as the website's check-in page, asks first (opening it changes nothing), then Confirm they have arrived checks the volunteer in (POST /v2/volunteering/checkin/verify/{code}) and names them, and Check them out checks them out. The API's own refusals (checking yourself in, checking out before the shift starts) are shown in its words. The volunteer's QR code still opens the website's page; this one serves a coordinator who uses the accessible site. New wording hand-translated in all eleven languages.Accessible site — community projects are listed again, and members can propose and support them. The Community projects tab (
/volunteering?tab=community_projects) had always said there were no projects: it read the API's reply as a list when it is{ items, cursor, has_more }. It now shows the community's approved, active and completed projects with who proposed them, where, when and how many volunteers are needed, and each has Support this project or Remove your support. Propose a project opens/volunteering/community-projects/propose(title, description, and optional category, place, number of volunteers and start date); a proposal returns to the tab saying it will appear once an administrator approves it. New wording hand-translated in all eleven languages.Accessible site — who applied to one opportunity. The people who run an opportunity now have Review applications on its page, opening
/volunteering/opportunities/{id}/applications: that opportunity's applicants (GET /v2/volunteering/opportunities/{id}/applications), waiting for a decision by default, with Approved / Declined / All tabs and load more. Each waiting application has the same approve-or-decline form, with an optional note, as the organisation review list, and the organiser comes back to the same tab with the outcome; a decided one shows its status and note. Until now the accessible site had only the organisation-wide list. Not built: the website's tick-several-and-approve-all and its search of the loaded names. New wording hand-translated in all eleven languages.Accessible site — organisers retire, reinstate and remove volunteers. The volunteers roster (
/volunteering/organisations/{id}/volunteers) gains Active and Retired tabs, as on the website: an active volunteer can be retired (their hours and history are kept and their places on upcoming shifts are released) or removed (taken off every opportunity and upcoming shift), each after a page that names the volunteer and says what will happen; a retired volunteer can be reinstated in one step, and shows when they were retired. The volunteer is told each time, by the API. The outcome names the volunteer on the roster, carried in the session, never in the address. Until now the accessible roster was read-only. Same wording as the website in all eleven languages.Accessible site — organisers ask volunteers to cover a shift at short notice, and withdraw the request. The shift list (
/volunteering/opportunities/{id}/shifts) now offers Ask for help on every upcoming shift, opening a page with the website's message, priority, required-skills and stop-asking-after choices (POST /v2/volunteering/emergency-alerts); the result says how many volunteers were asked, or that nobody could be. Active requests are listed above the shifts with how many were asked, accepted and declined, each with a Withdraw button (DELETE /v2/volunteering/emergency-alerts/{id}). Until now only the website could send one, so a volunteer's urgent-requests page on the accessible site could fill only from there. Same wording as the website in all eleven languages.Accessible site — an organisation's list of its own opportunities.
GET /volunteering/organisations/{id}/opportunities, linked first on the organisation dashboard: every opportunity the organisation has posted, as on the website's dashboard tab, with Open / Closed / Cancelled / All tabs that each show their count. Each opportunity links to its page and (unless cancelled) its edit page, and shows how many applications wait for a decision, how many volunteers are approved and how many shifts are coming up; an open opportunity with no upcoming shifts says so. Until now the dashboard showed only a count, so a closed or cancelled opportunity, which never appears in the public list, could be reached only by its address. Same wording as the website in all eleven languages.Website — Manage Organisation gains a Wellbeing tab. An organisation's owner and administrators now see, on their organisation dashboard (
/volunteering/org/{id}/dashboard?tab=wellbeing), which of their volunteers said they are Low or Struggling in a wellbeing check-in and asked for someone to get in touch: the volunteer's name, how they feel, when they checked in, when the organisation was told, and whether the community's team has responded; never the note. Each row offers Message and View profile. Open alerts carry "please check in with them soon; if you are worried for their safety right now, contact the emergency services, then tell your community's team". Plain team members do not see the tab. The bells and emails an organisation owner or administrator receives for a wellbeing alert now open this tab instead of the volunteer's profile. Until now organisation contacts were told by bell and email but had no screen of their own, which is why an organisation owner looking in Manage Organisation found nothing. Translated in all eleven languages. Tests:OrgWellbeingTab.test.tsx,VolOrgDashboardPage.test.tsx("hides the Wellbeing tab from a plain team member").Accessible site — an organisation's wellbeing alerts page.
GET /volunteering/organisations/{id}/wellbeing, linked from the organisation dashboard beside Safeguarding reports: the same list as the website's tab, as a GOV.UK table with an Important notice while anyone is waiting, a Message link per volunteer and a confidentiality line. 403 for anyone with no part in the organisation. Copy in all eleven languages; web-uk catalogues, route matrix and API ledger regenerated. Test:web-uk/tests/volunteering-org-wellbeing.test.js.Volunteering — organisations can read their own wellbeing alerts (server side).
GET /v2/volunteering/organisations/{orgId}/wellbeing(owner, admin members and the organisation's safeguarding lead; 403NOT_ORGANISATION_CONTACTfor anyone else, 404 for an unknown organisation, 60 requests a minute) lists the organisation's volunteers (approved applications to its opportunities or approved hours in the last 180 days) who raised a low-mood wellbeing alert in the last 90 days, withvolunteer,mood,checkin_at,status,told_at(when the organisation was first told) — never the note, never a private check-in. Wellbeing bells and emails to an organisation owner or administrator now deep-link to/volunteering/org/{id}/dashboard?tab=wellbeing; a safeguarding lead who is not on the team is still pointed at the volunteer's profile. The phone's push-link review records the new destination (it opens the native organisation dashboard). Tests:OrganisationWellbeingViewTest(5),VolunteerWellbeingMoodCheckinTest(link assertion),pushProducerContract.Phone app — the wellbeing screen says honestly who was told, shows the community's support card, and says when the team was last told. Reaches members with the next over-the-air update. After a Low or Struggling check-in with "Let someone get in touch with me" ticked, the confirmation now says "We have told N people from your community", "Your community was already told today", or, as a warning, "Nobody could be told yet. We will try again with your next check-in"; a private check-in says only you can see it. An "If you need support now" card under the score shows the community's text and tappable links (https, mailto, tel, or a page on the community website); "Your community's team was last told on {date}" appears on the score card. Translated in all seven app languages, with the plural forms the locale tests require. The change is in the code only: it goes out by
eas updateafter the server side is live; no store build is needed. Tests:volunteering-my-wellbeing.test.tsx(three new cases), the locale suites.Accessible site — the wellbeing page says honestly who was told, shows the community's support box, and says when the team was last told. After a saved check-in the page now shows one of three notices: "Thank you for telling us. We have told N people from your community" (
?status=checkin-saved-shared&told=N), "Your community was already told today" (checkin-saved-already), or, as an Important notice rather than an error, "nobody could be told yet. We will try again with your next check-in" (checkin-saved-nobody); an older API that only sendsteam_notifiedstill works. An "If you need support now" inset shows the community's text and links (internal links go through the tenant prefix; only https, mailto, tel or site pages are rendered), and "Your community's team was last told on {date}" appears under the burnout risk. The load-error message is now translated instead of hard-coded English. Copy in all eleven languages; web-uk catalogues, route matrix and API ledger regenerated. Test:web-uk/tests/volunteering-wellbeing.test.js(nine cases).Website — the volunteer's Wellbeing tab now says honestly who was told, shows the community's support box, and says when the team was last told. After a Low or Struggling check-in with "Let someone get in touch with me" ticked, a card under the heading (not only a toast) says what happened: "We have told N people from your community" with a short "What happens next", "Your community was already told today (time)", or, in amber, "Nobody could be told yet. We will try again with your next check-in" with the support box right beside it; a private check-in says "Only you can see this check-in". A new "If you need support now" card is always shown under the score, with the community's own text and links (set on Admin → Volunteering → Wellbeing alerts) or a default that says to contact the team. The score card adds "Your community's team was last told on {date}" once anyone has been told. Translated in all eleven languages. Tests:
WellbeingTab.test.tsx(five new cases).Admin — a real Wellbeing page: alerts with who was told, a case drawer with notes, and the community's wellbeing settings. Admin → Volunteering → Wellbeing alerts is now its own page (
/admin/volunteering/wellbeing, with a red sidebar badge counting active alerts and a row on the Overview's "Needs your attention"); until now the alert list was buried on the Overview and the admin Safeguarding page did not mention it at all, which is why an administrator looking for a volunteer's alert found nothing. Each alert now says "Told N people · date" or "Nobody could be told yet", and Open shows the case: the volunteer (with View profile and Message links), why the alert was raised, the check-ins they chose to share with their notes, who was told (each person or masked address, how, sent / queued / not delivered, when) and permanent staff notes that can be added but never edited or deleted. Bells and emails link straight to the alert (?alert=). Community administrators also get "Who is told first, and the support box": up to ten wellbeing contacts chosen from the members, an extra email address, and the plain-text support box with up to five links that every volunteer sees. Brokers see the alerts and the drawer, never the settings. The admin Safeguarding page gains a card linking to the wellbeing page. New wording hand-translated in all eleven languages, including the plural forms Polish, Irish and Arabic need. Tests:VolunteerWellbeing.test.tsx,WellbeingAlertDrawer.test.tsx,WellbeingSettingsCard.test.tsx, plusWellbeingAlertsPanel,AdminSidebar,VolunteeringOverview,attention-pluralsand the volunteering redirects suites.Volunteering — wellbeing alerts get a case view, permanent staff notes, community wellbeing settings and a "need support now" box (server side).
GET /v2/admin/volunteering/wellbeing/alerts/{id}(brokers, coordinators and admins) returns the alert, its burnout indicators, the volunteer's shared check-ins of the last 90 days with their notes, who was told (each person or masked address, tier, bell or email, sent/queued/failed, when) and the staff notes;POST …/alerts/{id}/notes {body}appends a note that can never be edited or deleted (vol_wellbeing_alert_notes, database triggers), and the existingPUT …/alerts/{id}with anotenow appends there too instead of overwritingcoordinator_notes(shown read-only aslegacy_noteswhere it still has text). The alert list gainstold_countandlast_told_at. New community-admin-only settingsGET/PUT /v2/admin/volunteering/wellbeing/settings: up to ten named wellbeing contacts (validated as active members of this community; an id from anywhere else is refused with a 422 naming the field), one extra wellbeing address, and the support box (plain text up to 2,000 characters, up to five links that must behttps://,mailto:,tel:or a page on this site). The volunteer'sGET /v2/volunteering/wellbeingnow carriessupport(the community's text and links, or a translated default that says to contact the team),last_team_notified_atandlast_told_count.GET /v2/admin/badge-countsand the volunteering overview's attention row gainactive_wellbeing_alerts. The GDPR export includes the member's notification record and the notes on their alerts; erasure removes the notification rows (as volunteer and as recipient), while notes stay attached to the alert id because the table forbids deletion (DPO question, DPO-14). Retention policy entry forvol_wellbeing_alert_notifications(730 days minimum, like the alerts). New wording hand-translated in all eleven languages. Tests:VolunteerWellbeingSettingsTest(5),VolunteerWellbeingAlertDetailTest(5, including the trigger refusing UPDATE and DELETE).Volunteering — a wellbeing check-in now tells the right people, records who was told, and buzzes a staff phone. When a volunteer says they are Low or Struggling and ticks "Let someone get in touch with me", the people told are now chosen in order (
App\Services\Volunteering\WellbeingRecipientResolver): the community's named wellbeing contacts and its extra wellbeing address (settingsvolunteering.wellbeing_contact_user_idsandvolunteering.wellbeing_contact_email, admin screen to follow), then its safeguarding staff including the volunteer themselves when they are staff (owner decision, 20 Oct 2026: on hOUR Timebank the owner was the only staff account, so the "never about yourself" rule left nobody to tell and the feature looked cosmetic), then the contacts of the organisations they volunteer with, then the safeguarding staff of the community directly above (never a sibling, never the platform master), and only when none of those exists the community's own contact address. Every bell and email is recorded per person and channel in the new tablevol_wellbeing_alert_notifications(tier, channel, sent/queued/failed, never the note or the mood), which is what the admin panel will show as "who was told". Staff phones now get a time-sensitive push for the new curated typevolunteer_wellbeing("Pat Murphy would like someone to get in touch"), in all eleven languages. A new append-only tablevol_wellbeing_alert_notes(database triggers refuse UPDATE and DELETE, like the incident timeline) is created for coordinator notes, to be used by the admin screen that follows. Tests:VolunteerWellbeingRecipientChainTest,SendVolunteerLowMoodEmailsJobTest,VolunteerWellbeingMoodCheckinTest(seven new cases),PushCopyCatalogueTest.Volunteering — the asker is now told when a shift swap request expires. When
ShiftSwapService::expireStaleRequests()retires a request that can no longer happen (a shift started, or one of the two volunteers no longer holds the shift it names), nobody was told: the asker kept waiting for an answer that could never come. The asker now gets one bell and one phone alert, "Your shift swap request for 26 Oct 2026, 14:00 is no longer possible", naming the shift they asked for and written in their own language (rendered insideLocaleContext::withLocale, so a request expired by a cron sweep or by someone else's action still reads correctly). The other volunteer is not told; they were only ever asked, and the request simply leaves their list. New notification typevol_swap_expired:svc_notifications.shift_swap.expiredandnotifications.push_vol_swap_expiredin all eleven languages, aPushCopylock-screen entry (notifications.push.vol_swap_expired) so the alert names the shift time, grouped under volunteering in the notification centre, and the native push producer inventory regenerated. Tests:ShiftSwapStaleRequestTest(two new cases, one asserting the German rendering),PushCopyCatalogueTest(now covers the new type in every locale).Volunteering — shift waitlists polished end to end: website, accessible site, phone app, organiser roster and the alert that a place is free. A member told that a place has come free now sees when the offer lapses ("Claim by Fri 23 Oct, 14:00") on all three apps, from a new
expires_atonGET /v2/volunteering/my-waitlists(set only while the offer stands; the window stays 48 hours, now one sharedShiftWaitlistService::OFFER_WINDOW_HOURS). On the website, a full shift on the opportunity page now says "On the waitlist · place N" with Leave and a link to Your waitlists instead of offering a Join button the server refused with 409, shows "A spot is free for you" with a Claim spot button once a place is offered, hides Join when the community has the waitlist switched off, and each entry on the Waitlist tab links to its opportunity. The accessible site's waitlist page gains a Claim place button (POST /volunteering/waitlist/{shift}/claim), with its own messages for "claimed", "that place has gone" and "try again"; until now it told members to go and sign up from the opportunity page. The organiser's roster (website, accessible site, phone app, and the admin opportunities hub, which shares it) marks the person who has been offered the freed place, so an organiser can see it is spoken for. The phone's lock-screen alert now names the opportunity and the shift time ("A place is free on Riverbank clean-up") in the member's language, rather than the generic title, and is delivered as time-sensitive. The admin "Waitlist tab" switch now explains what it does. New wording is translated in all eleven website languages and all seven app languages. Tests:WaitlistFlowTest(seven new cases),WaitlistTab.test.tsx,OpportunityDetailPage.test.tsx("shift waitlist"),ShiftRosterModal.test.tsx,web-uk/tests/volunteering-shift-waitlist-join.test.js,web-uk/tests/volunteering-shift-management.test.js,volunteering-my-waitlists.test.tsx,volunteering-shift-roster.test.tsx,PushCopyCatalogueTest.Marketplace & Freecycle (phone) — the hub's filter sheet now offers "Listing type". On a phone the hub's only filter was the category; the search and category pages had already gained a listing-type choice (fixed price, negotiable, free, borrow & lend) on 17 October, so the sheet now carries the same chips. A tap applies at once and closes the sheet, as the category chips do; the chosen type shows as a removable chip under the search bar, travels in the address as
?price_type=, and hides the "Featured" strip like any other filter. Test:MarketplacePage.test.tsx("offers the same listing-type choice as the search page").Marketplace & Freecycle — the marketplace has a new name and a new "Borrow & lend" listing type. Members now see the module as "Marketplace & Freecycle" on the website, the accessible site and the phone app: a place to buy, sell, give away for free and lend. Nothing behind the name changed — the
marketplacefeature switch, every/marketplaceaddress and API path, and all table names are as before. A member can now list something to lend (a ladder, a pressure washer) rather than sell: the new listing typelendcarries no money or time-credit price, cannot be posted (collection or community delivery only), cannot be bought with Buy now and cannot receive a price offer (the server refuses all three with a translated message), and the call to action is "Ask to borrow", which opens a message to the owner. Owners can add plain-text loan terms (how long, where to collect, "please return clean"; up to 500 characters) that are shown only on lend listings. Browse them withGET /v2/marketplace/listings?price_type=lend. The accessible site previously labelled any listing without a price "Free" and offered a free checkout — it now shows "Borrow", hides Buy and Make an offer, shows the loan terms, and offers "Ask to borrow"; its create form gains the loan-terms box and refuses a lend listing that could be posted before calling the server. Schema:price_typegainslend(appended,ALGORITHM=INSTANT) andmarketplace_listings.loan_termsis added (an in-place rebuild, because the table has a full-text index — see the migration header; the table is small). A platform category "Tools & Equipment" is seeded. New strings are translated in all eleven languages. Tests:MarketplaceLendListingTest,web-uk/tests/marketplace-lend-listing.test.js.Admin — a place to add, rename, hide, reorder and delete Marketplace & Freecycle categories. Until now there was no screen and no endpoint for this: the only categories were the platform's seeded ones. New endpoints under
/v2/admin/marketplace/categories(list including hidden ones with live listing counts, create, update, reorder, delete), admin-only and behind themarketplacefeature. Platform-wide categories are listed but read-only; another community's categories are never visible; a category can sit one level under a top-level category; a name that clashes with a platform category is refused; hiding a parent hides its children; and a category that listings still use cannot be deleted (the response says how many), so an admin hides it instead and no live listing silently loses its category. In the admin panel the manager is a "Categories" card on Admin → Marketplace & Freecycle (also linked from the sidebar as "Categories"): add, inline edit, show/hide, move up/down, delete with confirmation, and a "Hide instead" offer when the server refuses a delete. Routes live inroutes/api-marketplace-admin.php. Walked live on the local stack on 17 Oct 2026 (category created through the screen, 201 from the API). Tests:AdminMarketplaceCategoryControllerTest,MarketplaceCategoriesPanel.test.tsx.Website — Marketplace & Freecycle: a "Borrow & Lend" page, lend listings everywhere, and a listing-type filter. New page
/marketplace/borrow(next to/marketplace/free, both reachable from the hub's quick links). The create and edit forms offer "Lend / borrow (item comes back)": the price fields disappear, an optional "Loan terms" box appears (500 characters, with a counter), and delivery collapses to collection only. A lend listing shows a "To borrow" chip instead of a price on cards and on its page, the Buy Now, Make Offer and Message Seller buttons give way to one "Ask to borrow" button (which opens a message to the owner, pre-filled for borrowing) and a "Loan terms" block. Advanced search, the category pages and the map's filters gain a "Listing type" choice (fixed, negotiable, free, lend), which none of them had; the map gets a lend pin colour. Category pages and category chips now show the category's icon (previously the icon's internal name, such as "Smartphone", was printed as text). Walked live on the local stack on 17 Oct 2026: a lend listing created through the form (201) and its page rendered with the chip and the terms. Translated in all eleven languages. Tests:BorrowLendPage.test.tsx,PriceBadge.test.tsx,CreateMarketplaceListingPage.test.tsx,EditMarketplaceListingPage.test.tsx,MarketplaceListingPage.test.tsx,DynamicIcon.test.tsxand the search, category, map and collections suites.Phone app — Marketplace & Freecycle: a "Borrow & lend" screen, lend listings, and a listing-type filter. Reaches members with the next app release. New screen (hub shortcut beside "Free items", deep link
/marketplace/borrow), "Lend / borrow (item comes back)" in the new- and edit-listing forms with the same no-price, loan-terms and collection-only behaviour as the website, "Ask to borrow" and a "Borrowing conditions" card on a lend listing's page, a "Borrow" label on cards, a "Listing type" chip row in advanced search (which had no price filter at all), and the quick-create tile now reads "Sell, give away or lend". Translated in all seven app languages. The change is in the code only: there is no new store build yet. Tests:marketplace-borrow.test.tsx,marketplace.test.tsx,marketplace-search.test.tsx,marketplace-detail.test.tsx,new-marketplace-listing.test.tsx,quick-create.test.tsx.Messages — edits, deletions and reactions now reach the other person live (server side). When someone edits a message, deletes it, or adds or removes a reaction, the server now tells both people in the conversation straight away over the live connection (
message.updated,message.deleted,message.reactionon each member's own channel). A "delete for me" is sent only to the person who did it, so the other member never learns of it. A client that was not connected can catch up: the thread request acceptsupdated_since(thesynced_attime it was last given) and then also returns messages below its cursor that were edited or deleted for everyone since. Reactions are live-only until the next full load, because a removed reaction leaves no timestamp behind. Group conversations are not covered. Tests:MessageUpdatedTest,MessageDeletedTest,MessageReactionTest,MessageMutationBroadcastTest.Web — an open conversation now follows edits, deletions and reactions as they happen. When the other person edits a message, the new text appears with the "edited" mark; when they delete it for everyone, the bubble turns into the "message deleted" placeholder; when either of you reacts, the counts update to the server's totals. A "delete for me" done on another of your own devices removes the bubble here too. If auto-translate is on, an edited message is translated again. The 30-second background poll now also catches edits and deletions made while the live connection was down (it sends the last sync time back as
updated_since). Tests:PusherContext.test.tsx,ConversationPage.realtime.test.tsx.Mobile — an open thread now follows edits, deletions and reactions as they happen. The same three live events as the web: an edited message shows its new text with the "Edited" mark, a message deleted for everyone turns into the deleted placeholder (and any open edit or options sheet for it closes), reaction pills take the server's counts, and your own reaction made on another device is highlighted here too. A "delete for me" done on another of your devices removes the bubble. The mobile app has no background poll, so changes made while the connection was down appear on the next refresh. Needs a new store build like the rest of the messaging revamp. Tests:
RealtimeContext.test.tsx,thread.test.tsx.Mobile — messaging revamp: read ticks, live updates, typing, and reactions on any message. Your own messages now show one grey tick when sent and two blue ticks once the other member has read them, updating live; the inbox card's tick follows the same rule. New messages, read receipts and typing arrive without refreshing; the inbox moves a conversation to the top and bumps its count as a message lands. Long-pressing a message (or tapping ⋯) opens a sheet with all six reactions plus Copy, Edit (own messages, within 24 hours) and Delete, matching the web; your own reaction is highlighted. Threads gain Today / Yesterday separators, grouped bubbles from the same sender, a "new messages" button when something arrives while you are scrolled up, and a tap on the header opens the member's profile. Every new string is translated in all seven app languages. Needs a NEW store build on both platforms: the Play build members have (1.9.0) cannot take this as an over-the-air update, and the iOS 1.10.0 build in review cannot either. Not yet walked on a device with two accounts. Tests:
RealtimeContext.test.tsx,thread.test.tsx,messages.test.tsx,useTypingSender.test.ts,threadRows.test.ts,color.test.ts,usePaginatedApi.test.ts,ActionSheet.test.tsx,lib/api/messages.test.ts.Pages — Preview now works for drafts, before anything is saved. The Preview button opens the page as it would look, in a window on top of the editor, on the create screen as well as when editing. Previously it opened the public address, which showed "Page Not Found" for any draft. When a page is published, a separate "Open live page" button opens the real address.
Mobile — the iOS app is set up for the App Store. The Apple association file (
react-frontend/public/.well-known/apple-app-site-association) now carries the real Team ID, soapp.project-nexus.ielinks can open the iPhone app once it is installed (Associated Domains);mobile/eas.jsoncarries the App Store Connect app ID in place of the placeholder, soeas submitandnpm run verify:ios-releasework. The signing certificate, provisioning profile, push key and App Store Connect API key live outside the repository.Pages — a community can put its own pages in the header menu, under a column it names itself. The Page Builder has a new "Header column" location (alongside the existing ones), and Admin → Pages has a small card to name that column (
general.header_column_title, saved throughPUT /v2/admin/settings; plain text, up to 30 characters; blank means the default "Our Pages"). The tenant bootstrap now returnsmenu_pages.header, and every menu page in it carries a shortdescription. An unknownmenu_locationand an invalid column title are refused with a 422 instead of being saved. Regression tests:AdminContentControllerTest,AdminConfigControllerTest,TenantBootstrapControllerTest,HeaderColumnTitleCard.test.tsx.Admin — a "Stripe account" page where a community connects its own Stripe account, so card donations and recurring support are paid straight into its bank account instead of the platform's. It sits in the Financial section for every community with Volunteering or Donations & Support switched on. Until now the only way to connect an account was a box inside Donations & Support, a feature that is off by default, so most communities taking one-off donations had no way to reach it. The page says in plain words where payments currently go, shows the account's status (not connected, pending, ready, or action needed with a count of what Stripe still wants), and offers the right next step: Connect Stripe, Finish setup in Stripe, Check status, or Disconnect (with a confirmation that explains what happens to money already taken). Coming back from Stripe is acknowledged, and an expired setup link is explained. New server endpoint
POST /v2/admin/member-premium/connect/dashboard-linkreturns a one-time link into the community's Stripe Express dashboard (balance, payouts, payments) without a separate Stripe login; it is admin-only, limited to 10 calls a minute, and, like every other use of the saved account, refuses an account that this community's own setup did not create (F-263). Donations & Support now shows a one-line summary with a "Manage Stripe account" link, its support levels say "Needs sync" when their Stripe prices live in a different account from the one payments now go to (previously "Synced" stayed on after connecting a community account, although checkout re-creates the prices on first use), and its finance figures say "Paid into your Stripe account" and "Paid via the platform account" instead of "Tenant Connect volume" and "Platform fallback volume". Everything is hand-translated in all eleven languages, with a help-drawer article. Regression tests:react-frontend/src/admin/modules/billing/StripeAccountPage.test.tsx,tests/Laravel/Feature/Controllers/MemberPremiumAdminStripeAccountControllerTest.php.Website — a "Donate" button in the top utility bar, shown only when the community has Donations & Support switched on. It sits next to the language picker and opens the community's donation page (
/premium); a signed-out visitor is asked to sign in first and is then taken there. Communities without themember_premiumfeature see no change. It reuses the existing "Donate" wording, so it is already translated in every language.Admin — a community can add its own welcome text to the "account approved" email. Settings → Registration & access has a new box, "Welcome text for the approval email", open to community admins and above. Whatever is written there appears in the email a new member receives when their account is approved, just after its opening paragraph; left empty, the email is exactly as before. Plain text only, up to 1,000 characters: it is escaped before it reaches the template, so formatting and links show as typed (an admin cannot add a link to an email carrying the platform's name), and a blank line starts a new paragraph. It is one text per community, sent as written whatever language the member uses. Stored as
general.approval_email_messagethroughPUT /v2/admin/settings, which refuses a non-text value or more than 1,000 characters with a translated 422. Requested by a Bridgwater Time Bank coordinator.Events — the organiser's message list can now say why some people did not get a message.
GET /v2/events/{id}/broadcasts?include=summaryandGET /v2/event-broadcasts/{id}?include=summaryadd a short excerpt of each message and counts of skipped and undelivered deliveries by reason (for example "turned email off"), never who they were. Withoutinclude=summarythe responses are unchanged, so the current phone app is unaffected.Events — every check-in screen now explains how check-in works, in plain English, on the website, the accessible website and the phone app. Organisers and staff get a short "How check-in works" panel at the top (who can use it, that it opens 30 minutes before the start and closes 24 hours after the end, the three ways to record arrivals, what Undo does, and how time-credit rewards behave), with hints under every control that needs one. The organiser's page now puts "Check in by name" first, scanning a code second, and offline mode last, marked as only needed without internet. Attendees see "Your check-in code (optional)" with three steps: create it, save or print it, show it at the door. Jargon such as "signed credential", "manifest" and "encrypted queue" is replaced with everyday words. Before check-in opens, the list now says when it opens instead of offering nothing. The accessible website also shows the attendee's code as a QR code, with no JavaScript needed. New and changed wording is hand-translated in every language each app supports.
Admin — an interrupted member import can be finished by checking the same file again. Members already created from that exact file are recognised, skipped and counted ("Already imported from this same file earlier, so skipped"), so only the rest are imported; a file with nothing left says so. A member with the same address from a different file is still reported as already a member.
Admin — a member import can now be undone. After an import finishes, stops, or cannot continue, an "Undo this import" button (with a confirmation, and the second-factor check) removes the members that import created, so a wrong file can be corrected and imported again. Only members nobody has touched are removed: anyone who has signed in, is not a plain member, has any exchange or other ledger entry, or whose balance has changed is kept, and the result says how many were removed and kept. Removed members go through the platform's normal member deletion (the email address is freed), their opening-balance entry is marked cancelled and their balance returns to zero, and welcome emails not yet sent are cancelled. Like the import, the undo works in short passes the browser repeats until it is done, with a running count, so a 5,000-member undo (measured at about four and a half minutes) does not outlive the browser's wait. It is refused while the import is still running, and every pass is recorded in the audit log.
Admin — send the welcome email to members who have never signed in, either the ones you select or everyone at once. On Admin → Users, selecting members now offers "Send invitation", and the "Never logged in" tab has "Invite everyone who has never signed in", which first shows how many members will be emailed and roughly how long it will take, and asks again if that number changes before you confirm; inviting everyone asks for a fresh second-factor code, like the member import. Both only queue the emails: they go out in the background at about one a second, each with a link to set a password. Members who have signed in, were invited in the last 24 hours, are already waiting, are held or suspended, or whose address cannot receive mail are skipped, and so are staff accounts (invite those one at a time). The result says how many were queued and how many were skipped. Administrators only, not brokers; each request is recorded in the audit log with its counts. Server:
POST /api/v2/admin/members/invitations,GET …/never-signed-in-count,POST …/never-signed-in.Admin — export every member in the same columns the member import reads, to move a community's members to another community. On Admin → Users, "Export all members" is now a menu with two choices: "Members report (all details)", which is the report it always was, and "File for importing elsewhere", which downloads
members-for-import-YYYY-MM-DD.csvwith the import template's columns (first_name,last_name,email,phone,location,balance) and one row for every member who has not been deleted or erased, staff accounts included (the import file has no role column, so anyone imported elsewhere becomes an ordinary member). It is the "file ready to re-import" that the 3.1.0 import entry listed as not yet built (asking members for their town is covered by the separate "Where are you based?" entries in this section). Administrators only, not brokers; 10 downloads per 5 minutes per administrator; each download is recorded in the audit log (member_export, with the row count) and the activity log before any data is sent, and nothing is sent if that record cannot be written. Balances are written with two decimals, a negative one is escaped like any other value that starts with-(the importer undoes that and starts the member at 0, with a warning), and values that start with=,+,-or@are escaped against spreadsheet formulas. Known limits: there is no second-factor prompt on this download yet (the existing members report has none either, and the download button cannot show the prompt); a community with more than 5,000 members exports fine but the importer refuses a file over 5,000 rows or 2 MB, so the file must be split first; and a member stored with a one-word name exports with an empty last name, which the importer refuses, so those rows need a last name added before the file can be imported. Server:GET /api/v2/admin/members/export.Welcome and invitation emails go out through a stored, paced outbox that survives restarts. The emails queued by the member import's "email each new member" option and by "Send invitation" / "Invite everyone who has never signed in" are rows in a database table (
member_invitation_outbox), not jobs that can be lost with a restart. A scheduled command (members:send-invitations, every minute, run in the background so its 50-second runs do not hold up other scheduled jobs) sends them at about one email a second (the time estimates assume about 40 a minute, allowing for the time each send takes), each with a fresh 7-day set-password link made at the moment it is sent. A send that fails is tried again after a growing wait (5, 20, 45 and 80 minutes) and is given up on after five tries; a send that was interrupted part-way is not repeated, and a member is not sent a second invitation within 24 hours. In rare cases where the mail service accepts a message but does not confirm it, the message may be retried and arrive twice. At the moment of sending, a member is skipped, and the reason is recorded, if they have already signed in, are waiting for an identity check or are held or suspended, were invited by any route in the last 24 hours, are no longer in the community, or have an address that cannot receive mail. A record holds who asked, when, the status and, for a failed send, the kind of error and a short message with any email address removed (for a database error, only its error codes), never the text of an email; finished records (sent, skipped or given up) are deleted 30 days after they last changed. The 30 days is a working choice that the Data Protection Officer has yet to confirm. First shipped with the import's email option in 3.1.0; these details were not described then.Server — every app can now tell when a member has no town, and the profile checks the town and map position it is given.
GET /api/v2/users/mereturnslocation_missing(true when the member's location is empty, missing or only spaces; the server decides, the apps just read it), andGET /api/v2/onboarding/statusreturnshas_location. Nothing requires a location: finishing onboarding and every other call still work without one. Saving a profile now refuses a location longer than 255 characters or that is not text, a latitude outside -90 to 90, a longitude outside -180 to 180, non-numbers, and 0,0 together (the same check registration has); leaving the map position out or sending null is fine. Admin → Users also accepts the filterstatus=no_locationfor members with no town. The website, accessible site and phone-app prompts that read this flag are separate changes.Website — the sign-up wizard asks "Where are you based?" of members who have no town. On the "Your profile" step, a member the server reports as having no location (
location_missing) now sees a town box using the same place search as registration, and cannot press Next until they fill it in (typed text is accepted; the map position is filled in within about a minute if they did not pick a place). It is not a new step, so the accessible site and phone app are unaffected; members who already have a location never see it, and such a member who already has a photo and bio is no longer skipped past the profile step. If the server refuses the town, the member sees a message in their own language. The server still never requires a location.Website — the home page keeps reminding members with no town to add one, and the reminder cannot be hidden. A member who has finished sign-up but has no location (
location_missing) sees a "Tell us where you are based" card at the top of the dashboard with the same place search and a Save button; saving it updates their profile and the card disappears. There is deliberately no close, "not now" or "remind me later" control and nothing is remembered in the browser, so it stays until a town is added. Members who have a location never see it. It does not wait for sign-up to be finished: a member an admin created or imported, in a community where the sign-up wizard is switched off or optional, is reminded too and may see the set-up banner beside it (communities that force the wizard ask before the home page is reached).Admin — a "No location" tab on Admin → Users lists the members who have no town. It sits beside "Onboarding Incomplete", can be opened from a
?filter=no_locationlink, and is also understood by the smart search (typeno locationorstatus:no_location), so an admin can see who still needs to be asked. The filter itself is tenant-scoped on the server, and deleted or erased accounts are never listed.Accessible site — members with no town are asked "Where are you based?" in sign-up and are reminded on the dashboard until they add one. On the "Your profile" step, a member the server reports as having no location (
location_missing, only a literaltrue) gets a plain text field and cannot continue with it blank; a member who already has a town sees no change, and the server still never requires one. The dashboard shows a banner with a small form (no JavaScript needed, no way to hide it) to every member with no town, whether or not sign-up is finished, that saves the town through a newPOST /dashboard/location(limited like the site's other forms) and returns with a "Location saved" message, or with an error summary if it is blank or could not be saved. All new wording is translated in all 11 languages.Volunteering — organisations can now retire a volunteer or remove them from the roster. Until now the Volunteers tab of an organisation's dashboard only listed people, and the only way to take someone off was one opportunity at a time from Applications, so a volunteer who had stopped could never be cleared. Each volunteer now has Retire and Remove buttons (each asks first), and the tab has Active and Retired lists. Retiring moves the volunteer to the Retired list with their hours, roles and history kept, frees any places they hold on shifts that have not started (the waiting list is offered them), and can be undone with Reinstate. Removing takes the volunteer off every one of the organisation's opportunities (their approved applications are deleted, as the existing per-opportunity removal does), frees upcoming shift places and clears any retirement; their logged hours are kept, pending applications are left alone, and they can apply again. The volunteer is told in their own language each time, and every action is written to the organisation's audit log. Open to the people who can already see the roster: the organisation's creator, its owners and admins, and community admins. The "volunteers" count on the Overview now counts active volunteers only. New table
vol_org_retired_volunteers; server:GET …/organisations/{id}/volunteers?status=retired,POST …/volunteers/{userId}/retire,POST …/volunteers/{userId}/reinstate,DELETE …/volunteers/{userId}. Translated in all 11 languages.Volunteering — a volunteer taken off an opportunity, or off an organisation's whole roster, is now told by email. Until now they got only an in-app bell and a phone push. Removal from one opportunity (from the organisation's Applications tab, the opportunity page, or Admin → Volunteering) emails the opportunity's name; "Remove" on the Volunteers tab emails the organisation's name and says any upcoming shift places were released. The email is calm rather than alarming: it thanks them, says their logged hours stay on their record, suggests contacting the organiser if they think it is a mistake, and links to browse opportunities. It is sent at once whatever the member's email-digest setting (new notification type
vol_volunteer_removed, in the dispatcher's always-instant list), in the member's own language (all 11), and not when someone removes themselves. Retiring or reinstating a volunteer is still a bell only.Phone app — members with no town are asked "Where are you based?" in sign-up and are reminded on the home tab until they add one. Reaches members with the next app release. On the "Your profile" step, a member the server reports as having no location (
location_missing, only a literaltrue) gets a town box and cannot continue with it blank; one who already has a photo and bio is no longer skipped past that step. On the home tab a card with a town box and a Save button stays until a town is added: it has no "Not now" and nothing is remembered on the phone. Members who have a town never see it. The card appears from the next app start after signing in (the sign-in reply does not carry the flag), and a member who has not finished sign-up is asked in the sign-up wizard instead. If the server refuses the town the member sees a message in their own language, and the server still never requires a location, so older app versions keep working. Translated in all 7 app languages. The change is in the code only; there is no new store build yet, and over-the-air updates are off, so it reaches phones only when the owner releases a build.Admin — deleted members have their own "Deleted" tab on Admin → Users and appear nowhere else on that page. Deleting a member erases the account but keeps an anonymised "Deleted User" row, and the list never checked for it, so those rows showed under "All Users" with an Active badge, and under "Never Logged in" and "Onboarding Incomplete" too. Every tab now leaves them out; the new "Deleted" tab (also
?filter=deleted, serverstatus=deleted) lists them with a "Deleted" badge, read-only: no action menu, no edit link and no bulk selection, because there is nothing left to approve, suspend, invite or edit. An account counts as deleted whendeleted_atoranonymized_atis set. The CRM's member counts (total, active, new this month, never logged in) now use the same rule; before, they left deleted accounts out only because erasure also marks them inactive, so a deleted account still marked active was counted. The admin dashboard already used this rule. The CRM activity timeline (and its CSV export) no longer shows anything a deleted member did (sign-ups, logins, listings, exchanges they sent, notes about them, tasks they created, groups they joined, profile edits); an exchange a live member made with a deleted member still shows on the live member's side. Labels translated in all 11 languages.API — outbound webhooks have platform-level endpoints under
/v2/admin/integrations/webhooks(list, create, update, delete, send a test, delivery log, rotate the signing secret, and the list of events the platform really sends). They are for a god account that is also a platform super admin, both flags, through a newrequireGodPlatformAdmin()guard: the existing "platform super admin" check lets a god-only account through and the god check alone ignores super admin, so neither was enough on its own. Each call names the community it manages (tenant_id, defaulting to the current one) and that community must be within the caller's reach. A webhook may subscribe only to the six events the platform actually fires; the signing secret is returned once, on creation or rotation, and never listed. The page that uses these endpoints follows in the next change; the volunteering endpoints stay until then. Pinned byAdminOutboundWebhooksControllerTest.Admin — the volunteering queues have counts:
GET /v2/admin/badge-countsnow also reports applications, hours entries, expense claims and project ideas waiting for a decision, and swap requests waiting for admin approval (only when the community requires it), andGET /v2/admin/volunteeringcarries the same figures asstats.attention. One source for the sidebar badges and the Overview's "Needs your attention" strip, so the two can never disagree (AdminBadgeCountServiceTest,AdminVolunteerControllerTest).Podcasts — a virus scanner for uploaded audio in production. New shared ClamAV container
nexus-php-clamav(compose.clamav.yml, imageclamav/clamav:1.4.3, 3 GB memory limit, size limits raised to 300 MB so clamd never passes a large file as OK without scanning it).compose.bluegreen.ymlpoints the app, queue and scheduler at it (CLAMAV_ADDRESS), and every deploy runsup -d clamavfrom the release (ensure_clamav_scanner, never fatal,NEXUS_SKIP_CLAMAV=1skips it). New scheduled commandpodcasts:rescan-unavailable-media(every 15 minutes) re-queuesscan_unavailableaudio once the scanner answersPING(PodcastMediaScanner::isReachable()). The verdict read now waits up to 120 s, so a large episode is not recorded unavailable while clamd is still scanning it. Tests:PodcastRescanUnavailableMediaTest(a real clamd-protocol server, clean and infected),scripts/test/test-clamav-scanner-wiring.sh(BLOCKING in CI). Checked by hand against the real ClamAV image: clean audio passes, the EICAR test file is caught, a 150 MB file scans in about 3 s.Podcasts — the studio explains how to publish and share a podcast. A "How to publish and share your podcast" guide at the top of the Podcast Studio (open for a creator with no show yet, folded otherwise) walks through creating a show, uploading audio here, or linking audio stored elsewhere — with the rule that matters: the link must be to the audio file itself (https://…/episode.mp3), and links to Spotify, Apple Podcasts, SoundCloud or YouTube pages, or Google Drive and Dropbox "share" links, will not play — then publishing and sharing. It names hosting options (Spotify for Creators, Buzzsprout, Podbean, Acast; the Internet Archive's "VBR MP3" link) without quoting their plan limits, which change. Each published, approved show in "My shows" now has a Share links box: the show page (members sign in to listen — podcast pages are behind sign-in) and, for Public shows in communities with RSS on, the podcast-app (RSS) link that works without signing in; each link is shown in full and has a Copy button. The audio fields' hints say the same in one line.
GET /v2/podcasts/minemeta gainsenable_rss_feed. Hand-translated in all eleven languages; labels the guide quotes are read from their own keys. Tests:PodcastStudioPage.test.tsx(guide and share links, 5 new),PodcastControllerTest. Checked in a browser on the local site, desktop and phone width; the feed link answered 200 RSS with no sign-in.
Changed
- Accessible site onboarding matches the website's current wizard (React 8578ce739): the interests step is retired (the server had been discarding it) and the skills step offers the category names, pre-ticks the member's existing skills and sends
skills: {offer, need, replace}as React does. - Admin sidebar: the offers-and-requests round-up is now labelled "Email Digest" (owner, 26 Oct 2026). "Offers & requests email" was too long for the side panel and ran off its edge. Shortened in all eleven languages (
admin_nav.json→listings_digest); the page itself and its search keywords are unchanged. - Safeguarding questions cut to one, reworded and set per country (owner, 25 Oct 2026). New members are asked only "I consider myself a vulnerable adult" and "none of these apply". Every other question is no longer offered to new members (
accepts_new_selections => false); members who hold one keep it: "only vetted members" (folded into the vulnerable-adult question; not in the Ireland preset at all, and retired in Irish communities where nobody holds it), "coordinator arranges all contact", "coordinator introductions", "no home visits", and "I plan to offer help involving children or vulnerable adults" (retired for now; minors are to be handled separately). Questions are re-ordered: vulnerable adult first, "none of these apply" last. Remaining wording corrected: "only vetted members" now reads "Only members whose vetting this community has recorded may contact me"; "no home visits" no longer promises visits "will be arranged through a coordinator"; the wizard introduction no longer promises members will be "matched with appropriately vetted members".config/safeguarding_presets.phpis built from one template per jurisdiction. Staff wording corrected: therestricts_messagingrule was labelled "Monitor messaging" (it blocks contact and monitors nothing; now "Contact through a coordinator", in the staff screens and the Help Centre guides for admins and brokers in all eleven languages), and "vetted members only" was described as hiding non-vetted members from discovery (it blocks all contact from them). - Safeguarding — a member with a contact-limiting choice no longer freezes the groups they belong to (owner decision, 25 Oct 2026). Until now one such member (vulnerable adult, vetted-only or coordinator-only) stopped everyone posting in their group's chat rooms, discussions, announcements, Q&A, wiki and files, stopped anyone joining it, and made event broadcasts and volunteer emergency alerts to an audience including them fail. New
SafeguardingInteractionPolicy::evaluateCommunitySpaceContact()/evaluateCommunitySpaceBroadcast()treat those as a shared space where a member's own contact choices are not applied; they still apply to anything aimed at the member (direct messages, @mentions, a Q&A vote on their question, exchanges, credits, group conversations). Blocks between two members still apply in spaces. Callers moved:GroupService::assertSafeguardingCohortAllowed()/assertSafeguardingBroadcastAllowed()(explicit targets and @mentions are checked as direct contact),GroupChatroomService,GroupLifecycleService(clone),EventBroadcastAudienceResolver,EventNotificationService,EventBroadcastDeliveryConsumer,VolunteerEmergencyAlertService. - The accessible site's home page uses the landing page editor's wording, on every community (2026-10-24). The heading is the editor's main heading (both halves), the paragraph under it is the editor's intro, and the community's own "How it works" steps and closing message appear too, in the accessible layout (
web-uk/src/lib/landing-content.js). A section switched off in the editor is hidden; an empty box keeps the accessible site's standard wording, and without editor wording the heading is the community's name (with editor wording the name moves into the caption above it). The closing message is shown only to visitors who are not signed in. Buttons stay the accessible site's own, because the editor's links point at main-site addresses. The landing page editor says so (content.landing_intro_accessible, 11 locales). Until now the accessible home used the separate "hub heading" / "hub intro" fields, shown on the three hub communities only, so most communities' front-page wording reached only the main site. New accessible keyhome.how_it_works_title(11 locales). Tests:landing-content.test.js,routes.test.js,LandingPageBuilder.test.tsx. - Public page descriptions name the community. Features, FAQ, Contact, Terms, Privacy, Accessibility, Cookies, Community guidelines, Acceptable use, the legal hub, the timebanking guide and the blog index used one sentence identical on every community; they now include its name (
{{name}}, 11 locales, each English description 160 characters or fewer, pinned bycommunityMetaDescriptions.test.ts). The stale "v1.6" is gone from the Features description. - Real-user page speed (React) and asset caching (nginx). The blog hero image loads eagerly at high priority with its size set and no fade-in; public event and footer logo images declare their size; the home headline animates position only, so the largest text is visible at first paint (
HeroSection.firstPaint.test.tsx).nginx.bluegreen.conf/nginx.conf: the one-yearimmutablecache now applies only to content-hashed/assets/files (every name there is hashed); other static files (favicon.svg,/icons,/images,sw-push-handler.js,og-default.svg) are cached a day and revalidated, so a replaced logo or icon no longer sticks for a year;gzip_comp_level6 andgzip_typesaddsapplication/manifest+json,application/rss+xml.scripts/test/test-prerender-nginx-runtime.shpins both (fails against the previous config). - Deploys refresh the SEO output they leave in Redis. Sitemaps and
llms.txtare cached an hour in Redis, which both colours share, so after a switch the new release served the old output until it expired (seen 23 Oct 2026).bluegreen-deploy.shrefresh_seo_caches()runssitemap:generate --clearandseo:publish-redirectson the new colour after the traffic switch and before the Cloudflare purge, on deploy and on rollback; a failure is logged and never fails the deploy. - Footer "Powered by" badge — new Project NEXUS logo. The built-in badge (shown by every community that has not set its own badge and does not inherit a hub's network badge) is the new full-colour Project NEXUS logo: dark lettering in light mode, white lettering in dark mode, on the web app; the accessible site uses the light-mode version. Published under new file names (
powered-by-project-nexus-2026-10-light.png/-dark.png) because/images/*is cached by browsers for a year; the older file names now carry the same artwork. Communities with their own or an inherited badge are unchanged — in production that includes Timebanking UK and every community under it (checked on the live site, 22 Oct 2026). Resized to 480 px wide and palette-compressed (about 29 KB each). Tests:Footer.test.tsx,asset-url.test.ts,web-uk/tests/footer-branding.test.js. - Admin SEO page — the two fields that only a hub community uses now say so. On
/admin/seo, "H1 Headline" and "Hero Intro Text" are relabelled Accessible site hub heading and Accessible site hub intro, each with help text saying it is only used by a hub community (shown on the accessible site's home page for the hub and the communities beneath it) and that the main website ignores it and uses the landing page builder instead. The health check row is "Accessible hub heading" and its "not set" note says it only matters for a hub. Nothing changes in what is stored or served;tenants.h1_headline/hero_introare still read byweb-ukfor hub tenants only. Relabelled in all eleven languages (the French label had been machine-translated as "Titre du premier semestre", a half-year title). Follow-up from the Timebank Ireland landing-page makeover, 22 Oct 2026. - Phone app — Expo patch releases.
expo55.0.31 → 55.0.32,expo-location→ 55.1.15,expo-sharing→ 55.0.25, the versions Expo SDK 55 now expects;expo-doctorin the Android Native Release Gate failed on the mismatch. Expo's own sub-packages moved with it (@expo/cli55.0.37,expo-modules-core55.0.27,expo-modules-autolinking55.0.28, andxcode3.0.1 → 4.0.0, an iOS build-tool dependency of Expo's config plugins). Nothing reaches phones until the next store build or over-the-air update; these touch native modules, so the over-the-air native-surface check applies. - Volunteering — the page is now a volunteering home with one page per section. The hub layout trialled at
?layout=hub-previewis the only layout (the old link still works and simply drops the flag). The wall of sixteen section buttons, the "More (11)" toggle, the phone section picker (VolunteeringSectionsSheet, deleted) and the old hero are gone. The home leads with three opportunities, then four cards grouping the sections ("Find your next role", "My volunteering", "Make a difference together", "Support & recognition") and a safeguarding and accessibility strip, then "More opportunities"; a search or filter shows "Search results" alone. Each card shows live numbers (useVolunteeringHubSummary: next shift, approved hours, "Waiting: n", "To answer: n"), and a red banner appears while an urgent request awaits the member's answer — fetched only for sections the community has switched on, each failing quietly. Opening a section gives it a page of its own: a "Volunteering home" button, the same name as the link pressed, and one plain sentence on what it is for; the twelve section components no longer repeat their own title (Wellbeing keeps a screen-reader-only one as its focus target). Moving into a section adds a history entry, so the phone's Back button returns home, and "Volunteering home" goes back the same way; a link to a section the community has switched off now goes home instead of to an arbitrary section. First-time members see the three-step welcome (its "Find opportunities" now moves to the list, and the header no longer repeats it); afterwards a one-line "How volunteering works" stays. Organisation owners and administrators get a strip at the top (what they manage, or that it awaits approval, with Manage and Post buttons); members who run none see "Run an organisation?" at the foot. "Log Hours" shows only to a member who can log hours. Every volunteering button uses a deeper pink (from-rose-600 to-pink-700) that meets WCAG AA contrast with white text, and the new controls are at least 44px tall. Copy in all eleven languages (hand-translated; a native-speaker review is still due); German uses the formal "Sie" like the rest of its screens, and Polish ("zmiana"), Spanish ("Cualificaciones") and Portuguese ("Salvaguarda", "Doações") use the words their other volunteering screens use. The member Help Centre describes the new page: nine volunteering articles and "Browsing organisations" now say which link to press instead of "press More" or "the My Hours tab", in all eleven languages, each button name filled in from that language's own page wording (guides.labels.test.tspasses). Tests:VolunteeringPage.test.tsx("volunteering home", phone layout, group sign-ups),VolunteeringHub.test.tsx,useVolunteeringHubSummary.test.ts, the twelve section tests;e2e/tests/volunteering/volunteering.spec.tsupdated (not run). - Docs —
docs/modules/volunteering.mdgains a "Volunteer wellbeing" section (how the score is built, who is told and in what order, the record of who was told, the staff endpoints and pages), a Wellbeing row in the key code locations, two failure-mode rows (a shared check-in that reached nobody; a bell with no email) and the five wellbeing test files. - Volunteering — the check-in response says honestly what happened.
POST /v2/volunteering/wellbeing/checkinnow returnsoutcome(told,already_told_today,nobody_available,private,not_low),told_countandtold_atbesideteam_notified, which is true only when at least one person or address was actually reached. The once-a-day limit now keys on a check-in that reached someone: a day on which nobody could be told no longer blocks the next check-in from trying again. The daily 04:00 assessment (volunteering:assess-wellbeing) now also scans volunteers whose only signal is a shared Low/Struggling check-in in the last 14 days, so their alert is refreshed and auto-resolved like everyone else's. The queued email job (SendVolunteerLowMoodEmails) works from the notification record: each recipient is marked sent or failed with a reason, a retry sends only to the people not yet reached, and a job that dies marks every unsent addressjob_failed. The schema dump is refreshed (it had fallen behind by six migrations, includingvol_mood_checkins.share_with_team). - Volunteering safeguarding — audit of the whole area for admins, brokers and organisations (19 Oct 2026). What changed, by who it affects. Brokers and coordinators: rows in the broker safeguarding list opened the admin panel, which they cannot enter, so each click bounced to the dashboard; rows now open the broker case file. Every staff alert and email (new report, "you are handling this", status change) now links to the case rather than the list, and the staff email no longer says "admin panel". The staff member handling a case is now called the handler in emails, not "DLP", which was also the name of the organisation's own safeguarding lead. The staff counts on the list no longer include reports about the viewer, which the list itself already hid. The status dropdown on the case file offers only the moves the server accepts, and a closed case says closed is final. Status lifecycle: "Resolved" was a dead end — it could never be closed, so it accepted updates for ever; a resolved report can now be closed or reopened to "Being looked into" (
INCIDENT_STATUS_TRANSITIONS). Organisations: the deputy safeguarding lead could never be appointed (nothing wrotedeputy_dlp_user_id);PUT /v2/admin/volunteering/organizations/{id}/dlpnow takesdlp_user_idand/ordeputy_dlp_user_id, either clearable with null, refuses the same person in both seats, and the admin dialog shows both seats with Remove buttons and an "Appoint as" choice. The organisation's Team tab now names its lead and deputy and says that community admins appoint them (GET …/organisations/{id}/membersgainssafeguarding). Plain team members no longer see a Safeguarding tab that always said "no access". The lead and deputy are told when a shared full report is withdrawn (bell). The organisation email button now says "Open the report", which is where it goes. Members: the "who is told" text under the report form now says the organisation also learns how serious it is and which opportunity, as it already did. Records: code comments anddocs/modules/organisations.mdclaimed the organisation lead was "a record only" with no access and no notices; both now describe what the role actually gives. Translated in all eleven languages. Tests:OrganisationLeadsAndLifecycleTest(new),VolunteerOrganizationDlpAssignmentTest,F536BrokersHandleVolunteeringIncidentsTest,IncidentNotificationMatrixTest,SafeguardingPanelPages.test.tsx,VolunteerSafeguarding.test.tsx,VolOrgDashboardPage.test.tsx,OrgTeamTab.test.tsx. - Volunteering — the downloadable hours certificate has been redesigned. The certificate a volunteer downloads was a plain web page with a double blue border and a table, headed "Project NEXUS Community Platform", with raw
2026-01-03dates and no way to check it beyond typing a code. It is now a proper A4 landscape certificate issued in the community's own name, with the community's uploaded logo and brand colour, a framed sheet, the volunteer's name, the hours as the centrepiece ("124 hours of volunteering to Hour Timebank between 3 January 2026 and 28 September 2026", in the reader's language), a breakdown of where the hours were given (largest first; more than six organisations are summarised and the full list is shown when the certificate is checked online), the issue date, the certificate code, a QR code that opens the public check page, and a plain note of how to check it. The saved file is self-contained (no scripts, no fonts or images fetched from anywhere, the logo embedded), so it looks the same opened from disk, printed, or saved as a PDF with Ctrl+P, and a hint at the top says so. Arabic renders right-to-left. An admin's copy of a withdrawn certificate is now watermarked "Withdrawn" with the date, so it cannot be mistaken for a live one; the holder's copy is still refused. The public check now also ignores spaces typed into the code. The platform's name no longer appears on any certificate. Every string is translated in all eleven languages (api.vol_certificate_html_*; the five keys that named the platform or printed raw dates are removed). The website, accessible site and phone app fetch the same file as before, so none of them needed a change. Tests:VolunteerCertificateControllerTest(branding, escaping, ordering and summary, singular hour, single-day wording, withdrawn watermark, spaced code),VolunteerCertificateServiceTest::test_formatHours_prints_clean_figures. - Organisation dashboard — the Safeguarding tab is now clearly clickable and says what each report means. Each report card was a plain block with only its title as a link, so it looked dead. The whole card now opens the report, with a "View report" arrow; it shows a one-line plain-English explanation of the report's status (received, being looked into, passed to a specialist, dealt with, closed), a "N still open · N in total" count above the list, and a reminder that reports are confidential, and a line saying that "the safeguarding team" means the community's administrators, brokers and coordinators (there is no separately appointed team). On the report page a box shows where the report stands and what it means, the update form explains what to write and what not to do (never contact the person who reported), an empty history says so, and a closed report no longer offers an update box (the server already refused it). Nothing about who made a report is shown. Translated in all eleven languages. Test:
OrgSafeguardingPage.test.tsx. - Phone push notifications now say what happened. Every ordinary push to the app used to arrive with a bare category title and the sentence "Open Timebank Global to view this private update." A new lock-screen copy catalogue (
App\Support\Push\PushCopy, keysnotifications.push.<type>in all eleven languages) renders the title and body in the recipient's language from a small whitelisted set of facts the producer passes — the person, the thing, the time, the hours — so a member now sees "New message from Alice / Tap to read and reply", "Reminder: Garden Day starts tomorrow at 10:00", "You received 1.5 hours from Alice", "Alice wants to connect", "You earned the Helper badge" and so on. Seventeen types are catalogued (messages, connection requests and acceptances, time credits, event reminders, invitations and cancellations, shift reminders and cancellations, exchange requests and acceptances, badges, group join requests, comments, replies, mentions, moderation); the producers for messages, connections, time credits, event reminders, invitations and cancellations, shift cancellations, exchanges, badges, group join requests, comments, replies and mentions now pass those facts (the shift-reminder entry is ready but dormant: pre-shift reminders are email-only today). Every value is stripped of markup and line breaks and capped (names 60 characters, titles 80, times 40), unknown fields are dropped, an entry renders only when every fact its title names is present (otherwise the curated title and generic body are kept), and safeguarding, GDPR, emergency and support-action pushes keep the category-only presentation. Context travels as a trailing$pushContext/$contextargument onNotificationDispatcher::dispatch(),fanOutPush()andFCMPushService::sendToUser(); existing callers are unaffected. - Phone push notifications — every other dispatcher push shows its own sentence. The roughly 220 types without catalogue copy now use the bell sentence the member already sees in the inbox (and already receives as a browser push), clamped to 180 characters, instead of the generic sentence — except payouts, billing, premium membership, identity verification, support reports, donations, adjustments, refunds and invoices, whose sentences state an amount, a payment outcome or a complaint and therefore keep the generic body. A catalogued type that cannot render (for example a commenter with an empty display name) also keeps the generic body rather than showing the comment text. Direct
FCMPushServiceproducers that pass no facts keep the old presentation until converted. - New notification setting: "Show the first line of a new message in the push notification" (
push_show_message_preview, off by default, 80 characters). Message text is the one private value, so it reaches the lock screen only for members who switch this on; it is never sent on the batch path.GET/PUT /api/v2/users/me/notificationscarry the flag. The toggle on the website, the accessible site and the phone settings screen is a follow-up — the API and the delivery honour it today. - Phone push delivery — messages pop up and group by conversation. Direct and group-chat messages are sent on the Android
messageschannel at high priority (the app's 1.10.0 build creates that channel at high importance; older installs are expected to fall back to the default channel — not yet verified on a device), event and shift reminders are also sent at high priority, and the iOS time-sensitive interruption level stays emergency-only. Athread_idsuch asmessage:10travels in the push data, and on the FCM paths also as the Android notification tag and the APNs collapse id / thread id, so repeated pushes from one sender replace rather than stack. - Marketplace & Freecycle — listing wording no longer assumes you are selling, and "Free (Freecycle)" is the default listing type on all three frontends. The hub button, the sidebar card and the empty-state action now read "Create Listing" (card: "Got something to pass on? Sell it, give it away or lend it to your community."), and the form opens as "Create a Listing — Sell, give away or lend an item to your community". The pricing section is headed "Offer & price" with a tag icon instead of a dollar sign, the choice is labelled "How are you offering this item?", and the options are ordered Free (Freecycle) → Lend / borrow → Fixed price → Negotiable with Free selected by default for a new listing (an edited listing keeps its own type; a saved draft keeps its choice). The price and currency fields still appear only for Fixed price and Negotiable. The accessible site (
web-uk) and the phone app carry the same wording, order and default:web-uknow sendsprice_type=freewhen the form posts without a choice, and the app's new-listing screen opens on Free unless a deep link names a type. Translated in all eleven website/accessible languages and all seven app languages; the accessible site's generated catalogues were regenerated. The phone-app part reaches members with the next store build. The Help Centre guide "How do I sell or give away an item?" now names the new labels in all eleven languages (the English steps were rewritten; the translated steps had their labels swapped and read a little stiffly until a translator polishes them). Tests:CreateMarketplaceListingPage.test.tsx(order and default pinned),guides.labels.test.ts,MarketplacePage.test.tsx,MyListingsPage.test.tsx,web-uk/tests/shared-accessible-shell.test.js,mobile/app/(modals)/new-marketplace-listing.test.tsx. - Marketplace & Freecycle — the last "Marketplace" labels on the website follow the new name. The browser-tab titles of the create, edit, map search, my listings, my offers, my orders, my sales and seller-onboarding pages ended "- Marketplace"; the "Back to Marketplace" links and the "Marketplace" breadcrumb on the listing, seller and category pages still used the old name. All now read "Marketplace & Freecycle" in every one of the eleven languages, each using the brand form that language already had on the hub. The create form's title placeholder "What are you selling?" is now "What are you offering?", because a listing can be a sale, a giveaway or a loan.
- Accessible site — the "Free to a good home" listing-type label is now a real translation in six languages. Arabic, German, Irish, Italian, Japanese and Portuguese carried a word-for-word machine rendering (German "Frei in ein gutes Zuhause", Italian "Libero per una buona casa", Portuguese "Livre …", the sense of "free as in liberty"). Each now uses the idiom its own speakers would use for giving something away.
lang/<locale>/govuk_alpha_commerce.php; catalogues regenerated. - Messages — the "new message" job no longer carries the sender's whole account record onto the queue. The event that fans a sent message out to the bell, email, push, broker-review and federation steps used to be stored on the Redis queue with the sender's full database row, password hash included. No member or client ever saw it, but it had no reason to be there. It now carries only the seven sender fields those steps read (
MessageSenderSummary). Nothing changes for members. Test:MessageSentTest. - Mobile docs — the first iPhone release is written up. New
mobile/docs/IOS_RELEASE_PROCEDURE.mdrecords how 1.10.0 (build 8) reached App Review on 16–17 October 2026: the credentials map (none needs an Apple password), the EAS build and TestFlight commands, App Store Connect set-up (seller, age rating, privacy, EU trader status), the screenshot workflow fixes and Apple's new medium-size requirement, notifications on iPhone, every trap hit, and what is still unverified. The mobile hand-off and README link it; the Apple build evidence, screenshot plan and release-candidate record now describe the submitted build. - Admin → Landing page is redesigned so it is clear what you are editing and what visitors will see. The screen now has a "Page order" list of the seven blocks in plain words (Welcome banner, Start-here cards, Quick highlights, Live numbers, How it works, Our values, Final invitation), each with a one-line description of where it appears, a Shown switch, drag-and-drop and arrows to reorder, and a "Customised" marker when it carries its own wording. Selecting a block shows a live preview of the real block as a signed-out visitor sees it (the welcome banner and final invitation look different to signed-in members), updating as you type, with a "Whole page" view. Every text box shows the standard wording it would use if left empty. Lists of cards, steps, values and highlights start from the standard items ("Edit these items") instead of silently replacing all of them the moment one item was added; the icon picker shows the icons by name instead of codes such as
user-plus. A card without a title, or a link that is not a/pathon the site or anhttps://address, blocks Save with a reason that names the block. Save, Discard and "Start again from the standard page" sit in a sticky bar; the reset changes the draft and needs Save (previously it published at once), a page saved equal to the standard page clears the stored customisation instead of storing a copy, and leaving with unsaved changes asks first. The "Live numbers" block's separate inner switch is folded into its Shown switch (a savedshow_live_stats: falseloads as hidden). "Open the live page" opens the public page in a new tab. The shared admin save bar now wraps its buttons on narrow screens. Hand-translated in all eleven languages; the Irish-review manifest (scripts/irish-react-reviewed-invariants.json) no longer lists the two retired placeholder keys. The drag-and-drop row's one inline style is approved inreact-frontend/scripts/check-design-tokens.mjs, beside the menu builder's identical one. Regression tests:LandingPageBuilder.test.tsx,landing-page-builder/configUtils.test.ts. - Admin → Pages reads plainly, and asks before you leave with unsaved changes. The editor is titled "New page" / "Edit page" and its button says "Create page"; the two locations are called "About" and "Header column" in the dropdown and in the pages list, with one explanation of both under the dropdown. The pages list shows each page's real address (
/page/<slug>), a "View" link for published pages, and "Not shown (draft)" for a draft set to appear in a menu; the delete confirmation names the page. The header-column title card says what it is for, counts characters as you type (n/30) and has its own "could not load" message. Back and Cancel, and any link out of the editor, now ask first when the form has unsaved changes (Rich text and Design re-reading the saved page on opening does not count as a change). The breadcrumb on the new-page screen says "New page" and "Builder" is no longer a link to a screen that does not exist. Custom HTML and Design pages now carry a page heading for screen readers. The Design editor's device names (Desktop, Tablet, Mobile), missing in every language, are added. All new wording is hand-translated in all eleven languages. Regression tests:PageBuilder.test.tsx,PagesAdmin.test.tsx,HeaderColumnTitleCard.test.tsx,AdminBreadcrumbs.test.tsx,CustomPage.test.tsx. - Mobile — iPhone asks for notification permission straight after sign-in, as Android already does. Android 13+ shows its own "Allow notifications" dialog the first time the app sets up notifications; iOS shows nothing until the app asks, so an iPhone member got no notifications until they found the Home card or Settings. The app now asks iOS once a member is signed in (on sign-in, or on opening the app already signed in). iOS presents the dialog once per install and answers silently afterwards, so nobody is asked twice; the Home card remains for anyone who declined. Regression test:
mobile/lib/context/AuthContext.test.tsx("asks iOS for notification permission…"). - Website — the "More" menu is now one open panel that never folds anything away, and the Community menu uses the same panel. Every group is its own column, sized to its words (230px at least); the built-in descriptions are capped at 45 characters so they wrap to two lines and are never cut short, while a page's own description is cut to two lines. The panel is centred and can be as wide as the screen less 16px each side. On a 1024px laptop the seven groups become two rows (4 + 3); on a 1366px laptop six groups become two rows of three; on an 800px-tall screen about 77px of the panel is reached by scrolling inside it, with a fade (43px at 1366x768). The Community menu is the same panel with three columns. Timebank Ireland's five Impact pages now sit in the community's own column, titled by the community (default "Our Pages"), on desktop and in the phone drawer. Menu descriptions are capped at 45 characters, with a test, and the "Features" line now reads "What this platform can do" in eleven languages. The More and Community buttons now tell screen readers they expand a panel, not that they open a menu. Regression tests:
DesktopMegaPanel.test.tsx,megaPanelLayout.test.ts,navigationDescriptions.test.ts,Navbar.test.tsx,MobileDrawer.test.tsx. The panel's group colours are delivered as classes and its measured geometry is a registered exception in the design-token gate, which the first push failed. - Partnership page (hOUR Timebank / Timebank Ireland,
/partner) — the "Together for Timebanking" banner is the new night-sky graphic. The image filereact-frontend/public/images/Timebanking-UK-and-Timebank-Ireland-Partners.{webp,png}is replaced with the owner's new artwork (1922×818, up from 1200×510); the page's declaredwidth/heightnow match the real file so the browser reserves the right space before the image loads. Because the new artwork has dark edges, the card no longer pads it with a white frame — the image fills the rounded card edge to edge. No wording changed; the alt text already describes this graphic. - Admin — the identity-provider credentials section now says what the keys are for and shows the webhook address to register. Registration & access → provider credentials explains that leaving the fields empty uses the platform's own provider account, that entering your organisation's keys runs checks (and pays for them) through your own account, and that both the API key and the webhook signing secret are needed. Each provider shows the exact webhook address to add in that provider's dashboard, with a copy button. Hand-translated in all eleven languages.
- A tenant super admin can now look around the communities below its own, the way a member sees them — but cannot take part there. Signed in to a child community, it used to be sent to the admin panel from every member page. Now it can open the feed, listings, members, events, groups and the rest, with a banner on every page: "You are viewing as a manager from another community. You can look around, but you cannot take part here." The server enforces the "look, don't take part" rule, not the page:
CrossTenantEntryadmits a visitor'sGET/HEADof the member API (api/v2/*, plus the navigation menus) and still refuses every member write, now with403 visiting_manager_read_onlyinstead oftenant_mismatch, so the website explains the refusal instead of treating the account as foreign. Browsing leaves no trace: listing, job, marketplace, help-article and group-question view counts and resource/file download counts skip a visitor; it never shows as online; reads that write or create rows for the caller (data exports, opening a conversation, seller profiles, partner passes, check-in codes, certificates, identity status, sub-account views) stay refused (CrossTenantEntry::VISITOR_REFUSED_READS); and looking around never creates the delegate account that organiser actions use. Pages about the visitor's own membership (dashboard, messages, wallet, notifications, settings, exchanges, matches, create/edit forms) send it to the community feed. Unchanged: only communities strictly below its own, never above or beside; the platform owner is never a visitor. Owner decision, 15 Oct 2026 (security engagement E-107). - Events — an event is shown to signed-out visitors only if its organiser chooses "List as a public event". Until now, a community that switched on public events showed every published event on its public events pages (
GET /v2/public/eventsand/v2/public/events/{id}, and the accessible site's "What's on"), and organisers had no say. When public events is on, the event form now has a "List as a public event" switch, off by default. Only events with it switched on are listed or can be opened by visitors who are not signed in. A members-only event's address answers 404, the same as a draft. 🔴 Existing events become members-only until their organiser switches this on (owner decision, 2026-10-15). Communities that use public events will see their public page empty until organisers opt in. The private-group, draft and archived rules still apply on top. When public events is off, the switch is not shown and the server ignoresis_public_listing; a saved choice is kept, not wiped. Every date of a repeating event carries the choice, and "this and future dates" edits can change it (patch.is_public_listing). New columnevents.is_public_listing, default 0. Write endpoints acceptis_public_listing, and the member event payload reports it. The website and the accessible site have it now. The accessible site shows it as a checkbox on its create, edit and repeating-edit forms. The phone app has the same switch in the code, but members only get it with the next app release, which must follow the server update. Translated in all 11 languages (the phone app's 7). - Bridgwater Time Bank community hostnames (
bridgwater.timebank.global,accessible-bridgwater.timebank.global) joined the platform on 2026-10-15. Both are now inscripts/uptime-targets.json(the accessible host twice: root, and/versionassertingnexus-webuk) and in the two deploy warnings that enumerate the accessible hostnames--without-webukwould take offline. The Apache vhosts and the origin-certificate binding live only on the server, as for every other community domain. - Super admin — creating or editing a community now shows its web addresses as you type. Under the slug on the Details tab, two read-only boxes show where the community will live without a custom domain: the main website address (
https://app.project-nexus.ie/{slug}) and the accessible website address (https://accessible.project-nexus.ie/{slug}/accessible), each with a copy button. They follow the slug as it is typed (or made from the name) and ignore whatever is typed in the custom-domain boxes, so they are always the addresses that keep working. A plain-English note beside them says the community can be reached there straight away and that these are for use until its own web address has been set up with the company that manages its web addresses (DNS); once a custom domain is typed in, the note says that domain becomes the main address when it is live. A community placed under a hub that has its own domain shows the hub-domain form of the main address (for exampleuk.timebank.global/stratford); the accessible address stays on the shared accessible host, because the accessible site serves a hub's sub-communities only from there. The SEO tab's "Google search preview" now shows the same address: it used to inventhttps://{slug}.project-nexus.ie, which is not an address any community has. The slug help text ("Slug Auto." / "Slug Manual.") is replaced with plain sentences. New and changed wording is hand-translated in all eleven languages. - Events — the organiser's message screen is rebuilt to be easy to use. The Communications tab is now "Messages to attendees". Writing a message is one form with numbered questions (what kind of message, who should get it, how, the message, when), the number of people it will reach appears by itself as you choose, and one button sends it ("Send now", or "Schedule" for a set time) after a plain "Ready to send?" check. Before, sending took four steps across three windows, behind a "Preview audience" step nobody was told about. Each message in the list shows the start of its text and one line in words ("Sent to 42 people on 3 Oct, 14:05 (Dublin time)", "Sending… 12 of 42 sent", "Will send on…"), updates by itself while it is going out, and says honestly how many people were skipped or could not be reached and why, with "Try again for N people". A scheduled message can be changed; a draft can be thrown away without typing a reason. Mistakes are explained next to the field, nothing typed is lost if the window is closed or the tab changes, and a slow connection never creates a second copy. The organiser help article uses the new name. Translated in all 11 languages.
- Events — organiser messages go out much faster. The message sender handled at most 50 deliveries a minute for the whole platform, so a message to 300 people by email, app and phone took over 18 minutes. Each run now keeps sending for up to 50 seconds while anything is due (
events:process-broadcasts --budget-seconds), and runs in the background so it no longer delays other scheduled jobs or raises a false "scheduled command failed" alert when one delivery is given up on. Retry and timing settings are now real configuration (EVENTS_BROADCAST_*). - Admin — the five volunteer reminder settings (shift reminder, post-shift feedback, lapsed volunteer nudge, qualification expiry, training expiry) are now ordinary volunteering configuration,
volunteering.reminder_*, held with every other volunteering setting rather than in their ownvol_reminder_settingstable. Each is an on/off switch plus one timing; the separate "email" switch is gone because email is the only channel the platform sends reminders on, so "on but email off" sent nothing and confused admins. A migration copies what each community had saved (a reminder with email switched off is copied as switched off, which is what that community experienced), never overwriting a value already present, so it is safe to run on both blue/green colours; an edit made on the old reminders page in the minutes between the copy and the colour switch is lost, and the old table stays one release before it is dropped. The nightly sweeps now find communities from their own shifts and records instead of from saved settings rows, and read each community's switch and window throughVolunteeringConfigurationService::getForTenant(). Pinned byCopyVolReminderSettingsIntoTenantSettingsTest,VolunteeringConfigurationServiceTestand the reminder sweep tests. - Admin — the volunteer reminder settings are now in the Module Configuration window, in a new "Reminders" section beside every other volunteering setting. Each of the five reminders (shift reminder, post-shift feedback, lapsed volunteer nudge, qualification expiry, training expiry) is one on/off switch and one timing with plain-English labels, in all eleven languages; the separate email switch is gone because email is the only channel. The window can now be opened straight onto one module with
/admin/module-configuration?configure=<module>, which is how the volunteering Settings entry reaches it, and closing a window opened that way returns the admin to the volunteering overview rather than leaving them on the platform-wide switches page. A save the server refuses (a number outside its range) now shows the server's own message naming the limits instead of a generic "save failed". The old Reminders tab on/admin/volunteering/configkeeps only the delivery log and the super-admin "Run due reminders" button, as a self-contained Reminder emails panel. Pinned bymoduleRegistry.test.ts,ModuleConfigModal.test.tsx,ModuleConfiguration.test.tsxandReminderEmailsPanel.test.tsx. - Admin — outbound webhooks have moved from Volunteering → Settings to a new Integrations section in the sidebar, visible only to a god account that is also a platform super admin. The page (
/admin/integrations/webhooks) manages any community's webhooks through a community picker that starts on the admin's own. Events are chosen from a checklist of the six events the platform actually sends, named in plain English ("A volunteer completes a shift", "A job offer is accepted") instead of a free-text box; the signing secret is shown once, when a webhook is added or its secret is regenerated, so the receiving system can be set up; the button that used to say "Retry" but only sent a test is now "Send test", and real failed deliveries are retried by the server. The old Volunteering → Settings page keeps only the reminder delivery log for now. Strings in all eleven languages. Pinned byOutboundWebhooks.test.tsx,GodPlatformRoute.test.tsx,AdminSidebar.test.tsxandaccess.test.ts. - Admin — the Volunteering sidebar is eleven entries instead of sixteen, grouped by what the admin is doing: "To approve" (Applications, Hours to verify, Expenses, Community projects, each with a count of what is waiting), "Running volunteering" (Opportunities & shifts, Organisations), "Volunteers & safety" (Training & certificates, Safeguarding & incidents), "Fundraising" (Fundraising & donations) and "Setup" (Settings). Shift swaps and Group bookings, which were always empty unless a community setting was on, are now tabs under Opportunities & shifts that appear only when that setting is on; Training, Qualifications and Certificates are tabs of one entry, as are the fundraising campaigns and donation refunds; every tab keeps its own address, and every old address, including the guessed ones, lands on the right tab. Settings opens the Module Configuration window straight onto volunteering. The Overview's five shortcut cards, which duplicated the sidebar, are replaced by a "Needs your attention" strip that lists only the queues with something waiting, with the count, and by the reminder-emails panel. Strings in all eleven languages. Pinned by
AdminSidebar.test.tsx,volunteering-redirects.test.tsx,VolunteeringHubTabs.test.tsx,AdminBreadcrumbs.test.tsxandVolunteeringOverview.test.tsx.
Removed
- The "I plan to offer services that may involve children or young people (under 18)" safeguarding question (owner, 25 Oct 2026: the platform is adults only). Removed from every preset; existing communities' rows are deactivated and their selections revoked by migration
2026_10_25_200000_overhaul_safeguarding_questions(it set no protection; rows are kept for the audit trail). The community-level "works with children" declaration for a safeguarding statement is separate and unchanged. - The "Accessible site hub heading" and "Accessible site hub intro" fields are retired (
tenants.h1_headline/hero_intro, 2026-10-24). They are gone from /admin/seo, the super-admin community form and detail page, the tenant bootstrapseoobject, the SEO audit (missing_homepage_h1is no longer raised) and the create/update paths inAdminConfigController,AdminSuperControllerandTenantHierarchyService; a stale form that still sends them is ignored. Their translations are removed too, including the never-shown legacytenants.edit.h1_headline_*/hero_intro_*andlabel_h1_headline/label_hero_intro*keys (lang/*/admin.json,lang/*/super_admin.json, Reactsuper_admin), and the audit message (no stored production audit contained it). The database columns are kept for now and nothing reads them. The only consumer of the bootstrap fields was the accessible site, changed in the same release. Tests:TenantBootstrapControllerTest,SeoAuditServiceTest,SeoOverview.test.tsx,TenantForm.test.tsx. - Phone app — twenty unused "More menu" marketplace strings are gone from
mobile/locales/*/profile.json. The marketplace shortcut section was removed from the More menu in June 2026 as clutter (only "Browse Marketplace & Freecycle" stayed), but its labels and descriptions lived on in all seven languages with nothing reading them. Deleted rather than re-wired, since a test still asserts that section must not return. No visible change. - Admin — the Custom Fields tab is gone from Volunteering → Settings (
/admin/volunteering/config), with everything behind it. An admin could define extra intake fields, but no website, accessible site or app screen ever showed one to a volunteer or saved an answer. The admin list also hid every field not marked "application" as soon as it was saved. Removed: the tab and its preview;/v2/admin/volunteering/custom-fields(list, create, update, delete,reorder) and the member route/v2/volunteering/custom-fields(no client read it); the custom-field half ofVolunteerFormService. Thevol_custom_fieldsandvol_custom_field_valuestables stay, because GDPR export and erasure read them. Its 54 labels are removed from all eleven languages. - Admin — the SMS and Push switches are gone from the volunteering reminders. The platform has no SMS sender, and every reminder job sends email only, so both switches changed nothing. The cards now offer the email channel alone, the delivery log loses its channel filter, and the page no longer sends
sms_enabled/push_enabled. The columns stay invol_reminder_settings. - Admin — the Groups "Tags & Collections" page (
/admin/groups/organization) is gone, with everything behind it. None of it worked end to end: auto-assign rules were stored but nothing ever applied them, no website, accessible site or app screen ever showed a collection, and no screen could put a tag on a group, so the tag chips on group pages could never appear. On 17 October 2026 production held zero tags, tag assignments, collections and rules. Removed: the page and its sidebar link;/v2/admin/group-tags,/v2/admin/group-collectionsand/v2/admin/group-auto-assign-rules; the member routes/v2/groups/{id}/tags,/v2/group-tags(and/popular,/suggest) and/v2/group-collections(no client read them);GroupTagService,GroupCollectionServiceand their controllers; group-template default tags, tag copying on clone, thetagssection of a group data export, and the tag chips on the group page. The empty tables are left in place. The page's labels are removed from all eleven languages. - Admin — the "Welcome message" box is gone from Settings. It promised its text was "shown to new members on their first sign-in", but nothing ever displayed it — not the website, the accessible site or the app — so an admin who filled it in got nothing, and it sat beside the working "Welcome text for the approval email" box.
general.welcome_messageis no longer offered, returned or saved by/v2/admin/enterprise/configor/v2/admin/settings(the latter now answers 422 when it is the only key), and is no longer published in the publicGET /v2/tenant/bootstrapsettings(no client read it). Text already saved is left in the database untouched; one production community had some. Its labels are removed from all eleven languages. Pinned byRetiredWelcomeMessageSettingTest. - Admin — the Footer placement for Page Builder pages is gone; pages can be filed under About or the Header column. A footer page showed as a lone single column in the desktop footer and did not appear on phones at all. A data migration (
2026_10_17_120000_retire_footer_page_placement) deletes the seedtermsandprivacyplaceholder pages (under 200 characters, filed in the footer) that duplicated the real legal pages the footer already links, moves any other footer page to the header column so it keeps a link, and clears each affected community's cached bootstrap.POST/PUT /v2/admin/pagesnow rejectmenu_location: footerwith a 422; the tenant bootstrap shows any storedfooterrow undermenu_pages.header, and the admin pages list labels it Header column. Thefooter.pagesstring is removed from all elevencommon.jsoncatalogues. - Admin — three volunteering switches that did nothing are gone from Module Configuration: "Alert Expiry (Hours)", "Skill-Based Alert Matching" and "Burnout Detection". They had sat in the window since March 2026 and nothing on the server ever read them, so toggling them changed nothing for any community. The keys (
volunteering.alert_default_expiry_hours,volunteering.alert_skill_matching,volunteering.burnout_detection) are removed from the server's allow-list, the admin window, the tenant defaults and all eleven locales; a value a community saved earlier stays in its settings table unread and harmless. Pinned byVolunteeringConfigurationServiceTestandmoduleRegistry.test.ts. - API —
GETandPUT /v2/admin/volunteering/reminder-settingsare gone, withVolunteerReminderService::getSettings()/updateSetting()behind them. The reminders are saved throughPUT /v2/admin/config/volunteering/bulklike every other volunteering setting. Nothing else called the old endpoints: not the accessible site, not the phone app, and not the public API description. - CI — main went red on the push-notification overhaul for two reasons that were not code defects. The emergency-alert controller test still pinned the old misleading
push_sent = 1for a Caring Community alert that policy never sends to phones (the service now records it honestly, so the test expects0), and the new root-redirect hook left the phone'slib/navigationcoverage at 97.1% against its 99.5% floor; two more render-sequence tests (a recovery link while signed in; the community-picker handoff) cover its last two branches. Nothing was deployed from the red commit. - API — the six volunteering webhook endpoints under
/v2/admin/volunteering/webhooksare gone, replaced by the platform-level/v2/admin/integrations/webhooksendpoints from the previous entry. No other client used them: not the accessible site, not the phone app, and not the public API description. - Admin — the Volunteering → Settings page (
/admin/volunteering/config) is gone. Its reminder settings are in the Module Configuration window, its webhooks on the Integrations page and its reminder delivery log on the volunteering Overview; the old address, and/admin/volunteering/settings, open the Module Configuration window on volunteering. Its twelve remaining strings leave all eleven languages.
Fixed
Paying on the accessible site returned the member to the website, with a broken address:
MemberPremiumService::safeReturnUrlaccepted only the React website, and the accessible site sent a relative path that was read as a website page; the success address was then built by gluing?session_id=onto an address that already had a?. NewApp\Support\Payments\CheckoutReturnUrlaccepts this community's website OR its accessible site (App\Support\Tenancy\AccessibleFrontendUrl, extracted unchanged from the password-reset email), refuses anything else (other hosts, other communities' paths, userinfo, backslashes, control characters, protocol-relative), and appends query parameters correctly;web-uknow sends its own absolute address. 🔴 Communities served on the SHARED accessible host (accessible.project-nexus.ie/{slug}/accessible) are only recognised whenACCESSIBLE_FRONTEND_URLis set, and production does not set it (checked 26 Oct): until it is, those members still return to the website. Communities with their own accessible domain (tenants 2, 11, 15, 16, 17) are unaffected.Accessible site — the Explore "Exchanges" card followed a retired setting: it read the hidden broker key
exchange_workflow.enabledthat f3450c8b8 replaced with theexchange_workflowfeature, so the card stayed hidden wherever exchanges were switched on.web-uk/src/lib/accessible-shell.jsnow follows the feature, as Laravel androutes/exchanges.jsdo.Organisation wallet notification opened "Page not found" on the accessible site:
VolOrgWalletServicestored the community slug in front of the link, unlike every other organiser link, so the accessible site prefixed it twice. It now stores/volunteering/org/{id}/dashboard?tab=wallet(React was unaffected). Test:VolOrgWalletServiceTest(+1).French machine translations of "Type" and "Action": eighteen French values read "Taper"/"Tapez" (to type on a keyboard) or "GDPR Detail" where the English is the noun "Type" or "Action". That also contradicted the invariant allowlist, so
check-php-lang-untranslated.mjswas already failing onmain. Corrected inlang/fr/(govuk_alpha, govuk_alpha_commerce, govuk_alpha_feed, govuk_alpha_saved, govuk_alpha_volunteering, api, emails_misc).Accessible site — organiser notifications opened "Page not found" (found by the 26 Oct React/accessible drift audit). The server writes organisation-staff bell and email links in the website's shape,
/volunteering/org/{id}/dashboard?tab=…and/volunteering/org/{id}/safeguarding[/{report}], from twelve producers (safeguarding reports, wellbeing alerts, fundraising and hand-overs, expenses, qualifications, applications, team, org wallet). The accessible site serves those pages at/volunteering/organisations/{id}/…and follows a stored link verbatim, so every one of them was a dead end there. Newweb-uk/src/routes/volunteering-org-links.jsredirects each address to the matching page (applications and hours to the manage page; unknown tab to the dashboard); access is still decided by the destination page. Checked live on the e2e environment (wellbeing, applications, safeguarding). Tests:web-uk/tests/volunteering-org-links.test.js(18), including one that reads every/dashboard?tab=the Laravel services write, so a new producer cannot reintroduce a dead link.Safeguarding — the "help involving children or vulnerable adults" option read "tutoring a member\x27s child".
lang/en/safeguarding.phpwrote the apostrophe as\x27inside single-quoted strings, where PHP prints it literally; members saw it on the settings page and the member safeguarding emails would have quoted it. Scotland's entry for the same option was also listed twice, so the second (label only) silently replaced the first; the merged entry keeps the label, and Scotland members keep seeing no description, as before (the option is no longer offered to new members). The earlier copy of 15 repeated keys inlang/en/api.phpis removed; PHP already used the later one, so every value is unchanged. NewPhpLangFileIntegrityTestfails on any literal escape in a single-quoted lang string and on any key repeated in one array literal (fails on the old files).Safeguarding — a vulnerable member starting a group conversation, editing a message or reacting is told it is their own safety choice. Those three paths built the error without
restricted_by_sender, so the member was told the other member "can only be contacted through a coordinator". They now shareMessageService::safeguardingErrorForDecision()with direct messages. Test:VulnerableMemberContactTest::test_a_vulnerable_member_starting_a_group_conversation_is_told_it_is_their_own_choice(fails on the old code).Safeguarding — three things that never worked (25 Oct 2026). The website wizard's "What activates as a result" summary read option
triggers, which the API never sends to members, so it always said "No automatic protections activate", even for a choice that blocked all contact; it now lists the option'seffects. The user flagsworks_with_vulnerable_adultsandno_home_visitswere only ever set when a selected option also hadrestricts_matching, which none of the flagging options have, so they could never become true; they now follow the member's selections. The England & Wales, Scotland and Northern Ireland "services involving vulnerable adults" option showed the under-18 description; each now has its own.Accessible site — members can save an insurance record again (owner, 25 Oct 2026; found by the ASP.NET audit verification, W-08). Since 14 Jul 2026 (
70047c526) Laravel stores insurance as a record only: type, optional provider and required expiry. It refuses anycertificate_filewith 422DOCUMENT_UPLOAD_FORBIDDENand discards every other field. The accessible form at/settings/insurancestill required a certificate file and always sent it, so every save failed. The form now asks only for type, provider (optional) and expiry date, as the website's Settings page does, and posts them as JSON throughcallUserSettingsApi. An expiry date that is missing or impossible is refused on the page, with the error on the date field and the type and provider kept. The policy number, cover amount, start date, notes and file fields are removed (Laravel never kept them), along with the multipart parser for that path anduploadInsuranceCertificate. The save-failed message no longer mentions uploading, hand-translated in all eleven languages (govuk_alpha_settings.states.insurance-failed). Tests:settings-contract-parity.test.js(+7),form-input-preserved-batch.test.js,shared-accessible-shell.test.js.Offers & requests round-up — follow-ups found by CI (25 Oct 2026, same release as the round-up). The admin page's "Send now" failure message is always the translated one, never the server's raw text.
/admin/listings-digestis listed inSpaRouteSegments::KNOWN, so a crawler asking for that address gets the app rather than a 404. The phone app's Laravel route inventory and the accessible site's route matrix and API ledger are regenerated for the new routes.Accessible site — podcast audio plays. The accessible frontend's Content-Security-Policy had no
media-src, so the browser fell back todefault-src 'self'and silently refused every<audio>source: an uploaded episode is served by the API host (/api/v2/podcasts/media/...), a linked one from any podcast host, never from the accessible site's own origin. Confirmed on the live header on 25 Oct 2026 (TimeBank Ireland's published episode would not play there while the API served it with206 Partial Content).media-src 'self' https: <asset origin>now matches the platform's own policy (SecurityHeaders.php:media-src 'self' https: blob:). Test:web-uk/tests/routes.test.js.Website — a podcast draft no longer looks published to its owner. The API returns not-yet-live episodes to the show's owner and admins, and the owner can play them; the show page listed them with no label, and the episode page's chip showed the episode's MODERATION result whenever it was not live, so an unpublished draft read "Approved" (owner, 25 Oct 2026: tenant 2's only episode was a draft,
published_atnull, and looked live).episodeAvailability()(react-frontend/src/lib/podcasts/availability.ts, the same rules asPodcastService::canViewEpisode()and web-uk'sepisodeAvailability()) names the reason — Draft, Archived, Pending review, Rejected/Flagged, Scheduled for a date, or the media status — in anEpisodeAvailabilityChipon both pages, with a notice that listeners cannot see the episode yet (show.unlisted_notice,episode.unlisted_notice, hand-translated in all eleven languages). Tests:availability.test.ts,PodcastEpisodePage.test.tsx(2 new).Accessible podcast studio — saving an episode no longer copies its summary into its description, and replacing the audio can clear other fields (25 Oct 2026). The edit form's Description box was filled from the display value, which falls back to the summary, so saving any edit of an episode with no description stored the summary as its description and the episode page then showed the same text twice; the box now holds the description only, and the episode page hides a summary identical to the description (episodes already saved that way). When an edit replaced the audio file (a multipart request),
appendPodcastEpisodeFields()dropped null values, so blanking the schedule, episode or season number, duration or file size did nothing; a cleared field is now sent empty, which the API reads as null. Tests:podcast-studio-gaps.test.js,podcast-episode-multipart-clears.test.js.Podcasts — editing an uploaded episode no longer deletes its audio; accessible podcast pages audited against the website (owner, 25 Oct 2026). Data loss:
/v2/podcasts/minereturns a hosted episode withaudio_urlset to the platform's own media address;web-uk's studio edit form pre-filled that into the "audio link" box and posted it back, andPodcastService::updateEpisode()treats anyaudio_urlas a replacement link, so saving any edit (a title change) detached and deleted the uploaded file and left the episode pointing at its own empty media route. The API now ignores anaudio_urlthat is the episode's own hosted address (isOwnHostedAudioAddress(): the media proxy path with or without a signature, or the stored disk URL); a genuinely new link still replaces the upload.web-ukno longer shows the link box for an uploaded episode. Show page (/podcasts/{id}): it dropped every episode whose status was not "published", so a show's owner or an admin saw "No episodes have been published yet." while the website listed the episode; it now lists every episode the API returns (the API returns not-yet-live episodes only to the owner and admins) with a label giving the reason listeners cannot hear it — draft, archived, awaiting review, not approved, scheduled for a time, audio still being checked, audio could not be used — and a notice explaining the labels. The show summary is shown; the owner gets "Manage this podcast" instead of "Subscribe", as on the website. RSS link: it read anrss_urlfield the API has never sent, so it never appeared; it is now built as the website builds it (/api/v2/podcasts/feed/{tenantId}/{slug}.xml) whenrss_enabledis true. Episode page: shows the summary as well as the description, and the same label and notice for a not-yet-live episode. Studio: a mistyped schedule date was read as "no date" and, on an edit, cleared the existing schedule while reporting "saved" — it is now refused with "Enter a valid scheduled publication date and time."; a failed cover upload after a successful create said the episode had failed (members added it again) — it now says the episode was added and the cover was not. New wording hand-translated in all eleven languages (govuk_alpha.podcasts.*,govuk_alpha_commerce.podcast_studio.status_episode_cover_failed). Tests:PodcastHostedAudioEditKeepsFileTest,web-uk/tests/podcast-pages-audit.test.js(9).Website on iPhone/Safari — a page opened before an update now reloads itself instead of showing the crash screen.
isChunkLoadError()inreact-frontend/src/routes/lazyWithRetry.tsmatched only the Chrome and Firefox wording of a stale-chunk failure, so Safari'sTypeError: Importing a module script failed.skipped the stale-chunk reload and reached the error boundary (Sentry NEXUS-REACT-16,/members, 24 Oct 2026, a member whose tab was still on the previous build). The Safari wording is now recognised and follows the same reload policy. Test:lazyWithRetry.test.ts("recognizes the Safari wording of a stale-chunk failure").Accessible site — podcast and episode pages no longer say "Page not found".
web-uklinks podcasts by number (/podcasts/{showId},/podcasts/{showId}/episodes/{episodeId}) from the feed, the podcast directory and each show page, and asksGET /v2/podcasts/{showId}[/{episodeId}]; those API routes looked up by slug only, so every such page answered 404 (found from the feed link on 24 Oct 2026).PodcastService::findShowBySlug()andfindEpisodeBySlug()now fall back to the numeric id when no slug matches (a slug that is all digits still wins; the episode stays confined to its show; tenant scope and visibility checks are unchanged). The website's slug addresses are unaffected. Tests:PodcastLookupByIdTest(show and episode by id, private episode still hidden, another community's show not reachable).Podcast cards in the accessible frontend's feed now link to the podcast — "View podcast show" opens the show and "Listen to episode" opens the episode, as on the website. Before,
web-uk'sfeedItemDeepLink()had no case forpodcast_show/podcast_episode, so a published podcast appeared as an unlinked "Activity" card.web-ukroutes podcasts by numeric id, soFeedServicenow also returns theshow_idthatPodcastServicealready records in the activity metadata; an episode from an older response without it links to the podcast directory. Labels added tolang/*/govuk_alpha.php(feed.view_typed,feed.item_types) in all eleven languages, reusing the website's wording. Tests:web-uk/tests/feed-podcast-links.test.js,PodcastFeedItemCarriesShowIdTest. (The mobile app's button was added inbff02a4c2.)Finished events no longer appear in the feed (website, mobile app, accessible frontend). An event keeps
publication_status = publishedandoperational_status = scheduledafter its time passes — nothing moves it tocompleted— so the lifecycle boundary inFeedService::getFeed()andfilterOutOrphanedItems()let past events through while the events page (which filters onstart_time) had already dropped them. Both now also requireCOALESCE(end_time, start_time) >= now(): an event leaves the feed when it ends, or when it starts if it has no end time. Opening an old event's card by its direct link is unchanged. Test:EventFeedLifecycleVisibilityTest::test_feed_hides_events_that_have_already_finished(both the events filter and the full feed).Mobile app: podcast cards in the feed showed "Invalid target_type" and did not open.
getDetailTargetinmobile/components/FeedItem.tsxhad no case forpodcast_show/podcast_episode, so a tap resolved to no destination; and a double tap sentPOST /api/v2/feed/likefor any type, which the server refuses for podcasts with a 400. Show cards now open/(modals)/podcast-show(byslug) and episode cards/(modals)/podcast-episode(byshow_slug+slug), matching the website'sgetItemDetailPath; a double tap on a card with nothing to like opens it instead. Podcast cards get their own label, icon and colour in all 7 app languages (wording copied from the website). Ships to phones by OTA or the next store build. Tests:FeedItem.test.tsx("podcast cards in the feed").Accessible frontend: the built-in "Powered by Project NEXUS" badge was a broken image on the shared host. The footer's default badge is the bare path
/images/powered-by-project-nexus-2026-10-light.png, but/imageswas missing fromUNPREFIXED_PATHSinweb-uk/src/middleware/tenant-routing.js, so onaccessible.project-nexus.ieit was treated as a page with no community in the URL and answered 404. Every community without its own uploaded badge (e.g.agoris,timebank-global) showed alt text instead of the logo; communities with an uploaded badge (served from the API host) and community accessible domains were unaffected. Test:tenant-routing-unknown-community.test.js.Addresses that never existed answer crawlers 404. A crawler asking for a page with no saved copy got the app shell with
noindexwhatever the address, which Google reports as a soft 404. nginx (nginx.bluegreen.conf@bot_spa_shell) now first asksGET /api/v2/seo/route-check(auth_request): 204 when the address is a known community and a first path segment the React app routes (App\Support\SpaRouteSegments, 180 segments copied from the React route files;SpaRouteSegmentsTestfails when they drift), 403 when it never existed, which becomes a 404 withnoindex. Fail-open: an unknown host, any error, a timeout (3 s), a 429 or an unreachable API keeps the noindex shell. Visitors and the render worker never reach the check.location /setsrecursive_error_pages onso the check's error pages apply. Tests:RouteCheckTest,SpaRouteSegmentsTest,scripts/test/test-prerender-nginx-runtime.sh(now runs a stand-in API container: 404 for an unknown address, the shell for a failing or unreachable check).Accessible frontend (web-uk) SEO, second pass. Canonicals map
/whats-on,/whats-on/{id},/guide(→/timebanking-guide) and/legal/{type}/versions(→/{type}/versions) to the main site, where React serves the same content; the deadtimebanking-guideentry is removed. Public pages carry their own meta description from existing translations (pageDescription(), 160 characters) instead of the community-wide one. What's-on event pages are titled by the event and emit Event JSON-LD ported from React's rules (no block for an in-person event with no venue); public pages emit BreadcrumbList; the community home emits WebSite JSON-LD (no SearchAction); blog Article gainsurl,mainEntityOfPageand a publisher logo. Legal document, accessibility, version-history, compare and blog-likers titles name their document or post. The community home H1 is the community name, with the "Accessible" label as its caption./helpand/trust-and-safetynamed every community "Project NEXUS Accessible" in their lead paragraph; they now use its own name. Signed-out HTML sendsVary: Accept-Language; the production image builds compressed CSS without a source map (190.7 → 162.2 KB);timeout-warning.jsloads only on signed-in pages. New| safesites reviewed insafe-filter-census.test.js. Tests:web-uk/tests/seo-page-meta.test.js(35).Page titles no longer repeat the community name ("About TimeBank Ireland | TimeBank Ireland"). New
joinTitle()(src/components/seo/joinTitle.ts) skips the " | Site" suffix when the page title already contains the site or branding name (case- and whitespace-insensitive), for<title>,og:titleandtwitter:title. The Features, Changelog and account-deletion titles no longer name the platform ("Project NEXUS v1.6"), in all 11 locales.Missing records answer crawlers 404. Help guides that do not exist, and custom pages, blog posts, public events and coupons the API reports missing (
isNotFoundResponse,src/lib/not-found.ts) emitprerender-status-code404; 5xx and network failures never do. In-person events with no venue no longer emit an invalid Event block.Blog dates are ISO 8601 with a timezone.
BlogServicesent"2026-10-23 14:00:00", which schema.org / Open Graph reject and React Native'snew Date()may not parse; it now sends2026-10-23T14:00:00+00:00(postshas no publish column, socreated_atstands in). The React page also normalises the old form. Tests:BlogPublicControllerTest,serverTime.test.ts,BlogPostPage.test.tsx."Accessible version" links open the same page on the accessible site when it has one (
accessibleFrontendPathFor()), otherwise its home page; they always opened the home page.Community admins edit every SEO field the super-admin tenant form has (owner, 23 Oct 2026).
/admin/seogains tagline, search engine visibility (tenants.robots_directive) and organisation type (tenants.seo_organization_type), read and saved throughGET/PUT /v2/admin/config/seo(tenant_tagline,tenant_robots_directive,tenant_seo_organization_type; an empty choice resets robots toindex, followand the type to NULL). New sharedsrc/admin/components/SeoChoiceFields.tsxrenders them on both screens with the same wording, replacing the super-admin form's own selects ("Robots.", "Noindex Follow"). NewApp\Support\TenantSeoFieldsapplies one set of rules on every path that writes these columns —/admin/seo,/admin/settings, super-admin create and update: column lengths (tagline 255, meta title 70, description 180, hub heading 100, image 500, social links 255), the four robots values, the five organisation types, and refusal of non-web schemes in the sharing image and social links (bare legacy values such asfacebook.com/xor/uploads/...still save). Until now a long value failed in the database and reached the admin as "refresh failed", and the choice fields accepted any text.PUT /v2/admin/settingsnow writes the sharing image to both places (TenantSharingImage). Tests:tests/Laravel/Feature/Seo/SeoFieldParityTest.php(6),SeoOverview.test.tsx.Second SEO audit (23 Oct 2026): both SEO screens checked field by field against what crawlers receive. Server:
/llms.txtand/llms-full.txtrefresh on any settings or content save (cache key carriesSitemapService::cacheVersion), answer 404 for a community set to noindex, omit the sitemap line when the automatic sitemap is off, and fall back past an empty meta description to the/admin/seodefault description and the tenant description; the app-host sitemap lists the communities actually served there fromPrerenderService::loadTenantTargets()(it dropped the platform master, whose domain is the sales site) and honours the automatic sitemap switch, as does the platform index;/admin/seo's sitemap link is the real address (it pointed the master and communities under it at the sales site) and the stats reportlisted/not_listed_reason; URL redirects that could never fire (query string,#,..,/, another site's address), a redirect to itself and a duplicate source are refused with a reason (SeoRedirectPublisher::validateNew) instead of saving or a 500; redirect markers are republished after an address change on either screen; the SEO audit treats never-changed canonical / Open Graph / Twitter switches as on, finds a sharing image held only as the setting, reportssitemap_not_publishedwhen the community has no sitemap, compares normalised redirect paths, flags blog posts only when they have neither a description nor an excerpt, checks CMS pages for a description (cms_page_meta_description_missing; the page title is always used), and dropssitemap_profiles_missing;seo_robots_txtis no longer accepted; a database failure on the SEO save says nothing was saved instead of blaming the crawler-copy refresh. Admin screens: the last-resort page description is translated; the sitemap card warns when no sitemap is published and says URLs are "listed", not "indexed"; content-type labels for static, CMS and public-event pages; counters show the real limits (70, 180) beside what search results show (about 60, 160); the missing-title and hub-heading health checks give correct advice; placeholder help texts rewritten ("Title Suffix.", "Meta Keywords.", canonical and sitemap switches, "Fallback meta description"); refused redirects show the server's reason; the super-admin preview falls back to the tagline and its hub heading/intro use the/admin/seowording. Accessible frontend: Google and Bing verification tags, meta keywords and, on a community's home page, Organization JSON-LD whose@idis the main site's (reviewed under F-315 insafe-filter-census.test.js). All new text in 11 languages. Tests:SeoAuditServiceTest(4 new),LlmsControllerTest(3),SitemapControllerTest(2),UrlRedirectsTest(1),seoAuditTranslations.test.ts,Redirects.test.tsx,PageMeta.test.tsx,TenantForm.test.tsx,web-uk/tests/seo-meta.test.js.Removed blog posts and CMS pages answer crawlers 410 Gone. The render engine deliberately never saves a "not found" page, so a deleted, unpublished or renamed post or page reached crawlers as the app shell (200, noindex since the soft-404 fix). New
App\Services\SeoGoneMarkers:PostPrerenderObserver/PagePrerenderObserver(newgoneRoutesFor/liveRoutesForhooks onPrerenderInvalidationObserver) leave an empty marker at.seo-gone/{host}{folder}{path}/_gin the shared crawler-copy volume and remove it on republish; nginx answers crawlers 410 for a marked address (onestat(), no reload; visitors and the render worker unaffected).seo:publish-redirects(hourly) also removes any marker whose address is in the community's sitemap again, so content republished outside an Eloquent save is never reported gone for long. Addresses that never existed keep the crawler noindex. Tests:tests/Laravel/Unit/Observers/GoneMarkersTest.php,tests/Laravel/Feature/Seo/GoneMarkersPruneTest.php, and five new cases inscripts/test/test-prerender-nginx-runtime.sh.A community set to noindex publishes no sitemap. When
tenants.robots_directivecontainsnoindex, its/sitemap.xmland/sitemap-{slug}.xmlanswer 404 and it is left out of its hub's sitemap index, the platform index and the app-domain sitemap (SitemapService::isNoindexTenant); the render planners do not consult it. Test:SitemapControllerTest.Accessible frontend:
og:localeand the community home title. Pages emitog:localefrom the page language (same map as ReactPageMeta), and a community's home page uses its/admin/seometa title as the whole title when set, as on the main site. Tests inweb-uk/tests/seo-meta.test.js.Accessible frontend (web-uk) SEO (audit, 23 Oct 2026). Pages now emit
<meta name="description">,<link rel="canonical">, Open Graph (og:type,og:site_name,og:title,og:description,og:url,og:image) and Twitter card tags, read from the tenant bootstrap as ReactPageMeta/SeoHeadread them (settings.seo_meta_description,seo.meta_description,branding.tagline,branding.og_image_url; theseo_open_graph/seo_twitter_cards/seo_canonical_urlsswitches honoured). The canonical points at the community's main-site address (own domain, else{parent_domain}/{slug}, elseapp.project-nexus.ie/{slug}) only for pages a signed-out visitor can open there (home, about/legal/help pages,/page/{slug}, blog); every other page is self-canonical, because a canonical aimed at a sign-in page would tell search engines to drop the accessible copy. Titles end with the community name (or itsseo_title_suffix) instead of "Project NEXUS Accessible", and the community home page title is the name alone. A tenantseo.robots_directive, signed-in sessions, auth/account/form/search pages and every 4xx/5xx render sendnoindex, nofollowas a meta tag and anX-Robots-Tagheader. NewGET /robots.txtkeeps crawlers out of signed-in areas on both URL shapes, each path anchored ($,/,?) so/medoes not block/members. The layout no longer falls back to GOV.UK's Open Graph image. Newweb-uk/src/lib/seo-meta.js; testsweb-uk/tests/seo-meta.test.js(16).Main app page tags and structured data (audit, 23 Oct 2026). Public event pages (
/whats-on/:id) emit valid schema.orgEventJSON-LD frombuildEventStructuredData()(src/lib/event-structured-data.ts): ISO 8601 dates in the event's zone with offset (date-only when all-day),eventStatus,eventAttendanceMode, aPlacewith address/geo, aVirtualLocationwhose url is the public page (joining links stay private), or both for hybrid; absoluteimage; the community asorganizer; unknown fields omitted, and no block without a name and start date. The event's picture is its og:image / twitter:image.BreadcrumbListmoved fromSeoHeadtoPageMeta(src/components/seo/breadcrumbs.ts): Home → a known section (translated, real route only) → the page title, community folder kept; raw ids and unknown segments are no longer emitted, and noindex pages carry none. The WebSiteSearchActionis removed (/listingsand/searchneed sign-in).VerifyEmailPage(every state) andTwoFactorSetupPageset noindex./help/:audiencehas its own title and description,/help/memberscanonicalises to/help, and legal version-history pages are titled after their document (new keyshelp_centre.audience_page_title,legal.page_meta.legal_version_history.{title,description}_with_document, all 11 locales).Sitemaps (SEO audit, 23 Oct 2026). (1) Fixed pages (about, terms, help…) no longer claim
<lastmod>= today on every request; only content with a real date carries one (the drift detector treats a missing date as no change, leaving those pages to the TTL refresh). (2)/sitemap-{slug}.xmlis served only on that community's own host, with its own folder: on the app host it listed platform-root addresses (missing/{slug}), and any domain answered any community's sitemap with its own host on the addresses. Every sitemap base now comes fromPrerenderService::loadTenantTargets()viaSitemapService::targetFor()/targetOrigin(), so the master community's index entry is the app host rather than its sales-site domain. (3) Blog posts and CMS pages marked noindex inseo_metadataare left out. (4)/whats-onand each publicly advertised event (/whats-on/{id}) are listed when a community has botheventsandpublic_eventson, using the public detail endpoint's own predicates (EventService::publicSitemapEntries). Help guide articles are deliberately not listed: the same ~360 guide pages exist on every community, so listing them would publish thousands of duplicate pages and renders. Tests:SitemapServiceTest(three new cases; its setup now creates a real member instead of falling back to user id 1, which made three listing/group tests error and the blog test pass vacuously),SitemapControllerTest(two new cases).robots.txt rules no longer block public pages that merely start with a private word. Every rule was a bare prefix:
/feedblocked/feedback,/membersblocked/membership,/*/feedblocked a post such as/blog/feed-the-hungry, and/*/messages,/*/wallet,/*/notificationsand seven more blocked the public help guides/help/members/messagesetc. Each private rule is now$-anchored,/and?variants; help guides whose names match a private word get explicit, longerAllowlines (Google applies the longest match). Newreact-frontend/src/test/robots.test.tsreads the file with Google's matching rules and checks every help guide (root and folder) is allowed and the private pages are blocked; 7 of its cases fail against the previous file./llms.txtand/llms-full.txtlink only to pages a signed-out reader can open, and a community in a folder gets its own file. The key-pages list sent AI crawlers to listings, events, groups, volunteering, jobs and organisations, all sign-in-only; it now lists home, about, guide, FAQ, help, trust and safety and contact, plus blog / What's On / coupons when switched on. nginx passes/{slug}/llms(-full).txtwithX-Llms-Prefix;LlmsControlleraccepts the folder only when it is a real community on that host (otherwise the host's own community), and points a folder community atsitemap-{slug}.xml. noindex posts are left out of the full file. Tests:LlmsControllerTest(three new cases).Renaming a blog post removes the old address's crawler copy at once.
PrerenderInvalidationObserver::retiredRoutesFor()(implemented byPostPrerenderObserverfromwasChanged('slug')/getPrevious()) deletes the old route's snapshot without re-rendering it; until now it stayed until a cleanup sweep reached it. Test:PostPrerenderObserverTest.Admin "URL redirects" now redirect. Until 23 Oct 2026 a saved redirect was never applied:
SeoRedirectMiddlewareruns only on the API, and every page request is served by nginx from the React build. Saving or deleting a redirect now writes or removes an empty marker in the shared crawler-copy volume (.seo-redirects/{host}{community folder}{path}/_r,App\Services\SeoRedirectPublisher); nginx checks for it on each page request (no reload) and hands a marked request to the newGET /api/v2/seo/resolve-redirect, which answers the 301 and counts the hit. Only known community hosts and safe relative destinations are honoured; a marker whose redirect was removed meanwhile sends the visitor on withnexus_rc=1, which nginx skips, so it cannot loop.seo:publish-redirects(hourly, and by hand after a fresh volume) rewrites every community's markers. Tests:tests/Laravel/Feature/Seo/UrlRedirectsTest.php; the nginx side inscripts/test/test-prerender-nginx-runtime.sh.Admin "404 Tracking" now records. Nothing wrote to
error_404_log, so the page was always empty. The app's "Page not found" screen now reports the address to the newPOST /api/v2/seo/not-found(throttled, sent without the session): the address and the referring page without its query string are stored; no IP address, account or browser. Only known community hosts are accepted, and the crawler-copy renderer is not counted. Tests:tests/Laravel/Feature/Seo/NotFoundTrackingTest.php,NotFoundPage.test.tsx.Crawlers asking for a page that has no saved copy get
X-Robots-Tag: noindex. nginx served them the bare app shell with HTTP 200, so missing and removed pages were "soft 404s" that could be indexed as empty pages. The crawler-only fallback (@bot_spa_shellinnginx.bluegreen.conf) now addsnoindex; visitors, the render worker, saved copies and a community home page without a copy are unchanged. Pinned byscripts/test/test-prerender-nginx-runtime.sh(fails against the previous config).Certificate check and pilot application status pages are no longer indexable.
/certificate-check/{code}(a member's certificate code in the address) and the pilot status page now setnoindex; the bare certificate-check form stays indexable. Tests inCertificateCheckPage.test.tsxandPilotApplyStatusPage.test.tsx.SEO audit (23 Oct 2026): what search engines receive now matches what the two SEO screens say. Checked against the live crawler copies of hour-timebank.ie, uk.timebank.global and a sub-community. (1) Verification codes reached nobody. Google's Search Console HTML-tag check (
Google-Site-Verification), its URL inspection and Rich Results tools (Google-InspectionTool),GoogleOther, and Bing'sBingPreview/msnbotwere not in the crawler list innginx.bluegreen.conf/nginx.conf, so they were served the bare app shell, which carries no verification tag (the verifier runs no JavaScript); added. The codes are also cleaned: pasting the whole tag orgoogle-site-verification=CODE(which the SEO page's own example suggested) now sends only the code. (2) Every crawler copy had two<title>tags: under React 19 Helmet adds its own<title>instead of reusing the one inindex.html. Theindex.htmltitle stays (the offline screen needs it for WCAGdocument-title, pinned bye2e/tests/app-shell-a11y.spec.ts, which caught a first attempt that removed it) and now has an id;PageMeta, which always writes a title, removes it on mount (shellTitle.ts). NodefaultTitleanywhere, because under React 19 that too becomes a second element. (3) Organisation structured data was ignored:NonprofitOrganization,CommunityOrganizationandVolunteerOrganizationare not schema.org types; the stored choice is now emitted asNGO(orLocalBusiness/GovernmentOrganization), and the logo is a full address instead of/uploads/…. (4) Sub-communities pointed at their hub:SeoHeadbuilt addresses from the router path, which drops the community folder, souk.timebank.global/west-somerset's hreflang, organisation URL and breadcrumbs named the hub's home page; it now uses the browser path and the community's own base. (5)ICBMis no longer emitted as0, 0for a community with no map position. (6) Noog:locale:alternate: each address is served to crawlers in one language. (7) On the home page the community's own meta keywords win over the translated defaults. (8) SEO audit false alarms: Canonical, Open Graph and Twitter Card switches that were never changed (on by default) were reported "not enabled"; the hub heading was demanded from communities that are not hubs; an unset title suffix was reported though pages correctly end with the community name; title and description lengths counted bytes, not characters. New audit issue: a robots directive containingnoindex(one dropdown choice removes a community from search). Wording in all eleven languages. Tests:SeoHead.test.tsx(11 new cases),PageMeta.test.tsx(2),SeoAuditServiceTest(5); the crawler list validated withnginx -tand probed for each new user agent, with an ordinary browser as control.The social sharing image is one value on both screens: the community's SEO page and the super-admin tenant form. Owner report, 23 Oct 2026 (Timebanking UK, tenant 11): an image uploaded on
/admin/seodid not show on/super-admin/tenants/11/edit. The SEO page wrote and read theseo_og_image_urlsetting; the super-admin form wrote and readtenants.og_image_url; neither saw the other. NewApp\Support\TenantSharingImage: every save on either screen now writes both places in the same transaction (AdminConfigController::updateSeoConfig,TenantHierarchyService::updateTenant), and both screens read the same way (getSeoConfig,AdminSuperController::tenantShow): the setting first, then the column, matching what the tenant bootstrap already served. A super-admin save that does not touch the image leaves it alone. Rows stored in only one place before this change show on both screens. Tests:tests/Laravel/Feature/Seo/SharingImageSyncTest.php(five cases; four fail against the previous code).Feed — the "Top Categories" chips open the listings page filtered to that category, instead of an empty page. Owner report, 23 Oct 2026, hour-timebank: clicking "DIY & Home (10)" or any other chip in the feed sidebar opened the listings page with no category selected and no listings.
TopCategoriesWidgetlinked with?category=<id>, but the listings page andGET /v2/listingsread?category=as a category slug; the server found no category called "12" and returned nothing. The chips now link by slug, whichGET /v2/feed/sidebaralready returned. Measured locally on tenant 2: the same category returns 0 listings by id and 9 by slug. The three widget tests that asserted the id link (and so pinned the bug) now assert the slug. Tests:TopCategoriesWidget.test.tsx(new "links by category slug, never by numeric id")."Trust this device" keeps working for every account a browser has trusted. Owner report, 23 Oct 2026: trusted devices were being asked for the code again. A browser held one
nexus_trusted_devicecookie per website address, so trusting a second account on the same address (another community onapp.project-nexus.ie, a staff member's second login, or the accessible pages on the same address, whose ownnexus_trusted_devicecookie had the same name and path) overwrote the first, and the first account was asked again with nothing revoked or logged. Each account now has its own cookie,__Host-nexus_td_<24 hex>(derived from tenant and user id with no secret, so rotatingAPP_KEYnever renames it), and the old cookie is still read so devices trusted before this release stay trusted. The cookie is now built explicitly (host-only,Secure,HttpOnly,SameSite=Lax, lasting exactly as long as the server honours it) instead of viacookie(), which takessession.domainand session SameSite from configuration; a configured domain the browser is not on makes the browser silently refuse the cookie.expires_atis now computed by the database clock that checks it (NOW() + INTERVAL n DAY) rather than PHP's, which was off by any timezone difference between the two. "Forget my devices" and turning two-factor off clear the account's cookie and the old one. The accessible site keeps one signednexus_trusted_<24 hex>cookie per account and hands all of them back at sign-in (web-uk/src/lib/trusted-device-cookies.js). Tests:TrustedDeviceCookieTest(two accounts in one browser, cookie attributes with a hostile session domain, old cookie honoured, forget clears both, database-clock expiry),two-factor-trusted-cookie.test.js.Sharing a community's link on Facebook, LinkedIn or X shows its own sharing image, not a cropped platform picture. Owner report, 23 Oct 2026: hour-timebank.ie shared on Facebook showed a Project NEXUS picture that did not fit the card. The crawler copy carried two
og:imagetags: a hard-coded default inreact-frontend/index.html(/og-default.svg, relative, and an SVG, which Facebook, LinkedIn and X cannot show), served before the real one because react-helmet-async never replaces a tag it did not create; thenPageMeta's, which was the community's logo (square) declared as a 1200x630 card. The default is removed fromindex.html;PageMetadeclaresog:image:width/heightandsummary_large_imageonly for a real sharing image (an explicit image or the community'sog_image_url), falls back to the logo without dimensions, and its last fallback is the platform PNG logo instead of the SVG. Tests:PageMeta.test.tsx(sharing image with dimensions and exactly oneog:image; logo without dimensions; never an SVG).Admin SEO page — an uploaded social sharing image is saved at once, instead of vanishing on refresh. On 22 Oct 2026 the owner uploaded a sharing image for hour-timebank on
/admin/seo, refreshed, and it was gone. The upload worked (the file is in the image library), but it only filled in the address box; the setting is stored by the page's Save button, which sits at the bottom below the audit card, and the field's own note wrongly said "press Save at the top of the page".SharingImageFieldgainsonPersist: on/admin/seoan upload or a removal now storesseo_og_image_urlstraight away (PUT /v2/admin/config/seowith that one key, which also refreshes the crawler copy) and says "Image uploaded and saved." / "Image removed.", or that it could not be saved. The super-admin tenant form keeps its own Save button and now says "Press Save to keep this image." with no location. Wording in all eleven languages. Tests:SharingImageField.test.tsx(four new cases),SeoOverview.test.tsx(an upload sends onlyseo_og_image_url).Page titles join a community's title suffix with " | " whatever form it was saved in.
seo_title_suffix(/admin/seo "Default title suffix") is free text and is stored both with a separator (" | My Timebank") and without ("Timebank Ireland", written for hour-timebank bymigrations/2026_05_13_normalize_hour_timebank_branding.sql). It never reached the site until this release's bootstrap fix, so the bare form had never been rendered;PageMetaused it raw and would have produced "About TimeBank IrelandTimebank Ireland". It now strips any leading separator (| · • : - – —) and always joins with " | "; a blank suffix falls back to the community name. The home page still uses the tenant meta title exactly as written. Caught before deploy. Tests:PageMeta.test.tsx(four suffix forms, home page unaffected).Deploys survive whatever a community writes in its landing page, legal documents and SEO settings. (1) The production deploy gate (
scripts/deploy/phases/candidate-journeys.sh) runse2e/tests/smoke.spec.tsagainst the candidate colour on LIVE hour-timebank data, and four of its page checks matched wording a community can change: the home hero (Exchange Skills|Future of Time Banking), the About heading, the Terms heading (a custom legal document carries the title its admin wrote) and the Help Centre heading. On 22 Oct 2026 hour-timebank rewrote its hero ("Give an hour. Get an hour back.") and the gate refused a deploy whose code was fine. All four now use one wording-blind check,expectOwnPageHeading(): a main heading is present, is not blank, and is not the app's "Something went wrong", "Page Not Found", "Coming Soon" or "Community not found" screen. (2) A community that switched its hero section off had no<h1>on its home page at all (bad for search engines and screen readers, and reported BLANK by the crawler delivery check);LandingPageRenderernow adds a visually hidden<h1>with the community's name when no hero is enabled. (3) One malformed section in a stored landing page could crash the whole home page; each section now renders in its own error boundary (a crashing section disappears and is reported to Sentry), a config without asectionslist falls back to the default page, and entries that are not sections are skipped. The crawler delivery check and the post-deploy error watch already check structure only. Tests:LandingPageRenderer.test.tsx(hero off keeps one hidden heading, no second heading with the hero on, brokensections, non-section entries, a crashing section), the four smoke checks run locally against hour-timebank.Exchanges — one on/off switch, in Module Configuration. Members of a community with the Exchange Workflow feature switched on still saw "Exchange Workflow Not Enabled". Exchanges needed a second, hidden switch as well:
exchange_workflow.enabledin the broker settings (tenant_settings.broker_config.exchange_workflow_enabled/tenants.configuration.broker_controls), which defaulted to off, had no control on any screen, and was silently turned on as a side effect of saving any other exchange workflow setting.BrokerControlConfigService::isExchangeWorkflowEnabled()now reads theexchange_workflowfeature only, so the Module Configuration toggle is the single switch (on by default for new communities). The hidden key is no longer accepted byPOST /v2/admin/broker/configuration, is stripped from its responses, and is dropped from stored broker settings on the next save.GET /v2/exchanges/configstill returnsexchange_workflow_enabled, now derived from the feature, so the website, accessible site and mobile app need no change. Tests:ExchangesControllerTest(feature on wins over a stored hidden "off"; feature off wins over a stored hidden "on"),AdminBrokerControllerTest(retired key neither returned nor stored).Super-admin tenant form, SEO tab — the preview shows the community's real address, the counters count, the hub-only fields say so, and the community's own sharing image wins. (1) The Google search preview always showed the platform address (
timebank.global/{slug}) even for a community on its own domain; it now showshttps://{domain}once a domain is entered. (2) "Characters Count" under Meta Title and Meta Description showed those two words and no number; it now readsN/70 charactersandN/180 characters. (3) "H1 Headline" and "Hero Intro" are the same twotenantscolumns relabelled on/admin/seothis morning, and are only read by the accessible site for a hub community; they are now "Accessible site hub heading" / "Accessible site hub intro" here too, on the form and the tenant detail page, in all eleven languages. (4) The default social sharing image is set on two screens:tenants.og_image_urlhere and theseo_og_image_urlsetting on/admin/seo; the bootstrap preferred the super-admin column, so a community admin could change theirs and see no effect. The community's own setting now wins and the super-admin value is the fallback. The four Tenant Meta boxes remain a second entry point for the same stored values as/admin/seo(last save wins, both re-render the crawler copy); robots directive and organisation type are set here only and are live. Tests:TenantForm.test.tsx(preview address, counters),TenantBootstrapControllerTest(sharing-image precedence both ways).Admin SEO page — every setting on it now reaches the site, and tenant admins can open it again. Audit of
/admin/seo(22 Oct 2026): (1)AdminConfigControllerstores the page'sseo_*settings as bare keys, but the tenant bootstrap only publishedgeneral.*keys, so the title suffix, global description, keywords, the canonical / Open Graph / Twitter Card switches and both search-engine verification tags were saved and never served;PageMetaandSeoHeadfell back to their defaults. The bootstrap now publishes those nine keys (TenantBootstrapController::PUBLIC_SEO_SETTINGS). (2) The "Auto Sitemap" switch savedseo_auto_sitemapand nothing read it;SitemapControllernow answers 404 with an empty urlset for a community that has switched it off, on both/sitemap.xmland/sitemap-{slug}.xml. (3) The "Custom robots.txt" box savedseo_robots_txtand nothing served it (robots.txt is a static file in the frontend container's nginx); the box is removed rather than left promising a per-community robots.txt, and its card is now headed "Search engine verification". (4) The page itself (/admin/seo) is open to every tenant admin again: its settings are per community and tenant-scoped on the backend; the rest of Growth & Discovery (audit, redirects, search analytics, prerender engine, 404 tracking) stays god-only as decided on 2 Oct 2026. The OG image, tenant meta title/description and the sitemap stats / cache clear / server audit buttons were already wired. Tests:TenantBootstrapControllerTest(settings exposed under their bare keys,seo_robots_txtnever public),SitemapControllerTest(served while on, 404 while off),AdminSidebar.test.tsx(SEO overview shown to a plain admin and a non-god platform super admin, the god-only pages still hidden).Website — the tenant "Meta title" and "Meta description" from
/admin/seonow title and describe the home page, and no longer get appended to every other page.PageMetausedtenants.meta_titleas the " | site name" suffix on every page and the home page ignoredtenants.meta_description, so filling them in for Timebank Ireland producedCommunity Timebanking Platform | TimeBank Ireland | Swap Skills, Earn Time Creditson the home page,About TimeBank Ireland | TimeBank Ireland | Swap Skills, …on every other page, and left the translated default description in place. Now the home page (PageMeta tenantHome) uses the meta title exactly as written and the meta description first, falling back to the translated defaults when they are blank; every other page's suffix is the community name (admin title suffix still wins), with the meta title only a last resort. The landing page body is untouched: it still comes from the landing page builder. The "Meta title" help text on/admin/seonow says this, in all eleven languages. Tests:PageMeta.test.tsx(three new cases).Podcasts — audio links that open a web page are refused, with the reason. On 21 Oct 2026 an episode saved with an
open.spotify.com/episode/…link would not play: the link opens Spotify's page, not an audio file, and the studio accepted any https address. Saving an episode (new or edited) now answers 422VALIDATION_FAILED/podcasts.audio_url_is_web_pagefor links to hosts that only ever serve pages — Spotify, Apple Podcasts, SoundCloud, YouTube, Google Drive, OneDrive, Dropbox share links withoutraw=1/dl=1, and Internet Archive/details/pages (PodcastService::webPageAudioService()). Hosts that serve both pages and files are deliberately not listed. The studio says which service the link belongs to before saving (react-frontend/src/lib/podcasts/audioLinks.ts) and marks existing episodes whose link is such a page. Hand-translated in all eleven languages. Tests:PodcastControllerTest::test_audio_links_that_open_a_web_page_are_refused_with_a_reason,audioLinks.test.ts,PodcastStudioPage.test.tsx(3 new).Deploy — the crawler check now follows the communities' real addresses.
scripts/deploy.shasks the server for the prerender engine's own plan (prerender:plan-routes, which reads the tenants table) andscripts/check-prerender-delivery.mjs --from-plan=probes every active community at the host and path it is rendered under. Until now it probed only a hand-kept list indeploy.env: after the owner removed Minehead's custom domain (the community is now West Somerset Time Bank atuk.timebank.global/west-somerset) every deploy reportedminehead-and-coast.timebank.globalBLANK, andbridgwater.timebank.globalhad never been checked. The hand-kept list is now only a fallback, and the probe names any address it lists that no community uses. Measured against production: 15 communities, 30 pages, all real and current (previously 9 addresses). Tests:scripts/test/check-prerender-delivery-origins.test.mjs(BLOCKING in CI),scripts/test/test-deploy-probe-after-prerender.shscenario 9.Community projects now say who proposed them, on the website and the accessible site. Both read a
proposer_namethe API never sent, so the website showed an empty name beside the person icon and the accessible site showed none. The project list and project responses now include it (an organisation account by its organisation's name).Proposing a community project with no title or description, or with a malformed date, now says why instead of claiming success.
POST /v2/volunteering/community-projectsanswered 201 Created with an empty body when the service refused the proposal, so the website said "proposal submitted" and nothing was saved. It now answers 422 with the field and the reason. Supporting a project that is not in the community answers 404 instead of 200{ success: false }.Podcasts — no uploaded episode could be published (409
MEDIA_NOT_READY). Production had no virus scanner whilepodcasts.enable_media_scanningis on by default, so every hosted upload was recordedscan_unavailable, which by design is never published — and nothing ever moved it out of that state, because the media job only scanspendingmedia. The scanner and the re-scan command above fix both halves. The studio's Publish buttons also showed "Could not save changes."; they now show the server's reason. Test:PodcastStudioPage.test.tsx("shows the server reason when an episode cannot be published").Email — the plain-text copy of every templated email opened with the stylesheet. When a caller supplied no text part,
Mailerderived one withstrip_tags(), which keeps the contents of<style>and the U+034F preview-text padding, so notifications reached inboxes with ~40 lines of CSS and invisible characters in the text/plain part — a part/part mismatch that spam filters score against, while members reported platform email landing in spam. The text part is now derived byMailer::plainTextFromHtml(): head, style and script removed, links kept as "label (address)", blocks as lines, padding characters dropped. Postmark and Gmail API paths both use it. Regression test:tests/Laravel/Unit/Core/MailerPlainTextTest.php.Volunteering wellbeing — the deep audit of 21 October: the server, the website, the accessible site and the phone app. Server: an alert raised from the volunteering pattern alone (hours dropping, long gaps, too many shifts) can be raised again once staff closed an earlier one more than 14 days ago — until now the first conclusion switched that volunteer's scan off for good — and a shared check-in in the same second as a conclusion still counts; switching a community's Wellbeing tab off now refuses check-ins too (
403 FEATURE_DISABLEDonGET /v2/volunteering/wellbeing,POST …/checkinand the organisation list), where before a shared check-in from the phone or the accessible site still told people; an organisation's Wellbeing list shows only the alerts that organisation was actually told about, and a pending or suspended organisation or a switched-off community above is no longer told; each volunteer's "told yet?" check, the telling and the alert write run under a short per-volunteer database lock, and new unique indexes (migration2026_10_21_140000) make a duplicate "who was told" row a no-op; retention keeps the "who was told" record while the volunteer has an open case and removes staff notes with their purged alert; erasing a volunteer deletes the bells other people received about them (here and in the community above), and erasing a staff member anonymises their recipient rows instead of deleting another volunteer's record that someone was told; a NEW high or critical alert from the nightly scan bells the community's safeguarding staff once (no email, no push); the scan stamps its last clean run (meta.last_scan_aton the alert list), logs a failed community at error and no longer lets one bad record abort a community; the email job's timeout is 55 s (below the queue's 90 s retry window) and a job that dies clears a "told" stamp nobody is behind; the volunteer's own email counts only people actually reached; push de-duplication no longer swallows a second volunteer's push to the same person within a minute; the mood word is gone from email subjects, preview lines and the device push text; email to an address-only recipient says it went to the community's contact address and replies go there; the hours windows compare dates with dates; the alert list throws a query error instead of showing an empty page; mood must be a whole number 1–5 and the note text of 500 characters or fewer (422); every wellbeing time is ISO 8601 with its offset; a refused support link names its row (support_links.N);volunteer_wellbeingnotifications are filed under Safeguarding; the export carries the share and told flags and masks third-party addresses. Website: a broker's bell or email now opens the alert's case drawer on the broker page; the organisation dashboard never shows a blank tab; times are read as UTC and shown in the reader's own time, and "already told" names the day; switched-off and rate-limited check-ins say so instead of "try again"; outcome card, drawer and settings errors are announced and keep focus; the settings card checks each support link before saving and marks the wrong one; logical (RTL-safe) spacing; the volunteer's note is shown in full; rest days are local dates; the admin and broker pages show when the nightly scan last ran; the member help article explains who is told, and admins and brokers get an article on wellbeing alerts. Accessible site: the warnings the server sends are shown (the page said "No wellbeing warnings right now" next to a real warning); times are labelled UTC and "already told" names the date; switched-off and rate-limited states have their own messages; the mood error links to the field and the nobody-told notice is announced; the last-told line shows without a score; Polish plural ranges cover 22–24, 32–34 …; wellbeing notifications are filed under Safeguarding. Phone (next over-the-air update): a staff wellbeing push opens a screen that says the alert is on the website, with one button to open it; the organisation dashboard gains a Wellbeing tab; the check-in outcome stays on screen as a card instead of a four-second toast; times and rest days are read correctly; switched-off and rate-limited states are handled; wellbeing notifications get a heart icon. Translated in all eleven website languages and all seven app languages. Tests:WellbeingDeepAuditRegressionTest(30) and the existing wellbeing suites; website, accessible-site and phone wellbeing suites (see the module doc).Volunteering wellbeing — twelve faults from the 21 October audit, across the server, the website, the accessible site and the phone app. Server: a check-in no longer counts as "told" once every email behind it has failed — the email job now retries a send that did not work (three attempts with backoff) instead of recording it failed on the first miss, and on its last attempt clears the "told" stamp on any check-in that reached nobody, so the next check-in tries the whole chain again;
told_countis the number of OTHER people told (the volunteer's own staff account is a confirmation, not a responder), and a lone staff volunteer gets the new outcometold_self_only; acknowledging an alert no longer makes the nightly scan create a twin (an active OR acknowledged alert is refreshed in place, and a resolved or dismissed one is reopened only by a check-in newer than the conclusion); the nightly scan now visits everyone with an open alert, so an alert whose only signal has aged out of the 14-day window is resolved rather than left active for ever; the scan skips a community whose volunteering feature is off or whose Wellbeing tab is switched off (the tab switch now switches the scan and the alerts off too); a shift booked next month no longer counts as activity in the last 30 days; the cancellation rate is reported as not measured (measured: false,rate_percent: null) instead of a reassuring 0%; the member dashboard gainsscore_basis(activity/no_data) so a volunteer with nothing to score is not shown 100/100 "Excellent"; every recipient's bell and push text, the mood word included, is now rendered in that recipient's own language. Website: the staff alert drawer ignores a stale response and refuses a note or status change unless the alert on screen is the one the buttons act on (overlapping loads could show one volunteer while a permanent note went on another); the cancellation row reads "Not measured"; the overcommitment row read a field that never existed and now shows. All three clients: the "let someone get in touch" hint names every recipient category (community team; the team of the community above; any extra wellbeing address; the organisations you volunteer with, feeling only); the confirmation counts other people and no longer promises a response time; a lone staff volunteer is told nobody else was told; a volunteer with nothing to score sees "Not enough activity yet"; the accessible site no longer shows a made-up 100/100 and low risk beside a load error. Records: staff notes can be removed once their alert is gone (migration replaces the delete trigger), and erasure removes them; DPO-14 updated. Translated in all eleven website languages and all seven app languages. Tests:WellbeingAuditRegressionTest(15),VolunteerWellbeingMoodCheckinTest,WellbeingAlertDrawer.test.tsx,WellbeingTab.test.tsx,web-uk/tests/volunteering-wellbeing.test.js,volunteering-my-wellbeing.test.tsx.Volunteering — the wellbeing email and bell named the organisation, not the person. Production, 20 Oct 2026: the subject read "Timebank Ireland said they are feeling low" because the volunteer's account is an organisation profile. Wellbeing messages, the alert list and the daily assessment summary now use the person's own name (
UserDisplayName::person(): first and last name, falling back to the display name).Volunteering — a lone administrator who was also the volunteer was never told, and the check-in was marked "told" anyway.
team_notified_atwas set even when the recipient list was empty, so every later check-in that day was silently skipped as "already told" and the volunteer read "someone will be in touch" while nobody had been. Both are fixed (see Changed).Volunteering — a completed shift swap no longer labels the shift you gave up as "Your shift". On the website, the accessible site and the phone app, the two shift boxes on a swap card read "Your shift" / "Their shift" (or "Proposed shift") whatever the status, which describes the assignment BEFORE the swap: once a swap was accepted or admin-approved, the shift under "Your shift" was the one the reader no longer had. Accepted and approved cards now read "Shift you gave up" / "Shift you now have", whichever way the request went. New wording in all eleven website languages (
swaps.shift_you_gave_up/swaps.shift_you_now_have, andvol_depth.swap_shift_gave_up/swap_shift_now_havefor the accessible site, with its generated catalogues resynced) and all seven app languages. Tests:ShiftSwapsTab.test.tsx,web-uk/tests/volunteering-shift-swap-request.test.js,mobile/app/(modals)/volunteering.test.tsx.Volunteering — the phone app's shift swaps list now pages. It read one page of twenty and stopped, on the stale belief (recorded in a comment) that the server could not page it;
GET /v2/volunteering/swapshas answered withmeta.cursor/meta.has_moresince September 2026, and the website tab already used them. The Swaps tab now loads throughusePaginatedApiwith a "Load more" button like the other member lists, andgetShiftSwapstakes a cursor. Tests:volunteering.test.tsx(two paging cases),lib/api/volunteering.test.ts.Volunteering — a shift swap request now expires when it can no longer happen, on every app. A request names two volunteers and the shifts they held when it was made, and nothing retired it when that stopped being true: if the asker cancelled their shift, moved to another one, was taken off the roster, or was moved by a different swap — or if a shift simply started — the other volunteer still saw Pending with a live Accept button on the website, the accessible site and the phone app. Pressing it either failed with a bare "Failed to accept swap request", or (where the community requires admin approval) parked a dead request in the admin queue, where Approve failed too and the sidebar badge counted it. Found walking the swaps journey on a seeded community on 19 October 2026 (shifts had never been used on a public community). The server now retires such requests as Expired — before every list, before every decision, and from every place a volunteer leaves a shift — and refuses a late decision with a plain reason ("This swap request has expired: a shift has started, or one of you is no longer on the shift it names", in all eleven languages). The website's Swap Requests tab shows that reason on a refused Accept, Reject or Cancel and reloads the list; it also offers Cancel on a request an admin is still deciding on (the server always allowed it, and the accessible site already did), gains a Load more button for members with more than twenty requests, and My Applications no longer shows seconds in a shift's time. The admin Swap requests page shows the server's reason when a decision is refused and reloads the queue. The phone app offers Cancel request while an admin is deciding (from its next release). Tests:
ShiftSwapStaleRequestTest(seven cases),ShiftSwapsTab.test.tsx(four new),VolunteerSwaps.test.tsx(new),volunteering.test.tsx(phone, one new).Volunteering — five shift-waitlist defects found in the 19 Oct polish pass. (1) A member who left a shift's waitlist (or whose offer lapsed) could never join it again: the table is unique per shift and member, so the second join threw and came back as "Failed to join waitlist"; the existing row is now reactivated at the back of the queue. (2) Queue positions went stale after a claim or an expired offer: the next two in line kept reading "position 2" and "position 3"; every path that vacates a place now closes the queue up. (3) A member holding an offer who tried to join again was told "this shift is not full" rather than that they were already on it. (4) A member who was offered a place and then signed up for that same shift the ordinary way (the accessible site's own advice) kept an open offer for 48 hours, with "A place is free" still showing on every app for a shift they already held; the offer is now settled on sign-up. (5) Entries for shifts that had already started stayed "waiting" forever and were still listed; they are now hidden from the list and closed by the nightly sweep. Also "1 spots" on the website's Waitlist tab, and the other ten languages' "spots" strings, which were machine-translated as "points" or "spots", now say places.
Caring Community emergency alerts no longer claim a phone push was sent when none was.
caring_*types are deliberately kept out of native push, yet every alert row was stampedpush_sent = 1. The flag is now set only when a device actually took the push, and the stored summary carries asuppressedflag that says the transport refused the type (counts only, never token strings — F-128 unchanged). The feature test that assertedpush_sent === 1now asserts the honest value.Thirteen admin and moderation pushes that landed on the notification centre now open the right page. "Your post has been hidden/removed", "Your review has been hidden/removed" and "Your volunteer application was declined" passed no link and so opened
/notificationson the phone; they now open the feed, the reviews page and the volunteering applications tab, and the remaining account, group and report outcomes name/notificationsexplicitly instead of relying on the fallback. The native push producer inventory is regenerated accordingly.Untranslated push fallbacks. The badge-removal bell and push were hard-coded English and rendered in the admin's language; they now use
api_controllers_3.admin_bells.badge_removedin all eleven languages, rendered in the member's language. The agent nudge senders (AgentExecutor,KiAgentService) fell back to a literal "NEXUS" title; they now use the translated default.docs/modules/notifications.mddescribed the generic lock-screen body as permanent; it now documents the catalogue, the bell-sentence fallback, the preview preference and the channel / priority / thread map..env.examplegains the three Firebase variablesconfig/services.phpreads.Course notifications on a community with its own domain no longer send the phone to the notification centre. Course enrolment and completion stored a full web address as the notification link; the native push sanitiser only trusts the platform host, so on a custom-domain community the push fell back to
/notifications, and the web dropdown would have prefixed the address with the community slug. The bell and push now store the same site-relative/courses/<slug>every other producer uses; the completion email keeps the absolute address (CourseNotificationServiceTest).Phone — a "new discussion" group notification now opens that discussion. The notification link names the discussion in its query (
/groups/8?tab=discussion&discussion_id=48); the phone's link mapper only recognised a discussion named in the fragment, so the tap opened the group's discussion tab and dropped the discussion. Both shapes now open the discussion itself (app/native-intent.test.ts,config/native-push-dynamic-route-review.json).Phone (Android) — tapping a "new message" push notification now opens the conversation when the app was closed, instead of the Home tab. On a cold start the app followed the link and then, one render later, still believed it was on the start-up screen and replaced the conversation with Home (
usePathname()lags one render behind React Navigation, so the Home rule indecideAuthRedirectfired against a stale/). The root redirect now lives inlib/navigation/useAuthRedirectEffect.tsand remembers the screen it last navigated from until the address actually changes;navigateToLinkreports whether it opened a screen so an unhandled link still falls through to Home. iPhone looked better only because iOS keeps the app in memory longer, so taps rarely cold-started. The same tap is no longer handled twice when Android reports it through both the launch intent and the live listener, a handled tap is cleared so a restart of the navigator cannot replay it, and message notifications get their own high-importance Android channel (messages) so they pop up once the server addresses it. Every tap now leaves apushbreadcrumb trail in Sentry (tap → decision → navigation, link summaries only) and a server-logged warning (push-tap-landed-home) if a tap ever ends on Home again. Opening a conversation now clears that conversation's "new message" alerts from the tray. Ships in 1.10.0 (version code 23); the live 1.9.0 build cannot take over-the-air updates. Tests:authRedirect.test.ts("does not replace a just-pushed deep-link screen with Home while the pathname is still stale"),useAuthRedirectEffect.test.tsx(the render sequence),navigateToLink.test.ts(result values). Root cause: the redirect decision re-ran on a render whose pathname had not caught up with the navigation it had just issued. Prevention: the decision now takes the last-issued pathname as an input and the hook test drives the exact render sequence.CI — the production build no longer fails on a design-token allowlist entry for the deleted
GroupOrganization.tsx. The 17 Oct groups clean-up removed that admin page; its one-style exception inreact-frontend/scripts/check-design-tokens.mjswas left behind, and the contract check refuses stale entries, so every build onmainwas red until the entry was dropped.Marketplace & Freecycle — a borrow & lend listing no longer tells search engines it is for sale at a price of 0. The listing page's structured data (JSON-LD
Product) gave every listing anOffer; for a lend listing it carriedprice: "0", which reads as "yours for nothing". A lend listing now has noOfferat all. A free (Freecycle) item keeps its zero-pricedOffer, because that one is true. Tests inMarketplaceListingPage.test.tsx.Coupons — a fixed-amount discount is shown in the currency's own minor unit, not always divided by 100. The admin coupons page and the seller's coupons page displayed
discount_value / 100. The server stores a fixed discount in the currency's minor unit (MerchantCouponService::calculateDiscountMinor()compares it directly with the order total in minor units, and the seller form says "Fixed amount in cents"), so dividing by 100 is right for euro, sterling and dollars but showed a ¥500 coupon as ¥5. A newformatMarketplaceMinorAmount()inreact-frontend/src/lib/marketplaceNumbers.tstakes the divisor from the currency (100 for EUR, 1 for JPY). Tests:marketplaceNumbers.test.ts,AdminCouponsPage.test.tsx.Volunteering → Settings — reminders and webhooks now do what the page says. "Run due reminders" sent nothing for a community that had never pressed Save on the reminders tab; it now uses the same defaults as the nightly job (on, 24 hours, email) and still stops when the reminder is switched off. A webhook's Failures count was never written and always read 0;
WebhookDispatchService::recordDeliveryOutcome()now adds one per failed delivery and resets it on success. The webhook log showed a blank event and status for every entry, because the page read field names the server never returns; it now readsevent_type,response_codeandstatus. "Test" said "Test sent" even when the receiving system refused it; it now reports the receiver's answer, and testing a webhook that does not exist answers 404 instead of 500. The events example now names events that are really sent (shift.completed,job.application.created) instead of two that never are. Regression tests:VolunteerConfigPageWiringTest,VolunteerConfig.test.tsx.Volunteering hubs — the highlighted tab now follows the page in a community addressed by slug. On the Training & certificates, Opportunities & shifts and Fundraising & donations entries (above), the tab strip compared the page address with the community prefix added, but inside a slug-addressed community the admin pages run under
TenantShell's nested router, whose address has the prefix already stripped, so the first tab stayed highlighted whichever page was open. Found on the e2e browser walk;VolunteeringHubTabsnow matches either form. Test added toVolunteeringHubTabs.test.tsx.Integrations — the "Integrations" breadcrumb above Outbound webhooks no longer leads to "Not Found". It links to
/admin/integrations, which had no route; that address now forwards to/admin/integrations/webhooks, behind the same god-and-platform-super-admin guard. Found on the e2e browser walk; test added tovolunteering-redirects.test.tsx.CI on
17b241424— four failures from the volunteering admin work above, fixed. (1)AdminOutboundWebhooksControllerreusedSuperPanelAccess's per-process answer even when it had been worked out for another account, so in a CI shard where an earlier test filled it every webhook request was refused; it now discards an answer that belongs to someone else (testtest_access_worked_out_earlier_for_another_account_is_not_reused). (2) The webhooks page and the Module Configuration window showed the server's own error text, which the admin UI-literal gate forbids. Each webhook refusal now carries a code (WEBHOOK_NAME_REQUIRED,WEBHOOK_URL_INVALID,WEBHOOK_URL_NOT_ALLOWED,WEBHOOK_EVENTS_REQUIRED,WEBHOOK_UNKNOWN_EVENT,WEBHOOK_FIELD_TOO_LONG, viaApp\Exceptions\WebhookValidationException) that the page turns into translated wording. A volunteering setting outside its range is now named on screen with its limits ("“Hours before the shift” must be between 1 and 168"), in all eleven languages. (3) The mobile app's committed Laravel route inventory is refreshed for the new Integrations addresses and the removed reminder-settings ones.Integrations — a webhook saved before the new rules can now be switched off. Found in production on 18 Oct: Timebank Ireland's seeded "CRM Volunteering Integration" points at an internal Docker address and lists twelve retired events. The edit form sends every field back, so unticking Active was refused by the address and event checks and the webhook could not be switched off from the page.
WebhookDispatchService::updateWebhook()now re-checks an unchanged address or event list only when the webhook will be active afterwards. A changed address is still always checked, and switching a legacy webhook back on is still refused until its address is fixed. Tests inAdminOutboundWebhooksControllerTest.Admin donations list — the Donor column now shows the donor's name instead of "User #ID".
GET /v2/admin/volunteering/donationsadds adonor_nameto each row (VolunteerDonationService::withDonorNames()): the member's display name for a member gift, the name a guest typed for a guest gift, and null for an anonymous gift, which still shows as "Anonymous". Names are looked up in one tenant-scoped query. The CSV export is unchanged. Regression tests:VolunteerCommunityControllerTest::test_admin_donation_list_names_the_donor,DonationRefunds.test.tsx.Admin dashboard — "Messages to review" now matches the review queue it opens, and refreshes when you come back. The figure counted every unreviewed message copy, but the queue hides the viewer's own conversations (F-436: nobody reviews their own messages), so an admin who was a party to copied messages saw a number the queue could never reach — on Timebank Ireland, "7" over an empty list, all seven being the admin's own conversations.
GET /v2/admin/badge-countsnow passes the viewer toAdminBadgeCountService, which applies the same exclusion and no longer shares its cached figures between viewers. The badge hook also re-checks when the window regains focus or the tab becomes visible, instead of only once a minute. Regression tests:AdminBadgeCountServiceTest,useAdminBadgeCounts.test.ts.Messages — new messages, typing and read receipts now arrive live, on the web and in the phone app people already have installed. A sent message was broadcast on a channel no client could subscribe to (one-to-one chats never have the
conversation_participantsrow that channel requires), so the web only saw new messages on its 30-second poll and the phone app only on the next refresh or push; reading a conversation told nobody; and the typing signal went to a channel the web was not listening on. The server now sendsnew-messageon the recipient's own channel (the one the shipped mobile build already listens to) and on the pair's chat channel, with a trimmed sender and no account fields; it sends a newmessage.readevent to the sender (ticks turn blue the moment the other member opens the conversation) and to the reader's other devices (their badge clears); typing goes out with a timestamp and resolves the tenant outside a web request. The web binds all three on the user channel and deduplicates the chat-channel echo. Two-state receipts by decision: sent / read, no "delivered". Regression tests:MessageSentTest,MessageReadTest,MessageReadReceiptTest,MessagePairIdTest,PusherServiceTest(chat-channel auth),MessageServiceTypingIndicatorTest,PusherContext.test.tsx,ConversationPage.realtime.test.tsx,MessagesPage.realtime.test.tsx,MessageBubble.test.tsx. The contract is documented indocs/modules/messaging.md. Whether production has working Pusher keys is a separate check.Design mode opens again in the Page Builder and the Newsletter builder. Choosing Design showed an empty grey area or crashed, because the builder redrew its side panels in the new language before the editing canvas had been created. The language sync now waits for the canvas. Regression tests:
PageDesignBuilder.test.tsx,NewsletterBuilder.test.tsx.Pages — a failed save now says why, and a renamed web address is announced. A refused save used to show only a generic "could not save"; it now shows the server's own reason (for example that the address is already used by another page). When the server changes the address you typed, the editor tells you the address it actually saved. Regression tests:
PageBuilder.test.tsx,AdminContentControllerTest.Admin — saving a page or the header-column title no longer blanks the screen with "Loading community". Saving refreshed the community's details in a way that put the whole app back into its loading state; the refresh is now quiet and the header updates in place (
refreshTenant({ silent: true })). Regression tests:TenantContext.test.tsx,PageBuilder.test.tsx,PagesAdmin.test.tsx,HeaderColumnTitleCard.test.tsx.Pages — web addresses work for every language, and no address is reserved. A title in Arabic, Chinese or any non-Latin script used to produce an empty or unusable address. Accented Latin letters are now transliterated (for example "Café Évènements" becomes
cafe-evenements), and when nothing usable remains the server generatespage-plus six characters. Pages live under/page/, so the old list of reserved words is gone and no word is refused. Clearing the address when editing an existing page keeps the old address instead of erroring. Regression tests:AdminContentControllerTest,PageBuilder.test.tsx.Pages — a page that links to member profiles is now refused when you save it, instead of being saved and then quietly hidden. Public pages may not link to member profiles, so the public site always showed "Page Not Found" for them, leaving a dead menu link. The editor now says so at save time, in the community's language. The rule moved into one shared place (
PageContentPolicy) used by both the editor and the public page. The check that hides pages linking to member profiles now treats a space or line break after the link as the end of it; an escaping slip meant it previously looked for a backslash or the letter "s" instead.The iOS Simulator Screenshots workflow no longer fails on a sign-in response that has not carried the member's tenant and role since the two-factor work. Its pre-flight check read
tenant_idandrolefrom the login body; it now reads them from/v2/users/mewith the issued token, which is where the app itself reads them. Root Cause: the login response was slimmed toid,first_nameandemail_maskedand the workflow's check was never updated. Prevention: the check now fails with a named reason for a missing token or the wrong tenant, and no longer insists the capture account be an admin: any Partner Demo member without two-factor sign-in will do, which is what App Review needs as well. It also selects Xcode 26.2 before compiling, as the launch-smoke job already does, because Expo SDK 55's native modules no longer build with the runner's default Xcode. Finally, Xcode now ad-hoc signs the Simulator app ("Sign to Run Locally", no Apple credentials): unsigned, the app could not save the sign-in it had just been given and showed "Unable to sign in". The job's time limit is raised to two hours because a fresh compile alone takes about 40 minutes. Signed, the app now triggers iOS's "Save Password?" sheet and keeps its sign-in across reinstalls, so the tour dismisses the sheet (and the first-run notifications card) before the first picture, and each retry resets the Simulator's Keychain.Arabic — the Stripe account page now shows the number of items Stripe still needs in the one, two and zero forms of that sentence; they previously spelled it out without the number.
Identity checks through a community's own Stripe account now complete. A community can store its own Stripe Identity key and webhook secret so checks run (and are paid for) through its own Stripe account. New checks already used that key, but reading a check's result, cancelling one, and verifying the webhooks Stripe sends about it all used the platform's key and secret instead, so for such a community Stripe answered "not found" on every status read and every webhook was rejected as unsigned; results only arrived through the hourly stuck-session sweep. The provider now looks up which community a session belongs to and uses that community's key and secret (the platform's when it has none). Root Cause: the provider's status, cancel and signature-check methods were written before per-community keys existed and read the platform key directly. Prevention:
tests/Laravel/Unit/Services/Identity/StripeIdentityProviderTenantCredentialsTest.phppins key and secret resolution for a community with its own keys, one without, and an unknown session."Manage my support" works for supporters whose recurring donation is in the community's own Stripe account. Stripe refuses to open its billing portal on a connected account unless a portal configuration exists there, and a community's connected account has no dashboard in which to save one, so the button failed for every such supporter. The platform now finds the account's configuration or creates one (invoice history, change card, cancel at the end of the period) and names it when opening the portal. Supporters paying through the platform account are unaffected. Root Cause: the portal session was opened on the connected account exactly as on the platform account, where a saved default configuration exists. Prevention:
tests/Laravel/Feature/Controllers/MemberPremiumConnectedBillingPortalTest.php.Card donations to a community with its own Stripe account could not be completed. When a community had connected its own Stripe account and it was ready, the server correctly created the payment on that account, but the card form in the browser was always set up against the platform's Stripe account, so Stripe refused to confirm the payment ("No such payment_intent") and every one-off card donation to such a community failed at the final step. Recurring support was unaffected because it runs through Stripe's hosted checkout. The payment-intent response (
POST /v2/donations/payment-intent) now says which Stripe account the payment was created on (stripe_account_id, null for the platform account), and the card form initialises Stripe.js against that account. Root Cause: the card form loaded Stripe.js once, for the platform account only, and nothing told it when a connected account was in use. Prevention: regression tests on the server response, the checkout hand-off and the card form (tests/Laravel/Feature/Controllers/MemberPremiumAdminStripeAccountControllerTest.php,react-frontend/src/components/donations/DonationCheckout.test.tsx,react-frontend/src/components/donations/StripePaymentForm.test.tsx).Admin panel — "Back to site" works for a tenant super admin managing a community below its own. Reported from Bridgwater: in a child community's admin panel, a Timebanking UK tenant super admin pressing "Back to site" saw the admin panel reload. The button went to
/dashboard, andProtectedRoutesends a visiting manager from every member page straight back to/admin. For a visiting manager (isVisitingManager) the button now goes to the community's public home page, and the header's notifications bell and "My profile" item — member pages that bounced the same way — are not shown. A community's own admins still go to the dashboard. Pinned byAdminHeader.test.tsx("a tenant super admin managing a community below its own").Emails — the approval welcome email shows its list as a list, and seventeen emails greet members properly. Reported from Bridgwater: the "your account has been approved" email printed its "things you can do" list as raw
<ul style=…><li …>code, and opened "Hi Great news, Sarah!,". The list was hand-built HTML passed toEmailTemplateBuilder::paragraph(), which since F-038 shows any non-formatting tag as text; it now usesbulletList(). The greeting was a whole translated sentence passed togreeting(), which wraps its argument in "Hi …,": the same mistake gave "Hi Hi Sarah, welcome to …!," in both email-verification emails and "Hi Important notice, Sarah," in the suspension, ban and deletion notices, among others. A newsalutation()prints a complete greeting line as written (same text rules asparagraph()), and those seventeen call sites use it. The data-request acknowledgement also used{name}/{community}, which the translator never fills in, so it read "Hi {name}," in all eleven languages; now{{name}}/{{community}}. Pinned byEmailTemplateBuilderSalutationTest(including a scan that refuses a translated sentence passed togreeting()) andtest_approval_email_reads_as_written.Sign-in — a tenant super admin can sign in on a child community's own web address and stay signed in. Signing in on
bridgwater.timebank.globalas a Timebanking UK tenant super admin showed "Your session has expired" straight away. When a request arrives on a community's own domain, the platform does not compare the sign-in token's community at the door, so the only check was in theAuthenticatemiddleware, which answered the member-only background calls (notification counts, presence, unread messages) with401 auth_required. The website reads a 401 as an expired session: it refreshed the token, was refused again and signed the manager out. Those calls are now refused with403 tenant_mismatch, the same answer the header and slug addresses already gave, which the website ignores for background calls; a plain member of another community is still signed out, now through that same answer onGET /api/v2/users/me. Pinned bytest_on_the_child_custom_domain_a_member_call_is_a_mismatch_not_a_lost_session.Accessible frontend — accepting or rejecting cookies works again on every community's own accessible address. Pressing Accept or Reject on the cookie banner showed "Sorry, there is a problem" on
accessible-bridgwater.timebank.global,accessible-ryde.timebank.globaland every other community accessible domain;/report-a-problemand/sitemap.txtfailed the same way, and a mistyped community onaccessible.project-nexus.ieshowed that error instead of "Page not found". On a community domain web-uk asks the platform whether the first part of the path is a child community; since F-443 the platform answers an unknown name with 400INVALID_TENANTrather than 404, and web-uk only recognised the 404.tenant-routing.jsnow treats both as "no such community" (isUnknownCommunityError); any other 400 still surfaces as a fault. Pinned byweb-uk/tests/tenant-routing-unknown-community.test.js.Regional analytics — the monthly report email's "Download report" link now opens the report. The PDF is written to the platform's private file store, but the email carried a bare
/storage/…path with nothing behind it (the generator's note said a controller would sign it on download; no such controller or route existed), so the link could never be opened in any form, and a full-address variant tried earlier was reverted because the private file still could not be served that way. The email now carries a signed link on the API host,GET /api/v2/regional-analytics/reports/{reportId}/{expires}/{signature}, that streams the stored PDF. The signature covers the report and its expiry with the application key (the same scheme as the policy-email links), the link works for 30 days from sending, and it is refused when the signature is wrong, the link has expired (410), the report's subscription has been cancelled, or the report and subscription are not in the same community. The report row keeps the stored file path infile_urlrather than an address. Email-links audit F3 (2026-10-14). Pinned bytests/Laravel/Feature/RegionalAnalytics/ReportDownloadLinkTest.php, which runs the real monthly command, fetches the link from the captured email and checks it returnsapplication/pdf; the formerly skipped case inSlugOnlyEmailLinksSweepTestis now a real assertion.Emails — the mail app's "Unsubscribe" button and the digest's "unsubscribe from events" link now work for communities without their own web address. The link was built as
https://{host}/{community}/api/v2/notifications/unsubscribe…; the community prefix belongs to pages, not to the API, and nothing forwards/{community}/api/…to the server, so for a community reached by its slug (agoris and partner-demo under timebank.global, stratford and crewkerne under uk.timebank.global) the button opened the app's own "page not found" and Gmail's one-click unsubscribe failed silently. The link now sits directly under the community's host, as it already did for communities with their own domain; the community itself is carried by the signed token.NotificationUnsubscribeController::buildSignedUrl(); pinned for all four community shapes (own domain, slug-only, child under a hub with a domain, child under a hub without one) bytests/Laravel/Unit/Core/TenantEmailLinkShapesTest.php, which also pins every other email-link helper (getFrontendUrl+getSlugPrefix,EmailTemplateBuilder::tenantUrl,UserTenantResolver, the provisioning welcome URL and the accessible password-reset base) for the same shapes.Emails — the welcome invitation's "manage preferences" footer link now points at the member's own community. The buttons already did; the footer followed whichever community the sender was working in, so an invitation resent from a parent community's screen pointed the member at the parent's address.
WelcomeInvitationMailernow renders inside the member's community. Pinned, with every other link in the welcome, verification, password-reset (web and accessible), new-message, event, activity-digest, match-digest, group-invite, newsletter, regional-report and safeguarding-alert emails, for a slug-only community and a child under a hub, bytests/Laravel/Feature/Email/SlugOnlyEmailLinksSweepTest.php.Emails — the five member emails an administrator can trigger (two-factor reset, password changed, password-reset link, account approved, account reactivated) now render inside the member's own community, so the footer "manage preferences" link matches the buttons. The buttons were already built from the member's community; the footer followed the community the administrator's request resolved to, so a manager acting from a community above would have sent a footer pointing at its own address. The five senders in
AdminUsersControllernow resolve the member's community first and render and send inside it (the same fix as the welcome invitation); the three that were inline are now private methods. Each endpoint still refuses a member outside the request's community, so this is reachable only through a manager working from above. Email-links audit F7b (2026-10-14). Pinned bytests/Laravel/Feature/Controllers/AdminUsersMemberEmailCommunityTest.php, which renders each email for a member of a child community with the parent hub active and checks every link, footer included.Newsletters — the one-click unsubscribe header, the open-tracking pixel and click-tracked links now use the API's real
/api/v2/newsletter/…address. They were written without/apiand worked only through compatibility routes, which stay in place so already-sent newsletters keep working.Safeguarding — the critical-report alert's header names and links the community the report belongs to. The stock mail header linked the API host.
Help Centre — the "On the day of an event" guide names the check-in buttons as they now appear ("Your check-in code (optional)", "Replace code" and their translations), in all 11 languages; the Arabic and Irish guide for scheduling a feed post uses the composer's own wording. The message screen's dates are formatted through the app's shared language setting (a build rule had refused the earlier form).
Events — a manager from a parent community can now open, send, cancel and retry an organiser message in a community below, not just draft it; it acts as its delegate there, as for the other organiser tools. Switching organiser messages off from above while one was scheduled made the whole Events settings save fail; it now succeeds and records the delegate.
Events — organisers are told why a message action was refused when the message changed after the page loaded, sending had already started, the same action was repeated with different details, or they are not on the event team, in all 11 languages, instead of "The message could not be scheduled". Unexpected server errors on these pages are now logged with where they happened.
Events — organiser messages are now emailed to attendees who never chose an email digest. A message sent from an event's Communications tab was emailed only to members who had switched on the email digest in their own settings, which almost nobody had, so the email was skipped for nearly everyone and the message still showed as "Sent". The community's default digest setting now counts as "no choice made": the email goes out at once. A member who turned event emails or the digest off is still skipped. Event invitations had the same rule and are fixed the same way (owner decision, 14 Oct 2026).
Events — "Retry failed deliveries" no longer stops organiser messages from sending. After a retry, the next failed attempt reused an earlier attempt number, the database refused it, and the whole batch stopped, leaving up to 50 messages stuck for ten minutes at a time. Attempts are now numbered across the delivery's whole life.
Events — an organiser message no longer appears twice in a member's notifications when its delivery is handed over again after an interruption.
Events — a finished organiser message can no longer stay "Sending" for ever. Every run of the message sender now closes any message whose deliveries are all finished, and reports how many it closed.
Emails — the plain link under every email button is now readable. Members reported "you've got a message" emails with no link. Every message email sent since 14 September had the "View Conversation" button, but some mail apps hide or disable styled buttons, and the copy-this-link line beneath it was 11px light grey and easy to miss. That line is now 14px, darker, with the address underlined (
EmailTemplateBuilder::renderButton, so it applies to every email that has a button). Pinned byEmailTemplateBuilderTest::testButtonFallbackLinkIsReadable.Events — scanning an attendee's check-in code on the website now records it straight away when online. Before, a scanned code waited on the device until someone pressed Sync, so a volunteer who never pressed it recorded nothing. With no internet the code is still saved on the device and sent at the next sync.
Events — the phone app can now undo a check-in mistake. The attendance list offers Undo, with a short reason, when the server allows it; before, a wrong check-in could only be corrected on the website.
Events — check-in refusals now say what is wrong and what to do. The phone app and the accessible website showed "this record changed elsewhere" for every refusal, including check-in not being open yet, being closed, the person having no confirmed place, a missing Undo reason, and an action that does not fit the person's current status. Each now has its own sentence.
Events — check-in screens no longer say attendance never changes balances. That was wrong when an event offers a time-credit reward: the reward is given on the first check-in, reversed if that check-in is undone, and never paid twice.
Events — offline check-in times on the website were an hour off in summer. Disagreement, device and code expiry times arrive from the server in UTC and were read as local time.
Events — organisers can create and edit ticket types again for events outside UTC. The ticket form sent its sales-open, sales-close and refund-cutoff times in UTC (
…Z), but the server requires the event zone's own offset, so every Irish or UK event in summer time was refused with "Dates must use the event's own time zone offset". The form now sends the offset (for example+01:00). A regression test covers an Irish summer event.Events (admin) — Admin → Events no longer goes blank when one event's stored status disagrees with its lifecycle. One such row made the whole list answer with a server error and the page said "No events found", so the approval queue could not be used. The list now shows the event with its lifecycle state and a "Needs attention" label, and a failed load is shown as a failure with Retry instead of an empty list.
Events (admin) — times on Admin → Events and the Events settings history were an hour off in summer. The server sent stored times without saying they were UTC, so the browser read them as local time. They are now sent with their offset.
Events — a recurring series' own page no longer shows error boxes. The events list links to the series itself, but registration, safety steps, tickets, check-in and calendar links exist only on each date, so those cards and the organiser's People, Registration, Safety, Tickets and Check-in sections showed "could not be loaded" errors there. The series page now explains this and lists its dates as links, and the "Upcoming dates in this series" list no longer shows the first date twice.
Events — polish on the event, create and calendar pages. After an RSVP or a waitlist change the event page now refreshes in place instead of flashing "Loading event…" and reloading every card (a failed refresh keeps the page and says so); the create form asks for the community's recurrence settings once instead of on every remount; the repeat pattern is described in words ("Every week on Monday and Thursday, 3 times") instead of a raw
RRULE:FREQ=WEEKLY;COUNT=3string; the date pickers run from today to five years ahead (editing a past event keeps its own date) and the month view from five years back to five years ahead, instead of 1900–2099; distances on the events list use the reader's language for units; and the cancellation reason and the leave-waitlist button are readable in the light theme. Every date picker built on the sharedDatePickernow also honours itsminValue/maxValueinside the calendar popover — HeroUI's calendar defaults overrode them.Events — organiser Manage page. Registration opening and closing times, the registration cutoff, invitation expiry and scheduling, and a broadcast's "send later" time are now shown and saved in the event's own time zone; they used the organiser's browser zone and were sent as UTC (
Z), which the server refuses for registration and agenda times on any event outside UTC (for example in Irish summer time). Agenda sessions are read and written in the event zone. Undoing a guest's attendance now asks for the reason the server requires (it always failed before), and only the attendance actions the server accepts are offered, each with its own busy state. When the community's safety enforcement is off, the Safety tab says so and keeps the requirements editor behind "Prepare requirements anyway"; paging through safety reviews no longer throws away unsaved requirement edits. Invitations to selected members use a name search instead of typed member ID numbers.Events — admin. When the community's Attendance Rewards feature is off, Events settings says so (instead of "Available") and points to Module Configuration, and the Attendance rewards page explains that the ledger and reversals still work but retries need the feature. Claim failure and reversal reasons are shown as words instead of codes, and a filtered list with no matches says "No claims match these filters" with Clear filters, keeping Refresh visible.
Events settings history no longer exposes an administrator from above. When a manager from a parent community, or the platform owner, changes a community's event settings, that community's own admins now see "An administrator from a parent community" or "A platform administrator" instead of "Unknown administrator", and the response no longer carries that person's account number. Admins who can reach the person's community (the parent's tenant super admin, the platform owner) still see the name. This follows the access rule: an administrator sees people in their own community and the communities below it, never above.
Events — the public What's On pages show each event's own time, not the visitor's, and a failed list load says so with a Try again button instead of "no events".
Events — clearer messages and fewer dead ends on the event and organiser pages. Refusals (archive, cancel, publish, join or leave the waitlist, accept a waitlist offer, and the organiser's forms, invitations, agenda, safety reviews, broadcasts, templates and team) now show the server's explanation instead of a generic failure; leaving a waitlist that is refused now says why instead of doing nothing; the page reloads after an RSVP that lands on the waitlist; the reminders card is hidden when the community has reminders switched off; the calendar feeds window says when feeds are switched off; the "recurring" switch is no longer offered while editing (it had no effect); a weekly series starts on the start date's weekday by default; the series warnings in the archive and cancel dialogs appear again; the leave-RSVP confirmation says what will happen; "1 spots left" and "1 responses" read correctly; and the submit-for-review dialog no longer claims the event can be edited while it waits.
Events (Arabic): "spots left" and "more dates" now always show the number, matching every other language; the one/two/zero forms had dropped it. The admin events pages' server-reason messages are marked as already translated by the server, and the event page reloads its calendar links when an event turns out to be a series template.
Events (organisers) — smaller fixes in the manage page. Registration answers show the question and the answer as written (not a key and quoted JSON); writing a broadcast no longer throws away the audience preview; a reason typed for one person no longer carries over to the next; "Use template" keeps the community's address on slug-addressed communities; the Federation tab is only offered where the community uses federation; and muted text and table lines in Analytics and Tickets have the right colours again.
Events (admin) — the Events settings page explains refusals and copes with platform switches. A refused save now shows the reason (for example "not a whole number" or "someone else changed these settings", which also reloads the page); a community whose timed waitlist offers were on before the platform switch was turned off can save again; "Outbox authoritative" is only offered when the platform can deliver it; "Restore this section" says when there was nothing to restore; series blueprints are listed under platform readiness; the page has its own title; the Admin → Events lifecycle actions show the server's reason and refresh the row after a conflict; column sorting that only sorted the visible page is removed; the reward amount is checked against the ceiling before saving; the claims ledger hides Retry where retries are switched off and limits the reversal reason to its 200 characters; and the attendance-rewards page has a proper breadcrumb.
Admin — Pending approvals can be cleared. The queue and every count of it (sidebar badge, dashboard, CRM, broker panel) selected on
is_approved = 0alone, so an applicant who was deleted, banned, suspended or rejected stayed pending for ever. All five now shareApp\Support\Members\PendingApproval: not approved, statuspending/active/NULL, and not deleted or anonymised.Events — "send later" for event broadcasts works from the website. The website sends the time in the browser's standard format (ending in "Z"), which the server refused, so every scheduled broadcast failed with "Validation failed".
Events — a member can add a guest again after their guests were withdrawn. Cancelling and re-registering used to leave the member unable to bring the same (or any) guest back. A database migration (
2026_10_11_180000_let_withdrawn_event_guests_be_re_added) changes the guest guard so only current guests count against the limit; it drops a CHECK constraint instantly and adds no table rebuild.Events — withdrawing or declining a request that was waiting for approval also withdraws its guests.
Events — a scheduled invitation campaign whose event was cancelled before 11 October is now closed by the scheduler instead of being reported as failed on every run.
Events — organisers can see who still owes required registration answers. The People and Check-in lists show an "Answers outstanding" label. The website asks for this with
include=required_answersonGET /v2/events/{id}/people; without it the response is unchanged, so the current mobile app is unaffected (a future app build already accepts the field).Events — a pending place is shown as "Requested — waiting for the organizer to approve" instead of "You're going!", and the RSVP message says the organiser needs to approve.
Events — cancelling a place after the organiser's deadline shows the real reason on the event page instead of a generic error.
Events — a member editing an approved event is told that only an administrator can change its wording, place, links or picture, instead of "You do not have permission to edit this event".
Events — late reminders no longer say the wrong time. A reminder that was already due when someone registered is not sent; a reminder sent late for any other reason states the time actually left.
Events — offline check-in explains itself. Refusals no longer say "Invalid input"; common problems (a cancelled, replaced or expired pass, an expired device, an out-of-date roster, a duplicate scan) have their own messages, and the check-in queue shows each decision in words instead of a code such as
credential_rotated.Events — broadcasts and old-registration-data clean-up say what is wrong (scheduled in the past, a follow-up scheduled before the event ends, no audience, empty message, clean-up run before the event has ended) instead of "Validation failed". Ticket date errors now name the date field.
Mobile app source — the "repeat" option is hidden where the community has switched repeating events off (ships with the next app build).
Events — organisers and members are told what is wrong instead of a bare "Validation failed". About 25 common mistakes in tickets (dates without the event's time offset, sales window, refund cut-off, per-member limit, sold out), safety rules (minimum age, code-of-conduct text, publishing without a draft, an out-of-date agreement), registration settings and invitations (window or cut-off after the event starts, invitation expiry, an event that is no longer going ahead) now come back with a translated message and the field concerned. The error codes are unchanged; the error object gains a
reasonproperty. Withdrawing after the organiser's cancellation deadline says so, and issuing or accepting an invitation for a cancelled, completed or archived event is refused with 409 and its own message.Events — a place held for the organiser's approval is reported as pending, not confirmed. Accepting an invitation and the older RSVP now say
pendingwhen the organiser must still approve, and the attendance points are paid when the organiser approves rather than on the request.Events — an undone check-in can no longer be paid later by an administrator's "retry". Undoing a check-in also closes an unpaid (failed) reward, and retry refuses unless the member is still checked in; checking them in again pays once.
Events — "Repeat this event" is no longer offered in communities that switched repeating events off, and the platform switches screen no longer offers the "recurrence v2" switch, which had no effect since the old engine was removed.
Events — the events health check no longer reports check-in records as an unprocessed backlog. They are audit records that nothing is meant to process.
Events — the mobile app's event Team screen works again for organisers who are not administrators. The 10 October surname change (F-591) removed the
last_namefield from the team list; the mobile app requires the field (it may be empty), so its Team screen failed. The field is back, always empty for non-administrators, so surnames stay hidden.Events — when a place frees up and timed waitlist offers are not in use, the first person waiting is moved into it automatically instead of the place going to whoever presses Register next. All the usual checks apply (capacity including their guests, the organiser's manual approval, the registration window, published safety rules); someone who does not pass is skipped and keeps their place in the queue. They are told through the normal "your registration is confirmed" or "is pending" message. Communities with timed offers on keep the offer-and-accept flow.
Events — required registration questions now have to be answered before check-in. When an event's published registration form has required questions (including waivers), a member who has not submitted their answers cannot be checked in by any route, the organiser's check-in button is disabled for them, and the member is told on the event page. Registering for a place is unchanged.
Events — cancelling or archiving an event now cancels its scheduled and previewed invitation campaigns; invitations cannot be issued or accepted for a cancelled, completed or archived event, and invitation messages still queued for one are not sent. Postponed events still accept invitations, like registrations.
Events — switching organiser broadcasts off now cancels broadcasts that were already scheduled, and anything still queued is not sent while the setting is off.
Events — the nightly events integrity check no longer reports automatic waitlist moves, or an accepted waitlist place waiting for the organiser's approval, as tampering.
Events — the older check-in routes explain a refusal for unanswered required questions with the same clear message, instead of a server error.
Events — a member moved up from the waitlist gets one message, not an extra "You accepted your place" notice for something they did not do.
Events — with safety enforcement switched on, an event that has no published safety rules is open to members again. Enforcement used to refuse everyone on such an event with "Safety eligibility could not be checked right now. Please try again." Now an event's own rules (minimum age, code of conduct) are applied only where its organiser has published them; blocks between a member and the organiser, an organiser's recorded refusal and the community's safeguarding policy still apply to every event. Rules that were published and are being edited stay in force while the edit is in progress, and archived rules no longer apply.
Email sent through a community's own SMTP server is no longer reported as sent when the server refuses it. The built-in SMTP client read each server reply but never checked its code, so a refusal such as
550after the message, or a refused recipient, still counted as a successful send. Every step now requires its expected reply (220,250,334,235,354, and250once the message is complete), and anything else ends the attempt as a failure. Registration staff alerts no longer mark such a refusal as delivered, so recovery can follow it up. Postmark and Gmail sending are unchanged.Events — event invitations and "your guest was removed" emails are now actually sent. Under the default notification delivery (
outbox_authoritative) the scheduledevents:process-notification-outboxnever picked upevent.invitation.issuedorevent.registration_guest.withdrawn, so invited members were never told and those rows stayed pending for ever. Rows already stuck will be sent after the next deploy; invitations that have since expired are suppressed by the existing expiry check.Events — an event can no longer go over its capacity when a guest was added before the place was approved (F-586). Approving or confirming a place now needs a seat for the member and for each guest they already added; otherwise it is refused as full.
Events — accepting a waitlist offer respects the organiser's manual approval and the registration closing time (F-587). Under manual approval the accepted place is now pending for the organiser to decide, and the seat is offered to the next person on the waitlist; after registration closes, acceptance is refused.
Events — undoing a check-in now takes back the attendance time-credit reward (F-588). A later check-in pays it again; undoing a check-out keeps it. If the ledger refuses the reversal, the undo still happens and the check-in screen reports
reverse_failedso an administrator can reverse it from the rewards ledger.Events — "who may create events" now also applies to creating an event from a template (F-589), and creating from a template or creating a repeating event now asks for any pending legal documents to be accepted first, like creating a single event.
Events — when a community requires events to be approved, a member can no longer change the wording, place, links or picture of an approved event without review (F-590). Such an edit is refused with a message; times and capacity can still be changed, and administrators are not affected.
Events — cancelled, postponed and completed events stay visible with their state in the events list and on the public What's On page, so a shared link to a cancelled event says it was cancelled instead of "Event not available". They still cannot be registered for.
Events — the organiser's registration cancellation cut-off is now enforced: after it passes, a member can no longer withdraw a confirmed place themselves (organisers can still cancel places, and a pending request can still be withdrawn).
Events — the spaces left on an event now count the guests already coming, so the page no longer shows a free space and a Register button when the room is actually full.
Events — the event team list shows ordinary organisers first names only (F-591), following the platform's rule that surnames are visible only to administrators; administrators and the person themselves still see the full name, and an organisation's contact person is never shown.
Registration staff alerts now say "open the members list" in all 11 languages, member discovery no longer stops a page early, and the alert recovery command reports failed sends. The neutral new-member alert links to the staff members list; German, Spanish, French, Dutch, Portuguese, Polish, Arabic and Japanese still told staff to sign in and view the registrant's profile, so their bell, body and button now match the destination. In ranked member discovery, a member hidden since the ranking was cached could end paging one page early and leave the last visible member unreachable; paging now follows the cached ranking.
emails:recover-captured-registration-staffcounted a send as recovered whenever the listener returned, even though the listener absorbs per-recipient failures; it now checks where each recovered row ended up, reportsunresolved_recipientsand exits with a failure when any stays unresolved. The committed schema dump now includes theregistration_staff_email_deliveriesledger table.The location box on sign-up, in the welcome steps and in the home-page reminder no longer drops the first letters typed. Clicking the box swapped in the place search, which moved the cursor out of the box, so the first characters went nowhere and a second click was needed. The cursor now stays in the box (at the end of what is there) and nothing typed is lost; the maps code still loads only after the first click.
Accessible site — the town field on the registration page no longer promises a suggestion list that does not exist. The hint told people to "pick a suggestion" and said free-text towns were not accepted, but this site has no suggestion list and nothing fills the hidden map position, so the advice could not be followed. It now says to type the town, city or area in your own words (all 11 languages).
Admin — the member import now refuses a file whose balances add up to more than 1,000,000 hours, email addresses the platform could never send to (reserved domains such as
.invalidand.local, and bracketed IP addresses; on a development machine the domains listed inMAIL_CAPTURE_RECIPIENT_DOMAINSare still allowed), and invisible or right-to-left control characters inside names. The file total guards against a balance column in the wrong unit (each balance was already capped at 100,000 hours, so one file could create far more than any community holds). Zero-width spaces, right-to-left overrides and similar characters could make one member's name look like another's; the joiners that Persian and Indic names need are still allowed.Admin — a member import that was interrupted can no longer leave an identity check unstarted without saying so. If the server stopped just after a member was saved, the retry treated that member as complete. It now records the administrator's attestation if it was missing, and reports a member held for an identity check as needing follow-up.
Admin — exporting all members for re-import now asks for a fresh second factor, like the import itself. The website's file downloads can now answer the second-factor prompt and ask again, so any download behind that gate works.
Registration staff alerts — replaying an event no longer duplicates staff bells or their push notifications. The bell uses the existing unique per-recipient event key; push is sent only when that bell is first created. Email delivery still needs a separate per-recipient retry and provider-outcome repair before it can be counted as reliable.
Registration staff alerts — replaying an event no longer duplicates staff bells or their push notifications. The bell uses the existing unique per-recipient event key; push is sent only when that bell is first created. Email delivery is tracked separately per recipient, with unresolved provider outcomes held for review.
Registration staff email outcomes — an unroutable staff address is now recorded as a definite pre-transport failure. No provider call is made for that captured recipient. Transport timeouts, suppressed addresses and other false returns remain unknown until their acceptance status can be established.
Registration staff delivery review — operators now have a read-only, tenant-scoped command for unresolved recipient rows and exact mail-receipt counts. It shows delivery and dispatch IDs, status, attempt count and bounded error code while withholding recipient email and claim token; it never changes state or sends mail.
Registration staff email reconciliation — a single exact positive mail-log receipt can now resolve an unknown delivery as provider-accepted. The scheduled recovery job matches tenant, category, dispatch ID and stable recipient key, records the evidence row ID, and makes no second send. Missing, failed or conflicting receipts remain unknown for provider or operator review.
Registration staff alert crashes — claims left in flight for over twenty minutes now move to an explicit unknown state for reconciliation. The bounded recovery job holds up to 100 such rows per run, reports aggregate counts and never sends them again automatically. Each inline attempt also carries a distinct dispatch ID in the ledger and mail metadata so evidence can be matched. Fresh claims and other tenants remain untouched.
Registration staff emails — an unconfirmed Postmark or Gmail result no longer triggers an SMTP fallback for the same alert. The per-recipient delivery stays unresolved for provider reconciliation, avoiding a second automatic send when the first provider may already have accepted it. Other mail categories retain their configured fallback behavior.
Captured registration staff alerts — a bounded recovery job now revisits never-attempted recipient intents when the inline event was lost. It waits two minutes, selects at most 20 registrations every five minutes, checks a 45-second deadline between registrations, and limits automatic sends to the first seven days. It checks that the registrant remains available and the staff recipient is still eligible; revoked recipients and unavailable registrants are cancelled. Ambiguous and claimed attempts are never automatically retried.
Registration staff alert replay — new registrations now claim each captured staff email recipient before sending. A confirmed send is recorded as accepted; an unconfirmed send is held for reconciliation instead of being blindly resent. Replaying the event can reach unsent captured recipients even if a stale whole-event done key exists, while accepted or unknown recipients are not emailed again. Historical events without captured intent keep their legacy behavior; a bounded job now recovers never-attempted captured rows, while ambiguous outcomes require evidence or operator review.
Registration staff email intent — new registrations now commit per-recipient alert intent with the account and invite redemption. A failed one-use invite redemption rolls the account back instead of leaving a rejected orphan. Invite redemption and deactivation now use Laravel's affected-row count so their results are actually checked. New rows begin captured and can be claimed only through the inline sender; bounded recovery and exact positive receipt reconciliation are described above.
Registration staff email recovery foundation — a per-recipient claim ledger now has a stable tenant/registrant/staff identity and an explicit unknown-provider outcome. Registration captures non-sendable intent and the inline sender records its attempt; unresolved provider acceptance still needs human review.
Sign-up and password reset can no longer get stuck on "Checking against known data breaches…". The new-password check asks Have I Been Pwned (
api.pwnedpasswords.com) from the browser whether the password is in a known breach. It already let the member continue when that request failed, but it had no time limit, so a request that hung instead of failing — seen on 9 Oct 2026 in a sandboxed browser, and possible behind some firewalls, captive portals and privacy extensions — left the check running for ever and the form could never be sent.usePasswordChecknow aborts the request after 4 seconds (HIBP_TIMEOUT_MS) and lets the member continue, saying "Long enough. We could not check known data breaches just now, so this will be checked when you submit." The server still checks every new password against the same breach list on registration and reset (PwnedPasswordService), so that remains the real gate. The hook's messages, which were hard-coded English, now use translation keys (auth:password_check.*) translated into all 11 languages. Change password in Settings does not use this check and was not affected.Admin — the member import no longer shows a "minutes to send" estimate when nobody will be emailed. In a community that requires an identity check, until the administrator ticks that they have checked each person's identity, every imported member is held and none is emailed. The "Email each new member…" option still showed "about N minutes for this file" beside it, which was untrue; it now shows only the note that members waiting for an identity check are not emailed now. The estimate returns as soon as the identity box is ticked.
Security documents now record the completed independent penetration test.
SECURITY.md,docs/SECURITY-ASSURANCE.mdandAGENTS.mdsaid no independent penetration test had been performed. A specialist firm completed one in October 2026, against an isolated staging copy of the platform holding only synthetic data; the findings it reported were fixed and confirmed by its retest, apart from one low-severity item accepted as a known risk. The documents now say so, and that the firm's report is confidential while its letter of assurance is available on request. The next independent test is planned for about twelve months later, or sooner if a customer requires one. At the testing firm's suggestion the firm is now named:docs/SECURITY-ASSURANCE.mdgains an "Independent assurance" section naming Cyphere (linked) as the 2026 independent security testing partner and stating that independent testing is repeated every year, andSECURITY.mdand the README link to it.Volunteering — the qualification-expiry reminder window had two defaults. The admin settings page showed 14 days while the qualifications register actually sent reminders 30 days ahead, so a community that never pressed Save saw one number and got another. One default now, 30 days (
volunteering.reminder_credential_expiry_days_before), and the training-expiry sweep's community discovery uses the largest window an admin can set instead of a fixed 14 days, so a community with a longer window is found without first saving a settings row.Admin — saving the volunteering configuration now refuses a number outside its allowed range and writes nothing. The Module Configuration window's number boxes carry a minimum and maximum, but a typed value was accepted whatever it was; the server only coerced it to a whole number. Every numeric volunteering setting now has a range (
VolunteeringConfigurationService::RANGES, pinned against the defaults byVolunteeringConfigurationServiceTest), and a request with any value outside it gets a 422 naming the setting, with nothing saved, rather than silently keeping the good keys and dropping the bad one (AdminConfigControllerTest).Webhooks — a failed delivery is now retried a limited number of times with a growing wait, then left alone. The five-minute retry job re-sent every failed delivery of the last 24 hours on every run, never counting attempts, so a dead endpoint was hammered about 288 times a day. Each attempt is now counted, the next one is scheduled 5, 15 then 45 minutes out, and the webhook's
max_retries(3 by default) is honoured. Delivery also goes through oneOutboundWebhookTransportclass so tests can prove this without touching the network (WebhookRetryCapTest).Webhooks — the list no longer returns every webhook's signing secret. It was sent to the browser on every page load and never shown; the secret is now given out once, when the webhook is created or its secret rotated, which is the moment the receiving system is being set up.
Admin — the breadcrumb trail on the volunteering Shift swaps and Donation refunds pages read "⚠ swaps" and "⚠ donations". Both segments, and the new hub addresses, are named now.
scripts/check-admin-breadcrumbs.mjs, which is meant to catch this, had been reading a locale file with no breadcrumb labels in it since it left CI in April 2026; it readsadmin_nav.json, the file the breadcrumbs really use, again. It still reports segments outside this change (redirect-only addresses and a few other pages) and is not back in CI.Admin — three review findings on the volunteering panel work above, fixed before it shipped. The "Needs your attention" counts now have every plural form Polish, Irish and Arabic need, so an admin in those languages no longer sees the English wording for most numbers; the migration that copies the old reminder timings clamps a value outside the new limits (an old 300-hour reminder would otherwise have blocked every save of the volunteering settings window); and closing a Module Configuration window that was opened by its own Configure button no longer sends the admin to the volunteering overview just because
?configure=volunteeringwas still in the address.
Security
- npm-audit exceptions reviewed ahead of expiry. node-forge
GHSA-86w9-cpqp-85rv(mobile, developer tooling only) renewed to 2027-02-02 after re-checking: 1.4.0 is still the latest release, no app source imports it, and the app still configures no update code signing. Both bracesGHSA-vfj7-8cjw-p6xmentries (mobile, web-uk) removed: braces now arrives only through development tools (lint-staged, nodemon), andscripts/npm-audit-gate.mjs --package-lock-onlypasses for both trees without them. Without this the Security Scan would have gone red again on 2 and 3 November when the old entries expired. - Security Scan green again — the npm-audit gate gets the zod exception E-113 gave only to Trivy.
GHSA-mrhx-c7xc-m3w5(CVE-2023-54404, zod below 4.6.6) has no 3.x fix; the app's own zod is 4.6.7, and the remaining copy is the one@expo/clipins at 3.25.76 for its developer-machine dev server (still so in the latest @expo/cli, 58.1.15). E-113 recorded the exception in.trivyignoreonly, so the blocking "NPM Audit (production deps)" step, which reads.npm-audit-exceptions.json, has failed every Security Scan run since. The matching entry is added with the same reasoning, expiring 2027-01-25. All five audited trees (root, react-frontend, e2e, mobile, web-uk) passscripts/npm-audit-gate.mjslocally. - Dependency advisories cleared across the accessible site, phone app and website tooling (E-113). Accessible site (
web-uk/, production runtime):sanitize-html2.17.7 → 2.18.1 (Dependabot #177–#179:meta http-equiv=refreshjavascript:/data:URLs,<noscript>early close, SMIL namespace-prefixjavascript:);express4.22.2 → 4.22.3, pullingbody-parser1.20.6 → 1.20.9 (CVE-2026-111181, decompression-stream DoS — this was failing the blocking Trivy gate); devshell-quote→ 1.12.0. Phone app (mobile/):zod4.3.6 → 4.6.7 (Dependabot #193, CVE-2023-54404, memory exhaustion on oversized constrained collections). Website and root tooling (dev/build only, not served):vite7.3.6 → 7.3.7 (#180–#189, incl. launch-editor command-injection on Windows),vitest/@vitest/coverage-v8/@vitest/mocker4.1.11 → 4.1.12 (#190, #191); react-frontend lock regenerated so npm 10.9ciaccepts it..trivyignore: two justified entries —certifiGHSA-6q48-6c7w-9gw2 in Mockery's Python docs file undervendor/(never installed), andzod3.25.76 kept inside@expo/cli's dev server, which pins zod 3 and has no 3.x fix. Verified: web-uk 208 suites / 3,950 tests, mobiletsc+ 635 suites / 7,548 tests, react-frontendtsc+ build + 1,434 files / 17,716 tests,npm audit0 in web-uk and root. - Container base images updated for the October 2026 security releases (E-112: F-602 Critical, F-603, F-604). PHP
8.2.30→8.2.35(PHP2Shell, CVE-2026-109618/109619: unauthenticated remote code execution under Apache mod_php, exploited in the wild from 21 Oct 2026; no configuration mitigation exists) inDockerfile,Dockerfile.prod,Dockerfile.bluegreen, withconfig.platform.phpincomposer.json/composer.lockto match. nginx1.30-alpine→1.30.6-alpine(14 Oct 2026 release, five CVEs) in both React production Dockerfiles and the four nginx test scripts. Node22→22.23.4(21 Oct 2026 release, ten CVEs incl. a Brotli decoder overflow) for the web-uk and React builder images. Production was running 8.2.30 / 1.30.5 / 22.22.0: the PHP tag was pinned exactly and nothing proposed bumps, while the nginx and Node line tags never advanced because the deploy builds with--no-cachebut not--pull. All three are now exact patch pins,F552ReactNginxVersionDisclosureTestaccepts an exact patch on a stable nginx line, and Dependabot now watches thedockerecosystem weekly (patch bumps; PHP minors, nginx lines and Node majors ignored as deliberate upgrades). - Member listings are no longer published in
/llms-full.txt(F-601, Medium). From 12 May 2026 the public, no-sign-in "AI summary" file listed each community's 30 newest active listings — title, offer or request, link and the first 160 characters of the member's description — and nginx routes AI crawlers to it, although listings are member-only everywhere else and the sitemap deliberately leaves member content out. Confirmed live on 23 Oct 2026 (30 listings on hour-timebank.ie, 4 on uk.timebank.global). The section is removed fromLlmsController::renderFull, and the cache key is versioned so a copy cached before the fix is not served. Test:tests/Laravel/Feature/Controllers/LlmsControllerTest.php(fails against the previous code). - Mobile — over-the-air app updates are switched back on, unsigned (owner decision, permanent). They were switched off on 25 Sep 2026 because they are not code-signed (security finding F-201); the owner reversed that and accepted the risk.
mobile/app.jsonagain hasupdates.enabled: trueandcheckAutomatically: "ON_LOAD", andnpm run verify:releasenow requires both. Signing needs the paid EAS Production plan; until then the Expo account is the control (two-factor sign-in, one publisher, no stray access tokens). Takes effect only from the next store build on each platform — Android 1.9.0 (code 22) and iPhone 1.10.0 (build 8) were built with updates off and never fetch one.mobile/docs/SECURITY.mdnow lists this as an accepted risk rather than a control. - API —
symfony/http-foundation7.4.13 → 7.4.21 (CVE-2026-108082). A craftedCache-Controlrequest header could make the header parser (HeaderUtils::split()) take excessive time, a regular-expression denial of service. The advisory reached the Composer audit on 15 Oct 2026 and turned the Security Scan red on224200967; only this package moves incomposer.lock. Middleware unit tests (263, 1 skipped as before) and the sign-in and cross-community tests (32) pass on the new version;composer auditis clean. - Website — two low-severity runtime advisories patched (Dependabot #161, #169, #175).
dompurify(sanitises member-written HTML before it is shown) 3.4.15 → 3.4.17, andi18next-http-backend(loads each language's text) 3.0.6 → 4.0.3, which also includes a later security release. Version 4 uses the browser's built-infetchinstead of the bundledcross-fetch, which every supported browser provides; nothing else in how translations load changed. - Four high development-tool advisories patched (Dependabot #173, #174, #165, #168, #166).
sharp0.35.4 → 0.35.5 (with its image library 1.3.3 → 1.3.4) in the website and the repository root,source-map-js1.2.1 → 1.2.2 (Tailwind, Sass, PostCSS and test tooling) in both, andcompression1.8.1 → 1.8.2 under the Lighthouse CI server. All are build- or test-time dependencies; the production build still optimises images with the updatedsharp. Lock files and thesharpminimum only. - Two critical development-tool advisories patched (Dependabot #176, #167). The accessible site's
concurrentlydev-script runner now resolvesshell-quote1.12.0 (was 1.9.0; command injection via a line terminator inquote()), and the website's Lighthouse CI server resolvesproxy-addr2.0.8 (was 2.0.7; IP spoofing through IPv4-mapped IPv6 trust subnets). Both are development-only dependencies; neither ships in the live site. Lock files only. - Lighthouse CI no longer installs
extract-zip, and a help-label script no longer prints a local file path. Lighthouse CI (@lhci/cli0.15.1, whose newest release still pins Lighthouse 12) pulled inextract-zip2.0.1 throughpuppeteer-core→@puppeteer/browsers; it is unmaintained and has two High advisories (Dependabot #110, #121) with no fixed release.react-frontend/package.jsonnow overrides Lighthouse to 13.4.1 under@lhci/cliand@lhci/utils, whose newer browser helper unpacks withmodern-tarinstead, soextract-zip,yauzland their helpers leave the lockfile. Lighthouse's own tree moves with it (its Sentry Node client 7 → 10,configstore5 → 7); outside that tree onlyzod(4.4.3 → 4.6.7, within the app's declared range) and the test-onlyws(8.21 → 8.22) change.scripts/write-help-label-baseline.mjsprinted the resolved report path, which CodeQL flags as clear-text logging (#1783); it now prints a fixed message. Dev and CI tooling only: nothing in the built site changes. First prepared on 28 September on an unmerged branch; reapplied to currentmainwith the lockfile rebuilt under npm 10.9.3. - Events — the public event page's structured data can no longer be broken out of by an event description. The search-engine data block on the public What's On detail page was written without escaping "<", so a description containing
</script>could end the block early in the saved page served to search engines. It is now escaped as on the member page (register F-595). - Jobs — the job page's structured data can no longer be broken out of by a job title or description (F-595 follow-up). The JobPosting data block on the job detail page was the one remaining place that wrote member-written text into a JSON-LD script without escaping "<", so a title containing
</script>could end the block early in the saved page served to search engines. All 17 JSON-LD blocks in the React frontend (job, marketplace, event, public event, group, ideation challenge, idea, knowledge-base, listing, organisation, volunteering opportunity, blog, contact, FAQ and the three site-wide blocks inSeoHead) now go through one helper,toJsonLd()inreact-frontend/src/lib/json-ld.ts, which also escapes ">" and "&". The group, challenge and idea pages had already been escaped in June and gain regression tests. - A tenant super admin administers its own community and every community below it — never one above or beside it (owner rule, 12 October 2026). A tenant admin acts only inside its own community; the global super admin and god keep platform-wide reach. On 11 October a stricter reading ("own community only") was shipped for events and the super-admin panel; the owner clarified the next day that the communities below are included, and that reach is restored. What differs from 3.1.0: a tenant super admin can no longer create events in an unrelated community just because that community lets members create events — it needs authority there (
EventConfigurationService::canCreateasksTenantAdminScope);TenantAdminScopeandSuperPanelAccessmatch the hierarchy on a trailing-slash-anchoredtenants.pathprefix, so/1/2/never matches/1/20/and an empty path refuses; a tenant super admin on the master community isregionalover the communities under it, nevermaster; a tenant super admin cannot move its own community or switch its hub setting. An event's Team tab and lifecycle history now follow the same rule (EventRoleService,EventStaffController,EventLifecycleHistoryQueryService), while staff can still only be chosen from the event's own community. - A tenant super admin's sign-in now works in the admin API of the communities below its own — and only there. Every request used to be pinned to the account's home community unless it was a global super admin or god. The new
CrossTenantEntrycheck (used byTenantContextandAuthenticate) also admits a tenant super admin into a community strictly below its home community, for/api/v2/admin/*and a read-onlyGET /api/v2/users/meonly, with a token issued for its home community. Every member-facing request there (posting, messaging, wallet, event registration, the organiser tools under/v2/events/*) is still refused withtenant_mismatch; tenant admins, brokers and members stay pinned to their own community. Admin screens that looked up the acting admin inside the current community (events admin, safeguarding, caring community, Verein federation, member role/email/status edits) now load the admin by id and check its authority, while the members being managed stay confined to the community. The website now takes a tenant super admin there too — see the next entry. - A tenant super admin can sign in to, stay signed in to, and manage the communities below its own; anyone else is signed out on arrival (owner, 12 October 2026). Owner: "If a user tries to sign in to a community that they're not allowed to sign in to, it should sign them out. If they're allowed to sign in, they're allowed to sign in to manage those communities." Server: a lower community's sign-in page now finds an admin-tier tenant super admin of a community above it (login priority 4, after every existing match); a refresh token keeps working in a community the account may enter (
CrossTenantEntry::mayEnter(): global super admin anywhere, tenant super admin strictly below), decided on the locked user row before rotation; and the requests admitted below now include the event team (GET/POST /v2/events/{id}/staff,DELETE …/staff/{id}), the organiser views that write nothing (event, people, attendees, attendance, waitlist, history, broadcasts list, tickets, safety, agenda, registration management and invitations, offline check-in workspace) and a read-only member-directory search (GET /v2/users, for picking a team member). Taking part as a member (RSVP, waitlist, own tickets, forms, agreements, reminders, calendar) stays refused. Most organiser changes are deliberately not admitted yet: 45 event tables record the acting account with a foreign key that requires it to belong to the event's community, so such a write by an account from another community fails in the database; widening needs the owner's decision. Event controllers and services that looked the acting account up only inside the current community now useCrossTenantEntry::activeActor(); a ticket's recipient is still always a member of the community (EventTicketingSupport::member()). Website: switching community no longer signs anyone out first, and booting a different community no longer clears the stored session — even a global super admin used to lose it. Instead the website asks the server who the account is there, and atenant_mismatchanswer ends the session. A tenant super admin visiting a community below is taken to its admin panel (an event's page opens on its organiser page), and the member terms and onboarding of that community are not asked of it. - Super-admin panel — the audit log is scoped by the community acted on, not by where the actor lives (F-593). A tenant super admin saw entries written by platform admins homed in its community about unrelated communities, and missed entries about its own sub-communities.
super_admin_audit_loggainstarget_tenant_id(appended,ALGORITHM=INSTANT, backfilled where derivable); a move is recorded against the nearest common parent of both ends, bulk actions are platform-only, and a tenant super admin now sees only entries about its own community and those below it. - A tenant super admin can now change the events of the communities below its own, recorded under a stand-in account of that community (owner, 12 October 2026: "Go with option 3"). About 45 event tables record who acted with a foreign key that requires that account to belong to the event's community, so until now a manager from above could only view events below. Now it acts there as its delegate: a
usersrow in the lower community with the new statusdelegate(appended to the enum withALGORITHM=INSTANT), role admin, the manager's name, no password and a non-deliverable@delegate.invalidaddress, linked to the real account in the newtenant_manager_delegatestable (migration2026_10_12_180000_create_tenant_manager_delegates). It is created on first use (ManagerDelegate::forPrincipal()), and usable only while the manager is still active and still a tenant super admin above that community (ManagerDelegate::isUsable(), checked on every use). It can never sign in:Authenticaterefuses any status but active, andTokenServicerefuses to mint a token for one. Database triggers refuse giving it a password, changing its email, role, community or platform flags, turning it into an ordinary account, or turning an ordinary account into one. It is left out of the admin user list and detail page and the member export.CrossTenantEntry::activeActor()andBaseApiController::getActingUserId()hand the delegate to the event controllers and services on organiser requests below; actor checks that readstatus = 'active'now accept a usable delegate (ManagerDelegate::isActingAccount()), while the members acted on stay strictly in-community. All event organiser actions are admitted below again — cancel, edit, publish, approve or reject registrations, check-in and attendance, broadcasts, tickets, registration forms and settings, safety rules and reviews, agenda, templates, check-in devices and offline sync, analytics. The same fixes the admin panel's cancel of an event with scheduled messages for a manager from another community, including the global super admin (register O-209). Data protection question DPO-11 raised. - Regional analytics subscription admin checks for a global super admin itself (F-594). The controller asked
requireSuperAdmin(), which also admits a tenant super admin; only the route middleware stood in the way. All seven actions now callrequirePlatformSuperAdmin(). - Removed code that still treated a tenant super admin as platform-wide: the unused
User::isTenantSuperAdmin()/User::isMasterSuperAdmin()and the unregisteredapp/Middleware/SuperPanelAccess.php;Enterprise\PermissionService::isSuperAdmin()now accepts the global tier only; the super panel no longer writesis_super_admininto a tenant super admin's session. - "Network administrator" is no longer used. The documents, code comments and the super-admin panel now say "tenant super admin" (the panel's "Assign as Regional Super Admin" now reads "Assign as Tenant Super Admin", in all 11 languages).
- Ranked member results recheck current visibility before showing a profile. CommunityRank keeps candidate IDs for three minutes; a cached ID could still disclose a member after they restricted their profile, opted out of search, lost approval or became inactive. The directory now checks those rules again when loading the profile fields and uses a fresh visible count for unfiltered ranked results.
- Member search and directory now use the active, approved Admin check for private profiles. An account with an Admin role but outstanding approval could still open a connections-only profile through directory and search results; search also showed surnames that ordinary members do not see. Both routes now use the shared profile visibility rule. Active, approved Admins keep their review access.
- Pending or unapproved member profiles are now staff-only until activation and approval. A newly registered, unapproved account could be opened by any signed-in member of the same community through its numeric profile URL even before activation, revealing its first name and location. The profile API and profile-linked activity and availability routes now return 404 to ordinary members while either the account status is inactive or approval is outstanding, while the registrant and active, approved community administrators, brokers and coordinators retain their review path. Member search, the ordinary and ranked directory, nearby results, connection suggestions and mention suggestions now also exclude pending and unapproved accounts. A numeric ID can no longer be used to create a connection request to a pending or unapproved registrant, with the approval check made under the same user-row lock as request creation. The connection-status route no longer confirms a pending or unapproved account to an ordinary Member, and old connection rows no longer display that person's name, bio or location to that audience. When a search index returns only stale or ineligible hits, current database search can still find a newly approved member. Tenant isolation and approved member discovery remain unchanged.
- Suspended or unapproved administrators no longer get the member-profile privacy exemption. Shared profile visibility checks now require the viewer to be active and approved before applying Admin-tier access, matching the direct pending-profile rule.
- Bulk member-profile queries now enforce the pending-account gate themselves. Shared profile visibility SQL could include a pending or unapproved account when its privacy setting was public, even though direct profile and directory checks had already been tightened. Bulk results now require an active, approved account for other members and anonymous viewers; a registrant can still inspect their own account and staff can still use their review path.
Internal
- Deploy crawler check judges each page against its own refresh interval.
scripts/check-prerender-delivery.mjsreported 12 of 30 pages "STALE" on the 25 Oct deploy: every/aboutpage, re-rendered about 43 hours earlier. Since 2026-10-23 the sitemap gives fixed pages no<lastmod>(SitemapService::getStaticPageUrls), so the drift detector leaves them to their TTL inconfig/prerender.php(30 days for/about), while the check still assumed a daily re-render. It now reads that TTL table and allows each page its own interval plus 6 hours, never less than the 30-hour daily limit, so a frozen home page (6-hour TTL) is still caught; if the table cannot be read, every page keeps the strict daily limit. Tests:scripts/test/check-prerender-delivery-freshness.test.mjs(+6). Also:database/schema/mysql-schema.sqlgains thelistings_digest_sendstable and its migration record, copied from production (read-only export) rather than re-dumping the whole file. - Mobile bundle ceiling raised by 1% (owner-approved). The E-113
zod4.6.7 upgrade put the Android bundle 6,943 bytes over the start-up budget (ci.yml run40122930143, 18,901,225 bytes against 18,894,282).mobile/startup-budget.jsonnow records that measurement and a ceiling of 19,090,237 — measured + 1%, deliberately not the usual + 10%, because the bundle has grown ~10% a month twice running and needs weight taken out rather than repeated raises. - Mobile Laravel route inventory refreshed for
bb783b8cd, which changedbootstrap/app.php(theseo:probeschedule) without regeneratingmobile/docs/generated/laravel-api-route-inventory.json; its fingerprint covers that file, so the Android release gate and the E2E live-route check refused it (run40011073613). Regenerated withnpm --prefix mobile run api:routes;drift:checkandcheck-live-route-inventory.mjspass. - CI fixes for the 23 Oct 2026 SEO commits (full run
39975159651red on4f00ca49c). The sitemap and llms code read the app address fromconfig('app.frontend_url'), which falls back toAPP_URL, while the render engine and the tests readFRONTEND_URLwith the production default; they agreed locally and on the server but not in CI. Both now use the newconfig('prerender.frontend_url'), read exactly asPrerenderService::frontendHost()reads it.SitemapControllerTestasks for a community's sitemap on that community's own host (it relied on any host being accepted);Unit/Controllers/LlmsControllerTestpins the public-only links instead of the removed organisations link;SeoServiceTeststubs the redirect publisher behind its mocked database.scripts/test/test-spa-shell-fallback.mjsaccepts snapshot lookups that hand off to@bot_spa_shelland checks that location serves/_spa.html, sets the shell marker and says noindex.event-structured-data.tsmarks its offset-onlyIntl.DateTimeFormat('en-US')locale-exempt. Regeneratedweb-ukroute matrix / API ledger (836 routes, +1/robots.txt) and the mobile Laravel route inventory. - Preflight runs the changed PHP tests as
www-data. It ran them as root, which on 21 Oct 2026 left root-owned scratch under the bind-mountedstorage/framework/testing/disks/{local,s3}/podcasts; the next normal run ofPodcastControllerTestfailed 12 tests with "Permission denied". The pre-commit hook has run aswww-datasince 7 Oct;scripts/test/test-precommit-runs-php-as-www-data.shnow pins preflight too. - CI — four red jobs on
0d8e2b7d4, fixed. The podcast studio guide passed its labels as one object, whichcheck-i18n-varscannot see (3 false mismatches); eacht()call now names its values.bootstrap/app.phpgained the podcast re-scan schedule, so the phone's API route snapshot fingerprint went stale (npm --prefix mobile run api:routes; no route changed).CommunityProjectService::proposerName()joined first and last names by hand, over the display-name gate's ceiling of 0; it now usesUserDisplayName::resolvePrefixed($row, 'proposer_'), same result. - CI — Docker Build Verify now builds the images production deploys. Production blue/green builds
Dockerfile.bluegreenandreact-frontend/Dockerfile.bluegreen(compose.bluegreen.yml); the job built onlyDockerfile,Dockerfile.prodandreact-frontend/Dockerfile.prod, so on 21 Oct 2026 a Redis-extension fix that missedDockerfile.bluegreenpassed CI and failed at deploy. The job now builds both blue/green images (stagingCHANGELOG.mdinto the React context asbluegreen-deploy.shdoes) and checks the PHP image loads theredisextension. - Docker — no image build depends on pecl.php.net any more. On 21 Oct 2026 pecl.php.net's REST catalogue came back malformed to the production host ("Package redis Version 6.3.0 does not have REST xml available", also after
pecl channel-update), so two production deploys of2b032253efailed at the image build; nothing went live and the active colour kept serving. A third (4f39071d7, directpecl.php.net/get/download) failed because that fix missedDockerfile.bluegreen— the file production blue/green actually builds, and one CI's Docker Build Verify does not build — and CI on the next commit then failed because pecl.php.net was answering 502/504 to everyone. The Redis extension (Dockerfile,Dockerfile.prod,Dockerfile.bluegreen, one identical block) and PCOV (Dockerfile) are now built from their GitHub tag archives withdocker-php-ext-install, pinned to the versions already in use (phpredis 6.3.0, pcov 1.0.12) and checked against a SHA-256 first. Both blocks were built and the extensions loaded on the production host before commit. - CI — four red gates on push
d1a89a014(21 Oct 2026). PHP shard 10:EventRecurrenceDefinitionBlueprintIntegrationTestfroze Carbon at 2026-10-20 whileEventServicechecks a new series' start against the real clock, so the fixture's "tomorrow 09:00" became the past on 21 Oct and all six tests answered 422 — the freeze moves to 2030-10-22 (also a Tuesday five days before the October clock change). Bundle budget:VolunteeringPageimportedVolunteeringWelcomestatically for its dismissed check; the flag now lives involunteeringWelcomeStorage.tsand the panel is lazy-loaded again. Admin i18n token integrity:extractLiteralRoutes()took a sentence-ending full stop as part of a route ("/contact."), failing the correct Japanese; trailing dots are now dropped. Docs lint: blank line before a heading indocs/modules/volunteering.md. - CI — the wellbeing work passes the gates it failed on first push (20 Oct 2026). The wellbeing PHP tests now run in a community of their own (the shared test community held another staff account in CI, so "told 1 person" read 2); the Japanese settings hint keeps the literal
/contact.token; six Arabic plural forms keep{{count}}; and the wellbeing push producer's three destinations are listed inmobile/config/native-push-dynamic-route-review.json. The admin wellbeing drawer shows trend and status words through translations (it printed raw values such asslightly declining), with labels in all eleven languages, and the phone's committed API route snapshot is refreshed for the four new wellbeing routes. scripts/verify-admin-security.shchecks the live super-panel code again. It readapp/Middleware/SuperPanelAccess.php(deleted as unused) and rantests/Middleware/SuperPanelAccessTest.php(long gone); it now readsapp/Core/SuperPanelAccess.phpand runstests/Laravel/Feature/SuperAdmin/TenantSuperAdminPanelSubtreeTest.php. The script is run by hand, not in CI..env.examplenow enables the recurrence v2 engine (EVENTS_RECURRENCE_V2_ENABLED=true), matchingconfig/events.phpand production; it is the only recurrence engine since the old one was removed. CI keeps its explicit setting.- Cutting a release now refreshes the accessible site's changelog pages, and CI fails if a released version's page is missing or out of date. The accessible frontend serves
/changelogfrom pages pre-rendered intoweb-uk/src/lib/generated/changelog/and committed (its Docker build cannot seeCHANGELOG.md). Nothing regenerated them, so 2.1.0, 3.0.0 and 3.1.0 were missing until9764ad880.scripts/release.mjsnow runsweb-uk/scripts/build-changelog.jsaftercopy-changelogand commits that directory with the release (still by explicit pathspec); it refuses before writing anything,--dry-runincluded, whenmarked/sanitize-htmlare not installed underweb-uk/. Newnpm --prefix web-uk run check:changelog:released(--check-released) compares every released version's page and the released entries ofindex.json, exits 2 when it cannot run, and is BLOCKING in the always-onDocumentation, Version, and Changelog Hygienejob (which now installs web-uk's dependencies with--ignore-scripts) and inscripts/preflight.mjs.unreleased.jsonis deliberately not gated: nearly every commit edits[Unreleased], so gating it would demand a regeneration on all of them; each release cut refreshes it, and the fullcheck:changelogstill reports it.