diff --git a/compose.yaml b/compose.yaml
index 089de88..6bf9ace 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,4 +1,5 @@
x-app-environment: &app-environment
+ DEPLOYMENT_ENV: ${DEPLOYMENT_ENV:?Set DEPLOYMENT_ENV in .env}
APP_ROLE: web
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env}
DATABASE_SOCKET_DIR: ${DATABASE_SOCKET_DIR:-}
diff --git a/config/config.exs b/config/config.exs
index 6e90caa..4c3c009 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -27,6 +27,7 @@ config :who_need_help, :scopes,
]
config :who_need_help,
+ deployment_env: :development,
ecto_repos: [WhoNeedHelp.Repo],
generators: [timestamp_type: :utc_datetime, binary_id: true],
app_role: :web,
diff --git a/config/runtime.exs b/config/runtime.exs
index 3394d9b..239a4e0 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -1,5 +1,20 @@
import Config
+deployment_env =
+ case System.get_env("DEPLOYMENT_ENV", "development") do
+ "development" ->
+ :development
+
+ "test" ->
+ :test
+
+ "production" ->
+ :production
+
+ other ->
+ raise "DEPLOYMENT_ENV must be development, test, or production; got #{inspect(other)}"
+ end
+
app_role =
case System.get_env("APP_ROLE", "web") do
"web" -> :web
@@ -46,6 +61,7 @@ rate_limit_policies =
end
config :who_need_help,
+ deployment_env: deployment_env,
app_role: app_role,
codex_session_id: System.get_env("CODEX_SESSION_ID", "not-configured"),
map_tile_url:
diff --git a/config/test.exs b/config/test.exs
index 406a121..f068a23 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,6 +1,7 @@
import Config
config :who_need_help, :handover_secret, "isolated-test-handover-secret"
+config :who_need_help, :deployment_env, :test
config :who_need_help, :tracking_presence_cleanup_grace_ms, 100
# Unit tests opt in to individual policies inside the relevant test. This keeps
# unrelated examples independent from shared counters and mirrors the explicit
diff --git a/docs/verification.md b/docs/verification.md
index 1f55ca6..d250066 100644
--- a/docs/verification.md
+++ b/docs/verification.md
@@ -2322,3 +2322,33 @@ promoted.
`cf7bacdf61ffb171ebac85e32dfcf12bc6972d59`; its application, database, and
Mailpit remained healthy, and public readiness remained `ready`. The remote
repository and hackathon-test deployment were not mutated.
+
+# 2026-08-09 local public search-discovery verification
+
+- The public home, Privacy Policy, Safety rules, and Terms of Service pages now
+ expose locale-aware canonical and reciprocal `en`/`uk`/`ru`/`x-default`
+ alternate links. Every other route defaults to `noindex, nofollow`, so
+ authenticated, support, legal, moderation, request, activity, profile, and
+ staff surfaces are not presented as public search results.
+- `/sitemap.xml` contains exactly twelve absolute entries: the four public
+ pages in each of the three supported locales. It contains no account,
+ request, activity, support, legal, moderation, notification, or staff URL.
+- `/robots.txt` is environment-aware. Production permits the public pages,
+ lists the private route families as disallowed, and advertises the absolute
+ sitemap URL. Development and test configurations return `Disallow: /`.
+- Nineteen focused controller tests passed against an isolated temporary
+ PostgreSQL database. The complete isolated quality/security gate then passed
+ 455 ExUnit tests, all fourteen browser map-clustering tests, and every
+ configured compiler, formatting, xref, Credo, Sobelow, Dialyzer,
+ dependency, image, Compose, Helm, migration, rollback, and observability
+ check. The Debian 13.6 runtime-image scan reported zero detected
+ vulnerabilities.
+- The isolated quality unit
+ `codex-heavy-wnh-quality-seo-rerun-20260809-20260809-082004-916736.service`
+ exited with status `0` after 4 minutes 47 seconds and reported a 210.9 MiB
+ memory peak. Its run-scoped images, containers, networks, and volumes were
+ removed. Two older quality database volumes remain referenced by their own
+ stopped containers and were not changed by this verification.
+- This verification changed only the local checkout. The public Git remote,
+ production deployment, and frozen hackathon test deployment were not
+ changed.
diff --git a/lib/who_need_help_web.ex b/lib/who_need_help_web.ex
index b06699b..e9c44b6 100644
--- a/lib/who_need_help_web.ex
+++ b/lib/who_need_help_web.ex
@@ -18,7 +18,7 @@ defmodule WhoNeedHelpWeb do
"""
def static_paths,
- do: ~w(assets fonts images favicon.ico manifest.webmanifest offline.html robots.txt sw.js)
+ do: ~w(assets fonts images favicon.ico manifest.webmanifest offline.html sw.js)
def router do
quote do
diff --git a/lib/who_need_help_web/components/layouts/root.html.heex b/lib/who_need_help_web/components/layouts/root.html.heex
index 4b3ae0d..4041ae9 100644
--- a/lib/who_need_help_web/components/layouts/root.html.heex
+++ b/lib/who_need_help_web/components/layouts/root.html.heex
@@ -16,6 +16,7 @@
+
+ <%= if canonical_url = assigns[:canonical_url] do %>
+
+ <% end %>
+ <%= for {language, url} <- assigns[:language_alternates] || [] do %>
+
+ <% end %>
<.live_title
default="Who Need Help"
suffix={
diff --git a/lib/who_need_help_web/controllers/page_controller.ex b/lib/who_need_help_web/controllers/page_controller.ex
index e0b3ba5..713fe0a 100644
--- a/lib/who_need_help_web/controllers/page_controller.ex
+++ b/lib/who_need_help_web/controllers/page_controller.ex
@@ -1,42 +1,60 @@
defmodule WhoNeedHelpWeb.PageController do
use WhoNeedHelpWeb, :controller
+ alias WhoNeedHelpWeb.SearchMetadata
+
def home(conn, _params) do
- render(conn, :home,
- page_description:
+ assigns =
+ SearchMetadata.public_page_assigns("/")
+ |> Keyword.put(
+ :page_description,
gettext(
"Ask nearby volunteers for free medicine pickup or safe practical help, coordinate privately, and verify the handover."
)
- )
+ )
+
+ render(conn, :home, assigns)
end
def privacy(conn, _params) do
- render(conn, :privacy,
- page_title: gettext("Privacy Policy"),
- page_description:
+ assigns =
+ SearchMetadata.public_page_assigns("/privacy")
+ |> Keyword.put(:page_title, gettext("Privacy Policy"))
+ |> Keyword.put(
+ :page_description,
gettext(
"How Who Need Help processes account, Google sign-in, location, communication, and safety data."
)
- )
+ )
+
+ render(conn, :privacy, assigns)
end
def safety(conn, _params) do
- render(conn, :safety,
- page_title: gettext("Safety rules"),
- page_description:
+ assigns =
+ SearchMetadata.public_page_assigns("/safety")
+ |> Keyword.put(:page_title, gettext("Safety rules"))
+ |> Keyword.put(
+ :page_description,
gettext(
"Safety guidance for voluntary help, medicine pickup, roadside requests, location sharing, and in-person activities."
)
- )
+ )
+
+ render(conn, :safety, assigns)
end
def terms(conn, _params) do
- render(conn, :terms,
- page_title: gettext("Terms of Service"),
- page_description:
+ assigns =
+ SearchMetadata.public_page_assigns("/terms")
+ |> Keyword.put(:page_title, gettext("Terms of Service"))
+ |> Keyword.put(
+ :page_description,
gettext(
"The conditions for using Who Need Help to coordinate voluntary help and social activities."
)
- )
+ )
+
+ render(conn, :terms, assigns)
end
end
diff --git a/lib/who_need_help_web/controllers/search_document_controller.ex b/lib/who_need_help_web/controllers/search_document_controller.ex
new file mode 100644
index 0000000..6d7a4fe
--- /dev/null
+++ b/lib/who_need_help_web/controllers/search_document_controller.ex
@@ -0,0 +1,89 @@
+defmodule WhoNeedHelpWeb.SearchDocumentController do
+ use WhoNeedHelpWeb, :controller
+
+ alias WhoNeedHelpWeb.SearchMetadata
+
+ @public_paths ["/", "/privacy", "/safety", "/terms"]
+ @locales ~w(en uk ru)
+ @private_prefixes ~w(
+ /account/
+ /activities
+ /admin
+ /analytics
+ /auth
+ /categories/proposals
+ /legal
+ /mobile
+ /moderation
+ /notifications
+ /people
+ /profile
+ /reports
+ /requests
+ /support
+ /users
+ )
+
+ def robots(conn, _params) do
+ body =
+ case Application.fetch_env!(:who_need_help, :deployment_env) do
+ :production -> production_robots()
+ _non_production -> "User-agent: *\nDisallow: /\n"
+ end
+
+ conn
+ |> put_resp_content_type("text/plain", "utf-8")
+ |> put_resp_header("cache-control", "public, max-age=3600")
+ |> send_resp(:ok, body)
+ end
+
+ def sitemap(conn, _params) do
+ entries =
+ for path <- @public_paths,
+ locale <- @locales do
+ sitemap_entry(path, locale)
+ end
+
+ body =
+ "\n" <>
+ "