Unreleased
Changes that have landed since the last release.
Added
- The accessible frontend now publishes the account-deletion and child-safety-standards
pages, so both footers offer the same pages. They were the last two links in the React
footer with no accessible equivalent — both 404ed. Verified by comparing the two footers'
destinations and requesting every one: nothing the React footer links is missing from the
accessible site, and all 23 accessible footer links resolve. The accessible footer keeps
four extras, each deliberate: Volunteering, Features, the cookie-settings link, and
"report a problem" (WCAG 2.2 §3.2.6 Consistent Help, which React covers with its floating
reporter instead).
- 🔴 Both pages have ONE source, shared with the React pages, generated by
web-uk/scripts/build-legal-text.jsfromreact-frontend/public/locales/*/legal.json. This is compliance copy: it names the operating company, a charity number, a registered address, the Google Play package id, a designated child-safety contact and an effective date, and the child-safety page is what Google Play is pointed at. Two hand-maintained copies would eventually disagree, and the day they did, one frontend would be telling members something untrue about deleting their data or about how a child-safety report is handled. A test asserts those specific details are identical to React's. - They sit at
/account-deletionand/child-safety— React's own paths, and not under/legal/, becauseaccount_deletionandchild_safetyare not inLegalController::VALID_TYPES: there is no per-community version to fetch, no version history, and nothing for the/legal/:type/versionsroutes to answer. - Built strictly to the Design System: numbered steps as ordered lists, the zero-tolerance
statement in a warning-text component, the reporting and request routes as start buttons
via
govukButton, and what is deleted versus what may be retained as two separately headed lists rather than one mixed one.
- 🔴 Both pages have ONE source, shared with the React pages, generated by
Fixed
Every legal page on the accessible frontend showed the wrong name.
communityName()inweb-uk/src/routes/legal.jsreadres.locals.tenant, which the shell does not set — it setstenantName,tenantSlugandcommunityName— so the legal hub, terms, privacy, cookies, community guidelines, acceptable use and the accessibility statement all captioned themselves "Project NEXUS Accessible" instead of the community's own name, on a site where eleven communities share the templates. It now preferstenantNameand falls back through the older reads, so nothing that does pass atenantobject regresses.The accessible frontend has a changelog, rendered from
CHANGELOG.mditself./changeloglists every release with its date and/changelog/<version>shows that release's notes. It is a list-then-detail pair rather than one page because the file holds 18 releases and the largest single release is over 2,000 lines; the React page renders the whole ~7,900-line document in the browser, which is a poor trade for the members this frontend exists for. The footer's Changelog link, deliberately withheld while the page did not exist, is now live.- Markdown is rendered at BUILD time, not per request, by
web-uk/scripts/build-changelog.js, andmarkedis a devDependency for that reason: the internet-facing app ships no markdown parser and parses nothing per request. The output is passed through the existingsanitizeCmsHtmlallow-list even though the source is our own file, becausemarkedpasses raw HTML through by default — a test asserts no release contains a script tag, an iframe or anon*handler. - One generated file per release, plus a small index. The first version wrote a single
2.7 MB JSON;
requirethen pulled the entire rendered history into memory at boot to serve a page needing one release, and every new release produced a 2.7 MB diff in a public repository. The index carries metadata only and is loaded at boot; a body is loaded on demand. - The release slug from the URL is matched against the index before it is used to build
a path, so only a generated file can ever be read. Pinned by a test that feeds it
../indexand../../package.
- Markdown is rendered at BUILD time, not per request, by
The accessible frontend's
/featurespage now shows the whole platform catalogue — 8 groups, 119 features — with working search and category filtering. It was a hand-written list of six bullets, which had drifted comprehensively from the React page's 119.- The catalogue is now shared. The group order, item order and each feature's maturity
moved out of
react-frontend/src/pages/public/FeaturesPage.tsxintoreact-frontend/src/data/featuresCatalogue.json, which both frontends read, so the two pages cannot drift again. The React page imports it directly; the accessible frontend keeps a generated copy (npm --prefix web-uk run build:features, verified bycheck:features) because its Docker build context isweb-uk/, so neither the catalogue nor the React locale files are in its image. - Nothing was re-translated. All 119 features and their descriptions already existed in
all eleven languages for the React page, so the generated copy carries that text rather
than re-keying ~2,600 strings into
lang/*.phpand creating a second copy to keep in step. - 🔴 Search and category filtering run on the SERVER, as a plain
GETform. The React page does the same job in JavaScript with a search box and filter chips; doing that here would leave the members this frontend exists for with no filtering at all. Search folds case and accents on both sides of the comparison, an unknown category in the query string is ignored rather than matching nothing, groups with no surviving items are dropped instead of rendering empty headings, and because it is aGETa filtered view is a shareable URL.
- The catalogue is now shared. The group order, item order and each feature's maturity
moved out of
The accessible frontend's footer now carries the community's partner logo, the source repository and the powered-by badge, each under its own heading, in GOV.UK Design System style. The React footer has shown all three for a long time; the accessible frontend showed none of them, so a community's funder or council partner was invisible to exactly the members most likely to have been referred by one. The bottom of the footer is now a deliberate replica of the React footer's four bands — the community's copyright line with cookie settings opposite it, the release status line, the three brand marks, and the licence strip — which also removes the four stacked sentences it replaced, between them saying "Project NEXUS" three times. Details that are deliberate, each for a reason:
- The powered-by badge sits in the second
govuk-footer__meta-item, the slot GOV.UK's own footer uses for its origin mark, and deliberately does not reuse the link class GOV.UK applies there, because that class renders the royal crest through a::beforebackground image and this service is forbidden from displaying it. Pinned byweb-uk/tests/footer-branding.test.js. - The badge uses the light artwork, not a choice between the light/dark pair the admin
panel offers, because
govuk-footeris light and this frontend has no dark mode — there is not oneprefers-color-schemerule in its stylesheet, the GOV.UK Design System being light-only. Both variants remain in use platform-wide: React picks between them by theme and this frontend's black header already uses each community's dark logo. - Every brand mark sits on an explicit white plate. Measured, not assumed:
powered-by-nexus-light.pngis PNG colour type 2 (RGB, no alpha channel) with a white background baked in, against a footer background ofrgb(244, 248, 251)— so without a plate the badge rendered as a stray white rectangle. Community partner logos have the same problem more often than not, arriving as opaque JPEGs. - The source-repository link is not configurable per community and must not become so:
AGPL-3.0 Section 7(b) requires the running application to link to its own source, and
react-frontend/src/config/externalLinks.tskeeps the same URL in a single constant for that reason. The partner logo and the powered-by badge are per-community, reading the samegeneral.*settings the React footer reads, so configuring them once covers both frontends. - The release line states the platform version and links Features and Documentation. It does not link Changelog, because the accessible frontend has no changelog page yet and a footer link to a 404 across eleven live community sites is worse than a missing link. It also makes no maturity claim about the accessible service itself, whose GOV.UK phase banner correctly still reads "Beta".
- The copyright year is literal text in the translations, as the neighbouring
attributionkey has always carried it, and needs bumping once a year in eleven files. A:yearplaceholder was tried and reverted the same day;web-uk/src/lib/template-filters.jsrecords the two ways it broke, the second of which failed 30+ test suites.
- The powered-by badge sits in the second
GET /api/v2/tenant/bootstrapnow returnsconfig.platform_version. The accessible frontend prints the platform version in its footer and had no source for it: its Docker build context isweb-uk/, so the repository'sVERSIONfile is not in its image. The value comes fromconfig('app.version')and nowhere else, becausescripts/check-version-consistency.mjsalready pins that config value to the rootVERSIONfile — so a release bump carries through and no downstream copy can drift. Note thatdata.configis now always present in the response, where previously it was omitted for a community with no footer settings configured; nothing read its absence as a signal, and both frontends already access it optionally. Regression tests:TenantBootstrapControllerTest::test_bootstrap_exposes_the_platform_version_for_frontends_without_the_version_fileand::test_bootstrap_platform_version_tracks_the_repository_version_file.A nightly Sentry sweep now tells a human when errors need attention. Until now the only error monitoring ran for 30 minutes after a deploy, so anything that did not coincide with a deploy was invisible: a Redis name-resolution fault ran from 2026-09-06 to 2026-09-16 (1237 events) and ended only because a routine Docker upgrade restarted the daemon, and a daily safeguarding pager fired 17 times from 2026-08-30 naming three real members who could not be contacted.
scripts/sentry-triage.mjshad been written as "the collection half of the nightly Sentry loop" and had never once run on a schedule. The newSentry Nightlyworkflow runs it every morning at 09:00 UTC, just after the platform's own 08:05 pagers, and alerts over Telegram — the channel the owner already receives — only when the state changes, matchinguptime-check.ymlrather than nagging daily about a known issue. Because the repository is public,scripts/check-sentry-nightly.mjsprints counts only to the workflow log and writes the issue titles to a file that only the Telegram step reads. The run fails when the sweep or the alert cannot be delivered, never merely because issues exist.sentry-triage.mjsalso accepts its credentials from the environment now, since.secrets.local/sentry.envdoes not exist in CI, and no longer runs its sweep when imported. Regression tests:scripts/test/sentry-nightly-gate.test.mjs(including that no title, culprit, short id or permalink can reach the public log) andscripts/test/sentry-triage-config.test.mjs.The public sales order endpoint now accepts an enquiry with no quote attached.
POST /api/v2/sales/ordersrequired a completequoteobject (plan name, capacity label, billing cycle, pricing mode and five price labels), so the sales site could only use it from the quote builder on its hidden/hostingpage. A plain "tell us about your community" enquiry from a public page had nowhere to go, which left the sales site withmailto:links as its only route to the inbox — and those do nothing for a visitor with no mail app configured.quoteis now optional and all-or-nothing: send it complete, or omit it entirely. A half-filled quote is still rejected, because accepting one would put a half-priced estimate in the enquiry email. Quote-less enquiries get their own subject line ("Project NEXUS enquiry" rather than "Project NEXUS order enquiry") and an email body with the pricing rows and line-item table left out instead of rendered as a column of dashes. Existing callers that send a full quote are unaffected. The development-onlyaspnet-backend/mirror of this controller had the same hard requirement and has been changed in step, so the two do not drift apart before that migration finishes. Regression tests:SalesOrderApiTest::test_public_sales_order_accepts_a_general_enquiry_with_no_quote,::test_public_sales_order_still_rejects_a_half_filled_quote,::test_public_sales_order_still_requires_a_contact_name_without_a_quote,::test_public_sales_order_honeypot_silently_accepts_without_sending, andSalesOrderControllerUnitTests.Submit_AcceptsGeneralEnquiryWithNoQuote/.Submit_StillRejectsHalfFilledQuoteon the ASP.NET side.
Changed
The footer's "Powered by Project NEXUS" badge links to project-nexus.net. The marketing site moved from
project-nexus.ietoproject-nexus.net; the badge's destination is not a per-tenant setting but a constant inFooter.tsx, so one change covers every community that has not setgeneral.powered_by_urlof its own. Measured against the live bootstrap API on 2026-09-17: six of the thirteen public communities plus the platform's own master tenant have no powered-by settings at all, andstratfordandpartner-demohave a custom badge image but no custom link — all nine follow the constant. The five communities that point the badge attimebanking.org(awid,crewkerne-timebank,minehead-and-coast-timebank,ryde,timebanking-org) are untouched. No database change and no per-tenant admin work is involved. Regression tests:Footer.test.tsx— "links the default powered-by badge at the marketing site", "prefers the community's own powered-by link over the default".project-nexus.netis an allowed CORS origin. The sales site's enquiry form posts to this API cross-origin, so moving the site to.netwithout allowlisting it would have failed every submission at preflight with no server-side error to find afterwards. Added to all three copies of the list —config/cors.php,App\Core\CorsHelperandApp\Helpers\CorsHelper(the hot path) — because they have drifted apart before. The.iepair stays allowed while the old domain still resolves. Regression tests:tests/Laravel/Unit/Helpers/CorsHelperTest.php::test_getAllowedOrigins_includes_the_sales_site_originsand the matching test intests/Laravel/Unit/Core/CorsHelperTest.php.Gamification milestone emails are paced to one per member per hour. Awards arrive in clusters: on 2026-09-17 one member received four emails in three seconds — a listing confirmation, two badges and a level-up. That is the pattern that earns a "report spam" click, and milestone mail earns no engagement to offset it.
GamificationEmailService::sendMilestoneEmail()now claims a per-member, per-tenant slot withCache::add(atomic, so two awards racing in the same second cannot both win it) before sending, and hands the slot back if the send fails. The award itself is untouched: the in-app notification and push still fire, and the monthly gamification digest still recaps every badge earned, so nothing is lost but the burst. The window isconfig('mail.gamification_milestone_min_interval_seconds'), default 3600; 0 disables it. If the cache is unreachable the email is sent — a cache outage must never silently stop milestone mail platform-wide. This is deliberately the smaller of the two options: folding milestones into thenotification_digestqueue would replace a branded milestone email with a plain digest line and would do nothing for members whose digest frequency isinstant, so it is a product decision rather than a deliverability fix. Regression tests:GamificationEmailServiceTest::test_only_the_first_milestone_email_in_the_window_claims_the_slot,::test_the_slot_is_per_member_and_per_tenant,::test_a_failed_send_hands_the_slot_back,::test_the_limit_can_be_switched_off,::test_the_default_window_is_one_hour.
Fixed
Erasing an account now also deletes the notifications still queued for that person. GDPR erasure deleted the bell notifications (
notifications) but left the queued copies (notification_queue), which carrycontent_snippet— text describing what that member did — andemail_body, a fully rendered email addressed to them. Because the digest and instant runners deliberately skip erased accounts, nothing ever consumed those rows: they sat for up to seven days aspending, were then flipped tofailedby the stale-row expiry, and were only deleted thirty days after that. So a member's own content could survive roughly 37 days past the erasure they asked for. Both erasure paths (GdprServiceandUserService::deleteAccount) now delete the rows outright — nothing there was ever sent, so there is no delivery audit trail to preserve — and the daily cleanup carries a sweep for rows left by accounts erased before this change, using the same predicate the recipient queries use so the two cannot drift apart. The sweep runs in both the scheduled cleanup and the admin-triggered one. Regression tests:MemberSelfServiceTest::test_delete_account_removes_notifications_still_queued_for_the_member,CronDigestAnonymisedRecipientTest::test_cleanup_clears_queue_rows_left_by_an_already_erased_account.Email is no longer sent to addresses that can never receive it. Six of the nine hard bounces on the Postmark transactional stream in the 30 days to 2026-09-17 were to structurally undeliverable addresses: four seeded demo members on
@partner-demo.testand two erased users on@anonymized.local. Neither domain resolves —.testis reserved by RFC 2606 and.localby mDNS — so every one of those sends was a guaranteed hard bounce charged against the sending domain's reputation, and the reputation damage is what puts ordinary member mail in the spam folder.EmailDispatchService::send()now refuses a recipient under.test,.local,.invalid,.exampleor.localhostbefore it resolves a tenant or touches the mailer. It returnsfalse(the existing "not sent" contract, so no caller is told a send succeeded) and logs atinfo, notwarning, because this is expected behaviour rather than an incident. The guard sits insend(), whichsendRaw(),sendWithOptions()andEmailService::send()all funnel into, so one check covers every caller. Seeded demo data is unchanged:@partner-demo.testremains correct for a fixture, it is simply no longer mailed. Regression tests:EmailDispatchServiceTest::test_reserved_suffixes_are_recognised_as_unroutable,::test_ordinary_domains_are_not_treated_as_unroutable,::test_send_to_reserved_domain_returns_false_without_reaching_the_mailer,::test_send_to_anonymised_user_address_is_refused,::test_guard_covers_every_public_entry_point,::test_reserved_suffix_list_is_exactly_the_documented_set.Erased accounts are no longer treated as notification recipients. GDPR erasure keeps the
usersrow and rewritesemailtodeleted_<id>_<hash>@anonymized.local, stampingdeleted_atandanonymized_at(GdprService,UserService::deleteAccount). The notification digest and instant-queue recipient queries joineduserswithout testing either stamp, so erased people were selected as recipients, had their queued items claimed, and produced a hard bounce every run.deleted_at IS NULL AND anonymized_at IS NULLis now part of the digest roll-up query, both instant-queue fetches and the hot-match recipient query — the four recipient lists that had no account-state filter at all. The remaining recipient queries inCronJobRunneralready filter onstatus = 'active', which an erased account never is. The send guard above remains the backstop; this is the real fix, because a blocked send still costs a round trip and still means the code believed that person was a recipient. Regression tests:CronDigestAnonymisedRecipientTest(all three cases, including a live-member control).A sales enquiry no longer goes out looking like a community's own billing mail. The sales site's enquiry form posts to
POST /api/v2/sales/orders, and the resulting email was indistinguishable from ordinary platform notification mail — a real enquiry from a prospective customer was missed because of it. It arrived as"hOUR TimeBank" <billing@project-nexus.net>with audit categorybilling. Two causes. The category:submit()passed'billing', whichMailer::resolveFromPrefix()maps to the billing From-address bucket. There is now a ninth bucket,Mailer::CATEGORY_ENQUIRIES, and a new audit categorysales_enquirymapped to it — tested before the billing branch so themarketplace_/ billing test cannot swallow it. The From name: it was never tenant inference (the route already passestenant_id => nullwithallow_missing_tenant, and that correctly resolves to no tenant); it was the platform-wide default From name, which production sets to a single community's name.send()now accepts an optionalfromNamein its options array, threaded to the newMailer::withFromName(), and the sales route sets it explicitly. No existing caller passes one, so every other email's From address and name are unchanged, and the new bucket stays on the transactional Postmark stream. Regression tests:MailerTest::test_sales_enquiry_category_routes_to_its_own_from_bucket,::test_sales_enquiry_bucket_does_not_disturb_the_existing_billing_bucket,::test_sales_enquiry_stays_on_the_transactional_stream,::test_withFromName_overrides_the_default_sender_name,::test_withFromName_ignores_empty_values_and_strips_header_injection,SalesOrderApiTest::test_public_sales_order_sends_as_a_sales_enquiry_not_tenant_billing_mail.The platform master's public front page (
app.project-nexus.ie) is now actually prerendered for crawlers. The 2026-09-13 planner fix was not enough:PrerenderService::loadTenantTargets()and the render script's ownget_tenants()/ static page list still excluded tenant id 1, so every scheduled path (auto-recache, drift detection, the job processor) never planned master and a queued master job was claimed but never ran. Master is now a target everywhere, served at the app host root and never at its owntenants.domain(the sales site). Regression tests:PrerenderServiceTest::test_tenant_targets_include_the_platform_master_at_the_app_host_root,scripts/test/test-prerender-tenant-plan.sh.A queued prerender job no longer kills a deploy's full render.
prerender-tenants.shtreated every new invocation as a "newer deploy" and cancelled the in-flight run, so the once-a-minuteprerender-job-processor.shterminated each post-deploy render about 50 seconds in — after the master pages had rendered, before they were published. A targeted run (--tenant/--routes) that finds the lock held now exits 75 with alock_takeover_refusedevent instead, and the processor skips its tick while the render lock is held. Regression tests:scripts/test/test-prerender-lock-takeover.sh(scenario 6),scripts/test/test-prerender-processor-render-lock.sh; all three prerender harnesses now run in CI's Migration Safety Gate.The deploy's crawler delivery probe now runs after the post-deploy render has published, instead of seconds after the traffic switch. It measured the previous generation of snapshots and reported the master tenant's front page blank on two consecutive deploys where the render then succeeded minutes later.
scripts/deploy.shnow starts the 30-minute error watch in the background at the switch (its window is timestamped from when it starts), waits for the server's detached render log to record its end via the newscripts/wait-for-prerender-publish.sh(bounded, never fails the deploy, never signals the server), then probes, then collects the watch result. The waiter is a child process anddeploy.shholds the SSH host and key as plain shell variables, so the invocation passes both explicitly — without that it refused to start and the probe fired early anyway. Regression test:scripts/test/test-deploy-probe-after-prerender.sh, run in CI; it pins the ordering, the waiter's exit codes, and the credential handover itself.