From ed4d85dcab008416e9428d284ff116cd6ff0b1f3 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Mon, 10 Aug 2026 16:45:10 +0300 Subject: [PATCH] Harden launch verification and update LiveView --- android/play-store/release-checklist.md | 18 +- android/play-store/store-presence-runbook.md | 8 + docs/public-launch-checklist.md | 18 +- docs/verification.md | 69 +++++- mix.lock | 2 +- priv/gettext/default.pot | 34 +-- scripts/production-web-push-smoke.exs | 217 +++++++++++++++++++ scripts/production-web-push-smoke.sh | 207 ++++++++++++++++++ 8 files changed, 548 insertions(+), 25 deletions(-) create mode 100644 scripts/production-web-push-smoke.exs create mode 100755 scripts/production-web-push-smoke.sh diff --git a/android/play-store/release-checklist.md b/android/play-store/release-checklist.md index eeee3b9..7dbf967 100644 --- a/android/play-store/release-checklist.md +++ b/android/play-store/release-checklist.md @@ -83,6 +83,10 @@ - [x] Four current 1080×1920 physical-phone screenshots captured and reviewed. - [x] English, Ukrainian, and Russian listing copy prepared. - [x] Alt-text copy (≤140 characters) prepared in `store-assets/README.md`. +- [x] Revalidate the exact prepared listing text and visual assets immediately + before Console entry. On 2026-08-10 the repository validator passed all + three localized text limits, the 512×512 icon, the 1024×500 RGB feature + graphic, and all four 1080×1920 RGB phone screenshots. - [ ] Enter the prepared alt text when the assets are uploaded in Play Console. - [ ] Choose category/tags in the current Console options. - [ ] Complete **Select an app category and provide contact details** in Play @@ -96,7 +100,10 @@ contact, verify that contact can access the urgent queue, and complete the Play child-safety self-certification only from observed operational evidence. Adult-only positioning does not remove this requirement for an - app declared as Social. + app declared as Social. A fresh public check on 2026-08-10 returned + `404` from `https://whoneedhelp.com/child-safety`; do not save the Social + category or self-certify until the later local route is released and + verified on production. ## App content @@ -132,6 +139,11 @@ its exact path and checksum are recorded in `location-and-fgs-declaration.md`. Hosting it as an unlisted video and saving the resulting URL in Play Console remain incomplete. + Immediately before upload on 2026-08-10 the exact final file was + revalidated as H.264, 720×1600, 21.379802 seconds, SHA-256 + `3c5f52019bf8a42ff42e1d9232afc126b62627390d74eed75084d82ecb33ac56`; + a fresh contact-sheet review still showed the disclosure, Android prompt, + minimized persistent notification with Stop, and returned stopped state. - [ ] Reconcile any target-SDK-37 persistent precise-location declaration shown by Play Console with the exact artifact. The app uses a user-started location foreground service and does not declare @@ -163,6 +175,10 @@ then deleted and both production and frozen-test readiness remained healthy. This verifies observed app behavior; it does not complete the separate Play Console policy declarations or the final video above. + The strict verifier passed again on 2026-08-10 after the physical phone + was reconnected: Google Play installer, version `0.1.2 (3)`, Play signing + identity, verified production App Link, and `MainActivity` resolution all + matched the protected production identity record. - [ ] Closed track created and opt-in link tested. - [ ] At least 12 testers continuously opted in for 14 days. - [ ] Tester feedback and fixes documented. diff --git a/android/play-store/store-presence-runbook.md b/android/play-store/store-presence-runbook.md index b761921..2be5852 100644 --- a/android/play-store/store-presence-runbook.md +++ b/android/play-store/store-presence-runbook.md @@ -10,6 +10,9 @@ does not authorize saving fields in Play Console or publishing a release. - Tags are not selected. - Store-listing contact email, phone, and website are empty. - No default store listing has been created. +- A second read-only inspection found the default English listing still empty + except for an existing unsaved app-name value `Who Need Help`. The unsaved + value was preserved; no field was entered, discarded, saved, or published. - The Dashboard showed 9 of 11 setup tasks complete. The remaining setup tasks were category/contact details and the store listing. @@ -31,6 +34,11 @@ Run `./scripts/android-store-assets-validate.sh` immediately before upload. The validator checks image dimensions/formats and listing-length constraints; it does not prove Play policy approval or visual quality. +The validator passed again on 2026-08-10, and a fresh combined visual review +confirmed that the approved icon, feature graphic, and four phone screenshots +contain no visible email, private message, handover code, or real precise +location. + ## Contact fields - Public support email candidate: `contact@whoneedhelp.com` diff --git a/docs/public-launch-checklist.md b/docs/public-launch-checklist.md index 4a36a80..59e632b 100644 --- a/docs/public-launch-checklist.md +++ b/docs/public-launch-checklist.md @@ -37,6 +37,12 @@ and full structured results are recorded in `docs/verification.md`. This is local isolated evidence only and does not mark the production support/legal or SMTP checklist items complete. +The latest complete local rerun on 2026-08-10 passed 459 ExUnit tests, +fourteen browser-asset tests, every configured quality/security gate, and the +final image scans after updating Phoenix LiveView from advisory-affected +`1.2.8` to patched `1.2.9`. Exact unit, timing, memory, cleanup, and dependency +evidence are recorded in `docs/verification.md`. + ## 2. Verify production configuration without exposing secrets Run both checks against the single ignored production `.env`. The first reports @@ -140,9 +146,15 @@ as forward-only rather than receiving an invented database rollback. `assetlinks.json` return the expected production identity. - [ ] Registration, returning-user login, and settings linking complete against the production Google OAuth client and exact callback origin. -- [ ] A production-generated authentication email reaches an external mailbox; - sender identity and every URL use the production domain. -- [ ] Browser Web Push reaches a real subscribed browser. +- [x] A production-generated authentication email reaches an external mailbox + and is DKIM-signed by the production domain. +- [ ] Authentication-email action URLs use the production domain directly. + Brevo currently rewrites the action href through its tracking domain even + though the visible fallback origin is `https://whoneedhelp.com/`. +- [x] The Web Push provider accepts a production notification for a real active + browser subscription without disabling the device. +- [ ] A person has observed the resulting operating-system browser notification + and its navigation target on the subscribed workstation. - [x] The production Android build signs in, opens verified App Links, receives FCM, and performs user-started foreground location sharing on a physical device. diff --git a/docs/verification.md b/docs/verification.md index 535bcbd..8a8bb9a 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -3,6 +3,39 @@ Observed through 2026-08-10 in the local workspace. This report separates observed results from product limits and unknown production properties. +## Current local quality and dependency-security proof on 2026-08-10 + +- The complete isolated `scripts/quality.sh` pipeline passed in user-systemd + unit + `codex-heavy-wnh-quality-final-20260810-20260810-163515-639199.service`. + It completed successfully in 6 minutes 46.460 seconds with a measured + 230.7 MiB memory peak. ExUnit reported 459 passing tests with seed `280346`, + and the browser asset suite reported fourteen passes. +- The run passed the configured repository policy, ShellCheck, Hadolint, + actionlint, Compose, Helm, migration/rollback, observability, formatting, + compiler, xref, Credo, Sobelow, Dialyzer, Hex audit, npm audit, and container + image gates. The final Debian 13.6 application image and the pinned + infrastructure images reported zero detected vulnerabilities. +- The initially locked Phoenix LiveView `1.2.8` was affected by + `GHSA-36m4-rm57-3prf` / `CVE-2026-64941`. The lock now selects the patched + `1.2.9` release; the subsequent Hex audit reported no retired or advisory + packages. +- The generated Gettext template was refreshed with the repository's official + extractor. Its changes are source-line references only; no message identifiers + were added or removed. +- Exact-name inspection after completion found no image, container, network, + or volume belonging to scope `20260810133515-639775` / + `wnh_quality_20260810133515639775`. +- This was local verification only. It did not deploy production, modify the + frozen hackathon-test checkout, change shared Caddy, save Google Play Console + fields, install an Android package, or push the public Git remote. +- After the physical phone was reconnected, the strict installed-build verifier + again observed `org.whoneedhelp.mobile` version `0.1.2 (3)`, installer + `com.android.vending`, a supplied Play App Signing identity, a verified + `whoneedhelp.com` App Link resolving to `MainActivity`, and no Android claim + on the browser-only Google OAuth callback. The verifier did not reinstall the + package, clear its data, or change device permissions. + ## External-monitor SMTP isolation proof on 2026-08-09 - The local change set based on revision `360c7a5` adds an optional, @@ -1034,8 +1067,8 @@ this audit. | Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. The Play-delivered production build repeated the disclosure, foreground permission, minimized-app sampling, notification Stop, raw-position deletion, offline recovery, and process-recreation paths on a physical phone. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. | | Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. | | Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. | -| Account registration and sign-in | Implemented and browser/physical-device verified | Email registration is a single passwordless flow: it records the display name and acceptance once, sends a confirmation link, and does not duplicate a user on subsequent sign-in. Confirmed users can keep using magic links or add a password in settings. Google OpenID Connect registration, sign-in, link, unlink, replay prevention, verified-email enforcement, and account-ownership rules are covered by the automated suite. Real headed Chrome exercised the development callback and identity-linking paths. The Play-delivered production build then completed production Google sign-in without a secondary ownership email or duplicate account. | A fresh production authentication-email delivery to an external mailbox remains a separate launch check. | -| Notifications and nearby alerts | Implemented and browser/physical-device verified | Users can configure push/email preferences, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban delivery jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. Development Web Push and FCM delivery were exercised. The Play-delivered production build registered its FCM device, received one run-scoped production notification, and routed its tap to the in-app inbox; exact cleanup removed that notification and its jobs. | Production browser Web Push remains unverified. | +| Account registration and sign-in | Implemented and browser/physical-device verified | Email registration is a single passwordless flow: it records the display name and acceptance once, sends a confirmation link, and does not duplicate a user on subsequent sign-in. Confirmed users can keep using magic links or add a password in settings. Google OpenID Connect registration, sign-in, link, unlink, replay prevention, verified-email enforcement, and account-ownership rules are covered by the automated suite. Real headed Chrome exercised the development callback and identity-linking paths. The Play-delivered production build then completed production Google sign-in without a secondary ownership email or duplicate account. A production authentication email was also observed in the external Gmail mailbox with `whoneedhelp.com` DKIM signing. | Brevo rewrites the action href through its tracking domain; direct production-domain action URLs remain an open deliverability/privacy check. | +| Notifications and nearby alerts | Implemented and browser/physical-device verified | Users can configure push/email preferences, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban delivery jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. Development Web Push and FCM delivery were exercised. The Play-delivered production build registered its FCM device, received one run-scoped production notification, and routed its tap to the in-app inbox; exact cleanup removed that notification and its jobs. A guarded production smoke then delivered one browser-only job to the newest real active Web Push subscription on its first attempt and removed the exact job and notification. | Provider acceptance and the still-active subscription are verified; visible operating-system presentation and click navigation were not programmatically observed. | | Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. | | Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms use separate audited workflows; public support remains pending and outside the staff queue until its private email link verifies the contact, while authenticated submissions use the account email immediately. Exact pending repeats are deduplicated, email/IP intake limits are independently configurable, and moderator-only operations can update verified cases. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, measured rate-limit thresholds, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. | | Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. | @@ -1043,7 +1076,7 @@ this audit. | Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. | | Local observability | Implemented and protocol-verified | Pinned Prometheus scraped the exact 3 web and 2 worker targets with a file Bearer credential; Grafana provisioned a healthy datasource and ten-panel web/worker/BEAM/Ecto/Oban dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. | | Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. The one-run MinIO project and volume were removed after retaining the non-secret evidence. | The drill proves the local mechanism, not off-site durability, database HA, or a production RPO/RTO/retention policy. | -| External protocol boundaries | Implemented and provider-verified for the current pilot paths | Assent/Req and Swoosh/gen_smtp paths have internal success/rejection/retry/replay/timeout coverage. Google OIDC discovery/authorization/token/JWKS with nonce and PKCE and the optional GitHub boundary are locally exercised through mocks. Real development checks covered Google, authenticated Brevo SMTP, Web Push, and FCM. The Play-delivered build additionally completed production Google OIDC and production FCM delivery. The current push code includes provider-neutral HTTP delivery plus standards-based Web Push and FCM adapters with durable Oban retries and invalid-device cleanup. UniSender observations below are retained only as historical evidence and do not describe current wiring. | The real GitHub provider, fresh production authentication-email delivery, production browser Web Push, and APNs remain unverified. SMTP exactly-once delivery is not claimed. | +| External protocol boundaries | Implemented and provider-verified for the current pilot paths | Assent/Req and Swoosh/gen_smtp paths have internal success/rejection/retry/replay/timeout coverage. Google OIDC discovery/authorization/token/JWKS with nonce and PKCE and the optional GitHub boundary are locally exercised through mocks. Real development checks covered Google, authenticated Brevo SMTP, Web Push, and FCM. Production checks covered Google OIDC, Brevo authentication-email receipt, browser Web Push provider acceptance, and FCM delivery. The current push code includes provider-neutral HTTP delivery plus standards-based Web Push and FCM adapters with durable Oban retries and invalid-device cleanup. UniSender observations below are retained only as historical evidence and do not describe current wiring. | The real GitHub provider, direct production-domain authentication action URL, visible browser OS-notification interaction, and APNs remain unverified. SMTP exactly-once delivery is not claimed. | ## Reproducible checks @@ -2782,3 +2815,33 @@ promoted. reporting action without submitting the form and observed the expected `child_sexual_abuse_material` selection, no horizontal overflow, and zero console errors or warnings. + +# 2026-08-10 production authentication-email and browser Web Push checks + +- A production passwordless sign-in request generated an authentication email + that arrived in the external operator mailbox at 10:48 EEST. + Gmail reported `Who Need Help ` as the sender, + `whoneedhelp.com` as the signing domain, Brevo infrastructure as the mailing + domain, and TLS transport. The message states a 15-minute lifetime and shows + `https://whoneedhelp.com/` as its fallback origin. The action href itself is + currently rewritten through a Brevo tracking domain, so the separate + direct-production-domain URL gate remains open. The inspected message was + restored to unread state after verification. +- A guarded production Web Push smoke targeted the newest active browser-only + Web Push device for the confirmed production account. It created one scoped + notification and one `DeviceDeliveryWorker` job, did not enqueue email or + target Android FCM, and verified the exact job as `completed` on attempt 1 + while the device remained active. Exact cleanup removed one job and one + notification; the local state file and remote temporary files were absent + afterward. Provider acceptance to a real subscription is therefore verified; + operating-system notification presentation and click navigation were not + directly observed. +- The smoke wrapper now preserves its mode-`0600` state file plus the remote + manifest and script whenever exact record cleanup does not finish. Temporary + files are removed only after the cleanup action has deleted and rechecked the + run-scoped records. This avoids losing recovery identifiers on an interrupted + or failed cleanup. +- A read-only ADB recheck observed the connected Xiaomi `23122PCD1G` and the + Play-installed `org.whoneedhelp.mobile` package at `0.1.2 (3)`, target SDK 37, + with installer `com.android.vending`. No package reinstall, data clear, or + permission mutation was performed. diff --git a/mix.lock b/mix.lock index 32578b5..7a8eceb 100644 --- a/mix.lock +++ b/mix.lock @@ -41,7 +41,7 @@ "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.8", "5006fd7b429c42489600fbc1600c750d0f0e5b5ea4965d9758e429b979392991", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b05ffe21f43c0ff219da62948b482c324aa5b8873e17b0c0cac58289a178af38"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.9", "d35ddac2fab4480e6bdbf712ff104fd9e969a2bbe81b578ee80f066b371f56db", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f9528c3d7046edabbb30a91710ca33988f8d8bc20a964a1fc48b32134572afa"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 8578f9f..47065e6 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3122,7 +3122,7 @@ msgid "Removal notice updated." msgstr "" #: lib/who_need_help_web/components/layouts.ex:201 -#: lib/who_need_help_web/controllers/content_removal_controller.ex:137 +#: lib/who_need_help_web/controllers/content_removal_controller.ex:136 #: lib/who_need_help_web/controllers/support_html/new.html.heex:16 #, elixir-autogen, elixir-format msgid "Report content" @@ -3256,7 +3256,7 @@ msgstr "" msgid "TAKE IT DOWN" msgstr "" -#: lib/who_need_help_web/controllers/content_removal_controller.ex:136 +#: lib/who_need_help_web/controllers/content_removal_controller.ex:135 #: lib/who_need_help_web/controllers/support_html/new.html.heex:19 #, elixir-autogen, elixir-format msgid "TAKE IT DOWN request" @@ -3284,7 +3284,7 @@ msgstr "" msgid "Threat to life or safety" msgstr "" -#: lib/who_need_help_web/controllers/content_removal_controller.ex:104 +#: lib/who_need_help_web/controllers/content_removal_controller.ex:103 #, elixir-autogen, elixir-format msgid "Too many removal notices. Please try again later." msgstr "" @@ -5274,19 +5274,19 @@ msgstr "" msgid "Case status" msgstr "" -#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:39 +#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:42 #: lib/who_need_help_web/controllers/support_controller.ex:227 #: lib/who_need_help_web/live/report_live.ex:50 #, elixir-autogen, elixir-format msgid "Contact support" msgstr "" -#: lib/who_need_help_web/controllers/content_removal_controller.ex:57 +#: lib/who_need_help_web/controllers/content_removal_controller.ex:56 #, elixir-autogen, elixir-format msgid "Content-removal notice %{reference}" msgstr "" -#: lib/who_need_help_web/controllers/content_removal_controller.ex:112 +#: lib/who_need_help_web/controllers/content_removal_controller.ex:111 #, elixir-autogen, elixir-format msgid "Content-removal notice received" msgstr "" @@ -7720,7 +7720,7 @@ msgstr "" msgid "You do not have permission for this action. Role changes also require a recent sign-in." msgstr "" -#: lib/who_need_help_web/controllers/content_removal_controller.ex:71 +#: lib/who_need_help_web/controllers/content_removal_controller.ex:70 #, elixir-autogen, elixir-format msgid "Your email was confirmed and the notice was sent for review." msgstr "" @@ -7776,22 +7776,22 @@ msgstr "" msgid "Child safety standards" msgstr "" -#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:67 +#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:70 #, elixir-autogen, elixir-format msgid "Child-safety contact" msgstr "" -#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:54 +#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:57 #, elixir-autogen, elixir-format msgid "Child-safety reports enter an urgent restricted review queue. Who Need Help may restrict access, remove or disable content, suspend accounts, preserve the minimum information needed for investigation, and cooperate with lawful requests. After obtaining actual knowledge of confirmed CSAM, the operator reports it to the National Center for Missing & Exploited Children or the relevant regional authority when required by applicable law." msgstr "" -#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:43 +#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:46 #, elixir-autogen, elixir-format msgid "Do not upload, reproduce, download, forward, or email suspected CSAM. Provide only the minimum information and exact in-service URL needed to locate it. If a child is in immediate danger, contact local emergency services or law enforcement first." msgstr "" -#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:51 +#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:54 #, elixir-autogen, elixir-format msgid "How Who Need Help responds" msgstr "" @@ -7801,7 +7801,7 @@ msgstr "" msgid "How to report" msgstr "" -#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:74 +#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:77 #, elixir-autogen, elixir-format msgid "Open the support form" msgstr "" @@ -7811,22 +7811,22 @@ msgstr "" msgid "Prohibited conduct and content" msgstr "" -#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:36 +#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:39 #, elixir-autogen, elixir-format msgid "Report child-safety content" msgstr "" -#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:59 +#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:62 #, elixir-autogen, elixir-format msgid "Reports are reviewed by authorised staff. Automated signals may prioritize a case but are not treated as proof by themselves. A report does not replace contacting emergency services when someone is in immediate danger." msgstr "" -#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:81 +#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:84 #, elixir-autogen, elixir-format msgid "Scope and updates" msgstr "" -#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:84 +#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:87 #, elixir-autogen, elixir-format msgid "These standards supplement the Terms of Service and Safety Rules. They apply even though Who Need Help is limited to adults and currently does not provide user media uploads. The standards will be updated if product capabilities, reporting duties, or applicable law change." msgstr "" @@ -7856,7 +7856,7 @@ msgstr "" msgid "ZERO TOLERANCE FOR CHILD SEXUAL ABUSE AND EXPLOITATION" msgstr "" -#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:70 +#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:73 #, elixir-autogen, elixir-format msgid "Child-safety reports are routed through the protected support and content-removal queues. Google Play, authorities, and users can contact the operator through the support form." msgstr "" diff --git a/scripts/production-web-push-smoke.exs b/scripts/production-web-push-smoke.exs new file mode 100644 index 0000000..fc6ec6d --- /dev/null +++ b/scripts/production-web-push-smoke.exs @@ -0,0 +1,217 @@ +defmodule WhoNeedHelp.ProductionWebPushSmoke do + import Ecto.Query + + alias Oban.Job + alias WhoNeedHelp.Accounts.User + alias WhoNeedHelp.Notifications.{Notification, PushDevice} + alias WhoNeedHelp.Push.DeviceDeliveryWorker + alias WhoNeedHelp.Repo + + @allowed_actions ~w(prepare verify cleanup) + + def run(action, options) when action in @allowed_actions and is_map(options) do + context = verified_context(options) + + case action do + "prepare" -> prepare(context) + "verify" -> verify(context) + "cleanup" -> cleanup(context) + end + end + + def run(_action, _options), do: raise("unsupported Web Push smoke action") + + defp verified_context(options) do + run_id = required_option!(options, :run_id) + expected_database = required_option!(options, :expected_database) + user_email = required_option!(options, :user_email) |> String.downcase() + manifest_path = required_option!(options, :manifest_path) + + unless Regex.match?(~r/^[a-z0-9-]+$/, run_id), do: raise("invalid run id") + + unless String.starts_with?(manifest_path, "/tmp/wnh-production-web-push-") do + raise "invalid manifest path" + end + + %Postgrex.Result{rows: [[actual_database]]} = + Repo.query!("SELECT current_database()", [], log: false) + + unless actual_database == expected_database, do: raise("database identity mismatch") + + %{ + run_id: run_id, + database: actual_database, + user_email: user_email, + manifest_path: manifest_path, + idempotency_key: "production-web-push-smoke:#{run_id}" + } + end + + defp prepare(context) do + if File.exists?(context.manifest_path), do: raise("manifest already exists") + + user = Repo.get_by(User, email: context.user_email) + + unless match?(%User{confirmed_at: %DateTime{}, moderation_status: :active}, user) do + raise "target user is missing, unconfirmed, or inactive" + end + + device = + PushDevice + |> where( + [device], + device.user_id == ^user.id and device.platform == :web and + device.provider == :web_push and is_nil(device.disabled_at) + ) + |> order_by([device], desc: device.last_seen_at, desc: device.inserted_at) + |> limit(1) + |> Repo.one() + + unless match?(%PushDevice{}, device), do: raise("no active Web Push device exists") + + if Repo.exists?( + from(notification in Notification, + where: notification.idempotency_key == ^context.idempotency_key + ) + ) do + raise "run-scoped notification already exists" + end + + {:ok, fixture} = + Repo.transaction(fn -> + notification = + %Notification{} + |> Notification.changeset(%{ + user_id: user.id, + kind: :support_update, + title: "Who Need Help notification check", + body: "Production browser notifications are working.", + path: "/notifications", + data: %{"run_id" => context.run_id, "synthetic" => true}, + idempotency_key: context.idempotency_key + }) + |> Repo.insert!() + + job = + %{"notification_id" => notification.id, "device_id" => device.id} + |> DeviceDeliveryWorker.new() + |> Oban.insert!() + + %{notification: notification, device: device, job: job} + end) + + manifest = %{ + "schema_version" => 1, + "run_id" => context.run_id, + "database" => context.database, + "user_email" => context.user_email, + "idempotency_key" => context.idempotency_key, + "notification_id" => fixture.notification.id, + "device_id" => fixture.device.id, + "job_id" => fixture.job.id + } + + File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true)) + File.chmod!(context.manifest_path, 0o600) + + IO.puts("web_push_smoke_prepared=true") + IO.puts("job_id=#{fixture.job.id}") + IO.puts("delivery_scope=latest active Web Push device only") + IO.puts("email_enqueued=false") + end + + defp verify(context) do + fixture = load_and_validate_manifest!(context) + notification = Repo.get(Notification, fixture["notification_id"]) + device = Repo.get(PushDevice, fixture["device_id"]) + job = Repo.get(Job, fixture["job_id"]) + + unless match?(%Notification{}, notification) and + notification.user_id == device.user_id and + notification.idempotency_key == context.idempotency_key and + match?(%PushDevice{platform: :web, provider: :web_push, disabled_at: nil}, device) and + match?(%Job{state: "completed", attempt: 1}, job) and + job.args["notification_id"] == notification.id and + job.args["device_id"] == device.id do + raise "Web Push smoke has not completed successfully on the exact target" + end + + IO.puts("web_push_provider_delivery_verified=true") + IO.puts("job_state=#{job.state}") + IO.puts("job_attempt=#{job.attempt}") + IO.puts("device_still_active=true") + end + + defp cleanup(context) do + fixture = load_and_validate_manifest!(context) + + job = Repo.get(Job, fixture["job_id"]) + notification = Repo.get(Notification, fixture["notification_id"]) + + unless match?(%Job{}, job) and match?(%Notification{}, notification) and + notification.idempotency_key == context.idempotency_key and + job.args["notification_id"] == notification.id and + job.args["device_id"] == fixture["device_id"] do + raise "run-scoped records no longer match the manifest" + end + + {:ok, deleted} = + Repo.transaction(fn -> + {jobs, _} = Repo.delete_all(from(candidate in Job, where: candidate.id == ^job.id)) + + {notifications, _} = + Repo.delete_all( + from(candidate in Notification, + where: + candidate.id == ^notification.id and + candidate.idempotency_key == ^context.idempotency_key + ) + ) + + %{jobs: jobs, notifications: notifications} + end) + + unless deleted == %{jobs: 1, notifications: 1} do + raise "exact Web Push smoke cleanup failed" + end + + File.rm!(context.manifest_path) + + if Repo.exists?( + from(candidate in Notification, + where: candidate.idempotency_key == ^context.idempotency_key + ) + ) do + raise "run-scoped notification remains after cleanup" + end + + IO.puts("web_push_smoke_cleanup_verified=true") + IO.puts("deleted_jobs=1") + IO.puts("deleted_notifications=1") + end + + defp load_and_validate_manifest!(context) do + manifest = context.manifest_path |> File.read!() |> Jason.decode!() + + valid? = + manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and + manifest["database"] == context.database and + manifest["user_email"] == context.user_email and + manifest["idempotency_key"] == context.idempotency_key and + uuid?(manifest["notification_id"]) and uuid?(manifest["device_id"]) and + is_integer(manifest["job_id"]) + + unless valid?, do: raise("manifest does not match this exact run") + manifest + end + + defp required_option!(options, name) do + case Map.get(options, name) do + value when is_binary(value) and value != "" -> value + _missing -> raise("#{name} is required") + end + end + + defp uuid?(value) when is_binary(value), do: match?({:ok, _}, Ecto.UUID.cast(value)) + defp uuid?(_value), do: false +end \ No newline at end of file diff --git a/scripts/production-web-push-smoke.sh b/scripts/production-web-push-smoke.sh new file mode 100755 index 0000000..5677475 --- /dev/null +++ b/scripts/production-web-push-smoke.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +SSH_TARGET=${PRODUCTION_SSH_TARGET:-whoneedhelp} +REMOTE_ROOT=/srv/who_need_help-production +REMOTE_ENV=$REMOTE_ROOT/.env +EXPECTED_PROJECT=who_need_help_production +EXPECTED_ORIGIN=https://whoneedhelp.com +STATE_FILE="$ROOT/output/runtime/production-web-push-smoke.env" +LOCAL_SCRIPT="$ROOT/scripts/production-web-push-smoke.exs" + +usage() { + cat >&2 <<'EOF' +Usage: + ./scripts/production-web-push-smoke.sh plan USER_EMAIL --check-only whoneedhelp.com + ./scripts/production-web-push-smoke.sh run USER_EMAIL --confirm whoneedhelp.com + +run sends one browser-only production Web Push notification to the newest active +Web Push device for USER_EMAIL, verifies the exact Oban delivery completed on its +first attempt, then removes the run-scoped notification, job, and temporary files. +It never invokes the notification email worker or the Android FCM device. +EOF + exit 1 +} + +read_remote_env() { + local key=$1 + + ssh -o BatchMode=yes "$SSH_TARGET" \ + "awk -F= -v key='$key' '\$1 == key {value = substr(\$0, index(\$0, \"=\") + 1); sub(/\\r\$/, \"\", value); if ((value ~ /^\".*\"\$/) || (value ~ /^\\047.*\\047\$/)) value = substr(value, 2, length(value) - 2); count++} END {if (count == 1) print value; else exit 1}' '$REMOTE_ENV'" +} + +encode() { + printf %s "$1" | base64 | tr -d '\n' +} + +if [[ $# -ne 4 ]]; then + usage +fi + +ACTION=$1 +USER_EMAIL=${2,,} +CONFIRMATION=$3 +HOST=$4 + +case "$ACTION" in + plan) [[ "$CONFIRMATION" == --check-only ]] || usage ;; + run) [[ "$CONFIRMATION" == --confirm ]] || usage ;; + *) usage ;; +esac + +[[ "$HOST" == whoneedhelp.com ]] || usage + +if [[ ! "$USER_EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]]; then + echo "USER_EMAIL is invalid." >&2 + exit 1 +fi + +if [[ ! -f "$LOCAL_SCRIPT" ]]; then + echo "Local smoke script is missing: $LOCAL_SCRIPT" >&2 + exit 1 +fi + +DEPLOYMENT_ENV=$(read_remote_env DEPLOYMENT_ENV) +DEPLOYMENT_TARGET=$(read_remote_env DEPLOYMENT_TARGET) +PROJECT=$(read_remote_env COMPOSE_PROJECT_NAME) +ORIGIN=$(read_remote_env WNH_BASE_URL) +EXPECTED_DATABASE=$(read_remote_env POSTGRES_DB) +EXPECTED_IMAGE=$(read_remote_env APP_IMAGE) + +if [[ "$DEPLOYMENT_ENV" != production || "$DEPLOYMENT_TARGET" != compose || + "$PROJECT" != "$EXPECTED_PROJECT" || "$ORIGIN" != "$EXPECTED_ORIGIN" || + -z "$EXPECTED_DATABASE" ]]; then + echo "Remote deployment identity does not match the production target." >&2 + exit 1 +fi + +CONTAINER=$(ssh -o BatchMode=yes "$SSH_TARGET" \ + "cd '$REMOTE_ROOT' && ./scripts/compose.sh '$REMOTE_ENV' ps -q app | head -n 1") + +if [[ -z "$CONTAINER" ]]; then + echo "No running production app container was found." >&2 + exit 1 +fi + +read -r OBSERVED_IMAGE CONTAINER_STATE CONTAINER_HEALTH < <( + ssh -o BatchMode=yes "$SSH_TARGET" \ + "docker inspect --format '{{.Config.Image}} {{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' '$CONTAINER'" +) + +if [[ "$OBSERVED_IMAGE" != "$EXPECTED_IMAGE" || "$CONTAINER_STATE" != running || + "$CONTAINER_HEALTH" != healthy ]]; then + echo "Production container identity or health does not match the environment." >&2 + exit 1 +fi + +ACTUAL_DATABASE=$(ssh -o BatchMode=yes "$SSH_TARGET" \ + "docker exec '$CONTAINER' /app/bin/who_need_help rpc '%Postgrex.Result{rows: [[database]]} = WhoNeedHelp.Repo.query!(\"SELECT current_database()\", [], log: false); IO.puts(database)'" | tail -n 1) + +if [[ "$ACTUAL_DATABASE" != "$EXPECTED_DATABASE" ]]; then + echo "Production database identity mismatch." >&2 + exit 1 +fi + +if [[ "$ACTION" == plan ]]; then + printf 'scope=one production notification and one browser-only Web Push delivery job\n' + printf 'target=%s\ndatabase=%s\nimage=%s\ncontainer=%s\n' \ + "$USER_EMAIL" "$ACTUAL_DATABASE" "$OBSERVED_IMAGE" "$CONTAINER" + printf 'excluded=email delivery, Android FCM, frozen test project, Caddy, public Git\n' + printf 'cleanup=exact run-scoped notification, job, manifest, and temporary script\n' + exit 0 +fi + +if [[ -e "$STATE_FILE" ]]; then + echo "A prior Web Push smoke state exists; inspect it before starting another run." >&2 + exit 1 +fi + +mkdir -p "$ROOT/output/runtime" +chmod 700 "$ROOT/output/runtime" +umask 077 + +RUN_ID="$(date -u +%Y%m%d%H%M%S)-$(tr -d - "$STATE_FILE" </dev/null 2>&1 || true + rm -f "$STATE_FILE" +} + +preserve_failed_run() { + local status=$1 + + trap - EXIT INT TERM + + if [[ "$cleanup_complete" == true ]]; then + remove_temporary_files + else + printf '%s\n' \ + "Web Push smoke did not complete exact record cleanup." \ + "Run-scoped state was preserved for inspection:" \ + " local state: $STATE_FILE" \ + " remote manifest: $REMOTE_MANIFEST" \ + " remote script: $REMOTE_SCRIPT" >&2 + fi + + exit "$status" +} + +trap 'preserve_failed_run $?' EXIT +trap 'preserve_failed_run 130' INT +trap 'preserve_failed_run 143' TERM + +ssh -o BatchMode=yes "$SSH_TARGET" \ + "docker exec -i '$CONTAINER' sh -c 'umask 077; cat >\"$REMOTE_SCRIPT\"'" <"$LOCAL_SCRIPT" + +RUN=$(encode "$RUN_ID") +DATABASE=$(encode "$EXPECTED_DATABASE") +EMAIL=$(encode "$USER_EMAIL") +MANIFEST=$(encode "$REMOTE_MANIFEST") + +rpc_action() { + local action=$1 + local expression + expression="Code.require_file(\"$REMOTE_SCRIPT\"); WhoNeedHelp.ProductionWebPushSmoke.run(\"$action\", %{run_id: Base.decode64!(\"$RUN\"), expected_database: Base.decode64!(\"$DATABASE\"), user_email: Base.decode64!(\"$EMAIL\"), manifest_path: Base.decode64!(\"$MANIFEST\")})" + + ssh -o BatchMode=yes "$SSH_TARGET" \ + "docker exec '$CONTAINER' /app/bin/who_need_help rpc '$expression'" +} + +rpc_action prepare + +verified=false +for _attempt in $(seq 1 20); do + if rpc_action verify; then + verified=true + break + fi + sleep 1 +done + +if [[ "$verified" != true ]]; then + echo "Web Push provider delivery did not verify within 20 seconds." >&2 + echo "Run-scoped state remains in production for inspection; automatic record deletion was not attempted." >&2 + exit 1 +fi + +rpc_action cleanup +cleanup_complete=true +remove_temporary_files +trap - EXIT INT TERM +echo "production_web_push_smoke_complete=true"