01a03535 - Add invite and promo landing pages - #30
Conversation
EN: Escape reflected referral codes at the crawler HTML text sinks and use a function replacer so a $ in the code is never a backreference; treat a plain-text "Cannot GET" 404 as unavailable (read the body as text, not JSON); render the promo action text verbatim (do not trim the displayed value); drop the Android debug-keystore fingerprint from the production Digital Asset Links; widen the English visual matrix to tablet and mobile with regenerated baselines. DE: Reflektierte Referral-Codes an den Crawler-HTML-Textstellen escapen und per Funktions-Replacer einsetzen (ein $ im Code ist nie mehr eine Backreference); einen Klartext-404 "Cannot GET" als unavailable behandeln (Body als Text statt JSON lesen); Promo-Aktionstext 1:1 anzeigen (Anzeige nicht trimmen); den Android-Debug-Keystore-Fingerprint aus den Prod-assetlinks entfernen; die englische Visual-Matrix auf Tablet und Mobile erweitern (Baselines neu erzeugt).
EN: Extend the loading/invalid/unavailable English invite and promo views to tablet-chromium and mobile-safari (completing the language x device x state matrix) and add the regenerated baselines. DE: Die englischen loading/invalid/unavailable-Views fuer Invite und Promo auf tablet-chromium und mobile-safari erweitern (Matrix Sprache x Geraet x Zustand vollstaendig) und die neu erzeugten Baselines ergaenzen.
EN: Treat only a non-2xx text body as a message so a 200 with a non-JSON body (a misconfigured proxy/CDN) falls through to the safe "unavailable" state instead of a false-positive success page; add escaping tests for the image-alt and description share sinks and a browser-mirror escaping test; add an e2e regression for the 200-non-JSON case; document the deliberate escape-contract difference between the server and browser replaceTaggedAttr mirrors; drop the now-single debug fingerprint sentence from the README; and remove a stray "(orchestrator)" suffix from three test names. DE: Nur einen non-2xx-Textbody als Message behandeln, damit ein 200 mit Nicht-JSON-Body (fehlkonfigurierter Proxy/CDN) in den sicheren "unavailable"-Zustand faellt statt in eine falsch-positive Erfolgsseite; Escaping-Tests fuer die image-alt- und description-Share-Sinks sowie einen Browser-Mirror-Escaping-Test ergaenzen; eine e2e-Regression fuer den 200-Nicht-JSON-Fall hinzufuegen; die bewusste Escape-Vertragsdifferenz zwischen Server- und Browser-replaceTaggedAttr-Mirror dokumentieren; den nun einzelnen Debug-Fingerprint-Satz aus dem README entfernen; und ein verirrtes "(orchestrator)"-Suffix aus drei Testnamen loeschen.
EN: Make the escaping assertions per-sink (count the escaped code once per og/twitter/meta sink instead of a single global check) so a per-sink regression is caught; add the missing quote-escape assertion and image-alt coverage to the browser-mirror test; and assert the stubbed route was actually hit in the 200-non-JSON e2e test so it cannot pass via the generic network-error path. DE: Die Escaping-Assertions sink-spezifisch machen (den escapten Code je og/twitter/meta-Sink einmal zaehlen statt einer globalen Pruefung), damit eine Regression je Sink auffaellt; die fehlende Quote-Escape-Assertion und image-alt-Abdeckung im Browser-Mirror-Test ergaenzen; und im 200-Nicht-JSON-e2e pruefen, dass die gestubbte Route wirklich getroffen wurde, damit er nicht ueber den generischen Netzwerkfehler-Pfad besteht.
|
Finalisation (double-review + CI green) This branch supersedes #27 with clean, signed, tool-neutral commits and adds the review hardening on top.
Ready for human review and merge. |
## Summary / Zusammenfassung **EN.** RealUnit referral programme and promo-code client (Offerte 24.08.2026 + mail Dani 17:42). The DFX API is the decision authority; this app is the rendering layer for eligibility, terms, invites, overview, frozen-CHF prizes, the KYC invite/promo step, and App Links. This branch supersedes #942 with clean, signed, tool-neutral commits and carries the review finalisation on top of the feature. It was hardened through a cross-vendor double-review (Claude Opus 5 + Codex) to zero findings. **DE.** RealUnit-Empfehlungsprogramm und Promo-Code in der App (Offerte 24.08.2026 + Mail Dani 17:42). Die DFX-API entscheidet; die App rendert Kachel, Teilnahmebedingungen, Einladungen, Übersicht, Prämien mit eingefrorenem Frankenwert, KYC-Schritt Invite/Promo und App Links. Dieser Branch ersetzt #942 mit sauberen, signierten, werkzeugneutralen Commits und enthält die Review-Finalisierung zusätzlich zum Feature. Er wurde per Cross-Vendor-Doppelreview (Claude Opus 5 + Codex) auf null Findings gehärtet. <details> <summary>Scope, review finalisation, measurements</summary> ### Scope - Dashboard and settings tiles gated on `GET /v1/realunit/referral/summary` `eligible` (KYC-verified, ≥ 70 REALU, not an employee). Subtitle **Erhalte 20 REALU pro Weiterempfehlung**. - Teilnahmebedingungen from authenticated `GET /v1/realunit/referral/terms` (`version`, `markdown`, `markdownEn`), with bundled assets only as an outage fallback; create-invite only after the acceptance checkbox. Acceptance posts the exact rendered version so a newer server version reopens the gate, and the posted version always matches the text actually shown. - Server-generated share text, copy and send; invite URL always `https://realunit.app/invite/{code}`. - Overview: **Open / Credited** only (TB Ziff. 7); defensive client parsing drops Deleted/expired rows. Total tile label **Aktienkurs**. History uses CHF frozen at credit (TB Ziff. 6). - KYC registration step for invite and promo (promo Aktionstext in its own dialog), on the sticky `ScrollableActionsLayout` inside a `SafeArea` so next/skip clear system insets. No stacking with an invite prize (API rejects). - Universal Links / App Links; Play Install Referrer on Android; iOS re-tap / paste. - Prize rows: whole REALU, date, frozen CHF. History watch includes `referralPayout` (type 5). - DE/EN, handbook `#spec-referral`. Referral/promo goldens regenerated via `golden-regenerate.yaml`. ### Review finalisation on top of the feature (this replaces #942) - **Correctness:** symmetric DE/EN precedence in `campaignTextForLocale`; a generation guard in the eligibility reload; terms acceptance reset on a language reload, adopting the API terms version only together with the text it belongs to; the overview Equatable state compares every rendered per-invite field plus the share-price label. - **Conformity:** the KYC step uses the sticky `ScrollableActionsLayout` in a `SafeArea` and is covered by a responsive-matrix test that asserts both sticky CTAs; the pure `format_frozen_chf` helper moved into `packages/io`; the frozen-CHF label uses the text theme; the unused payout merger and the orphaned `getPayouts` service method were removed; the referral surfaces are analyzer-clean. - **Second review round:** a further cross-vendor double-review (Claude Opus 5 logic + Codex conformity) drove the finalisation to zero findings again. Fixed: the eligibility cubit now guards every post-await `emit` with `isClosed` (no emit-after-close when a cubit is closed mid-fetch); KYC registration hands the deeplink stash to the referral field only while the referral step is the active step, preserving the do-not-refill-after-skip contract while the field keeps stash-over-clipboard precedence; `frozen_chf_label` moved into `lib/widgets/` (only non-referral importers) with imports re-sorted per CONTRIBUTING.md; the test-only pending-code sync helpers marked `@visibleForTesting`; `shareReferralInvite` annotated for integration-test exclusion. New regression test: the eligibility cubit does not emit after being closed mid-flight. - **Deferred with rationale (not defects):** the lenient `referralJson*` DTO helpers are kept deliberately (locale-tolerant CHF/number parsing; strict `as` casts would throw on the API's locale-formatted values); a few raw `TextStyle(color:)` sites are left as-is (a golden-affecting style refinement tracked as follow-up, not a correctness issue); the `NEEDS_TERMS` retry-only screen is escapable via back and only reachable through an unusual backend state race (reviewer: not a blocker). ### Measured (self-hosted / native) | Check | Result | | --- | --- | | `flutter test --exclude-tags golden` | **5951 passed** | | `flutter analyze` | **0 issues** (repo's enabled lints applied across the referral feature) | | Referral + routing suites | **545 passed** | | Round-2 re-verify on the Flutter host (`flutter analyze` + referral/kyc/routing suites at `fef34be`) | **0 issues, 635 passed** | | `golden-regenerate.yaml` on RealUnitCH/app | two successful runs updating the KYC-step + frozen-CHF baselines ([33922437059](https://github.com/RealUnitCH/app/actions/runs/33922437059) → `fddb4ec`, [33925355107](https://github.com/RealUnitCH/app/actions/runs/33925355107) → `69db0ea`) | ### Post-merge / operator actions - **Backend rollout:** [DFXswiss/backend#5415](DFXswiss/backend#5415) implements and tests the matching routes; merge/deploy remains human-owned. - Human: merge [RealUnitCH/web#30](RealUnitCH/web#30) so production AASA / assetlinks / `/invite/*` are HTTP 200. - Human: `PRIZE_WALLET_KEY` for settled 20 REALU transfers. Related: [RealUnitCH/intern#3](RealUnitCH/intern#3) (do not auto-close). </details> --------- Co-authored-by: Jonny Luca <320529100+JonnyLuca@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com>
Three ways a wrong referrer could be credited: - unwrapNestedCode matched `invite/` anywhere, so an issued code containing it (`PREFIXINVITE/AB12CD`) collapsed onto its tail. Codes may legitimately contain a slash and are not character-validated, so this hit honest users. Anchored at start or slash, on both the browser and the function mirror. - codeFromReferralHostInPath accepted our host from any segment of a foreign path, so `example.com/r/realunit.app/invite/ATTACKER` yielded ATTACKER. Restricted to the marked viewer forms that need it: /amp/s/<host>, /c/s/<host> and /https/<host>. - The function-side landing matcher was unbounded, so `evilrealunit.app` contained our host as a substring and passed. Two tests asserted the first and second behaviours as intended and were corrected; the attack case is now a regression test.
coverage.include only listed public/js/lib, so the 100% badge was earned by a browser-side mirror whose two HTML transformers are referenced from tests only, while functions/lib/itunes-banner.js — the module the middleware imports — was measured not at all. It sits at 98.98% lines / 88% branches; that is now a ratchet, not a blind spot. Adds tests for three paths this uncovered (bare store link, unparseable lang, fragment-only code) and drops a dead scheme-less retry in the host-in-path unwrap.
Four places passed without checking the thing under test: three parser fallbacks accepted any non-empty string, the cap test accepted any 32-character output, the Play-referrer expectation was built from the same production helper it verifies, and a setAttribute stub discarded the value it was called with.
Three ground rules contradicted the code they govern: public/ does not ship verbatim for invite/promo (the middleware rewrites those bytes), "no external resources of any kind (…, fetch)" sits next to the API lookup the CSP explicitly allows, and the visual matrix listed three families while VIEWS carries six across 72 views.
The page switched its copy on result.state but every hand-off kept using the kind from the URL path, so /invite/<a promo code> showed the promo text while the deeplink, the smart app banner and the Play install referrer still said invite= — the app then processed it as the wrong programme. The regression test fails without the fix.
…rrogate Capping ran after the trailing-punctuation strip, so a cut landing on a separator left it in place (…C. instead of …C). A cut through a surrogate pair left a lone surrogate, which makes encodeURIComponent throw when the code goes into the lookup URL and the deeplink. The 32-character cap itself stays: the API's sanitizeReferralCode and the app fold identically, so rejecting here instead would put the three out of step.
|
EN. This PR carried no formal review — the only evidence was a self-posted comment. A full double review (conformity + logic) was run against the real head; unlike its sibling RealUnitCH/app#966 the CI here was honest and the suite genuinely green, so the findings are real defects rather than a broken baseline. Five of them could credit a referral to the wrong person or send the app the wrong programme; all five are fixed and each is covered by a test that fails when the fix is reverted. Remaining findings are quality items with no user-facing harm and follow in a separate PR. DE. Dieser PR hatte kein formales Review — der einzige Beleg war eine Selbstauskunft. Es lief ein vollständiges Doppelreview am echten Head. Anders als beim Schwester-PR RealUnitCH/app#966 war die CI hier ehrlich und die Suite wirklich grün, die Funde sind also echte Defekte. Fünf davon konnten eine Empfehlung dem Falschen gutschreiben oder der App die falsche Programmart übergeben; alle fünf sind behoben und jeweils durch einen Test abgesichert, der ohne die Korrektur rot wird. Der Rest sind Qualitätsthemen ohne Nutzerschaden und kommt als eigener PR nach. Findings fixed hereWrong referrer could be credited
Wrong programme handed to the app
Crash and silent mangling in the cap
Two tests asserted the bugs as intended behaviour — Test and gate quality
Documentation correctedThree ground rules described something other than the code they govern: Verification
Deliberately not changed — carried into the follow-up PR
|
Summary / Zusammenfassung
EN. Invite and promo landing pages on realunit.app (Offerte Entwurf 3, point 3): public lookup, store badges, copy, Universal Links / App Links, Play Install Referrer. Language × device × state visuals are covered. This branch supersedes #27 with clean, signed, tool-neutral commits; the tree is byte-identical to the reviewed #27 head.
DE. Einladungs- und Promo-Landings auf realunit.app (Offerte Entwurf 3, Ziff. 3): öffentlicher Lookup, Store-Badges, Kopieren, Universal Links / App Links, Play Install Referrer. Sprache × Gerät × Zustand visuell abgedeckt. Dieser Branch ersetzt #27 mit sauberen, signierten, werkzeugneutralen Commits; der Baum ist byte-identisch zum reviewten #27-Head.
Scope, measurements, blockers
Scope
/invite/{code}personal greeting from public lookup;/promo/{code}campaign text (promo Aktionstext 1:1).GET /v1/realunit/referral/code/:codeconsumes backendinviterName/inviteeName(CORS onrealunit.app). NestJS unmountedCannot GETis unavailable, not invalid..well-known/). Extra AASA alias/apple-app-site-associationas HTTP 200 (www included).tests/site.spec.mjs+tests/behavior.spec.mjs+tests/visual.spec.mjs. Visual matrix intests/pages.mjs(invite/promo × ok/loading/invalid/unavailable/spent/missing-code/fallback × DE/EN × desktop/tablet/iOS/Android as applicable). Baselines viae2e:docker:updateonly.Measured (native host; content identical to the reviewed #27 head)
npm run checkcheck-site6 HTML / 54 refs; vitest 144/144; 100% lines/statements/branches/functionsnpm run e2e:dockercheck-visual112/112 baselines presentPost-merge / operator actions
realunit.appAASA, assetlinks and/invite/*stop 404ing.Related: RealUnitCH/intern#3 (do not auto-close).